@enricai/barnacle 1.6.12 → 1.6.13

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 (96) hide show
  1. package/README.md +113 -9
  2. package/dist/api/schemas/submissions.d.ts +14 -0
  3. package/dist/api/schemas/submissions.d.ts.map +1 -1
  4. package/dist/api/schemas/submissions.js +1 -0
  5. package/dist/api/schemas/submissions.js.map +1 -1
  6. package/dist/config.d.ts +33 -0
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +4 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/lib/telemetry/beacon-capture.d.ts +45 -2
  11. package/dist/lib/telemetry/beacon-capture.d.ts.map +1 -1
  12. package/dist/lib/telemetry/beacon-capture.js +36 -0
  13. package/dist/lib/telemetry/beacon-capture.js.map +1 -1
  14. package/dist/lib/telemetry/reconciliation-record.d.ts +41 -1
  15. package/dist/lib/telemetry/reconciliation-record.d.ts.map +1 -1
  16. package/dist/lib/telemetry/reconciliation-record.js +29 -2
  17. package/dist/lib/telemetry/reconciliation-record.js.map +1 -1
  18. package/dist/lib/telemetry/reconciliation-source.d.ts +7 -1
  19. package/dist/lib/telemetry/reconciliation-source.d.ts.map +1 -1
  20. package/dist/lib/telemetry/reconciliation-source.js +21 -5
  21. package/dist/lib/telemetry/reconciliation-source.js.map +1 -1
  22. package/dist/lib/telemetry/run-telemetry.d.ts +67 -0
  23. package/dist/lib/telemetry/run-telemetry.d.ts.map +1 -0
  24. package/dist/lib/telemetry/run-telemetry.js +55 -0
  25. package/dist/lib/telemetry/run-telemetry.js.map +1 -0
  26. package/dist/lib/telemetry/submission-capture.d.ts +11 -4
  27. package/dist/lib/telemetry/submission-capture.d.ts.map +1 -1
  28. package/dist/lib/telemetry/submission-capture.js +1 -0
  29. package/dist/lib/telemetry/submission-capture.js.map +1 -1
  30. package/dist/lib/telemetry/submission-reader.d.ts +11 -3
  31. package/dist/lib/telemetry/submission-reader.d.ts.map +1 -1
  32. package/dist/lib/telemetry/submission-reader.js +32 -8
  33. package/dist/lib/telemetry/submission-reader.js.map +1 -1
  34. package/dist/lib/tracking-click.d.ts.map +1 -1
  35. package/dist/lib/tracking-click.js +19 -0
  36. package/dist/lib/tracking-click.js.map +1 -1
  37. package/dist/plugins/loader.d.ts +20 -0
  38. package/dist/plugins/loader.d.ts.map +1 -1
  39. package/dist/plugins/loader.js +94 -17
  40. package/dist/plugins/loader.js.map +1 -1
  41. package/dist/scraper/deep-locator-actuate.d.ts +94 -0
  42. package/dist/scraper/deep-locator-actuate.d.ts.map +1 -0
  43. package/dist/scraper/deep-locator-actuate.js +216 -0
  44. package/dist/scraper/deep-locator-actuate.js.map +1 -0
  45. package/dist/scraper/deep-locator-candidates.d.ts +105 -18
  46. package/dist/scraper/deep-locator-candidates.d.ts.map +1 -1
  47. package/dist/scraper/deep-locator-candidates.js +238 -46
  48. package/dist/scraper/deep-locator-candidates.js.map +1 -1
  49. package/dist/scraper/deep-locator-click.d.ts +71 -0
  50. package/dist/scraper/deep-locator-click.d.ts.map +1 -0
  51. package/dist/scraper/deep-locator-click.js +71 -0
  52. package/dist/scraper/deep-locator-click.js.map +1 -0
  53. package/dist/scraper/deep-locator-fake.d.ts +344 -14
  54. package/dist/scraper/deep-locator-fake.d.ts.map +1 -1
  55. package/dist/scraper/deep-locator-fake.js +509 -7
  56. package/dist/scraper/deep-locator-fake.js.map +1 -1
  57. package/dist/scraper/deep-locator-scan.d.ts +220 -0
  58. package/dist/scraper/deep-locator-scan.d.ts.map +1 -0
  59. package/dist/scraper/deep-locator-scan.js +386 -0
  60. package/dist/scraper/deep-locator-scan.js.map +1 -0
  61. package/dist/scraper/fake-timer-advance.d.ts +47 -0
  62. package/dist/scraper/fake-timer-advance.d.ts.map +1 -0
  63. package/dist/scraper/fake-timer-advance.js +44 -0
  64. package/dist/scraper/fake-timer-advance.js.map +1 -0
  65. package/dist/scraper/flow-runner.d.ts +36 -1
  66. package/dist/scraper/flow-runner.d.ts.map +1 -1
  67. package/dist/scraper/flow-runner.js +354 -56
  68. package/dist/scraper/flow-runner.js.map +1 -1
  69. package/dist/scraper/frame-target.d.ts +28 -0
  70. package/dist/scraper/frame-target.d.ts.map +1 -1
  71. package/dist/scraper/frame-target.js +30 -0
  72. package/dist/scraper/frame-target.js.map +1 -1
  73. package/dist/scraper/session-browserbase.d.ts +13 -0
  74. package/dist/scraper/session-browserbase.d.ts.map +1 -1
  75. package/dist/scraper/session-browserbase.js +29 -0
  76. package/dist/scraper/session-browserbase.js.map +1 -1
  77. package/dist/scraper/session-ip.d.ts +69 -0
  78. package/dist/scraper/session-ip.d.ts.map +1 -0
  79. package/dist/scraper/session-ip.js +88 -0
  80. package/dist/scraper/session-ip.js.map +1 -0
  81. package/dist/scraper/session-shared.d.ts +9 -0
  82. package/dist/scraper/session-shared.d.ts.map +1 -1
  83. package/dist/scraper/session-shared.js.map +1 -1
  84. package/dist/scraper/submit-control.d.ts +14 -2
  85. package/dist/scraper/submit-control.d.ts.map +1 -1
  86. package/dist/scraper/submit-control.js +33 -2
  87. package/dist/scraper/submit-control.js.map +1 -1
  88. package/dist/scripts/recon-browser.d.ts.map +1 -1
  89. package/dist/scripts/recon-browser.js +32 -0
  90. package/dist/scripts/recon-browser.js.map +1 -1
  91. package/dist/site-plugin.d.ts +20 -0
  92. package/dist/site-plugin.d.ts.map +1 -1
  93. package/dist/testing/integration-runner.d.ts.map +1 -1
  94. package/dist/testing/integration-runner.js +6 -7
  95. package/dist/testing/integration-runner.js.map +1 -1
  96. package/package.json +1 -1
