@enricai/barnacle 1.12.79 → 1.12.80

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 (135) hide show
  1. package/dist/api/helpers/reply.d.ts.map +1 -1
  2. package/dist/api/helpers/reply.js +5 -2
  3. package/dist/api/helpers/reply.js.map +1 -1
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +2 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/lib/llm/judge.d.ts.map +1 -1
  9. package/dist/lib/llm/judge.js +6 -1
  10. package/dist/lib/llm/judge.js.map +1 -1
  11. package/dist/lib/llm/judges/verify-step-advanced.d.ts +30 -0
  12. package/dist/lib/llm/judges/verify-step-advanced.d.ts.map +1 -0
  13. package/dist/lib/llm/judges/verify-step-advanced.js +62 -0
  14. package/dist/lib/llm/judges/verify-step-advanced.js.map +1 -0
  15. package/dist/lib/llm/judges/verify-submit.d.ts +7 -1
  16. package/dist/lib/llm/judges/verify-submit.d.ts.map +1 -1
  17. package/dist/lib/llm/judges/verify-submit.js +29 -8
  18. package/dist/lib/llm/judges/verify-submit.js.map +1 -1
  19. package/dist/lib/llm/schemas.d.ts +8 -0
  20. package/dist/lib/llm/schemas.d.ts.map +1 -1
  21. package/dist/lib/llm/schemas.js +21 -13
  22. package/dist/lib/llm/schemas.js.map +1 -1
  23. package/dist/lib/logging.d.ts +7 -0
  24. package/dist/lib/logging.d.ts.map +1 -1
  25. package/dist/lib/logging.js +19 -1
  26. package/dist/lib/logging.js.map +1 -1
  27. package/dist/lib/pii-redaction.d.ts +104 -0
  28. package/dist/lib/pii-redaction.d.ts.map +1 -0
  29. package/dist/lib/pii-redaction.js +397 -0
  30. package/dist/lib/pii-redaction.js.map +1 -0
  31. package/dist/lib/random.d.ts +3 -1
  32. package/dist/lib/random.d.ts.map +1 -1
  33. package/dist/lib/random.js +7 -2
  34. package/dist/lib/random.js.map +1 -1
  35. package/dist/lib/telemetry/beacon-capture.d.ts.map +1 -1
  36. package/dist/lib/telemetry/beacon-capture.js +2 -0
  37. package/dist/lib/telemetry/beacon-capture.js.map +1 -1
  38. package/dist/lib/telemetry/call-capture.d.ts.map +1 -1
  39. package/dist/lib/telemetry/call-capture.js +4 -1
  40. package/dist/lib/telemetry/call-capture.js.map +1 -1
  41. package/dist/lib/telemetry/submission-capture.d.ts +11 -1
  42. package/dist/lib/telemetry/submission-capture.d.ts.map +1 -1
  43. package/dist/lib/telemetry/submission-capture.js +32 -3
  44. package/dist/lib/telemetry/submission-capture.js.map +1 -1
  45. package/dist/plugins/config-plugin.d.ts +8 -0
  46. package/dist/plugins/config-plugin.d.ts.map +1 -1
  47. package/dist/plugins/config-plugin.js +4 -0
  48. package/dist/plugins/config-plugin.js.map +1 -1
  49. package/dist/plugins/discover.d.ts.map +1 -1
  50. package/dist/plugins/discover.js +2 -0
  51. package/dist/plugins/discover.js.map +1 -1
  52. package/dist/plugins/loader.d.ts.map +1 -1
  53. package/dist/plugins/loader.js +23 -6
  54. package/dist/plugins/loader.js.map +1 -1
  55. package/dist/scraper/active-page.d.ts +18 -0
  56. package/dist/scraper/active-page.d.ts.map +1 -0
  57. package/dist/scraper/active-page.js +53 -0
  58. package/dist/scraper/active-page.js.map +1 -0
  59. package/dist/scraper/click-settle.d.ts +19 -0
  60. package/dist/scraper/click-settle.d.ts.map +1 -0
  61. package/dist/scraper/click-settle.js +65 -0
  62. package/dist/scraper/click-settle.js.map +1 -0
  63. package/dist/scraper/deep-locator-candidates.d.ts +20 -1
  64. package/dist/scraper/deep-locator-candidates.d.ts.map +1 -1
  65. package/dist/scraper/deep-locator-candidates.js +75 -11
  66. package/dist/scraper/deep-locator-candidates.js.map +1 -1
  67. package/dist/scraper/flow-runner.d.ts +321 -6
  68. package/dist/scraper/flow-runner.d.ts.map +1 -1
  69. package/dist/scraper/flow-runner.js +2087 -341
  70. package/dist/scraper/flow-runner.js.map +1 -1
  71. package/dist/scraper/navigate.d.ts +3 -3
  72. package/dist/scraper/navigate.js +4 -4
  73. package/dist/scraper/navigate.js.map +1 -1
  74. package/dist/scraper/phantom-click.d.ts +34 -2
  75. package/dist/scraper/phantom-click.d.ts.map +1 -1
  76. package/dist/scraper/phantom-click.js +80 -6
  77. package/dist/scraper/phantom-click.js.map +1 -1
  78. package/dist/scraper/quoted-spans.d.ts +35 -0
  79. package/dist/scraper/quoted-spans.d.ts.map +1 -0
  80. package/dist/scraper/quoted-spans.js +83 -0
  81. package/dist/scraper/quoted-spans.js.map +1 -0
  82. package/dist/scraper/session-browserbase.d.ts +4 -2
  83. package/dist/scraper/session-browserbase.d.ts.map +1 -1
  84. package/dist/scraper/session-browserbase.js +36 -5
  85. package/dist/scraper/session-browserbase.js.map +1 -1
  86. package/dist/scraper/session-steel.d.ts.map +1 -1
  87. package/dist/scraper/session-steel.js +16 -2
  88. package/dist/scraper/session-steel.js.map +1 -1
  89. package/dist/scraper/session-teardown.d.ts +1 -0
  90. package/dist/scraper/session-teardown.d.ts.map +1 -1
  91. package/dist/scraper/session-teardown.js +19 -8
  92. package/dist/scraper/session-teardown.js.map +1 -1
  93. package/dist/scraper/spa-readiness.d.ts +13 -1
  94. package/dist/scraper/spa-readiness.d.ts.map +1 -1
  95. package/dist/scraper/spa-readiness.js +31 -1
  96. package/dist/scraper/spa-readiness.js.map +1 -1
  97. package/dist/scraper/stagehand-guard.d.ts +8 -3
  98. package/dist/scraper/stagehand-guard.d.ts.map +1 -1
  99. package/dist/scraper/stagehand-guard.js +181 -13
  100. package/dist/scraper/stagehand-guard.js.map +1 -1
  101. package/dist/scraper/transient-provider-error.d.ts +19 -0
  102. package/dist/scraper/transient-provider-error.d.ts.map +1 -0
  103. package/dist/scraper/transient-provider-error.js +59 -0
  104. package/dist/scraper/transient-provider-error.js.map +1 -0
  105. package/dist/scraper/trusted-click.d.ts +122 -0
  106. package/dist/scraper/trusted-click.d.ts.map +1 -0
  107. package/dist/scraper/trusted-click.js +201 -0
  108. package/dist/scraper/trusted-click.js.map +1 -0
  109. package/dist/scraper/upload-affordance-expr.d.ts +33 -0
  110. package/dist/scraper/upload-affordance-expr.d.ts.map +1 -0
  111. package/dist/scraper/upload-affordance-expr.js +91 -0
  112. package/dist/scraper/upload-affordance-expr.js.map +1 -0
  113. package/dist/scripts/recon-browser.d.ts +26 -1
  114. package/dist/scripts/recon-browser.d.ts.map +1 -1
  115. package/dist/scripts/recon-browser.js +108 -21
  116. package/dist/scripts/recon-browser.js.map +1 -1
  117. package/dist/scripts/recon-capture-rebind.d.ts +11 -0
  118. package/dist/scripts/recon-capture-rebind.d.ts.map +1 -0
  119. package/dist/scripts/recon-capture-rebind.js +26 -0
  120. package/dist/scripts/recon-capture-rebind.js.map +1 -0
  121. package/dist/scripts/recon-generate.d.ts +112 -9
  122. package/dist/scripts/recon-generate.d.ts.map +1 -1
  123. package/dist/scripts/recon-generate.js +889 -181
  124. package/dist/scripts/recon-generate.js.map +1 -1
  125. package/dist/scripts/recon-shared.d.ts +7 -0
  126. package/dist/scripts/recon-shared.d.ts.map +1 -1
  127. package/dist/scripts/recon-shared.js +19 -0
  128. package/dist/scripts/recon-shared.js.map +1 -1
  129. package/dist/site-plugin.d.ts +8 -0
  130. package/dist/site-plugin.d.ts.map +1 -1
  131. package/dist/types/pii-redaction.d.ts +39 -0
  132. package/dist/types/pii-redaction.d.ts.map +1 -0
  133. package/dist/types/pii-redaction.js +3 -0
  134. package/dist/types/pii-redaction.js.map +1 -0
  135. package/package.json +14 -1
@@ -14,10 +14,11 @@
14
14
  * the terminal failure dump.
15
15
  */
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.hasOriginOrPathChanged = exports.TRAILING_GRACE_WINDOW = exports.STEP_WATCHDOG_MS = exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR = exports.GOTO_TIMEOUT_MS = exports.waitForSpaReady = void 0;
17
+ exports.hasOriginOrPathChanged = exports.SEGMENTED_DATE_ENUMERATE_EXPR = exports.ERROR_SUMMARY_EXPR = exports.ADVANCE_CLICK_RX = exports.TRAILING_GRACE_WINDOW = exports.STEP_WATCHDOG_MS = exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR = exports.GOTO_TIMEOUT_MS = exports.waitForSpaReady = void 0;
18
18
  exports.mergeResponseHeaders = mergeResponseHeaders;
19
19
  exports.wireSignalCapture = wireSignalCapture;
20
20
  exports.shouldRetryCaptchaRegistry = shouldRetryCaptchaRegistry;
21
+ exports.buildDeepMarkerCentreExpr = buildDeepMarkerCentreExpr;
21
22
  exports.prepareFailureDumpBody = prepareFailureDumpBody;
22
23
  exports.fieldValuesAtFailureExpr = fieldValuesAtFailureExpr;
23
24
  exports.snapshotPage = snapshotPage;
@@ -29,7 +30,10 @@ exports.isAdvanceStep = isAdvanceStep;
29
30
  exports.isSubmitIntentStep = isSubmitIntentStep;
30
31
  exports.stripNegatedSubmitClauses = stripNegatedSubmitClauses;
31
32
  exports.classifyStepSubmitIntent = classifyStepSubmitIntent;
33
+ exports.isStepDeclaredNonSubmit = isStepDeclaredNonSubmit;
32
34
  exports.isStepSubmitShaped = isStepSubmitShaped;
35
+ exports.isStepSettleWaitShaped = isStepSettleWaitShaped;
36
+ exports.isMidFlowSubmitShapedStep = isMidFlowSubmitShapedStep;
33
37
  exports.isCheckboxOrRadioIntentStep = isCheckboxOrRadioIntentStep;
34
38
  exports.shouldCaptureSelectionState = shouldCaptureSelectionState;
35
39
  exports.flowHasSubmitSemantics = flowHasSubmitSemantics;
@@ -41,6 +45,7 @@ exports.windowHasTransitionBody = windowHasTransitionBody;
41
45
  exports.windowHasAdvanceTransition = windowHasAdvanceTransition;
42
46
  exports.shouldVetoFallbackAdvance = shouldVetoFallbackAdvance;
43
47
  exports.isDomOnlyAdvanceVerified = isDomOnlyAdvanceVerified;
48
+ exports.isNullVerdictAdvanceCredited = isNullVerdictAdvanceCredited;
44
49
  exports.isClickViewSwapVerified = isClickViewSwapVerified;
45
50
  exports.selectionCountFromSignature = selectionCountFromSignature;
46
51
  exports.shouldReadbackFillOnActSuccess = shouldReadbackFillOnActSuccess;
@@ -60,6 +65,8 @@ exports.formatValidationRejectedReason = formatValidationRejectedReason;
60
65
  exports.selectBodyExcerpt = selectBodyExcerpt;
61
66
  exports.extractLivePageFormEvidence = extractLivePageFormEvidence;
62
67
  exports.probeLeafInvalidContainers = probeLeafInvalidContainers;
68
+ exports.buildSubmitJudgeStepContext = buildSubmitJudgeStepContext;
69
+ exports.judgeStepOutcome = judgeStepOutcome;
63
70
  exports.renderLeafInvalidFields = renderLeafInvalidFields;
64
71
  exports.extractSubmitFailureEvidence = extractSubmitFailureEvidence;
65
72
  exports.extractGaEventEvidence = extractGaEventEvidence;
@@ -68,16 +75,24 @@ exports.fillHtml5DateTimeInput = fillHtml5DateTimeInput;
68
75
  exports.fillTextDatepickerInput = fillTextDatepickerInput;
69
76
  exports.verifyFillReadback = verifyFillReadback;
70
77
  exports.verifyPromptSelectorCommitted = verifyPromptSelectorCommitted;
78
+ exports.tailRetargetPhrasesForStep = tailRetargetPhrasesForStep;
79
+ exports.stepQuotesValueNotControlName = stepQuotesValueNotControlName;
71
80
  exports.isUploadAffordanceLabel = isUploadAffordanceLabel;
72
81
  exports.writeFixtureToTempFile = writeFixtureToTempFile;
73
82
  exports.attachToSurfacedInput = attachToSurfacedInput;
74
83
  exports.surfaceAndUpload = surfaceAndUpload;
84
+ exports.clickUploadAffordance = clickUploadAffordance;
85
+ exports.setFilesViaCdp = setFilesViaCdp;
75
86
  exports.parseSelectStep = parseSelectStep;
76
87
  exports.parseFillStep = parseFillStep;
77
88
  exports.parseFillValueIntent = parseFillValueIntent;
89
+ exports.parseSegmentedDateValue = parseSegmentedDateValue;
90
+ exports.resolveSegmentedDateGroup = resolveSegmentedDateGroup;
91
+ exports.trySegmentedDateSpinbuttonPrimitive = trySegmentedDateSpinbuttonPrimitive;
78
92
  exports.parseRadioStep = parseRadioStep;
79
93
  exports.parseWidgetOptionClickStep = parseWidgetOptionClickStep;
80
94
  exports.parseCheckStep = parseCheckStep;
95
+ exports.parseCheckStateStep = parseCheckStateStep;
81
96
  exports.pollEnumerate = pollEnumerate;
82
97
  exports.waitForTransitionBody = waitForTransitionBody;
83
98
  exports.injectCaptchaTokenAndSubmit = injectCaptchaTokenAndSubmit;
@@ -85,8 +100,11 @@ exports.submitCaptchaGatedForm = submitCaptchaGatedForm;
85
100
  exports.trySelectPrimitive = trySelectPrimitive;
86
101
  exports.chooseRequiredSelectOption = chooseRequiredSelectOption;
87
102
  exports.tryFillRequiredSelectsPrimitive = tryFillRequiredSelectsPrimitive;
103
+ exports.tryCheckboxStatePrimitive = tryCheckboxStatePrimitive;
88
104
  exports.buildRadioIdXPath = buildRadioIdXPath;
89
105
  exports.selectRadioGroupOption = selectRadioGroupOption;
106
+ exports.applyRadioSelection = applyRadioSelection;
107
+ exports.dismissLeftoverPromptPopups = dismissLeftoverPromptPopups;
90
108
  exports.hasUnfilledRequiredControlForStep = hasUnfilledRequiredControlForStep;
91
109
  exports.simulateDragDropUpload = simulateDragDropUpload;
92
110
  exports.dispatchJqueryChangeEvent = dispatchJqueryChangeEvent;
@@ -98,6 +116,8 @@ exports.tryDeterministicFieldLabelActuation = tryDeterministicFieldLabelActuatio
98
116
  exports.probeStepBeforeAttempts = probeStepBeforeAttempts;
99
117
  exports.extractLinkFromMessage = extractLinkFromMessage;
100
118
  exports.extractCodeFromMessage = extractCodeFromMessage;
119
+ exports.spliceEmailStepFillValue = spliceEmailStepFillValue;
120
+ exports.primitiveOptionLabel = primitiveOptionLabel;
101
121
  exports.executeStepWithHealing = executeStepWithHealing;
102
122
  exports.executeNavigateStep = executeNavigateStep;
103
123
  exports.runHealingFlow = runHealingFlow;
@@ -113,15 +133,20 @@ const error_messages_1 = require("../lib/llm/judges/error-messages");
113
133
  const invalid_fields_1 = require("../lib/llm/judges/invalid-fields");
114
134
  const modal_priority_1 = require("../lib/llm/judges/modal-priority");
115
135
  const select_option_1 = require("../lib/llm/judges/select-option");
136
+ const verify_step_advanced_1 = require("../lib/llm/judges/verify-step-advanced");
116
137
  const verify_submit_1 = require("../lib/llm/judges/verify-submit");
117
138
  const schemas_1 = require("../lib/llm/schemas");
118
139
  const logging_1 = require("../lib/logging");
140
+ const pii_redaction_1 = require("../lib/pii-redaction");
119
141
  const call_capture_1 = require("../lib/telemetry/call-capture");
120
142
  const call_types_1 = require("../lib/telemetry/call-types");
121
143
  const capture_filters_1 = require("../recon/capture-filters");
144
+ const actionable_visibility_expr_1 = require("./actionable-visibility-expr");
145
+ const active_page_1 = require("./active-page");
122
146
  const browser_click_expr_1 = require("./browser-click-expr");
123
147
  const captcha_callback_capture_1 = require("./captcha-callback-capture");
124
148
  const captcha_solver_1 = require("./captcha-solver");
149
+ const click_settle_1 = require("./click-settle");
125
150
  const deep_locator_actuate_1 = require("./deep-locator-actuate");
126
151
  const deep_locator_candidates_1 = require("./deep-locator-candidates");
127
152
  const deep_locator_click_1 = require("./deep-locator-click");
@@ -129,11 +154,14 @@ const deep_locator_scan_1 = require("./deep-locator-scan");
129
154
  const errors_2 = require("./errors");
130
155
  const frame_target_1 = require("./frame-target");
131
156
  const phantom_click_1 = require("./phantom-click");
157
+ const quoted_spans_1 = require("./quoted-spans");
132
158
  const session_teardown_1 = require("./session-teardown");
133
159
  const spa_readiness_1 = require("./spa-readiness");
134
160
  const stagehand_guard_1 = require("./stagehand-guard");
135
161
  const submit_control_1 = require("./submit-control");
136
162
  const target_resolution_diagnostic_1 = require("./target-resolution-diagnostic");
163
+ const trusted_click_1 = require("./trusted-click");
164
+ const upload_affordance_expr_1 = require("./upload-affordance-expr");
137
165
  const watchdog_1 = require("./watchdog");
138
166
  const recon_shared_1 = require("../scripts/recon-shared");
139
167
  const client_1 = require("../testmail/client");
@@ -368,7 +396,9 @@ function wireSignalCapture(page, params) {
368
396
  recentCaptureMeta.splice(0, recentCaptureMeta.length - RECENT_CAPTURES_WINDOW);
369
397
  }
370
398
  }
371
- logger.info(`captured [${capture.status}] ${capture.method} ${req.url} → ${filename}`);
399
+ // Query strings carry form GETs and analytics beacons (often form-field
400
+ // values); origin + path is enough to follow the request sequence.
401
+ logger.info(`captured [${capture.status}] ${capture.method} ${req.url.split("?")[0]} → ${filename}`);
372
402
  };
373
403
  session.on("Network.requestWillBeSent", onRequest);
374
404
  session.on("Network.requestWillBeSentExtraInfo", onRequestExtraInfo);
@@ -618,11 +648,13 @@ const PROMPT_VALUE_SELECTORS = [
618
648
  ].join(",");
619
649
  /**
620
650
  * The widget-kit's empty-state value text ("0 items selected") — a filled
621
- * widget's value node reads the chosen option instead. Matched
651
+ * widget's value node reads the chosen option instead — as is a generic
652
+ * placeholder prompt ("Select One", "Choose a country") a trigger button shows
653
+ * until a value is picked. Matched
622
654
  * case-insensitively as a whole phrase so a non-empty selection ("1 item
623
655
  * selected, …") is correctly read as filled.
624
656
  */
625
- const PROMPT_EMPTY_VALUE_RX = /^\s*0\s+items?\s+selected\s*$/i;
657
+ const PROMPT_EMPTY_VALUE_RX = /^\s*(?:0\s+items?\s+selected|(?:please\s+)?(?:select|choose|pick)(?:\s+[^\s]+){0,2}\s*(?:\.{3}|…)?)\s*$/i;
626
658
  /**
627
659
  * {@link PROMPT_EMPTY_VALUE_RX}'s source and flags as plain strings, passed into
628
660
  * the browser-evaluated expressions to reconstruct the RegExp there. Kept as
@@ -695,12 +727,73 @@ const PROMPT_CURRENT_TEXT_EXPR = `((w, valueSel, emptyRx) => {
695
727
  const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
696
728
  if (adid) { const opt = document.getElementById(adid); if (opt && optionLabel(opt)) return norm(optionLabel(opt)); }
697
729
  if (w.tagName === "INPUT" && (w.value || "").trim()) return norm(w.value);
698
- if (w.tagName === "BUTTON" && buttonValue(w).trim()) return norm(buttonValue(w));
730
+ if (w.tagName === "BUTTON" && buttonValue(w).trim() && !emptyRx.test(buttonValue(w).trim())) return norm(buttonValue(w));
699
731
  const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
700
732
  const raw = lbl ? (lbl.textContent || "") : "";
701
733
  if (raw.trim() && !emptyRx.test(raw.trim())) return norm(raw);
702
734
  return "";
703
735
  })`;
736
+ /**
737
+ * Browser-side expression reading what a prompt-selector widget ANNOUNCES it
738
+ * committed, lowercased and whitespace-collapsed, or `""` when nothing is
739
+ * readable. The announcement is ground truth over any inferred state: first the
740
+ * "N items selected, X" `aria-describedby` node on the container or one of its
741
+ * descendants (the trigger union may resolve to the OUTER container, so the
742
+ * container's own attribute alone misses it), else the nearest
743
+ * `selectedItemList` — while a popup is open the description can read only
744
+ * "Expanded", hence the fallback. Shared by the pick read-back and the
745
+ * act-success guard so the two never disagree on what the widget committed.
746
+ */
747
+ const PROMPT_ANNOUNCED_VALUE_EXPR = `((w) => {
748
+ const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
749
+ const announceRxs = [
750
+ /\\d+\\s+items?\\s+selected\\s*,?\\s*(.*)$/,
751
+ /^(?:currently\\s+)?selected(?:\\s+(?:item|option|value))?\\s*[:,-]\\s*(.+)$/,
752
+ /^(.+?)\\s+(?:is\\s+)?selected\\.?$/,
753
+ ];
754
+ for (const el of [w, ...Array.from(w.querySelectorAll("[aria-describedby]"))]) {
755
+ for (const ref of (el.getAttribute("aria-describedby") || "").split(/\\s+/)) {
756
+ const refEl = ref ? document.getElementById(ref) : null;
757
+ const said = refEl ? norm(refEl.textContent) : "";
758
+ for (const rx of announceRxs) {
759
+ const m = rx.exec(said);
760
+ if (m && m[1] && !/^\\d+\\s+items?$/.test(m[1])) return m[1];
761
+ }
762
+ }
763
+ }
764
+ let anode = w;
765
+ for (let d = 0; d < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && anode; d++) {
766
+ const list = anode.querySelector ? anode.querySelector('[data-automation-id="selectedItemList"]') : null;
767
+ if (list && norm(list.textContent)) return norm(list.textContent);
768
+ anode = anode.parentElement;
769
+ }
770
+ return "";
771
+ })`;
772
+ /**
773
+ * Browser-side expression resolving ONE trigger-union hit to its widget
774
+ * container. A single widget often matches {@link PROMPT_TRIGGER_SELECTORS} more
775
+ * than once (e.g. an outer container AND its inner filter input both carry
776
+ * `data-uxi-widget-type`), so prefer a real interactive ancestor, else the
777
+ * OUTERMOST widget-kit container on the ancestor chain (so the inner input and
778
+ * its container collapse to the same node), else the element itself. Shared by
779
+ * widget enumeration and the option-scope ownership filter so both agree on what
780
+ * "a widget" is — including widgets that are already committed and so never
781
+ * stamped with the enumeration mark.
782
+ */
783
+ const PROMPT_WIDGET_CONTAINER_EXPR = `((el) => {
784
+ const interactive = el.closest("button,[role='button'],[role='combobox']");
785
+ if (interactive) return interactive;
786
+ const kitSel = "[data-uxi-widget-type='selectinput'],[data-uxi-widget-type='multiselect'],[data-automation-id='multiSelectContainer']";
787
+ let container = el;
788
+ let node = el.closest(kitSel);
789
+ while (node) {
790
+ container = node;
791
+ const up = node.parentElement && node.parentElement.closest(kitSel);
792
+ if (!up || up === node) break;
793
+ node = up;
794
+ }
795
+ return container;
796
+ })`;
704
797
  /**
705
798
  * Browser-side expression resolving the DOM root within which a chosen widget's
706
799
  * popup options / filter input live. Popup placement is vendor-split: the ARIA
@@ -837,6 +930,55 @@ const ATTEMPT_BACKOFF_MS = 1_000;
837
930
  * actionable.
838
931
  */
839
932
  const N16_TRUSTED_CLICK_TIMEOUT_MS = 3_000;
933
+ /**
934
+ * Shared pre-click centre + hit-test for a Stagehand-resolved selector, so
935
+ * observe-act and the natural-language act attempts share one routine.
936
+ * Returns a failure message when the control stays covered, null otherwise.
937
+ */
938
+ async function centreObservedClickTarget(target, selector, onUnavailable, deferWhenMissing = false) {
939
+ try {
940
+ await (0, trusted_click_1.centreSelectorForClick)(target, selector);
941
+ return null;
942
+ }
943
+ catch (err) {
944
+ // A TypeError means the page cannot answer the hit-test probe (no
945
+ // evaluate seam), not that the control is covered; defer to the act.
946
+ if (err instanceof TypeError) {
947
+ onUnavailable(`centre hit-test unavailable: ${(0, errors_1.toErrorMessage)(err)}`);
948
+ return null;
949
+ }
950
+ // Stagehand's own resolution of a natural-language act can differ from the
951
+ // observe result, so an unresolvable element there is the act's to judge.
952
+ if (deferWhenMissing && (0, errors_1.toErrorMessage)(err).includes("is not in the document")) {
953
+ onUnavailable(`centre target unresolved: ${(0, errors_1.toErrorMessage)(err)}`);
954
+ return null;
955
+ }
956
+ return `observed click target not clickable: ${(0, errors_1.toErrorMessage)(err)}`;
957
+ }
958
+ }
959
+ /**
960
+ * Builds an in-page expression that finds the marker-stamped element, piercing
961
+ * open shadow roots (plain `querySelector` cannot), and centre + hit-tests it.
962
+ */
963
+ function buildDeepMarkerCentreExpr(markerSelector) {
964
+ return `(() => {
965
+ const sel = ${JSON.stringify(markerSelector)};
966
+ const find = (root) => {
967
+ const hit = root.querySelector(sel);
968
+ if (hit) return hit;
969
+ for (const n of root.querySelectorAll("*")) {
970
+ if (n.shadowRoot) {
971
+ const r = find(n.shadowRoot);
972
+ if (r) return r;
973
+ }
974
+ }
975
+ return null;
976
+ };
977
+ const el = find(document);
978
+ if (!el) return { state: "missing" };
979
+ return (${trusted_click_1.CENTRE_AND_HIT_TEST_FN}).call(el);
980
+ })()`;
981
+ }
840
982
  /**
841
983
  * Per-step watchdog passed as Stagehand's native `timeout` on every
842
984
  * `act()`/`observe()`. Stagehand's `ensureTimeRemaining()` checks the
@@ -955,6 +1097,10 @@ function prepareFailureDumpBody(raw) {
955
1097
  * entry, so the weak page-wide signature and the strict per-element gate
956
1098
  * agree on exactly what counts as a class-token selection marker.
957
1099
  */
