cdp-skill 1.0.8 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +80 -35
  2. package/SKILL.md +151 -239
  3. package/install.js +1 -0
  4. package/package.json +1 -1
  5. package/src/aria/index.js +8 -0
  6. package/src/aria/output-processor.js +173 -0
  7. package/src/aria/role-query.js +1229 -0
  8. package/src/aria/snapshot.js +459 -0
  9. package/src/aria.js +237 -43
  10. package/src/cdp/browser.js +22 -4
  11. package/src/cdp-skill.js +245 -69
  12. package/src/dom/click-executor.js +240 -76
  13. package/src/dom/element-locator.js +34 -25
  14. package/src/dom/fill-executor.js +55 -27
  15. package/src/page/dialog-handler.js +119 -0
  16. package/src/page/page-controller.js +190 -3
  17. package/src/runner/context-helpers.js +33 -55
  18. package/src/runner/execute-dynamic.js +8 -7
  19. package/src/runner/execute-form.js +11 -11
  20. package/src/runner/execute-input.js +2 -2
  21. package/src/runner/execute-interaction.js +99 -120
  22. package/src/runner/execute-navigation.js +11 -26
  23. package/src/runner/execute-query.js +8 -5
  24. package/src/runner/step-executors.js +225 -84
  25. package/src/runner/step-registry.js +1064 -0
  26. package/src/runner/step-validator.js +16 -754
  27. package/src/tests/Aria.test.js +1025 -0
  28. package/src/tests/ContextHelpers.test.js +39 -28
  29. package/src/tests/ExecuteBrowser.test.js +572 -0
  30. package/src/tests/ExecuteDynamic.test.js +2 -457
  31. package/src/tests/ExecuteForm.test.js +700 -0
  32. package/src/tests/ExecuteInput.test.js +540 -0
  33. package/src/tests/ExecuteInteraction.test.js +319 -0
  34. package/src/tests/ExecuteQuery.test.js +820 -0
  35. package/src/tests/FillExecutor.test.js +2 -2
  36. package/src/tests/StepValidator.test.js +222 -76
  37. package/src/tests/TestRunner.test.js +36 -25
  38. package/src/tests/integration.test.js +2 -1
  39. package/src/types.js +9 -9
  40. package/src/utils/backoff.js +118 -0
  41. package/src/utils/cdp-helpers.js +130 -0
  42. package/src/utils/devices.js +140 -0
  43. package/src/utils/errors.js +242 -0
  44. package/src/utils/index.js +65 -0
  45. package/src/utils/temp.js +75 -0
  46. package/src/utils/validators.js +433 -0
  47. package/src/utils.js +14 -1142
@@ -133,10 +133,11 @@ export async function executeGetDom(pageController, params) {
133
133
  };
