@enricai/barnacle 1.12.79 → 1.12.81

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 +53 -0
  60. package/dist/scraper/click-settle.d.ts.map +1 -0
  61. package/dist/scraper/click-settle.js +135 -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 +347 -6
  68. package/dist/scraper/flow-runner.d.ts.map +1 -1
  69. package/dist/scraper/flow-runner.js +2457 -357
  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 +18 -3
  98. package/dist/scraper/stagehand-guard.d.ts.map +1 -1
  99. package/dist/scraper/stagehand-guard.js +198 -12
  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 +133 -0
  106. package/dist/scraper/trusted-click.d.ts.map +1 -0
  107. package/dist/scraper/trusted-click.js +205 -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 +110 -9
  122. package/dist/scripts/recon-generate.d.ts.map +1 -1
  123. package/dist/scripts/recon-generate.js +962 -183
  124. package/dist/scripts/recon-generate.js.map +1 -1
  125. package/dist/scripts/recon-shared.d.ts +8 -0
  126. package/dist/scripts/recon-shared.d.ts.map +1 -1
  127. package/dist/scripts/recon-shared.js +14 -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,12 +14,14 @@
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;
24
+ exports.settleWithPageQuiescence = settleWithPageQuiescence;
23
25
  exports.snapshotPage = snapshotPage;
24
26
  exports.resolveDumpPageIdentity = resolveDumpPageIdentity;
25
27
  exports.findRecentBackendError = findRecentBackendError;
@@ -29,7 +31,10 @@ exports.isAdvanceStep = isAdvanceStep;
29
31
  exports.isSubmitIntentStep = isSubmitIntentStep;
30
32
  exports.stripNegatedSubmitClauses = stripNegatedSubmitClauses;
31
33
  exports.classifyStepSubmitIntent = classifyStepSubmitIntent;
34
+ exports.isStepDeclaredNonSubmit = isStepDeclaredNonSubmit;
32
35
  exports.isStepSubmitShaped = isStepSubmitShaped;
36
+ exports.isStepSettleWaitShaped = isStepSettleWaitShaped;
37
+ exports.isMidFlowSubmitShapedStep = isMidFlowSubmitShapedStep;
33
38
  exports.isCheckboxOrRadioIntentStep = isCheckboxOrRadioIntentStep;
34
39
  exports.shouldCaptureSelectionState = shouldCaptureSelectionState;
35
40
  exports.flowHasSubmitSemantics = flowHasSubmitSemantics;
@@ -41,6 +46,7 @@ exports.windowHasTransitionBody = windowHasTransitionBody;
41
46
  exports.windowHasAdvanceTransition = windowHasAdvanceTransition;
42
47
  exports.shouldVetoFallbackAdvance = shouldVetoFallbackAdvance;
43
48
  exports.isDomOnlyAdvanceVerified = isDomOnlyAdvanceVerified;
49
+ exports.isNullVerdictAdvanceCredited = isNullVerdictAdvanceCredited;
44
50
  exports.isClickViewSwapVerified = isClickViewSwapVerified;
45
51
  exports.selectionCountFromSignature = selectionCountFromSignature;
46
52
  exports.shouldReadbackFillOnActSuccess = shouldReadbackFillOnActSuccess;
@@ -60,6 +66,8 @@ exports.formatValidationRejectedReason = formatValidationRejectedReason;
60
66
  exports.selectBodyExcerpt = selectBodyExcerpt;
61
67
  exports.extractLivePageFormEvidence = extractLivePageFormEvidence;
62
68
  exports.probeLeafInvalidContainers = probeLeafInvalidContainers;
69
+ exports.buildSubmitJudgeStepContext = buildSubmitJudgeStepContext;
70
+ exports.judgeStepOutcome = judgeStepOutcome;
63
71
  exports.renderLeafInvalidFields = renderLeafInvalidFields;
64
72
  exports.extractSubmitFailureEvidence = extractSubmitFailureEvidence;
65
73
  exports.extractGaEventEvidence = extractGaEventEvidence;
@@ -68,16 +76,25 @@ exports.fillHtml5DateTimeInput = fillHtml5DateTimeInput;
68
76
  exports.fillTextDatepickerInput = fillTextDatepickerInput;
69
77
  exports.verifyFillReadback = verifyFillReadback;
70
78
  exports.verifyPromptSelectorCommitted = verifyPromptSelectorCommitted;
79
+ exports.tailRetargetPhrasesForStep = tailRetargetPhrasesForStep;
80
+ exports.stepQuotesValueNotControlName = stepQuotesValueNotControlName;
71
81
  exports.isUploadAffordanceLabel = isUploadAffordanceLabel;
72
82
  exports.writeFixtureToTempFile = writeFixtureToTempFile;
73
83
  exports.attachToSurfacedInput = attachToSurfacedInput;
74
84
  exports.surfaceAndUpload = surfaceAndUpload;
85
+ exports.clickUploadAffordance = clickUploadAffordance;
86
+ exports.setFilesViaCdp = setFilesViaCdp;
75
87
  exports.parseSelectStep = parseSelectStep;
76
88
  exports.parseFillStep = parseFillStep;
77
89
  exports.parseFillValueIntent = parseFillValueIntent;
90
+ exports.parseSegmentedDateValue = parseSegmentedDateValue;
91
+ exports.resolveSegmentedDateGroup = resolveSegmentedDateGroup;
92
+ exports.parseSegmentedPartStep = parseSegmentedPartStep;
93
+ exports.trySegmentedDateSpinbuttonPrimitive = trySegmentedDateSpinbuttonPrimitive;
78
94
  exports.parseRadioStep = parseRadioStep;
79
95
  exports.parseWidgetOptionClickStep = parseWidgetOptionClickStep;
80
96
  exports.parseCheckStep = parseCheckStep;
97
+ exports.parseCheckStateStep = parseCheckStateStep;
81
98
  exports.pollEnumerate = pollEnumerate;
82
99
  exports.waitForTransitionBody = waitForTransitionBody;
83
100
  exports.injectCaptchaTokenAndSubmit = injectCaptchaTokenAndSubmit;
@@ -85,8 +102,11 @@ exports.submitCaptchaGatedForm = submitCaptchaGatedForm;
85
102
  exports.trySelectPrimitive = trySelectPrimitive;
86
103
  exports.chooseRequiredSelectOption = chooseRequiredSelectOption;
87
104
  exports.tryFillRequiredSelectsPrimitive = tryFillRequiredSelectsPrimitive;
105
+ exports.tryCheckboxStatePrimitive = tryCheckboxStatePrimitive;
88
106
  exports.buildRadioIdXPath = buildRadioIdXPath;
89
107
  exports.selectRadioGroupOption = selectRadioGroupOption;
108
+ exports.applyRadioSelection = applyRadioSelection;
109
+ exports.dismissLeftoverPromptPopups = dismissLeftoverPromptPopups;
90
110
  exports.hasUnfilledRequiredControlForStep = hasUnfilledRequiredControlForStep;
91
111
  exports.simulateDragDropUpload = simulateDragDropUpload;
92
112
  exports.dispatchJqueryChangeEvent = dispatchJqueryChangeEvent;
@@ -98,6 +118,8 @@ exports.tryDeterministicFieldLabelActuation = tryDeterministicFieldLabelActuatio
98
118
  exports.probeStepBeforeAttempts = probeStepBeforeAttempts;
99
119
  exports.extractLinkFromMessage = extractLinkFromMessage;
100
120
  exports.extractCodeFromMessage = extractCodeFromMessage;
121
+ exports.spliceEmailStepFillValue = spliceEmailStepFillValue;
122
+ exports.primitiveOptionLabel = primitiveOptionLabel;
101
123
  exports.executeStepWithHealing = executeStepWithHealing;
102
124
  exports.executeNavigateStep = executeNavigateStep;
103
125
  exports.runHealingFlow = runHealingFlow;
@@ -113,15 +135,20 @@ const error_messages_1 = require("../lib/llm/judges/error-messages");
113
135
  const invalid_fields_1 = require("../lib/llm/judges/invalid-fields");
114
136
  const modal_priority_1 = require("../lib/llm/judges/modal-priority");
115
137
  const select_option_1 = require("../lib/llm/judges/select-option");
138
+ const verify_step_advanced_1 = require("../lib/llm/judges/verify-step-advanced");
116
139
  const verify_submit_1 = require("../lib/llm/judges/verify-submit");
117
140
  const schemas_1 = require("../lib/llm/schemas");
118
141
  const logging_1 = require("../lib/logging");
142
+ const pii_redaction_1 = require("../lib/pii-redaction");
119
143
  const call_capture_1 = require("../lib/telemetry/call-capture");
120
144
  const call_types_1 = require("../lib/telemetry/call-types");
121
145
  const capture_filters_1 = require("../recon/capture-filters");
146
+ const actionable_visibility_expr_1 = require("./actionable-visibility-expr");
147
+ const active_page_1 = require("./active-page");
122
148
  const browser_click_expr_1 = require("./browser-click-expr");
123
149
  const captcha_callback_capture_1 = require("./captcha-callback-capture");
124
150
  const captcha_solver_1 = require("./captcha-solver");
151
+ const click_settle_1 = require("./click-settle");
125
152
  const deep_locator_actuate_1 = require("./deep-locator-actuate");
126
153
  const deep_locator_candidates_1 = require("./deep-locator-candidates");
127
154
  const deep_locator_click_1 = require("./deep-locator-click");
@@ -129,11 +156,14 @@ const deep_locator_scan_1 = require("./deep-locator-scan");
129
156
  const errors_2 = require("./errors");
130
157
  const frame_target_1 = require("./frame-target");
131
158
  const phantom_click_1 = require("./phantom-click");
159
+ const quoted_spans_1 = require("./quoted-spans");
132
160
  const session_teardown_1 = require("./session-teardown");
133
161
  const spa_readiness_1 = require("./spa-readiness");
134
162
  const stagehand_guard_1 = require("./stagehand-guard");
135
163
  const submit_control_1 = require("./submit-control");
136
164
  const target_resolution_diagnostic_1 = require("./target-resolution-diagnostic");
165
+ const trusted_click_1 = require("./trusted-click");
166
+ const upload_affordance_expr_1 = require("./upload-affordance-expr");
137
167
  const watchdog_1 = require("./watchdog");
138
168
  const recon_shared_1 = require("../scripts/recon-shared");
139
169
  const client_1 = require("../testmail/client");
@@ -368,7 +398,9 @@ function wireSignalCapture(page, params) {
368
398
  recentCaptureMeta.splice(0, recentCaptureMeta.length - RECENT_CAPTURES_WINDOW);
369
399
  }
370
400
  }
371
- logger.info(`captured [${capture.status}] ${capture.method} ${req.url} → ${filename}`);
401
+ // Query strings carry form GETs and analytics beacons (often form-field
402
+ // values); origin + path is enough to follow the request sequence.
403
+ logger.info(`captured [${capture.status}] ${capture.method} ${req.url.split("?")[0]} → ${filename}`);
372
404
  };
373
405
  session.on("Network.requestWillBeSent", onRequest);
374
406
  session.on("Network.requestWillBeSentExtraInfo", onRequestExtraInfo);
@@ -618,11 +650,13 @@ const PROMPT_VALUE_SELECTORS = [
618
650
  ].join(",");
619
651
  /**
620
652
  * The widget-kit's empty-state value text ("0 items selected") — a filled
621
- * widget's value node reads the chosen option instead. Matched
653
+ * widget's value node reads the chosen option instead — as is a generic
654
+ * placeholder prompt ("Select One", "Choose a country") a trigger button shows
655
+ * until a value is picked. Matched
622
656
  * case-insensitively as a whole phrase so a non-empty selection ("1 item
623
657
  * selected, …") is correctly read as filled.
624
658
  */
625
- const PROMPT_EMPTY_VALUE_RX = /^\s*0\s+items?\s+selected\s*$/i;
659
+ 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
660
  /**
627
661
  * {@link PROMPT_EMPTY_VALUE_RX}'s source and flags as plain strings, passed into
628
662
  * the browser-evaluated expressions to reconstruct the RegExp there. Kept as
@@ -695,12 +729,87 @@ const PROMPT_CURRENT_TEXT_EXPR = `((w, valueSel, emptyRx) => {
695
729
  const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
696
730
  if (adid) { const opt = document.getElementById(adid); if (opt && optionLabel(opt)) return norm(optionLabel(opt)); }
697
731
  if (w.tagName === "INPUT" && (w.value || "").trim()) return norm(w.value);
698
- if (w.tagName === "BUTTON" && buttonValue(w).trim()) return norm(buttonValue(w));
732
+ if (w.tagName === "BUTTON" && buttonValue(w).trim() && !emptyRx.test(buttonValue(w).trim())) return norm(buttonValue(w));
699
733
  const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
700
734
  const raw = lbl ? (lbl.textContent || "") : "";
701
735
  if (raw.trim() && !emptyRx.test(raw.trim())) return norm(raw);
702
736
  return "";
703
737
  })`;
738
+ /**
739
+ * Browser-side predicate: does a widget's committed text name the requested
740
+ * option as a whole phrase? A bare substring test credits "Astrophysics" for
741
+ * "Physics"; comparing on word boundaries (punctuation folded to spaces) keeps
742
+ * chip lists and "N items selected, X" announcements matching while a longer,
743
+ * different option no longer does.
744
+ */
745
+ const PROMPT_NAMES_OPTION_EXPR = `((committed, wanted) => {
746
+ const words = (s) => " " + (s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim() + " ";
747
+ const want = words(wanted);
748
+ return want.trim() === "" || words(committed).includes(want);
749
+ })`;
750
+ /**
751
+ * Browser-side expression reading what a prompt-selector widget ANNOUNCES it
752
+ * committed, lowercased and whitespace-collapsed, or `""` when nothing is
753
+ * readable. The announcement is ground truth over any inferred state: first the
754
+ * "N items selected, X" `aria-describedby` node on the container or one of its
755
+ * descendants (the trigger union may resolve to the OUTER container, so the
756
+ * container's own attribute alone misses it), else the nearest
757
+ * `selectedItemList` — while a popup is open the description can read only
758
+ * "Expanded", hence the fallback. Shared by the pick read-back and the
759
+ * act-success guard so the two never disagree on what the widget committed.
760
+ */
761
+ const PROMPT_ANNOUNCED_VALUE_EXPR = `((w) => {
762
+ const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
763
+ const announceRxs = [
764
+ /\\d+\\s+items?\\s+selected\\s*,?\\s*(.*)$/,
765
+ /^(?:currently\\s+)?selected(?:\\s+(?:item|option|value))?\\s*[:,-]\\s*(.+)$/,
766
+ /^(.+?)\\s+(?:is\\s+)?selected\\.?$/,
767
+ ];
768
+ for (const el of [w, ...Array.from(w.querySelectorAll("[aria-describedby]"))]) {
769
+ for (const ref of (el.getAttribute("aria-describedby") || "").split(/\\s+/)) {
770
+ const refEl = ref ? document.getElementById(ref) : null;
771
+ const said = refEl ? norm(refEl.textContent) : "";
772
+ for (const rx of announceRxs) {
773
+ const m = rx.exec(said);
774
+ if (m && m[1] && !/^\\d+\\s+items?$/.test(m[1])) return m[1];
775
+ }
776
+ }
777
+ }
778
+ let anode = w;
779
+ for (let d = 0; d < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && anode; d++) {
780
+ const list = anode.querySelector ? anode.querySelector('[data-automation-id="selectedItemList"]') : null;
781
+ const entries = list ? Array.from(list.querySelectorAll("*")).filter((e) => e.children.length === 0).map((e) => norm(e.textContent)).filter(Boolean) : [];
782
+ if (entries.length > 0) return entries.join(" , ");
783
+ if (list && norm(list.textContent)) return norm(list.textContent);
784
+ anode = anode.parentElement;
785
+ }
786
+ return "";
787
+ })`;
788
+ /**
789
+ * Browser-side expression resolving ONE trigger-union hit to its widget
790
+ * container. A single widget often matches {@link PROMPT_TRIGGER_SELECTORS} more
791
+ * than once (e.g. an outer container AND its inner filter input both carry
792
+ * `data-uxi-widget-type`), so prefer a real interactive ancestor, else the
793
+ * OUTERMOST widget-kit container on the ancestor chain (so the inner input and
794
+ * its container collapse to the same node), else the element itself. Shared by
795
+ * widget enumeration and the option-scope ownership filter so both agree on what
796
+ * "a widget" is — including widgets that are already committed and so never
797
+ * stamped with the enumeration mark.
798
+ */
799
+ const PROMPT_WIDGET_CONTAINER_EXPR = `((el) => {
800
+ const interactive = el.closest("button,[role='button'],[role='combobox']");
801
+ if (interactive) return interactive;
802
+ const kitSel = "[data-uxi-widget-type='selectinput'],[data-uxi-widget-type='multiselect'],[data-automation-id='multiSelectContainer']";
803
+ let container = el;
804
+ let node = el.closest(kitSel);
805
+ while (node) {
806
+ container = node;
807
+ const up = node.parentElement && node.parentElement.closest(kitSel);
808
+ if (!up || up === node) break;
809
+ node = up;
810
+ }
811
+ return container;
812
+ })`;
704
813
  /**
705
814
  * Browser-side expression resolving the DOM root within which a chosen widget's
706
815
  * popup options / filter input live. Popup placement is vendor-split: the ARIA
@@ -837,6 +946,44 @@ const ATTEMPT_BACKOFF_MS = 1_000;
837
946
  * actionable.
838
947
  */
839
948
  const N16_TRUSTED_CLICK_TIMEOUT_MS = 3_000;
949
+ /**
950
+ * Shared pre-click centre + hit-test for a Stagehand-resolved selector, so
951
+ * observe-act and the natural-language act attempts share one routine.
952
+ * Returns a failure message when the control cannot be centred and hit-tested
953
+ * (covered, unresolved, or the page cannot answer the probe), null otherwise.
954
+ */
955
+ async function centreObservedClickTarget(target, selector) {
956
+ try {
957
+ await (0, trusted_click_1.centreSelectorForClick)(target, selector);
958
+ return null;
959
+ }
960
+ catch (err) {
961
+ return `observed click target not clickable: ${(0, errors_1.toErrorMessage)(err)}`;
962
+ }
963
+ }
964
+ /**
965
+ * Builds an in-page expression that finds the marker-stamped element, piercing
966
+ * open shadow roots (plain `querySelector` cannot), and centre + hit-tests it.
967
+ */
968
+ function buildDeepMarkerCentreExpr(markerSelector) {
969
+ return `(() => {
970
+ const sel = ${JSON.stringify(markerSelector)};
971
+ const find = (root) => {
972
+ const hit = root.querySelector(sel);
973
+ if (hit) return hit;
974
+ for (const n of root.querySelectorAll("*")) {
975
+ if (n.shadowRoot) {
976
+ const r = find(n.shadowRoot);
977
+ if (r) return r;
978
+ }
979
+ }
980
+ return null;
981
+ };
982
+ const el = find(document);
983
+ if (!el) return { state: "missing" };
984
+ return (${trusted_click_1.CENTRE_AND_HIT_TEST_FN}).call(el);
985
+ })()`;
986
+ }
840
987
  /**
841
988
  * Per-step watchdog passed as Stagehand's native `timeout` on every
842
989
  * `act()`/`observe()`. Stagehand's `ensureTimeRemaining()` checks the
@@ -955,6 +1102,10 @@ function prepareFailureDumpBody(raw) {
955
1102
  * entry, so the weak page-wide signature and the strict per-element gate
956
1103
  * agree on exactly what counts as a class-token selection marker.
957
1104
  */
1105
+ /** Shared by both checkbox credit sites so they agree on what "aria-managed" reads. */
1106
+ function ariaCheckedReadExpr(elVar) {
1107
+ return `${elVar}.getAttribute("aria-checked")`;
1108
+ }
958
1109
  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
1110
  /** Caps mirroring {@link DOM_SNAPSHOT_EXPR}'s truncation so a pathological page can't bloat the failure bundle. */
960
1111
  const FIELD_VALUES_AT_FAILURE_MAX_CONTROLS = 200;
@@ -1005,6 +1156,14 @@ function fieldValuesAtFailureExpr() {
1005
1156
  return out;
1006
1157
  })()`;
1007
1158
  }
1159
+ /**
1160
+ * Settles after a click using the target's own text signature and in-page
1161
+ * quiescence reader, so every post-click site decides from the same settle
1162
+ * signal instead of hand-building the readers.
1163
+ */
1164
+ async function settleWithPageQuiescence(page, target, signalCounter, preNetworkCount, submitShaped, preVisibleTextSignature) {
1165
+ await (0, click_settle_1.settleAfterClick)(page, signalCounter, preNetworkCount, submitShaped, async () => (await snapshotPage(target, signalCounter)).visibleTextSignature, preVisibleTextSignature, async () => target.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR));
1166
+ }
1008
1167
  /**
1009
1168
  * Captures the pre/post signal triple the submit-verify cascade diffs.
1010
1169
  * Accepts the optional `page` so a resolved child `FrameTarget` whose
@@ -1243,6 +1402,25 @@ const ADVANCE_STEP_PHRASES = [
1243
1402
  "proceed to the next",
1244
1403
  "advance to the next",
1245
1404
  ];
1405
+ /** Advance-click step wording ("Next"/"Continue"/"Save and continue") for the n+16 error-summary veto. */
1406
+ exports.ADVANCE_CLICK_RX = /\b(next|continue|proceed|advance)\b/i;
1407
+ /**
1408
+ * Page-level error summary check, evaluated in the page after an advance
1409
+ * click: an error-summary container, a visible `role=alert` that mentions an
1410
+ * error, or a heading/button whose WHOLE text is "Errors Found" / "N errors
1411
+ * found" (prose such as "no errors found" is not a summary). Network and text
1412
+ * movement on a page showing its own validation summary is the form rejecting
1413
+ * the advance, not progress.
1414
+ */
1415
+ exports.ERROR_SUMMARY_EXPR = `(() => {
1416
+ const vis = (e) => !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length);
1417
+ const text = (e) => (e.textContent || "").trim();
1418
+ const summarySel = '[class*="error-summary" i],[class*="errorsummary" i],[class*="validation-summary" i],[id*="error-summary" i]';
1419
+ if (Array.from(document.querySelectorAll(summarySel)).some((e) => vis(e) && text(e))) return true;
1420
+ if (Array.from(document.querySelectorAll('[role="alert"]')).some((e) => vis(e) && /\\berrors?\\b/i.test(text(e)))) return true;
1421
+ const titles = 'h1,h2,h3,h4,h5,h6,[role="heading"],button,[role="button"]';
1422
+ return Array.from(document.querySelectorAll(titles)).some((e) => vis(e) && /^(?:\\d+\\s+)?errors?\\s+found$/i.test(text(e)));
1423
+ })()`;
1246
1424
  /**
1247
1425
  * Is this flow step an advance/"Next" click (move to the next wizard page),
1248
1426
  * rather than a field-answer? Used to veto a DOM-only "success" (a field toggle)
@@ -1290,19 +1468,18 @@ function isSubmitIntentStep(instruction) {
1290
1468
  }
1291
1469
  /**
1292
1470
  * 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.
1471
+ * order/submit action, a purchase-action control named directly ("Book Now",
1472
+ * "Reserve", "Add to Cart"), or a "proceeds/leads/advances to" phrase naming one.
1296
1473
  */
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)?)";
1474
+ 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
1475
  /**
1299
1476
  * A negation followed, in the same clause, by a submit target ("Do NOT click
1300
1477
  * ... any control that proceeds to checkout"), or an explicit "this is not a
1301
1478
  * submit click" / "without submitting" declaration.
1302
1479
  */
1303
1480
  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;
1481
+ /** A positive submit imperative outside any negated clause ("Submit the form.", "Place the order."). */
1482
+ 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
1483
  /**
1307
1484
  * The instruction with every negated submit clause blanked, so callers that
1308
1485
  * match submit wording on the step text (flow-load seeding) read only what the
@@ -1328,6 +1505,14 @@ function classifyStepSubmitIntent(instruction) {
1328
1505
  return "non-submit";
1329
1506
  return isSubmitIntentStep(instruction) ? "submit" : "unspecified";
1330
1507
  }
1508
+ /**
1509
+ * Single derivation of "this step's own instruction declares it non-submit",
1510
+ * so every submit-judge gate honours the veto identically. An explicit
1511
+ * `submitStep` flag always overrides the declaration.
1512
+ */
1513
+ function isStepDeclaredNonSubmit(params) {
1514
+ return !params.submitStep && classifyStepSubmitIntent(params.step) === "non-submit";
1515
+ }
1331
1516
  /**
1332
1517
  * Single seam answering "is this step submit-shaped?": an explicit `submitStep`
1333
1518
  * flag is authoritative, a declared non-submit instruction vetoes every
@@ -1337,10 +1522,26 @@ function isStepSubmitShaped(params) {
1337
1522
  const { step, submitStep, isFinalStep, flowHasSubmitSemantics: flowSubmit } = params;
1338
1523
  if (submitStep)
1339
1524
  return true;
1340
- const intent = classifyStepSubmitIntent(step);
1341
- if (intent === "non-submit")
1525
+ if (isStepDeclaredNonSubmit({ step, submitStep }))
1526
+ return false;
1527
+ return (isFinalStep && flowSubmit) || classifyStepSubmitIntent(step) === "submit";
1528
+ }
1529
+ /**
1530
+ * Whether a click may need the long idle settle wait: a submit-shaped step or
1531
+ * one naming an advance word, since both can start their round-trip late.
1532
+ */
1533
+ function isStepSettleWaitShaped(params) {
1534
+ return isStepSubmitShaped(params) || isAdvanceStep(params.step);
1535
+ }
1536
+ /**
1537
+ * Submit-shaped but NOT the flow's final step: a mid-flow advancing submit
1538
+ * (e.g. account creation) that can be credited on a demonstrable page advance
1539
+ * instead of needing the final submit's network/URL proof.
1540
+ */
1541
+ function isMidFlowSubmitShapedStep(params) {
1542
+ if (params.isFinalStep)
1342
1543
  return false;
1343
- return (isFinalStep && flowSubmit) || intent === "submit";
1544
+ return isStepSubmitShaped(params) || params.elementSubmitShaped === true;
1344
1545
  }
1345
1546
  /**
1346
1547
  * Is this flow step's intent to check a checkbox or select a radio option?
@@ -1396,9 +1597,16 @@ function shouldCaptureSelectionState(params) {
1396
1597
  function flowHasSubmitSemantics(params) {
1397
1598
  const { steps, submitEndpointPattern, requireSubmitEndpointMatch } = params;
1398
1599
  const hasExplicitSubmitStep = steps.some((s) => s.submitStep);
1399
- return hasExplicitSubmitStep
1400
- ? (steps[steps.length - 1]?.submitStep ?? false)
1401
- : submitEndpointPattern !== null || requireSubmitEndpointMatch;
1600
+ if (hasExplicitSubmitStep)
1601
+ return steps[steps.length - 1]?.submitStep ?? false;
1602
+ if (submitEndpointPattern === null && !requireSubmitEndpointMatch)
1603
+ return false;
1604
+ // An earlier step whose own wording declares submit already owns the role;
1605
+ // the fallback is only for a self-heal-appended final step.
1606
+ const earlierStepOwnsSubmit = steps
1607
+ .slice(0, -1)
1608
+ .some((s) => classifyStepSubmitIntent(s.instruction) === "submit");
1609
+ return !earlierStepOwnsSubmit;
1402
1610
  }
1403
1611
  /**
1404
1612
  * Whether a flow should be WARNed that its DOM-only advance guard is disarmed.
@@ -1617,6 +1825,20 @@ function isDomOnlyAdvanceVerified(params) {
1617
1825
  // non-advancing POST) does not verify it.
1618
1826
  return networkIsRealAdvance || urlChanged;
1619
1827
  }
1828
+ /**
1829
+ * Decides whether a non-final submit-shaped step is credited when the outcome
1830
+ * judge returned no verdict. An unavailable judge is missing evidence, not a
1831
+ * rejection, so strong structural click signals (a real network advance or a
1832
+ * demonstrable page advance) credit the step instead of failing it and
1833
+ * re-clicking at the same position. Final steps and weak-signal cases return
1834
+ * false and keep the DOM-state fallback / failure path.
1835
+ */
1836
+ function isNullVerdictAdvanceCredited(params) {
1837
+ if (!params.midFlowSubmit || !params.judgeUnavailable || params.invalidMarkerCount > 0) {
1838
+ return false;
1839
+ }
1840
+ return params.networkAdvanced || params.pageAdvanced;
1841
+ }
1620
1842
  /**
1621
1843
  * Client-side view-swap verification gate. Credits a click that produces
1622
1844
  * substantial DOM growth with zero network as a verified step outcome when
@@ -1682,12 +1904,18 @@ function isDomOnlyAdvanceVerified(params) {
1682
1904
  * page), which also nets a large negative byte delta with zero network/url
1683
1905
  * and no text-signature match. A reset replaces the clicked control itself
1684
1906
  * along with everything else, whereas a genuine same-page toggle leaves the
1685
- * clicked control in place. `clickedElementStillPresent` — whether the
1907
+ * clicked control in place. A click-driven view swap (a login-to-registration
1908
+ * form swap, a chooser-to-form swap) also removes its own trigger because the
1909
+ * trigger belonged to the replaced view, but it GROWS the DOM, whereas a reset
1910
+ * reverts to an earlier, smaller screen. The veto therefore keys on absence
1911
+ * combined with a DOM that did not grow (`bytesDelta <= 0`), not on absence
1912
+ * alone. `clickedElementStillPresent` — whether the
1686
1913
  * resolved click target can still be found in the post-click DOM, from a
1687
1914
  * best-effort live probe — is `false` only on POSITIVE proof of absence;
1688
1915
  * when the probe can't run (non-xpath selector, evaluate failure) it stays
1689
1916
  * at its default `true` so it never manufactures a veto out of missing
1690
- * information. Only a confirmed `false` blocks the credit.
1917
+ * information. Only a confirmed `false` paired with a non-growing DOM blocks
1918
+ * the credit.
1691
1919
  *
1692
1920
  * **Destination-plausibility veto:** none of the signals above look at
1693
1921
  * WHERE the click landed. A non-submit click that bounces to a page
@@ -1699,15 +1927,43 @@ function isDomOnlyAdvanceVerified(params) {
1699
1927
  * no-veto behavior — matching the sibling `destinationPlausible` param on
1700
1928
  * `classifyPhantomClick` — so existing/future callers that don't thread it
1701
1929
  * are unaffected.
1930
+ *
1931
+ * **Mid-flow submit:** `midFlowSubmit` (see {@link isMidFlowSubmitShapedStep})
1932
+ * lets a submit-shaped, non-final step be credited when `pageAdvanced` (the
1933
+ * shared {@link hasPageAdvanced} judgement of the visible text; defaults to
1934
+ * `textChanged` when omitted) holds — byte delta is not required, since a
1935
+ * wizard swap can keep the HTML size — with the same invalid-marker and
1936
+ * destination vetoes. The clicked control being gone is NOT a veto here: a
1937
+ * wizard advance normally replaces the button along with the page. The
1938
+ * credit also holds when Stagehand reported a successful act with no
1939
+ * resolved action (`actSucceeded`) or a non-click, non-state-class method.
1940
+ * Omitted,
1941
+ * the submit-shaped rejection is unchanged.
1702
1942
  */
1703
1943
  function isClickViewSwapVerified(params) {
1704
1944
  const VIEW_SWAP_MIN_BYTES = config_1.config.scraper.viewSwapMinBytesThreshold;
1705
1945
  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;
1946
+ const { resolvedAction, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, clickedElementStillPresent = true, resolvedElementIsSubmitShaped = false, destinationPlausible, midFlowSubmit = false, pageAdvanced = textChanged, actSucceeded = false, } = params;
1947
+ // The page advancing is the evidence for a mid-flow submit, so it does not
1948
+ // depend on how Stagehand reported the gesture: a successful act that
1949
+ // listed no actions, or a non-state-class method, still ran a click-class
1950
+ // action. Mirrors the settle gate's `mayTriggerRoundTrip`.
1951
+ const midFlowActRan = resolvedAction === null ? actSucceeded : !STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
1952
+ if (midFlowSubmit &&
1953
+ midFlowActRan &&
1954
+ destinationPlausible !== false &&
1955
+ !isAdvanceWithPattern &&
1956
+ invalidMarkerDelta <= 0 &&
1957
+ textChanged &&
1958
+ pageAdvanced) {
1959
+ return true;
1960
+ }
1707
1961
  if (resolvedAction?.method !== "click")
1708
1962
  return false;
1709
1963
  if (destinationPlausible === false)
1710
1964
  return false;
1965
+ if (midFlowSubmit && (submitStep || resolvedElementIsSubmitShaped))
1966
+ return false;
1711
1967
  // Only the step's own explicit submitStep flag identifies the step that
1712
1968
  // actually needs network/URL verification — mirrors the submit-judge
1713
1969
  // gate's hasSubmitTransitionSignal discipline (see 5763ac2). Inferring
@@ -1730,7 +1986,7 @@ function isClickViewSwapVerified(params) {
1730
1986
  return false;
1731
1987
  if (invalidMarkerDelta > 0)
1732
1988
  return false;
1733
- if (!clickedElementStillPresent)
1989
+ if (!clickedElementStillPresent && bytesDelta <= 0)
1734
1990
  return false;
1735
1991
  if (Math.abs(bytesDelta) >= VIEW_SWAP_MIN_BYTES)
1736
1992
  return true;
@@ -2123,7 +2379,7 @@ For yes/no questions, choose the candidate-favorable answer ('No' for adverse qu
2123
2379
 
2124
2380
  `