@@ -56,6 +56,7 @@ exports.writeFixtureToTempFile = writeFixtureToTempFile;
56
56
  exports.attachToSurfacedInput = attachToSurfacedInput;
57
57
  exports.surfaceAndUpload = surfaceAndUpload;
58
58
  exports.parseSelectStep = parseSelectStep;
59
+ exports.parseFillStep = parseFillStep;
59
60
  exports.parseRadioStep = parseRadioStep;
60
61
  exports.pollEnumerate = pollEnumerate;
61
62
  exports.waitForTransitionBody = waitForTransitionBody;
@@ -87,7 +88,10 @@ const schemas_1 = require("../lib/llm/schemas");
87
88
  const logging_1 = require("../lib/logging");
88
89
  const call_capture_1 = require("../lib/telemetry/call-capture");
89
90
  const call_types_1 = require("../lib/telemetry/call-types");
91
+ const deep_locator_actuate_1 = require("../scraper/deep-locator-actuate");
90
92
  const deep_locator_candidates_1 = require("../scraper/deep-locator-candidates");
93
+ const deep_locator_click_1 = require("../scraper/deep-locator-click");
94
+ const deep_locator_scan_1 = require("../scraper/deep-locator-scan");
91
95
  const errors_2 = require("../scraper/errors");
92
96
  const frame_target_1 = require("../scraper/frame-target");
93
97
  const phantom_click_1 = require("../scraper/phantom-click");
@@ -476,7 +480,14 @@ exports.TRAILING_GRACE_WINDOW = 2;
476
480
  * shape via Runtime.callFunctionOn.
477
481
  */