1100
+ /** Shared by both checkbox credit sites so they agree on what "aria-managed" reads. */
1101
+ function ariaCheckedReadExpr(elVar) {
1102
+ return `${elVar}.getAttribute("aria-checked")`;
1103
+ }
958
1104
  const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { html: 0, text: "", values: "", state: "" }; const t = b.innerText || ""; const controls = Array.from(b.querySelectorAll("input, textarea, select")).filter((el) => el.offsetParent !== null); const values = controls.map((el) => { if (el.type === "checkbox" || el.type === "radio") return el.checked ? "1" : "0"; return el.value || ""; }).join("|").slice(0, 2000); const ariaSel = "[aria-pressed],[aria-checked],[aria-selected],[data-state],[data-selected],[data-checked],[role=option],[role=switch],[role=checkbox],[role=menuitemcheckbox]"; const classSel = ${JSON.stringify(browser_click_expr_1.SELECTION_MARKER_CLASS_SELECTOR_SRC)}; const classRx = ${browser_click_expr_1.SELECTION_MARKER_CLASS_TOKEN_REGEX_SRC}; const skip = (el) => el.closest("[role=dialog],[role=tooltip],[aria-live]") !== null; const seen = new Set(); for (const el of b.querySelectorAll(ariaSel + "," + classSel)) { if (el.offsetParent !== null && !skip(el)) seen.add(el); } let idx = 0; const parts = []; for (const el of seen) { const ap = el.getAttribute("aria-pressed") || ""; const ac = el.getAttribute("aria-checked") || ""; const as = el.getAttribute("aria-selected") || ""; const dsRaw = el.getAttribute("data-state") || ""; const ds = (dsRaw === "open" || dsRaw === "closed") ? "" : dsRaw; const dsel = el.hasAttribute("data-selected") ? "1" : ""; const dchk = el.hasAttribute("data-checked") ? "1" : ""; const clsHit = classRx.test(el.getAttribute("class") || "") ? "1" : "0"; const i = idx++; if (!ap && !ac && !as && !ds && !dsel && !dchk && clsHit === "0") continue; parts.push(i + ":" + ap + "," + ac + "," + as + "," + ds + "," + dsel + "," + dchk + "," + clsHit); } const joined = parts.join("|"); let h = 2166136261; for (let k = 0; k < joined.length; k++) { h ^= joined.charCodeAt(k); h = Math.imul(h, 16777619); } const state = (h >>> 0).toString(36) + ":" + parts.length; return { html: (b.outerHTML || "").length, text: t.length + ":" + t.slice(0, 200), values, state }; })()`;
959
1105
  /** Caps mirroring {@link DOM_SNAPSHOT_EXPR}'s truncation so a pathological page can't bloat the failure bundle. */
960
1106
  const FIELD_VALUES_AT_FAILURE_MAX_CONTROLS = 200;
@@ -1243,6 +1389,25 @@ const ADVANCE_STEP_PHRASES = [
1243
1389
  "proceed to the next",
1244
1390
  "advance to the next",
1245
1391
  ];
1392
+ /** Advance-click step wording ("Next"/"Continue"/"Save and continue") for the n+16 error-summary veto. */
1393
+ exports.ADVANCE_CLICK_RX = /\b(next|continue|proceed|advance)\b/i;
1394
+ /**
1395
+ * Page-level error summary check, evaluated in the page after an advance
1396
+ * click: an error-summary container, a visible `role=alert` that mentions an
1397
+ * error, or a heading/button whose WHOLE text is "Errors Found" / "N errors
1398
+ * found" (prose such as "no errors found" is not a summary). Network and text
1399
+ * movement on a page showing its own validation summary is the form rejecting
1400
+ * the advance, not progress.
1401
+ */
1402
+ exports.ERROR_SUMMARY_EXPR = `(() => {
1403
+ const vis = (e) => !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length);
1404
+ const text = (e) => (e.textContent || "").trim();
1405
+ const summarySel = '[class*="error-summary" i],[class*="errorsummary" i],[class*="validation-summary" i],[id*="error-summary" i]';
1406
+ if (Array.from(document.querySelectorAll(summarySel)).some((e) => vis(e) && text(e))) return true;
1407
+ if (Array.from(document.querySelectorAll('[role="alert"]')).some((e) => vis(e) && /\\berrors?\\b/i.test(text(e)))) return true;
1408
+ const titles = 'h1,h2,h3,h4,h5,h6,[role="heading"],button,[role="button"]';
1409
+ return Array.from(document.querySelectorAll(titles)).some((e) => vis(e) && /^(?:\\d+\\s+)?errors?\\s+found$/i.test(text(e)));
1410
+ })()`;
1246
1411
  /**
1247
1412
  * Is this flow step an advance/"Next" click (move to the next wizard page),
1248
1413
  * rather than a field-answer? Used to veto a DOM-only "success" (a field toggle)
@@ -1290,19 +1455,18 @@ function isSubmitIntentStep(instruction) {
1290
1455
  }
1291
1456
  /**
1292
1457
  * Targets whose negation declares the step is not a submit: a checkout/payment/
1293
- * order/submit action, or a "proceeds/leads/advances to" phrase naming one.
1294
- * Bare "do not click Book Now" stays undeclared — the control name alone says
1295
- * nothing about whether the author means a submit.
1458
+ * order/submit action, a purchase-action control named directly ("Book Now",
1459
+ * "Reserve", "Add to Cart"), or a "proceeds/leads/advances to" phrase naming one.
1296
1460
  */
1297
- const SUBMIT_TARGET_SRC = "(?:(?:proceed|advance|move|go|lead|take|navigate|continue)(?:s|ing|es)?\\s+(?:you\\s+)?(?:on\\s+)?(?:to|toward|towards|into|with)\\s+(?:the\\s+|a\\s+|your\\s+)?(?:\\w+\\s+)?(?:checkout|check ?out|payments?|purchase|order|booking|reservation)|(?:place|complete|finali[sz]e|confirm)(?:s|ing)?\\s+(?:the\\s+|your\\s+|an?\\s+)?(?:order|purchase|booking|reservation|payment|checkout)|(?:advance|move|go|lead|take|navigate|continue)(?:s|ing|es)?\\s+(?:you\\s+)?(?:on\\s+)?(?:to|toward|towards|into|with|past|through)\\s+(?:the\\s+|a\\s+|your\\s+)?(?:next|following|final|last)\\s+(?:step|page|screen|stage)|(?:advance|advances|advancing)\\s+(?:the\\s+|your\\s+)?(?:booking|order|reservation|purchase)|check ?out|pay(?:ment|ing)?|purchas(?:e|es|ing)|buy(?:s|ing)?|charg(?:e|es|ing)|submit(?:s|ting)?)";
1461
+ const SUBMIT_TARGET_SRC = "(?:(?:proceed|advance|move|go|lead|take|navigate|continue)(?:s|ing|es)?\\s+(?:you\\s+)?(?:on\\s+)?(?:to|toward|towards|into|with)\\s+(?:the\\s+|a\\s+|your\\s+)?(?:\\w+\\s+)?(?:checkout|check ?out|payments?|purchase|order|booking|reservation)|(?:place|complete|finali[sz]e|confirm)(?:s|ing)?\\s+(?:the\\s+|your\\s+|an?\\s+)?(?:order|purchase|booking|reservation|payment|checkout)|(?:advance|move|go|lead|take|navigate|continue)(?:s|ing|es)?\\s+(?:you\\s+)?(?:on\\s+)?(?:to|toward|towards|into|with|past|through)\\s+(?:the\\s+|a\\s+|your\\s+)?(?:next|following|final|last)\\s+(?:step|page|screen|stage)|(?:advance|advances|advancing)\\s+(?:the\\s+|your\\s+)?(?:booking|order|reservation|purchase)|check ?out|book(?:s|ing)? now|reserv(?:e|es|ing)|add(?:s|ing)? to (?:the |your )?(?:cart|basket|bag)|pay(?:ment|ing)?|purchas(?:e|es|ing)|buy(?:s|ing)?|charg(?:e|es|ing)|submit(?:s|ting)?)";
1298
1462
  /**
1299
1463
  * A negation followed, in the same clause, by a submit target ("Do NOT click
1300
1464
  * ... any control that proceeds to checkout"), or an explicit "this is not a
1301
1465
  * submit click" / "without submitting" declaration.
1302
1466
  */
1303
1467
  const NEGATED_SUBMIT_CLAUSE = new RegExp(`\\b(?:do not|don't|dont|never|must not|should not|shouldn't|does not|doesn't|avoid|refrain from)\\b[^.;\\n]*?\\b${SUBMIT_TARGET_SRC}\\b[^.;\\n]*|\\b(?:(?:this|that|it)\\s+(?:is|are)\\s+not|is not|isn't|not)\\s+(?:a|an|the)?\\s*(?:final\\s+|actual\\s+)?(?:submit|submission|checkout|payment|purchase)\\b[^.;\\n]*|\\bwithout\\s+(?:${SUBMIT_TARGET_SRC})\\b[^.;\\n]*`, "gi");
1304
- /** A positive submit imperative outside any negated clause ("Submit the form."). */
1305
- const LEADING_SUBMIT_IMPERATIVE = /(?:^|[.;]\s*)(?:then\s+)?submit\b/i;
1468
+ /** A positive submit imperative outside any negated clause ("Submit the form.", "Place the order."). */
1469
+ const LEADING_SUBMIT_IMPERATIVE = /(?:^|[.;]\s*)(?:then\s+)?(?:click\s+(?:on\s+)?(?:the\s+)?['"]?)?(?:submit\b|place\s+(?:the\s+)?order\b|complete\s+(?:the\s+)?(?:order|purchase|application|checkout)\b|finish\s+(?:and\s+)?(?:order|purchase|application|checkout)\b|confirm\s+(?:and\s+)?(?:order|purchase|application)\b|save\s+and\s+continue\b|finali[sz]e\b)/i;
1306
1470
  /**
1307
1471
  * The instruction with every negated submit clause blanked, so callers that
1308
1472
  * match submit wording on the step text (flow-load seeding) read only what the
@@ -1328,6 +1492,14 @@ function classifyStepSubmitIntent(instruction) {
1328
1492
  return "non-submit";
1329
1493
  return isSubmitIntentStep(instruction) ? "submit" : "unspecified";
1330
1494
  }
1495
+ /**
1496
+ * Single derivation of "this step's own instruction declares it non-submit",
1497
+ * so every submit-judge gate honours the veto identically. An explicit
1498
+ * `submitStep` flag always overrides the declaration.
1499
+ */
1500
+ function isStepDeclaredNonSubmit(params) {
1501
+ return !params.submitStep && classifyStepSubmitIntent(params.step) === "non-submit";
1502
+ }
1331
1503
  /**
1332
1504
  * Single seam answering "is this step submit-shaped?": an explicit `submitStep`
1333
1505
  * flag is authoritative, a declared non-submit instruction vetoes every
@@ -1337,10 +1509,26 @@ function isStepSubmitShaped(params) {
1337
1509
  const { step, submitStep, isFinalStep, flowHasSubmitSemantics: flowSubmit } = params;
1338
1510
  if (submitStep)
1339
1511
  return true;
1340
- const intent = classifyStepSubmitIntent(step);
1341
- if (intent === "non-submit")
1512
+ if (isStepDeclaredNonSubmit({ step, submitStep }))
1513
+ return false;
1514
+ return (isFinalStep && flowSubmit) || classifyStepSubmitIntent(step) === "submit";
1515
+ }
1516
+ /**
1517
+ * Whether a click may need the long idle settle wait: a submit-shaped step or
1518
+ * one naming an advance word, since both can start their round-trip late.
1519
+ */
1520
+ function isStepSettleWaitShaped(params) {
1521
+ return isStepSubmitShaped(params) || isAdvanceStep(params.step);
1522
+ }
1523
+ /**
1524
+ * Submit-shaped but NOT the flow's final step: a mid-flow advancing submit
1525
+ * (e.g. account creation) that can be credited on a demonstrable page advance
1526
+ * instead of needing the final submit's network/URL proof.
1527
+ */
1528
+ function isMidFlowSubmitShapedStep(params) {
1529
+ if (params.isFinalStep)
1342
1530
  return false;
1343
- return (isFinalStep && flowSubmit) || intent === "submit";
1531
+ return isStepSubmitShaped(params) || params.elementSubmitShaped === true;
1344
1532
  }
1345
1533
  /**
1346
1534
  * Is this flow step's intent to check a checkbox or select a radio option?
@@ -1396,9 +1584,16 @@ function shouldCaptureSelectionState(params) {
1396
1584
  function flowHasSubmitSemantics(params) {
1397
1585
  const { steps, submitEndpointPattern, requireSubmitEndpointMatch } = params;
1398
1586
  const hasExplicitSubmitStep = steps.some((s) => s.submitStep);
1399
- return hasExplicitSubmitStep
1400
- ? (steps[steps.length - 1]?.submitStep ?? false)
1401
- : submitEndpointPattern !== null || requireSubmitEndpointMatch;
1587
+ if (hasExplicitSubmitStep)
1588
+ return steps[steps.length - 1]?.submitStep ?? false;
1589
+ if (submitEndpointPattern === null && !requireSubmitEndpointMatch)
1590
+ return false;
1591
+ // An earlier step whose own wording declares submit already owns the role;
1592
+ // the fallback is only for a self-heal-appended final step.
1593
+ const earlierStepOwnsSubmit = steps
1594
+ .slice(0, -1)
1595
+ .some((s) => classifyStepSubmitIntent(s.instruction) === "submit");
1596
+ return !earlierStepOwnsSubmit;
1402
1597
  }
1403
1598
  /**
1404
1599
  * Whether a flow should be WARNed that its DOM-only advance guard is disarmed.
@@ -1617,6 +1812,20 @@ function isDomOnlyAdvanceVerified(params) {
1617
1812
  // non-advancing POST) does not verify it.
1618
1813
  return networkIsRealAdvance || urlChanged;
1619
1814
  }
1815
+ /**
1816
+ * Decides whether a non-final submit-shaped step is credited when the outcome
1817
+ * judge returned no verdict. An unavailable judge is missing evidence, not a
1818
+ * rejection, so strong structural click signals (a real network advance or a
1819
+ * demonstrable page advance) credit the step instead of failing it and
1820
+ * re-clicking at the same position. Final steps and weak-signal cases return
1821
+ * false and keep the DOM-state fallback / failure path.
1822
+ */
1823
+ function isNullVerdictAdvanceCredited(params) {
1824
+ if (!params.midFlowSubmit || !params.judgeUnavailable || params.invalidMarkerCount > 0) {
1825
+ return false;
1826
+ }
1827
+ return params.networkAdvanced || params.pageAdvanced;
1828
+ }
1620
1829
  /**
1621
1830
  * Client-side view-swap verification gate. Credits a click that produces
1622
1831
  * substantial DOM growth with zero network as a verified step outcome when
@@ -1682,12 +1891,18 @@ function isDomOnlyAdvanceVerified(params) {
1682
1891
  * page), which also nets a large negative byte delta with zero network/url
1683
1892
  * and no text-signature match. A reset replaces the clicked control itself
1684
1893
  * along with everything else, whereas a genuine same-page toggle leaves the
1685
- * clicked control in place. `clickedElementStillPresent` — whether the
1894
+ * clicked control in place. A click-driven view swap (a login-to-registration
1895
+ * form swap, a chooser-to-form swap) also removes its own trigger because the
1896
+ * trigger belonged to the replaced view, but it GROWS the DOM, whereas a reset
1897
+ * reverts to an earlier, smaller screen. The veto therefore keys on absence
1898
+ * combined with a DOM that did not grow (`bytesDelta <= 0`), not on absence
1899
+ * alone. `clickedElementStillPresent` — whether the
1686
1900
  * resolved click target can still be found in the post-click DOM, from a
1687
1901
  * best-effort live probe — is `false` only on POSITIVE proof of absence;
1688
1902
  * when the probe can't run (non-xpath selector, evaluate failure) it stays
1689
1903
  * at its default `true` so it never manufactures a veto out of missing
1690
- * information. Only a confirmed `false` blocks the credit.
1904
+ * information. Only a confirmed `false` paired with a non-growing DOM blocks
1905
+ * the credit.
1691
1906
  *
1692
1907
  * **Destination-plausibility veto:** none of the signals above look at
1693
1908
  * WHERE the click landed. A non-submit click that bounces to a page
@@ -1699,15 +1914,43 @@ function isDomOnlyAdvanceVerified(params) {
1699
1914
  * no-veto behavior — matching the sibling `destinationPlausible` param on
1700
1915
  * `classifyPhantomClick` — so existing/future callers that don't thread it
1701
1916
  * are unaffected.
1917
+ *
1918
+ * **Mid-flow submit:** `midFlowSubmit` (see {@link isMidFlowSubmitShapedStep})
1919
+ * lets a submit-shaped, non-final step be credited when `pageAdvanced` (the
1920
+ * shared {@link hasPageAdvanced} judgement of the visible text; defaults to
1921
+ * `textChanged` when omitted) holds — byte delta is not required, since a
1922
+ * wizard swap can keep the HTML size — with the same invalid-marker and
1923
+ * destination vetoes. The clicked control being gone is NOT a veto here: a
1924
+ * wizard advance normally replaces the button along with the page. The
1925
+ * credit also holds when Stagehand reported a successful act with no
1926
+ * resolved action (`actSucceeded`) or a non-click, non-state-class method.
1927
+ * Omitted,
1928
+ * the submit-shaped rejection is unchanged.
1702
1929
  */
1703
1930
  function isClickViewSwapVerified(params) {
1704
1931
  const VIEW_SWAP_MIN_BYTES = config_1.config.scraper.viewSwapMinBytesThreshold;
1705
1932
  const VIEW_SWAP_REVEAL_MIN_BYTES = config_1.config.scraper.viewSwapRevealMinBytesThreshold;
1706
- const { resolvedAction, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, clickedElementStillPresent = true, resolvedElementIsSubmitShaped = false, destinationPlausible, } = params;
1933
+ const { resolvedAction, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, clickedElementStillPresent = true, resolvedElementIsSubmitShaped = false, destinationPlausible, midFlowSubmit = false, pageAdvanced = textChanged, actSucceeded = false, } = params;
1934
+ // The page advancing is the evidence for a mid-flow submit, so it does not
1935
+ // depend on how Stagehand reported the gesture: a successful act that
1936
+ // listed no actions, or a non-state-class method, still ran a click-class
1937
+ // action. Mirrors the settle gate's `mayTriggerRoundTrip`.
1938
+ const midFlowActRan = resolvedAction === null ? actSucceeded : !STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
1939
+ if (midFlowSubmit &&
1940
+ midFlowActRan &&
1941
+ destinationPlausible !== false &&
1942
+ !isAdvanceWithPattern &&
1943
+ invalidMarkerDelta <= 0 &&
1944
+ textChanged &&
1945
+ pageAdvanced) {
1946
+ return true;
1947
+ }
1707
1948
  if (resolvedAction?.method !== "click")
1708
1949
  return false;
1709
1950
  if (destinationPlausible === false)
1710
1951
  return false;
1952
+ if (midFlowSubmit && (submitStep || resolvedElementIsSubmitShaped))
1953
+ return false;
1711
1954
  // Only the step's own explicit submitStep flag identifies the step that
1712
1955
  // actually needs network/URL verification — mirrors the submit-judge
1713
1956
  // gate's hasSubmitTransitionSignal discipline (see 5763ac2). Inferring
@@ -1730,7 +1973,7 @@ function isClickViewSwapVerified(params) {
1730
1973
  return false;
1731
1974
  if (invalidMarkerDelta > 0)
1732
1975
  return false;
1733
- if (!clickedElementStillPresent)
1976
+ if (!clickedElementStillPresent && bytesDelta <= 0)
1734
1977
  return false;
1735
1978
  if (Math.abs(bytesDelta) >= VIEW_SWAP_MIN_BYTES)
1736
1979
  return true;
@@ -2123,7 +2366,7 @@ For yes/no questions, choose the candidate-favorable answer ('No' for adverse qu
2123
2366
 
2124
2367
  `
2125
2368
  : "";
2126
- const prompt = `You are helping a browser automation agent recover from a failed step in a recon flow.
2369
+ const rawPrompt = `You are helping a browser automation agent recover from a failed step in a recon flow.
2127
2370
 
2128
2371
  ${redirectBlock}ORIGINAL INSTRUCTION:
2129
2372
  ${originalStep}
@@ -2159,6 +2402,10 @@ PAGE TRANSITION + VALIDATOR TELEMETRY (parsed from Google Analytics Measurement
2159
2402
  ${gaEventEvidence || "(none)"}
2160
2403
 
2161
2404
  Rewrite the instruction so a Stagehand act() call can resolve it unambiguously to a different element than the ones already tried. Keep it short — one sentence, natural language, no quotes around it. If the invalid-fields section above names a field AND the interactive-targets section below lists clickable options inside that same container, your rewrite picks one of those options (e.g. for a yes/no question rendered as two radio labels, choose the candidate-favorable answer — 'No' for "do you have a non-compete", 'Yes' for "are you authorized to work", 'Prefer not to say' for demographic disclosures) rather than retrying the original click. If the unfocused observe section shows an open modal/dialog with a Save or Close action, your rewrite invokes that action so the underlying form can clear its blocking state. Set outcome to "impossible" with a one-line reason only when the original instruction's element does not exist on the current page and no redirect target is available.`;
2405
+ // Request values reach the rephrase LLM only as %tokens%; the rewrite is
2406
+ // resolved back before the cascade acts on it (which re-tokenizes it for
2407
+ // Stagehand). A rewrite citing a token we never issued is unusable.
2408
+ const { text: prompt, variables } = (0, pii_redaction_1.tokenizeSensitiveValues)(rawPrompt);
2162
2409
  const model = client.modelId;
2163
2410
  const t0 = performance.now();
2164
2411
  try {
@@ -2188,7 +2435,9 @@ Rewrite the instruction so a Stagehand act() call can resolve it unambiguously t
2188
2435
  });
2189
2436
  if (parsed.outcome === "impossible")
2190
2437
  return null;
2191
- return parsed.instruction;
2438
+ if ((0, pii_redaction_1.unresolvedTokens)(parsed.instruction, variables).length > 0)
2439
+ return null;
2440
+ return (0, pii_redaction_1.detokenize)(parsed.instruction, variables);
2192
2441
  }
2193
2442
  catch (err) {
2194
2443
  const message = err instanceof Error ? err.message : String(err);
@@ -2596,6 +2845,107 @@ async function probeLeafInvalidContainers(target) {
2596
2845
  return [];
2597
2846
  }
2598
2847
  }
2848
+ /**
2849
+ * Reads live body text for the submit judge, because a SPA's page title often
2850
+ * stays stale after a mid-flow transition. Failure yields "" so the judge
2851
+ * simply reasons without it.
2852
+ */
2853
+ async function probeVisibleTextForJudge(target) {
2854
+ return (await probeRawVisibleText(target)).slice(0, 3000);
2855
+ }
2856
+ /**
2857
+ * Untruncated body text, so a settle signature built from it uses the same
2858
+ * unit (raw `innerText.length`) as {@link DOM_SNAPSHOT_EXPR}'s pre-click one.
2859
+ */
2860
+ async function probeRawVisibleText(target) {
2861
+ try {
2862
+ const text = await (0, watchdog_1.withWatchdog)(() => target.evaluate(`(() => (document.body ? document.body.innerText || "" : ""))()`), {
2863
+ timeoutMs: config_1.config.scraper.frameEvaluateTimeoutMs,
2864
+ label: "flow-runner: submit-verification visible text probe",
2865
+ });
2866
+ return typeof text === "string" ? text : "";
2867
+ }
2868
+ catch {
2869
+ return "";
2870
+ }
2871
+ }
2872
+ /**
2873
+ * Shared step-context slice of the submit judge's input, so every judge call
2874
+ * site tells the judge what the step was meant to do, whether it is the final
2875
+ * submit, and what the page shows now.
2876
+ */
2877
+ async function buildSubmitJudgeStepContext(params) {
2878
+ return {
2879
+ stepInstruction: params.step,
2880
+ isFinalStep: params.isFinalStep,
2881
+ visibleText: await probeVisibleTextForJudge(params.target),
2882
+ };
2883
+ }
2884
+ const STEP_REJUDGE_DELAY_MS = 5000;
2885
+ const STEP_REJUDGE_COMPARE_CHARS = 1200;
2886
+ /**
2887
+ * Single entry for both submit-verification judge sites. The final step keeps
2888
+ * the strict submit judge; a non-final step is judged on whether the page
2889
+ * advanced, against the settled post-click text, because the page is still
2890
+ * mid-transition when the click returns and a pre-transition read would make
2891
+ * a successful advance look like no change.
2892
+ */
2893
+ async function judgeStepOutcome(params) {
2894
+ const { client, captureFn, step, isFinalStep, target } = params;
2895
+ if (isFinalStep) {
2896
+ return (0, verify_submit_1.verifySubmitWithLLM)({
2897
+ client,
2898
+ input: {
2899
+ ...(await buildSubmitJudgeStepContext({ step, isFinalStep, target })),
2900
+ ...params.submitInput,
2901
+ },
2902
+ captureFn,
2903
+ });
2904
+ }
2905
+ await (0, click_settle_1.settleAfterClick)(params.page, params.signalCounter, params.preNetworkCount, isStepSettleWaitShaped({ step, submitStep: false, isFinalStep, flowHasSubmitSemantics: false }), async () => {
2906
+ const text = await probeRawVisibleText(target);
2907
+ return `${text.length}:${text.slice(0, 200)}`;
2908
+ }, params.preVisibleTextSignature);
2909
+ const colon = params.preVisibleTextSignature.indexOf(":");
2910
+ const beforeText = (colon === -1 ? params.preVisibleTextSignature : params.preVisibleTextSignature.slice(colon + 1))
2911
+ .replace(/\s+/g, " ")
2912
+ .trim();
2913
+ const judgeText = (afterText) => (0, verify_step_advanced_1.verifyStepAdvancedWithLLM)({
2914
+ client,
2915
+ input: {
2916
+ stepInstruction: step,
2917
+ beforeText,
2918
+ afterText,
2919
+ invalidMarkerCount: params.submitInput.invalidMarkerCount,
2920
+ },
2921
+ captureFn,
2922
+ });
2923
+ const rejudgeIfTextMoved = async (first, judgedText) => {
2924
+ if (first === null || first.advanced)
2925
+ return first;
2926
+ await params.page.waitForTimeout(STEP_REJUDGE_DELAY_MS);
2927
+ const laterText = await probeVisibleTextForJudge(target);
2928
+ if (laterText.slice(0, STEP_REJUDGE_COMPARE_CHARS) ===
2929
+ judgedText.slice(0, STEP_REJUDGE_COMPARE_CHARS)) {
2930
+ return first;
2931
+ }
2932
+ return (await judgeText(laterText)) ?? first;
2933
+ };
2934
+ const afterText = await probeVisibleTextForJudge(target);
2935
+ const firstVerdict = await judgeText(afterText);
2936
+ const verdict = await rejudgeIfTextMoved(firstVerdict, afterText);
2937
+ if (verdict === null)
2938
+ return null;
2939
+ return verdict.advanced
2940
+ ? {
2941
+ verified: true,
2942
+ network_signal: null,
2943
+ dom_signal: null,
2944
+ url_signal: null,
2945
+ rationale: verdict.reason,
2946
+ }
2947
+ : { verified: false, reason: verdict.reason };
2948
+ }
2599
2949
  /**
2600
2950
  * Render a list of structured leaf-invalid records as the numbered evidence
2601
2951
  * lines the prompt's FORM FIELDS section expects. Surfaces label + framework
@@ -3018,7 +3368,7 @@ async function dispatchEnterKey(target, xpath) {
3018
3368
  function isCommittedDateReadback(readback) {
3019
3369
  if (readback === null)
3020
3370
  return false;
3021
- if (readback.outcome === "rejected")
3371
+ if (readback.outcome === "rejected" || readback.outcome === "segment")
3022
3372
  return false;
3023
3373
  // A datepicker reformats what it accepts (e.g. "01/2020" → "2020-01"), so
3024
3374
  // "differs" is a commit, not a rejection — require only a non-empty value
@@ -3113,8 +3463,7 @@ async function fillTextDatepickerInput(target, selector, value) {
3113
3463
  // Strategy 2 — open-and-pick. Required for picker-only widgets (the reported
3114
3464
  // month/year picker ignores typed input entirely).
3115
3465
  try {
3116
- const locator = target.locator(selector).first();
3117
- await locator.click();
3466
+ await (0, trusted_click_1.clickCentredTrusted)(target, selector, { label: "date input" });
3118
3467
  }
3119
3468
  catch {
3120
3469
  return { filled: false, postValue: "", strategy: "none" };
@@ -3208,6 +3557,10 @@ async function verifyFillReadback(target, selector, expectedValue) {
3208
3557
  if (!el) return null;
3209
3558
  const tag = el.tagName ? el.tagName.toLowerCase() : "";
3210
3559
  let actual = "";
3560
+ const siblings = el.parentElement ? el.parentElement.querySelectorAll("[role='spinbutton']").length : 0;
3561
+ if (el.getAttribute && el.getAttribute("role") === "spinbutton" && siblings > 1) {
3562
+ return { outcome: "segment", postValue: el.value || el.getAttribute("aria-valuenow") || "", tag };
3563
+ }
3211
3564
  if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
3212
3565
  actual = el.value || "";
3213
3566
  } else if (el.isContentEditable) {
@@ -3226,8 +3579,12 @@ async function verifyFillReadback(target, selector, expectedValue) {
3226
3579
  if (raw === null || typeof raw !== "object")
3227
3580
  return null;
3228
3581
  const r = raw;
3229
- if (r.outcome !== "matched" && r.outcome !== "rejected" && r.outcome !== "differs")
3582
+ if (r.outcome !== "matched" &&
3583
+ r.outcome !== "rejected" &&
3584
+ r.outcome !== "differs" &&
3585
+ r.outcome !== "segment") {
3230
3586
  return null;
3587
+ }
3231
3588
  return {
3232
3589
  outcome: r.outcome,
3233
3590
  postValue: typeof r.postValue === "string" ? r.postValue : "",
@@ -3251,8 +3608,13 @@ async function verifyFillReadback(target, selector, expectedValue) {
3251
3608
  * `tryPromptSelectorPrimitive`'s widget-container walk). Returns
3252
3609
  * `isPromptWidget: false` for a resolved element that isn't part of this
3253
3610
  * widget family at all, so a non-prompt click/fill is untouched.
3611
+ *
3612
+ * When the step names the option it wants (`requestedOption`), "committed"
3613
+ * means the widget's announced/readable value names THAT option: a widget that
3614
+ * holds a different option is a wrong commit, not a landed one. Without a
3615
+ * requested option any readable value counts, as before.
3254
3616
  */