2125
2381
  : "";
2126
- const prompt = `You are helping a browser automation agent recover from a failed step in a recon flow.
2382
+ const rawPrompt = `You are helping a browser automation agent recover from a failed step in a recon flow.
2127
2383
 
2128
2384
  ${redirectBlock}ORIGINAL INSTRUCTION:
2129
2385
  ${originalStep}
@@ -2159,6 +2415,10 @@ PAGE TRANSITION + VALIDATOR TELEMETRY (parsed from Google Analytics Measurement
2159
2415
  ${gaEventEvidence || "(none)"}
2160
2416
 
2161
2417
  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.`;
2418
+ // Request values reach the rephrase LLM only as %tokens%; the rewrite is
2419
+ // resolved back before the cascade acts on it (which re-tokenizes it for
2420
+ // Stagehand). A rewrite citing a token we never issued is unusable.
2421
+ const { text: prompt, variables } = (0, pii_redaction_1.tokenizeSensitiveValues)(rawPrompt);
2162
2422
  const model = client.modelId;
2163
2423
  const t0 = performance.now();
2164
2424
  try {
@@ -2188,7 +2448,9 @@ Rewrite the instruction so a Stagehand act() call can resolve it unambiguously t
2188
2448
  });
2189
2449
  if (parsed.outcome === "impossible")
2190
2450
  return null;
2191
- return parsed.instruction;
2451
+ if ((0, pii_redaction_1.unresolvedTokens)(parsed.instruction, variables).length > 0)
2452
+ return null;
2453
+ return (0, pii_redaction_1.detokenize)(parsed.instruction, variables);
2192
2454
  }
2193
2455
  catch (err) {
2194
2456
  const message = err instanceof Error ? err.message : String(err);
@@ -2596,6 +2858,107 @@ async function probeLeafInvalidContainers(target) {
2596
2858
  return [];
2597
2859
  }
2598
2860
  }
2861
+ /**
2862
+ * Reads live body text for the submit judge, because a SPA's page title often
2863
+ * stays stale after a mid-flow transition. Failure yields "" so the judge
2864
+ * simply reasons without it.
2865
+ */
2866
+ async function probeVisibleTextForJudge(target) {
2867
+ return (await probeRawVisibleText(target)).slice(0, 3000);
2868
+ }
2869
+ /**
2870
+ * Untruncated body text, so a settle signature built from it uses the same
2871
+ * unit (raw `innerText.length`) as {@link DOM_SNAPSHOT_EXPR}'s pre-click one.
2872
+ */
2873
+ async function probeRawVisibleText(target) {
2874
+ try {
2875
+ const text = await (0, watchdog_1.withWatchdog)(() => target.evaluate(`(() => (document.body ? document.body.innerText || "" : ""))()`), {
2876
+ timeoutMs: config_1.config.scraper.frameEvaluateTimeoutMs,
2877
+ label: "flow-runner: submit-verification visible text probe",
2878
+ });
2879
+ return typeof text === "string" ? text : "";
2880
+ }
2881
+ catch {
2882
+ return "";
2883
+ }
2884
+ }
2885
+ /**
2886
+ * Shared step-context slice of the submit judge's input, so every judge call
2887
+ * site tells the judge what the step was meant to do, whether it is the final
2888
+ * submit, and what the page shows now.
2889
+ */
2890
+ async function buildSubmitJudgeStepContext(params) {
2891
+ return {
2892
+ stepInstruction: params.step,
2893
+ isFinalStep: params.isFinalStep,
2894
+ visibleText: await probeVisibleTextForJudge(params.target),
2895
+ };
2896
+ }
2897
+ const STEP_REJUDGE_DELAY_MS = 5000;
2898
+ const STEP_REJUDGE_COMPARE_CHARS = 1200;
2899
+ /**
2900
+ * Single entry for both submit-verification judge sites. The final step keeps
2901
+ * the strict submit judge; a non-final step is judged on whether the page
2902
+ * advanced, against the settled post-click text, because the page is still
2903
+ * mid-transition when the click returns and a pre-transition read would make
2904
+ * a successful advance look like no change.
2905
+ */
2906
+ async function judgeStepOutcome(params) {
2907
+ const { client, captureFn, step, isFinalStep, target } = params;
2908
+ if (isFinalStep) {
2909
+ return (0, verify_submit_1.verifySubmitWithLLM)({
2910
+ client,
2911
+ input: {
2912
+ ...(await buildSubmitJudgeStepContext({ step, isFinalStep, target })),
2913
+ ...params.submitInput,
2914
+ },
2915
+ captureFn,
2916
+ });
2917
+ }
2918
+ await (0, click_settle_1.settleAfterClick)(params.page, params.signalCounter, params.preNetworkCount, isStepSettleWaitShaped({ step, submitStep: false, isFinalStep, flowHasSubmitSemantics: false }), async () => {
2919
+ const text = await probeRawVisibleText(target);
2920
+ return `${text.length}:${text.slice(0, 200)}`;
2921
+ }, params.preVisibleTextSignature, async () => target.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR));
2922
+ const colon = params.preVisibleTextSignature.indexOf(":");
2923
+ const beforeText = (colon === -1 ? params.preVisibleTextSignature : params.preVisibleTextSignature.slice(colon + 1))
2924
+ .replace(/\s+/g, " ")
2925
+ .trim();
2926
+ const judgeText = (afterText) => (0, verify_step_advanced_1.verifyStepAdvancedWithLLM)({
2927
+ client,
2928
+ input: {
2929
+ stepInstruction: step,
2930
+ beforeText,
2931
+ afterText,
2932
+ invalidMarkerCount: params.submitInput.invalidMarkerCount,
2933
+ },
2934
+ captureFn,
2935
+ });
2936
+ const rejudgeIfTextMoved = async (first, judgedText) => {
2937
+ if (first === null || first.advanced)
2938
+ return first;
2939
+ await params.page.waitForTimeout(STEP_REJUDGE_DELAY_MS);
2940
+ const laterText = await probeVisibleTextForJudge(target);
2941
+ if (laterText.slice(0, STEP_REJUDGE_COMPARE_CHARS) ===
2942
+ judgedText.slice(0, STEP_REJUDGE_COMPARE_CHARS)) {
2943
+ return first;
2944
+ }
2945
+ return (await judgeText(laterText)) ?? first;
2946
+ };
2947
+ const afterText = await probeVisibleTextForJudge(target);
2948
+ const firstVerdict = await judgeText(afterText);
2949
+ const verdict = await rejudgeIfTextMoved(firstVerdict, afterText);
2950
+ if (verdict === null)
2951
+ return null;
2952
+ return verdict.advanced
2953
+ ? {
2954
+ verified: true,
2955
+ network_signal: null,
2956
+ dom_signal: null,
2957
+ url_signal: null,
2958
+ rationale: verdict.reason,
2959
+ }
2960
+ : { verified: false, reason: verdict.reason };
2961
+ }
2599
2962
  /**
2600
2963
  * Render a list of structured leaf-invalid records as the numbered evidence
2601
2964
  * lines the prompt's FORM FIELDS section expects. Surfaces label + framework
@@ -3018,7 +3381,7 @@ async function dispatchEnterKey(target, xpath) {
3018
3381
  function isCommittedDateReadback(readback) {
3019
3382
  if (readback === null)
3020
3383
  return false;
3021
- if (readback.outcome === "rejected")
3384
+ if (readback.outcome === "rejected" || readback.outcome === "segment")
3022
3385
  return false;
3023
3386
  // A datepicker reformats what it accepts (e.g. "01/2020" → "2020-01"), so
3024
3387
  // "differs" is a commit, not a rejection — require only a non-empty value
@@ -3113,8 +3476,7 @@ async function fillTextDatepickerInput(target, selector, value) {
3113
3476
  // Strategy 2 — open-and-pick. Required for picker-only widgets (the reported
3114
3477
  // month/year picker ignores typed input entirely).
3115
3478
  try {
3116
- const locator = target.locator(selector).first();
3117
- await locator.click();
3479
+ await (0, trusted_click_1.clickCentredTrusted)(target, selector, { label: "date input" });
3118
3480
  }
3119
3481
  catch {
3120
3482
  return { filled: false, postValue: "", strategy: "none" };
@@ -3208,6 +3570,10 @@ async function verifyFillReadback(target, selector, expectedValue) {
3208
3570
  if (!el) return null;
3209
3571
  const tag = el.tagName ? el.tagName.toLowerCase() : "";
3210
3572
  let actual = "";
3573
+ ${SEGMENT_OWNER_JS}
3574
+ if (isDateSegment(el)) {
3575
+ return { outcome: "segment", postValue: el.value || el.getAttribute("aria-valuenow") || "", tag };
3576
+ }
3211
3577
  if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
3212
3578
  actual = el.value || "";
3213
3579
  } else if (el.isContentEditable) {
@@ -3226,8 +3592,12 @@ async function verifyFillReadback(target, selector, expectedValue) {
3226
3592
  if (raw === null || typeof raw !== "object")
3227
3593
  return null;
3228
3594
  const r = raw;
3229
- if (r.outcome !== "matched" && r.outcome !== "rejected" && r.outcome !== "differs")
3595
+ if (r.outcome !== "matched" &&
3596
+ r.outcome !== "rejected" &&
3597
+ r.outcome !== "differs" &&
3598
+ r.outcome !== "segment") {
3230
3599
  return null;
3600
+ }
3231
3601
  return {
3232
3602
  outcome: r.outcome,
3233
3603
  postValue: typeof r.postValue === "string" ? r.postValue : "",
@@ -3251,8 +3621,13 @@ async function verifyFillReadback(target, selector, expectedValue) {
3251
3621
  * `tryPromptSelectorPrimitive`'s widget-container walk). Returns
3252
3622
  * `isPromptWidget: false` for a resolved element that isn't part of this
3253
3623
  * widget family at all, so a non-prompt click/fill is untouched.
3624
+ *
3625
+ * When the step names the option it wants (`requestedOption`), "committed"
3626
+ * means the widget's announced/readable value names THAT option: a widget that
3627
+ * holds a different option is a wrong commit, not a landed one. Without a
3628
+ * requested option any readable value counts, as before.
3254
3629
  */
3255
- async function verifyPromptSelectorCommitted(target, selector) {
3630
+ async function verifyPromptSelectorCommitted(target, selector, requestedOption) {
3256
3631
  const xpath = xpathBodyForEvaluate(selector);
3257
3632
  if (xpath === null)
3258
3633
  return null;
@@ -3261,13 +3636,17 @@ async function verifyPromptSelectorCommitted(target, selector) {
3261
3636
  const valueSel = ${JSON.stringify(PROMPT_VALUE_SELECTORS)};
3262
3637
  const emptyRx = new RegExp(${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)});
3263
3638
  const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
3639
+ const announcedValue = ${PROMPT_ANNOUNCED_VALUE_EXPR};
3640
+ const namesOption = ${PROMPT_NAMES_OPTION_EXPR};
3641
+ const want = ${JSON.stringify(requestedOption ?? "")};
3264
3642
  const xpath = ${JSON.stringify(xpath)};
3265
3643
  const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3266
3644
  const el = result.singleNodeValue;
3267
3645
  if (!el) return { isPromptWidget: false, committed: false };
3268
3646
  const widget = el.matches && el.matches(triggerSel) ? el : el.closest(triggerSel);
3269
3647
  if (!widget) return { isPromptWidget: false, committed: false };
3270
- return { isPromptWidget: true, committed: currentText(widget, valueSel, emptyRx) !== "" };
3648
+ const value = announcedValue(widget) || currentText(widget, valueSel, emptyRx);
3649
+ return { isPromptWidget: true, committed: value !== "" && (!want || namesOption(value, want)) };
3271
3650
  })()`;
3272
3651
  try {
3273
3652
  const raw = await target.evaluate(expr);
@@ -3418,20 +3797,63 @@ function xpathTailForRetarget(xpath) {
3418
3797
  * Falls back to the first document-order match when no candidate clears that
3419
3798
  * bar, preserving the previous behavior for every single-match (the common)
3420
3799
  * case.
3800
+ *
3801
+ * `phrases` (optional): the step's quoted target phrases. Matches whose text
3802
+ * (innerText/textContent/aria-label, case-insensitive, containment in either
3803
+ * direction) names none of them are dropped, so the loose tag/position tail
3804
+ * cannot hand back an element that is not the named target. An empty list, or
3805
+ * a match with no text at all, is unrestricted.
3421
3806
  */
3422
- const XPATH_TAIL_RETARGET_RESOLVE_FN_SRC = `((tail, preferSubmit) => {
3807
+ const XPATH_TAIL_RETARGET_RESOLVE_FN_SRC = `((tail, preferSubmit, phrases) => {
3423
3808
  const isNegative = ${submit_control_1.NEGATIVE_TEXT_EXPR};
3424
3809
  const accessibleName = (el) =>
3425
3810
  (el.getAttribute("aria-label") || el.textContent || "").replace(/\\s+/g, " ").trim().toLowerCase();
3426
3811
  const isSubmitShaped = ${submit_control_1.SUBMIT_SHAPE_EXPR};
3427
3812
  const r = document.evaluate("//" + tail, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
3813
+ const names = (phrases || []).map((p) => String(p).replace(/\\s+/g, " ").trim().toLowerCase()).filter(Boolean);
3814
+ const namesTarget = (el) => {
3815
+ if (names.length === 0) return true;
3816
+ const texts = [el.innerText, el.textContent, el.getAttribute("aria-label")]
3817
+ .map((t) => (t || "").replace(/\\s+/g, " ").trim().toLowerCase())
3818
+ .filter(Boolean);
3819
+ if (texts.length === 0) return true;
3820
+ return texts.some((t) => names.some((n) => t.includes(n) || n.includes(t)));
3821
+ };
3428
3822
  const nodes = [];
3429
- for (let i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
3823
+ for (let i = 0; i < r.snapshotLength; i++) {
3824
+ const node = r.snapshotItem(i);
3825
+ if (namesTarget(node)) nodes.push(node);
3826
+ }
3430
3827
  if (nodes.length <= 1) return nodes[0] || null;
3431
3828
  if (!preferSubmit) return nodes[0];
3432
3829
  const preferred = nodes.find((el) => isSubmitShaped(el) && !isNegative(accessibleName(el)));
3433
3830
  return preferred || nodes[0];
3434
3831
  })`;
3832
+ /**
3833
+ * The quoted, non-negated target phrases a plain click step names, used to
3834
+ * refuse a tail-retargeted element that is not that target. Field-label,
3835
+ * select, and radio steps quote option/label values rather than the clicked
3836
+ * control's own text, so they yield no phrases and keep the unrestricted
3837
+ * retarget.
3838
+ */
3839
+ function tailRetargetPhrasesForStep(step) {
3840
+ if (stepQuotesValueNotControlName(step))
3841
+ return [];
3842
+ return (0, deep_locator_candidates_1.extractTaggedPhrases)(step)
3843
+ .filter((p) => !p.negated)
3844
+ .map((p) => p.text);
3845
+ }
3846
+ /**
3847
+ * True when the step's quoted text is a value (fill/select/radio/field-label)
3848
+ * rather than the clicked control's own name, however loosely the prose is
3849
+ * phrased. Shared so every quoted-phrase guard exempts the same steps.
3850
+ */
3851
+ function stepQuotesValueNotControlName(step) {
3852
+ return Boolean(parseFieldLabelTarget(step) ||
3853
+ parseFillValueIntent(step) ||
3854
+ parseSelectStep(step) ||
3855
+ parseRadioStep(step));
3856
+ }
3435
3857
  /**
3436
3858
  * Resolve any selector a caller might hold into a bare XPath body for
3437
3859
  * `document.evaluate`. `verifyFillReadback` is shared across call sites that
@@ -3596,10 +4018,10 @@ const NEARBY_SELECTION_CONTAINER_FN_SRC = `(el) => {
3596
4018
  * source {@link selectionAncestorChanged} recomputes ancestor keys with, so the
3597
4019
  * two byte-match each other and Stagehand's resolved `xpath=/html[1]/…`
3598
4020
  * 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.
4021
+ * skipped — they churn without a selection change. Visibility uses the shared
4022
+ * {@link ACTIONABLE_VISIBILITY_EXPR} (rect + `getComputedStyle`, skipping an
4023
+ * element's own `aria-hidden="true"`; not `offsetParent`), so on-screen
4024
+ * `position:fixed` controls — a sticky Next/Submit bar — still enter the map.
3603
4025
  * The visibility gate is waived for an `input`/`select` nested inside a nearby
3604
4026
  * combobox/listbox/group container: a design-system option commits its real
3605
4027
  * selection to a `display:none` or `aria-hidden` sibling input, not to the
@@ -3620,12 +4042,7 @@ const SELECTION_STATE_MAP_EXPR = `(() => {
3620
4042
  if (!b) return {};
3621
4043
  const xpathOf = ${XPATH_OF_FN_SRC};
3622
4044
  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
- };
4045
+ const visible = ${actionable_visibility_expr_1.ACTIONABLE_VISIBILITY_EXPR};
3629
4046
  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
4047
  const skip = (el) => el.closest("[role=dialog],[role=tooltip],[aria-live]") !== null;
3631
4048
  const isCommittedValueControl = (el) =>
@@ -3718,7 +4135,10 @@ async function readElementSelectionFingerprint(target, selector) {
3718
4135
  * own xpath still resolve to a node in the post-click DOM? A same-page
3719
4136
  * toggle (a settings-page tab switcher swapping its panel content) leaves
3720
4137
  * 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
4138
+ * to an earlier screen replaces it along with everything else. A view swap
4139
+ * (e.g. a login-to-registration form swap) also removes its trigger, so
4140
+ * absence only vetoes when the DOM did not grow; the caller applies that
4141
+ * direction check. Returns
3722
4142
  * `true` — never veto — whenever presence genuinely can't be determined (a
3723
4143
  * non-xpath selector, an evaluate failure): only a positive `false` read
3724
4144
  * back from the DOM counts as proof of absence.
@@ -3767,7 +4187,7 @@ async function resolvedClickTargetStillPresent(target, selector) {
3767
4187
  * (Stagehand act()) call site never passes this — its resolved selector came
3768
4188
  * from Stagehand's own successful resolution, so no retarget applies there.
3769
4189
  */
3770
- async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = null) {
4190
+ async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = null, phrases = []) {
3771
4191
  const xpath = xpathBodyForEvaluate(selector);
3772
4192
  if (!xpath)
3773
4193
  return false;
@@ -3775,7 +4195,7 @@ async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = n
3775
4195
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3776
4196
  let el = r.singleNodeValue;
3777
4197
  if (!el && ${JSON.stringify(xpathTail)}) {
3778
- el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(true)});
4198
+ el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(true)}, ${JSON.stringify(phrases)});
3779
4199
  }
3780
4200
  const isSubmitShaped = ${submit_control_1.SUBMIT_SHAPE_EXPR};
3781
4201
  return isSubmitShaped(el);
@@ -3804,7 +4224,7 @@ async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = n
3804
4224
  * strong signal (a real URL/network transition) instead of trusting it
3805
4225
  * blindly, without touching the (already well-tested) weak DOM-signal path.
3806
4226
  */
3807
- async function resolvedClickUsedXpathTailRetarget(target, selector, xpathTail) {
4227
+ async function resolvedClickUsedXpathTailRetarget(target, selector, xpathTail, phrases = []) {
3808
4228
  if (!xpathTail)
3809
4229
  return false;
3810
4230
  const xpath = xpathBodyForEvaluate(selector);
@@ -3812,7 +4232,8 @@ async function resolvedClickUsedXpathTailRetarget(target, selector, xpathTail) {
3812
4232
  return false;
3813
4233
  const expr = `(() => {
3814
4234
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3815
- return r.singleNodeValue === null;
4235
+ if (r.singleNodeValue !== null) return false;
4236
+ return ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(true)}, ${JSON.stringify(phrases)}) !== null;
3816
4237
  })()`;
3817
4238
  try {
3818
4239
  return (await target.evaluate(expr)) === true;
@@ -4092,7 +4513,16 @@ const UPLOAD_WIDGET_RENDER_INTERVAL_MS = 600;
4092
4513
  * matches `/attachment`; the others cover the other ATS upload sinks. Module-
4093
4514
  * level so both the raw-input path and the click-to-surface path share one list.
4094
4515
  */
4095
- const UPLOAD_URL_PATTERNS = ["/upload", "/resume", "/file", "/attachment", "/document"];
4516
+ const UPLOAD_URL_PATTERNS = [
4517
+ "/upload",
4518
+ "/resume",
4519
+ "/file",
4520
+ "/attach",
4521
+ "/document",
4522
+ "/cv",
4523
+ "/media",
4524
+ "/import",
4525
+ ];
4096
4526
  /**
4097
4527
  * Poll the recent-capture window for an upload-related POST after a file has
4098
4528
  * been attached. Extracted from `tryUploadPrimitive` so the raw-input path,
@@ -4101,7 +4531,7 @@ const UPLOAD_URL_PATTERNS = ["/upload", "/resume", "/file", "/attachment", "/doc
4101
4531
  * {@link UPLOAD_URL_PATTERNS} lands; false if the timeout elapses first.
4102
4532
  */
4103
4533
  async function waitForUploadNetworkSignal(params) {
4104
- const { page, fixture, logger, signalCounter, recentCaptureMeta } = params;
4534
+ const { page, fixture, logger, signalCounter, recentCaptureMeta, stopWhen } = params;
4105
4535
  const networkCountBefore = signalCounter.n;
4106
4536
  const captureMetaCountBefore = recentCaptureMeta.length;
4107
4537
  const startedAt = performance.now();
@@ -4119,6 +4549,8 @@ async function waitForUploadNetworkSignal(params) {
4119
4549
  return true;
4120
4550
  }
4121
4551
  }
4552
+ if (stopWhen?.())
4553
+ return false;
4122
4554
  await page.waitForTimeout(UPLOAD_NETWORK_POLL_INTERVAL_MS);
4123
4555
  }
4124
4556
  return false;
@@ -4132,14 +4564,7 @@ async function waitForUploadNetworkSignal(params) {
4132
4564
  * surface path never fires a skip/decline/submit control.
4133
4565
  */
4134
4566
  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);
4567
+ return (0, upload_affordance_expr_1.matchesUploadAffordanceVocabulary)(label);
4143
4568
  }
4144
4569
  /**
4145
4570
  * Materialize the in-memory upload fixture to a temp file so CDP
@@ -4215,13 +4640,33 @@ async function tryUploadPrimitive(params) {
4215
4640
  logger,
4216
4641
  signalCounter,
4217
4642
  recentCaptureMeta,
4643
+ browserbaseSessionId: params.browserbaseSessionId,
4218
4644
  });
4219
4645
  if (surfaced)
4220
4646
  return true;
4221
4647
  logger.info("upload primitive: click-to-surface failed; falling through to cascade");
4222
4648
  return false;
4223
4649
  }
4224
- return attachToSurfacedInput({ page, target, fixture, logger, signalCounter, recentCaptureMeta });
4650
+ const attached = await attachToSurfacedInput({
4651
+ page,
4652
+ target,
4653
+ fixture,
4654
+ logger,
4655
+ signalCounter,
4656
+ recentCaptureMeta,
4657
+ });
4658
+ if (attached)
4659
+ return true;
4660
+ logger.info("upload primitive: existing input did not take the file; attempting click-to-surface");
4661
+ return surfaceAndUpload({
4662
+ page,
4663
+ target,
4664
+ fixture,
4665
+ logger,
4666
+ signalCounter,
4667
+ recentCaptureMeta,
4668
+ browserbaseSessionId: params.browserbaseSessionId,
4669
+ });
4225
4670
  }
4226
4671
  /**
4227
4672
  * Attach the fixture to an already-surfaced `<input type=file>` (raw or freshly
@@ -4232,7 +4677,11 @@ async function tryUploadPrimitive(params) {
4232
4677
  */
4233
4678
  async function attachToSurfacedInput(params) {
4234
4679
  const { page, target, fixture, logger, signalCounter, recentCaptureMeta } = params;
4235
- const inputLocator = target.locator("xpath=//input[@type='file']").first();
4680
+ // Prefer an enabled input (a widget can keep a disabled decoy beside the live one);
4681
+ // the second union branch only matches when no enabled file input exists.
4682
+ const inputLocator = target
4683
+ .locator("xpath=(//input[@type='file' and not(@disabled)] | //input[@type='file'][not(//input[@type='file' and not(@disabled)])])[1]")
4684
+ .first();
4236
4685
  try {
4237
4686
  await inputLocator.setInputFiles({
4238
4687
  name: fixture.name,
@@ -4321,28 +4770,21 @@ async function attachToSurfacedInput(params) {
4321
4770
  * was attached.
4322
4771
  */
4323
4772
  async function surfaceAndUpload(params) {
4324
- const { page, target, fixture, logger, signalCounter, recentCaptureMeta } = params;
4773
+ const { page, target, fixture, logger, signalCounter, recentCaptureMeta, browserbaseSessionId } = params;
4325
4774
  // Render-gate: the input-less strategies below (drag-drop is one-shot, the
4326
4775
  // affordance click resolves what's in the DOM) all race the async widget
4327
4776
  // mount. Wait (bounded, same window as the raw-input probe) for ANY upload
4328
4777
  // target to appear — a dropzone, an upload-affordance button, or an
4329
4778
  // <input type=file> — so every downstream strategy runs against a rendered
4330
- // widget. Static evaluate literal (no interpolation); dropzone list parallels
4779
+ // widget. Dropzone list is the shared FIND_DROP_ZONE_EXPR used by
4331
4780
  // simulateDragDropUpload and the button matcher parallels clickUploadAffordance.
4332
4781
  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
- };
4782
+ const isUpload = ${upload_affordance_expr_1.UPLOAD_AFFORDANCE_MATCHER_EXPR};
4341
4783
  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 };
4784
+ if (${upload_affordance_expr_1.FIND_DROP_ZONE_EXPR}(document)) return { present: true };
4785
+ const actionable = ${actionable_visibility_expr_1.ACTIONABLE_VISIBILITY_EXPR};
4786
+ const btns = Array.from(document.querySelectorAll(${JSON.stringify(upload_affordance_expr_1.UPLOAD_AFFORDANCE_CONTROL_SELECTOR)}));
4787
+ if (btns.some((el) => actionable(el) && isUpload(el.getAttribute("aria-label") || el.textContent || ""))) return { present: true };
4346
4788
  return { present: false };
4347
4789
  })()`;
4348
4790
  const gate = await pollEnumerate(page, target, targetExpr, (r) => r?.present === true, {
@@ -4387,39 +4829,53 @@ async function surfaceAndUpload(params) {
4387
4829
  if (!(await clickUploadAffordance(page, target, logger)))
4388
4830
  return false;
4389
4831
  // 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 })) {
4832
+ // click, no chooser, no input. Stops waiting the moment a chooser is
4833
+ // intercepted — that widget wants the file via the chooser, so burning the
4834
+ // full network window first only delays Strategy B.
4835
+ if (await waitForUploadNetworkSignal({
4836
+ page,
4837
+ fixture,
4838
+ logger,
4839
+ signalCounter,
4840
+ recentCaptureMeta,
4841
+ stopWhen: () => chooserBackendNodeId !== null,
4842
+ })) {
4392
4843
  logger.info("upload primitive: resolved via click → immediate upload POST");
4393
4844
  return true;
4394
4845
  }
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({
4846
+ // Strategy B: the click opened a native chooser we intercepted. Tried before A —
4847
+ // the chooser names the exact input the widget wants, while A re-sets whatever
4848
+ // input exists, which a React widget may ignore.
4849
+ if (chooserBackendNodeId !== null) {
4850
+ logger.info(`upload primitive: native file chooser intercepted (backendNodeId=${chooserBackendNodeId}); setting files via CDP`);
4851
+ if (await setFilesViaCdp({
4400
4852
  page,
4401
4853
  target,
4854
+ session,
4855
+ backendNodeId: chooserBackendNodeId,
4402
4856
  fixture,
4403
4857
  logger,
4404
4858
  signalCounter,
4405
4859
  recentCaptureMeta,
4860
+ browserbaseSessionId,
4406
4861
  })) {
4407
4862
  return true;
4408
4863
  }
4409
4864
  }
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({
4865
+ // Strategy A: the click lazily mounted a hidden <input type=file>.
4866
+ const appeared = await pollEnumerate(page, target, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0);
4867
+ if ((appeared ?? 0) > 0) {
4868
+ logger.info("upload primitive: click surfaced a hidden <input type=file>");
4869
+ if (await attachToSurfacedInput({
4414
4870
  page,
4415
4871
  target,
4416
- session,
4417
- backendNodeId: chooserBackendNodeId,
4418
4872
  fixture,
4419
4873
  logger,
4420
4874
  signalCounter,
4421
4875
  recentCaptureMeta,
4422
- });
4876
+ })) {
4877
+ return true;
4878
+ }
4423
4879
  }
4424
4880
  return false;
4425
4881
  }
@@ -4428,6 +4884,7 @@ async function surfaceAndUpload(params) {
4428
4884
  await session.send("Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => { });
4429
4885
  }
4430
4886
  }
4887
+ const UPLOAD_AFFORDANCE_MARKER = "data-barnacle-upload-affordance";
4431
4888
  /**
4432
4889
  * Locate and click the resume-upload affordance in the page DOM. Uses a DOM
4433
4890
  * enumerate (NOT Stagehand observe, which returns [] for these MUI buttons)
@@ -4440,40 +4897,71 @@ async function surfaceAndUpload(params) {
4440
4897
  * (the frame the enumerate/click runs against).
4441
4898
  */
4442
4899
  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.
4900
+ // The browser-side matcher is the shared upload-affordance vocabulary; the enumerate stays
4901
+ // a static string (same trust posture as the other
4902
+ // primitives); only shared constants are interpolated.
4446
4903
  const expr = `(() => {
4447
4904
  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"));
4905
+ const isUpload = ${upload_affordance_expr_1.UPLOAD_AFFORDANCE_MATCHER_EXPR};
4906
+ const els = Array.from(document.querySelectorAll(${JSON.stringify(upload_affordance_expr_1.UPLOAD_AFFORDANCE_CONTROL_SELECTOR)}));
4456
4907
  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 };
4908
+ const actionable = ${actionable_visibility_expr_1.ACTIONABLE_VISIBILITY_EXPR};
4909
+ const matches = els.filter((el) => actionable(el) && isUpload(el.getAttribute("aria-label") || el.textContent || ""));
4910
+ if (matches.length === 0) return { marked: false };
4459
4911
  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) };
4912
+ chosen.setAttribute("${UPLOAD_AFFORDANCE_MARKER}", "");
4913
+ return { marked: true, text: norm(chosen.getAttribute("aria-label") || chosen.textContent || "").slice(0, 50) };
4462
4914
  })()`;
4915
+ const markerSelector = `[${UPLOAD_AFFORDANCE_MARKER}]`;
4463
4916
  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;
4917
+ const result = (await pollEnumerate(page, target, expr, (r) => r?.marked === true)) ?? { marked: false };
4918
+ if (!result.marked) {
4919
+ logger.info("upload primitive: no upload affordance button found in DOM");
4920
+ return false;
4468
4921
  }
4469
- logger.info("upload primitive: no upload affordance button found in DOM");
4470
- return false;
4922
+ // A trusted CDP click is required: an in-page el.click() never opens a file chooser.
4923
+ try {
4924
+ await (0, trusted_click_1.clickCentredTrusted)(target, markerSelector, { label: "upload affordance" });
4925
+ }
4926
+ finally {
4927
+ await target
4928
+ .evaluate(`document.querySelectorAll(${JSON.stringify(markerSelector)}).forEach((el) => el.removeAttribute("${UPLOAD_AFFORDANCE_MARKER}"))`)
4929
+ .catch(() => { });
4930
+ }
4931
+ logger.info(`upload primitive: clicked upload affordance "${result.text ?? ""}"`);
4932
+ return true;
4471
4933
  }
4472
4934
  catch (err) {
4473
4935
  logger.warn(`upload primitive: affordance click threw: ${(0, errors_1.toErrorMessage)(err)}`);
4474
4936
  return false;
4475
4937
  }
4476
4938
  }
4939
+ /**
4940
+ * Push the fixture to Browserbase's session uploads endpoint, because a remote
4941
+ * browser cannot read a path on this host. Returns the path the remote
4942
+ * browser sees, or null when the upload is rejected.
4943
+ */
4944
+ async function uploadFixtureToRemoteSession(sessionId, fixture, logger) {
4945
+ const form = new FormData();
4946
+ form.append("file", new Blob([new Uint8Array(fixture.buffer)], { type: fixture.mimeType }), fixture.name);
4947
+ try {
4948
+ const res = await fetch(`${config_1.config.scraper.browserbaseBaseUrl}/v1/sessions/${sessionId}/uploads`, {
4949
+ method: "POST",
4950
+ headers: { "X-BB-API-Key": config_1.config.scraper.browserbaseApiKey ?? "" },
4951
+ body: form,
4952
+ });
4953
+ const accepted = res.ok ?? (res.status >= 200 && res.status < 300);
4954
+ if (!accepted) {
4955
+ logger.warn(`upload primitive: remote session upload failed (status=${res.status})`);
4956
+ return null;
4957
+ }
4958
+ return `/tmp/.uploads/${fixture.name}`;
4959
+ }
4960
+ catch (err) {
4961
+ logger.warn(`upload primitive: remote session upload threw: ${(0, errors_1.toErrorMessage)(err)}`);
4962
+ return null;
4963
+ }
4964
+ }
4477
4965
  /**
4478
4966
  * Complete a native-chooser upload the recon intercepted via CDP. CDP's
4479
4967
  * `DOM.setFileInputFiles` takes filesystem PATHS (not buffers), so it uses the
@@ -4482,11 +4970,15 @@ async function clickUploadAffordance(page, target, logger) {
4482
4970
  * shared upload-network signal.
4483
4971
  */
4484
4972
  async function setFilesViaCdp(params) {
4485
- const { page, target, session, backendNodeId, fixture, logger, signalCounter, recentCaptureMeta, } = params;
4973
+ const { page, target, session, backendNodeId, fixture, logger, signalCounter, recentCaptureMeta, browserbaseSessionId, } = params;
4486
4974
  // CDP needs a filesystem path; the fixture is an in-memory buffer. Write it
4487
4975
  // to a temp file (tiny — a few KB) so the path is always valid regardless of
4488
4976
  // where the recon loaded the fixture from.
4489
- const path = writeFixtureToTempFile(fixture);
4977
+ const path = browserbaseSessionId
4978
+ ? await uploadFixtureToRemoteSession(browserbaseSessionId, fixture, logger)
4979
+ : writeFixtureToTempFile(fixture);
4980
+ if (path === null)
4981
+ return false;
4490
4982
  try {
4491
4983
  await session.send("DOM.setFileInputFiles", { files: [path], backendNodeId });
4492
4984
  }
@@ -4538,6 +5030,19 @@ async function setFilesViaCdp(params) {
4538
5030
  // it in the instruction (e.g. a page/step-context quote like "'My
4539
5031
  // Information' step").
4540
5032
  const WIDGET_NOUN_RE = /\b(prompt\s+selector|multiselect|typeahead|dropdown|field|question|checkbox|radio\s+button|radio)\b/i;
5033
+ /**
5034
+ * Matches `prefix` (which must end in a `(?=')` lookahead) and returns the
5035
+ * contraction-aware quoted phrase that starts right after it, so anchored
5036
+ * step parsers pair quotes the same way as {@link findQuotedSpans}.
5037
+ */
5038
+ function matchQuotedAfter(instruction, prefix) {
5039
+ const match = prefix.exec(instruction);
5040
+ if (!match)
5041
+ return null;
5042
+ const at = match.index + match[0].length;
5043
+ const phrase = (0, quoted_spans_1.findQuotedSpans)(instruction).find((p) => p.start === at);
5044
+ return phrase ? { match, phrase } : null;
5045
+ }
4541
5046
  /**
4542
5047
  * Pick the QUESTION LABEL out of an instruction's quoted phrases, given the
4543
5048
  * already-extracted OPTION.
@@ -4555,21 +5060,17 @@ const WIDGET_NOUN_RE = /\b(prompt\s+selector|multiselect|typeahead|dropdown|fiel
4555
5060
  * non-option quote so existing un-adorned phrasings keep working.
4556
5061
  */
4557
5062
  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);
5063
+ const candidates = (0, quoted_spans_1.findQuotedSpans)(instruction).filter((s) => s.text.trim() !== option);
4560
5064
  if (candidates.length === 0)
4561
5065
  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);
5066
+ const adjacentToWidgetNoun = candidates.find((s) => {
5067
+ const before = instruction.slice(Math.max(0, s.start - 25), s.start);
5068
+ const after = instruction.slice(s.end, s.end + 40);
4567
5069
  return (/\bfor(?:\s+the)?(?:\s+(?:question|answer))?\s*$/i.test(before) || WIDGET_NOUN_RE.test(after));
4568
5070
  });
4569
5071
  // biome-ignore lint/style/noNonNullAssertion: candidates is guarded non-empty above, so the fallback element is always present
4570
5072
  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();
5073
+ return picked.text.trim();
4573
5074
  }
4574
5075
  function parseSelectStep(instruction) {
4575
5076
  const lower = instruction.toLowerCase();
@@ -4582,8 +5083,7 @@ function parseSelectStep(instruction) {
4582
5083
  // Catch-all steps ("for any remaining…") have no concrete single target.
4583
5084
  if (/\bany\s+remaining\b/.test(lower))
4584
5085
  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]);
5086
+ const quoted = (0, quoted_spans_1.findQuotedSpans)(instruction).map((s) => s.text);
4587
5087
  if (quoted.length === 0)
4588
5088
  return null;
4589
5089
  // The OPTION is the quoted string following the word "select", allowing a
@@ -4591,11 +5091,10 @@ function parseSelectStep(instruction) {
4591
5091
  // quote — real flow phrasing like "select the option 'Job Boards'" or
4592
5092
  // "select the answer 'X'" otherwise fails to parse and the caller
4593
5093
  // silently no-ops before touching the DOM.
4594
- const selMatch = instruction.match(/\bselect(?:\s+or\s+check)?\s+(?:the\s+\S+\s+)?'([^']+)'/i);
5094
+ const selMatch = matchQuotedAfter(instruction, /\bselect(?:\s+or\s+check)?\s+(?:the\s+\S+\s+)?(?=')/i)?.phrase;
4595
5095
  if (!selMatch)
4596
5096
  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();
5097
+ const option = selMatch.text.trim();
4599
5098
  // The QUESTION LABEL, when present, is a DIFFERENT quoted string — the one
4600
5099
  // introduced by "for '…'" or adjacent to a widget noun like "'…' dropdown"
4601
5100
  // or "'…' prompt selector". See `pickQuestionLabel`.
@@ -4627,11 +5126,11 @@ function parseSelectStep(instruction) {
4627
5126
  * to the normal cascade unchanged.
4628
5127
  */
4629
5128
  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)
5129
+ const found = matchQuotedAfter(instruction, /\bfill(?:\s+in)?\s+(?:the\s+)?(.+?)\s+(?:field|input|textbox)\s+with\s+(?=')/i);
5130
+ if (!found)
4632
5131
  return null;
4633
- const fieldLabel = match[1]?.trim();
4634
- const value = match[2]?.trim();
5132
+ const fieldLabel = found.match[1]?.trim();
5133
+ const value = found.phrase.text.trim();
4635
5134
  if (!fieldLabel || !value)
4636
5135
  return null;
4637
5136
  return { fieldLabel, value };
@@ -4659,15 +5158,700 @@ function parseFillValueIntent(instruction) {
4659
5158
  if (parseSelectStep(instruction))
4660
5159
  return null;
4661
5160
  if (/\bfill(?:\s+in)?\b/i.test(instruction)) {
4662
- const value = instruction.match(/\bwith\s+'([^']+)'/i)?.[1]?.trim();
5161
+ const value = matchQuotedAfter(instruction, /\bwith\s+(?=')/i)?.phrase.text.trim();
4663
5162
  return value ? { value } : null;
4664
5163
  }
4665
5164
  if (/\btype\b/i.test(instruction)) {
4666
- const value = instruction.match(/\btype\s+'([^']+)'/i)?.[1]?.trim();
5165
+ const value = matchQuotedAfter(instruction, /\btype\s+(?=')/i)?.phrase.text.trim();
4667
5166
  return value ? { value } : null;
4668
5167
  }
4669
5168
  return null;
4670
5169
  }
5170
+ const SEGMENTED_DATE_VALUE_RE = /^[\d/.\s-]+$/;
5171
+ const SEGMENTED_DATE_MIN_DIGITS = 4;
5172
+ const SEGMENTED_DATE_MAX_DIGITS = 8;
5173
+ const SEGMENTED_DATE_KEY_DELAY_MS = 50;
5174
+ const SEGMENTED_DATE_FIELD_WORDS = new Set([
5175
+ "from",
5176
+ "to",
5177
+ "start",
5178
+ "end",
5179
+ "since",
5180
+ "until",
5181
+ "through",
5182
+ "begin",
5183
+ "finish",
5184
+ ]);
5185
+ const SEGMENTED_DATE_STOPWORDS = new Set([
5186
+ "a",
5187
+ "an",
5188
+ "and",
5189
+ "at",
5190
+ "date",
5191
+ "enter",
5192
+ "fill",
5193
+ "for",
5194
+ "in",
5195
+ "into",
5196
+ "of",
5197
+ "on",
5198
+ "the",
5199
+ "then",
5200
+ "to",
5201
+ "type",
5202
+ "with",
5203
+ ]);
5204
+ /**
5205
+ * Extract the digits a fill/type/enter step wants typed into a multi-segment
5206
+ * date widget. Limited to 4-8 digits so phone numbers and other long numerics
5207
+ * never trigger the segmented-date path. Reuses `parseFillValueIntent` and
5208
+ * falls back to the last date-shaped quoted phrase for verbless steps such as
5209
+ * "'From' date in Work Experience 1, '012020'".
5210
+ */
5211
+ function parseSegmentedDateValue(instruction) {
5212
+ const spans = (0, quoted_spans_1.findQuotedSpans)(instruction);
5213
+ const intent = parseFillValueIntent(instruction)?.value;
5214
+ const candidates = intent
5215
+ ? [intent]
5216
+ : spans
5217
+ .map((s) => s.text.trim())
5218
+ .filter((t) => SEGMENTED_DATE_VALUE_RE.test(t))
5219
+ .reverse();
5220
+ for (const raw of candidates) {
5221
+ if (!SEGMENTED_DATE_VALUE_RE.test(raw))
5222
+ continue;
5223
+ const digits = raw.replace(/\D/g, "");
5224
+ if (digits.length < SEGMENTED_DATE_MIN_DIGITS || digits.length > SEGMENTED_DATE_MAX_DIGITS) {
5225
+ continue;
5226
+ }
5227
+ return { digits, valueSpan: raw };
5228
+ }
5229
+ if (spans.length > 0 && intent)
5230
+ return null;
5231
+ const bare = instruction
5232
+ .split(/\s+/)
5233
+ .map((t) => t.replace(/[.,;:]+$/, ""))
5234
+ .filter((t) => /\d/.test(t) && SEGMENTED_DATE_VALUE_RE.test(t))
5235
+ .reverse();
5236
+ for (const raw of bare) {
5237
+ const digits = raw.replace(/\D/g, "");
5238
+ if (digits.length < SEGMENTED_DATE_MIN_DIGITS || digits.length > SEGMENTED_DATE_MAX_DIGITS) {
5239
+ continue;
5240
+ }
5241
+ return { digits, valueSpan: raw };
5242
+ }
5243
+ return null;
5244
+ }
5245
+ /** Keeps a typed date's shape (segment count and widths) in logs without its digits. */
5246
+ function maskDateDigits(text) {
5247
+ return text.replace(/\d/g, "9");
5248
+ }
5249
+ function segmentedDateTokens(text) {
5250
+ return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
5251
+ }
5252
+ const SEGMENTED_DATE_PART_LABELS = new Set(["mm", "dd", "yyyy", "yy", "month", "day", "year"]);
5253
+ const SEGMENTED_DATE_FIELD_NOUNS = new Set([
5254
+ "date",
5255
+ "field",
5256
+ "group",
5257
+ "input",
5258
+ "spinbutton",
5259
+ "segment",
5260
+ "value",
5261
+ "part",
5262
+ ]);
5263
+ const SEGMENTED_DATE_NAMING_DETERMINERS = new Set(["the", "its", "this", "that", "for", "of"]);
5264
+ /**
5265
+ * Step tokens with the field words that are mere prose removed. "from"/"to"
5266
+ * only name a date field when a determiner precedes them ("the To date"), a
5267
+ * field noun follows ("From date"), or they are capitalised mid-sentence;
5268
+ * "auto-advances from month to year" names nothing and must never select a
5269
+ * group labelled "From".
5270
+ */
5271
+ function segmentedDateStepTokens(text) {
5272
+ const words = text.match(/[A-Za-z0-9]+/g) ?? [];
5273
+ return words.flatMap((word, i) => {
5274
+ const token = word.toLowerCase();
5275
+ if (!SEGMENTED_DATE_FIELD_WORDS.has(token))
5276
+ return [token];
5277
+ const prev = (words[i - 1] ?? "").toLowerCase();
5278
+ const next = (words[i + 1] ?? "").toLowerCase();
5279
+ const named = SEGMENTED_DATE_NAMING_DETERMINERS.has(prev) ||
5280
+ SEGMENTED_DATE_FIELD_NOUNS.has(next) ||
5281
+ (i > 0 && /^[A-Z]/.test(word));
5282
+ return named ? [token] : [];
5283
+ });
5284
+ }
5285
+ const SEGMENTED_DATE_STEP_NOISE = new Set([
5286
+ "barnaclevalue",
5287
+ "click",
5288
+ "type",
5289
+ "enter",
5290
+ "fill",
5291
+ "set",
5292
+ "select",
5293
+ "input",
5294
+ "write",
5295
+ "put",
5296
+ "choose",
5297
+ "use",
5298
+ "press",
5299
+ "focus",
5300
+ "spinbutton",
5301
+ "part",
5302
+ "segment",
5303
+ "digits",
5304
+ "box",
5305
+ "first",
5306
+ "second",
5307
+ "into",
5308
+ "as",
5309
+ "by",
5310
+ "from",
5311
+ "to",
5312
+ "is",
5313
+ "be",
5314
+ "it",
5315
+ "its",
5316
+ "this",
5317
+ "that",
5318
+ "my",
5319
+ "your",
5320
+ ...SEGMENTED_DATE_STOPWORDS,
5321
+ ...SEGMENTED_DATE_FIELD_WORDS,
5322
+ ...SEGMENTED_DATE_PART_LABELS,
5323
+ ...SEGMENTED_DATE_FIELD_NOUNS,
5324
+ ]);
5325
+ /**
5326
+ * Pick which spinbutton group a segmented-date step targets. Groups whose
5327
+ * segment widths (digit count of `aria-valuemax`) don't sum to the value's
5328
+ * digit count are dropped. A group is only eligible when a field the step names
5329
+ * (quoted, e.g. 'From', or unquoted in field position, e.g. "the To date")
5330
+ * matches its OWN label as whole words — a group with no `label` is judged by
5331
+ * its whole `context` — and every other quoted phrase is found in the group's
5332
+ * label or section. The section the step names ("Work Experience 1") is
5333
+ * then resolved: when a width-fitting group elsewhere matches the step's
5334
+ * section words better than any eligible group does, or the group's own
5335
+ * section shares none of the step's remaining words (or numbering), the named
5336
+ * section renders no such field and the step resolves to nothing rather than to
5337
+ * another section's. Returns the index into `groups`, or `null` when nothing
5338
+ * matches or the best match is tied — writing a date into a group that merely
5339
+ * fits the width would corrupt another field. Groups that hold the same
5340
+ * segments under different names never tie.
5341
+ */
5342
+ function resolveSegmentedDateGroup(instruction, groups) {
5343
+ const value = parseSegmentedDateValue(instruction);
5344
+ if (!value)
5345
+ return null;
5346
+ const compatible = (group) => group.segmentMaxes.map((max) => String(max).length).reduce((a, b) => a + b, 0) ===
5347
+ value.digits.length;
5348
+ return pickSegmentedDateGroup(instruction, groups, value, compatible);
5349
+ }
5350
+ const SEGMENTED_DATE_PART_BY_LABEL = {
5351
+ month: "month",
5352
+ mm: "month",
5353
+ day: "day",
5354
+ dd: "day",
5355
+ year: "year",
5356
+ yyyy: "year",
5357
+ yy: "year",
5358
+ };
5359
+ /**
5360
+ * Parse a per-segment step such as "type '01' into Month". Requires a quoted
5361
+ * 1-4 digit value AND exactly one part label (Month/MM, Day/DD, Year/YYYY), so
5362
+ * a bare short numeric (ZIP fragment, age) is never claimed.
5363
+ */
5364
+ function parseSegmentedPartStep(instruction) {
5365
+ const raw = parseFillValueIntent(instruction)?.value;
5366
+ if (!raw || !/^\d{1,4}$/.test(raw))
5367
+ return null;
5368
+ const parts = new Set(segmentedDateTokens(instruction.replace(raw, " ")).flatMap((t) => {
5369
+ const part = SEGMENTED_DATE_PART_BY_LABEL[t];
5370
+ return part ? [part] : [];
5371
+ }));
5372
+ const [part] = [...parts];
5373
+ return parts.size === 1 && part ? { digits: raw, valueSpan: raw, part } : null;
5374
+ }
5375
+ /** Index of the single segment in `group` whose own label names `part`, or null when none/ambiguous. */
5376
+ function segmentIndexForPart(group, part) {
5377
+ const hits = (group.segmentLabels ?? []).flatMap((label, si) => segmentedDateTokens(label).some((t) => SEGMENTED_DATE_PART_BY_LABEL[t] === part) ? [si] : []);
5378
+ return hits.length === 1 ? (hits[0] ?? null) : null;
5379
+ }
5380
+ function pickSegmentedDateGroup(instruction, groups, value, compatible) {
5381
+ const valueTokens = segmentedDateTokens(value.valueSpan).join(" ");
5382
+ const quoted = (0, quoted_spans_1.findQuotedSpans)(instruction)
5383
+ .map((s) => segmentedDateTokens(s.text.trim()))
5384
+ .filter((t) => t.length > 0 &&
5385
+ t.join(" ") !== valueTokens &&
5386
+ !t.every((token) => SEGMENTED_DATE_PART_LABELS.has(token)));
5387
+ const stepTokens = segmentedDateStepTokens(instruction.replace(value.valueSpan, " barnaclevalue "));
5388
+ const targets = quoted.length > 0 ? quoted : [stepTokens];
5389
+ const contains = (hay, needle) => {
5390
+ for (let i = 0; i + needle.length <= hay.length; i++) {
5391
+ if (needle.every((t, j) => hay[i + j] === t))
5392
+ return true;
5393
+ }
5394
+ return false;
5395
+ };
5396
+ const stepSet = new Set(stepTokens);
5397
+ const sectionScore = (group) => {
5398
+ const own = new Set(segmentedDateTokens(group.label ?? ""));
5399
+ const sectionTokens = new Set(segmentedDateTokens(group.section ?? group.context));
5400
+ return [...sectionTokens].filter((t) => stepSet.has(t) &&
5401
+ !own.has(t) &&
5402
+ !SEGMENTED_DATE_STOPWORDS.has(t) &&
5403
+ !SEGMENTED_DATE_FIELD_WORDS.has(t)).length;
5404
+ };
5405
+ const quotedTokens = new Set(quoted.flat());
5406
+ const leftover = stepTokens.filter((t) => !SEGMENTED_DATE_STEP_NOISE.has(t) && !quotedTokens.has(t));
5407
+ const isNumber = (t) => /^\d+$/.test(t);
5408
+ // A group under another heading is not the field the step names, however
5409
+ // well its label matches: the step's own words must find the section.
5410
+ // A section that only repeats the group's own label is no heading at all
5411
+ // (Workday enumerates "From*" as both), so it cannot name another section.
5412
+ const namesOtherSection = (group) => {
5413
+ const section = segmentedDateTokens(group.section ?? "");
5414
+ const label = segmentedDateTokens(group.label ?? "");
5415
+ if (section.length === 0 || contains(label, section))
5416
+ return false;
5417
+ const known = new Set([...section, ...label]);
5418
+ const words = leftover.filter((t) => !isNumber(t));
5419
+ const numbers = leftover.filter(isNumber);
5420
+ const sectionNumbers = section.filter(isNumber);
5421
+ if (numbers.length > 0 && sectionNumbers.length > 0) {
5422
+ if (!numbers.some((n) => sectionNumbers.includes(n)))
5423
+ return true;
5424
+ }
5425
+ if (words.some((t) => section.includes(t)))
5426
+ return false;
5427
+ return words.some((t) => !known.has(t));
5428
+ };
5429
+ const bestSection = Math.max(0, ...groups.filter(compatible).map((group) => sectionScore(group)));
5430
+ let bestScore = -1;
5431
+ let leaders = [];
5432
+ groups.forEach((group, index) => {
5433
+ if (!compatible(group))
5434
+ return;
5435
+ const own = segmentedDateTokens(group.label ?? group.context);
5436
+ if (own.length === 0)
5437
+ return;
5438
+ const namesOwnField = group.label === undefined &&
5439
+ quoted.length === 0 &&
5440
+ own.some((t) => SEGMENTED_DATE_FIELD_WORDS.has(t) && stepSet.has(t));
5441
+ const namesOwnLabel = (t) => contains(t, own) || (quoted.length > 0 && contains(own, t));
5442
+ if (!namesOwnField && !targets.some(namesOwnLabel))
5443
+ return;
5444
+ const named = segmentedDateTokens(`${group.section ?? ""} ${group.context}`);
5445
+ if (quoted.some((t) => !namesOwnLabel(t) && !contains(named, t)))
5446
+ return;
5447
+ if (namesOtherSection(group))
5448
+ return;
5449
+ const score = sectionScore(group);
5450
+ if (score < bestSection)
5451
+ return;
5452
+ if (score > bestScore) {
5453
+ bestScore = score;
5454
+ leaders = [index];
5455
+ }
5456
+ else if (score === bestScore) {
5457
+ const alias = (a, b) => (groups[a]?.sameAs ?? a) === (groups[b]?.sameAs ?? b) ||
5458
+ groups[a]?.sameAs === b ||
5459
+ groups[b]?.sameAs === a;
5460
+ const twin = leaders.findIndex((leader) => alias(leader, index));
5461
+ if (twin < 0)
5462
+ leaders.push(index);
5463
+ else
5464
+ leaders[twin] = Math.min(leaders[twin] ?? index, index);
5465
+ }
5466
+ });
5467
+ if (leaders.length === 1)
5468
+ return leaders[0] ?? null;
5469
+ if (leaders.length > 1) {
5470
+ // A nested group is one wrapper's slice of a larger date widget (e.g. the
5471
+ // Year half of "From MM/YYYY"), so it only ties with a standalone group of
5472
+ // the same label when no section text separates them — Workday renders
5473
+ // "Work Experience 1 From" and "Education 1 From" with no heading the
5474
+ // enumerator can see. The standalone group is the field the step names.
5475
+ const standalone = leaders.filter((i) => !groups[i]?.nested);
5476
+ return standalone.length === 1 ? (standalone[0] ?? null) : null;
5477
+ }
5478
+ // A step that names no field at all can only mean the one date widget on the
5479
+ // page; a named field that matched no label must never fall back to it.
5480
+ if (quoted.length > 0 || stepTokens.some((t) => SEGMENTED_DATE_FIELD_WORDS.has(t)))
5481
+ return null;
5482
+ const only = groups.flatMap((g, i) => (compatible(g) && !g.nested ? [i] : []));
5483
+ return only.length === 1 ? (only[0] ?? null) : null;
5484
+ }
5485
+ /**
5486
+ * In-page helpers shared by the date-segment enumeration and the fill
5487
+ * readback so "is this spinbutton one segment of a multi-segment widget" has a
5488
+ * single definition; defines `spins`, `ownerOf` and `isDateSegment`.
5489
+ */
5490
+ const SEGMENT_OWNER_JS = `
5491
+ const hiddenAttr = (node) => node.hasAttribute("hidden") || node.getAttribute("aria-hidden") === "true";
5492
+ const styleOf = (node) => {
5493
+ try { return typeof getComputedStyle === "function" ? getComputedStyle(node) : window.getComputedStyle(node); } catch { return null; }
5494
+ };
5495
+ const isHidden = (el) => {
5496
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
5497
+ if (hiddenAttr(node)) return true;
5498
+ const style = styleOf(node);
5499
+ if (style && (style.display === "none" || style.visibility === "hidden")) return true;
5500
+ }
5501
+ return false;
5502
+ };
5503
+ const spins = Array.from(document.querySelectorAll("input[role='spinbutton']")).filter((el) => !isHidden(el));
5504
+ const norm = (s) => (s || "").replace(/\\s+/g, " ").trim();
5505
+ const idText = (ids) => (ids || "").split(/\\s+/).map((id) => {
5506
+ const ref = id ? document.getElementById(id) : null;
5507
+ return ref ? norm(ref.textContent) : "";
5508
+ }).join(" ");
5509
+ const legendOf = (node) => {
5510
+ const legend = node.tagName === "FIELDSET" ? node.querySelector(":scope > legend") : null;
5511
+ return legend ? norm(legend.textContent) : "";
5512
+ };
5513
+ const namedBy = (node) => [legendOf(node), norm(node.getAttribute("aria-label")), idText(node.getAttribute("aria-labelledby"))].filter(Boolean).join(" ");
5514
+ const tiedToSpin = (l) => l.querySelector("input,[role='spinbutton']") || (l.getAttribute("for") && spins.some((sp) => sp.id === l.getAttribute("for")));
5515
+ const hasCaption = (node) => namedBy(node) !== "" || Array.from(node.querySelectorAll("label,legend")).some((l) => !tiedToSpin(l));
5516
+ const captionOwnerOf = (el) => {
5517
+ const explicit = el.closest("fieldset,[role='group']");
5518
+ if (explicit) return explicit;
5519
+ for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
5520
+ if (hasCaption(node)) return node;
5521
+ }
5522
+ return null;
5523
+ };
5524
+ const bareSpins = spins.filter((sp) => !captionOwnerOf(sp));
5525
+ const hasOtherControl = (node) => Array.from(node.querySelectorAll("input,select,textarea")).some((c) => !spins.includes(c) && c.type !== "hidden");
5526
+ // Segments that each sit in their own uncaptioned wrapper share no
5527
+ // fieldset/caption, so the nearest ancestor holding several bare spinbuttons
5528
+ // (and no other form control) is the widget they belong to.
5529
+ const bareOwnerOf = (el) => {
5530
+ for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
5531
+ if (hasOtherControl(node)) break;
5532
+ if (bareSpins.filter((sp) => node.contains(sp)).length > 1) return node;
5533
+ }
5534
+ return el.parentElement;
5535
+ };
5536
+ const ownerOf = (el) => captionOwnerOf(el) || bareOwnerOf(el);
5537
+ const isDateSegment = (el) => {
5538
+ if (!el.getAttribute || el.getAttribute("role") !== "spinbutton" || !spins.includes(el)) return false;
5539
+ const box = ownerOf(el);
5540
+ return spins.filter((sp) => ownerOf(sp) === box).length > 1;
5541
+ };
5542
+ `;
5543
+ const SEGMENTED_DATE_MARKER_ATTR = "data-barnacle-segdate";
5544
+ /** Exported so tests can run the real DOM enumeration against markup. */
5545
+ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5546
+ const attr = ${JSON.stringify(SEGMENTED_DATE_MARKER_ATTR)};
5547
+ const stale = document.querySelectorAll("[" + attr + "]");
5548
+ stale.forEach((el) => el.removeAttribute(attr));
5549
+ ${SEGMENT_OWNER_JS}
5550
+ if (spins.length === 0) return { groups: [] };
5551
+ const headingSibling = (node) => {
5552
+ let prevSib = node.previousElementSibling;
5553
+ for (let hop = 0; hop < 3 && prevSib; hop++, prevSib = prevSib.previousElementSibling) {
5554
+ if (prevSib.matches("h1,h2,h3,h4,h5,h6,legend,[role='heading']")) return norm(prevSib.textContent);
5555
+ }
5556
+ return "";
5557
+ };
5558
+ const sectionOf = (box, includeSelf) => {
5559
+ const parts = [includeSelf ? namedBy(box) : "", headingSibling(box)];
5560
+ let node = box.parentElement;
5561
+ for (let depth = 0; depth < 4 && node && node !== document.body; depth++, node = node.parentElement) {
5562
+ parts.push(namedBy(node), headingSibling(node));
5563
+ }
5564
+ return Array.from(new Set(parts.filter(Boolean))).join(" ");
5565
+ };
5566
+ const ownCaption = (box) => {
5567
+ const caption = Array.from(box.querySelectorAll("label,legend")).find((l) => !l.querySelector("input,[role='spinbutton']") && !(l.getAttribute("for") && box.querySelector("[id='" + l.getAttribute("for") + "']")));
5568
+ if (caption) return norm(caption.textContent);
5569
+ const prev = box.previousElementSibling;
5570
+ return prev && prev.matches("label,legend") && !prev.querySelector("input,[role='spinbutton']") ? norm(prev.textContent) : "";
5571
+ };
5572
+ const byContainer = new Map();
5573
+ for (const el of spins) {
5574
+ const box = ownerOf(el);
5575
+ if (!byContainer.has(box)) byContainer.set(box, []);
5576
+ byContainer.get(box).push(el);
5577
+ }
5578
+ const labelText = (seg) => {
5579
+ const parts = [idText(seg.getAttribute("aria-labelledby")), norm(seg.getAttribute("aria-label")), norm(seg.getAttribute("placeholder"))];
5580
+ if (seg.id) {
5581
+ document.querySelectorAll("label").forEach((l) => { if (l.getAttribute("for") === seg.id) parts.push(norm(l.textContent)); });
5582
+ }
5583
+ const wrap = seg.closest("label");
5584
+ if (wrap) parts.push(norm(wrap.textContent));
5585
+ return parts.filter(Boolean).join(" ");
5586
+ };
5587
+ const maxOf = (seg, si, count) => {
5588
+ const declared = Number(seg.getAttribute("aria-valuemax"));
5589
+ if (declared > 0) return declared;
5590
+ const own = labelText(seg).toLowerCase();
5591
+ if (/year|yyyy|\byy\b/.test(own)) return 9999;
5592
+ if (/month|\bmm\b/.test(own)) return 12;
5593
+ if (/day|\bdd\b/.test(own)) return 31;
5594
+ const len = Number(seg.getAttribute("maxlength"));
5595
+ if (len > 0) return Math.pow(10, len) - 1;
5596
+ return count > 1 && si === count - 1 ? 9999 : 12;
5597
+ };
5598
+ const groups = [];
5599
+ const mark = (seg, token) => {
5600
+ const prior = seg.getAttribute(attr);
5601
+ seg.setAttribute(attr, prior ? prior + " " + token : token);
5602
+ };
5603
+ const addGroup = (segs, box, nested) => {
5604
+ const gi = groups.length;
5605
+ segs.forEach((seg, si) => mark(seg, gi + ":" + si));
5606
+ let inner = null;
5607
+ for (let node = segs[0].parentElement; node && node !== box && box.contains(node) && !inner; node = node.parentElement) {
5608
+ if (segs.every((seg) => node.contains(seg)) && hasCaption(node)) inner = node;
5609
+ }
5610
+ const labelBox = inner || box;
5611
+ const own = namedBy(labelBox) || ownCaption(labelBox) || segs.map((seg) => idText(seg.getAttribute("aria-labelledby"))).filter(Boolean).join(" ");
5612
+ const section = sectionOf(labelBox, Boolean(inner));
5613
+ groups.push({
5614
+ segmentMaxes: segs.map((seg, si) => maxOf(seg, si, segs.length)),
5615
+ context: [section, own].filter(Boolean).join(" "),
5616
+ label: own,
5617
+ section,
5618
+ nested,
5619
+ segmentLabels: segs.map((seg) => labelText(seg)),
5620
+ });
5621
+ };
5622
+ const PART_WORD = /^(mm|dd|yyyy|yy|month|day|year)$/i;
5623
+ const keyOf = (text) => norm(text).split(" ").filter((word) => {
5624
+ const parts = word.split(/[^A-Za-z0-9]+/).filter(Boolean);
5625
+ return parts.length > 0 && !parts.every((part) => PART_WORD.test(part));
5626
+ }).join(" ");
5627
+ const ownKey = (seg) => {
5628
+ const parts = [idText(seg.getAttribute("aria-labelledby")), norm(seg.getAttribute("aria-label"))];
5629
+ if (seg.id) {
5630
+ document.querySelectorAll("label").forEach((l) => { if (l.getAttribute("for") === seg.id) parts.push(norm(l.textContent)); });
5631
+ }
5632
+ const wrap = seg.closest("label");
5633
+ if (wrap) parts.push(norm(wrap.textContent));
5634
+ return keyOf(parts.filter(Boolean).join(" "));
5635
+ };
5636
+ // A container's legend names the whole section; the field a segment belongs
5637
+ // to is the caption naming it, or the caption nearest before it with no other
5638
+ // control in between.
5639
+ const fieldKeys = (segs, box) => {
5640
+ const own = new Set(segs);
5641
+ const boxLegend = box.tagName === "FIELDSET" ? box.querySelector(":scope > legend") : null;
5642
+ const keys = new Map();
5643
+ let current = "";
5644
+ box.querySelectorAll("label,legend,input,select,textarea,button").forEach((el) => {
5645
+ if (el === boxLegend) return;
5646
+ if (el.matches("label,legend")) {
5647
+ const target = el.getAttribute("for");
5648
+ if (isHidden(el) || el.querySelector("input,select,textarea,[role='spinbutton']") || (target && box.querySelector("[id='" + target + "']"))) return;
5649
+ current = keyOf(el.textContent);
5650
+ return;
5651
+ }
5652
+ if (!own.has(el)) {
5653
+ if (!isHidden(el)) current = "";
5654
+ return;
5655
+ }
5656
+ keys.set(el, ownKey(el) || current);
5657
+ });
5658
+ return segs.map((seg) => keys.get(seg) || "");
5659
+ };
5660
+ const captionRuns = [];
5661
+ const nestedCandidates = [];
5662
+ byContainer.forEach((segs, box) => {
5663
+ addGroup(segs, box, false);
5664
+ const outerIndex = groups.length - 1;
5665
+ const byParent = new Map();
5666
+ for (const seg of segs) {
5667
+ if (!byParent.has(seg.parentElement)) byParent.set(seg.parentElement, []);
5668
+ byParent.get(seg.parentElement).push(seg);
5669
+ }
5670
+ // A shared fieldset can hold several independent dates (From and To year
5671
+ // spinbuttons side by side); each wrapper is then its own candidate group.
5672
+ if (byParent.size > 1) {
5673
+ byParent.forEach((inner, parent) => nestedCandidates.push([inner, parent]));
5674
+ return;
5675
+ }
5676
+ const keys = fieldKeys(segs, box);
5677
+ const runs = [];
5678
+ segs.forEach((seg, si) => {
5679
+ const last = runs[runs.length - 1];
5680
+ if (last && last.key === keys[si]) last.segs.push(seg);
5681
+ else runs.push({ key: keys[si], segs: [seg] });
5682
+ });
5683
+ runs.filter((run) => run.key).forEach((run) => captionRuns.push({ run, box, outerIndex, whole: run.segs.length === segs.length }));
5684
+ });
5685
+ nestedCandidates.forEach(([inner, parent]) => addGroup(inner, parent, true));
5686
+ captionRuns.forEach(({ run, box, outerIndex, whole }) => {
5687
+ const outer = groups[outerIndex];
5688
+ const same = (a, b) => a.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim() === b.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
5689
+ if (whole && same(outer.label || "", run.key)) return;
5690
+ const gi = groups.length;
5691
+ run.segs.forEach((seg, si) => mark(seg, gi + ":" + si));
5692
+ const section = sectionOf(box, true);
5693
+ groups.push({
5694
+ segmentMaxes: run.segs.map((seg, si) => maxOf(seg, si, run.segs.length)),
5695
+ context: [section, run.key].filter(Boolean).join(" "),
5696
+ label: run.key,
5697
+ section,
5698
+ nested: true,
5699
+ sameAs: whole ? outerIndex : undefined,
5700
+ });
5701
+ });
5702
+ return { groups };
5703
+ })()`;
5704
+ const SEGMENTED_DATE_CLEAR_EXPR = `(() => {
5705
+ document.querySelectorAll("[${SEGMENTED_DATE_MARKER_ATTR}]").forEach((el) => el.removeAttribute(${JSON.stringify(SEGMENTED_DATE_MARKER_ATTR)}));
5706
+ })()`;
5707
+ /**
5708
+ * Type a date into a multi-segment `role=spinbutton` widget with real key
5709
+ * events (raw CDP key events, `locator.type` with a delay as fallback), then
5710
+ * verify every segment numerically.
5711
+ *
5712
+ * Why this exists: such widgets keep each segment's value in component state
5713
+ * driven by `keydown`/`beforeinput`; `fill`/`insertText` bypasses that and
5714
+ * garbles the segments. Returns a marker id on commit, or null on no
5715
+ * spinbuttons, an unresolvable group, or any segment mismatch so the caller
5716
+ * falls through to the cascade.
5717
+ */
5718
+ async function trySegmentedDateSpinbuttonPrimitive(params) {
5719
+ const { page, target, instruction, logger } = params;
5720
+ const whole = parseSegmentedDateValue(instruction);
5721
+ const part = parseSegmentedPartStep(instruction);
5722
+ if (!whole && !part)
5723
+ return null;
5724
+ const sendCDP = frameCdpSender(page, target);
5725
+ try {
5726
+ const enumerated = (await target.evaluate(exports.SEGMENTED_DATE_ENUMERATE_EXPR));
5727
+ if (!enumerated?.groups?.length)
5728
+ return null;
5729
+ const wholeGi = whole ? resolveSegmentedDateGroup(instruction, enumerated.groups) : null;
5730
+ const partStep = wholeGi === null ? part : null;
5731
+ const value = (wholeGi !== null ? whole : part);
5732
+ const partOf = (group) => partStep ? segmentIndexForPart(group, partStep.part) : null;
5733
+ const partFits = (group) => {
5734
+ const si = partOf(group);
5735
+ return (!group.nested &&
5736
+ si !== null &&
5737
+ value.digits.length <= String(group.segmentMaxes[si] ?? 0).length);
5738
+ };
5739
+ const gi = wholeGi ??
5740
+ (partStep
5741
+ ? pickSegmentedDateGroup(instruction, enumerated.groups, partStep, partFits)
5742
+ : null);
5743
+ if (gi === null) {
5744
+ logger.info("segmented date primitive: no unambiguous group; falling through");
5745
+ await target.evaluate(SEGMENTED_DATE_CLEAR_EXPR);
5746
+ return null;
5747
+ }
5748
+ const maxes = enumerated.groups[gi]?.segmentMaxes ?? [];
5749
+ const widths = maxes.map((max) => String(max).length);
5750
+ const onlySegment = partStep ? partOf(enumerated.groups[gi]) : null;
5751
+ const active = widths.flatMap((_, si) => onlySegment === null || si === onlySegment ? [si] : []);
5752
+ const sliceFor = (si) => {
5753
+ if (onlySegment !== null)
5754
+ return value.digits;
5755
+ const start = widths.slice(0, si).reduce((a, b) => a + b, 0);
5756
+ return value.digits.slice(start, start + (widths[si] ?? 0));
5757
+ };
5758
+ const expected = active.map((si) => Number(sliceFor(si)));
5759
+ const segmentSelector = (si) => `[${SEGMENTED_DATE_MARKER_ATTR}~="${gi}:${si}"]`;
5760
+ const focusSegment = async (si) => {
5761
+ const selector = segmentSelector(si);
5762
+ // A below-the-fold segment would otherwise take the trusted click at an
5763
+ // off-screen point; centre it first so the click and key events land on it.
5764
+ try {
5765
+ await target.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (el && typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center", inline: "center" }); })()`);
5766
+ }
5767
+ catch {
5768
+ // scrolling is best-effort; the key events only need the segment focused
5769
+ }
5770
+ try {
5771
+ // An open option popup can cover the field; clear it so the trusted
5772
+ // click lands on the segment instead of falling back to a bare focus().
5773
+ if (await clearObscuringPopup(page, target, selector)) {
5774
+ await (0, trusted_click_1.clickCentredTrusted)(target, selector, { label: "date segment" });
5775
+ }
5776
+ }
5777
+ catch {
5778
+ // click unavailable; focus below is what the key events need
5779
+ }
5780
+ await target.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (el && document.activeElement !== el) el.focus(); })()`);
5781
+ };
5782
+ const typeViaCdp = async (digits) => {
5783
+ for (const digit of digits) {
5784
+ const keyArgs = {
5785
+ key: digit,
5786
+ code: `Digit${digit}`,
5787
+ windowsVirtualKeyCode: 48 + Number(digit),
5788
+ };
5789
+ await sendCDP("Input.dispatchKeyEvent", { type: "keyDown", text: digit, ...keyArgs });
5790
+ await sendCDP("Input.dispatchKeyEvent", { type: "keyUp", ...keyArgs });
5791
+ }
5792
+ };
5793
+ const segmentDigits = active.map((si) => sliceFor(si));
5794
+ const readSegments = async () => (await target.evaluate(`(() => {
5795
+ const sels = ${JSON.stringify(active.map((si) => segmentSelector(si)))};
5796
+ return sels.map((sel) => {
5797
+ const s = document.querySelector(sel);
5798
+ if (!s) return NaN;
5799
+ const now = s.getAttribute("aria-valuenow");
5800
+ const raw = now !== null && now !== "" ? now : s.value;
5801
+ return raw === undefined || raw === null || String(raw).trim() === "" ? NaN : Number(raw);
5802
+ });
5803
+ })()`));
5804
+ const matches = (got) => Array.isArray(got) &&
5805
+ got.length === expected.length &&
5806
+ got.every((n, i) => n === expected[i]);
5807
+ // Raw CDP key events go to the focused segment, so each segment is focused
5808
+ // explicitly rather than trusting the widget to auto-advance. A delayed
5809
+ // locator.type (per-character key events, never Input.insertText) covers a
5810
+ // page whose CDP channel does not reach the widget.
5811
+ const typeViaLocator = async () => {
5812
+ for (const [k, digits] of segmentDigits.entries()) {
5813
+ await target
5814
+ .locator(segmentSelector(active[k] ?? 0))
5815
+ .first()
5816
+ .type(digits, {
5817
+ delay: SEGMENTED_DATE_KEY_DELAY_MS,
5818
+ });
5819
+ }
5820
+ };
5821
+ const typeViaKeyEvents = async () => {
5822
+ for (const [k, digits] of segmentDigits.entries()) {
5823
+ await focusSegment(active[k] ?? 0);
5824
+ await typeViaCdp(digits);
5825
+ }
5826
+ };
5827
+ let actual = [];
5828
+ for (const type of [typeViaKeyEvents, typeViaLocator]) {
5829
+ try {
5830
+ await type();
5831
+ }
5832
+ catch (err) {
5833
+ logger.info(`segmented date primitive: delivery attempt threw: ${(0, errors_1.toErrorMessage)(err)}`);
5834
+ continue;
5835
+ }
5836
+ actual = await readSegments();
5837
+ if (matches(actual))
5838
+ break;
5839
+ }
5840
+ if (!matches(actual))
5841
+ actual = await readSegments();
5842
+ await target.evaluate(SEGMENTED_DATE_CLEAR_EXPR);
5843
+ if (!matches(actual)) {
5844
+ logger.info(`segmented date primitive: readback ${maskDateDigits(JSON.stringify(actual))} != ${maskDateDigits(JSON.stringify(expected))}; falling through`);
5845
+ return null;
5846
+ }
5847
+ logger.info(`segmented date primitive: committed segments ${maskDateDigits(JSON.stringify(actual))}`);
5848
+ return `segmented-date-${gi}`;
5849
+ }
5850
+ catch (err) {
5851
+ logger.warn(`segmented date primitive: threw: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
5852
+ return null;
5853
+ }
5854
+ }
4671
5855
  /**
4672
5856
  * Parse a single-choice RADIO flow step into the option to click and (when
4673
5857
  * present) the question label that scopes which radio group it targets.
@@ -4703,16 +5887,14 @@ function parseRadioStep(instruction) {
4703
5887
  // Catch-all steps ("for any remaining…") have no concrete single target.
4704
5888
  if (/\bany\s+remaining\b/.test(lower))
4705
5889
  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]);
5890
+ const quoted = (0, quoted_spans_1.findQuotedSpans)(instruction).map((s) => s.text);
4708
5891
  if (quoted.length === 0)
4709
5892
  return null;
4710
5893
  // The OPTION is the quoted string immediately after "click the".
4711
- const optMatch = instruction.match(/\bclick\s+the\s+'([^']+)'/i);
5894
+ const optMatch = matchQuotedAfter(instruction, /\bclick\s+the\s+(?=')/i)?.phrase;
4712
5895
  if (!optMatch)
4713
5896
  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();
5897
+ const option = optMatch.text.trim();
4716
5898
  // The QUESTION LABEL, when present, is a DIFFERENT quoted string — the one
4717
5899
  // introduced by "for the question '…'" / "for the '…' question". Some steps
4718
5900
  // phrase the question un-quoted ("…about requiring visa sponsorship"); in
@@ -4757,11 +5939,10 @@ function parseWidgetOptionClickStep(instruction) {
4757
5939
  // tolerant of a short qualifier ("top-level", "leaf") between the noun and
4758
5940
  // the quote — e.g. "click the top-level category 'Job Boards'" or "click
4759
5941
  // the leaf option 'Internet - Job Boards/Search Engines'".
4760
- const optMatch = instruction.match(/\bclick\s+the\s+(?:\S+\s+)?(?:category|option)\s+'([^']+)'/i);
5942
+ const optMatch = matchQuotedAfter(instruction, /\bclick\s+the\s+(?:\S+\s+)?(?:category|option)\s+(?=')/i)?.phrase;
4761
5943
  if (!optMatch)
4762
5944
  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();
5945
+ const option = optMatch.text.trim();
4765
5946
  return { option };
4766
5947
  }
4767
5948
  /**
@@ -4782,10 +5963,31 @@ function parseCheckStep(instruction) {
4782
5963
  const lower = instruction.toLowerCase();
4783
5964
  if (/\bany\s+remaining\b/.test(lower))
4784
5965
  return null;
4785
- const match = instruction.match(/\b(?:check|click)\s+(?:the\s+)?'([^']+)'.*?\bcheckbox\b/i);
4786
- const label = match?.[1]?.trim();
5966
+ const found = matchQuotedAfter(instruction, /\b(?:un)?check\s+(?:the\s+)?(?=')|\bclick\s+(?:the\s+)?(?=')/i);
5967
+ const rest = found ? instruction.slice(found.phrase.end) : "";
5968
+ const label = /^.*?\bcheckbox\b/i.test(rest) ? found?.phrase.text.trim() : undefined;
4787
5969
  return label ? { label } : null;
4788
5970
  }
5971
+ /**
5972
+ * Parse a single-checkbox STATE step ("Check the 'X' checkbox" / "Uncheck the
5973
+ * 'X' checkbox") into its label, desired state and trailing section words.
5974
+ *
5975
+ * Why this exists: "Click the … checkbox" is a toggle and stays with the
5976
+ * cascade; only the explicit check/uncheck verbs name a target state the
5977
+ * primitive can compare against what the page committed.
5978
+ */
5979
+ function parseCheckStateStep(instruction) {
5980
+ const parsed = parseCheckStep(instruction);
5981
+ const found = matchQuotedAfter(instruction, /\b(un)?check\s+(?:the\s+)?(?=')/i);
5982
+ if (!parsed || !found)
5983
+ return null;
5984
+ const outside = instruction.slice(0, found.match.index) +
5985
+ " " +
5986
+ instruction.slice(found.phrase.end).replace(/^.*?\bcheckbox\b/i, "");
5987
+ const stop = new Set(["the", "for", "and", "section", "under", "this", "that", "with", "from"]);
5988
+ const sectionWords = (outside.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter((w) => !stop.has(w) && (w.length >= 3 || /^\d+$/.test(w)));
5989
+ return { label: parsed.label, desired: !found.match[1], sectionWords };
5990
+ }
4789
5991
  /** 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
5992
  function stripQuotedLabel(label) {
4791
5993
  return label.replace(/^'+|'+$/g, "").trim();
@@ -5013,6 +6215,16 @@ function isNavigatingEvaluateRejection(error) {
5013
6215
  message.includes("no frame for given id found") ||
5014
6216
  GENERIC_CONTENT_FREE_EVAL_REJECTION_MESSAGES.has(message.trim()));
5015
6217
  }
6218
+ /**
6219
+ * Settles after a click and returns the post-click snapshot, so the settle
6220
+ * wait and the phantom classifier decide from the same reader and baseline.
6221
+ * Without a page there is nothing to wait on and the snapshot is taken as-is.
6222
+ */
6223
+ async function settleAndSnapshotPostClick(target, signalCounter, page, pre) {
6224
+ if (page)
6225
+ await settleWithPageQuiescence(page, target, signalCounter, pre.networkCount, true, pre.visibleTextSignature);
6226
+ return snapshotPage(target, signalCounter, page);
6227
+ }
5016
6228
  /**
5017
6229
  * Shared in-page discovery of the widget's callback, used identically by
5018
6230
  * `injectCaptchaTokenAndSubmit`'s precheck (to decide the branch) and its
@@ -5320,13 +6532,14 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
5320
6532
  if (clickResult.clicked) {
5321
6533
  if (!pre || !verification)
5322
6534
  return true;
5323
- const post = await snapshotPage(target, verification.signalCounter, verification.page);
6535
+ const post = await settleAndSnapshotPostClick(target, verification.signalCounter, verification.page, pre);
5324
6536
  const verdict = (0, phantom_click_1.classifyPhantomClick)({
5325
6537
  actResultSuccess: true,
5326
6538
  pre,
5327
6539
  post,
5328
6540
  isSubmitShapedStep: true,
5329
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, post.url),
6541
+ isMidFlowSubmitStep: verification.isMidFlowSubmitStep,
6542
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, post.url, verification.isMidFlowSubmitStep ? pre.url : undefined),
5330
6543
  });
5331
6544
  if (verdict !== "phantom")
5332
6545
  return true;
@@ -5340,13 +6553,14 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
5340
6553
  throw err;
5341
6554
  });
5342
6555
  if (runnerUpClickResult.clicked) {
5343
- const runnerUpPost = await snapshotPage(target, verification.signalCounter, verification.page);
6556
+ const runnerUpPost = await settleAndSnapshotPostClick(target, verification.signalCounter, verification.page, post);
5344
6557
  const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
5345
6558
  actResultSuccess: true,
5346
6559
  pre: post,
5347
6560
  post: runnerUpPost,
5348
6561
  isSubmitShapedStep: true,
5349
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, runnerUpPost.url),
6562
+ isMidFlowSubmitStep: verification.isMidFlowSubmitStep,
6563
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, runnerUpPost.url, verification.isMidFlowSubmitStep ? post.url : undefined),
5350
6564
  });
5351
6565
  if (runnerUpVerdict !== "phantom")
5352
6566
  return true;
@@ -5461,7 +6675,7 @@ async function trySelectPrimitive(params) {
5461
6675
  if (!parsed)
5462
6676
  return null;
5463
6677
  const { option, questionLabel } = parsed;
5464
- const optLabel = `option "${redactIfSensitive(option, redactValue).slice(0, 40)}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
6678
+ const optLabel = primitiveOptionLabel(option, questionLabel, redactValue);
5465
6679
  // Phase 1 (browser, no mutation): find the target select — the one whose
5466
6680
  // nearby text matches the question label (or, when no label, any select).
5467
6681
  // Try a deterministic option match first; if it hits, apply immediately (no
@@ -5559,7 +6773,7 @@ async function trySelectPrimitive(params) {
5559
6773
  if (enumResult.detMatch) {
5560
6774
  const { ok, stillInvalid } = await applySelectValue(page, target, enumResult.detMatch.selIdx, enumResult.detMatch.value);
5561
6775
  if (ok && !stillInvalid) {
5562
- logger.info(`select primitive: set dropdown to "${enumResult.detMatch.text.trim().slice(0, 40)}" (${optLabel})`);
6776
+ logger.info(`select primitive: set dropdown to "${(0, pii_redaction_1.maskValueShape)(enumResult.detMatch.text.trim())}" (${optLabel})`);
5563
6777
  return enumResult.detMatch.id;
5564
6778
  }
5565
6779
  logger.info(`select primitive: dropdown value for ${optLabel} did not commit (ok=${ok} stillInvalid=${stillInvalid}); falling through to cascade`);
@@ -5596,7 +6810,7 @@ async function trySelectPrimitive(params) {
5596
6810
  // same as the fast path.
5597
6811
  const { ok, stillInvalid } = await applySelectValue(page, target, chosenCandidate.selIdx, chosenOption.value);
5598
6812
  if (ok && !stillInvalid) {
5599
- logger.info(`select primitive: LLM chose "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
6813
+ logger.info(`select primitive: LLM chose "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
5600
6814
  return chosenCandidate.id;
5601
6815
  }
5602
6816
  logger.info(`select primitive: LLM-chosen value for ${optLabel} did not commit (ok=${ok} stillInvalid=${stillInvalid}); falling through`);
@@ -5745,25 +6959,183 @@ async function tryFillRequiredSelectsPrimitive(params) {
5745
6959
  allCommitted = false;
5746
6960
  continue;
5747
6961
  }
5748
- const chosen = cand.options.find((o) => o.text === chosenText);
5749
- if (!chosen) {
5750
- allCommitted = false;
6962
+ const chosen = cand.options.find((o) => o.text === chosenText);
6963
+ if (!chosen) {
6964
+ allCommitted = false;
6965
+ continue;
6966
+ }
6967
+ const { ok, stillInvalid } = await applySelectValue(page, target, cand.selIdx, chosen.value);
6968
+ if (ok && !stillInvalid) {
6969
+ logger.info(`required-select primitive: filled "${cand.label.slice(0, 40)}" with "${(0, pii_redaction_1.maskValueShape)(chosen.text)}"`);
6970
+ }
6971
+ else {
6972
+ allCommitted = false;
6973
+ logger.info(`required-select primitive: "${cand.label.slice(0, 40)}" did not commit (ok=${ok} stillInvalid=${stillInvalid})`);
6974
+ }
6975
+ }
6976
+ return allCommitted;
6977
+ }
6978
+ catch (err) {
6979
+ logger.warn(`required-select primitive: evaluate threw: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
6980
+ return false;
6981
+ }
6982
+ }
6983
+ /** Marker stamped on the matched checkbox / its label so trusted clicks can address them. */
6984
+ const CHECKBOX_STATE_MARKER_ATTR = "data-barnacle-cbstate";
6985
+ const CHECKBOX_STATE_POLL_MS = 200;
6986
+ const CHECKBOX_STATE_POLL_ATTEMPTS = 8;
6987
+ /**
6988
+ * Commits a single "Check/Uncheck the 'X' checkbox" step by comparing the
6989
+ * box's committed state to the requested one, so a pre-ticked box is never
6990
+ * toggled off by a blind click. Returns the box's DOM id (possibly empty) on
6991
+ * success, or null when ambiguous, absent, obscured or unflipped so the
6992
+ * caller falls through to the cascade.
6993
+ */
6994
+ async function tryCheckboxStatePrimitive(params) {
6995
+ const { page, target, instruction, logger } = params;
6996
+ const parsed = parseCheckStateStep(instruction);
6997
+ if (!parsed)
6998
+ return null;
6999
+ const attr = CHECKBOX_STATE_MARKER_ATTR;
7000
+ const stampExpr = `((label, words) => {
7001
+ const norm = (s) => (s || "").replace(/\\s+/g, " ").replace(/\\s*[*:]+$/, "").trim().toLowerCase();
7002
+ const want = norm(label);
7003
+ document.querySelectorAll("[${attr}]").forEach((e) => e.removeAttribute("${attr}"));
7004
+ const visible = (e) => {
7005
+ try { return !!e && e.getClientRects().length > 0 && getComputedStyle(e).visibility !== "hidden"; } catch { return !!e; }
7006
+ };
7007
+ const labelsOf = (cb) => {
7008
+ const out = [];
7009
+ if (cb.id) {
7010
+ document.querySelectorAll("label").forEach((l) => { if (l.getAttribute("for") === cb.id) out.push(l); });
7011
+ }
7012
+ const w = cb.closest("label");
7013
+ if (w && !out.includes(w)) out.push(w);
7014
+ return out;
7015
+ };
7016
+ const isOn = (cb) => {
7017
+ const a = cb.getAttribute("aria-checked");
7018
+ return a === "true" ? true : a === "false" ? false : cb.checked === true;
7019
+ };
7020
+ const boxes = Array.from(document.querySelectorAll("input[type=checkbox],[role=checkbox]"));
7021
+ let hits = [];
7022
+ for (const cb of boxes) {
7023
+ const ls = labelsOf(cb);
7024
+ const names = ls.map((l) => norm(l.textContent));
7025
+ const al = cb.getAttribute("aria-label");
7026
+ if (al) names.push(norm(al));
7027
+ const alb = cb.getAttribute("aria-labelledby");
7028
+ if (alb) {
7029
+ const refs = alb.split(/\\s+/).map((id) => document.getElementById(id)).filter(Boolean);
7030
+ if (refs.length > 0) names.push(norm(refs.map((r) => r.textContent).join(" ")));
7031
+ }
7032
+ if (!names.includes(want)) continue;
7033
+ if (!visible(cb) && !ls.some(visible)) continue;
7034
+ hits.push({ cb, ls });
7035
+ }
7036
+ const sectionText = (cb) => {
7037
+ const parts = [];
7038
+ for (let n = cb.parentElement; n; n = n.parentElement) {
7039
+ const lb = n.getAttribute("aria-labelledby");
7040
+ if (lb) lb.split(/\\s+/).forEach((id) => { const el = document.getElementById(id); if (el) parts.push(el.textContent); });
7041
+ const lg = n.querySelector(":scope > legend");
7042
+ if (lg) parts.push(lg.textContent);
7043
+ const hd = n.querySelector(":scope > h2, :scope > h3, :scope > h4");
7044
+ if (hd) parts.push(hd.textContent);
7045
+ let sib = n.previousElementSibling;
7046
+ for (let hop = 0; hop < 3 && sib; hop++, sib = sib.previousElementSibling) {
7047
+ if (!sib.matches("h1,h2,h3,h4,h5,h6,legend,[role='heading']")) continue;
7048
+ parts.push(sib.textContent);
7049
+ break;
7050
+ }
7051
+ }
7052
+ return norm(parts.join(" "));
7053
+ };
7054
+ if (hits.length > 1 && words.length > 0) {
7055
+ const scored = hits.map((h) => {
7056
+ const tokens = new Set(sectionText(h.cb).match(/[a-z0-9]+/g) || []);
7057
+ return { h, score: words.filter((w) => tokens.has(w)).length };
7058
+ });
7059
+ const top = Math.max(...scored.map((x) => x.score));
7060
+ if (top > 0) hits = scored.filter((x) => x.score === top).map((x) => x.h);
7061
+ }
7062
+ if (hits.length === 0) return { status: "none" };
7063
+ if (hits.length > 1) return { status: "ambiguous" };
7064
+ const { cb, ls } = hits[0];
7065
+ cb.setAttribute("${attr}", "box");
7066
+ const lab = ls.find(visible);
7067
+ if (lab) lab.setAttribute("${attr}", "label");
7068
+ return { status: "ok", checked: isOn(cb), id: cb.id || "", hasLabel: !!lab };
7069
+ })(${JSON.stringify(parsed.label.toLowerCase())}, ${JSON.stringify(parsed.sectionWords)})`;
7070
+ const stateExpr = `(() => {
7071
+ const cb = document.querySelector("[${attr}=box]");
7072
+ if (!cb) return null;
7073
+ const a = cb.getAttribute("aria-checked");
7074
+ return a === "true" ? true : a === "false" ? false : cb.checked === true;
7075
+ })()`;
7076
+ const clearExpr = `document.querySelectorAll("[${attr}]").forEach((e) => e.removeAttribute("${attr}"))`;
7077
+ try {
7078
+ const stamped = (await target.evaluate(stampExpr));
7079
+ if (stamped?.status !== "ok")
7080
+ return null;
7081
+ const id = stamped.id ?? "";
7082
+ if (stamped.checked === parsed.desired) {
7083
+ logger.info(`checkbox state primitive: "${parsed.label.slice(0, 40)}" already ${parsed.desired ? "checked" : "unchecked"}, no click`);
7084
+ return id;
7085
+ }
7086
+ const clickSels = [...(stamped.hasLabel ? [`[${attr}="label"]`] : []), `[${attr}="box"]`];
7087
+ const reachedDesired = async () => {
7088
+ for (let i = 0; i < CHECKBOX_STATE_POLL_ATTEMPTS; i++) {
7089
+ await page.waitForTimeout(CHECKBOX_STATE_POLL_MS).catch(() => undefined);
7090
+ if ((await target.evaluate(stateExpr)) === parsed.desired)
7091
+ return true;
7092
+ }
7093
+ return false;
7094
+ };
7095
+ const succeed = () => {
7096
+ logger.info(`checkbox state primitive: "${parsed.label.slice(0, 40)}" now ${parsed.desired ? "checked" : "unchecked"}`);
7097
+ return id;
7098
+ };
7099
+ let overlaid = false;
7100
+ let trustedDelivered = false;
7101
+ for (const sel of clickSels) {
7102
+ const firstProbe = await (0, trusted_click_1.probeClickPoint)(target, sel);
7103
+ const probe = firstProbe?.state === "occluded" && firstProbe.occluder !== "none"
7104
+ ? await dismissLeftoverPromptPopups(page, target).then(() => (0, trusted_click_1.probeClickPoint)(target, sel), () => firstProbe)
7105
+ : firstProbe;
7106
+ if (probe?.state === "occluded" && probe.occluder !== "none") {
7107
+ overlaid = true;
5751
7108
  continue;
5752
7109
  }
5753
- const { ok, stillInvalid } = await applySelectValue(page, target, cand.selIdx, chosen.value);
5754
- if (ok && !stillInvalid) {
5755
- logger.info(`required-select primitive: filled "${cand.label.slice(0, 40)}" with "${chosen.text.slice(0, 40)}"`);
5756
- }
5757
- else {
5758
- allCommitted = false;
5759
- logger.info(`required-select primitive: "${cand.label.slice(0, 40)}" did not commit (ok=${ok} stillInvalid=${stillInvalid})`);
7110
+ if (probe?.state !== "hit")
7111
+ continue;
7112
+ const delivered = await (0, trusted_click_1.clickCentredTrusted)(target, sel, { label: "checkbox" })
7113
+ .then(() => true)
7114
+ .catch(() => false);
7115
+ trustedDelivered ||= delivered;
7116
+ if (delivered && (await reachedDesired()))
7117
+ return succeed();
7118
+ }
7119
+ // A hit test that cannot resolve a point at all (no layout) says nothing
7120
+ // about whether a programmatic click toggles the box, so it is tried once;
7121
+ // a named overlay or an already-delivered trusted click that did not flip the
7122
+ // box is left to the cascade, since a second click could toggle it back.
7123
+ if (!overlaid && !trustedDelivered) {
7124
+ for (const sel of clickSels) {
7125
+ await target.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(sel)}); if (el) el.click(); })()`);
7126
+ if (await reachedDesired())
7127
+ return succeed();
5760
7128
  }
5761
7129
  }
5762
- return allCommitted;
7130
+ logger.warn(`checkbox state primitive: "${parsed.label.slice(0, 40)}" did not reach desired state, falling through`);
7131
+ return null;
5763
7132
  }
5764
7133
  catch (err) {
5765
- logger.warn(`required-select primitive: evaluate threw: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
5766
- return false;
7134
+ logger.warn(`checkbox state primitive failed: ${(0, errors_1.toErrorMessage)(err)}`);
7135
+ return null;
7136
+ }
7137
+ finally {
7138
+ await target.evaluate(clearExpr).catch(() => undefined);
5767
7139
  }
5768
7140
  }
5769
7141
  /**
@@ -5800,7 +7172,7 @@ async function tryCheckboxPrimitive(params) {
5800
7172
  if (!parsed)
5801
7173
  return null;
5802
7174
  const { option, questionLabel } = parsed;
5803
- const optLabel = `option "${redactIfSensitive(option, redactValue).slice(0, 40)}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
7175
+ const optLabel = primitiveOptionLabel(option, questionLabel, redactValue);
5804
7176
  // Phase 1 (browser, no mutation): find checkbox GROUPS and their options.
5805
7177
  // A group is a `c-MultiCheckboxInput` container or a `<fieldset>` containing
5806
7178
  // checkboxes. Question label = the group's legend / associated label; each
@@ -5931,7 +7303,7 @@ async function tryCheckboxPrimitive(params) {
5931
7303
  })(${JSON.stringify(chosenGroup.gi)}, ${JSON.stringify(chosenOption.bi)})`;
5932
7304
  const applyResult = (await target.evaluate(applyExpr));
5933
7305
  if (applyResult?.ok) {
5934
- logger.info(`checkbox primitive: LLM checked "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
7306
+ logger.info(`checkbox primitive: LLM checked "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
5935
7307
  return applyResult.id;
5936
7308
  }
5937
7309
  logger.info(`checkbox primitive: LLM-chosen checkbox did not stick for ${optLabel}; falling through`);
@@ -6127,7 +7499,7 @@ async function tryRadioPrimitive(params) {
6127
7499
  if (!parsed)
6128
7500
  return null;
6129
7501
  const { option, questionLabel } = parsed;
6130
- const optLabel = `option "${redactIfSensitive(option, redactValue).slice(0, 40)}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
7502
+ const optLabel = primitiveOptionLabel(option, questionLabel, redactValue);
6131
7503
  // Phase 1 (browser): find radio GROUPS and their options. A group is a
6132
7504
  // `<fieldset>` / `[role=radiogroup]` / `[role=group]` / `[class*='RadioGroup']`
6133
7505
  // container with radios. Question label = the group's legend / associated label; each option
@@ -6212,7 +7584,7 @@ async function tryRadioPrimitive(params) {
6212
7584
  xpath: chosenOpt?.xpath ?? "",
6213
7585
  });
6214
7586
  if (applied) {
6215
- logger.info(`radio primitive: selected "${(chosenOpt?.text ?? "").trim().slice(0, 40)}" (${optLabel})`);
7587
+ logger.info(`radio primitive: selected "${(0, pii_redaction_1.maskValueShape)((chosenOpt?.text ?? "").trim())}" (${optLabel})`);
6216
7588
  return chosenOpt?.id ?? "";
6217
7589
  }
6218
7590
  logger.info(`radio primitive: chosen radio did not stick for ${optLabel}; falling through`);
@@ -6258,7 +7630,7 @@ async function tryRadioPrimitive(params) {
6258
7630
  }),
6259
7631
  };
6260
7632
  if (applyResult?.ok) {
6261
- logger.info(`radio primitive: LLM selected "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
7633
+ logger.info(`radio primitive: LLM selected "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
6262
7634
  return chosenOption.id ?? "";
6263
7635
  }
6264
7636
  logger.info(`radio primitive: LLM-chosen radio did not stick for ${optLabel}; falling through`);
@@ -6310,7 +7682,7 @@ async function applyRadioSelection(target, gi, ri, hint) {
6310
7682
  const inputSel = hint.id ? buildRadioIdXPath(hint.id) : hint.xpath ? `xpath=${hint.xpath}` : null;
6311
7683
  if (inputSel) {
6312
7684
  try {
6313
- await target.locator(inputSel).first().click();
7685
+ await (0, trusted_click_1.clickCentredTrusted)(target, inputSel, { label: "radio input" });
6314
7686
  if (await readback())
6315
7687
  return true;
6316
7688
  }
@@ -6321,10 +7693,9 @@ async function applyRadioSelection(target, gi, ri, hint) {
6321
7693
  // Tier B — trusted click on the associated label (MUI hides the real input).
6322
7694
  if (hint.id) {
6323
7695
  try {
6324
- await target
6325
- .locator(`xpath=//label[@for=${JSON.stringify(hint.id)}]`)
6326
- .first()
6327
- .click();
7696
+ await (0, trusted_click_1.clickCentredTrusted)(target, `xpath=//label[@for=${JSON.stringify(hint.id)}]`, {
7697
+ label: "radio label",
7698
+ });
6328
7699
  if (await readback())
6329
7700
  return true;
6330
7701
  }
@@ -6357,6 +7728,29 @@ async function applyRadioSelection(target, gi, ri, hint) {
6357
7728
  await target.evaluate(applyExpr).catch(() => ({ ok: false }));
6358
7729
  return await readback();
6359
7730
  }
7731
+ /**
7732
+ * Extracts the option a step wants committed into a prompt-selector widget,
7733
+ * with the question label that scopes which widget. Flow/replan generation
7734
+ * words this as a SELECT, a FILL of the widget's search box, a radio-style
7735
+ * answer click, or a cascading-multiselect category/leaf click, so all four
7736
+ * shapes resolve to one `{ option, questionLabel }` — shared by the picking
7737
+ * primitive and the act-success committed-value guard so both compare the page
7738
+ * against the same requested value.
7739
+ */
7740
+ function parsePromptSelectorStep(instruction) {
7741
+ const select = parseSelectStep(instruction);
7742
+ if (select)
7743
+ return { ...select, isFill: false };
7744
+ const fill = parseFillStep(instruction);
7745
+ if (fill) {
7746
+ return { option: fill.value, questionLabel: stripQuotedLabel(fill.fieldLabel), isFill: true };
7747
+ }
7748
+ const answer = parseRadioStep(instruction);
7749
+ if (answer)
7750
+ return { ...answer, isFill: false };
7751
+ const optionClick = parseWidgetOptionClickStep(instruction);
7752
+ return optionClick ? { option: optionClick.option, questionLabel: null, isFill: false } : null;
7753
+ }
6360
7754
  /**
6361
7755
  * Answer a native-control-less popup-dropdown widget (a combobox that opens a
6362
7756
  * listbox popup and renders NO `<select>`/`<input>` a focused `<select>`/
@@ -6428,23 +7822,11 @@ async function tryPromptSelectorPrimitive(params) {
6428
7822
  // shape), then parseWidgetOptionClickStep (option only, no question
6429
7823
  // label) so the widget-matching/open/readback phases below run unchanged
6430
7824
  // 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;
7825
+ const parsed = parsePromptSelectorStep(instruction);
6444
7826
  if (!parsed)
6445
7827
  return null;
6446
7828
  const { option, questionLabel } = parsed;
6447
- const optLabel = `option "${redactIfSensitive(option, redactValue).slice(0, 40)}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
7829
+ const optLabel = primitiveOptionLabel(option, questionLabel, redactValue, parsed.isFill);
6448
7830
  const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
6449
7831
  // Phase 1 (browser, read-only except for the marker attribute stamped for
6450
7832
  // Phase 2's click addressing): find candidate widgets — popup-dropdown
@@ -6457,6 +7839,7 @@ async function tryPromptSelectorPrimitive(params) {
6457
7839
  const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
6458
7840
  const triggers = Array.from(document.querySelectorAll(triggerSel));
6459
7841
  if (triggers.length === 0) return { widgetPresent: false };
7842
+ const containerOf = ${PROMPT_WIDGET_CONTAINER_EXPR};
6460
7843
  const seen = new Set();
6461
7844
  const resolved = [];
6462
7845
  for (const el of triggers) {
@@ -6466,18 +7849,7 @@ async function tryPromptSelectorPrimitive(params) {
6466
7849
  // interactive ancestor, else the OUTERMOST widget-kit container on the
6467
7850
  // ancestor chain (so the inner input and its container collapse to the
6468
7851
  // 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
- }
7852
+ const container = containerOf(el);
6481
7853
  if (seen.has(container)) continue;
6482
7854
  seen.add(container);
6483
7855
  resolved.push(container);
@@ -6499,7 +7871,9 @@ async function tryPromptSelectorPrimitive(params) {
6499
7871
  if (parts.length) return parts.join(" ").replace(/\\s+/g, " ").trim().slice(0, 120);
6500
7872
  }
6501
7873
  const al = w.getAttribute("aria-label");
6502
- if (al) return al.replace(/\\s+/g, " ").trim().slice(0, 120);
7874
+ // A trigger's aria-label can merely echo its placeholder ("Select One
7875
+ // Required"), which names no question — defer to the <label>/group lookup.
7876
+ if (al && !emptyRx.test(al.replace(/\\s*required\\s*$/i, ""))) return al.replace(/\\s+/g, " ").trim().slice(0, 120);
6503
7877
  const labelledIds = [w.id, ...Array.from(w.querySelectorAll("[id]")).map((e) => e.id)].filter(Boolean);
6504
7878
  for (const id of labelledIds) {
6505
7879
  const lab = document.querySelector("label[for='" + (window.CSS && CSS.escape ? CSS.escape(id) : id) + "']");
@@ -6575,7 +7949,7 @@ async function tryPromptSelectorPrimitive(params) {
6575
7949
  logger.info(`prompt-selector primitive: no unambiguous widget match for ${optLabel}; falling through to cascade`);
6576
7950
  return null;
6577
7951
  }
6578
- const enumerateOptionsExpr = `((widgetMarkAttr, wIdx, markAttr, optionSel, searchSel) => {
7952
+ const enumerateOptionsExpr = `((widgetMarkAttr, wIdx, markAttr, optionSel, searchSel, triggerSel) => {
6579
7953
  const optionLabel = ${OPTION_ACCESSIBLE_LABEL_EXPR};
6580
7954
  const w = document.querySelector("[" + widgetMarkAttr + '="' + wIdx + '"]');
6581
7955
  if (!w) return { optionsPresent: false };
@@ -6593,12 +7967,35 @@ async function tryPromptSelectorPrimitive(params) {
6593
7967
  // inline subtree), so a sibling widget's options are never picked. Fall
6594
7968
  // back to document only when neither a portal nor an inline popup is found.
6595
7969
  const scope = ${PROMPT_SCOPE_ROOT_EXPR}(w);
6596
- const all = Array.from(scope.querySelectorAll(optionSel));
7970
+ // Under the document fallback, ownership is structural: content is foreign
7971
+ // when, climbing from it WITHOUT passing through this widget, some ancestor
7972
+ // is or holds another widget's trigger — e.g. a committed sibling's chips,
7973
+ // whatever markup wraps them. Resolved from the trigger union rather than
7974
+ // the enumeration mark, because a COMMITTED sibling is never marked (only
7975
+ // unfilled widgets are) yet its chips are the likeliest leak. An unlinked
7976
+ // portal sits under no widget, so it stays readable.
7977
+ const containerOf = ${PROMPT_WIDGET_CONTAINER_EXPR};
7978
+ const foreignRoots = scope === document
7979
+ ? Array.from(new Set(Array.from(document.querySelectorAll(triggerSel)).map(containerOf)))
7980
+ .filter((o) => o !== w && !o.contains(w) && !w.contains(o))
7981
+ : [];
7982
+ const heldByOtherWidget = (el) => {
7983
+ if (scope !== document || w.contains(el)) return false;
7984
+ for (let a = el; a && a !== document.body && !a.contains(w); a = a.parentElement) {
7985
+ if (a.matches(triggerSel) || a.querySelector(triggerSel)) return true;
7986
+ }
7987
+ return false;
7988
+ };
7989
+ const isOwned = (el) => !foreignRoots.some((o) => o.contains(el)) && !heldByOtherWidget(el);
7990
+ const all = Array.from(scope.querySelectorAll(optionSel)).filter(isOwned);
6597
7991
  // A union hit may be an ancestor of another (e.g. role=listbox > li > p):
6598
7992
  // keep only leaf-most option nodes with their own text so we don't double
6599
7993
  // count or address a wrapper.
6600
7994
  const opts = all.filter((el) => !all.some((o) => o !== el && el.contains(o)));
6601
- const searchInput = scope.querySelector(searchSel);
7995
+ const searchInput = scope === document
7996
+ ? (w.matches(searchSel) || w.matches("input") ? w : w.querySelector(searchSel))
7997
+ : (scope.matches && scope.matches(searchSel) ? scope : null)
7998
+ || Array.from(scope.querySelectorAll(searchSel)).find(isOwned) || null;
6602
7999
  // Mark the scoped filter input so the Node-side fill addresses THIS
6603
8000
  // widget's input (Playwright's locator can't re-run the scope resolution).
6604
8001
  if (searchInput) searchInput.setAttribute(markAttr + "-search", "1");
@@ -6606,13 +8003,23 @@ async function tryPromptSelectorPrimitive(params) {
6606
8003
  // is typed into — report the popup as present (with searchable=true) so the
6607
8004
  // caller types the filter first, rather than falling through as "no popup".
6608
8005
  if (opts.length === 0) {
6609
- return searchInput ? { optionsPresent: true, searchable: true, options: [] } : { optionsPresent: false };
8006
+ return searchInput ? { optionsPresent: true, searchable: true, options: [], scopeIsDocument: scope === document } : { optionsPresent: false };
6610
8007
  }
6611
8008
  const options = [];
6612
8009
  for (let i = 0; i < opts.length; i++) {
6613
- opts[i].setAttribute(markAttr, String(i));
6614
8010
  const label = optionLabel(opts[i]);
6615
- options.push({ oIdx: i, text: label });
8011
+ const ids = window.__bclOptionNodeIds || (window.__bclOptionNodeIds = { map: new WeakMap(), next: 0 });
8012
+ if (!ids.map.has(opts[i])) ids.map.set(opts[i], ids.next++);
8013
+ // Stamped with the node's own identity, not its list position: a
8014
+ // re-enumeration after a virtualized re-render or scroll can reorder
8015
+ // positions, so a positional stamp would re-address a different item.
8016
+ opts[i].setAttribute(markAttr, String(ids.map.get(opts[i])));
8017
+ options.push({
8018
+ oIdx: i,
8019
+ text: label,
8020
+ nodeId: ids.map.get(opts[i]),
8021
+ domText: (opts[i].textContent || "").replace(/\\s+/g, " ").trim(),
8022
+ });
6616
8023
  }
6617
8024
  // scopeIsDocument distinguishes a genuinely resolved (portal or inline)
6618
8025
  // popup from the document-wide last-resort fallback in
@@ -6622,7 +8029,7 @@ async function tryPromptSelectorPrimitive(params) {
6622
8029
  // that resolved to THIS widget's own inline subtree or its own
6623
8030
  // aria-controls/aria-owns target counts.
6624
8031
  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)})`;
8032
+ })(${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
8033
  // Pre-check (single evaluate, no polling): a PRIOR primitive call on this
6627
8034
  // same unreloaded page may have already opened/drilled this exact widget's
6628
8035
  // popup (a two-level cascading multiselect authored as two flow steps —
@@ -6634,6 +8041,12 @@ async function tryPromptSelectorPrimitive(params) {
6634
8041
  // an unrelated widget's stale/leftover popup elsewhere on the page as
6635
8042
  // "already open"); a genuinely closed popup falls through to the normal
6636
8043
  // open-click + poll path below unchanged.
8044
+ // A popup whose aria-controls link lands after the click is not yet
8045
+ // resolvable; a document-scoped read would credit a sibling widget's
8046
+ // content, so keep polling until a non-document scope resolves. The poll
8047
+ // returns the last read when the budget runs out, so a popup that
8048
+ // genuinely has no linkage still resolves through the document fallback.
8049
+ const isOwnScopePopup = (r) => r?.optionsPresent === true && r.scopeIsDocument !== true;
6637
8050
  const precheck = (await target.evaluate(enumerateOptionsExpr));
6638
8051
  const alreadyOpen = precheck?.optionsPresent === true &&
6639
8052
  (precheck.options?.length ?? 0) > 0 &&
@@ -6659,7 +8072,7 @@ async function tryPromptSelectorPrimitive(params) {
6659
8072
  // Fall back to the container selector.
6660
8073
  }
6661
8074
  try {
6662
- await target.locator(triggerSel).first().click();
8075
+ await (0, trusted_click_1.clickCentredTrusted)(target, triggerSel, { label: "prompt-selector trigger" });
6663
8076
  }
6664
8077
  catch (err) {
6665
8078
  logger.info(`prompt-selector primitive: trigger click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
@@ -6672,7 +8085,7 @@ async function tryPromptSelectorPrimitive(params) {
6672
8085
  }
6673
8086
  const optionsInitial = alreadyOpen
6674
8087
  ? precheck
6675
- : await pollEnumerate(page, target, enumerateOptionsExpr, (r) => r?.optionsPresent === true);
8088
+ : await pollEnumerate(page, target, enumerateOptionsExpr, isOwnScopePopup);
6676
8089
  if (!optionsInitial?.optionsPresent) {
6677
8090
  logger.info(`prompt-selector primitive: popup for ${optLabel} did not render options; falling through`);
6678
8091
  return null;
@@ -6703,19 +8116,61 @@ async function tryPromptSelectorPrimitive(params) {
6703
8116
  // to its popup), falling back to the union only if the mark is absent.
6704
8117
  const scopedSearchSel = `[${PROMPT_OPTION_MARK_ATTR}-search="1"]`;
6705
8118
  const marked = await target.locator(scopedSearchSel).count();
6706
- await target
6707
- .locator(marked > 0 ? scopedSearchSel : PROMPT_SEARCH_SELECTORS)
6708
- .first()
6709
- .fill(option);
8119
+ if (marked === 0) {
8120
+ logger.info(`prompt-selector primitive: no filter input owned by the widget for ${optLabel}; falling through`);
8121
+ return null;
8122
+ }
8123
+ await target.locator(scopedSearchSel).first().fill(option);
6710
8124
  }
6711
8125
  catch (err) {
6712
8126
  logger.info(`prompt-selector primitive: filter-input type failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
6713
8127
  return null;
6714
8128
  }
6715
8129
  await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
6716
- const optionsFiltered = await pollEnumerate(page, target, enumerateOptionsExpr, (r) => r?.optionsPresent === true);
8130
+ let optionsFiltered = await pollEnumerate(page, target, enumerateOptionsExpr, isOwnScopePopup);
8131
+ // An as-you-type search may still be in flight; Enter now would re-query with
8132
+ // an empty search and swap in the full list. Search-on-submit widgets never
8133
+ // name the option unprompted, so the wait is bounded.
8134
+ for (let wait = 0; wait < PROMPT_SELECTOR_MAX_SEARCH_WAITS &&
8135
+ optionsFiltered?.optionsPresent &&
8136
+ !promptOptionsNameOption(optionsFiltered, option); wait++) {
8137
+ await page.waitForTimeout(PROMPT_SELECTOR_SEARCH_WAIT_MS);
8138
+ optionsFiltered = (await target.evaluate(enumerateOptionsExpr));
8139
+ }
8140
+ // Search-on-submit widgets keep a placeholder (or unrelated chips) until an
8141
+ // Enter keydown; filter-as-you-type widgets already name the option here.
8142
+ // The list is settled BEFORE each submit decision, so a search-as-you-type
8143
+ // list still re-rendering (stale rows from the prior query) never reads as
8144
+ // "names nothing", and the precondition is re-read right before the key
8145
+ // press so a list that loaded in the meantime is not submitted at.
8146
+ for (let submit = 0;; submit++) {
8147
+ optionsFiltered = optionsFiltered?.optionsPresent
8148
+ ? await readStablePromptOptions(page, target, enumerateOptionsExpr, optionsFiltered)
8149
+ : null;
8150
+ if (!optionsFiltered ||
8151
+ promptOptionsNameOption(optionsFiltered, option) ||
8152
+ submit >= PROMPT_SELECTOR_MAX_ENTER_SUBMITS) {
8153
+ break;
8154
+ }
8155
+ const live = (await target.evaluate(enumerateOptionsExpr));
8156
+ if (promptOptionsNameOption(live, option)) {
8157
+ optionsFiltered = live;
8158
+ continue;
8159
+ }
8160
+ logger.info(`prompt-selector primitive: filtered list for ${optLabel} names no matching option; submitting with Enter`);
8161
+ try {
8162
+ await target.evaluate(`(() => { const el = document.querySelector('[${PROMPT_OPTION_MARK_ATTR}-search="1"]'); if (el) el.focus(); })()`);
8163
+ await sendTrustedEnter(page, target);
8164
+ }
8165
+ catch (err) {
8166
+ logger.info(`prompt-selector primitive: enter submit failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}`);
8167
+ break;
8168
+ }
8169
+ await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
8170
+ optionsFiltered = await pollEnumerate(page, target, enumerateOptionsExpr, (r) => isOwnScopePopup(r) && promptOptionsNameOption(r, option));
8171
+ }
6717
8172
  if (!optionsFiltered?.optionsPresent) {
6718
- logger.info(`prompt-selector primitive: filtered popup for ${optLabel} rendered no options; falling through`);
8173
+ logger.info(`prompt-selector primitive: filtered popup for ${optLabel} rendered no stable options; falling through`);
6719
8174
  return null;
6720
8175
  }
6721
8176
  return await commitPromptOption({
@@ -6737,6 +8192,217 @@ async function tryPromptSelectorPrimitive(params) {
6737
8192
  return null;
6738
8193
  }
6739
8194
  }
8195
+ /** Cap on re-reads while waiting for the filtered list to stop changing. */
8196
+ const PROMPT_SELECTOR_MAX_STABLE_READS = 5;
8197
+ /**
8198
+ * Re-reads the filtered list until two consecutive reads carry identical
8199
+ * ordered option text AND node identity, so a virtualized list that reuses
8200
+ * nodes is never treated as settled. The last read is returned because each
8201
+ * read re-marks the live nodes; an earlier read's marks may point at detached
8202
+ * elements. Returns null when the list is still changing at the cap, so the
8203
+ * committer is never handed an unsettled list.
8204
+ */
8205
+ async function readStablePromptOptions(page, target, enumerateOptionsExpr, initial) {
8206
+ const key = (r) => JSON.stringify((r?.options ?? []).map((o) => [o.text, o.domText ?? null]));
8207
+ let current = initial;
8208
+ for (let read = 0; read < PROMPT_SELECTOR_MAX_STABLE_READS; read++) {
8209
+ await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
8210
+ const next = (await target.evaluate(enumerateOptionsExpr));
8211
+ const stable = key(next) === key(current);
8212
+ current = next;
8213
+ if (stable)
8214
+ return current;
8215
+ }
8216
+ logger.info("prompt-selector primitive: filtered list still changing after stable-read cap");
8217
+ return null;
8218
+ }
8219
+ /** Cap on click-time re-resolves when the stamped node is re-bound mid-scroll. */
8220
+ const PROMPT_SELECTOR_MAX_CLICK_RESOLVES = 3;
8221
+ /** Bounded wait for an in-flight as-you-type search before concluding it is search-on-submit. */
8222
+ const PROMPT_SELECTOR_MAX_SEARCH_WAITS = 6;
8223
+ const PROMPT_SELECTOR_SEARCH_WAIT_MS = 500;
8224
+ /** Cap on trusted Enter submits for a search-on-submit prompt selector. */
8225
+ const PROMPT_SELECTOR_MAX_ENTER_SUBMITS = 2;
8226
+ /** Shared option-text normalisation so submit detection and commit agree on a match. */
8227
+ function normPromptOptionText(s) {
8228
+ return s.replace(/\s+/g, " ").trim().toLowerCase();
8229
+ }
8230
+ /** Entries a widget renders when it holds no results (e.g. "No Items.", "No results for x"). */
8231
+ 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/;
8232
+ /**
8233
+ * Detects whether the list already names the requested option, so the submit
8234
+ * decision never fires Enter at a widget that searches as you type. Looser than
8235
+ * commitPromptOption's exact detMatch on purpose: a rendered label that differs
8236
+ * by a prefix or suffix still means the list answered the query. A shorter
8237
+ * entry the request merely contains (a chip "Oslo" for "University of Oslo")
8238
+ * names nothing requested. Placeholder entries never count, even when they
8239
+ * echo the requested text.
8240
+ */
8241
+ function promptOptionsNameOption(result, option) {
8242
+ const want = normPromptOptionText(option);
8243
+ if (want === "")
8244
+ return true;
8245
+ return realPromptOptions(result?.options ?? [], option).some((o) => {
8246
+ const text = normPromptOptionText(o.text);
8247
+ return text === want || text.includes(want);
8248
+ });
8249
+ }
8250
+ /**
8251
+ * Single classification of which rendered entries are real options, shared by
8252
+ * the submit decision and the committer so they cannot disagree. Placeholders
8253
+ * ("No Items.", "No results for x") are dropped, except an entry that is
8254
+ * exactly the requested text, which is the user's own option.
8255
+ */
8256
+ function realPromptOptions(options, option) {
8257
+ const want = normPromptOptionText(option);
8258
+ return options.filter((o) => {
8259
+ const text = normPromptOptionText(o.text);
8260
+ if (text === "")
8261
+ return false;
8262
+ return text === want || !PROMPT_PLACEHOLDER_RE.test(text);
8263
+ });
8264
+ }
8265
+ /**
8266
+ * Single owner of CDP delivery for trusted input: a child-frame target's own
8267
+ * session when it has one, otherwise the page's, so keys reach the frame that
8268
+ * holds the focused element.
8269
+ */
8270
+ function frameCdpSender(page, target) {
8271
+ return (method, args) => target.frame?.session ? target.frame.session.send(method, args) : page.sendCDP(method, args);
8272
+ }
8273
+ /**
8274
+ * Clicks at a frame-local viewport point through the same CDP route as keys, so
8275
+ * the press lands where the hit test just ran instead of re-resolving a locator.
8276
+ * A same-process child frame has no session of its own, so its offset in the
8277
+ * page is added to reach page coordinates.
8278
+ */
8279
+ async function dispatchTrustedClick(page, target, x, y) {
8280
+ if (target.frame === null) {
8281
+ await page.click(x, y);
8282
+ return;
8283
+ }
8284
+ const send = frameCdpSender(page, target);
8285
+ const offset = target.frame.session
8286
+ ? { x: 0, y: 0 }
8287
+ : ((await target
8288
+ .evaluate(`(() => {
8289
+ const f = window.frameElement;
8290
+ if (!f) return null;
8291
+ const r = f.getBoundingClientRect();
8292
+ return { x: r.left, y: r.top };
8293
+ })()`)
8294
+ .catch(() => null)) ?? { x: 0, y: 0 });
8295
+ const point = { x: x + offset.x, y: y + offset.y, button: "left", clickCount: 1 };
8296
+ await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y });
8297
+ await send("Input.dispatchMouseEvent", { type: "mousePressed", ...point });
8298
+ await send("Input.dispatchMouseEvent", { type: "mouseReleased", ...point });
8299
+ }
8300
+ async function sendTrustedKey(page, target, key, text) {
8301
+ const send = frameCdpSender(page, target);
8302
+ await send("Input.dispatchKeyEvent", { type: "keyDown", ...(text ? { text } : {}), ...key });
8303
+ await send("Input.dispatchKeyEvent", { type: "keyUp", ...key });
8304
+ }
8305
+ /** Dispatches a trusted Enter to the focused element (the filter input) via the target frame's CDP session. */
8306
+ async function sendTrustedEnter(page, target) {
8307
+ await sendTrustedKey(page, target, { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, "\r");
8308
+ }
8309
+ /** Dispatches a trusted Escape to the focused element via the target frame's CDP session, closing a popup left open. */
8310
+ async function sendTrustedEscape(page, target) {
8311
+ await sendTrustedKey(page, target, {
8312
+ key: "Escape",
8313
+ code: "Escape",
8314
+ windowsVirtualKeyCode: 27,
8315
+ nativeVirtualKeyCode: 27,
8316
+ });
8317
+ }
8318
+ /**
8319
+ * Counts rendered listbox / activeListContainer popups other than the chip
8320
+ * list; an emptied ("No Items.") popup still counts because it paints over
8321
+ * neighbouring fields just the same.
8322
+ */
8323
+ const VISIBLE_LEFTOVER_POPUP_COUNT_EXPR = `(() => {
8324
+ const visible = (el) => {
8325
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
8326
+ if (n.hidden || n.getAttribute("aria-hidden") === "true") return false;
8327
+ let cs = null;
8328
+ try { cs = getComputedStyle(n); } catch { cs = null; }
8329
+ if (cs && (cs.display === "none" || cs.visibility === "hidden")) return false;
8330
+ }
8331
+ return true;
8332
+ };
8333
+ const sel = '[role="listbox"], [data-automation-id="activeListContainer"]';
8334
+ return Array.from(document.querySelectorAll(sel)).filter(
8335
+ (el) => !el.matches('[data-automation-id="selectedItemList"]') && !el.closest('[data-automation-id="selectedItemList"]') && visible(el)
8336
+ ).length;
8337
+ })()`;
8338
+ /**
8339
+ * Synthetic Escape for a page whose CDP channel is unavailable or whose popup
8340
+ * ignored the trusted one: the popup's own keydown handler is what closes it.
8341
+ */
8342
+ const IN_PAGE_ESCAPE_EXPR = `(() => {
8343
+ const el = document.activeElement || document.body;
8344
+ for (const type of ["keydown", "keyup"]) {
8345
+ el.dispatchEvent(new KeyboardEvent(type, { key: "Escape", code: "Escape", keyCode: 27, bubbles: true, cancelable: true }));
8346
+ }
8347
+ })()`;
8348
+ /**
8349
+ * Closes any popup still painted after a verified commit so it cannot
8350
+ * intercept the next field's click. Best-effort: a trusted Escape first, then
8351
+ * a synthetic one when the trusted dispatch failed or left the popup painted.
8352
+ */
8353
+ async function dismissLeftoverPromptPopups(page, target) {
8354
+ for (let attempt = 0; attempt < 2; attempt++) {
8355
+ const count = (await target
8356
+ .evaluate(VISIBLE_LEFTOVER_POPUP_COUNT_EXPR)
8357
+ .catch(() => 0));
8358
+ if (!count)
8359
+ return;
8360
+ logger.info(`popup dismissal: dismissing ${count} leftover popup(s) via Escape`);
8361
+ const trusted = await sendTrustedEscape(page, target)
8362
+ .then(() => true)
8363
+ .catch((err) => {
8364
+ logger.info(`popup dismissal: Escape dispatch failed: ${(0, errors_1.toErrorMessage)(err)}`);
8365
+ return false;
8366
+ });
8367
+ if (!trusted || attempt > 0)
8368
+ await target.evaluate(IN_PAGE_ESCAPE_EXPR).catch(() => undefined);
8369
+ await settleAfterEscape(page);
8370
+ }
8371
+ }
8372
+ /** Waits for a dismissed popup to unpaint; a page without a timer seam just proceeds. */
8373
+ async function settleAfterEscape(page) {
8374
+ await Promise.resolve()
8375
+ .then(() => page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS))
8376
+ .catch(() => undefined);
8377
+ }
8378
+ /**
8379
+ * Clears whatever popup paints over `selector`'s click point so the caller's
8380
+ * trusted click lands on the field. The hit test, not the popup's markup, is
8381
+ * the evidence: a named occluder gets the listbox-aware dismissal first and
8382
+ * then a plain Escape for popups that dismissal does not recognise. Resolves
8383
+ * true only when a fresh hit test reaches the field; an indeterminate probe
8384
+ * (no layout, nothing at the point) only ever gets the listbox-aware
8385
+ * dismissal, never a blind Escape.
8386
+ */
8387
+ async function clearObscuringPopup(page, target, selector) {
8388
+ const covered = (probe) => probe?.state === "occluded" && probe.occluder !== undefined && probe.occluder !== "none";
8389
+ const first = await (0, trusted_click_1.probeClickPoint)(target, selector);
8390
+ if (first?.state === "hit")
8391
+ return true;
8392
+ await dismissLeftoverPromptPopups(page, target).catch(() => undefined);
8393
+ const afterListbox = await (0, trusted_click_1.probeClickPoint)(target, selector);
8394
+ if (afterListbox?.state === "hit")
8395
+ return true;
8396
+ if (!covered(afterListbox))
8397
+ return false;
8398
+ const trusted = await sendTrustedEscape(page, target)
8399
+ .then(() => true)
8400
+ .catch(() => false);
8401
+ if (!trusted)
8402
+ await target.evaluate(IN_PAGE_ESCAPE_EXPR).catch(() => undefined);
8403
+ await settleAfterEscape(page);
8404
+ return (await (0, trusted_click_1.probeClickPoint)(target, selector))?.state === "hit";
8405
+ }
6740
8406
  /**
6741
8407
  * Phase 3 of {@link tryPromptSelectorPrimitive}: given the popup's rendered
6742
8408
  * (possibly filtered) option list, match the requested option — deterministic
@@ -6759,7 +8425,7 @@ async function tryPromptSelectorPrimitive(params) {
6759
8425
  */
6760
8426
  async function commitPromptOption(params) {
6761
8427
  const { page, target, logger, anthropic, captureFn, optLabel, option, questionLabel, chosen, enumerateOptionsExpr, } = params;
6762
- const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
8428
+ const norm = normPromptOptionText;
6763
8429
  const wantOpt = norm(option);
6764
8430
  const optionSetKey = (opts) => opts
6765
8431
  .map((o) => norm(o.text))
@@ -6767,7 +8433,7 @@ async function commitPromptOption(params) {
6767
8433
  .join("");
6768
8434
  let optionsResult = params.optionsResult;
6769
8435
  for (let depth = 0; depth <= PROMPT_SELECTOR_MAX_DRILL_DEPTH; depth++) {
6770
- const options = optionsResult.options ?? [];
8436
+ const options = realPromptOptions(optionsResult.options ?? [], option);
6771
8437
  const detMatch = options.find((o) => norm(o.text) === wantOpt) ?? null;
6772
8438
  if (detMatch === null && (anthropic === null || options.length === 0)) {
6773
8439
  logger.info(`prompt-selector primitive: no option match for ${optLabel}${anthropic === null ? " (no LLM client)" : ""}; falling through to cascade`);
@@ -6796,35 +8462,83 @@ async function commitPromptOption(params) {
6796
8462
  return null;
6797
8463
  }
6798
8464
  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();
8465
+ // A virtualized list may reuse nodes between the read and the click, so the
8466
+ // stamped index can point at a different item. Each attempt re-stamps and
8467
+ // re-resolves the matched option by text, and the click itself asserts the
8468
+ // node still carries that item's text after the centring scroll; a mismatch
8469
+ // re-resolves instead of clicking.
8470
+ const wantChosenText = norm(chosenOption.text);
8471
+ let clicked = false;
8472
+ for (let attempt = 0; attempt < PROMPT_SELECTOR_MAX_CLICK_RESOLVES && !clicked; attempt++) {
8473
+ const fresh = (await target.evaluate(enumerateOptionsExpr).catch(() => null));
8474
+ const freshMatches = realPromptOptions(fresh?.options ?? [], option).filter((o) => norm(o.text) === wantChosenText);
8475
+ const freshOption = freshMatches.find((o) => o.nodeId !== undefined && o.nodeId === chosenOption.nodeId) ??
8476
+ freshMatches[0];
8477
+ if (!freshOption) {
8478
+ logger.info(`prompt-selector primitive: matched option no longer present for ${optLabel}; falling through`);
8479
+ return null;
8480
+ }
8481
+ const optionSel = `[${PROMPT_OPTION_MARK_ATTR}="${freshOption.nodeId ?? freshOption.oIdx}"]`;
8482
+ const markedLabel = (await target
8483
+ .evaluate(`((sel, labelOf) => {
8484
+ const el = document.querySelector(sel);
8485
+ return el ? labelOf(el) : null;
8486
+ })(${JSON.stringify(optionSel)}, ${OPTION_ACCESSIBLE_LABEL_EXPR})`)
8487
+ .catch(() => null));
8488
+ if (markedLabel === null || !norm(markedLabel).includes(wantChosenText)) {
8489
+ logger.info(`prompt-selector primitive: marked option node for ${optLabel} no longer shows the matched option; re-resolving`);
8490
+ continue;
8491
+ }
8492
+ try {
8493
+ const expectedDomText = freshOption.domText ?? wantChosenText;
8494
+ await (0, trusted_click_1.clickCentredTrusted)(target, optionSel, {
8495
+ label: "prompt-selector option",
8496
+ expectText: expectedDomText,
8497
+ clickAtPoint: async (x, y) => {
8498
+ const stillThere = await target
8499
+ .evaluate(`((x, y, want) => {
8500
+ const hit = document.elementFromPoint(x, y);
8501
+ const el = hit && hit.closest(${JSON.stringify(optionSel)});
8502
+ return !!el && (el.textContent || "").replace(/\\s+/g, " ").trim() === want;
8503
+ })(${x}, ${y}, ${JSON.stringify(expectedDomText)})`)
8504
+ .catch(() => false);
8505
+ if (!stillThere) {
8506
+ throw new trusted_click_1.CentredClickError("centred click: prompt-selector option identity mismatch at click point, click withheld", "mismatch");
8507
+ }
8508
+ await dispatchTrustedClick(page, target, x, y);
8509
+ },
8510
+ verifyBeforeClick: async () => (await target
8511
+ .evaluate(`((sel, want) => {
8512
+ const el = document.querySelector(sel);
8513
+ if (!el || !el.isConnected) return false;
8514
+ return (el.textContent || "").replace(/\\s+/g, " ").trim() === want;
8515
+ })(${JSON.stringify(optionSel)}, ${JSON.stringify(expectedDomText)})`)
8516
+ .catch(() => null)) !== false,
8517
+ });
8518
+ clicked = true;
8519
+ }
8520
+ catch (err) {
8521
+ const mismatch = (0, errors_1.toErrorMessage)(err).includes("identity mismatch");
8522
+ logger.info(`prompt-selector primitive: option click ${mismatch ? "withheld" : "failed"} for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; ${mismatch ? "re-resolving" : "falling through"}`);
8523
+ if (!mismatch)
8524
+ return null;
8525
+ }
6802
8526
  }
6803
- catch (err) {
6804
- logger.info(`prompt-selector primitive: option click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
8527
+ if (!clicked)
6805
8528
  return null;
6806
- }
6807
8529
  await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
6808
- const isRequestedLeaf = norm(chosenOption.text) === wantOpt;
8530
+ const isRequestedOption = norm(chosenOption.text) === wantOpt;
6809
8531
  const readbackExpr = `((wIdx, markAttr, valueSel, emptyRxSrc, emptyRxFlags, wantText, isRequestedLeaf) => {
6810
8532
  const isInvalid = ${INVALID_MARKER_EL_EXPR};
6811
8533
  const isPairedHiddenSelect = ${exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR};
6812
8534
  const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
6813
- const buttonValue = ${BUTTON_VALUE_EXPR};
6814
- const optionLabel = ${OPTION_ACCESSIBLE_LABEL_EXPR};
6815
8535
  const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
8536
+ const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
8537
+ const announcedValue = ${PROMPT_ANNOUNCED_VALUE_EXPR};
8538
+ const namesOption = ${PROMPT_NAMES_OPTION_EXPR};
6816
8539
  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;
8540
+ if (!w) return { ok: false, id: "", stillInvalid: false };
8541
+ const text = currentText(w, valueSel, emptyRx);
6828
8542
  const textMatches = wantText ? text.includes(wantText) : text !== "";
6829
8543
  let node = w;
6830
8544
  let stillInvalid = false;
@@ -6877,14 +8591,37 @@ async function commitPromptOption(params) {
6877
8591
  cnode = cnode.parentElement;
6878
8592
  }
6879
8593
  }
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: "" })));
8594
+ // The widget's own announcement of what it committed is ground truth when readable.
8595
+ const committed = announcedValue(w);
8596
+ if (committed) {
8597
+ return { ok: !!wantText && namesOption(committed, wantText), id: w.id || "", stillInvalid, committed };
8598
+ }
8599
+ // A wrong option leaves the widget valid, so !stillInvalid proves nothing
8600
+ // once the widget's own value is readable: a readable value naming another
8601
+ // option is a wrong commit. The "Expanded"/"Collapsed" state words are
8602
+ // announcements, not values. Only a requested-leaf click is refused here —
8603
+ // a category click that swapped the popup must still reach the drill check.
8604
+ const stateOnly = /^(?:expanded|collapsed)\\.?$/.test(text);
8605
+ if (isRequestedLeaf && text !== "" && !stateOnly && !textMatches && !hiddenSelectMatches) {
8606
+ return { ok: false, id: w.id || "", stillInvalid, committed: text };
8607
+ }
8608
+ return { ok: textMatches || hiddenSelectMatches, id: w.id || "", stillInvalid };
8609
+ })(${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)})`;
8610
+ const runReadback = async () => (await target
8611
+ .evaluate(readbackExpr)
8612
+ .catch(() => ({ ok: false, id: "", stillInvalid: true })));
8613
+ let readback = await runReadback();
8614
+ if (readback?.ok) {
8615
+ await dismissLeftoverPromptPopups(page, target);
8616
+ readback = await runReadback();
8617
+ }
8618
+ if (!readback?.ok && readback?.committed) {
8619
+ await dismissLeftoverPromptPopups(page, target);
8620
+ 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`);
8621
+ return null;
8622
+ }
6886
8623
  if (readback?.ok) {
6887
- logger.info(`prompt-selector primitive: selected "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${matchReason})`);
8624
+ logger.info(`prompt-selector primitive: selected "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} (${matchReason})`);
6888
8625
  return readback.id;
6889
8626
  }
6890
8627
  if (depth === PROMPT_SELECTOR_MAX_DRILL_DEPTH) {
@@ -6896,12 +8633,20 @@ async function commitPromptOption(params) {
6896
8633
  const reEnumerated = (await target
6897
8634
  .evaluate(enumerateOptionsExpr)
6898
8635
  .catch(() => ({ optionsPresent: false })));
6899
- const newOptions = reEnumerated?.optionsPresent ? (reEnumerated.options ?? []) : [];
8636
+ const newOptions = reEnumerated?.optionsPresent && reEnumerated.scopeIsDocument !== true
8637
+ ? (reEnumerated.options ?? [])
8638
+ : [];
6900
8639
  const drilled = newOptions.length > 0 && optionSetKey(newOptions) !== optionSetKey(options);
6901
8640
  if (!drilled) {
6902
8641
  logger.info(`prompt-selector primitive: selection for ${optLabel} did not commit; falling through to cascade`);
6903
8642
  return null;
6904
8643
  }
8644
+ // Category click on the requested option: the drill itself is the observed
8645
+ // effect, and it never commits a value, so only a widget not flagged invalid counts.
8646
+ if (isRequestedOption && !readback.stillInvalid) {
8647
+ logger.info(`prompt-selector primitive: requested option "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} opened a new option set (${matchReason})`);
8648
+ return readback?.id ?? "";
8649
+ }
6905
8650
  logger.info(`prompt-selector primitive: click for ${optLabel} drilled the popup to a new option set; re-matching at the new level`);
6906
8651
  optionsResult = { options: newOptions };
6907
8652
  }
@@ -6928,8 +8673,10 @@ async function hasUnfilledRequiredControlForStep(target, instruction) {
6928
8673
  const label = extractRequiredControlProbeLabel(instruction);
6929
8674
  if (!label)
6930
8675
  return false;
6931
- const expr = `((label, triggerSel) => {
8676
+ const expr = `((label, triggerSel, valueSel, emptyRxSrc, emptyRxFlags) => {
6932
8677
  const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
8678
+ const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
8679
+ const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
6933
8680
  const want = norm(label);
6934
8681
  if (!want) return false;
6935
8682
  const isInvalid = ${INVALID_MARKER_EL_EXPR};
@@ -6948,6 +8695,7 @@ async function hasUnfilledRequiredControlForStep(target, instruction) {
6948
8695
  if (!el) return false;
6949
8696
  if (el.getAttribute && el.getAttribute("aria-invalid") === "true") return true;
6950
8697
  if (isInvalid(el)) return true;
8698
+ if (el.matches && el.matches(triggerSel)) return currentText(el, valueSel, emptyRx) === "";
6951
8699
  const v = ("value" in el) ? el.value : "";
6952
8700
  return !v || String(v).trim() === "";
6953
8701
  };
@@ -6998,7 +8746,7 @@ async function hasUnfilledRequiredControlForStep(target, instruction) {
6998
8746
  if (txt && (txt.includes(want) || want.includes(txt))) return true;
6999
8747
  }
7000
8748
  return false;
7001
- })(${JSON.stringify(label)}, ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)})`;
8749
+ })(${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
8750
  try {
7003
8751
  return (await target.evaluate(expr)) === true;
7004
8752
  }
@@ -7028,24 +8776,7 @@ async function simulateDragDropUpload(target, fixture, logger) {
7028
8776
  const fileName = ${JSON.stringify(fixture.name)};
7029
8777
  const fileType = ${JSON.stringify(fixture.mimeType)};
7030
8778
  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
- }
8779
+ const dropZone = ${upload_affordance_expr_1.FIND_DROP_ZONE_EXPR}(document);
7049
8780
  if (!dropZone) return { ok: false, reason: "no drop zone found" };
7050
8781
  try {
7051
8782
  const binStr = atob(base64);
@@ -7160,6 +8891,12 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
7160
8891
  const expected = action.arguments?.[0];
7161
8892
  if (typeof expected !== "string" || expected.length === 0)
7162
8893
  return false;
8894
+ // A lone spinbutton segment holds one slice of a date, so a matching
8895
+ // digit string proves nothing about the whole value, and the
8896
+ // fill("")+type retype below would overwrite committed neighbours.
8897
+ if ((await verifyFillReadback(target, selector, expected))?.outcome === "segment") {
8898
+ return false;
8899
+ }
7163
8900
  const current = await locator.inputValue();
7164
8901
  // Masked/formatted inputs (e.g. a phone field displaying
7165
8902
  // "(212) 555-0123" after typing "2125550123") reformat the typed
@@ -7398,6 +9135,13 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
7398
9135
  // owned panel to now render option content before crediting it.
7399
9136
  return await comboboxOpenerPanelOpened(target, xpath, preSelectionState);
7400
9137
  }
9138
+ // A control exposing aria-checked is framework-managed: its own
9139
+ // aria-checked is the committed state, so a leftover invalid marker
9140
+ // on an ancestor must not veto a genuinely ticked box.
9141
+ 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; })()`;
9142
+ const ariaChecked = await target.evaluate(ariaCheckedExpr).catch(() => null);
9143
+ if (ariaChecked === "true" || ariaChecked === "false")
9144
+ return ariaChecked === "true";
7401
9145
  const isCheckedNow = await locator.isChecked();
7402
9146
  if (!isCheckedNow)
7403
9147
  return false;
@@ -8017,20 +9761,32 @@ function extractCodeFromMessage(msg, pattern) {
8017
9761
  * know the value came from an inbox rather than the flow's payload.
8018
9762
  */
8019
9763
  function spliceEmailStepFillValue(step, code) {
8020
- return /with\s+'[^']*'/i.test(step)
8021
- ? step.replace(/with\s+'[^']*'/i, `with '${code}'`)
9764
+ const match = (0, quoted_spans_1.matchAnchored)(step, /with\s+'[^']*'/i);
9765
+ return match
9766
+ ? `${step.slice(0, match.start)}with '${code}'${step.slice(match.end)}`
8022
9767
  : `${step} with '${code}'`;
8023
9768
  }
8024
9769
  /**
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.
9770
+ * Masks a typed/selected value for logs and attempt diagnostics. Values are
9771
+ * caller-supplied answers (or the emailStep-extracted single-use code spliced into
9772
+ * the step, see {@link spliceEmailStepFillValue}), so only their shape is
9773
+ * kept — enough to debug format mismatches, never the value itself.
9774
+ */
9775
+ function maskLoggedValue(text, sensitiveValue) {
9776
+ return sensitiveValue && text === sensitiveValue ? "[redacted]" : (0, pii_redaction_1.maskValueShape)(text);
9777
+ }
9778
+ /**
9779
+ * Builds the "option ..., question ..." log label shared by the select,
9780
+ * checkbox, radio and prompt-selector primitives. A value parsed from a FILL
9781
+ * step is typed user data (names, emails, passwords), so it is logged only as
9782
+ * its length; other options are still caller answers, so they keep only
9783
+ * their shape via {@link maskLoggedValue}.
8031
9784
  */
8032
- function redactIfSensitive(text, sensitiveValue) {
8033
- return sensitiveValue && text === sensitiveValue ? "[redacted]" : text;
9785
+ function primitiveOptionLabel(option, questionLabel, redactValue, fromFill = false) {
9786
+ const shown = fromFill
9787
+ ? `[${option.length}-char typed value]`
9788
+ : maskLoggedValue(option, redactValue).slice(0, 40);
9789
+ return `option "${shown}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
8034
9790
  }
8035
9791
  /**
8036
9792
  * Last-resort escalation for the cascade-exhaustion path: when every
@@ -8064,7 +9820,7 @@ function redactIfSensitive(text, sensitiveValue) {
8064
9820
  * candidate was actually clickable, or every click phantomed.
8065
9821
  */
8066
9822
  async function probeChildFrameSubmitFallback(params) {
8067
- const { page, signalCounter, step } = params;
9823
+ const { page, signalCounter, step, isMidFlowSubmitStep } = params;
8068
9824
  // `page.mainFrameId`/`page.frames` are read defensively: some call sites
8069
9825
  // (and their test fakes) model a `Page` that never attaches child frames at
8070
9826
  // all and doesn't implement this pair, which is indistinguishable here from
@@ -8117,13 +9873,18 @@ async function probeChildFrameSubmitFallback(params) {
8117
9873
  const clickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex))).catch(() => ({ clicked: false })));
8118
9874
  if (!clickResult.clicked)
8119
9875
  continue;
9876
+ await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, true, async () => {
9877
+ const snap = await snapshotPage(target, signalCounter);
9878
+ return snap.visibleTextSignature;
9879
+ }, pre.visibleTextSignature, async () => target.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR));
8120
9880
  const post = await snapshotPage(target, signalCounter, page);
8121
9881
  const verdict = (0, phantom_click_1.classifyPhantomClick)({
8122
9882
  actResultSuccess: true,
8123
9883
  pre,
8124
9884
  post,
8125
9885
  isSubmitShapedStep: true,
8126
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, post.url),
9886
+ isMidFlowSubmitStep,
9887
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, isMidFlowSubmitStep ? pre.url : undefined),
8127
9888
  });
8128
9889
  if (verdict !== "phantom")
8129
9890
  return target;
@@ -8133,21 +9894,52 @@ async function probeChildFrameSubmitFallback(params) {
8133
9894
  const runnerUpClickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))).catch(() => ({ clicked: false })));
8134
9895
  if (!runnerUpClickResult.clicked)
8135
9896
  continue;
9897
+ await (0, click_settle_1.settleAfterClick)(page, signalCounter, post.networkCount, true, async () => {
9898
+ const snap = await snapshotPage(target, signalCounter);
9899
+ return snap.visibleTextSignature;
9900
+ }, post.visibleTextSignature, async () => target.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR));
8136
9901
  const runnerUpPost = await snapshotPage(target, signalCounter, page);
8137
9902
  const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
8138
9903
  actResultSuccess: true,
8139
9904
  pre,
8140
9905
  post: runnerUpPost,
8141
9906
  isSubmitShapedStep: true,
8142
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, runnerUpPost.url),
9907
+ isMidFlowSubmitStep,
9908
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, runnerUpPost.url, isMidFlowSubmitStep ? post.url : undefined),
8143
9909
  });
8144
9910
  if (runnerUpVerdict !== "phantom")
8145
9911
  return target;
8146
9912
  }
8147
9913
  return null;
8148
9914
  }
9915
+ /**
9916
+ * Rejects with the signal's reason as soon as it aborts, so an in-step await
9917
+ * on a torn-down session stops instead of waiting out its own timeout.
9918
+ * Passes the promise through untouched when no signal is supplied.
9919
+ */
9920
+ async function raceAbortSignal(work, signal) {
9921
+ if (!signal)
9922
+ return work;
9923
+ if (signal.aborted) {
9924
+ work.catch(() => undefined);
9925
+ throw signal.reason;
9926
+ }
9927
+ let onAbort;
9928
+ const aborted = new Promise((_, reject) => {
9929
+ onAbort = () => reject(signal.reason);
9930
+ signal.addEventListener("abort", onAbort, { once: true });
9931
+ });
9932
+ try {
9933
+ return await Promise.race([work, aborted]);
9934
+ }
9935
+ finally {
9936
+ if (onAbort)
9937
+ signal.removeEventListener("abort", onAbort);
9938
+ work.catch(() => undefined);
9939
+ }
9940
+ }
8149
9941
  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;
9942
+ 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
9943
  // Mutable: the `emailStep` code-extract path splices the extracted code
8152
9944
  // into this instruction (see the emailStep hook block below) so the
8153
9945
  // pre-existing fill cascade downstream picks it up as the value to type
@@ -8164,6 +9956,9 @@ async function executeStepWithHealing(params) {
8164
9956
  // function keeps reading `frameTarget` unchanged; only its declaration
8165
9957
  // moved so it can be reassigned.
8166
9958
  let frameTarget = params.frameTarget;
9959
+ // Rebound when a click opens a new browsing context, so every later probe,
9960
+ // verifier and key event in the cascade reads the page the click navigated to.
9961
+ let page = params.page;
8167
9962
  let frameReresolveAttempted = false;
8168
9963
  // Emailed-verification hook. Runs FIRST — before upload/select/checkbox/
8169
9964
  // radio/prompt-selector/captcha or any other cascade primitive — so an
@@ -8321,8 +10116,11 @@ async function executeStepWithHealing(params) {
8321
10116
  // Submit, and unfilled required fields produce silent submit failures
8322
10117
  // (verified 2026-06-15 on one measured tenant's telemetry). Site-agnostic: any flow
8323
10118
  // 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;
10119
+ const stepDeclaredNonSubmit = isStepDeclaredNonSubmit({ step, submitStep });
10120
+ // Single final-step inference every submit-strictness gate below reads, so a
10121
+ // declared non-submit veto cannot be honoured at one site and missed at another.
10122
+ const inferredFinalSubmitStep = isFinalStep && !stepDeclaredNonSubmit;
10123
+ const requireSubmitEndpoint = (inferredFinalSubmitStep || submitStep) && submitEndpointPattern !== null;
8326
10124
  // Capture the per-element selection baseline (for verifyDomEffect's element-
8327
10125
  // scoped click read-back) ONLY for selection/field-answer click steps — never
8328
10126
  // for submit/advance steps, whose own self-toggling buttons must not
@@ -8350,6 +10148,7 @@ async function executeStepWithHealing(params) {
8350
10148
  fixture: uploadFixture,
8351
10149
  logger,
8352
10150
  signalCounter,
10151
+ browserbaseSessionId: stagehand.browserbaseSessionID,
8353
10152
  recentCaptureMeta,
8354
10153
  })) {
8355
10154
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by upload primitive`);
@@ -8400,6 +10199,17 @@ async function executeStepWithHealing(params) {
8400
10199
  trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: checkboxTargetId });
8401
10200
  return "completed";
8402
10201
  }
10202
+ const checkboxStateTargetId = await tryCheckboxStatePrimitive({
10203
+ page,
10204
+ target: selectFrameTarget,
10205
+ instruction: step,
10206
+ logger,
10207
+ });
10208
+ if (checkboxStateTargetId !== null) {
10209
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by checkbox state primitive`);
10210
+ trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: checkboxStateTargetId });
10211
+ return "completed";
10212
+ }
8403
10213
  // When the step is a single-choice RADIO answer ("Click the 'Yes' answer for
8404
10214
  // the question '…'"), commit it directly in the DOM. Runs AFTER select/
8405
10215
  // checkbox (which own their verbs) and BEFORE the cascade, so radios never
@@ -8445,6 +10255,19 @@ async function executeStepWithHealing(params) {
8445
10255
  trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: promptSelectorTargetId });
8446
10256
  return "completed";
8447
10257
  }
10258
+ // Multi-segment date spinbuttons need real key events; the cascade's
10259
+ // insertText would garble the segments. Falls through on null.
10260
+ const segmentedDateTargetId = await trySegmentedDateSpinbuttonPrimitive({
10261
+ page,
10262
+ target: selectFrameTarget,
10263
+ instruction: step,
10264
+ logger,
10265
+ });
10266
+ if (segmentedDateTargetId !== null) {
10267
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by segmented-date primitive`);
10268
+ trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: segmentedDateTargetId });
10269
+ return "completed";
10270
+ }
8448
10271
  // On a CATCH-ALL step ("for any remaining … question"), fill every
