@enricai/barnacle 1.12.2 → 1.12.4
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/README.md +1 -1
- package/dist/lib/llm/anthropic-client.d.ts +10 -2
- package/dist/lib/llm/anthropic-client.d.ts.map +1 -1
- package/dist/lib/llm/anthropic-client.js.map +1 -1
- package/dist/plugins/config-plugin.d.ts.map +1 -1
- package/dist/plugins/config-plugin.js.map +1 -1
- package/dist/scraper/deep-locator-fake.js +1 -1
- package/dist/scraper/deep-locator-scan.d.ts +1 -1
- package/dist/scraper/deep-locator-scan.js +1 -1
- package/dist/scraper/flow-runner.d.ts +57 -23
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +410 -126
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scraper/session-browserbase.d.ts.map +1 -1
- package/dist/scraper/session-browserbase.js +7 -0
- package/dist/scraper/session-browserbase.js.map +1 -1
- package/dist/scraper/session-steel.d.ts.map +1 -1
- package/dist/scraper/session-steel.js +7 -0
- package/dist/scraper/session-steel.js.map +1 -1
- package/dist/scripts/recon-browser.d.ts +33 -0
- package/dist/scripts/recon-browser.d.ts.map +1 -1
- package/dist/scripts/recon-browser.js +27 -0
- package/dist/scripts/recon-browser.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +27 -2
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +116 -5
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +5 -3
|
@@ -24,6 +24,7 @@ exports.findRecentPageTransition = findRecentPageTransition;
|
|
|
24
24
|
exports.isWizardExitAction = isWizardExitAction;
|
|
25
25
|
exports.isAdvanceStep = isAdvanceStep;
|
|
26
26
|
exports.shouldCaptureSelectionState = shouldCaptureSelectionState;
|
|
27
|
+
exports.flowHasSubmitSemantics = flowHasSubmitSemantics;
|
|
27
28
|
exports.shouldWarnMissingAdvancePattern = shouldWarnMissingAdvancePattern;
|
|
28
29
|
exports.parseCaptureIndex = parseCaptureIndex;
|
|
29
30
|
exports.latestCaptureIndex = latestCaptureIndex;
|
|
@@ -58,6 +59,7 @@ exports.normalizeDateValue = normalizeDateValue;
|
|
|
58
59
|
exports.fillHtml5DateTimeInput = fillHtml5DateTimeInput;
|
|
59
60
|
exports.fillTextDatepickerInput = fillTextDatepickerInput;
|
|
60
61
|
exports.verifyFillReadback = verifyFillReadback;
|
|
62
|
+
exports.verifyPromptSelectorCommitted = verifyPromptSelectorCommitted;
|
|
61
63
|
exports.isUploadAffordanceLabel = isUploadAffordanceLabel;
|
|
62
64
|
exports.writeFixtureToTempFile = writeFixtureToTempFile;
|
|
63
65
|
exports.attachToSurfacedInput = attachToSurfacedInput;
|
|
@@ -386,6 +388,13 @@ const SELECT_SETTLE_MS = 400;
|
|
|
386
388
|
* triggered it.
|
|
387
389
|
*/
|
|
388
390
|
const PROMPT_SELECTOR_SETTLE_MS = 400;
|
|
391
|
+
/**
|
|
392
|
+
* Cap on the category->leaf drill loop in {@link commitPromptOption}: a
|
|
393
|
+
* two-level cascading multiselect (category click re-renders the popup to
|
|
394
|
+
* leaves) only ever drills one level deep in the wild, so 3 gives headroom
|
|
395
|
+
* for a rarer 2-level cascade without letting a genuinely broken widget spin.
|
|
396
|
+
*/
|
|
397
|
+
const PROMPT_SELECTOR_MAX_DRILL_DEPTH = 3;
|
|
389
398
|
/**
|
|
390
399
|
* Temporary attribute {@link tryPromptSelectorPrimitive} stamps onto each
|
|
391
400
|
* candidate widget during its read-only enumerate pass, so the Node-side click
|
|
@@ -396,6 +405,18 @@ const PROMPT_SELECTOR_SETTLE_MS = 400;
|
|
|
396
405
|
const PROMPT_WIDGET_MARK_ATTR = "data-bcl-prompt-idx";
|
|
397
406
|
/** Same role as {@link PROMPT_WIDGET_MARK_ATTR}, but for the popup's rendered option entries. */
|
|
398
407
|
const PROMPT_OPTION_MARK_ATTR = "data-bcl-prompt-opt-idx";
|
|
408
|
+
/**
|
|
409
|
+
* Cross-vendor selector for a genuine focusable form control — the only kind
|
|
410
|
+
* of element a native click event reliably opens. Phase 1's container walk
|
|
411
|
+
* marks the OUTERMOST widget-kit container so one widget resolves to one
|
|
412
|
+
* candidate, but that container is often a layout wrapper with no click
|
|
413
|
+
* handler of its own (e.g. a typeahead/chip widget's real open+filter control
|
|
414
|
+
* is an `<input>` nested a level deeper, inside its own `*InputContainer`
|
|
415
|
+
* wrapper). Phase 2 prefers clicking this interactive descendant over the
|
|
416
|
+
* marked container itself, falling back to the container only when it has
|
|
417
|
+
* none — no vendor branch, just "click the real control if one exists".
|
|
418
|
+
*/
|
|
419
|
+
const PROMPT_INTERACTIVE_CONTROL_SELECTORS = "button,[role='combobox'],[role='button'],input";
|
|
399
420
|
/**
|
|
400
421
|
* Cross-vendor selector union that identifies the TRIGGER of a native-control-less
|
|
401
422
|
* popup-dropdown widget (a combobox that opens a listbox popup and renders no
|
|
@@ -486,6 +507,30 @@ const PROMPT_EMPTY_VALUE_RX_FLAGS = PROMPT_EMPTY_VALUE_RX.flags;
|
|
|
486
507
|
* the remaining `textContent`.
|
|
487
508
|
*/
|
|
488
509
|
const BUTTON_VALUE_EXPR = "((el) => { const c = el.cloneNode(true); for (const n of c.querySelectorAll(\"[role='option'],[role='listbox'],[aria-hidden='true'],abbr,svg\")) n.remove(); return c.textContent || \"\"; })";
|
|
510
|
+
/**
|
|
511
|
+
* Browser-side expression reading a prompt-selector widget's OWN committed-value
|
|
512
|
+
* text — the single source of truth for "is this widget filled?", shared by the
|
|
513
|
+
* widget-enumeration phase ({@link tryPromptSelectorPrimitive}'s
|
|
514
|
+
* `enumerateWidgetsExpr`) and the act-success committed-value gate
|
|
515
|
+
* ({@link verifyPromptSelectorCommitted}) so the two never drift into separate
|
|
516
|
+
* ideas of "committed". Value via the union: `aria-activedescendant` → the
|
|
517
|
+
* referenced option's text, else the widget's own value (an `<input>`'s
|
|
518
|
+
* `value`, or a `<button>`-trigger's own label text via {@link BUTTON_VALUE_EXPR}),
|
|
519
|
+
* else a selection-label node ({@link PROMPT_VALUE_SELECTORS}) — treating the
|
|
520
|
+
* widget-kit empty-state phrase ({@link PROMPT_EMPTY_VALUE_RX}) as no value.
|
|
521
|
+
*/
|
|
522
|
+
const PROMPT_CURRENT_TEXT_EXPR = `((w, valueSel, emptyRx) => {
|
|
523
|
+
const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
|
|
524
|
+
const buttonValue = ${BUTTON_VALUE_EXPR};
|
|
525
|
+
const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
|
|
526
|
+
if (adid) { const opt = document.getElementById(adid); if (opt && opt.textContent.trim()) return norm(opt.textContent); }
|
|
527
|
+
if (w.tagName === "INPUT" && (w.value || "").trim()) return norm(w.value);
|
|
528
|
+
if (w.tagName === "BUTTON" && buttonValue(w).trim()) return norm(buttonValue(w));
|
|
529
|
+
const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
|
|
530
|
+
const raw = lbl ? (lbl.textContent || "") : "";
|
|
531
|
+
if (raw.trim() && !emptyRx.test(raw.trim())) return norm(raw);
|
|
532
|
+
return "";
|
|
533
|
+
})`;
|
|
489
534
|
/**
|
|
490
535
|
* Browser-side expression resolving the DOM root within which a chosen widget's
|
|
491
536
|
* popup options / filter input live. Popup placement is vendor-split: the ARIA
|
|
@@ -923,8 +968,20 @@ function isAdvanceStep(instruction) {
|
|
|
923
968
|
* cascade depends on is unit-testable, not buried in `executeStepWithHealing`.
|
|
924
969
|
*/
|
|
925
970
|
function shouldCaptureSelectionState(params) {
|
|
926
|
-
const { step, isFinalStep, submitStep } = params;
|
|
927
|
-
return !(
|
|
971
|
+
const { step, isFinalStep, submitStep, flowHasSubmitSemantics } = params;
|
|
972
|
+
return !(submitStep || (isFinalStep && flowHasSubmitSemantics) || isAdvanceStep(step));
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Whether a flow has ANY submit semantics at all — a step flagged
|
|
976
|
+
* `submitStep: true`, a `submitEndpointPattern`, or `requireSubmitEndpointMatch`.
|
|
977
|
+
* A read-only flow (none of the three) has no submit shape anywhere, so its
|
|
978
|
+
* final step is an ordinary read/click, not a submit. Pure + exported so
|
|
979
|
+
* callers can stop inferring submit-shape from `isFinalStep` alone on flows
|
|
980
|
+
* that never declared a submit.
|
|
981
|
+
*/
|
|
982
|
+
function flowHasSubmitSemantics(params) {
|
|
983
|
+
const { steps, submitEndpointPattern, requireSubmitEndpointMatch } = params;
|
|
984
|
+
return (steps.some((s) => s.submitStep) || submitEndpointPattern !== null || requireSubmitEndpointMatch);
|
|
928
985
|
}
|
|
929
986
|
/**
|
|
930
987
|
* Whether a flow should be WARNed that its DOM-only advance guard is disarmed.
|
|
@@ -1200,10 +1257,10 @@ function isDomOnlyAdvanceVerified(params) {
|
|
|
1200
1257
|
function isClickViewSwapVerified(params) {
|
|
1201
1258
|
const VIEW_SWAP_MIN_BYTES = config_1.config.scraper.viewSwapMinBytesThreshold;
|
|
1202
1259
|
const VIEW_SWAP_REVEAL_MIN_BYTES = config_1.config.scraper.viewSwapRevealMinBytesThreshold;
|
|
1203
|
-
const { resolvedAction, isFinalStep, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, } = params;
|
|
1260
|
+
const { resolvedAction, isFinalStep, submitStep, flowHasSubmitSemantics, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, } = params;
|
|
1204
1261
|
if (resolvedAction?.method !== "click")
|
|
1205
1262
|
return false;
|
|
1206
|
-
if (
|
|
1263
|
+
if (submitStep || (isFinalStep && flowHasSubmitSemantics))
|
|
1207
1264
|
return false;
|
|
1208
1265
|
if (isAdvanceWithPattern)
|
|
1209
1266
|
return false;
|
|
@@ -2715,6 +2772,50 @@ async function verifyFillReadback(target, selector, expectedValue) {
|
|
|
2715
2772
|
return null;
|
|
2716
2773
|
}
|
|
2717
2774
|
}
|
|
2775
|
+
/**
|
|
2776
|
+
* Committed-value guard for the prompt-selector widget family
|
|
2777
|
+
* (`data-uxi-widget-type='multiselect'`/`selectinput`, `role=combobox`, …),
|
|
2778
|
+
* mirroring {@link verifyFillReadback}'s role for plain inputs but reading the
|
|
2779
|
+
* widget's OWN committed-value node ({@link PROMPT_CURRENT_TEXT_EXPR}) instead
|
|
2780
|
+
* of a bare `<input>.value` — a chip multiselect's committed state lives on
|
|
2781
|
+
* `aria-activedescendant`/a selection-label node, not the trigger's own value.
|
|
2782
|
+
* Walks UP from the resolved element to the nearest {@link PROMPT_TRIGGER_SELECTORS}
|
|
2783
|
+
* ancestor (the resolved element is often the inner filter input or icon, not
|
|
2784
|
+
* the widget container itself — same resolution discipline as
|
|
2785
|
+
* `tryPromptSelectorPrimitive`'s widget-container walk). Returns
|
|
2786
|
+
* `isPromptWidget: false` for a resolved element that isn't part of this
|
|
2787
|
+
* widget family at all, so a non-prompt click/fill is untouched.
|
|
2788
|
+
*/
|
|
2789
|
+
async function verifyPromptSelectorCommitted(target, selector) {
|
|
2790
|
+
const xpath = xpathBodyForEvaluate(selector);
|
|
2791
|
+
if (xpath === null)
|
|
2792
|
+
return null;
|
|
2793
|
+
const expr = `(() => {
|
|
2794
|
+
const triggerSel = ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)};
|
|
2795
|
+
const valueSel = ${JSON.stringify(PROMPT_VALUE_SELECTORS)};
|
|
2796
|
+
const emptyRx = new RegExp(${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)});
|
|
2797
|
+
const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
|
|
2798
|
+
const xpath = ${JSON.stringify(xpath)};
|
|
2799
|
+
const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
2800
|
+
const el = result.singleNodeValue;
|
|
2801
|
+
if (!el) return { isPromptWidget: false, committed: false };
|
|
2802
|
+
const widget = el.matches && el.matches(triggerSel) ? el : el.closest(triggerSel);
|
|
2803
|
+
if (!widget) return { isPromptWidget: false, committed: false };
|
|
2804
|
+
return { isPromptWidget: true, committed: currentText(widget, valueSel, emptyRx) !== "" };
|
|
2805
|
+
})()`;
|
|
2806
|
+
try {
|
|
2807
|
+
const raw = await target.evaluate(expr);
|
|
2808
|
+
if (raw === null || typeof raw !== "object")
|
|
2809
|
+
return null;
|
|
2810
|
+
const r = raw;
|
|
2811
|
+
if (typeof r.isPromptWidget !== "boolean" || typeof r.committed !== "boolean")
|
|
2812
|
+
return null;
|
|
2813
|
+
return { isPromptWidget: r.isPromptWidget, committed: r.committed };
|
|
2814
|
+
}
|
|
2815
|
+
catch {
|
|
2816
|
+
return null;
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2718
2819
|
/**
|
|
2719
2820
|
* Pull field-level errors out of an arbitrary JSON response body. Walks a
|
|
2720
2821
|
* few of the conventional ATS shapes; falls through to `[]` so the caller
|
|
@@ -3521,11 +3622,54 @@ async function setFilesViaCdp(params) {
|
|
|
3521
3622
|
* Recognizes the flow's conventional phrasings, all quoted:
|
|
3522
3623
|
* "select 'Yes'", "select or check 'BLS'",
|
|
3523
3624
|
* "for 'What is your highest level…?' select 'BSN completed'",
|
|
3524
|
-
* "select 'Texas' in the State/Region dropdown"
|
|
3625
|
+
* "select 'Texas' in the State/Region dropdown",
|
|
3626
|
+
* "…then select the option 'Job Boards' from the popup list" (a compound
|
|
3627
|
+
* step that opens the widget in one clause and selects in another —
|
|
3628
|
+
* "select" is followed by a noun phrase like "the option"/"the value"
|
|
3629
|
+
* before the quoted option itself, rather than the quote directly).
|
|
3525
3630
|
* Returns null when the step is not a single-dropdown select (e.g. generic
|
|
3526
3631
|
* "for any remaining question…" catch-alls, or radio/checkbox-only steps) so
|
|
3527
3632
|
* the caller falls through to the normal cascade.
|
|
3528
3633
|
*/
|
|
3634
|
+
// Nouns that name a form widget/question, used by `pickQuestionLabel` to tell
|
|
3635
|
+
// a genuine field label ("'How Did You Hear About Us?' prompt selector")
|
|
3636
|
+
// apart from an unrelated leading quoted phrase that just happens to precede
|
|
3637
|
+
// it in the instruction (e.g. a page/step-context quote like "'My
|
|
3638
|
+
// Information' step").
|
|
3639
|
+
const WIDGET_NOUN_RE = /\b(prompt\s+selector|multiselect|typeahead|dropdown|field|question|checkbox|radio\s+button|radio)\b/i;
|
|
3640
|
+
/**
|
|
3641
|
+
* Pick the QUESTION LABEL out of an instruction's quoted phrases, given the
|
|
3642
|
+
* already-extracted OPTION.
|
|
3643
|
+
*
|
|
3644
|
+
* Why this exists: an instruction can carry more than one quoted phrase that
|
|
3645
|
+
* is not the option — e.g. a page/step-context phrase ("On the authenticated
|
|
3646
|
+
* 'My Information' step, open the 'How Did You Hear About Us?' prompt
|
|
3647
|
+
* selector…") — so naively taking the first non-option quote picks the
|
|
3648
|
+
* context phrase instead of the actual widget label. This prefers a quote
|
|
3649
|
+
* that sits immediately next to a widget noun (`multiselect`/`typeahead`/
|
|
3650
|
+
* `dropdown`/`field`/`question`/`checkbox`/`radio button`/`prompt selector`)
|
|
3651
|
+
* or is introduced
|
|
3652
|
+
* by "for"/"for the"/"for the question"/"for the answer" (e.g. "click the
|
|
3653
|
+
* 'Yes' answer for the question '…'"), before falling back to the first
|
|
3654
|
+
* non-option quote so existing un-adorned phrasings keep working.
|
|
3655
|
+
*/
|
|
3656
|
+
function pickQuestionLabel(instruction, option) {
|
|
3657
|
+
// biome-ignore lint/style/noNonNullAssertion: capture group 1 is required by the pattern, so it is present on every match
|
|
3658
|
+
const candidates = [...instruction.matchAll(/'([^']+)'/g)].filter((m) => m[1].trim() !== option);
|
|
3659
|
+
if (candidates.length === 0)
|
|
3660
|
+
return null;
|
|
3661
|
+
const adjacentToWidgetNoun = candidates.find((m) => {
|
|
3662
|
+
const start = m.index ?? 0;
|
|
3663
|
+
const end = start + m[0].length;
|
|
3664
|
+
const before = instruction.slice(Math.max(0, start - 25), start);
|
|
3665
|
+
const after = instruction.slice(end, end + 40);
|
|
3666
|
+
return (/\bfor(?:\s+the)?(?:\s+(?:question|answer))?\s*$/i.test(before) || WIDGET_NOUN_RE.test(after));
|
|
3667
|
+
});
|
|
3668
|
+
// biome-ignore lint/style/noNonNullAssertion: candidates is guarded non-empty above, so the fallback element is always present
|
|
3669
|
+
const picked = (adjacentToWidgetNoun ?? candidates[0]);
|
|
3670
|
+
// biome-ignore lint/style/noNonNullAssertion: capture group 1 is required by the pattern, so it is present on every match
|
|
3671
|
+
return picked[1].trim();
|
|
3672
|
+
}
|
|
3529
3673
|
function parseSelectStep(instruction) {
|
|
3530
3674
|
const lower = instruction.toLowerCase();
|
|
3531
3675
|
// Must look like a dropdown selection, not a radio/checkbox click. "select
|
|
@@ -3541,16 +3685,20 @@ function parseSelectStep(instruction) {
|
|
|
3541
3685
|
const quoted = [...instruction.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
|
3542
3686
|
if (quoted.length === 0)
|
|
3543
3687
|
return null;
|
|
3544
|
-
// The OPTION is the quoted string
|
|
3545
|
-
|
|
3688
|
+
// The OPTION is the quoted string following the word "select", allowing a
|
|
3689
|
+
// short filler ("the option", "the answer") between the verb and the
|
|
3690
|
+
// quote — real flow phrasing like "select the option 'Job Boards'" or
|
|
3691
|
+
// "select the answer 'X'" otherwise fails to parse and the caller
|
|
3692
|
+
// silently no-ops before touching the DOM.
|
|
3693
|
+
const selMatch = instruction.match(/\bselect(?:\s+or\s+check)?\s+(?:the\s+\S+\s+)?'([^']+)'/i);
|
|
3546
3694
|
if (!selMatch)
|
|
3547
3695
|
return null;
|
|
3548
3696
|
// biome-ignore lint/style/noNonNullAssertion: guarded by the !selMatch early-return; group 1 is required by the pattern
|
|
3549
3697
|
const option = selMatch[1].trim();
|
|
3550
3698
|
// The QUESTION LABEL, when present, is a DIFFERENT quoted string — the one
|
|
3551
|
-
// introduced by "for '…'" or
|
|
3552
|
-
//
|
|
3553
|
-
const questionLabel =
|
|
3699
|
+
// introduced by "for '…'" or adjacent to a widget noun like "'…' dropdown"
|
|
3700
|
+
// or "'…' prompt selector". See `pickQuestionLabel`.
|
|
3701
|
+
const questionLabel = pickQuestionLabel(instruction, option);
|
|
3554
3702
|
return { option, questionLabel };
|
|
3555
3703
|
}
|
|
3556
3704
|
/**
|
|
@@ -3668,8 +3816,9 @@ function parseRadioStep(instruction) {
|
|
|
3668
3816
|
// introduced by "for the question '…'" / "for the '…' question". Some steps
|
|
3669
3817
|
// phrase the question un-quoted ("…about requiring visa sponsorship"); in
|
|
3670
3818
|
// that case there is no second quoted string and questionLabel stays null,
|
|
3671
|
-
// which the primitive handles via LLM group-matching.
|
|
3672
|
-
|
|
3819
|
+
// which the primitive handles via LLM group-matching. See
|
|
3820
|
+
// `pickQuestionLabel`.
|
|
3821
|
+
const questionLabel = pickQuestionLabel(instruction, option);
|
|
3673
3822
|
return { option, questionLabel };
|
|
3674
3823
|
}
|
|
3675
3824
|
/**
|
|
@@ -4969,28 +5118,15 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
4969
5118
|
}
|
|
4970
5119
|
return "";
|
|
4971
5120
|
};
|
|
4972
|
-
const currentText =
|
|
4973
|
-
|
|
4974
|
-
// own value (an <input>'s value, or a <button>-trigger's own label text —
|
|
4975
|
-
// a button that IS the trigger carries the committed choice as its own
|
|
4976
|
-
// text), else a selection-label node — treating the widget-kit empty-state
|
|
4977
|
-
// phrase as no value.
|
|
4978
|
-
const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
|
|
4979
|
-
if (adid) { const opt = document.getElementById(adid); if (opt && opt.textContent.trim()) return norm(opt.textContent); }
|
|
4980
|
-
if (w.tagName === "INPUT" && (w.value || "").trim()) return norm(w.value);
|
|
4981
|
-
if (w.tagName === "BUTTON" && buttonValue(w).trim()) return norm(buttonValue(w));
|
|
4982
|
-
const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
|
|
4983
|
-
const raw = lbl ? (lbl.textContent || "") : "";
|
|
4984
|
-
if (raw.trim() && !emptyRx.test(raw.trim())) return norm(raw);
|
|
4985
|
-
return "";
|
|
4986
|
-
};
|
|
5121
|
+
const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
|
|
5122
|
+
const currentTextOf = (w) => currentText(w, valueSel, emptyRx);
|
|
4987
5123
|
const isUnfilled = (w) => {
|
|
4988
5124
|
let node = w;
|
|
4989
5125
|
for (let d = 0; d < 6 && node; d++) {
|
|
4990
5126
|
if (node.getAttribute && isInvalid(node)) return true;
|
|
4991
5127
|
node = node.parentElement;
|
|
4992
5128
|
}
|
|
4993
|
-
return
|
|
5129
|
+
return currentTextOf(w) === "";
|
|
4994
5130
|
};
|
|
4995
5131
|
// Clear stale marks from a prior call on this same page (this primitive
|
|
4996
5132
|
// runs once per "select 'X'" step, and an application wizard answers several
|
|
@@ -5038,17 +5174,6 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
5038
5174
|
logger.info(`prompt-selector primitive: no unambiguous widget match for ${optLabel}; falling through to cascade`);
|
|
5039
5175
|
return null;
|
|
5040
5176
|
}
|
|
5041
|
-
// Phase 2 (real gesture): open the popup. These widgets' trigger requires a
|
|
5042
|
-
// genuine click — a synthetic dispatchEvent does not fire its handler.
|
|
5043
|
-
const triggerSel = `[${PROMPT_WIDGET_MARK_ATTR}="${chosen.wIdx}"]`;
|
|
5044
|
-
try {
|
|
5045
|
-
await target.locator(triggerSel).first().click();
|
|
5046
|
-
}
|
|
5047
|
-
catch (err) {
|
|
5048
|
-
logger.info(`prompt-selector primitive: trigger click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
|
|
5049
|
-
return null;
|
|
5050
|
-
}
|
|
5051
|
-
await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
|
|
5052
5177
|
const enumerateOptionsExpr = `((widgetMarkAttr, wIdx, markAttr, optionSel, searchSel) => {
|
|
5053
5178
|
const w = document.querySelector("[" + widgetMarkAttr + '="' + wIdx + '"]');
|
|
5054
5179
|
if (!w) return { optionsPresent: false };
|
|
@@ -5087,9 +5212,65 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
5087
5212
|
const label = opts[i].getAttribute("data-automation-label") || opts[i].getAttribute("aria-label") || opts[i].textContent || "";
|
|
5088
5213
|
options.push({ oIdx: i, text: label.replace(/\\s+/g, " ").trim() });
|
|
5089
5214
|
}
|
|
5090
|
-
|
|
5215
|
+
// scopeIsDocument distinguishes a genuinely resolved (portal or inline)
|
|
5216
|
+
// popup from the document-wide last-resort fallback in
|
|
5217
|
+
// PROMPT_SCOPE_ROOT_EXPR — the pre-open pre-check below must NOT treat a
|
|
5218
|
+
// still-unopened widget as "already open" on the strength of some OTHER
|
|
5219
|
+
// widget's stale/leftover popup elsewhere in the document; only a scope
|
|
5220
|
+
// that resolved to THIS widget's own inline subtree or its own
|
|
5221
|
+
// aria-controls/aria-owns target counts.
|
|
5222
|
+
return { optionsPresent: true, searchable: !!searchInput, options, scopeIsDocument: scope === document };
|
|
5091
5223
|
})(${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)})`;
|
|
5092
|
-
|
|
5224
|
+
// Pre-check (single evaluate, no polling): a PRIOR primitive call on this
|
|
5225
|
+
// same unreloaded page may have already opened/drilled this exact widget's
|
|
5226
|
+
// popup (a two-level cascading multiselect authored as two flow steps —
|
|
5227
|
+
// category, then leaf — re-enters here for the leaf with the popup already
|
|
5228
|
+
// open). Re-clicking the trigger in that case perturbs/closes the
|
|
5229
|
+
// already-rendered popup instead of reading it. Skip the open-click
|
|
5230
|
+
// entirely when options are already present AND resolved to THIS widget's
|
|
5231
|
+
// OWN scope (not the document-wide fallback, which could otherwise credit
|
|
5232
|
+
// an unrelated widget's stale/leftover popup elsewhere on the page as
|
|
5233
|
+
// "already open"); a genuinely closed popup falls through to the normal
|
|
5234
|
+
// open-click + poll path below unchanged.
|
|
5235
|
+
const precheck = (await target.evaluate(enumerateOptionsExpr));
|
|
5236
|
+
const alreadyOpen = precheck?.optionsPresent === true &&
|
|
5237
|
+
(precheck.options?.length ?? 0) > 0 &&
|
|
5238
|
+
precheck.scopeIsDocument !== true;
|
|
5239
|
+
if (!alreadyOpen) {
|
|
5240
|
+
// Phase 2 (real gesture): open the popup. These widgets' trigger requires a
|
|
5241
|
+
// genuine click — a synthetic dispatchEvent does not fire its handler.
|
|
5242
|
+
// Prefer the marked widget's own interactive descendant (a real form
|
|
5243
|
+
// control) over the marked container itself, since the container is
|
|
5244
|
+
// frequently a non-interactive layout wrapper for widget shapes whose
|
|
5245
|
+
// actual trigger sits a level deeper (see PROMPT_INTERACTIVE_CONTROL_SELECTORS).
|
|
5246
|
+
const containerTriggerSel = `[${PROMPT_WIDGET_MARK_ATTR}="${chosen.wIdx}"]`;
|
|
5247
|
+
const innerTriggerSel = PROMPT_INTERACTIVE_CONTROL_SELECTORS.split(",")
|
|
5248
|
+
.map((s) => `${containerTriggerSel} ${s}`)
|
|
5249
|
+
.join(",");
|
|
5250
|
+
let triggerSel = containerTriggerSel;
|
|
5251
|
+
try {
|
|
5252
|
+
const innerCount = await target.locator(innerTriggerSel).count();
|
|
5253
|
+
if (innerCount > 0)
|
|
5254
|
+
triggerSel = innerTriggerSel;
|
|
5255
|
+
}
|
|
5256
|
+
catch {
|
|
5257
|
+
// Fall back to the container selector.
|
|
5258
|
+
}
|
|
5259
|
+
try {
|
|
5260
|
+
await target.locator(triggerSel).first().click();
|
|
5261
|
+
}
|
|
5262
|
+
catch (err) {
|
|
5263
|
+
logger.info(`prompt-selector primitive: trigger click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
|
|
5264
|
+
return null;
|
|
5265
|
+
}
|
|
5266
|
+
await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
|
|
5267
|
+
}
|
|
5268
|
+
else {
|
|
5269
|
+
logger.info(`prompt-selector primitive: popup for ${optLabel} already open with rendered options; skipping trigger click`);
|
|
5270
|
+
}
|
|
5271
|
+
const optionsInitial = alreadyOpen
|
|
5272
|
+
? precheck
|
|
5273
|
+
: await pollEnumerate(page, target, enumerateOptionsExpr, (r) => r?.optionsPresent === true);
|
|
5093
5274
|
if (!optionsInitial?.optionsPresent) {
|
|
5094
5275
|
logger.info(`prompt-selector primitive: popup for ${optLabel} did not render options; falling through`);
|
|
5095
5276
|
return null;
|
|
@@ -5112,6 +5293,7 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
5112
5293
|
questionLabel,
|
|
5113
5294
|
chosen,
|
|
5114
5295
|
optionsResult,
|
|
5296
|
+
enumerateOptionsExpr,
|
|
5115
5297
|
});
|
|
5116
5298
|
}
|
|
5117
5299
|
try {
|
|
@@ -5145,6 +5327,7 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
5145
5327
|
questionLabel,
|
|
5146
5328
|
chosen,
|
|
5147
5329
|
optionsResult: optionsFiltered,
|
|
5330
|
+
enumerateOptionsExpr,
|
|
5148
5331
|
});
|
|
5149
5332
|
}
|
|
5150
5333
|
catch (err) {
|
|
@@ -5161,82 +5344,115 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
5161
5344
|
* by the value union, or its invalid marker cleared). Split out from the main
|
|
5162
5345
|
* function because both the static and searchable-then-filtered branches need
|
|
5163
5346
|
* the identical match/click/verify sequence.
|
|
5347
|
+
*
|
|
5348
|
+
* Bounded (see {@link PROMPT_SELECTOR_MAX_DRILL_DEPTH}) category->leaf drill:
|
|
5349
|
+
* when a click's readback fails, that is ambiguous between a genuine
|
|
5350
|
+
* non-commit and a category click that swapped the popup to a deeper level
|
|
5351
|
+
* (a two-level cascading multiselect authored as a SINGLE step naming only
|
|
5352
|
+
* the leaf). Re-enumerating after a failed readback and comparing the
|
|
5353
|
+
* option set disambiguates the two: an unchanged set is a real failure and
|
|
5354
|
+
* must still fall through to the cascade; a changed set means the drill
|
|
5355
|
+
* fired, so re-match/re-click continues at the new level instead of
|
|
5356
|
+
* abandoning the primitive.
|
|
5164
5357
|
*/
|
|
5165
5358
|
async function commitPromptOption(params) {
|
|
5166
|
-
const { page, target, logger, anthropic, captureFn, optLabel, option, questionLabel, chosen,
|
|
5359
|
+
const { page, target, logger, anthropic, captureFn, optLabel, option, questionLabel, chosen, enumerateOptionsExpr, } = params;
|
|
5167
5360
|
const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
|
|
5168
5361
|
const wantOpt = norm(option);
|
|
5169
|
-
const
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5362
|
+
const optionSetKey = (opts) => opts
|
|
5363
|
+
.map((o) => norm(o.text))
|
|
5364
|
+
.sort()
|
|
5365
|
+
.join("");
|
|
5366
|
+
let optionsResult = params.optionsResult;
|
|
5367
|
+
for (let depth = 0; depth <= PROMPT_SELECTOR_MAX_DRILL_DEPTH; depth++) {
|
|
5368
|
+
const options = optionsResult.options ?? [];
|
|
5369
|
+
const detMatch = options.find((o) => norm(o.text) === wantOpt) ?? null;
|
|
5370
|
+
if (detMatch === null && (anthropic === null || options.length === 0)) {
|
|
5371
|
+
logger.info(`prompt-selector primitive: no option match for ${optLabel}${anthropic === null ? " (no LLM client)" : ""}; falling through to cascade`);
|
|
5372
|
+
return null;
|
|
5373
|
+
}
|
|
5374
|
+
const verdict = detMatch === null
|
|
5375
|
+
? await (0, select_option_1.judgeSelectOptionWithLLM)({
|
|
5376
|
+
client: anthropic,
|
|
5377
|
+
input: {
|
|
5378
|
+
questionLabel,
|
|
5379
|
+
desiredHint: option,
|
|
5380
|
+
candidates: [{ label: chosen.label || null, options: options.map((o) => o.text) }],
|
|
5381
|
+
},
|
|
5382
|
+
captureFn,
|
|
5383
|
+
})
|
|
5384
|
+
: null;
|
|
5385
|
+
if (detMatch === null &&
|
|
5386
|
+
(!verdict || verdict.selectIndex === null || verdict.optionIndex === null)) {
|
|
5387
|
+
logger.info(`prompt-selector primitive: LLM found no matching option for ${optLabel}${verdict ? ` (${verdict.reason})` : ""}; falling through to cascade`);
|
|
5388
|
+
return null;
|
|
5389
|
+
}
|
|
5390
|
+
const chosenOption = detMatch ??
|
|
5391
|
+
(verdict && verdict.optionIndex !== null ? (options[verdict.optionIndex] ?? null) : null);
|
|
5392
|
+
if (!chosenOption) {
|
|
5393
|
+
logger.info(`prompt-selector primitive: option match out of range for ${optLabel}; falling through`);
|
|
5394
|
+
return null;
|
|
5395
|
+
}
|
|
5396
|
+
const matchReason = detMatch ? "deterministic" : `LLM: ${(verdict?.reason ?? "").slice(0, 60)}`;
|
|
5397
|
+
const optionSel = `[${PROMPT_OPTION_MARK_ATTR}="${chosenOption.oIdx}"]`;
|
|
5398
|
+
try {
|
|
5399
|
+
await target.locator(optionSel).first().click();
|
|
5400
|
+
}
|
|
5401
|
+
catch (err) {
|
|
5402
|
+
logger.info(`prompt-selector primitive: option click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
|
|
5403
|
+
return null;
|
|
5404
|
+
}
|
|
5405
|
+
await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
|
|
5406
|
+
const readbackExpr = `((wIdx, markAttr, valueSel, emptyRxSrc, emptyRxFlags, wantText) => {
|
|
5407
|
+
const isInvalid = ${INVALID_MARKER_EL_EXPR};
|
|
5408
|
+
const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
|
|
5409
|
+
const buttonValue = ${BUTTON_VALUE_EXPR};
|
|
5410
|
+
const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
|
|
5411
|
+
const w = document.querySelector("[" + markAttr + '="' + wIdx + '"]');
|
|
5412
|
+
if (!w) return { ok: false, id: "" };
|
|
5413
|
+
// Value via the union: aria-activedescendant, own input value, a <button>'s
|
|
5414
|
+
// own value text (popup-pollution-safe, see BUTTON_VALUE_EXPR), or a
|
|
5415
|
+
// selection-label node (empty-state phrase treated as no value).
|
|
5416
|
+
const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
|
|
5417
|
+
const adText = adid && document.getElementById(adid) ? norm(document.getElementById(adid).textContent) : "";
|
|
5418
|
+
const own = w.tagName === "INPUT" ? norm(w.value || "") : w.tagName === "BUTTON" ? norm(buttonValue(w)) : "";
|
|
5419
|
+
const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
|
|
5420
|
+
const lblRaw = lbl ? (lbl.textContent || "") : "";
|
|
5421
|
+
const lblText = lblRaw.trim() && !emptyRx.test(lblRaw.trim()) ? norm(lblRaw) : "";
|
|
5422
|
+
const text = adText || own || lblText;
|
|
5423
|
+
const textMatches = wantText ? text.includes(wantText) : text !== "";
|
|
5424
|
+
let node = w;
|
|
5425
|
+
let stillInvalid = false;
|
|
5426
|
+
for (let d = 0; d < 6 && node; d++) {
|
|
5427
|
+
if (node.getAttribute && isInvalid(node)) { stillInvalid = true; break; }
|
|
5428
|
+
node = node.parentElement;
|
|
5429
|
+
}
|
|
5430
|
+
return { ok: textMatches || !stillInvalid, id: w.id || "" };
|
|
5431
|
+
})(${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))})`;
|
|
5432
|
+
const readback = (await target.evaluate(readbackExpr).catch(() => ({ ok: false, id: "" })));
|
|
5433
|
+
if (readback?.ok) {
|
|
5434
|
+
logger.info(`prompt-selector primitive: selected "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${matchReason})`);
|
|
5435
|
+
return readback.id;
|
|
5436
|
+
}
|
|
5437
|
+
if (depth === PROMPT_SELECTOR_MAX_DRILL_DEPTH) {
|
|
5438
|
+
logger.info(`prompt-selector primitive: selection for ${optLabel} did not commit; falling through to cascade`);
|
|
5439
|
+
return null;
|
|
5440
|
+
}
|
|
5441
|
+
// Readback failed — disambiguate a genuine non-commit from a
|
|
5442
|
+
// category->leaf drill by re-enumerating and comparing the option set.
|
|
5443
|
+
const reEnumerated = (await target
|
|
5444
|
+
.evaluate(enumerateOptionsExpr)
|
|
5445
|
+
.catch(() => ({ optionsPresent: false })));
|
|
5446
|
+
const newOptions = reEnumerated?.optionsPresent ? (reEnumerated.options ?? []) : [];
|
|
5447
|
+
const drilled = newOptions.length > 0 && optionSetKey(newOptions) !== optionSetKey(options);
|
|
5448
|
+
if (!drilled) {
|
|
5449
|
+
logger.info(`prompt-selector primitive: selection for ${optLabel} did not commit; falling through to cascade`);
|
|
5450
|
+
return null;
|
|
5451
|
+
}
|
|
5452
|
+
logger.info(`prompt-selector primitive: click for ${optLabel} drilled the popup to a new option set; re-matching at the new level`);
|
|
5453
|
+
optionsResult = { options: newOptions };
|
|
5237
5454
|
}
|
|
5238
|
-
|
|
5239
|
-
return readback.id;
|
|
5455
|
+
return null;
|
|
5240
5456
|
}
|
|
5241
5457
|
/**
|
|
5242
5458
|
* Guard for the optional-step fast-skip: is there a REQUIRED, still-empty (or
|
|
@@ -6146,7 +6362,7 @@ async function probeStepBeforeAttempts(params) {
|
|
|
6146
6362
|
}
|
|
6147
6363
|
}
|
|
6148
6364
|
async function executeStepWithHealing(params) {
|
|
6149
|
-
const { stagehand, page, step, optional, upload, submitStep, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, rephraseModel, logger, captureFn, uploadFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, } = params;
|
|
6365
|
+
const { stagehand, page, step, optional, upload, submitStep, 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, } = params;
|
|
6150
6366
|
// Mutable (not the destructured const above) so a lost frame-attach race
|
|
6151
6367
|
// can be upgraded in place once the OOPIF attaches later in the cascade —
|
|
6152
6368
|
// see reresolveFrameTargetIfLost below. Every existing reference in this
|
|
@@ -6214,7 +6430,12 @@ async function executeStepWithHealing(params) {
|
|
|
6214
6430
|
// false-credit the step, and whose advance/submit verdicts require a real
|
|
6215
6431
|
// network/URL transition. Also keeps the extra full-DOM evaluate off the
|
|
6216
6432
|
// submit/advance path. Step-level intent (available before the attempt loop).
|
|
6217
|
-
const captureSelectionState = shouldCaptureSelectionState({
|
|
6433
|
+
const captureSelectionState = shouldCaptureSelectionState({
|
|
6434
|
+
step,
|
|
6435
|
+
isFinalStep,
|
|
6436
|
+
submitStep,
|
|
6437
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
6438
|
+
});
|
|
6218
6439
|
const attempts = [];
|
|
6219
6440
|
const triedSelectors = [];
|
|
6220
6441
|
const failureReasons = [];
|
|
@@ -6648,7 +6869,7 @@ async function executeStepWithHealing(params) {
|
|
|
6648
6869
|
// techniques faster.
|
|
6649
6870
|
if (attempt > 1) {
|
|
6650
6871
|
const wouldBeTechnique = attempt === 2
|
|
6651
|
-
? phantomClickAfterAttempt1 && (
|
|
6872
|
+
? phantomClickAfterAttempt1 && (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))
|
|
6652
6873
|
? "deep-submit-locator"
|
|
6653
6874
|
: phantomClickAfterAttempt1
|
|
6654
6875
|
? "trusted-click-retry"
|
|
@@ -6667,7 +6888,7 @@ async function executeStepWithHealing(params) {
|
|
|
6667
6888
|
})),
|
|
6668
6889
|
advanceUnmovedAfterAttempt1,
|
|
6669
6890
|
phantomClickAfterAttempt1,
|
|
6670
|
-
submitShapedStep:
|
|
6891
|
+
submitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
|
|
6671
6892
|
});
|
|
6672
6893
|
if (decision.skip) {
|
|
6673
6894
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
|
|
@@ -6795,7 +7016,9 @@ async function executeStepWithHealing(params) {
|
|
|
6795
7016
|
}
|
|
6796
7017
|
}
|
|
6797
7018
|
}
|
|
6798
|
-
else if (attempt === 2 &&
|
|
7019
|
+
else if (attempt === 2 &&
|
|
7020
|
+
phantomClickAfterAttempt1 &&
|
|
7021
|
+
(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
|
|
6799
7022
|
// Deep submit-control locator: attempt 1 phantom-clicked (Stagehand
|
|
6800
7023
|
// reported success but pre/post showed zero effect), so the target is
|
|
6801
7024
|
// almost certainly unreachable via document.querySelectorAll — most
|
|
@@ -6917,7 +7140,9 @@ async function executeStepWithHealing(params) {
|
|
|
6917
7140
|
}
|
|
6918
7141
|
}
|
|
6919
7142
|
}
|
|
6920
|
-
else if (attempt === 2 &&
|
|
7143
|
+
else if (attempt === 2 &&
|
|
7144
|
+
phantomClickAfterAttempt1 &&
|
|
7145
|
+
!(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
|
|
6921
7146
|
// Trusted-click retry: attempt 1 phantom-clicked a NON-submit control —
|
|
6922
7147
|
// Stagehand reported success but pre/post showed zero effect. On a
|
|
6923
7148
|
// design-system widget (React synthetic-event delegation, custom
|
|
@@ -7595,7 +7820,7 @@ async function executeStepWithHealing(params) {
|
|
|
7595
7820
|
// `advanceTransitionBodyPattern` are unaffected.
|
|
7596
7821
|
const domVerifiedForStep = isDomOnlyAdvanceVerified({
|
|
7597
7822
|
hasPattern: advanceTransitionBodyPattern !== null,
|
|
7598
|
-
isFinalOrSubmit:
|
|
7823
|
+
isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
|
|
7599
7824
|
isAdvance: isAdvanceStep(step),
|
|
7600
7825
|
domVerified,
|
|
7601
7826
|
networkIsRealAdvance,
|
|
@@ -7628,6 +7853,7 @@ async function executeStepWithHealing(params) {
|
|
|
7628
7853
|
resolvedAction,
|
|
7629
7854
|
isFinalStep,
|
|
7630
7855
|
submitStep,
|
|
7856
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
7631
7857
|
isAdvanceWithPattern: isAdvanceStep(step) && advanceTransitionBodyPattern !== null,
|
|
7632
7858
|
networkDelta: post.networkCount - pre.networkCount,
|
|
7633
7859
|
bytesDelta: post.bodyHtmlLength - pre.bodyHtmlLength,
|
|
@@ -7714,11 +7940,44 @@ async function executeStepWithHealing(params) {
|
|
|
7714
7940
|
}
|
|
7715
7941
|
}
|
|
7716
7942
|
}
|
|
7943
|
+
// Committed-value guard for the prompt-selector widget family (popup
|
|
7944
|
+
// multiselect/typeahead trigger widgets). The primitive's own picking
|
|
7945
|
+
// gesture ({@link tryPromptSelectorPrimitive}) only runs on the observe-act
|
|
7946
|
+
// fallback path; a resolved-as-click act against the SAME widget shape can
|
|
7947
|
+
// open its popup, report success, and — since opening a popup mutates the
|
|
7948
|
+
// DOM (bytes changed) — ride `clickViewSwapVerified` to a phantom success
|
|
7949
|
+
// without ever committing an option. Gate it the same way as the datepicker
|
|
7950
|
+
// guard above: readback the widget's own committed-value node
|
|
7951
|
+
// ({@link verifyPromptSelectorCommitted}) and, when the resolved element IS
|
|
7952
|
+
// this widget shape but nothing committed, suppress the weak signals.
|
|
7953
|
+
// `isPromptWidget: false` for anything outside this widget family, so a
|
|
7954
|
+
// plain click/fill is untouched.
|
|
7955
|
+
let promptSelectorRejected = false;
|
|
7956
|
+
// Exempt only on a genuine network-advance or URL-change signal, not the
|
|
7957
|
+
// broader `hasStrongSignal` (which the sibling datepicker gate above
|
|
7958
|
+
// legitimately relies on) — `domVerifiedForStep` is true here simply
|
|
7959
|
+
// because opening the widget's popup mutates the DOM, so reusing it would
|
|
7960
|
+
// let that popup-open alone skip the readback below.
|
|
7961
|
+
const hasNetworkOrUrlSignal = networkIsRealAdvance || urlChanged;
|
|
7962
|
+
if (record.actResultSuccess === true && !hasNetworkOrUrlSignal) {
|
|
7963
|
+
const readbackSelector = resolvedAction?.selector ?? triedSelectors[triedSelectors.length - 1] ?? null;
|
|
7964
|
+
if (readbackSelector) {
|
|
7965
|
+
const readbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
|
|
7966
|
+
const promptReadback = await verifyPromptSelectorCommitted(readbackTarget, readbackSelector);
|
|
7967
|
+
if (promptReadback?.isPromptWidget === true && promptReadback.committed === false) {
|
|
7968
|
+
promptSelectorRejected = true;
|
|
7969
|
+
record.actResultSuccess = false;
|
|
7970
|
+
record.errorMessage = `prompt-selector-fill-rejected: resolved element <${readbackSelector}> is a prompt-selector-shaped widget but no committed value landed`;
|
|
7971
|
+
}
|
|
7972
|
+
}
|
|
7973
|
+
}
|
|
7717
7974
|
let verified = networkIsRealAdvance ||
|
|
7718
7975
|
urlChanged ||
|
|
7719
7976
|
domVerifiedForStep ||
|
|
7720
7977
|
datepickerCommitted ||
|
|
7721
|
-
(!datepickerRejected &&
|
|
7978
|
+
(!datepickerRejected &&
|
|
7979
|
+
!promptSelectorRejected &&
|
|
7980
|
+
(clickViewSwapVerified || formValueVerified));
|
|
7722
7981
|
// Final-step submit-verification gate. Replaces the deterministic
|
|
7723
7982
|
// submitEndpointPattern regex with a Haiku 4.5 LLM judgment over
|
|
7724
7983
|
// multi-signal evidence (network captures, page URL/title, DOM
|
|
@@ -8004,7 +8263,7 @@ async function executeStepWithHealing(params) {
|
|
|
8004
8263
|
}));
|
|
8005
8264
|
const fallbackDomOnlyAdvance = shouldVetoFallbackAdvance({
|
|
8006
8265
|
hasPattern: advanceTransitionBodyPattern !== null,
|
|
8007
|
-
isFinalOrSubmit:
|
|
8266
|
+
isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
|
|
8008
8267
|
isAdvance: isAdvanceStep(step),
|
|
8009
8268
|
retryUrlChanged,
|
|
8010
8269
|
retryNetworkIsRealAdvance,
|
|
@@ -8159,7 +8418,7 @@ async function executeStepWithHealing(params) {
|
|
|
8159
8418
|
// checked) into `domVerified`. A registered selection toggle no longer
|
|
8160
8419
|
// reads as a phantom just because it moved no network/URL/bytes.
|
|
8161
8420
|
elementStateChanged: domVerified,
|
|
8162
|
-
isSubmitShapedStep:
|
|
8421
|
+
isSubmitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
|
|
8163
8422
|
});
|
|
8164
8423
|
const reason = record.errorMessage
|
|
8165
8424
|
? effectSignals
|
|
@@ -8178,7 +8437,8 @@ async function executeStepWithHealing(params) {
|
|
|
8178
8437
|
// Empirically grounded: 22 of 22 JSON-envelope ATS Continue/Submit step-failure
|
|
8179
8438
|
// dumps in a 2026-06-10 survey had the paired touched+dirty + visible
|
|
8180
8439
|
// error text pattern with 3 distinct rejection messages.
|
|
8181
|
-
if (record.resolvedMethod === "click" &&
|
|
8440
|
+
if (record.resolvedMethod === "click" &&
|
|
8441
|
+
(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
|
|
8182
8442
|
const live = await extractLivePageFormEvidence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), {
|
|
8183
8443
|
client: anthropic,
|
|
8184
8444
|
knownErrorClassPrefixes,
|
|
@@ -8223,7 +8483,7 @@ async function executeStepWithHealing(params) {
|
|
|
8223
8483
|
phantomClickAfterAttempt1 = record.phantomClickVerdict === "phantom";
|
|
8224
8484
|
if (phantomClickAfterAttempt1) {
|
|
8225
8485
|
const suppressedCount = getSuppressedAisdkElementIdErrorCount?.();
|
|
8226
|
-
const escalationTarget =
|
|
8486
|
+
const escalationTarget = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag)
|
|
8227
8487
|
? "escalating attempt 2 to deep-submit-locator"
|
|
8228
8488
|
: "non-submit step — escalating attempt 2 to trusted-click-retry (trusted CDP click on the resolved target)";
|
|
8229
8489
|
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} phantom click detected on attempt 1 (${record.technique}): reported success with no network/url/dom change${suppressedCount !== undefined ? `; ${suppressedCount} AISDK elementId errors suppressed this session (corroborating, not causal)` : ""} — ${escalationTarget}`);
|
|
@@ -8233,7 +8493,7 @@ async function executeStepWithHealing(params) {
|
|
|
8233
8493
|
// Treat the canonical submit click as "final" for this predicate
|
|
8234
8494
|
// even when it lives mid-flow. See requireSubmitEndpoint derivation
|
|
8235
8495
|
// above for the same gate-widening rationale.
|
|
8236
|
-
isFinalStep:
|
|
8496
|
+
isFinalStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
|
|
8237
8497
|
requireSubmitEndpoint,
|
|
8238
8498
|
resolvedMethod: record.resolvedMethod,
|
|
8239
8499
|
effectSignals,
|
|
@@ -8253,7 +8513,7 @@ async function executeStepWithHealing(params) {
|
|
|
8253
8513
|
// can reorder a later step forward — instead of burning the cascade.
|
|
8254
8514
|
const advanceStalled = isAdvanceStalled({
|
|
8255
8515
|
isAdvance: isAdvanceStep(step),
|
|
8256
|
-
isFinalOrSubmit:
|
|
8516
|
+
isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
|
|
8257
8517
|
hasPattern: advanceTransitionBodyPattern !== null,
|
|
8258
8518
|
clickFired: record.resolvedMethod === "click" && record.actResultSuccess === true,
|
|
8259
8519
|
networkFired,
|
|
@@ -8399,6 +8659,11 @@ async function runHealingFlow(deps) {
|
|
|
8399
8659
|
}
|
|
8400
8660
|
},
|
|
8401
8661
|
});
|
|
8662
|
+
const flowHasSubmitSemanticsFlag = flowHasSubmitSemantics({
|
|
8663
|
+
steps,
|
|
8664
|
+
submitEndpointPattern: deps.submitEndpointPattern ?? null,
|
|
8665
|
+
requireSubmitEndpointMatch: deps.requireSubmitEndpointMatch ?? false,
|
|
8666
|
+
});
|
|
8402
8667
|
if (shouldWarnMissingAdvancePattern(steps.map((s) => s.instruction), deps.advanceTransitionBodyPattern ?? null)) {
|
|
8403
8668
|
logger.warn("flow has advance steps but no advanceTransitionBodyPattern — DOM-only advance guard is disarmed; pointer/page desync is possible on validation-re-render wizards");
|
|
8404
8669
|
}
|
|
@@ -8407,6 +8672,24 @@ async function runHealingFlow(deps) {
|
|
|
8407
8672
|
if (maxFlowMs !== undefined && Date.now() - start > maxFlowMs) {
|
|
8408
8673
|
throw new errors_2.StepVerificationError(`${formatStepPrefix(i, () => steps.length)} flow exceeded its maxFlowMs budget (${maxFlowMs}ms)`, "flow-timeout");
|
|
8409
8674
|
}
|
|
8675
|
+
// Liveness gate: a closed/crashed Stagehand session (e.g. the
|
|
8676
|
+
// shutdown-supervisor force-releasing mid-flow) makes `page.url()`
|
|
8677
|
+
// throw synchronously — the one call every Page implementation
|
|
8678
|
+
// answers without touching the DOM. Everything downstream (observe/
|
|
8679
|
+
// act probes) treats a thrown error as "page has no candidates right
|
|
8680
|
+
// now" and, for an optional step, quietly skips it — so without this
|
|
8681
|
+
// check the loop runs to completion and `runHealingFlow` resolves as
|
|
8682
|
+
// if the flow finished, even though the session died partway through.
|
|
8683
|
+
// Checking here, before any step-specific handling, means a dead
|
|
8684
|
+
// session is reported as a `SessionTimeoutError` distinct from a
|
|
8685
|
+
// legitimately-absent optional step, and the flow's own step count
|
|
8686
|
+
// never quietly reaches `steps.length` past the point of death.
|
|
8687
|
+
try {
|
|
8688
|
+
page.url();
|
|
8689
|
+
}
|
|
8690
|
+
catch (err) {
|
|
8691
|
+
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`);
|
|
8692
|
+
}
|
|
8410
8693
|
lastStepIndex = i;
|
|
8411
8694
|
// Resolved fresh per step (not cached across the run) so a cross-origin
|
|
8412
8695
|
// iframe that attaches mid-flow (e.g. after an "Apply" click reveals a
|
|
@@ -8423,6 +8706,7 @@ async function runHealingFlow(deps) {
|
|
|
8423
8706
|
optional: s.optional,
|
|
8424
8707
|
upload: s.upload,
|
|
8425
8708
|
submitStep: s.submitStep,
|
|
8709
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
8426
8710
|
stepIndex: i,
|
|
8427
8711
|
totalSteps: () => steps.length,
|
|
8428
8712
|
phase: "flow",
|