@enricai/barnacle 1.6.6 → 1.6.8

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 (49) hide show
  1. package/README.md +2 -1
  2. package/dist/plugins/config-plugin.d.ts +2 -0
  3. package/dist/plugins/config-plugin.d.ts.map +1 -1
  4. package/dist/plugins/config-plugin.js +4 -0
  5. package/dist/plugins/config-plugin.js.map +1 -1
  6. package/dist/plugins/discover.d.ts.map +1 -1
  7. package/dist/plugins/discover.js +1 -0
  8. package/dist/plugins/discover.js.map +1 -1
  9. package/dist/plugins/loader.js +2 -2
  10. package/dist/plugins/loader.js.map +1 -1
  11. package/dist/scraper/behavioral-signals.d.ts +10 -3
  12. package/dist/scraper/behavioral-signals.d.ts.map +1 -1
  13. package/dist/scraper/behavioral-signals.js +17 -6
  14. package/dist/scraper/behavioral-signals.js.map +1 -1
  15. package/dist/scraper/deep-query.d.ts +8 -0
  16. package/dist/scraper/deep-query.d.ts.map +1 -1
  17. package/dist/scraper/deep-query.js +9 -1
  18. package/dist/scraper/deep-query.js.map +1 -1
  19. package/dist/scraper/flow-runner.d.ts +156 -24
  20. package/dist/scraper/flow-runner.d.ts.map +1 -1
  21. package/dist/scraper/flow-runner.js +234 -130
  22. package/dist/scraper/flow-runner.js.map +1 -1
  23. package/dist/scraper/frame-target.d.ts +88 -0
  24. package/dist/scraper/frame-target.d.ts.map +1 -0
  25. package/dist/scraper/frame-target.js +164 -0
  26. package/dist/scraper/frame-target.js.map +1 -0
  27. package/dist/scraper/retry.d.ts +2 -2
  28. package/dist/scraper/retry.d.ts.map +1 -1
  29. package/dist/scraper/retry.js +8 -8
  30. package/dist/scraper/retry.js.map +1 -1
  31. package/dist/scraper/stagehand-guard.d.ts +31 -3
  32. package/dist/scraper/stagehand-guard.d.ts.map +1 -1
  33. package/dist/scraper/stagehand-guard.js +53 -7
  34. package/dist/scraper/stagehand-guard.js.map +1 -1
  35. package/dist/scraper/submit-control.d.ts +15 -2
  36. package/dist/scraper/submit-control.d.ts.map +1 -1
  37. package/dist/scraper/submit-control.js +17 -4
  38. package/dist/scraper/submit-control.js.map +1 -1
  39. package/dist/scripts/recon-browser.d.ts +80 -1
  40. package/dist/scripts/recon-browser.d.ts.map +1 -1
  41. package/dist/scripts/recon-browser.js +36 -8
  42. package/dist/scripts/recon-browser.js.map +1 -1
  43. package/dist/scripts/recon-generate.d.ts +6 -0
  44. package/dist/scripts/recon-generate.d.ts.map +1 -1
  45. package/dist/scripts/recon-generate.js +9 -7
  46. package/dist/scripts/recon-generate.js.map +1 -1
  47. package/dist/site-plugin.d.ts +7 -0
  48. package/dist/site-plugin.d.ts.map +1 -1
  49. package/package.json +1 -1
@@ -43,6 +43,7 @@ exports.renderUnfocusedObserve = renderUnfocusedObserve;
43
43
  exports.pairInvalidWithErrors = pairInvalidWithErrors;
44
44
  exports.formatValidationRejectedReason = formatValidationRejectedReason;
45
45
  exports.selectBodyExcerpt = selectBodyExcerpt;
46
+ exports.extractLivePageFormEvidence = extractLivePageFormEvidence;
46
47
  exports.probeLeafInvalidContainers = probeLeafInvalidContainers;
47
48
  exports.renderLeafInvalidFields = renderLeafInvalidFields;
48
49
  exports.extractSubmitFailureEvidence = extractSubmitFailureEvidence;
@@ -52,6 +53,8 @@ exports.fillHtml5DateTimeInput = fillHtml5DateTimeInput;
52
53
  exports.verifyFillReadback = verifyFillReadback;
53
54
  exports.isUploadAffordanceLabel = isUploadAffordanceLabel;
54
55
  exports.writeFixtureToTempFile = writeFixtureToTempFile;
56
+ exports.attachToSurfacedInput = attachToSurfacedInput;
57
+ exports.surfaceAndUpload = surfaceAndUpload;
55
58
  exports.parseSelectStep = parseSelectStep;
56
59
  exports.parseRadioStep = parseRadioStep;
57
60
  exports.pollEnumerate = pollEnumerate;
@@ -59,6 +62,9 @@ exports.waitForTransitionBody = waitForTransitionBody;
59
62
  exports.chooseRequiredSelectOption = chooseRequiredSelectOption;
60
63
  exports.buildRadioIdXPath = buildRadioIdXPath;
61
64
  exports.selectRadioGroupOption = selectRadioGroupOption;
65
+ exports.simulateDragDropUpload = simulateDragDropUpload;
66
+ exports.dispatchJqueryChangeEvent = dispatchJqueryChangeEvent;
67
+ exports.verifyDomEffect = verifyDomEffect;
62
68
  exports.narrowInvalidFormControl = narrowInvalidFormControl;
63
69
  exports.formatStepPrefix = formatStepPrefix;
64
70
  exports.probeStepBeforeAttempts = probeStepBeforeAttempts;
@@ -82,6 +88,7 @@ const logging_1 = require("../lib/logging");
82
88
  const call_capture_1 = require("../lib/telemetry/call-capture");
83
89
  const call_types_1 = require("../lib/telemetry/call-types");
84
90
  const errors_2 = require("../scraper/errors");
91
+ const frame_target_1 = require("../scraper/frame-target");
85
92
  const phantom_click_1 = require("../scraper/phantom-click");
86
93
  const stagehand_guard_1 = require("../scraper/stagehand-guard");
87
94
  const submit_control_1 = require("../scraper/submit-control");
@@ -467,11 +474,11 @@ exports.TRAILING_GRACE_WINDOW = 2;
467
474
  * shape via Runtime.callFunctionOn.
468
475
  */