8449
10272
  // required-but-empty native <select> — including MuiNativeSelect dropdowns
8450
10273
  // (tabindex=-1) that Stagehand observe can't see and that no concrete flow
@@ -8480,6 +10303,7 @@ async function executeStepWithHealing(params) {
8480
10303
  isFinalStep,
8481
10304
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
8482
10305
  });
10306
+ const isSettleWaitStep = isSubmitOrFinalStep || isAdvanceStep(step);
8483
10307
  if (captchaGated && isSubmitOrFinalStep) {
8484
10308
  let captchaTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
8485
10309
  const sitekeyProbeExpr = `(() => {
@@ -8623,6 +10447,12 @@ async function executeStepWithHealing(params) {
8623
10447
  signalCounter,
8624
10448
  page,
8625
10449
  step,
10450
+ isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
10451
+ step,
10452
+ submitStep,
10453
+ isFinalStep,
10454
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10455
+ }),
8626
10456
  });
8627
10457
  }
8628
10458
  catch (err) {
@@ -8767,7 +10597,8 @@ async function executeStepWithHealing(params) {
8767
10597
  // candidate matching the step's instruction we either skip cleanly
8768
10598
  // (optional) or escalate straight to replan (required) — far cheaper than
8769
10599
  // burning 4 attempts on a page that clearly isn't the right one.
8770
- const probeResult = await probeStepBeforeAttempts({
10600
+ params.abortSignal?.throwIfAborted();
10601
+ const probeResult = await raceAbortSignal(probeStepBeforeAttempts({
8771
10602
  stagehand,
8772
10603
  page,
8773
10604
  step,
@@ -8780,7 +10611,8 @@ async function executeStepWithHealing(params) {
8780
10611
  await reresolveFrameTargetIfLost();
8781
10612
  return frameTarget;
8782
10613
  },
8783
- });
10614
+ }), params.abortSignal);
10615
+ params.abortSignal?.throwIfAborted();
8784
10616
  if (probeResult === "absent") {
8785
10617
  if (optional) {
8786
10618
  // Don't fast-skip when the page plainly has a required, still-empty
@@ -8823,7 +10655,7 @@ async function executeStepWithHealing(params) {
8823
10655
  });
8824
10656
  if (backendErrorUrl !== null) {
8825
10657
  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");
10658
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactPii)(step).slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
8827
10659
  }
8828
10660
  // Bounded same-origin child-iframe fallback: the probe above only ever
8829
10661
  // looked at the resolved (declared-or-main) frame's own light+shadow DOM,
@@ -8842,7 +10674,17 @@ async function executeStepWithHealing(params) {
8842
10674
  isFinalStep,
8843
10675
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
8844
10676
  })) {
8845
- const fallbackTarget = await probeChildFrameSubmitFallback({ page, signalCounter, step });
10677
+ const fallbackTarget = await probeChildFrameSubmitFallback({
10678
+ page,
10679
+ signalCounter,
10680
+ step,
10681
+ isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
10682
+ step,
10683
+ submitStep,
10684
+ isFinalStep,
10685
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10686
+ }),
10687
+ });
8846
10688
  if (fallbackTarget) {
8847
10689
  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
10690
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
@@ -8865,6 +10707,7 @@ async function executeStepWithHealing(params) {
8865
10707
  const fieldValuesAtFailure = await (frameTarget ?? page)
8866
10708
  .evaluate(fieldValuesAtFailureExpr())
8867
10709
  .catch(() => null);
10710
+ params.abortSignal?.throwIfAborted();
8868
10711
  const probeAbsentObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
8869
10712
  // observe() is blind to a cross-origin OOPIF, so a frame-scoped empty
8870
10713
  // result degrades to the deep-locator resolver for dump evidence — this
@@ -8897,7 +10740,7 @@ async function executeStepWithHealing(params) {
8897
10740
  targetResolutionDiagnostic,
8898
10741
  fieldValuesAtFailure,
8899
10742
  }) ?? 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");
10743
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactPii)(step).slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
8901
10744
  }
8902
10745
  // Pre-submit form-validity probe. Fires on the canonical submit step
8903
10746
  // (either the final flow step OR a step explicitly flagged `submitStep:
@@ -9118,7 +10961,7 @@ async function executeStepWithHealing(params) {
9118
10961
  if (!frameTarget?.frame) {
9119
10962
  const topWindowTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
9120
10963
  try {
9121
- await (0, watchdog_1.withWatchdog)(() => topWindowTarget.locator(selector).first().click(), {
10964
+ await (0, watchdog_1.withWatchdog)(() => (0, trusted_click_1.clickCentredTrusted)(topWindowTarget, selector), {
9122
10965
  timeoutMs: N16_TRUSTED_CLICK_TIMEOUT_MS,
9123
10966
  label: "n+16 fallback: trusted click delivery",
9124
10967
  });
@@ -9156,6 +10999,26 @@ async function executeStepWithHealing(params) {
9156
10999
  return { delivered: false, reason: "threw", message: (0, errors_1.toErrorMessage)(err) };
9157
11000
  }
9158
11001
  };
11002
+ // Baseline is per step, not per attempt, so a tab opened by a click whose
11003
+ // attempt then failed is still new to the next attempt. Hand-built sessions in
11004
+ // unit tests carry no browser context (or one without a page list); there is no
11005
+ // tab to follow.
11006
+ const followableContext = () => typeof stagehand.context?.pages === "function" ? stagehand.context : null;
11007
+ let knownPages = followableContext()?.pages() ?? [];
11008
+ const adoptOpenedPage = async (discoverWaitMs) => {
11009
+ const context = followableContext();
11010
+ if (!context)
11011
+ return false;
11012
+ const followed = await (0, active_page_1.followNewPage)(context, page, knownPages, discoverWaitMs);
11013
+ if (followed === page)
11014
+ return false;
11015
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} click opened a new page; following it`);
11016
+ page = followed;
11017
+ frameTarget = undefined;
11018
+ knownPages = context.pages();
11019
+ params.onPageAdopted?.(followed);
11020
+ return true;
11021
+ };
9159
11022
  let phantomClickAfterAttempt1 = false;
9160
11023
  let attempt1UnreachableViaLightDom = false;
9161
11024
  // Last candidate list the deep-submit-locator itself ranked this step, if
@@ -9212,8 +11075,12 @@ async function executeStepWithHealing(params) {
9212
11075
  }
9213
11076
  }
9214
11077
  if (attempt > 1) {
9215
- await page.waitForTimeout(attempt * ATTEMPT_BACKOFF_MS);
11078
+ await raceAbortSignal(page.waitForTimeout(attempt * ATTEMPT_BACKOFF_MS), params.abortSignal);
11079
+ params.abortSignal?.throwIfAborted();
9216
11080
  }
11081
+ // A click from an earlier attempt that errored after opening a tab leaves the
11082
+ // run on the stale page; pick the tab up before measuring anything.
11083
+ await adoptOpenedPage(0);
9217
11084
  const pre = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page, captureSelectionState);
9218
11085
  // Snapshot the meta-tail length so the final-step pattern gate can scope
9219
11086
  // its URL scan to captures added DURING this attempt (not historical
@@ -9249,6 +11116,23 @@ async function executeStepWithHealing(params) {
9249
11116
  // whether this attempt's signal should come from the network/URL pair or
9250
11117
  // from DOM re-read. Captured here so both branches of the cascade write to it.
9251
11118
  let resolvedAction = null;
11119
+ // Stagehand resolves a natural-language act target internally, so for a
11120
+ // click-intent instruction resolve it with observe first and centre it;
11121
+ // a sticky bar otherwise swallows Stagehand's own click. Throws when the
11122
+ // control stays covered so the attempt fails instead of clicking an overlay.
11123
+ // Resolves true once the observe ran, so guardedAct skips its own pass.
11124
+ const preCentreNaturalLanguageClick = async (instruction) => {
11125
+ if (!(0, stagehand_guard_1.hasClickIntent)(instruction))
11126
+ return false;
11127
+ const observed = await (0, stagehand_guard_1.guardedObserve)(stagehand, instruction, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget);
11128
+ const first = observed[0];
11129
+ if (!(0, stagehand_guard_1.isTrustedClickMethod)(first?.method) || !first?.selector)
11130
+ return true;
11131
+ const failure = await centreObservedClickTarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), first.selector);
11132
+ if (failure)
11133
+ throw new Error(failure);
11134
+ return true;
11135
+ };
9252
11136
  try {
9253
11137
  // Field-label-first routing: for a frame-scoped fill/select step,
9254
11138
  // Stagehand's own act()/observe() can resolve a real, in-DOM, plausible-
@@ -9279,7 +11163,7 @@ async function executeStepWithHealing(params) {
9279
11163
  triedSelectors.push(fieldLabelOutcome.matched.selector);
9280
11164
  record.triedSelectors = [fieldLabelOutcome.matched.selector];
9281
11165
  record.actResultSuccess = true;
9282
- record.actResultDescription = `deepLocator ${fieldLabelOutcome.fieldTarget.kind === "fill" ? "filled" : "selected"} "${fieldLabelOutcome.matched.accessibleText || fieldLabelOutcome.matched.selector}" with "${fieldLabelOutcome.fieldTarget.value}"`;
11166
+ 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
11167
  // Same rationale as the pre-existing candidates===0 branch below:
9284
11168
  // the write + read-back the actuation helper already performed IS
9285
11169
  // the verification signal — no resolvedAction/network/url verifier
@@ -9312,7 +11196,9 @@ async function executeStepWithHealing(params) {
9312
11196
  if (attempt === 1) {
9313
11197
  record.technique = "act-string";
9314
11198
  record.instruction = step;
9315
- const result = await (0, stagehand_guard_1.guardedAct)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
11199
+ params.abortSignal?.throwIfAborted();
11200
+ const preCentred = await preCentreNaturalLanguageClick(step);
11201
+ 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
11202
  record.actResultSuccess = result.success;
9317
11203
  record.actResultDescription = result.actionDescription;
9318
11204
  // Deny-list guard (post-hoc): attempt-1 act resolves internally, so we
@@ -9421,7 +11307,15 @@ async function executeStepWithHealing(params) {
9421
11307
  // burn the step budget probing all of them.
9422
11308
  const runnerUp = ranked[1];
9423
11309
  if (runnerUp) {
9424
- const midPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
11310
+ const midTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
11311
+ const midPost = await (0, click_settle_1.settleAndSnapshot)({
11312
+ page,
11313
+ counter: signalCounter,
11314
+ pre,
11315
+ submitShaped: true,
11316
+ snapshot: (withPage) => snapshotPage(midTarget, signalCounter, withPage ? page : undefined),
11317
+ readQuiescence: async () => midTarget.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR),
11318
+ });
9425
11319
  const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
9426
11320
  actResultSuccess: true,
9427
11321
  pre,
@@ -9430,7 +11324,20 @@ async function executeStepWithHealing(params) {
9430
11324
  // stray selection flip must not mask a top-pick phantom and skip
9431
11325
  // the runner-up retry.
9432
11326
  isSubmitShapedStep: true,
9433
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, midPost.url),
11327
+ isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
11328
+ step,
11329
+ submitStep,
11330
+ isFinalStep,
11331
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11332
+ }),
11333
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, midPost.url, isMidFlowSubmitShapedStep({
11334
+ step,
11335
+ submitStep,
11336
+ isFinalStep,
11337
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11338
+ })
11339
+ ? pre.url
11340
+ : undefined),
9434
11341
  });
9435
11342
  if (topVerdict === "phantom") {
9436
11343
  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 +11462,8 @@ async function executeStepWithHealing(params) {
9555
11462
  : { found: false };
9556
11463
  if (markResult.found) {
9557
11464
  try {
9558
- await topWindowTarget.locator(`[${markerAttr}="${markerValue}"]`).first().click();
11465
+ const markerSelector = `[${markerAttr}="${markerValue}"]`;
11466
+ await (0, trusted_click_1.clickAfterCentredHitTest)((0, trusted_click_1.firstMatchClickable)(topWindowTarget.locator(markerSelector)), () => topWindowTarget.evaluate(buildDeepMarkerCentreExpr(markerSelector)), { label: markerSelector });
9559
11467
  clickedShadowDescendant = true;
9560
11468
  }
9561
11469
  finally {
@@ -9565,7 +11473,7 @@ async function executeStepWithHealing(params) {
9565
11473
  }
9566
11474
  }
9567
11475
  else {
9568
- await topWindowTarget.locator(topWindowSelector).first().click();
11476
+ await (0, trusted_click_1.clickCentredTrusted)(topWindowTarget, topWindowSelector);
9569
11477
  }
9570
11478
  }
9571
11479
  catch (err) {
@@ -9626,6 +11534,7 @@ async function executeStepWithHealing(params) {
9626
11534
  const observeOptions = attempt === 4 && triedSelectors.length > 0
9627
11535
  ? { ignoreSelectors: [...triedSelectors], timeout: exports.STEP_WATCHDOG_MS }
9628
11536
  : { timeout: exports.STEP_WATCHDOG_MS };
11537
+ params.abortSignal?.throwIfAborted();
9629
11538
  const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, observeOptions, captureFn, frameTarget);
9630
11539
  // observe() is blind to a cross-origin OOPIF (see
9631
11540
  // deep-locator-candidates.ts's module docblock) — when the step is
@@ -9761,8 +11670,18 @@ async function executeStepWithHealing(params) {
9761
11670
  }
9762
11671
  }
9763
11672
  else {
9764
- // biome-ignore lint/style/noNonNullAssertion: this else-branch runs only when candidates.length !== 0
9765
- const target = candidates[0];
11673
+ const quotedPhrases = stepQuotesValueNotControlName(step)
11674
+ ? []
11675
+ : (0, deep_locator_candidates_1.extractTaggedPhrases)(step);
11676
+ const verdict = (0, deep_locator_candidates_1.vetObserveCandidates)(candidates, quotedPhrases);
11677
+ if (verdict.refuse || !verdict.candidate) {
11678
+ 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)`;
11679
+ attempts.push(record);
11680
+ failureReasons.push(record.errorMessage);
11681
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
11682
+ continue;
11683
+ }
11684
+ const target = verdict.candidate;
9766
11685
  // Deny-list guard: never act on a wizard-exit control (save-and-exit,
9767
11686
  // cancel, restart) for a click/advance step. Record it as tried (so a
9768
11687
  // re-observe with ignoreSelectors surfaces a different candidate) and
@@ -9806,7 +11725,22 @@ async function executeStepWithHealing(params) {
9806
11725
  };
9807
11726
  }
9808
11727
  }
9809
- const result = await (0, stagehand_guard_1.guardedAct)(stagehand, overriddenTarget, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
11728
+ params.abortSignal?.throwIfAborted();
11729
+ // Stagehand's internal Locator.click() does not scroll past sticky
11730
+ // bars, so centre + hit-test the observed control first; an
11731
+ // unrecoverable occlusion fails the attempt instead of clicking an
11732
+ // overlay.
11733
+ if ((0, stagehand_guard_1.isTrustedClickMethod)(overriddenTarget.method)) {
11734
+ const centreFailure = await centreObservedClickTarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), overriddenTarget.selector);
11735
+ if (centreFailure) {
11736
+ record.errorMessage = centreFailure;
11737
+ attempts.push(record);
11738
+ failureReasons.push(record.errorMessage);
11739
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
11740
+ continue;
11741
+ }
11742
+ }
11743
+ const result = await (0, stagehand_guard_1.guardedAct)(stagehand, overriddenTarget, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page));
9810
11744
  record.actResultSuccess = result.success;
