@enricai/barnacle 1.6.12 → 1.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) 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/bedrock.d.ts +8 -5
  11. package/dist/lib/bedrock.d.ts.map +1 -1
  12. package/dist/lib/bedrock.js +8 -5
  13. package/dist/lib/bedrock.js.map +1 -1
  14. package/dist/lib/http.d.ts +4 -3
  15. package/dist/lib/http.d.ts.map +1 -1
  16. package/dist/lib/http.js +25 -3
  17. package/dist/lib/http.js.map +1 -1
  18. package/dist/lib/telemetry/beacon-capture.d.ts +45 -2
  19. package/dist/lib/telemetry/beacon-capture.d.ts.map +1 -1
  20. package/dist/lib/telemetry/beacon-capture.js +36 -0
  21. package/dist/lib/telemetry/beacon-capture.js.map +1 -1
  22. package/dist/lib/telemetry/reconciliation-record.d.ts +41 -1
  23. package/dist/lib/telemetry/reconciliation-record.d.ts.map +1 -1
  24. package/dist/lib/telemetry/reconciliation-record.js +29 -2
  25. package/dist/lib/telemetry/reconciliation-record.js.map +1 -1
  26. package/dist/lib/telemetry/reconciliation-source.d.ts +7 -1
  27. package/dist/lib/telemetry/reconciliation-source.d.ts.map +1 -1
  28. package/dist/lib/telemetry/reconciliation-source.js +21 -5
  29. package/dist/lib/telemetry/reconciliation-source.js.map +1 -1
  30. package/dist/lib/telemetry/run-telemetry.d.ts +67 -0
  31. package/dist/lib/telemetry/run-telemetry.d.ts.map +1 -0
  32. package/dist/lib/telemetry/run-telemetry.js +55 -0
  33. package/dist/lib/telemetry/run-telemetry.js.map +1 -0
  34. package/dist/lib/telemetry/submission-capture.d.ts +11 -4
  35. package/dist/lib/telemetry/submission-capture.d.ts.map +1 -1
  36. package/dist/lib/telemetry/submission-capture.js +1 -0
  37. package/dist/lib/telemetry/submission-capture.js.map +1 -1
  38. package/dist/lib/telemetry/submission-reader.d.ts +11 -3
  39. package/dist/lib/telemetry/submission-reader.d.ts.map +1 -1
  40. package/dist/lib/telemetry/submission-reader.js +32 -8
  41. package/dist/lib/telemetry/submission-reader.js.map +1 -1
  42. package/dist/lib/tracking-click.d.ts.map +1 -1
  43. package/dist/lib/tracking-click.js +19 -0
  44. package/dist/lib/tracking-click.js.map +1 -1
  45. package/dist/plugins/config-plugin.js +3 -3
  46. package/dist/plugins/loader.d.ts +20 -0
  47. package/dist/plugins/loader.d.ts.map +1 -1
  48. package/dist/plugins/loader.js +94 -17
  49. package/dist/plugins/loader.js.map +1 -1
  50. package/dist/scraper/deep-locator-actuate.d.ts +94 -0
  51. package/dist/scraper/deep-locator-actuate.d.ts.map +1 -0
  52. package/dist/scraper/deep-locator-actuate.js +216 -0
  53. package/dist/scraper/deep-locator-actuate.js.map +1 -0
  54. package/dist/scraper/deep-locator-candidates.d.ts +105 -18
  55. package/dist/scraper/deep-locator-candidates.d.ts.map +1 -1
  56. package/dist/scraper/deep-locator-candidates.js +238 -46
  57. package/dist/scraper/deep-locator-candidates.js.map +1 -1
  58. package/dist/scraper/deep-locator-click.d.ts +71 -0
  59. package/dist/scraper/deep-locator-click.d.ts.map +1 -0
  60. package/dist/scraper/deep-locator-click.js +71 -0
  61. package/dist/scraper/deep-locator-click.js.map +1 -0
  62. package/dist/scraper/deep-locator-fake.d.ts +344 -14
  63. package/dist/scraper/deep-locator-fake.d.ts.map +1 -1
  64. package/dist/scraper/deep-locator-fake.js +509 -7
  65. package/dist/scraper/deep-locator-fake.js.map +1 -1
  66. package/dist/scraper/deep-locator-scan.d.ts +220 -0
  67. package/dist/scraper/deep-locator-scan.d.ts.map +1 -0
  68. package/dist/scraper/deep-locator-scan.js +386 -0
  69. package/dist/scraper/deep-locator-scan.js.map +1 -0
  70. package/dist/scraper/fake-timer-advance.d.ts +47 -0
  71. package/dist/scraper/fake-timer-advance.d.ts.map +1 -0
  72. package/dist/scraper/fake-timer-advance.js +44 -0
  73. package/dist/scraper/fake-timer-advance.js.map +1 -0
  74. package/dist/scraper/flow-runner.d.ts +80 -9
  75. package/dist/scraper/flow-runner.d.ts.map +1 -1
  76. package/dist/scraper/flow-runner.js +417 -63
  77. package/dist/scraper/flow-runner.js.map +1 -1
  78. package/dist/scraper/frame-target.d.ts +28 -0
  79. package/dist/scraper/frame-target.d.ts.map +1 -1
  80. package/dist/scraper/frame-target.js +30 -0
  81. package/dist/scraper/frame-target.js.map +1 -1
  82. package/dist/scraper/session-browserbase.d.ts +13 -0
  83. package/dist/scraper/session-browserbase.d.ts.map +1 -1
  84. package/dist/scraper/session-browserbase.js +29 -0
  85. package/dist/scraper/session-browserbase.js.map +1 -1
  86. package/dist/scraper/session-ip.d.ts +69 -0
  87. package/dist/scraper/session-ip.d.ts.map +1 -0
  88. package/dist/scraper/session-ip.js +88 -0
  89. package/dist/scraper/session-ip.js.map +1 -0
  90. package/dist/scraper/session-shared.d.ts +9 -0
  91. package/dist/scraper/session-shared.d.ts.map +1 -1
  92. package/dist/scraper/session-shared.js.map +1 -1
  93. package/dist/scraper/submit-control.d.ts +14 -2
  94. package/dist/scraper/submit-control.d.ts.map +1 -1
  95. package/dist/scraper/submit-control.js +33 -2
  96. package/dist/scraper/submit-control.js.map +1 -1
  97. package/dist/scripts/recon-browser.d.ts +1 -1
  98. package/dist/scripts/recon-browser.d.ts.map +1 -1
  99. package/dist/scripts/recon-browser.js +48 -16
  100. package/dist/scripts/recon-browser.js.map +1 -1
  101. package/dist/scripts/recon-generate.js +3 -3
  102. package/dist/scripts/recon-generate.js.map +1 -1
  103. package/dist/site-plugin.d.ts +20 -0
  104. package/dist/site-plugin.d.ts.map +1 -1
  105. package/dist/testing/integration-runner.d.ts.map +1 -1
  106. package/dist/testing/integration-runner.js +6 -7
  107. package/dist/testing/integration-runner.js.map +1 -1
  108. package/package.json +3 -2