469
476
  const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { html: 0, text: "" }; const t = b.innerText || ""; return { html: (b.outerHTML || "").length, text: t.length + ":" + t.slice(0, 200) }; })()`;
470
- async function snapshotPage(page, signalCounter) {
477
+ async function snapshotPage(target, signalCounter) {
471
478
  let bodyHtmlLength = 0;
472
479
  let visibleTextSignature = "";
473
480
  try {
474
- const result = await page.evaluate(DOM_SNAPSHOT_EXPR);
481
+ const result = await target.evaluate(DOM_SNAPSHOT_EXPR);
475
482
  if (result !== null &&
476
483
  typeof result === "object" &&
477
484
  "html" in result &&
@@ -488,7 +495,7 @@ async function snapshotPage(page, signalCounter) {
488
495
  }
489
496
  return {
490
497
  networkCount: signalCounter.n,
491
- url: page.url(),
498
+ url: await target.url(),
492
499
  bodyHtmlLength,
493
500
  visibleTextSignature,
494
501
  };
@@ -508,6 +515,18 @@ async function snapshotPage(page, signalCounter) {
508
515
  * envelope shapes. New ATSs can be added by extending the recognized
509
516
  * keys; the existing ones cover the four most common patterns.
510
517
  */
518
+ /**
519
+ * Reads the title/url pair for a step-failure dump, scoped to whichever
520
+ * frame the step actually ran against — so a triager sees the same frame's
521
+ * body HTML and url instead of pairing a child frame's DOM with the top
522
+ * document's url. `title()` intentionally still reads the top document for
523
+ * a child frame (see `frame-target.ts`); `url()` is the frame discriminator.
524
+ */
525
+ async function resolveDumpPageIdentity(page, frameTarget) {
526
+ const pageTitle = await (frameTarget ?? page).title().catch(() => "");
527
+ const pageUrl = await (frameTarget ? frameTarget.url() : Promise.resolve(page.url())).catch(() => page.url());
528
+ return { pageTitle, pageUrl };
529
+ }
511
530
  /**
512
531
  * Detect whether the supplied capture-meta window contains a backend
513
532
  * 5xx response that matches the configured submit endpoint pattern.
@@ -851,15 +870,18 @@ function isDomOnlyAdvanceVerified(params) {
851
870
  return networkIsRealAdvance || urlChanged;
852
871
  }
853
872
  /**
854
- * Read-only count of ng-invalid form controls on the page. Side-effect-free
873
+ * Read-only count of ng-invalid form controls on the resolved frame. Side-effect-free
855
874
  * counterpart to `probeFormValidityBeforeSubmit` (which also auto-fills
856
875
  * unselected radio groups via element.click()). Used by the cascade's
857
876
  * early-exit predicate to detect "the Submit click revealed new required
858
877
  * questions" — when this count grows from 0 (pre-submit) to ≥1 (post-attempt-1),
859
878
  * attempts 2-5 cannot succeed and the cascade should route to replan
860
- * immediately instead of burning Stagehand calls.
879
+ * immediately instead of burning Stagehand calls. Accepts a `FrameTarget` so a
880
+ * wizard embedded in a cross-origin iframe (e.g. UCHealth's Talemetry form) is
881
+ * scanned on its own frame; a main-frame target delegates straight to
882
+ * `page.evaluate`, matching today's behavior byte-for-byte.
861
883
  */
862
- async function countNgInvalidContainers(page) {
884
+ async function countNgInvalidContainers(target) {
863
885
  const expr = `(() => {
864
886
  const isInvalid = ${INVALID_MARKER_EL_EXPR};
865
887
  let n = 0;
@@ -869,7 +891,7 @@ async function countNgInvalidContainers(page) {
869
891
  return n;
870
892
  })()`;
871
893
  try {
872
- const raw = await page.evaluate(expr);
894
+ const raw = await target.evaluate(expr);
873
895
  return typeof raw === "number" ? raw : 0;
874
896
  }
875
897
  catch {
@@ -1470,10 +1492,10 @@ function selectBodyExcerpt(body) {
1470
1492
  const start = Math.max(0, absoluteIndex - BODY_EXCERPT_FORM_WINDOW / 4);
1471
1493
  return body.slice(start, start + BODY_EXCERPT_FORM_WINDOW);
1472
1494
  }
1473
- async function extractLivePageFormEvidence(page, options) {
1495
+ async function extractLivePageFormEvidence(_page, target, options) {
1474
1496
  let body = "";
1475
1497
  try {
1476
- const raw = await page.evaluate("document.body ? document.body.outerHTML : null");
1498
+ const raw = await target.evaluate("document.body ? document.body.outerHTML : null");
1477
1499
  if (typeof raw === "string")
1478
1500
  body = raw;
1479
1501
  }
@@ -1502,13 +1524,13 @@ async function extractLivePageFormEvidence(page, options) {
1502
1524
  // field was named in FORM FIELDS. Deterministic extraction gives the
1503
1525
  // LLM `"Address" <app-input> — error: "This field is required"` instead.
1504
1526
  const [leafFields, errorVerdict, interactiveTargets] = await Promise.all([
1505
- probeLeafInvalidContainers(page),
1527
+ probeLeafInvalidContainers(target),
1506
1528
  (0, error_messages_1.judgeErrorMessagesWithLLM)({
1507
1529
  client,
1508
1530
  input: { bodyHtmlExcerpt: bodyExcerpt },
1509
1531
  captureFn,
1510
1532
  }),
1511
- extractInteractiveTargetsNearInvalid(page).catch(() => []),
1533
+ extractInteractiveTargetsNearInvalid(target).catch(() => []),
1512
1534
  ]);
1513
1535
  // Probe is the primary signal. Judge only runs if probe is empty AND a
1514
1536
  // client is available — so the fallback semantics match today's behavior
@@ -1558,13 +1580,13 @@ async function extractLivePageFormEvidence(page, options) {
1558
1580
  * LLM for fuzzy judgment, deterministic extraction for structurally-derivable
1559
1581
  * signals" — DOM tree walking is the latter.
1560
1582
  *
1561
- * Returns up to 12 leaf records. Empty array on `page.evaluate` failure (safe
1583
+ * Returns up to 12 leaf records. Empty array on evaluate failure (safe
1562
1584
  * fallback to the existing Haiku judge upstream). The `inputTag` and
1563
1585
  * `visibleErrorText` fields let the prompt distinguish a smart-address
1564
1586
  * autocomplete (where typing-only fails and the cascade needs dropdown
1565
1587
  * selection) from a plain text input.
1566
1588
  */
1567
- async function probeLeafInvalidContainers(page) {
1589
+ async function probeLeafInvalidContainers(target) {
1568
1590
  const expr = `(() => {
1569
1591
  const SELECTOR =
1570
1592
  "[class*='ng-invalid']:not(:has([class*='ng-invalid'])), " +
@@ -1649,11 +1671,11 @@ async function probeLeafInvalidContainers(page) {
1649
1671
  return out;
1650
1672
  })()`;
1651
1673
  try {
1652
- const result = await page.evaluate(expr);
1674
+ const result = await target.evaluate(expr);
1653
1675
  return Array.isArray(result) ? result : [];
1654
1676
  }
1655
1677
  catch {
1656
- // page.evaluate failure (navigation in-flight, CSP, browser detached)
1678
+ // evaluate failure (navigation in-flight, CSP, browser detached)
1657
1679
  // is non-fatal — caller falls back to the Haiku judge.
1658
1680
  return [];
1659
1681
  }
@@ -1676,7 +1698,7 @@ function renderLeafInvalidFields(fields) {
1676
1698
  });
1677
1699
  return lines.join("\n");
1678
1700
  }
1679
- async function extractInteractiveTargetsNearInvalid(page) {
1701
+ async function extractInteractiveTargetsNearInvalid(target) {
1680
1702
  const expr = `(() => {
1681
1703
  const out = [];
1682
1704
  const containers = document.querySelectorAll(
@@ -1732,7 +1754,7 @@ async function extractInteractiveTargetsNearInvalid(page) {
1732
1754
  }
1733
1755
  return out;
1734
1756
  })()`;
1735
- const result = await page.evaluate(expr);
1757
+ const result = await target.evaluate(expr);
1736
1758
  return Array.isArray(result) ? result : [];
1737
1759
  }
1738
1760
  /**
@@ -1988,12 +2010,12 @@ function normalizeDateValue(raw, inputType) {
1988
2010
  }
1989
2011
  return null;
1990
2012
  }
1991
- async function fillHtml5DateTimeInput(page, xpath, value) {
2013
+ async function fillHtml5DateTimeInput(target, xpath, value) {
1992
2014
  const HTML5_DATE_TYPES = new Set(["date", "time", "datetime-local", "month", "week"]);
1993
2015
  // K'/H' Change 1: pre-normalize the value before dispatching to the page
1994
2016
  // evaluator. The HTML5 spec rejects programmatic .value writes that don't
1995
2017
  // match the canonical format — see normalizeDateValue TSDoc.
1996
- // We don't yet know the input type until the page.evaluate runs (we'd
2018
+ // We don't yet know the input type until the evaluate runs (we'd
1997
2019
  // have to probe it first), so we try BOTH the raw value AND a normalized
1998
2020
  // pass: if raw works, fine; if raw fails (post-value mismatch), the
1999
2021
  // returned filled=false signal tells the caller to retry with a normalized
@@ -2022,7 +2044,7 @@ async function fillHtml5DateTimeInput(page, xpath, value) {
2022
2044
  return { filled: el.value === value, postValue: el.value || "", inputType };
2023
2045
  })()`;
2024
2046
  try {
2025
- const raw = await page.evaluate(expr);
2047
+ const raw = await target.evaluate(expr);
2026
2048
  if (raw === null || typeof raw !== "object")
2027
2049
  return null;
2028
2050
  const r = raw;
@@ -2058,7 +2080,7 @@ async function fillHtml5DateTimeInput(page, xpath, value) {
2058
2080
  * element regardless of framework wrapping. Industry-standard pattern
2059
2081
  * (react-testing-library's `getByDisplayValue` does the same readback).
2060
2082
  */
2061
- async function verifyFillReadback(page, xpath, expectedValue) {
2083
+ async function verifyFillReadback(target, xpath, expectedValue) {
2062
2084
  const expr = `(() => {
2063
2085
  const xpath = ${JSON.stringify(xpath)};
2064
2086
  const expected = ${JSON.stringify(expectedValue)};
@@ -2081,7 +2103,7 @@ async function verifyFillReadback(page, xpath, expectedValue) {
2081
2103
  return { outcome, postValue: actual, tag };
2082
2104
  })()`;
2083
2105
  try {
2084
- const raw = await page.evaluate(expr);
2106
+ const raw = await target.evaluate(expr);
2085
2107
  if (raw === null || typeof raw !== "object")
2086
2108
  return null;
2087
2109
  const r = raw;
@@ -2285,7 +2307,7 @@ function writeFixtureToTempFile(fixture) {
2285
2307
  * existing cascade in that case).
2286
2308
  */
2287
2309
  async function tryUploadPrimitive(params) {
2288
- const { page, isUploadStep, fixture, logger, signalCounter, recentCaptureMeta } = params;
2310
+ const { page, target, isUploadStep, fixture, logger, signalCounter, recentCaptureMeta } = params;
2289
2311
  if (!isUploadStep) {
2290
2312
  return false;
2291
2313
  }
@@ -2302,7 +2324,7 @@ async function tryUploadPrimitive(params) {
2302
2324
  // already rendered it pay nothing.
2303
2325
  let inputCount = 0;
2304
2326
  try {
2305
- inputCount = await pollEnumerate(page, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0, { attempts: UPLOAD_WIDGET_RENDER_ATTEMPTS, intervalMs: UPLOAD_WIDGET_RENDER_INTERVAL_MS });
2327
+ inputCount = await pollEnumerate(page, target, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0, { attempts: UPLOAD_WIDGET_RENDER_ATTEMPTS, intervalMs: UPLOAD_WIDGET_RENDER_INTERVAL_MS });
2306
2328
  }
2307
2329
  catch (err) {
2308
2330
  logger.warn(`upload primitive: file-input probe threw: ${(0, errors_1.toErrorMessage)(err)}`);
@@ -2315,6 +2337,7 @@ async function tryUploadPrimitive(params) {
2315
2337
  logger.info("upload primitive: no <input type=file> after render wait; attempting click-to-surface");
2316
2338
  const surfaced = await surfaceAndUpload({
2317
2339
  page,
2340
+ target,
2318
2341
  fixture,
2319
2342
  logger,
2320
2343
  signalCounter,
@@ -2325,7 +2348,7 @@ async function tryUploadPrimitive(params) {
2325
2348
  logger.info("upload primitive: click-to-surface failed; falling through to cascade");
2326
2349
  return false;
2327
2350
  }
2328
- return attachToSurfacedInput({ page, fixture, logger, signalCounter, recentCaptureMeta });
2351
+ return attachToSurfacedInput({ page, target, fixture, logger, signalCounter, recentCaptureMeta });
2329
2352
  }
2330
2353
  /**
2331
2354
  * Attach the fixture to an already-surfaced `<input type=file>` (raw or freshly
@@ -2335,10 +2358,10 @@ async function tryUploadPrimitive(params) {
2335
2358
  * + drag-drop-fallback implementation.
2336
2359
  */
2337
2360
  async function attachToSurfacedInput(params) {
2338
- const { page, fixture, logger, signalCounter, recentCaptureMeta } = params;
2339
- const target = page.locator("xpath=//input[@type='file']").first();
2361
+ const { page, target, fixture, logger, signalCounter, recentCaptureMeta } = params;
2362
+ const inputLocator = target.locator("xpath=//input[@type='file']").first();
2340
2363
  try {
2341
- await target.setInputFiles({
2364
+ await inputLocator.setInputFiles({
2342
2365
  name: fixture.name,
2343
2366
  mimeType: fixture.mimeType,
2344
2367
  buffer: fixture.buffer,
@@ -2365,7 +2388,7 @@ async function attachToSurfacedInput(params) {
2365
2388
  // Industry-standard workaround documented across Playwright
2366
2389
  // community. Site-agnostic — works for any tenant with framework-
2367
2390
  // wrapped file inputs.
2368
- await page
2391
+ await target
2369
2392
  .evaluate("(() => { const els = document.querySelectorAll('input[type=file]'); for (const el of els) { if (el.files && el.files.length > 0) { el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); return true; } } return false; })()")
2370
2393
  .catch((err) => {
2371
2394
  logger.warn(`upload primitive: change dispatch failed: ${(0, errors_1.toErrorMessage)(err)}`);
@@ -2391,7 +2414,7 @@ async function attachToSurfacedInput(params) {
2391
2414
  // interpolation from external data, no risk of injecting attacker-controlled
2392
2415
  // values into the browser-side JS. Same trust posture as the type-probe
2393
2416
  // expression in verifyDomEffect's click case.
2394
- const attachedLength = await page
2417
+ const attachedLength = await target
2395
2418
  .evaluate("(() => { const els = document.querySelectorAll('input[type=file]'); for (const el of els) { if (el.files && el.files.length > 0) return el.files.length; } return 0; })()")
2396
2419
  .catch(() => 0);
2397
2420
  if (typeof attachedLength !== "number" || attachedLength === 0) {
@@ -2402,7 +2425,7 @@ async function attachToSurfacedInput(params) {
2402
2425
  // a DataTransfer fires on the visible drop area — they don't observe
2403
2426
  // the hidden input's `files[]` mutations even with synthetic `change`
2404
2427
  // dispatches. This is the documented Playwright community workaround.
2405
- const dragDropOk = await simulateDragDropUpload(page, fixture, logger);
2428
+ const dragDropOk = await simulateDragDropUpload(target, fixture, logger);
2406
2429
  if (dragDropOk) {
2407
2430
  logger.info(`upload primitive: drag-drop fallback succeeded (name=${fixture.name}, size=${fixture.buffer.length}b)`);
2408
2431
  return true;
@@ -2425,7 +2448,7 @@ async function attachToSurfacedInput(params) {
2425
2448
  * was attached.
2426
2449
  */
2427
2450
  async function surfaceAndUpload(params) {
2428
- const { page, fixture, logger, signalCounter, recentCaptureMeta } = params;
2451
+ const { page, target, fixture, logger, signalCounter, recentCaptureMeta } = params;
2429
2452
  // Render-gate: the input-less strategies below (drag-drop is one-shot, the
2430
2453
  // affordance click resolves what's in the DOM) all race the async widget
2431
2454
  // mount. Wait (bounded, same window as the raw-input probe) for ANY upload
@@ -2449,7 +2472,7 @@ async function surfaceAndUpload(params) {
2449
2472
  if (btns.some((el) => isUpload(el.getAttribute("aria-label") || el.textContent || ""))) return { present: true };
2450
2473
  return { present: false };
2451
2474
  })()`;
2452
- const gate = await pollEnumerate(page, targetExpr, (r) => r?.present === true, {
2475
+ const gate = await pollEnumerate(page, target, targetExpr, (r) => r?.present === true, {
2453
2476
  attempts: UPLOAD_WIDGET_RENDER_ATTEMPTS,
2454
2477
  intervalMs: UPLOAD_WIDGET_RENDER_INTERVAL_MS,
2455
2478
  });
@@ -2461,13 +2484,18 @@ async function surfaceAndUpload(params) {
2461
2484
  // Strategy DZ: a synthetic drop is cheap, needs no click/chooser, and the
2462
2485
  // widget IS a dropzone. If it registers the file (upload POST or attached
2463
2486
  // input), we're done without touching CDP.
2464
- if (await simulateDragDropUpload(page, fixture, logger)) {
2487
+ if (await simulateDragDropUpload(target, fixture, logger)) {
2465
2488
  if (await waitForUploadNetworkSignal({ page, fixture, logger, signalCounter, recentCaptureMeta })) {
2466
2489
  logger.info("upload primitive: resolved via drag-drop onto dropzone");
2467
2490
  return true;
2468
2491
  }
2469
2492
  }
2470
- const session = page.getSessionForFrame(page.mainFrameId());
2493
+ // The file-chooser CDP interception below must run on the upload target's
2494
+ // OWN session — an OOPIF (e.g. UCHealth's Talemetry wizard) has its own CDP
2495
+ // target, and a chooser it opens is only observable via that frame's
2496
+ // session, not the main session. Main-frame targets fall back to
2497
+ // page.getSessionForFrame(page.mainFrameId()), matching today's behavior.
2498
+ const session = target.frame ? target.frame.session : page.getSessionForFrame(page.mainFrameId());
2471
2499
  let chooserBackendNodeId = null;
2472
2500
  const onChooser = (paramsIn) => {
2473
2501
  const p = paramsIn;
@@ -2477,13 +2505,13 @@ async function surfaceAndUpload(params) {
2477
2505
  // ARM native-chooser interception BEFORE the click. Page.fileChooserOpened
2478
2506
  // only carries a backendNodeId while interception is enabled; without it a
2479
2507
  // chooser-opening click would pop a real OS dialog and hang the run.
2480
- await page.sendCDP("Page.enable").catch(() => { });
2481
- await page
2482
- .sendCDP("Page.setInterceptFileChooserDialog", { enabled: true })
2508
+ await session.send("Page.enable").catch(() => { });
2509
+ await session
2510
+ .send("Page.setInterceptFileChooserDialog", { enabled: true })
2483
2511
  .catch((e) => logger.warn(`upload primitive: chooser-intercept arm failed: ${(0, errors_1.toErrorMessage)(e)}`));
2484
2512
  session.on("Page.fileChooserOpened", onChooser);
2485
2513
  try {
2486
- if (!(await clickUploadAffordance(page, logger)))
2514
+ if (!(await clickUploadAffordance(page, target, logger)))
2487
2515
  return false;
2488
2516
  // Strategy 0: some MUI widgets XHR straight to attachment_upload_url on
2489
2517
  // click, no chooser, no input.
@@ -2492,10 +2520,17 @@ async function surfaceAndUpload(params) {
2492
2520
  return true;
2493
2521
  }
2494
2522
  // Strategy A: the click lazily mounted a hidden <input type=file>.
2495
- const appeared = await pollEnumerate(page, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0);
2523
+ const appeared = await pollEnumerate(page, target, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0);
2496
2524
  if ((appeared ?? 0) > 0) {
2497
2525
  logger.info("upload primitive: click surfaced a hidden <input type=file>");
2498
- if (await attachToSurfacedInput({ page, fixture, logger, signalCounter, recentCaptureMeta })) {
2526
+ if (await attachToSurfacedInput({
2527
+ page,
2528
+ target,
2529
+ fixture,
2530
+ logger,
2531
+ signalCounter,
2532
+ recentCaptureMeta,
2533
+ })) {
2499
2534
  return true;
2500
2535
  }
2501
2536
  }
@@ -2504,6 +2539,7 @@ async function surfaceAndUpload(params) {
2504
2539
  logger.info(`upload primitive: native file chooser intercepted (backendNodeId=${chooserBackendNodeId}); setting files via CDP`);
2505
2540
  return setFilesViaCdp({
2506
2541
  page,
2542
+ target,
2507
2543
  session,
2508
2544
  backendNodeId: chooserBackendNodeId,
2509
2545
  fixture,
@@ -2516,7 +2552,7 @@ async function surfaceAndUpload(params) {
2516
2552
  }
2517
2553
  finally {
2518
2554
  session.off("Page.fileChooserOpened", onChooser);
2519
- await page.sendCDP("Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => { });
2555
+ await session.send("Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => { });
2520
2556
  }
2521
2557
  }
2522
2558
  /**
@@ -2526,8 +2562,11 @@ async function surfaceAndUpload(params) {
2526
2562
  * upload vocabulary as {@link isUploadAffordanceLabel}, preferring controls
2527
2563
  * scoped inside an attachment/upload/resume container. Returns whether a
2528
2564
  * matching control was clicked.
2565
+ *
2566
+ * Takes both `page` (for `pollEnumerate`'s `waitForTimeout`) and `target`
2567
+ * (the frame the enumerate/click runs against).
2529
2568
  */
2530
- async function clickUploadAffordance(page, logger) {
2569
+ async function clickUploadAffordance(page, target, logger) {
2531
2570
  // The browser-side matcher mirrors isUploadAffordanceLabel; kept as a literal
2532
2571
  // so the enumerate is a static string (same trust posture as the other
2533
2572
  // primitives). No external interpolation.
@@ -2549,7 +2588,7 @@ async function clickUploadAffordance(page, logger) {
2549
2588
  return { clicked: true, text: norm(chosen.getAttribute("aria-label") || chosen.textContent || "").slice(0, 50) };
2550
2589
  })()`;
2551
2590
  try {
2552
- const result = (await pollEnumerate(page, expr, (r) => r?.clicked === true)) ?? { clicked: false };
2591
+ const result = (await pollEnumerate(page, target, expr, (r) => r?.clicked === true)) ?? { clicked: false };
2553
2592
  if (result.clicked) {
2554
2593
  logger.info(`upload primitive: clicked upload affordance "${result.text ?? ""}"`);
2555
2594
  return true;
@@ -2570,7 +2609,7 @@ async function clickUploadAffordance(page, logger) {
2570
2609
  * shared upload-network signal.
2571
2610
  */
2572
2611
  async function setFilesViaCdp(params) {
2573
- const { page, session, backendNodeId, fixture, logger, signalCounter, recentCaptureMeta } = params;
2612
+ const { page, target, session, backendNodeId, fixture, logger, signalCounter, recentCaptureMeta, } = params;
2574
2613
  // CDP needs a filesystem path; the fixture is an in-memory buffer. Write it
2575
2614
  // to a temp file (tiny — a few KB) so the path is always valid regardless of
2576
2615
  // where the recon loaded the fixture from.
@@ -2588,7 +2627,7 @@ async function setFilesViaCdp(params) {
2588
2627
  // CDP-set files don't surface via input.files, so the DOM-attached-files
2589
2628
  // check can't confirm; treat a filename chip appearing in the DOM as the
2590
2629
  // secondary success signal (the MUI widget renders the chosen filename).
2591
- const nameShown = await page
2630
+ const nameShown = await target
2592
2631
  .evaluate(`document.body && document.body.textContent && document.body.textContent.indexOf(${JSON.stringify(fixture.name)}) !== -1`)
2593
2632
  .catch(() => false);
2594
2633
  if (nameShown === true) {
@@ -2714,14 +2753,20 @@ const PRIMITIVE_ENUMERATE_RETRY_MS = 600;
2714
2753
  * `opts` overrides the attempt count / interval for callers that need a longer
2715
2754
  * window (the resume-upload widget can take 5s+ to mount); omitting it keeps the
2716
2755
  * default ~3s window so every existing caller is unchanged.
2756
+ *
2757
+ * Takes both `page` (for `waitForTimeout`, which `FrameTarget` has no
2758
+ * equivalent of) and `target` (for the enumerate itself), so a widget
2759
+ * rendered inside a resolved cross-origin child frame is polled on its own
2760
+ * frame; a main-frame `target` delegates straight to `page.evaluate`,
2761
+ * matching today's behavior byte-for-byte.
2717
2762
  */
2718
- async function pollEnumerate(page, expr, isPresent, opts) {
2763
+ async function pollEnumerate(page, target, expr, isPresent, opts) {
2719
2764
  const attempts = opts?.attempts ?? PRIMITIVE_ENUMERATE_ATTEMPTS;
2720
2765
  const intervalMs = opts?.intervalMs ?? PRIMITIVE_ENUMERATE_RETRY_MS;
2721
- let result = (await page.evaluate(expr));
2766
+ let result = (await target.evaluate(expr));
2722
2767
  for (let attempt = 1; attempt < attempts && !isPresent(result); attempt++) {
2723
2768
  await page.waitForTimeout(intervalMs);
2724
- result = (await page.evaluate(expr));
2769
+ result = (await target.evaluate(expr));
2725
2770
  }
2726
2771
  return result;
2727
2772
  }
@@ -2798,8 +2843,13 @@ async function waitForTransitionBody(params) {
2798
2843
  * refuse to claim success on an uncommitted select, routing to the cascade/replan
2799
2844
  * instead of silently advancing. Walks ≤6 ancestors for the invalid marker, same
2800
2845
  * as the radio/checkbox primitives.
2846
+ *
2847
+ * Accepts a `FrameTarget` (plus the underlying `page` for `waitForTimeout`,
2848
+ * which `FrameTarget` has no equivalent of) so a wizard embedded in a
2849
+ * cross-origin iframe is set on its own frame; a main-frame target delegates
2850
+ * straight to `page.evaluate`, matching today's behavior byte-for-byte.
2801
2851
  */
2802
- async function applySelectValue(page, selIdx, value) {
2852
+ async function applySelectValue(page, target, selIdx, value) {
2803
2853
  const setExpr = `((selIdx, value) => {
2804
2854
  const sel = Array.from(document.querySelectorAll("select"))[selIdx];
2805
2855
  if (!sel) return { ok: false };
@@ -2810,7 +2860,7 @@ async function applySelectValue(page, selIdx, value) {
2810
2860
  sel.dispatchEvent(new Event("blur", { bubbles: true }));
2811
2861
  return { ok: sel.value === value };
2812
2862
  })(${JSON.stringify(selIdx)}, ${JSON.stringify(value)})`;
2813
- const setResult = (await page.evaluate(setExpr));
2863
+ const setResult = (await target.evaluate(setExpr));
2814
2864
  if (!setResult?.ok)
2815
2865
  return { ok: false, stillInvalid: false };
2816
2866
  await page.waitForTimeout(SELECT_SETTLE_MS);
@@ -2825,11 +2875,11 @@ async function applySelectValue(page, selIdx, value) {
2825
2875
  }
2826
2876
  return false;
2827
2877
  })(${JSON.stringify(selIdx)})`;
2828
- const stillInvalid = (await page.evaluate(invalidExpr).catch(() => false));
2878
+ const stillInvalid = (await target.evaluate(invalidExpr).catch(() => false));
2829
2879
  return { ok: true, stillInvalid };
2830
2880
  }
2831
2881
  async function trySelectPrimitive(params) {
2832
- const { page, instruction, logger, anthropic, captureFn } = params;
2882
+ const { page, target, instruction, logger, anthropic, captureFn } = params;
2833
2883
  const parsed = parseSelectStep(instruction);
2834
2884
  if (!parsed)
2835
2885
  return false;
@@ -2914,7 +2964,7 @@ async function trySelectPrimitive(params) {
2914
2964
  try {
2915
2965
  // Settle-retry: the <select> may render a beat after the step fires (SPA
2916
2966
  // render-lag); poll until it appears or the cap is hit.
2917
- const enumResult = await pollEnumerate(page, enumerateExpr, (r) => r?.selectPresent === true);
2967
+ const enumResult = await pollEnumerate(page, target, enumerateExpr, (r) => r?.selectPresent === true);
2918
2968
  // No <select> on the page at all (e.g. the question is a radio group) —
2919
2969
  // fall through to the cascade unchanged; the LLM picker can't help here.
2920
2970
  if (!enumResult?.selectPresent) {
@@ -2926,7 +2976,7 @@ async function trySelectPrimitive(params) {
2926
2976
  // is uncommitted (a later worklet re-render will wipe it) — refuse to claim
2927
2977
  // success so the cascade/replan can retry rather than silently advancing.
2928
2978
  if (enumResult.detMatch) {
2929
- const { ok, stillInvalid } = await applySelectValue(page, enumResult.detMatch.selIdx, enumResult.detMatch.value);
2979
+ const { ok, stillInvalid } = await applySelectValue(page, target, enumResult.detMatch.selIdx, enumResult.detMatch.value);
2930
2980
  if (ok && !stillInvalid) {
2931
2981
  logger.info(`select primitive: set dropdown to "${enumResult.detMatch.text.trim().slice(0, 40)}" (${optLabel})`);
2932
2982
  return true;
@@ -2963,7 +3013,7 @@ async function trySelectPrimitive(params) {
2963
3013
  // Apply pass (set + settle + invalid-readback): set the chosen select by its
2964
3014
  // ORIGINAL DOM index, then confirm it committed (cleared the invalid marker),
2965
3015
  // same as the fast path.
2966
- const { ok, stillInvalid } = await applySelectValue(page, chosenCandidate.selIdx, chosenOption.value);
3016
+ const { ok, stillInvalid } = await applySelectValue(page, target, chosenCandidate.selIdx, chosenOption.value);
2967
3017
  if (ok && !stillInvalid) {
2968
3018
  logger.info(`select primitive: LLM chose "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
2969
3019
  return true;
@@ -3027,7 +3077,7 @@ function chooseRequiredSelectOption(options) {
3027
3077
  * required-empty select is present, so radio/checkbox catch-alls are unaffected.
3028
3078
  */
3029
3079
  async function tryFillRequiredSelectsPrimitive(params) {
3030
- const { page, instruction, logger, anthropic, captureFn } = params;
3080
+ const { page, target, instruction, logger, anthropic, captureFn } = params;
3031
3081
  // Gate: catch-all steps only. parseSelectStep returns null for these (its
3032
3082
  // `any remaining` guard), so this never collides with the single-target
3033
3083
  // trySelectPrimitive that owns concrete "select 'X'" steps.
@@ -3085,7 +3135,7 @@ async function tryFillRequiredSelectsPrimitive(params) {
3085
3135
  return { candidates };
3086
3136
  })()`;
3087
3137
  try {
3088
- const enumResult = await pollEnumerate(page, enumerateExpr, (r) => Array.isArray(r?.candidates));
3138
+ const enumResult = await pollEnumerate(page, target, enumerateExpr, (r) => Array.isArray(r?.candidates));
3089
3139
  const candidates = enumResult?.candidates ?? [];
3090
3140
  if (candidates.length === 0)
3091
3141
  return false;
@@ -3118,7 +3168,7 @@ async function tryFillRequiredSelectsPrimitive(params) {
3118
3168
  allCommitted = false;
3119
3169
  continue;
3120
3170
  }
3121
- const { ok, stillInvalid } = await applySelectValue(page, cand.selIdx, chosen.value);
3171
+ const { ok, stillInvalid } = await applySelectValue(page, target, cand.selIdx, chosen.value);
3122
3172
  if (ok && !stillInvalid) {
3123
3173
  logger.info(`required-select primitive: filled "${cand.label.slice(0, 40)}" with "${chosen.text.slice(0, 40)}"`);
3124
3174
  }
@@ -3158,7 +3208,7 @@ async function tryFillRequiredSelectsPrimitive(params) {
3158
3208
  * also where `<select>`-only pages go, since trySelectPrimitive runs first).
3159
3209
  */
3160
3210
  async function tryCheckboxPrimitive(params) {
3161
- const { page, instruction, logger, anthropic, captureFn } = params;
3211
+ const { page, target, instruction, logger, anthropic, captureFn } = params;
3162
3212
  const parsed = parseSelectStep(instruction);
3163
3213
  if (!parsed)
3164
3214
  return false;
@@ -3243,7 +3293,7 @@ async function tryCheckboxPrimitive(params) {
3243
3293
  try {
3244
3294
  // Settle-retry: the checkbox group may render a beat after the step fires
3245
3295
  // (SPA render-lag); poll until it appears or the cap is hit.
3246
- const enumResult = await pollEnumerate(page, enumerateExpr, (r) => r?.groupPresent === true);
3296
+ const enumResult = await pollEnumerate(page, target, enumerateExpr, (r) => r?.groupPresent === true);
3247
3297
  if (!enumResult?.groupPresent)
3248
3298
  return false; // no checkbox groups → cascade
3249
3299
  if (enumResult.applied && enumResult.ok) {
@@ -3292,7 +3342,7 @@ async function tryCheckboxPrimitive(params) {
3292
3342
  }
3293
3343
  return { ok: cb.checked === true };
3294
3344
  })(${JSON.stringify(chosenGroup.gi)}, ${JSON.stringify(chosenOption.bi)})`;
3295
- const applyResult = (await page.evaluate(applyExpr));
3345
+ const applyResult = (await target.evaluate(applyExpr));
3296
3346
  if (applyResult?.ok) {
3297
3347
  logger.info(`checkbox primitive: LLM checked "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
3298
3348
  return true;
@@ -3485,7 +3535,7 @@ function selectRadioGroupOption(params) {
3485
3535
  * (checkbox/select/absent) falls through to the cascade.
3486
3536
  */
3487
3537
  async function tryRadioPrimitive(params) {
3488
- const { page, instruction, logger, anthropic, captureFn } = params;
3538
+ const { page, target, instruction, logger, anthropic, captureFn } = params;
3489
3539
  const parsed = parseRadioStep(instruction);
3490
3540
  if (!parsed)
3491
3541
  return false;
@@ -3558,7 +3608,7 @@ async function tryRadioPrimitive(params) {
3558
3608
  return { groupPresent: true, groups };
3559
3609
  })(${JSON.stringify(option)})`;
3560
3610
  try {
3561
- const enumResult = await pollEnumerate(page, enumerateExpr, (r) => r?.groupPresent === true);
3611
+ const enumResult = await pollEnumerate(page, target, enumerateExpr, (r) => r?.groupPresent === true);
3562
3612
  if (!enumResult?.groupPresent)
3563
3613
  return false; // no radio group → cascade
3564
3614
  const groups = enumResult.groups ?? [];
@@ -3570,7 +3620,7 @@ async function tryRadioPrimitive(params) {
3570
3620
  const selection = selectRadioGroupOption({ groups, wantOption: option, questionLabel });
3571
3621
  if (selection !== null && selection !== "ambiguous") {
3572
3622
  const chosenOpt = groups[selection.gi]?.options.find((o) => o.ri === selection.ri);
3573
- const applied = await applyRadioSelection(page, selection.gi, selection.ri, {
3623
+ const applied = await applyRadioSelection(target, selection.gi, selection.ri, {
3574
3624
  id: chosenOpt?.id ?? "",
3575
3625
  xpath: chosenOpt?.xpath ?? "",
3576
3626
  });
@@ -3615,7 +3665,7 @@ async function tryRadioPrimitive(params) {
3615
3665
  // biome-ignore lint/style/noNonNullAssertion: guarded above by the verdict.optionIndex === null early-return
3616
3666
  const chosenOption = chosenGroup.options[verdict.optionIndex];
3617
3667
  const applyResult = {
3618
- ok: await applyRadioSelection(page, chosenGroup.gi, chosenOption.ri, {
3668
+ ok: await applyRadioSelection(target, chosenGroup.gi, chosenOption.ri, {
3619
3669
  id: chosenOption.id,
3620
3670
  xpath: chosenOption.xpath,
3621
3671
  }),
@@ -3649,7 +3699,7 @@ async function tryRadioPrimitive(params) {
3649
3699
  * detached / non-MUI radios that already commit that way).
3650
3700
  * Returns whether the commit stuck; false → caller falls through to the cascade.
3651
3701
  */
3652
- async function applyRadioSelection(page, gi, ri, hint) {
3702
+ async function applyRadioSelection(target, gi, ri, hint) {
3653
3703
  // Post-settle readback for the input identified by id (preferred) or xpath:
3654
3704
  // checked===true AND no INVALID_MARKER_EL_EXPR ancestor within 6 hops.
3655
3705
  const readbackExpr = (sel) => `((id, xp) => {
@@ -3665,15 +3715,15 @@ async function applyRadioSelection(page, gi, ri, hint) {
3665
3715
  return { ok: true };
3666
3716
  })(${JSON.stringify(sel.id)}, ${JSON.stringify(sel.xpath)})`;
3667
3717
  const readback = async () => {
3668
- await page.waitForTimeout(RADIO_SETTLE_MS);
3669
- const r = (await page.evaluate(readbackExpr(hint)).catch(() => ({ ok: false })));
3718
+ await (0, frame_target_1.sleep)(RADIO_SETTLE_MS);
3719
+ const r = (await target.evaluate(readbackExpr(hint)).catch(() => ({ ok: false })));
3670
3720
  return r?.ok === true;
3671
3721
  };
3672
3722
  // Tier A — trusted hit-tested click on the input by id (or xpath).
3673
3723
  const inputSel = hint.id ? buildRadioIdXPath(hint.id) : hint.xpath ? `xpath=${hint.xpath}` : null;
3674
3724
  if (inputSel) {
3675
3725
  try {
3676
- await page.locator(inputSel).first().click();
3726
+ await target.locator(inputSel).first().click();
3677
3727
  if (await readback())
3678
3728
  return true;
3679
3729
  }
@@ -3684,7 +3734,7 @@ async function applyRadioSelection(page, gi, ri, hint) {
3684
3734
  // Tier B — trusted click on the associated label (MUI hides the real input).
3685
3735
  if (hint.id) {
3686
3736
  try {
3687
- await page
3737
+ await target
3688
3738
  .locator(`xpath=//label[@for=${JSON.stringify(hint.id)}]`)
3689
3739
  .first()
3690
3740
  .click();
@@ -3717,7 +3767,7 @@ async function applyRadioSelection(page, gi, ri, hint) {
3717
3767
  }
3718
3768
  return { ok: true };
3719
3769
  })(${JSON.stringify(gi)}, ${JSON.stringify(ri)})`;
3720
- await page.evaluate(applyExpr).catch(() => ({ ok: false }));
3770
+ await target.evaluate(applyExpr).catch(() => ({ ok: false }));
3721
3771
  return await readback();
3722
3772
  }
3723
3773
  /**
@@ -3736,7 +3786,7 @@ async function applyRadioSelection(page, gi, ri, hint) {
3736
3786
  * step (e.g. "dismiss modal" on a modal-less page) has no such control, so the
3737
3787
  * fast-skip the comments call essential is preserved.
3738
3788
  */
3739
- async function hasUnfilledRequiredControlForStep(page, instruction) {
3789
+ async function hasUnfilledRequiredControlForStep(target, instruction) {
3740
3790
  const parsed = parseSelectStep(instruction);
3741
3791
  if (!parsed?.questionLabel)
3742
3792
  return false;
@@ -3785,7 +3835,7 @@ async function hasUnfilledRequiredControlForStep(page, instruction) {
3785
3835
  return false;
3786
3836
  })(${JSON.stringify(parsed.questionLabel)})`;
3787
3837
  try {
3788
- return (await page.evaluate(expr)) === true;
3838
+ return (await target.evaluate(expr)) === true;
3789
3839
  }
3790
3840
  catch {
3791
3841
  return false;
@@ -3807,7 +3857,7 @@ async function hasUnfilledRequiredControlForStep(page, instruction) {
3807
3857
  * drag-and-drop API. Works on react-dropzone, Material Dropzone, custom
3808
3858
  * <uapp-upload>/<app-upload>, and any other drop-zone-based upload UI.
3809
3859
  */
3810
- async function simulateDragDropUpload(page, fixture, logger) {
3860
+ async function simulateDragDropUpload(target, fixture, logger) {
3811
3861
  const base64 = fixture.buffer.toString("base64");
3812
3862
  const expr = `(async () => {
3813
3863
  const fileName = ${JSON.stringify(fixture.name)};
@@ -3853,7 +3903,7 @@ async function simulateDragDropUpload(page, fixture, logger) {
3853
3903
  }
3854
3904
  })()`;
3855
3905
  try {
3856
- const result = await page.evaluate(expr);
3906
+ const result = await target.evaluate(expr);
3857
3907
  if (result && typeof result === "object" && "ok" in result && result.ok === true) {
3858
3908
  const tag = "dropZoneTag" in result ? String(result.dropZoneTag) : "(unknown)";
3859
3909
  logger.info(`upload primitive: drag-drop dispatched on <${tag}>`);
@@ -3889,7 +3939,7 @@ async function simulateDragDropUpload(page, fixture, logger) {
3889
3939
  * safely escapes it into a JS string literal. The expression body is a fixed
3890
3940
  * literal — no user-controlled JS execution.
3891
3941
  */
3892
- async function dispatchJqueryChangeEvent(page, selector) {
3942
+ async function dispatchJqueryChangeEvent(target, selector) {
3893
3943
  const xpath = xpathBody(selector);
3894
3944
  if (!xpath)
3895
3945
  return;
@@ -3908,7 +3958,7 @@ async function dispatchJqueryChangeEvent(page, selector) {
3908
3958
  }
3909
3959
  })()`;
3910
3960
  try {
3911
- await page.evaluate(expr);
3961
+ await target.evaluate(expr);
3912
3962
  }
3913
3963
  catch {
3914
3964
  // best-effort: jQuery dispatch failure shouldn't fail the verifier
@@ -3920,14 +3970,17 @@ async function dispatchJqueryChangeEvent(page, selector) {
3920
3970
  * Re-read DOM state from the same selector Stagehand acted upon and compare
3921
3971
  * against what it tried to write. Falls back to `false` on any locator error
3922
3972
  * so the navigation-class signal is still the deciding vote when this returns.
3973
+ *
3974
+ * `target` scopes both the locator/evaluate reads and the jQuery-change
3975
+ * dispatch to the resolved frame (main or a cross-origin child).
3923
3976
  */
3924
- async function verifyDomEffect(page, action) {
3977
+ async function verifyDomEffect(target, action) {
3925
3978
  const selector = action.selector;
3926
3979
  const method = action.method;
3927
3980
  if (!selector || !method)
3928
3981
  return false;
3929
3982
  try {
3930
- const locator = page.locator(selector).first();
3983
+ const locator = target.locator(selector).first();
3931
3984
  switch (method) {
3932
3985
  case "fill":
3933
3986
  case "type": {
@@ -3943,7 +3996,7 @@ async function verifyDomEffect(page, action) {
3943
3996
  // delegated handler) record the value
3944
3997
  // into their internal data model. Without this, the SPA's next
3945
3998
  // re-render wipes the typed value back to empty.
3946
- await dispatchJqueryChangeEvent(page, selector);
3999
+ await dispatchJqueryChangeEvent(target, selector);
3947
4000
  // Angular reactive forms (e.g. ADP WOTC questionnaire on tcs.adp.com)
3948
4001
  // don't pick up CDP Input.insertText OR dispatchEvent('input') —
3949
4002
  // their FormControl model updates require real keyboard events.
@@ -3991,7 +4044,7 @@ async function verifyDomEffect(page, action) {
3991
4044
  const expr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); const el = r.singleNodeValue; if (!el || el.tagName !== "SELECT") return null; const opt = el.options[el.selectedIndex]; if (!opt) return { value: "", label: "", text: "" }; return { value: (opt.value || "").trim(), label: (opt.label || "").trim(), text: (opt.textContent || "").trim() }; })()`;
3992
4045
  let selected = null;
3993
4046
  try {
3994
- const result = await page.evaluate(expr);
4047
+ const result = await target.evaluate(expr);
3995
4048
  if (result !== null &&
3996
4049
  typeof result === "object" &&
3997
4050
  "value" in result &&
@@ -4032,7 +4085,7 @@ async function verifyDomEffect(page, action) {
4032
4085
  // Node-side typechecking doesn't choke on the browser globals
4033
4086
  // `document`/`XPathResult`.
4034
4087
  const expr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); const el = r.singleNodeValue; return el ? (el.type || null) : null; })()`;
4035
- const result = await page.evaluate(expr);
4088
+ const result = await target.evaluate(expr);
4036
4089
  inputType = typeof result === "string" ? result : null;
4037
4090
  }
4038
4091
  catch {
@@ -4066,7 +4119,7 @@ async function verifyDomEffect(page, action) {
4066
4119
  }
4067
4120
  return false;
4068
4121
  })()`;
4069
- const ancestorStillInvalid = await page.evaluate(ancestorInvalidExpr).catch(() => false);
4122
+ const ancestorStillInvalid = await target.evaluate(ancestorInvalidExpr).catch(() => false);
4070
4123
  return !ancestorStillInvalid;
4071
4124
  }
4072
4125
  default:
@@ -4329,9 +4382,9 @@ function formatStepPrefix(stepIndex, totalSteps) {
4329
4382
  return total === undefined ? `step ${stepIndex + 1}` : `step ${stepIndex + 1}/${total}`;
4330
4383
  }
4331
4384
  async function probeFormValidityBeforeSubmit(params) {
4332
- const { page, stepIndex, totalSteps, logger } = params;
4385
+ const { target, stepIndex, totalSteps, logger } = params;
4333
4386
  try {
4334
- const raw = await page.evaluate(FORM_VALIDITY_PROBE_EXPR);
4387
+ const raw = await target.evaluate(FORM_VALIDITY_PROBE_EXPR);
4335
4388
  if (!Array.isArray(raw))
4336
4389
  return [];
4337
4390
  const out = [];
@@ -4363,11 +4416,15 @@ async function probeFormValidityBeforeSubmit(params) {
4363
4416
  * candidates for a declarative "Fill in X" step even when the field is present —
4364
4417
  * so a 0-candidate focused result falls back to an unfocused observe before the
4365
4418
  * step is declared "absent". Exported for tests.
4419
+ *
4420
+ * `frameTarget` scopes both observe calls to a resolved cross-origin child
4421
+ * frame when the flow declared `frameSelector`; omitted (main frame) is
4422
+ * byte-identical to today's unscoped calls.
4366
4423
  */
4367
4424
  async function probeStepBeforeAttempts(params) {
4368
- const { stagehand, step, stepIndex, totalSteps, logger, captureFn } = params;
4425
+ const { stagehand, step, stepIndex, totalSteps, logger, captureFn, frameTarget } = params;
4369
4426
  try {
4370
- const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
4427
+ const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget);
4371
4428
  if (candidates.length === 0) {
4372
4429
  // Focused observe under-returns on some controlled-component forms:
4373
4430
  // Stagehand's instruction-scoped observe can resolve zero candidates for a
@@ -4380,7 +4437,7 @@ async function probeStepBeforeAttempts(params) {
4380
4437
  // to the cascade (its observe-act / rephrase attempts recover the field
4381
4438
  // without spending the scarce replan budget). Only genuinely blank pages —
4382
4439
  // where the unfocused observe is also empty — stay "absent".
4383
- const unfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
4440
+ const unfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget);
4384
4441
  if (unfocused.length > 0) {
4385
4442
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: focused probe found 0 candidates but unfocused observe found ${unfocused.length} — treating as present (let cascade resolve)`);
4386
4443
  return "present";
@@ -4399,7 +4456,7 @@ async function probeStepBeforeAttempts(params) {
4399
4456
  }
4400
4457
  }
4401
4458
  async function executeStepWithHealing(params) {
4402
- const { stagehand, page, step, optional, upload, submitStep, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, logger, captureFn, resumeFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, } = params;
4459
+ const { stagehand, page, frameTarget, step, optional, upload, submitStep, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, logger, captureFn, resumeFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, } = params;
4403
4460
  // Read-once to suppress "unused" — knownErrorClassPrefixes is threaded
4404
4461
  // through executeStepWithHealing's signature so the cascade has it in
4405
4462
  // scope when the invalid-fields judge migration (Task #43) lands. The
@@ -4433,6 +4490,7 @@ async function executeStepWithHealing(params) {
4433
4490
  // the existing cascade.
4434
4491
  if (await tryUploadPrimitive({
4435
4492
  page,
4493
+ target: await (0, frame_target_1.resolveFrameTarget)(page),
4436
4494
  isUploadStep: upload,
4437
4495
  fixture: resumeFixture,
4438
4496
  logger,
@@ -4449,7 +4507,20 @@ async function executeStepWithHealing(params) {
4449
4507
  // cascade would otherwise skip them ("no candidates") and leave a required
4450
4508
  // question unanswered. No-op (returns false → falls through) when the step
4451
4509
  // isn't a single-dropdown select or no option matches.
4452
- if (await trySelectPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4510
+ //
4511
+ // resolveFrameTarget(page) resolves synchronously to the main-frame target
4512
+ // when no frameSelector is set, so this bridge is behavior-identical for
4513
+ // every existing site until the sibling subtask threads a resolved target
4514
+ // through end-to-end.
4515
+ const selectFrameTarget = await (0, frame_target_1.resolveFrameTarget)(page);
4516
+ if (await trySelectPrimitive({
4517
+ page,
4518
+ target: selectFrameTarget,
4519
+ instruction: step,
4520
+ logger,
4521
+ anthropic,
4522
+ captureFn,
4523
+ })) {
4453
4524
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by select primitive`);
4454
4525
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4455
4526
  return "completed";
@@ -4459,7 +4530,14 @@ async function executeStepWithHealing(params) {
4459
4530
  // screening questions this way — answer it directly in the DOM. Runs AFTER
4460
4531
  // trySelectPrimitive (which handles <select> and no-ops on checkbox-only
4461
4532
  // pages). No-op (falls through) when there's no checkbox group or no match.
4462
- if (await tryCheckboxPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4533
+ if (await tryCheckboxPrimitive({
4534
+ page,
4535
+ target: selectFrameTarget,
4536
+ instruction: step,
4537
+ logger,
4538
+ anthropic,
4539
+ captureFn,
4540
+ })) {
4463
4541
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by checkbox primitive`);
4464
4542
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4465
4543
  return "completed";
@@ -4470,7 +4548,14 @@ async function executeStepWithHealing(params) {
4470
4548
  // reach the observe cascade's el.click() fallback that fails to commit MUI/
4471
4549
  // React controlled state (the wizard ATS's Basic-Info Step-2 wall). No-op (falls
4472
4550
  // through) when there's no radio group or no confident option match.
4473
- if (await tryRadioPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4551
+ if (await tryRadioPrimitive({
4552
+ page,
4553
+ target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
4554
+ instruction: step,
4555
+ logger,
4556
+ anthropic,
4557
+ captureFn,
4558
+ })) {
4474
4559
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by radio primitive`);
4475
4560
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4476
4561
  return "completed";
@@ -4481,7 +4566,14 @@ async function executeStepWithHealing(params) {
4481
4566
  // step targets (requisition-specific specialty questions). Runs only on the
4482
4567
  // catch-all (parseSelectStep returns null there, so trySelectPrimitive above
4483
4568
  // skipped it) and no-ops when the page has no required-empty select.
4484
- if (await tryFillRequiredSelectsPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
4569
+ if (await tryFillRequiredSelectsPrimitive({
4570
+ page,
4571
+ target: selectFrameTarget,
4572
+ instruction: step,
4573
+ logger,
4574
+ anthropic,
4575
+ captureFn,
4576
+ })) {
4485
4577
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by required-select primitive`);
4486
4578
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
4487
4579
  return "completed";
@@ -4501,6 +4593,7 @@ async function executeStepWithHealing(params) {
4501
4593
  totalSteps,
4502
4594
  logger,
4503
4595
  captureFn,
4596
+ frameTarget,
4504
4597
  });
4505
4598
  if (probeResult === "absent") {
4506
4599
  if (optional) {
@@ -4508,7 +4601,7 @@ async function executeStepWithHealing(params) {
4508
4601
  // control this step was meant to answer (SPA hydration lag / observe
4509
4602
  // can't resolve the widget) — skipping would leave a required field empty
4510
4603
  // and silently doom the later submit. Fall through to the cascade instead.
4511
- if (await hasUnfilledRequiredControlForStep(page, step)) {
4604
+ if (await hasUnfilledRequiredControlForStep(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), step)) {
4512
4605
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} probe-absent but a required unfilled control matches the question; NOT skipping (escalating to cascade)`);
4513
4606
  }
4514
4607
  else {
@@ -4553,19 +4646,19 @@ async function executeStepWithHealing(params) {
4553
4646
  // burns the replan budget in seconds. Embed `see <path>` in the
4554
4647
  // throw message so the existing regex at the dispatcher (`/see
4555
4648
  // (\/[^\s]+)$/`) extracts dumpPath for replanRemainingFlow.
4556
- const pageTitle = await page.title().catch(() => "");
4557
- const bodyOuterHtmlRaw = await page
4649
+ const { pageTitle, pageUrl } = await resolveDumpPageIdentity(page, frameTarget);
4650
+ const bodyOuterHtmlRaw = await (frameTarget ?? page)
4558
4651
  .evaluate("document.body ? document.body.outerHTML : null")
4559
4652
  .catch(() => null);
4560
4653
  const bodyOuterHtml = typeof bodyOuterHtmlRaw === "string" ? bodyOuterHtmlRaw.slice(0, 100_000) : null;
4561
- const unfocusedObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn).catch(() => []);
4654
+ const unfocusedObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
4562
4655
  const dumpPath = onStepFailure?.({
4563
4656
  stepIndex,
4564
4657
  phase,
4565
4658
  originalStep: step,
4566
4659
  attempts: [],
4567
4660
  finalObserve: [],
4568
- pageUrl: page.url(),
4661
+ pageUrl,
4569
4662
  pageTitle,
4570
4663
  recentCaptures,
4571
4664
  bodyOuterHtml,
@@ -4591,10 +4684,12 @@ async function executeStepWithHealing(params) {
4591
4684
  // form-validity auto-picker runs. The early-exit predicate compares this
4592
4685
  // to the post-attempt-1 count to detect "the click revealed NEW required
4593
4686
  // fields" — a state attempts 2-5 mathematically can't clear.
4594
- const preSubmitInvalidCount = requireSubmitEndpoint ? await countNgInvalidContainers(page) : 0;
4687
+ const preSubmitInvalidCount = requireSubmitEndpoint
4688
+ ? await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page))
4689
+ : 0;
4595
4690
  if (requireSubmitEndpoint) {
4596
4691
  const invalidControls = await probeFormValidityBeforeSubmit({
4597
- page,
4692
+ target: await (0, frame_target_1.resolveFrameTarget)(page),
4598
4693
  stepIndex,
4599
4694
  totalSteps,
4600
4695
  logger,
@@ -4695,7 +4790,7 @@ async function executeStepWithHealing(params) {
4695
4790
  if (attempt > 1) {
4696
4791
  await page.waitForTimeout(attempt * ATTEMPT_BACKOFF_MS);
4697
4792
  }
4698
- const pre = await snapshotPage(page, signalCounter);
4793
+ const pre = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
4699
4794
  // Snapshot the meta-tail length so the final-step pattern gate can scope
4700
4795
  // its URL scan to captures added DURING this attempt (not historical
4701
4796
  // tail from earlier steps).
@@ -4778,7 +4873,7 @@ async function executeStepWithHealing(params) {
4778
4873
  for (let deepAttempt = 1; deepAttempt <= 2; deepAttempt++) {
4779
4874
  let ranked;
4780
4875
  try {
4781
- ranked = (await page.evaluate((0, submit_control_1.buildRankSubmitCandidatesExpr)()));
4876
+ ranked = (await (frameTarget ?? page).evaluate((0, submit_control_1.buildRankSubmitCandidatesExpr)()));
4782
4877
  }
4783
4878
  catch (err) {
4784
4879
  // A thrown evaluate (page navigated away / frame detached) is not
@@ -4798,7 +4893,7 @@ async function executeStepWithHealing(params) {
4798
4893
  triedSelectors.push(`deep-index:${top.deepIndex}`);
4799
4894
  let clickResult;
4800
4895
  try {
4801
- clickResult = (await page.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex)));
4896
+ clickResult = (await (frameTarget ?? page).evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex)));
4802
4897
  }
4803
4898
  catch (err) {
4804
4899
  record.errorMessage = `deep-submit-locator: click evaluate threw ${(0, errors_1.toErrorMessage)(err)}`;
@@ -4828,7 +4923,7 @@ async function executeStepWithHealing(params) {
4828
4923
  // burn the step budget probing all of them.
4829
4924
  const runnerUp = ranked[1];
4830
4925
  if (runnerUp) {
4831
- const midPost = await snapshotPage(page, signalCounter);
4926
+ const midPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
4832
4927
  const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
4833
4928
  actResultSuccess: true,
4834
4929
  pre,
@@ -4842,7 +4937,7 @@ async function executeStepWithHealing(params) {
4842
4937
  `deep-index:${runnerUp.deepIndex}`,
4843
4938
  ];
4844
4939
  triedSelectors.push(`deep-index:${runnerUp.deepIndex}`);
4845
- const runnerUpClickResult = (await page
4940
+ const runnerUpClickResult = (await (frameTarget ?? page)
4846
4941
  .evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))
4847
4942
  .catch(() => ({ clicked: false })));
4848
4943
  record.actResultSuccess = runnerUpClickResult.clicked;
@@ -4878,7 +4973,7 @@ async function executeStepWithHealing(params) {
4878
4973
  const observeOptions = attempt === 4 && triedSelectors.length > 0
4879
4974
  ? { ignoreSelectors: [...triedSelectors], timeout: exports.STEP_WATCHDOG_MS }
4880
4975
  : { timeout: exports.STEP_WATCHDOG_MS };
4881
- const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, observeOptions, captureFn);
4976
+ const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, observeOptions, captureFn, frameTarget);
4882
4977
  if (candidates.length === 0) {
4883
4978
  record.errorMessage = "observe returned no candidates";
4884
4979
  // Optional-step short-circuit: when attempt 2 confirms no candidates
@@ -4899,7 +4994,7 @@ async function executeStepWithHealing(params) {
4899
4994
  // still-empty control matching this step's question is present,
4900
4995
  // don't fast-skip — let the healing cascade continue so the
4901
4996
  // required field gets answered instead of silently doomed.
4902
- if (await hasUnfilledRequiredControlForStep(page, step)) {
4997
+ if (await hasUnfilledRequiredControlForStep(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), step)) {
4903
4998
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} no candidates after act+observe but a required unfilled control matches; NOT skipping (continuing cascade)`);
4904
4999
  }
4905
5000
  else {
@@ -4956,7 +5051,8 @@ async function executeStepWithHealing(params) {
4956
5051
  target.arguments.length > 0) {
4957
5052
  const fillValue = target.arguments[0];
4958
5053
  if (typeof fillValue === "string") {
4959
- const dateFill = await fillHtml5DateTimeInput(page, target.selector, fillValue);
5054
+ const frameTarget = await (0, frame_target_1.resolveFrameTarget)(page);
5055
+ const dateFill = await fillHtml5DateTimeInput(frameTarget, target.selector, fillValue);
4960
5056
  if (dateFill !== null) {
4961
5057
  record.errorMessage = dateFill.filled
4962
5058
  ? `html5-date-fallback: filled ${dateFill.inputType}="${dateFill.postValue}"`
@@ -4976,7 +5072,7 @@ async function executeStepWithHealing(params) {
4976
5072
  // component rejection, masked-input library reformatting).
4977
5073
  // Generic primitive that the verifier's existing signals
4978
5074
  // (network/url/dom/htmlDelta/textChanged) miss.
4979
- const readback = await verifyFillReadback(page, target.selector, fillValue);
5075
+ const readback = await verifyFillReadback(frameTarget, target.selector, fillValue);
4980
5076
  if (readback !== null) {
4981
5077
  if (readback.outcome === "rejected") {
4982
5078
  record.errorMessage = `fill-value-rejected: tried "${fillValue.slice(0, 60)}" on <${readback.tag}>; element value remains empty (silent rejection — HTML5 type validation, framework controlled-component, or masked-input library)`;
@@ -5094,7 +5190,8 @@ async function executeStepWithHealing(params) {
5094
5190
  return { resolved: true, isCheckable: true, checked: false, strategyUsed: null };
5095
5191
  })()`;
5096
5192
  try {
5097
- const result = await page.evaluate(probeExpr);
5193
+ const structuredClickTarget = await (0, frame_target_1.resolveFrameTarget)(page);
5194
+ const result = await structuredClickTarget.evaluate(probeExpr);
5098
5195
  if (result !== null && typeof result === "object" && "resolved" in result) {
5099
5196
  const probe = result;
5100
5197
  if (probe.resolved !== true || probe.isCheckable !== true) {
@@ -5148,7 +5245,7 @@ async function executeStepWithHealing(params) {
5148
5245
  // Fetch live-page evidence so the rephrase prompt can reason about
5149
5246
  // form state, not just observe candidates. Mirrors the same
5150
5247
  // extraction the cascade-exhaust dump path already does.
5151
- const livePageEvidence = await extractLivePageFormEvidence(page, {
5248
+ const livePageEvidence = await extractLivePageFormEvidence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), {
5152
5249
  client: anthropic,
5153
5250
  knownErrorClassPrefixes,
5154
5251
  captureFn,
@@ -5208,7 +5305,7 @@ async function executeStepWithHealing(params) {
5208
5305
  continue;
5209
5306
  }
5210
5307
  await page.waitForTimeout(STEP_PAUSE_MS);
5211
- const post = await snapshotPage(page, signalCounter);
5308
+ const post = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
5212
5309
  record.post = post;
5213
5310
  if (resolvedAction) {
5214
5311
  record.resolvedMethod = resolvedAction.method ?? null;
@@ -5225,7 +5322,7 @@ async function executeStepWithHealing(params) {
5225
5322
  // false for non-radio/non-checkbox clicks so the network/URL signal still
5226
5323
  // decides those. Radios/checkboxes are click-but-no-network just like fills.
5227
5324
  const domVerified = resolvedAction !== null && (isStateClass || isClick)
5228
- ? await verifyDomEffect(page, resolvedAction)
5325
+ ? await verifyDomEffect(await (0, frame_target_1.resolveFrameTarget)(page), resolvedAction)
5229
5326
  : false;
5230
5327
  // Interior-advance transition gate (opt-in). On SPAs where a page advance
5231
5328
  // and a mere field-edit share one endpoint URL (the wizard ATS's `/gq`:
@@ -5318,7 +5415,8 @@ async function executeStepWithHealing(params) {
5318
5415
  return null;
5319
5416
  })()`;
5320
5417
  try {
5321
- domSubmittedMatch = (await page.evaluate(probeExpr));
5418
+ const submittedStateTarget = await (0, frame_target_1.resolveFrameTarget)(page);
5419
+ domSubmittedMatch = (await submittedStateTarget.evaluate(probeExpr));
5322
5420
  }
5323
5421
  catch (err) {
5324
5422
  logger.warn(`submitted-state DOM probe threw: ${(0, errors_1.toErrorMessage)(err)} — judge will reason without it`);
@@ -5330,7 +5428,7 @@ async function executeStepWithHealing(params) {
5330
5428
  // Quick invalid-marker count (deterministic DOM querying — counting
5331
5429
  // structural ng-invalid containers is not fuzzy matching, just
5332
5430
  // observing existence).
5333
- const invalidMarkerCount = await countNgInvalidContainers(page).catch(() => 0);
5431
+ const invalidMarkerCount = await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).catch(() => 0);
5334
5432
  const pageTitle = await page.title().catch(() => "");
5335
5433
  const matchedSubmittedSelectors = domSubmittedMatch !== null ? [domSubmittedMatch] : [];
5336
5434
  const judgeVerdict = await (0, verify_submit_1.verifySubmitWithLLM)({
@@ -5442,7 +5540,8 @@ async function executeStepWithHealing(params) {
5442
5540
  // reliably trigger that default action — same gap N+42 documented
5443
5541
  // for direct checkbox/radio clicks.
5444
5542
  const clickExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); let el = r.singleNodeValue; if (!el || typeof el.click !== "function") return { fired: false }; if (el.tagName === "LABEL") { const wrapped = el.querySelector("input[type=checkbox], input[type=radio]"); if (wrapped) el = wrapped; } if (el.type === "checkbox" || el.type === "radio") { el.checked = true; el.dispatchEvent(new Event("click", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { fired: true, kind: "checkbox", checked: el.checked }; } el.click(); return { fired: true, kind: "click" }; })()`;
5445
- const probeResult = (await page.evaluate(clickExpr));
5543
+ const n16FallbackTarget = await (0, frame_target_1.resolveFrameTarget)(page);
5544
+ const probeResult = (await n16FallbackTarget.evaluate(clickExpr));
5446
5545
  const fired = probeResult.fired;
5447
5546
  // Vacuous-click guard for the n+16 fallback. Same rationale as
5448
5547
  // verifyDomEffect's click case: a checkbox/radio input's .checked
@@ -5465,7 +5564,7 @@ async function executeStepWithHealing(params) {
5465
5564
  }
5466
5565
  return false;
5467
5566
  })()`;
5468
- ancestorStillInvalid = (await page
5567
+ ancestorStillInvalid = (await n16FallbackTarget
5469
5568
  .evaluate(ancestorInvalidExpr)
5470
5569
  .catch(() => false));
5471
5570
  }
@@ -5473,7 +5572,7 @@ async function executeStepWithHealing(params) {
5473
5572
  probeResult.checked === true &&
5474
5573
  !ancestorStillInvalid;
5475
5574
  await page.waitForTimeout(STEP_PAUSE_MS);
5476
- const retryPost = await snapshotPage(page, signalCounter);
5575
+ const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
5477
5576
  const retryNetworkFired = retryPost.networkCount > pre.networkCount;
5478
5577
  const retryUrlChanged = retryPost.url !== pre.url;
5479
5578
  const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
@@ -5490,7 +5589,9 @@ async function executeStepWithHealing(params) {
5490
5589
  // Confirmed no-op signature on the wizard ATS's COMPENSATION page (network=false
5491
5590
  // url=false htmlDelta>0 textChanged) mis-scored verified=true(dom).
5492
5591
  const clickWasDomOnly = probeResult.kind === "click" && !retryNetworkFired && !retryUrlChanged;
5493
- const clickBlockedByInvalid = clickWasDomOnly && (await countNgInvalidContainers(page).catch(() => 0)) > 0;
5592
+ const clickBlockedByInvalid = clickWasDomOnly &&
5593
+ (await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).catch(() => 0)) >
5594
+ 0;
5494
5595
  // Advance-transition gate (same as the primary verifier, applied to the
5495
5596
  // n+16 fallback). RC2: for a non-submit ADVANCE/"Next" step (per the
5496
5597
  // ORIGINAL instruction) the fallback's positive signals — a network POST
@@ -5553,14 +5654,14 @@ async function executeStepWithHealing(params) {
5553
5654
  return null;
5554
5655
  })()`;
5555
5656
  try {
5556
- domSubmittedMatch = (await page.evaluate(probeExpr));
5657
+ domSubmittedMatch = (await (frameTarget ?? page).evaluate(probeExpr));
5557
5658
  }
5558
5659
  catch (err) {
5559
5660
  logger.warn(`n+16 submitted-state DOM probe threw: ${(0, errors_1.toErrorMessage)(err)}`);
5560
5661
  }
5561
5662
  }
5562
- const unfocusedForJudge = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn).catch(() => []);
5563
- const invalidMarkerCount = await countNgInvalidContainers(page).catch(() => 0);
5663
+ const unfocusedForJudge = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5664
+ const invalidMarkerCount = await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).catch(() => 0);
5564
5665
  const pageTitle = await page.title().catch(() => "");
5565
5666
  const matchedSubmittedSelectors = domSubmittedMatch !== null ? [domSubmittedMatch] : [];
5566
5667
  const judgeVerdict = await (0, verify_submit_1.verifySubmitWithLLM)({
@@ -5670,7 +5771,7 @@ async function executeStepWithHealing(params) {
5670
5771
  // dumps in a 2026-06-10 survey had the paired touched+dirty + visible
5671
5772
  // error text pattern with 3 distinct rejection messages.
5672
5773
  if (record.resolvedMethod === "click" && (isFinalStep || submitStep)) {
5673
- const live = await extractLivePageFormEvidence(page, {
5774
+ const live = await extractLivePageFormEvidence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), {
5674
5775
  client: anthropic,
5675
5776
  knownErrorClassPrefixes,
5676
5777
  captureFn,
@@ -5719,7 +5820,7 @@ async function executeStepWithHealing(params) {
5719
5820
  : "non-submit step — leaving the normal structured-click/observe-act-exclude ladder intact";
5720
5821
  logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} phantom click detected on attempt 1 (${record.technique}): reported success with no network/url/dom change${suppressedCount !== undefined ? `; ${suppressedCount} AISDK elementId errors suppressed this session (corroborating, not causal)` : ""} — ${escalationTarget}`);
5721
5822
  }
5722
- const postAttemptInvalidCount = await countNgInvalidContainers(page);
5823
+ const postAttemptInvalidCount = await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page));
5723
5824
  const earlyExit = isSubmitRevealedInvalid({
5724
5825
  // Treat the canonical submit click as "final" for this predicate
5725
5826
  // even when it lives mid-flow. See requireSubmitEndpoint derivation
@@ -5759,23 +5860,23 @@ async function executeStepWithHealing(params) {
5759
5860
  }
5760
5861
  }
5761
5862
  }
5762
- const finalObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn).catch(() => []);
5763
- const pageTitle = await page.title().catch(() => "");
5863
+ const finalObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5864
+ const { pageTitle, pageUrl } = await resolveDumpPageIdentity(page, frameTarget);
5764
5865
  // Discriminator data for "Stagehand sees nothing" failures: capture the raw
5765
5866
  // DOM and an unfocused observe so a triager can tell empty-page from
5766
5867
  // Stagehand-can't-see-it without reproducing the failure.
5767
- const bodyOuterHtmlRaw = await page
5868
+ const bodyOuterHtmlRaw = await (frameTarget ?? page)
5768
5869
  .evaluate("document.body ? document.body.outerHTML : null")
5769
5870
  .catch(() => null);
5770
5871
  const bodyOuterHtml = typeof bodyOuterHtmlRaw === "string" ? bodyOuterHtmlRaw.slice(0, 100_000) : null;
5771
- const unfocusedObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn).catch(() => []);
5872
+ const unfocusedObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5772
5873
  const dumpPath = onStepFailure?.({
5773
5874
  stepIndex,
5774
5875
  phase,
5775
5876
  originalStep: step,
5776
5877
  attempts,
5777
5878
  finalObserve,
5778
- pageUrl: page.url(),
5879
+ pageUrl,
5779
5880
  pageTitle,
5780
5881
  recentCaptures,
5781
5882
  bodyOuterHtml,
@@ -5850,6 +5951,8 @@ async function runHealingFlow(deps) {
5850
5951
  let submitVerified = false;
5851
5952
  let submitStepSkipped = false;
5852
5953
  let lastStepIndex = -1;
5954
+ const frameTarget = await (0, frame_target_1.resolveFrameTarget)(page, deps.frameSelector);
5955
+ await (0, frame_target_1.waitForChildFrameReady)(frameTarget);
5853
5956
  const stopCapture = wireSignalCapture(page, {
5854
5957
  counter,
5855
5958
  signalCounter,
@@ -5887,6 +5990,7 @@ async function runHealingFlow(deps) {
5887
5990
  anthropic,
5888
5991
  logger,
5889
5992
  resumeFixture,
5993
+ frameTarget,
5890
5994
  isFinalStep: i === steps.length - 1,
5891
5995
  submitEndpointPattern: deps.submitEndpointPattern ?? null,
5892
5996
  submittedStateSelectors: deps.submittedStateSelectors ?? [],