9811
11745
  record.actResultDescription = result.actionDescription;
9812
11746
  // observe(...)[0] is what Stagehand acted on; use it directly when
@@ -9838,8 +11772,8 @@ async function executeStepWithHealing(params) {
9838
11772
  const dateFill = await fillHtml5DateTimeInput(dateFillTarget, overriddenTarget.selector, fillValue);
9839
11773
  if (dateFill !== null) {
9840
11774
  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)})`;
11775
+ ? `html5-date-fallback: filled ${dateFill.inputType}="${maskLoggedValue(dateFill.postValue, emailStepCode)}"`
11776
+ : `html5-date-fallback: failed to fill ${dateFill.inputType} (post=${maskLoggedValue(dateFill.postValue, emailStepCode)})`;
9843
11777
  // Override the act result based on the deterministic fill
9844
11778
  // outcome — the helper bypasses Stagehand's schema-error
9845
11779
  // failure mode by writing directly via the native setter.
@@ -9859,8 +11793,8 @@ async function executeStepWithHealing(params) {
9859
11793
  const datepickerFill = await fillTextDatepickerInput(dateFillTarget, overriddenTarget.selector, fillValue);
9860
11794
  if (datepickerFill !== null) {
9861
11795
  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)})`;
11796
+ ? `text-datepicker-fill: filled via ${datepickerFill.strategy} "${maskLoggedValue(datepickerFill.postValue, emailStepCode)}"`
11797
+ : `text-datepicker-fill: failed to commit "${maskLoggedValue(fillValue.slice(0, 60), emailStepCode)}" (post=${maskLoggedValue(datepickerFill.postValue, emailStepCode)})`;
9864
11798
  if (datepickerFill.filled) {
9865
11799
  record.actResultSuccess = true;
9866
11800
  resolvedAction = overriddenTarget;
@@ -9878,12 +11812,16 @@ async function executeStepWithHealing(params) {
9878
11812
  // (network/url/dom/htmlDelta/textChanged) miss.
9879
11813
  const readback = await verifyFillReadback(dateFillTarget, overriddenTarget.selector, fillValue);
9880
11814
  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)`;
11815
+ if (readback.outcome === "segment") {
11816
+ 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`;
11817
+ record.actResultSuccess = false;
11818
+ }
11819
+ else if (readback.outcome === "rejected") {
11820
+ 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
11821
  record.actResultSuccess = false;
9884
11822
  }
