@enricai/barnacle 1.12.2 → 1.12.3
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/scraper/flow-runner.d.ts +53 -19
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +238 -43
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scripts/recon-browser.d.ts.map +1 -1
- package/dist/scripts/recon-browser.js +5 -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 +1 -1
|
@@ -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;
|
|
@@ -396,6 +398,18 @@ const PROMPT_SELECTOR_SETTLE_MS = 400;
|
|
|
396
398
|
const PROMPT_WIDGET_MARK_ATTR = "data-bcl-prompt-idx";
|
|
397
399
|
/** Same role as {@link PROMPT_WIDGET_MARK_ATTR}, but for the popup's rendered option entries. */
|
|
398
400
|
const PROMPT_OPTION_MARK_ATTR = "data-bcl-prompt-opt-idx";
|
|
401
|
+
/**
|
|
402
|
+
* Cross-vendor selector for a genuine focusable form control — the only kind
|
|
403
|
+
* of element a native click event reliably opens. Phase 1's container walk
|
|
404
|
+
* marks the OUTERMOST widget-kit container so one widget resolves to one
|
|
405
|
+
* candidate, but that container is often a layout wrapper with no click
|
|
406
|
+
* handler of its own (e.g. a typeahead/chip widget's real open+filter control
|
|
407
|
+
* is an `<input>` nested a level deeper, inside its own `*InputContainer`
|
|
408
|
+
* wrapper). Phase 2 prefers clicking this interactive descendant over the
|
|
409
|
+
* marked container itself, falling back to the container only when it has
|
|
410
|
+
* none — no vendor branch, just "click the real control if one exists".
|
|
411
|
+
*/
|
|
412
|
+
const PROMPT_INTERACTIVE_CONTROL_SELECTORS = "button,[role='combobox'],[role='button'],input";
|
|
399
413
|
/**
|
|
400
414
|
* Cross-vendor selector union that identifies the TRIGGER of a native-control-less
|
|
401
415
|
* popup-dropdown widget (a combobox that opens a listbox popup and renders no
|
|
@@ -486,6 +500,30 @@ const PROMPT_EMPTY_VALUE_RX_FLAGS = PROMPT_EMPTY_VALUE_RX.flags;
|
|
|
486
500
|
* the remaining `textContent`.
|
|
487
501
|
*/
|
|
488
502
|
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 || \"\"; })";
|
|
503
|
+
/**
|
|
504
|
+
* Browser-side expression reading a prompt-selector widget's OWN committed-value
|
|
505
|
+
* text — the single source of truth for "is this widget filled?", shared by the
|
|
506
|
+
* widget-enumeration phase ({@link tryPromptSelectorPrimitive}'s
|
|
507
|
+
* `enumerateWidgetsExpr`) and the act-success committed-value gate
|
|
508
|
+
* ({@link verifyPromptSelectorCommitted}) so the two never drift into separate
|
|
509
|
+
* ideas of "committed". Value via the union: `aria-activedescendant` → the
|
|
510
|
+
* referenced option's text, else the widget's own value (an `<input>`'s
|
|
511
|
+
* `value`, or a `<button>`-trigger's own label text via {@link BUTTON_VALUE_EXPR}),
|
|
512
|
+
* else a selection-label node ({@link PROMPT_VALUE_SELECTORS}) — treating the
|
|
513
|
+
* widget-kit empty-state phrase ({@link PROMPT_EMPTY_VALUE_RX}) as no value.
|
|
514
|
+
*/
|
|
515
|
+
const PROMPT_CURRENT_TEXT_EXPR = `((w, valueSel, emptyRx) => {
|
|
516
|
+
const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
|
|
517
|
+
const buttonValue = ${BUTTON_VALUE_EXPR};
|
|
518
|
+
const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
|
|
519
|
+
if (adid) { const opt = document.getElementById(adid); if (opt && opt.textContent.trim()) return norm(opt.textContent); }
|
|
520
|
+
if (w.tagName === "INPUT" && (w.value || "").trim()) return norm(w.value);
|
|
521
|
+
if (w.tagName === "BUTTON" && buttonValue(w).trim()) return norm(buttonValue(w));
|
|
522
|
+
const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
|
|
523
|
+
const raw = lbl ? (lbl.textContent || "") : "";
|
|
524
|
+
if (raw.trim() && !emptyRx.test(raw.trim())) return norm(raw);
|
|
525
|
+
return "";
|
|
526
|
+
})`;
|
|
489
527
|
/**
|
|
490
528
|
* Browser-side expression resolving the DOM root within which a chosen widget's
|
|
491
529
|
* popup options / filter input live. Popup placement is vendor-split: the ARIA
|
|
@@ -923,8 +961,20 @@ function isAdvanceStep(instruction) {
|
|
|
923
961
|
* cascade depends on is unit-testable, not buried in `executeStepWithHealing`.
|
|
924
962
|
*/
|
|
925
963
|
function shouldCaptureSelectionState(params) {
|
|
926
|
-
const { step, isFinalStep, submitStep } = params;
|
|
927
|
-
return !(
|
|
964
|
+
const { step, isFinalStep, submitStep, flowHasSubmitSemantics } = params;
|
|
965
|
+
return !(submitStep || (isFinalStep && flowHasSubmitSemantics) || isAdvanceStep(step));
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Whether a flow has ANY submit semantics at all — a step flagged
|
|
969
|
+
* `submitStep: true`, a `submitEndpointPattern`, or `requireSubmitEndpointMatch`.
|
|
970
|
+
* A read-only flow (none of the three) has no submit shape anywhere, so its
|
|
971
|
+
* final step is an ordinary read/click, not a submit. Pure + exported so
|
|
972
|
+
* callers can stop inferring submit-shape from `isFinalStep` alone on flows
|
|
973
|
+
* that never declared a submit.
|
|
974
|
+
*/
|
|
975
|
+
function flowHasSubmitSemantics(params) {
|
|
976
|
+
const { steps, submitEndpointPattern, requireSubmitEndpointMatch } = params;
|
|
977
|
+
return (steps.some((s) => s.submitStep) || submitEndpointPattern !== null || requireSubmitEndpointMatch);
|
|
928
978
|
}
|
|
929
979
|
/**
|
|
930
980
|
* Whether a flow should be WARNed that its DOM-only advance guard is disarmed.
|
|
@@ -1200,10 +1250,10 @@ function isDomOnlyAdvanceVerified(params) {
|
|
|
1200
1250
|
function isClickViewSwapVerified(params) {
|
|
1201
1251
|
const VIEW_SWAP_MIN_BYTES = config_1.config.scraper.viewSwapMinBytesThreshold;
|
|
1202
1252
|
const VIEW_SWAP_REVEAL_MIN_BYTES = config_1.config.scraper.viewSwapRevealMinBytesThreshold;
|
|
1203
|
-
const { resolvedAction, isFinalStep, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, } = params;
|
|
1253
|
+
const { resolvedAction, isFinalStep, submitStep, flowHasSubmitSemantics, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, } = params;
|
|
1204
1254
|
if (resolvedAction?.method !== "click")
|
|
1205
1255
|
return false;
|
|
1206
|
-
if (
|
|
1256
|
+
if (submitStep || (isFinalStep && flowHasSubmitSemantics))
|
|
1207
1257
|
return false;
|
|
1208
1258
|
if (isAdvanceWithPattern)
|
|
1209
1259
|
return false;
|
|
@@ -2715,6 +2765,50 @@ async function verifyFillReadback(target, selector, expectedValue) {
|
|
|
2715
2765
|
return null;
|
|
2716
2766
|
}
|
|
2717
2767
|
}
|
|
2768
|
+
/**
|
|
2769
|
+
* Committed-value guard for the prompt-selector widget family
|
|
2770
|
+
* (`data-uxi-widget-type='multiselect'`/`selectinput`, `role=combobox`, …),
|
|
2771
|
+
* mirroring {@link verifyFillReadback}'s role for plain inputs but reading the
|
|
2772
|
+
* widget's OWN committed-value node ({@link PROMPT_CURRENT_TEXT_EXPR}) instead
|
|
2773
|
+
* of a bare `<input>.value` — a chip multiselect's committed state lives on
|
|
2774
|
+
* `aria-activedescendant`/a selection-label node, not the trigger's own value.
|
|
2775
|
+
* Walks UP from the resolved element to the nearest {@link PROMPT_TRIGGER_SELECTORS}
|
|
2776
|
+
* ancestor (the resolved element is often the inner filter input or icon, not
|
|
2777
|
+
* the widget container itself — same resolution discipline as
|
|
2778
|
+
* `tryPromptSelectorPrimitive`'s widget-container walk). Returns
|
|
2779
|
+
* `isPromptWidget: false` for a resolved element that isn't part of this
|
|
2780
|
+
* widget family at all, so a non-prompt click/fill is untouched.
|
|
2781
|
+
*/
|
|
2782
|
+
async function verifyPromptSelectorCommitted(target, selector) {
|
|
2783
|
+
const xpath = xpathBodyForEvaluate(selector);
|
|
2784
|
+
if (xpath === null)
|
|
2785
|
+
return null;
|
|
2786
|
+
const expr = `(() => {
|
|
2787
|
+
const triggerSel = ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)};
|
|
2788
|
+
const valueSel = ${JSON.stringify(PROMPT_VALUE_SELECTORS)};
|
|
2789
|
+
const emptyRx = new RegExp(${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)});
|
|
2790
|
+
const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
|
|
2791
|
+
const xpath = ${JSON.stringify(xpath)};
|
|
2792
|
+
const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
2793
|
+
const el = result.singleNodeValue;
|
|
2794
|
+
if (!el) return { isPromptWidget: false, committed: false };
|
|
2795
|
+
const widget = el.matches && el.matches(triggerSel) ? el : el.closest(triggerSel);
|
|
2796
|
+
if (!widget) return { isPromptWidget: false, committed: false };
|
|
2797
|
+
return { isPromptWidget: true, committed: currentText(widget, valueSel, emptyRx) !== "" };
|
|
2798
|
+
})()`;
|
|
2799
|
+
try {
|
|
2800
|
+
const raw = await target.evaluate(expr);
|
|
2801
|
+
if (raw === null || typeof raw !== "object")
|
|
2802
|
+
return null;
|
|
2803
|
+
const r = raw;
|
|
2804
|
+
if (typeof r.isPromptWidget !== "boolean" || typeof r.committed !== "boolean")
|
|
2805
|
+
return null;
|
|
2806
|
+
return { isPromptWidget: r.isPromptWidget, committed: r.committed };
|
|
2807
|
+
}
|
|
2808
|
+
catch {
|
|
2809
|
+
return null;
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2718
2812
|
/**
|
|
2719
2813
|
* Pull field-level errors out of an arbitrary JSON response body. Walks a
|
|
2720
2814
|
* few of the conventional ATS shapes; falls through to `[]` so the caller
|
|
@@ -3521,11 +3615,54 @@ async function setFilesViaCdp(params) {
|
|
|
3521
3615
|
* Recognizes the flow's conventional phrasings, all quoted:
|
|
3522
3616
|
* "select 'Yes'", "select or check 'BLS'",
|
|
3523
3617
|
* "for 'What is your highest level…?' select 'BSN completed'",
|
|
3524
|
-
* "select 'Texas' in the State/Region dropdown"
|
|
3618
|
+
* "select 'Texas' in the State/Region dropdown",
|
|
3619
|
+
* "…then select the option 'Job Boards' from the popup list" (a compound
|
|
3620
|
+
* step that opens the widget in one clause and selects in another —
|
|
3621
|
+
* "select" is followed by a noun phrase like "the option"/"the value"
|
|
3622
|
+
* before the quoted option itself, rather than the quote directly).
|
|
3525
3623
|
* Returns null when the step is not a single-dropdown select (e.g. generic
|
|
3526
3624
|
* "for any remaining question…" catch-alls, or radio/checkbox-only steps) so
|
|
3527
3625
|
* the caller falls through to the normal cascade.
|
|
3528
3626
|
*/
|
|
3627
|
+
// Nouns that name a form widget/question, used by `pickQuestionLabel` to tell
|
|
3628
|
+
// a genuine field label ("'How Did You Hear About Us?' prompt selector")
|
|
3629
|
+
// apart from an unrelated leading quoted phrase that just happens to precede
|
|
3630
|
+
// it in the instruction (e.g. a page/step-context quote like "'My
|
|
3631
|
+
// Information' step").
|
|
3632
|
+
const WIDGET_NOUN_RE = /\b(prompt\s+selector|multiselect|typeahead|dropdown|field|question|checkbox|radio\s+button|radio)\b/i;
|
|
3633
|
+
/**
|
|
3634
|
+
* Pick the QUESTION LABEL out of an instruction's quoted phrases, given the
|
|
3635
|
+
* already-extracted OPTION.
|
|
3636
|
+
*
|
|
3637
|
+
* Why this exists: an instruction can carry more than one quoted phrase that
|
|
3638
|
+
* is not the option — e.g. a page/step-context phrase ("On the authenticated
|
|
3639
|
+
* 'My Information' step, open the 'How Did You Hear About Us?' prompt
|
|
3640
|
+
* selector…") — so naively taking the first non-option quote picks the
|
|
3641
|
+
* context phrase instead of the actual widget label. This prefers a quote
|
|
3642
|
+
* that sits immediately next to a widget noun (`multiselect`/`typeahead`/
|
|
3643
|
+
* `dropdown`/`field`/`question`/`checkbox`/`radio button`/`prompt selector`)
|
|
3644
|
+
* or is introduced
|
|
3645
|
+
* by "for"/"for the"/"for the question"/"for the answer" (e.g. "click the
|
|
3646
|
+
* 'Yes' answer for the question '…'"), before falling back to the first
|
|
3647
|
+
* non-option quote so existing un-adorned phrasings keep working.
|
|
3648
|
+
*/
|
|
3649
|
+
function pickQuestionLabel(instruction, option) {
|
|
3650
|
+
// biome-ignore lint/style/noNonNullAssertion: capture group 1 is required by the pattern, so it is present on every match
|
|
3651
|
+
const candidates = [...instruction.matchAll(/'([^']+)'/g)].filter((m) => m[1].trim() !== option);
|
|
3652
|
+
if (candidates.length === 0)
|
|
3653
|
+
return null;
|
|
3654
|
+
const adjacentToWidgetNoun = candidates.find((m) => {
|
|
3655
|
+
const start = m.index ?? 0;
|
|
3656
|
+
const end = start + m[0].length;
|
|
3657
|
+
const before = instruction.slice(Math.max(0, start - 25), start);
|
|
3658
|
+
const after = instruction.slice(end, end + 40);
|
|
3659
|
+
return (/\bfor(?:\s+the)?(?:\s+(?:question|answer))?\s*$/i.test(before) || WIDGET_NOUN_RE.test(after));
|
|
3660
|
+
});
|
|
3661
|
+
// biome-ignore lint/style/noNonNullAssertion: candidates is guarded non-empty above, so the fallback element is always present
|
|
3662
|
+
const picked = (adjacentToWidgetNoun ?? candidates[0]);
|
|
3663
|
+
// biome-ignore lint/style/noNonNullAssertion: capture group 1 is required by the pattern, so it is present on every match
|
|
3664
|
+
return picked[1].trim();
|
|
3665
|
+
}
|
|
3529
3666
|
function parseSelectStep(instruction) {
|
|
3530
3667
|
const lower = instruction.toLowerCase();
|
|
3531
3668
|
// Must look like a dropdown selection, not a radio/checkbox click. "select
|
|
@@ -3541,16 +3678,20 @@ function parseSelectStep(instruction) {
|
|
|
3541
3678
|
const quoted = [...instruction.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
|
3542
3679
|
if (quoted.length === 0)
|
|
3543
3680
|
return null;
|
|
3544
|
-
// The OPTION is the quoted string
|
|
3545
|
-
|
|
3681
|
+
// The OPTION is the quoted string following the word "select", allowing a
|
|
3682
|
+
// short filler ("the option", "the answer") between the verb and the
|
|
3683
|
+
// quote — real flow phrasing like "select the option 'Job Boards'" or
|
|
3684
|
+
// "select the answer 'X'" otherwise fails to parse and the caller
|
|
3685
|
+
// silently no-ops before touching the DOM.
|
|
3686
|
+
const selMatch = instruction.match(/\bselect(?:\s+or\s+check)?\s+(?:the\s+\S+\s+)?'([^']+)'/i);
|
|
3546
3687
|
if (!selMatch)
|
|
3547
3688
|
return null;
|
|
3548
3689
|
// biome-ignore lint/style/noNonNullAssertion: guarded by the !selMatch early-return; group 1 is required by the pattern
|
|
3549
3690
|
const option = selMatch[1].trim();
|
|
3550
3691
|
// The QUESTION LABEL, when present, is a DIFFERENT quoted string — the one
|
|
3551
|
-
// introduced by "for '…'" or
|
|
3552
|
-
//
|
|
3553
|
-
const questionLabel =
|
|
3692
|
+
// introduced by "for '…'" or adjacent to a widget noun like "'…' dropdown"
|
|
3693
|
+
// or "'…' prompt selector". See `pickQuestionLabel`.
|
|
3694
|
+
const questionLabel = pickQuestionLabel(instruction, option);
|
|
3554
3695
|
return { option, questionLabel };
|
|
3555
3696
|
}
|
|
3556
3697
|
/**
|
|
@@ -3668,8 +3809,9 @@ function parseRadioStep(instruction) {
|
|
|
3668
3809
|
// introduced by "for the question '…'" / "for the '…' question". Some steps
|
|
3669
3810
|
// phrase the question un-quoted ("…about requiring visa sponsorship"); in
|
|
3670
3811
|
// that case there is no second quoted string and questionLabel stays null,
|
|
3671
|
-
// which the primitive handles via LLM group-matching.
|
|
3672
|
-
|
|
3812
|
+
// which the primitive handles via LLM group-matching. See
|
|
3813
|
+
// `pickQuestionLabel`.
|
|
3814
|
+
const questionLabel = pickQuestionLabel(instruction, option);
|
|
3673
3815
|
return { option, questionLabel };
|
|
3674
3816
|
}
|
|
3675
3817
|
/**
|
|
@@ -4969,28 +5111,15 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
4969
5111
|
}
|
|
4970
5112
|
return "";
|
|
4971
5113
|
};
|
|
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
|
-
};
|
|
5114
|
+
const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
|
|
5115
|
+
const currentTextOf = (w) => currentText(w, valueSel, emptyRx);
|
|
4987
5116
|
const isUnfilled = (w) => {
|
|
4988
5117
|
let node = w;
|
|
4989
5118
|
for (let d = 0; d < 6 && node; d++) {
|
|
4990
5119
|
if (node.getAttribute && isInvalid(node)) return true;
|
|
4991
5120
|
node = node.parentElement;
|
|
4992
5121
|
}
|
|
4993
|
-
return
|
|
5122
|
+
return currentTextOf(w) === "";
|
|
4994
5123
|
};
|
|
4995
5124
|
// Clear stale marks from a prior call on this same page (this primitive
|
|
4996
5125
|
// runs once per "select 'X'" step, and an application wizard answers several
|
|
@@ -5040,7 +5169,23 @@ async function tryPromptSelectorPrimitive(params) {
|
|
|
5040
5169
|
}
|
|
5041
5170
|
// Phase 2 (real gesture): open the popup. These widgets' trigger requires a
|
|
5042
5171
|
// genuine click — a synthetic dispatchEvent does not fire its handler.
|
|
5043
|
-
|
|
5172
|
+
// Prefer the marked widget's own interactive descendant (a real form
|
|
5173
|
+
// control) over the marked container itself, since the container is
|
|
5174
|
+
// frequently a non-interactive layout wrapper for widget shapes whose
|
|
5175
|
+
// actual trigger sits a level deeper (see PROMPT_INTERACTIVE_CONTROL_SELECTORS).
|
|
5176
|
+
const containerTriggerSel = `[${PROMPT_WIDGET_MARK_ATTR}="${chosen.wIdx}"]`;
|
|
5177
|
+
const innerTriggerSel = PROMPT_INTERACTIVE_CONTROL_SELECTORS.split(",")
|
|
5178
|
+
.map((s) => `${containerTriggerSel} ${s}`)
|
|
5179
|
+
.join(",");
|
|
5180
|
+
let triggerSel = containerTriggerSel;
|
|
5181
|
+
try {
|
|
5182
|
+
const innerCount = await target.locator(innerTriggerSel).count();
|
|
5183
|
+
if (innerCount > 0)
|
|
5184
|
+
triggerSel = innerTriggerSel;
|
|
5185
|
+
}
|
|
5186
|
+
catch {
|
|
5187
|
+
// Fall back to the container selector.
|
|
5188
|
+
}
|
|
5044
5189
|
try {
|
|
5045
5190
|
await target.locator(triggerSel).first().click();
|
|
5046
5191
|
}
|
|
@@ -6146,7 +6291,7 @@ async function probeStepBeforeAttempts(params) {
|
|
|
6146
6291
|
}
|
|
6147
6292
|
}
|
|
6148
6293
|
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;
|
|
6294
|
+
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
6295
|
// Mutable (not the destructured const above) so a lost frame-attach race
|
|
6151
6296
|
// can be upgraded in place once the OOPIF attaches later in the cascade —
|
|
6152
6297
|
// see reresolveFrameTargetIfLost below. Every existing reference in this
|
|
@@ -6214,7 +6359,12 @@ async function executeStepWithHealing(params) {
|
|
|
6214
6359
|
// false-credit the step, and whose advance/submit verdicts require a real
|
|
6215
6360
|
// network/URL transition. Also keeps the extra full-DOM evaluate off the
|
|
6216
6361
|
// submit/advance path. Step-level intent (available before the attempt loop).
|
|
6217
|
-
const captureSelectionState = shouldCaptureSelectionState({
|
|
6362
|
+
const captureSelectionState = shouldCaptureSelectionState({
|
|
6363
|
+
step,
|
|
6364
|
+
isFinalStep,
|
|
6365
|
+
submitStep,
|
|
6366
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
6367
|
+
});
|
|
6218
6368
|
const attempts = [];
|
|
6219
6369
|
const triedSelectors = [];
|
|
6220
6370
|
const failureReasons = [];
|
|
@@ -6648,7 +6798,7 @@ async function executeStepWithHealing(params) {
|
|
|
6648
6798
|
// techniques faster.
|
|
6649
6799
|
if (attempt > 1) {
|
|
6650
6800
|
const wouldBeTechnique = attempt === 2
|
|
6651
|
-
? phantomClickAfterAttempt1 && (
|
|
6801
|
+
? phantomClickAfterAttempt1 && (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))
|
|
6652
6802
|
? "deep-submit-locator"
|
|
6653
6803
|
: phantomClickAfterAttempt1
|
|
6654
6804
|
? "trusted-click-retry"
|
|
@@ -6667,7 +6817,7 @@ async function executeStepWithHealing(params) {
|
|
|
6667
6817
|
})),
|
|
6668
6818
|
advanceUnmovedAfterAttempt1,
|
|
6669
6819
|
phantomClickAfterAttempt1,
|
|
6670
|
-
submitShapedStep:
|
|
6820
|
+
submitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
|
|
6671
6821
|
});
|
|
6672
6822
|
if (decision.skip) {
|
|
6673
6823
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
|
|
@@ -6795,7 +6945,9 @@ async function executeStepWithHealing(params) {
|
|
|
6795
6945
|
}
|
|
6796
6946
|
}
|
|
6797
6947
|
}
|
|
6798
|
-
else if (attempt === 2 &&
|
|
6948
|
+
else if (attempt === 2 &&
|
|
6949
|
+
phantomClickAfterAttempt1 &&
|
|
6950
|
+
(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
|
|
6799
6951
|
// Deep submit-control locator: attempt 1 phantom-clicked (Stagehand
|
|
6800
6952
|
// reported success but pre/post showed zero effect), so the target is
|
|
6801
6953
|
// almost certainly unreachable via document.querySelectorAll — most
|
|
@@ -6917,7 +7069,9 @@ async function executeStepWithHealing(params) {
|
|
|
6917
7069
|
}
|
|
6918
7070
|
}
|
|
6919
7071
|
}
|
|
6920
|
-
else if (attempt === 2 &&
|
|
7072
|
+
else if (attempt === 2 &&
|
|
7073
|
+
phantomClickAfterAttempt1 &&
|
|
7074
|
+
!(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
|
|
6921
7075
|
// Trusted-click retry: attempt 1 phantom-clicked a NON-submit control —
|
|
6922
7076
|
// Stagehand reported success but pre/post showed zero effect. On a
|
|
6923
7077
|
// design-system widget (React synthetic-event delegation, custom
|
|
@@ -7595,7 +7749,7 @@ async function executeStepWithHealing(params) {
|
|
|
7595
7749
|
// `advanceTransitionBodyPattern` are unaffected.
|
|
7596
7750
|
const domVerifiedForStep = isDomOnlyAdvanceVerified({
|
|
7597
7751
|
hasPattern: advanceTransitionBodyPattern !== null,
|
|
7598
|
-
isFinalOrSubmit:
|
|
7752
|
+
isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
|
|
7599
7753
|
isAdvance: isAdvanceStep(step),
|
|
7600
7754
|
domVerified,
|
|
7601
7755
|
networkIsRealAdvance,
|
|
@@ -7628,6 +7782,7 @@ async function executeStepWithHealing(params) {
|
|
|
7628
7782
|
resolvedAction,
|
|
7629
7783
|
isFinalStep,
|
|
7630
7784
|
submitStep,
|
|
7785
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
7631
7786
|
isAdvanceWithPattern: isAdvanceStep(step) && advanceTransitionBodyPattern !== null,
|
|
7632
7787
|
networkDelta: post.networkCount - pre.networkCount,
|
|
7633
7788
|
bytesDelta: post.bodyHtmlLength - pre.bodyHtmlLength,
|
|
@@ -7714,11 +7869,44 @@ async function executeStepWithHealing(params) {
|
|
|
7714
7869
|
}
|
|
7715
7870
|
}
|
|
7716
7871
|
}
|
|
7872
|
+
// Committed-value guard for the prompt-selector widget family (popup
|
|
7873
|
+
// multiselect/typeahead trigger widgets). The primitive's own picking
|
|
7874
|
+
// gesture ({@link tryPromptSelectorPrimitive}) only runs on the observe-act
|
|
7875
|
+
// fallback path; a resolved-as-click act against the SAME widget shape can
|
|
7876
|
+
// open its popup, report success, and — since opening a popup mutates the
|
|
7877
|
+
// DOM (bytes changed) — ride `clickViewSwapVerified` to a phantom success
|
|
7878
|
+
// without ever committing an option. Gate it the same way as the datepicker
|
|
7879
|
+
// guard above: readback the widget's own committed-value node
|
|
7880
|
+
// ({@link verifyPromptSelectorCommitted}) and, when the resolved element IS
|
|
7881
|
+
// this widget shape but nothing committed, suppress the weak signals.
|
|
7882
|
+
// `isPromptWidget: false` for anything outside this widget family, so a
|
|
7883
|
+
// plain click/fill is untouched.
|
|
7884
|
+
let promptSelectorRejected = false;
|
|
7885
|
+
// Exempt only on a genuine network-advance or URL-change signal, not the
|
|
7886
|
+
// broader `hasStrongSignal` (which the sibling datepicker gate above
|
|
7887
|
+
// legitimately relies on) — `domVerifiedForStep` is true here simply
|
|
7888
|
+
// because opening the widget's popup mutates the DOM, so reusing it would
|
|
7889
|
+
// let that popup-open alone skip the readback below.
|
|
7890
|
+
const hasNetworkOrUrlSignal = networkIsRealAdvance || urlChanged;
|
|
7891
|
+
if (record.actResultSuccess === true && !hasNetworkOrUrlSignal) {
|
|
7892
|
+
const readbackSelector = resolvedAction?.selector ?? triedSelectors[triedSelectors.length - 1] ?? null;
|
|
7893
|
+
if (readbackSelector) {
|
|
7894
|
+
const readbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
|
|
7895
|
+
const promptReadback = await verifyPromptSelectorCommitted(readbackTarget, readbackSelector);
|
|
7896
|
+
if (promptReadback?.isPromptWidget === true && promptReadback.committed === false) {
|
|
7897
|
+
promptSelectorRejected = true;
|
|
7898
|
+
record.actResultSuccess = false;
|
|
7899
|
+
record.errorMessage = `prompt-selector-fill-rejected: resolved element <${readbackSelector}> is a prompt-selector-shaped widget but no committed value landed`;
|
|
7900
|
+
}
|
|
7901
|
+
}
|
|
7902
|
+
}
|
|
7717
7903
|
let verified = networkIsRealAdvance ||
|
|
7718
7904
|
urlChanged ||
|
|
7719
7905
|
domVerifiedForStep ||
|
|
7720
7906
|
datepickerCommitted ||
|
|
7721
|
-
(!datepickerRejected &&
|
|
7907
|
+
(!datepickerRejected &&
|
|
7908
|
+
!promptSelectorRejected &&
|
|
7909
|
+
(clickViewSwapVerified || formValueVerified));
|
|
7722
7910
|
// Final-step submit-verification gate. Replaces the deterministic
|
|
7723
7911
|
// submitEndpointPattern regex with a Haiku 4.5 LLM judgment over
|
|
7724
7912
|
// multi-signal evidence (network captures, page URL/title, DOM
|
|
@@ -8004,7 +8192,7 @@ async function executeStepWithHealing(params) {
|
|
|
8004
8192
|
}));
|
|
8005
8193
|
const fallbackDomOnlyAdvance = shouldVetoFallbackAdvance({
|
|
8006
8194
|
hasPattern: advanceTransitionBodyPattern !== null,
|
|
8007
|
-
isFinalOrSubmit:
|
|
8195
|
+
isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
|
|
8008
8196
|
isAdvance: isAdvanceStep(step),
|
|
8009
8197
|
retryUrlChanged,
|
|
8010
8198
|
retryNetworkIsRealAdvance,
|
|
@@ -8159,7 +8347,7 @@ async function executeStepWithHealing(params) {
|
|
|
8159
8347
|
// checked) into `domVerified`. A registered selection toggle no longer
|
|
8160
8348
|
// reads as a phantom just because it moved no network/URL/bytes.
|
|
8161
8349
|
elementStateChanged: domVerified,
|
|
8162
|
-
isSubmitShapedStep:
|
|
8350
|
+
isSubmitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
|
|
8163
8351
|
});
|
|
8164
8352
|
const reason = record.errorMessage
|
|
8165
8353
|
? effectSignals
|
|
@@ -8178,7 +8366,8 @@ async function executeStepWithHealing(params) {
|
|
|
8178
8366
|
// Empirically grounded: 22 of 22 JSON-envelope ATS Continue/Submit step-failure
|
|
8179
8367
|
// dumps in a 2026-06-10 survey had the paired touched+dirty + visible
|
|
8180
8368
|
// error text pattern with 3 distinct rejection messages.
|
|
8181
|
-
if (record.resolvedMethod === "click" &&
|
|
8369
|
+
if (record.resolvedMethod === "click" &&
|
|
8370
|
+
(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
|
|
8182
8371
|
const live = await extractLivePageFormEvidence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), {
|
|
8183
8372
|
client: anthropic,
|
|
8184
8373
|
knownErrorClassPrefixes,
|
|
@@ -8223,7 +8412,7 @@ async function executeStepWithHealing(params) {
|
|
|
8223
8412
|
phantomClickAfterAttempt1 = record.phantomClickVerdict === "phantom";
|
|
8224
8413
|
if (phantomClickAfterAttempt1) {
|
|
8225
8414
|
const suppressedCount = getSuppressedAisdkElementIdErrorCount?.();
|
|
8226
|
-
const escalationTarget =
|
|
8415
|
+
const escalationTarget = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag)
|
|
8227
8416
|
? "escalating attempt 2 to deep-submit-locator"
|
|
8228
8417
|
: "non-submit step — escalating attempt 2 to trusted-click-retry (trusted CDP click on the resolved target)";
|
|
8229
8418
|
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 +8422,7 @@ async function executeStepWithHealing(params) {
|
|
|
8233
8422
|
// Treat the canonical submit click as "final" for this predicate
|
|
8234
8423
|
// even when it lives mid-flow. See requireSubmitEndpoint derivation
|
|
8235
8424
|
// above for the same gate-widening rationale.
|
|
8236
|
-
isFinalStep:
|
|
8425
|
+
isFinalStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
|
|
8237
8426
|
requireSubmitEndpoint,
|
|
8238
8427
|
resolvedMethod: record.resolvedMethod,
|
|
8239
8428
|
effectSignals,
|
|
@@ -8253,7 +8442,7 @@ async function executeStepWithHealing(params) {
|
|
|
8253
8442
|
// can reorder a later step forward — instead of burning the cascade.
|
|
8254
8443
|
const advanceStalled = isAdvanceStalled({
|
|
8255
8444
|
isAdvance: isAdvanceStep(step),
|
|
8256
|
-
isFinalOrSubmit:
|
|
8445
|
+
isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
|
|
8257
8446
|
hasPattern: advanceTransitionBodyPattern !== null,
|
|
8258
8447
|
clickFired: record.resolvedMethod === "click" && record.actResultSuccess === true,
|
|
8259
8448
|
networkFired,
|
|
@@ -8399,6 +8588,11 @@ async function runHealingFlow(deps) {
|
|
|
8399
8588
|
}
|
|
8400
8589
|
},
|
|
8401
8590
|
});
|
|
8591
|
+
const flowHasSubmitSemanticsFlag = flowHasSubmitSemantics({
|
|
8592
|
+
steps,
|
|
8593
|
+
submitEndpointPattern: deps.submitEndpointPattern ?? null,
|
|
8594
|
+
requireSubmitEndpointMatch: deps.requireSubmitEndpointMatch ?? false,
|
|
8595
|
+
});
|
|
8402
8596
|
if (shouldWarnMissingAdvancePattern(steps.map((s) => s.instruction), deps.advanceTransitionBodyPattern ?? null)) {
|
|
8403
8597
|
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
8598
|
}
|
|
@@ -8423,6 +8617,7 @@ async function runHealingFlow(deps) {
|
|
|
8423
8617
|
optional: s.optional,
|
|
8424
8618
|
upload: s.upload,
|
|
8425
8619
|
submitStep: s.submitStep,
|
|
8620
|
+
flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
|
|
8426
8621
|
stepIndex: i,
|
|
8427
8622
|
totalSteps: () => steps.length,
|
|
8428
8623
|
phase: "flow",
|