3255
- async function verifyPromptSelectorCommitted(target, selector) {
3617
+ async function verifyPromptSelectorCommitted(target, selector, requestedOption) {
3256
3618
  const xpath = xpathBodyForEvaluate(selector);
3257
3619
  if (xpath === null)
3258
3620
  return null;
@@ -3261,13 +3623,17 @@ async function verifyPromptSelectorCommitted(target, selector) {
3261
3623
  const valueSel = ${JSON.stringify(PROMPT_VALUE_SELECTORS)};
3262
3624
  const emptyRx = new RegExp(${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)});
3263
3625
  const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
3626
+ const announcedValue = ${PROMPT_ANNOUNCED_VALUE_EXPR};
3627
+ const squash = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
3628
+ const want = squash(${JSON.stringify(requestedOption ?? "")});
3264
3629
  const xpath = ${JSON.stringify(xpath)};
3265
3630
  const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3266
3631
  const el = result.singleNodeValue;
3267
3632
  if (!el) return { isPromptWidget: false, committed: false };
3268
3633
  const widget = el.matches && el.matches(triggerSel) ? el : el.closest(triggerSel);
3269
3634
  if (!widget) return { isPromptWidget: false, committed: false };
3270
- return { isPromptWidget: true, committed: currentText(widget, valueSel, emptyRx) !== "" };
3635
+ const value = announcedValue(widget) || currentText(widget, valueSel, emptyRx);
3636
+ return { isPromptWidget: true, committed: value !== "" && (!want || squash(value).includes(want)) };
3271
3637
  })()`;
3272
3638
  try {
3273
3639
  const raw = await target.evaluate(expr);
@@ -3418,20 +3784,63 @@ function xpathTailForRetarget(xpath) {
3418
3784
  * Falls back to the first document-order match when no candidate clears that
3419
3785
  * bar, preserving the previous behavior for every single-match (the common)
3420
3786
  * case.
3787
+ *
3788
+ * `phrases` (optional): the step's quoted target phrases. Matches whose text
3789
+ * (innerText/textContent/aria-label, case-insensitive, containment in either
3790
+ * direction) names none of them are dropped, so the loose tag/position tail
3791
+ * cannot hand back an element that is not the named target. An empty list, or
3792
+ * a match with no text at all, is unrestricted.
3421
3793
  */
3422
- const XPATH_TAIL_RETARGET_RESOLVE_FN_SRC = `((tail, preferSubmit) => {
3794
+ const XPATH_TAIL_RETARGET_RESOLVE_FN_SRC = `((tail, preferSubmit, phrases) => {
3423
3795
  const isNegative = ${submit_control_1.NEGATIVE_TEXT_EXPR};
3424
3796
  const accessibleName = (el) =>
3425
3797
  (el.getAttribute("aria-label") || el.textContent || "").replace(/\\s+/g, " ").trim().toLowerCase();
3426
3798
  const isSubmitShaped = ${submit_control_1.SUBMIT_SHAPE_EXPR};
3427
3799
  const r = document.evaluate("//" + tail, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
3800
+ const names = (phrases || []).map((p) => String(p).replace(/\\s+/g, " ").trim().toLowerCase()).filter(Boolean);
3801
+ const namesTarget = (el) => {
3802
+ if (names.length === 0) return true;
3803
+ const texts = [el.innerText, el.textContent, el.getAttribute("aria-label")]
3804
+ .map((t) => (t || "").replace(/\\s+/g, " ").trim().toLowerCase())
3805
+ .filter(Boolean);
3806
+ if (texts.length === 0) return true;
3807
+ return texts.some((t) => names.some((n) => t.includes(n) || n.includes(t)));
3808
+ };
3428
3809
  const nodes = [];
3429
- for (let i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
3810
+ for (let i = 0; i < r.snapshotLength; i++) {
3811
+ const node = r.snapshotItem(i);
3812
+ if (namesTarget(node)) nodes.push(node);
3813
+ }
3430
3814
  if (nodes.length <= 1) return nodes[0] || null;
3431
3815
  if (!preferSubmit) return nodes[0];
3432
3816
  const preferred = nodes.find((el) => isSubmitShaped(el) && !isNegative(accessibleName(el)));
3433
3817
  return preferred || nodes[0];
3434
3818
  })`;
3819
+ /**
3820
+ * The quoted, non-negated target phrases a plain click step names, used to
3821
+ * refuse a tail-retargeted element that is not that target. Field-label,
3822
+ * select, and radio steps quote option/label values rather than the clicked
3823
+ * control's own text, so they yield no phrases and keep the unrestricted
3824
+ * retarget.
3825
+ */
3826
+ function tailRetargetPhrasesForStep(step) {
3827
+ if (stepQuotesValueNotControlName(step))
3828
+ return [];
3829
+ return (0, deep_locator_candidates_1.extractTaggedPhrases)(step)
3830
+ .filter((p) => !p.negated)
3831
+ .map((p) => p.text);
3832
+ }
3833
+ /**
3834
+ * True when the step's quoted text is a value (fill/select/radio/field-label)
3835
+ * rather than the clicked control's own name, however loosely the prose is
3836
+ * phrased. Shared so every quoted-phrase guard exempts the same steps.
3837
+ */
3838
+ function stepQuotesValueNotControlName(step) {
3839
+ return Boolean(parseFieldLabelTarget(step) ||
3840
+ parseFillValueIntent(step) ||
3841
+ parseSelectStep(step) ||
3842
+ parseRadioStep(step));
3843
+ }
3435
3844
  /**
3436
3845
  * Resolve any selector a caller might hold into a bare XPath body for
3437
3846
  * `document.evaluate`. `verifyFillReadback` is shared across call sites that
@@ -3596,10 +4005,10 @@ const NEARBY_SELECTION_CONTAINER_FN_SRC = `(el) => {
3596
4005
  * source {@link selectionAncestorChanged} recomputes ancestor keys with, so the
3597
4006
  * two byte-match each other and Stagehand's resolved `xpath=/html[1]/…`
3598
4007
  * selectors. `[role=dialog],[role=tooltip],[aria-live]` subtrees are
3599
- * skipped — they churn without a selection change. Visibility uses the same
3600
- * rect + `getComputedStyle` idiom as `deep-locator-scan.ts`'s `IS_VISIBLE_EXPR`
3601
- * (rather than `offsetParent`), so on-screen `position:fixed` controls — a
3602
- * sticky Next/Submit bar, whose `offsetParent` is `null` — still enter the map.
4008
+ * skipped — they churn without a selection change. Visibility uses the shared
4009
+ * {@link ACTIONABLE_VISIBILITY_EXPR} (rect + `getComputedStyle`, skipping an
4010
+ * element's own `aria-hidden="true"`; not `offsetParent`), so on-screen
4011
+ * `position:fixed` controls — a sticky Next/Submit bar — still enter the map.
3603
4012
  * The visibility gate is waived for an `input`/`select` nested inside a nearby
3604
4013
  * combobox/listbox/group container: a design-system option commits its real
3605
4014
  * selection to a `display:none` or `aria-hidden` sibling input, not to the
@@ -3620,12 +4029,7 @@ const SELECTION_STATE_MAP_EXPR = `(() => {
3620
4029
  if (!b) return {};
3621
4030
  const xpathOf = ${XPATH_OF_FN_SRC};
3622
4031
  const nearbySelectionContainer = ${NEARBY_SELECTION_CONTAINER_FN_SRC};
3623
- const visible = (el) => {
3624
- const rect = el.getBoundingClientRect();
3625
- if (rect.width === 0 && rect.height === 0) return false;
3626
- const style = getComputedStyle(el);
3627
- return style.display !== "none" && style.visibility !== "hidden";
3628
- };
4032
+ const visible = ${actionable_visibility_expr_1.ACTIONABLE_VISIBILITY_EXPR};
3629
4033
  const sel = "button,[role=button],a,li,[tabindex],input,select,textarea,[role=option],[role=tab],[role=switch],[role=checkbox],[role=menuitemcheckbox],[role=combobox]," + ${JSON.stringify(browser_click_expr_1.SELECTION_MARKER_CLASS_SELECTOR_SRC)};
3630
4034
  const skip = (el) => el.closest("[role=dialog],[role=tooltip],[aria-live]") !== null;
3631
4035
  const isCommittedValueControl = (el) =>
@@ -3718,7 +4122,10 @@ async function readElementSelectionFingerprint(target, selector) {
3718
4122
  * own xpath still resolve to a node in the post-click DOM? A same-page
3719
4123
  * toggle (a settings-page tab switcher swapping its panel content) leaves
3720
4124
  * the clicked control itself in place; a full client-side reset that reverts
3721
- * to an earlier screen replaces it along with everything else. Returns
4125
+ * to an earlier screen replaces it along with everything else. A view swap
4126
+ * (e.g. a login-to-registration form swap) also removes its trigger, so
4127
+ * absence only vetoes when the DOM did not grow; the caller applies that
4128
+ * direction check. Returns
3722
4129
  * `true` — never veto — whenever presence genuinely can't be determined (a
3723
4130
  * non-xpath selector, an evaluate failure): only a positive `false` read
3724
4131
  * back from the DOM counts as proof of absence.
@@ -3767,7 +4174,7 @@ async function resolvedClickTargetStillPresent(target, selector) {
3767
4174
  * (Stagehand act()) call site never passes this — its resolved selector came
3768
4175
  * from Stagehand's own successful resolution, so no retarget applies there.
3769
4176
  */
3770
- async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = null) {
4177
+ async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = null, phrases = []) {
3771
4178
  const xpath = xpathBodyForEvaluate(selector);
3772
4179
  if (!xpath)
3773
4180
  return false;
@@ -3775,7 +4182,7 @@ async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = n
3775
4182
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3776
4183
  let el = r.singleNodeValue;
3777
4184
  if (!el && ${JSON.stringify(xpathTail)}) {
3778
- el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(true)});
4185
+ el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(true)}, ${JSON.stringify(phrases)});
3779
4186
  }
3780
4187
  const isSubmitShaped = ${submit_control_1.SUBMIT_SHAPE_EXPR};
3781
4188
  return isSubmitShaped(el);
@@ -3804,7 +4211,7 @@ async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = n
3804
4211
  * strong signal (a real URL/network transition) instead of trusting it
3805
4212
  * blindly, without touching the (already well-tested) weak DOM-signal path.
3806
4213
  */