9885
11823
  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)`);
11824
+ 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
11825
  }
9888
11826
  }
9889
11827
  }
@@ -10046,6 +11984,7 @@ async function executeStepWithHealing(params) {
10046
11984
  "anthropic billing exhausted (FATAL_BILLING already logged); skipping rephrase";
10047
11985
  }
10048
11986
  else {
11987
+ params.abortSignal?.throwIfAborted();
10049
11988
  const observedCandidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
10050
11989
  // observe() is blind to a cross-origin OOPIF, so a frame-scoped
10051
11990
  // empty result degrades to the deep-locator resolver for prompt
@@ -10070,6 +12009,7 @@ async function executeStepWithHealing(params) {
10070
12009
  // frameTarget: for a frame-scoped flow the ambient UI that matters
10071
12010
  // (the wizard's own Save/Close controls) lives inside the iframe
10072
12011
  // alongside the failed step, not in the top document.
12012
+ params.abortSignal?.throwIfAborted();
10073
12013
  const observedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
10074
12014
  const unfocused = observedUnfocused.length === 0 && frameTarget?.frame
10075
12015
  ? await deepLocatorCandidatesAsActions(page, frameTarget)
@@ -10087,7 +12027,9 @@ async function executeStepWithHealing(params) {
10087
12027
  }
10088
12028
  else {
10089
12029
  record.instruction = rephrased;
10090
- const result = await (0, stagehand_guard_1.guardedAct)(stagehand, rephrased, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
12030
+ params.abortSignal?.throwIfAborted();
12031
+ const preCentred = await preCentreNaturalLanguageClick(rephrased);
12032
+ 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
12033
  record.actResultSuccess = result.success;
10092
12034
  record.actResultDescription = result.actionDescription;
10093
12035
  for (const action of result.actions ?? []) {
@@ -10116,7 +12058,7 @@ async function executeStepWithHealing(params) {
10116
12058
  // cannot produce a positive signal: verified is forced false and the
10117
12059
  // cascade always falls through to attempt 2. Skip the wasted ~2-3s
10118
12060
  // of timeout + snapshot work and let the cascade try observe-act.
10119
- if (attempt === 1 && resolvedAction === null) {
12061
+ if (attempt === 1 && resolvedAction === null && record.actResultSuccess !== true) {
10120
12062
  attempts.push(record);
10121
12063
  const reason = record.errorMessage
10122
12064
  ? `attempt 1 fast-skipped (no resolved action): ${record.errorMessage}`
@@ -10124,7 +12066,18 @@ async function executeStepWithHealing(params) {
10124
12066
  failureReasons.push(reason);
10125
12067
  continue;
10126
12068
  }
12069
+ // A fill/select/type action cannot open a tab, so skip the discovery wait for it.
12070
+ await adoptOpenedPage(resolvedAction && STATE_CLASS_METHODS.has(resolvedAction.method ?? "") ? 0 : undefined);
10127
12071
  await page.waitForTimeout(STEP_PAUSE_MS);
12072
+ // A successful act that reported no actions still ran a click-class action;
12073
+ // judging it before its round-trip lands would cascade onto the advanced page.
12074
+ const mayTriggerRoundTrip = resolvedAction === null
12075
+ ? record.actResultSuccess === true
12076
+ : !STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
12077
+ if (mayTriggerRoundTrip) {
12078
+ const settleTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
12079
+ await settleWithPageQuiescence(page, settleTarget, signalCounter, pre.networkCount, isSettleWaitStep, pre.visibleTextSignature);
12080
+ }
10128
12081
  const post = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
10129
12082
  record.post = post;
10130
12083
  if (resolvedAction) {
@@ -10134,7 +12087,13 @@ async function executeStepWithHealing(params) {
10134
12087
  const networkFired = post.networkCount > pre.networkCount;
10135
12088
  // Computed once: gates every credit signal that takes no post-click URL
10136
12089
  // (network, DOM read-back) and classifyPhantomClick's destinationPlausible.
10137
- const destinationPlausible = (0, phantom_click_1.isPlausibleStepDestination)(step, post.url);
12090
+ const stepIsMidFlowSubmit = isMidFlowSubmitShapedStep({
12091
+ step,
12092
+ submitStep,
12093
+ isFinalStep,
12094
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12095
+ });
12096
+ const destinationPlausible = (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, stepIsMidFlowSubmit ? pre.url : undefined);
10138
12097
  const urlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, post.url) && destinationPlausible;
10139
12098
  const isStateClass = resolvedAction !== null && STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
10140
12099
  const isClick = resolvedAction !== null && resolvedAction.method === "click";
@@ -10227,9 +12186,10 @@ async function executeStepWithHealing(params) {
10227
12186
  // Zero-signal reset veto input for the view-swap gate below. Read only
10228
12187
  // for a resolved click with a usable selector — a page reset that reverts
10229
12188
  // 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.
12189
+ // replaces the clicked control along with everything else and shrinks
12190
+ // the DOM, while a genuine same-page toggle leaves it in place and a
12191
+ // view swap removes it but grows the DOM (absence vetoes only when
12192
+ // bytesDelta <= 0). See isClickViewSwapVerified's doc comment.
10233
12193
  const clickedElementStillPresent = isClick && resolvedAction?.selector
10234
12194
  ? await resolvedClickTargetStillPresent(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector)
10235
12195
  : true;
@@ -10248,6 +12208,17 @@ async function executeStepWithHealing(params) {
10248
12208
  // cascade to a 5-attempt failure and global replan. Vetoed when the click's
10249
12209
  // ng-invalid marker count grew, or when the landed destination is implausible
10250
12210
  // for the step (see isClickViewSwapVerified's doc comment).
12211
+ const midFlowSubmit = isMidFlowSubmitShapedStep({
12212
+ step,
12213
+ submitStep,
12214
+ isFinalStep,
12215
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12216
+ elementSubmitShaped: resolvedElementIsSubmitShaped,
12217
+ });
12218
+ // An element-inferred mid-flow submit joins the exemption the step-derived
12219
+ // one already got: a click that never left its URL did not bounce to a gate.
12220
+ const midFlowDestinationPlausible = destinationPlausible ||
12221
+ (midFlowSubmit && (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, pre.url));
10251
12222
  const clickViewSwapVerified = isClickViewSwapVerified({
10252
12223
  resolvedAction,
10253
12224
  submitStep,
@@ -10258,7 +12229,10 @@ async function executeStepWithHealing(params) {
10258
12229
  invalidMarkerDelta: postInvalidMarkerCount - preInvalidMarkerCount,
10259
12230
  clickedElementStillPresent,
10260
12231
  resolvedElementIsSubmitShaped,
10261
- destinationPlausible,
12232
+ destinationPlausible: midFlowDestinationPlausible,
12233
+ midFlowSubmit,
12234
+ pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, post),
12235
+ actSucceeded: record.actResultSuccess === true,
10262
12236
  });
10263
12237
  if (clickViewSwapVerified === false &&
10264
12238
  isClick &&
@@ -10325,7 +12299,12 @@ async function executeStepWithHealing(params) {
10325
12299
  if (readbackSelector && fillIntent !== null) {
10326
12300
  const readbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
10327
12301
  const readback = await verifyFillReadback(readbackTarget, readbackSelector, fillIntent.value);
10328
- if (readback?.outcome === "rejected") {
12302
+ if (readback?.outcome === "segment") {
12303
+ datepickerRejected = true;
12304
+ record.actResultSuccess = false;
12305
+ 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`;
12306
+ }
12307
+ else if (readback?.outcome === "rejected") {
10329
12308
  // Value didn't land. First try the datepicker primitive; it self-gates
10330
12309
  // on react-datepicker's `.react-datepicker__input-container` and returns
10331
12310
  // null for any non-datepicker input, so a plain rejected fill falls
@@ -10334,7 +12313,7 @@ async function executeStepWithHealing(params) {
10334
12313
  if (datepickerFill !== null) {
10335
12314
  if (datepickerFill.filled) {
10336
12315
  datepickerCommitted = true;
10337
- record.errorMessage = `text-datepicker-fill: filled via ${datepickerFill.strategy} "${redactIfSensitive(datepickerFill.postValue, emailStepCode)}"`;
12316
+ record.errorMessage = `text-datepicker-fill: filled via ${datepickerFill.strategy} "${maskLoggedValue(datepickerFill.postValue, emailStepCode)}"`;
10338
12317
  }
10339
12318
  else {
10340
12319
  // A gated-in datepicker that neither gesture commits is a real fill
@@ -10342,7 +12321,7 @@ async function executeStepWithHealing(params) {
10342
12321
  // so suppress the weak view-swap/form-value acceptance and escalate.
10343
12322
  datepickerRejected = true;
10344
12323
  record.actResultSuccess = false;
10345
- record.errorMessage = `text-datepicker-fill: failed to commit "${redactIfSensitive(fillIntent.value.slice(0, 60), emailStepCode)}" (post=${redactIfSensitive(datepickerFill.postValue, emailStepCode)})`;
12324
+ record.errorMessage = `text-datepicker-fill: failed to commit "${maskLoggedValue(fillIntent.value.slice(0, 60), emailStepCode)}" (post=${maskLoggedValue(datepickerFill.postValue, emailStepCode)})`;
10346
12325
  }
10347
12326
  }
10348
12327
  else {
@@ -10351,7 +12330,7 @@ async function executeStepWithHealing(params) {
10351
12330
  // failure dump reads identically regardless of which path caught it.
10352
12331
  datepickerRejected = true;
10353
12332
  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)`;
12333
+ 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
12334
  }
10356
12335
  }
10357
12336
  }
@@ -10379,7 +12358,7 @@ async function executeStepWithHealing(params) {
10379
12358
  const readbackSelector = resolvedAction?.selector ?? triedSelectors[triedSelectors.length - 1] ?? null;
10380
12359
  if (readbackSelector) {
10381
12360
  const readbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
10382
- const promptReadback = await verifyPromptSelectorCommitted(readbackTarget, readbackSelector);
12361
+ const promptReadback = await verifyPromptSelectorCommitted(readbackTarget, readbackSelector, parsePromptSelectorStep(step)?.option);
10383
12362
  if (promptReadback?.isPromptWidget === true && promptReadback.committed === false) {
10384
12363
  promptSelectorRejected = true;
10385
12364
  record.actResultSuccess = false;
@@ -10403,13 +12382,15 @@ async function executeStepWithHealing(params) {
10403
12382
  pre,
10404
12383
  post,
10405
12384
  elementStateChanged: domVerified && destinationPlausible,
10406
- isSubmitShapedStep: isStepSubmitShaped({
10407
- step,
10408
- submitStep,
10409
- isFinalStep,
10410
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10411
- }),
10412
- destinationPlausible,
12385
+ isSubmitShapedStep: midFlowSubmit ||
12386
+ isStepSubmitShaped({
12387
+ step,
12388
+ submitStep,
12389
+ isFinalStep,
12390
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12391
+ }),
12392
+ isMidFlowSubmitStep: midFlowSubmit,
12393
+ destinationPlausible: midFlowDestinationPlausible,
10413
12394
  });
10414
12395
  // An `"effective"` verdict driven purely by the page-wide byte-delta
10415
12396
  // floor (`TRIVIAL_DOM_DELTA_BYTES`, 500B) is intentionally NOT trusted
@@ -10502,6 +12483,7 @@ async function executeStepWithHealing(params) {
10502
12483
  }
10503
12484
  // Build the unfocused-observe evidence list. Used by the judge to
10504
12485
  // assess whether the page transitioned to a success state.
12486
+ params.abortSignal?.throwIfAborted();
10505
12487
  const unfocusedForJudge = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
10506
12488
  // Quick invalid-marker count (deterministic DOM querying — counting
10507
12489
  // structural ng-invalid containers is not fuzzy matching, just
@@ -10512,9 +12494,17 @@ async function executeStepWithHealing(params) {
10512
12494
  label: "flow-runner: submit-verification page title probe",
10513
12495
  }).catch(() => "");
10514
12496
  const matchedSubmittedSelectors = domSubmittedMatch !== null ? [domSubmittedMatch] : [];
10515
- const judgeVerdict = await (0, verify_submit_1.verifySubmitWithLLM)({
12497
+ const judgeVerdict = await judgeStepOutcome({
10516
12498
  client: anthropic,
10517
- input: {
12499
+ captureFn,
12500
+ step,
12501
+ isFinalStep,
12502
+ page,
12503
+ target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
12504
+ signalCounter,
12505
+ preNetworkCount: pre.networkCount,
12506
+ preVisibleTextSignature: pre.visibleTextSignature,
12507
+ submitInput: {
10518
12508
  pageUrl: post.url,
10519
12509
  pageTitle,
10520
12510
  unfocusedObserve: unfocusedForJudge.map((a) => ({
@@ -10528,9 +12518,22 @@ async function executeStepWithHealing(params) {
10528
12518
  successPageTitleHints,
10529
12519
  submittedStateSelectors: matchedSubmittedSelectors,
10530
12520
  },
10531
- captureFn,
10532
12521
  });
10533
- if (judgeVerdict === null) {
12522
+ // A mid-flow transition is credited on the page's own demonstrable
12523
+ // advance (visible text swapped); the judge's final-submit-flavoured
12524
+ // view of a stale title or missing network must not undo it.
12525
+ const midFlowAdvanceCredited = (midFlowSubmit && clickViewSwapVerified) ||
12526
+ isNullVerdictAdvanceCredited({
12527
+ midFlowSubmit,
12528
+ judgeUnavailable: judgeVerdict === null,
12529
+ networkAdvanced: networkIsRealAdvance,
12530
+ pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, post),
12531
+ invalidMarkerCount,
12532
+ });
12533
+ if (midFlowAdvanceCredited && judgeVerdict?.verified !== true) {
12534
+ logger.info(`mid-flow submit credited on demonstrable page advance (judge ${judgeVerdict === null ? "unavailable" : `rejected: ${judgeVerdict.reason}`})`);
12535
+ }
12536
+ else if (judgeVerdict === null) {
10534
12537
  // Bedrock-only deployment or judge call failed entirely. Fall back
10535
12538
  // to the DOM-state probe + requireSubmitEndpointMatch policy: if a
10536
12539
  // submitted-state selector matched and the site didn't require
@@ -10650,15 +12653,16 @@ async function executeStepWithHealing(params) {
10650
12653
  // tail node `clickExpr` below is about to click, instead of only
10651
12654
  // ever checking the (possibly unresolvable) primary xpath — see
10652
12655
  // resolvedClickTargetIsSubmitShaped's doc comment.
12656
+ const tailPhrases = tailRetargetPhrasesForStep(step);
10653
12657
  const retryResolvedElementIsSubmitShaped = stepDeclaredNonSubmit
10654
12658
  ? false
10655
- : await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector, xpathTail);
12659
+ : await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector, xpathTail, tailPhrases);
10656
12660
  // See resolvedClickUsedXpathTailRetarget's doc comment: flags that
10657
12661
  // this click's target identity came only from the loose tail
10658
12662
  // heuristic, not Stagehand's own resolved selector.
10659
12663
  const retryClickUsedXpathTailRetarget = stepDeclaredNonSubmit
10660
12664
  ? false
10661
- : await resolvedClickUsedXpathTailRetarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector, xpathTail);
12665
+ : await resolvedClickUsedXpathTailRetarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector, xpathTail, tailPhrases);
10662
12666
  // Trusted-click delivery for the plain-click branch: attempted
10663
12667
  // BEFORE the resolve/activate expression below (so a successful
10664
12668
  // delivery can suppress the synthetic `clickActivationExpr`
@@ -10690,10 +12694,12 @@ async function executeStepWithHealing(params) {
10690
12694
  const n16TrustedClickFailure = !n16TrustedDelivered && "reason" in n16TrustedClickResult
10691
12695
  ? n16TrustedClickResult
10692
12696
  : 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" }; })()`;
12697
+ 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
12698
  const n16FallbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
10695
12699
  const probeResult = (await n16FallbackTarget.evaluate(clickExpr));
10696
12700
  const fired = probeResult.fired;
12701
+ if (fired)
12702
+ await adoptOpenedPage();
10697
12703
  // Vacuous-click guard for the n+16 fallback. Same rationale as
10698
12704
  // verifyDomEffect's click case: a checkbox/radio input's .checked
10699
12705
  // property can flip true via CDP click even when the input lives on
@@ -10703,13 +12709,15 @@ async function executeStepWithHealing(params) {
10703
12709
  // ancestor-invalid signal through prevents the cascade from
10704
12710
  // advancing past a vacuous click.
10705
12711
  let ancestorStillInvalid = false;
10706
- if (probeResult.kind === "checkbox" && probeResult.checked === true) {
12712
+ if (probeResult.kind === "checkbox" &&
12713
+ probeResult.checked === true &&
12714
+ probeResult.ariaManaged !== true) {
10707
12715
  const ancestorInvalidExpr = `(() => {
10708
12716
  const isInvalid = ${INVALID_MARKER_EL_EXPR};
10709
12717
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
10710
12718
  let node = r.singleNodeValue;
10711
12719
  if (!node && ${JSON.stringify(xpathTail)}) {
10712
- node = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)});
12720
+ node = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)}, ${JSON.stringify(tailPhrases)});
10713
12721
  }
10714
12722
  if (!node) return false;
10715
12723
  for (let depth = 0; depth < 6 && node; depth++) {
@@ -10718,18 +12726,32 @@ async function executeStepWithHealing(params) {
10718
12726
  }
10719
12727
  return false;
10720
12728
  })()`;
10721
- ancestorStillInvalid = (await n16FallbackTarget
12729
+ ancestorStillInvalid = (await (frameTarget ?? (0, frame_target_1.mainFrameTarget)(page))
10722
12730
  .evaluate(ancestorInvalidExpr)
10723
12731
  .catch(() => false));
10724
12732
  }
12733
+ // An aria-managed box is never forced, so the framework's own
12734
+ // aria-checked decides; a stale invalid ancestor is not consulted.
12735
+ const forcedCheckboxRejected = probeResult.kind === "checkbox" &&
12736
+ (probeResult.ariaManaged === true
12737
+ ? probeResult.checked !== true
12738
+ : ancestorStillInvalid);
10725
12739
  const checkboxStateVerified = probeResult.kind === "checkbox" &&
10726
12740
  probeResult.checked === true &&
10727
- !ancestorStillInvalid;
12741
+ (probeResult.ariaManaged === true || !ancestorStillInvalid);
10728
12742
  await page.waitForTimeout(STEP_PAUSE_MS);
12743
+ await settleWithPageQuiescence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, pre.networkCount, isSettleWaitStep, pre.visibleTextSignature);
10729
12744
  const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
10730
12745
  const retryNetworkFired = retryPost.networkCount > pre.networkCount;
12746
+ const retryMidFlowSubmit = isMidFlowSubmitShapedStep({
12747
+ step,
12748
+ submitStep,
12749
+ isFinalStep,
12750
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12751
+ elementSubmitShaped: retryResolvedElementIsSubmitShaped,
12752
+ });
10731
12753
  const retryUrlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, retryPost.url) &&
10732
- (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url);
12754
+ (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url, retryMidFlowSubmit ? pre.url : undefined);
10733
12755
  const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
10734
12756
  const retryTextChanged = retryPost.visibleTextSignature !== pre.visibleTextSignature;
10735
12757
  const retryFormValueChanged = retryPost.formValueSignature !== pre.formValueSignature;
@@ -10793,7 +12815,7 @@ async function executeStepWithHealing(params) {
10793
12815
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
10794
12816
  let el = r.singleNodeValue;
10795
12817
  if (!el && ${JSON.stringify(xpathTail)}) {
10796
- el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)});
12818
+ el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)}, ${JSON.stringify(tailPhrases)});
10797
12819
  }
