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