3807
- async function resolvedClickUsedXpathTailRetarget(target, selector, xpathTail) {
4214
+ async function resolvedClickUsedXpathTailRetarget(target, selector, xpathTail, phrases = []) {
3808
4215
  if (!xpathTail)
3809
4216
  return false;
3810
4217
  const xpath = xpathBodyForEvaluate(selector);
@@ -3812,7 +4219,8 @@ async function resolvedClickUsedXpathTailRetarget(target, selector, xpathTail) {
3812
4219
  return false;
3813
4220
  const expr = `(() => {
3814
4221
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3815
- return r.singleNodeValue === null;
4222
+ if (r.singleNodeValue !== null) return false;
4223
+ return ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(true)}, ${JSON.stringify(phrases)}) !== null;
3816
4224
  })()`;
3817
4225
  try {
3818
4226
  return (await target.evaluate(expr)) === true;
@@ -4092,7 +4500,16 @@ const UPLOAD_WIDGET_RENDER_INTERVAL_MS = 600;
4092
4500
  * matches `/attachment`; the others cover the other ATS upload sinks. Module-
4093
4501
  * level so both the raw-input path and the click-to-surface path share one list.
4094
4502
  */
4095
- const UPLOAD_URL_PATTERNS = ["/upload", "/resume", "/file", "/attachment", "/document"];
4503
+ const UPLOAD_URL_PATTERNS = [
4504
+ "/upload",
4505
+ "/resume",
4506
+ "/file",
4507
+ "/attach",
4508
+ "/document",
4509
+ "/cv",
4510
+ "/media",
4511
+ "/import",
4512
+ ];
4096
4513
  /**
4097
4514
  * Poll the recent-capture window for an upload-related POST after a file has
4098
4515
  * been attached. Extracted from `tryUploadPrimitive` so the raw-input path,
@@ -4101,7 +4518,7 @@ const UPLOAD_URL_PATTERNS = ["/upload", "/resume", "/file", "/attachment", "/doc
4101
4518
  * {@link UPLOAD_URL_PATTERNS} lands; false if the timeout elapses first.
4102
4519
  */
4103
4520
  async function waitForUploadNetworkSignal(params) {
4104
- const { page, fixture, logger, signalCounter, recentCaptureMeta } = params;
4521
+ const { page, fixture, logger, signalCounter, recentCaptureMeta, stopWhen } = params;
4105
4522
  const networkCountBefore = signalCounter.n;
4106
4523
  const captureMetaCountBefore = recentCaptureMeta.length;
4107
4524
  const startedAt = performance.now();
@@ -4119,6 +4536,8 @@ async function waitForUploadNetworkSignal(params) {
4119
4536
  return true;
4120
4537
  }
4121
4538
  }
4539
+ if (stopWhen?.())
4540
+ return false;
4122
4541
  await page.waitForTimeout(UPLOAD_NETWORK_POLL_INTERVAL_MS);
4123
4542
  }
4124
4543
  return false;
@@ -4132,14 +4551,7 @@ async function waitForUploadNetworkSignal(params) {
4132
4551
  * surface path never fires a skip/decline/submit control.
4133
4552
  */
4134
4553
  function isUploadAffordanceLabel(label) {
4135
- const norm = label.replace(/\s+/g, " ").trim().toLowerCase();
4136
- if (norm.length === 0)
4137
- return false;
4138
- if (/\b(later|skip|without|remove|delete|cancel)\b/.test(norm))
4139
- return false;
4140
- if (/\bno file\b/.test(norm))
4141
- return false;
4142
- return /\b(upload|browse|select file|choose file|attach|add (a )?(resume|cv|file)|resume\/cv)\b/.test(norm);
4554
+ return (0, upload_affordance_expr_1.matchesUploadAffordanceVocabulary)(label);
4143
4555
  }
4144
4556
  /**
4145
4557
  * Materialize the in-memory upload fixture to a temp file so CDP
@@ -4215,13 +4627,33 @@ async function tryUploadPrimitive(params) {
4215
4627
  logger,
4216
4628
  signalCounter,
4217
4629
  recentCaptureMeta,
4630
+ browserbaseSessionId: params.browserbaseSessionId,
4218
4631
  });
4219
4632
  if (surfaced)
4220
4633
  return true;
4221
4634
  logger.info("upload primitive: click-to-surface failed; falling through to cascade");
4222
4635
  return false;
4223
4636
  }
4224
- return attachToSurfacedInput({ page, target, fixture, logger, signalCounter, recentCaptureMeta });
4637
+ const attached = await attachToSurfacedInput({
4638
+ page,
4639
+ target,
4640
+ fixture,
4641
+ logger,
4642
+ signalCounter,
4643
+ recentCaptureMeta,
4644
+ });
4645
+ if (attached)
4646
+ return true;
4647
+ logger.info("upload primitive: existing input did not take the file; attempting click-to-surface");
4648
+ return surfaceAndUpload({
4649
+ page,
4650
+ target,
4651
+ fixture,
4652
+ logger,
4653
+ signalCounter,
4654
+ recentCaptureMeta,
4655
+ browserbaseSessionId: params.browserbaseSessionId,
4656
+ });
4225
4657
  }
4226
4658
  /**
4227
4659
  * Attach the fixture to an already-surfaced `<input type=file>` (raw or freshly
@@ -4232,7 +4664,11 @@ async function tryUploadPrimitive(params) {
4232
4664
  */
4233
4665
  async function attachToSurfacedInput(params) {
4234
4666
  const { page, target, fixture, logger, signalCounter, recentCaptureMeta } = params;
4235
- const inputLocator = target.locator("xpath=//input[@type='file']").first();
4667
+ // Prefer an enabled input (a widget can keep a disabled decoy beside the live one);
4668
+ // the second union branch only matches when no enabled file input exists.
4669
+ const inputLocator = target
4670
+ .locator("xpath=(//input[@type='file' and not(@disabled)] | //input[@type='file'][not(//input[@type='file' and not(@disabled)])])[1]")
4671
+ .first();
4236
4672
  try {
4237
4673
  await inputLocator.setInputFiles({
4238
4674
  name: fixture.name,
@@ -4321,28 +4757,21 @@ async function attachToSurfacedInput(params) {
4321
4757
  * was attached.
4322
4758
  */
4323
4759
  async function surfaceAndUpload(params) {
4324
- const { page, target, fixture, logger, signalCounter, recentCaptureMeta } = params;
4760
+ const { page, target, fixture, logger, signalCounter, recentCaptureMeta, browserbaseSessionId } = params;
4325
4761
  // Render-gate: the input-less strategies below (drag-drop is one-shot, the
4326
4762
  // affordance click resolves what's in the DOM) all race the async widget
4327
4763
  // mount. Wait (bounded, same window as the raw-input probe) for ANY upload
4328
4764
  // target to appear — a dropzone, an upload-affordance button, or an
4329
4765
  // <input type=file> — so every downstream strategy runs against a rendered
4330
- // widget. Static evaluate literal (no interpolation); dropzone list parallels
4766
+ // widget. Dropzone list is the shared FIND_DROP_ZONE_EXPR used by
4331
4767
  // simulateDragDropUpload and the button matcher parallels clickUploadAffordance.
4332
4768
  const targetExpr = `(() => {
4333
- const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
4334
- const isUpload = (raw) => {
4335
- const t = norm(raw);
4336
- if (!t) return false;
4337
- if (/\\b(later|skip|without|remove|delete|cancel)\\b/.test(t)) return false;
4338
- if (/\\bno file\\b/.test(t)) return false;
4339
- return /\\b(upload|browse|select file|choose file|attach|add (a )?(resume|cv|file)|resume\\/cv)\\b/.test(t);
4340
- };
4769
+ const isUpload = ${upload_affordance_expr_1.UPLOAD_AFFORDANCE_MATCHER_EXPR};
4341
4770
  if (document.querySelector("input[type=file]")) return { present: true };
4342
- const dz = document.querySelector("[class*='dropzone'],[class*='drop-zone'],[class*='file-drop'],[class*='upload-zone'],[class*='ResumeUpload'],[class*='resumeUpload'],[class*='resume-upload'],uapp-upload,app-upload");
4343
- if (dz) return { present: true };
4344
- const btns = Array.from(document.querySelectorAll("button,[role='button'],a"));
4345
- if (btns.some((el) => isUpload(el.getAttribute("aria-label") || el.textContent || ""))) return { present: true };
4771
+ if (${upload_affordance_expr_1.FIND_DROP_ZONE_EXPR}(document)) return { present: true };
4772
+ const actionable = ${actionable_visibility_expr_1.ACTIONABLE_VISIBILITY_EXPR};
4773
+ const btns = Array.from(document.querySelectorAll(${JSON.stringify(upload_affordance_expr_1.UPLOAD_AFFORDANCE_CONTROL_SELECTOR)}));
4774
+ if (btns.some((el) => actionable(el) && isUpload(el.getAttribute("aria-label") || el.textContent || ""))) return { present: true };
4346
4775
  return { present: false };
4347
4776
  })()`;
4348
4777
  const gate = await pollEnumerate(page, target, targetExpr, (r) => r?.present === true, {
@@ -4387,39 +4816,53 @@ async function surfaceAndUpload(params) {
4387
4816
  if (!(await clickUploadAffordance(page, target, logger)))
4388
4817
  return false;
4389
4818
  // Strategy 0: some MUI widgets XHR straight to attachment_upload_url on
4390
- // click, no chooser, no input.
4391
- if (await waitForUploadNetworkSignal({ page, fixture, logger, signalCounter, recentCaptureMeta })) {
4819
+ // click, no chooser, no input. Stops waiting the moment a chooser is
4820
+ // intercepted — that widget wants the file via the chooser, so burning the
4821
+ // full network window first only delays Strategy B.
4822
+ if (await waitForUploadNetworkSignal({
4823
+ page,
4824
+ fixture,
4825
+ logger,
4826
+ signalCounter,
4827
+ recentCaptureMeta,
4828
+ stopWhen: () => chooserBackendNodeId !== null,
4829
+ })) {
4392
4830
  logger.info("upload primitive: resolved via click → immediate upload POST");
4393
4831
  return true;
4394
4832
  }
4395
- // Strategy A: the click lazily mounted a hidden <input type=file>.
4396
- const appeared = await pollEnumerate(page, target, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0);
4397
- if ((appeared ?? 0) > 0) {
4398
- logger.info("upload primitive: click surfaced a hidden <input type=file>");
4399
- if (await attachToSurfacedInput({
4833
+ // Strategy B: the click opened a native chooser we intercepted. Tried before A —
4834
+ // the chooser names the exact input the widget wants, while A re-sets whatever
4835
+ // input exists, which a React widget may ignore.
4836
+ if (chooserBackendNodeId !== null) {
4837
+ logger.info(`upload primitive: native file chooser intercepted (backendNodeId=${chooserBackendNodeId}); setting files via CDP`);
4838
+ if (await setFilesViaCdp({
4400
4839
  page,
4401
4840
  target,
4841
+ session,
4842
+ backendNodeId: chooserBackendNodeId,
4402
4843
  fixture,
4403
4844
  logger,
4404
4845
  signalCounter,
4405
4846
  recentCaptureMeta,
4847
+ browserbaseSessionId,
4406
4848
  })) {
4407
4849
  return true;
4408
4850
  }
4409
4851
  }
4410
- // Strategy B: the click opened a native chooser we intercepted.
4411
- if (chooserBackendNodeId !== null) {
4412
- logger.info(`upload primitive: native file chooser intercepted (backendNodeId=${chooserBackendNodeId}); setting files via CDP`);
4413
- return setFilesViaCdp({
4852
+ // Strategy A: the click lazily mounted a hidden <input type=file>.
4853
+ const appeared = await pollEnumerate(page, target, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0);
4854
+ if ((appeared ?? 0) > 0) {
4855
+ logger.info("upload primitive: click surfaced a hidden <input type=file>");
4856
+ if (await attachToSurfacedInput({
4414
4857
  page,
4415
4858
  target,
4416
- session,
4417
- backendNodeId: chooserBackendNodeId,
4418
4859
  fixture,
4419
4860
  logger,
4420
4861
  signalCounter,
4421
4862
  recentCaptureMeta,
4422
- });
4863
+ })) {
4864
+ return true;
4865
+ }
4423
4866
  }
4424
4867
  return false;
4425
4868
  }
@@ -4428,6 +4871,7 @@ async function surfaceAndUpload(params) {
4428
4871
  await session.send("Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => { });
4429
4872
  }
4430
4873
  }
4874
+ const UPLOAD_AFFORDANCE_MARKER = "data-barnacle-upload-affordance";
4431
4875
  /**
4432
4876
  * Locate and click the resume-upload affordance in the page DOM. Uses a DOM
4433
4877
  * enumerate (NOT Stagehand observe, which returns [] for these MUI buttons)
@@ -4440,40 +4884,71 @@ async function surfaceAndUpload(params) {
4440
4884
  * (the frame the enumerate/click runs against).
4441
4885
  */
4442
4886
  async function clickUploadAffordance(page, target, logger) {
4443
- // The browser-side matcher parallels isUploadAffordanceLabel; kept as a literal
4444
- // so the enumerate is a static string (same trust posture as the other
4445
- // primitives). No external interpolation.
4887
+ // The browser-side matcher is the shared upload-affordance vocabulary; the enumerate stays
4888
+ // a static string (same trust posture as the other
4889
+ // primitives); only shared constants are interpolated.
4446
4890
  const expr = `(() => {
4447
4891
  const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
4448
- const isUpload = (raw) => {
4449
- const t = norm(raw);
4450
- if (!t) return false;
4451
- if (/\\b(later|skip|without|remove|delete|cancel)\\b/.test(t)) return false;
4452
- if (/\\bno file\\b/.test(t)) return false;
4453
- return /\\b(upload|browse|select file|choose file|attach|add (a )?(resume|cv|file)|resume\\/cv)\\b/.test(t);
4454
- };
4455
- const els = Array.from(document.querySelectorAll("button,[role='button'],a"));
4892
+ const isUpload = ${upload_affordance_expr_1.UPLOAD_AFFORDANCE_MATCHER_EXPR};
4893
+ const els = Array.from(document.querySelectorAll(${JSON.stringify(upload_affordance_expr_1.UPLOAD_AFFORDANCE_CONTROL_SELECTOR)}));
4456
4894
  const scoped = (el) => !!el.closest("[class*='ttachment'],[class*='pload'],[class*='esume']");
4457
- const matches = els.filter((el) => isUpload(el.getAttribute("aria-label") || el.textContent || ""));
4458
- if (matches.length === 0) return { clicked: false };
4895
+ const actionable = ${actionable_visibility_expr_1.ACTIONABLE_VISIBILITY_EXPR};
4896
+ const matches = els.filter((el) => actionable(el) && isUpload(el.getAttribute("aria-label") || el.textContent || ""));
4897
+ if (matches.length === 0) return { marked: false };
4459
4898
  const chosen = matches.find(scoped) || matches[0];
4460
- chosen.click();
4461
- return { clicked: true, text: norm(chosen.getAttribute("aria-label") || chosen.textContent || "").slice(0, 50) };
4899
+ chosen.setAttribute("${UPLOAD_AFFORDANCE_MARKER}", "");
4900
+ return { marked: true, text: norm(chosen.getAttribute("aria-label") || chosen.textContent || "").slice(0, 50) };
4462
4901
  })()`;
4902
+ const markerSelector = `[${UPLOAD_AFFORDANCE_MARKER}]`;
4463
4903
  try {
4464
- const result = (await pollEnumerate(page, target, expr, (r) => r?.clicked === true)) ?? { clicked: false };
4465
- if (result.clicked) {
4466
- logger.info(`upload primitive: clicked upload affordance "${result.text ?? ""}"`);
4467
- return true;
4904
+ const result = (await pollEnumerate(page, target, expr, (r) => r?.marked === true)) ?? { marked: false };
4905
+ if (!result.marked) {
4906
+ logger.info("upload primitive: no upload affordance button found in DOM");
4907
+ return false;
4468
4908
  }
4469
- logger.info("upload primitive: no upload affordance button found in DOM");
4470
- return false;
4909
+ // A trusted CDP click is required: an in-page el.click() never opens a file chooser.
4910
+ try {
4911
+ await (0, trusted_click_1.clickCentredTrusted)(target, markerSelector, { label: "upload affordance" });
4912
+ }
4913
+ finally {
4914
+ await target
4915
+ .evaluate(`document.querySelectorAll(${JSON.stringify(markerSelector)}).forEach((el) => el.removeAttribute("${UPLOAD_AFFORDANCE_MARKER}"))`)
4916
+ .catch(() => { });
4917
+ }
4918
+ logger.info(`upload primitive: clicked upload affordance "${result.text ?? ""}"`);
4919
+ return true;
4471
4920
  }
4472
4921
  catch (err) {
4473
4922
  logger.warn(`upload primitive: affordance click threw: ${(0, errors_1.toErrorMessage)(err)}`);
4474
4923
  return false;
4475
4924
  }
4476
4925
  }
4926
+ /**
4927
+ * Push the fixture to Browserbase's session uploads endpoint, because a remote
4928
+ * browser cannot read a path on this host. Returns the path the remote
4929
+ * browser sees, or null when the upload is rejected.
4930
+ */
4931
+ async function uploadFixtureToRemoteSession(sessionId, fixture, logger) {
4932
+ const form = new FormData();
4933
+ form.append("file", new Blob([new Uint8Array(fixture.buffer)], { type: fixture.mimeType }), fixture.name);
4934
+ try {
4935
+ const res = await fetch(`${config_1.config.scraper.browserbaseBaseUrl}/v1/sessions/${sessionId}/uploads`, {
4936
+ method: "POST",
4937
+ headers: { "X-BB-API-Key": config_1.config.scraper.browserbaseApiKey ?? "" },
4938
+ body: form,
4939
+ });
4940
+ const accepted = res.ok ?? (res.status >= 200 && res.status < 300);
4941
+ if (!accepted) {
4942
+ logger.warn(`upload primitive: remote session upload failed (status=${res.status})`);
4943
+ return null;
4944
+ }
4945
+ return `/tmp/.uploads/${fixture.name}`;
4946
+ }
4947
+ catch (err) {
4948
+ logger.warn(`upload primitive: remote session upload threw: ${(0, errors_1.toErrorMessage)(err)}`);
4949
+ return null;
4950
+ }
4951
+ }
4477
4952
  /**
4478
4953
  * Complete a native-chooser upload the recon intercepted via CDP. CDP's
4479
4954
  * `DOM.setFileInputFiles` takes filesystem PATHS (not buffers), so it uses the
@@ -4482,11 +4957,15 @@ async function clickUploadAffordance(page, target, logger) {
4482
4957
  * shared upload-network signal.
4483
4958
  */
4484
4959
  async function setFilesViaCdp(params) {
4485
- const { page, target, session, backendNodeId, fixture, logger, signalCounter, recentCaptureMeta, } = params;
4960
+ const { page, target, session, backendNodeId, fixture, logger, signalCounter, recentCaptureMeta, browserbaseSessionId, } = params;
4486
4961
  // CDP needs a filesystem path; the fixture is an in-memory buffer. Write it
4487
4962
  // to a temp file (tiny — a few KB) so the path is always valid regardless of
4488
4963
  // where the recon loaded the fixture from.
4489
- const path = writeFixtureToTempFile(fixture);
4964
+ const path = browserbaseSessionId
4965
+ ? await uploadFixtureToRemoteSession(browserbaseSessionId, fixture, logger)
4966
+ : writeFixtureToTempFile(fixture);
4967
+ if (path === null)
4968
+ return false;
4490
4969
  try {
4491
4970
  await session.send("DOM.setFileInputFiles", { files: [path], backendNodeId });
4492
4971
  }
@@ -4538,6 +5017,19 @@ async function setFilesViaCdp(params) {
4538
5017
  // it in the instruction (e.g. a page/step-context quote like "'My
4539
5018
  // Information' step").
4540
5019
  const WIDGET_NOUN_RE = /\b(prompt\s+selector|multiselect|typeahead|dropdown|field|question|checkbox|radio\s+button|radio)\b/i;
5020
+ /**
5021
+ * Matches `prefix` (which must end in a `(?=')` lookahead) and returns the
5022
+ * contraction-aware quoted phrase that starts right after it, so anchored
5023
+ * step parsers pair quotes the same way as {@link findQuotedSpans}.
5024
+ */
5025
+ function matchQuotedAfter(instruction, prefix) {
5026
+ const match = prefix.exec(instruction);
5027
+ if (!match)
5028
+ return null;
5029
+ const at = match.index + match[0].length;
5030
+ const phrase = (0, quoted_spans_1.findQuotedSpans)(instruction).find((p) => p.start === at);
5031
+ return phrase ? { match, phrase } : null;
5032
+ }
4541
5033
  /**
4542
5034
  * Pick the QUESTION LABEL out of an instruction's quoted phrases, given the
4543
5035
  * already-extracted OPTION.
@@ -4555,21 +5047,17 @@ const WIDGET_NOUN_RE = /\b(prompt\s+selector|multiselect|typeahead|dropdown|fiel
4555
5047
  * non-option quote so existing un-adorned phrasings keep working.
4556
5048
  */
4557
5049
  function pickQuestionLabel(instruction, option) {
4558
- // biome-ignore lint/style/noNonNullAssertion: capture group 1 is required by the pattern, so it is present on every match
4559
- const candidates = [...instruction.matchAll(/'([^']+)'/g)].filter((m) => m[1].trim() !== option);
5050
+ const candidates = (0, quoted_spans_1.findQuotedSpans)(instruction).filter((s) => s.text.trim() !== option);
4560
5051
  if (candidates.length === 0)
4561
5052
  return null;
4562
- const adjacentToWidgetNoun = candidates.find((m) => {
4563
- const start = m.index ?? 0;
4564
- const end = start + m[0].length;
4565
- const before = instruction.slice(Math.max(0, start - 25), start);
4566
- const after = instruction.slice(end, end + 40);
5053
+ const adjacentToWidgetNoun = candidates.find((s) => {
5054
+ const before = instruction.slice(Math.max(0, s.start - 25), s.start);
5055
+ const after = instruction.slice(s.end, s.end + 40);
4567
5056
  return (/\bfor(?:\s+the)?(?:\s+(?:question|answer))?\s*$/i.test(before) || WIDGET_NOUN_RE.test(after));
4568
5057
  });
4569
5058
  // biome-ignore lint/style/noNonNullAssertion: candidates is guarded non-empty above, so the fallback element is always present
4570
5059
  const picked = (adjacentToWidgetNoun ?? candidates[0]);
4571
- // biome-ignore lint/style/noNonNullAssertion: capture group 1 is required by the pattern, so it is present on every match
4572
- return picked[1].trim();
5060
+ return picked.text.trim();
4573
5061
  }
4574
5062
  function parseSelectStep(instruction) {
4575
5063
  const lower = instruction.toLowerCase();
@@ -4582,8 +5070,7 @@ function parseSelectStep(instruction) {
4582
5070
  // Catch-all steps ("for any remaining…") have no concrete single target.
4583
5071
  if (/\bany\s+remaining\b/.test(lower))
4584
5072
  return null;
4585
- // biome-ignore lint/style/noNonNullAssertion: capture group 1 is required by the pattern, so it is present on every match
4586
- const quoted = [...instruction.matchAll(/'([^']+)'/g)].map((m) => m[1]);
5073
+ const quoted = (0, quoted_spans_1.findQuotedSpans)(instruction).map((s) => s.text);
4587
5074
  if (quoted.length === 0)
4588
5075
  return null;
4589
5076
  // The OPTION is the quoted string following the word "select", allowing a
@@ -4591,11 +5078,10 @@ function parseSelectStep(instruction) {
4591
5078
  // quote — real flow phrasing like "select the option 'Job Boards'" or
4592
5079
  // "select the answer 'X'" otherwise fails to parse and the caller
4593
5080
  // silently no-ops before touching the DOM.
4594
- const selMatch = instruction.match(/\bselect(?:\s+or\s+check)?\s+(?:the\s+\S+\s+)?'([^']+)'/i);
5081
+ const selMatch = matchQuotedAfter(instruction, /\bselect(?:\s+or\s+check)?\s+(?:the\s+\S+\s+)?(?=')/i)?.phrase;
4595
5082
  if (!selMatch)
4596
5083
  return null;
4597
- // biome-ignore lint/style/noNonNullAssertion: guarded by the !selMatch early-return; group 1 is required by the pattern
4598
- const option = selMatch[1].trim();
5084
+ const option = selMatch.text.trim();
4599
5085
  // The QUESTION LABEL, when present, is a DIFFERENT quoted string — the one
4600
5086
  // introduced by "for '…'" or adjacent to a widget noun like "'…' dropdown"
4601
5087
  // or "'…' prompt selector". See `pickQuestionLabel`.
@@ -4627,11 +5113,11 @@ function parseSelectStep(instruction) {
4627
5113
  * to the normal cascade unchanged.
4628
5114
  */
4629
5115
  function parseFillStep(instruction) {
4630
- const match = instruction.match(/\bfill(?:\s+in)?\s+(?:the\s+)?(.+?)\s+(?:field|input|textbox)\s+with\s+'([^']+)'/i);
4631
- if (!match)
5116
+ const found = matchQuotedAfter(instruction, /\bfill(?:\s+in)?\s+(?:the\s+)?(.+?)\s+(?:field|input|textbox)\s+with\s+(?=')/i);
5117
+ if (!found)
4632
5118
  return null;
4633
- const fieldLabel = match[1]?.trim();
4634
- const value = match[2]?.trim();
5119
+ const fieldLabel = found.match[1]?.trim();
5120
+ const value = found.phrase.text.trim();
4635
5121
  if (!fieldLabel || !value)
4636
5122
  return null;
4637
5123
  return { fieldLabel, value };
@@ -4659,15 +5145,459 @@ function parseFillValueIntent(instruction) {
4659
5145
  if (parseSelectStep(instruction))
4660
5146
  return null;
4661
5147
  if (/\bfill(?:\s+in)?\b/i.test(instruction)) {
4662
- const value = instruction.match(/\bwith\s+'([^']+)'/i)?.[1]?.trim();
5148
+ const value = matchQuotedAfter(instruction, /\bwith\s+(?=')/i)?.phrase.text.trim();
4663
5149
  return value ? { value } : null;
4664
5150
  }
4665
5151
  if (/\btype\b/i.test(instruction)) {
4666
- const value = instruction.match(/\btype\s+'([^']+)'/i)?.[1]?.trim();
5152
+ const value = matchQuotedAfter(instruction, /\btype\s+(?=')/i)?.phrase.text.trim();
4667
5153
  return value ? { value } : null;
4668
5154
  }
4669
5155
  return null;
4670
5156
  }
5157
+ const SEGMENTED_DATE_VALUE_RE = /^[\d/.\s-]+$/;
5158
+ const SEGMENTED_DATE_MIN_DIGITS = 4;
5159
+ const SEGMENTED_DATE_MAX_DIGITS = 8;
5160
+ const SEGMENTED_DATE_KEY_DELAY_MS = 50;
5161
+ const SEGMENTED_DATE_FIELD_WORDS = new Set([
5162
+ "from",
5163
+ "to",
5164
+ "start",
5165
+ "end",
5166
+ "since",
5167
+ "until",
5168
+ "through",
5169
+ "begin",
5170
+ "finish",
5171
+ ]);
5172
+ const SEGMENTED_DATE_STOPWORDS = new Set([
5173
+ "a",
5174
+ "an",
5175
+ "and",
5176
+ "at",
5177
+ "date",
5178
+ "enter",
5179
+ "fill",
5180
+ "for",
5181
+ "in",
5182
+ "into",
5183
+ "of",
5184
+ "on",
5185
+ "the",
5186
+ "then",
5187
+ "to",
5188
+ "type",
5189
+ "with",
5190
+ ]);
5191
+ /**
5192
+ * Extract the digits a fill/type/enter step wants typed into a multi-segment
5193
+ * date widget. Limited to 4-8 digits so phone numbers and other long numerics
5194
+ * never trigger the segmented-date path. Reuses `parseFillValueIntent` and
5195
+ * falls back to the last date-shaped quoted phrase for verbless steps such as
5196
+ * "'From' date in Work Experience 1, '012020'".
5197
+ */
5198
+ function parseSegmentedDateValue(instruction) {
5199
+ const spans = (0, quoted_spans_1.findQuotedSpans)(instruction);
5200
+ const intent = parseFillValueIntent(instruction)?.value;
5201
+ const candidates = intent
5202
+ ? [intent]
5203
+ : spans
5204
+ .map((s) => s.text.trim())
5205
+ .filter((t) => SEGMENTED_DATE_VALUE_RE.test(t))
5206
+ .reverse();
5207
+ for (const raw of candidates) {
5208
+ if (!SEGMENTED_DATE_VALUE_RE.test(raw))
5209
+ continue;
5210
+ const digits = raw.replace(/\D/g, "");
5211
+ if (digits.length < SEGMENTED_DATE_MIN_DIGITS || digits.length > SEGMENTED_DATE_MAX_DIGITS) {
5212
+ continue;
5213
+ }
5214
+ return { digits, valueSpan: raw };
5215
+ }
5216
+ if (spans.length > 0 && intent)
5217
+ return null;
5218
+ const bare = instruction
5219
+ .split(/\s+/)
5220
+ .map((t) => t.replace(/[.,;:]+$/, ""))
5221
+ .filter((t) => /\d/.test(t) && SEGMENTED_DATE_VALUE_RE.test(t))
5222
+ .reverse();
5223
+ for (const raw of bare) {
5224
+ const digits = raw.replace(/\D/g, "");
5225
+ if (digits.length < SEGMENTED_DATE_MIN_DIGITS || digits.length > SEGMENTED_DATE_MAX_DIGITS) {
5226
+ continue;
5227
+ }
5228
+ return { digits, valueSpan: raw };
5229
+ }
5230
+ return null;
5231
+ }
5232
+ /** Keeps a typed date's shape (segment count and widths) in logs without its digits. */
5233
+ function maskDateDigits(text) {
5234
+ return text.replace(/\d/g, "9");
5235
+ }
5236
+ function segmentedDateTokens(text) {
5237
+ return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
5238
+ }
5239
+ const SEGMENTED_DATE_PART_LABELS = new Set(["mm", "dd", "yyyy", "yy", "month", "day", "year"]);
5240
+ const SEGMENTED_DATE_FIELD_NOUNS = new Set([
5241
+ "date",
5242
+ "field",
5243
+ "group",
5244
+ "input",
5245
+ "spinbutton",
5246
+ "segment",
5247
+ "value",
5248
+ "part",
5249
+ ]);
5250
+ const SEGMENTED_DATE_NAMING_DETERMINERS = new Set(["the", "its", "this", "that", "for", "of"]);
5251
+ /**
5252
+ * Step tokens with the field words that are mere prose removed. "from"/"to"
5253
+ * only name a date field when a determiner precedes them ("the To date"), a
5254
+ * field noun follows ("From date"), or they are capitalised mid-sentence;
5255
+ * "auto-advances from month to year" names nothing and must never select a
5256
+ * group labelled "From".
5257
+ */
5258
+ function segmentedDateStepTokens(text) {
5259
+ const words = text.match(/[A-Za-z0-9]+/g) ?? [];
5260
+ return words.flatMap((word, i) => {
5261
+ const token = word.toLowerCase();
5262
+ if (!SEGMENTED_DATE_FIELD_WORDS.has(token))
5263
+ return [token];
5264
+ const prev = (words[i - 1] ?? "").toLowerCase();
5265
+ const next = (words[i + 1] ?? "").toLowerCase();
5266
+ const named = SEGMENTED_DATE_NAMING_DETERMINERS.has(prev) ||
5267
+ SEGMENTED_DATE_FIELD_NOUNS.has(next) ||
5268
+ (i > 0 && /^[A-Z]/.test(word));
5269
+ return named ? [token] : [];
5270
+ });
5271
+ }
5272
+ /**
5273
+ * Pick which spinbutton group a segmented-date step targets. Groups whose
5274
+ * segment widths (digit count of `aria-valuemax`) don't sum to the value's
5275
+ * digit count are dropped. A group is only eligible when a field the step names
5276
+ * (quoted, e.g. 'From', or unquoted in field position, e.g. "the To date")
5277
+ * matches its OWN label as whole words — a group with no `label` is judged by
5278
+ * its whole `context`. The section the step names ("Work Experience 1") is
5279
+ * then resolved: when a width-fitting group elsewhere matches the step's
5280
+ * section words better than any eligible group does, the named section renders
5281
+ * no such field and the step resolves to nothing rather than to another
5282
+ * section's. Returns the index into `groups`, or `null` when nothing matches
5283
+ * or the best match is tied — writing a date into a group that merely fits the
5284
+ * width would corrupt another field.
5285
+ */
5286
+ function resolveSegmentedDateGroup(instruction, groups) {
5287
+ const value = parseSegmentedDateValue(instruction);
5288
+ if (!value)
5289
+ return null;
5290
+ const valueTokens = segmentedDateTokens(value.valueSpan).join(" ");
5291
+ const quoted = (0, quoted_spans_1.findQuotedSpans)(instruction)
5292
+ .map((s) => segmentedDateTokens(s.text.trim()))
5293
+ .filter((t) => t.length > 0 &&
5294
+ t.join(" ") !== valueTokens &&
5295
+ !(t.length === 1 && SEGMENTED_DATE_PART_LABELS.has(t[0] ?? "")));
5296
+ const stepTokens = segmentedDateStepTokens(instruction.replace(value.valueSpan, " barnaclevalue "));
5297
+ const targets = quoted.length > 0 ? quoted : [stepTokens];
5298
+ const contains = (hay, needle) => {
5299
+ for (let i = 0; i + needle.length <= hay.length; i++) {
5300
+ if (needle.every((t, j) => hay[i + j] === t))
5301
+ return true;
5302
+ }
5303
+ return false;
5304
+ };
5305
+ const compatible = (group) => group.segmentMaxes.map((max) => String(max).length).reduce((a, b) => a + b, 0) ===
5306
+ value.digits.length;
5307
+ const stepSet = new Set(stepTokens);
5308
+ const sectionScore = (group) => {
5309
+ const own = new Set(segmentedDateTokens(group.label ?? ""));
5310
+ const sectionTokens = new Set(segmentedDateTokens(group.section ?? group.context));
5311
+ return [...sectionTokens].filter((t) => stepSet.has(t) &&
5312
+ !own.has(t) &&
5313
+ !SEGMENTED_DATE_STOPWORDS.has(t) &&
5314
+ !SEGMENTED_DATE_FIELD_WORDS.has(t)).length;
5315
+ };
5316
+ const bestSection = Math.max(0, ...groups.filter(compatible).map((group) => sectionScore(group)));
5317
+ let best = -1;
5318
+ let bestScore = -1;
5319
+ let tied = false;
5320
+ groups.forEach((group, index) => {
5321
+ if (!compatible(group))
5322
+ return;
5323
+ const own = segmentedDateTokens(group.label ?? group.context);
5324
+ if (own.length === 0)
5325
+ return;
5326
+ const namesOwnField = group.label === undefined &&
5327
+ quoted.length === 0 &&
5328
+ own.some((t) => SEGMENTED_DATE_FIELD_WORDS.has(t) && stepSet.has(t));
5329
+ if (!namesOwnField &&
5330
+ !targets.some((t) => contains(t, own) || (quoted.length > 0 && contains(own, t)))) {
5331
+ return;
5332
+ }
5333
+ const score = sectionScore(group);
5334
+ if (score < bestSection)
5335
+ return;
5336
+ if (score > bestScore) {
5337
+ best = index;
5338
+ bestScore = score;
5339
+ tied = false;
5340
+ }
5341
+ else if (score === bestScore) {
5342
+ tied = true;
5343
+ }
5344
+ });
5345
+ if (best >= 0)
5346
+ return tied ? null : best;
5347
+ // A step that names no field at all can only mean the one date widget on the
5348
+ // page; a named field that matched no label must never fall back to it.
5349
+ if (quoted.length > 0 || stepTokens.some((t) => SEGMENTED_DATE_FIELD_WORDS.has(t)))
5350
+ return null;
5351
+ const only = groups.flatMap((g, i) => (compatible(g) && !g.nested ? [i] : []));
5352
+ return only.length === 1 ? (only[0] ?? null) : null;
5353
+ }
5354
+ const SEGMENTED_DATE_MARKER_ATTR = "data-barnacle-segdate";
5355
+ /** Exported so tests can run the real DOM enumeration against markup. */
5356
+ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5357
+ const attr = ${JSON.stringify(SEGMENTED_DATE_MARKER_ATTR)};
5358
+ const stale = document.querySelectorAll("[" + attr + "]");
5359
+ stale.forEach((el) => el.removeAttribute(attr));
5360
+ const hiddenAttr = (node) => node.hasAttribute("hidden") || node.getAttribute("aria-hidden") === "true";
5361
+ const styleOf = (node) => {
5362
+ try { return typeof getComputedStyle === "function" ? getComputedStyle(node) : window.getComputedStyle(node); } catch { return null; }
5363
+ };
5364
+ const isHidden = (el) => {
5365
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
5366
+ if (hiddenAttr(node)) return true;
5367
+ const style = styleOf(node);
5368
+ if (style && (style.display === "none" || style.visibility === "hidden")) return true;
5369
+ }
5370
+ return false;
5371
+ };
5372
+ const spins = Array.from(document.querySelectorAll("input[role='spinbutton']")).filter((el) => !isHidden(el));
5373
+ if (spins.length === 0) return { groups: [] };
5374
+ const norm = (s) => (s || "").replace(/\\s+/g, " ").trim();
5375
+ const byContainer = new Map();
5376
+ for (const el of spins) {
5377
+ const box = el.closest("fieldset,[role='group']") || el.parentElement;
5378
+ if (!byContainer.has(box)) byContainer.set(box, []);
5379
+ byContainer.get(box).push(el);
5380
+ }
5381
+ const idText = (ids) => (ids || "").split(/\\s+/).map((id) => {
5382
+ const ref = id ? document.getElementById(id) : null;
5383
+ return ref ? norm(ref.textContent) : "";
5384
+ }).join(" ");
5385
+ const headingSibling = (node) => {
5386
+ let prevSib = node.previousElementSibling;
5387
+ for (let hop = 0; hop < 3 && prevSib; hop++, prevSib = prevSib.previousElementSibling) {
5388
+ if (prevSib.matches("h1,h2,h3,h4,h5,h6,legend,[role='heading']")) return norm(prevSib.textContent);
5389
+ }
5390
+ return "";
5391
+ };
5392
+ const legendOf = (node) => {
5393
+ const legend = node.tagName === "FIELDSET" ? node.querySelector(":scope > legend") : null;
5394
+ return legend ? norm(legend.textContent) : "";
5395
+ };
5396
+ const namedBy = (node) => [legendOf(node), norm(node.getAttribute("aria-label")), idText(node.getAttribute("aria-labelledby"))].filter(Boolean).join(" ");
5397
+ const sectionOf = (box, includeSelf) => {
5398
+ const parts = [includeSelf ? namedBy(box) : "", headingSibling(box)];
5399
+ let node = box.parentElement;
5400
+ for (let depth = 0; depth < 4 && node && node !== document.body; depth++, node = node.parentElement) {
5401
+ parts.push(namedBy(node), headingSibling(node));
5402
+ }
5403
+ return Array.from(new Set(parts.filter(Boolean))).join(" ");
5404
+ };
5405
+ const ownCaption = (box) => {
5406
+ const caption = Array.from(box.querySelectorAll("label,legend")).find((l) => !l.querySelector("input,[role='spinbutton']") && !(l.getAttribute("for") && box.querySelector("[id='" + l.getAttribute("for") + "']")));
5407
+ if (caption) return norm(caption.textContent);
5408
+ const prev = box.previousElementSibling;
5409
+ return prev && prev.matches("label,legend") && !prev.querySelector("input,[role='spinbutton']") ? norm(prev.textContent) : "";
5410
+ };
5411
+ const formFieldOf = (box) => box.closest("[data-automation-id^='formField-']");
5412
+ const formFieldLabel = (field) => {
5413
+ const label = field.querySelector("label,legend");
5414
+ return label ? norm(label.textContent) : "";
5415
+ };
5416
+ const labelText = (seg) => {
5417
+ const parts = [idText(seg.getAttribute("aria-labelledby")), norm(seg.getAttribute("aria-label")), norm(seg.getAttribute("placeholder"))];
5418
+ if (seg.id) {
5419
+ document.querySelectorAll("label").forEach((l) => { if (l.getAttribute("for") === seg.id) parts.push(norm(l.textContent)); });
5420
+ }
5421
+ const wrap = seg.closest("label");
5422
+ if (wrap) parts.push(norm(wrap.textContent));
5423
+ return parts.filter(Boolean).join(" ");
5424
+ };
5425
+ const maxOf = (seg, si, count) => {
5426
+ const declared = Number(seg.getAttribute("aria-valuemax"));
5427
+ if (declared > 0) return declared;
5428
+ const own = labelText(seg).toLowerCase();
5429
+ if (/year|yyyy|\byy\b/.test(own)) return 9999;
5430
+ if (/month|\bmm\b/.test(own)) return 12;
5431
+ if (/day|\bdd\b/.test(own)) return 31;
5432
+ const len = Number(seg.getAttribute("maxlength"));
5433
+ if (len > 0) return Math.pow(10, len) - 1;
5434
+ return count > 1 && si === count - 1 ? 9999 : 12;
5435
+ };
5436
+ const groups = [];
5437
+ const mark = (seg, token) => {
5438
+ const prior = seg.getAttribute(attr);
5439
+ seg.setAttribute(attr, prior ? prior + " " + token : token);
5440
+ };
5441
+ const addGroup = (segs, box, nested) => {
5442
+ const gi = groups.length;
5443
+ segs.forEach((seg, si) => mark(seg, gi + ":" + si));
5444
+ const field = formFieldOf(box) || segs[0].closest("[data-automation-id^='formField-']");
5445
+ const innerField = field && field !== box && box.contains(field) && segs.every((seg) => field.contains(seg)) ? formFieldLabel(field) : "";
5446
+ const own = innerField || namedBy(box) || (field ? formFieldLabel(field) : "") || ownCaption(box) || segs.map((seg) => idText(seg.getAttribute("aria-labelledby"))).filter(Boolean).join(" ");
5447
+ const section = sectionOf(box, Boolean(innerField));
5448
+ groups.push({
5449
+ segmentMaxes: segs.map((seg, si) => maxOf(seg, si, segs.length)),
5450
+ context: [section, own].filter(Boolean).join(" "),
5451
+ label: own,
5452
+ section,
5453
+ nested,
5454
+ });
5455
+ };
5456
+ const nestedCandidates = [];
5457
+ byContainer.forEach((segs, box) => {
5458
+ addGroup(segs, box, false);
5459
+ const byParent = new Map();
5460
+ for (const seg of segs) {
5461
+ if (!byParent.has(seg.parentElement)) byParent.set(seg.parentElement, []);
5462
+ byParent.get(seg.parentElement).push(seg);
5463
+ }
5464
+ // A shared fieldset can hold several independent dates (From and To year
5465
+ // spinbuttons side by side); each wrapper is then its own candidate group.
5466
+ if (byParent.size > 1) byParent.forEach((inner, parent) => nestedCandidates.push([inner, parent]));
5467
+ });
5468
+ nestedCandidates.forEach(([inner, parent]) => addGroup(inner, parent, true));
5469
+ return { groups };
5470
+ })()`;
5471
+ const SEGMENTED_DATE_CLEAR_EXPR = `(() => {
5472
+ document.querySelectorAll("[${SEGMENTED_DATE_MARKER_ATTR}]").forEach((el) => el.removeAttribute(${JSON.stringify(SEGMENTED_DATE_MARKER_ATTR)}));
5473
+ })()`;
5474
+ /**
5475
+ * Type a date into a multi-segment `role=spinbutton` widget with real key
5476
+ * events (raw CDP key events, `locator.type` with a delay as fallback), then
5477
+ * verify every segment numerically.
5478
+ *
5479
+ * Why this exists: such widgets keep each segment's value in component state
5480
+ * driven by `keydown`/`beforeinput`; `fill`/`insertText` bypasses that and
5481
+ * garbles the segments. Returns a marker id on commit, or null on no
5482
+ * spinbuttons, an unresolvable group, or any segment mismatch so the caller
5483
+ * falls through to the cascade.
5484
+ */
5485
+ async function trySegmentedDateSpinbuttonPrimitive(params) {
5486
+ const { page, target, instruction, logger } = params;
5487
+ const value = parseSegmentedDateValue(instruction);
5488
+ if (!value)
5489
+ return null;
5490
+ const sendCDP = (method, args) => target.frame?.session ? target.frame.session.send(method, args) : page.sendCDP(method, args);
5491
+ try {
5492
+ const enumerated = (await target.evaluate(exports.SEGMENTED_DATE_ENUMERATE_EXPR));
5493
+ if (!enumerated?.groups?.length)
5494
+ return null;
5495
+ const gi = resolveSegmentedDateGroup(instruction, enumerated.groups);
5496
+ if (gi === null) {
5497
+ logger.info("segmented date primitive: no unambiguous group; falling through");
5498
+ await target.evaluate(SEGMENTED_DATE_CLEAR_EXPR);
5499
+ return null;
5500
+ }
5501
+ const maxes = enumerated.groups[gi]?.segmentMaxes ?? [];
5502
+ const widths = maxes.map((max) => String(max).length);
5503
+ const expected = widths.map((w, i) => {
5504
+ const start = widths.slice(0, i).reduce((a, b) => a + b, 0);
5505
+ return Number(value.digits.slice(start, start + w));
5506
+ });
5507
+ const segmentSelector = (si) => `[${SEGMENTED_DATE_MARKER_ATTR}~="${gi}:${si}"]`;
5508
+ const focusSegment = async (si) => {
5509
+ const selector = segmentSelector(si);
5510
+ // A below-the-fold segment would otherwise take the trusted click at an
5511
+ // off-screen point; centre it first so the click and key events land on it.
5512
+ try {
5513
+ await target.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (el && typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center", inline: "center" }); })()`);
5514
+ }
5515
+ catch {
5516
+ // scrolling is best-effort; the key events only need the segment focused
5517
+ }
5518
+ try {
5519
+ if (await (0, trusted_click_1.isClickPointUnobscured)(target, selector)) {
5520
+ await (0, trusted_click_1.clickCentredTrusted)(target, selector, { label: "date segment" });
5521
+ }
5522
+ }
5523
+ catch {
5524
+ // click unavailable; focus below is what the key events need
5525
+ }
5526
+ await target.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (el && document.activeElement !== el) el.focus(); })()`);
5527
+ };
5528
+ const typeViaCdp = async (digits) => {
5529
+ for (const digit of digits) {
5530
+ const keyArgs = {
5531
+ key: digit,
5532
+ code: `Digit${digit}`,
5533
+ windowsVirtualKeyCode: 48 + Number(digit),
5534
+ };
5535
+ await sendCDP("Input.dispatchKeyEvent", { type: "keyDown", text: digit, ...keyArgs });
5536
+ await sendCDP("Input.dispatchKeyEvent", { type: "keyUp", ...keyArgs });
5537
+ }
5538
+ };
5539
+ const segmentDigits = widths.map((w, i) => {
5540
+ const from = widths.slice(0, i).reduce((acc, n) => acc + n, 0);
5541
+ return value.digits.slice(from, from + w);
5542
+ });
5543
+ const readSegments = async () => (await target.evaluate(`(() => {
5544
+ const sels = ${JSON.stringify(widths.map((_, si) => segmentSelector(si)))};
5545
+ return sels.map((sel) => {
5546
+ const s = document.querySelector(sel);
5547
+ if (!s) return NaN;
5548
+ const now = s.getAttribute("aria-valuenow");
5549
+ const raw = now !== null && now !== "" ? now : s.value;
5550
+ return raw === undefined || raw === null || String(raw).trim() === "" ? NaN : Number(raw);
5551
+ });
5552
+ })()`));
5553
+ const matches = (got) => Array.isArray(got) &&
5554
+ got.length === expected.length &&
5555
+ got.every((n, i) => n === expected[i]);
5556
+ // Raw CDP key events go to the focused segment, so each segment is focused
5557
+ // explicitly rather than trusting the widget to auto-advance. A delayed
5558
+ // locator.type (per-character key events, never Input.insertText) covers a
5559
+ // page whose CDP channel does not reach the widget.
5560
+ const typeViaLocator = async () => {
5561
+ for (const [si, digits] of segmentDigits.entries()) {
5562
+ await target.locator(segmentSelector(si)).first().type(digits, {
5563
+ delay: SEGMENTED_DATE_KEY_DELAY_MS,
5564
+ });
5565
+ }
5566
+ };
5567
+ const typeViaKeyEvents = async () => {
5568
+ for (const [si, digits] of segmentDigits.entries()) {
5569
+ await focusSegment(si);
5570
+ await typeViaCdp(digits);
5571
+ }
5572
+ };
5573
+ let actual = [];
5574
+ for (const type of [typeViaKeyEvents, typeViaLocator]) {
5575
+ try {
5576
+ await type();
5577
+ }
5578
+ catch (err) {
5579
+ logger.info(`segmented date primitive: delivery attempt threw: ${(0, errors_1.toErrorMessage)(err)}`);
5580
+ continue;
5581
+ }
5582
+ actual = await readSegments();
5583
+ if (matches(actual))
5584
+ break;
5585
+ }
5586
+ if (!matches(actual))
5587
+ actual = await readSegments();
5588
+ await target.evaluate(SEGMENTED_DATE_CLEAR_EXPR);
5589
+ if (!matches(actual)) {
5590
+ logger.info(`segmented date primitive: readback ${maskDateDigits(JSON.stringify(actual))} != ${maskDateDigits(JSON.stringify(expected))}; falling through`);
5591
+ return null;
5592
+ }
5593
+ logger.info(`segmented date primitive: committed segments ${maskDateDigits(JSON.stringify(actual))}`);
5594
+ return `segmented-date-${gi}`;
5595
+ }
5596
+ catch (err) {
5597
+ logger.warn(`segmented date primitive: threw: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
5598
+ return null;
5599
+ }
5600
+ }
4671
5601
  /**
4672
5602
  * Parse a single-choice RADIO flow step into the option to click and (when
4673
5603
  * present) the question label that scopes which radio group it targets.
@@ -4703,16 +5633,14 @@ function parseRadioStep(instruction) {
4703
5633
  // Catch-all steps ("for any remaining…") have no concrete single target.
4704
5634
  if (/\bany\s+remaining\b/.test(lower))
4705
5635
  return null;
4706
- // biome-ignore lint/style/noNonNullAssertion: capture group 1 is required by the pattern, so it is present on every match
4707
- const quoted = [...instruction.matchAll(/'([^']+)'/g)].map((m) => m[1]);
5636
+ const quoted = (0, quoted_spans_1.findQuotedSpans)(instruction).map((s) => s.text);
4708
5637
  if (quoted.length === 0)
4709
5638
  return null;
4710
5639
  // The OPTION is the quoted string immediately after "click the".
4711
- const optMatch = instruction.match(/\bclick\s+the\s+'([^']+)'/i);
5640
+ const optMatch = matchQuotedAfter(instruction, /\bclick\s+the\s+(?=')/i)?.phrase;
4712
5641
  if (!optMatch)
4713
5642
  return null;
4714
- // biome-ignore lint/style/noNonNullAssertion: guarded by the !optMatch early-return; group 1 is required by the pattern
4715
- const option = optMatch[1].trim();
5643
+ const option = optMatch.text.trim();
4716
5644
  // The QUESTION LABEL, when present, is a DIFFERENT quoted string — the one
4717
5645
  // introduced by "for the question '…'" / "for the '…' question". Some steps
4718
5646
  // phrase the question un-quoted ("…about requiring visa sponsorship"); in
@@ -4757,11 +5685,10 @@ function parseWidgetOptionClickStep(instruction) {
4757
5685
  // tolerant of a short qualifier ("top-level", "leaf") between the noun and
4758
5686
  // the quote — e.g. "click the top-level category 'Job Boards'" or "click
4759
5687
  // the leaf option 'Internet - Job Boards/Search Engines'".
4760
- const optMatch = instruction.match(/\bclick\s+the\s+(?:\S+\s+)?(?:category|option)\s+'([^']+)'/i);
5688
+ const optMatch = matchQuotedAfter(instruction, /\bclick\s+the\s+(?:\S+\s+)?(?:category|option)\s+(?=')/i)?.phrase;
4761
5689
  if (!optMatch)
4762
5690
  return null;
4763
- // biome-ignore lint/style/noNonNullAssertion: guarded by the !optMatch early-return; group 1 is required by the pattern
4764
- const option = optMatch[1].trim();
5691
+ const option = optMatch.text.trim();
4765
5692
  return { option };
4766
5693
  }
4767
5694
  /**
@@ -4782,10 +5709,31 @@ function parseCheckStep(instruction) {
4782
5709
  const lower = instruction.toLowerCase();
4783
5710
  if (/\bany\s+remaining\b/.test(lower))
4784
5711
  return null;
4785
- const match = instruction.match(/\b(?:check|click)\s+(?:the\s+)?'([^']+)'.*?\bcheckbox\b/i);
4786
- const label = match?.[1]?.trim();
5712
+ const found = matchQuotedAfter(instruction, /\b(?:un)?check\s+(?:the\s+)?(?=')|\bclick\s+(?:the\s+)?(?=')/i);
5713
+ const rest = found ? instruction.slice(found.phrase.end) : "";
5714
+ const label = /^.*?\bcheckbox\b/i.test(rest) ? found?.phrase.text.trim() : undefined;
4787
5715
  return label ? { label } : null;
4788
5716
  }
5717
+ /**
5718
+ * Parse a single-checkbox STATE step ("Check the 'X' checkbox" / "Uncheck the
5719
+ * 'X' checkbox") into its label, desired state and trailing section words.
5720
+ *
5721
+ * Why this exists: "Click the … checkbox" is a toggle and stays with the
5722
+ * cascade; only the explicit check/uncheck verbs name a target state the
5723
+ * primitive can compare against what the page committed.
5724
+ */
5725
+ function parseCheckStateStep(instruction) {
5726
+ const parsed = parseCheckStep(instruction);
5727
+ const found = matchQuotedAfter(instruction, /\b(un)?check\s+(?:the\s+)?(?=')/i);
5728
+ if (!parsed || !found)
5729
+ return null;
5730
+ const outside = instruction.slice(0, found.match.index) +
5731
+ " " +
5732
+ instruction.slice(found.phrase.end).replace(/^.*?\bcheckbox\b/i, "");
5733
+ const stop = new Set(["the", "for", "and", "section", "under", "this", "that", "with", "from"]);
5734
+ const sectionWords = (outside.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter((w) => !stop.has(w) && (w.length >= 3 || /^\d+$/.test(w)));
5735
+ return { label: parsed.label, desired: !found.match[1], sectionWords };
5736
+ }
4789
5737
  /** Strips wrapping single quotes a parser's capture group may include (e.g. when a flow author quotes the field name itself), so a probe label compares plain text against plain text. */
4790
5738
  function stripQuotedLabel(label) {
4791
5739
  return label.replace(/^'+|'+$/g, "").trim();
@@ -5320,13 +6268,16 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
5320
6268
  if (clickResult.clicked) {
5321
6269
  if (!pre || !verification)
5322
6270
  return true;
6271
+ if (verification.page)
6272
+ await (0, click_settle_1.settleAfterClick)(verification.page, verification.signalCounter, pre.networkCount, true, async () => (await snapshotPage(target, verification.signalCounter)).visibleTextSignature, pre.visibleTextSignature);
5323
6273
  const post = await snapshotPage(target, verification.signalCounter, verification.page);
5324
6274
  const verdict = (0, phantom_click_1.classifyPhantomClick)({
5325
6275
  actResultSuccess: true,
5326
6276
  pre,
5327
6277
  post,
5328
6278
  isSubmitShapedStep: true,
5329
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, post.url),
6279
+ isMidFlowSubmitStep: verification.isMidFlowSubmitStep,
6280
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, post.url, verification.isMidFlowSubmitStep ? pre.url : undefined),
5330
6281
  });
5331
6282
  if (verdict !== "phantom")
5332
6283
  return true;
@@ -5340,13 +6291,16 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
5340
6291
  throw err;
5341
6292
  });
5342
6293
  if (runnerUpClickResult.clicked) {
6294
+ if (verification.page)
6295
+ await (0, click_settle_1.settleAfterClick)(verification.page, verification.signalCounter, post.networkCount, true, async () => (await snapshotPage(target, verification.signalCounter)).visibleTextSignature, post.visibleTextSignature);
5343
6296
  const runnerUpPost = await snapshotPage(target, verification.signalCounter, verification.page);
5344
6297
  const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
5345
6298
  actResultSuccess: true,
5346
6299
  pre: post,
5347
6300
  post: runnerUpPost,
5348
6301
  isSubmitShapedStep: true,
5349
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, runnerUpPost.url),
6302
+ isMidFlowSubmitStep: verification.isMidFlowSubmitStep,
6303
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, runnerUpPost.url, verification.isMidFlowSubmitStep ? post.url : undefined),
5350
6304
  });
5351
6305
  if (runnerUpVerdict !== "phantom")
5352
6306
  return true;
@@ -5461,7 +6415,7 @@ async function trySelectPrimitive(params) {
5461
6415
  if (!parsed)
5462
6416
  return null;
5463
6417
  const { option, questionLabel } = parsed;
5464
- const optLabel = `option "${redactIfSensitive(option, redactValue).slice(0, 40)}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
6418
+ const optLabel = primitiveOptionLabel(option, questionLabel, redactValue);
5465
6419
  // Phase 1 (browser, no mutation): find the target select — the one whose
5466
6420
  // nearby text matches the question label (or, when no label, any select).
5467
6421
  // Try a deterministic option match first; if it hits, apply immediately (no
@@ -5559,7 +6513,7 @@ async function trySelectPrimitive(params) {
5559
6513
  if (enumResult.detMatch) {
5560
6514
  const { ok, stillInvalid } = await applySelectValue(page, target, enumResult.detMatch.selIdx, enumResult.detMatch.value);
5561
6515
  if (ok && !stillInvalid) {
5562
- logger.info(`select primitive: set dropdown to "${enumResult.detMatch.text.trim().slice(0, 40)}" (${optLabel})`);
6516
+ logger.info(`select primitive: set dropdown to "${(0, pii_redaction_1.maskValueShape)(enumResult.detMatch.text.trim())}" (${optLabel})`);
5563
6517
  return enumResult.detMatch.id;
5564
6518
  }
5565
6519
  logger.info(`select primitive: dropdown value for ${optLabel} did not commit (ok=${ok} stillInvalid=${stillInvalid}); falling through to cascade`);
@@ -5596,7 +6550,7 @@ async function trySelectPrimitive(params) {
5596
6550
  // same as the fast path.
5597
6551
  const { ok, stillInvalid } = await applySelectValue(page, target, chosenCandidate.selIdx, chosenOption.value);
5598
6552
  if (ok && !stillInvalid) {
5599
- logger.info(`select primitive: LLM chose "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
6553
+ logger.info(`select primitive: LLM chose "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
5600
6554
  return chosenCandidate.id;
5601
6555
  }
5602
6556
  logger.info(`select primitive: LLM-chosen value for ${optLabel} did not commit (ok=${ok} stillInvalid=${stillInvalid}); falling through`);
@@ -5752,7 +6706,7 @@ async function tryFillRequiredSelectsPrimitive(params) {
5752
6706
  }
5753
6707
  const { ok, stillInvalid } = await applySelectValue(page, target, cand.selIdx, chosen.value);
5754
6708
  if (ok && !stillInvalid) {
5755
- logger.info(`required-select primitive: filled "${cand.label.slice(0, 40)}" with "${chosen.text.slice(0, 40)}"`);
6709
+ logger.info(`required-select primitive: filled "${cand.label.slice(0, 40)}" with "${(0, pii_redaction_1.maskValueShape)(chosen.text)}"`);
5756
6710
  }
5757
6711
  else {
5758
6712
  allCommitted = false;
@@ -5766,6 +6720,161 @@ async function tryFillRequiredSelectsPrimitive(params) {
5766
6720
  return false;
5767
6721
  }
5768
6722
  }
6723
+ /** Marker stamped on the matched checkbox / its label so trusted clicks can address them. */
6724
+ const CHECKBOX_STATE_MARKER_ATTR = "data-barnacle-cbstate";
6725
+ const CHECKBOX_STATE_POLL_MS = 200;
6726
+ const CHECKBOX_STATE_POLL_ATTEMPTS = 8;
6727
+ /**
6728
+ * Commits a single "Check/Uncheck the 'X' checkbox" step by comparing the
6729
+ * box's committed state to the requested one, so a pre-ticked box is never
6730
+ * toggled off by a blind click. Returns the box's DOM id (possibly empty) on
6731
+ * success, or null when ambiguous, absent, obscured or unflipped so the
6732
+ * caller falls through to the cascade.
6733
+ */
6734
+ async function tryCheckboxStatePrimitive(params) {
6735
+ const { page, target, instruction, logger } = params;
6736
+ const parsed = parseCheckStateStep(instruction);
6737
+ if (!parsed)
6738
+ return null;
6739
+ const attr = CHECKBOX_STATE_MARKER_ATTR;
6740
+ const stampExpr = `((label, words) => {
6741
+ const norm = (s) => (s || "").replace(/\\s+/g, " ").replace(/\\s*[*:]+$/, "").trim().toLowerCase();
6742
+ const want = norm(label);
6743
+ document.querySelectorAll("[${attr}]").forEach((e) => e.removeAttribute("${attr}"));
6744
+ const visible = (e) => {
6745
+ try { return !!e && e.getClientRects().length > 0 && getComputedStyle(e).visibility !== "hidden"; } catch { return !!e; }
6746
+ };
6747
+ const labelsOf = (cb) => {
6748
+ const out = [];
6749
+ if (cb.id) {
6750
+ document.querySelectorAll("label").forEach((l) => { if (l.getAttribute("for") === cb.id) out.push(l); });
6751
+ }
6752
+ const w = cb.closest("label");
6753
+ if (w && !out.includes(w)) out.push(w);
6754
+ return out;
6755
+ };
6756
+ const isOn = (cb) => {
6757
+ const a = cb.getAttribute("aria-checked");
6758
+ return a === "true" ? true : a === "false" ? false : cb.checked === true;
6759
+ };
6760
+ const boxes = Array.from(document.querySelectorAll("input[type=checkbox],[role=checkbox]"));
6761
+ let hits = [];
6762
+ for (const cb of boxes) {
6763
+ const ls = labelsOf(cb);
6764
+ const names = ls.map((l) => norm(l.textContent));
6765
+ const al = cb.getAttribute("aria-label");
6766
+ if (al) names.push(norm(al));
6767
+ const alb = cb.getAttribute("aria-labelledby");
6768
+ if (alb) {
6769
+ const refs = alb.split(/\\s+/).map((id) => document.getElementById(id)).filter(Boolean);
6770
+ if (refs.length > 0) names.push(norm(refs.map((r) => r.textContent).join(" ")));
6771
+ }
6772
+ if (!names.includes(want)) continue;
6773
+ if (!visible(cb) && !ls.some(visible)) continue;
6774
+ hits.push({ cb, ls });
6775
+ }
6776
+ const sectionText = (cb) => {
6777
+ const parts = [];
6778
+ for (let n = cb.parentElement; n; n = n.parentElement) {
6779
+ const lb = n.getAttribute("aria-labelledby");
6780
+ if (lb) lb.split(/\\s+/).forEach((id) => { const el = document.getElementById(id); if (el) parts.push(el.textContent); });
6781
+ const lg = n.querySelector(":scope > legend");
6782
+ if (lg) parts.push(lg.textContent);
6783
+ const hd = n.querySelector(":scope > h2, :scope > h3, :scope > h4");
6784
+ if (hd) parts.push(hd.textContent);
6785
+ let sib = n.previousElementSibling;
6786
+ for (let hop = 0; hop < 3 && sib; hop++, sib = sib.previousElementSibling) {
6787
+ if (!sib.matches("h1,h2,h3,h4,h5,h6,legend,[role='heading']")) continue;
6788
+ parts.push(sib.textContent);
6789
+ break;
6790
+ }
6791
+ }
6792
+ return norm(parts.join(" "));
6793
+ };
6794
+ if (hits.length > 1 && words.length > 0) {
6795
+ const scored = hits.map((h) => {
6796
+ const tokens = new Set(sectionText(h.cb).match(/[a-z0-9]+/g) || []);
6797
+ return { h, score: words.filter((w) => tokens.has(w)).length };
6798
+ });
6799
+ const top = Math.max(...scored.map((x) => x.score));
6800
+ if (top > 0) hits = scored.filter((x) => x.score === top).map((x) => x.h);
6801
+ }
6802
+ if (hits.length === 0) return { status: "none" };
6803
+ if (hits.length > 1) return { status: "ambiguous" };
6804
+ const { cb, ls } = hits[0];
6805
+ cb.setAttribute("${attr}", "box");
6806
+ const lab = ls.find(visible);
6807
+ if (lab) lab.setAttribute("${attr}", "label");
6808
+ return { status: "ok", checked: isOn(cb), id: cb.id || "", hasLabel: !!lab };
6809
+ })(${JSON.stringify(parsed.label.toLowerCase())}, ${JSON.stringify(parsed.sectionWords)})`;
6810
+ const stateExpr = `(() => {
6811
+ const cb = document.querySelector("[${attr}=box]");
6812
+ if (!cb) return null;
6813
+ const a = cb.getAttribute("aria-checked");
6814
+ return a === "true" ? true : a === "false" ? false : cb.checked === true;
6815
+ })()`;
6816
+ const clearExpr = `document.querySelectorAll("[${attr}]").forEach((e) => e.removeAttribute("${attr}"))`;
6817
+ try {
6818
+ const stamped = (await target.evaluate(stampExpr));
6819
+ if (stamped?.status !== "ok")
6820
+ return null;
6821
+ const id = stamped.id ?? "";
6822
+ if (stamped.checked === parsed.desired) {
6823
+ logger.info(`checkbox state primitive: "${parsed.label.slice(0, 40)}" already ${parsed.desired ? "checked" : "unchecked"}, no click`);
6824
+ return id;
6825
+ }
6826
+ const clickSels = [...(stamped.hasLabel ? [`[${attr}="label"]`] : []), `[${attr}="box"]`];
6827
+ const reachedDesired = async () => {
6828
+ for (let i = 0; i < CHECKBOX_STATE_POLL_ATTEMPTS; i++) {
6829
+ await page.waitForTimeout(CHECKBOX_STATE_POLL_MS).catch(() => undefined);
6830
+ if ((await target.evaluate(stateExpr)) === parsed.desired)
6831
+ return true;
6832
+ }
6833
+ return false;
6834
+ };
6835
+ const succeed = () => {
6836
+ logger.info(`checkbox state primitive: "${parsed.label.slice(0, 40)}" now ${parsed.desired ? "checked" : "unchecked"}`);
6837
+ return id;
6838
+ };
6839
+ let overlaid = false;
6840
+ let trustedDelivered = false;
6841
+ for (const sel of clickSels) {
6842
+ const probe = await (0, trusted_click_1.probeClickPoint)(target, sel);
6843
+ if (probe?.state === "occluded" && probe.occluder !== "none") {
6844
+ overlaid = true;
6845
+ continue;
6846
+ }
6847
+ if (probe?.state !== "hit")
6848
+ continue;
6849
+ const delivered = await (0, trusted_click_1.clickCentredTrusted)(target, sel, { label: "checkbox" })
6850
+ .then(() => true)
6851
+ .catch(() => false);
6852
+ trustedDelivered ||= delivered;
6853
+ if (delivered && (await reachedDesired()))
6854
+ return succeed();
6855
+ }
6856
+ // A hit test that cannot resolve a point at all (no layout) says nothing
6857
+ // about whether a programmatic click toggles the box, so it is tried once;
6858
+ // a named overlay or an already-delivered trusted click that did not flip the
6859
+ // box is left to the cascade, since a second click could toggle it back.
6860
+ if (!overlaid && !trustedDelivered) {
6861
+ for (const sel of clickSels) {
6862
+ await target.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(sel)}); if (el) el.click(); })()`);
6863
+ if (await reachedDesired())
6864
+ return succeed();
6865
+ }
6866
+ }
6867
+ logger.warn(`checkbox state primitive: "${parsed.label.slice(0, 40)}" did not reach desired state, falling through`);
6868
+ return null;
6869
+ }
6870
+ catch (err) {
6871
+ logger.warn(`checkbox state primitive failed: ${(0, errors_1.toErrorMessage)(err)}`);
6872
+ return null;
6873
+ }
6874
+ finally {
6875
+ await target.evaluate(clearExpr).catch(() => undefined);
6876
+ }
6877
+ }
5769
6878
  /**
5770
6879
  * Site-agnostic checkbox primitive: answer a multi-select checkbox-group
5771
6880
  * question by directly checking the matching option in the DOM, bypassing
@@ -5800,7 +6909,7 @@ async function tryCheckboxPrimitive(params) {
5800
6909
  if (!parsed)
5801
6910
  return null;
5802
6911
  const { option, questionLabel } = parsed;
5803
- const optLabel = `option "${redactIfSensitive(option, redactValue).slice(0, 40)}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
6912
+ const optLabel = primitiveOptionLabel(option, questionLabel, redactValue);
5804
6913
  // Phase 1 (browser, no mutation): find checkbox GROUPS and their options.
5805
6914
  // A group is a `c-MultiCheckboxInput` container or a `<fieldset>` containing
5806
6915
  // checkboxes. Question label = the group's legend / associated label; each
@@ -5931,7 +7040,7 @@ async function tryCheckboxPrimitive(params) {
5931
7040
  })(${JSON.stringify(chosenGroup.gi)}, ${JSON.stringify(chosenOption.bi)})`;
5932
7041
  const applyResult = (await target.evaluate(applyExpr));
5933
7042
  if (applyResult?.ok) {
5934
- logger.info(`checkbox primitive: LLM checked "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
7043
+ logger.info(`checkbox primitive: LLM checked "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
5935
7044
  return applyResult.id;
5936
7045
  }
5937
7046
  logger.info(`checkbox primitive: LLM-chosen checkbox did not stick for ${optLabel}; falling through`);
@@ -6127,7 +7236,7 @@ async function tryRadioPrimitive(params) {
6127
7236
  if (!parsed)
6128
7237
  return null;
6129
7238
  const { option, questionLabel } = parsed;
6130
- const optLabel = `option "${redactIfSensitive(option, redactValue).slice(0, 40)}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
7239
+ const optLabel = primitiveOptionLabel(option, questionLabel, redactValue);
6131
7240
  // Phase 1 (browser): find radio GROUPS and their options. A group is a
6132
7241
  // `<fieldset>` / `[role=radiogroup]` / `[role=group]` / `[class*='RadioGroup']`
6133
7242
  // container with radios. Question label = the group's legend / associated label; each option
@@ -6212,7 +7321,7 @@ async function tryRadioPrimitive(params) {
6212
7321
  xpath: chosenOpt?.xpath ?? "",
6213
7322
  });
6214
7323
  if (applied) {
6215
- logger.info(`radio primitive: selected "${(chosenOpt?.text ?? "").trim().slice(0, 40)}" (${optLabel})`);
7324
+ logger.info(`radio primitive: selected "${(0, pii_redaction_1.maskValueShape)((chosenOpt?.text ?? "").trim())}" (${optLabel})`);
6216
7325
  return chosenOpt?.id ?? "";
6217
7326
  }
6218
7327
  logger.info(`radio primitive: chosen radio did not stick for ${optLabel}; falling through`);
@@ -6258,7 +7367,7 @@ async function tryRadioPrimitive(params) {
6258
7367
  }),
6259
7368
  };
6260
7369
  if (applyResult?.ok) {
6261
- logger.info(`radio primitive: LLM selected "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
7370
+ logger.info(`radio primitive: LLM selected "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
6262
7371
  return chosenOption.id ?? "";
6263
7372
  }
6264
7373
  logger.info(`radio primitive: LLM-chosen radio did not stick for ${optLabel}; falling through`);
@@ -6310,7 +7419,7 @@ async function applyRadioSelection(target, gi, ri, hint) {
6310
7419
  const inputSel = hint.id ? buildRadioIdXPath(hint.id) : hint.xpath ? `xpath=${hint.xpath}` : null;
6311
7420
  if (inputSel) {
6312
7421
  try {
6313
- await target.locator(inputSel).first().click();
7422
+ await (0, trusted_click_1.clickCentredTrusted)(target, inputSel, { label: "radio input" });
6314
7423
  if (await readback())
6315
7424
  return true;
6316
7425
  }
@@ -6321,10 +7430,9 @@ async function applyRadioSelection(target, gi, ri, hint) {
6321
7430
  // Tier B — trusted click on the associated label (MUI hides the real input).
6322
7431
  if (hint.id) {
6323
7432
  try {
6324
- await target
6325
- .locator(`xpath=//label[@for=${JSON.stringify(hint.id)}]`)
6326
- .first()
6327
- .click();
7433
+ await (0, trusted_click_1.clickCentredTrusted)(target, `xpath=//label[@for=${JSON.stringify(hint.id)}]`, {
7434
+ label: "radio label",
7435
+ });
6328
7436
  if (await readback())
6329
7437
  return true;
6330
7438
  }
@@ -6357,6 +7465,29 @@ async function applyRadioSelection(target, gi, ri, hint) {
6357
7465
  await target.evaluate(applyExpr).catch(() => ({ ok: false }));
6358
7466
  return await readback();
6359
7467
  }
7468
+ /**
7469
+ * Extracts the option a step wants committed into a prompt-selector widget,
7470
+ * with the question label that scopes which widget. Flow/replan generation
7471
+ * words this as a SELECT, a FILL of the widget's search box, a radio-style
7472
+ * answer click, or a cascading-multiselect category/leaf click, so all four
7473
+ * shapes resolve to one `{ option, questionLabel }` — shared by the picking
7474
+ * primitive and the act-success committed-value guard so both compare the page
7475
+ * against the same requested value.
7476
+ */
7477
+ function parsePromptSelectorStep(instruction) {
7478
+ const select = parseSelectStep(instruction);
7479
+ if (select)
7480
+ return { ...select, isFill: false };
7481
+ const fill = parseFillStep(instruction);
7482
+ if (fill) {
7483
+ return { option: fill.value, questionLabel: stripQuotedLabel(fill.fieldLabel), isFill: true };
7484
+ }
7485
+ const answer = parseRadioStep(instruction);
7486
+ if (answer)
7487
+ return { ...answer, isFill: false };
7488
+ const optionClick = parseWidgetOptionClickStep(instruction);
7489
+ return optionClick ? { option: optionClick.option, questionLabel: null, isFill: false } : null;
7490
+ }
6360
7491
  /**
6361
7492
  * Answer a native-control-less popup-dropdown widget (a combobox that opens a
6362
7493
  * listbox popup and renders NO `<select>`/`<input>` a focused `<select>`/
@@ -6428,23 +7559,11 @@ async function tryPromptSelectorPrimitive(params) {
6428
7559
  // shape), then parseWidgetOptionClickStep (option only, no question
6429
7560
  // label) so the widget-matching/open/readback phases below run unchanged
6430
7561
  // regardless of which verb the instruction used.
6431
- const parsedSelect = parseSelectStep(instruction);
6432
- const parsedFill = parsedSelect ? null : parseFillStep(instruction);
6433
- const parsedAnswer = parsedSelect || parsedFill ? null : parseRadioStep(instruction);
6434
- const parsedOptionClick = parsedSelect || parsedFill || parsedAnswer ? null : parseWidgetOptionClickStep(instruction);
6435
- const parsed = parsedSelect
6436
- ? parsedSelect
6437
- : parsedFill
6438
- ? { option: parsedFill.value, questionLabel: stripQuotedLabel(parsedFill.fieldLabel) }
6439
- : parsedAnswer
6440
- ? parsedAnswer
6441
- : parsedOptionClick
6442
- ? { option: parsedOptionClick.option, questionLabel: null }
6443
- : null;
7562
+ const parsed = parsePromptSelectorStep(instruction);
6444
7563
  if (!parsed)
6445
7564
  return null;
6446
7565
  const { option, questionLabel } = parsed;
6447
- const optLabel = `option "${redactIfSensitive(option, redactValue).slice(0, 40)}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
7566
+ const optLabel = primitiveOptionLabel(option, questionLabel, redactValue, parsed.isFill);
6448
7567
  const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
6449
7568
  // Phase 1 (browser, read-only except for the marker attribute stamped for
6450
7569
  // Phase 2's click addressing): find candidate widgets — popup-dropdown
@@ -6457,6 +7576,7 @@ async function tryPromptSelectorPrimitive(params) {
6457
7576
  const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
6458
7577
  const triggers = Array.from(document.querySelectorAll(triggerSel));
6459
7578
  if (triggers.length === 0) return { widgetPresent: false };
7579
+ const containerOf = ${PROMPT_WIDGET_CONTAINER_EXPR};
6460
7580
  const seen = new Set();
6461
7581
  const resolved = [];
6462
7582
  for (const el of triggers) {
@@ -6466,18 +7586,7 @@ async function tryPromptSelectorPrimitive(params) {
6466
7586
  // interactive ancestor, else the OUTERMOST widget-kit container on the
6467
7587
  // ancestor chain (so the inner input and its container collapse to the
6468
7588
  // same node), else the element itself.
6469
- const interactive = el.closest("button,[role='button'],[role='combobox']");
6470
- let container = interactive || el;
6471
- if (!interactive) {
6472
- const kitSel = "[data-uxi-widget-type='selectinput'],[data-uxi-widget-type='multiselect'],[data-automation-id='multiSelectContainer']";
6473
- let node = el.closest(kitSel);
6474
- while (node) {
6475
- container = node;
6476
- const up = node.parentElement && node.parentElement.closest(kitSel);
6477
- if (!up || up === node) break;
6478
- node = up;
6479
- }
6480
- }
7589
+ const container = containerOf(el);
6481
7590
  if (seen.has(container)) continue;
6482
7591
  seen.add(container);
6483
7592
  resolved.push(container);
@@ -6499,7 +7608,9 @@ async function tryPromptSelectorPrimitive(params) {
6499
7608
  if (parts.length) return parts.join(" ").replace(/\\s+/g, " ").trim().slice(0, 120);
6500
7609
  }
6501
7610
  const al = w.getAttribute("aria-label");
6502
- if (al) return al.replace(/\\s+/g, " ").trim().slice(0, 120);
7611
+ // A trigger's aria-label can merely echo its placeholder ("Select One
7612
+ // Required"), which names no question — defer to the <label>/group lookup.
7613
+ if (al && !emptyRx.test(al.replace(/\\s*required\\s*$/i, ""))) return al.replace(/\\s+/g, " ").trim().slice(0, 120);
6503
7614
  const labelledIds = [w.id, ...Array.from(w.querySelectorAll("[id]")).map((e) => e.id)].filter(Boolean);
6504
7615
  for (const id of labelledIds) {
6505
7616
  const lab = document.querySelector("label[for='" + (window.CSS && CSS.escape ? CSS.escape(id) : id) + "']");
@@ -6575,7 +7686,7 @@ async function tryPromptSelectorPrimitive(params) {
6575
7686
  logger.info(`prompt-selector primitive: no unambiguous widget match for ${optLabel}; falling through to cascade`);
6576
7687
  return null;
6577
7688
  }
6578
- const enumerateOptionsExpr = `((widgetMarkAttr, wIdx, markAttr, optionSel, searchSel) => {
7689
+ const enumerateOptionsExpr = `((widgetMarkAttr, wIdx, markAttr, optionSel, searchSel, triggerSel) => {
6579
7690
  const optionLabel = ${OPTION_ACCESSIBLE_LABEL_EXPR};
6580
7691
  const w = document.querySelector("[" + widgetMarkAttr + '="' + wIdx + '"]');
6581
7692
  if (!w) return { optionsPresent: false };
@@ -6593,12 +7704,35 @@ async function tryPromptSelectorPrimitive(params) {
6593
7704
  // inline subtree), so a sibling widget's options are never picked. Fall
6594
7705
  // back to document only when neither a portal nor an inline popup is found.
6595
7706
  const scope = ${PROMPT_SCOPE_ROOT_EXPR}(w);
6596
- const all = Array.from(scope.querySelectorAll(optionSel));
7707
+ // Under the document fallback, ownership is structural: content is foreign
7708
+ // when, climbing from it WITHOUT passing through this widget, some ancestor
7709
+ // is or holds another widget's trigger — e.g. a committed sibling's chips,
7710
+ // whatever markup wraps them. Resolved from the trigger union rather than
7711
+ // the enumeration mark, because a COMMITTED sibling is never marked (only
7712
+ // unfilled widgets are) yet its chips are the likeliest leak. An unlinked
7713
+ // portal sits under no widget, so it stays readable.
7714
+ const containerOf = ${PROMPT_WIDGET_CONTAINER_EXPR};
7715
+ const foreignRoots = scope === document
7716
+ ? Array.from(new Set(Array.from(document.querySelectorAll(triggerSel)).map(containerOf)))
7717
+ .filter((o) => o !== w && !o.contains(w) && !w.contains(o))
7718
+ : [];
7719
+ const heldByOtherWidget = (el) => {
7720
+ if (scope !== document || w.contains(el)) return false;
7721
+ for (let a = el; a && a !== document.body && !a.contains(w); a = a.parentElement) {
7722
+ if (a.matches(triggerSel) || a.querySelector(triggerSel)) return true;
7723
+ }
7724
+ return false;
7725
+ };
7726
+ const isOwned = (el) => !foreignRoots.some((o) => o.contains(el)) && !heldByOtherWidget(el);
7727
+ const all = Array.from(scope.querySelectorAll(optionSel)).filter(isOwned);
6597
7728
  // A union hit may be an ancestor of another (e.g. role=listbox > li > p):
6598
7729
  // keep only leaf-most option nodes with their own text so we don't double
6599
7730
  // count or address a wrapper.
6600
7731
  const opts = all.filter((el) => !all.some((o) => o !== el && el.contains(o)));
6601
- const searchInput = scope.querySelector(searchSel);
7732
+ const searchInput = scope === document
7733
+ ? (w.matches(searchSel) || w.matches("input") ? w : w.querySelector(searchSel))
7734
+ : (scope.matches && scope.matches(searchSel) ? scope : null)
7735
+ || Array.from(scope.querySelectorAll(searchSel)).find(isOwned) || null;
6602
7736
  // Mark the scoped filter input so the Node-side fill addresses THIS
6603
7737
  // widget's input (Playwright's locator can't re-run the scope resolution).
6604
7738
  if (searchInput) searchInput.setAttribute(markAttr + "-search", "1");
@@ -6606,13 +7740,23 @@ async function tryPromptSelectorPrimitive(params) {
6606
7740
  // is typed into — report the popup as present (with searchable=true) so the
6607
7741
  // caller types the filter first, rather than falling through as "no popup".
6608
7742
  if (opts.length === 0) {
6609
- return searchInput ? { optionsPresent: true, searchable: true, options: [] } : { optionsPresent: false };
7743
+ return searchInput ? { optionsPresent: true, searchable: true, options: [], scopeIsDocument: scope === document } : { optionsPresent: false };
6610
7744
  }
6611
7745
  const options = [];
6612
7746
  for (let i = 0; i < opts.length; i++) {
6613
- opts[i].setAttribute(markAttr, String(i));
6614
7747
  const label = optionLabel(opts[i]);
6615
- options.push({ oIdx: i, text: label });
7748
+ const ids = window.__bclOptionNodeIds || (window.__bclOptionNodeIds = { map: new WeakMap(), next: 0 });
7749
+ if (!ids.map.has(opts[i])) ids.map.set(opts[i], ids.next++);
7750
+ // Stamped with the node's own identity, not its list position: a
7751
+ // re-enumeration after a virtualized re-render or scroll can reorder
7752
+ // positions, so a positional stamp would re-address a different item.
7753
+ opts[i].setAttribute(markAttr, String(ids.map.get(opts[i])));
7754
+ options.push({
7755
+ oIdx: i,
7756
+ text: label,
7757
+ nodeId: ids.map.get(opts[i]),
7758
+ domText: (opts[i].textContent || "").replace(/\\s+/g, " ").trim(),
7759
+ });
6616
7760
  }
6617
7761
  // scopeIsDocument distinguishes a genuinely resolved (portal or inline)
6618
7762
  // popup from the document-wide last-resort fallback in
@@ -6622,7 +7766,7 @@ async function tryPromptSelectorPrimitive(params) {
6622
7766
  // that resolved to THIS widget's own inline subtree or its own
6623
7767
  // aria-controls/aria-owns target counts.
6624
7768
  return { optionsPresent: true, searchable: !!searchInput, options, scopeIsDocument: scope === document };
6625
- })(${JSON.stringify(PROMPT_WIDGET_MARK_ATTR)}, ${JSON.stringify(chosen.wIdx)}, ${JSON.stringify(PROMPT_OPTION_MARK_ATTR)}, ${JSON.stringify(PROMPT_OPTION_SELECTORS)}, ${JSON.stringify(PROMPT_SEARCH_SELECTORS)})`;
7769
+ })(${JSON.stringify(PROMPT_WIDGET_MARK_ATTR)}, ${JSON.stringify(chosen.wIdx)}, ${JSON.stringify(PROMPT_OPTION_MARK_ATTR)}, ${JSON.stringify(PROMPT_OPTION_SELECTORS)}, ${JSON.stringify(PROMPT_SEARCH_SELECTORS)}, ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)})`;
6626
7770
  // Pre-check (single evaluate, no polling): a PRIOR primitive call on this
6627
7771
  // same unreloaded page may have already opened/drilled this exact widget's
6628
7772
  // popup (a two-level cascading multiselect authored as two flow steps —
@@ -6634,6 +7778,12 @@ async function tryPromptSelectorPrimitive(params) {
6634
7778
  // an unrelated widget's stale/leftover popup elsewhere on the page as
6635
7779
  // "already open"); a genuinely closed popup falls through to the normal
6636
7780
  // open-click + poll path below unchanged.
7781
+ // A popup whose aria-controls link lands after the click is not yet
7782
+ // resolvable; a document-scoped read would credit a sibling widget's
7783
+ // content, so keep polling until a non-document scope resolves. The poll
7784
+ // returns the last read when the budget runs out, so a popup that
7785
+ // genuinely has no linkage still resolves through the document fallback.
7786
+ const isOwnScopePopup = (r) => r?.optionsPresent === true && r.scopeIsDocument !== true;
6637
7787
  const precheck = (await target.evaluate(enumerateOptionsExpr));
6638
7788
  const alreadyOpen = precheck?.optionsPresent === true &&
6639
7789
  (precheck.options?.length ?? 0) > 0 &&
@@ -6659,7 +7809,7 @@ async function tryPromptSelectorPrimitive(params) {
6659
7809
  // Fall back to the container selector.
6660
7810
  }
6661
7811
  try {
6662
- await target.locator(triggerSel).first().click();
7812
+ await (0, trusted_click_1.clickCentredTrusted)(target, triggerSel, { label: "prompt-selector trigger" });
6663
7813
  }
6664
7814
  catch (err) {
6665
7815
  logger.info(`prompt-selector primitive: trigger click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
@@ -6672,7 +7822,7 @@ async function tryPromptSelectorPrimitive(params) {
6672
7822
  }
6673
7823
  const optionsInitial = alreadyOpen
6674
7824
  ? precheck
6675
- : await pollEnumerate(page, target, enumerateOptionsExpr, (r) => r?.optionsPresent === true);
7825
+ : await pollEnumerate(page, target, enumerateOptionsExpr, isOwnScopePopup);
6676
7826
  if (!optionsInitial?.optionsPresent) {
6677
7827
  logger.info(`prompt-selector primitive: popup for ${optLabel} did not render options; falling through`);
6678
7828
  return null;
@@ -6703,19 +7853,61 @@ async function tryPromptSelectorPrimitive(params) {
6703
7853
  // to its popup), falling back to the union only if the mark is absent.
6704
7854
  const scopedSearchSel = `[${PROMPT_OPTION_MARK_ATTR}-search="1"]`;
6705
7855
  const marked = await target.locator(scopedSearchSel).count();
6706
- await target
6707
- .locator(marked > 0 ? scopedSearchSel : PROMPT_SEARCH_SELECTORS)
6708
- .first()
6709
- .fill(option);
7856
+ if (marked === 0) {
7857
+ logger.info(`prompt-selector primitive: no filter input owned by the widget for ${optLabel}; falling through`);
7858
+ return null;
7859
+ }
7860
+ await target.locator(scopedSearchSel).first().fill(option);
6710
7861
  }
6711
7862
  catch (err) {
6712
7863
  logger.info(`prompt-selector primitive: filter-input type failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
6713
7864
  return null;
6714
7865
  }
6715
7866
  await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
6716
- const optionsFiltered = await pollEnumerate(page, target, enumerateOptionsExpr, (r) => r?.optionsPresent === true);
7867
+ let optionsFiltered = await pollEnumerate(page, target, enumerateOptionsExpr, isOwnScopePopup);
7868
+ // An as-you-type search may still be in flight; Enter now would re-query with
7869
+ // an empty search and swap in the full list. Search-on-submit widgets never
7870
+ // name the option unprompted, so the wait is bounded.
7871
+ for (let wait = 0; wait < PROMPT_SELECTOR_MAX_SEARCH_WAITS &&
7872
+ optionsFiltered?.optionsPresent &&
7873
+ !promptOptionsNameOption(optionsFiltered, option); wait++) {
7874
+ await page.waitForTimeout(PROMPT_SELECTOR_SEARCH_WAIT_MS);
7875
+ optionsFiltered = (await target.evaluate(enumerateOptionsExpr));
7876
+ }
7877
+ // Search-on-submit widgets keep a placeholder (or unrelated chips) until an
7878
+ // Enter keydown; filter-as-you-type widgets already name the option here.
7879
+ // The list is settled BEFORE each submit decision, so a search-as-you-type
7880
+ // list still re-rendering (stale rows from the prior query) never reads as
7881
+ // "names nothing", and the precondition is re-read right before the key
7882
+ // press so a list that loaded in the meantime is not submitted at.
7883
+ for (let submit = 0;; submit++) {
7884
+ optionsFiltered = optionsFiltered?.optionsPresent
7885
+ ? await readStablePromptOptions(page, target, enumerateOptionsExpr, optionsFiltered)
7886
+ : null;
7887
+ if (!optionsFiltered ||
7888
+ promptOptionsNameOption(optionsFiltered, option) ||
7889
+ submit >= PROMPT_SELECTOR_MAX_ENTER_SUBMITS) {
7890
+ break;
7891
+ }
7892
+ const live = (await target.evaluate(enumerateOptionsExpr));
7893
+ if (promptOptionsNameOption(live, option)) {
7894
+ optionsFiltered = live;
7895
+ continue;
7896
+ }
7897
+ logger.info(`prompt-selector primitive: filtered list for ${optLabel} names no matching option; submitting with Enter`);
7898
+ try {
7899
+ await target.evaluate(`(() => { const el = document.querySelector('[${PROMPT_OPTION_MARK_ATTR}-search="1"]'); if (el) el.focus(); })()`);
7900
+ await sendTrustedEnter(page);
7901
+ }
7902
+ catch (err) {
7903
+ logger.info(`prompt-selector primitive: enter submit failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}`);
7904
+ break;
7905
+ }
7906
+ await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
7907
+ optionsFiltered = await pollEnumerate(page, target, enumerateOptionsExpr, (r) => isOwnScopePopup(r) && promptOptionsNameOption(r, option));
7908
+ }
6717
7909
  if (!optionsFiltered?.optionsPresent) {
6718
- logger.info(`prompt-selector primitive: filtered popup for ${optLabel} rendered no options; falling through`);
7910
+ logger.info(`prompt-selector primitive: filtered popup for ${optLabel} rendered no stable options; falling through`);
6719
7911
  return null;
6720
7912
  }
6721
7913
  return await commitPromptOption({
@@ -6737,6 +7929,138 @@ async function tryPromptSelectorPrimitive(params) {
6737
7929
  return null;
6738
7930
  }
6739
7931
  }
7932
+ /** Cap on re-reads while waiting for the filtered list to stop changing. */
7933
+ const PROMPT_SELECTOR_MAX_STABLE_READS = 5;
7934
+ /**
7935
+ * Re-reads the filtered list until two consecutive reads carry identical
7936
+ * ordered option text AND node identity, so a virtualized list that reuses
7937
+ * nodes is never treated as settled. The last read is returned because each
7938
+ * read re-marks the live nodes; an earlier read's marks may point at detached
7939
+ * elements. Returns null when the list is still changing at the cap, so the
7940
+ * committer is never handed an unsettled list.
7941
+ */
7942
+ async function readStablePromptOptions(page, target, enumerateOptionsExpr, initial) {
7943
+ const key = (r) => JSON.stringify((r?.options ?? []).map((o) => [o.text, o.nodeId ?? null]));
7944
+ let current = initial;
7945
+ for (let read = 0; read < PROMPT_SELECTOR_MAX_STABLE_READS; read++) {
7946
+ await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
7947
+ const next = (await target.evaluate(enumerateOptionsExpr));
7948
+ const stable = key(next) === key(current);
7949
+ current = next;
7950
+ if (stable)
7951
+ return current;
7952
+ }
7953
+ logger.info("prompt-selector primitive: filtered list still changing after stable-read cap");
7954
+ return null;
7955
+ }
7956
+ /** Cap on click-time re-resolves when the stamped node is re-bound mid-scroll. */
7957
+ const PROMPT_SELECTOR_MAX_CLICK_RESOLVES = 3;
7958
+ /** Bounded wait for an in-flight as-you-type search before concluding it is search-on-submit. */
7959
+ const PROMPT_SELECTOR_MAX_SEARCH_WAITS = 6;
7960
+ const PROMPT_SELECTOR_SEARCH_WAIT_MS = 500;
7961
+ /** Cap on trusted Enter submits for a search-on-submit prompt selector. */
7962
+ const PROMPT_SELECTOR_MAX_ENTER_SUBMITS = 2;
7963
+ /** Shared option-text normalisation so submit detection and commit agree on a match. */
7964
+ function normPromptOptionText(s) {
7965
+ return s.replace(/\s+/g, " ").trim().toLowerCase();
7966
+ }
7967
+ /** Entries a widget renders when it holds no results (e.g. "No Items.", "No results for x"). */
7968
+ const PROMPT_PLACEHOLDER_RE = /^(no\b|nothing\b|none$|loading|searching|type to search|press enter|hit enter|search for|\.\.\.)|\b(no (results?|items?|matches|options?|data|records)|not found|nothing found|0 results?)\b/;
7969
+ /**
7970
+ * Detects whether the list already names the requested option, so the submit
7971
+ * decision never fires Enter at a widget that searches as you type. Looser than
7972
+ * commitPromptOption's exact detMatch on purpose: a rendered label that differs
7973
+ * by a prefix or suffix still means the list answered the query. A shorter
7974
+ * entry the request merely contains (a chip "Oslo" for "University of Oslo")
7975
+ * names nothing requested. Placeholder entries never count, even when they
7976
+ * echo the requested text.
7977
+ */
7978
+ function promptOptionsNameOption(result, option) {
7979
+ const want = normPromptOptionText(option);
7980
+ if (want === "")
7981
+ return true;
7982
+ return (result?.options ?? []).some((o) => {
7983
+ const text = normPromptOptionText(o.text);
7984
+ if (text === "")
7985
+ return false;
7986
+ if (text === want)
7987
+ return true;
7988
+ if (PROMPT_PLACEHOLDER_RE.test(text))
7989
+ return false;
7990
+ return text.includes(want);
7991
+ });
7992
+ }
7993
+ /** Dispatches a trusted Enter to the focused element (the filter input) via CDP. */
7994
+ async function sendTrustedEnter(page) {
7995
+ const key = { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 };
7996
+ await page.sendCDP("Input.dispatchKeyEvent", { type: "keyDown", text: "\r", ...key });
7997
+ await page.sendCDP("Input.dispatchKeyEvent", { type: "keyUp", ...key });
7998
+ }
7999
+ /** Dispatches a trusted Escape to the focused element via CDP, closing a popup left open. */
8000
+ async function sendTrustedEscape(page) {
8001
+ const key = {
8002
+ key: "Escape",
8003
+ code: "Escape",
8004
+ windowsVirtualKeyCode: 27,
8005
+ nativeVirtualKeyCode: 27,
8006
+ };
8007
+ await page.sendCDP("Input.dispatchKeyEvent", { type: "keyDown", ...key });
8008
+ await page.sendCDP("Input.dispatchKeyEvent", { type: "keyUp", ...key });
8009
+ }
8010
+ /**
8011
+ * Counts rendered listbox / activeListContainer popups other than the chip
8012
+ * list; an emptied ("No Items.") popup still counts because it paints over
8013
+ * neighbouring fields just the same.
8014
+ */
8015
+ const VISIBLE_LEFTOVER_POPUP_COUNT_EXPR = `(() => {
8016
+ const visible = (el) => {
8017
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
8018
+ if (n.hidden || n.getAttribute("aria-hidden") === "true") return false;
8019
+ let cs = null;
8020
+ try { cs = getComputedStyle(n); } catch { cs = null; }
8021
+ if (cs && (cs.display === "none" || cs.visibility === "hidden")) return false;
8022
+ }
8023
+ return true;
8024
+ };
8025
+ const sel = '[role="listbox"], [data-automation-id="activeListContainer"]';
8026
+ return Array.from(document.querySelectorAll(sel)).filter(
8027
+ (el) => !el.matches('[data-automation-id="selectedItemList"]') && !el.closest('[data-automation-id="selectedItemList"]') && visible(el)
8028
+ ).length;
8029
+ })()`;
8030
+ /**
8031
+ * Synthetic Escape for a page whose CDP channel is unavailable or whose popup
8032
+ * ignored the trusted one: the popup's own keydown handler is what closes it.
8033
+ */
8034
+ const IN_PAGE_ESCAPE_EXPR = `(() => {
8035
+ const el = document.activeElement || document.body;
8036
+ for (const type of ["keydown", "keyup"]) {
8037
+ el.dispatchEvent(new KeyboardEvent(type, { key: "Escape", code: "Escape", keyCode: 27, bubbles: true, cancelable: true }));
8038
+ }
8039
+ })()`;
8040
+ /**
8041
+ * Closes any popup still painted after a verified commit so it cannot
8042
+ * intercept the next field's click. Best-effort: a trusted Escape first, then
8043
+ * a synthetic one when the trusted dispatch failed or left the popup painted.
8044
+ */
8045
+ async function dismissLeftoverPromptPopups(page, target) {
8046
+ for (let attempt = 0; attempt < 2; attempt++) {
8047
+ const count = (await target
8048
+ .evaluate(VISIBLE_LEFTOVER_POPUP_COUNT_EXPR)
8049
+ .catch(() => 0));
8050
+ if (!count)
8051
+ return;
8052
+ logger.info(`prompt-selector primitive: dismissing ${count} leftover popup(s) via Escape`);
8053
+ const trusted = await sendTrustedEscape(page)
8054
+ .then(() => true)
8055
+ .catch((err) => {
8056
+ logger.info(`prompt-selector primitive: Escape dispatch failed: ${(0, errors_1.toErrorMessage)(err)}`);
8057
+ return false;
8058
+ });
8059
+ if (!trusted || attempt > 0)
8060
+ await target.evaluate(IN_PAGE_ESCAPE_EXPR).catch(() => undefined);
8061
+ await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS).catch(() => undefined);
8062
+ }
8063
+ }
6740
8064
  /**
6741
8065
  * Phase 3 of {@link tryPromptSelectorPrimitive}: given the popup's rendered
6742
8066
  * (possibly filtered) option list, match the requested option — deterministic
@@ -6759,7 +8083,7 @@ async function tryPromptSelectorPrimitive(params) {
6759
8083
  */
6760
8084
  async function commitPromptOption(params) {
6761
8085
  const { page, target, logger, anthropic, captureFn, optLabel, option, questionLabel, chosen, enumerateOptionsExpr, } = params;
6762
- const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
8086
+ const norm = normPromptOptionText;
6763
8087
  const wantOpt = norm(option);
6764
8088
  const optionSetKey = (opts) => opts
6765
8089
  .map((o) => norm(o.text))
@@ -6796,35 +8120,69 @@ async function commitPromptOption(params) {
6796
8120
  return null;
6797
8121
  }
6798
8122
  const matchReason = detMatch ? "deterministic" : `LLM: ${(verdict?.reason ?? "").slice(0, 60)}`;
6799
- const optionSel = `[${PROMPT_OPTION_MARK_ATTR}="${chosenOption.oIdx}"]`;
6800
- try {
6801
- await target.locator(optionSel).first().click();
8123
+ // A virtualized list may reuse nodes between the read and the click, so the
8124
+ // stamped index can point at a different item. Each attempt re-stamps and
8125
+ // re-resolves the matched option by text, and the click itself asserts the
8126
+ // node still carries that item's text after the centring scroll; a mismatch
8127
+ // re-resolves instead of clicking.
8128
+ const wantChosenText = norm(chosenOption.text);
8129
+ let clicked = false;
8130
+ for (let attempt = 0; attempt < PROMPT_SELECTOR_MAX_CLICK_RESOLVES && !clicked; attempt++) {
8131
+ const fresh = (await target.evaluate(enumerateOptionsExpr).catch(() => null));
8132
+ const freshMatches = (fresh?.options ?? []).filter((o) => norm(o.text) === wantChosenText);
8133
+ const freshOption = freshMatches.find((o) => o.nodeId !== undefined && o.nodeId === chosenOption.nodeId) ??
8134
+ freshMatches[0];
8135
+ if (!freshOption) {
8136
+ logger.info(`prompt-selector primitive: matched option no longer present for ${optLabel}; falling through`);
8137
+ return null;
8138
+ }
8139
+ const optionSel = `[${PROMPT_OPTION_MARK_ATTR}="${freshOption.nodeId ?? freshOption.oIdx}"]`;
8140
+ const markedLabel = (await target
8141
+ .evaluate(`((sel, labelOf) => {
8142
+ const el = document.querySelector(sel);
8143
+ return el ? labelOf(el) : null;
8144
+ })(${JSON.stringify(optionSel)}, ${OPTION_ACCESSIBLE_LABEL_EXPR})`)
8145
+ .catch(() => null));
8146
+ if (markedLabel === null || !norm(markedLabel).includes(wantChosenText)) {
8147
+ logger.info(`prompt-selector primitive: marked option node for ${optLabel} no longer shows the matched option; re-resolving`);
8148
+ continue;
8149
+ }
8150
+ try {
8151
+ const expectedDomText = freshOption.domText ?? wantChosenText;
8152
+ await (0, trusted_click_1.clickCentredTrusted)(target, optionSel, {
8153
+ label: "prompt-selector option",
8154
+ expectText: expectedDomText,
8155
+ verifyBeforeClick: async () => (await target
8156
+ .evaluate(`((sel, want) => {
8157
+ const el = document.querySelector(sel);
8158
+ if (!el || !el.isConnected) return false;
8159
+ return (el.textContent || "").replace(/\\s+/g, " ").trim() === want;
8160
+ })(${JSON.stringify(optionSel)}, ${JSON.stringify(expectedDomText)})`)
8161
+ .catch(() => null)) !== false,
8162
+ });
8163
+ clicked = true;
8164
+ }
8165
+ catch (err) {
8166
+ const mismatch = (0, errors_1.toErrorMessage)(err).includes("identity mismatch");
8167
+ logger.info(`prompt-selector primitive: option click ${mismatch ? "withheld" : "failed"} for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; ${mismatch ? "re-resolving" : "falling through"}`);
8168
+ if (!mismatch)
8169
+ return null;
8170
+ }
6802
8171
  }
6803
- catch (err) {
6804
- logger.info(`prompt-selector primitive: option click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
8172
+ if (!clicked)
6805
8173
  return null;
6806
- }
6807
8174
  await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
6808
- const isRequestedLeaf = norm(chosenOption.text) === wantOpt;
8175
+ const isRequestedOption = norm(chosenOption.text) === wantOpt;
6809
8176
  const readbackExpr = `((wIdx, markAttr, valueSel, emptyRxSrc, emptyRxFlags, wantText, isRequestedLeaf) => {
6810
8177
  const isInvalid = ${INVALID_MARKER_EL_EXPR};
6811
8178
  const isPairedHiddenSelect = ${exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR};
6812
8179
  const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
6813
- const buttonValue = ${BUTTON_VALUE_EXPR};
6814
- const optionLabel = ${OPTION_ACCESSIBLE_LABEL_EXPR};
6815
8180
  const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
8181
+ const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
8182
+ const announcedValue = ${PROMPT_ANNOUNCED_VALUE_EXPR};
6816
8183
  const w = document.querySelector("[" + markAttr + '="' + wIdx + '"]');
6817
- if (!w) return { ok: false, id: "" };
6818
- // Value via the union: aria-activedescendant, own input value, a <button>'s
6819
- // own value text (popup-pollution-safe, see BUTTON_VALUE_EXPR), or a
6820
- // selection-label node (empty-state phrase treated as no value).
6821
- const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
6822
- const adText = adid && document.getElementById(adid) ? norm(optionLabel(document.getElementById(adid))) : "";
6823
- const own = w.tagName === "INPUT" ? norm(w.value || "") : w.tagName === "BUTTON" ? norm(buttonValue(w)) : "";
6824
- const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
6825
- const lblRaw = lbl ? (lbl.textContent || "") : "";
6826
- const lblText = lblRaw.trim() && !emptyRx.test(lblRaw.trim()) ? norm(lblRaw) : "";
6827
- const text = adText || own || lblText;
8184
+ if (!w) return { ok: false, id: "", stillInvalid: false };
8185
+ const text = currentText(w, valueSel, emptyRx);
6828
8186
  const textMatches = wantText ? text.includes(wantText) : text !== "";
6829
8187
  let node = w;
6830
8188
  let stillInvalid = false;
@@ -6877,14 +8235,37 @@ async function commitPromptOption(params) {
6877
8235
  cnode = cnode.parentElement;
6878
8236
  }
6879
8237
  }
6880
- // A category click may leave the widget's aria-invalid unset until final
6881
- // submit, so !stillInvalid alone only counts as success when this click
6882
- // landed on the requested leaf option, not an intermediate category.
6883
- return { ok: textMatches || (isRequestedLeaf && !stillInvalid) || hiddenSelectMatches, id: w.id || "" };
6884
- })(${JSON.stringify(chosen.wIdx)}, ${JSON.stringify(PROMPT_WIDGET_MARK_ATTR)}, ${JSON.stringify(PROMPT_VALUE_SELECTORS)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)}, ${JSON.stringify(norm(chosenOption.text))}, ${JSON.stringify(isRequestedLeaf)})`;
6885
- const readback = (await target.evaluate(readbackExpr).catch(() => ({ ok: false, id: "" })));
8238
+ // The widget's own announcement of what it committed is ground truth when readable.
8239
+ const committed = announcedValue(w);
8240
+ if (committed) {
8241
+ return { ok: !!wantText && committed.includes(wantText), id: w.id || "", stillInvalid, committed };
8242
+ }
8243
+ // A wrong option leaves the widget valid, so !stillInvalid proves nothing
8244
+ // once the widget's own value is readable: a readable value naming another
8245
+ // option is a wrong commit. The "Expanded"/"Collapsed" state words are
8246
+ // announcements, not values. Only a requested-leaf click is refused here —
8247
+ // a category click that swapped the popup must still reach the drill check.
8248
+ const stateOnly = /^(?:expanded|collapsed)\\.?$/.test(text);
8249
+ if (isRequestedLeaf && text !== "" && !stateOnly && !textMatches && !hiddenSelectMatches) {
8250
+ return { ok: false, id: w.id || "", stillInvalid, committed: text };
8251
+ }
8252
+ return { ok: textMatches || hiddenSelectMatches, id: w.id || "", stillInvalid };
8253
+ })(${JSON.stringify(chosen.wIdx)}, ${JSON.stringify(PROMPT_WIDGET_MARK_ATTR)}, ${JSON.stringify(PROMPT_VALUE_SELECTORS)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)}, ${JSON.stringify(norm(chosenOption.text))}, ${JSON.stringify(isRequestedOption)})`;
8254
+ const runReadback = async () => (await target
8255
+ .evaluate(readbackExpr)
8256
+ .catch(() => ({ ok: false, id: "", stillInvalid: true })));
8257
+ let readback = await runReadback();
8258
+ if (readback?.ok) {
8259
+ await dismissLeftoverPromptPopups(page, target);
8260
+ readback = await runReadback();
8261
+ }
8262
+ if (!readback?.ok && readback?.committed) {
8263
+ await dismissLeftoverPromptPopups(page, target);
8264
+ logger.info(`prompt-selector primitive: committed "${(0, pii_redaction_1.maskValueShape)(readback.committed)}" instead of "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel}; falling through`);
8265
+ return null;
8266
+ }
6886
8267
  if (readback?.ok) {
6887
- logger.info(`prompt-selector primitive: selected "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${matchReason})`);
8268
+ logger.info(`prompt-selector primitive: selected "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} (${matchReason})`);
6888
8269
  return readback.id;
6889
8270
  }
6890
8271
  if (depth === PROMPT_SELECTOR_MAX_DRILL_DEPTH) {
@@ -6896,12 +8277,20 @@ async function commitPromptOption(params) {
6896
8277
  const reEnumerated = (await target
6897
8278
  .evaluate(enumerateOptionsExpr)
6898
8279
  .catch(() => ({ optionsPresent: false })));
6899
- const newOptions = reEnumerated?.optionsPresent ? (reEnumerated.options ?? []) : [];
8280
+ const newOptions = reEnumerated?.optionsPresent && reEnumerated.scopeIsDocument !== true
8281
+ ? (reEnumerated.options ?? [])
8282
+ : [];
6900
8283
  const drilled = newOptions.length > 0 && optionSetKey(newOptions) !== optionSetKey(options);
6901
8284
  if (!drilled) {
6902
8285
  logger.info(`prompt-selector primitive: selection for ${optLabel} did not commit; falling through to cascade`);
6903
8286
  return null;
6904
8287
  }
8288
+ // Category click on the requested option: the drill itself is the observed
8289
+ // effect, and it never commits a value, so only a widget not flagged invalid counts.
8290
+ if (isRequestedOption && !readback.stillInvalid) {
8291
+ logger.info(`prompt-selector primitive: requested option "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} opened a new option set (${matchReason})`);
8292
+ return readback?.id ?? "";
8293
+ }
6905
8294
  logger.info(`prompt-selector primitive: click for ${optLabel} drilled the popup to a new option set; re-matching at the new level`);
6906
8295
  optionsResult = { options: newOptions };
6907
8296
  }
@@ -6928,8 +8317,10 @@ async function hasUnfilledRequiredControlForStep(target, instruction) {
6928
8317
  const label = extractRequiredControlProbeLabel(instruction);
6929
8318
  if (!label)
6930
8319
  return false;
6931
- const expr = `((label, triggerSel) => {
8320
+ const expr = `((label, triggerSel, valueSel, emptyRxSrc, emptyRxFlags) => {
6932
8321
  const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
8322
+ const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
8323
+ const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
6933
8324
  const want = norm(label);
6934
8325
  if (!want) return false;
6935
8326
  const isInvalid = ${INVALID_MARKER_EL_EXPR};
@@ -6948,6 +8339,7 @@ async function hasUnfilledRequiredControlForStep(target, instruction) {
6948
8339
  if (!el) return false;
6949
8340
  if (el.getAttribute && el.getAttribute("aria-invalid") === "true") return true;
6950
8341
  if (isInvalid(el)) return true;
8342
+ if (el.matches && el.matches(triggerSel)) return currentText(el, valueSel, emptyRx) === "";
6951
8343
  const v = ("value" in el) ? el.value : "";
6952
8344
  return !v || String(v).trim() === "";
6953
8345
  };
@@ -6998,7 +8390,7 @@ async function hasUnfilledRequiredControlForStep(target, instruction) {
6998
8390
  if (txt && (txt.includes(want) || want.includes(txt))) return true;
6999
8391
  }
7000
8392
  return false;
7001
- })(${JSON.stringify(label)}, ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)})`;
8393
+ })(${JSON.stringify(label)}, ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)}, ${JSON.stringify(PROMPT_VALUE_SELECTORS)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)})`;
7002
8394
  try {
7003
8395
  return (await target.evaluate(expr)) === true;
7004
8396
  }
@@ -7028,24 +8420,7 @@ async function simulateDragDropUpload(target, fixture, logger) {
7028
8420
  const fileName = ${JSON.stringify(fixture.name)};
7029
8421
  const fileType = ${JSON.stringify(fixture.mimeType)};
7030
8422
  const base64 = ${JSON.stringify(base64)};
7031
- const dropZoneSelectors = [
7032
- "uapp-upload",
7033
- "app-upload",
7034
- "uapp-resume-upload",
7035
- "[class*='dropzone']",
7036
- "[class*='drop-zone']",
7037
- "[class*='file-drop']",
7038
- "[class*='upload-zone']",
7039
- "[class*='uapp-upload']",
7040
- "[class*='ResumeUpload']",
7041
- "[class*='resumeUpload']",
7042
- "[class*='resume-upload']",
7043
- ];
7044
- let dropZone = null;
7045
- for (const sel of dropZoneSelectors) {
7046
- const el = document.querySelector(sel);
7047
- if (el) { dropZone = el; break; }
7048
- }
8423
+ const dropZone = ${upload_affordance_expr_1.FIND_DROP_ZONE_EXPR}(document);
7049
8424
  if (!dropZone) return { ok: false, reason: "no drop zone found" };
7050
8425
  try {
7051
8426
  const binStr = atob(base64);
@@ -7160,6 +8535,12 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
7160
8535
  const expected = action.arguments?.[0];
7161
8536
  if (typeof expected !== "string" || expected.length === 0)
7162
8537
  return false;
8538
+ // A lone spinbutton segment holds one slice of a date, so a matching
8539
+ // digit string proves nothing about the whole value, and the
8540
+ // fill("")+type retype below would overwrite committed neighbours.
8541
+ if ((await verifyFillReadback(target, selector, expected))?.outcome === "segment") {
8542
+ return false;
8543
+ }
7163
8544
  const current = await locator.inputValue();
7164
8545
  // Masked/formatted inputs (e.g. a phone field displaying
7165
8546
  // "(212) 555-0123" after typing "2125550123") reformat the typed
@@ -7398,6 +8779,13 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
7398
8779
  // owned panel to now render option content before crediting it.
7399
8780
  return await comboboxOpenerPanelOpened(target, xpath, preSelectionState);
7400
8781
  }
8782
+ // A control exposing aria-checked is framework-managed: its own
8783
+ // aria-checked is the committed state, so a leftover invalid marker
8784
+ // on an ancestor must not veto a genuinely ticked box.
8785
+ const ariaCheckedExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); const el = r.singleNodeValue; return el ? ${ariaCheckedReadExpr("el")} : null; })()`;
8786
+ const ariaChecked = await target.evaluate(ariaCheckedExpr).catch(() => null);
8787
+ if (ariaChecked === "true" || ariaChecked === "false")
8788
+ return ariaChecked === "true";
7401
8789
  const isCheckedNow = await locator.isChecked();
7402
8790
  if (!isCheckedNow)
7403
8791
  return false;
@@ -8017,20 +9405,32 @@ function extractCodeFromMessage(msg, pattern) {
8017
9405
  * know the value came from an inbox rather than the flow's payload.
8018
9406
  */
8019
9407
  function spliceEmailStepFillValue(step, code) {
8020
- return /with\s+'[^']*'/i.test(step)
8021
- ? step.replace(/with\s+'[^']*'/i, `with '${code}'`)
9408
+ const match = (0, quoted_spans_1.matchAnchored)(step, /with\s+'[^']*'/i);
9409
+ return match
9410
+ ? `${step.slice(0, match.start)}with '${code}'${step.slice(match.end)}`
8022
9411
  : `${step} with '${code}'`;
8023
9412
  }
8024
9413
  /**
8025
- * Masks `text` when it equals `sensitiveValue` (the emailStep-extracted code
8026
- * spliced into the step's fill value, see {@link spliceEmailStepFillValue}).
8027
- * The select/checkbox/radio/prompt-selector primitives all re-parse the
8028
- * (possibly code-bearing) instruction independently of the fill primitive,
8029
- * so each one's own "option"/diagnostic logging must redact it too — the
8030
- * code is a single-use credential and must never reach the logs verbatim.
9414
+ * Masks a typed/selected value for logs and attempt diagnostics. Values are
9415
+ * caller-supplied answers (or the emailStep-extracted single-use code spliced into
9416
+ * the step, see {@link spliceEmailStepFillValue}), so only their shape is
9417
+ * kept — enough to debug format mismatches, never the value itself.
8031
9418
  */
8032
- function redactIfSensitive(text, sensitiveValue) {
8033
- return sensitiveValue && text === sensitiveValue ? "[redacted]" : text;
9419
+ function maskLoggedValue(text, sensitiveValue) {
9420
+ return sensitiveValue && text === sensitiveValue ? "[redacted]" : (0, pii_redaction_1.maskValueShape)(text);
9421
+ }
9422
+ /**
9423
+ * Builds the "option ..., question ..." log label shared by the select,
9424
+ * checkbox, radio and prompt-selector primitives. A value parsed from a FILL
9425
+ * step is typed user data (names, emails, passwords), so it is logged only as
9426
+ * its length; other options are still caller answers, so they keep only
9427
+ * their shape via {@link maskLoggedValue}.
9428
+ */
9429
+ function primitiveOptionLabel(option, questionLabel, redactValue, fromFill = false) {
9430
+ const shown = fromFill
9431
+ ? `[${option.length}-char typed value]`
9432
+ : maskLoggedValue(option, redactValue).slice(0, 40);
9433
+ return `option "${shown}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
8034
9434
  }
8035
9435
  /**
8036
9436
  * Last-resort escalation for the cascade-exhaustion path: when every
@@ -8064,7 +9464,7 @@ function redactIfSensitive(text, sensitiveValue) {
8064
9464
  * candidate was actually clickable, or every click phantomed.
8065
9465
  */
8066
9466
  async function probeChildFrameSubmitFallback(params) {
8067
- const { page, signalCounter, step } = params;
9467
+ const { page, signalCounter, step, isMidFlowSubmitStep } = params;
8068
9468
  // `page.mainFrameId`/`page.frames` are read defensively: some call sites
8069
9469
  // (and their test fakes) model a `Page` that never attaches child frames at
8070
9470
  // all and doesn't implement this pair, which is indistinguishable here from
@@ -8117,13 +9517,18 @@ async function probeChildFrameSubmitFallback(params) {
8117
9517
  const clickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex))).catch(() => ({ clicked: false })));
8118
9518
  if (!clickResult.clicked)
8119
9519
  continue;
9520
+ await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, true, async () => {
9521
+ const snap = await snapshotPage(target, signalCounter);
9522
+ return snap.visibleTextSignature;
9523
+ }, pre.visibleTextSignature);
8120
9524
  const post = await snapshotPage(target, signalCounter, page);
8121
9525
  const verdict = (0, phantom_click_1.classifyPhantomClick)({
8122
9526
  actResultSuccess: true,
8123
9527
  pre,
8124
9528
  post,
8125
9529
  isSubmitShapedStep: true,
8126
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, post.url),
9530
+ isMidFlowSubmitStep,
9531
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, isMidFlowSubmitStep ? pre.url : undefined),
8127
9532
  });
8128
9533
  if (verdict !== "phantom")
8129
9534
  return target;
@@ -8133,21 +9538,52 @@ async function probeChildFrameSubmitFallback(params) {
8133
9538
  const runnerUpClickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))).catch(() => ({ clicked: false })));
8134
9539
  if (!runnerUpClickResult.clicked)
8135
9540
  continue;
9541
+ await (0, click_settle_1.settleAfterClick)(page, signalCounter, post.networkCount, true, async () => {
9542
+ const snap = await snapshotPage(target, signalCounter);
9543
+ return snap.visibleTextSignature;
9544
+ }, post.visibleTextSignature);
8136
9545
  const runnerUpPost = await snapshotPage(target, signalCounter, page);
8137
9546
  const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
8138
9547
  actResultSuccess: true,
8139
9548
  pre,
8140
9549
  post: runnerUpPost,
8141
9550
  isSubmitShapedStep: true,
8142
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, runnerUpPost.url),
9551
+ isMidFlowSubmitStep,
9552
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, runnerUpPost.url, isMidFlowSubmitStep ? post.url : undefined),
8143
9553
  });
8144
9554
  if (runnerUpVerdict !== "phantom")
8145
9555
  return target;
8146
9556
  }
8147
9557
  return null;
8148
9558
  }
9559
+ /**
9560
+ * Rejects with the signal's reason as soon as it aborts, so an in-step await
9561
+ * on a torn-down session stops instead of waiting out its own timeout.
9562
+ * Passes the promise through untouched when no signal is supplied.
9563
+ */
9564
+ async function raceAbortSignal(work, signal) {
9565
+ if (!signal)
9566
+ return work;
9567
+ if (signal.aborted) {
9568
+ work.catch(() => undefined);
9569
+ throw signal.reason;
9570
+ }
9571
+ let onAbort;
9572
+ const aborted = new Promise((_, reject) => {
9573
+ onAbort = () => reject(signal.reason);
9574
+ signal.addEventListener("abort", onAbort, { once: true });
9575
+ });
9576
+ try {
9577
+ return await Promise.race([work, aborted]);
9578
+ }
9579
+ finally {
9580
+ if (onAbort)
9581
+ signal.removeEventListener("abort", onAbort);
9582
+ work.catch(() => undefined);
9583
+ }
9584
+ }
8149
9585
  async function executeStepWithHealing(params) {
8150
- const { stagehand, page, optional, upload, submitStep, captchaGated = false, emailStep = false, emailStepConfig, allocatedInbox = null, sessionProxy = null, flowHasSubmitSemantics: flowHasSubmitSemanticsFlag, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, rephraseModel, logger, captureFn, uploadFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, onStepHeal, } = params;
9586
+ const { stagehand, optional, upload, submitStep, captchaGated = false, emailStep = false, emailStepConfig, allocatedInbox = null, sessionProxy = null, flowHasSubmitSemantics: flowHasSubmitSemanticsFlag, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, rephraseModel, logger, captureFn, uploadFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, onStepHeal, } = params;
8151
9587
  // Mutable: the `emailStep` code-extract path splices the extracted code
8152
9588
  // into this instruction (see the emailStep hook block below) so the
8153
9589
  // pre-existing fill cascade downstream picks it up as the value to type
@@ -8164,6 +9600,9 @@ async function executeStepWithHealing(params) {
8164
9600
  // function keeps reading `frameTarget` unchanged; only its declaration
8165
9601
  // moved so it can be reassigned.
8166
9602
  let frameTarget = params.frameTarget;
9603
+ // Rebound when a click opens a new browsing context, so every later probe,
9604
+ // verifier and key event in the cascade reads the page the click navigated to.
9605
+ let page = params.page;
8167
9606
  let frameReresolveAttempted = false;
8168
9607
  // Emailed-verification hook. Runs FIRST — before upload/select/checkbox/
8169
9608
  // radio/prompt-selector/captcha or any other cascade primitive — so an
@@ -8321,8 +9760,11 @@ async function executeStepWithHealing(params) {
8321
9760
  // Submit, and unfilled required fields produce silent submit failures
8322
9761
  // (verified 2026-06-15 on one measured tenant's telemetry). Site-agnostic: any flow
8323
9762
  // whose submit is mid-list can mark its submit step explicitly.
8324
- const stepDeclaredNonSubmit = !submitStep && classifyStepSubmitIntent(step) === "non-submit";
8325
- const requireSubmitEndpoint = (isFinalStep || submitStep) && !stepDeclaredNonSubmit && submitEndpointPattern !== null;
9763
+ const stepDeclaredNonSubmit = isStepDeclaredNonSubmit({ step, submitStep });
9764
+ // Single final-step inference every submit-strictness gate below reads, so a
9765
+ // declared non-submit veto cannot be honoured at one site and missed at another.
9766
+ const inferredFinalSubmitStep = isFinalStep && !stepDeclaredNonSubmit;
9767
+ const requireSubmitEndpoint = (inferredFinalSubmitStep || submitStep) && submitEndpointPattern !== null;
8326
9768
  // Capture the per-element selection baseline (for verifyDomEffect's element-
8327
9769
  // scoped click read-back) ONLY for selection/field-answer click steps — never
8328
9770
  // for submit/advance steps, whose own self-toggling buttons must not
@@ -8350,6 +9792,7 @@ async function executeStepWithHealing(params) {
8350
9792
  fixture: uploadFixture,
8351
9793
  logger,
8352
9794
  signalCounter,
9795
+ browserbaseSessionId: stagehand.browserbaseSessionID,
8353
9796
  recentCaptureMeta,
8354
9797
  })) {
8355
9798
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by upload primitive`);
@@ -8400,6 +9843,17 @@ async function executeStepWithHealing(params) {
8400
9843
  trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: checkboxTargetId });
8401
9844
  return "completed";
8402
9845
  }
9846
+ const checkboxStateTargetId = await tryCheckboxStatePrimitive({
9847
+ page,
9848
+ target: selectFrameTarget,
9849
+ instruction: step,
9850
+ logger,
9851
+ });
9852
+ if (checkboxStateTargetId !== null) {
9853
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by checkbox state primitive`);
9854
+ trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: checkboxStateTargetId });
9855
+ return "completed";
9856
+ }
8403
9857
  // When the step is a single-choice RADIO answer ("Click the 'Yes' answer for