10798
12820
  return el ? isDisabled(el) : false;
10799
12821
  })()`;
@@ -10824,6 +12846,12 @@ async function executeStepWithHealing(params) {
10824
12846
  // Destination-gated so an implausible landing URL's traffic cannot satisfy
10825
12847
  // the veto or the submit-transition carve-out downstream.
10826
12848
  const retryDestinationPlausible = (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url);
12849
+ // Only the verdict sees the pre.url exemption, and only for a demonstrable
12850
+ // page advance: bare network/DOM signals stay gated on the landed URL.
12851
+ const retryMidFlowDestinationPlausible = retryDestinationPlausible ||
12852
+ (retryMidFlowSubmit &&
12853
+ (0, phantom_click_1.hasPageAdvanced)(pre, retryPost) &&
12854
+ (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url, pre.url));
10827
12855
  const retryNetworkIsRealAdvance = retryNetworkFired &&
10828
12856
  retryDestinationPlausible &&
10829
12857
  (await waitForTransitionBody({
@@ -10907,9 +12935,7 @@ async function executeStepWithHealing(params) {
10907
12935
  isFinalStep,
10908
12936
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10909
12937
  }) || retryResolvedElementIsSubmitShaped;
10910
- const weakDomSignalsAllowed = ((!(isFinalStep && !stepDeclaredNonSubmit) &&
10911
- !submitStep &&
10912
- !retryResolvedElementIsSubmitShaped) ||
12938
+ const weakDomSignalsAllowed = ((!inferredFinalSubmitStep && !submitStep && !retryResolvedElementIsSubmitShaped) ||
10913
12939
  requireSubmitEndpoint) &&
10914
12940
  !isCheckboxOrRadioIntentStep(step) &&
10915
12941
  !clickTargetIsSelectionMarker;
@@ -10931,8 +12957,9 @@ async function executeStepWithHealing(params) {
10931
12957
  pre,
10932
12958
  post: retryPost,
10933
12959
  elementStateChanged: retrySelectionStateChanged,
10934
- isSubmitShapedStep: retrySubmitShaped,
10935
- destinationPlausible: retryDestinationPlausible,
12960
+ isSubmitShapedStep: retryMidFlowSubmit || retrySubmitShaped,
12961
+ isMidFlowSubmitStep: retryMidFlowSubmit,
12962
+ destinationPlausible: retryMidFlowDestinationPlausible,
10936
12963
  });
10937
12964
  // Destination-plausibility audit of every remaining disjunct in
10938
12965
  // this OR (the recon report's closing instruction, see
@@ -10948,15 +12975,28 @@ async function executeStepWithHealing(params) {
10948
12975
  // three get the same `isPlausibleStepDestination` gate
10949
12976
  // `weakDomSignalsAllowed` already has (bugfix-002) — `retryUrlChanged`
10950
12977
  // already folds the same check in at its own computation above.
12978
+ // An unfired probe with no trusted delivery clicked nothing, so DOM
12979
+ // movement is ambient settling, not this step's effect.
12980
+ const n16ClickDelivered = fired === true || n16TrustedDelivered;
12981
+ const retryShowsErrorSummary = exports.ADVANCE_CLICK_RX.test(step)
12982
+ ? (await (frameTarget ?? (0, frame_target_1.mainFrameTarget)(page))
12983
+ .evaluate(exports.ERROR_SUMMARY_EXPR)
12984
+ .catch(() => false))
12985
+ : false;
10951
12986
  let retryVerified = !clickBlockedByDisabled &&
12987
+ !retryShowsErrorSummary &&
10952
12988
  !clickBlockedByInvalid &&
10953
12989
  !fallbackDomOnlyAdvance &&
10954
- ((retryNetworkFired && retryDestinationPlausible) ||
10955
- retryUrlChanged ||
12990
+ !forcedCheckboxRejected &&
12991
+ ((n16ClickDelivered && retryNetworkFired && retryDestinationPlausible) ||
12992
+ (n16ClickDelivered && retryUrlChanged) ||
10956
12993
  (checkboxStateVerified && retryDestinationPlausible) ||
10957
12994
  (retrySelectionStateChanged && retryDestinationPlausible) ||
10958
- (!retrySubmitShaped && retryVerdict === "effective") ||
10959
- (weakDomSignalsAllowed &&
12995
+ (n16ClickDelivered &&
12996
+ (!retrySubmitShaped || retryMidFlowSubmit) &&
12997
+ retryVerdict === "effective") ||
12998
+ (n16ClickDelivered &&
12999
+ weakDomSignalsAllowed &&
10960
13000
  retryDestinationPlausible &&
10961
13001
  (retryHtmlDelta !== 0 || retryTextChanged || retryFormValueChanged)));
10962
13002
  if (retryVerified) {
@@ -11000,9 +13040,8 @@ async function executeStepWithHealing(params) {
11000
13040
  // flow-runner.overlay-hidden-target-trusted-click-acceptance.test.ts
11001
13041
  // and flow-runner.evidence-table-alternating-verdict-regression.test.ts,
11002
13042
  // both of which rely on that carve-out and must stay green).
11003
- const retryDestinationUnconfirmed = isFinalStep &&
13043
+ const retryDestinationUnconfirmed = inferredFinalSubmitStep &&
11004
13044
  !submitStep &&
11005
- !stepDeclaredNonSubmit &&
11006
13045
  retryUrlChanged &&
11007
13046
  !retryResolvedElementIsSubmitShaped;
11008
13047
  if (retryVerified && retryDestinationUnconfirmed && !requireSubmitEndpoint) {
@@ -11048,9 +13087,17 @@ async function executeStepWithHealing(params) {
11048
13087
  label: "flow-runner: submit-verification retry page title probe",
11049
13088
  }).catch(() => "");
11050
13089
  const matchedSubmittedSelectors = domSubmittedMatch !== null ? [domSubmittedMatch] : [];
11051
- const judgeVerdict = await (0, verify_submit_1.verifySubmitWithLLM)({
13090
+ const judgeVerdict = await judgeStepOutcome({
11052
13091
  client: anthropic,
11053
- input: {
13092
+ captureFn,
13093
+ step,
13094
+ isFinalStep,
13095
+ page,
13096
+ target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
13097
+ signalCounter,
13098
+ preNetworkCount: pre.networkCount,
13099
+ preVisibleTextSignature: pre.visibleTextSignature,
13100
+ submitInput: {
11054
13101
  pageUrl: retryPost.url,
11055
13102
  pageTitle,
11056
13103
  unfocusedObserve: unfocusedForJudge.map((a) => ({
@@ -11064,9 +13111,21 @@ async function executeStepWithHealing(params) {
11064
13111
  successPageTitleHints,
11065
13112
  submittedStateSelectors: matchedSubmittedSelectors,
11066
13113
  },
11067
- captureFn,
11068
13114
  });
11069
- if (judgeVerdict === null) {
13115
+ const retryMidFlowAdvanceCredited = (retryMidFlowSubmit &&
13116
+ retryVerdict === "effective" &&
13117
+ (0, phantom_click_1.hasPageAdvanced)(pre, retryPost)) ||
13118
+ isNullVerdictAdvanceCredited({
13119
+ midFlowSubmit: retryMidFlowSubmit,
13120
+ judgeUnavailable: judgeVerdict === null,
13121
+ networkAdvanced: retryNetworkFired && retryDestinationPlausible,
13122
+ pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, retryPost),
13123
+ invalidMarkerCount,
13124
+ });
13125
+ if (retryMidFlowAdvanceCredited && judgeVerdict?.verified !== true) {
13126
+ logger.info(`n+16 fallback mid-flow submit credited on demonstrable page advance (judge ${judgeVerdict === null ? "unavailable" : `rejected: ${judgeVerdict.reason}`})`);
13127
+ }
13128
+ else if (judgeVerdict === null) {
11070
13129
  // Bedrock-only or judge failed; fall back to DOM-state + policy.
11071
13130
  if (!requireSubmitEndpointMatch && domSubmittedMatch !== null) {
11072
13131
  logger.info(`n+16 fallback submit verified via submitted-state DOM selector '${domSubmittedMatch}' (judge unavailable)`);
@@ -11321,7 +13380,17 @@ async function executeStepWithHealing(params) {
11321
13380
  isFinalStep,
11322
13381
  flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11323
13382
  })) {
11324
- const fallbackTarget = await probeChildFrameSubmitFallback({ page, signalCounter, step });
13383
+ const fallbackTarget = await probeChildFrameSubmitFallback({
13384
+ page,
13385
+ signalCounter,
13386
+ step,
13387
+ isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
13388
+ step,
13389
+ submitStep,
13390
+ isFinalStep,
13391
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
13392
+ }),
13393
+ });
11325
13394
  if (fallbackTarget) {
11326
13395
  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
13396
  trajectory?.push({ stepIndex, verifiedBy: "dom" });
@@ -11378,7 +13447,7 @@ async function executeStepWithHealing(params) {
11378
13447
  if (dumpPath !== null) {
11379
13448
  logger.error(`${formatStepPrefix(stepIndex, totalSteps)} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);