@@ -29,6 +29,7 @@ exports.windowHasTransitionBody = windowHasTransitionBody;
29
29
  exports.windowHasAdvanceTransition = windowHasAdvanceTransition;
30
30
  exports.shouldVetoFallbackAdvance = shouldVetoFallbackAdvance;
31
31
  exports.isDomOnlyAdvanceVerified = isDomOnlyAdvanceVerified;
32
+ exports.isClickViewSwapVerified = isClickViewSwapVerified;
32
33
  exports.countNgInvalidContainers = countNgInvalidContainers;
33
34
  exports.describeAttemptEffectSignals = describeAttemptEffectSignals;
34
35
  exports.shouldSkipTechnique = shouldSkipTechnique;
@@ -56,6 +57,7 @@ exports.writeFixtureToTempFile = writeFixtureToTempFile;
56
57
  exports.attachToSurfacedInput = attachToSurfacedInput;
57
58
  exports.surfaceAndUpload = surfaceAndUpload;
58
59
  exports.parseSelectStep = parseSelectStep;
60
+ exports.parseFillStep = parseFillStep;
59
61
  exports.parseRadioStep = parseRadioStep;
60
62
  exports.pollEnumerate = pollEnumerate;
61
63
  exports.waitForTransitionBody = waitForTransitionBody;
@@ -87,7 +89,10 @@ const schemas_1 = require("../lib/llm/schemas");
87
89
  const logging_1 = require("../lib/logging");
88
90
  const call_capture_1 = require("../lib/telemetry/call-capture");
89
91
  const call_types_1 = require("../lib/telemetry/call-types");
92
+ const deep_locator_actuate_1 = require("../scraper/deep-locator-actuate");
90
93
  const deep_locator_candidates_1 = require("../scraper/deep-locator-candidates");
94
+ const deep_locator_click_1 = require("../scraper/deep-locator-click");
95
+ const deep_locator_scan_1 = require("../scraper/deep-locator-scan");
91
96
  const errors_2 = require("../scraper/errors");
92
97
  const frame_target_1 = require("../scraper/frame-target");
93
98
  const phantom_click_1 = require("../scraper/phantom-click");
@@ -476,7 +481,14 @@ exports.TRAILING_GRACE_WINDOW = 2;
476
481
  * shape via Runtime.callFunctionOn.
477
482
  */