8404
9858
  // the question '…'"), commit it directly in the DOM. Runs AFTER select/
8405
9859
  // checkbox (which own their verbs) and BEFORE the cascade, so radios never
@@ -8445,6 +9899,19 @@ async function executeStepWithHealing(params) {
8445
9899
  trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: promptSelectorTargetId });
8446
9900
  return "completed";
8447
9901
  }
9902
+ // Multi-segment date spinbuttons need real key events; the cascade's
9903
+ // insertText would garble the segments. Falls through on null.
9904
+ const segmentedDateTargetId = await trySegmentedDateSpinbuttonPrimitive({
9905
+ page,
9906
+ target: selectFrameTarget,
9907
+ instruction: step,
9908
+ logger,
9909
+ });
9910
+ if (segmentedDateTargetId !== null) {
9911
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by segmented-date primitive`);
9912
+ trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: segmentedDateTargetId });
9913
+ return "completed";
9914
+ }
8448
9915
  // On a CATCH-ALL step ("for any remaining … question"), fill every
8449
9916
  // required-but-empty native <select> — including MuiNativeSelect dropdowns
8450
9917
  // (tabindex=-1) that Stagehand observe can't see and that no concrete flow
@@ -8480,6 +9947,7 @@ async function executeStepWithHealing(params) {
8480
9947
  isFinalStep,
8481
9948
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
8482
9949
  });
9950
+ const isSettleWaitStep = isSubmitOrFinalStep || isAdvanceStep(step);
8483
9951
  if (captchaGated && isSubmitOrFinalStep) {
8484
9952
  let captchaTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
8485
9953
  const sitekeyProbeExpr = `(() => {
@@ -8623,6 +10091,12 @@ async function executeStepWithHealing(params) {
8623
10091
  signalCounter,
8624
10092
  page,
8625
10093
  step,
10094
+ isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
10095
+ step,
10096
+ submitStep,
10097
+ isFinalStep,
10098
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10099
+ }),
8626
10100
  });