134
134
  })()`;
135
135
 
136
- const result = await session.send('Runtime.evaluate', {
137
- expression,
138
- returnByValue: true
139
- });
136
+ const evalArgs = { expression, returnByValue: true };
137
+ const contextId = pageController.getFrameContext();
138
+ if (contextId) evalArgs.contextId = contextId;
139
+
140
+ const result = await session.send('Runtime.evaluate', evalArgs);
140
141
 
141
142
  if (result.exceptionDetails) {
142
143
  throw new Error(`getDom error: ${result.exceptionDetails.text}`);
@@ -746,7 +747,9 @@ export async function executeQuery(elementLocator, params) {
746
747
  */
747
748
 
748
749
  export async function executeRoleQuery(elementLocator, params) {
749
- const roleQueryExecutor = createRoleQueryExecutor(elementLocator.session, elementLocator);
750
+ const roleQueryExecutor = createRoleQueryExecutor(elementLocator.session, elementLocator, {
751
+ getFrameContext: elementLocator.getFrameContext
752
+ });
750
753
  return roleQueryExecutor.execute(params);
751
754
  }
752
755
 
@@ -49,12 +49,40 @@ import { executeWait, executeWaitForNavigation, executeScroll } from './execute-
49
49
  import { executeClick, executeHover, executeDrag } from './execute-interaction.js';
50
50
  import { executeFillActive, executeSelectOption } from './execute-input.js';
51
51
  import { executeSnapshot, executeSnapshotSearch, executeQuery, executeQueryAll, executeInspect, executeGetDom, executeGetBox, executeRefAt, executeElementsAt, executeElementsNear } from './execute-query.js';
52
- import { executeValidate, executeSubmit, executeExtract } from './execute-form.js';
52
+ // executeRefAt, executeElementsNear kept for internal dispatch from unified elementsAt
53
+ import { executeSubmit, executeExtract } from './execute-form.js';
53
54
  import { executePdf, executeEval, executeCookies, executeListTabs, executeCloseTab, executeConsole, formatCommandConsole } from './execute-browser.js';
54
- import { executePageFunction, executePoll, executePipeline, executeWriteSiteProfile, executeReadSiteProfile, loadSiteProfile } from './execute-dynamic.js';
55
+ // executeEval kept for internal dispatch from unified pageFunction
56
+ import { executePageFunction, executePoll, executeWriteSiteProfile, executeReadSiteProfile, loadSiteProfile } from './execute-dynamic.js';
55
57
 
56
58
  const keyValidator = createKeyValidator();
57
59
 
60
+ /**
61
+ * Detect if a string looks like a function expression (vs a bare expression).
62
+ * Functions start with (, function keyword, async keyword, or match arrow function patterns.
63
+ * Must use word boundaries to avoid matching identifiers like "asyncStorage" or "functionName".
64
+ */
65
+ function isFunctionExpression(str) {
66
+ const trimmed = str.trim();
67
+ // Parenthesized expression / IIFE / arrow with parens: (...)
68
+ if (trimmed.startsWith('(')) {
69
+ return true;
70
+ }
71
+ // function keyword followed by space, paren, or * (generator)
72
+ if (/^function[\s*(]/.test(trimmed)) {
73
+ return true;
74
+ }
75
+ // async keyword followed by space or paren (async function, async () =>)
76
+ if (/^async[\s(]/.test(trimmed)) {
77
+ return true;
78
+ }
79
+ // Arrow function: identifier => ...
80
+ if (/^\w+\s*=>/.test(trimmed)) {
81
+ return true;
82
+ }
83
+ return false;
84
+ }
85
+
58
86
  /**
59
87
  * Execute a single test step
60
88
  * @param {Object} deps - Dependencies
@@ -64,7 +92,7 @@ const keyValidator = createKeyValidator();
64
92
  */
65
93
  export async function executeStep(deps, step, options = {}) {
66
94
  const { pageController, elementLocator, inputEmulator } = deps;
67
- const stepTimeout = options.stepTimeout || 30000;
95
+ let stepTimeout = options.stepTimeout || 30000;
68
96
  const isOptional = step.optional === true;
69
97
 
70
98
  const stepResult = {
@@ -101,6 +129,9 @@ export async function executeStep(deps, step, options = {}) {
101
129
  const gotoOptions = typeof step.goto === 'object' ? step.goto : {};
102
130
  await pageController.navigate(url, gotoOptions);
103
131
 
132
+ // Wait for network to settle after navigation (best-effort, never throws)
133
+ await pageController.waitForNetworkSettle();
134
+
104
135
  // Site profile: load existing or signal that none exists
105
136
  try {
106
137
  const currentUrl = await pageController.evaluateInFrame('window.location.href', { returnByValue: true });
@@ -122,15 +153,27 @@ export async function executeStep(deps, step, options = {}) {
122
153
  stepResult.action = 'reload';
123
154
  const reloadOptions = step.reload === true ? {} : step.reload;
124
155
  await pageController.reload(reloadOptions);
156
+ await pageController.waitForNetworkSettle();
157
+ } else if (step.sleep !== undefined) {
158
+ stepResult.action = 'sleep';
159
+ await sleep(step.sleep);
125
160
  } else if (step.wait !== undefined) {
126
161
  stepResult.action = 'wait';
127
- if (typeof step.wait === 'number') {
128
- await sleep(step.wait);
129
- } else {
130
- await executeWait(elementLocator, step.wait);
131
- }
162
+ await executeWait(elementLocator, step.wait);
132
163
  } else if (step.click !== undefined) {
133
164
  stepResult.action = 'click';
165
+
166
+ // Capture tabs before click for new-tab detection
167
+ let tabsBefore = null;
168
+ try {
169
+ if (deps.browser) {
170
+ const pages = await deps.browser.getPages();
171
+ tabsBefore = new Set(pages.map(p => p.targetId));
172
+ }
173
+ } catch {
174
+ // Detection failure is non-fatal
175
+ }
176
+
134
177
  const clickResult = await executeClick(elementLocator, inputEmulator, deps.ariaSnapshot, step.click);
135
178
  if (clickResult) {
136
179
  if (clickResult.method) {
@@ -154,29 +197,75 @@ export async function executeStep(deps, step, options = {}) {
154
197
  stepResult.warning = 'CDP click was intercepted, used JavaScript click fallback';
155
198
  }
156
199
  }
200
+
201
+ // Detect new tabs opened by the click
202
+ try {
203
+ if (tabsBefore && deps.browser) {
204
+ await sleep(200);
205
+ const pagesAfter = await deps.browser.getPages();
206
+ const newTabs = pagesAfter
207
+ .filter(p => !tabsBefore.has(p.targetId))
208
+ .map(p => ({ targetId: p.targetId, url: p.url, title: p.title }));
209
+ if (newTabs.length > 0) {
210
+ // Register new tabs in the tab registry so agents can switch to them
211
+ if (deps.registerNewTab) {
212
+ for (const tab of newTabs) {
213
+ tab.alias = deps.registerNewTab(tab.targetId);
214
+ }
215
+ }
216
+ stepResult.output = stepResult.output || {};
217
+ stepResult.output.newTabs = newTabs;
218
+ }
219
+ }
220
+ } catch {
221
+ // Detection failure is non-fatal
222
+ }
157
223
  } else if (step.fill !== undefined) {
158
224
  stepResult.action = 'fill';
159
- const fillExecutor = createFillExecutor(
160
- elementLocator.session,
161
- elementLocator,
162
- inputEmulator,
163
- deps.ariaSnapshot
164
- );
165
- const urlBeforeFill = await getCurrentUrl(elementLocator.session);
166
- await fillExecutor.execute(step.fill);
167
- const urlAfterFill = await getCurrentUrl(elementLocator.session);
168
- if (urlAfterFill !== urlBeforeFill) {
169
- stepResult.output = { navigated: true, newUrl: urlAfterFill };
225
+ const params = step.fill;
226
+
227
+ if (typeof params === 'string') {
228
+ // Shape 1: focused mode — type into active element
229
+ stepResult.output = await executeFillActive(pageController, inputEmulator, params);
230
+ stepResult.output.mode = 'focused';
231
+ } else if (params && typeof params === 'object') {
232
+ const hasTargeting = params.selector || params.ref || params.label;
233
+ const hasFields = params.fields && typeof params.fields === 'object';
234
+
235
+ if (hasTargeting) {
236
+ // Shape 2: single field with targeting
237
+ const fillExecutor = createFillExecutor(
238
+ elementLocator.session,
239
+ elementLocator,
240
+ inputEmulator,
241
+ deps.ariaSnapshot,
242
+ { getFrameContext: pageController.getFrameContext }
243
+ );
244
+ const urlBeforeFill = await getCurrentUrl(elementLocator.session);
245
+ await fillExecutor.execute(params);
246
+ const urlAfterFill = await getCurrentUrl(elementLocator.session);
247
+ stepResult.output = { mode: 'single' };
248
+ if (urlAfterFill !== urlBeforeFill) {
249
+ stepResult.output.navigated = true;
250
+ stepResult.output.newUrl = urlAfterFill;
251
+ }
252
+ } else if (params.value !== undefined && !hasFields) {
253
+ // Shape 3: focused with options
254
+ stepResult.output = await executeFillActive(pageController, inputEmulator, params);
255
+ stepResult.output.mode = 'focused';
256
+ } else {
257
+ // Shape 4 ({fields: ...}) or Shape 5 (plain mapping)
258
+ const fillExecutor = createFillExecutor(
259
+ elementLocator.session,
260
+ elementLocator,
261
+ inputEmulator,
262
+ deps.ariaSnapshot,
263
+ { getFrameContext: pageController.getFrameContext }
264
+ );
265
+ stepResult.output = await fillExecutor.executeBatch(params);
266
+ stepResult.output.mode = 'batch';
267
+ }
170
268
  }
171
- } else if (step.fillForm !== undefined) {
172
- stepResult.action = 'fillForm';
173
- const fillExecutor = createFillExecutor(
174
- elementLocator.session,
175
- elementLocator,
176
- inputEmulator,
177
- deps.ariaSnapshot
178
- );
179
- stepResult.output = await fillExecutor.executeBatch(step.fillForm);
180
269
  } else if (step.press !== undefined) {
181
270
  stepResult.action = 'press';
182
271
  const keyValidation = keyValidator.validate(step.press);
@@ -203,11 +292,10 @@ export async function executeStep(deps, step, options = {}) {
203
292
  } else if (step.pdf !== undefined) {
204
293
  stepResult.action = 'pdf';
205
294
  stepResult.output = await executePdf(deps.pdfCapture, elementLocator, step.pdf);
206
- } else if (step.eval !== undefined) {
207
- stepResult.action = 'eval';
208
- stepResult.output = await executeEval(pageController, step.eval);
209
295
  } else if (step.snapshot !== undefined) {
210
296
  stepResult.action = 'snapshot';
297
+ // Brief network settle before capturing — catches async content loading
298
+ await pageController.waitForNetworkSettle({ timeout: 1500, idleTime: 200 });
211
299
  stepResult.output = await executeSnapshot(deps.ariaSnapshot, step.snapshot, { tabAlias: options.tabAlias, inlineLimit: options.inlineLimit });
212
300
  } else if (step.snapshotSearch !== undefined) {
213
301
  stepResult.action = 'snapshotSearch';
@@ -229,11 +317,13 @@ export async function executeStep(deps, step, options = {}) {
229
317
  const backOptions = step.back === true ? {} : step.back;
230
318
  const entry = await pageController.goBack(backOptions);
231
319
  stepResult.output = entry ? { url: entry.url, title: entry.title } : { noHistory: true };
320
+ if (entry) await pageController.waitForNetworkSettle();
232
321
  } else if (step.forward !== undefined) {
233
322
  stepResult.action = 'forward';
234
323
  const forwardOptions = step.forward === true ? {} : step.forward;
235
324
  const entry = await pageController.goForward(forwardOptions);
236
325
  stepResult.output = entry ? { url: entry.url, title: entry.title } : { noHistory: true };
326
+ if (entry) await pageController.waitForNetworkSettle();
237
327
  } else if (step.waitForNavigation !== undefined) {
238
328
  stepResult.action = 'waitForNavigation';
239
329
  await executeWaitForNavigation(pageController, step.waitForNavigation);
@@ -243,16 +333,21 @@ export async function executeStep(deps, step, options = {}) {
243
333
  } else if (step.closeTab !== undefined) {
244
334
  stepResult.action = 'closeTab';
245
335
  stepResult.output = await executeCloseTab(deps.browser, step.closeTab);
246
- } else if (step.openTab !== undefined) {
247
- stepResult.action = 'openTab';
248
- if (step._openTabHandled) {
249
- if (step._openTabUrl) {
250
- await pageController.navigate(step._openTabUrl);
336
+ } else if (step.newTab !== undefined) {
337
+ stepResult.action = 'newTab';
338
+ if (step._newTabHandled) {
339
+ if (step._newTabUrl) {
340
+ const navOptions = {};
341
+ if (step._newTabTimeout) {
342
+ navOptions.timeout = step._newTabTimeout;
343
+ }
344
+ await pageController.navigate(step._newTabUrl, navOptions);
345
+ await pageController.waitForNetworkSettle();
251
346
 
252
347
  // Site profile check (same as goto)
253
348
  try {
254
349
  const currentUrl = await pageController.evaluateInFrame('window.location.href', { returnByValue: true });
255
- const resolvedUrl = currentUrl.result?.value || step._openTabUrl;
350
+ const resolvedUrl = currentUrl.result?.value || step._newTabUrl;
256
351
  const domain = new URL(resolvedUrl).hostname.replace(/^www\./, '');
257
352
 
258
353
  const existingProfile = await loadSiteProfile(domain);
@@ -267,29 +362,18 @@ export async function executeStep(deps, step, options = {}) {
267
362
  // Profile errors are non-fatal
268
363
  }
269
364
  }
270
- stepResult.output = { tab: step._openTabAlias };
365
+ stepResult.output = { tab: step._newTabAlias };
271
366
  } else {
272
367
  throw new Error('openTab must be the first step when no targetId is provided');
273
368
  }
274
- } else if (step.type !== undefined) {
275
- stepResult.action = 'type';
369
+ } else if (step.selectText !== undefined) {
370
+ stepResult.action = 'selectText';
276
371
  const keyboardExecutor = createKeyboardExecutor(
277
372
  elementLocator.session,
278
373
  elementLocator,
279
374
  inputEmulator
280
375
  );
281
- stepResult.output = await keyboardExecutor.executeType(step.type);
282
- } else if (step.select !== undefined) {
283
- stepResult.action = 'select';
284
- const keyboardExecutor = createKeyboardExecutor(
285
- elementLocator.session,
286
- elementLocator,
287
- inputEmulator
288
- );
289
- stepResult.output = await keyboardExecutor.executeSelect(step.select);
290
- } else if (step.validate !== undefined) {
291
- stepResult.action = 'validate';
292
- stepResult.output = await executeValidate(elementLocator, step.validate);
376
+ stepResult.output = await keyboardExecutor.executeSelect(step.selectText);
293
377
  } else if (step.submit !== undefined) {
294
378
  stepResult.action = 'submit';
295
379
  stepResult.output = await executeSubmit(elementLocator, step.submit);
@@ -299,26 +383,44 @@ export async function executeStep(deps, step, options = {}) {
299
383
  } else if (step.queryAll !== undefined) {
300
384
  stepResult.action = 'queryAll';
301
385
  stepResult.output = await executeQueryAll(elementLocator, step.queryAll);
302
- } else if (step.switchToFrame !== undefined) {
303
- stepResult.action = 'switchToFrame';
304
- stepResult.output = await pageController.switchToFrame(step.switchToFrame);
305
- } else if (step.switchToMainFrame !== undefined) {
306
- stepResult.action = 'switchToMainFrame';
307
- stepResult.output = await pageController.switchToMainFrame();
308
- } else if (step.listFrames !== undefined) {
309
- stepResult.action = 'listFrames';
310
- stepResult.output = await pageController.getFrameTree();
386
+ } else if (step.frame !== undefined) {
387
+ stepResult.action = 'frame';
388
+ const frameParams = step.frame;
389
+ if (frameParams === 'top') {
390
+ stepResult.output = await pageController.switchToMainFrame();
391
+ } else if (typeof frameParams === 'object' && frameParams.list) {
392
+ stepResult.output = await pageController.getFrameTree();
393
+ } else {
394
+ // string selector, number index, or {name: "foo"} — all go to switchToFrame
395
+ stepResult.output = await pageController.switchToFrame(frameParams);
396
+ }
311
397
  } else if (step.drag !== undefined) {
312
398
  stepResult.action = 'drag';
313
399
  stepResult.output = await executeDrag(elementLocator, inputEmulator, pageController, deps.ariaSnapshot, step.drag);
314
- } else if (step.formState !== undefined) {
315
- stepResult.action = 'formState';
316
- const formValidator = createFormValidator(elementLocator.session, elementLocator);
317
- const formSelector = typeof step.formState === 'string' ? step.formState : step.formState.selector;
318
- stepResult.output = await formValidator.getFormState(formSelector);
319
- } else if (step.extract !== undefined) {
320
- stepResult.action = 'extract';
321
- stepResult.output = await executeExtract(deps, step.extract);
400
+ } else if (step.get !== undefined) {
401
+ stepResult.action = 'get';
402
+ const getParams = step.get;
403
+ const mode = typeof getParams === 'object' ? getParams.mode : null;
404
+
405
+ if (mode === 'html') {
406
+ // HTML extraction mode → use getDom
407
+ stepResult.output = await executeGetDom(pageController, getParams);
408
+ stepResult.output.mode = 'html';
409
+ } else if (mode === 'box') {
410
+ // Bounding box mode → use getBox (requires ref format)
411
+ stepResult.output = await executeGetBox(deps.ariaSnapshot, getParams.ref || getParams.selector);
412
+ stepResult.output.mode = 'box';
413
+ } else if (mode === 'value') {
414
+ // Form value extraction → use formState
415
+ const formValidator = createFormValidator(elementLocator.session, elementLocator);
416
+ const selector = typeof getParams === 'string' ? getParams : getParams.selector;
417
+ stepResult.output = await formValidator.getFormState(selector);
418
+ stepResult.output.mode = 'value';
419
+ } else {
420
+ // Default: text/attributes extraction → use extract
421
+ stepResult.output = await executeExtract(deps, getParams);
422
+ stepResult.output.mode = mode || 'text';
423
+ }
322
424
  } else if (step.selectOption !== undefined) {
323
425
  stepResult.action = 'selectOption';
324
426
  stepResult.output = await executeSelectOption(elementLocator, step.selectOption);
@@ -328,33 +430,63 @@ export async function executeStep(deps, step, options = {}) {
328
430
  } else if (step.getBox !== undefined) {
329
431
  stepResult.action = 'getBox';
330
432
  stepResult.output = await executeGetBox(deps.ariaSnapshot, step.getBox);
331
- } else if (step.fillActive !== undefined) {
332
- stepResult.action = 'fillActive';
333
- stepResult.output = await executeFillActive(pageController, inputEmulator, step.fillActive);
334
- } else if (step.refAt !== undefined) {
335
- stepResult.action = 'refAt';
336
- stepResult.output = await executeRefAt(pageController.session, step.refAt);
337
433
  } else if (step.elementsAt !== undefined) {
338
434
  stepResult.action = 'elementsAt';
339
- stepResult.output = await executeElementsAt(pageController.session, step.elementsAt);
340
- } else if (step.elementsNear !== undefined) {
341
- stepResult.action = 'elementsNear';
342
- stepResult.output = await executeElementsNear(pageController.session, step.elementsNear);
435
+ const eaParams = step.elementsAt;
436
+ if (Array.isArray(eaParams)) {
437
+ // Batch mode (array of coordinates)
438
+ stepResult.output = await executeElementsAt(pageController.session, eaParams);
439
+ } else if (eaParams && typeof eaParams === 'object' && eaParams.radius !== undefined) {
440
+ // Nearby mode (has radius)
441
+ stepResult.output = await executeElementsNear(pageController.session, eaParams);
442
+ } else {
443
+ // Single point mode (was refAt)
444
+ stepResult.output = await executeRefAt(pageController.session, eaParams);
445
+ }
343
446
  } else if (step.pageFunction !== undefined) {
344
447
  stepResult.action = 'pageFunction';
345
- stepResult.output = await executePageFunction(pageController, step.pageFunction);
448
+ const pfParams = step.pageFunction;
449
+ // Check if this is a bare expression (not a function)
450
+ const pfStr = typeof pfParams === 'string' ? pfParams : (pfParams?.fn || pfParams?.expression);
451
+ const isBareExpression = pfStr && !isFunctionExpression(pfStr);
452
+ if (isBareExpression) {
453
+ // Use eval-style wrapping for bare expressions
454
+ const evalParams = typeof pfParams === 'string'
455
+ ? pfStr
456
+ : { expression: pfStr, await: pfParams?.await, serialize: pfParams?.serialize, timeout: pfParams?.timeout };
457
+ stepResult.output = await executeEval(pageController, evalParams);
458
+ } else {
459
+ // If expression key provided, remap to fn
460
+ if (typeof pfParams === 'object' && pfParams.expression && !pfParams.fn) {
461
+ stepResult.output = await executePageFunction(pageController, { ...pfParams, fn: pfParams.expression });
462
+ } else {
463
+ stepResult.output = await executePageFunction(pageController, pfParams);
464
+ }
465
+ }
346
466
  } else if (step.poll !== undefined) {
347
467
  stepResult.action = 'poll';
348
468
  stepResult.output = await executePoll(pageController, step.poll);
349
- } else if (step.pipeline !== undefined) {
350
- stepResult.action = 'pipeline';
351
- stepResult.output = await executePipeline(pageController, step.pipeline);
352
469
  } else if (step.writeSiteProfile !== undefined) {
353
470
  stepResult.action = 'writeSiteProfile';
354
471
  stepResult.output = await executeWriteSiteProfile(step.writeSiteProfile);
355
472
  } else if (step.readSiteProfile !== undefined) {
356
473
  stepResult.action = 'readSiteProfile';
357
474
  stepResult.output = await executeReadSiteProfile(step.readSiteProfile);
475
+ } else if (step.switchTab !== undefined) {
476
+ stepResult.action = 'switchTab';
477
+ if (step._switchTabHandled) {
478
+ stepResult.output = { tab: step._switchTabAlias, connected: true };
479
+ } else {
480
+ throw new Error('switchTab must be the first step when no tab is specified');
481
+ }
482
+ } else if (step.getUrl !== undefined) {
483
+ stepResult.action = 'getUrl';
484
+ const urlResult = await pageController.evaluateInFrame('window.location.href', { returnByValue: true });
485
+ stepResult.output = { url: urlResult.result?.value };
486
+ } else if (step.getTitle !== undefined) {
487
+ stepResult.action = 'getTitle';
488
+ const titleResult = await pageController.evaluateInFrame('document.title', { returnByValue: true });
489
+ stepResult.output = { title: titleResult.result?.value };
358
490
  }
359
491
 
360
492
  // Process hooks on action steps (settledWhen, observe)
@@ -367,7 +499,11 @@ export async function executeStep(deps, step, options = {}) {
367
499
  timeout: stepTimeout || 30000
368
500
  });
369
501
  if (!settledResult.resolved) {
370
- stepResult.warning = (stepResult.warning || '') + ' settledWhen timed out without resolving';
502
+ const lastValStr = settledResult.lastValue !== undefined
503
+ ? ` (last value: ${JSON.stringify(settledResult.lastValue).substring(0, 200)})`
504
+ : '';
505
+ stepResult.warning = (stepResult.warning || '') +
506
+ `settledWhen timed out after ${settledResult.elapsed || 'unknown'}ms${lastValStr}`;
371
507
  }
372
508
  }
373
509
 
@@ -380,6 +516,11 @@ export async function executeStep(deps, step, options = {}) {
380
516
  const definedAction = STEP_TYPES.find(type => step[type] !== undefined);
381
517
  const stepParams = definedAction ? step[definedAction] : null;
382
518
 
519
+ // Step-level timeout overrides the default step timeout
520
+ if (stepParams && typeof stepParams === 'object' && typeof stepParams.timeout === 'number' && stepParams.timeout > 0) {
521
+ stepTimeout = stepParams.timeout;
522
+ }
523
+
383
524
  let timeoutId;
384
525
  try {
385
526
  const stepPromise = executeStepInternal();