478
482
  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) }; })()`;
479
- async function snapshotPage(target, signalCounter) {
483
+ /**
484
+ * Captures the pre/post signal triple the submit-verify cascade diffs.
485
+ * Accepts the optional `page` so a resolved child `FrameTarget` whose
486
+ * `url()` rejects (OOPIF detached by the submit it just fired) can still
487
+ * report the main frame's post-navigation URL instead of throwing the
488
+ * whole attempt out of `executeStepWithHealing`.
489
+ */
490
+ async function snapshotPage(target, signalCounter, page) {
480
491
  let bodyHtmlLength = 0;
481
492
  let visibleTextSignature = "";
482
493
  try {
@@ -495,9 +506,16 @@ async function snapshotPage(target, signalCounter) {
495
506
  // Snapshot is observational; on failure, defaults to 0/"" so the verifier
496
507
  // sees no delta. Real state-class checks already cover the verified path.
497
508
  }
509
+ // A resolved child FrameTarget's url() reads location.href off the CDP
510
+ // frame session (frame-target.ts's childFrameTarget), which rejects (or
511
+ // trips its watchdog) once the OOPIF detaches — most commonly right after
512
+ // a submit click tears down the wizard iframe. Falling back to page.url()
513
+ // lets the post-submit navigation still register as a urlChanged signal
514
+ // instead of throwing the whole step out of the cascade.
515
+ const url = page ? await target.url().catch(() => page.url()) : await target.url();
498
516
  return {
499
517
  networkCount: signalCounter.n,
500
- url: await target.url(),
518
+ url,
501
519
  bodyHtmlLength,
502
520
  visibleTextSignature,
503
521
  };
@@ -2687,6 +2705,40 @@ function parseSelectStep(instruction) {
2687
2705
  const questionLabel = quoted.find((q) => q.trim() !== option)?.trim() ?? null;
2688
2706
  return { option, questionLabel };
2689
2707
  }
2708
+ /**
2709
+ * Parse a declarative TEXT-FILL flow step into the field label to target and
2710
+ * the value to type into it.
2711
+ *
2712
+ * Why this exists (sibling of `parseSelectStep`/`parseRadioStep`): the deep-
2713
+ * locator cascade's candidate ranking (`resolveDeepLocatorCandidates`'s
2714
+ * `scoreCandidate`) ranks by the instruction's quoted phrases, which for a
2715
+ * fill step is only the VALUE ("Fill in the First Name field with
2716
+ * 'Reginald'" quotes just `'Reginald'`) — no candidate's accessible name
2717
+ * (e.g. "First Name") ever matches a person's name, so every candidate ties
2718
+ * at score 0 and DOM order decides, clicking whatever happens to be first
2719
+ * rather than filling the named field. This parser recovers the FIELD LABEL
2720
+ * ("First Name") so the caller can match it directly against a candidate's
2721
+ * accessible name instead of ranking by the value.
2722
+ *
2723
+ * Recognizes the flow's conventional phrasing (confirmed against
2724
+ * `src/recon/fixtures/shipped-ats-flow-steps.json`):
2725
+ * "Fill in the First Name field with 'Reginald'",
2726
+ * "Fill the signature field with 'Name'",
2727
+ * "Fill in the Acme Non-Employee ID textbox with 'NA'".
2728
+ * Returns null when the step doesn't match this shape (no "fill" verb, no
2729
+ * field/input/textbox noun, or no quoted value) so the caller falls through
2730
+ * to the normal cascade unchanged.
2731
+ */
2732
+ function parseFillStep(instruction) {
2733
+ const match = instruction.match(/\bfill(?:\s+in)?\s+(?:the\s+)?(.+?)\s+(?:field|input|textbox)\s+with\s+'([^']+)'/i);
2734
+ if (!match)
2735
+ return null;
2736
+ const fieldLabel = match[1]?.trim();
2737
+ const value = match[2]?.trim();
2738
+ if (!fieldLabel || !value)
2739
+ return null;
2740
+ return { fieldLabel, value };
2741
+ }
2690
2742
  /**
2691
2743
  * Parse a single-choice RADIO flow step into the option to click and (when
2692
2744
  * present) the question label that scopes which radio group it targets.
@@ -2740,6 +2792,44 @@ function parseRadioStep(instruction) {
2740
2792
  const questionLabel = quoted.find((q) => q.trim() !== option)?.trim() ?? null;
2741
2793
  return { option, questionLabel };
2742
2794
  }
2795
+ function resolveDeepLocatorActuation(step) {
2796
+ const selectParsed = parseSelectStep(step);
2797
+ if (selectParsed)
2798
+ return { kind: "select", value: selectParsed.option };
2799
+ const fillParsed = parseFillStep(step);
2800
+ if (fillParsed)
2801
+ return { kind: "fill", value: fillParsed.value };
2802
+ return { kind: "click" };
2803
+ }
2804
+ /** Whitespace-collapsed, lowercased comparison key for {@link findDeepLocatorCandidateByFieldLabel}. */
2805
+ function normalizeFieldLabel(text) {
2806
+ return text.replace(/\s+/g, " ").trim().toLowerCase();
2807
+ }
2808
+ /**
2809
+ * Finds the candidate whose accessible name identifies the named field —
2810
+ * `fieldLabel` (from {@link parseFillStep}/{@link parseSelectStep}), NOT the
2811
+ * value being written — among a frame's deepLocator candidates. Exact match
2812
+ * wins first (a bare "First Name" input against a `fieldLabel` of "First
2813
+ * Name"); otherwise a substring match either direction (a `fieldLabel` of
2814
+ * "Acme Non-Employee ID" against an accessible name of "Non-Employee ID", or
2815
+ * the reverse) so minor phrasing drift between the flow's field noun and the
2816
+ * control's own label still resolves. Returns `null` — never a guess — when
2817
+ * no candidate's accessible name relates to `fieldLabel` at all, so the
2818
+ * caller can refuse to act rather than fill/click the wrong control.
2819
+ */
2820
+ function findDeepLocatorCandidateByFieldLabel(candidates, fieldLabel) {
2821
+ const normalizedLabel = normalizeFieldLabel(fieldLabel);
2822
+ if (!normalizedLabel)
2823
+ return null;
2824
+ const named = candidates
2825
+ .map((candidate) => ({ candidate, text: normalizeFieldLabel(candidate.accessibleText) }))
2826
+ .filter((entry) => entry.text.length > 0);
2827
+ const exact = named.find((entry) => entry.text === normalizedLabel);
2828
+ if (exact)
2829
+ return exact.candidate;
2830
+ const partial = named.find((entry) => entry.text.includes(normalizedLabel) || normalizedLabel.includes(entry.text));
2831
+ return partial?.candidate ?? null;
2832
+ }
2743
2833
  /** Max settle-retry attempts for a primitive's DOM enumerate (see `pollEnumerate`). */
2744
2834
  const PRIMITIVE_ENUMERATE_ATTEMPTS = 5;
2745
2835
  /** Delay between settle-retry attempts. Total cap ≈ ATTEMPTS × this. */
@@ -4414,25 +4504,63 @@ async function probeFormValidityBeforeSubmit(params) {
4414
4504
  }
4415
4505
  }
4416
4506
  /**
4417
- * Adapts `resolveDeepLocatorCandidates` results into `Action`-shaped
4418
- * evidence for `rephraseWithLLM`, which only knows about Stagehand's
4419
- * `Action` type. Never throws: `resolveDeepLocatorCandidates` itself
4420
- * degrades to `[]` on a resolver failure, so this only feeds the rephrase
4421
- * prompt richer evidence when available a frame-scoped step whose
4422
- * observe AND deepLocator both come back empty just gets the same `[]`
4423
- * evidence it would have gotten before this fix.
4507
+ * Resolves deepLocator candidates scoped to `INTERACTIVE_CANDIDATE_SELECTOR`,
4508
+ * widening once to the unscoped `"*"` hop when the scoped pass finds
4509
+ * nothing. `INTERACTIVE_CANDIDATE_SELECTOR` is what makes a dense OOPIF
4510
+ * form's candidate set tractable (371 nodes down to a handful see
4511
+ * `deep-locator-candidates.ts`'s module docblock), but it over-narrows a
4512
+ * `div`/`span` tile that carries only a click handler no `role`/
4513
+ * `tabindex` so a scoped-empty result would otherwise strand an attempt
4514
+ * the pre-1.6.13 unscoped `"*"` hop still resolved. The widened pass only
4515
+ * runs when the scoped one returns zero, so the dense-form fast path
4516
+ * (`flow-runner.oopif-dense-form-budget.test.ts`) still costs a single
4517
+ * resolve. Returns the `innerSelector` actually used alongside the
4518
+ * candidates so a caller driving `clickDeepLocatorCandidate` re-derives the
4519
+ * SAME hop the candidates were ranked/indexed against.
4520
+ */
4521
+ async function resolveDeepLocatorCandidatesWithWidening(page, frameSelector, instruction, timeoutOptions) {
4522
+ const scoped = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameSelector, deep_locator_scan_1.INTERACTIVE_CANDIDATE_SELECTOR, instruction, timeoutOptions);
4523
+ if (scoped.length > 0) {
4524
+ return { candidates: scoped, innerSelector: deep_locator_scan_1.INTERACTIVE_CANDIDATE_SELECTOR };
4525
+ }
4526
+ const widened = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameSelector, "*", instruction, timeoutOptions);
4527
+ return { candidates: widened, innerSelector: "*" };
4528
+ }
4529
+ /**
4530
+ * Adapts `resolveDeepLocatorCandidatesWithWidening` results into
4531
+ * `Action`-shaped evidence for `rephraseWithLLM`, which only knows about
4532
+ * Stagehand's `Action` type. Never throws: `resolveDeepLocatorCandidates`
4533
+ * itself degrades to `[]` on a resolver failure, so this only feeds the
4534
+ * rephrase prompt richer evidence when available — a frame-scoped step
4535
+ * whose observe AND deepLocator (scoped, then widened) both come back
4536
+ * empty just gets the same `[]` evidence it would have gotten before this
4537
+ * fix.
4538
+ *
4539
+ * `instruction` is forwarded so the evidence list is ranked by relevance to
4540
+ * the step, same as the act path — an unranked `[]`-then-DOM-order list
4541
+ * would feed the rephrase LLM its worst evidence first instead of its best.
4542
+ * It's also what {@link resolveDeepLocatorActuation} reads to pick the
4543
+ * evidence's `method`/`arguments` — a rephrase evaluating a "Fill in the
4544
+ * First Name field with 'Reginald'" step should see fill/"Reginald"
4545
+ * evidence, not a hard-coded click, now that the cascade itself can
4546
+ * actuate fill/select. Omitted (the unfocused-observe evidence list) falls
4547
+ * back to `click` with no value, same as before this method inference
4548
+ * existed.
4424
4549
  *
4425
- * `instruction` is forwarded to `resolveDeepLocatorCandidates` so the
4426
- * evidence list is ranked by relevance to the step, same as the act path —
4427
- * an unranked `[]`-then-DOM-order list would feed the rephrase LLM its
4428
- * worst evidence first instead of its best.
4550
+ * Takes the caller's already-resolved `frameTarget` so
4551
+ * `resolveDeepLocatorCandidates` uses the batched frame-scoped evaluate
4552
+ * instead of re-resolving it internally.
4429
4553
  */
4430
- async function deepLocatorCandidatesAsActions(page, frameSelector, instruction) {
4431
- const candidates = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameSelector, "*", instruction);
4554
+ async function deepLocatorCandidatesAsActions(page, frameTarget, instruction) {
4555
+ const { candidates } = await resolveDeepLocatorCandidatesWithWidening(page, frameTarget.frameSelector, instruction, { frameTarget });
4556
+ const actuation = instruction
4557
+ ? resolveDeepLocatorActuation(instruction)
4558
+ : { kind: "click" };
4432
4559
  return candidates.map((c) => ({
4433
4560
  selector: c.selector,
4434
4561
  description: c.accessibleText || "(no accessible text)",
4435
- method: "click",
4562
+ method: actuation.kind === "select" ? "selectOption" : actuation.kind === "fill" ? "fill" : "click",
4563
+ ...(actuation.kind === "click" ? {} : { arguments: [actuation.value] }),
4436
4564
  }));
4437
4565
  }
4438
4566
  /**
@@ -4479,7 +4607,16 @@ async function probeStepBeforeAttempts(params) {
4479
4607
  ? await reresolveFrameTarget()
4480
4608
  : frameTarget;
4481
4609
  if (effectiveFrameTarget?.frame) {
4482
- const deepLocatorCandidates = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, effectiveFrameTarget.frameSelector, "*");
4610
+ // Pass the already-resolved `effectiveFrameTarget` so the batched
4611
+ // evaluate reuses it instead of `resolveDeepLocatorCandidates`
4612
+ // re-resolving via its own internal `resolveFrameTarget` fallback —
4613
+ // same "reuse the ambient target" rule as every other frame-scoped
4614
+ // call in this function. Innerselector stays "*": this probe only
4615
+ // answers "does this frame have any content", and batching already
4616
+ // makes that reachability check cheap (one evaluate over every
4617
+ // node) — scoping to interactive elements would false-negative a
4618
+ // frame with rendered content but no controls yet.
4619
+ const deepLocatorCandidates = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, effectiveFrameTarget.frameSelector, "*", undefined, { frameTarget: effectiveFrameTarget });
4483
4620
  if (deepLocatorCandidates.length > 0) {
4484
4621
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: observe found 0 candidates (focused and unfocused) but deepLocator found ${deepLocatorCandidates.length} — treating as present (let cascade resolve)`);
4485
4622
  return "present";