8627
10101
  }
8628
10102
  catch (err) {
@@ -8767,7 +10241,8 @@ async function executeStepWithHealing(params) {
8767
10241
  // candidate matching the step's instruction we either skip cleanly
8768
10242
  // (optional) or escalate straight to replan (required) — far cheaper than
8769
10243
  // burning 4 attempts on a page that clearly isn't the right one.
8770
- const probeResult = await probeStepBeforeAttempts({
10244
+ params.abortSignal?.throwIfAborted();
10245
+ const probeResult = await raceAbortSignal(probeStepBeforeAttempts({
8771
10246
  stagehand,
8772
10247
  page,
8773
10248
  step,
@@ -8780,7 +10255,8 @@ async function executeStepWithHealing(params) {
8780
10255
  await reresolveFrameTargetIfLost();
8781
10256
  return frameTarget;
8782
10257
  },
8783
- });
10258
+ }), params.abortSignal);
10259
+ params.abortSignal?.throwIfAborted();
8784
10260
  if (probeResult === "absent") {
8785
10261
  if (optional) {
8786
10262
  // Don't fast-skip when the page plainly has a required, still-empty
@@ -8823,7 +10299,7 @@ async function executeStepWithHealing(params) {
8823
10299
  });
8824
10300
  if (backendErrorUrl !== null) {
8825
10301
  logger.error(`${formatStepPrefix(stepIndex, totalSteps)} backend error detected (submit endpoint returned 5xx: ${backendErrorUrl}); aborting cascade`);
8826
- throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
10302
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactSecrets)(step).slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
8827
10303
  }
8828
10304
  // Bounded same-origin child-iframe fallback: the probe above only ever
8829
10305
  // looked at the resolved (declared-or-main) frame's own light+shadow DOM,
@@ -8842,7 +10318,17 @@ async function executeStepWithHealing(params) {
8842
10318
  isFinalStep,
8843
10319
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
8844
10320
  })) {
8845
- const fallbackTarget = await probeChildFrameSubmitFallback({ page, signalCounter, step });
10321
+ const fallbackTarget = await probeChildFrameSubmitFallback({
10322
+ page,
10323
+ signalCounter,
10324
+ step,
10325
+ isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
10326
+ step,
10327
+ submitStep,
10328
+ isFinalStep,
10329
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10330
+ }),
10331
+ });
8846
10332
  if (fallbackTarget) {
8847
10333
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} probe-absent: no candidate in the resolved frame, but a same-origin child iframe surfaced a submit-shaped candidate — clicked and verified there instead of failing`);
8848
10334
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
@@ -8865,6 +10351,7 @@ async function executeStepWithHealing(params) {
8865
10351
  const fieldValuesAtFailure = await (frameTarget ?? page)
8866
10352
  .evaluate(fieldValuesAtFailureExpr())
8867
10353
  .catch(() => null);
10354
+ params.abortSignal?.throwIfAborted();
8868
10355
  const probeAbsentObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
8869
10356
  // observe() is blind to a cross-origin OOPIF, so a frame-scoped empty
8870
10357
  // result degrades to the deep-locator resolver for dump evidence — this
@@ -8897,7 +10384,7 @@ async function executeStepWithHealing(params) {
8897
10384
  targetResolutionDiagnostic,
8898
10385
  fieldValuesAtFailure,
8899
10386
  }) ?? null;
8900
- throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
10387
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactSecrets)(step).slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
8901
10388
  }
8902
10389
  // Pre-submit form-validity probe. Fires on the canonical submit step
8903
10390
  // (either the final flow step OR a step explicitly flagged `submitStep:
@@ -9118,7 +10605,7 @@ async function executeStepWithHealing(params) {
9118
10605
  if (!frameTarget?.frame) {
9119
10606
  const topWindowTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
9120
10607
  try {
9121
- await (0, watchdog_1.withWatchdog)(() => topWindowTarget.locator(selector).first().click(), {
10608
+ await (0, watchdog_1.withWatchdog)(() => (0, trusted_click_1.clickCentredTrusted)(topWindowTarget, selector), {
9122
10609
  timeoutMs: N16_TRUSTED_CLICK_TIMEOUT_MS,
9123
10610
  label: "n+16 fallback: trusted click delivery",
9124
10611
  });
@@ -9156,6 +10643,26 @@ async function executeStepWithHealing(params) {
9156
10643
  return { delivered: false, reason: "threw", message: (0, errors_1.toErrorMessage)(err) };
9157
10644
  }
9158
10645
  };
10646
+ // Baseline is per step, not per attempt, so a tab opened by a click whose
10647
+ // attempt then failed is still new to the next attempt. Hand-built sessions in
10648
+ // unit tests carry no browser context (or one without a page list); there is no
10649
+ // tab to follow.
10650
+ const followableContext = () => typeof stagehand.context?.pages === "function" ? stagehand.context : null;
10651
+ let knownPages = followableContext()?.pages() ?? [];
10652
+ const adoptOpenedPage = async (discoverWaitMs) => {
10653
+ const context = followableContext();
10654
+ if (!context)
10655
+ return false;
10656
+ const followed = await (0, active_page_1.followNewPage)(context, page, knownPages, discoverWaitMs);
10657
+ if (followed === page)
10658
+ return false;
10659
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} click opened a new page; following it`);
10660
+ page = followed;
10661
+ frameTarget = undefined;
10662
+ knownPages = context.pages();
10663
+ params.onPageAdopted?.(followed);
10664
+ return true;
10665
+ };
9159
10666
  let phantomClickAfterAttempt1 = false;
9160
10667
  let attempt1UnreachableViaLightDom = false;
9161
10668
  // Last candidate list the deep-submit-locator itself ranked this step, if
@@ -9212,8 +10719,12 @@ async function executeStepWithHealing(params) {
9212
10719
  }
9213
10720
  }
9214
10721
  if (attempt > 1) {
9215
- await page.waitForTimeout(attempt * ATTEMPT_BACKOFF_MS);
10722
+ await raceAbortSignal(page.waitForTimeout(attempt * ATTEMPT_BACKOFF_MS), params.abortSignal);
10723
+ params.abortSignal?.throwIfAborted();
9216
10724
  }
10725
+ // A click from an earlier attempt that errored after opening a tab leaves the
10726
+ // run on the stale page; pick the tab up before measuring anything.
10727
+ await adoptOpenedPage(0);
9217
10728
  const pre = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page, captureSelectionState);
9218
10729
  // Snapshot the meta-tail length so the final-step pattern gate can scope
9219
10730
  // its URL scan to captures added DURING this attempt (not historical
@@ -9249,6 +10760,23 @@ async function executeStepWithHealing(params) {
9249
10760
  // whether this attempt's signal should come from the network/URL pair or
9250
10761
  // from DOM re-read. Captured here so both branches of the cascade write to it.
9251
10762
  let resolvedAction = null;
10763
+ // Stagehand resolves a natural-language act target internally, so for a
10764
+ // click-intent instruction resolve it with observe first and centre it;
10765
+ // a sticky bar otherwise swallows Stagehand's own click. Throws when the
10766
+ // control stays covered so the attempt fails instead of clicking an overlay.
10767
+ // Resolves true once the observe ran, so guardedAct skips its own pass.
10768
+ const preCentreNaturalLanguageClick = async (instruction) => {
10769
+ if (!stagehand_guard_1.CLICK_INTENT_RE.test(instruction))
10770
+ return false;
10771
+ const observed = await (0, stagehand_guard_1.guardedObserve)(stagehand, instruction, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
10772
+ const first = observed[0];
10773
+ if (first?.method !== "click" || !first.selector)
10774
+ return true;
10775
+ const failure = await centreObservedClickTarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), first.selector, (msg) => logger.debug(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${msg}`), true);
10776
+ if (failure)
10777
+ throw new Error(failure);
10778
+ return true;
10779
+ };
9252
10780
  try {
9253
10781
  // Field-label-first routing: for a frame-scoped fill/select step,
9254
10782
  // Stagehand's own act()/observe() can resolve a real, in-DOM, plausible-
@@ -9279,7 +10807,7 @@ async function executeStepWithHealing(params) {
9279
10807
  triedSelectors.push(fieldLabelOutcome.matched.selector);
9280
10808
  record.triedSelectors = [fieldLabelOutcome.matched.selector];
9281
10809
  record.actResultSuccess = true;
9282
- record.actResultDescription = `deepLocator ${fieldLabelOutcome.fieldTarget.kind === "fill" ? "filled" : "selected"} "${fieldLabelOutcome.matched.accessibleText || fieldLabelOutcome.matched.selector}" with "${fieldLabelOutcome.fieldTarget.value}"`;
10810
+ record.actResultDescription = `deepLocator ${fieldLabelOutcome.fieldTarget.kind === "fill" ? "filled" : "selected"} "${fieldLabelOutcome.matched.accessibleText || fieldLabelOutcome.matched.selector}" with "${(0, pii_redaction_1.maskValueShape)(fieldLabelOutcome.fieldTarget.value)}"`;
9283
10811
  // Same rationale as the pre-existing candidates===0 branch below:
9284
10812
  // the write + read-back the actuation helper already performed IS
9285
10813
  // the verification signal — no resolvedAction/network/url verifier
@@ -9312,7 +10840,9 @@ async function executeStepWithHealing(params) {
9312
10840
  if (attempt === 1) {
9313
10841
  record.technique = "act-string";
9314
10842
  record.instruction = step;
9315
- const result = await (0, stagehand_guard_1.guardedAct)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
10843
+ params.abortSignal?.throwIfAborted();
10844
+ const preCentred = await preCentreNaturalLanguageClick(step);
10845
+ const result = await (0, stagehand_guard_1.guardedAct)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), preCentred);
9316
10846
  record.actResultSuccess = result.success;
9317
10847
  record.actResultDescription = result.actionDescription;
9318
10848
  // Deny-list guard (post-hoc): attempt-1 act resolves internally, so we
@@ -9421,6 +10951,10 @@ async function executeStepWithHealing(params) {
9421
10951
  // burn the step budget probing all of them.
9422
10952
  const runnerUp = ranked[1];
9423
10953
  if (runnerUp) {
10954
+ await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, true, async () => {
10955
+ const snap = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
10956
+ return snap.visibleTextSignature;
10957
+ }, pre.visibleTextSignature);
9424
10958
  const midPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
9425
10959
  const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
9426
10960
  actResultSuccess: true,
@@ -9430,7 +10964,20 @@ async function executeStepWithHealing(params) {
9430
10964
  // stray selection flip must not mask a top-pick phantom and skip
9431
10965
  // the runner-up retry.
9432
10966
  isSubmitShapedStep: true,
9433
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, midPost.url),
10967
+ isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
10968
+ step,
10969
+ submitStep,
10970
+ isFinalStep,
10971
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10972
+ }),
10973
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, midPost.url, isMidFlowSubmitShapedStep({
10974
+ step,
10975
+ submitStep,
10976
+ isFinalStep,
10977
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10978
+ })
10979
+ ? pre.url
10980
+ : undefined),
9434
10981
  });
9435
10982
  if (topVerdict === "phantom") {
9436
10983
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} deep-submit-locator top pick (${top.tag} "${top.accessibleName}") phantom-clicked; retrying runner-up ${runnerUp.tag} "${runnerUp.accessibleName}" (tier ${runnerUp.tier})`);
@@ -9555,7 +11102,8 @@ async function executeStepWithHealing(params) {
9555
11102
  : { found: false };
9556
11103
  if (markResult.found) {
9557
11104
  try {
9558
- await topWindowTarget.locator(`[${markerAttr}="${markerValue}"]`).first().click();
11105
+ const markerSelector = `[${markerAttr}="${markerValue}"]`;
11106
+ await (0, trusted_click_1.clickAfterCentredHitTest)((0, trusted_click_1.firstMatchClickable)(topWindowTarget.locator(markerSelector)), () => topWindowTarget.evaluate(buildDeepMarkerCentreExpr(markerSelector)), { label: markerSelector });
9559
11107
  clickedShadowDescendant = true;
9560
11108
  }
9561
11109
  finally {
@@ -9565,7 +11113,7 @@ async function executeStepWithHealing(params) {
9565
11113
  }
9566
11114
  }
9567
11115
  else {
9568
- await topWindowTarget.locator(topWindowSelector).first().click();
11116
+ await (0, trusted_click_1.clickCentredTrusted)(topWindowTarget, topWindowSelector);
9569
11117
  }
9570
11118
  }
9571
11119
  catch (err) {
@@ -9626,6 +11174,7 @@ async function executeStepWithHealing(params) {
9626
11174
  const observeOptions = attempt === 4 && triedSelectors.length > 0
9627
11175
  ? { ignoreSelectors: [...triedSelectors], timeout: exports.STEP_WATCHDOG_MS }
9628
11176
  : { timeout: exports.STEP_WATCHDOG_MS };
11177
+ params.abortSignal?.throwIfAborted();
9629
11178
  const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, observeOptions, captureFn, frameTarget);
9630
11179
  // observe() is blind to a cross-origin OOPIF (see
9631
11180
  // deep-locator-candidates.ts's module docblock) — when the step is
@@ -9761,8 +11310,18 @@ async function executeStepWithHealing(params) {
9761
11310
  }
9762
11311
  }
9763
11312
  else {
9764
- // biome-ignore lint/style/noNonNullAssertion: this else-branch runs only when candidates.length !== 0
9765
- const target = candidates[0];
11313
+ const quotedPhrases = stepQuotesValueNotControlName(step)
11314
+ ? []
11315
+ : (0, deep_locator_candidates_1.extractTaggedPhrases)(step);
11316
+ const verdict = (0, deep_locator_candidates_1.vetObserveCandidates)(candidates, quotedPhrases);
11317
+ if (verdict.refuse || !verdict.candidate) {
11318
+ record.errorMessage = `observe candidate does not match the control named in the step: "${(verdict.candidate?.description ?? "").slice(0, 60)}" (refusing to act on an unscored guess)`;
11319
+ attempts.push(record);
11320
+ failureReasons.push(record.errorMessage);
11321
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
11322
+ continue;
11323
+ }
11324
+ const target = verdict.candidate;
9766
11325
  // Deny-list guard: never act on a wizard-exit control (save-and-exit,
9767
11326
  // cancel, restart) for a click/advance step. Record it as tried (so a
9768
11327
  // re-observe with ignoreSelectors surfaces a different candidate) and
@@ -9806,7 +11365,22 @@ async function executeStepWithHealing(params) {
9806
11365
  };
9807
11366
  }
9808
11367
  }
9809
- const result = await (0, stagehand_guard_1.guardedAct)(stagehand, overriddenTarget, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
11368
+ params.abortSignal?.throwIfAborted();
11369
+ // Stagehand's internal Locator.click() does not scroll past sticky
11370
+ // bars, so centre + hit-test the observed control first; an
11371
+ // unrecoverable occlusion fails the attempt instead of clicking an
11372
+ // overlay.
11373
+ if (overriddenTarget.method === "click") {
11374
+ const centreFailure = await centreObservedClickTarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), overriddenTarget.selector, (msg) => logger.debug(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${msg}`));
11375
+ if (centreFailure) {
11376
+ record.errorMessage = centreFailure;
11377
+ attempts.push(record);
11378
+ failureReasons.push(record.errorMessage);
11379
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
11380
+ continue;
11381
+ }
11382
+ }
11383
+ const result = await (0, stagehand_guard_1.guardedAct)(stagehand, overriddenTarget, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page));
9810
11384
  record.actResultSuccess = result.success;
9811
11385
  record.actResultDescription = result.actionDescription;
9812
11386
  // observe(...)[0] is what Stagehand acted on; use it directly when
