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