478
483
  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) {
484
+ /**
485
+ * Captures the pre/post signal triple the submit-verify cascade diffs.
486
+ * Accepts the optional `page` so a resolved child `FrameTarget` whose
487
+ * `url()` rejects (OOPIF detached by the submit it just fired) can still
488
+ * report the main frame's post-navigation URL instead of throwing the
489
+ * whole attempt out of `executeStepWithHealing`.
490
+ */
491
+ async function snapshotPage(target, signalCounter, page) {
480
492
  let bodyHtmlLength = 0;
481
493
  let visibleTextSignature = "";
482
494
  try {
@@ -495,9 +507,16 @@ async function snapshotPage(target, signalCounter) {
495
507
  // Snapshot is observational; on failure, defaults to 0/"" so the verifier
496
508
  // sees no delta. Real state-class checks already cover the verified path.
497
509
  }
510
+ // A resolved child FrameTarget's url() reads location.href off the CDP
511
+ // frame session (frame-target.ts's childFrameTarget), which rejects (or
512
+ // trips its watchdog) once the OOPIF detaches — most commonly right after
513
+ // a submit click tears down the wizard iframe. Falling back to page.url()
514
+ // lets the post-submit navigation still register as a urlChanged signal
515
+ // instead of throwing the whole step out of the cascade.
516
+ const url = page ? await target.url().catch(() => page.url()) : await target.url();
498
517
  return {
499
518
  networkCount: signalCounter.n,
500
- url: await target.url(),
519
+ url,
501
520
  bodyHtmlLength,
502
521
  visibleTextSignature,
503
522
  };
@@ -874,6 +893,43 @@ function isDomOnlyAdvanceVerified(params) {
874
893
  // non-advancing POST) does not verify it.
875
894
  return networkIsRealAdvance || urlChanged;
876
895
  }
896
+ /**
897
+ * Client-side view-swap verification gate. Credits a click that produces
898
+ * substantial DOM growth with zero network as a verified step outcome when
899
+ * the step is a plain client-side reveal (not a submit/final step, not an
900
+ * advance/'Next' step on a site with advanceTransitionBodyPattern configured).
901
+ *
902
+ * **Why this exists:** SPA/iframe wizards often swap visible screens
903
+ * (e.g. method-chooser → basic-info form) via client-side DOM manipulation
904
+ * with zero network requests. The existing verifyDomEffect (line 4866-4891)
905
+ * explicitly returns false for non-radio/checkbox clicks, routing those
906
+ * to the network/URL signal — which correctly verifies navigation but
907
+ * false-negatives client-side view swaps. This gate fills that gap.
908
+ *
909
+ * **Threshold rationale:** 5000B is 10× the trivial boundary (500B) and
910
+ * safely distinguishes real view swaps (measured case: +49518B) from
911
+ * validation-error reflows (typically < 2KB per codebase evidence at
912
+ * line 2040-2044: error text capped at 200 chars, container markup adds
913
+ * 150-350B per error, worst-case ~10 errors = ~3500B max).
914
+ *
915
+ * **Scope guards:** Excludes final/submit steps (those require real network
916
+ * per isSubmitRevealedInvalid + LLM submit judge) and advance-pattern steps
917
+ * (those require real transition per isDomOnlyAdvanceVerified/isAdvanceStalled
918
+ * to avoid wizard-ATS autosave-vs-transition ambiguity).
919
+ */
920
+ function isClickViewSwapVerified(params) {
921
+ const VIEW_SWAP_MIN_BYTES = 5000;
922
+ const { resolvedAction, isFinalStep, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, } = params;
923
+ if (resolvedAction?.method !== "click")
924
+ return false;
925
+ if (isFinalStep || submitStep)
926
+ return false;
927
+ if (isAdvanceWithPattern)
928
+ return false;
929
+ if (networkDelta !== 0)
930
+ return false;
931
+ return bytesDelta >= VIEW_SWAP_MIN_BYTES;
932
+ }
877
933
  /**
878
934
  * Read-only count of ng-invalid form controls on the resolved frame. Side-effect-free
879
935
  * counterpart to `probeFormValidityBeforeSubmit` (which also auto-fills
@@ -2277,7 +2333,7 @@ function isUploadAffordanceLabel(label) {
2277
2333
  return /\b(upload|browse|select file|choose file|attach|add (a )?(resume|cv|file)|resume\/cv)\b/.test(norm);
2278
2334
  }
2279
2335
  /**
2280
- * Materialize the in-memory resume fixture to a temp file so CDP
2336
+ * Materialize the in-memory upload fixture to a temp file so CDP
2281
2337
  * `DOM.setFileInputFiles` (which requires a filesystem path, unlike
2282
2338
  * Playwright's `locator.setInputFiles`) can reference it. Only used as a
2283
2339
  * fallback when the on-disk fixture path is unavailable. Process-scoped — the
@@ -2687,6 +2743,40 @@ function parseSelectStep(instruction) {
2687
2743
  const questionLabel = quoted.find((q) => q.trim() !== option)?.trim() ?? null;
2688
2744
  return { option, questionLabel };
2689
2745
  }
2746
+ /**
2747
+ * Parse a declarative TEXT-FILL flow step into the field label to target and
2748
+ * the value to type into it.
2749
+ *
2750
+ * Why this exists (sibling of `parseSelectStep`/`parseRadioStep`): the deep-
2751
+ * locator cascade's candidate ranking (`resolveDeepLocatorCandidates`'s
2752
+ * `scoreCandidate`) ranks by the instruction's quoted phrases, which for a
2753
+ * fill step is only the VALUE ("Fill in the First Name field with
2754
+ * 'Reginald'" quotes just `'Reginald'`) — no candidate's accessible name
2755
+ * (e.g. "First Name") ever matches a person's name, so every candidate ties
2756
+ * at score 0 and DOM order decides, clicking whatever happens to be first
2757
+ * rather than filling the named field. This parser recovers the FIELD LABEL
2758
+ * ("First Name") so the caller can match it directly against a candidate's
2759
+ * accessible name instead of ranking by the value.
2760
+ *
2761
+ * Recognizes the flow's conventional phrasing (confirmed against
2762
+ * `src/recon/fixtures/shipped-ats-flow-steps.json`):
2763
+ * "Fill in the First Name field with 'Reginald'",
2764
+ * "Fill the signature field with 'Name'",
2765
+ * "Fill in the Acme Non-Employee ID textbox with 'NA'".
2766
+ * Returns null when the step doesn't match this shape (no "fill" verb, no
2767
+ * field/input/textbox noun, or no quoted value) so the caller falls through
2768
+ * to the normal cascade unchanged.
2769
+ */
2770
+ function parseFillStep(instruction) {
2771
+ const match = instruction.match(/\bfill(?:\s+in)?\s+(?:the\s+)?(.+?)\s+(?:field|input|textbox)\s+with\s+'([^']+)'/i);
2772
+ if (!match)
2773
+ return null;
2774
+ const fieldLabel = match[1]?.trim();
2775
+ const value = match[2]?.trim();
2776
+ if (!fieldLabel || !value)
2777
+ return null;
2778
+ return { fieldLabel, value };
2779
+ }
2690
2780
  /**
2691
2781
  * Parse a single-choice RADIO flow step into the option to click and (when
2692
2782
  * present) the question label that scopes which radio group it targets.
@@ -2740,6 +2830,44 @@ function parseRadioStep(instruction) {
2740
2830
  const questionLabel = quoted.find((q) => q.trim() !== option)?.trim() ?? null;
2741
2831
  return { option, questionLabel };
2742
2832
  }
2833
+ function resolveDeepLocatorActuation(step) {
2834
+ const selectParsed = parseSelectStep(step);
2835
+ if (selectParsed)
2836
+ return { kind: "select", value: selectParsed.option };
2837
+ const fillParsed = parseFillStep(step);
2838
+ if (fillParsed)
2839
+ return { kind: "fill", value: fillParsed.value };
2840
+ return { kind: "click" };
2841
+ }
2842
+ /** Whitespace-collapsed, lowercased comparison key for {@link findDeepLocatorCandidateByFieldLabel}. */
2843
+ function normalizeFieldLabel(text) {
2844
+ return text.replace(/\s+/g, " ").trim().toLowerCase();
2845
+ }
2846
+ /**
2847
+ * Finds the candidate whose accessible name identifies the named field —
2848
+ * `fieldLabel` (from {@link parseFillStep}/{@link parseSelectStep}), NOT the
2849
+ * value being written — among a frame's deepLocator candidates. Exact match
2850
+ * wins first (a bare "First Name" input against a `fieldLabel` of "First
2851
+ * Name"); otherwise a substring match either direction (a `fieldLabel` of
2852
+ * "Acme Non-Employee ID" against an accessible name of "Non-Employee ID", or
2853
+ * the reverse) so minor phrasing drift between the flow's field noun and the
2854
+ * control's own label still resolves. Returns `null` — never a guess — when
2855
+ * no candidate's accessible name relates to `fieldLabel` at all, so the
2856
+ * caller can refuse to act rather than fill/click the wrong control.
2857
+ */
2858
+ function findDeepLocatorCandidateByFieldLabel(candidates, fieldLabel) {
2859
+ const normalizedLabel = normalizeFieldLabel(fieldLabel);
2860
+ if (!normalizedLabel)
2861
+ return null;
2862
+ const named = candidates
2863
+ .map((candidate) => ({ candidate, text: normalizeFieldLabel(candidate.accessibleText) }))
2864
+ .filter((entry) => entry.text.length > 0);
2865
+ const exact = named.find((entry) => entry.text === normalizedLabel);
2866
+ if (exact)
2867
+ return exact.candidate;
2868
+ const partial = named.find((entry) => entry.text.includes(normalizedLabel) || normalizedLabel.includes(entry.text));
2869
+ return partial?.candidate ?? null;
2870
+ }
2743
2871
  /** Max settle-retry attempts for a primitive's DOM enumerate (see `pollEnumerate`). */
2744
2872
  const PRIMITIVE_ENUMERATE_ATTEMPTS = 5;
2745
2873
  /** Delay between settle-retry attempts. Total cap ≈ ATTEMPTS × this. */
@@ -4414,25 +4542,63 @@ async function probeFormValidityBeforeSubmit(params) {
4414
4542
  }
4415
4543
  }
4416
4544
  /**
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.
4545
+ * Resolves deepLocator candidates scoped to `INTERACTIVE_CANDIDATE_SELECTOR`,
4546
+ * widening once to the unscoped `"*"` hop when the scoped pass finds
4547
+ * nothing. `INTERACTIVE_CANDIDATE_SELECTOR` is what makes a dense OOPIF
4548
+ * form's candidate set tractable (371 nodes down to a handful see
4549
+ * `deep-locator-candidates.ts`'s module docblock), but it over-narrows a
4550
+ * `div`/`span` tile that carries only a click handler no `role`/
4551
+ * `tabindex` so a scoped-empty result would otherwise strand an attempt
4552
+ * the pre-1.6.13 unscoped `"*"` hop still resolved. The widened pass only
4553
+ * runs when the scoped one returns zero, so the dense-form fast path
4554
+ * (`flow-runner.oopif-dense-form-budget.test.ts`) still costs a single
4555
+ * resolve. Returns the `innerSelector` actually used alongside the
4556
+ * candidates so a caller driving `clickDeepLocatorCandidate` re-derives the
4557
+ * SAME hop the candidates were ranked/indexed against.
4558
+ */
4559
+ async function resolveDeepLocatorCandidatesWithWidening(page, frameSelector, instruction, timeoutOptions) {
4560
+ const scoped = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameSelector, deep_locator_scan_1.INTERACTIVE_CANDIDATE_SELECTOR, instruction, timeoutOptions);
4561
+ if (scoped.length > 0) {
4562
+ return { candidates: scoped, innerSelector: deep_locator_scan_1.INTERACTIVE_CANDIDATE_SELECTOR };
4563
+ }
4564
+ const widened = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameSelector, "*", instruction, timeoutOptions);
4565
+ return { candidates: widened, innerSelector: "*" };
4566
+ }
4567
+ /**
4568
+ * Adapts `resolveDeepLocatorCandidatesWithWidening` results into
4569
+ * `Action`-shaped evidence for `rephraseWithLLM`, which only knows about
4570
+ * Stagehand's `Action` type. Never throws: `resolveDeepLocatorCandidates`
4571
+ * itself degrades to `[]` on a resolver failure, so this only feeds the
4572
+ * rephrase prompt richer evidence when available — a frame-scoped step
4573
+ * whose observe AND deepLocator (scoped, then widened) both come back
4574
+ * empty just gets the same `[]` evidence it would have gotten before this
4575
+ * fix.
4576
+ *
4577
+ * `instruction` is forwarded so the evidence list is ranked by relevance to
4578
+ * the step, same as the act path — an unranked `[]`-then-DOM-order list
4579
+ * would feed the rephrase LLM its worst evidence first instead of its best.
4580
+ * It's also what {@link resolveDeepLocatorActuation} reads to pick the
4581
+ * evidence's `method`/`arguments` — a rephrase evaluating a "Fill in the
4582
+ * First Name field with 'Reginald'" step should see fill/"Reginald"
4583
+ * evidence, not a hard-coded click, now that the cascade itself can
4584
+ * actuate fill/select. Omitted (the unfocused-observe evidence list) falls
4585
+ * back to `click` with no value, same as before this method inference
4586
+ * existed.
4424
4587
  *
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.
4588
+ * Takes the caller's already-resolved `frameTarget` so
4589
+ * `resolveDeepLocatorCandidates` uses the batched frame-scoped evaluate
4590
+ * instead of re-resolving it internally.
4429
4591
  */