11380
13449
  }
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");
13450
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactPii)(step).slice(0, 60)}) failed verification after ${MAX_STEP_ATTEMPTS} attempts${dumpPath ? `; see ${dumpPath}` : ""}`, phantomClickAfterAttempt1 ? "phantom-click-exhausted" : "cascade-exhausted");
11382
13451
  }
11383
13452
  /**
11384
13453
  * Navigates `page` directly to `url` and waits for the SPA to hydrate,
@@ -11424,7 +13493,11 @@ async function executeNavigateStep(params) {
11424
13493
  */
11425
13494
  async function runHealingFlow(deps) {
11426
13495
  const { stagehand, page, steps, logger, anthropic, rephraseModel, uploadFixture, maxFlowMs } = deps;
13496
+ for (const secret of deps.secrets ?? [])
13497
+ (0, pii_redaction_1.registerSecretValue)(secret);
11427
13498
  const counter = { n: 0 };
13499
+ // Rebound when a step's click opens a new tab, so later steps drive that page.
13500
+ let activePage = page;
11428
13501
  const signalCounter = { n: 0 };
11429
13502
  const recentCaptures = [];
11430
13503
  const recentCaptureMeta = [];
@@ -11432,7 +13505,17 @@ async function runHealingFlow(deps) {
11432
13505
  let submitVerified = false;
11433
13506
  let submitStepSkipped = false;
11434
13507
  let lastStepIndex = -1;
11435
- const stopCapture = wireSignalCapture(page, {
13508
+ const liveOriginOf = (target) => {
13509
+ try {
13510
+ return new URL(target.url()).origin;
13511
+ }
13512
+ catch {
13513
+ return "";
13514
+ }
13515
+ };
13516
+ // Seeded so the first step does not re-gate; the initial gate lives with the caller's goto.
13517
+ let lastOrigin = liveOriginOf(activePage);
13518
+ const wireCapture = (target) => wireSignalCapture(target, {
11436
13519
  counter,
11437
13520
  signalCounter,
11438
13521
  recentCaptures,
@@ -11440,13 +13523,15 @@ async function runHealingFlow(deps) {
11440
13523
  getCurrentPhase: () => "flow",
11441
13524
  getCurrentPageOrigin: () => {
11442
13525
  try {
11443
- return new URL(page.url()).origin;
13526
+ return new URL(activePage.url()).origin;
11444
13527
  }
11445
13528
  catch {
11446
13529
  return "";
11447
13530
  }
11448
13531
  },
11449
13532
  });
13533
+ // Listeners on earlier pages stay wired (a prior tab can still emit) and all stop on exit.
13534
+ const stopFns = [wireCapture(page)];
11450
13535
  const flowHasSubmitSemanticsFlag = flowHasSubmitSemantics({
11451
13536
  steps,
11452
13537
  submitEndpointPattern: deps.submitEndpointPattern ?? null,
@@ -11473,19 +13558,27 @@ async function runHealingFlow(deps) {
11473
13558
  // legitimately-absent optional step, and the flow's own step count
11474
13559
  // never quietly reaches `steps.length` past the point of death.
11475
13560
  try {
11476
- page.url();
13561
+ activePage.url();
11477
13562
  }
11478
13563
  catch (err) {
11479
13564
  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
13565
  }
11481
13566
  lastStepIndex = i;
13567
+ // A cross-origin hop (redirect, new tab, navigateTo) lands on a SPA the initial
13568
+ // readiness gate never covered; wait for hydration before the step probes a shell.
13569
+ const currentOrigin = liveOriginOf(activePage);
13570
+ if (currentOrigin !== "" && currentOrigin !== lastOrigin) {
13571
+ logger.info(`origin changed ${lastOrigin || "(none)"} → ${currentOrigin}; re-gating on SPA hydration`);
13572
+ await (0, spa_readiness_1.regateSpaAfterOriginChange)(activePage, logger);
13573
+ lastOrigin = currentOrigin;
13574
+ }
11482
13575
  // A navigateTo step is a direct page.goto, not an act/observe against
11483
13576
  // the current DOM, so it has no target frame to resolve and skips
11484
13577
  // straight to executeNavigateStep instead of the self-heal cascade.
11485
13578
  let stepPromise;
11486
13579
  if (s.navigateTo !== undefined) {
11487
13580
  stepPromise = executeNavigateStep({
11488
- page,
13581
+ page: activePage,
11489
13582
  url: s.navigateTo,
11490
13583
  optional: s.optional,
11491
13584
  logger,
@@ -11498,11 +13591,17 @@ async function runHealingFlow(deps) {
11498
13591
  // reachable, paralleling the recon CLI's per-step resolution. `resolveFrameTarget`
11499
13592
  // falls back to the main-frame target when `frameSelector` is null/unresolvable,
11500
13593
  // 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);
13594
+ const frameTarget = await (0, frame_target_1.resolveFrameTarget)(activePage, deps.frameSelector);
11502
13595
  await (0, frame_target_1.waitForChildFrameReady)(frameTarget);
11503
13596
  stepPromise = executeStepWithHealing({
11504
13597
  stagehand,
11505
- page,
13598
+ page: activePage,
13599
+ onPageAdopted: (adopted) => {
13600
+ if (adopted === activePage)
13601
+ return;
13602
+ activePage = adopted;
13603
+ stopFns.push(wireCapture(adopted));
13604
+ },
11506
13605
  abortSignal: deps.abortSignal,
11507
13606
  step: s.instruction,
11508
13607
  optional: s.optional,
@@ -11548,7 +13647,7 @@ async function runHealingFlow(deps) {
11548
13647
  // runHealingFlow resolve as if the flow succeeded. Mirrors the same
11549
13648
  // gate in recon-browser.ts's step loop.
11550
13649
  try {
11551
- page.url();
13650
+ activePage.url();
11552
13651
  }
11553
13652
  catch (err) {
11554
13653
  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 +13662,8 @@ async function runHealingFlow(deps) {
11563
13662
  }
11564
13663
  }
11565
13664
  finally {
11566
- stopCapture();
13665
+ for (const stop of stopFns)
13666
+ stop();
11567
13667
  }
11568
13668
  return { submitVerified, submitStepSkipped, lastStepIndex };
11569
13669
  }