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
@@ -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, executeWriteSiteManifest, loadSiteManifest, runLightAutoFit } 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,37 +129,51 @@ 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
 
104
- // Site manifest: load existing or run light auto-fit
132
+ // Wait for network to settle after navigation (best-effort, never throws)
133
+ await pageController.waitForNetworkSettle();
134
+
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 });
107
138
  const resolvedUrl = currentUrl.result?.value || url;
108
139
  const domain = new URL(resolvedUrl).hostname.replace(/^www\./, '');
109
140
 
110
- const existingManifest = await loadSiteManifest(domain);
111
- if (existingManifest) {
112
- stepResult.siteManifest = existingManifest;
141
+ const existingProfile = await loadSiteProfile(domain);
142
+ if (existingProfile) {
143
+ stepResult.siteProfile = existingProfile;
113
144
  } else {
114
- const fitResult = await runLightAutoFit(pageController, resolvedUrl);
115
- if (fitResult) {
116
- stepResult.fitted = fitResult;
117
- }
145
+ stepResult.profileAvailable = false;
146
+ stepResult.profileDomain = domain;
147
+ stepResult.hint = `Unknown site: ${domain}. No site profile exists. Create one with writeSiteProfile after exploring the site (snapshot, pageFunction, snapshotSearch). This speeds up all future visits.`;
118
148
  }
119
149
  } catch {
120
- // Manifest/fit errors are non-fatal
150
+ // Profile errors are non-fatal
121
151
  }