4430
- async function deepLocatorCandidatesAsActions(page, frameSelector, instruction) {
4431
- const candidates = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameSelector, "*", instruction);
4592
+ async function deepLocatorCandidatesAsActions(page, frameTarget, instruction) {
4593
+ const { candidates } = await resolveDeepLocatorCandidatesWithWidening(page, frameTarget.frameSelector, instruction, { frameTarget });
4594
+ const actuation = instruction
4595
+ ? resolveDeepLocatorActuation(instruction)
4596
+ : { kind: "click" };
4432
4597
  return candidates.map((c) => ({
4433
4598
  selector: c.selector,
4434
4599
  description: c.accessibleText || "(no accessible text)",
4435
- method: "click",
4600
+ method: actuation.kind === "select" ? "selectOption" : actuation.kind === "fill" ? "fill" : "click",
4601
+ ...(actuation.kind === "click" ? {} : { arguments: [actuation.value] }),
4436
4602
  }));
4437
4603
  }
4438
4604
  /**
@@ -4479,7 +4645,16 @@ async function probeStepBeforeAttempts(params) {
4479
4645
  ? await reresolveFrameTarget()
4480
4646
  : frameTarget;
4481
4647
  if (effectiveFrameTarget?.frame) {
4482
- const deepLocatorCandidates = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, effectiveFrameTarget.frameSelector, "*");
4648
+ // Pass the already-resolved `effectiveFrameTarget` so the batched
4649
+ // evaluate reuses it instead of `resolveDeepLocatorCandidates`
4650
+ // re-resolving via its own internal `resolveFrameTarget` fallback —
4651
+ // same "reuse the ambient target" rule as every other frame-scoped
4652
+ // call in this function. Innerselector stays "*": this probe only
4653
+ // answers "does this frame have any content", and batching already
4654
+ // makes that reachability check cheap (one evaluate over every
4655
+ // node) — scoping to interactive elements would false-negative a
4656
+ // frame with rendered content but no controls yet.
4657
+ const deepLocatorCandidates = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, effectiveFrameTarget.frameSelector, "*", undefined, { frameTarget: effectiveFrameTarget });
4483
4658
  if (deepLocatorCandidates.length > 0) {
4484
4659
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: observe found 0 candidates (focused and unfocused) but deepLocator found ${deepLocatorCandidates.length} — treating as present (let cascade resolve)`);
4485
4660
  return "present";
@@ -4499,7 +4674,7 @@ async function probeStepBeforeAttempts(params) {
4499
4674
  }
4500
4675
  }
4501
4676
  async function executeStepWithHealing(params) {
4502
- 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;
4677
+ const { stagehand, page, step, optional, upload, submitStep, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, logger, captureFn, uploadFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, } = params;
4503
4678
  // Mutable (not the destructured const above) so a lost frame-attach race
4504
4679
  // can be upgraded in place once the OOPIF attaches later in the cascade —
4505
4680
  // see reresolveFrameTargetIfLost below. Every existing reference in this
@@ -4573,7 +4748,7 @@ async function executeStepWithHealing(params) {
4573
4748
  page,
4574
4749
  target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
4575
4750
  isUploadStep: upload,
4576
- fixture: resumeFixture,
4751
+ fixture: uploadFixture,
4577
4752
  logger,
4578
4753
  signalCounter,
4579
4754
  recentCaptureMeta,
@@ -4744,7 +4919,7 @@ async function executeStepWithHealing(params) {
4744
4919
  // replan budget on every frame-scoped probe-absent failure.
4745
4920
  await reresolveFrameTargetIfLost();
4746
4921
  const unfocusedObserve = probeAbsentObservedUnfocused.length === 0 && frameTarget?.frame
4747
- ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector)
4922
+ ? await deepLocatorCandidatesAsActions(page, frameTarget)
4748
4923
  : probeAbsentObservedUnfocused;
4749
4924
  const dumpPath = onStepFailure?.({
4750
4925
  stepIndex,
@@ -4884,7 +5059,7 @@ async function executeStepWithHealing(params) {
4884
5059
  if (attempt > 1) {
4885
5060
  await page.waitForTimeout(attempt * ATTEMPT_BACKOFF_MS);
4886
5061
  }
4887
- const pre = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
5062
+ const pre = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
4888
5063
  // Snapshot the meta-tail length so the final-step pattern gate can scope
4889
5064
  // its URL scan to captures added DURING this attempt (not historical
4890
5065
  // tail from earlier steps).
@@ -5017,7 +5192,7 @@ async function executeStepWithHealing(params) {
5017
5192
  // burn the step budget probing all of them.
5018
5193
  const runnerUp = ranked[1];
5019
5194
  if (runnerUp) {
5020
- const midPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
5195
+ const midPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
5021
5196
  const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
5022
5197
  actResultSuccess: true,
5023
5198
  pre,
@@ -5079,46 +5254,207 @@ async function executeStepWithHealing(params) {
5079
5254
  // exclusion is applied here by filtering resolved (already-ranked)
5080
5255
  // candidates against triedSelectors instead — otherwise attempt 4
5081
5256
  // would re-pick the same failed element and burn the attempt.
5257
+ // `resolveDeepLocatorCandidatesWithWidening` widens the hop to `"*"`
5258
+ // once when the interactive-scoped pass finds nothing — a
5259
+ // `div`/`span` tile with only a click handler (no role/tabindex)
5260
+ // matches nothing scoped, so the widened pass is what still
5261
+ // surfaces it — and reports which innerSelector actually resolved
5262
+ // so the click below re-derives the same hop, not a stale one.
5082
5263
  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
- : [];
5264
+ const deepLocatorResolution = candidates.length === 0 && frameTarget?.frame
5265
+ ? await resolveDeepLocatorCandidatesWithWidening(page, frameTarget.frameSelector, step, { frameTarget })
5266
+ : null;
5267
+ const deepLocatorInnerSelector = deepLocatorResolution?.innerSelector ?? deep_locator_scan_1.INTERACTIVE_CANDIDATE_SELECTOR;
5268
+ const deepLocatorCandidates = (deepLocatorResolution?.candidates ?? []).filter((c) => !triedSelectors.includes(c.selector));
5086
5269
  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];
5270
+ // A fill/select step must actuate the NAMED FIELD through the
5271
+ // fill/select seam, never the click-only walk below: candidate
5272
+ // ranking scores by the instruction's quoted VALUE (see
5273
+ // parseFillStep's docblock) for "Fill in the First Name field
5274
+ // with 'Reginald'" no control's accessible name is ever 'Reginald',
5275
+ // so every candidate ties at score 0 and the click walk would fire
5276
+ // on whatever sits first in DOM order (the bug report's wizard
5277
+ // 'Close' mis-click). Detect that shape first and route
5278
+ // deterministically to the candidate matching the field's own
5279
+ // label, refusing to click when no candidate names that field.
5280
+ const fillStep = parseFillStep(step);
5281
+ const selectStep = fillStep ? null : parseSelectStep(step);
5282
+ const fieldTarget = fillStep
5283
+ ? { kind: "fill", fieldLabel: fillStep.fieldLabel, value: fillStep.value }
5284
+ : selectStep?.questionLabel
5285
+ ? { kind: "select", fieldLabel: selectStep.questionLabel, value: selectStep.option }
5286
+ : null;
5287
+ if (fieldTarget) {
5288
+ const matched = findDeepLocatorCandidateByFieldLabel(deepLocatorCandidates, fieldTarget.fieldLabel);
5289
+ if (!matched) {
5290
+ const failureMessage = `deepLocator: no candidate matched field "${fieldTarget.fieldLabel}" (refusing to click an unrelated control)`;
5291
+ record.actResultSuccess = false;
5292
+ record.errorMessage = failureMessage;
5097
5293
  attempts.push(record);
5098
- failureReasons.push(record.errorMessage);
5099
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
5294
+ failureReasons.push(failureMessage);
5295
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
5100
5296
  continue;
5101
5297
  }
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);
5298
+ triedSelectors.push(matched.selector);
5299
+ record.triedSelectors = [matched.selector];
5300
+ const actuated = fieldTarget.kind === "fill"
5301
+ ? await (0, deep_locator_actuate_1.fillDeepLocatorCandidate)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, matched.index, fieldTarget.value, { frameTarget })
5302
+ : await (0, deep_locator_actuate_1.selectDeepLocatorCandidateOption)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, matched.index, fieldTarget.value, { frameTarget });
5303
+ if (actuated) {
5304
+ record.instruction = `deepLocator: ${matched.accessibleText || "(no accessible text)"}`;
5106
5305
  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
- };
5306
+ record.actResultDescription = `deepLocator ${fieldTarget.kind === "fill" ? "filled" : "selected"} "${matched.accessibleText || matched.selector}" with "${fieldTarget.value}"`;
5307
+ // verifyDomEffect can't resolve a `deeplocator=` selector (see
5308
+ // deep-locator-actuate.ts's module docblock: target.locator()
5309
+ // has no meaning for cross-origin OOPIF hop notation) — the
5310
+ // write + read-back fillDeepLocatorCandidate/
5311
+ // selectDeepLocatorCandidateOption already performed IS the
5312
+ // verification signal, so record it directly instead of
5313
+ // synthesizing a resolvedAction that a verifier can only ever
5314
+ // score false for.
5315
+ record.verifiedBy = "dom";
5316
+ attempts.push(record);
5317
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.actResultDescription}`);
5318
+ trajectory?.push({ stepIndex, verifiedBy: "dom" });
5319
+ return "completed";
5116
5320
  }
5117
- catch (err) {
5321
+ const failureMessage = `deepLocator: ${fieldTarget.kind} on "${matched.accessibleText || matched.selector}" did not verify (read-back mismatch or rejected write)`;
5322
+ record.actResultSuccess = false;
5323
+ record.errorMessage = failureMessage;
5324
+ attempts.push(record);
5325
+ failureReasons.push(failureMessage);
5326
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
5327
+ continue;
5328
+ }
5329
+ // A select step whose question is phrased un-quoted (parseSelectStep
5330
+ // returns `questionLabel: null`) has no fieldLabel to route through
5331
+ // findDeepLocatorCandidateByFieldLabel above, so ranking below falls
5332
+ // back to the OPTION value alone. Scoring the full interactive-scoped
5333
+ // `deepLocatorCandidates` here (buttons, inputs, links alongside any
5334
+ // <select>) would refuse on virtually every dense form: an unrelated
5335
+ // 'First Name' input or 'Submit Application' button never mentions
5336
+ // the quoted option any more than the genuine <select> target does,
5337
+ // so every one of them ties at score 0 right alongside it — not just
5338
+ // the two genuinely ambiguous <select>s the guard exists to catch.
5339
+ // A fresh resolve scoped to "select" narrows the tie check to only
5340
+ // the candidates that could actually satisfy a select-write (a
5341
+ // non-<select> candidate already reports "not-actionable" and is
5342
+ // skipped by the walk below regardless — see
5343
+ // buildSelectFrameCandidateExpr's `el.options || []` lookup), so
5344
+ // this only refuses when two (or more) real <select>s tie for
5345
+ // relevance, e.g. two 'Yes'/'No' controls whose own accessible names
5346
+ // never mention the option text. A unique top-ranked <select>
5347
+ // (including the common single-<select>-in-frame case, where a tie
5348
+ // is impossible) still reaches the walk.
5349
+ if (selectStep && !selectStep.questionLabel) {
5350
+ const selectCandidates = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameTarget?.frameSelector, "select", null, { frameTarget });
5351
+ const optionPhrases = (0, deep_locator_candidates_1.extractTaggedPhrases)(step);
5352
+ const optionScores = selectCandidates.map((candidate) => (0, deep_locator_candidates_1.scoreCandidate)(candidate.accessibleText, optionPhrases));
5353
+ const topScore = optionScores.length > 0 ? Math.max(...optionScores) : 0;
5354
+ const tiedForTop = optionScores.filter((score) => score === topScore).length;
5355
+ if (tiedForTop > 1) {
5356
+ 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
5357
  record.actResultSuccess = false;
5119
- record.errorMessage = `deepLocator: click threw ${(0, errors_1.toErrorMessage)(err)}`;
5358
+ record.errorMessage = failureMessage;
5359
+ attempts.push(record);
5360
+ failureReasons.push(failureMessage);
5361
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
5362
+ continue;
5120
5363
  }
5121
5364
  }
5365
+ // Actionable-candidate walk: a top pick that rejects with the CDP
5366
+ // `-32000 Node does not have a layout object` error (an unrendered
5367
+ // node) or is refused by the wizard-exit deny-list costs only that
5368
+ // one candidate, not the whole attempt — the next ranked candidate
5369
+ // is tried instead. `attemptTriedSelectors` mirrors the walk's own
5370
+ // click attempts as they happen (not just on a successful return)
5371
+ // so a click that throws a REAL error (e.g. a wedged
5372
+ // `WatchdogTimeoutError`) still feeds attempt 4's exclusion filter.
5373
+ const attemptTriedSelectors = [];
5374
+ const denyCandidate = (candidate) => {
5375
+ const denied = isWizardExitAction(candidate.accessibleText, wizardExitButtonLabels);
5376
+ if (denied) {
5377
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: refused wizard-exit control: "${candidate.accessibleText.slice(0, 60)}"`);
5378
+ }
5379
+ return denied;
5380
+ };
5381
+ // Intent discrimination: observe()'s Stagehand-resolved action
5382
+ // carries its own method + fill/select arguments (see attempt 1's
5383
+ // `target.method === "fill"` handling above); the deepLocator
5384
+ // fallback has no such resolved action, so the step prose is the
5385
+ // only place the fill/select value can come from. Derived once per
5386
+ // attempt (not per candidate) — every candidate in this walk is a
5387
+ // guess at the SAME step's target, so they all actuate the same way.
5388
+ const actuation = resolveDeepLocatorActuation(step);
5389
+ const cascadeResult = await (0, deep_locator_click_1.clickFirstActionableCandidate)(deepLocatorCandidates, async (candidate) => {
5390
+ attemptTriedSelectors.push(candidate.selector);
5391
+ if (actuation.kind === "select") {
5392
+ const verified = await (0, deep_locator_actuate_1.selectDeepLocatorCandidateOption)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, candidate.index, actuation.value, { frameTarget });
5393
+ // selectDeepLocatorCandidateOption never throws on an ordinary
5394
+ // failed write (see deep-locator-actuate.ts's writeAndVerify) —
5395
+ // it resolves `false` for both a rejected selectOption() and a
5396
+ // read-back mismatch. clickFirstActionableCandidate infers
5397
+ // success from "didn't throw", so a `false` here must become a
5398
+ // throw or the walk would wrongly report this candidate as
5399
+ // actuated.
5400
+ if (!verified)
5401
+ throw new Error("-32000 Node does not have a layout object");
5402
+ }
5403
+ else if (actuation.kind === "fill") {
5404
+ const verified = await (0, deep_locator_actuate_1.fillDeepLocatorCandidate)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, candidate.index, actuation.value, { frameTarget });
5405
+ if (!verified)
5406
+ throw new Error("-32000 Node does not have a layout object");
5407
+ }
5408
+ else {
5409
+ await (0, deep_locator_candidates_1.clickDeepLocatorCandidate)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, candidate.index, { frameTarget });
5410
+ }
5411
+ }, { denyCandidate })
5412
+ .then((outcome) => ({ outcome, error: null }))
5413
+ .catch((error) => ({ outcome: null, error }));
5414
+ triedSelectors.push(...attemptTriedSelectors);
5415
+ record.triedSelectors = [...attemptTriedSelectors];
5416
+ if (cascadeResult.outcome?.clicked && cascadeResult.outcome.candidate) {
5417
+ const acted = cascadeResult.outcome.candidate;
5418
+ const verb = actuation.kind === "select"
5419
+ ? "selected"
5420
+ : actuation.kind === "fill"
5421
+ ? "filled"
5422
+ : "clicked";
5423
+ record.instruction = `deepLocator: ${acted.accessibleText || "(no accessible text)"}`;
5424
+ record.actResultSuccess = true;
5425
+ record.actResultDescription = `deepLocator ${verb} "${acted.accessibleText || acted.selector}"`;
5426
+ // Synthesize an action matching the actuation kind so downstream
5427
+ // verification (network/url/dom — STATE_CLASS_METHODS treats
5428
+ // fill/selectOption as DOM-verifiable, not click) treats this
5429
+ // exactly like any other resolved action — same idiom as
5430
+ // deep-submit-locator/structured-click.
5431
+ resolvedAction =
5432
+ actuation.kind === "click"
5433
+ ? {
5434
+ selector: acted.selector,
5435
+ description: record.actResultDescription,
5436
+ method: "click",
5437
+ }
5438
+ : {
5439
+ selector: acted.selector,
5440
+ description: record.actResultDescription,
5441
+ method: actuation.kind === "select" ? "selectOption" : "fill",
5442
+ arguments: [actuation.value],
5443
+ };
5444
+ }
5445
+ else {
5446
+ const failureMessage = cascadeResult.error
5447
+ ? `deepLocator: ${actuation.kind} threw ${(0, errors_1.toErrorMessage)(cascadeResult.error)}`
5448
+ : attemptTriedSelectors.length > 0
5449
+ ? `deepLocator: no actionable candidate (${attemptTriedSelectors.length} not-actionable)`
5450
+ : "deepLocator: no actionable candidate (every candidate refused by the wizard-exit deny-list)";
5451
+ record.actResultSuccess = false;
5452
+ record.errorMessage = failureMessage;
5453
+ attempts.push(record);
5454
+ failureReasons.push(failureMessage);
5455
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
5456
+ continue;
5457
+ }
5122
5458
  }
5123
5459
  else if (candidates.length === 0) {
5124
5460
  record.errorMessage = "observe returned no candidates";
@@ -5395,7 +5731,7 @@ async function executeStepWithHealing(params) {
5395
5731
  // prompt, so a resolver error/empty result is fine to swallow.
5396
5732
  await reresolveFrameTargetIfLost();
5397
5733
  const candidates = observedCandidates.length === 0 && frameTarget?.frame
5398
- ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector, step)
5734
+ ? await deepLocatorCandidatesAsActions(page, frameTarget, step)
5399
5735
  : observedCandidates;
5400
5736
  // Fetch live-page evidence so the rephrase prompt can reason about
5401
5737
  // form state, not just observe candidates. Mirrors the same
@@ -5413,7 +5749,7 @@ async function executeStepWithHealing(params) {
5413
5749
  // alongside the failed step, not in the top document.
5414
5750
  const observedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5415
5751
  const unfocused = observedUnfocused.length === 0 && frameTarget?.frame
5416
- ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector)
5752
+ ? await deepLocatorCandidatesAsActions(page, frameTarget)
5417
5753
  : observedUnfocused;
5418
5754
  const submitFailureList = extractSubmitFailureEvidence(recentCaptures, ownBackendHostnames);
5419
5755
  const gaEventList = extractGaEventEvidence(recentCaptures);
@@ -5466,7 +5802,7 @@ async function executeStepWithHealing(params) {
5466
5802
  continue;
5467
5803
  }
5468
5804
  await page.waitForTimeout(STEP_PAUSE_MS);
5469
- const post = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
5805
+ const post = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
5470
5806
  record.post = post;
5471
5807
  if (resolvedAction) {
5472
5808
  record.resolvedMethod = resolvedAction.method ?? null;
@@ -5541,7 +5877,19 @@ async function executeStepWithHealing(params) {
5541
5877
  if (domVerified && !domVerifiedForStep) {
5542
5878
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} advance step succeeded only via DOM state change (field toggle / non-advancing POST), not a real transition; not treating as verified`);
5543
5879
  }
5544
- let verified = networkIsRealAdvance || urlChanged || domVerifiedForStep;
5880
+ // Client-side view-swap gate: credit a click that produces substantial
5881
+ // DOM growth (≥5KB) with zero network when it's NOT a submit/final step
5882
+ // and NOT an advance-pattern step. Fixes the UCHealth "Manual Application"
5883
+ // case where a +49KB DOM-only view swap was scored as "no observable effect".
5884
+ const clickViewSwapVerified = isClickViewSwapVerified({
5885
+ resolvedAction,
5886
+ isFinalStep,
5887
+ submitStep,
5888
+ isAdvanceWithPattern: isAdvanceStep(step) && advanceTransitionBodyPattern !== null,
5889
+ networkDelta: post.networkCount - pre.networkCount,
5890
+ bytesDelta: post.bodyHtmlLength - pre.bodyHtmlLength,
5891
+ });
5892
+ let verified = networkIsRealAdvance || urlChanged || domVerifiedForStep || clickViewSwapVerified;
5545
5893
  // Final-step submit-verification gate. Replaces the deterministic
5546
5894
  // submitEndpointPattern regex with a Haiku 4.5 LLM judgment over
5547
5895
  // multi-signal evidence (network captures, page URL/title, DOM
@@ -5654,7 +6002,13 @@ async function executeStepWithHealing(params) {
5654
6002
  if (verified && record.verifiedBy === null) {
5655
6003
  // Preserve a richer verdict set earlier in this attempt (e.g.
5656
6004
  // "submitted-state-dom" from the final-step DOM fallback).
5657
- record.verifiedBy = urlChanged ? "url" : networkFired ? "network" : "dom";
6005
+ record.verifiedBy = clickViewSwapVerified
6006
+ ? "view-swap"
6007
+ : urlChanged
6008
+ ? "url"
6009
+ : networkFired
6010
+ ? "network"
6011
+ : "dom";
5658
6012
  }
5659
6013
  // N+16 probe: Stagehand's CDP click sometimes lands on the button without
5660
6014
  // triggering React's SyntheticEvent layer (or jQuery delegated handlers).
@@ -5736,7 +6090,7 @@ async function executeStepWithHealing(params) {
5736
6090
  probeResult.checked === true &&
5737
6091
  !ancestorStillInvalid;
5738
6092
  await page.waitForTimeout(STEP_PAUSE_MS);
5739
- const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
6093
+ const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
5740
6094
  const retryNetworkFired = retryPost.networkCount > pre.networkCount;
5741
6095
  const retryUrlChanged = retryPost.url !== pre.url;
5742
6096
  const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
@@ -6035,7 +6389,7 @@ async function executeStepWithHealing(params) {
6035
6389
  // replan budget on every frame-scoped cascade-exhaust failure.
6036
6390
  await reresolveFrameTargetIfLost();
6037
6391
  const finalObserve = cascadeExhaustObservedFinal.length === 0 && frameTarget?.frame
6038
- ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector, step)
6392
+ ? await deepLocatorCandidatesAsActions(page, frameTarget, step)
6039
6393
  : cascadeExhaustObservedFinal;
6040
6394
  const { pageTitle, pageUrl } = await resolveDumpPageIdentity(page, frameTarget);
6041
6395
  // Discriminator data for "Stagehand sees nothing" failures: capture the raw
@@ -6047,7 +6401,7 @@ async function executeStepWithHealing(params) {
6047
6401
  const bodyOuterHtml = typeof bodyOuterHtmlRaw === "string" ? bodyOuterHtmlRaw.slice(0, 100_000) : null;
6048
6402
  const cascadeExhaustObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
6049
6403
  const unfocusedObserve = cascadeExhaustObservedUnfocused.length === 0 && frameTarget?.frame
6050
- ? await deepLocatorCandidatesAsActions(page, frameTarget.frameSelector)
6404
+ ? await deepLocatorCandidatesAsActions(page, frameTarget)
6051
6405
  : cascadeExhaustObservedUnfocused;
6052
6406
  const dumpPath = onStepFailure?.({
6053
6407
  stepIndex,
@@ -6134,7 +6488,7 @@ async function waitForSpaReady(page, logger, opts = {}) {
6134
6488
  * than let the flow report phantom success.
6135
6489
  */
6136
6490
  async function runHealingFlow(deps) {
6137
- const { stagehand, page, steps, logger, anthropic, resumeFixture, maxFlowMs } = deps;
6491
+ const { stagehand, page, steps, logger, anthropic, uploadFixture, maxFlowMs } = deps;
6138
6492
  const counter = { n: 0 };
6139
6493
  const signalCounter = { n: 0 };
6140
6494
  const recentCaptures = [];
@@ -6187,7 +6541,7 @@ async function runHealingFlow(deps) {
6187
6541
  recentCaptureMeta,
6188
6542
  anthropic,
6189
6543
  logger,
6190
- resumeFixture,
6544
+ uploadFixture,
6191
6545
  frameTarget,
6192
6546
  isFinalStep: i === steps.length - 1,
6193
6547
  submitEndpointPattern: deps.submitEndpointPattern ?? null,