@@ -9838,8 +11412,8 @@ async function executeStepWithHealing(params) {
9838
11412
  const dateFill = await fillHtml5DateTimeInput(dateFillTarget, overriddenTarget.selector, fillValue);
9839
11413
  if (dateFill !== null) {
9840
11414
  record.errorMessage = dateFill.filled
9841
- ? `html5-date-fallback: filled ${dateFill.inputType}="${redactIfSensitive(dateFill.postValue, emailStepCode)}"`
9842
- : `html5-date-fallback: failed to fill ${dateFill.inputType} (post=${redactIfSensitive(dateFill.postValue, emailStepCode)})`;
11415
+ ? `html5-date-fallback: filled ${dateFill.inputType}="${maskLoggedValue(dateFill.postValue, emailStepCode)}"`
11416
+ : `html5-date-fallback: failed to fill ${dateFill.inputType} (post=${maskLoggedValue(dateFill.postValue, emailStepCode)})`;
9843
11417
  // Override the act result based on the deterministic fill
9844
11418
  // outcome — the helper bypasses Stagehand's schema-error
9845
11419
  // failure mode by writing directly via the native setter.
@@ -9859,8 +11433,8 @@ async function executeStepWithHealing(params) {
9859
11433
  const datepickerFill = await fillTextDatepickerInput(dateFillTarget, overriddenTarget.selector, fillValue);
9860
11434
  if (datepickerFill !== null) {
9861
11435
  record.errorMessage = datepickerFill.filled
9862
- ? `text-datepicker-fill: filled via ${datepickerFill.strategy} "${redactIfSensitive(datepickerFill.postValue, emailStepCode)}"`
9863
- : `text-datepicker-fill: failed to commit "${redactIfSensitive(fillValue.slice(0, 60), emailStepCode)}" (post=${redactIfSensitive(datepickerFill.postValue, emailStepCode)})`;
11436
+ ? `text-datepicker-fill: filled via ${datepickerFill.strategy} "${maskLoggedValue(datepickerFill.postValue, emailStepCode)}"`
11437
+ : `text-datepicker-fill: failed to commit "${maskLoggedValue(fillValue.slice(0, 60), emailStepCode)}" (post=${maskLoggedValue(datepickerFill.postValue, emailStepCode)})`;
9864
11438
  if (datepickerFill.filled) {
9865
11439
  record.actResultSuccess = true;
9866
11440
  resolvedAction = overriddenTarget;
@@ -9878,12 +11452,16 @@ async function executeStepWithHealing(params) {
9878
11452
  // (network/url/dom/htmlDelta/textChanged) miss.
9879
11453
  const readback = await verifyFillReadback(dateFillTarget, overriddenTarget.selector, fillValue);
9880
11454
  if (readback !== null) {
9881
- if (readback.outcome === "rejected") {
9882
- record.errorMessage = `fill-value-rejected: tried "${redactIfSensitive(fillValue.slice(0, 60), emailStepCode)}" on <${readback.tag}>; element value remains empty (silent rejection — HTML5 type validation, framework controlled-component, or masked-input library)`;
11455
+ if (readback.outcome === "segment") {
11456
+ record.errorMessage = `fill-value-rejected: tried "${maskLoggedValue(fillValue.slice(0, 60), emailStepCode)}" on a single spinbutton segment; a lone segment cannot hold the whole value`;
11457
+ record.actResultSuccess = false;
11458
+ }
11459
+ else if (readback.outcome === "rejected") {
11460
+ record.errorMessage = `fill-value-rejected: tried "${maskLoggedValue(fillValue.slice(0, 60), emailStepCode)}" on <${readback.tag}>; element value remains empty (silent rejection — HTML5 type validation, framework controlled-component, or masked-input library)`;
9883
11461
  record.actResultSuccess = false;
9884
11462
  }
9885
11463
  else if (readback.outcome === "differs") {
9886
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} fill-value-differs: tried "${redactIfSensitive(fillValue.slice(0, 60), emailStepCode)}" got "${redactIfSensitive(readback.postValue.slice(0, 60), emailStepCode)}" (framework reformatted)`);
11464
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} fill-value-differs: tried "${maskLoggedValue(fillValue.slice(0, 60), emailStepCode)}" got "${maskLoggedValue(readback.postValue.slice(0, 60), emailStepCode)}" (framework reformatted)`);
9887
11465
  }
9888
11466
  }
9889
11467
  }
@@ -10046,6 +11624,7 @@ async function executeStepWithHealing(params) {
10046
11624
  "anthropic billing exhausted (FATAL_BILLING already logged); skipping rephrase";
10047
11625
  }
10048
11626
  else {
11627
+ params.abortSignal?.throwIfAborted();
10049
11628
  const observedCandidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
10050
11629
  // observe() is blind to a cross-origin OOPIF, so a frame-scoped
10051
11630
  // empty result degrades to the deep-locator resolver for prompt
@@ -10070,6 +11649,7 @@ async function executeStepWithHealing(params) {
10070
11649
  // frameTarget: for a frame-scoped flow the ambient UI that matters
10071
11650
  // (the wizard's own Save/Close controls) lives inside the iframe
10072
11651
  // alongside the failed step, not in the top document.
11652
+ params.abortSignal?.throwIfAborted();
10073
11653
  const observedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
10074
11654
  const unfocused = observedUnfocused.length === 0 && frameTarget?.frame
10075
11655
  ? await deepLocatorCandidatesAsActions(page, frameTarget)
@@ -10087,7 +11667,9 @@ async function executeStepWithHealing(params) {
10087
11667
  }
10088
11668
  else {
10089
11669
  record.instruction = rephrased;
10090
- const result = await (0, stagehand_guard_1.guardedAct)(stagehand, rephrased, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
11670
+ params.abortSignal?.throwIfAborted();
11671
+ const preCentred = await preCentreNaturalLanguageClick(rephrased);
11672
+ const result = await (0, stagehand_guard_1.guardedAct)(stagehand, rephrased, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), preCentred);
10091
11673
  record.actResultSuccess = result.success;
10092
11674
  record.actResultDescription = result.actionDescription;
10093
11675
  for (const action of result.actions ?? []) {
@@ -10116,7 +11698,7 @@ async function executeStepWithHealing(params) {
10116
11698
  // cannot produce a positive signal: verified is forced false and the
10117
11699
  // cascade always falls through to attempt 2. Skip the wasted ~2-3s
10118
11700
  // of timeout + snapshot work and let the cascade try observe-act.
10119
- if (attempt === 1 && resolvedAction === null) {
11701
+ if (attempt === 1 && resolvedAction === null && record.actResultSuccess !== true) {
10120
11702
  attempts.push(record);
10121
11703
  const reason = record.errorMessage
10122
11704
  ? `attempt 1 fast-skipped (no resolved action): ${record.errorMessage}`
@@ -10124,7 +11706,21 @@ async function executeStepWithHealing(params) {
10124
11706
  failureReasons.push(reason);
10125
11707
  continue;
10126
11708
  }
11709
+ // A fill/select/type action cannot open a tab, so skip the discovery wait for it.
11710
+ await adoptOpenedPage(resolvedAction && STATE_CLASS_METHODS.has(resolvedAction.method ?? "") ? 0 : undefined);
10127
11711
  await page.waitForTimeout(STEP_PAUSE_MS);
11712
+ // A successful act that reported no actions still ran a click-class action;
11713
+ // judging it before its round-trip lands would cascade onto the advanced page.
11714
+ const mayTriggerRoundTrip = resolvedAction === null
11715
+ ? record.actResultSuccess === true
11716
+ : !STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
11717
+ if (mayTriggerRoundTrip) {
11718
+ const settleTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
11719
+ await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, isSettleWaitStep, async () => {
11720
+ const snap = await snapshotPage(settleTarget, signalCounter);
11721
+ return snap.visibleTextSignature;
11722
+ }, pre.visibleTextSignature);
11723
+ }
10128
11724
  const post = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
10129
11725
  record.post = post;
10130
11726
  if (resolvedAction) {
@@ -10134,7 +11730,13 @@ async function executeStepWithHealing(params) {
10134
11730
  const networkFired = post.networkCount > pre.networkCount;
10135
11731
  // Computed once: gates every credit signal that takes no post-click URL
10136
11732
  // (network, DOM read-back) and classifyPhantomClick's destinationPlausible.
10137
- const destinationPlausible = (0, phantom_click_1.isPlausibleStepDestination)(step, post.url);
11733
+ const stepIsMidFlowSubmit = isMidFlowSubmitShapedStep({
11734
+ step,
11735
+ submitStep,
11736
+ isFinalStep,
11737
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11738
+ });
11739
+ const destinationPlausible = (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, stepIsMidFlowSubmit ? pre.url : undefined);
10138
11740
  const urlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, post.url) && destinationPlausible;
10139
11741
  const isStateClass = resolvedAction !== null && STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
10140
11742
  const isClick = resolvedAction !== null && resolvedAction.method === "click";
@@ -10227,9 +11829,10 @@ async function executeStepWithHealing(params) {
10227
11829
  // Zero-signal reset veto input for the view-swap gate below. Read only
10228
11830
  // for a resolved click with a usable selector — a page reset that reverts
10229
11831
  // to an earlier screen (recon-viewswap-false-pass-on-zero-signal-click.md)
10230
- // replaces the clicked control along with everything else, while a
10231
- // genuine same-page toggle (a settings-page tab switcher) leaves it in
10232
- // place. See isClickViewSwapVerified's doc comment.
11832
+ // replaces the clicked control along with everything else and shrinks
11833
+ // the DOM, while a genuine same-page toggle leaves it in place and a
11834
+ // view swap removes it but grows the DOM (absence vetoes only when
11835
+ // bytesDelta <= 0). See isClickViewSwapVerified's doc comment.
10233
11836
  const clickedElementStillPresent = isClick && resolvedAction?.selector
10234
11837
  ? await resolvedClickTargetStillPresent(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector)
10235
11838
  : true;
@@ -10248,6 +11851,17 @@ async function executeStepWithHealing(params) {
10248
11851
  // cascade to a 5-attempt failure and global replan. Vetoed when the click's
10249
11852
  // ng-invalid marker count grew, or when the landed destination is implausible
10250
11853
  // for the step (see isClickViewSwapVerified's doc comment).
11854
+ const midFlowSubmit = isMidFlowSubmitShapedStep({
11855
+ step,
11856
+ submitStep,
11857
+ isFinalStep,
11858
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11859
+ elementSubmitShaped: resolvedElementIsSubmitShaped,
11860
+ });
11861
+ // An element-inferred mid-flow submit joins the exemption the step-derived
11862
+ // one already got: a click that never left its URL did not bounce to a gate.
11863
+ const midFlowDestinationPlausible = destinationPlausible ||
11864
+ (midFlowSubmit && (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, pre.url));
10251
11865
  const clickViewSwapVerified = isClickViewSwapVerified({
10252
11866
  resolvedAction,
10253
11867
  submitStep,
@@ -10258,7 +11872,10 @@ async function executeStepWithHealing(params) {
10258
11872
  invalidMarkerDelta: postInvalidMarkerCount - preInvalidMarkerCount,
10259
11873
  clickedElementStillPresent,
10260
11874
  resolvedElementIsSubmitShaped,
10261
- destinationPlausible,
11875
+ destinationPlausible: midFlowDestinationPlausible,
11876
+ midFlowSubmit,
11877
+ pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, post),
11878
+ actSucceeded: record.actResultSuccess === true,
10262
11879
  });
10263
11880
  if (clickViewSwapVerified === false &&
10264
11881
  isClick &&
@@ -10325,7 +11942,12 @@ async function executeStepWithHealing(params) {
10325
11942
  if (readbackSelector && fillIntent !== null) {
10326
11943
  const readbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
10327
11944
  const readback = await verifyFillReadback(readbackTarget, readbackSelector, fillIntent.value);
10328
- if (readback?.outcome === "rejected") {
11945
+ if (readback?.outcome === "segment") {
11946
+ datepickerRejected = true;
11947
+ record.actResultSuccess = false;
11948
+ record.errorMessage = `fill-value-rejected: tried "${maskLoggedValue(fillIntent.value.slice(0, 60), emailStepCode)}" on a single spinbutton segment; a lone segment cannot hold the whole value`;
11949
+ }
11950
+ else if (readback?.outcome === "rejected") {
10329
11951
  // Value didn't land. First try the datepicker primitive; it self-gates
10330
11952
  // on react-datepicker's `.react-datepicker__input-container` and returns
10331
11953
  // null for any non-datepicker input, so a plain rejected fill falls
@@ -10334,7 +11956,7 @@ async function executeStepWithHealing(params) {
10334
11956
  if (datepickerFill !== null) {
10335
11957
  if (datepickerFill.filled) {
10336
11958
  datepickerCommitted = true;
10337
- record.errorMessage = `text-datepicker-fill: filled via ${datepickerFill.strategy} "${redactIfSensitive(datepickerFill.postValue, emailStepCode)}"`;
11959
+ record.errorMessage = `text-datepicker-fill: filled via ${datepickerFill.strategy} "${maskLoggedValue(datepickerFill.postValue, emailStepCode)}"`;
10338
11960
  }
10339
11961
  else {
10340
11962
  // A gated-in datepicker that neither gesture commits is a real fill
@@ -10342,7 +11964,7 @@ async function executeStepWithHealing(params) {
10342
11964
  // so suppress the weak view-swap/form-value acceptance and escalate.
10343
11965
  datepickerRejected = true;
10344
11966
  record.actResultSuccess = false;
10345
- record.errorMessage = `text-datepicker-fill: failed to commit "${redactIfSensitive(fillIntent.value.slice(0, 60), emailStepCode)}" (post=${redactIfSensitive(datepickerFill.postValue, emailStepCode)})`;
11967
+ record.errorMessage = `text-datepicker-fill: failed to commit "${maskLoggedValue(fillIntent.value.slice(0, 60), emailStepCode)}" (post=${maskLoggedValue(datepickerFill.postValue, emailStepCode)})`;
10346
11968
  }
10347
11969
  }
10348
11970
  else {
@@ -10351,7 +11973,7 @@ async function executeStepWithHealing(params) {
10351
11973
  // failure dump reads identically regardless of which path caught it.
10352
11974
  datepickerRejected = true;
10353
11975
  record.actResultSuccess = false;
10354
- record.errorMessage = `fill-value-rejected: tried "${redactIfSensitive(fillIntent.value.slice(0, 60), emailStepCode)}" on <${readback.tag}>; element value remains empty (silent rejection — HTML5 type validation, framework controlled-component, or masked-input library)`;
11976
+ record.errorMessage = `fill-value-rejected: tried "${maskLoggedValue(fillIntent.value.slice(0, 60), emailStepCode)}" on <${readback.tag}>; element value remains empty (silent rejection — HTML5 type validation, framework controlled-component, or masked-input library)`;
10355
11977
  }
10356
11978
  }
10357
11979
  }
@@ -10379,7 +12001,7 @@ async function executeStepWithHealing(params) {
10379
12001
  const readbackSelector = resolvedAction?.selector ?? triedSelectors[triedSelectors.length - 1] ?? null;
10380
12002
  if (readbackSelector) {
10381
12003
  const readbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
10382
- const promptReadback = await verifyPromptSelectorCommitted(readbackTarget, readbackSelector);
12004
+ const promptReadback = await verifyPromptSelectorCommitted(readbackTarget, readbackSelector, parsePromptSelectorStep(step)?.option);
10383
12005
  if (promptReadback?.isPromptWidget === true && promptReadback.committed === false) {
10384
12006
  promptSelectorRejected = true;
10385
12007
  record.actResultSuccess = false;
@@ -10403,13 +12025,15 @@ async function executeStepWithHealing(params) {
10403
12025
  pre,
10404
12026
  post,
10405
12027
  elementStateChanged: domVerified && destinationPlausible,
10406
- isSubmitShapedStep: isStepSubmitShaped({
10407
- step,
10408
- submitStep,
10409
- isFinalStep,
10410
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10411
- }),
10412
- destinationPlausible,
12028
+ isSubmitShapedStep: midFlowSubmit ||
12029
+ isStepSubmitShaped({
12030
+ step,
12031
+ submitStep,
12032
+ isFinalStep,
12033
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12034
+ }),
12035
+ isMidFlowSubmitStep: midFlowSubmit,
12036
+ destinationPlausible: midFlowDestinationPlausible,
10413
12037
  });
10414
12038
  // An `"effective"` verdict driven purely by the page-wide byte-delta
10415
12039
  // floor (`TRIVIAL_DOM_DELTA_BYTES`, 500B) is intentionally NOT trusted
@@ -10502,6 +12126,7 @@ async function executeStepWithHealing(params) {
10502
12126
  }
10503
12127
  // Build the unfocused-observe evidence list. Used by the judge to
10504
12128
  // assess whether the page transitioned to a success state.
12129
+ params.abortSignal?.throwIfAborted();
10505
12130
  const unfocusedForJudge = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
10506
12131
  // Quick invalid-marker count (deterministic DOM querying — counting
10507
12132
  // structural ng-invalid containers is not fuzzy matching, just
@@ -10512,9 +12137,17 @@ async function executeStepWithHealing(params) {
10512
12137
  label: "flow-runner: submit-verification page title probe",
10513
12138
  }).catch(() => "");
10514
12139
  const matchedSubmittedSelectors = domSubmittedMatch !== null ? [domSubmittedMatch] : [];
10515
- const judgeVerdict = await (0, verify_submit_1.verifySubmitWithLLM)({
12140
+ const judgeVerdict = await judgeStepOutcome({
10516
12141
  client: anthropic,
10517
- input: {
12142
+ captureFn,
12143
+ step,
12144
+ isFinalStep,
12145
+ page,
12146
+ target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
12147
+ signalCounter,
12148
+ preNetworkCount: pre.networkCount,
12149
+ preVisibleTextSignature: pre.visibleTextSignature,
12150
+ submitInput: {
10518
12151
  pageUrl: post.url,
10519
12152
  pageTitle,
10520
12153
  unfocusedObserve: unfocusedForJudge.map((a) => ({
@@ -10528,9 +12161,22 @@ async function executeStepWithHealing(params) {
10528
12161
  successPageTitleHints,
10529
12162
  submittedStateSelectors: matchedSubmittedSelectors,
10530
12163
  },
10531
- captureFn,
10532
12164
  });
10533
- if (judgeVerdict === null) {
12165
+ // A mid-flow transition is credited on the page's own demonstrable
12166
+ // advance (visible text swapped); the judge's final-submit-flavoured
12167
+ // view of a stale title or missing network must not undo it.
12168
+ const midFlowAdvanceCredited = (midFlowSubmit && clickViewSwapVerified) ||
12169
+ isNullVerdictAdvanceCredited({
12170
+ midFlowSubmit,
12171
+ judgeUnavailable: judgeVerdict === null,
12172
+ networkAdvanced: networkIsRealAdvance,
12173
+ pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, post),
12174
+ invalidMarkerCount,
12175
+ });
12176
+ if (midFlowAdvanceCredited && judgeVerdict?.verified !== true) {
12177
+ logger.info(`mid-flow submit credited on demonstrable page advance (judge ${judgeVerdict === null ? "unavailable" : `rejected: ${judgeVerdict.reason}`})`);
12178
+ }
12179
+ else if (judgeVerdict === null) {
10534
12180
  // Bedrock-only deployment or judge call failed entirely. Fall back
10535
12181
  // to the DOM-state probe + requireSubmitEndpointMatch policy: if a
10536
12182
  // submitted-state selector matched and the site didn't require
@@ -10650,15 +12296,16 @@ async function executeStepWithHealing(params) {
10650
12296
  // tail node `clickExpr` below is about to click, instead of only
10651
12297
  // ever checking the (possibly unresolvable) primary xpath — see
10652
12298
  // resolvedClickTargetIsSubmitShaped's doc comment.
12299
+ const tailPhrases = tailRetargetPhrasesForStep(step);
10653
12300
  const retryResolvedElementIsSubmitShaped = stepDeclaredNonSubmit
10654
12301
  ? false
10655
- : await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector, xpathTail);
12302
+ : await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector, xpathTail, tailPhrases);
10656
12303
  // See resolvedClickUsedXpathTailRetarget's doc comment: flags that
10657
12304
  // this click's target identity came only from the loose tail
10658
12305
  // heuristic, not Stagehand's own resolved selector.
10659
12306
  const retryClickUsedXpathTailRetarget = stepDeclaredNonSubmit
10660
12307
  ? false
10661
- : await resolvedClickUsedXpathTailRetarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector, xpathTail);
12308
+ : await resolvedClickUsedXpathTailRetarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector, xpathTail, tailPhrases);
10662
12309
  // Trusted-click delivery for the plain-click branch: attempted
10663
12310
  // BEFORE the resolve/activate expression below (so a successful
10664
12311
  // delivery can suppress the synthetic `clickActivationExpr`
@@ -10690,10 +12337,12 @@ async function executeStepWithHealing(params) {
10690
12337
  const n16TrustedClickFailure = !n16TrustedDelivered && "reason" in n16TrustedClickResult
10691
12338
  ? n16TrustedClickResult
10692
12339
  : null;
10693
- const clickExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); let el = r.singleNodeValue; if (!el && ${JSON.stringify(xpathTail)}) { el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)}); } 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 }; } let __n16SmMatched = false; ${(0, browser_click_expr_1.retargetToSelectionMarkerExpr)("el", "__n16SmMatched")} ${n16TrustedDelivered ? "" : (0, browser_click_expr_1.clickActivationExpr)("el")} if (__n16SmMatched) { el.dispatchEvent(new Event("change", { bubbles: true })); } return { fired: true, kind: "click" }; })()`;
12340
+ const clickExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); let el = r.singleNodeValue; if (!el && ${JSON.stringify(xpathTail)}) { el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)}, ${JSON.stringify(tailPhrases)}); if (!el && ${JSON.stringify(tailPhrases.length > 0)} && ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(false)}, []) !== null) return { fired: false, phraseMismatch: true }; } 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") { const __ac = ${ariaCheckedReadExpr("el")}; if (__ac === "true" || __ac === "false") return { fired: true, kind: "checkbox", checked: __ac === "true", ariaManaged: true }; 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 }; } let __n16SmMatched = false; ${(0, browser_click_expr_1.retargetToSelectionMarkerExpr)("el", "__n16SmMatched")} ${n16TrustedDelivered ? "" : (0, browser_click_expr_1.clickActivationExpr)("el")} if (__n16SmMatched) { el.dispatchEvent(new Event("change", { bubbles: true })); } return { fired: true, kind: "click" }; })()`;
10694
12341
  const n16FallbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
10695
12342
  const probeResult = (await n16FallbackTarget.evaluate(clickExpr));
10696
12343
  const fired = probeResult.fired;
12344
+ if (fired)
12345
+ await adoptOpenedPage();
10697
12346
  // Vacuous-click guard for the n+16 fallback. Same rationale as
10698
12347
  // verifyDomEffect's click case: a checkbox/radio input's .checked
10699
12348
  // property can flip true via CDP click even when the input lives on
@@ -10703,13 +12352,15 @@ async function executeStepWithHealing(params) {
10703
12352
  // ancestor-invalid signal through prevents the cascade from
10704
12353
  // advancing past a vacuous click.
10705
12354
  let ancestorStillInvalid = false;
10706
- if (probeResult.kind === "checkbox" && probeResult.checked === true) {
12355
+ if (probeResult.kind === "checkbox" &&
12356
+ probeResult.checked === true &&
12357
+ probeResult.ariaManaged !== true) {
10707
12358
  const ancestorInvalidExpr = `(() => {
10708
12359
  const isInvalid = ${INVALID_MARKER_EL_EXPR};
10709
12360
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
10710
12361
  let node = r.singleNodeValue;
10711
12362
  if (!node && ${JSON.stringify(xpathTail)}) {
10712
- node = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)});
12363
+ node = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)}, ${JSON.stringify(tailPhrases)});
10713
12364
  }
10714
12365
  if (!node) return false;
10715
12366
  for (let depth = 0; depth < 6 && node; depth++) {
@@ -10718,18 +12369,35 @@ async function executeStepWithHealing(params) {
10718
12369
  }
10719
12370
  return false;
10720
12371
  })()`;
10721
- ancestorStillInvalid = (await n16FallbackTarget
12372
+ ancestorStillInvalid = (await (frameTarget ?? (0, frame_target_1.mainFrameTarget)(page))
10722
12373
  .evaluate(ancestorInvalidExpr)
10723
12374
  .catch(() => false));
10724
12375
  }
12376
+ // An aria-managed box is never forced, so the framework's own
12377
+ // aria-checked decides; a stale invalid ancestor is not consulted.
12378
+ const forcedCheckboxRejected = probeResult.kind === "checkbox" &&
12379
+ (probeResult.ariaManaged === true
12380
+ ? probeResult.checked !== true
12381
+ : ancestorStillInvalid);
10725
12382
  const checkboxStateVerified = probeResult.kind === "checkbox" &&
10726
12383
  probeResult.checked === true &&
10727
- !ancestorStillInvalid;
12384
+ (probeResult.ariaManaged === true || !ancestorStillInvalid);
10728
12385
  await page.waitForTimeout(STEP_PAUSE_MS);
12386
+ await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, isSettleWaitStep, async () => {
12387
+ const snap = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
12388
+ return snap.visibleTextSignature;
12389
+ }, pre.visibleTextSignature);
10729
12390
  const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
10730
12391
  const retryNetworkFired = retryPost.networkCount > pre.networkCount;
12392
+ const retryMidFlowSubmit = isMidFlowSubmitShapedStep({
12393
+ step,
12394
+ submitStep,
12395
+ isFinalStep,
12396
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12397
+ elementSubmitShaped: retryResolvedElementIsSubmitShaped,
12398
+ });
10731
12399
  const retryUrlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, retryPost.url) &&
10732
- (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url);
12400
+ (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url, retryMidFlowSubmit ? pre.url : undefined);
10733
12401
  const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
10734
12402
  const retryTextChanged = retryPost.visibleTextSignature !== pre.visibleTextSignature;
10735
12403
  const retryFormValueChanged = retryPost.formValueSignature !== pre.formValueSignature;
@@ -10793,7 +12461,7 @@ async function executeStepWithHealing(params) {
10793
12461
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
10794
12462
  let el = r.singleNodeValue;
10795
12463
  if (!el && ${JSON.stringify(xpathTail)}) {
10796
- el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)});
12464
+ el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)}, ${JSON.stringify(tailPhrases)});
10797
12465
  }
10798
12466
  return el ? isDisabled(el) : false;
10799
12467
  })()`;
@@ -10824,6 +12492,12 @@ async function executeStepWithHealing(params) {
10824
12492
  // Destination-gated so an implausible landing URL's traffic cannot satisfy
10825
12493
  // the veto or the submit-transition carve-out downstream.
10826
12494
  const retryDestinationPlausible = (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url);
12495
+ // Only the verdict sees the pre.url exemption, and only for a demonstrable
12496
+ // page advance: bare network/DOM signals stay gated on the landed URL.
12497
+ const retryMidFlowDestinationPlausible = retryDestinationPlausible ||
12498
+ (retryMidFlowSubmit &&
12499
+ (0, phantom_click_1.hasPageAdvanced)(pre, retryPost) &&
12500
+ (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url, pre.url));
10827
12501
  const retryNetworkIsRealAdvance = retryNetworkFired &&
10828
12502
  retryDestinationPlausible &&
10829
12503
  (await waitForTransitionBody({
@@ -10907,9 +12581,7 @@ async function executeStepWithHealing(params) {
10907
12581
  isFinalStep,
10908
12582
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10909
12583
  }) || retryResolvedElementIsSubmitShaped;
10910
- const weakDomSignalsAllowed = ((!(isFinalStep && !stepDeclaredNonSubmit) &&
10911
- !submitStep &&
10912
- !retryResolvedElementIsSubmitShaped) ||
12584
+ const weakDomSignalsAllowed = ((!inferredFinalSubmitStep && !submitStep && !retryResolvedElementIsSubmitShaped) ||
10913
12585
  requireSubmitEndpoint) &&
10914
12586
  !isCheckboxOrRadioIntentStep(step) &&
10915
12587
  !clickTargetIsSelectionMarker;
@@ -10931,8 +12603,9 @@ async function executeStepWithHealing(params) {
10931
12603
  pre,
10932
12604
  post: retryPost,
10933
12605
  elementStateChanged: retrySelectionStateChanged,
10934
- isSubmitShapedStep: retrySubmitShaped,
10935
- destinationPlausible: retryDestinationPlausible,
12606
+ isSubmitShapedStep: retryMidFlowSubmit || retrySubmitShaped,
12607
+ isMidFlowSubmitStep: retryMidFlowSubmit,
12608
+ destinationPlausible: retryMidFlowDestinationPlausible,
10936
12609
  });
10937
12610
  // Destination-plausibility audit of every remaining disjunct in
10938
12611
  // this OR (the recon report's closing instruction, see
@@ -10948,15 +12621,28 @@ async function executeStepWithHealing(params) {
10948
12621
  // three get the same `isPlausibleStepDestination` gate
10949
12622
  // `weakDomSignalsAllowed` already has (bugfix-002) — `retryUrlChanged`
10950
12623
  // already folds the same check in at its own computation above.
12624
+ // An unfired probe with no trusted delivery clicked nothing, so DOM
12625
+ // movement is ambient settling, not this step's effect.
12626
+ const n16ClickDelivered = fired === true || n16TrustedDelivered;
12627
+ const retryShowsErrorSummary = exports.ADVANCE_CLICK_RX.test(step)
12628
+ ? (await (frameTarget ?? (0, frame_target_1.mainFrameTarget)(page))
12629
+ .evaluate(exports.ERROR_SUMMARY_EXPR)
12630
+ .catch(() => false))
12631
+ : false;
10951
12632
  let retryVerified = !clickBlockedByDisabled &&
12633
+ !retryShowsErrorSummary &&
10952
12634
  !clickBlockedByInvalid &&
10953
12635
  !fallbackDomOnlyAdvance &&
10954
- ((retryNetworkFired && retryDestinationPlausible) ||
10955
- retryUrlChanged ||
12636
+ !forcedCheckboxRejected &&
12637
+ ((n16ClickDelivered && retryNetworkFired && retryDestinationPlausible) ||
12638
+ (n16ClickDelivered && retryUrlChanged) ||
10956
12639
  (checkboxStateVerified && retryDestinationPlausible) ||
10957
12640
  (retrySelectionStateChanged && retryDestinationPlausible) ||
10958
- (!retrySubmitShaped && retryVerdict === "effective") ||
10959
- (weakDomSignalsAllowed &&
12641
+ (n16ClickDelivered &&
12642
+ (!retrySubmitShaped || retryMidFlowSubmit) &&
12643
+ retryVerdict === "effective") ||
12644
+ (n16ClickDelivered &&
12645
+ weakDomSignalsAllowed &&
10960
12646
  retryDestinationPlausible &&
10961
12647
  (retryHtmlDelta !== 0 || retryTextChanged || retryFormValueChanged)));
10962
12648
  if (retryVerified) {
@@ -11000,9 +12686,8 @@ async function executeStepWithHealing(params) {
11000
12686
  // flow-runner.overlay-hidden-target-trusted-click-acceptance.test.ts
11001
12687
  // and flow-runner.evidence-table-alternating-verdict-regression.test.ts,
11002
12688
  // both of which rely on that carve-out and must stay green).
11003
- const retryDestinationUnconfirmed = isFinalStep &&
12689
+ const retryDestinationUnconfirmed = inferredFinalSubmitStep &&
11004
12690
  !submitStep &&
11005
- !stepDeclaredNonSubmit &&
11006
12691
  retryUrlChanged &&
11007
12692
  !retryResolvedElementIsSubmitShaped;
11008
12693
  if (retryVerified && retryDestinationUnconfirmed && !requireSubmitEndpoint) {
@@ -11048,9 +12733,17 @@ async function executeStepWithHealing(params) {
11048
12733
  label: "flow-runner: submit-verification retry page title probe",
11049
12734
  }).catch(() => "");
11050
12735
  const matchedSubmittedSelectors = domSubmittedMatch !== null ? [domSubmittedMatch] : [];
11051
- const judgeVerdict = await (0, verify_submit_1.verifySubmitWithLLM)({
12736
+ const judgeVerdict = await judgeStepOutcome({
11052
12737
  client: anthropic,
11053
- input: {
12738
+ captureFn,
12739
+ step,
12740
+ isFinalStep,
12741
+ page,
12742
+ target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
12743
+ signalCounter,
12744
+ preNetworkCount: pre.networkCount,
12745
+ preVisibleTextSignature: pre.visibleTextSignature,
12746
+ submitInput: {
11054
12747
  pageUrl: retryPost.url,
11055
12748
  pageTitle,
11056
12749
  unfocusedObserve: unfocusedForJudge.map((a) => ({
@@ -11064,9 +12757,21 @@ async function executeStepWithHealing(params) {
11064
12757
  successPageTitleHints,
11065
12758
  submittedStateSelectors: matchedSubmittedSelectors,
11066
12759
  },
11067
- captureFn,
11068
12760
  });
11069
- if (judgeVerdict === null) {
12761
+ const retryMidFlowAdvanceCredited = (retryMidFlowSubmit &&
12762
+ retryVerdict === "effective" &&
12763
+ (0, phantom_click_1.hasPageAdvanced)(pre, retryPost)) ||
12764
+ isNullVerdictAdvanceCredited({
12765
+ midFlowSubmit: retryMidFlowSubmit,
12766
+ judgeUnavailable: judgeVerdict === null,
12767
+ networkAdvanced: retryNetworkFired && retryDestinationPlausible,
12768
+ pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, retryPost),
12769
+ invalidMarkerCount,
12770
+ });
12771
+ if (retryMidFlowAdvanceCredited && judgeVerdict?.verified !== true) {
12772
+ logger.info(`n+16 fallback mid-flow submit credited on demonstrable page advance (judge ${judgeVerdict === null ? "unavailable" : `rejected: ${judgeVerdict.reason}`})`);
12773
+ }
12774
+ else if (judgeVerdict === null) {
11070
12775
  // Bedrock-only or judge failed; fall back to DOM-state + policy.
11071
12776
  if (!requireSubmitEndpointMatch && domSubmittedMatch !== null) {
11072
12777
  logger.info(`n+16 fallback submit verified via submitted-state DOM selector '${domSubmittedMatch}' (judge unavailable)`);
@@ -11321,7 +13026,17 @@ async function executeStepWithHealing(params) {
11321
13026
  isFinalStep,
11322
13027
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11323
13028
  })) {
11324
- const fallbackTarget = await probeChildFrameSubmitFallback({ page, signalCounter, step });
13029
+ const fallbackTarget = await probeChildFrameSubmitFallback({
13030
+ page,
13031
+ signalCounter,
13032
+ step,
13033
+ isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
13034
+ step,
13035
+ submitStep,
13036
+ isFinalStep,
13037
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
13038
+ }),
13039
+ });
11325
13040
  if (fallbackTarget) {
11326
13041
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} cascade-exhausted: no candidate resolvable in the declared/main frame, but a same-origin child iframe surfaced a submit-shaped candidate — clicked and verified there instead of failing`);
11327
13042
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
@@ -11378,7 +13093,7 @@ async function executeStepWithHealing(params) {
11378
13093
  if (dumpPath !== null) {
11379
13094
  logger.error(`${formatStepPrefix(stepIndex, totalSteps)} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);
11380
13095
  }
11381
- throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) failed verification after ${MAX_STEP_ATTEMPTS} attempts${dumpPath ? `; see ${dumpPath}` : ""}`, phantomClickAfterAttempt1 ? "phantom-click-exhausted" : "cascade-exhausted");
13096
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactSecrets)(step).slice(0, 60)}) failed verification after ${MAX_STEP_ATTEMPTS} attempts${dumpPath ? `; see ${dumpPath}` : ""}`, phantomClickAfterAttempt1 ? "phantom-click-exhausted" : "cascade-exhausted");
11382
13097
  }
11383
13098
  /**
11384
13099
  * Navigates `page` directly to `url` and waits for the SPA to hydrate,
@@ -11424,7 +13139,11 @@ async function executeNavigateStep(params) {
11424
13139
  */
11425
13140
  async function runHealingFlow(deps) {
11426
13141
  const { stagehand, page, steps, logger, anthropic, rephraseModel, uploadFixture, maxFlowMs } = deps;
13142
+ for (const secret of deps.secrets ?? [])
13143
+ (0, pii_redaction_1.registerSecretValue)(secret);
11427
13144
  const counter = { n: 0 };
13145
+ // Rebound when a step's click opens a new tab, so later steps drive that page.
13146
+ let activePage = page;
11428
13147
  const signalCounter = { n: 0 };
11429
13148
  const recentCaptures = [];
11430
13149
  const recentCaptureMeta = [];
@@ -11432,7 +13151,17 @@ async function runHealingFlow(deps) {
11432
13151
  let submitVerified = false;
11433
13152
  let submitStepSkipped = false;
11434
13153
  let lastStepIndex = -1;
11435
- const stopCapture = wireSignalCapture(page, {
13154
+ const liveOriginOf = (target) => {
13155
+ try {
13156
+ return new URL(target.url()).origin;
13157
+ }
13158
+ catch {
13159
+ return "";
13160
+ }
13161
+ };
13162
+ // Seeded so the first step does not re-gate; the initial gate lives with the caller's goto.
13163
+ let lastOrigin = liveOriginOf(activePage);
13164
+ const wireCapture = (target) => wireSignalCapture(target, {
11436
13165
  counter,
11437
13166
  signalCounter,
11438
13167
  recentCaptures,
@@ -11440,13 +13169,15 @@ async function runHealingFlow(deps) {
11440
13169
  getCurrentPhase: () => "flow",
11441
13170
  getCurrentPageOrigin: () => {
11442
13171
  try {
11443
- return new URL(page.url()).origin;
13172
+ return new URL(activePage.url()).origin;
11444
13173
  }
11445
13174
  catch {
11446
13175
  return "";
11447
13176
  }
11448
13177
  },
11449
13178
  });
13179
+ // Listeners on earlier pages stay wired (a prior tab can still emit) and all stop on exit.
13180
+ const stopFns = [wireCapture(page)];
11450
13181
  const flowHasSubmitSemanticsFlag = flowHasSubmitSemantics({
11451
13182
  steps,
11452
13183
  submitEndpointPattern: deps.submitEndpointPattern ?? null,
@@ -11473,19 +13204,27 @@ async function runHealingFlow(deps) {
11473
13204
  // legitimately-absent optional step, and the flow's own step count
11474
13205
  // never quietly reaches `steps.length` past the point of death.
11475
13206
  try {
11476
- page.url();
13207
+ activePage.url();
11477
13208
  }
11478
13209
  catch (err) {
11479
13210
  throw new errors_2.SessionTimeoutError(`${formatStepPrefix(i, () => steps.length)} session appears closed/dead (page.url() threw: ${(0, errors_1.toErrorMessage)(err)}) — aborting after ${i} of ${steps.length} steps completed`);
11480
13211
  }
11481
13212
  lastStepIndex = i;
13213
+ // A cross-origin hop (redirect, new tab, navigateTo) lands on a SPA the initial
13214
+ // readiness gate never covered; wait for hydration before the step probes a shell.
13215
+ const currentOrigin = liveOriginOf(activePage);
13216
+ if (currentOrigin !== "" && currentOrigin !== lastOrigin) {
13217
+ logger.info(`origin changed ${lastOrigin || "(none)"} → ${currentOrigin}; re-gating on SPA hydration`);
13218
+ await (0, spa_readiness_1.regateSpaAfterOriginChange)(activePage, logger);
13219
+ lastOrigin = currentOrigin;
13220
+ }
11482
13221
  // A navigateTo step is a direct page.goto, not an act/observe against
11483
13222
  // the current DOM, so it has no target frame to resolve and skips
11484
13223
  // straight to executeNavigateStep instead of the self-heal cascade.
11485
13224
  let stepPromise;
11486
13225
  if (s.navigateTo !== undefined) {
11487
13226
  stepPromise = executeNavigateStep({
11488
- page,
13227
+ page: activePage,
11489
13228
  url: s.navigateTo,
11490
13229
  optional: s.optional,
11491
13230
  logger,
@@ -11498,11 +13237,17 @@ async function runHealingFlow(deps) {
11498
13237
  // reachable, paralleling the recon CLI's per-step resolution. `resolveFrameTarget`
11499
13238
  // falls back to the main-frame target when `frameSelector` is null/unresolvable,
11500
13239
  // so this is a no-op for every flow that doesn't declare one.
11501
- const frameTarget = await (0, frame_target_1.resolveFrameTarget)(page, deps.frameSelector);
13240
+ const frameTarget = await (0, frame_target_1.resolveFrameTarget)(activePage, deps.frameSelector);
11502
13241
  await (0, frame_target_1.waitForChildFrameReady)(frameTarget);
11503
13242
  stepPromise = executeStepWithHealing({
11504
13243
  stagehand,
11505
- page,
13244
+ page: activePage,
13245
+ onPageAdopted: (adopted) => {
13246
+ if (adopted === activePage)
13247
+ return;
13248
+ activePage = adopted;
13249
+ stopFns.push(wireCapture(adopted));
13250
+ },
11506
13251
  abortSignal: deps.abortSignal,
11507
13252
  step: s.instruction,
11508
13253
  optional: s.optional,
@@ -11548,7 +13293,7 @@ async function runHealingFlow(deps) {
11548
13293
  // runHealingFlow resolve as if the flow succeeded. Mirrors the same
11549
13294
  // gate in recon-browser.ts's step loop.
11550
13295
  try {
11551
- page.url();
13296
+ activePage.url();
11552
13297
  }
11553
13298
  catch (err) {
11554
13299
  throw new errors_2.SessionTimeoutError(`${formatStepPrefix(i, () => steps.length)} session appears closed/dead (page.url() threw: ${(0, errors_1.toErrorMessage)(err)}) after step ${i} of ${steps.length} resolved`);
@@ -11563,7 +13308,8 @@ async function runHealingFlow(deps) {
11563
13308
  }
11564
13309
  }
11565
13310
  finally {
11566
- stopCapture();
13311
+ for (const stop of stopFns)
13312
+ stop();
11567
13313
  }
11568
13314
  return { submitVerified, submitStepSkipped, lastStepIndex };
11569
13315
  }