@@ -4744,7 +4881,7 @@ async function executeStepWithHealing(params) {
4744
4881
  // replan budget on every frame-scoped probe-absent failure.
4745
4882
  await reresolveFrameTargetIfLost();
4746
4883
  const unfocusedObserve = probeAbsentObservedUnfocused.length === 0 && frameTarget?.frame
4747
- ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector)
4884
+ ? await deepLocatorCandidatesAsActions(page, frameTarget)
4748
4885
  : probeAbsentObservedUnfocused;
4749
4886
  const dumpPath = onStepFailure?.({
4750
4887
  stepIndex,
@@ -4884,7 +5021,7 @@ async function executeStepWithHealing(params) {
4884
5021
  if (attempt > 1) {
4885
5022
  await page.waitForTimeout(attempt * ATTEMPT_BACKOFF_MS);
4886
5023
  }
4887
- const pre = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
5024
+ const pre = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
4888
5025
  // Snapshot the meta-tail length so the final-step pattern gate can scope
4889
5026
  // its URL scan to captures added DURING this attempt (not historical
4890
5027
  // tail from earlier steps).
@@ -5017,7 +5154,7 @@ async function executeStepWithHealing(params) {
5017
5154
  // burn the step budget probing all of them.
5018
5155
  const runnerUp = ranked[1];
5019
5156
  if (runnerUp) {
5020
- const midPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
5157
+ const midPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
5021
5158
  const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
5022
5159
  actResultSuccess: true,
5023
5160
  pre,
@@ -5079,45 +5216,206 @@ async function executeStepWithHealing(params) {
5079
5216
  // exclusion is applied here by filtering resolved (already-ranked)
5080
5217
  // candidates against triedSelectors instead — otherwise attempt 4
5081
5218
  // would re-pick the same failed element and burn the attempt.
5219
+ // `resolveDeepLocatorCandidatesWithWidening` widens the hop to `"*"`
5220
+ // once when the interactive-scoped pass finds nothing — a
5221
+ // `div`/`span` tile with only a click handler (no role/tabindex)
5222
+ // matches nothing scoped, so the widened pass is what still
5223
+ // surfaces it — and reports which innerSelector actually resolved
5224
+ // so the click below re-derives the same hop, not a stale one.
5082
5225
  await reresolveFrameTargetIfLost();
5083
- const deepLocatorCandidates = candidates.length === 0 && frameTarget?.frame
5084
- ? (await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameTarget.frameSelector, "*", step)).filter((c) => !triedSelectors.includes(c.selector))
5085
- : [];
5226
+ const deepLocatorResolution = candidates.length === 0 && frameTarget?.frame
5227
+ ? await resolveDeepLocatorCandidatesWithWidening(page, frameTarget.frameSelector, step, { frameTarget })
5228
+ : null;
5229
+ const deepLocatorInnerSelector = deepLocatorResolution?.innerSelector ?? deep_locator_scan_1.INTERACTIVE_CANDIDATE_SELECTOR;
5230
+ const deepLocatorCandidates = (deepLocatorResolution?.candidates ?? []).filter((c) => !triedSelectors.includes(c.selector));
5086
5231
  if (candidates.length === 0 && deepLocatorCandidates.length > 0) {
5087
- const top = deepLocatorCandidates[0];
5088
- if (top) {
5089
- record.instruction = `deepLocator: ${top.accessibleText || "(no accessible text)"}`;
5090
- // Deny-list guard: mirrors the observe branch's refusal below
5091
- // never act on a wizard-exit control regardless of which
5092
- // candidate source (observe vs. deepLocator) surfaced it.
5093
- if (isWizardExitAction(top.accessibleText, wizardExitButtonLabels)) {
5094
- record.errorMessage = `refused wizard-exit control: "${top.accessibleText.slice(0, 60)}"`;
5095
- triedSelectors.push(top.selector);
5096
- record.triedSelectors = [top.selector];
5232
+ // A fill/select step must actuate the NAMED FIELD through the
5233
+ // fill/select seam, never the click-only walk below: candidate
5234
+ // ranking scores by the instruction's quoted VALUE (see
5235
+ // parseFillStep's docblock) for "Fill in the First Name field
5236
+ // with 'Reginald'" no control's accessible name is ever 'Reginald',
5237
+ // so every candidate ties at score 0 and the click walk would fire
5238
+ // on whatever sits first in DOM order (the bug report's wizard
5239
+ // 'Close' mis-click). Detect that shape first and route
5240
+ // deterministically to the candidate matching the field's own
5241
+ // label, refusing to click when no candidate names that field.
5242
+ const fillStep = parseFillStep(step);
5243
+ const selectStep = fillStep ? null : parseSelectStep(step);
5244
+ const fieldTarget = fillStep
5245
+ ? { kind: "fill", fieldLabel: fillStep.fieldLabel, value: fillStep.value }
5246
+ : selectStep?.questionLabel
5247
+ ? { kind: "select", fieldLabel: selectStep.questionLabel, value: selectStep.option }
5248
+ : null;
5249
+ if (fieldTarget) {
5250
+ const matched = findDeepLocatorCandidateByFieldLabel(deepLocatorCandidates, fieldTarget.fieldLabel);
5251
+ if (!matched) {
5252
+ const failureMessage = `deepLocator: no candidate matched field "${fieldTarget.fieldLabel}" (refusing to click an unrelated control)`;
5253
+ record.actResultSuccess = false;
5254
+ record.errorMessage = failureMessage;
5097
5255
  attempts.push(record);
5098
- failureReasons.push(record.errorMessage);
5099
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
5256
+ failureReasons.push(failureMessage);
5257
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
5100
5258
  continue;
5101
5259
  }
5102
- triedSelectors.push(top.selector);
5103
- record.triedSelectors = [top.selector];
5104
- try {
5105
- await (0, deep_locator_candidates_1.clickDeepLocatorCandidate)(page, frameTarget?.frameSelector, "*", top.index);
5260
+ triedSelectors.push(matched.selector);
5261
+ record.triedSelectors = [matched.selector];
5262
+ const actuated = fieldTarget.kind === "fill"
5263
+ ? await (0, deep_locator_actuate_1.fillDeepLocatorCandidate)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, matched.index, fieldTarget.value, { frameTarget })
5264
+ : await (0, deep_locator_actuate_1.selectDeepLocatorCandidateOption)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, matched.index, fieldTarget.value, { frameTarget });
5265
+ if (actuated) {
5266
+ record.instruction = `deepLocator: ${matched.accessibleText || "(no accessible text)"}`;
5106
5267
  record.actResultSuccess = true;
5107
- record.actResultDescription = `deepLocator clicked "${top.accessibleText || top.selector}"`;
5108
- // Synthesize a click action so downstream verification (network
5109
- // / url / dom) treats this exactly like any other resolved
5110
- // click same idiom as deep-submit-locator/structured-click.
5111
- resolvedAction = {
5112
- selector: top.selector,
5113
- description: record.actResultDescription,
5114
- method: "click",
5115
- };
5268
+ record.actResultDescription = `deepLocator ${fieldTarget.kind === "fill" ? "filled" : "selected"} "${matched.accessibleText || matched.selector}" with "${fieldTarget.value}"`;
5269
+ // verifyDomEffect can't resolve a `deeplocator=` selector (see
5270
+ // deep-locator-actuate.ts's module docblock: target.locator()
5271
+ // has no meaning for cross-origin OOPIF hop notation) — the
5272
+ // write + read-back fillDeepLocatorCandidate/
5273
+ // selectDeepLocatorCandidateOption already performed IS the
5274
+ // verification signal, so record it directly instead of
5275
+ // synthesizing a resolvedAction that a verifier can only ever
5276
+ // score false for.
5277
+ record.verifiedBy = "dom";
5278
+ attempts.push(record);
5279
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.actResultDescription}`);
5280
+ trajectory?.push({ stepIndex, verifiedBy: "dom" });
5281
+ return "completed";
5116
5282
  }
5117
- catch (err) {
5283
+ const failureMessage = `deepLocator: ${fieldTarget.kind} on "${matched.accessibleText || matched.selector}" did not verify (read-back mismatch or rejected write)`;
5284
+ record.actResultSuccess = false;
5285
+ record.errorMessage = failureMessage;
5286
+ attempts.push(record);
5287
+ failureReasons.push(failureMessage);
5288
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
5289
+ continue;
5290
+ }
5291
+ // A select step whose question is phrased un-quoted (parseSelectStep
5292
+ // returns `questionLabel: null`) has no fieldLabel to route through
5293
+ // findDeepLocatorCandidateByFieldLabel above, so ranking below falls
5294
+ // back to the OPTION value alone. Scoring the full interactive-scoped
5295
+ // `deepLocatorCandidates` here (buttons, inputs, links alongside any
5296
+ // <select>) would refuse on virtually every dense form: an unrelated
5297
+ // 'First Name' input or 'Submit Application' button never mentions
5298
+ // the quoted option any more than the genuine <select> target does,
5299
+ // so every one of them ties at score 0 right alongside it — not just
5300
+ // the two genuinely ambiguous <select>s the guard exists to catch.
5301
+ // A fresh resolve scoped to "select" narrows the tie check to only
5302
+ // the candidates that could actually satisfy a select-write (a
5303
+ // non-<select> candidate already reports "not-actionable" and is
5304
+ // skipped by the walk below regardless — see
5305
+ // buildSelectFrameCandidateExpr's `el.options || []` lookup), so
5306
+ // this only refuses when two (or more) real <select>s tie for
5307
+ // relevance, e.g. two 'Yes'/'No' controls whose own accessible names
5308
+ // never mention the option text. A unique top-ranked <select>
5309
+ // (including the common single-<select>-in-frame case, where a tie
5310
+ // is impossible) still reaches the walk.
5311
+ if (selectStep && !selectStep.questionLabel) {
5312
+ const selectCandidates = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameTarget?.frameSelector, "select", null, { frameTarget });
5313
+ const optionPhrases = (0, deep_locator_candidates_1.extractTaggedPhrases)(step);
5314
+ const optionScores = selectCandidates.map((candidate) => (0, deep_locator_candidates_1.scoreCandidate)(candidate.accessibleText, optionPhrases));
5315
+ const topScore = optionScores.length > 0 ? Math.max(...optionScores) : 0;
5316
+ const tiedForTop = optionScores.filter((score) => score === topScore).length;
5317
+ if (tiedForTop > 1) {
5318
+ const failureMessage = `deepLocator: select step's question is un-quoted and ${tiedForTop} candidates tie for relevance to "${selectStep.option}" (refusing to guess which control to select)`;
5118
5319
  record.actResultSuccess = false;
5119
- record.errorMessage = `deepLocator: click threw ${(0, errors_1.toErrorMessage)(err)}`;
5320
+ record.errorMessage = failureMessage;
5321
+ attempts.push(record);
5322
+ failureReasons.push(failureMessage);
5323
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
5324
+ continue;
5325
+ }
5326
+ }
5327
+ // Actionable-candidate walk: a top pick that rejects with the CDP
5328
+ // `-32000 Node does not have a layout object` error (an unrendered
5329
+ // node) or is refused by the wizard-exit deny-list costs only that
5330
+ // one candidate, not the whole attempt — the next ranked candidate
5331
+ // is tried instead. `attemptTriedSelectors` mirrors the walk's own
5332
+ // click attempts as they happen (not just on a successful return)
5333
+ // so a click that throws a REAL error (e.g. a wedged
5334
+ // `WatchdogTimeoutError`) still feeds attempt 4's exclusion filter.
5335
+ const attemptTriedSelectors = [];
5336
+ const denyCandidate = (candidate) => {
5337
+ const denied = isWizardExitAction(candidate.accessibleText, wizardExitButtonLabels);
5338
+ if (denied) {
5339
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: refused wizard-exit control: "${candidate.accessibleText.slice(0, 60)}"`);
5340
+ }
5341
+ return denied;
5342
+ };
5343
+ // Intent discrimination: observe()'s Stagehand-resolved action
5344
+ // carries its own method + fill/select arguments (see attempt 1's
5345
+ // `target.method === "fill"` handling above); the deepLocator
5346
+ // fallback has no such resolved action, so the step prose is the
5347
+ // only place the fill/select value can come from. Derived once per
5348
+ // attempt (not per candidate) — every candidate in this walk is a
5349
+ // guess at the SAME step's target, so they all actuate the same way.
5350
+ const actuation = resolveDeepLocatorActuation(step);
5351
+ const cascadeResult = await (0, deep_locator_click_1.clickFirstActionableCandidate)(deepLocatorCandidates, async (candidate) => {
5352
+ attemptTriedSelectors.push(candidate.selector);
5353
+ if (actuation.kind === "select") {
5354
+ const verified = await (0, deep_locator_actuate_1.selectDeepLocatorCandidateOption)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, candidate.index, actuation.value, { frameTarget });
5355
+ // selectDeepLocatorCandidateOption never throws on an ordinary
5356
+ // failed write (see deep-locator-actuate.ts's writeAndVerify) —
5357
+ // it resolves `false` for both a rejected selectOption() and a
5358
+ // read-back mismatch. clickFirstActionableCandidate infers
5359
+ // success from "didn't throw", so a `false` here must become a
5360
+ // throw or the walk would wrongly report this candidate as
5361
+ // actuated.
5362
+ if (!verified)
5363
+ throw new Error("-32000 Node does not have a layout object");
5120
5364
  }
5365
+ else if (actuation.kind === "fill") {
5366
+ const verified = await (0, deep_locator_actuate_1.fillDeepLocatorCandidate)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, candidate.index, actuation.value, { frameTarget });
5367
+ if (!verified)
5368
+ throw new Error("-32000 Node does not have a layout object");
5369
+ }
5370
+ else {
5371
+ await (0, deep_locator_candidates_1.clickDeepLocatorCandidate)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, candidate.index, { frameTarget });
5372
+ }
5373
+ }, { denyCandidate })
5374
+ .then((outcome) => ({ outcome, error: null }))
5375
+ .catch((error) => ({ outcome: null, error }));
5376
+ triedSelectors.push(...attemptTriedSelectors);
5377
+ record.triedSelectors = [...attemptTriedSelectors];
5378
+ if (cascadeResult.outcome?.clicked && cascadeResult.outcome.candidate) {
5379
+ const acted = cascadeResult.outcome.candidate;
5380
+ const verb = actuation.kind === "select"
5381
+ ? "selected"
5382
+ : actuation.kind === "fill"
5383
+ ? "filled"
5384
+ : "clicked";
5385
+ record.instruction = `deepLocator: ${acted.accessibleText || "(no accessible text)"}`;
5386
+ record.actResultSuccess = true;
5387
+ record.actResultDescription = `deepLocator ${verb} "${acted.accessibleText || acted.selector}"`;
5388
+ // Synthesize an action matching the actuation kind so downstream
5389
+ // verification (network/url/dom — STATE_CLASS_METHODS treats
5390
+ // fill/selectOption as DOM-verifiable, not click) treats this
5391
+ // exactly like any other resolved action — same idiom as
5392
+ // deep-submit-locator/structured-click.
5393
+ resolvedAction =
5394
+ actuation.kind === "click"
5395
+ ? {
5396
+ selector: acted.selector,
5397
+ description: record.actResultDescription,
5398
+ method: "click",
5399
+ }
5400
+ : {
5401
+ selector: acted.selector,
5402
+ description: record.actResultDescription,
5403
+ method: actuation.kind === "select" ? "selectOption" : "fill",
5404
+ arguments: [actuation.value],
5405
+ };
5406
+ }
5407
+ else {
5408
+ const failureMessage = cascadeResult.error
5409
+ ? `deepLocator: ${actuation.kind} threw ${(0, errors_1.toErrorMessage)(cascadeResult.error)}`
5410
+ : attemptTriedSelectors.length > 0
5411
+ ? `deepLocator: no actionable candidate (${attemptTriedSelectors.length} not-actionable)`
5412
+ : "deepLocator: no actionable candidate (every candidate refused by the wizard-exit deny-list)";
5413
+ record.actResultSuccess = false;
5414
+ record.errorMessage = failureMessage;
5415
+ attempts.push(record);
5416
+ failureReasons.push(failureMessage);
5417
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
5418
+ continue;
5121
5419
  }
5122
5420
  }
5123
5421
  else if (candidates.length === 0) {
@@ -5395,7 +5693,7 @@ async function executeStepWithHealing(params) {
5395
5693
  // prompt, so a resolver error/empty result is fine to swallow.
5396
5694
  await reresolveFrameTargetIfLost();
5397
5695
  const candidates = observedCandidates.length === 0 && frameTarget?.frame
5398
- ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector, step)
5696
+ ? await deepLocatorCandidatesAsActions(page, frameTarget, step)
5399
5697
  : observedCandidates;
5400
5698
  // Fetch live-page evidence so the rephrase prompt can reason about
5401
5699
  // form state, not just observe candidates. Mirrors the same
@@ -5413,7 +5711,7 @@ async function executeStepWithHealing(params) {
5413
5711
  // alongside the failed step, not in the top document.
5414
5712
  const observedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5415
5713
  const unfocused = observedUnfocused.length === 0 && frameTarget?.frame
5416
- ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector)
5714
+ ? await deepLocatorCandidatesAsActions(page, frameTarget)
5417
5715
  : observedUnfocused;
5418
5716
  const submitFailureList = extractSubmitFailureEvidence(recentCaptures, ownBackendHostnames);
5419
5717
  const gaEventList = extractGaEventEvidence(recentCaptures);
@@ -5466,7 +5764,7 @@ async function executeStepWithHealing(params) {
5466
5764
  continue;
5467
5765
  }
5468
5766
  await page.waitForTimeout(STEP_PAUSE_MS);
5469
- const post = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
5767
+ const post = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
5470
5768
  record.post = post;
5471
5769
  if (resolvedAction) {
5472
5770
  record.resolvedMethod = resolvedAction.method ?? null;
@@ -5736,7 +6034,7 @@ async function executeStepWithHealing(params) {
5736
6034
  probeResult.checked === true &&
5737
6035
  !ancestorStillInvalid;
5738
6036
  await page.waitForTimeout(STEP_PAUSE_MS);
5739
- const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
6037
+ const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
5740
6038
  const retryNetworkFired = retryPost.networkCount > pre.networkCount;
5741
6039
  const retryUrlChanged = retryPost.url !== pre.url;
5742
6040
  const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
@@ -6035,7 +6333,7 @@ async function executeStepWithHealing(params) {
6035
6333
  // replan budget on every frame-scoped cascade-exhaust failure.
6036
6334
  await reresolveFrameTargetIfLost();
6037
6335
  const finalObserve = cascadeExhaustObservedFinal.length === 0 && frameTarget?.frame
6038
- ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector, step)
6336
+ ? await deepLocatorCandidatesAsActions(page, frameTarget, step)
6039
6337
  : cascadeExhaustObservedFinal;
6040
6338
  const { pageTitle, pageUrl } = await resolveDumpPageIdentity(page, frameTarget);
6041
6339
  // Discriminator data for "Stagehand sees nothing" failures: capture the raw
@@ -6047,7 +6345,7 @@ async function executeStepWithHealing(params) {
6047
6345
  const bodyOuterHtml = typeof bodyOuterHtmlRaw === "string" ? bodyOuterHtmlRaw.slice(0, 100_000) : null;
6048
6346
  const cascadeExhaustObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
6049
6347
  const unfocusedObserve = cascadeExhaustObservedUnfocused.length === 0 && frameTarget?.frame
6050
- ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector)
6348
+ ? await deepLocatorCandidatesAsActions(page, frameTarget)
6051
6349
  : cascadeExhaustObservedUnfocused;
6052
6350
  const dumpPath = onStepFailure?.({
6053
6351
  stepIndex,