@enricai/barnacle 1.12.78 → 1.12.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/helpers/reply.d.ts.map +1 -1
- package/dist/api/helpers/reply.js +5 -2
- package/dist/api/helpers/reply.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/lib/llm/judge.d.ts.map +1 -1
- package/dist/lib/llm/judge.js +6 -1
- package/dist/lib/llm/judge.js.map +1 -1
- package/dist/lib/llm/judges/verify-step-advanced.d.ts +30 -0
- package/dist/lib/llm/judges/verify-step-advanced.d.ts.map +1 -0
- package/dist/lib/llm/judges/verify-step-advanced.js +62 -0
- package/dist/lib/llm/judges/verify-step-advanced.js.map +1 -0
- package/dist/lib/llm/judges/verify-submit.d.ts +7 -1
- package/dist/lib/llm/judges/verify-submit.d.ts.map +1 -1
- package/dist/lib/llm/judges/verify-submit.js +29 -8
- package/dist/lib/llm/judges/verify-submit.js.map +1 -1
- package/dist/lib/llm/schemas.d.ts +8 -0
- package/dist/lib/llm/schemas.d.ts.map +1 -1
- package/dist/lib/llm/schemas.js +21 -13
- package/dist/lib/llm/schemas.js.map +1 -1
- package/dist/lib/logging.d.ts +7 -0
- package/dist/lib/logging.d.ts.map +1 -1
- package/dist/lib/logging.js +19 -1
- package/dist/lib/logging.js.map +1 -1
- package/dist/lib/pii-redaction.d.ts +104 -0
- package/dist/lib/pii-redaction.d.ts.map +1 -0
- package/dist/lib/pii-redaction.js +397 -0
- package/dist/lib/pii-redaction.js.map +1 -0
- package/dist/lib/random.d.ts +3 -1
- package/dist/lib/random.d.ts.map +1 -1
- package/dist/lib/random.js +7 -2
- package/dist/lib/random.js.map +1 -1
- package/dist/lib/telemetry/beacon-capture.d.ts.map +1 -1
- package/dist/lib/telemetry/beacon-capture.js +2 -0
- package/dist/lib/telemetry/beacon-capture.js.map +1 -1
- package/dist/lib/telemetry/call-capture.d.ts.map +1 -1
- package/dist/lib/telemetry/call-capture.js +4 -1
- package/dist/lib/telemetry/call-capture.js.map +1 -1
- package/dist/lib/telemetry/submission-capture.d.ts +11 -1
- package/dist/lib/telemetry/submission-capture.d.ts.map +1 -1
- package/dist/lib/telemetry/submission-capture.js +32 -3
- package/dist/lib/telemetry/submission-capture.js.map +1 -1
- package/dist/plugins/config-plugin.d.ts +8 -0
- package/dist/plugins/config-plugin.d.ts.map +1 -1
- package/dist/plugins/config-plugin.js +6 -1
- 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/actionable-visibility-expr.d.ts +12 -0
- package/dist/scraper/actionable-visibility-expr.d.ts.map +1 -0
- package/dist/scraper/actionable-visibility-expr.js +22 -0
- package/dist/scraper/actionable-visibility-expr.js.map +1 -0
- 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/browser-click-expr.d.ts +1 -1
- package/dist/scraper/browser-click-expr.d.ts.map +1 -1
- package/dist/scraper/browser-click-expr.js +2 -6
- package/dist/scraper/browser-click-expr.js.map +1 -1
- package/dist/scraper/click-settle.d.ts +19 -0
- package/dist/scraper/click-settle.d.ts.map +1 -0
- package/dist/scraper/click-settle.js +65 -0
- package/dist/scraper/click-settle.js.map +1 -0
- package/dist/scraper/deep-locator-candidates.d.ts +20 -1
- package/dist/scraper/deep-locator-candidates.d.ts.map +1 -1
- package/dist/scraper/deep-locator-candidates.js +110 -22
- package/dist/scraper/deep-locator-candidates.js.map +1 -1
- package/dist/scraper/deep-locator-scan.d.ts +12 -0
- package/dist/scraper/deep-locator-scan.d.ts.map +1 -1
- package/dist/scraper/deep-locator-scan.js +42 -13
- package/dist/scraper/deep-locator-scan.js.map +1 -1
- package/dist/scraper/errors.d.ts +10 -0
- package/dist/scraper/errors.d.ts.map +1 -1
- package/dist/scraper/errors.js +19 -1
- package/dist/scraper/errors.js.map +1 -1
- package/dist/scraper/flow-runner.d.ts +355 -6
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +2255 -350
- 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/pool.d.ts.map +1 -1
- package/dist/scraper/pool.js +8 -1
- package/dist/scraper/pool.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/retry.d.ts +1 -1
- package/dist/scraper/retry.d.ts.map +1 -1
- package/dist/scraper/retry.js +5 -1
- package/dist/scraper/retry.js.map +1 -1
- 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 +63 -20
- package/dist/scraper/session-browserbase.js.map +1 -1
- package/dist/scraper/session-shared.d.ts +5 -0
- package/dist/scraper/session-shared.d.ts.map +1 -1
- package/dist/scraper/session-shared.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 +31 -1
- package/dist/scraper/session-teardown.d.ts.map +1 -1
- package/dist/scraper/session-teardown.js +60 -4
- package/dist/scraper/session-teardown.js.map +1 -1
- package/dist/scraper/spa-readiness.d.ts +13 -1
- package/dist/scraper/spa-readiness.d.ts.map +1 -1
- package/dist/scraper/spa-readiness.js +31 -1
- package/dist/scraper/spa-readiness.js.map +1 -1
- package/dist/scraper/stagehand-guard.d.ts +8 -3
- package/dist/scraper/stagehand-guard.d.ts.map +1 -1
- package/dist/scraper/stagehand-guard.js +181 -13
- package/dist/scraper/stagehand-guard.js.map +1 -1
- package/dist/scraper/submit-control.d.ts +2 -2
- package/dist/scraper/submit-control.d.ts.map +1 -1
- package/dist/scraper/submit-control.js +5 -21
- package/dist/scraper/submit-control.js.map +1 -1
- package/dist/scraper/target-resolution-diagnostic.d.ts.map +1 -1
- package/dist/scraper/target-resolution-diagnostic.js +6 -11
- package/dist/scraper/target-resolution-diagnostic.js.map +1 -1
- package/dist/scraper/transient-provider-error.d.ts +19 -0
- package/dist/scraper/transient-provider-error.d.ts.map +1 -0
- package/dist/scraper/transient-provider-error.js +59 -0
- package/dist/scraper/transient-provider-error.js.map +1 -0
- package/dist/scraper/trusted-click.d.ts +122 -0
- package/dist/scraper/trusted-click.d.ts.map +1 -0
- package/dist/scraper/trusted-click.js +201 -0
- package/dist/scraper/trusted-click.js.map +1 -0
- package/dist/scraper/upload-affordance-expr.d.ts +33 -0
- package/dist/scraper/upload-affordance-expr.d.ts.map +1 -0
- package/dist/scraper/upload-affordance-expr.js +91 -0
- package/dist/scraper/upload-affordance-expr.js.map +1 -0
- package/dist/scripts/recon-browser.d.ts +30 -2
- package/dist/scripts/recon-browser.d.ts.map +1 -1
- package/dist/scripts/recon-browser.js +133 -27
- 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 +159 -8
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +998 -182
- package/dist/scripts/recon-generate.js.map +1 -1
- package/dist/scripts/recon-shared.d.ts +7 -0
- package/dist/scripts/recon-shared.d.ts.map +1 -1
- package/dist/scripts/recon-shared.js +19 -0
- package/dist/scripts/recon-shared.js.map +1 -1
- package/dist/site-plugin.d.ts +8 -0
- package/dist/site-plugin.d.ts.map +1 -1
- package/dist/types/pii-redaction.d.ts +39 -0
- package/dist/types/pii-redaction.d.ts.map +1 -0
- package/dist/types/pii-redaction.js +3 -0
- package/dist/types/pii-redaction.js.map +1 -0
- package/package.json +14 -1
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
* the terminal failure dump.
|
|
15
15
|
*/
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.hasOriginOrPathChanged = exports.TRAILING_GRACE_WINDOW = exports.STEP_WATCHDOG_MS = exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR = exports.GOTO_TIMEOUT_MS = exports.waitForSpaReady = void 0;
|
|
17
|
+
exports.hasOriginOrPathChanged = exports.SEGMENTED_DATE_ENUMERATE_EXPR = exports.ERROR_SUMMARY_EXPR = exports.ADVANCE_CLICK_RX = exports.TRAILING_GRACE_WINDOW = exports.STEP_WATCHDOG_MS = exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR = exports.GOTO_TIMEOUT_MS = exports.waitForSpaReady = void 0;
|
|
18
18
|
exports.mergeResponseHeaders = mergeResponseHeaders;
|
|
19
19
|
exports.wireSignalCapture = wireSignalCapture;
|
|
20
20
|
exports.shouldRetryCaptchaRegistry = shouldRetryCaptchaRegistry;
|
|
21
|
+
exports.buildDeepMarkerCentreExpr = buildDeepMarkerCentreExpr;
|
|
21
22
|
exports.prepareFailureDumpBody = prepareFailureDumpBody;
|
|
22
23
|
exports.fieldValuesAtFailureExpr = fieldValuesAtFailureExpr;
|
|
23
24
|
exports.snapshotPage = snapshotPage;
|
|
@@ -27,6 +28,12 @@ exports.findRecentPageTransition = findRecentPageTransition;
|
|
|
27
28
|
exports.isWizardExitAction = isWizardExitAction;
|
|
28
29
|
exports.isAdvanceStep = isAdvanceStep;
|
|
29
30
|
exports.isSubmitIntentStep = isSubmitIntentStep;
|
|
31
|
+
exports.stripNegatedSubmitClauses = stripNegatedSubmitClauses;
|
|
32
|
+
exports.classifyStepSubmitIntent = classifyStepSubmitIntent;
|
|
33
|
+
exports.isStepDeclaredNonSubmit = isStepDeclaredNonSubmit;
|
|
34
|
+
exports.isStepSubmitShaped = isStepSubmitShaped;
|
|
35
|
+
exports.isStepSettleWaitShaped = isStepSettleWaitShaped;
|
|
36
|
+
exports.isMidFlowSubmitShapedStep = isMidFlowSubmitShapedStep;
|
|
30
37
|
exports.isCheckboxOrRadioIntentStep = isCheckboxOrRadioIntentStep;
|
|
31
38
|
exports.shouldCaptureSelectionState = shouldCaptureSelectionState;
|
|
32
39
|
exports.flowHasSubmitSemantics = flowHasSubmitSemantics;
|
|
@@ -38,6 +45,7 @@ exports.windowHasTransitionBody = windowHasTransitionBody;
|
|
|
38
45
|
exports.windowHasAdvanceTransition = windowHasAdvanceTransition;
|
|
39
46
|
exports.shouldVetoFallbackAdvance = shouldVetoFallbackAdvance;
|
|
40
47
|
exports.isDomOnlyAdvanceVerified = isDomOnlyAdvanceVerified;
|
|
48
|
+
exports.isNullVerdictAdvanceCredited = isNullVerdictAdvanceCredited;
|
|
41
49
|
exports.isClickViewSwapVerified = isClickViewSwapVerified;
|
|
42
50
|
exports.selectionCountFromSignature = selectionCountFromSignature;
|
|
43
51
|
exports.shouldReadbackFillOnActSuccess = shouldReadbackFillOnActSuccess;
|
|
@@ -57,6 +65,8 @@ exports.formatValidationRejectedReason = formatValidationRejectedReason;
|
|
|
57
65
|
exports.selectBodyExcerpt = selectBodyExcerpt;
|
|
58
66
|
exports.extractLivePageFormEvidence = extractLivePageFormEvidence;
|
|
59
67
|
exports.probeLeafInvalidContainers = probeLeafInvalidContainers;
|
|
68
|
+
exports.buildSubmitJudgeStepContext = buildSubmitJudgeStepContext;
|
|
69
|
+
exports.judgeStepOutcome = judgeStepOutcome;
|
|
60
70
|
exports.renderLeafInvalidFields = renderLeafInvalidFields;
|
|
61
71
|
exports.extractSubmitFailureEvidence = extractSubmitFailureEvidence;
|
|
62
72
|
exports.extractGaEventEvidence = extractGaEventEvidence;
|
|
@@ -65,16 +75,24 @@ exports.fillHtml5DateTimeInput = fillHtml5DateTimeInput;
|
|
|
65
75
|
exports.fillTextDatepickerInput = fillTextDatepickerInput;
|
|
66
76
|
exports.verifyFillReadback = verifyFillReadback;
|
|
67
77
|
exports.verifyPromptSelectorCommitted = verifyPromptSelectorCommitted;
|
|
78
|
+
exports.tailRetargetPhrasesForStep = tailRetargetPhrasesForStep;
|
|
79
|
+
exports.stepQuotesValueNotControlName = stepQuotesValueNotControlName;
|
|
68
80
|
exports.isUploadAffordanceLabel = isUploadAffordanceLabel;
|
|
69
81
|
exports.writeFixtureToTempFile = writeFixtureToTempFile;
|
|
70
82
|
exports.attachToSurfacedInput = attachToSurfacedInput;
|
|
71
83
|
exports.surfaceAndUpload = surfaceAndUpload;
|
|
84
|
+
exports.clickUploadAffordance = clickUploadAffordance;
|
|
85
|
+
exports.setFilesViaCdp = setFilesViaCdp;
|
|
72
86
|
exports.parseSelectStep = parseSelectStep;
|
|
73
87
|
exports.parseFillStep = parseFillStep;
|
|
74
88
|
exports.parseFillValueIntent = parseFillValueIntent;
|
|
89
|
+
exports.parseSegmentedDateValue = parseSegmentedDateValue;
|
|
90
|
+
exports.resolveSegmentedDateGroup = resolveSegmentedDateGroup;
|
|
91
|
+
exports.trySegmentedDateSpinbuttonPrimitive = trySegmentedDateSpinbuttonPrimitive;
|
|
75
92
|
exports.parseRadioStep = parseRadioStep;
|
|
76
93
|
exports.parseWidgetOptionClickStep = parseWidgetOptionClickStep;
|
|
77
94
|
exports.parseCheckStep = parseCheckStep;
|
|
95
|
+
exports.parseCheckStateStep = parseCheckStateStep;
|
|
78
96
|
exports.pollEnumerate = pollEnumerate;
|
|
79
97
|
exports.waitForTransitionBody = waitForTransitionBody;
|
|
80
98
|
exports.injectCaptchaTokenAndSubmit = injectCaptchaTokenAndSubmit;
|
|
@@ -82,8 +100,11 @@ exports.submitCaptchaGatedForm = submitCaptchaGatedForm;
|
|
|
82
100
|
exports.trySelectPrimitive = trySelectPrimitive;
|
|
83
101
|
exports.chooseRequiredSelectOption = chooseRequiredSelectOption;
|
|
84
102
|
exports.tryFillRequiredSelectsPrimitive = tryFillRequiredSelectsPrimitive;
|
|
103
|
+
exports.tryCheckboxStatePrimitive = tryCheckboxStatePrimitive;
|
|
85
104
|
exports.buildRadioIdXPath = buildRadioIdXPath;
|
|
86
105
|
exports.selectRadioGroupOption = selectRadioGroupOption;
|
|
106
|
+
exports.applyRadioSelection = applyRadioSelection;
|
|
107
|
+
exports.dismissLeftoverPromptPopups = dismissLeftoverPromptPopups;
|
|
87
108
|
exports.hasUnfilledRequiredControlForStep = hasUnfilledRequiredControlForStep;
|
|
88
109
|
exports.simulateDragDropUpload = simulateDragDropUpload;
|
|
89
110
|
exports.dispatchJqueryChangeEvent = dispatchJqueryChangeEvent;
|
|
@@ -95,6 +116,8 @@ exports.tryDeterministicFieldLabelActuation = tryDeterministicFieldLabelActuatio
|
|
|
95
116
|
exports.probeStepBeforeAttempts = probeStepBeforeAttempts;
|
|
96
117
|
exports.extractLinkFromMessage = extractLinkFromMessage;
|
|
97
118
|
exports.extractCodeFromMessage = extractCodeFromMessage;
|
|
119
|
+
exports.spliceEmailStepFillValue = spliceEmailStepFillValue;
|
|
120
|
+
exports.primitiveOptionLabel = primitiveOptionLabel;
|
|
98
121
|
exports.executeStepWithHealing = executeStepWithHealing;
|
|
99
122
|
exports.executeNavigateStep = executeNavigateStep;
|
|
100
123
|
exports.runHealingFlow = runHealingFlow;
|
|
@@ -110,15 +133,20 @@ const error_messages_1 = require("../lib/llm/judges/error-messages");
|
|
|
110
133
|
const invalid_fields_1 = require("../lib/llm/judges/invalid-fields");
|
|
111
134
|
const modal_priority_1 = require("../lib/llm/judges/modal-priority");
|
|
112
135
|
const select_option_1 = require("../lib/llm/judges/select-option");
|
|
136
|
+
const verify_step_advanced_1 = require("../lib/llm/judges/verify-step-advanced");
|
|
113
137
|
const verify_submit_1 = require("../lib/llm/judges/verify-submit");
|
|
114
138
|
const schemas_1 = require("../lib/llm/schemas");
|
|
115
139
|
const logging_1 = require("../lib/logging");
|
|
140
|
+
const pii_redaction_1 = require("../lib/pii-redaction");
|
|
116
141
|
const call_capture_1 = require("../lib/telemetry/call-capture");
|
|
117
142
|
const call_types_1 = require("../lib/telemetry/call-types");
|
|
118
143
|
const capture_filters_1 = require("../recon/capture-filters");
|
|
144
|
+
const actionable_visibility_expr_1 = require("./actionable-visibility-expr");
|
|
145
|
+
const active_page_1 = require("./active-page");
|
|
119
146
|
const browser_click_expr_1 = require("./browser-click-expr");
|
|
120
147
|
const captcha_callback_capture_1 = require("./captcha-callback-capture");
|
|
121
148
|
const captcha_solver_1 = require("./captcha-solver");
|
|
149
|
+
const click_settle_1 = require("./click-settle");
|
|
122
150
|
const deep_locator_actuate_1 = require("./deep-locator-actuate");
|
|
123
151
|
const deep_locator_candidates_1 = require("./deep-locator-candidates");
|
|
124
152
|
const deep_locator_click_1 = require("./deep-locator-click");
|
|
@@ -126,11 +154,14 @@ const deep_locator_scan_1 = require("./deep-locator-scan");
|
|
|
126
154
|
const errors_2 = require("./errors");
|
|
127
155
|
const frame_target_1 = require("./frame-target");
|
|
128
156
|
const phantom_click_1 = require("./phantom-click");
|
|
157
|
+
const quoted_spans_1 = require("./quoted-spans");
|
|
129
158
|
const session_teardown_1 = require("./session-teardown");
|
|
130
159
|
const spa_readiness_1 = require("./spa-readiness");
|
|
131
160
|
const stagehand_guard_1 = require("./stagehand-guard");
|
|
132
161
|
const submit_control_1 = require("./submit-control");
|
|
133
162
|
const target_resolution_diagnostic_1 = require("./target-resolution-diagnostic");
|
|
163
|
+
const trusted_click_1 = require("./trusted-click");
|
|
164
|
+
const upload_affordance_expr_1 = require("./upload-affordance-expr");
|
|
134
165
|
const watchdog_1 = require("./watchdog");
|
|
135
166
|
const recon_shared_1 = require("../scripts/recon-shared");
|
|
136
167
|
const client_1 = require("../testmail/client");
|
|
@@ -365,7 +396,9 @@ function wireSignalCapture(page, params) {
|
|
|
365
396
|
recentCaptureMeta.splice(0, recentCaptureMeta.length - RECENT_CAPTURES_WINDOW);
|
|
366
397
|
}
|
|
367
398
|
}
|
|
368
|
-
|
|
399
|
+
// Query strings carry form GETs and analytics beacons (often form-field
|
|
400
|
+
// values); origin + path is enough to follow the request sequence.
|
|
401
|
+
logger.info(`captured [${capture.status}] ${capture.method} ${req.url.split("?")[0]} → ${filename}`);
|
|
369
402
|
};
|
|
370
403
|
session.on("Network.requestWillBeSent", onRequest);
|
|
371
404
|
session.on("Network.requestWillBeSentExtraInfo", onRequestExtraInfo);
|
|
@@ -615,11 +648,13 @@ const PROMPT_VALUE_SELECTORS = [
|
|
|
615
648
|
].join(",");
|
|
616
649
|
/**
|
|
617
650
|
* The widget-kit's empty-state value text ("0 items selected") — a filled
|
|
618
|
-
* widget's value node reads the chosen option instead
|
|
651
|
+
* widget's value node reads the chosen option instead — as is a generic
|
|
652
|
+
* placeholder prompt ("Select One", "Choose a country") a trigger button shows
|
|
653
|
+
* until a value is picked. Matched
|
|
619
654
|
* case-insensitively as a whole phrase so a non-empty selection ("1 item
|
|
620
655
|
* selected, …") is correctly read as filled.
|
|
621
656
|
*/
|
|
622
|
-
const PROMPT_EMPTY_VALUE_RX = /^\s*0\s+items?\s+selected\s*$/i;
|
|
657
|
+
const PROMPT_EMPTY_VALUE_RX = /^\s*(?:0\s+items?\s+selected|(?:please\s+)?(?:select|choose|pick)(?:\s+[^\s]+){0,2}\s*(?:\.{3}|…)?)\s*$/i;
|
|
623
658
|
/**
|
|
624
659
|
* {@link PROMPT_EMPTY_VALUE_RX}'s source and flags as plain strings, passed into
|
|
625
660
|
* the browser-evaluated expressions to reconstruct the RegExp there. Kept as
|
|
@@ -692,12 +727,73 @@ const PROMPT_CURRENT_TEXT_EXPR = `((w, valueSel, emptyRx) => {
|
|
|
692
727
|
const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
|
|
693
728
|
if (adid) { const opt = document.getElementById(adid); if (opt && optionLabel(opt)) return norm(optionLabel(opt)); }
|
|
694
729
|
if (w.tagName === "INPUT" && (w.value || "").trim()) return norm(w.value);
|
|
695
|
-
if (w.tagName === "BUTTON" && buttonValue(w).trim()) return norm(buttonValue(w));
|
|
730
|
+
if (w.tagName === "BUTTON" && buttonValue(w).trim() && !emptyRx.test(buttonValue(w).trim())) return norm(buttonValue(w));
|
|
696
731
|
const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
|
|
697
732
|
const raw = lbl ? (lbl.textContent || "") : "";
|
|
698
733
|
if (raw.trim() && !emptyRx.test(raw.trim())) return norm(raw);
|
|
699
734
|
return "";
|
|
700
735
|
})`;
|
|
736
|
+
/**
|
|
737
|
+
* Browser-side expression reading what a prompt-selector widget ANNOUNCES it
|
|
738
|
+
* committed, lowercased and whitespace-collapsed, or `""` when nothing is
|
|
739
|
+
* readable. The announcement is ground truth over any inferred state: first the
|
|
740
|
+
* "N items selected, X" `aria-describedby` node on the container or one of its
|
|
741
|
+
* descendants (the trigger union may resolve to the OUTER container, so the
|
|
742
|
+
* container's own attribute alone misses it), else the nearest
|
|
743
|
+
* `selectedItemList` — while a popup is open the description can read only
|
|
744
|
+
* "Expanded", hence the fallback. Shared by the pick read-back and the
|
|
745
|
+
* act-success guard so the two never disagree on what the widget committed.
|
|
746
|
+
*/
|
|
747
|
+
const PROMPT_ANNOUNCED_VALUE_EXPR = `((w) => {
|
|
748
|
+
const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
|
|
749
|
+
const announceRxs = [
|
|
750
|
+
/\\d+\\s+items?\\s+selected\\s*,?\\s*(.*)$/,
|
|
751
|
+
/^(?:currently\\s+)?selected(?:\\s+(?:item|option|value))?\\s*[:,-]\\s*(.+)$/,
|
|
752
|
+
/^(.+?)\\s+(?:is\\s+)?selected\\.?$/,
|
|
753
|
+
];
|
|
754
|
+
for (const el of [w, ...Array.from(w.querySelectorAll("[aria-describedby]"))]) {
|
|
755
|
+
for (const ref of (el.getAttribute("aria-describedby") || "").split(/\\s+/)) {
|
|
756
|
+
const refEl = ref ? document.getElementById(ref) : null;
|
|
757
|
+
const said = refEl ? norm(refEl.textContent) : "";
|
|
758
|
+
for (const rx of announceRxs) {
|
|
759
|
+
const m = rx.exec(said);
|
|
760
|
+
if (m && m[1] && !/^\\d+\\s+items?$/.test(m[1])) return m[1];
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
let anode = w;
|
|
765
|
+
for (let d = 0; d < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && anode; d++) {
|
|
766
|
+
const list = anode.querySelector ? anode.querySelector('[data-automation-id="selectedItemList"]') : null;
|
|
767
|
+
if (list && norm(list.textContent)) return norm(list.textContent);
|
|
768
|
+
anode = anode.parentElement;
|
|
769
|
+
}
|
|
770
|
+
return "";
|
|
771
|
+
})`;
|
|
772
|
+
/**
|
|
773
|
+
* Browser-side expression resolving ONE trigger-union hit to its widget
|
|
774
|
+
* container. A single widget often matches {@link PROMPT_TRIGGER_SELECTORS} more
|
|
775
|
+
* than once (e.g. an outer container AND its inner filter input both carry
|
|
776
|
+
* `data-uxi-widget-type`), so prefer a real interactive ancestor, else the
|
|
777
|
+
* OUTERMOST widget-kit container on the ancestor chain (so the inner input and
|
|
778
|
+
* its container collapse to the same node), else the element itself. Shared by
|
|
779
|
+
* widget enumeration and the option-scope ownership filter so both agree on what
|
|
780
|
+
* "a widget" is — including widgets that are already committed and so never
|
|
781
|
+
* stamped with the enumeration mark.
|
|
782
|
+
*/
|
|
783
|
+
const PROMPT_WIDGET_CONTAINER_EXPR = `((el) => {
|
|
784
|
+
const interactive = el.closest("button,[role='button'],[role='combobox']");
|
|
785
|
+
if (interactive) return interactive;
|
|
786
|
+
const kitSel = "[data-uxi-widget-type='selectinput'],[data-uxi-widget-type='multiselect'],[data-automation-id='multiSelectContainer']";
|
|
787
|
+
let container = el;
|
|
788
|
+
let node = el.closest(kitSel);
|
|
789
|
+
while (node) {
|
|
790
|
+
container = node;
|
|
791
|
+
const up = node.parentElement && node.parentElement.closest(kitSel);
|
|
792
|
+
if (!up || up === node) break;
|
|
793
|
+
node = up;
|
|
794
|
+
}
|
|
795
|
+
return container;
|
|
796
|
+
})`;
|
|
701
797
|
/**
|
|
702
798
|
* Browser-side expression resolving the DOM root within which a chosen widget's
|
|
703
799
|
* popup options / filter input live. Popup placement is vendor-split: the ARIA
|
|
@@ -834,6 +930,55 @@ const ATTEMPT_BACKOFF_MS = 1_000;
|
|
|
834
930
|
* actionable.
|
|
835
931
|
*/
|
|
836
932
|
const N16_TRUSTED_CLICK_TIMEOUT_MS = 3_000;
|
|
933
|
+
/**
|
|
934
|
+
* Shared pre-click centre + hit-test for a Stagehand-resolved selector, so
|
|
935
|
+
* observe-act and the natural-language act attempts share one routine.
|
|
936
|
+
* Returns a failure message when the control stays covered, null otherwise.
|
|
937
|
+
*/
|
|
938
|
+
async function centreObservedClickTarget(target, selector, onUnavailable, deferWhenMissing = false) {
|
|
939
|
+
try {
|
|
940
|
+
await (0, trusted_click_1.centreSelectorForClick)(target, selector);
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
catch (err) {
|
|
944
|
+
// A TypeError means the page cannot answer the hit-test probe (no
|
|
945
|
+
// evaluate seam), not that the control is covered; defer to the act.
|
|
946
|
+
if (err instanceof TypeError) {
|
|
947
|
+
onUnavailable(`centre hit-test unavailable: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
// Stagehand's own resolution of a natural-language act can differ from the
|
|
951
|
+
// observe result, so an unresolvable element there is the act's to judge.
|
|
952
|
+
if (deferWhenMissing && (0, errors_1.toErrorMessage)(err).includes("is not in the document")) {
|
|
953
|
+
onUnavailable(`centre target unresolved: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
954
|
+
return null;
|
|
955
|
+
}
|
|
956
|
+
return `observed click target not clickable: ${(0, errors_1.toErrorMessage)(err)}`;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Builds an in-page expression that finds the marker-stamped element, piercing
|
|
961
|
+
* open shadow roots (plain `querySelector` cannot), and centre + hit-tests it.
|
|
962
|
+
*/
|
|
963
|
+
function buildDeepMarkerCentreExpr(markerSelector) {
|
|
964
|
+
return `(() => {
|
|
965
|
+
const sel = ${JSON.stringify(markerSelector)};
|
|
966
|
+
const find = (root) => {
|
|
967
|
+
const hit = root.querySelector(sel);
|
|
968
|
+
if (hit) return hit;
|
|
969
|
+
for (const n of root.querySelectorAll("*")) {
|
|
970
|
+
if (n.shadowRoot) {
|
|
971
|
+
const r = find(n.shadowRoot);
|
|
972
|
+
if (r) return r;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return null;
|
|
976
|
+
};
|
|
977
|
+
const el = find(document);
|
|
978
|
+
if (!el) return { state: "missing" };
|
|
979
|
+
return (${trusted_click_1.CENTRE_AND_HIT_TEST_FN}).call(el);
|
|
980
|
+
})()`;
|
|
981
|
+
}
|
|
837
982
|
/**
|
|
838
983
|
* Per-step watchdog passed as Stagehand's native `timeout` on every
|
|
839
984
|
* `act()`/`observe()`. Stagehand's `ensureTimeRemaining()` checks the
|
|
@@ -952,6 +1097,10 @@ function prepareFailureDumpBody(raw) {
|
|
|
952
1097
|
* entry, so the weak page-wide signature and the strict per-element gate
|
|
953
1098
|
* agree on exactly what counts as a class-token selection marker.
|
|
954
1099
|
*/
|
|
1100
|
+
/** Shared by both checkbox credit sites so they agree on what "aria-managed" reads. */
|
|
1101
|
+
function ariaCheckedReadExpr(elVar) {
|
|
1102
|
+
return `${elVar}.getAttribute("aria-checked")`;
|
|
1103
|
+
}
|
|
955
1104
|
const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { html: 0, text: "", values: "", state: "" }; const t = b.innerText || ""; const controls = Array.from(b.querySelectorAll("input, textarea, select")).filter((el) => el.offsetParent !== null); const values = controls.map((el) => { if (el.type === "checkbox" || el.type === "radio") return el.checked ? "1" : "0"; return el.value || ""; }).join("|").slice(0, 2000); const ariaSel = "[aria-pressed],[aria-checked],[aria-selected],[data-state],[data-selected],[data-checked],[role=option],[role=switch],[role=checkbox],[role=menuitemcheckbox]"; const classSel = ${JSON.stringify(browser_click_expr_1.SELECTION_MARKER_CLASS_SELECTOR_SRC)}; const classRx = ${browser_click_expr_1.SELECTION_MARKER_CLASS_TOKEN_REGEX_SRC}; const skip = (el) => el.closest("[role=dialog],[role=tooltip],[aria-live]") !== null; const seen = new Set(); for (const el of b.querySelectorAll(ariaSel + "," + classSel)) { if (el.offsetParent !== null && !skip(el)) seen.add(el); } let idx = 0; const parts = []; for (const el of seen) { const ap = el.getAttribute("aria-pressed") || ""; const ac = el.getAttribute("aria-checked") || ""; const as = el.getAttribute("aria-selected") || ""; const dsRaw = el.getAttribute("data-state") || ""; const ds = (dsRaw === "open" || dsRaw === "closed") ? "" : dsRaw; const dsel = el.hasAttribute("data-selected") ? "1" : ""; const dchk = el.hasAttribute("data-checked") ? "1" : ""; const clsHit = classRx.test(el.getAttribute("class") || "") ? "1" : "0"; const i = idx++; if (!ap && !ac && !as && !ds && !dsel && !dchk && clsHit === "0") continue; parts.push(i + ":" + ap + "," + ac + "," + as + "," + ds + "," + dsel + "," + dchk + "," + clsHit); } const joined = parts.join("|"); let h = 2166136261; for (let k = 0; k < joined.length; k++) { h ^= joined.charCodeAt(k); h = Math.imul(h, 16777619); } const state = (h >>> 0).toString(36) + ":" + parts.length; return { html: (b.outerHTML || "").length, text: t.length + ":" + t.slice(0, 200), values, state }; })()`;
|
|
956
1105
|
/** Caps mirroring {@link DOM_SNAPSHOT_EXPR}'s truncation so a pathological page can't bloat the failure bundle. */
|
|
957
1106
|
const FIELD_VALUES_AT_FAILURE_MAX_CONTROLS = 200;
|
|
@@ -1240,6 +1389,25 @@ const ADVANCE_STEP_PHRASES = [
|
|
|
1240
1389
|
"proceed to the next",
|
|
1241
1390
|
"advance to the next",
|
|
1242
1391
|
];
|
|
1392
|
+
/** Advance-click step wording ("Next"/"Continue"/"Save and continue") for the n+16 error-summary veto. */
|
|
1393
|
+
exports.ADVANCE_CLICK_RX = /\b(next|continue|proceed|advance)\b/i;
|
|
1394
|
+
/**
|
|
1395
|
+
* Page-level error summary check, evaluated in the page after an advance
|
|
1396
|
+
* click: an error-summary container, a visible `role=alert` that mentions an
|
|
1397
|
+
* error, or a heading/button whose WHOLE text is "Errors Found" / "N errors
|
|
1398
|
+
* found" (prose such as "no errors found" is not a summary). Network and text
|
|
1399
|
+
* movement on a page showing its own validation summary is the form rejecting
|
|
1400
|
+
* the advance, not progress.
|
|
1401
|
+
*/
|
|
1402
|
+
exports.ERROR_SUMMARY_EXPR = `(() => {
|
|
1403
|
+
const vis = (e) => !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length);
|
|
1404
|
+
const text = (e) => (e.textContent || "").trim();
|
|
1405
|
+
const summarySel = '[class*="error-summary" i],[class*="errorsummary" i],[class*="validation-summary" i],[id*="error-summary" i]';
|
|
1406
|
+
if (Array.from(document.querySelectorAll(summarySel)).some((e) => vis(e) && text(e))) return true;
|
|
1407
|
+
if (Array.from(document.querySelectorAll('[role="alert"]')).some((e) => vis(e) && /\\berrors?\\b/i.test(text(e)))) return true;
|
|
1408
|
+
const titles = 'h1,h2,h3,h4,h5,h6,[role="heading"],button,[role="button"]';
|
|
1409
|
+
return Array.from(document.querySelectorAll(titles)).some((e) => vis(e) && /^(?:\\d+\\s+)?errors?\\s+found$/i.test(text(e)));
|
|
1410
|
+
})()`;
|
|
1243
1411
|
/**
|
|
1244
1412
|
* Is this flow step an advance/"Next" click (move to the next wizard page),
|
|
1245
1413
|
* rather than a field-answer? Used to veto a DOM-only "success" (a field toggle)
|
|
@@ -1285,6 +1453,83 @@ function isSubmitIntentStep(instruction) {
|
|
|
1285
1453
|
const haystack = instruction.toLowerCase();
|
|
1286
1454
|
return SUBMIT_INTENT_STEP_PHRASES.some((p) => haystack.includes(p));
|
|
1287
1455
|
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Targets whose negation declares the step is not a submit: a checkout/payment/
|
|
1458
|
+
* order/submit action, a purchase-action control named directly ("Book Now",
|
|
1459
|
+
* "Reserve", "Add to Cart"), or a "proceeds/leads/advances to" phrase naming one.
|
|
1460
|
+
*/
|
|
1461
|
+
const SUBMIT_TARGET_SRC = "(?:(?:proceed|advance|move|go|lead|take|navigate|continue)(?:s|ing|es)?\\s+(?:you\\s+)?(?:on\\s+)?(?:to|toward|towards|into|with)\\s+(?:the\\s+|a\\s+|your\\s+)?(?:\\w+\\s+)?(?:checkout|check ?out|payments?|purchase|order|booking|reservation)|(?:place|complete|finali[sz]e|confirm)(?:s|ing)?\\s+(?:the\\s+|your\\s+|an?\\s+)?(?:order|purchase|booking|reservation|payment|checkout)|(?:advance|move|go|lead|take|navigate|continue)(?:s|ing|es)?\\s+(?:you\\s+)?(?:on\\s+)?(?:to|toward|towards|into|with|past|through)\\s+(?:the\\s+|a\\s+|your\\s+)?(?:next|following|final|last)\\s+(?:step|page|screen|stage)|(?:advance|advances|advancing)\\s+(?:the\\s+|your\\s+)?(?:booking|order|reservation|purchase)|check ?out|book(?:s|ing)? now|reserv(?:e|es|ing)|add(?:s|ing)? to (?:the |your )?(?:cart|basket|bag)|pay(?:ment|ing)?|purchas(?:e|es|ing)|buy(?:s|ing)?|charg(?:e|es|ing)|submit(?:s|ting)?)";
|
|
1462
|
+
/**
|
|
1463
|
+
* A negation followed, in the same clause, by a submit target ("Do NOT click
|
|
1464
|
+
* ... any control that proceeds to checkout"), or an explicit "this is not a
|
|
1465
|
+
* submit click" / "without submitting" declaration.
|
|
1466
|
+
*/
|
|
1467
|
+
const NEGATED_SUBMIT_CLAUSE = new RegExp(`\\b(?:do not|don't|dont|never|must not|should not|shouldn't|does not|doesn't|avoid|refrain from)\\b[^.;\\n]*?\\b${SUBMIT_TARGET_SRC}\\b[^.;\\n]*|\\b(?:(?:this|that|it)\\s+(?:is|are)\\s+not|is not|isn't|not)\\s+(?:a|an|the)?\\s*(?:final\\s+|actual\\s+)?(?:submit|submission|checkout|payment|purchase)\\b[^.;\\n]*|\\bwithout\\s+(?:${SUBMIT_TARGET_SRC})\\b[^.;\\n]*`, "gi");
|
|
1468
|
+
/** A positive submit imperative outside any negated clause ("Submit the form.", "Place the order."). */
|
|
1469
|
+
const LEADING_SUBMIT_IMPERATIVE = /(?:^|[.;]\s*)(?:then\s+)?(?:click\s+(?:on\s+)?(?:the\s+)?['"]?)?(?:submit\b|place\s+(?:the\s+)?order\b|complete\s+(?:the\s+)?(?:order|purchase|application|checkout)\b|finish\s+(?:and\s+)?(?:order|purchase|application|checkout)\b|confirm\s+(?:and\s+)?(?:order|purchase|application)\b|save\s+and\s+continue\b|finali[sz]e\b)/i;
|
|
1470
|
+
/**
|
|
1471
|
+
* The instruction with every negated submit clause blanked, so callers that
|
|
1472
|
+
* match submit wording on the step text (flow-load seeding) read only what the
|
|
1473
|
+
* step positively asks for and never a guardrail ("Do NOT ... proceeds to
|
|
1474
|
+
* checkout"). Pure.
|
|
1475
|
+
*/
|
|
1476
|
+
function stripNegatedSubmitClauses(instruction) {
|
|
1477
|
+
return instruction.replace(NEGATED_SUBMIT_CLAUSE, " ");
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Reads the step's own instruction for a declared submit intent, so an
|
|
1481
|
+
* explicit "this is NOT a submit/checkout click" can veto inferred submit
|
|
1482
|
+
* shape. A positive submit phrase outside the negated clause keeps "submit"
|
|
1483
|
+
* so a real submit step that mentions a guardrail is not demoted. Pure.
|
|
1484
|
+
*/
|
|
1485
|
+
function classifyStepSubmitIntent(instruction) {
|
|
1486
|
+
if (!instruction)
|
|
1487
|
+
return "unspecified";
|
|
1488
|
+
const remainder = stripNegatedSubmitClauses(instruction);
|
|
1489
|
+
if (isSubmitIntentStep(remainder) || LEADING_SUBMIT_IMPERATIVE.test(remainder))
|
|
1490
|
+
return "submit";
|
|
1491
|
+
if (remainder !== instruction)
|
|
1492
|
+
return "non-submit";
|
|
1493
|
+
return isSubmitIntentStep(instruction) ? "submit" : "unspecified";
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Single derivation of "this step's own instruction declares it non-submit",
|
|
1497
|
+
* so every submit-judge gate honours the veto identically. An explicit
|
|
1498
|
+
* `submitStep` flag always overrides the declaration.
|
|
1499
|
+
*/
|
|
1500
|
+
function isStepDeclaredNonSubmit(params) {
|
|
1501
|
+
return !params.submitStep && classifyStepSubmitIntent(params.step) === "non-submit";
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Single seam answering "is this step submit-shaped?": an explicit `submitStep`
|
|
1505
|
+
* flag is authoritative, a declared non-submit instruction vetoes every
|
|
1506
|
+
* inferred path, otherwise final-step inference or submit-intent wording applies.
|
|
1507
|
+
*/
|
|
1508
|
+
function isStepSubmitShaped(params) {
|
|
1509
|
+
const { step, submitStep, isFinalStep, flowHasSubmitSemantics: flowSubmit } = params;
|
|
1510
|
+
if (submitStep)
|
|
1511
|
+
return true;
|
|
1512
|
+
if (isStepDeclaredNonSubmit({ step, submitStep }))
|
|
1513
|
+
return false;
|
|
1514
|
+
return (isFinalStep && flowSubmit) || classifyStepSubmitIntent(step) === "submit";
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Whether a click may need the long idle settle wait: a submit-shaped step or
|
|
1518
|
+
* one naming an advance word, since both can start their round-trip late.
|
|
1519
|
+
*/
|
|
1520
|
+
function isStepSettleWaitShaped(params) {
|
|
1521
|
+
return isStepSubmitShaped(params) || isAdvanceStep(params.step);
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* Submit-shaped but NOT the flow's final step: a mid-flow advancing submit
|
|
1525
|
+
* (e.g. account creation) that can be credited on a demonstrable page advance
|
|
1526
|
+
* instead of needing the final submit's network/URL proof.
|
|
1527
|
+
*/
|
|
1528
|
+
function isMidFlowSubmitShapedStep(params) {
|
|
1529
|
+
if (params.isFinalStep)
|
|
1530
|
+
return false;
|
|
1531
|
+
return isStepSubmitShaped(params) || params.elementSubmitShaped === true;
|
|
1532
|
+
}
|
|
1288
1533
|
/**
|
|
1289
1534
|
* Is this flow step's intent to check a checkbox or select a radio option?
|
|
1290
1535
|
* Used to veto a weak page-wide DOM signal (htmlDelta/textChanged/formValueChanged)
|
|
@@ -1339,9 +1584,16 @@ function shouldCaptureSelectionState(params) {
|
|
|
1339
1584
|
function flowHasSubmitSemantics(params) {
|
|
1340
1585
|
const { steps, submitEndpointPattern, requireSubmitEndpointMatch } = params;
|
|
1341
1586
|
const hasExplicitSubmitStep = steps.some((s) => s.submitStep);
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1587
|
+
if (hasExplicitSubmitStep)
|
|
1588
|
+
return steps[steps.length - 1]?.submitStep ?? false;
|
|
1589
|
+
if (submitEndpointPattern === null && !requireSubmitEndpointMatch)
|
|
1590
|
+
return false;
|
|
1591
|
+
// An earlier step whose own wording declares submit already owns the role;
|
|
1592
|
+
// the fallback is only for a self-heal-appended final step.
|
|
1593
|
+
const earlierStepOwnsSubmit = steps
|
|
1594
|
+
.slice(0, -1)
|
|
1595
|
+
.some((s) => classifyStepSubmitIntent(s.instruction) === "submit");
|
|
1596
|
+
return !earlierStepOwnsSubmit;
|
|
1345
1597
|
}
|
|
1346
1598
|
/**
|
|
1347
1599
|
* Whether a flow should be WARNed that its DOM-only advance guard is disarmed.
|
|
@@ -1560,6 +1812,20 @@ function isDomOnlyAdvanceVerified(params) {
|
|
|
1560
1812
|
// non-advancing POST) does not verify it.
|
|
1561
1813
|
return networkIsRealAdvance || urlChanged;
|
|
1562
1814
|
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Decides whether a non-final submit-shaped step is credited when the outcome
|
|
1817
|
+
* judge returned no verdict. An unavailable judge is missing evidence, not a
|
|
1818
|
+
* rejection, so strong structural click signals (a real network advance or a
|
|
1819
|
+
* demonstrable page advance) credit the step instead of failing it and
|
|
1820
|
+
* re-clicking at the same position. Final steps and weak-signal cases return
|
|
1821
|
+
* false and keep the DOM-state fallback / failure path.
|
|
1822
|
+
*/
|
|
1823
|
+
function isNullVerdictAdvanceCredited(params) {
|
|
1824
|
+
if (!params.midFlowSubmit || !params.judgeUnavailable || params.invalidMarkerCount > 0) {
|
|
1825
|
+
return false;
|
|
1826
|
+
}
|
|
1827
|
+
return params.networkAdvanced || params.pageAdvanced;
|
|
1828
|
+
}
|
|
1563
1829
|
/**
|
|
1564
1830
|
* Client-side view-swap verification gate. Credits a click that produces
|
|
1565
1831
|
* substantial DOM growth with zero network as a verified step outcome when
|
|
@@ -1625,12 +1891,18 @@ function isDomOnlyAdvanceVerified(params) {
|
|
|
1625
1891
|
* page), which also nets a large negative byte delta with zero network/url
|
|
1626
1892
|
* and no text-signature match. A reset replaces the clicked control itself
|
|
1627
1893
|
* along with everything else, whereas a genuine same-page toggle leaves the
|
|
1628
|
-
* clicked control in place.
|
|
1894
|
+
* clicked control in place. A click-driven view swap (a login-to-registration
|
|
1895
|
+
* form swap, a chooser-to-form swap) also removes its own trigger because the
|
|
1896
|
+
* trigger belonged to the replaced view, but it GROWS the DOM, whereas a reset
|
|
1897
|
+
* reverts to an earlier, smaller screen. The veto therefore keys on absence
|
|
1898
|
+
* combined with a DOM that did not grow (`bytesDelta <= 0`), not on absence
|
|
1899
|
+
* alone. `clickedElementStillPresent` — whether the
|
|
1629
1900
|
* resolved click target can still be found in the post-click DOM, from a
|
|
1630
1901
|
* best-effort live probe — is `false` only on POSITIVE proof of absence;
|
|
1631
1902
|
* when the probe can't run (non-xpath selector, evaluate failure) it stays
|
|
1632
1903
|
* at its default `true` so it never manufactures a veto out of missing
|
|
1633
|
-
* information. Only a confirmed `false`
|
|
1904
|
+
* information. Only a confirmed `false` paired with a non-growing DOM blocks
|
|
1905
|
+
* the credit.
|
|
1634
1906
|
*
|
|
1635
1907
|
* **Destination-plausibility veto:** none of the signals above look at
|
|
1636
1908
|
* WHERE the click landed. A non-submit click that bounces to a page
|
|
@@ -1642,15 +1914,43 @@ function isDomOnlyAdvanceVerified(params) {
|
|
|
1642
1914
|
* no-veto behavior — matching the sibling `destinationPlausible` param on
|
|
1643
1915
|
* `classifyPhantomClick` — so existing/future callers that don't thread it
|
|
1644
1916
|
* are unaffected.
|
|
1917
|
+
*
|
|
1918
|
+
* **Mid-flow submit:** `midFlowSubmit` (see {@link isMidFlowSubmitShapedStep})
|
|
1919
|
+
* lets a submit-shaped, non-final step be credited when `pageAdvanced` (the
|
|
1920
|
+
* shared {@link hasPageAdvanced} judgement of the visible text; defaults to
|
|
1921
|
+
* `textChanged` when omitted) holds — byte delta is not required, since a
|
|
1922
|
+
* wizard swap can keep the HTML size — with the same invalid-marker and
|
|
1923
|
+
* destination vetoes. The clicked control being gone is NOT a veto here: a
|
|
1924
|
+
* wizard advance normally replaces the button along with the page. The
|
|
1925
|
+
* credit also holds when Stagehand reported a successful act with no
|
|
1926
|
+
* resolved action (`actSucceeded`) or a non-click, non-state-class method.
|
|
1927
|
+
* Omitted,
|
|
1928
|
+
* the submit-shaped rejection is unchanged.
|
|
1645
1929
|
*/
|
|
1646
1930
|
function isClickViewSwapVerified(params) {
|
|
1647
1931
|
const VIEW_SWAP_MIN_BYTES = config_1.config.scraper.viewSwapMinBytesThreshold;
|
|
1648
1932
|
const VIEW_SWAP_REVEAL_MIN_BYTES = config_1.config.scraper.viewSwapRevealMinBytesThreshold;
|
|
1649
|
-
const { resolvedAction, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, clickedElementStillPresent = true, resolvedElementIsSubmitShaped = false, destinationPlausible, } = params;
|
|
1933
|
+
const { resolvedAction, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, clickedElementStillPresent = true, resolvedElementIsSubmitShaped = false, destinationPlausible, midFlowSubmit = false, pageAdvanced = textChanged, actSucceeded = false, } = params;
|
|
1934
|
+
// The page advancing is the evidence for a mid-flow submit, so it does not
|
|
1935
|
+
// depend on how Stagehand reported the gesture: a successful act that
|
|
1936
|
+
// listed no actions, or a non-state-class method, still ran a click-class
|
|
1937
|
+
// action. Mirrors the settle gate's `mayTriggerRoundTrip`.
|
|
1938
|
+
const midFlowActRan = resolvedAction === null ? actSucceeded : !STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
|
|
1939
|
+
if (midFlowSubmit &&
|
|
1940
|
+
midFlowActRan &&
|
|
1941
|
+
destinationPlausible !== false &&
|
|
1942
|
+
!isAdvanceWithPattern &&
|
|
1943
|
+
invalidMarkerDelta <= 0 &&
|
|
1944
|
+
textChanged &&
|
|
1945
|
+
pageAdvanced) {
|
|
1946
|
+
return true;
|
|
1947
|
+
}
|
|
1650
1948
|
if (resolvedAction?.method !== "click")
|
|
1651
1949
|
return false;
|
|
1652
1950
|
if (destinationPlausible === false)
|
|
1653
1951
|
return false;
|
|
1952
|
+
if (midFlowSubmit && (submitStep || resolvedElementIsSubmitShaped))
|
|
1953
|
+
return false;
|
|
1654
1954
|
// Only the step's own explicit submitStep flag identifies the step that
|
|
1655
1955
|
// actually needs network/URL verification — mirrors the submit-judge
|
|
1656
1956
|
// gate's hasSubmitTransitionSignal discipline (see 5763ac2). Inferring
|
|
@@ -1673,7 +1973,7 @@ function isClickViewSwapVerified(params) {
|
|
|
1673
1973
|
return false;
|
|
1674
1974
|
if (invalidMarkerDelta > 0)
|
|
1675
1975
|
return false;
|
|
1676
|
-
if (!clickedElementStillPresent)
|
|
1976
|
+
if (!clickedElementStillPresent && bytesDelta <= 0)
|
|
1677
1977
|
return false;
|
|
1678
1978
|
if (Math.abs(bytesDelta) >= VIEW_SWAP_MIN_BYTES)
|
|
1679
1979
|
return true;
|
|
@@ -2066,7 +2366,7 @@ For yes/no questions, choose the candidate-favorable answer ('No' for adverse qu
|
|
|
2066
2366
|
|
|
2067
2367
|
`
|
|
2068
2368
|
: "";
|
|
2069
|
-
const
|
|
2369
|
+
const rawPrompt = `You are helping a browser automation agent recover from a failed step in a recon flow.
|
|
2070
2370
|
|
|
2071
2371
|
${redirectBlock}ORIGINAL INSTRUCTION:
|
|
2072
2372
|
${originalStep}
|
|
@@ -2102,6 +2402,10 @@ PAGE TRANSITION + VALIDATOR TELEMETRY (parsed from Google Analytics Measurement
|
|
|
2102
2402
|
${gaEventEvidence || "(none)"}
|
|
2103
2403
|
|
|
2104
2404
|
Rewrite the instruction so a Stagehand act() call can resolve it unambiguously to a different element than the ones already tried. Keep it short — one sentence, natural language, no quotes around it. If the invalid-fields section above names a field AND the interactive-targets section below lists clickable options inside that same container, your rewrite picks one of those options (e.g. for a yes/no question rendered as two radio labels, choose the candidate-favorable answer — 'No' for "do you have a non-compete", 'Yes' for "are you authorized to work", 'Prefer not to say' for demographic disclosures) rather than retrying the original click. If the unfocused observe section shows an open modal/dialog with a Save or Close action, your rewrite invokes that action so the underlying form can clear its blocking state. Set outcome to "impossible" with a one-line reason only when the original instruction's element does not exist on the current page and no redirect target is available.`;
|
|
2405
|
+
// Request values reach the rephrase LLM only as %tokens%; the rewrite is
|
|
2406
|
+
// resolved back before the cascade acts on it (which re-tokenizes it for
|
|
2407
|
+
// Stagehand). A rewrite citing a token we never issued is unusable.
|
|
2408
|
+
const { text: prompt, variables } = (0, pii_redaction_1.tokenizeSensitiveValues)(rawPrompt);
|
|
2105
2409
|
const model = client.modelId;
|
|
2106
2410
|
const t0 = performance.now();
|
|
2107
2411
|
try {
|
|
@@ -2131,7 +2435,9 @@ Rewrite the instruction so a Stagehand act() call can resolve it unambiguously t
|
|
|
2131
2435
|
});
|
|
2132
2436
|
if (parsed.outcome === "impossible")
|
|
2133
2437
|
return null;
|
|
2134
|
-
|
|
2438
|
+
if ((0, pii_redaction_1.unresolvedTokens)(parsed.instruction, variables).length > 0)
|
|
2439
|
+
return null;
|
|
2440
|
+
return (0, pii_redaction_1.detokenize)(parsed.instruction, variables);
|
|
2135
2441
|
}
|
|
2136
2442
|
catch (err) {
|
|
2137
2443
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -2539,6 +2845,107 @@ async function probeLeafInvalidContainers(target) {
|
|
|
2539
2845
|
return [];
|
|
2540
2846
|
}
|
|
2541
2847
|
}
|
|
2848
|
+
/**
|
|
2849
|
+
* Reads live body text for the submit judge, because a SPA's page title often
|
|
2850
|
+
* stays stale after a mid-flow transition. Failure yields "" so the judge
|
|
2851
|
+
* simply reasons without it.
|
|
2852
|
+
*/
|
|
2853
|
+
async function probeVisibleTextForJudge(target) {
|
|
2854
|
+
return (await probeRawVisibleText(target)).slice(0, 3000);
|
|
2855
|
+
}
|
|
2856
|
+
/**
|
|
2857
|
+
* Untruncated body text, so a settle signature built from it uses the same
|
|
2858
|
+
* unit (raw `innerText.length`) as {@link DOM_SNAPSHOT_EXPR}'s pre-click one.
|
|
2859
|
+
*/
|
|
2860
|
+
async function probeRawVisibleText(target) {
|
|
2861
|
+
try {
|
|
2862
|
+
const text = await (0, watchdog_1.withWatchdog)(() => target.evaluate(`(() => (document.body ? document.body.innerText || "" : ""))()`), {
|
|
2863
|
+
timeoutMs: config_1.config.scraper.frameEvaluateTimeoutMs,
|
|
2864
|
+
label: "flow-runner: submit-verification visible text probe",
|
|
2865
|
+
});
|
|
2866
|
+
return typeof text === "string" ? text : "";
|
|
2867
|
+
}
|
|
2868
|
+
catch {
|
|
2869
|
+
return "";
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
/**
|
|
2873
|
+
* Shared step-context slice of the submit judge's input, so every judge call
|
|
2874
|
+
* site tells the judge what the step was meant to do, whether it is the final
|
|
2875
|
+
* submit, and what the page shows now.
|
|
2876
|
+
*/
|
|
2877
|
+
async function buildSubmitJudgeStepContext(params) {
|
|
2878
|
+
return {
|
|
2879
|
+
stepInstruction: params.step,
|
|
2880
|
+
isFinalStep: params.isFinalStep,
|
|
2881
|
+
visibleText: await probeVisibleTextForJudge(params.target),
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
const STEP_REJUDGE_DELAY_MS = 5000;
|
|
2885
|
+
const STEP_REJUDGE_COMPARE_CHARS = 1200;
|
|
2886
|
+
/**
|
|
2887
|
+
* Single entry for both submit-verification judge sites. The final step keeps
|
|
2888
|
+
* the strict submit judge; a non-final step is judged on whether the page
|
|
2889
|
+
* advanced, against the settled post-click text, because the page is still
|
|
2890
|
+
* mid-transition when the click returns and a pre-transition read would make
|
|
2891
|
+
* a successful advance look like no change.
|
|
2892
|
+
*/
|
|
2893
|
+
async function judgeStepOutcome(params) {
|
|
2894
|
+
const { client, captureFn, step, isFinalStep, target } = params;
|
|
2895
|
+
if (isFinalStep) {
|
|
2896
|
+
return (0, verify_submit_1.verifySubmitWithLLM)({
|
|
2897
|
+
client,
|
|
2898
|
+
input: {
|
|
2899
|
+
...(await buildSubmitJudgeStepContext({ step, isFinalStep, target })),
|
|
2900
|
+
...params.submitInput,
|
|
2901
|
+
},
|
|
2902
|
+
captureFn,
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
await (0, click_settle_1.settleAfterClick)(params.page, params.signalCounter, params.preNetworkCount, isStepSettleWaitShaped({ step, submitStep: false, isFinalStep, flowHasSubmitSemantics: false }), async () => {
|
|
2906
|
+
const text = await probeRawVisibleText(target);
|
|
2907
|
+
return `${text.length}:${text.slice(0, 200)}`;
|
|
2908
|
+
}, params.preVisibleTextSignature);
|
|
2909
|
+
const colon = params.preVisibleTextSignature.indexOf(":");
|
|
2910
|
+
const beforeText = (colon === -1 ? params.preVisibleTextSignature : params.preVisibleTextSignature.slice(colon + 1))
|
|
2911
|
+
.replace(/\s+/g, " ")
|
|
2912
|
+
.trim();
|
|
2913
|
+
const judgeText = (afterText) => (0, verify_step_advanced_1.verifyStepAdvancedWithLLM)({
|
|
2914
|
+
client,
|
|
2915
|
+
input: {
|
|
2916
|
+
stepInstruction: step,
|
|
2917
|
+
beforeText,
|
|
2918
|
+
afterText,
|
|
2919
|
+
invalidMarkerCount: params.submitInput.invalidMarkerCount,
|
|
2920
|
+
},
|
|
2921
|
+
captureFn,
|
|
2922
|
+
});
|
|
2923
|
+
const rejudgeIfTextMoved = async (first, judgedText) => {
|
|
2924
|
+
if (first === null || first.advanced)
|
|
2925
|
+
return first;
|
|
2926
|
+
await params.page.waitForTimeout(STEP_REJUDGE_DELAY_MS);
|
|
2927
|
+
const laterText = await probeVisibleTextForJudge(target);
|
|
2928
|
+
if (laterText.slice(0, STEP_REJUDGE_COMPARE_CHARS) ===
|
|
2929
|
+
judgedText.slice(0, STEP_REJUDGE_COMPARE_CHARS)) {
|
|
2930
|
+
return first;
|
|
2931
|
+
}
|
|
2932
|
+
return (await judgeText(laterText)) ?? first;
|
|
2933
|
+
};
|
|
2934
|
+
const afterText = await probeVisibleTextForJudge(target);
|
|
2935
|
+
const firstVerdict = await judgeText(afterText);
|
|
2936
|
+
const verdict = await rejudgeIfTextMoved(firstVerdict, afterText);
|
|
2937
|
+
if (verdict === null)
|
|
2938
|
+
return null;
|
|
2939
|
+
return verdict.advanced
|
|
2940
|
+
? {
|
|
2941
|
+
verified: true,
|
|
2942
|
+
network_signal: null,
|
|
2943
|
+
dom_signal: null,
|
|
2944
|
+
url_signal: null,
|
|
2945
|
+
rationale: verdict.reason,
|
|
2946
|
+
}
|
|
2947
|
+
: { verified: false, reason: verdict.reason };
|
|
2948
|
+
}
|
|
2542
2949
|
/**
|
|
2543
2950
|
* Render a list of structured leaf-invalid records as the numbered evidence
|
|
2544
2951
|
* lines the prompt's FORM FIELDS section expects. Surfaces label + framework
|
|
@@ -2961,7 +3368,7 @@ async function dispatchEnterKey(target, xpath) {
|
|
|
2961
3368
|
function isCommittedDateReadback(readback) {
|
|
2962
3369
|
if (readback === null)
|
|
2963
3370
|
return false;
|
|
2964
|
-
if (readback.outcome === "rejected")
|
|
3371
|
+
if (readback.outcome === "rejected" || readback.outcome === "segment")
|
|
2965
3372
|
return false;
|
|
2966
3373
|
// A datepicker reformats what it accepts (e.g. "01/2020" → "2020-01"), so
|
|
2967
3374
|
// "differs" is a commit, not a rejection — require only a non-empty value
|
|
@@ -3056,8 +3463,7 @@ async function fillTextDatepickerInput(target, selector, value) {
|
|
|
3056
3463
|
// Strategy 2 — open-and-pick. Required for picker-only widgets (the reported
|
|
3057
3464
|
// month/year picker ignores typed input entirely).
|
|
3058
3465
|
try {
|
|
3059
|
-
|
|
3060
|
-
await locator.click();
|
|
3466
|
+
await (0, trusted_click_1.clickCentredTrusted)(target, selector, { label: "date input" });
|
|
3061
3467
|
}
|
|
3062
3468
|
catch {
|
|
3063
3469
|
return { filled: false, postValue: "", strategy: "none" };
|
|
@@ -3151,6 +3557,10 @@ async function verifyFillReadback(target, selector, expectedValue) {
|
|
|
3151
3557
|
if (!el) return null;
|
|
3152
3558
|
const tag = el.tagName ? el.tagName.toLowerCase() : "";
|
|
3153
3559
|
let actual = "";
|
|
3560
|
+
const siblings = el.parentElement ? el.parentElement.querySelectorAll("[role='spinbutton']").length : 0;
|
|
3561
|
+
if (el.getAttribute && el.getAttribute("role") === "spinbutton" && siblings > 1) {
|
|
3562
|
+
return { outcome: "segment", postValue: el.value || el.getAttribute("aria-valuenow") || "", tag };
|
|
3563
|
+
}
|
|
3154
3564
|
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
|
|
3155
3565
|
actual = el.value || "";
|
|
3156
3566
|
} else if (el.isContentEditable) {
|
|
@@ -3169,8 +3579,12 @@ async function verifyFillReadback(target, selector, expectedValue) {
|
|
|
3169
3579
|
if (raw === null || typeof raw !== "object")
|
|
3170
3580
|
return null;
|
|
3171
3581
|
const r = raw;
|
|
3172
|
-
if (r.outcome !== "matched" &&
|
|
3582
|
+
if (r.outcome !== "matched" &&
|
|
3583
|
+
r.outcome !== "rejected" &&
|
|
3584
|
+
r.outcome !== "differs" &&
|
|
3585
|
+
r.outcome !== "segment") {
|
|
3173
3586
|
return null;
|
|
3587
|
+
}
|
|
3174
3588
|
return {
|
|
3175
3589
|
outcome: r.outcome,
|
|
3176
3590
|
postValue: typeof r.postValue === "string" ? r.postValue : "",
|
|
@@ -3194,8 +3608,13 @@ async function verifyFillReadback(target, selector, expectedValue) {
|
|
|
3194
3608
|
* `tryPromptSelectorPrimitive`'s widget-container walk). Returns
|
|
3195
3609
|
* `isPromptWidget: false` for a resolved element that isn't part of this
|
|
3196
3610
|
* widget family at all, so a non-prompt click/fill is untouched.
|
|
3611
|
+
*
|
|
3612
|
+
* When the step names the option it wants (`requestedOption`), "committed"
|
|
3613
|
+
* means the widget's announced/readable value names THAT option: a widget that
|
|
3614
|
+
* holds a different option is a wrong commit, not a landed one. Without a
|
|
3615
|
+
* requested option any readable value counts, as before.
|
|
3197
3616
|
*/
|
|
3198
|
-
async function verifyPromptSelectorCommitted(target, selector) {
|
|
3617
|
+
async function verifyPromptSelectorCommitted(target, selector, requestedOption) {
|
|
3199
3618
|
const xpath = xpathBodyForEvaluate(selector);
|
|
3200
3619
|
if (xpath === null)
|
|
3201
3620
|
return null;
|
|
@@ -3204,13 +3623,17 @@ async function verifyPromptSelectorCommitted(target, selector) {
|
|
|
3204
3623
|
const valueSel = ${JSON.stringify(PROMPT_VALUE_SELECTORS)};
|
|
3205
3624
|
const emptyRx = new RegExp(${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)});
|
|
3206
3625
|
const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
|
|
3626
|
+
const announcedValue = ${PROMPT_ANNOUNCED_VALUE_EXPR};
|
|
3627
|
+
const squash = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
3628
|
+
const want = squash(${JSON.stringify(requestedOption ?? "")});
|
|
3207
3629
|
const xpath = ${JSON.stringify(xpath)};
|
|
3208
3630
|
const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
3209
3631
|
const el = result.singleNodeValue;
|
|
3210
3632
|
if (!el) return { isPromptWidget: false, committed: false };
|
|
3211
3633
|
const widget = el.matches && el.matches(triggerSel) ? el : el.closest(triggerSel);
|
|
3212
3634
|
if (!widget) return { isPromptWidget: false, committed: false };
|
|
3213
|
-
|
|
3635
|
+
const value = announcedValue(widget) || currentText(widget, valueSel, emptyRx);
|
|
3636
|
+
return { isPromptWidget: true, committed: value !== "" && (!want || squash(value).includes(want)) };
|
|
3214
3637
|
})()`;
|
|
3215
3638
|
try {
|
|
3216
3639
|
const raw = await target.evaluate(expr);
|
|
@@ -3361,19 +3784,63 @@ function xpathTailForRetarget(xpath) {
|
|
|
3361
3784
|
* Falls back to the first document-order match when no candidate clears that
|
|
3362
3785
|
* bar, preserving the previous behavior for every single-match (the common)
|
|
3363
3786
|
* case.
|
|
3787
|
+
*
|
|
3788
|
+
* `phrases` (optional): the step's quoted target phrases. Matches whose text
|
|
3789
|
+
* (innerText/textContent/aria-label, case-insensitive, containment in either
|
|
3790
|
+
* direction) names none of them are dropped, so the loose tag/position tail
|
|
3791
|
+
* cannot hand back an element that is not the named target. An empty list, or
|
|
3792
|
+
* a match with no text at all, is unrestricted.
|
|
3364
3793
|
*/
|
|
3365
|
-
const XPATH_TAIL_RETARGET_RESOLVE_FN_SRC = `((tail) => {
|
|
3794
|
+
const XPATH_TAIL_RETARGET_RESOLVE_FN_SRC = `((tail, preferSubmit, phrases) => {
|
|
3366
3795
|
const isNegative = ${submit_control_1.NEGATIVE_TEXT_EXPR};
|
|
3367
3796
|
const accessibleName = (el) =>
|
|
3368
3797
|
(el.getAttribute("aria-label") || el.textContent || "").replace(/\\s+/g, " ").trim().toLowerCase();
|
|
3369
3798
|
const isSubmitShaped = ${submit_control_1.SUBMIT_SHAPE_EXPR};
|
|
3370
3799
|
const r = document.evaluate("//" + tail, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
3800
|
+
const names = (phrases || []).map((p) => String(p).replace(/\\s+/g, " ").trim().toLowerCase()).filter(Boolean);
|
|
3801
|
+
const namesTarget = (el) => {
|
|
3802
|
+
if (names.length === 0) return true;
|
|
3803
|
+
const texts = [el.innerText, el.textContent, el.getAttribute("aria-label")]
|
|
3804
|
+
.map((t) => (t || "").replace(/\\s+/g, " ").trim().toLowerCase())
|
|
3805
|
+
.filter(Boolean);
|
|
3806
|
+
if (texts.length === 0) return true;
|
|
3807
|
+
return texts.some((t) => names.some((n) => t.includes(n) || n.includes(t)));
|
|
3808
|
+
};
|
|
3371
3809
|
const nodes = [];
|
|
3372
|
-
for (let i = 0; i < r.snapshotLength; i++)
|
|
3810
|
+
for (let i = 0; i < r.snapshotLength; i++) {
|
|
3811
|
+
const node = r.snapshotItem(i);
|
|
3812
|
+
if (namesTarget(node)) nodes.push(node);
|
|
3813
|
+
}
|
|
3373
3814
|
if (nodes.length <= 1) return nodes[0] || null;
|
|
3815
|
+
if (!preferSubmit) return nodes[0];
|
|
3374
3816
|
const preferred = nodes.find((el) => isSubmitShaped(el) && !isNegative(accessibleName(el)));
|
|
3375
3817
|
return preferred || nodes[0];
|
|
3376
3818
|
})`;
|
|
3819
|
+
/**
|
|
3820
|
+
* The quoted, non-negated target phrases a plain click step names, used to
|
|
3821
|
+
* refuse a tail-retargeted element that is not that target. Field-label,
|
|
3822
|
+
* select, and radio steps quote option/label values rather than the clicked
|
|
3823
|
+
* control's own text, so they yield no phrases and keep the unrestricted
|
|
3824
|
+
* retarget.
|
|
3825
|
+
*/
|
|
3826
|
+
function tailRetargetPhrasesForStep(step) {
|
|
3827
|
+
if (stepQuotesValueNotControlName(step))
|
|
3828
|
+
return [];
|
|
3829
|
+
return (0, deep_locator_candidates_1.extractTaggedPhrases)(step)
|
|
3830
|
+
.filter((p) => !p.negated)
|
|
3831
|
+
.map((p) => p.text);
|
|
3832
|
+
}
|
|
3833
|
+
/**
|
|
3834
|
+
* True when the step's quoted text is a value (fill/select/radio/field-label)
|
|
3835
|
+
* rather than the clicked control's own name, however loosely the prose is
|
|
3836
|
+
* phrased. Shared so every quoted-phrase guard exempts the same steps.
|
|
3837
|
+
*/
|
|
3838
|
+
function stepQuotesValueNotControlName(step) {
|
|
3839
|
+
return Boolean(parseFieldLabelTarget(step) ||
|
|
3840
|
+
parseFillValueIntent(step) ||
|
|
3841
|
+
parseSelectStep(step) ||
|
|
3842
|
+
parseRadioStep(step));
|
|
3843
|
+
}
|
|
3377
3844
|
/**
|
|
3378
3845
|
* Resolve any selector a caller might hold into a bare XPath body for
|
|
3379
3846
|
* `document.evaluate`. `verifyFillReadback` is shared across call sites that
|
|
@@ -3538,10 +4005,10 @@ const NEARBY_SELECTION_CONTAINER_FN_SRC = `(el) => {
|
|
|
3538
4005
|
* source {@link selectionAncestorChanged} recomputes ancestor keys with, so the
|
|
3539
4006
|
* two byte-match each other and Stagehand's resolved `xpath=/html[1]/…`
|
|
3540
4007
|
* selectors. `[role=dialog],[role=tooltip],[aria-live]` subtrees are
|
|
3541
|
-
* skipped — they churn without a selection change. Visibility uses the
|
|
3542
|
-
* rect + `getComputedStyle
|
|
3543
|
-
*
|
|
3544
|
-
* sticky Next/Submit bar
|
|
4008
|
+
* skipped — they churn without a selection change. Visibility uses the shared
|
|
4009
|
+
* {@link ACTIONABLE_VISIBILITY_EXPR} (rect + `getComputedStyle`, skipping an
|
|
4010
|
+
* element's own `aria-hidden="true"`; not `offsetParent`), so on-screen
|
|
4011
|
+
* `position:fixed` controls — a sticky Next/Submit bar — still enter the map.
|
|
3545
4012
|
* The visibility gate is waived for an `input`/`select` nested inside a nearby
|
|
3546
4013
|
* combobox/listbox/group container: a design-system option commits its real
|
|
3547
4014
|
* selection to a `display:none` or `aria-hidden` sibling input, not to the
|
|
@@ -3562,12 +4029,7 @@ const SELECTION_STATE_MAP_EXPR = `(() => {
|
|
|
3562
4029
|
if (!b) return {};
|
|
3563
4030
|
const xpathOf = ${XPATH_OF_FN_SRC};
|
|
3564
4031
|
const nearbySelectionContainer = ${NEARBY_SELECTION_CONTAINER_FN_SRC};
|
|
3565
|
-
const visible =
|
|
3566
|
-
const rect = el.getBoundingClientRect();
|
|
3567
|
-
if (rect.width === 0 && rect.height === 0) return false;
|
|
3568
|
-
const style = getComputedStyle(el);
|
|
3569
|
-
return style.display !== "none" && style.visibility !== "hidden";
|
|
3570
|
-
};
|
|
4032
|
+
const visible = ${actionable_visibility_expr_1.ACTIONABLE_VISIBILITY_EXPR};
|
|
3571
4033
|
const sel = "button,[role=button],a,li,[tabindex],input,select,textarea,[role=option],[role=tab],[role=switch],[role=checkbox],[role=menuitemcheckbox],[role=combobox]," + ${JSON.stringify(browser_click_expr_1.SELECTION_MARKER_CLASS_SELECTOR_SRC)};
|
|
3572
4034
|
const skip = (el) => el.closest("[role=dialog],[role=tooltip],[aria-live]") !== null;
|
|
3573
4035
|
const isCommittedValueControl = (el) =>
|
|
@@ -3660,7 +4122,10 @@ async function readElementSelectionFingerprint(target, selector) {
|
|
|
3660
4122
|
* own xpath still resolve to a node in the post-click DOM? A same-page
|
|
3661
4123
|
* toggle (a settings-page tab switcher swapping its panel content) leaves
|
|
3662
4124
|
* the clicked control itself in place; a full client-side reset that reverts
|
|
3663
|
-
* to an earlier screen replaces it along with everything else.
|
|
4125
|
+
* to an earlier screen replaces it along with everything else. A view swap
|
|
4126
|
+
* (e.g. a login-to-registration form swap) also removes its trigger, so
|
|
4127
|
+
* absence only vetoes when the DOM did not grow; the caller applies that
|
|
4128
|
+
* direction check. Returns
|
|
3664
4129
|
* `true` — never veto — whenever presence genuinely can't be determined (a
|
|
3665
4130
|
* non-xpath selector, an evaluate failure): only a positive `false` read
|
|
3666
4131
|
* back from the DOM counts as proof of absence.
|
|
@@ -3709,7 +4174,7 @@ async function resolvedClickTargetStillPresent(target, selector) {
|
|
|
3709
4174
|
* (Stagehand act()) call site never passes this — its resolved selector came
|
|
3710
4175
|
* from Stagehand's own successful resolution, so no retarget applies there.
|
|
3711
4176
|
*/
|
|
3712
|
-
async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = null) {
|
|
4177
|
+
async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = null, phrases = []) {
|
|
3713
4178
|
const xpath = xpathBodyForEvaluate(selector);
|
|
3714
4179
|
if (!xpath)
|
|
3715
4180
|
return false;
|
|
@@ -3717,7 +4182,7 @@ async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = n
|
|
|
3717
4182
|
const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
3718
4183
|
let el = r.singleNodeValue;
|
|
3719
4184
|
if (!el && ${JSON.stringify(xpathTail)}) {
|
|
3720
|
-
el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)});
|
|
4185
|
+
el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(true)}, ${JSON.stringify(phrases)});
|
|
3721
4186
|
}
|
|
3722
4187
|
const isSubmitShaped = ${submit_control_1.SUBMIT_SHAPE_EXPR};
|
|
3723
4188
|
return isSubmitShaped(el);
|
|
@@ -3746,7 +4211,7 @@ async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = n
|
|
|
3746
4211
|
* strong signal (a real URL/network transition) instead of trusting it
|
|
3747
4212
|
* blindly, without touching the (already well-tested) weak DOM-signal path.
|
|
3748
4213
|
*/
|
|
3749
|
-
async function resolvedClickUsedXpathTailRetarget(target, selector, xpathTail) {
|
|
4214
|
+
async function resolvedClickUsedXpathTailRetarget(target, selector, xpathTail, phrases = []) {
|
|
3750
4215
|
if (!xpathTail)
|
|
3751
4216
|
return false;
|
|
3752
4217
|
const xpath = xpathBodyForEvaluate(selector);
|
|
@@ -3754,7 +4219,8 @@ async function resolvedClickUsedXpathTailRetarget(target, selector, xpathTail) {
|
|
|
3754
4219
|
return false;
|
|
3755
4220
|
const expr = `(() => {
|
|
3756
4221
|
const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
3757
|
-
|
|
4222
|
+
if (r.singleNodeValue !== null) return false;
|
|
4223
|
+
return ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(true)}, ${JSON.stringify(phrases)}) !== null;
|
|
3758
4224
|
})()`;
|
|
3759
4225
|
try {
|
|
3760
4226
|
return (await target.evaluate(expr)) === true;
|
|
@@ -4034,7 +4500,16 @@ const UPLOAD_WIDGET_RENDER_INTERVAL_MS = 600;
|
|
|
4034
4500
|
* matches `/attachment`; the others cover the other ATS upload sinks. Module-
|
|
4035
4501
|
* level so both the raw-input path and the click-to-surface path share one list.
|
|
4036
4502
|
*/
|
|
4037
|
-
const UPLOAD_URL_PATTERNS = [
|
|
4503
|
+
const UPLOAD_URL_PATTERNS = [
|
|
4504
|
+
"/upload",
|
|
4505
|
+
"/resume",
|
|
4506
|
+
"/file",
|
|
4507
|
+
"/attach",
|
|
4508
|
+
"/document",
|
|
4509
|
+
"/cv",
|
|
4510
|
+
"/media",
|
|
4511
|
+
"/import",
|
|
4512
|
+
];
|
|
4038
4513
|
/**
|
|
4039
4514
|
* Poll the recent-capture window for an upload-related POST after a file has
|
|
4040
4515
|
* been attached. Extracted from `tryUploadPrimitive` so the raw-input path,
|
|
@@ -4043,7 +4518,7 @@ const UPLOAD_URL_PATTERNS = ["/upload", "/resume", "/file", "/attachment", "/doc
|
|
|
4043
4518
|
* {@link UPLOAD_URL_PATTERNS} lands; false if the timeout elapses first.
|
|
4044
4519
|
*/
|
|
4045
4520
|
async function waitForUploadNetworkSignal(params) {
|
|
4046
|
-
const { page, fixture, logger, signalCounter, recentCaptureMeta } = params;
|
|
4521
|
+
const { page, fixture, logger, signalCounter, recentCaptureMeta, stopWhen } = params;
|
|
4047
4522
|
const networkCountBefore = signalCounter.n;
|
|
4048
4523
|
const captureMetaCountBefore = recentCaptureMeta.length;
|
|
4049
4524
|
const startedAt = performance.now();
|
|
@@ -4061,6 +4536,8 @@ async function waitForUploadNetworkSignal(params) {
|
|
|
4061
4536
|
return true;
|
|
4062
4537
|
}
|
|
4063
4538
|
}
|
|
4539
|
+
if (stopWhen?.())
|
|
4540
|
+
return false;
|
|
4064
4541
|
await page.waitForTimeout(UPLOAD_NETWORK_POLL_INTERVAL_MS);
|
|
4065
4542
|
}
|
|
4066
4543
|
return false;
|
|
@@ -4074,14 +4551,7 @@ async function waitForUploadNetworkSignal(params) {
|
|
|
4074
4551
|
* surface path never fires a skip/decline/submit control.
|
|
4075
4552
|
*/
|
|
4076
4553
|
function isUploadAffordanceLabel(label) {
|
|
4077
|
-
|
|
4078
|
-
if (norm.length === 0)
|
|
4079
|
-
return false;
|
|
4080
|
-
if (/\b(later|skip|without|remove|delete|cancel)\b/.test(norm))
|
|
4081
|
-
return false;
|
|
4082
|
-
if (/\bno file\b/.test(norm))
|
|
4083
|
-
return false;
|
|
4084
|
-
return /\b(upload|browse|select file|choose file|attach|add (a )?(resume|cv|file)|resume\/cv)\b/.test(norm);
|
|
4554
|
+
return (0, upload_affordance_expr_1.matchesUploadAffordanceVocabulary)(label);
|
|
4085
4555
|
}
|
|
4086
4556
|
/**
|
|
4087
4557
|
* Materialize the in-memory upload fixture to a temp file so CDP
|
|
@@ -4157,13 +4627,33 @@ async function tryUploadPrimitive(params) {
|
|
|
4157
4627
|
logger,
|
|
4158
4628
|
signalCounter,
|
|
4159
4629
|
recentCaptureMeta,
|
|
4630
|
+
browserbaseSessionId: params.browserbaseSessionId,
|
|
4160
4631
|
});
|
|
4161
4632
|
if (surfaced)
|
|
4162
4633
|
return true;
|
|
4163
4634
|
logger.info("upload primitive: click-to-surface failed; falling through to cascade");
|
|
4164
4635
|
return false;
|
|
4165
4636
|
}
|
|
4166
|
-
|
|
4637
|
+
const attached = await attachToSurfacedInput({
|
|
4638
|
+
page,
|
|
4639
|
+
target,
|
|
4640
|
+
fixture,
|
|
4641
|
+
logger,
|
|
4642
|
+
signalCounter,
|
|
4643
|
+
recentCaptureMeta,
|
|
4644
|
+
});
|
|
4645
|
+
if (attached)
|
|
4646
|
+
return true;
|
|
4647
|
+
logger.info("upload primitive: existing input did not take the file; attempting click-to-surface");
|
|
4648
|
+
return surfaceAndUpload({
|
|
4649
|
+
page,
|
|
4650
|
+
target,
|
|
4651
|
+
fixture,
|
|
4652
|
+
logger,
|
|
4653
|
+
signalCounter,
|
|
4654
|
+
recentCaptureMeta,
|
|
4655
|
+
browserbaseSessionId: params.browserbaseSessionId,
|
|
4656
|
+
});
|
|
4167
4657
|
}
|
|
4168
4658
|
/**
|
|
4169
4659
|
* Attach the fixture to an already-surfaced `<input type=file>` (raw or freshly
|
|
@@ -4174,7 +4664,11 @@ async function tryUploadPrimitive(params) {
|
|
|
4174
4664
|
*/
|
|
4175
4665
|
async function attachToSurfacedInput(params) {
|
|
4176
4666
|
const { page, target, fixture, logger, signalCounter, recentCaptureMeta } = params;
|
|
4177
|
-
|
|
4667
|
+
// Prefer an enabled input (a widget can keep a disabled decoy beside the live one);
|
|
4668
|
+
// the second union branch only matches when no enabled file input exists.
|
|
4669
|
+
const inputLocator = target
|
|
4670
|
+
.locator("xpath=(//input[@type='file' and not(@disabled)] | //input[@type='file'][not(//input[@type='file' and not(@disabled)])])[1]")
|
|
4671
|
+
.first();
|
|
4178
4672
|
try {
|
|
4179
4673
|
await inputLocator.setInputFiles({
|
|
4180
4674
|
name: fixture.name,
|
|
@@ -4263,28 +4757,21 @@ async function attachToSurfacedInput(params) {
|
|
|
4263
4757
|
* was attached.
|
|
4264
4758
|
*/
|
|
4265
4759
|
async function surfaceAndUpload(params) {
|
|
4266
|
-
const { page, target, fixture, logger, signalCounter, recentCaptureMeta } = params;
|
|
4760
|
+
const { page, target, fixture, logger, signalCounter, recentCaptureMeta, browserbaseSessionId } = params;
|
|
4267
4761
|
// Render-gate: the input-less strategies below (drag-drop is one-shot, the
|
|
4268
4762
|
// affordance click resolves what's in the DOM) all race the async widget
|
|
4269
4763
|
// mount. Wait (bounded, same window as the raw-input probe) for ANY upload
|
|
4270
4764
|
// target to appear — a dropzone, an upload-affordance button, or an
|
|
4271
4765
|
// <input type=file> — so every downstream strategy runs against a rendered
|
|
4272
|
-
// widget.
|
|
4766
|
+
// widget. Dropzone list is the shared FIND_DROP_ZONE_EXPR used by
|
|
4273
4767
|
// simulateDragDropUpload and the button matcher parallels clickUploadAffordance.
|
|
4274
4768
|
const targetExpr = `(() => {
|
|
4275
|
-
const
|
|
4276
|
-
const isUpload = (raw) => {
|
|
4277
|
-
const t = norm(raw);
|
|
4278
|
-
if (!t) return false;
|
|
4279
|
-
if (/\\b(later|skip|without|remove|delete|cancel)\\b/.test(t)) return false;
|
|
4280
|
-
if (/\\bno file\\b/.test(t)) return false;
|
|
4281
|
-
return /\\b(upload|browse|select file|choose file|attach|add (a )?(resume|cv|file)|resume\\/cv)\\b/.test(t);
|
|
4282
|
-
};
|
|
4769
|
+
const isUpload = ${upload_affordance_expr_1.UPLOAD_AFFORDANCE_MATCHER_EXPR};
|
|
4283
4770
|
if (document.querySelector("input[type=file]")) return { present: true };
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
const btns = Array.from(document.querySelectorAll(
|
|
4287
|
-
if (btns.some((el) => isUpload(el.getAttribute("aria-label") || el.textContent || ""))) return { present: true };
|
|
4771
|
+
if (${upload_affordance_expr_1.FIND_DROP_ZONE_EXPR}(document)) return { present: true };
|
|
4772
|
+
const actionable = ${actionable_visibility_expr_1.ACTIONABLE_VISIBILITY_EXPR};
|
|
4773
|
+
const btns = Array.from(document.querySelectorAll(${JSON.stringify(upload_affordance_expr_1.UPLOAD_AFFORDANCE_CONTROL_SELECTOR)}));
|
|
4774
|
+
if (btns.some((el) => actionable(el) && isUpload(el.getAttribute("aria-label") || el.textContent || ""))) return { present: true };
|
|
4288
4775
|
return { present: false };
|
|
4289
4776
|
})()`;
|
|
4290
4777
|
const gate = await pollEnumerate(page, target, targetExpr, (r) => r?.present === true, {
|
|
@@ -4329,39 +4816,53 @@ async function surfaceAndUpload(params) {
|
|
|
4329
4816
|
if (!(await clickUploadAffordance(page, target, logger)))
|
|
4330
4817
|
return false;
|
|
4331
4818
|
// Strategy 0: some MUI widgets XHR straight to attachment_upload_url on
|
|
4332
|
-
// click, no chooser, no input.
|
|
4333
|
-
|
|
4819
|
+
// click, no chooser, no input. Stops waiting the moment a chooser is
|
|
4820
|
+
// intercepted — that widget wants the file via the chooser, so burning the
|
|
4821
|
+
// full network window first only delays Strategy B.
|
|
4822
|
+
if (await waitForUploadNetworkSignal({
|
|
4823
|
+
page,
|
|
4824
|
+
fixture,
|
|
4825
|
+
logger,
|
|
4826
|
+
signalCounter,
|
|
4827
|
+
recentCaptureMeta,
|
|
4828
|
+
stopWhen: () => chooserBackendNodeId !== null,
|
|
4829
|
+
})) {
|
|
4334
4830
|
logger.info("upload primitive: resolved via click → immediate upload POST");
|
|
4335
4831
|
return true;
|
|
4336
4832
|
}
|
|
4337
|
-
// Strategy
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4833
|
+
// Strategy B: the click opened a native chooser we intercepted. Tried before A —
|
|
4834
|
+
// the chooser names the exact input the widget wants, while A re-sets whatever
|
|
4835
|
+
// input exists, which a React widget may ignore.
|
|
4836
|
+
if (chooserBackendNodeId !== null) {
|
|
4837
|
+
logger.info(`upload primitive: native file chooser intercepted (backendNodeId=${chooserBackendNodeId}); setting files via CDP`);
|
|
4838
|
+
if (await setFilesViaCdp({
|
|
4342
4839
|
page,
|
|
4343
4840
|
target,
|
|
4841
|
+
session,
|
|
4842
|
+
backendNodeId: chooserBackendNodeId,
|
|
4344
4843
|
fixture,
|
|
4345
4844
|
logger,
|
|
4346
4845
|
signalCounter,
|
|
4347
4846
|
recentCaptureMeta,
|
|
4847
|
+
browserbaseSessionId,
|
|
4348
4848
|
})) {
|
|
4349
4849
|
return true;
|
|
4350
4850
|
}
|
|
4351
4851
|
}
|
|
4352
|
-
// Strategy
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4852
|
+
// Strategy A: the click lazily mounted a hidden <input type=file>.
|
|
4853
|
+
const appeared = await pollEnumerate(page, target, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0);
|
|
4854
|
+
if ((appeared ?? 0) > 0) {
|
|
4855
|
+
logger.info("upload primitive: click surfaced a hidden <input type=file>");
|
|
4856
|
+
if (await attachToSurfacedInput({
|
|
4356
4857
|
page,
|
|
4357
4858
|
target,
|
|
4358
|
-
session,
|
|
4359
|
-
backendNodeId: chooserBackendNodeId,
|
|
4360
4859
|
fixture,
|
|
4361
4860
|
logger,
|
|
4362
4861
|
signalCounter,
|
|
4363
4862
|
recentCaptureMeta,
|
|
4364
|
-
})
|
|
4863
|
+
})) {
|
|
4864
|
+
return true;
|
|
4865
|
+
}
|
|
4365
4866
|
}
|
|
4366
4867
|
return false;
|
|
4367
4868
|
}
|
|
@@ -4370,6 +4871,7 @@ async function surfaceAndUpload(params) {
|
|
|
4370
4871
|
await session.send("Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => { });
|
|
4371
4872
|
}
|
|
4372
4873
|
}
|
|
4874
|
+
const UPLOAD_AFFORDANCE_MARKER = "data-barnacle-upload-affordance";
|
|
4373
4875
|
/**
|
|
4374
4876
|
* Locate and click the resume-upload affordance in the page DOM. Uses a DOM
|
|
4375
4877
|
* enumerate (NOT Stagehand observe, which returns [] for these MUI buttons)
|
|
@@ -4382,40 +4884,71 @@ async function surfaceAndUpload(params) {
|
|
|
4382
4884
|
* (the frame the enumerate/click runs against).
|
|
4383
4885
|
*/
|
|
4384
4886
|
async function clickUploadAffordance(page, target, logger) {
|
|
4385
|
-
// The browser-side matcher
|
|
4386
|
-
//
|
|
4387
|
-
// primitives)
|
|
4887
|
+
// The browser-side matcher is the shared upload-affordance vocabulary; the enumerate stays
|
|
4888
|
+
// a static string (same trust posture as the other
|
|
4889
|
+
// primitives); only shared constants are interpolated.
|
|
4388
4890
|
const expr = `(() => {
|
|
4389
4891
|
const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
|
|
4390
|
-
const isUpload =
|
|
4391
|
-
|
|
4392
|
-
if (!t) return false;
|
|
4393
|
-
if (/\\b(later|skip|without|remove|delete|cancel)\\b/.test(t)) return false;
|
|
4394
|
-
if (/\\bno file\\b/.test(t)) return false;
|
|
4395
|
-
return /\\b(upload|browse|select file|choose file|attach|add (a )?(resume|cv|file)|resume\\/cv)\\b/.test(t);
|
|
4396
|
-
};
|
|
4397
|
-
const els = Array.from(document.querySelectorAll("button,[role='button'],a"));
|
|
4892
|
+
const isUpload = ${upload_affordance_expr_1.UPLOAD_AFFORDANCE_MATCHER_EXPR};
|
|
4893
|
+
const els = Array.from(document.querySelectorAll(${JSON.stringify(upload_affordance_expr_1.UPLOAD_AFFORDANCE_CONTROL_SELECTOR)}));
|
|
4398
4894
|
const scoped = (el) => !!el.closest("[class*='ttachment'],[class*='pload'],[class*='esume']");
|
|
4399
|
-
const
|
|
4400
|
-
|
|
4895
|
+
const actionable = ${actionable_visibility_expr_1.ACTIONABLE_VISIBILITY_EXPR};
|
|
4896
|
+
const matches = els.filter((el) => actionable(el) && isUpload(el.getAttribute("aria-label") || el.textContent || ""));
|
|
4897
|
+
if (matches.length === 0) return { marked: false };
|
|
4401
4898
|
const chosen = matches.find(scoped) || matches[0];
|
|
4402
|
-
chosen.
|
|
4403
|
-
return {
|
|
4899
|
+
chosen.setAttribute("${UPLOAD_AFFORDANCE_MARKER}", "");
|
|
4900
|
+
return { marked: true, text: norm(chosen.getAttribute("aria-label") || chosen.textContent || "").slice(0, 50) };
|
|
4404
4901
|
})()`;
|
|
4902
|
+
const markerSelector = `[${UPLOAD_AFFORDANCE_MARKER}]`;
|
|
4405
4903
|
try {
|
|
4406
|
-
const result = (await pollEnumerate(page, target, expr, (r) => r?.
|
|
4407
|
-
if (result.
|
|
4408
|
-
logger.info(
|
|
4409
|
-
return
|
|
4904
|
+
const result = (await pollEnumerate(page, target, expr, (r) => r?.marked === true)) ?? { marked: false };
|
|
4905
|
+
if (!result.marked) {
|
|
4906
|
+
logger.info("upload primitive: no upload affordance button found in DOM");
|
|
4907
|
+
return false;
|
|
4410
4908
|
}
|
|
4411
|
-
|
|
4412
|
-
|
|
4909
|
+
// A trusted CDP click is required: an in-page el.click() never opens a file chooser.
|
|
4910
|
+
try {
|
|
4911
|
+
await (0, trusted_click_1.clickCentredTrusted)(target, markerSelector, { label: "upload affordance" });
|
|
4912
|
+
}
|
|
4913
|
+
finally {
|
|
4914
|
+
await target
|
|
4915
|
+
.evaluate(`document.querySelectorAll(${JSON.stringify(markerSelector)}).forEach((el) => el.removeAttribute("${UPLOAD_AFFORDANCE_MARKER}"))`)
|
|
4916
|
+
.catch(() => { });
|
|
4917
|
+
}
|
|
4918
|
+
logger.info(`upload primitive: clicked upload affordance "${result.text ?? ""}"`);
|
|
4919
|
+
return true;
|
|
4413
4920
|
}
|
|
4414
4921
|
catch (err) {
|
|
4415
4922
|
logger.warn(`upload primitive: affordance click threw: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
4416
4923
|
return false;
|
|
4417
4924
|
}
|
|
4418
4925
|
}
|
|
4926
|
+
/**
|
|
4927
|
+
* Push the fixture to Browserbase's session uploads endpoint, because a remote
|
|
4928
|
+
* browser cannot read a path on this host. Returns the path the remote
|
|
4929
|
+
* browser sees, or null when the upload is rejected.
|
|
4930
|
+
*/
|
|
4931
|
+
async function uploadFixtureToRemoteSession(sessionId, fixture, logger) {
|
|
4932
|
+
const form = new FormData();
|
|
4933
|
+
form.append("file", new Blob([new Uint8Array(fixture.buffer)], { type: fixture.mimeType }), fixture.name);
|
|
4934
|
+
try {
|
|
4935
|
+
const res = await fetch(`${config_1.config.scraper.browserbaseBaseUrl}/v1/sessions/${sessionId}/uploads`, {
|
|
4936
|
+
method: "POST",
|
|
4937
|
+
headers: { "X-BB-API-Key": config_1.config.scraper.browserbaseApiKey ?? "" },
|
|
4938
|
+
body: form,
|
|
4939
|
+
});
|
|
4940
|
+
const accepted = res.ok ?? (res.status >= 200 && res.status < 300);
|
|
4941
|
+
if (!accepted) {
|
|
4942
|
+
logger.warn(`upload primitive: remote session upload failed (status=${res.status})`);
|
|
4943
|
+
return null;
|
|
4944
|
+
}
|
|
4945
|
+
return `/tmp/.uploads/${fixture.name}`;
|
|
4946
|
+
}
|
|
4947
|
+
catch (err) {
|
|
4948
|
+
logger.warn(`upload primitive: remote session upload threw: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
4949
|
+
return null;
|
|
4950
|
+
}
|
|
4951
|
+
}
|
|
4419
4952
|
/**
|
|
4420
4953
|
* Complete a native-chooser upload the recon intercepted via CDP. CDP's
|
|
4421
4954
|
* `DOM.setFileInputFiles` takes filesystem PATHS (not buffers), so it uses the
|
|
@@ -4424,11 +4957,15 @@ async function clickUploadAffordance(page, target, logger) {
|
|
|
4424
4957
|
* shared upload-network signal.
|
|
4425
4958
|
*/
|
|
4426
4959
|
async function setFilesViaCdp(params) {
|
|
4427
|
-
const { page, target, session, backendNodeId, fixture, logger, signalCounter, recentCaptureMeta, } = params;
|
|
4960
|
+
const { page, target, session, backendNodeId, fixture, logger, signalCounter, recentCaptureMeta, browserbaseSessionId, } = params;
|
|
4428
4961
|
// CDP needs a filesystem path; the fixture is an in-memory buffer. Write it
|
|
4429
4962
|
// to a temp file (tiny — a few KB) so the path is always valid regardless of
|
|
4430
4963
|
// where the recon loaded the fixture from.
|
|
4431
|
-
const path =
|
|
4964
|
+
const path = browserbaseSessionId
|
|
4965
|
+
? await uploadFixtureToRemoteSession(browserbaseSessionId, fixture, logger)
|
|
4966
|
+
: writeFixtureToTempFile(fixture);
|
|
4967
|
+
if (path === null)
|
|
4968
|
+
return false;
|
|
4432
4969
|
try {
|
|
4433
4970
|
await session.send("DOM.setFileInputFiles", { files: [path], backendNodeId });
|
|
4434
4971
|
}
|
|
@@ -4480,6 +5017,19 @@ async function setFilesViaCdp(params) {
|
|
|
4480
5017
|
// it in the instruction (e.g. a page/step-context quote like "'My
|
|
4481
5018
|
// Information' step").
|
|
4482
5019
|
const WIDGET_NOUN_RE = /\b(prompt\s+selector|multiselect|typeahead|dropdown|field|question|checkbox|radio\s+button|radio)\b/i;
|
|
5020
|
+
/**
|
|
5021
|
+
* Matches `prefix` (which must end in a `(?=')` lookahead) and returns the
|
|
5022
|
+
* contraction-aware quoted phrase that starts right after it, so anchored
|
|
5023
|
+
* step parsers pair quotes the same way as {@link findQuotedSpans}.
|
|
5024
|
+
*/
|
|
5025
|
+
function matchQuotedAfter(instruction, prefix) {
|
|
5026
|
+
const match = prefix.exec(instruction);
|
|
5027
|
+
if (!match)
|
|
5028
|
+
return null;
|
|
5029
|
+
const at = match.index + match[0].length;
|
|
5030
|
+
const phrase = (0, quoted_spans_1.findQuotedSpans)(instruction).find((p) => p.start === at);
|
|
5031
|
+
return phrase ? { match, phrase } : null;
|
|
5032
|
+
}
|
|
4483
5033
|
/**
|
|
4484
5034
|
* Pick the QUESTION LABEL out of an instruction's quoted phrases, given the
|
|
4485
5035
|
* already-extracted OPTION.
|
|
@@ -4497,21 +5047,17 @@ const WIDGET_NOUN_RE = /\b(prompt\s+selector|multiselect|typeahead|dropdown|fiel
|
|
|
4497
5047
|
* non-option quote so existing un-adorned phrasings keep working.
|
|
4498
5048
|
*/
|
|
4499
5049
|
function pickQuestionLabel(instruction, option) {
|
|
4500
|
-
|
|
4501
|
-
const candidates = [...instruction.matchAll(/'([^']+)'/g)].filter((m) => m[1].trim() !== option);
|
|
5050
|
+
const candidates = (0, quoted_spans_1.findQuotedSpans)(instruction).filter((s) => s.text.trim() !== option);
|
|
4502
5051
|
if (candidates.length === 0)
|
|
4503
5052
|
return null;
|
|
4504
|
-
const adjacentToWidgetNoun = candidates.find((
|
|
4505
|
-
const
|
|
4506
|
-
const
|
|
4507
|
-
const before = instruction.slice(Math.max(0, start - 25), start);
|
|
4508
|
-
const after = instruction.slice(end, end + 40);
|
|
5053
|
+
const adjacentToWidgetNoun = candidates.find((s) => {
|
|
5054
|
+
const before = instruction.slice(Math.max(0, s.start - 25), s.start);
|
|
5055
|
+
const after = instruction.slice(s.end, s.end + 40);
|
|
4509
5056
|
return (/\bfor(?:\s+the)?(?:\s+(?:question|answer))?\s*$/i.test(before) || WIDGET_NOUN_RE.test(after));
|
|
4510
5057
|
});
|
|
4511
5058
|
// biome-ignore lint/style/noNonNullAssertion: candidates is guarded non-empty above, so the fallback element is always present
|
|
4512
5059
|
const picked = (adjacentToWidgetNoun ?? candidates[0]);
|
|
4513
|
-
|
|
4514
|
-
return picked[1].trim();
|
|
5060
|
+
return picked.text.trim();
|
|
4515
5061
|
}
|
|
4516
5062
|
function parseSelectStep(instruction) {
|
|
4517
5063
|
const lower = instruction.toLowerCase();
|
|
@@ -4524,8 +5070,7 @@ function parseSelectStep(instruction) {
|
|
|
4524
5070
|
// Catch-all steps ("for any remaining…") have no concrete single target.
|
|
4525
5071
|
if (/\bany\s+remaining\b/.test(lower))
|
|
4526
5072
|
return null;
|
|
4527
|
-
|
|
4528
|
-
const quoted = [...instruction.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
|
5073
|
+
const quoted = (0, quoted_spans_1.findQuotedSpans)(instruction).map((s) => s.text);
|
|
4529
5074
|
if (quoted.length === 0)
|
|
4530
5075
|
return null;
|
|
4531
5076
|
// The OPTION is the quoted string following the word "select", allowing a
|
|
@@ -4533,11 +5078,10 @@ function parseSelectStep(instruction) {
|
|
|
4533
5078
|
// quote — real flow phrasing like "select the option 'Job Boards'" or
|
|
4534
5079
|
// "select the answer 'X'" otherwise fails to parse and the caller
|
|
4535
5080
|
// silently no-ops before touching the DOM.
|
|
4536
|
-
const selMatch = instruction
|
|
5081
|
+
const selMatch = matchQuotedAfter(instruction, /\bselect(?:\s+or\s+check)?\s+(?:the\s+\S+\s+)?(?=')/i)?.phrase;
|
|
4537
5082
|
if (!selMatch)
|
|
4538
5083
|
return null;
|
|
4539
|
-
|
|
4540
|
-
const option = selMatch[1].trim();
|
|
5084
|
+
const option = selMatch.text.trim();
|
|
4541
5085
|
// The QUESTION LABEL, when present, is a DIFFERENT quoted string — the one
|
|
4542
5086
|
// introduced by "for '…'" or adjacent to a widget noun like "'…' dropdown"
|
|
4543
5087
|
// or "'…' prompt selector". See `pickQuestionLabel`.
|
|
@@ -4569,11 +5113,11 @@ function parseSelectStep(instruction) {
|
|
|
4569
5113
|
* to the normal cascade unchanged.
|
|
4570
5114
|
*/
|
|
4571
5115
|
function parseFillStep(instruction) {
|
|
4572
|
-
const
|
|
4573
|
-
if (!
|
|
5116
|
+
const found = matchQuotedAfter(instruction, /\bfill(?:\s+in)?\s+(?:the\s+)?(.+?)\s+(?:field|input|textbox)\s+with\s+(?=')/i);
|
|
5117
|
+
if (!found)
|
|
4574
5118
|
return null;
|
|
4575
|
-
const fieldLabel = match[1]?.trim();
|
|
4576
|
-
const value =
|
|
5119
|
+
const fieldLabel = found.match[1]?.trim();
|
|
5120
|
+
const value = found.phrase.text.trim();
|
|
4577
5121
|
if (!fieldLabel || !value)
|
|
4578
5122
|
return null;
|
|
4579
5123
|
return { fieldLabel, value };
|
|
@@ -4601,14 +5145,458 @@ function parseFillValueIntent(instruction) {
|
|
|
4601
5145
|
if (parseSelectStep(instruction))
|
|
4602
5146
|
return null;
|
|
4603
5147
|
if (/\bfill(?:\s+in)?\b/i.test(instruction)) {
|
|
4604
|
-
const value = instruction
|
|
5148
|
+
const value = matchQuotedAfter(instruction, /\bwith\s+(?=')/i)?.phrase.text.trim();
|
|
4605
5149
|
return value ? { value } : null;
|
|
4606
5150
|
}
|
|
4607
5151
|
if (/\btype\b/i.test(instruction)) {
|
|
4608
|
-
const value = instruction
|
|
5152
|
+
const value = matchQuotedAfter(instruction, /\btype\s+(?=')/i)?.phrase.text.trim();
|
|
4609
5153
|
return value ? { value } : null;
|
|
4610
5154
|
}
|
|
4611
|
-
return null;
|
|
5155
|
+
return null;
|
|
5156
|
+
}
|
|
5157
|
+
const SEGMENTED_DATE_VALUE_RE = /^[\d/.\s-]+$/;
|
|
5158
|
+
const SEGMENTED_DATE_MIN_DIGITS = 4;
|
|
5159
|
+
const SEGMENTED_DATE_MAX_DIGITS = 8;
|
|
5160
|
+
const SEGMENTED_DATE_KEY_DELAY_MS = 50;
|
|
5161
|
+
const SEGMENTED_DATE_FIELD_WORDS = new Set([
|
|
5162
|
+
"from",
|
|
5163
|
+
"to",
|
|
5164
|
+
"start",
|
|
5165
|
+
"end",
|
|
5166
|
+
"since",
|
|
5167
|
+
"until",
|
|
5168
|
+
"through",
|
|
5169
|
+
"begin",
|
|
5170
|
+
"finish",
|
|
5171
|
+
]);
|
|
5172
|
+
const SEGMENTED_DATE_STOPWORDS = new Set([
|
|
5173
|
+
"a",
|
|
5174
|
+
"an",
|
|
5175
|
+
"and",
|
|
5176
|
+
"at",
|
|
5177
|
+
"date",
|
|
5178
|
+
"enter",
|
|
5179
|
+
"fill",
|
|
5180
|
+
"for",
|
|
5181
|
+
"in",
|
|
5182
|
+
"into",
|
|
5183
|
+
"of",
|
|
5184
|
+
"on",
|
|
5185
|
+
"the",
|
|
5186
|
+
"then",
|
|
5187
|
+
"to",
|
|
5188
|
+
"type",
|
|
5189
|
+
"with",
|
|
5190
|
+
]);
|
|
5191
|
+
/**
|
|
5192
|
+
* Extract the digits a fill/type/enter step wants typed into a multi-segment
|
|
5193
|
+
* date widget. Limited to 4-8 digits so phone numbers and other long numerics
|
|
5194
|
+
* never trigger the segmented-date path. Reuses `parseFillValueIntent` and
|
|
5195
|
+
* falls back to the last date-shaped quoted phrase for verbless steps such as
|
|
5196
|
+
* "'From' date in Work Experience 1, '012020'".
|
|
5197
|
+
*/
|
|
5198
|
+
function parseSegmentedDateValue(instruction) {
|
|
5199
|
+
const spans = (0, quoted_spans_1.findQuotedSpans)(instruction);
|
|
5200
|
+
const intent = parseFillValueIntent(instruction)?.value;
|
|
5201
|
+
const candidates = intent
|
|
5202
|
+
? [intent]
|
|
5203
|
+
: spans
|
|
5204
|
+
.map((s) => s.text.trim())
|
|
5205
|
+
.filter((t) => SEGMENTED_DATE_VALUE_RE.test(t))
|
|
5206
|
+
.reverse();
|
|
5207
|
+
for (const raw of candidates) {
|
|
5208
|
+
if (!SEGMENTED_DATE_VALUE_RE.test(raw))
|
|
5209
|
+
continue;
|
|
5210
|
+
const digits = raw.replace(/\D/g, "");
|
|
5211
|
+
if (digits.length < SEGMENTED_DATE_MIN_DIGITS || digits.length > SEGMENTED_DATE_MAX_DIGITS) {
|
|
5212
|
+
continue;
|
|
5213
|
+
}
|
|
5214
|
+
return { digits, valueSpan: raw };
|
|
5215
|
+
}
|
|
5216
|
+
if (spans.length > 0 && intent)
|
|
5217
|
+
return null;
|
|
5218
|
+
const bare = instruction
|
|
5219
|
+
.split(/\s+/)
|
|
5220
|
+
.map((t) => t.replace(/[.,;:]+$/, ""))
|
|
5221
|
+
.filter((t) => /\d/.test(t) && SEGMENTED_DATE_VALUE_RE.test(t))
|
|
5222
|
+
.reverse();
|
|
5223
|
+
for (const raw of bare) {
|
|
5224
|
+
const digits = raw.replace(/\D/g, "");
|
|
5225
|
+
if (digits.length < SEGMENTED_DATE_MIN_DIGITS || digits.length > SEGMENTED_DATE_MAX_DIGITS) {
|
|
5226
|
+
continue;
|
|
5227
|
+
}
|
|
5228
|
+
return { digits, valueSpan: raw };
|
|
5229
|
+
}
|
|
5230
|
+
return null;
|
|
5231
|
+
}
|
|
5232
|
+
/** Keeps a typed date's shape (segment count and widths) in logs without its digits. */
|
|
5233
|
+
function maskDateDigits(text) {
|
|
5234
|
+
return text.replace(/\d/g, "9");
|
|
5235
|
+
}
|
|
5236
|
+
function segmentedDateTokens(text) {
|
|
5237
|
+
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
5238
|
+
}
|
|
5239
|
+
const SEGMENTED_DATE_PART_LABELS = new Set(["mm", "dd", "yyyy", "yy", "month", "day", "year"]);
|
|
5240
|
+
const SEGMENTED_DATE_FIELD_NOUNS = new Set([
|
|
5241
|
+
"date",
|
|
5242
|
+
"field",
|
|
5243
|
+
"group",
|
|
5244
|
+
"input",
|
|
5245
|
+
"spinbutton",
|
|
5246
|
+
"segment",
|
|
5247
|
+
"value",
|
|
5248
|
+
"part",
|
|
5249
|
+
]);
|
|
5250
|
+
const SEGMENTED_DATE_NAMING_DETERMINERS = new Set(["the", "its", "this", "that", "for", "of"]);
|
|
5251
|
+
/**
|
|
5252
|
+
* Step tokens with the field words that are mere prose removed. "from"/"to"
|
|
5253
|
+
* only name a date field when a determiner precedes them ("the To date"), a
|
|
5254
|
+
* field noun follows ("From date"), or they are capitalised mid-sentence;
|
|
5255
|
+
* "auto-advances from month to year" names nothing and must never select a
|
|
5256
|
+
* group labelled "From".
|
|
5257
|
+
*/
|
|
5258
|
+
function segmentedDateStepTokens(text) {
|
|
5259
|
+
const words = text.match(/[A-Za-z0-9]+/g) ?? [];
|
|
5260
|
+
return words.flatMap((word, i) => {
|
|
5261
|
+
const token = word.toLowerCase();
|
|
5262
|
+
if (!SEGMENTED_DATE_FIELD_WORDS.has(token))
|
|
5263
|
+
return [token];
|
|
5264
|
+
const prev = (words[i - 1] ?? "").toLowerCase();
|
|
5265
|
+
const next = (words[i + 1] ?? "").toLowerCase();
|
|
5266
|
+
const named = SEGMENTED_DATE_NAMING_DETERMINERS.has(prev) ||
|
|
5267
|
+
SEGMENTED_DATE_FIELD_NOUNS.has(next) ||
|
|
5268
|
+
(i > 0 && /^[A-Z]/.test(word));
|
|
5269
|
+
return named ? [token] : [];
|
|
5270
|
+
});
|
|
5271
|
+
}
|
|
5272
|
+
/**
|
|
5273
|
+
* Pick which spinbutton group a segmented-date step targets. Groups whose
|
|
5274
|
+
* segment widths (digit count of `aria-valuemax`) don't sum to the value's
|
|
5275
|
+
* digit count are dropped. A group is only eligible when a field the step names
|
|
5276
|
+
* (quoted, e.g. 'From', or unquoted in field position, e.g. "the To date")
|
|
5277
|
+
* matches its OWN label as whole words — a group with no `label` is judged by
|
|
5278
|
+
* its whole `context`. The section the step names ("Work Experience 1") is
|
|
5279
|
+
* then resolved: when a width-fitting group elsewhere matches the step's
|
|
5280
|
+
* section words better than any eligible group does, the named section renders
|
|
5281
|
+
* no such field and the step resolves to nothing rather than to another
|
|
5282
|
+
* section's. Returns the index into `groups`, or `null` when nothing matches
|
|
5283
|
+
* or the best match is tied — writing a date into a group that merely fits the
|
|
5284
|
+
* width would corrupt another field.
|
|
5285
|
+
*/
|
|
5286
|
+
function resolveSegmentedDateGroup(instruction, groups) {
|
|
5287
|
+
const value = parseSegmentedDateValue(instruction);
|
|
5288
|
+
if (!value)
|
|
5289
|
+
return null;
|
|
5290
|
+
const valueTokens = segmentedDateTokens(value.valueSpan).join(" ");
|
|
5291
|
+
const quoted = (0, quoted_spans_1.findQuotedSpans)(instruction)
|
|
5292
|
+
.map((s) => segmentedDateTokens(s.text.trim()))
|
|
5293
|
+
.filter((t) => t.length > 0 &&
|
|
5294
|
+
t.join(" ") !== valueTokens &&
|
|
5295
|
+
!(t.length === 1 && SEGMENTED_DATE_PART_LABELS.has(t[0] ?? "")));
|
|
5296
|
+
const stepTokens = segmentedDateStepTokens(instruction.replace(value.valueSpan, " barnaclevalue "));
|
|
5297
|
+
const targets = quoted.length > 0 ? quoted : [stepTokens];
|
|
5298
|
+
const contains = (hay, needle) => {
|
|
5299
|
+
for (let i = 0; i + needle.length <= hay.length; i++) {
|
|
5300
|
+
if (needle.every((t, j) => hay[i + j] === t))
|
|
5301
|
+
return true;
|
|
5302
|
+
}
|
|
5303
|
+
return false;
|
|
5304
|
+
};
|
|
5305
|
+
const compatible = (group) => group.segmentMaxes.map((max) => String(max).length).reduce((a, b) => a + b, 0) ===
|
|
5306
|
+
value.digits.length;
|
|
5307
|
+
const stepSet = new Set(stepTokens);
|
|
5308
|
+
const sectionScore = (group) => {
|
|
5309
|
+
const own = new Set(segmentedDateTokens(group.label ?? ""));
|
|
5310
|
+
const sectionTokens = new Set(segmentedDateTokens(group.section ?? group.context));
|
|
5311
|
+
return [...sectionTokens].filter((t) => stepSet.has(t) &&
|
|
5312
|
+
!own.has(t) &&
|
|
5313
|
+
!SEGMENTED_DATE_STOPWORDS.has(t) &&
|
|
5314
|
+
!SEGMENTED_DATE_FIELD_WORDS.has(t)).length;
|
|
5315
|
+
};
|
|
5316
|
+
const bestSection = Math.max(0, ...groups.filter(compatible).map((group) => sectionScore(group)));
|
|
5317
|
+
let best = -1;
|
|
5318
|
+
let bestScore = -1;
|
|
5319
|
+
let tied = false;
|
|
5320
|
+
groups.forEach((group, index) => {
|
|
5321
|
+
if (!compatible(group))
|
|
5322
|
+
return;
|
|
5323
|
+
const own = segmentedDateTokens(group.label ?? group.context);
|
|
5324
|
+
if (own.length === 0)
|
|
5325
|
+
return;
|
|
5326
|
+
const namesOwnField = group.label === undefined &&
|
|
5327
|
+
quoted.length === 0 &&
|
|
5328
|
+
own.some((t) => SEGMENTED_DATE_FIELD_WORDS.has(t) && stepSet.has(t));
|
|
5329
|
+
if (!namesOwnField &&
|
|
5330
|
+
!targets.some((t) => contains(t, own) || (quoted.length > 0 && contains(own, t)))) {
|
|
5331
|
+
return;
|
|
5332
|
+
}
|
|
5333
|
+
const score = sectionScore(group);
|
|
5334
|
+
if (score < bestSection)
|
|
5335
|
+
return;
|
|
5336
|
+
if (score > bestScore) {
|
|
5337
|
+
best = index;
|
|
5338
|
+
bestScore = score;
|
|
5339
|
+
tied = false;
|
|
5340
|
+
}
|
|
5341
|
+
else if (score === bestScore) {
|
|
5342
|
+
tied = true;
|
|
5343
|
+
}
|
|
5344
|
+
});
|
|
5345
|
+
if (best >= 0)
|
|
5346
|
+
return tied ? null : best;
|
|
5347
|
+
// A step that names no field at all can only mean the one date widget on the
|
|
5348
|
+
// page; a named field that matched no label must never fall back to it.
|
|
5349
|
+
if (quoted.length > 0 || stepTokens.some((t) => SEGMENTED_DATE_FIELD_WORDS.has(t)))
|
|
5350
|
+
return null;
|
|
5351
|
+
const only = groups.flatMap((g, i) => (compatible(g) && !g.nested ? [i] : []));
|
|
5352
|
+
return only.length === 1 ? (only[0] ?? null) : null;
|
|
5353
|
+
}
|
|
5354
|
+
const SEGMENTED_DATE_MARKER_ATTR = "data-barnacle-segdate";
|
|
5355
|
+
/** Exported so tests can run the real DOM enumeration against markup. */
|
|
5356
|
+
exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
|
|
5357
|
+
const attr = ${JSON.stringify(SEGMENTED_DATE_MARKER_ATTR)};
|
|
5358
|
+
const stale = document.querySelectorAll("[" + attr + "]");
|
|
5359
|
+
stale.forEach((el) => el.removeAttribute(attr));
|
|
5360
|
+
const hiddenAttr = (node) => node.hasAttribute("hidden") || node.getAttribute("aria-hidden") === "true";
|
|
5361
|
+
const styleOf = (node) => {
|
|
5362
|
+
try { return typeof getComputedStyle === "function" ? getComputedStyle(node) : window.getComputedStyle(node); } catch { return null; }
|
|
5363
|
+
};
|
|
5364
|
+
const isHidden = (el) => {
|
|
5365
|
+
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
|
|
5366
|
+
if (hiddenAttr(node)) return true;
|
|
5367
|
+
const style = styleOf(node);
|
|
5368
|
+
if (style && (style.display === "none" || style.visibility === "hidden")) return true;
|
|
5369
|
+
}
|
|
5370
|
+
return false;
|
|
5371
|
+
};
|
|
5372
|
+
const spins = Array.from(document.querySelectorAll("input[role='spinbutton']")).filter((el) => !isHidden(el));
|
|
5373
|
+
if (spins.length === 0) return { groups: [] };
|
|
5374
|
+
const norm = (s) => (s || "").replace(/\\s+/g, " ").trim();
|
|
5375
|
+
const byContainer = new Map();
|
|
5376
|
+
for (const el of spins) {
|
|
5377
|
+
const box = el.closest("fieldset,[role='group']") || el.parentElement;
|
|
5378
|
+
if (!byContainer.has(box)) byContainer.set(box, []);
|
|
5379
|
+
byContainer.get(box).push(el);
|
|
5380
|
+
}
|
|
5381
|
+
const idText = (ids) => (ids || "").split(/\\s+/).map((id) => {
|
|
5382
|
+
const ref = id ? document.getElementById(id) : null;
|
|
5383
|
+
return ref ? norm(ref.textContent) : "";
|
|
5384
|
+
}).join(" ");
|
|
5385
|
+
const headingSibling = (node) => {
|
|
5386
|
+
let prevSib = node.previousElementSibling;
|
|
5387
|
+
for (let hop = 0; hop < 3 && prevSib; hop++, prevSib = prevSib.previousElementSibling) {
|
|
5388
|
+
if (prevSib.matches("h1,h2,h3,h4,h5,h6,legend,[role='heading']")) return norm(prevSib.textContent);
|
|
5389
|
+
}
|
|
5390
|
+
return "";
|
|
5391
|
+
};
|
|
5392
|
+
const legendOf = (node) => {
|
|
5393
|
+
const legend = node.tagName === "FIELDSET" ? node.querySelector(":scope > legend") : null;
|
|
5394
|
+
return legend ? norm(legend.textContent) : "";
|
|
5395
|
+
};
|
|
5396
|
+
const namedBy = (node) => [legendOf(node), norm(node.getAttribute("aria-label")), idText(node.getAttribute("aria-labelledby"))].filter(Boolean).join(" ");
|
|
5397
|
+
const sectionOf = (box, includeSelf) => {
|
|
5398
|
+
const parts = [includeSelf ? namedBy(box) : "", headingSibling(box)];
|
|
5399
|
+
let node = box.parentElement;
|
|
5400
|
+
for (let depth = 0; depth < 4 && node && node !== document.body; depth++, node = node.parentElement) {
|
|
5401
|
+
parts.push(namedBy(node), headingSibling(node));
|
|
5402
|
+
}
|
|
5403
|
+
return Array.from(new Set(parts.filter(Boolean))).join(" ");
|
|
5404
|
+
};
|
|
5405
|
+
const ownCaption = (box) => {
|
|
5406
|
+
const caption = Array.from(box.querySelectorAll("label,legend")).find((l) => !l.querySelector("input,[role='spinbutton']") && !(l.getAttribute("for") && box.querySelector("[id='" + l.getAttribute("for") + "']")));
|
|
5407
|
+
if (caption) return norm(caption.textContent);
|
|
5408
|
+
const prev = box.previousElementSibling;
|
|
5409
|
+
return prev && prev.matches("label,legend") && !prev.querySelector("input,[role='spinbutton']") ? norm(prev.textContent) : "";
|
|
5410
|
+
};
|
|
5411
|
+
const formFieldOf = (box) => box.closest("[data-automation-id^='formField-']");
|
|
5412
|
+
const formFieldLabel = (field) => {
|
|
5413
|
+
const label = field.querySelector("label,legend");
|
|
5414
|
+
return label ? norm(label.textContent) : "";
|
|
5415
|
+
};
|
|
5416
|
+
const labelText = (seg) => {
|
|
5417
|
+
const parts = [idText(seg.getAttribute("aria-labelledby")), norm(seg.getAttribute("aria-label")), norm(seg.getAttribute("placeholder"))];
|
|
5418
|
+
if (seg.id) {
|
|
5419
|
+
document.querySelectorAll("label").forEach((l) => { if (l.getAttribute("for") === seg.id) parts.push(norm(l.textContent)); });
|
|
5420
|
+
}
|
|
5421
|
+
const wrap = seg.closest("label");
|
|
5422
|
+
if (wrap) parts.push(norm(wrap.textContent));
|
|
5423
|
+
return parts.filter(Boolean).join(" ");
|
|
5424
|
+
};
|
|
5425
|
+
const maxOf = (seg, si, count) => {
|
|
5426
|
+
const declared = Number(seg.getAttribute("aria-valuemax"));
|
|
5427
|
+
if (declared > 0) return declared;
|
|
5428
|
+
const own = labelText(seg).toLowerCase();
|
|
5429
|
+
if (/year|yyyy|\byy\b/.test(own)) return 9999;
|
|
5430
|
+
if (/month|\bmm\b/.test(own)) return 12;
|
|
5431
|
+
if (/day|\bdd\b/.test(own)) return 31;
|
|
5432
|
+
const len = Number(seg.getAttribute("maxlength"));
|
|
5433
|
+
if (len > 0) return Math.pow(10, len) - 1;
|
|
5434
|
+
return count > 1 && si === count - 1 ? 9999 : 12;
|
|
5435
|
+
};
|
|
5436
|
+
const groups = [];
|
|
5437
|
+
const mark = (seg, token) => {
|
|
5438
|
+
const prior = seg.getAttribute(attr);
|
|
5439
|
+
seg.setAttribute(attr, prior ? prior + " " + token : token);
|
|
5440
|
+
};
|
|
5441
|
+
const addGroup = (segs, box, nested) => {
|
|
5442
|
+
const gi = groups.length;
|
|
5443
|
+
segs.forEach((seg, si) => mark(seg, gi + ":" + si));
|
|
5444
|
+
const field = formFieldOf(box) || segs[0].closest("[data-automation-id^='formField-']");
|
|
5445
|
+
const innerField = field && field !== box && box.contains(field) && segs.every((seg) => field.contains(seg)) ? formFieldLabel(field) : "";
|
|
5446
|
+
const own = innerField || namedBy(box) || (field ? formFieldLabel(field) : "") || ownCaption(box) || segs.map((seg) => idText(seg.getAttribute("aria-labelledby"))).filter(Boolean).join(" ");
|
|
5447
|
+
const section = sectionOf(box, Boolean(innerField));
|
|
5448
|
+
groups.push({
|
|
5449
|
+
segmentMaxes: segs.map((seg, si) => maxOf(seg, si, segs.length)),
|
|
5450
|
+
context: [section, own].filter(Boolean).join(" "),
|
|
5451
|
+
label: own,
|
|
5452
|
+
section,
|
|
5453
|
+
nested,
|
|
5454
|
+
});
|
|
5455
|
+
};
|
|
5456
|
+
const nestedCandidates = [];
|
|
5457
|
+
byContainer.forEach((segs, box) => {
|
|
5458
|
+
addGroup(segs, box, false);
|
|
5459
|
+
const byParent = new Map();
|
|
5460
|
+
for (const seg of segs) {
|
|
5461
|
+
if (!byParent.has(seg.parentElement)) byParent.set(seg.parentElement, []);
|
|
5462
|
+
byParent.get(seg.parentElement).push(seg);
|
|
5463
|
+
}
|
|
5464
|
+
// A shared fieldset can hold several independent dates (From and To year
|
|
5465
|
+
// spinbuttons side by side); each wrapper is then its own candidate group.
|
|
5466
|
+
if (byParent.size > 1) byParent.forEach((inner, parent) => nestedCandidates.push([inner, parent]));
|
|
5467
|
+
});
|
|
5468
|
+
nestedCandidates.forEach(([inner, parent]) => addGroup(inner, parent, true));
|
|
5469
|
+
return { groups };
|
|
5470
|
+
})()`;
|
|
5471
|
+
const SEGMENTED_DATE_CLEAR_EXPR = `(() => {
|
|
5472
|
+
document.querySelectorAll("[${SEGMENTED_DATE_MARKER_ATTR}]").forEach((el) => el.removeAttribute(${JSON.stringify(SEGMENTED_DATE_MARKER_ATTR)}));
|
|
5473
|
+
})()`;
|
|
5474
|
+
/**
|
|
5475
|
+
* Type a date into a multi-segment `role=spinbutton` widget with real key
|
|
5476
|
+
* events (raw CDP key events, `locator.type` with a delay as fallback), then
|
|
5477
|
+
* verify every segment numerically.
|
|
5478
|
+
*
|
|
5479
|
+
* Why this exists: such widgets keep each segment's value in component state
|
|
5480
|
+
* driven by `keydown`/`beforeinput`; `fill`/`insertText` bypasses that and
|
|
5481
|
+
* garbles the segments. Returns a marker id on commit, or null on no
|
|
5482
|
+
* spinbuttons, an unresolvable group, or any segment mismatch so the caller
|
|
5483
|
+
* falls through to the cascade.
|
|
5484
|
+
*/
|
|
5485
|
+
async function trySegmentedDateSpinbuttonPrimitive(params) {
|
|
5486
|
+
const { page, target, instruction, logger } = params;
|
|
5487
|
+
const value = parseSegmentedDateValue(instruction);
|
|
5488
|
+
if (!value)
|
|
5489
|
+
return null;
|
|
5490
|
+
const sendCDP = (method, args) => target.frame?.session ? target.frame.session.send(method, args) : page.sendCDP(method, args);
|
|
5491
|
+
try {
|
|
5492
|
+
const enumerated = (await target.evaluate(exports.SEGMENTED_DATE_ENUMERATE_EXPR));
|
|
5493
|
+
if (!enumerated?.groups?.length)
|
|
5494
|
+
return null;
|
|
5495
|
+
const gi = resolveSegmentedDateGroup(instruction, enumerated.groups);
|
|
5496
|
+
if (gi === null) {
|
|
5497
|
+
logger.info("segmented date primitive: no unambiguous group; falling through");
|
|
5498
|
+
await target.evaluate(SEGMENTED_DATE_CLEAR_EXPR);
|
|
5499
|
+
return null;
|
|
5500
|
+
}
|
|
5501
|
+
const maxes = enumerated.groups[gi]?.segmentMaxes ?? [];
|
|
5502
|
+
const widths = maxes.map((max) => String(max).length);
|
|
5503
|
+
const expected = widths.map((w, i) => {
|
|
5504
|
+
const start = widths.slice(0, i).reduce((a, b) => a + b, 0);
|
|
5505
|
+
return Number(value.digits.slice(start, start + w));
|
|
5506
|
+
});
|
|
5507
|
+
const segmentSelector = (si) => `[${SEGMENTED_DATE_MARKER_ATTR}~="${gi}:${si}"]`;
|
|
5508
|
+
const focusSegment = async (si) => {
|
|
5509
|
+
const selector = segmentSelector(si);
|
|
5510
|
+
// A below-the-fold segment would otherwise take the trusted click at an
|
|
5511
|
+
// off-screen point; centre it first so the click and key events land on it.
|
|
5512
|
+
try {
|
|
5513
|
+
await target.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (el && typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center", inline: "center" }); })()`);
|
|
5514
|
+
}
|
|
5515
|
+
catch {
|
|
5516
|
+
// scrolling is best-effort; the key events only need the segment focused
|
|
5517
|
+
}
|
|
5518
|
+
try {
|
|
5519
|
+
if (await (0, trusted_click_1.isClickPointUnobscured)(target, selector)) {
|
|
5520
|
+
await (0, trusted_click_1.clickCentredTrusted)(target, selector, { label: "date segment" });
|
|
5521
|
+
}
|
|
5522
|
+
}
|
|
5523
|
+
catch {
|
|
5524
|
+
// click unavailable; focus below is what the key events need
|
|
5525
|
+
}
|
|
5526
|
+
await target.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (el && document.activeElement !== el) el.focus(); })()`);
|
|
5527
|
+
};
|
|
5528
|
+
const typeViaCdp = async (digits) => {
|
|
5529
|
+
for (const digit of digits) {
|
|
5530
|
+
const keyArgs = {
|
|
5531
|
+
key: digit,
|
|
5532
|
+
code: `Digit${digit}`,
|
|
5533
|
+
windowsVirtualKeyCode: 48 + Number(digit),
|
|
5534
|
+
};
|
|
5535
|
+
await sendCDP("Input.dispatchKeyEvent", { type: "keyDown", text: digit, ...keyArgs });
|
|
5536
|
+
await sendCDP("Input.dispatchKeyEvent", { type: "keyUp", ...keyArgs });
|
|
5537
|
+
}
|
|
5538
|
+
};
|
|
5539
|
+
const segmentDigits = widths.map((w, i) => {
|
|
5540
|
+
const from = widths.slice(0, i).reduce((acc, n) => acc + n, 0);
|
|
5541
|
+
return value.digits.slice(from, from + w);
|
|
5542
|
+
});
|
|
5543
|
+
const readSegments = async () => (await target.evaluate(`(() => {
|
|
5544
|
+
const sels = ${JSON.stringify(widths.map((_, si) => segmentSelector(si)))};
|
|
5545
|
+
return sels.map((sel) => {
|
|
5546
|
+
const s = document.querySelector(sel);
|
|
5547
|
+
if (!s) return NaN;
|
|
5548
|
+
const now = s.getAttribute("aria-valuenow");
|
|
5549
|
+
const raw = now !== null && now !== "" ? now : s.value;
|
|
5550
|
+
return raw === undefined || raw === null || String(raw).trim() === "" ? NaN : Number(raw);
|
|
5551
|
+
});
|
|
5552
|
+
})()`));
|
|
5553
|
+
const matches = (got) => Array.isArray(got) &&
|
|
5554
|
+
got.length === expected.length &&
|
|
5555
|
+
got.every((n, i) => n === expected[i]);
|
|
5556
|
+
// Raw CDP key events go to the focused segment, so each segment is focused
|
|
5557
|
+
// explicitly rather than trusting the widget to auto-advance. A delayed
|
|
5558
|
+
// locator.type (per-character key events, never Input.insertText) covers a
|
|
5559
|
+
// page whose CDP channel does not reach the widget.
|
|
5560
|
+
const typeViaLocator = async () => {
|
|
5561
|
+
for (const [si, digits] of segmentDigits.entries()) {
|
|
5562
|
+
await target.locator(segmentSelector(si)).first().type(digits, {
|
|
5563
|
+
delay: SEGMENTED_DATE_KEY_DELAY_MS,
|
|
5564
|
+
});
|
|
5565
|
+
}
|
|
5566
|
+
};
|
|
5567
|
+
const typeViaKeyEvents = async () => {
|
|
5568
|
+
for (const [si, digits] of segmentDigits.entries()) {
|
|
5569
|
+
await focusSegment(si);
|
|
5570
|
+
await typeViaCdp(digits);
|
|
5571
|
+
}
|
|
5572
|
+
};
|
|
5573
|
+
let actual = [];
|
|
5574
|
+
for (const type of [typeViaKeyEvents, typeViaLocator]) {
|
|
5575
|
+
try {
|
|
5576
|
+
await type();
|
|
5577
|
+
}
|
|
5578
|
+
catch (err) {
|
|
5579
|
+
logger.info(`segmented date primitive: delivery attempt threw: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
5580
|
+
continue;
|
|
5581
|
+
}
|
|
5582
|
+
actual = await readSegments();
|
|
5583
|
+
if (matches(actual))
|
|
5584
|
+
break;
|
|
5585
|
+
}
|
|
5586
|
+
if (!matches(actual))
|
|
5587
|
+
actual = await readSegments();
|
|
5588
|
+
await target.evaluate(SEGMENTED_DATE_CLEAR_EXPR);
|
|
5589
|
+
if (!matches(actual)) {
|
|
5590
|
+
logger.info(`segmented date primitive: readback ${maskDateDigits(JSON.stringify(actual))} != ${maskDateDigits(JSON.stringify(expected))}; falling through`);
|
|
5591
|
+
return null;
|
|
5592
|
+
}
|
|
5593
|
+
logger.info(`segmented date primitive: committed segments ${maskDateDigits(JSON.stringify(actual))}`);
|
|
5594
|
+
return `segmented-date-${gi}`;
|
|
5595
|
+
}
|
|
5596
|
+
catch (err) {
|
|
5597
|
+
logger.warn(`segmented date primitive: threw: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
|
|
5598
|
+
return null;
|
|
5599
|
+
}
|
|
4612
5600
|
}
|
|
4613
5601
|
/**
|
|
4614
5602
|
* Parse a single-choice RADIO flow step into the option to click and (when
|
|
@@ -4645,16 +5633,14 @@ function parseRadioStep(instruction) {
|
|
|
4645
5633
|
// Catch-all steps ("for any remaining…") have no concrete single target.
|
|
4646
5634
|
if (/\bany\s+remaining\b/.test(lower))
|
|
4647
5635
|
return null;
|
|
4648
|
-
|
|
4649
|
-
const quoted = [...instruction.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
|
5636
|
+
const quoted = (0, quoted_spans_1.findQuotedSpans)(instruction).map((s) => s.text);
|
|
4650
5637
|
if (quoted.length === 0)
|
|
4651
5638
|
return null;
|
|
4652
5639
|
// The OPTION is the quoted string immediately after "click the".
|
|
4653
|
-
const optMatch = instruction
|
|
5640
|
+
const optMatch = matchQuotedAfter(instruction, /\bclick\s+the\s+(?=')/i)?.phrase;
|
|
4654
5641
|
if (!optMatch)
|
|
4655
5642
|
return null;
|
|
4656
|
-
|
|
4657
|
-
const option = optMatch[1].trim();
|
|
5643
|
+
const option = optMatch.text.trim();
|
|
4658
5644
|
// The QUESTION LABEL, when present, is a DIFFERENT quoted string — the one
|
|
4659
5645
|
// introduced by "for the question '…'" / "for the '…' question". Some steps
|
|
4660
5646
|
// phrase the question un-quoted ("…about requiring visa sponsorship"); in
|
|
@@ -4699,11 +5685,10 @@ function parseWidgetOptionClickStep(instruction) {
|
|
|
4699
5685
|
// tolerant of a short qualifier ("top-level", "leaf") between the noun and
|
|
4700
5686
|
// the quote — e.g. "click the top-level category 'Job Boards'" or "click
|
|
4701
5687
|
// the leaf option 'Internet - Job Boards/Search Engines'".
|
|
4702
|
-
const optMatch = instruction
|
|
5688
|
+
const optMatch = matchQuotedAfter(instruction, /\bclick\s+the\s+(?:\S+\s+)?(?:category|option)\s+(?=')/i)?.phrase;
|
|
4703
5689
|
if (!optMatch)
|
|
4704
5690
|
return null;
|
|
4705
|
-
|
|
4706
|
-
const option = optMatch[1].trim();
|
|
5691
|
+
const option = optMatch.text.trim();
|
|
4707
5692
|
return { option };
|
|
4708
5693
|
}
|
|
4709
5694
|
/**
|
|
@@ -4724,10 +5709,31 @@ function parseCheckStep(instruction) {
|
|
|
4724
5709
|
const lower = instruction.toLowerCase();
|
|
4725
5710
|
if (/\bany\s+remaining\b/.test(lower))
|
|
4726
5711
|
return null;
|
|
4727
|
-
const
|
|
4728
|
-
const
|
|
5712
|
+
const found = matchQuotedAfter(instruction, /\b(?:un)?check\s+(?:the\s+)?(?=')|\bclick\s+(?:the\s+)?(?=')/i);
|
|
5713
|
+
const rest = found ? instruction.slice(found.phrase.end) : "";
|
|
5714
|
+
const label = /^.*?\bcheckbox\b/i.test(rest) ? found?.phrase.text.trim() : undefined;
|
|
4729
5715
|
return label ? { label } : null;
|
|
4730
5716
|
}
|
|
5717
|
+
/**
|
|
5718
|
+
* Parse a single-checkbox STATE step ("Check the 'X' checkbox" / "Uncheck the
|
|
5719
|
+
* 'X' checkbox") into its label, desired state and trailing section words.
|
|
5720
|
+
*
|
|
5721
|
+
* Why this exists: "Click the … checkbox" is a toggle and stays with the
|
|
5722
|
+
* cascade; only the explicit check/uncheck verbs name a target state the
|
|
5723
|
+
* primitive can compare against what the page committed.
|
|
5724
|
+
*/
|
|
5725
|
+
function parseCheckStateStep(instruction) {
|
|
5726
|
+
const parsed = parseCheckStep(instruction);
|
|
5727
|
+
const found = matchQuotedAfter(instruction, /\b(un)?check\s+(?:the\s+)?(?=')/i);
|
|
5728
|
+
if (!parsed || !found)
|
|
5729
|
+
return null;
|
|
5730
|
+
const outside = instruction.slice(0, found.match.index) +
|
|
5731
|
+
" " +
|
|
5732
|
+
instruction.slice(found.phrase.end).replace(/^.*?\bcheckbox\b/i, "");
|
|
5733
|
+
const stop = new Set(["the", "for", "and", "section", "under", "this", "that", "with", "from"]);
|
|
5734
|
+
const sectionWords = (outside.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter((w) => !stop.has(w) && (w.length >= 3 || /^\d+$/.test(w)));
|
|
5735
|
+
return { label: parsed.label, desired: !found.match[1], sectionWords };
|
|
5736
|
+
}
|
|
4731
5737
|
/** Strips wrapping single quotes a parser's capture group may include (e.g. when a flow author quotes the field name itself), so a probe label compares plain text against plain text. */
|
|
4732
5738
|
function stripQuotedLabel(label) {
|
|
4733
5739
|
return label.replace(/^'+|'+$/g, "").trim();
|
|
@@ -5262,13 +6268,16 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
|
|
|
5262
6268
|
if (clickResult.clicked) {
|
|
5263
6269
|
if (!pre || !verification)
|
|
5264
6270
|
return true;
|
|
6271
|
+
if (verification.page)
|
|
6272
|
+
await (0, click_settle_1.settleAfterClick)(verification.page, verification.signalCounter, pre.networkCount, true, async () => (await snapshotPage(target, verification.signalCounter)).visibleTextSignature, pre.visibleTextSignature);
|
|
5265
6273
|
const post = await snapshotPage(target, verification.signalCounter, verification.page);
|
|
5266
6274
|
const verdict = (0, phantom_click_1.classifyPhantomClick)({
|
|
5267
6275
|
actResultSuccess: true,
|
|
5268
6276
|
pre,
|
|
5269
6277
|
post,
|
|
5270
6278
|
isSubmitShapedStep: true,
|
|
5271
|
-
|
|
6279
|
+
isMidFlowSubmitStep: verification.isMidFlowSubmitStep,
|
|
6280
|
+
destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, post.url, verification.isMidFlowSubmitStep ? pre.url : undefined),
|
|
5272
6281
|
});
|
|
5273
6282
|
if (verdict !== "phantom")
|
|
5274
6283
|
return true;
|
|
@@ -5282,13 +6291,16 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
|
|
|
5282
6291
|
throw err;
|
|
5283
6292
|
});
|
|
5284
6293
|
if (runnerUpClickResult.clicked) {
|
|
6294
|
+
if (verification.page)
|
|
6295
|
+
await (0, click_settle_1.settleAfterClick)(verification.page, verification.signalCounter, post.networkCount, true, async () => (await snapshotPage(target, verification.signalCounter)).visibleTextSignature, post.visibleTextSignature);
|
|
5285
6296
|
const runnerUpPost = await snapshotPage(target, verification.signalCounter, verification.page);
|
|
5286
6297
|
const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
|
|
5287
6298
|
actResultSuccess: true,
|
|
5288
6299
|
pre: post,
|
|
5289
6300
|
post: runnerUpPost,
|
|
5290
6301
|
isSubmitShapedStep: true,
|
|
5291
|
-
|
|
6302
|
+
isMidFlowSubmitStep: verification.isMidFlowSubmitStep,
|
|
6303
|
+
destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(verification.step, runnerUpPost.url, verification.isMidFlowSubmitStep ? post.url : undefined),
|
|
5292
6304
|
});
|
|
5293
6305
|
if (runnerUpVerdict !== "phantom")
|
|
5294
6306
|
return true;
|
|
@@ -5403,7 +6415,7 @@ async function trySelectPrimitive(params) {
|
|
|
5403
6415
|
if (!parsed)
|
|
5404
6416
|
return null;
|
|
5405
6417
|
const { option, questionLabel } = parsed;
|
|
5406
|
-
const optLabel =
|
|
6418
|
+
const optLabel = primitiveOptionLabel(option, questionLabel, redactValue);
|
|
5407
6419
|
// Phase 1 (browser, no mutation): find the target select — the one whose
|
|
5408
6420
|
// nearby text matches the question label (or, when no label, any select).
|
|
5409
6421
|
// Try a deterministic option match first; if it hits, apply immediately (no
|
|
@@ -5501,7 +6513,7 @@ async function trySelectPrimitive(params) {
|
|
|
5501
6513
|
if (enumResult.detMatch) {
|
|
5502
6514
|
const { ok, stillInvalid } = await applySelectValue(page, target, enumResult.detMatch.selIdx, enumResult.detMatch.value);
|
|
5503
6515
|
if (ok && !stillInvalid) {
|
|
5504
|
-
logger.info(`select primitive: set dropdown to "${enumResult.detMatch.text.trim()
|
|
6516
|
+
logger.info(`select primitive: set dropdown to "${(0, pii_redaction_1.maskValueShape)(enumResult.detMatch.text.trim())}" (${optLabel})`);
|
|
5505
6517
|
return enumResult.detMatch.id;
|
|
5506
6518
|
}
|
|
5507
6519
|
logger.info(`select primitive: dropdown value for ${optLabel} did not commit (ok=${ok} stillInvalid=${stillInvalid}); falling through to cascade`);
|
|
@@ -5538,7 +6550,7 @@ async function trySelectPrimitive(params) {
|
|
|
5538
6550
|
// same as the fast path.
|
|
5539
6551
|
const { ok, stillInvalid } = await applySelectValue(page, target, chosenCandidate.selIdx, chosenOption.value);
|
|
5540
6552
|
if (ok && !stillInvalid) {
|
|
5541
|
-
logger.info(`select primitive: LLM chose "${
|
|
6553
|
+
logger.info(`select primitive: LLM chose "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
|
|
5542
6554
|
return chosenCandidate.id;
|
|
5543
6555
|
}
|
|
5544
6556
|
logger.info(`select primitive: LLM-chosen value for ${optLabel} did not commit (ok=${ok} stillInvalid=${stillInvalid}); falling through`);
|
|
@@ -5694,7 +6706,7 @@ async function tryFillRequiredSelectsPrimitive(params) {
|
|
|
5694
6706
|
}
|
|
5695
6707
|
const { ok, stillInvalid } = await applySelectValue(page, target, cand.selIdx, chosen.value);
|
|
5696
6708
|
if (ok && !stillInvalid) {
|
|
5697
|
-
logger.info(`required-select primitive: filled "${cand.label.slice(0, 40)}" with "${
|
|
6709
|
+
logger.info(`required-select primitive: filled "${cand.label.slice(0, 40)}" with "${(0, pii_redaction_1.maskValueShape)(chosen.text)}"`);
|
|
5698
6710
|
}
|
|
5699
6711
|
else {
|
|
5700
6712
|
allCommitted = false;
|
|
@@ -5708,6 +6720,161 @@ async function tryFillRequiredSelectsPrimitive(params) {
|
|
|
5708
6720
|
return false;
|
|
5709
6721
|
}
|
|
5710
6722
|
}
|
|
6723
|
+
/** Marker stamped on the matched checkbox / its label so trusted clicks can address them. */
|
|
6724
|
+
const CHECKBOX_STATE_MARKER_ATTR = "data-barnacle-cbstate";
|
|
6725
|
+
const CHECKBOX_STATE_POLL_MS = 200;
|
|
6726
|
+
const CHECKBOX_STATE_POLL_ATTEMPTS = 8;
|
|
6727
|
+
/**
|
|
6728
|
+
* Commits a single "Check/Uncheck the 'X' checkbox" step by comparing the
|
|
6729
|
+
* box's committed state to the requested one, so a pre-ticked box is never
|
|
6730
|
+
* toggled off by a blind click. Returns the box's DOM id (possibly empty) on
|
|
6731
|
+
* success, or null when ambiguous, absent, obscured or unflipped so the
|
|
6732
|
+
* caller falls through to the cascade.
|
|
6733
|
+
*/
|
|
6734
|
+
async function tryCheckboxStatePrimitive(params) {
|
|
6735
|
+
const { page, target, instruction, logger } = params;
|
|
6736
|
+
const parsed = parseCheckStateStep(instruction);
|
|
6737
|
+
if (!parsed)
|
|
6738
|
+
return null;
|
|
6739
|
+
const attr = CHECKBOX_STATE_MARKER_ATTR;
|
|
6740
|
+
const stampExpr = `((label, words) => {
|
|
6741
|
+
const norm = (s) => (s || "").replace(/\\s+/g, " ").replace(/\\s*[*:]+$/, "").trim().toLowerCase();
|
|
6742
|
+
const want = norm(label);
|
|
6743
|
+
document.querySelectorAll("[${attr}]").forEach((e) => e.removeAttribute("${attr}"));
|
|
6744
|
+
const visible = (e) => {
|
|
6745
|
+
try { return !!e && e.getClientRects().length > 0 && getComputedStyle(e).visibility !== "hidden"; } catch { return !!e; }
|
|
6746
|
+
};
|
|
6747
|
+
const labelsOf = (cb) => {
|
|
6748
|
+
const out = [];
|
|
6749
|
+
if (cb.id) {
|
|
6750
|
+
document.querySelectorAll("label").forEach((l) => { if (l.getAttribute("for") === cb.id) out.push(l); });
|
|
6751
|
+
}
|
|
6752
|
+
const w = cb.closest("label");
|
|
6753
|
+
if (w && !out.includes(w)) out.push(w);
|
|
6754
|
+
return out;
|
|
6755
|
+
};
|
|
6756
|
+
const isOn = (cb) => {
|
|
6757
|
+
const a = cb.getAttribute("aria-checked");
|
|
6758
|
+
return a === "true" ? true : a === "false" ? false : cb.checked === true;
|
|
6759
|
+
};
|
|
6760
|
+
const boxes = Array.from(document.querySelectorAll("input[type=checkbox],[role=checkbox]"));
|
|
6761
|
+
let hits = [];
|
|
6762
|
+
for (const cb of boxes) {
|
|
6763
|
+
const ls = labelsOf(cb);
|
|
6764
|
+
const names = ls.map((l) => norm(l.textContent));
|
|
6765
|
+
const al = cb.getAttribute("aria-label");
|
|
6766
|
+
if (al) names.push(norm(al));
|
|
6767
|
+
const alb = cb.getAttribute("aria-labelledby");
|
|
6768
|
+
if (alb) {
|
|
6769
|
+
const refs = alb.split(/\\s+/).map((id) => document.getElementById(id)).filter(Boolean);
|
|
6770
|
+
if (refs.length > 0) names.push(norm(refs.map((r) => r.textContent).join(" ")));
|
|
6771
|
+
}
|
|
6772
|
+
if (!names.includes(want)) continue;
|
|
6773
|
+
if (!visible(cb) && !ls.some(visible)) continue;
|
|
6774
|
+
hits.push({ cb, ls });
|
|
6775
|
+
}
|
|
6776
|
+
const sectionText = (cb) => {
|
|
6777
|
+
const parts = [];
|
|
6778
|
+
for (let n = cb.parentElement; n; n = n.parentElement) {
|
|
6779
|
+
const lb = n.getAttribute("aria-labelledby");
|
|
6780
|
+
if (lb) lb.split(/\\s+/).forEach((id) => { const el = document.getElementById(id); if (el) parts.push(el.textContent); });
|
|
6781
|
+
const lg = n.querySelector(":scope > legend");
|
|
6782
|
+
if (lg) parts.push(lg.textContent);
|
|
6783
|
+
const hd = n.querySelector(":scope > h2, :scope > h3, :scope > h4");
|
|
6784
|
+
if (hd) parts.push(hd.textContent);
|
|
6785
|
+
let sib = n.previousElementSibling;
|
|
6786
|
+
for (let hop = 0; hop < 3 && sib; hop++, sib = sib.previousElementSibling) {
|
|
6787
|
+
if (!sib.matches("h1,h2,h3,h4,h5,h6,legend,[role='heading']")) continue;
|
|
6788
|
+
parts.push(sib.textContent);
|
|
6789
|
+
break;
|
|
6790
|
+
}
|
|
6791
|
+
}
|
|
6792
|
+
return norm(parts.join(" "));
|
|
6793
|
+
};
|
|
6794
|
+
if (hits.length > 1 && words.length > 0) {
|
|
6795
|
+
const scored = hits.map((h) => {
|
|
6796
|
+
const tokens = new Set(sectionText(h.cb).match(/[a-z0-9]+/g) || []);
|
|
6797
|
+
return { h, score: words.filter((w) => tokens.has(w)).length };
|
|
6798
|
+
});
|
|
6799
|
+
const top = Math.max(...scored.map((x) => x.score));
|
|
6800
|
+
if (top > 0) hits = scored.filter((x) => x.score === top).map((x) => x.h);
|
|
6801
|
+
}
|
|
6802
|
+
if (hits.length === 0) return { status: "none" };
|
|
6803
|
+
if (hits.length > 1) return { status: "ambiguous" };
|
|
6804
|
+
const { cb, ls } = hits[0];
|
|
6805
|
+
cb.setAttribute("${attr}", "box");
|
|
6806
|
+
const lab = ls.find(visible);
|
|
6807
|
+
if (lab) lab.setAttribute("${attr}", "label");
|
|
6808
|
+
return { status: "ok", checked: isOn(cb), id: cb.id || "", hasLabel: !!lab };
|
|
6809
|
+
})(${JSON.stringify(parsed.label.toLowerCase())}, ${JSON.stringify(parsed.sectionWords)})`;
|
|
6810
|
+
const stateExpr = `(() => {
|
|
6811
|
+
const cb = document.querySelector("[${attr}=box]");
|
|
6812
|
+
if (!cb) return null;
|
|
6813
|
+
const a = cb.getAttribute("aria-checked");
|
|
6814
|
+
return a === "true" ? true : a === "false" ? false : cb.checked === true;
|
|
6815
|
+
})()`;
|
|
6816
|
+
const clearExpr = `document.querySelectorAll("[${attr}]").forEach((e) => e.removeAttribute("${attr}"))`;
|
|
6817
|
+
try {
|
|
6818
|
+
const stamped = (await target.evaluate(stampExpr));
|
|
6819
|
+
if (stamped?.status !== "ok")
|
|
6820
|
+
return null;
|
|
6821
|
+
const id = stamped.id ?? "";
|
|
6822
|
+
if (stamped.checked === parsed.desired) {
|
|
6823
|
+
logger.info(`checkbox state primitive: "${parsed.label.slice(0, 40)}" already ${parsed.desired ? "checked" : "unchecked"}, no click`);
|
|
6824
|
+
return id;
|
|
6825
|
+
}
|
|
6826
|
+
const clickSels = [...(stamped.hasLabel ? [`[${attr}="label"]`] : []), `[${attr}="box"]`];
|
|
6827
|
+
const reachedDesired = async () => {
|
|
6828
|
+
for (let i = 0; i < CHECKBOX_STATE_POLL_ATTEMPTS; i++) {
|
|
6829
|
+
await page.waitForTimeout(CHECKBOX_STATE_POLL_MS).catch(() => undefined);
|
|
6830
|
+
if ((await target.evaluate(stateExpr)) === parsed.desired)
|
|
6831
|
+
return true;
|
|
6832
|
+
}
|
|
6833
|
+
return false;
|
|
6834
|
+
};
|
|
6835
|
+
const succeed = () => {
|
|
6836
|
+
logger.info(`checkbox state primitive: "${parsed.label.slice(0, 40)}" now ${parsed.desired ? "checked" : "unchecked"}`);
|
|
6837
|
+
return id;
|
|
6838
|
+
};
|
|
6839
|
+
let overlaid = false;
|
|
6840
|
+
let trustedDelivered = false;
|
|
6841
|
+
for (const sel of clickSels) {
|
|
6842
|
+
const probe = await (0, trusted_click_1.probeClickPoint)(target, sel);
|
|
6843
|
+
if (probe?.state === "occluded" && probe.occluder !== "none") {
|
|
6844
|
+
overlaid = true;
|
|
6845
|
+
continue;
|
|
6846
|
+
}
|
|
6847
|
+
if (probe?.state !== "hit")
|
|
6848
|
+
continue;
|
|
6849
|
+
const delivered = await (0, trusted_click_1.clickCentredTrusted)(target, sel, { label: "checkbox" })
|
|
6850
|
+
.then(() => true)
|
|
6851
|
+
.catch(() => false);
|
|
6852
|
+
trustedDelivered ||= delivered;
|
|
6853
|
+
if (delivered && (await reachedDesired()))
|
|
6854
|
+
return succeed();
|
|
6855
|
+
}
|
|
6856
|
+
// A hit test that cannot resolve a point at all (no layout) says nothing
|
|
6857
|
+
// about whether a programmatic click toggles the box, so it is tried once;
|
|
6858
|
+
// a named overlay or an already-delivered trusted click that did not flip the
|
|
6859
|
+
// box is left to the cascade, since a second click could toggle it back.
|
|
6860
|
+
if (!overlaid && !trustedDelivered) {
|
|
6861
|
+
for (const sel of clickSels) {
|
|
6862
|
+
await target.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(sel)}); if (el) el.click(); })()`);
|
|
6863
|
+
if (await reachedDesired())
|
|
6864
|
+
return succeed();
|
|
6865
|
+
}
|
|
6866
|
+
}
|
|
6867
|
+
logger.warn(`checkbox state primitive: "${parsed.label.slice(0, 40)}" did not reach desired state, falling through`);
|
|
6868
|
+
return null;
|
|
6869
|
+
}
|
|
6870
|
+
catch (err) {
|
|
6871
|
+
logger.warn(`checkbox state primitive failed: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
6872
|
+
return null;
|
|
6873
|
+
}
|
|
6874
|
+
finally {
|
|
6875
|
+
await target.evaluate(clearExpr).catch(() => undefined);
|
|
6876
|
+
}
|
|
6877
|
+
}
|
|
5711
6878
|
/**
|
|
5712
6879
|
* Site-agnostic checkbox primitive: answer a multi-select checkbox-group
|
|
5713
6880
|
* question by directly checking the matching option in the DOM, bypassing
|
|
@@ -5742,7 +6909,7 @@ async function tryCheckboxPrimitive(params) {
|
|
|
5742
6909
|
if (!parsed)
|
|
5743
6910
|
return null;
|
|
5744
6911
|
const { option, questionLabel } = parsed;
|
|
5745
|
-
const optLabel =
|
|
6912
|
+
const optLabel = primitiveOptionLabel(option, questionLabel, redactValue);
|
|
5746
6913
|
// Phase 1 (browser, no mutation): find checkbox GROUPS and their options.
|
|
5747
6914
|
// A group is a `c-MultiCheckboxInput` container or a `<fieldset>` containing
|
|
5748
6915
|
// checkboxes. Question label = the group's legend / associated label; each
|
|
@@ -5873,7 +7040,7 @@ async function tryCheckboxPrimitive(params) {
|
|
|
5873
7040
|
})(${JSON.stringify(chosenGroup.gi)}, ${JSON.stringify(chosenOption.bi)})`;
|
|
5874
7041
|
const applyResult = (await target.evaluate(applyExpr));
|
|
5875
7042
|
if (applyResult?.ok) {
|
|
5876
|
-
logger.info(`checkbox primitive: LLM checked "${
|
|
7043
|
+
logger.info(`checkbox primitive: LLM checked "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
|
|
5877
7044
|
return applyResult.id;
|
|
5878
7045
|
}
|
|
5879
7046
|
logger.info(`checkbox primitive: LLM-chosen checkbox did not stick for ${optLabel}; falling through`);
|
|
@@ -6069,7 +7236,7 @@ async function tryRadioPrimitive(params) {
|
|
|
6069
7236
|
if (!parsed)
|
|
6070
7237
|
return null;
|
|
6071
7238
|
const { option, questionLabel } = parsed;
|
|
6072
|
-
const optLabel =
|
|
7239
|
+
const optLabel = primitiveOptionLabel(option, questionLabel, redactValue);
|
|
6073
7240
|
// Phase 1 (browser): find radio GROUPS and their options. A group is a
|
|
6074
7241
|
// `<fieldset>` / `[role=radiogroup]` / `[role=group]` / `[class*='RadioGroup']`
|
|
6075
7242
|
// container with radios. Question label = the group's legend / associated label; each option
|
|
@@ -6154,7 +7321,7 @@ async function tryRadioPrimitive(params) {
|
|
|
6154
7321
|
xpath: chosenOpt?.xpath ?? "",
|
|
6155
7322
|
});
|
|
6156
7323
|
if (applied) {
|
|
6157
|
-
logger.info(`radio primitive: selected "${(chosenOpt?.text ?? "").trim()
|
|
7324
|
+
logger.info(`radio primitive: selected "${(0, pii_redaction_1.maskValueShape)((chosenOpt?.text ?? "").trim())}" (${optLabel})`);
|
|
6158
7325
|
return chosenOpt?.id ?? "";
|
|
6159
7326
|
}
|
|
6160
7327
|
logger.info(`radio primitive: chosen radio did not stick for ${optLabel}; falling through`);
|
|
@@ -6200,7 +7367,7 @@ async function tryRadioPrimitive(params) {
|
|
|
6200
7367
|
}),
|
|
6201
7368
|
};
|
|
6202
7369
|
if (applyResult?.ok) {
|
|
6203
|
-
logger.info(`radio primitive: LLM selected "${
|
|
7370
|
+
logger.info(`radio primitive: LLM selected "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
|
|
6204
7371
|
return chosenOption.id ?? "";
|
|
6205
7372
|
}
|
|
6206
7373
|
logger.info(`radio primitive: LLM-chosen radio did not stick for ${optLabel}; falling through`);
|
|
@@ -6252,7 +7419,7 @@ async function applyRadioSelection(target, gi, ri, hint) {
|
|
|
6252
7419
|
const inputSel = hint.id ? buildRadioIdXPath(hint.id) : hint.xpath ? `xpath=${hint.xpath}` : null;
|
|
6253
7420
|
if (inputSel) {
|
|
6254
7421
|
try {
|
|
6255
|
-
await
|
|
7422
|
+
await (0, trusted_click_1.clickCentredTrusted)(target, inputSel, { label: "radio input" });
|
|
6256
7423
|
if (await readback())
|
|
6257
7424
|
return true;
|
|
6258
7425
|
}
|
|
@@ -6263,10 +7430,9 @@ async function applyRadioSelection(target, gi, ri, hint) {
|
|
|
6263
7430
|
// Tier B — trusted click on the associated label (MUI hides the real input).
|
|
6264
7431
|
if (hint.id) {
|
|
6265
7432
|
try {
|
|
6266
|
-
await target
|
|
6267
|
-
|
|
6268
|
-
|
|
6269
|
-
.click();
|
|
7433
|
+
await (0, trusted_click_1.clickCentredTrusted)(target, `xpath=//label[@for=${JSON.stringify(hint.id)}]`, {
|
|
7434
|
+
label: "radio label",
|
|
7435
|
+
});
|
|
6270
7436
|
if (await readback())
|
|
6271
7437
|
return true;
|
|
6272
7438
|
}
|
|
@@ -6299,6 +7465,29 @@ async function applyRadioSelection(target, gi, ri, hint) {
|
|
|
6299
7465
|
await target.evaluate(applyExpr).catch(() => ({ ok: false }));
|
|
6300
7466
|
return await readback();
|
|
6301
7467
|
}
|
|
7468
|
+
/**
|
|
7469
|
+
* Extracts the option a step wants committed into a prompt-selector widget,
|
|
7470
|
+
* with the question label that scopes which widget. Flow/replan generation
|
|
7471
|
+
* words this as a SELECT, a FILL of the widget's search box, a radio-style
|
|
7472
|
+
* answer click, or a cascading-multiselect category/leaf click, so all four
|
|
7473
|
+
* shapes resolve to one `{ option, questionLabel }` — shared by the picking
|
|
7474
|
+
* primitive and the act-success committed-value guard so both compare the page
|
|
7475
|
+
* against the same requested value.
|
|
7476
|
+
*/
|
|
7477
|
+
function parsePromptSelectorStep(instruction) {
|
|
7478
|
+
const select = parseSelectStep(instruction);
|
|
7479
|
+
if (select)
|
|
7480
|
+
return { ...select, isFill: false };
|
|
7481
|
+
const fill = parseFillStep(instruction);
|
|
7482
|
+
if (fill) {
|
|
7483
|
+
return { option: fill.value, questionLabel: stripQuotedLabel(fill.fieldLabel), isFill: true };
|
|
7484
|
+
}
|
|
7485
|
+
const answer = parseRadioStep(instruction);
|
|
7486
|
+
if (answer)
|
|
7487
|
+
return { ...answer, isFill: false };
|
|
7488
|
+
const optionClick = parseWidgetOptionClickStep(instruction);
|
|
7489
|
+
return optionClick ? { option: optionClick.option, questionLabel: null, isFill: false } : null;
|
|
7490
|
+
}
|
|
6302
7491
|
/**
|
|
6303
7492
|
* Answer a native-control-less popup-dropdown widget (a combobox that opens a
|
|
6304
7493
|
* listbox popup and renders NO `<select>`/`<input>` a focused `<select>`/
|
|
@@ -6370,23 +7559,11 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
6370
7559
|
// shape), then parseWidgetOptionClickStep (option only, no question
|
|
6371
7560
|
// label) so the widget-matching/open/readback phases below run unchanged
|
|
6372
7561
|
// regardless of which verb the instruction used.
|
|
6373
|
-
const
|
|
6374
|
-
const parsedFill = parsedSelect ? null : parseFillStep(instruction);
|
|
6375
|
-
const parsedAnswer = parsedSelect || parsedFill ? null : parseRadioStep(instruction);
|
|
6376
|
-
const parsedOptionClick = parsedSelect || parsedFill || parsedAnswer ? null : parseWidgetOptionClickStep(instruction);
|
|
6377
|
-
const parsed = parsedSelect
|
|
6378
|
-
? parsedSelect
|
|
6379
|
-
: parsedFill
|
|
6380
|
-
? { option: parsedFill.value, questionLabel: stripQuotedLabel(parsedFill.fieldLabel) }
|
|
6381
|
-
: parsedAnswer
|
|
6382
|
-
? parsedAnswer
|
|
6383
|
-
: parsedOptionClick
|
|
6384
|
-
? { option: parsedOptionClick.option, questionLabel: null }
|
|
6385
|
-
: null;
|
|
7562
|
+
const parsed = parsePromptSelectorStep(instruction);
|
|
6386
7563
|
if (!parsed)
|
|
6387
7564
|
return null;
|
|
6388
7565
|
const { option, questionLabel } = parsed;
|
|
6389
|
-
const optLabel =
|
|
7566
|
+
const optLabel = primitiveOptionLabel(option, questionLabel, redactValue, parsed.isFill);
|
|
6390
7567
|
const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
|
|
6391
7568
|
// Phase 1 (browser, read-only except for the marker attribute stamped for
|
|
6392
7569
|
// Phase 2's click addressing): find candidate widgets — popup-dropdown
|
|
@@ -6399,6 +7576,7 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
6399
7576
|
const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
|
|
6400
7577
|
const triggers = Array.from(document.querySelectorAll(triggerSel));
|
|
6401
7578
|
if (triggers.length === 0) return { widgetPresent: false };
|
|
7579
|
+
const containerOf = ${PROMPT_WIDGET_CONTAINER_EXPR};
|
|
6402
7580
|
const seen = new Set();
|
|
6403
7581
|
const resolved = [];
|
|
6404
7582
|
for (const el of triggers) {
|
|
@@ -6408,18 +7586,7 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
6408
7586
|
// interactive ancestor, else the OUTERMOST widget-kit container on the
|
|
6409
7587
|
// ancestor chain (so the inner input and its container collapse to the
|
|
6410
7588
|
// same node), else the element itself.
|
|
6411
|
-
const
|
|
6412
|
-
let container = interactive || el;
|
|
6413
|
-
if (!interactive) {
|
|
6414
|
-
const kitSel = "[data-uxi-widget-type='selectinput'],[data-uxi-widget-type='multiselect'],[data-automation-id='multiSelectContainer']";
|
|
6415
|
-
let node = el.closest(kitSel);
|
|
6416
|
-
while (node) {
|
|
6417
|
-
container = node;
|
|
6418
|
-
const up = node.parentElement && node.parentElement.closest(kitSel);
|
|
6419
|
-
if (!up || up === node) break;
|
|
6420
|
-
node = up;
|
|
6421
|
-
}
|
|
6422
|
-
}
|
|
7589
|
+
const container = containerOf(el);
|
|
6423
7590
|
if (seen.has(container)) continue;
|
|
6424
7591
|
seen.add(container);
|
|
6425
7592
|
resolved.push(container);
|
|
@@ -6441,7 +7608,9 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
6441
7608
|
if (parts.length) return parts.join(" ").replace(/\\s+/g, " ").trim().slice(0, 120);
|
|
6442
7609
|
}
|
|
6443
7610
|
const al = w.getAttribute("aria-label");
|
|
6444
|
-
|
|
7611
|
+
// A trigger's aria-label can merely echo its placeholder ("Select One
|
|
7612
|
+
// Required"), which names no question — defer to the <label>/group lookup.
|
|
7613
|
+
if (al && !emptyRx.test(al.replace(/\\s*required\\s*$/i, ""))) return al.replace(/\\s+/g, " ").trim().slice(0, 120);
|
|
6445
7614
|
const labelledIds = [w.id, ...Array.from(w.querySelectorAll("[id]")).map((e) => e.id)].filter(Boolean);
|
|
6446
7615
|
for (const id of labelledIds) {
|
|
6447
7616
|
const lab = document.querySelector("label[for='" + (window.CSS && CSS.escape ? CSS.escape(id) : id) + "']");
|
|
@@ -6517,7 +7686,7 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
6517
7686
|
logger.info(`prompt-selector primitive: no unambiguous widget match for ${optLabel}; falling through to cascade`);
|
|
6518
7687
|
return null;
|
|
6519
7688
|
}
|
|
6520
|
-
const enumerateOptionsExpr = `((widgetMarkAttr, wIdx, markAttr, optionSel, searchSel) => {
|
|
7689
|
+
const enumerateOptionsExpr = `((widgetMarkAttr, wIdx, markAttr, optionSel, searchSel, triggerSel) => {
|
|
6521
7690
|
const optionLabel = ${OPTION_ACCESSIBLE_LABEL_EXPR};
|
|
6522
7691
|
const w = document.querySelector("[" + widgetMarkAttr + '="' + wIdx + '"]');
|
|
6523
7692
|
if (!w) return { optionsPresent: false };
|
|
@@ -6535,12 +7704,35 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
6535
7704
|
// inline subtree), so a sibling widget's options are never picked. Fall
|
|
6536
7705
|
// back to document only when neither a portal nor an inline popup is found.
|
|
6537
7706
|
const scope = ${PROMPT_SCOPE_ROOT_EXPR}(w);
|
|
6538
|
-
|
|
7707
|
+
// Under the document fallback, ownership is structural: content is foreign
|
|
7708
|
+
// when, climbing from it WITHOUT passing through this widget, some ancestor
|
|
7709
|
+
// is or holds another widget's trigger — e.g. a committed sibling's chips,
|
|
7710
|
+
// whatever markup wraps them. Resolved from the trigger union rather than
|
|
7711
|
+
// the enumeration mark, because a COMMITTED sibling is never marked (only
|
|
7712
|
+
// unfilled widgets are) yet its chips are the likeliest leak. An unlinked
|
|
7713
|
+
// portal sits under no widget, so it stays readable.
|
|
7714
|
+
const containerOf = ${PROMPT_WIDGET_CONTAINER_EXPR};
|
|
7715
|
+
const foreignRoots = scope === document
|
|
7716
|
+
? Array.from(new Set(Array.from(document.querySelectorAll(triggerSel)).map(containerOf)))
|
|
7717
|
+
.filter((o) => o !== w && !o.contains(w) && !w.contains(o))
|
|
7718
|
+
: [];
|
|
7719
|
+
const heldByOtherWidget = (el) => {
|
|
7720
|
+
if (scope !== document || w.contains(el)) return false;
|
|
7721
|
+
for (let a = el; a && a !== document.body && !a.contains(w); a = a.parentElement) {
|
|
7722
|
+
if (a.matches(triggerSel) || a.querySelector(triggerSel)) return true;
|
|
7723
|
+
}
|
|
7724
|
+
return false;
|
|
7725
|
+
};
|
|
7726
|
+
const isOwned = (el) => !foreignRoots.some((o) => o.contains(el)) && !heldByOtherWidget(el);
|
|
7727
|
+
const all = Array.from(scope.querySelectorAll(optionSel)).filter(isOwned);
|
|
6539
7728
|
// A union hit may be an ancestor of another (e.g. role=listbox > li > p):
|
|
6540
7729
|
// keep only leaf-most option nodes with their own text so we don't double
|
|
6541
7730
|
// count or address a wrapper.
|
|
6542
7731
|
const opts = all.filter((el) => !all.some((o) => o !== el && el.contains(o)));
|
|
6543
|
-
const searchInput = scope
|
|
7732
|
+
const searchInput = scope === document
|
|
7733
|
+
? (w.matches(searchSel) || w.matches("input") ? w : w.querySelector(searchSel))
|
|
7734
|
+
: (scope.matches && scope.matches(searchSel) ? scope : null)
|
|
7735
|
+
|| Array.from(scope.querySelectorAll(searchSel)).find(isOwned) || null;
|
|
6544
7736
|
// Mark the scoped filter input so the Node-side fill addresses THIS
|
|
6545
7737
|
// widget's input (Playwright's locator can't re-run the scope resolution).
|
|
6546
7738
|
if (searchInput) searchInput.setAttribute(markAttr + "-search", "1");
|
|
@@ -6548,13 +7740,23 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
6548
7740
|
// is typed into — report the popup as present (with searchable=true) so the
|
|
6549
7741
|
// caller types the filter first, rather than falling through as "no popup".
|
|
6550
7742
|
if (opts.length === 0) {
|
|
6551
|
-
return searchInput ? { optionsPresent: true, searchable: true, options: [] } : { optionsPresent: false };
|
|
7743
|
+
return searchInput ? { optionsPresent: true, searchable: true, options: [], scopeIsDocument: scope === document } : { optionsPresent: false };
|
|
6552
7744
|
}
|
|
6553
7745
|
const options = [];
|
|
6554
7746
|
for (let i = 0; i < opts.length; i++) {
|
|
6555
|
-
opts[i].setAttribute(markAttr, String(i));
|
|
6556
7747
|
const label = optionLabel(opts[i]);
|
|
6557
|
-
|
|
7748
|
+
const ids = window.__bclOptionNodeIds || (window.__bclOptionNodeIds = { map: new WeakMap(), next: 0 });
|
|
7749
|
+
if (!ids.map.has(opts[i])) ids.map.set(opts[i], ids.next++);
|
|
7750
|
+
// Stamped with the node's own identity, not its list position: a
|
|
7751
|
+
// re-enumeration after a virtualized re-render or scroll can reorder
|
|
7752
|
+
// positions, so a positional stamp would re-address a different item.
|
|
7753
|
+
opts[i].setAttribute(markAttr, String(ids.map.get(opts[i])));
|
|
7754
|
+
options.push({
|
|
7755
|
+
oIdx: i,
|
|
7756
|
+
text: label,
|
|
7757
|
+
nodeId: ids.map.get(opts[i]),
|
|
7758
|
+
domText: (opts[i].textContent || "").replace(/\\s+/g, " ").trim(),
|
|
7759
|
+
});
|
|
6558
7760
|
}
|
|
6559
7761
|
// scopeIsDocument distinguishes a genuinely resolved (portal or inline)
|
|
6560
7762
|
// popup from the document-wide last-resort fallback in
|
|
@@ -6564,7 +7766,7 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
6564
7766
|
// that resolved to THIS widget's own inline subtree or its own
|
|
6565
7767
|
// aria-controls/aria-owns target counts.
|
|
6566
7768
|
return { optionsPresent: true, searchable: !!searchInput, options, scopeIsDocument: scope === document };
|
|
6567
|
-
})(${JSON.stringify(PROMPT_WIDGET_MARK_ATTR)}, ${JSON.stringify(chosen.wIdx)}, ${JSON.stringify(PROMPT_OPTION_MARK_ATTR)}, ${JSON.stringify(PROMPT_OPTION_SELECTORS)}, ${JSON.stringify(PROMPT_SEARCH_SELECTORS)})`;
|
|
7769
|
+
})(${JSON.stringify(PROMPT_WIDGET_MARK_ATTR)}, ${JSON.stringify(chosen.wIdx)}, ${JSON.stringify(PROMPT_OPTION_MARK_ATTR)}, ${JSON.stringify(PROMPT_OPTION_SELECTORS)}, ${JSON.stringify(PROMPT_SEARCH_SELECTORS)}, ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)})`;
|
|
6568
7770
|
// Pre-check (single evaluate, no polling): a PRIOR primitive call on this
|
|
6569
7771
|
// same unreloaded page may have already opened/drilled this exact widget's
|
|
6570
7772
|
// popup (a two-level cascading multiselect authored as two flow steps —
|
|
@@ -6576,6 +7778,12 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
6576
7778
|
// an unrelated widget's stale/leftover popup elsewhere on the page as
|
|
6577
7779
|
// "already open"); a genuinely closed popup falls through to the normal
|
|
6578
7780
|
// open-click + poll path below unchanged.
|
|
7781
|
+
// A popup whose aria-controls link lands after the click is not yet
|
|
7782
|
+
// resolvable; a document-scoped read would credit a sibling widget's
|
|
7783
|
+
// content, so keep polling until a non-document scope resolves. The poll
|
|
7784
|
+
// returns the last read when the budget runs out, so a popup that
|
|
7785
|
+
// genuinely has no linkage still resolves through the document fallback.
|
|
7786
|
+
const isOwnScopePopup = (r) => r?.optionsPresent === true && r.scopeIsDocument !== true;
|
|
6579
7787
|
const precheck = (await target.evaluate(enumerateOptionsExpr));
|
|
6580
7788
|
const alreadyOpen = precheck?.optionsPresent === true &&
|
|
6581
7789
|
(precheck.options?.length ?? 0) > 0 &&
|
|
@@ -6601,7 +7809,7 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
6601
7809
|
// Fall back to the container selector.
|
|
6602
7810
|
}
|
|
6603
7811
|
try {
|
|
6604
|
-
await
|
|
7812
|
+
await (0, trusted_click_1.clickCentredTrusted)(target, triggerSel, { label: "prompt-selector trigger" });
|
|
6605
7813
|
}
|
|
6606
7814
|
catch (err) {
|
|
6607
7815
|
logger.info(`prompt-selector primitive: trigger click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
|
|
@@ -6614,7 +7822,7 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
6614
7822
|
}
|
|
6615
7823
|
const optionsInitial = alreadyOpen
|
|
6616
7824
|
? precheck
|
|
6617
|
-
: await pollEnumerate(page, target, enumerateOptionsExpr,
|
|
7825
|
+
: await pollEnumerate(page, target, enumerateOptionsExpr, isOwnScopePopup);
|
|
6618
7826
|
if (!optionsInitial?.optionsPresent) {
|
|
6619
7827
|
logger.info(`prompt-selector primitive: popup for ${optLabel} did not render options; falling through`);
|
|
6620
7828
|
return null;
|
|
@@ -6645,19 +7853,61 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
6645
7853
|
// to its popup), falling back to the union only if the mark is absent.
|
|
6646
7854
|
const scopedSearchSel = `[${PROMPT_OPTION_MARK_ATTR}-search="1"]`;
|
|
6647
7855
|
const marked = await target.locator(scopedSearchSel).count();
|
|
6648
|
-
|
|
6649
|
-
.
|
|
6650
|
-
|
|
6651
|
-
|
|
7856
|
+
if (marked === 0) {
|
|
7857
|
+
logger.info(`prompt-selector primitive: no filter input owned by the widget for ${optLabel}; falling through`);
|
|
7858
|
+
return null;
|
|
7859
|
+
}
|
|
7860
|
+
await target.locator(scopedSearchSel).first().fill(option);
|
|
6652
7861
|
}
|
|
6653
7862
|
catch (err) {
|
|
6654
7863
|
logger.info(`prompt-selector primitive: filter-input type failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
|
|
6655
7864
|
return null;
|
|
6656
7865
|
}
|
|
6657
7866
|
await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
|
|
6658
|
-
|
|
7867
|
+
let optionsFiltered = await pollEnumerate(page, target, enumerateOptionsExpr, isOwnScopePopup);
|
|
7868
|
+
// An as-you-type search may still be in flight; Enter now would re-query with
|
|
7869
|
+
// an empty search and swap in the full list. Search-on-submit widgets never
|
|
7870
|
+
// name the option unprompted, so the wait is bounded.
|
|
7871
|
+
for (let wait = 0; wait < PROMPT_SELECTOR_MAX_SEARCH_WAITS &&
|
|
7872
|
+
optionsFiltered?.optionsPresent &&
|
|
7873
|
+
!promptOptionsNameOption(optionsFiltered, option); wait++) {
|
|
7874
|
+
await page.waitForTimeout(PROMPT_SELECTOR_SEARCH_WAIT_MS);
|
|
7875
|
+
optionsFiltered = (await target.evaluate(enumerateOptionsExpr));
|
|
7876
|
+
}
|
|
7877
|
+
// Search-on-submit widgets keep a placeholder (or unrelated chips) until an
|
|
7878
|
+
// Enter keydown; filter-as-you-type widgets already name the option here.
|
|
7879
|
+
// The list is settled BEFORE each submit decision, so a search-as-you-type
|
|
7880
|
+
// list still re-rendering (stale rows from the prior query) never reads as
|
|
7881
|
+
// "names nothing", and the precondition is re-read right before the key
|
|
7882
|
+
// press so a list that loaded in the meantime is not submitted at.
|
|
7883
|
+
for (let submit = 0;; submit++) {
|
|
7884
|
+
optionsFiltered = optionsFiltered?.optionsPresent
|
|
7885
|
+
? await readStablePromptOptions(page, target, enumerateOptionsExpr, optionsFiltered)
|
|
7886
|
+
: null;
|
|
7887
|
+
if (!optionsFiltered ||
|
|
7888
|
+
promptOptionsNameOption(optionsFiltered, option) ||
|
|
7889
|
+
submit >= PROMPT_SELECTOR_MAX_ENTER_SUBMITS) {
|
|
7890
|
+
break;
|
|
7891
|
+
}
|
|
7892
|
+
const live = (await target.evaluate(enumerateOptionsExpr));
|
|
7893
|
+
if (promptOptionsNameOption(live, option)) {
|
|
7894
|
+
optionsFiltered = live;
|
|
7895
|
+
continue;
|
|
7896
|
+
}
|
|
7897
|
+
logger.info(`prompt-selector primitive: filtered list for ${optLabel} names no matching option; submitting with Enter`);
|
|
7898
|
+
try {
|
|
7899
|
+
await target.evaluate(`(() => { const el = document.querySelector('[${PROMPT_OPTION_MARK_ATTR}-search="1"]'); if (el) el.focus(); })()`);
|
|
7900
|
+
await sendTrustedEnter(page);
|
|
7901
|
+
}
|
|
7902
|
+
catch (err) {
|
|
7903
|
+
logger.info(`prompt-selector primitive: enter submit failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
7904
|
+
break;
|
|
7905
|
+
}
|
|
7906
|
+
await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
|
|
7907
|
+
optionsFiltered = await pollEnumerate(page, target, enumerateOptionsExpr, (r) => isOwnScopePopup(r) && promptOptionsNameOption(r, option));
|
|
7908
|
+
}
|
|
6659
7909
|
if (!optionsFiltered?.optionsPresent) {
|
|
6660
|
-
logger.info(`prompt-selector primitive: filtered popup for ${optLabel} rendered no options; falling through`);
|
|
7910
|
+
logger.info(`prompt-selector primitive: filtered popup for ${optLabel} rendered no stable options; falling through`);
|
|
6661
7911
|
return null;
|
|
6662
7912
|
}
|
|
6663
7913
|
return await commitPromptOption({
|
|
@@ -6679,6 +7929,138 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
6679
7929
|
return null;
|
|
6680
7930
|
}
|
|
6681
7931
|
}
|
|
7932
|
+
/** Cap on re-reads while waiting for the filtered list to stop changing. */
|
|
7933
|
+
const PROMPT_SELECTOR_MAX_STABLE_READS = 5;
|
|
7934
|
+
/**
|
|
7935
|
+
* Re-reads the filtered list until two consecutive reads carry identical
|
|
7936
|
+
* ordered option text AND node identity, so a virtualized list that reuses
|
|
7937
|
+
* nodes is never treated as settled. The last read is returned because each
|
|
7938
|
+
* read re-marks the live nodes; an earlier read's marks may point at detached
|
|
7939
|
+
* elements. Returns null when the list is still changing at the cap, so the
|
|
7940
|
+
* committer is never handed an unsettled list.
|
|
7941
|
+
*/
|
|
7942
|
+
async function readStablePromptOptions(page, target, enumerateOptionsExpr, initial) {
|
|
7943
|
+
const key = (r) => JSON.stringify((r?.options ?? []).map((o) => [o.text, o.nodeId ?? null]));
|
|
7944
|
+
let current = initial;
|
|
7945
|
+
for (let read = 0; read < PROMPT_SELECTOR_MAX_STABLE_READS; read++) {
|
|
7946
|
+
await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
|
|
7947
|
+
const next = (await target.evaluate(enumerateOptionsExpr));
|
|
7948
|
+
const stable = key(next) === key(current);
|
|
7949
|
+
current = next;
|
|
7950
|
+
if (stable)
|
|
7951
|
+
return current;
|
|
7952
|
+
}
|
|
7953
|
+
logger.info("prompt-selector primitive: filtered list still changing after stable-read cap");
|
|
7954
|
+
return null;
|
|
7955
|
+
}
|
|
7956
|
+
/** Cap on click-time re-resolves when the stamped node is re-bound mid-scroll. */
|
|
7957
|
+
const PROMPT_SELECTOR_MAX_CLICK_RESOLVES = 3;
|
|
7958
|
+
/** Bounded wait for an in-flight as-you-type search before concluding it is search-on-submit. */
|
|
7959
|
+
const PROMPT_SELECTOR_MAX_SEARCH_WAITS = 6;
|
|
7960
|
+
const PROMPT_SELECTOR_SEARCH_WAIT_MS = 500;
|
|
7961
|
+
/** Cap on trusted Enter submits for a search-on-submit prompt selector. */
|
|
7962
|
+
const PROMPT_SELECTOR_MAX_ENTER_SUBMITS = 2;
|
|
7963
|
+
/** Shared option-text normalisation so submit detection and commit agree on a match. */
|
|
7964
|
+
function normPromptOptionText(s) {
|
|
7965
|
+
return s.replace(/\s+/g, " ").trim().toLowerCase();
|
|
7966
|
+
}
|
|
7967
|
+
/** Entries a widget renders when it holds no results (e.g. "No Items.", "No results for x"). */
|
|
7968
|
+
const PROMPT_PLACEHOLDER_RE = /^(no\b|nothing\b|none$|loading|searching|type to search|press enter|hit enter|search for|\.\.\.)|\b(no (results?|items?|matches|options?|data|records)|not found|nothing found|0 results?)\b/;
|
|
7969
|
+
/**
|
|
7970
|
+
* Detects whether the list already names the requested option, so the submit
|
|
7971
|
+
* decision never fires Enter at a widget that searches as you type. Looser than
|
|
7972
|
+
* commitPromptOption's exact detMatch on purpose: a rendered label that differs
|
|
7973
|
+
* by a prefix or suffix still means the list answered the query. A shorter
|
|
7974
|
+
* entry the request merely contains (a chip "Oslo" for "University of Oslo")
|
|
7975
|
+
* names nothing requested. Placeholder entries never count, even when they
|
|
7976
|
+
* echo the requested text.
|
|
7977
|
+
*/
|
|
7978
|
+
function promptOptionsNameOption(result, option) {
|
|
7979
|
+
const want = normPromptOptionText(option);
|
|
7980
|
+
if (want === "")
|
|
7981
|
+
return true;
|
|
7982
|
+
return (result?.options ?? []).some((o) => {
|
|
7983
|
+
const text = normPromptOptionText(o.text);
|
|
7984
|
+
if (text === "")
|
|
7985
|
+
return false;
|
|
7986
|
+
if (text === want)
|
|
7987
|
+
return true;
|
|
7988
|
+
if (PROMPT_PLACEHOLDER_RE.test(text))
|
|
7989
|
+
return false;
|
|
7990
|
+
return text.includes(want);
|
|
7991
|
+
});
|
|
7992
|
+
}
|
|
7993
|
+
/** Dispatches a trusted Enter to the focused element (the filter input) via CDP. */
|
|
7994
|
+
async function sendTrustedEnter(page) {
|
|
7995
|
+
const key = { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 };
|
|
7996
|
+
await page.sendCDP("Input.dispatchKeyEvent", { type: "keyDown", text: "\r", ...key });
|
|
7997
|
+
await page.sendCDP("Input.dispatchKeyEvent", { type: "keyUp", ...key });
|
|
7998
|
+
}
|
|
7999
|
+
/** Dispatches a trusted Escape to the focused element via CDP, closing a popup left open. */
|
|
8000
|
+
async function sendTrustedEscape(page) {
|
|
8001
|
+
const key = {
|
|
8002
|
+
key: "Escape",
|
|
8003
|
+
code: "Escape",
|
|
8004
|
+
windowsVirtualKeyCode: 27,
|
|
8005
|
+
nativeVirtualKeyCode: 27,
|
|
8006
|
+
};
|
|
8007
|
+
await page.sendCDP("Input.dispatchKeyEvent", { type: "keyDown", ...key });
|
|
8008
|
+
await page.sendCDP("Input.dispatchKeyEvent", { type: "keyUp", ...key });
|
|
8009
|
+
}
|
|
8010
|
+
/**
|
|
8011
|
+
* Counts rendered listbox / activeListContainer popups other than the chip
|
|
8012
|
+
* list; an emptied ("No Items.") popup still counts because it paints over
|
|
8013
|
+
* neighbouring fields just the same.
|
|
8014
|
+
*/
|
|
8015
|
+
const VISIBLE_LEFTOVER_POPUP_COUNT_EXPR = `(() => {
|
|
8016
|
+
const visible = (el) => {
|
|
8017
|
+
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
|
|
8018
|
+
if (n.hidden || n.getAttribute("aria-hidden") === "true") return false;
|
|
8019
|
+
let cs = null;
|
|
8020
|
+
try { cs = getComputedStyle(n); } catch { cs = null; }
|
|
8021
|
+
if (cs && (cs.display === "none" || cs.visibility === "hidden")) return false;
|
|
8022
|
+
}
|
|
8023
|
+
return true;
|
|
8024
|
+
};
|
|
8025
|
+
const sel = '[role="listbox"], [data-automation-id="activeListContainer"]';
|
|
8026
|
+
return Array.from(document.querySelectorAll(sel)).filter(
|
|
8027
|
+
(el) => !el.matches('[data-automation-id="selectedItemList"]') && !el.closest('[data-automation-id="selectedItemList"]') && visible(el)
|
|
8028
|
+
).length;
|
|
8029
|
+
})()`;
|
|
8030
|
+
/**
|
|
8031
|
+
* Synthetic Escape for a page whose CDP channel is unavailable or whose popup
|
|
8032
|
+
* ignored the trusted one: the popup's own keydown handler is what closes it.
|
|
8033
|
+
*/
|
|
8034
|
+
const IN_PAGE_ESCAPE_EXPR = `(() => {
|
|
8035
|
+
const el = document.activeElement || document.body;
|
|
8036
|
+
for (const type of ["keydown", "keyup"]) {
|
|
8037
|
+
el.dispatchEvent(new KeyboardEvent(type, { key: "Escape", code: "Escape", keyCode: 27, bubbles: true, cancelable: true }));
|
|
8038
|
+
}
|
|
8039
|
+
})()`;
|
|
8040
|
+
/**
|
|
8041
|
+
* Closes any popup still painted after a verified commit so it cannot
|
|
8042
|
+
* intercept the next field's click. Best-effort: a trusted Escape first, then
|
|
8043
|
+
* a synthetic one when the trusted dispatch failed or left the popup painted.
|
|
8044
|
+
*/
|
|
8045
|
+
async function dismissLeftoverPromptPopups(page, target) {
|
|
8046
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
8047
|
+
const count = (await target
|
|
8048
|
+
.evaluate(VISIBLE_LEFTOVER_POPUP_COUNT_EXPR)
|
|
8049
|
+
.catch(() => 0));
|
|
8050
|
+
if (!count)
|
|
8051
|
+
return;
|
|
8052
|
+
logger.info(`prompt-selector primitive: dismissing ${count} leftover popup(s) via Escape`);
|
|
8053
|
+
const trusted = await sendTrustedEscape(page)
|
|
8054
|
+
.then(() => true)
|
|
8055
|
+
.catch((err) => {
|
|
8056
|
+
logger.info(`prompt-selector primitive: Escape dispatch failed: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
8057
|
+
return false;
|
|
8058
|
+
});
|
|
8059
|
+
if (!trusted || attempt > 0)
|
|
8060
|
+
await target.evaluate(IN_PAGE_ESCAPE_EXPR).catch(() => undefined);
|
|
8061
|
+
await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS).catch(() => undefined);
|
|
8062
|
+
}
|
|
8063
|
+
}
|
|
6682
8064
|
/**
|
|
6683
8065
|
* Phase 3 of {@link tryPromptSelectorPrimitive}: given the popup's rendered
|
|
6684
8066
|
* (possibly filtered) option list, match the requested option — deterministic
|
|
@@ -6701,7 +8083,7 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
6701
8083
|
*/
|
|
6702
8084
|
async function commitPromptOption(params) {
|
|
6703
8085
|
const { page, target, logger, anthropic, captureFn, optLabel, option, questionLabel, chosen, enumerateOptionsExpr, } = params;
|
|
6704
|
-
const norm =
|
|
8086
|
+
const norm = normPromptOptionText;
|
|
6705
8087
|
const wantOpt = norm(option);
|
|
6706
8088
|
const optionSetKey = (opts) => opts
|
|
6707
8089
|
.map((o) => norm(o.text))
|
|
@@ -6738,35 +8120,69 @@ async function commitPromptOption(params) {
|
|
|
6738
8120
|
return null;
|
|
6739
8121
|
}
|
|
6740
8122
|
const matchReason = detMatch ? "deterministic" : `LLM: ${(verdict?.reason ?? "").slice(0, 60)}`;
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
8123
|
+
// A virtualized list may reuse nodes between the read and the click, so the
|
|
8124
|
+
// stamped index can point at a different item. Each attempt re-stamps and
|
|
8125
|
+
// re-resolves the matched option by text, and the click itself asserts the
|
|
8126
|
+
// node still carries that item's text after the centring scroll; a mismatch
|
|
8127
|
+
// re-resolves instead of clicking.
|
|
8128
|
+
const wantChosenText = norm(chosenOption.text);
|
|
8129
|
+
let clicked = false;
|
|
8130
|
+
for (let attempt = 0; attempt < PROMPT_SELECTOR_MAX_CLICK_RESOLVES && !clicked; attempt++) {
|
|
8131
|
+
const fresh = (await target.evaluate(enumerateOptionsExpr).catch(() => null));
|
|
8132
|
+
const freshMatches = (fresh?.options ?? []).filter((o) => norm(o.text) === wantChosenText);
|
|
8133
|
+
const freshOption = freshMatches.find((o) => o.nodeId !== undefined && o.nodeId === chosenOption.nodeId) ??
|
|
8134
|
+
freshMatches[0];
|
|
8135
|
+
if (!freshOption) {
|
|
8136
|
+
logger.info(`prompt-selector primitive: matched option no longer present for ${optLabel}; falling through`);
|
|
8137
|
+
return null;
|
|
8138
|
+
}
|
|
8139
|
+
const optionSel = `[${PROMPT_OPTION_MARK_ATTR}="${freshOption.nodeId ?? freshOption.oIdx}"]`;
|
|
8140
|
+
const markedLabel = (await target
|
|
8141
|
+
.evaluate(`((sel, labelOf) => {
|
|
8142
|
+
const el = document.querySelector(sel);
|
|
8143
|
+
return el ? labelOf(el) : null;
|
|
8144
|
+
})(${JSON.stringify(optionSel)}, ${OPTION_ACCESSIBLE_LABEL_EXPR})`)
|
|
8145
|
+
.catch(() => null));
|
|
8146
|
+
if (markedLabel === null || !norm(markedLabel).includes(wantChosenText)) {
|
|
8147
|
+
logger.info(`prompt-selector primitive: marked option node for ${optLabel} no longer shows the matched option; re-resolving`);
|
|
8148
|
+
continue;
|
|
8149
|
+
}
|
|
8150
|
+
try {
|
|
8151
|
+
const expectedDomText = freshOption.domText ?? wantChosenText;
|
|
8152
|
+
await (0, trusted_click_1.clickCentredTrusted)(target, optionSel, {
|
|
8153
|
+
label: "prompt-selector option",
|
|
8154
|
+
expectText: expectedDomText,
|
|
8155
|
+
verifyBeforeClick: async () => (await target
|
|
8156
|
+
.evaluate(`((sel, want) => {
|
|
8157
|
+
const el = document.querySelector(sel);
|
|
8158
|
+
if (!el || !el.isConnected) return false;
|
|
8159
|
+
return (el.textContent || "").replace(/\\s+/g, " ").trim() === want;
|
|
8160
|
+
})(${JSON.stringify(optionSel)}, ${JSON.stringify(expectedDomText)})`)
|
|
8161
|
+
.catch(() => null)) !== false,
|
|
8162
|
+
});
|
|
8163
|
+
clicked = true;
|
|
8164
|
+
}
|
|
8165
|
+
catch (err) {
|
|
8166
|
+
const mismatch = (0, errors_1.toErrorMessage)(err).includes("identity mismatch");
|
|
8167
|
+
logger.info(`prompt-selector primitive: option click ${mismatch ? "withheld" : "failed"} for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; ${mismatch ? "re-resolving" : "falling through"}`);
|
|
8168
|
+
if (!mismatch)
|
|
8169
|
+
return null;
|
|
8170
|
+
}
|
|
6744
8171
|
}
|
|
6745
|
-
|
|
6746
|
-
logger.info(`prompt-selector primitive: option click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
|
|
8172
|
+
if (!clicked)
|
|
6747
8173
|
return null;
|
|
6748
|
-
}
|
|
6749
8174
|
await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
|
|
6750
|
-
const
|
|
8175
|
+
const isRequestedOption = norm(chosenOption.text) === wantOpt;
|
|
6751
8176
|
const readbackExpr = `((wIdx, markAttr, valueSel, emptyRxSrc, emptyRxFlags, wantText, isRequestedLeaf) => {
|
|
6752
8177
|
const isInvalid = ${INVALID_MARKER_EL_EXPR};
|
|
6753
8178
|
const isPairedHiddenSelect = ${exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR};
|
|
6754
8179
|
const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
|
|
6755
|
-
const buttonValue = ${BUTTON_VALUE_EXPR};
|
|
6756
|
-
const optionLabel = ${OPTION_ACCESSIBLE_LABEL_EXPR};
|
|
6757
8180
|
const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
|
|
8181
|
+
const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
|
|
8182
|
+
const announcedValue = ${PROMPT_ANNOUNCED_VALUE_EXPR};
|
|
6758
8183
|
const w = document.querySelector("[" + markAttr + '="' + wIdx + '"]');
|
|
6759
|
-
if (!w) return { ok: false, id: "" };
|
|
6760
|
-
|
|
6761
|
-
// own value text (popup-pollution-safe, see BUTTON_VALUE_EXPR), or a
|
|
6762
|
-
// selection-label node (empty-state phrase treated as no value).
|
|
6763
|
-
const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
|
|
6764
|
-
const adText = adid && document.getElementById(adid) ? norm(optionLabel(document.getElementById(adid))) : "";
|
|
6765
|
-
const own = w.tagName === "INPUT" ? norm(w.value || "") : w.tagName === "BUTTON" ? norm(buttonValue(w)) : "";
|
|
6766
|
-
const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
|
|
6767
|
-
const lblRaw = lbl ? (lbl.textContent || "") : "";
|
|
6768
|
-
const lblText = lblRaw.trim() && !emptyRx.test(lblRaw.trim()) ? norm(lblRaw) : "";
|
|
6769
|
-
const text = adText || own || lblText;
|
|
8184
|
+
if (!w) return { ok: false, id: "", stillInvalid: false };
|
|
8185
|
+
const text = currentText(w, valueSel, emptyRx);
|
|
6770
8186
|
const textMatches = wantText ? text.includes(wantText) : text !== "";
|
|
6771
8187
|
let node = w;
|
|
6772
8188
|
let stillInvalid = false;
|
|
@@ -6819,14 +8235,37 @@ async function commitPromptOption(params) {
|
|
|
6819
8235
|
cnode = cnode.parentElement;
|
|
6820
8236
|
}
|
|
6821
8237
|
}
|
|
6822
|
-
//
|
|
6823
|
-
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
8238
|
+
// The widget's own announcement of what it committed is ground truth when readable.
|
|
8239
|
+
const committed = announcedValue(w);
|
|
8240
|
+
if (committed) {
|
|
8241
|
+
return { ok: !!wantText && committed.includes(wantText), id: w.id || "", stillInvalid, committed };
|
|
8242
|
+
}
|
|
8243
|
+
// A wrong option leaves the widget valid, so !stillInvalid proves nothing
|
|
8244
|
+
// once the widget's own value is readable: a readable value naming another
|
|
8245
|
+
// option is a wrong commit. The "Expanded"/"Collapsed" state words are
|
|
8246
|
+
// announcements, not values. Only a requested-leaf click is refused here —
|
|
8247
|
+
// a category click that swapped the popup must still reach the drill check.
|
|
8248
|
+
const stateOnly = /^(?:expanded|collapsed)\\.?$/.test(text);
|
|
8249
|
+
if (isRequestedLeaf && text !== "" && !stateOnly && !textMatches && !hiddenSelectMatches) {
|
|
8250
|
+
return { ok: false, id: w.id || "", stillInvalid, committed: text };
|
|
8251
|
+
}
|
|
8252
|
+
return { ok: textMatches || hiddenSelectMatches, id: w.id || "", stillInvalid };
|
|
8253
|
+
})(${JSON.stringify(chosen.wIdx)}, ${JSON.stringify(PROMPT_WIDGET_MARK_ATTR)}, ${JSON.stringify(PROMPT_VALUE_SELECTORS)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)}, ${JSON.stringify(norm(chosenOption.text))}, ${JSON.stringify(isRequestedOption)})`;
|
|
8254
|
+
const runReadback = async () => (await target
|
|
8255
|
+
.evaluate(readbackExpr)
|
|
8256
|
+
.catch(() => ({ ok: false, id: "", stillInvalid: true })));
|
|
8257
|
+
let readback = await runReadback();
|
|
8258
|
+
if (readback?.ok) {
|
|
8259
|
+
await dismissLeftoverPromptPopups(page, target);
|
|
8260
|
+
readback = await runReadback();
|
|
8261
|
+
}
|
|
8262
|
+
if (!readback?.ok && readback?.committed) {
|
|
8263
|
+
await dismissLeftoverPromptPopups(page, target);
|
|
8264
|
+
logger.info(`prompt-selector primitive: committed "${(0, pii_redaction_1.maskValueShape)(readback.committed)}" instead of "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel}; falling through`);
|
|
8265
|
+
return null;
|
|
8266
|
+
}
|
|
6828
8267
|
if (readback?.ok) {
|
|
6829
|
-
logger.info(`prompt-selector primitive: selected "${
|
|
8268
|
+
logger.info(`prompt-selector primitive: selected "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} (${matchReason})`);
|
|
6830
8269
|
return readback.id;
|
|
6831
8270
|
}
|
|
6832
8271
|
if (depth === PROMPT_SELECTOR_MAX_DRILL_DEPTH) {
|
|
@@ -6838,12 +8277,20 @@ async function commitPromptOption(params) {
|
|
|
6838
8277
|
const reEnumerated = (await target
|
|
6839
8278
|
.evaluate(enumerateOptionsExpr)
|
|
6840
8279
|
.catch(() => ({ optionsPresent: false })));
|
|
6841
|
-
const newOptions = reEnumerated?.optionsPresent
|
|
8280
|
+
const newOptions = reEnumerated?.optionsPresent && reEnumerated.scopeIsDocument !== true
|
|
8281
|
+
? (reEnumerated.options ?? [])
|
|
8282
|
+
: [];
|
|
6842
8283
|
const drilled = newOptions.length > 0 && optionSetKey(newOptions) !== optionSetKey(options);
|
|
6843
8284
|
if (!drilled) {
|
|
6844
8285
|
logger.info(`prompt-selector primitive: selection for ${optLabel} did not commit; falling through to cascade`);
|
|
6845
8286
|
return null;
|
|
6846
8287
|
}
|
|
8288
|
+
// Category click on the requested option: the drill itself is the observed
|
|
8289
|
+
// effect, and it never commits a value, so only a widget not flagged invalid counts.
|
|
8290
|
+
if (isRequestedOption && !readback.stillInvalid) {
|
|
8291
|
+
logger.info(`prompt-selector primitive: requested option "${(0, pii_redaction_1.maskValueShape)(chosenOption.text)}" for ${optLabel} opened a new option set (${matchReason})`);
|
|
8292
|
+
return readback?.id ?? "";
|
|
8293
|
+
}
|
|
6847
8294
|
logger.info(`prompt-selector primitive: click for ${optLabel} drilled the popup to a new option set; re-matching at the new level`);
|
|
6848
8295
|
optionsResult = { options: newOptions };
|
|
6849
8296
|
}
|
|
@@ -6870,8 +8317,10 @@ async function hasUnfilledRequiredControlForStep(target, instruction) {
|
|
|
6870
8317
|
const label = extractRequiredControlProbeLabel(instruction);
|
|
6871
8318
|
if (!label)
|
|
6872
8319
|
return false;
|
|
6873
|
-
const expr = `((label, triggerSel) => {
|
|
8320
|
+
const expr = `((label, triggerSel, valueSel, emptyRxSrc, emptyRxFlags) => {
|
|
6874
8321
|
const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
|
|
8322
|
+
const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
|
|
8323
|
+
const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
|
|
6875
8324
|
const want = norm(label);
|
|
6876
8325
|
if (!want) return false;
|
|
6877
8326
|
const isInvalid = ${INVALID_MARKER_EL_EXPR};
|
|
@@ -6890,6 +8339,7 @@ async function hasUnfilledRequiredControlForStep(target, instruction) {
|
|
|
6890
8339
|
if (!el) return false;
|
|
6891
8340
|
if (el.getAttribute && el.getAttribute("aria-invalid") === "true") return true;
|
|
6892
8341
|
if (isInvalid(el)) return true;
|
|
8342
|
+
if (el.matches && el.matches(triggerSel)) return currentText(el, valueSel, emptyRx) === "";
|
|
6893
8343
|
const v = ("value" in el) ? el.value : "";
|
|
6894
8344
|
return !v || String(v).trim() === "";
|
|
6895
8345
|
};
|
|
@@ -6940,7 +8390,7 @@ async function hasUnfilledRequiredControlForStep(target, instruction) {
|
|
|
6940
8390
|
if (txt && (txt.includes(want) || want.includes(txt))) return true;
|
|
6941
8391
|
}
|
|
6942
8392
|
return false;
|
|
6943
|
-
})(${JSON.stringify(label)}, ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)})`;
|
|
8393
|
+
})(${JSON.stringify(label)}, ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)}, ${JSON.stringify(PROMPT_VALUE_SELECTORS)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)})`;
|
|
6944
8394
|
try {
|
|
6945
8395
|
return (await target.evaluate(expr)) === true;
|
|
6946
8396
|
}
|
|
@@ -6970,24 +8420,7 @@ async function simulateDragDropUpload(target, fixture, logger) {
|
|
|
6970
8420
|
const fileName = ${JSON.stringify(fixture.name)};
|
|
6971
8421
|
const fileType = ${JSON.stringify(fixture.mimeType)};
|
|
6972
8422
|
const base64 = ${JSON.stringify(base64)};
|
|
6973
|
-
const
|
|
6974
|
-
"uapp-upload",
|
|
6975
|
-
"app-upload",
|
|
6976
|
-
"uapp-resume-upload",
|
|
6977
|
-
"[class*='dropzone']",
|
|
6978
|
-
"[class*='drop-zone']",
|
|
6979
|
-
"[class*='file-drop']",
|
|
6980
|
-
"[class*='upload-zone']",
|
|
6981
|
-
"[class*='uapp-upload']",
|
|
6982
|
-
"[class*='ResumeUpload']",
|
|
6983
|
-
"[class*='resumeUpload']",
|
|
6984
|
-
"[class*='resume-upload']",
|
|
6985
|
-
];
|
|
6986
|
-
let dropZone = null;
|
|
6987
|
-
for (const sel of dropZoneSelectors) {
|
|
6988
|
-
const el = document.querySelector(sel);
|
|
6989
|
-
if (el) { dropZone = el; break; }
|
|
6990
|
-
}
|
|
8423
|
+
const dropZone = ${upload_affordance_expr_1.FIND_DROP_ZONE_EXPR}(document);
|
|
6991
8424
|
if (!dropZone) return { ok: false, reason: "no drop zone found" };
|
|
6992
8425
|
try {
|
|
6993
8426
|
const binStr = atob(base64);
|
|
@@ -7102,6 +8535,12 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
|
|
|
7102
8535
|
const expected = action.arguments?.[0];
|
|
7103
8536
|
if (typeof expected !== "string" || expected.length === 0)
|
|
7104
8537
|
return false;
|
|
8538
|
+
// A lone spinbutton segment holds one slice of a date, so a matching
|
|
8539
|
+
// digit string proves nothing about the whole value, and the
|
|
8540
|
+
// fill("")+type retype below would overwrite committed neighbours.
|
|
8541
|
+
if ((await verifyFillReadback(target, selector, expected))?.outcome === "segment") {
|
|
8542
|
+
return false;
|
|
8543
|
+
}
|
|
7105
8544
|
const current = await locator.inputValue();
|
|
7106
8545
|
// Masked/formatted inputs (e.g. a phone field displaying
|
|
7107
8546
|
// "(212) 555-0123" after typing "2125550123") reformat the typed
|
|
@@ -7340,6 +8779,13 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
|
|
|
7340
8779
|
// owned panel to now render option content before crediting it.
|
|
7341
8780
|
return await comboboxOpenerPanelOpened(target, xpath, preSelectionState);
|
|
7342
8781
|
}
|
|
8782
|
+
// A control exposing aria-checked is framework-managed: its own
|
|
8783
|
+
// aria-checked is the committed state, so a leftover invalid marker
|
|
8784
|
+
// on an ancestor must not veto a genuinely ticked box.
|
|
8785
|
+
const ariaCheckedExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); const el = r.singleNodeValue; return el ? ${ariaCheckedReadExpr("el")} : null; })()`;
|
|
8786
|
+
const ariaChecked = await target.evaluate(ariaCheckedExpr).catch(() => null);
|
|
8787
|
+
if (ariaChecked === "true" || ariaChecked === "false")
|
|
8788
|
+
return ariaChecked === "true";
|
|
7343
8789
|
const isCheckedNow = await locator.isChecked();
|
|
7344
8790
|
if (!isCheckedNow)
|
|
7345
8791
|
return false;
|
|
@@ -7959,20 +9405,32 @@ function extractCodeFromMessage(msg, pattern) {
|
|
|
7959
9405
|
* know the value came from an inbox rather than the flow's payload.
|
|
7960
9406
|
*/
|
|
7961
9407
|
function spliceEmailStepFillValue(step, code) {
|
|
7962
|
-
|
|
7963
|
-
|
|
9408
|
+
const match = (0, quoted_spans_1.matchAnchored)(step, /with\s+'[^']*'/i);
|
|
9409
|
+
return match
|
|
9410
|
+
? `${step.slice(0, match.start)}with '${code}'${step.slice(match.end)}`
|
|
7964
9411
|
: `${step} with '${code}'`;
|
|
7965
9412
|
}
|
|
7966
9413
|
/**
|
|
7967
|
-
* Masks
|
|
7968
|
-
*
|
|
7969
|
-
*
|
|
7970
|
-
*
|
|
7971
|
-
|
|
7972
|
-
|
|
9414
|
+
* Masks a typed/selected value for logs and attempt diagnostics. Values are
|
|
9415
|
+
* caller-supplied answers (or the emailStep-extracted single-use code spliced into
|
|
9416
|
+
* the step, see {@link spliceEmailStepFillValue}), so only their shape is
|
|
9417
|
+
* kept — enough to debug format mismatches, never the value itself.
|
|
9418
|
+
*/
|
|
9419
|
+
function maskLoggedValue(text, sensitiveValue) {
|
|
9420
|
+
return sensitiveValue && text === sensitiveValue ? "[redacted]" : (0, pii_redaction_1.maskValueShape)(text);
|
|
9421
|
+
}
|
|
9422
|
+
/**
|
|
9423
|
+
* Builds the "option ..., question ..." log label shared by the select,
|
|
9424
|
+
* checkbox, radio and prompt-selector primitives. A value parsed from a FILL
|
|
9425
|
+
* step is typed user data (names, emails, passwords), so it is logged only as
|
|
9426
|
+
* its length; other options are still caller answers, so they keep only
|
|
9427
|
+
* their shape via {@link maskLoggedValue}.
|
|
7973
9428
|
*/
|
|
7974
|
-
function
|
|
7975
|
-
|
|
9429
|
+
function primitiveOptionLabel(option, questionLabel, redactValue, fromFill = false) {
|
|
9430
|
+
const shown = fromFill
|
|
9431
|
+
? `[${option.length}-char typed value]`
|
|
9432
|
+
: maskLoggedValue(option, redactValue).slice(0, 40);
|
|
9433
|
+
return `option "${shown}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
|
|
7976
9434
|
}
|
|
7977
9435
|
/**
|
|
7978
9436
|
* Last-resort escalation for the cascade-exhaustion path: when every
|
|
@@ -8006,7 +9464,7 @@ function redactIfSensitive(text, sensitiveValue) {
|
|
|
8006
9464
|
* candidate was actually clickable, or every click phantomed.
|
|
8007
9465
|
*/
|
|
8008
9466
|
async function probeChildFrameSubmitFallback(params) {
|
|
8009
|
-
const { page, signalCounter, step } = params;
|
|
9467
|
+
const { page, signalCounter, step, isMidFlowSubmitStep } = params;
|
|
8010
9468
|
// `page.mainFrameId`/`page.frames` are read defensively: some call sites
|
|
8011
9469
|
// (and their test fakes) model a `Page` that never attaches child frames at
|
|
8012
9470
|
// all and doesn't implement this pair, which is indistinguishable here from
|
|
@@ -8059,13 +9517,18 @@ async function probeChildFrameSubmitFallback(params) {
|
|
|
8059
9517
|
const clickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex))).catch(() => ({ clicked: false })));
|
|
8060
9518
|
if (!clickResult.clicked)
|
|
8061
9519
|
continue;
|
|
9520
|
+
await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, true, async () => {
|
|
9521
|
+
const snap = await snapshotPage(target, signalCounter);
|
|
9522
|
+
return snap.visibleTextSignature;
|
|
9523
|
+
}, pre.visibleTextSignature);
|
|
8062
9524
|
const post = await snapshotPage(target, signalCounter, page);
|
|
8063
9525
|
const verdict = (0, phantom_click_1.classifyPhantomClick)({
|
|
8064
9526
|
actResultSuccess: true,
|
|
8065
9527
|
pre,
|
|
8066
9528
|
post,
|
|
8067
9529
|
isSubmitShapedStep: true,
|
|
8068
|
-
|
|
9530
|
+
isMidFlowSubmitStep,
|
|
9531
|
+
destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, isMidFlowSubmitStep ? pre.url : undefined),
|
|
8069
9532
|
});
|
|
8070
9533
|
if (verdict !== "phantom")
|
|
8071
9534
|
return target;
|
|
@@ -8075,21 +9538,52 @@ async function probeChildFrameSubmitFallback(params) {
|
|
|
8075
9538
|
const runnerUpClickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))).catch(() => ({ clicked: false })));
|
|
8076
9539
|
if (!runnerUpClickResult.clicked)
|
|
8077
9540
|
continue;
|
|
9541
|
+
await (0, click_settle_1.settleAfterClick)(page, signalCounter, post.networkCount, true, async () => {
|
|
9542
|
+
const snap = await snapshotPage(target, signalCounter);
|
|
9543
|
+
return snap.visibleTextSignature;
|
|
9544
|
+
}, post.visibleTextSignature);
|
|
8078
9545
|
const runnerUpPost = await snapshotPage(target, signalCounter, page);
|
|
8079
9546
|
const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
|
|
8080
9547
|
actResultSuccess: true,
|
|
8081
9548
|
pre,
|
|
8082
9549
|
post: runnerUpPost,
|
|
8083
9550
|
isSubmitShapedStep: true,
|
|
8084
|
-
|
|
9551
|
+
isMidFlowSubmitStep,
|
|
9552
|
+
destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, runnerUpPost.url, isMidFlowSubmitStep ? post.url : undefined),
|
|
8085
9553
|
});
|
|
8086
9554
|
if (runnerUpVerdict !== "phantom")
|
|
8087
9555
|
return target;
|
|
8088
9556
|
}
|
|
8089
9557
|
return null;
|
|
8090
9558
|
}
|
|
9559
|
+
/**
|
|
9560
|
+
* Rejects with the signal's reason as soon as it aborts, so an in-step await
|
|
9561
|
+
* on a torn-down session stops instead of waiting out its own timeout.
|
|
9562
|
+
* Passes the promise through untouched when no signal is supplied.
|
|
9563
|
+
*/
|
|
9564
|
+
async function raceAbortSignal(work, signal) {
|
|
9565
|
+
if (!signal)
|
|
9566
|
+
return work;
|
|
9567
|
+
if (signal.aborted) {
|
|
9568
|
+
work.catch(() => undefined);
|
|
9569
|
+
throw signal.reason;
|
|
9570
|
+
}
|
|
9571
|
+
let onAbort;
|
|
9572
|
+
const aborted = new Promise((_, reject) => {
|
|
9573
|
+
onAbort = () => reject(signal.reason);
|
|
9574
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
9575
|
+
});
|
|
9576
|
+
try {
|
|
9577
|
+
return await Promise.race([work, aborted]);
|
|
9578
|
+
}
|
|
9579
|
+
finally {
|
|
9580
|
+
if (onAbort)
|
|
9581
|
+
signal.removeEventListener("abort", onAbort);
|
|
9582
|
+
work.catch(() => undefined);
|
|
9583
|
+
}
|
|
9584
|
+
}
|
|
8091
9585
|
async function executeStepWithHealing(params) {
|
|
8092
|
-
const { stagehand,
|
|
9586
|
+
const { stagehand, optional, upload, submitStep, captchaGated = false, emailStep = false, emailStepConfig, allocatedInbox = null, sessionProxy = null, flowHasSubmitSemantics: flowHasSubmitSemanticsFlag, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, rephraseModel, logger, captureFn, uploadFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, onStepHeal, } = params;
|
|
8093
9587
|
// Mutable: the `emailStep` code-extract path splices the extracted code
|
|
8094
9588
|
// into this instruction (see the emailStep hook block below) so the
|
|
8095
9589
|
// pre-existing fill cascade downstream picks it up as the value to type
|
|
@@ -8106,6 +9600,9 @@ async function executeStepWithHealing(params) {
|
|
|
8106
9600
|
// function keeps reading `frameTarget` unchanged; only its declaration
|
|
8107
9601
|
// moved so it can be reassigned.
|
|
8108
9602
|
let frameTarget = params.frameTarget;
|
|
9603
|
+
// Rebound when a click opens a new browsing context, so every later probe,
|
|
9604
|
+
// verifier and key event in the cascade reads the page the click navigated to.
|
|
9605
|
+
let page = params.page;
|
|
8109
9606
|
let frameReresolveAttempted = false;
|
|
8110
9607
|
// Emailed-verification hook. Runs FIRST — before upload/select/checkbox/
|
|
8111
9608
|
// radio/prompt-selector/captcha or any other cascade primitive — so an
|
|
@@ -8263,7 +9760,11 @@ async function executeStepWithHealing(params) {
|
|
|
8263
9760
|
// Submit, and unfilled required fields produce silent submit failures
|
|
8264
9761
|
// (verified 2026-06-15 on one measured tenant's telemetry). Site-agnostic: any flow
|
|
8265
9762
|
// whose submit is mid-list can mark its submit step explicitly.
|
|
8266
|
-
const
|
|
9763
|
+
const stepDeclaredNonSubmit = isStepDeclaredNonSubmit({ step, submitStep });
|
|
9764
|
+
// Single final-step inference every submit-strictness gate below reads, so a
|
|
9765
|
+
// declared non-submit veto cannot be honoured at one site and missed at another.
|
|
9766
|
+
const inferredFinalSubmitStep = isFinalStep && !stepDeclaredNonSubmit;
|
|
9767
|
+
const requireSubmitEndpoint = (inferredFinalSubmitStep || submitStep) && submitEndpointPattern !== null;
|
|
8267
9768
|
// Capture the per-element selection baseline (for verifyDomEffect's element-
|
|
8268
9769
|
// scoped click read-back) ONLY for selection/field-answer click steps — never
|
|
8269
9770
|
// for submit/advance steps, whose own self-toggling buttons must not
|
|
@@ -8291,6 +9792,7 @@ async function executeStepWithHealing(params) {
|
|
|
8291
9792
|
fixture: uploadFixture,
|
|
8292
9793
|
logger,
|
|
8293
9794
|
signalCounter,
|
|
9795
|
+
browserbaseSessionId: stagehand.browserbaseSessionID,
|
|
8294
9796
|
recentCaptureMeta,
|
|
8295
9797
|
})) {
|
|
8296
9798
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by upload primitive`);
|
|
@@ -8341,6 +9843,17 @@ async function executeStepWithHealing(params) {
|
|
|
8341
9843
|
trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: checkboxTargetId });
|
|
8342
9844
|
return "completed";
|
|
8343
9845
|
}
|
|
9846
|
+
const checkboxStateTargetId = await tryCheckboxStatePrimitive({
|
|
9847
|
+
page,
|
|
9848
|
+
target: selectFrameTarget,
|
|
9849
|
+
instruction: step,
|
|
9850
|
+
logger,
|
|
9851
|
+
});
|
|
9852
|
+
if (checkboxStateTargetId !== null) {
|
|
9853
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by checkbox state primitive`);
|
|
9854
|
+
trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: checkboxStateTargetId });
|
|
9855
|
+
return "completed";
|
|
9856
|
+
}
|
|
8344
9857
|
// When the step is a single-choice RADIO answer ("Click the 'Yes' answer for
|
|
8345
9858
|
// the question '…'"), commit it directly in the DOM. Runs AFTER select/
|
|
8346
9859
|
// checkbox (which own their verbs) and BEFORE the cascade, so radios never
|
|
@@ -8386,6 +9899,19 @@ async function executeStepWithHealing(params) {
|
|
|
8386
9899
|
trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: promptSelectorTargetId });
|
|
8387
9900
|
return "completed";
|
|
8388
9901
|
}
|
|
9902
|
+
// Multi-segment date spinbuttons need real key events; the cascade's
|
|
9903
|
+
// insertText would garble the segments. Falls through on null.
|
|
9904
|
+
const segmentedDateTargetId = await trySegmentedDateSpinbuttonPrimitive({
|
|
9905
|
+
page,
|
|
9906
|
+
target: selectFrameTarget,
|
|
9907
|
+
instruction: step,
|
|
9908
|
+
logger,
|
|
9909
|
+
});
|
|
9910
|
+
if (segmentedDateTargetId !== null) {
|
|
9911
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by segmented-date primitive`);
|
|
9912
|
+
trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: segmentedDateTargetId });
|
|
9913
|
+
return "completed";
|
|
9914
|
+
}
|
|
8389
9915
|
// On a CATCH-ALL step ("for any remaining … question"), fill every
|
|
8390
9916
|
// required-but-empty native <select> — including MuiNativeSelect dropdowns
|
|
8391
9917
|
// (tabindex=-1) that Stagehand observe can't see and that no concrete flow
|
|
@@ -8415,7 +9941,13 @@ async function executeStepWithHealing(params) {
|
|
|
8415
9941
|
// when no solver key is configured or the solve otherwise fails — that
|
|
8416
9942
|
// propagates out of this function unchanged, so the step fails cleanly
|
|
8417
9943
|
// instead of silently falling through as if unverified-but-passing.
|
|
8418
|
-
const isSubmitOrFinalStep =
|
|
9944
|
+
const isSubmitOrFinalStep = isStepSubmitShaped({
|
|
9945
|
+
step,
|
|
9946
|
+
submitStep,
|
|
9947
|
+
isFinalStep,
|
|
9948
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
9949
|
+
});
|
|
9950
|
+
const isSettleWaitStep = isSubmitOrFinalStep || isAdvanceStep(step);
|
|
8419
9951
|
if (captchaGated && isSubmitOrFinalStep) {
|
|
8420
9952
|
let captchaTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
|
|
8421
9953
|
const sitekeyProbeExpr = `(() => {
|
|
@@ -8559,6 +10091,12 @@ async function executeStepWithHealing(params) {
|
|
|
8559
10091
|
signalCounter,
|
|
8560
10092
|
page,
|
|
8561
10093
|
step,
|
|
10094
|
+
isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
|
|
10095
|
+
step,
|
|
10096
|
+
submitStep,
|
|
10097
|
+
isFinalStep,
|
|
10098
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
10099
|
+
}),
|
|
8562
10100
|
});
|
|
8563
10101
|
}
|
|
8564
10102
|
catch (err) {
|
|
@@ -8703,7 +10241,8 @@ async function executeStepWithHealing(params) {
|
|
|
8703
10241
|
// candidate matching the step's instruction we either skip cleanly
|
|
8704
10242
|
// (optional) or escalate straight to replan (required) — far cheaper than
|
|
8705
10243
|
// burning 4 attempts on a page that clearly isn't the right one.
|
|
8706
|
-
|
|
10244
|
+
params.abortSignal?.throwIfAborted();
|
|
10245
|
+
const probeResult = await raceAbortSignal(probeStepBeforeAttempts({
|
|
8707
10246
|
stagehand,
|
|
8708
10247
|
page,
|
|
8709
10248
|
step,
|
|
@@ -8716,7 +10255,8 @@ async function executeStepWithHealing(params) {
|
|
|
8716
10255
|
await reresolveFrameTargetIfLost();
|
|
8717
10256
|
return frameTarget;
|
|
8718
10257
|
},
|
|
8719
|
-
});
|
|
10258
|
+
}), params.abortSignal);
|
|
10259
|
+
params.abortSignal?.throwIfAborted();
|
|
8720
10260
|
if (probeResult === "absent") {
|
|
8721
10261
|
if (optional) {
|
|
8722
10262
|
// Don't fast-skip when the page plainly has a required, still-empty
|
|
@@ -8759,7 +10299,7 @@ async function executeStepWithHealing(params) {
|
|
|
8759
10299
|
});
|
|
8760
10300
|
if (backendErrorUrl !== null) {
|
|
8761
10301
|
logger.error(`${formatStepPrefix(stepIndex, totalSteps)} backend error detected (submit endpoint returned 5xx: ${backendErrorUrl}); aborting cascade`);
|
|
8762
|
-
throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
|
|
10302
|
+
throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactSecrets)(step).slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
|
|
8763
10303
|
}
|
|
8764
10304
|
// Bounded same-origin child-iframe fallback: the probe above only ever
|
|
8765
10305
|
// looked at the resolved (declared-or-main) frame's own light+shadow DOM,
|
|
@@ -8772,8 +10312,23 @@ async function executeStepWithHealing(params) {
|
|
|
8772
10312
|
// a radio/select answer) could complete the step by clicking an unrelated
|
|
8773
10313
|
// submit control in an unrelated same-origin child iframe.
|
|
8774
10314
|
if (!frameTarget?.declaredFrameSelector &&
|
|
8775
|
-
(
|
|
8776
|
-
|
|
10315
|
+
isStepSubmitShaped({
|
|
10316
|
+
step,
|
|
10317
|
+
submitStep,
|
|
10318
|
+
isFinalStep,
|
|
10319
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
10320
|
+
})) {
|
|
10321
|
+
const fallbackTarget = await probeChildFrameSubmitFallback({
|
|
10322
|
+
page,
|
|
10323
|
+
signalCounter,
|
|
10324
|
+
step,
|
|
10325
|
+
isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
|
|
10326
|
+
step,
|
|
10327
|
+
submitStep,
|
|
10328
|
+
isFinalStep,
|
|
10329
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
10330
|
+
}),
|
|
10331
|
+
});
|
|
8777
10332
|
if (fallbackTarget) {
|
|
8778
10333
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} probe-absent: no candidate in the resolved frame, but a same-origin child iframe surfaced a submit-shaped candidate — clicked and verified there instead of failing`);
|
|
8779
10334
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
@@ -8796,6 +10351,7 @@ async function executeStepWithHealing(params) {
|
|
|
8796
10351
|
const fieldValuesAtFailure = await (frameTarget ?? page)
|
|
8797
10352
|
.evaluate(fieldValuesAtFailureExpr())
|
|
8798
10353
|
.catch(() => null);
|
|
10354
|
+
params.abortSignal?.throwIfAborted();
|
|
8799
10355
|
const probeAbsentObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
|
|
8800
10356
|
// observe() is blind to a cross-origin OOPIF, so a frame-scoped empty
|
|
8801
10357
|
// result degrades to the deep-locator resolver for dump evidence — this
|
|
@@ -8806,7 +10362,12 @@ async function executeStepWithHealing(params) {
|
|
|
8806
10362
|
const unfocusedObserve = probeAbsentObservedUnfocused.length === 0 && frameTarget?.frame
|
|
8807
10363
|
? await deepLocatorCandidatesAsActions(page, frameTarget)
|
|
8808
10364
|
: probeAbsentObservedUnfocused;
|
|
8809
|
-
const targetResolutionDiagnostic =
|
|
10365
|
+
const targetResolutionDiagnostic = isStepSubmitShaped({
|
|
10366
|
+
step,
|
|
10367
|
+
submitStep,
|
|
10368
|
+
isFinalStep,
|
|
10369
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
10370
|
+
})
|
|
8810
10371
|
? await (0, target_resolution_diagnostic_1.captureTargetResolutionDiagnosticSnapshot)(frameTarget, page)
|
|
8811
10372
|
: null;
|
|
8812
10373
|
const dumpPath = onStepFailure?.({
|
|
@@ -8823,7 +10384,7 @@ async function executeStepWithHealing(params) {
|
|
|
8823
10384
|
targetResolutionDiagnostic,
|
|
8824
10385
|
fieldValuesAtFailure,
|
|
8825
10386
|
}) ?? null;
|
|
8826
|
-
throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
|
|
10387
|
+
throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactSecrets)(step).slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
|
|
8827
10388
|
}
|
|
8828
10389
|
// Pre-submit form-validity probe. Fires on the canonical submit step
|
|
8829
10390
|
// (either the final flow step OR a step explicitly flagged `submitStep:
|
|
@@ -9044,7 +10605,7 @@ async function executeStepWithHealing(params) {
|
|
|
9044
10605
|
if (!frameTarget?.frame) {
|
|
9045
10606
|
const topWindowTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
|
|
9046
10607
|
try {
|
|
9047
|
-
await (0, watchdog_1.withWatchdog)(() =>
|
|
10608
|
+
await (0, watchdog_1.withWatchdog)(() => (0, trusted_click_1.clickCentredTrusted)(topWindowTarget, selector), {
|
|
9048
10609
|
timeoutMs: N16_TRUSTED_CLICK_TIMEOUT_MS,
|
|
9049
10610
|
label: "n+16 fallback: trusted click delivery",
|
|
9050
10611
|
});
|
|
@@ -9082,6 +10643,26 @@ async function executeStepWithHealing(params) {
|
|
|
9082
10643
|
return { delivered: false, reason: "threw", message: (0, errors_1.toErrorMessage)(err) };
|
|
9083
10644
|
}
|
|
9084
10645
|
};
|
|
10646
|
+
// Baseline is per step, not per attempt, so a tab opened by a click whose
|
|
10647
|
+
// attempt then failed is still new to the next attempt. Hand-built sessions in
|
|
10648
|
+
// unit tests carry no browser context (or one without a page list); there is no
|
|
10649
|
+
// tab to follow.
|
|
10650
|
+
const followableContext = () => typeof stagehand.context?.pages === "function" ? stagehand.context : null;
|
|
10651
|
+
let knownPages = followableContext()?.pages() ?? [];
|
|
10652
|
+
const adoptOpenedPage = async (discoverWaitMs) => {
|
|
10653
|
+
const context = followableContext();
|
|
10654
|
+
if (!context)
|
|
10655
|
+
return false;
|
|
10656
|
+
const followed = await (0, active_page_1.followNewPage)(context, page, knownPages, discoverWaitMs);
|
|
10657
|
+
if (followed === page)
|
|
10658
|
+
return false;
|
|
10659
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} click opened a new page; following it`);
|
|
10660
|
+
page = followed;
|
|
10661
|
+
frameTarget = undefined;
|
|
10662
|
+
knownPages = context.pages();
|
|
10663
|
+
params.onPageAdopted?.(followed);
|
|
10664
|
+
return true;
|
|
10665
|
+
};
|
|
9085
10666
|
let phantomClickAfterAttempt1 = false;
|
|
9086
10667
|
let attempt1UnreachableViaLightDom = false;
|
|
9087
10668
|
// Last candidate list the deep-submit-locator itself ranked this step, if
|
|
@@ -9091,6 +10672,7 @@ async function executeStepWithHealing(params) {
|
|
|
9091
10672
|
// already has an up-to-date ranking to report from.
|
|
9092
10673
|
let lastRankedSubmitCandidates = null;
|
|
9093
10674
|
for (let attempt = 1; attempt <= MAX_STEP_ATTEMPTS; attempt++) {
|
|
10675
|
+
params.abortSignal?.throwIfAborted();
|
|
9094
10676
|
// Telemetry-driven technique-skip: when a cascade technique's
|
|
9095
10677
|
// preconditions cannot be met by the prior attempts' state, running
|
|
9096
10678
|
// it would burn the attempt slot without exercising new behaviour.
|
|
@@ -9099,7 +10681,12 @@ async function executeStepWithHealing(params) {
|
|
|
9099
10681
|
if (attempt > 1) {
|
|
9100
10682
|
const wouldBeTechnique = attempt === 2
|
|
9101
10683
|
? attempt1UnreachableViaLightDom &&
|
|
9102
|
-
(
|
|
10684
|
+
isStepSubmitShaped({
|
|
10685
|
+
step,
|
|
10686
|
+
submitStep,
|
|
10687
|
+
isFinalStep,
|
|
10688
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
10689
|
+
})
|
|
9103
10690
|
? "deep-submit-locator"
|
|
9104
10691
|
: phantomClickAfterAttempt1
|
|
9105
10692
|
? "trusted-click-retry"
|
|
@@ -9118,7 +10705,12 @@ async function executeStepWithHealing(params) {
|
|
|
9118
10705
|
})),
|
|
9119
10706
|
advanceUnmovedAfterAttempt1,
|
|
9120
10707
|
attempt1UnreachableViaLightDom,
|
|
9121
|
-
submitShapedStep:
|
|
10708
|
+
submitShapedStep: isStepSubmitShaped({
|
|
10709
|
+
step,
|
|
10710
|
+
submitStep,
|
|
10711
|
+
isFinalStep,
|
|
10712
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
10713
|
+
}),
|
|
9122
10714
|
});
|
|
9123
10715
|
if (decision.skip) {
|
|
9124
10716
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
|
|
@@ -9127,8 +10719,12 @@ async function executeStepWithHealing(params) {
|
|
|
9127
10719
|
}
|
|
9128
10720
|
}
|
|
9129
10721
|
if (attempt > 1) {
|
|
9130
|
-
await page.waitForTimeout(attempt * ATTEMPT_BACKOFF_MS);
|
|
10722
|
+
await raceAbortSignal(page.waitForTimeout(attempt * ATTEMPT_BACKOFF_MS), params.abortSignal);
|
|
10723
|
+
params.abortSignal?.throwIfAborted();
|
|
9131
10724
|
}
|
|
10725
|
+
// A click from an earlier attempt that errored after opening a tab leaves the
|
|
10726
|
+
// run on the stale page; pick the tab up before measuring anything.
|
|
10727
|
+
await adoptOpenedPage(0);
|
|
9132
10728
|
const pre = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page, captureSelectionState);
|
|
9133
10729
|
// Snapshot the meta-tail length so the final-step pattern gate can scope
|
|
9134
10730
|
// its URL scan to captures added DURING this attempt (not historical
|
|
@@ -9164,6 +10760,23 @@ async function executeStepWithHealing(params) {
|
|
|
9164
10760
|
// whether this attempt's signal should come from the network/URL pair or
|
|
9165
10761
|
// from DOM re-read. Captured here so both branches of the cascade write to it.
|
|
9166
10762
|
let resolvedAction = null;
|
|
10763
|
+
// Stagehand resolves a natural-language act target internally, so for a
|
|
10764
|
+
// click-intent instruction resolve it with observe first and centre it;
|
|
10765
|
+
// a sticky bar otherwise swallows Stagehand's own click. Throws when the
|
|
10766
|
+
// control stays covered so the attempt fails instead of clicking an overlay.
|
|
10767
|
+
// Resolves true once the observe ran, so guardedAct skips its own pass.
|
|
10768
|
+
const preCentreNaturalLanguageClick = async (instruction) => {
|
|
10769
|
+
if (!stagehand_guard_1.CLICK_INTENT_RE.test(instruction))
|
|
10770
|
+
return false;
|
|
10771
|
+
const observed = await (0, stagehand_guard_1.guardedObserve)(stagehand, instruction, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
|
|
10772
|
+
const first = observed[0];
|
|
10773
|
+
if (first?.method !== "click" || !first.selector)
|
|
10774
|
+
return true;
|
|
10775
|
+
const failure = await centreObservedClickTarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), first.selector, (msg) => logger.debug(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${msg}`), true);
|
|
10776
|
+
if (failure)
|
|
10777
|
+
throw new Error(failure);
|
|
10778
|
+
return true;
|
|
10779
|
+
};
|
|
9167
10780
|
try {
|
|
9168
10781
|
// Field-label-first routing: for a frame-scoped fill/select step,
|
|
9169
10782
|
// Stagehand's own act()/observe() can resolve a real, in-DOM, plausible-
|
|
@@ -9194,7 +10807,7 @@ async function executeStepWithHealing(params) {
|
|
|
9194
10807
|
triedSelectors.push(fieldLabelOutcome.matched.selector);
|
|
9195
10808
|
record.triedSelectors = [fieldLabelOutcome.matched.selector];
|
|
9196
10809
|
record.actResultSuccess = true;
|
|
9197
|
-
record.actResultDescription = `deepLocator ${fieldLabelOutcome.fieldTarget.kind === "fill" ? "filled" : "selected"} "${fieldLabelOutcome.matched.accessibleText || fieldLabelOutcome.matched.selector}" with "${fieldLabelOutcome.fieldTarget.value}"`;
|
|
10810
|
+
record.actResultDescription = `deepLocator ${fieldLabelOutcome.fieldTarget.kind === "fill" ? "filled" : "selected"} "${fieldLabelOutcome.matched.accessibleText || fieldLabelOutcome.matched.selector}" with "${(0, pii_redaction_1.maskValueShape)(fieldLabelOutcome.fieldTarget.value)}"`;
|
|
9198
10811
|
// Same rationale as the pre-existing candidates===0 branch below:
|
|
9199
10812
|
// the write + read-back the actuation helper already performed IS
|
|
9200
10813
|
// the verification signal — no resolvedAction/network/url verifier
|
|
@@ -9227,7 +10840,9 @@ async function executeStepWithHealing(params) {
|
|
|
9227
10840
|
if (attempt === 1) {
|
|
9228
10841
|
record.technique = "act-string";
|
|
9229
10842
|
record.instruction = step;
|
|
9230
|
-
|
|
10843
|
+
params.abortSignal?.throwIfAborted();
|
|
10844
|
+
const preCentred = await preCentreNaturalLanguageClick(step);
|
|
10845
|
+
const result = await (0, stagehand_guard_1.guardedAct)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), preCentred);
|
|
9231
10846
|
record.actResultSuccess = result.success;
|
|
9232
10847
|
record.actResultDescription = result.actionDescription;
|
|
9233
10848
|
// Deny-list guard (post-hoc): attempt-1 act resolves internally, so we
|
|
@@ -9256,7 +10871,12 @@ async function executeStepWithHealing(params) {
|
|
|
9256
10871
|
}
|
|
9257
10872
|
else if (attempt === 2 &&
|
|
9258
10873
|
attempt1UnreachableViaLightDom &&
|
|
9259
|
-
(
|
|
10874
|
+
isStepSubmitShaped({
|
|
10875
|
+
step,
|
|
10876
|
+
submitStep,
|
|
10877
|
+
isFinalStep,
|
|
10878
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
10879
|
+
})) {
|
|
9260
10880
|
// Deep submit-control locator: attempt 1 either phantom-clicked
|
|
9261
10881
|
// (Stagehand reported success but pre/post showed zero effect) or
|
|
9262
10882
|
// came back "unresolved" (act() found zero candidates at all), so the
|
|
@@ -9331,6 +10951,10 @@ async function executeStepWithHealing(params) {
|
|
|
9331
10951
|
// burn the step budget probing all of them.
|
|
9332
10952
|
const runnerUp = ranked[1];
|
|
9333
10953
|
if (runnerUp) {
|
|
10954
|
+
await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, true, async () => {
|
|
10955
|
+
const snap = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
|
|
10956
|
+
return snap.visibleTextSignature;
|
|
10957
|
+
}, pre.visibleTextSignature);
|
|
9334
10958
|
const midPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
|
|
9335
10959
|
const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
|
|
9336
10960
|
actResultSuccess: true,
|
|
@@ -9340,7 +10964,20 @@ async function executeStepWithHealing(params) {
|
|
|
9340
10964
|
// stray selection flip must not mask a top-pick phantom and skip
|
|
9341
10965
|
// the runner-up retry.
|
|
9342
10966
|
isSubmitShapedStep: true,
|
|
9343
|
-
|
|
10967
|
+
isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
|
|
10968
|
+
step,
|
|
10969
|
+
submitStep,
|
|
10970
|
+
isFinalStep,
|
|
10971
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
10972
|
+
}),
|
|
10973
|
+
destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, midPost.url, isMidFlowSubmitShapedStep({
|
|
10974
|
+
step,
|
|
10975
|
+
submitStep,
|
|
10976
|
+
isFinalStep,
|
|
10977
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
10978
|
+
})
|
|
10979
|
+
? pre.url
|
|
10980
|
+
: undefined),
|
|
9344
10981
|
});
|
|
9345
10982
|
if (topVerdict === "phantom") {
|
|
9346
10983
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} deep-submit-locator top pick (${top.tag} "${top.accessibleName}") phantom-clicked; retrying runner-up ${runnerUp.tag} "${runnerUp.accessibleName}" (tier ${runnerUp.tier})`);
|
|
@@ -9383,7 +11020,12 @@ async function executeStepWithHealing(params) {
|
|
|
9383
11020
|
}
|
|
9384
11021
|
else if (attempt === 2 &&
|
|
9385
11022
|
phantomClickAfterAttempt1 &&
|
|
9386
|
-
!(
|
|
11023
|
+
!isStepSubmitShaped({
|
|
11024
|
+
step,
|
|
11025
|
+
submitStep,
|
|
11026
|
+
isFinalStep,
|
|
11027
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
11028
|
+
})) {
|
|
9387
11029
|
// Trusted-click retry: attempt 1 phantom-clicked a NON-submit control —
|
|
9388
11030
|
// Stagehand reported success but pre/post showed zero effect. On a
|
|
9389
11031
|
// design-system widget (React synthetic-event delegation, custom
|
|
@@ -9460,7 +11102,8 @@ async function executeStepWithHealing(params) {
|
|
|
9460
11102
|
: { found: false };
|
|
9461
11103
|
if (markResult.found) {
|
|
9462
11104
|
try {
|
|
9463
|
-
|
|
11105
|
+
const markerSelector = `[${markerAttr}="${markerValue}"]`;
|
|
11106
|
+
await (0, trusted_click_1.clickAfterCentredHitTest)((0, trusted_click_1.firstMatchClickable)(topWindowTarget.locator(markerSelector)), () => topWindowTarget.evaluate(buildDeepMarkerCentreExpr(markerSelector)), { label: markerSelector });
|
|
9464
11107
|
clickedShadowDescendant = true;
|
|
9465
11108
|
}
|
|
9466
11109
|
finally {
|
|
@@ -9470,7 +11113,7 @@ async function executeStepWithHealing(params) {
|
|
|
9470
11113
|
}
|
|
9471
11114
|
}
|
|
9472
11115
|
else {
|
|
9473
|
-
await
|
|
11116
|
+
await (0, trusted_click_1.clickCentredTrusted)(topWindowTarget, topWindowSelector);
|
|
9474
11117
|
}
|
|
9475
11118
|
}
|
|
9476
11119
|
catch (err) {
|
|
@@ -9531,6 +11174,7 @@ async function executeStepWithHealing(params) {
|
|
|
9531
11174
|
const observeOptions = attempt === 4 && triedSelectors.length > 0
|
|
9532
11175
|
? { ignoreSelectors: [...triedSelectors], timeout: exports.STEP_WATCHDOG_MS }
|
|
9533
11176
|
: { timeout: exports.STEP_WATCHDOG_MS };
|
|
11177
|
+
params.abortSignal?.throwIfAborted();
|
|
9534
11178
|
const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, observeOptions, captureFn, frameTarget);
|
|
9535
11179
|
// observe() is blind to a cross-origin OOPIF (see
|
|
9536
11180
|
// deep-locator-candidates.ts's module docblock) — when the step is
|
|
@@ -9666,8 +11310,18 @@ async function executeStepWithHealing(params) {
|
|
|
9666
11310
|
}
|
|
9667
11311
|
}
|
|
9668
11312
|
else {
|
|
9669
|
-
|
|
9670
|
-
|
|
11313
|
+
const quotedPhrases = stepQuotesValueNotControlName(step)
|
|
11314
|
+
? []
|
|
11315
|
+
: (0, deep_locator_candidates_1.extractTaggedPhrases)(step);
|
|
11316
|
+
const verdict = (0, deep_locator_candidates_1.vetObserveCandidates)(candidates, quotedPhrases);
|
|
11317
|
+
if (verdict.refuse || !verdict.candidate) {
|
|
11318
|
+
record.errorMessage = `observe candidate does not match the control named in the step: "${(verdict.candidate?.description ?? "").slice(0, 60)}" (refusing to act on an unscored guess)`;
|
|
11319
|
+
attempts.push(record);
|
|
11320
|
+
failureReasons.push(record.errorMessage);
|
|
11321
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
|
|
11322
|
+
continue;
|
|
11323
|
+
}
|
|
11324
|
+
const target = verdict.candidate;
|
|
9671
11325
|
// Deny-list guard: never act on a wizard-exit control (save-and-exit,
|
|
9672
11326
|
// cancel, restart) for a click/advance step. Record it as tried (so a
|
|
9673
11327
|
// re-observe with ignoreSelectors surfaces a different candidate) and
|
|
@@ -9711,7 +11365,22 @@ async function executeStepWithHealing(params) {
|
|
|
9711
11365
|
};
|
|
9712
11366
|
}
|
|
9713
11367
|
}
|
|
9714
|
-
|
|
11368
|
+
params.abortSignal?.throwIfAborted();
|
|
11369
|
+
// Stagehand's internal Locator.click() does not scroll past sticky
|
|
11370
|
+
// bars, so centre + hit-test the observed control first; an
|
|
11371
|
+
// unrecoverable occlusion fails the attempt instead of clicking an
|
|
11372
|
+
// overlay.
|
|
11373
|
+
if (overriddenTarget.method === "click") {
|
|
11374
|
+
const centreFailure = await centreObservedClickTarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), overriddenTarget.selector, (msg) => logger.debug(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${msg}`));
|
|
11375
|
+
if (centreFailure) {
|
|
11376
|
+
record.errorMessage = centreFailure;
|
|
11377
|
+
attempts.push(record);
|
|
11378
|
+
failureReasons.push(record.errorMessage);
|
|
11379
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
|
|
11380
|
+
continue;
|
|
11381
|
+
}
|
|
11382
|
+
}
|
|
11383
|
+
const result = await (0, stagehand_guard_1.guardedAct)(stagehand, overriddenTarget, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page));
|
|
9715
11384
|
record.actResultSuccess = result.success;
|
|
9716
11385
|
record.actResultDescription = result.actionDescription;
|
|
9717
11386
|
// observe(...)[0] is what Stagehand acted on; use it directly when
|
|
@@ -9743,8 +11412,8 @@ async function executeStepWithHealing(params) {
|
|
|
9743
11412
|
const dateFill = await fillHtml5DateTimeInput(dateFillTarget, overriddenTarget.selector, fillValue);
|
|
9744
11413
|
if (dateFill !== null) {
|
|
9745
11414
|
record.errorMessage = dateFill.filled
|
|
9746
|
-
? `html5-date-fallback: filled ${dateFill.inputType}="${
|
|
9747
|
-
: `html5-date-fallback: failed to fill ${dateFill.inputType} (post=${
|
|
11415
|
+
? `html5-date-fallback: filled ${dateFill.inputType}="${maskLoggedValue(dateFill.postValue, emailStepCode)}"`
|
|
11416
|
+
: `html5-date-fallback: failed to fill ${dateFill.inputType} (post=${maskLoggedValue(dateFill.postValue, emailStepCode)})`;
|
|
9748
11417
|
// Override the act result based on the deterministic fill
|
|
9749
11418
|
// outcome — the helper bypasses Stagehand's schema-error
|
|
9750
11419
|
// failure mode by writing directly via the native setter.
|
|
@@ -9764,8 +11433,8 @@ async function executeStepWithHealing(params) {
|
|
|
9764
11433
|
const datepickerFill = await fillTextDatepickerInput(dateFillTarget, overriddenTarget.selector, fillValue);
|
|
9765
11434
|
if (datepickerFill !== null) {
|
|
9766
11435
|
record.errorMessage = datepickerFill.filled
|
|
9767
|
-
? `text-datepicker-fill: filled via ${datepickerFill.strategy} "${
|
|
9768
|
-
: `text-datepicker-fill: failed to commit "${
|
|
11436
|
+
? `text-datepicker-fill: filled via ${datepickerFill.strategy} "${maskLoggedValue(datepickerFill.postValue, emailStepCode)}"`
|
|
11437
|
+
: `text-datepicker-fill: failed to commit "${maskLoggedValue(fillValue.slice(0, 60), emailStepCode)}" (post=${maskLoggedValue(datepickerFill.postValue, emailStepCode)})`;
|
|
9769
11438
|
if (datepickerFill.filled) {
|
|
9770
11439
|
record.actResultSuccess = true;
|
|
9771
11440
|
resolvedAction = overriddenTarget;
|
|
@@ -9783,12 +11452,16 @@ async function executeStepWithHealing(params) {
|
|
|
9783
11452
|
// (network/url/dom/htmlDelta/textChanged) miss.
|
|
9784
11453
|
const readback = await verifyFillReadback(dateFillTarget, overriddenTarget.selector, fillValue);
|
|
9785
11454
|
if (readback !== null) {
|
|
9786
|
-
if (readback.outcome === "
|
|
9787
|
-
record.errorMessage = `fill-value-rejected: tried "${
|
|
11455
|
+
if (readback.outcome === "segment") {
|
|
11456
|
+
record.errorMessage = `fill-value-rejected: tried "${maskLoggedValue(fillValue.slice(0, 60), emailStepCode)}" on a single spinbutton segment; a lone segment cannot hold the whole value`;
|
|
11457
|
+
record.actResultSuccess = false;
|
|
11458
|
+
}
|
|
11459
|
+
else if (readback.outcome === "rejected") {
|
|
11460
|
+
record.errorMessage = `fill-value-rejected: tried "${maskLoggedValue(fillValue.slice(0, 60), emailStepCode)}" on <${readback.tag}>; element value remains empty (silent rejection — HTML5 type validation, framework controlled-component, or masked-input library)`;
|
|
9788
11461
|
record.actResultSuccess = false;
|
|
9789
11462
|
}
|
|
9790
11463
|
else if (readback.outcome === "differs") {
|
|
9791
|
-
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} fill-value-differs: tried "${
|
|
11464
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} fill-value-differs: tried "${maskLoggedValue(fillValue.slice(0, 60), emailStepCode)}" got "${maskLoggedValue(readback.postValue.slice(0, 60), emailStepCode)}" (framework reformatted)`);
|
|
9792
11465
|
}
|
|
9793
11466
|
}
|
|
9794
11467
|
}
|
|
@@ -9951,6 +11624,7 @@ async function executeStepWithHealing(params) {
|
|
|
9951
11624
|
"anthropic billing exhausted (FATAL_BILLING already logged); skipping rephrase";
|
|
9952
11625
|
}
|
|
9953
11626
|
else {
|
|
11627
|
+
params.abortSignal?.throwIfAborted();
|
|
9954
11628
|
const observedCandidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
|
|
9955
11629
|
// observe() is blind to a cross-origin OOPIF, so a frame-scoped
|
|
9956
11630
|
// empty result degrades to the deep-locator resolver for prompt
|
|
@@ -9975,6 +11649,7 @@ async function executeStepWithHealing(params) {
|
|
|
9975
11649
|
// frameTarget: for a frame-scoped flow the ambient UI that matters
|
|
9976
11650
|
// (the wizard's own Save/Close controls) lives inside the iframe
|
|
9977
11651
|
// alongside the failed step, not in the top document.
|
|
11652
|
+
params.abortSignal?.throwIfAborted();
|
|
9978
11653
|
const observedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
|
|
9979
11654
|
const unfocused = observedUnfocused.length === 0 && frameTarget?.frame
|
|
9980
11655
|
? await deepLocatorCandidatesAsActions(page, frameTarget)
|
|
@@ -9992,7 +11667,9 @@ async function executeStepWithHealing(params) {
|
|
|
9992
11667
|
}
|
|
9993
11668
|
else {
|
|
9994
11669
|
record.instruction = rephrased;
|
|
9995
|
-
|
|
11670
|
+
params.abortSignal?.throwIfAborted();
|
|
11671
|
+
const preCentred = await preCentreNaturalLanguageClick(rephrased);
|
|
11672
|
+
const result = await (0, stagehand_guard_1.guardedAct)(stagehand, rephrased, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), preCentred);
|
|
9996
11673
|
record.actResultSuccess = result.success;
|
|
9997
11674
|
record.actResultDescription = result.actionDescription;
|
|
9998
11675
|
for (const action of result.actions ?? []) {
|
|
@@ -10021,7 +11698,7 @@ async function executeStepWithHealing(params) {
|
|
|
10021
11698
|
// cannot produce a positive signal: verified is forced false and the
|
|
10022
11699
|
// cascade always falls through to attempt 2. Skip the wasted ~2-3s
|
|
10023
11700
|
// of timeout + snapshot work and let the cascade try observe-act.
|
|
10024
|
-
if (attempt === 1 && resolvedAction === null) {
|
|
11701
|
+
if (attempt === 1 && resolvedAction === null && record.actResultSuccess !== true) {
|
|
10025
11702
|
attempts.push(record);
|
|
10026
11703
|
const reason = record.errorMessage
|
|
10027
11704
|
? `attempt 1 fast-skipped (no resolved action): ${record.errorMessage}`
|
|
@@ -10029,7 +11706,21 @@ async function executeStepWithHealing(params) {
|
|
|
10029
11706
|
failureReasons.push(reason);
|
|
10030
11707
|
continue;
|
|
10031
11708
|
}
|
|
11709
|
+
// A fill/select/type action cannot open a tab, so skip the discovery wait for it.
|
|
11710
|
+
await adoptOpenedPage(resolvedAction && STATE_CLASS_METHODS.has(resolvedAction.method ?? "") ? 0 : undefined);
|
|
10032
11711
|
await page.waitForTimeout(STEP_PAUSE_MS);
|
|
11712
|
+
// A successful act that reported no actions still ran a click-class action;
|
|
11713
|
+
// judging it before its round-trip lands would cascade onto the advanced page.
|
|
11714
|
+
const mayTriggerRoundTrip = resolvedAction === null
|
|
11715
|
+
? record.actResultSuccess === true
|
|
11716
|
+
: !STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
|
|
11717
|
+
if (mayTriggerRoundTrip) {
|
|
11718
|
+
const settleTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
|
|
11719
|
+
await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, isSettleWaitStep, async () => {
|
|
11720
|
+
const snap = await snapshotPage(settleTarget, signalCounter);
|
|
11721
|
+
return snap.visibleTextSignature;
|
|
11722
|
+
}, pre.visibleTextSignature);
|
|
11723
|
+
}
|
|
10033
11724
|
const post = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
|
|
10034
11725
|
record.post = post;
|
|
10035
11726
|
if (resolvedAction) {
|
|
@@ -10039,7 +11730,13 @@ async function executeStepWithHealing(params) {
|
|
|
10039
11730
|
const networkFired = post.networkCount > pre.networkCount;
|
|
10040
11731
|
// Computed once: gates every credit signal that takes no post-click URL
|
|
10041
11732
|
// (network, DOM read-back) and classifyPhantomClick's destinationPlausible.
|
|
10042
|
-
const
|
|
11733
|
+
const stepIsMidFlowSubmit = isMidFlowSubmitShapedStep({
|
|
11734
|
+
step,
|
|
11735
|
+
submitStep,
|
|
11736
|
+
isFinalStep,
|
|
11737
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
11738
|
+
});
|
|
11739
|
+
const destinationPlausible = (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, stepIsMidFlowSubmit ? pre.url : undefined);
|
|
10043
11740
|
const urlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, post.url) && destinationPlausible;
|
|
10044
11741
|
const isStateClass = resolvedAction !== null && STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
|
|
10045
11742
|
const isClick = resolvedAction !== null && resolvedAction.method === "click";
|
|
@@ -10103,7 +11800,12 @@ async function executeStepWithHealing(params) {
|
|
|
10103
11800
|
// `advanceTransitionBodyPattern` are unaffected.
|
|
10104
11801
|
const domVerifiedForStep = isDomOnlyAdvanceVerified({
|
|
10105
11802
|
hasPattern: advanceTransitionBodyPattern !== null,
|
|
10106
|
-
isFinalOrSubmit:
|
|
11803
|
+
isFinalOrSubmit: isStepSubmitShaped({
|
|
11804
|
+
step,
|
|
11805
|
+
submitStep,
|
|
11806
|
+
isFinalStep,
|
|
11807
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
11808
|
+
}),
|
|
10107
11809
|
isAdvance: isAdvanceStep(step),
|
|
10108
11810
|
domVerified: domVerified && destinationPlausible,
|
|
10109
11811
|
networkIsRealAdvance,
|
|
@@ -10127,16 +11829,17 @@ async function executeStepWithHealing(params) {
|
|
|
10127
11829
|
// Zero-signal reset veto input for the view-swap gate below. Read only
|
|
10128
11830
|
// for a resolved click with a usable selector — a page reset that reverts
|
|
10129
11831
|
// to an earlier screen (recon-viewswap-false-pass-on-zero-signal-click.md)
|
|
10130
|
-
// replaces the clicked control along with everything else
|
|
10131
|
-
//
|
|
10132
|
-
//
|
|
11832
|
+
// replaces the clicked control along with everything else and shrinks
|
|
11833
|
+
// the DOM, while a genuine same-page toggle leaves it in place and a
|
|
11834
|
+
// view swap removes it but grows the DOM (absence vetoes only when
|
|
11835
|
+
// bytesDelta <= 0). See isClickViewSwapVerified's doc comment.
|
|
10133
11836
|
const clickedElementStillPresent = isClick && resolvedAction?.selector
|
|
10134
11837
|
? await resolvedClickTargetStillPresent(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector)
|
|
10135
11838
|
: true;
|
|
10136
11839
|
// Attribute-based submit-shape read of the resolved click target itself —
|
|
10137
11840
|
// independent of the flow-authored `submitStep` flag. See
|
|
10138
11841
|
// resolvedClickTargetIsSubmitShaped's doc comment.
|
|
10139
|
-
const resolvedElementIsSubmitShaped = isClick && resolvedAction?.selector
|
|
11842
|
+
const resolvedElementIsSubmitShaped = isClick && !stepDeclaredNonSubmit && resolvedAction?.selector
|
|
10140
11843
|
? await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector)
|
|
10141
11844
|
: false;
|
|
10142
11845
|
// Client-side view-swap gate: credit a click that produces substantial
|
|
@@ -10148,6 +11851,17 @@ async function executeStepWithHealing(params) {
|
|
|
10148
11851
|
// cascade to a 5-attempt failure and global replan. Vetoed when the click's
|
|
10149
11852
|
// ng-invalid marker count grew, or when the landed destination is implausible
|
|
10150
11853
|
// for the step (see isClickViewSwapVerified's doc comment).
|
|
11854
|
+
const midFlowSubmit = isMidFlowSubmitShapedStep({
|
|
11855
|
+
step,
|
|
11856
|
+
submitStep,
|
|
11857
|
+
isFinalStep,
|
|
11858
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
11859
|
+
elementSubmitShaped: resolvedElementIsSubmitShaped,
|
|
11860
|
+
});
|
|
11861
|
+
// An element-inferred mid-flow submit joins the exemption the step-derived
|
|
11862
|
+
// one already got: a click that never left its URL did not bounce to a gate.
|
|
11863
|
+
const midFlowDestinationPlausible = destinationPlausible ||
|
|
11864
|
+
(midFlowSubmit && (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, pre.url));
|
|
10151
11865
|
const clickViewSwapVerified = isClickViewSwapVerified({
|
|
10152
11866
|
resolvedAction,
|
|
10153
11867
|
submitStep,
|
|
@@ -10158,7 +11872,10 @@ async function executeStepWithHealing(params) {
|
|
|
10158
11872
|
invalidMarkerDelta: postInvalidMarkerCount - preInvalidMarkerCount,
|
|
10159
11873
|
clickedElementStillPresent,
|
|
10160
11874
|
resolvedElementIsSubmitShaped,
|
|
10161
|
-
destinationPlausible,
|
|
11875
|
+
destinationPlausible: midFlowDestinationPlausible,
|
|
11876
|
+
midFlowSubmit,
|
|
11877
|
+
pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, post),
|
|
11878
|
+
actSucceeded: record.actResultSuccess === true,
|
|
10162
11879
|
});
|
|
10163
11880
|
if (clickViewSwapVerified === false &&
|
|
10164
11881
|
isClick &&
|
|
@@ -10191,10 +11908,12 @@ async function executeStepWithHealing(params) {
|
|
|
10191
11908
|
// submitStep, inferred final-step, or an attribute-detected submit
|
|
10192
11909
|
// control) may not ride this signal alone — only when the submit-judge
|
|
10193
11910
|
// gate (requireSubmitEndpoint) will re-litigate the credit below.
|
|
10194
|
-
const formValueWeakSignalAllowed = !(
|
|
10195
|
-
|
|
10196
|
-
|
|
10197
|
-
|
|
11911
|
+
const formValueWeakSignalAllowed = !(isStepSubmitShaped({
|
|
11912
|
+
step,
|
|
11913
|
+
submitStep,
|
|
11914
|
+
isFinalStep,
|
|
11915
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
11916
|
+
}) || resolvedElementIsSubmitShaped) || requireSubmitEndpoint;
|
|
10198
11917
|
const formValueVerified = isStateClass &&
|
|
10199
11918
|
formValueWeakSignalAllowed &&
|
|
10200
11919
|
destinationPlausible &&
|
|
@@ -10223,7 +11942,12 @@ async function executeStepWithHealing(params) {
|
|
|
10223
11942
|
if (readbackSelector && fillIntent !== null) {
|
|
10224
11943
|
const readbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
|
|
10225
11944
|
const readback = await verifyFillReadback(readbackTarget, readbackSelector, fillIntent.value);
|
|
10226
|
-
if (readback?.outcome === "
|
|
11945
|
+
if (readback?.outcome === "segment") {
|
|
11946
|
+
datepickerRejected = true;
|
|
11947
|
+
record.actResultSuccess = false;
|
|
11948
|
+
record.errorMessage = `fill-value-rejected: tried "${maskLoggedValue(fillIntent.value.slice(0, 60), emailStepCode)}" on a single spinbutton segment; a lone segment cannot hold the whole value`;
|
|
11949
|
+
}
|
|
11950
|
+
else if (readback?.outcome === "rejected") {
|
|
10227
11951
|
// Value didn't land. First try the datepicker primitive; it self-gates
|
|
10228
11952
|
// on react-datepicker's `.react-datepicker__input-container` and returns
|
|
10229
11953
|
// null for any non-datepicker input, so a plain rejected fill falls
|
|
@@ -10232,7 +11956,7 @@ async function executeStepWithHealing(params) {
|
|
|
10232
11956
|
if (datepickerFill !== null) {
|
|
10233
11957
|
if (datepickerFill.filled) {
|
|
10234
11958
|
datepickerCommitted = true;
|
|
10235
|
-
record.errorMessage = `text-datepicker-fill: filled via ${datepickerFill.strategy} "${
|
|
11959
|
+
record.errorMessage = `text-datepicker-fill: filled via ${datepickerFill.strategy} "${maskLoggedValue(datepickerFill.postValue, emailStepCode)}"`;
|
|
10236
11960
|
}
|
|
10237
11961
|
else {
|
|
10238
11962
|
// A gated-in datepicker that neither gesture commits is a real fill
|
|
@@ -10240,7 +11964,7 @@ async function executeStepWithHealing(params) {
|
|
|
10240
11964
|
// so suppress the weak view-swap/form-value acceptance and escalate.
|
|
10241
11965
|
datepickerRejected = true;
|
|
10242
11966
|
record.actResultSuccess = false;
|
|
10243
|
-
record.errorMessage = `text-datepicker-fill: failed to commit "${
|
|
11967
|
+
record.errorMessage = `text-datepicker-fill: failed to commit "${maskLoggedValue(fillIntent.value.slice(0, 60), emailStepCode)}" (post=${maskLoggedValue(datepickerFill.postValue, emailStepCode)})`;
|
|
10244
11968
|
}
|
|
10245
11969
|
}
|
|
10246
11970
|
else {
|
|
@@ -10249,7 +11973,7 @@ async function executeStepWithHealing(params) {
|
|
|
10249
11973
|
// failure dump reads identically regardless of which path caught it.
|
|
10250
11974
|
datepickerRejected = true;
|
|
10251
11975
|
record.actResultSuccess = false;
|
|
10252
|
-
record.errorMessage = `fill-value-rejected: tried "${
|
|
11976
|
+
record.errorMessage = `fill-value-rejected: tried "${maskLoggedValue(fillIntent.value.slice(0, 60), emailStepCode)}" on <${readback.tag}>; element value remains empty (silent rejection — HTML5 type validation, framework controlled-component, or masked-input library)`;
|
|
10253
11977
|
}
|
|
10254
11978
|
}
|
|
10255
11979
|
}
|
|
@@ -10277,7 +12001,7 @@ async function executeStepWithHealing(params) {
|
|
|
10277
12001
|
const readbackSelector = resolvedAction?.selector ?? triedSelectors[triedSelectors.length - 1] ?? null;
|
|
10278
12002
|
if (readbackSelector) {
|
|
10279
12003
|
const readbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
|
|
10280
|
-
const promptReadback = await verifyPromptSelectorCommitted(readbackTarget, readbackSelector);
|
|
12004
|
+
const promptReadback = await verifyPromptSelectorCommitted(readbackTarget, readbackSelector, parsePromptSelectorStep(step)?.option);
|
|
10281
12005
|
if (promptReadback?.isPromptWidget === true && promptReadback.committed === false) {
|
|
10282
12006
|
promptSelectorRejected = true;
|
|
10283
12007
|
record.actResultSuccess = false;
|
|
@@ -10301,8 +12025,15 @@ async function executeStepWithHealing(params) {
|
|
|
10301
12025
|
pre,
|
|
10302
12026
|
post,
|
|
10303
12027
|
elementStateChanged: domVerified && destinationPlausible,
|
|
10304
|
-
isSubmitShapedStep:
|
|
10305
|
-
|
|
12028
|
+
isSubmitShapedStep: midFlowSubmit ||
|
|
12029
|
+
isStepSubmitShaped({
|
|
12030
|
+
step,
|
|
12031
|
+
submitStep,
|
|
12032
|
+
isFinalStep,
|
|
12033
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
12034
|
+
}),
|
|
12035
|
+
isMidFlowSubmitStep: midFlowSubmit,
|
|
12036
|
+
destinationPlausible: midFlowDestinationPlausible,
|
|
10306
12037
|
});
|
|
10307
12038
|
// An `"effective"` verdict driven purely by the page-wide byte-delta
|
|
10308
12039
|
// floor (`TRIVIAL_DOM_DELTA_BYTES`, 500B) is intentionally NOT trusted
|
|
@@ -10323,8 +12054,12 @@ async function executeStepWithHealing(params) {
|
|
|
10323
12054
|
// respects the SAME DOM-only-advance veto as the block above: a wizard
|
|
10324
12055
|
// "Next" step on a pattern-configured site whose only signal is a field
|
|
10325
12056
|
// toggle must stay unverified, not get waved through by the verdict.
|
|
10326
|
-
const domEffectiveVerdict = !(
|
|
10327
|
-
|
|
12057
|
+
const domEffectiveVerdict = !isStepSubmitShaped({
|
|
12058
|
+
step,
|
|
12059
|
+
submitStep,
|
|
12060
|
+
isFinalStep,
|
|
12061
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
12062
|
+
}) && domVerifiedForStep;
|
|
10328
12063
|
let verified = networkIsRealAdvance ||
|
|
10329
12064
|
urlChanged ||
|
|
10330
12065
|
domVerifiedForStep ||
|
|
@@ -10391,6 +12126,7 @@ async function executeStepWithHealing(params) {
|
|
|
10391
12126
|
}
|
|
10392
12127
|
// Build the unfocused-observe evidence list. Used by the judge to
|
|
10393
12128
|
// assess whether the page transitioned to a success state.
|
|
12129
|
+
params.abortSignal?.throwIfAborted();
|
|
10394
12130
|
const unfocusedForJudge = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
|
|
10395
12131
|
// Quick invalid-marker count (deterministic DOM querying — counting
|
|
10396
12132
|
// structural ng-invalid containers is not fuzzy matching, just
|
|
@@ -10401,9 +12137,17 @@ async function executeStepWithHealing(params) {
|
|
|
10401
12137
|
label: "flow-runner: submit-verification page title probe",
|
|
10402
12138
|
}).catch(() => "");
|
|
10403
12139
|
const matchedSubmittedSelectors = domSubmittedMatch !== null ? [domSubmittedMatch] : [];
|
|
10404
|
-
const judgeVerdict = await (
|
|
12140
|
+
const judgeVerdict = await judgeStepOutcome({
|
|
10405
12141
|
client: anthropic,
|
|
10406
|
-
|
|
12142
|
+
captureFn,
|
|
12143
|
+
step,
|
|
12144
|
+
isFinalStep,
|
|
12145
|
+
page,
|
|
12146
|
+
target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
|
|
12147
|
+
signalCounter,
|
|
12148
|
+
preNetworkCount: pre.networkCount,
|
|
12149
|
+
preVisibleTextSignature: pre.visibleTextSignature,
|
|
12150
|
+
submitInput: {
|
|
10407
12151
|
pageUrl: post.url,
|
|
10408
12152
|
pageTitle,
|
|
10409
12153
|
unfocusedObserve: unfocusedForJudge.map((a) => ({
|
|
@@ -10417,9 +12161,22 @@ async function executeStepWithHealing(params) {
|
|
|
10417
12161
|
successPageTitleHints,
|
|
10418
12162
|
submittedStateSelectors: matchedSubmittedSelectors,
|
|
10419
12163
|
},
|
|
10420
|
-
captureFn,
|
|
10421
12164
|
});
|
|
10422
|
-
|
|
12165
|
+
// A mid-flow transition is credited on the page's own demonstrable
|
|
12166
|
+
// advance (visible text swapped); the judge's final-submit-flavoured
|
|
12167
|
+
// view of a stale title or missing network must not undo it.
|
|
12168
|
+
const midFlowAdvanceCredited = (midFlowSubmit && clickViewSwapVerified) ||
|
|
12169
|
+
isNullVerdictAdvanceCredited({
|
|
12170
|
+
midFlowSubmit,
|
|
12171
|
+
judgeUnavailable: judgeVerdict === null,
|
|
12172
|
+
networkAdvanced: networkIsRealAdvance,
|
|
12173
|
+
pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, post),
|
|
12174
|
+
invalidMarkerCount,
|
|
12175
|
+
});
|
|
12176
|
+
if (midFlowAdvanceCredited && judgeVerdict?.verified !== true) {
|
|
12177
|
+
logger.info(`mid-flow submit credited on demonstrable page advance (judge ${judgeVerdict === null ? "unavailable" : `rejected: ${judgeVerdict.reason}`})`);
|
|
12178
|
+
}
|
|
12179
|
+
else if (judgeVerdict === null) {
|
|
10423
12180
|
// Bedrock-only deployment or judge call failed entirely. Fall back
|
|
10424
12181
|
// to the DOM-state probe + requireSubmitEndpointMatch policy: if a
|
|
10425
12182
|
// submitted-state selector matched and the site didn't require
|
|
@@ -10539,11 +12296,16 @@ async function executeStepWithHealing(params) {
|
|
|
10539
12296
|
// tail node `clickExpr` below is about to click, instead of only
|
|
10540
12297
|
// ever checking the (possibly unresolvable) primary xpath — see
|
|
10541
12298
|
// resolvedClickTargetIsSubmitShaped's doc comment.
|
|
10542
|
-
const
|
|
12299
|
+
const tailPhrases = tailRetargetPhrasesForStep(step);
|
|
12300
|
+
const retryResolvedElementIsSubmitShaped = stepDeclaredNonSubmit
|
|
12301
|
+
? false
|
|
12302
|
+
: await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector, xpathTail, tailPhrases);
|
|
10543
12303
|
// See resolvedClickUsedXpathTailRetarget's doc comment: flags that
|
|
10544
12304
|
// this click's target identity came only from the loose tail
|
|
10545
12305
|
// heuristic, not Stagehand's own resolved selector.
|
|
10546
|
-
const retryClickUsedXpathTailRetarget =
|
|
12306
|
+
const retryClickUsedXpathTailRetarget = stepDeclaredNonSubmit
|
|
12307
|
+
? false
|
|
12308
|
+
: await resolvedClickUsedXpathTailRetarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector, xpathTail, tailPhrases);
|
|
10547
12309
|
// Trusted-click delivery for the plain-click branch: attempted
|
|
10548
12310
|
// BEFORE the resolve/activate expression below (so a successful
|
|
10549
12311
|
// delivery can suppress the synthetic `clickActivationExpr`
|
|
@@ -10575,10 +12337,12 @@ async function executeStepWithHealing(params) {
|
|
|
10575
12337
|
const n16TrustedClickFailure = !n16TrustedDelivered && "reason" in n16TrustedClickResult
|
|
10576
12338
|
? n16TrustedClickResult
|
|
10577
12339
|
: null;
|
|
10578
|
-
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)}); } if (!el || typeof el.click !== "function") return { fired: false }; if (el.tagName === "LABEL") { const wrapped = el.querySelector("input[type=checkbox], input[type=radio]"); if (wrapped) el = wrapped; } if (el.type === "checkbox" || el.type === "radio") { el.checked = true; el.dispatchEvent(new Event("click", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { fired: true, kind: "checkbox", checked: el.checked }; } let __n16SmMatched = false; ${(0, browser_click_expr_1.retargetToSelectionMarkerExpr)("el", "__n16SmMatched")} ${n16TrustedDelivered ? "" : (0, browser_click_expr_1.clickActivationExpr)("el")} if (__n16SmMatched) { el.dispatchEvent(new Event("change", { bubbles: true })); } return { fired: true, kind: "click" }; })()`;
|
|
12340
|
+
const clickExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); let el = r.singleNodeValue; if (!el && ${JSON.stringify(xpathTail)}) { el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)}, ${JSON.stringify(tailPhrases)}); if (!el && ${JSON.stringify(tailPhrases.length > 0)} && ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(false)}, []) !== null) return { fired: false, phraseMismatch: true }; } if (!el || typeof el.click !== "function") return { fired: false }; if (el.tagName === "LABEL") { const wrapped = el.querySelector("input[type=checkbox], input[type=radio]"); if (wrapped) el = wrapped; } if (el.type === "checkbox" || el.type === "radio") { const __ac = ${ariaCheckedReadExpr("el")}; if (__ac === "true" || __ac === "false") return { fired: true, kind: "checkbox", checked: __ac === "true", ariaManaged: true }; el.checked = true; el.dispatchEvent(new Event("click", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { fired: true, kind: "checkbox", checked: el.checked }; } let __n16SmMatched = false; ${(0, browser_click_expr_1.retargetToSelectionMarkerExpr)("el", "__n16SmMatched")} ${n16TrustedDelivered ? "" : (0, browser_click_expr_1.clickActivationExpr)("el")} if (__n16SmMatched) { el.dispatchEvent(new Event("change", { bubbles: true })); } return { fired: true, kind: "click" }; })()`;
|
|
10579
12341
|
const n16FallbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
|
|
10580
12342
|
const probeResult = (await n16FallbackTarget.evaluate(clickExpr));
|
|
10581
12343
|
const fired = probeResult.fired;
|
|
12344
|
+
if (fired)
|
|
12345
|
+
await adoptOpenedPage();
|
|
10582
12346
|
// Vacuous-click guard for the n+16 fallback. Same rationale as
|
|
10583
12347
|
// verifyDomEffect's click case: a checkbox/radio input's .checked
|
|
10584
12348
|
// property can flip true via CDP click even when the input lives on
|
|
@@ -10588,13 +12352,15 @@ async function executeStepWithHealing(params) {
|
|
|
10588
12352
|
// ancestor-invalid signal through prevents the cascade from
|
|
10589
12353
|
// advancing past a vacuous click.
|
|
10590
12354
|
let ancestorStillInvalid = false;
|
|
10591
|
-
if (probeResult.kind === "checkbox" &&
|
|
12355
|
+
if (probeResult.kind === "checkbox" &&
|
|
12356
|
+
probeResult.checked === true &&
|
|
12357
|
+
probeResult.ariaManaged !== true) {
|
|
10592
12358
|
const ancestorInvalidExpr = `(() => {
|
|
10593
12359
|
const isInvalid = ${INVALID_MARKER_EL_EXPR};
|
|
10594
12360
|
const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
10595
12361
|
let node = r.singleNodeValue;
|
|
10596
12362
|
if (!node && ${JSON.stringify(xpathTail)}) {
|
|
10597
|
-
node = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)});
|
|
12363
|
+
node = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)}, ${JSON.stringify(tailPhrases)});
|
|
10598
12364
|
}
|
|
10599
12365
|
if (!node) return false;
|
|
10600
12366
|
for (let depth = 0; depth < 6 && node; depth++) {
|
|
@@ -10603,18 +12369,35 @@ async function executeStepWithHealing(params) {
|
|
|
10603
12369
|
}
|
|
10604
12370
|
return false;
|
|
10605
12371
|
})()`;
|
|
10606
|
-
ancestorStillInvalid = (await
|
|
12372
|
+
ancestorStillInvalid = (await (frameTarget ?? (0, frame_target_1.mainFrameTarget)(page))
|
|
10607
12373
|
.evaluate(ancestorInvalidExpr)
|
|
10608
12374
|
.catch(() => false));
|
|
10609
12375
|
}
|
|
12376
|
+
// An aria-managed box is never forced, so the framework's own
|
|
12377
|
+
// aria-checked decides; a stale invalid ancestor is not consulted.
|
|
12378
|
+
const forcedCheckboxRejected = probeResult.kind === "checkbox" &&
|
|
12379
|
+
(probeResult.ariaManaged === true
|
|
12380
|
+
? probeResult.checked !== true
|
|
12381
|
+
: ancestorStillInvalid);
|
|
10610
12382
|
const checkboxStateVerified = probeResult.kind === "checkbox" &&
|
|
10611
12383
|
probeResult.checked === true &&
|
|
10612
|
-
!ancestorStillInvalid;
|
|
12384
|
+
(probeResult.ariaManaged === true || !ancestorStillInvalid);
|
|
10613
12385
|
await page.waitForTimeout(STEP_PAUSE_MS);
|
|
12386
|
+
await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, isSettleWaitStep, async () => {
|
|
12387
|
+
const snap = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
|
|
12388
|
+
return snap.visibleTextSignature;
|
|
12389
|
+
}, pre.visibleTextSignature);
|
|
10614
12390
|
const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
|
|
10615
12391
|
const retryNetworkFired = retryPost.networkCount > pre.networkCount;
|
|
12392
|
+
const retryMidFlowSubmit = isMidFlowSubmitShapedStep({
|
|
12393
|
+
step,
|
|
12394
|
+
submitStep,
|
|
12395
|
+
isFinalStep,
|
|
12396
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
12397
|
+
elementSubmitShaped: retryResolvedElementIsSubmitShaped,
|
|
12398
|
+
});
|
|
10616
12399
|
const retryUrlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, retryPost.url) &&
|
|
10617
|
-
(0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url);
|
|
12400
|
+
(0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url, retryMidFlowSubmit ? pre.url : undefined);
|
|
10618
12401
|
const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
|
|
10619
12402
|
const retryTextChanged = retryPost.visibleTextSignature !== pre.visibleTextSignature;
|
|
10620
12403
|
const retryFormValueChanged = retryPost.formValueSignature !== pre.formValueSignature;
|
|
@@ -10678,7 +12461,7 @@ async function executeStepWithHealing(params) {
|
|
|
10678
12461
|
const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
10679
12462
|
let el = r.singleNodeValue;
|
|
10680
12463
|
if (!el && ${JSON.stringify(xpathTail)}) {
|
|
10681
|
-
el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)});
|
|
12464
|
+
el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}, ${JSON.stringify(!stepDeclaredNonSubmit)}, ${JSON.stringify(tailPhrases)});
|
|
10682
12465
|
}
|
|
10683
12466
|
return el ? isDisabled(el) : false;
|
|
10684
12467
|
})()`;
|
|
@@ -10709,6 +12492,12 @@ async function executeStepWithHealing(params) {
|
|
|
10709
12492
|
// Destination-gated so an implausible landing URL's traffic cannot satisfy
|
|
10710
12493
|
// the veto or the submit-transition carve-out downstream.
|
|
10711
12494
|
const retryDestinationPlausible = (0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url);
|
|
12495
|
+
// Only the verdict sees the pre.url exemption, and only for a demonstrable
|
|
12496
|
+
// page advance: bare network/DOM signals stay gated on the landed URL.
|
|
12497
|
+
const retryMidFlowDestinationPlausible = retryDestinationPlausible ||
|
|
12498
|
+
(retryMidFlowSubmit &&
|
|
12499
|
+
(0, phantom_click_1.hasPageAdvanced)(pre, retryPost) &&
|
|
12500
|
+
(0, phantom_click_1.isPlausibleStepDestination)(step, retryPost.url, pre.url));
|
|
10712
12501
|
const retryNetworkIsRealAdvance = retryNetworkFired &&
|
|
10713
12502
|
retryDestinationPlausible &&
|
|
10714
12503
|
(await waitForTransitionBody({
|
|
@@ -10722,7 +12511,12 @@ async function executeStepWithHealing(params) {
|
|
|
10722
12511
|
}));
|
|
10723
12512
|
const fallbackDomOnlyAdvance = shouldVetoFallbackAdvance({
|
|
10724
12513
|
hasPattern: advanceTransitionBodyPattern !== null,
|
|
10725
|
-
isFinalOrSubmit:
|
|
12514
|
+
isFinalOrSubmit: isStepSubmitShaped({
|
|
12515
|
+
step,
|
|
12516
|
+
submitStep,
|
|
12517
|
+
isFinalStep,
|
|
12518
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
12519
|
+
}),
|
|
10726
12520
|
isAdvance: isAdvanceStep(step),
|
|
10727
12521
|
retryUrlChanged,
|
|
10728
12522
|
retryNetworkIsRealAdvance,
|
|
@@ -10781,11 +12575,13 @@ async function executeStepWithHealing(params) {
|
|
|
10781
12575
|
// step resolving onto an objectively submit-shaped control is
|
|
10782
12576
|
// treated identically to an explicitly flagged one everywhere this
|
|
10783
12577
|
// value is read below.
|
|
10784
|
-
const retrySubmitShaped =
|
|
10785
|
-
|
|
10786
|
-
|
|
10787
|
-
|
|
10788
|
-
|
|
12578
|
+
const retrySubmitShaped = isStepSubmitShaped({
|
|
12579
|
+
step,
|
|
12580
|
+
submitStep,
|
|
12581
|
+
isFinalStep,
|
|
12582
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
12583
|
+
}) || retryResolvedElementIsSubmitShaped;
|
|
12584
|
+
const weakDomSignalsAllowed = ((!inferredFinalSubmitStep && !submitStep && !retryResolvedElementIsSubmitShaped) ||
|
|
10789
12585
|
requireSubmitEndpoint) &&
|
|
10790
12586
|
!isCheckboxOrRadioIntentStep(step) &&
|
|
10791
12587
|
!clickTargetIsSelectionMarker;
|
|
@@ -10807,8 +12603,9 @@ async function executeStepWithHealing(params) {
|
|
|
10807
12603
|
pre,
|
|
10808
12604
|
post: retryPost,
|
|
10809
12605
|
elementStateChanged: retrySelectionStateChanged,
|
|
10810
|
-
isSubmitShapedStep: retrySubmitShaped,
|
|
10811
|
-
|
|
12606
|
+
isSubmitShapedStep: retryMidFlowSubmit || retrySubmitShaped,
|
|
12607
|
+
isMidFlowSubmitStep: retryMidFlowSubmit,
|
|
12608
|
+
destinationPlausible: retryMidFlowDestinationPlausible,
|
|
10812
12609
|
});
|
|
10813
12610
|
// Destination-plausibility audit of every remaining disjunct in
|
|
10814
12611
|
// this OR (the recon report's closing instruction, see
|
|
@@ -10824,15 +12621,28 @@ async function executeStepWithHealing(params) {
|
|
|
10824
12621
|
// three get the same `isPlausibleStepDestination` gate
|
|
10825
12622
|
// `weakDomSignalsAllowed` already has (bugfix-002) — `retryUrlChanged`
|
|
10826
12623
|
// already folds the same check in at its own computation above.
|
|
12624
|
+
// An unfired probe with no trusted delivery clicked nothing, so DOM
|
|
12625
|
+
// movement is ambient settling, not this step's effect.
|
|
12626
|
+
const n16ClickDelivered = fired === true || n16TrustedDelivered;
|
|
12627
|
+
const retryShowsErrorSummary = exports.ADVANCE_CLICK_RX.test(step)
|
|
12628
|
+
? (await (frameTarget ?? (0, frame_target_1.mainFrameTarget)(page))
|
|
12629
|
+
.evaluate(exports.ERROR_SUMMARY_EXPR)
|
|
12630
|
+
.catch(() => false))
|
|
12631
|
+
: false;
|
|
10827
12632
|
let retryVerified = !clickBlockedByDisabled &&
|
|
12633
|
+
!retryShowsErrorSummary &&
|
|
10828
12634
|
!clickBlockedByInvalid &&
|
|
10829
12635
|
!fallbackDomOnlyAdvance &&
|
|
10830
|
-
|
|
10831
|
-
|
|
12636
|
+
!forcedCheckboxRejected &&
|
|
12637
|
+
((n16ClickDelivered && retryNetworkFired && retryDestinationPlausible) ||
|
|
12638
|
+
(n16ClickDelivered && retryUrlChanged) ||
|
|
10832
12639
|
(checkboxStateVerified && retryDestinationPlausible) ||
|
|
10833
12640
|
(retrySelectionStateChanged && retryDestinationPlausible) ||
|
|
10834
|
-
(
|
|
10835
|
-
|
|
12641
|
+
(n16ClickDelivered &&
|
|
12642
|
+
(!retrySubmitShaped || retryMidFlowSubmit) &&
|
|
12643
|
+
retryVerdict === "effective") ||
|
|
12644
|
+
(n16ClickDelivered &&
|
|
12645
|
+
weakDomSignalsAllowed &&
|
|
10836
12646
|
retryDestinationPlausible &&
|
|
10837
12647
|
(retryHtmlDelta !== 0 || retryTextChanged || retryFormValueChanged)));
|
|
10838
12648
|
if (retryVerified) {
|
|
@@ -10876,7 +12686,10 @@ async function executeStepWithHealing(params) {
|
|
|
10876
12686
|
// flow-runner.overlay-hidden-target-trusted-click-acceptance.test.ts
|
|
10877
12687
|
// and flow-runner.evidence-table-alternating-verdict-regression.test.ts,
|
|
10878
12688
|
// both of which rely on that carve-out and must stay green).
|
|
10879
|
-
const retryDestinationUnconfirmed =
|
|
12689
|
+
const retryDestinationUnconfirmed = inferredFinalSubmitStep &&
|
|
12690
|
+
!submitStep &&
|
|
12691
|
+
retryUrlChanged &&
|
|
12692
|
+
!retryResolvedElementIsSubmitShaped;
|
|
10880
12693
|
if (retryVerified && retryDestinationUnconfirmed && !requireSubmitEndpoint) {
|
|
10881
12694
|
// Veto directly rather than routing through the judge below: no
|
|
10882
12695
|
// endpoint pattern and no submit-shaped element means there is
|
|
@@ -10920,9 +12733,17 @@ async function executeStepWithHealing(params) {
|
|
|
10920
12733
|
label: "flow-runner: submit-verification retry page title probe",
|
|
10921
12734
|
}).catch(() => "");
|
|
10922
12735
|
const matchedSubmittedSelectors = domSubmittedMatch !== null ? [domSubmittedMatch] : [];
|
|
10923
|
-
const judgeVerdict = await (
|
|
12736
|
+
const judgeVerdict = await judgeStepOutcome({
|
|
10924
12737
|
client: anthropic,
|
|
10925
|
-
|
|
12738
|
+
captureFn,
|
|
12739
|
+
step,
|
|
12740
|
+
isFinalStep,
|
|
12741
|
+
page,
|
|
12742
|
+
target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
|
|
12743
|
+
signalCounter,
|
|
12744
|
+
preNetworkCount: pre.networkCount,
|
|
12745
|
+
preVisibleTextSignature: pre.visibleTextSignature,
|
|
12746
|
+
submitInput: {
|
|
10926
12747
|
pageUrl: retryPost.url,
|
|
10927
12748
|
pageTitle,
|
|
10928
12749
|
unfocusedObserve: unfocusedForJudge.map((a) => ({
|
|
@@ -10936,9 +12757,21 @@ async function executeStepWithHealing(params) {
|
|
|
10936
12757
|
successPageTitleHints,
|
|
10937
12758
|
submittedStateSelectors: matchedSubmittedSelectors,
|
|
10938
12759
|
},
|
|
10939
|
-
captureFn,
|
|
10940
12760
|
});
|
|
10941
|
-
|
|
12761
|
+
const retryMidFlowAdvanceCredited = (retryMidFlowSubmit &&
|
|
12762
|
+
retryVerdict === "effective" &&
|
|
12763
|
+
(0, phantom_click_1.hasPageAdvanced)(pre, retryPost)) ||
|
|
12764
|
+
isNullVerdictAdvanceCredited({
|
|
12765
|
+
midFlowSubmit: retryMidFlowSubmit,
|
|
12766
|
+
judgeUnavailable: judgeVerdict === null,
|
|
12767
|
+
networkAdvanced: retryNetworkFired && retryDestinationPlausible,
|
|
12768
|
+
pageAdvanced: (0, phantom_click_1.hasPageAdvanced)(pre, retryPost),
|
|
12769
|
+
invalidMarkerCount,
|
|
12770
|
+
});
|
|
12771
|
+
if (retryMidFlowAdvanceCredited && judgeVerdict?.verified !== true) {
|
|
12772
|
+
logger.info(`n+16 fallback mid-flow submit credited on demonstrable page advance (judge ${judgeVerdict === null ? "unavailable" : `rejected: ${judgeVerdict.reason}`})`);
|
|
12773
|
+
}
|
|
12774
|
+
else if (judgeVerdict === null) {
|
|
10942
12775
|
// Bedrock-only or judge failed; fall back to DOM-state + policy.
|
|
10943
12776
|
if (!requireSubmitEndpointMatch && domSubmittedMatch !== null) {
|
|
10944
12777
|
logger.info(`n+16 fallback submit verified via submitted-state DOM selector '${domSubmittedMatch}' (judge unavailable)`);
|
|
@@ -11047,7 +12880,12 @@ async function executeStepWithHealing(params) {
|
|
|
11047
12880
|
// dumps in a 2026-06-10 survey had the paired touched+dirty + visible
|
|
11048
12881
|
// error text pattern with 3 distinct rejection messages.
|
|
11049
12882
|
if (record.resolvedMethod === "click" &&
|
|
11050
|
-
(
|
|
12883
|
+
isStepSubmitShaped({
|
|
12884
|
+
step,
|
|
12885
|
+
submitStep,
|
|
12886
|
+
isFinalStep,
|
|
12887
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
12888
|
+
})) {
|
|
11051
12889
|
const live = await extractLivePageFormEvidence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), {
|
|
11052
12890
|
client: anthropic,
|
|
11053
12891
|
knownErrorClassPrefixes,
|
|
@@ -11108,7 +12946,12 @@ async function executeStepWithHealing(params) {
|
|
|
11108
12946
|
// (triedSelectors), which is empty on an "unresolved" verdict, so a
|
|
11109
12947
|
// non-submit step with that verdict falls through to the normal
|
|
11110
12948
|
// ladder instead of escalating.
|
|
11111
|
-
const escalationTarget =
|
|
12949
|
+
const escalationTarget = isStepSubmitShaped({
|
|
12950
|
+
step,
|
|
12951
|
+
submitStep,
|
|
12952
|
+
isFinalStep,
|
|
12953
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
12954
|
+
})
|
|
11112
12955
|
? "escalating attempt 2 to deep-submit-locator"
|
|
11113
12956
|
: phantomClickAfterAttempt1
|
|
11114
12957
|
? "non-submit step — escalating attempt 2 to trusted-click-retry (trusted CDP click on the resolved target)"
|
|
@@ -11120,7 +12963,12 @@ async function executeStepWithHealing(params) {
|
|
|
11120
12963
|
// Treat the canonical submit click as "final" for this predicate
|
|
11121
12964
|
// even when it lives mid-flow. See requireSubmitEndpoint derivation
|
|
11122
12965
|
// above for the same gate-widening rationale.
|
|
11123
|
-
isFinalStep:
|
|
12966
|
+
isFinalStep: isStepSubmitShaped({
|
|
12967
|
+
step,
|
|
12968
|
+
submitStep,
|
|
12969
|
+
isFinalStep,
|
|
12970
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
12971
|
+
}),
|
|
11124
12972
|
requireSubmitEndpoint,
|
|
11125
12973
|
resolvedMethod: record.resolvedMethod,
|
|
11126
12974
|
effectSignals,
|
|
@@ -11140,7 +12988,12 @@ async function executeStepWithHealing(params) {
|
|
|
11140
12988
|
// can reorder a later step forward — instead of burning the cascade.
|
|
11141
12989
|
const advanceStalled = isAdvanceStalled({
|
|
11142
12990
|
isAdvance: isAdvanceStep(step),
|
|
11143
|
-
isFinalOrSubmit:
|
|
12991
|
+
isFinalOrSubmit: isStepSubmitShaped({
|
|
12992
|
+
step,
|
|
12993
|
+
submitStep,
|
|
12994
|
+
isFinalStep,
|
|
12995
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
12996
|
+
}),
|
|
11144
12997
|
hasPattern: advanceTransitionBodyPattern !== null,
|
|
11145
12998
|
clickFired: record.resolvedMethod === "click" && record.actResultSuccess === true,
|
|
11146
12999
|
networkFired,
|
|
@@ -11167,8 +13020,23 @@ async function executeStepWithHealing(params) {
|
|
|
11167
13020
|
// firing on a non-submit step could complete it by clicking an unrelated
|
|
11168
13021
|
// submit control in an unrelated same-origin child iframe.
|
|
11169
13022
|
if (!frameTarget?.declaredFrameSelector &&
|
|
11170
|
-
(
|
|
11171
|
-
|
|
13023
|
+
isStepSubmitShaped({
|
|
13024
|
+
step,
|
|
13025
|
+
submitStep,
|
|
13026
|
+
isFinalStep,
|
|
13027
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
13028
|
+
})) {
|
|
13029
|
+
const fallbackTarget = await probeChildFrameSubmitFallback({
|
|
13030
|
+
page,
|
|
13031
|
+
signalCounter,
|
|
13032
|
+
step,
|
|
13033
|
+
isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
|
|
13034
|
+
step,
|
|
13035
|
+
submitStep,
|
|
13036
|
+
isFinalStep,
|
|
13037
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
13038
|
+
}),
|
|
13039
|
+
});
|
|
11172
13040
|
if (fallbackTarget) {
|
|
11173
13041
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} cascade-exhausted: no candidate resolvable in the declared/main frame, but a same-origin child iframe surfaced a submit-shaped candidate — clicked and verified there instead of failing`);
|
|
11174
13042
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
@@ -11200,7 +13068,12 @@ async function executeStepWithHealing(params) {
|
|
|
11200
13068
|
const unfocusedObserve = cascadeExhaustObservedUnfocused.length === 0 && frameTarget?.frame
|
|
11201
13069
|
? await deepLocatorCandidatesAsActions(page, frameTarget)
|
|
11202
13070
|
: cascadeExhaustObservedUnfocused;
|
|
11203
|
-
const targetResolutionDiagnostic =
|
|
13071
|
+
const targetResolutionDiagnostic = isStepSubmitShaped({
|
|
13072
|
+
step,
|
|
13073
|
+
submitStep,
|
|
13074
|
+
isFinalStep,
|
|
13075
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
13076
|
+
})
|
|
11204
13077
|
? await (0, target_resolution_diagnostic_1.captureTargetResolutionDiagnosticSnapshot)(frameTarget, page, lastRankedSubmitCandidates)
|
|
11205
13078
|
: null;
|
|
11206
13079
|
const dumpPath = onStepFailure?.({
|
|
@@ -11220,7 +13093,7 @@ async function executeStepWithHealing(params) {
|
|
|
11220
13093
|
if (dumpPath !== null) {
|
|
11221
13094
|
logger.error(`${formatStepPrefix(stepIndex, totalSteps)} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);
|
|
11222
13095
|
}
|
|
11223
|
-
throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) failed verification after ${MAX_STEP_ATTEMPTS} attempts${dumpPath ? `; see ${dumpPath}` : ""}`, phantomClickAfterAttempt1 ? "phantom-click-exhausted" : "cascade-exhausted");
|
|
13096
|
+
throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactSecrets)(step).slice(0, 60)}) failed verification after ${MAX_STEP_ATTEMPTS} attempts${dumpPath ? `; see ${dumpPath}` : ""}`, phantomClickAfterAttempt1 ? "phantom-click-exhausted" : "cascade-exhausted");
|
|
11224
13097
|
}
|
|
11225
13098
|
/**
|
|
11226
13099
|
* Navigates `page` directly to `url` and waits for the SPA to hydrate,
|
|
@@ -11266,7 +13139,11 @@ async function executeNavigateStep(params) {
|
|
|
11266
13139
|
*/
|
|
11267
13140
|
async function runHealingFlow(deps) {
|
|
11268
13141
|
const { stagehand, page, steps, logger, anthropic, rephraseModel, uploadFixture, maxFlowMs } = deps;
|
|
13142
|
+
for (const secret of deps.secrets ?? [])
|
|
13143
|
+
(0, pii_redaction_1.registerSecretValue)(secret);
|
|
11269
13144
|
const counter = { n: 0 };
|
|
13145
|
+
// Rebound when a step's click opens a new tab, so later steps drive that page.
|
|
13146
|
+
let activePage = page;
|
|
11270
13147
|
const signalCounter = { n: 0 };
|
|
11271
13148
|
const recentCaptures = [];
|
|
11272
13149
|
const recentCaptureMeta = [];
|
|
@@ -11274,7 +13151,17 @@ async function runHealingFlow(deps) {
|
|
|
11274
13151
|
let submitVerified = false;
|
|
11275
13152
|
let submitStepSkipped = false;
|
|
11276
13153
|
let lastStepIndex = -1;
|
|
11277
|
-
const
|
|
13154
|
+
const liveOriginOf = (target) => {
|
|
13155
|
+
try {
|
|
13156
|
+
return new URL(target.url()).origin;
|
|
13157
|
+
}
|
|
13158
|
+
catch {
|
|
13159
|
+
return "";
|
|
13160
|
+
}
|
|
13161
|
+
};
|
|
13162
|
+
// Seeded so the first step does not re-gate; the initial gate lives with the caller's goto.
|
|
13163
|
+
let lastOrigin = liveOriginOf(activePage);
|
|
13164
|
+
const wireCapture = (target) => wireSignalCapture(target, {
|
|
11278
13165
|
counter,
|
|
11279
13166
|
signalCounter,
|
|
11280
13167
|
recentCaptures,
|
|
@@ -11282,13 +13169,15 @@ async function runHealingFlow(deps) {
|
|
|
11282
13169
|
getCurrentPhase: () => "flow",
|
|
11283
13170
|
getCurrentPageOrigin: () => {
|
|
11284
13171
|
try {
|
|
11285
|
-
return new URL(
|
|
13172
|
+
return new URL(activePage.url()).origin;
|
|
11286
13173
|
}
|
|
11287
13174
|
catch {
|
|
11288
13175
|
return "";
|
|
11289
13176
|
}
|
|
11290
13177
|
},
|
|
11291
13178
|
});
|
|
13179
|
+
// Listeners on earlier pages stay wired (a prior tab can still emit) and all stop on exit.
|
|
13180
|
+
const stopFns = [wireCapture(page)];
|
|
11292
13181
|
const flowHasSubmitSemanticsFlag = flowHasSubmitSemantics({
|
|
11293
13182
|
steps,
|
|
11294
13183
|
submitEndpointPattern: deps.submitEndpointPattern ?? null,
|
|
@@ -11315,19 +13204,27 @@ async function runHealingFlow(deps) {
|
|
|
11315
13204
|
// legitimately-absent optional step, and the flow's own step count
|
|
11316
13205
|
// never quietly reaches `steps.length` past the point of death.
|
|
11317
13206
|
try {
|
|
11318
|
-
|
|
13207
|
+
activePage.url();
|
|
11319
13208
|
}
|
|
11320
13209
|
catch (err) {
|
|
11321
13210
|
throw new errors_2.SessionTimeoutError(`${formatStepPrefix(i, () => steps.length)} session appears closed/dead (page.url() threw: ${(0, errors_1.toErrorMessage)(err)}) — aborting after ${i} of ${steps.length} steps completed`);
|
|
11322
13211
|
}
|
|
11323
13212
|
lastStepIndex = i;
|
|
13213
|
+
// A cross-origin hop (redirect, new tab, navigateTo) lands on a SPA the initial
|
|
13214
|
+
// readiness gate never covered; wait for hydration before the step probes a shell.
|
|
13215
|
+
const currentOrigin = liveOriginOf(activePage);
|
|
13216
|
+
if (currentOrigin !== "" && currentOrigin !== lastOrigin) {
|
|
13217
|
+
logger.info(`origin changed ${lastOrigin || "(none)"} → ${currentOrigin}; re-gating on SPA hydration`);
|
|
13218
|
+
await (0, spa_readiness_1.regateSpaAfterOriginChange)(activePage, logger);
|
|
13219
|
+
lastOrigin = currentOrigin;
|
|
13220
|
+
}
|
|
11324
13221
|
// A navigateTo step is a direct page.goto, not an act/observe against
|
|
11325
13222
|
// the current DOM, so it has no target frame to resolve and skips
|
|
11326
13223
|
// straight to executeNavigateStep instead of the self-heal cascade.
|
|
11327
13224
|
let stepPromise;
|
|
11328
13225
|
if (s.navigateTo !== undefined) {
|
|
11329
13226
|
stepPromise = executeNavigateStep({
|
|
11330
|
-
page,
|
|
13227
|
+
page: activePage,
|
|
11331
13228
|
url: s.navigateTo,
|
|
11332
13229
|
optional: s.optional,
|
|
11333
13230
|
logger,
|
|
@@ -11340,11 +13237,18 @@ async function runHealingFlow(deps) {
|
|
|
11340
13237
|
// reachable, paralleling the recon CLI's per-step resolution. `resolveFrameTarget`
|
|
11341
13238
|
// falls back to the main-frame target when `frameSelector` is null/unresolvable,
|
|
11342
13239
|
// so this is a no-op for every flow that doesn't declare one.
|
|
11343
|
-
const frameTarget = await (0, frame_target_1.resolveFrameTarget)(
|
|
13240
|
+
const frameTarget = await (0, frame_target_1.resolveFrameTarget)(activePage, deps.frameSelector);
|
|
11344
13241
|
await (0, frame_target_1.waitForChildFrameReady)(frameTarget);
|
|
11345
13242
|
stepPromise = executeStepWithHealing({
|
|
11346
13243
|
stagehand,
|
|
11347
|
-
page,
|
|
13244
|
+
page: activePage,
|
|
13245
|
+
onPageAdopted: (adopted) => {
|
|
13246
|
+
if (adopted === activePage)
|
|
13247
|
+
return;
|
|
13248
|
+
activePage = adopted;
|
|
13249
|
+
stopFns.push(wireCapture(adopted));
|
|
13250
|
+
},
|
|
13251
|
+
abortSignal: deps.abortSignal,
|
|
11348
13252
|
step: s.instruction,
|
|
11349
13253
|
optional: s.optional,
|
|
11350
13254
|
upload: s.upload,
|
|
@@ -11389,7 +13293,7 @@ async function runHealingFlow(deps) {
|
|
|
11389
13293
|
// runHealingFlow resolve as if the flow succeeded. Mirrors the same
|
|
11390
13294
|
// gate in recon-browser.ts's step loop.
|
|
11391
13295
|
try {
|
|
11392
|
-
|
|
13296
|
+
activePage.url();
|
|
11393
13297
|
}
|
|
11394
13298
|
catch (err) {
|
|
11395
13299
|
throw new errors_2.SessionTimeoutError(`${formatStepPrefix(i, () => steps.length)} session appears closed/dead (page.url() threw: ${(0, errors_1.toErrorMessage)(err)}) after step ${i} of ${steps.length} resolved`);
|
|
@@ -11404,7 +13308,8 @@ async function runHealingFlow(deps) {
|
|
|
11404
13308
|
}
|
|
11405
13309
|
}
|
|
11406
13310
|
finally {
|
|
11407
|
-
|
|
13311
|
+
for (const stop of stopFns)
|
|
13312
|
+
stop();
|
|
11408
13313
|
}
|
|
11409
13314
|
return { submitVerified, submitStepSkipped, lastStepIndex };
|
|
11410
13315
|
}
|