122
152
  } else if (step.reload !== undefined) {
123
153
  stepResult.action = 'reload';
124
154
  const reloadOptions = step.reload === true ? {} : step.reload;
125
155
  await pageController.reload(reloadOptions);
156
+ await pageController.waitForNetworkSettle();
157
+ } else if (step.sleep !== undefined) {
158
+ stepResult.action = 'sleep';
159
+ await sleep(step.sleep);
126
160
  } else if (step.wait !== undefined) {
127
161
  stepResult.action = 'wait';
128
- if (typeof step.wait === 'number') {
129
- await sleep(step.wait);
130
- } else {
131
- await executeWait(elementLocator, step.wait);
132
- }
162
+ await executeWait(elementLocator, step.wait);
133
163
  } else if (step.click !== undefined) {
134
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
+
135
177
  const clickResult = await executeClick(elementLocator, inputEmulator, deps.ariaSnapshot, step.click);
136
178
  if (clickResult) {
137
179
  if (clickResult.method) {
@@ -155,29 +197,75 @@ export async function executeStep(deps, step, options = {}) {
155
197
  stepResult.warning = 'CDP click was intercepted, used JavaScript click fallback';
156
198
  }
157
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
+ }
158
223
  } else if (step.fill !== undefined) {
159
224
  stepResult.action = 'fill';
160
- const fillExecutor = createFillExecutor(
161
- elementLocator.session,
162
- elementLocator,
163
- inputEmulator,
164
- deps.ariaSnapshot
165
- );
166
- const urlBeforeFill = await getCurrentUrl(elementLocator.session);
167
- await fillExecutor.execute(step.fill);
168
- const urlAfterFill = await getCurrentUrl(elementLocator.session);
169
- if (urlAfterFill !== urlBeforeFill) {
170
- 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
+ }
171
268
  }
172
- } else if (step.fillForm !== undefined) {
173
- stepResult.action = 'fillForm';
174
- const fillExecutor = createFillExecutor(
175
- elementLocator.session,
176
- elementLocator,
177
- inputEmulator,
178
- deps.ariaSnapshot
179
- );
180
- stepResult.output = await fillExecutor.executeBatch(step.fillForm);
181
269
  } else if (step.press !== undefined) {
182
270
  stepResult.action = 'press';
183
271
  const keyValidation = keyValidator.validate(step.press);
@@ -204,11 +292,10 @@ export async function executeStep(deps, step, options = {}) {
204
292
  } else if (step.pdf !== undefined) {
205
293
  stepResult.action = 'pdf';
206
294
  stepResult.output = await executePdf(deps.pdfCapture, elementLocator, step.pdf);
207
- } else if (step.eval !== undefined) {
208
- stepResult.action = 'eval';
209
- stepResult.output = await executeEval(pageController, step.eval);
210
295
  } else if (step.snapshot !== undefined) {
211
296
  stepResult.action = 'snapshot';
297
+ // Brief network settle before capturing — catches async content loading
298
+ await pageController.waitForNetworkSettle({ timeout: 1500, idleTime: 200 });
212
299
  stepResult.output = await executeSnapshot(deps.ariaSnapshot, step.snapshot, { tabAlias: options.tabAlias, inlineLimit: options.inlineLimit });
213
300
  } else if (step.snapshotSearch !== undefined) {
214
301
  stepResult.action = 'snapshotSearch';
@@ -230,11 +317,13 @@ export async function executeStep(deps, step, options = {}) {
230
317
  const backOptions = step.back === true ? {} : step.back;
231
318
  const entry = await pageController.goBack(backOptions);
232
319
  stepResult.output = entry ? { url: entry.url, title: entry.title } : { noHistory: true };
320
+ if (entry) await pageController.waitForNetworkSettle();
233
321
  } else if (step.forward !== undefined) {
234
322
  stepResult.action = 'forward';
235
323
  const forwardOptions = step.forward === true ? {} : step.forward;
236
324
  const entry = await pageController.goForward(forwardOptions);
237
325
  stepResult.output = entry ? { url: entry.url, title: entry.title } : { noHistory: true };
326
+ if (entry) await pageController.waitForNetworkSettle();
238
327
  } else if (step.waitForNavigation !== undefined) {
239
328
  stepResult.action = 'waitForNavigation';
240
329
  await executeWaitForNavigation(pageController, step.waitForNavigation);
@@ -244,35 +333,47 @@ export async function executeStep(deps, step, options = {}) {
244
333
  } else if (step.closeTab !== undefined) {
245
334
  stepResult.action = 'closeTab';
246
335
  stepResult.output = await executeCloseTab(deps.browser, step.closeTab);
247
- } else if (step.openTab !== undefined) {
248
- stepResult.action = 'openTab';
249
- if (step._openTabHandled) {
250
- if (step._openTabUrl) {
251
- 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();
346
+
347
+ // Site profile check (same as goto)
348
+ try {
349
+ const currentUrl = await pageController.evaluateInFrame('window.location.href', { returnByValue: true });
350
+ const resolvedUrl = currentUrl.result?.value || step._newTabUrl;
351
+ const domain = new URL(resolvedUrl).hostname.replace(/^www\./, '');
352
+
353
+ const existingProfile = await loadSiteProfile(domain);
354
+ if (existingProfile) {
355
+ stepResult.siteProfile = existingProfile;
356
+ } else {
357
+ stepResult.profileAvailable = false;
358
+ stepResult.profileDomain = domain;
359
+ stepResult.hint = `Unknown site: ${domain}. No site profile exists. Create one with writeSiteProfile after exploring the site (snapshot, pageFunction, snapshotSearch). This speeds up all future visits.`;
360
+ }
361
+ } catch {
362
+ // Profile errors are non-fatal
363
+ }
252
364
  }
253
- stepResult.output = { tab: step._openTabAlias };
365
+ stepResult.output = { tab: step._newTabAlias };
254
366
  } else {
255
367
  throw new Error('openTab must be the first step when no targetId is provided');
256
368
  }
257
- } else if (step.type !== undefined) {
258
- stepResult.action = 'type';
369
+ } else if (step.selectText !== undefined) {
370
+ stepResult.action = 'selectText';
259
371
  const keyboardExecutor = createKeyboardExecutor(
260
372
  elementLocator.session,
261
373
  elementLocator,
262
374
  inputEmulator
263
375
  );
264
- stepResult.output = await keyboardExecutor.executeType(step.type);
265
- } else if (step.select !== undefined) {
266
- stepResult.action = 'select';
267
- const keyboardExecutor = createKeyboardExecutor(
268
- elementLocator.session,
269
- elementLocator,
270
- inputEmulator
271
- );
272
- stepResult.output = await keyboardExecutor.executeSelect(step.select);
273
- } else if (step.validate !== undefined) {
274
- stepResult.action = 'validate';
275
- stepResult.output = await executeValidate(elementLocator, step.validate);
376
+ stepResult.output = await keyboardExecutor.executeSelect(step.selectText);
276
377
  } else if (step.submit !== undefined) {
277
378
  stepResult.action = 'submit';
278
379
  stepResult.output = await executeSubmit(elementLocator, step.submit);
@@ -282,26 +383,44 @@ export async function executeStep(deps, step, options = {}) {
282
383
  } else if (step.queryAll !== undefined) {
283
384
  stepResult.action = 'queryAll';
284
385
  stepResult.output = await executeQueryAll(elementLocator, step.queryAll);
285
- } else if (step.switchToFrame !== undefined) {
286
- stepResult.action = 'switchToFrame';
287
- stepResult.output = await pageController.switchToFrame(step.switchToFrame);
288
- } else if (step.switchToMainFrame !== undefined) {
289
- stepResult.action = 'switchToMainFrame';
290
- stepResult.output = await pageController.switchToMainFrame();
291
- } else if (step.listFrames !== undefined) {
292
- stepResult.action = 'listFrames';
293
- 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
+ }
294
397
  } else if (step.drag !== undefined) {
295
398
  stepResult.action = 'drag';
296
399
  stepResult.output = await executeDrag(elementLocator, inputEmulator, pageController, deps.ariaSnapshot, step.drag);
297
- } else if (step.formState !== undefined) {
298
- stepResult.action = 'formState';
299
- const formValidator = createFormValidator(elementLocator.session, elementLocator);
300
- const formSelector = typeof step.formState === 'string' ? step.formState : step.formState.selector;
301
- stepResult.output = await formValidator.getFormState(formSelector);
302
- } else if (step.extract !== undefined) {
303
- stepResult.action = 'extract';
304
- 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
+ }
305
424
  } else if (step.selectOption !== undefined) {
306
425
  stepResult.action = 'selectOption';
307
426
  stepResult.output = await executeSelectOption(elementLocator, step.selectOption);
@@ -311,30 +430,63 @@ export async function executeStep(deps, step, options = {}) {
311
430
  } else if (step.getBox !== undefined) {
312
431
  stepResult.action = 'getBox';
313
432
  stepResult.output = await executeGetBox(deps.ariaSnapshot, step.getBox);
314
- } else if (step.fillActive !== undefined) {
315
- stepResult.action = 'fillActive';
316
- stepResult.output = await executeFillActive(pageController, inputEmulator, step.fillActive);
317
- } else if (step.refAt !== undefined) {
318
- stepResult.action = 'refAt';
319
- stepResult.output = await executeRefAt(pageController.session, step.refAt);
320
433
  } else if (step.elementsAt !== undefined) {
321
434
  stepResult.action = 'elementsAt';
322
- stepResult.output = await executeElementsAt(pageController.session, step.elementsAt);
323
- } else if (step.elementsNear !== undefined) {
324
- stepResult.action = 'elementsNear';
325
- 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
+ }
326
446
  } else if (step.pageFunction !== undefined) {
327
447
  stepResult.action = 'pageFunction';
328
- 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
+ }
329
466
  } else if (step.poll !== undefined) {
330
467
  stepResult.action = 'poll';
331
468
  stepResult.output = await executePoll(pageController, step.poll);
332
- } else if (step.pipeline !== undefined) {
333
- stepResult.action = 'pipeline';
334
- stepResult.output = await executePipeline(pageController, step.pipeline);
335
- } else if (step.writeSiteManifest !== undefined) {
336
- stepResult.action = 'writeSiteManifest';
337
- stepResult.output = await executeWriteSiteManifest(step.writeSiteManifest);
469
+ } else if (step.writeSiteProfile !== undefined) {
470
+ stepResult.action = 'writeSiteProfile';
471
+ stepResult.output = await executeWriteSiteProfile(step.writeSiteProfile);
472
+ } else if (step.readSiteProfile !== undefined) {
473
+ stepResult.action = 'readSiteProfile';
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 };
338
490
  }
339
491
 
340
492
  // Process hooks on action steps (settledWhen, observe)
@@ -347,7 +499,11 @@ export async function executeStep(deps, step, options = {}) {
347
499
  timeout: stepTimeout || 30000
348
500
  });
349
501
  if (!settledResult.resolved) {
350
- 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}`;
351
507
  }
352
508
  }
353
509
 
@@ -360,6 +516,11 @@ export async function executeStep(deps, step, options = {}) {
360
516
  const definedAction = STEP_TYPES.find(type => step[type] !== undefined);
361
517
  const stepParams = definedAction ? step[definedAction] : null;
362
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
+
363
524
  let timeoutId;
364
525
  try {
365
526
  const stepPromise = executeStepInternal();