@enricai/barnacle 1.12.23 → 1.12.25
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/scraper/browser-click-expr.d.ts +88 -0
- package/dist/scraper/browser-click-expr.d.ts.map +1 -1
- package/dist/scraper/browser-click-expr.js +133 -0
- package/dist/scraper/browser-click-expr.js.map +1 -1
- package/dist/scraper/flow-runner.d.ts +13 -0
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +328 -37
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +23 -0
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +180 -25
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +1 -1
|
@@ -23,6 +23,7 @@ exports.findRecentBackendError = findRecentBackendError;
|
|
|
23
23
|
exports.findRecentPageTransition = findRecentPageTransition;
|
|
24
24
|
exports.isWizardExitAction = isWizardExitAction;
|
|
25
25
|
exports.isAdvanceStep = isAdvanceStep;
|
|
26
|
+
exports.isCheckboxOrRadioIntentStep = isCheckboxOrRadioIntentStep;
|
|
26
27
|
exports.shouldCaptureSelectionState = shouldCaptureSelectionState;
|
|
27
28
|
exports.flowHasSubmitSemantics = flowHasSubmitSemantics;
|
|
28
29
|
exports.shouldWarnMissingAdvancePattern = shouldWarnMissingAdvancePattern;
|
|
@@ -669,6 +670,30 @@ const INVALID_MARKER_EL_EXPR = `((el) => {
|
|
|
669
670
|
if (el.getAttribute && el.getAttribute("aria-invalid") === "true") return true;
|
|
670
671
|
return false;
|
|
671
672
|
})`;
|
|
673
|
+
/**
|
|
674
|
+
* Browser-context predicate string: given an element `el` in scope, is it
|
|
675
|
+
* disabled — either directly (native `disabled` property or
|
|
676
|
+
* `aria-disabled="true"`) or via an ancestor within
|
|
677
|
+
* {@link MAX_SELECTION_ANCESTOR_DEPTH} levels? Stagehand can resolve a click
|
|
678
|
+
* to a decorative leaf (a plain `<span>`/text node) nested inside a
|
|
679
|
+
* non-form clickable wrapper (`<div role="button" aria-disabled="true">`) —
|
|
680
|
+
* the leaf itself carries no `disabled` property and no `aria-disabled`
|
|
681
|
+
* attribute of its own, so a leaf-only check misses it even though the
|
|
682
|
+
* click can't have done anything. Climbs the SAME bounded depth as
|
|
683
|
+
* {@link NEARBY_SELECTION_CONTAINER_FN_SRC} rather than an unbounded walk,
|
|
684
|
+
* so a disabled ancestor far up the tree (e.g. a disabled `<fieldset>`
|
|
685
|
+
* wrapping an entire form section) doesn't false-positive-veto an
|
|
686
|
+
* unrelated enabled control nested arbitrarily deep inside it. Interpolate
|
|
687
|
+
* into a `page.evaluate` expr where `el` is bound.
|
|
688
|
+
*/
|
|
689
|
+
const DISABLED_MARKER_EL_EXPR = `((el) => {
|
|
690
|
+
for (let depth = 0; depth < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && el; depth++) {
|
|
691
|
+
if (el.disabled === true) return true;
|
|
692
|
+
if (el.getAttribute && el.getAttribute("aria-disabled") === "true") return true;
|
|
693
|
+
el = el.parentElement;
|
|
694
|
+
}
|
|
695
|
+
return false;
|
|
696
|
+
})`;
|
|
672
697
|
/**
|
|
673
698
|
* How many steps from the end of the flow are considered "trailing" for the
|
|
674
699
|
* Tier 1 grace path. A verification failure on an optional step within this
|
|
@@ -708,11 +733,17 @@ function prepareFailureDumpBody(raw) {
|
|
|
708
733
|
return pruned.slice(0, FAILURE_DUMP_MAX_BODY_LENGTH);
|
|
709
734
|
}
|
|
710
735
|
/**
|
|
711
|
-
* Trust boundary:
|
|
712
|
-
*
|
|
713
|
-
*
|
|
736
|
+
* Trust boundary: composed only from compile-time constants
|
|
737
|
+
* ({@link SELECTION_MARKER_CLASS_SELECTOR_SRC},
|
|
738
|
+
* {@link SELECTION_MARKER_CLASS_TOKEN_REGEX_SRC}) — no runtime/request
|
|
739
|
+
* interpolation, so no injection surface. Runs in browser context and returns
|
|
740
|
+
* a typed-narrow shape via Runtime.callFunctionOn. The class-token selector
|
|
741
|
+
* and regex are the SAME source {@link SELECTION_STATE_MAP_EXPR} uses to
|
|
742
|
+
* decide which class-only-marked elements get a strict-verification baseline
|
|
743
|
+
* entry, so the weak page-wide signature and the strict per-element gate
|
|
744
|
+
* agree on exactly what counts as a class-token selection marker.
|
|
714
745
|
*/
|
|
715
|
-
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 =
|
|
746
|
+
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 }; })()`;
|
|
716
747
|
/**
|
|
717
748
|
* Captures the pre/post signal triple the submit-verify cascade diffs.
|
|
718
749
|
* Accepts the optional `page` so a resolved child `FrameTarget` whose
|
|
@@ -957,6 +988,23 @@ function isAdvanceStep(instruction) {
|
|
|
957
988
|
const haystack = instruction.toLowerCase();
|
|
958
989
|
return ADVANCE_STEP_PHRASES.some((p) => haystack.includes(p));
|
|
959
990
|
}
|
|
991
|
+
/**
|
|
992
|
+
* Is this flow step's intent to check a checkbox or select a radio option?
|
|
993
|
+
* Used to veto a weak page-wide DOM signal (htmlDelta/textChanged/formValueChanged)
|
|
994
|
+
* from crediting a checkbox/radio-intent step: those signals move whenever ANY
|
|
995
|
+
* field on the page changes (e.g. an earlier fill), so they say nothing about
|
|
996
|
+
* whether THIS control actually committed. A checkbox/radio-intent step must be
|
|
997
|
+
* verified by the element's own committed state (`checkboxStateVerified`) or a
|
|
998
|
+
* real network/url/selection-state signal, the same discipline `isAdvanceStep`
|
|
999
|
+
* already applies to advance/Next steps. Keyed on the ORIGINAL step instruction,
|
|
1000
|
+
* via the same parsers (`parseCheckStep`/`parseRadioStep`) their primitives use.
|
|
1001
|
+
* Pure; unit-testable seam paralleling `isAdvanceStep`.
|
|
1002
|
+
*/
|
|
1003
|
+
function isCheckboxOrRadioIntentStep(instruction) {
|
|
1004
|
+
if (!instruction)
|
|
1005
|
+
return false;
|
|
1006
|
+
return parseCheckStep(instruction) !== null || parseRadioStep(instruction) !== null;
|
|
1007
|
+
}
|
|
960
1008
|
/**
|
|
961
1009
|
* Whether `snapshotPage` should build the per-element selection baseline
|
|
962
1010
|
* (`StepSnapshot.selectionStateByXpath`) for this step — i.e. whether
|
|
@@ -2900,6 +2948,42 @@ const STATE_CLASS_METHODS = new Set([
|
|
|
2900
2948
|
function xpathBody(selector) {
|
|
2901
2949
|
return selector.startsWith("xpath=") ? selector.slice("xpath=".length) : null;
|
|
2902
2950
|
}
|
|
2951
|
+
/**
|
|
2952
|
+
* Stagehand's resolved xpath (`nodeToAbsoluteXPath` in its DOM helper) is
|
|
2953
|
+
* built purely from sibling-position counts — `/html[1]/body[1]/div[7]/label[1]/input[1]`,
|
|
2954
|
+
* with no `@id`/`@name` predicates anywhere in the path. A validation
|
|
2955
|
+
* re-render triggered by an earlier field's blur/change (e.g. an inline
|
|
2956
|
+
* error node inserted as a preceding sibling of an ancestor container)
|
|
2957
|
+
* shifts every ancestor's positional index below the insertion point, so
|
|
2958
|
+
* the full absolute path stops matching the live DOM even though the leaf
|
|
2959
|
+
* element itself never moved or re-rendered. The leaf's own count of
|
|
2960
|
+
* same-tag preceding siblings is untouched by an unrelated ancestor
|
|
2961
|
+
* insertion, so re-anchoring on just the last two path steps (the leaf and
|
|
2962
|
+
* its immediate parent, tag + position predicate intact) recovers the live
|
|
2963
|
+
* node deterministically without guessing at a new selector.
|
|
2964
|
+
*/
|
|
2965
|
+
function xpathTailForRetarget(xpath) {
|
|
2966
|
+
const steps = [];
|
|
2967
|
+
let depth = 0;
|
|
2968
|
+
let current = "";
|
|
2969
|
+
for (const ch of xpath) {
|
|
2970
|
+
if (ch === "[")
|
|
2971
|
+
depth++;
|
|
2972
|
+
if (ch === "]")
|
|
2973
|
+
depth--;
|
|
2974
|
+
if (ch === "/" && depth === 0) {
|
|
2975
|
+
if (current)
|
|
2976
|
+
steps.push(current);
|
|
2977
|
+
current = "";
|
|
2978
|
+
continue;
|
|
2979
|
+
}
|
|
2980
|
+
current += ch;
|
|
2981
|
+
}
|
|
2982
|
+
if (current)
|
|
2983
|
+
steps.push(current);
|
|
2984
|
+
const tail = steps.slice(-2).filter(Boolean);
|
|
2985
|
+
return tail.length > 0 ? tail.join("/") : null;
|
|
2986
|
+
}
|
|
2903
2987
|
/**
|
|
2904
2988
|
* Resolve any selector a caller might hold into a bare XPath body for
|
|
2905
2989
|
* `document.evaluate`. `verifyFillReadback` is shared across call sites that
|
|
@@ -2968,6 +3052,37 @@ function selectionFingerprintObjSrc(elVar, dsVar) {
|
|
|
2968
3052
|
function elementSelectionFingerprintExpr(xpath) {
|
|
2969
3053
|
return `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); const el = r.singleNodeValue; if (!el) return null; const ds = el.getAttribute("data-state") || ""; return ${selectionFingerprintObjSrc("el", "ds")}; })()`;
|
|
2970
3054
|
}
|
|
3055
|
+
/**
|
|
3056
|
+
* In-page source for `(el) => Element | null`: the nearest ancestor of `el`,
|
|
3057
|
+
* within {@link MAX_SELECTION_ANCESTOR_DEPTH} levels, whose subtree contains a
|
|
3058
|
+
* selection-marker element (`role="combobox"`/`role="listbox"`, or a
|
|
3059
|
+
* {@link WIDGET_KIT_SELECTION_MARKER_SELECTORS} match) — climbing ancestor
|
|
3060
|
+
* levels rather than a single `closest()` call, because a design-system
|
|
3061
|
+
* combobox's marker (the `role="combobox"` trigger) and its committed-value
|
|
3062
|
+
* control (a hidden `<input>`) are commonly SIBLINGS under a shared wrapper
|
|
3063
|
+
* that itself carries no role/class marker: `closest()` only searches
|
|
3064
|
+
* self-and-ancestors of `el`, so it can never see a marker that lives on a
|
|
3065
|
+
* cousin subtree. At each level, `querySelectorAll` searches the WHOLE
|
|
3066
|
+
* subtree (not just direct children), so the marker may be nested arbitrarily
|
|
3067
|
+
* deep under that ancestor. Returns the FIRST (nearest) qualifying ancestor,
|
|
3068
|
+
* so a same-shaped input/select outside that common ancestor is never
|
|
3069
|
+
* mistaken for the same control's committed-value sibling. Shared by
|
|
3070
|
+
* {@link SELECTION_STATE_MAP_EXPR}'s `isCommittedValueControl` and
|
|
3071
|
+
* {@link selectionSiblingCommittedValueChanged}'s container resolution so the
|
|
3072
|
+
* baseline capture and the read-back agree on exactly what counts as
|
|
3073
|
+
* "nearby" — two independently-drifting copies would silently disagree on
|
|
3074
|
+
* which hidden controls get a baseline entry vs. which get diffed against
|
|
3075
|
+
* one, reintroducing the bug this closes.
|
|
3076
|
+
*/
|
|
3077
|
+
const NEARBY_SELECTION_CONTAINER_FN_SRC = `(el) => {
|
|
3078
|
+
const MARKER_SEL = '[role="combobox"],[role="listbox"],' + ${JSON.stringify(browser_click_expr_1.WIDGET_KIT_SELECTION_MARKER_SELECTORS)};
|
|
3079
|
+
let node = el.parentElement;
|
|
3080
|
+
for (let depth = 0; depth < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && node; depth++) {
|
|
3081
|
+
if (node.querySelectorAll && node.querySelectorAll(MARKER_SEL).length > 0) return node;
|
|
3082
|
+
node = node.parentElement;
|
|
3083
|
+
}
|
|
3084
|
+
return null;
|
|
3085
|
+
}`;
|
|
2971
3086
|
/**
|
|
2972
3087
|
* Browser-side expression: build `{ absolutePositionalXpath →
|
|
2973
3088
|
* ElementSelectionFingerprint }` for every VISIBLE interactive element, the
|
|
@@ -2982,22 +3097,40 @@ function elementSelectionFingerprintExpr(xpath) {
|
|
|
2982
3097
|
* rect + `getComputedStyle` idiom as `deep-locator-scan.ts`'s `IS_VISIBLE_EXPR`
|
|
2983
3098
|
* (rather than `offsetParent`), so on-screen `position:fixed` controls — a
|
|
2984
3099
|
* sticky Next/Submit bar, whose `offsetParent` is `null` — still enter the map.
|
|
3100
|
+
* The visibility gate is waived for an `input`/`select` nested inside a nearby
|
|
3101
|
+
* combobox/listbox/group container: a design-system option commits its real
|
|
3102
|
+
* selection to a `display:none` or `aria-hidden` sibling input, not to the
|
|
3103
|
+
* visible trigger label, so excluding it here would make that control
|
|
3104
|
+
* permanently invisible to the read-back — the baseline is where a hidden
|
|
3105
|
+
* committed-value control gets ITS OWN entry (keyed by its own xpath, reusing
|
|
3106
|
+
* the existing `value` field), so a later diff has something to compare
|
|
3107
|
+
* against. The selector also folds in
|
|
3108
|
+
* {@link SELECTION_MARKER_CLASS_SELECTOR_SRC} — the SAME class-token vocabulary
|
|
3109
|
+
* `DOM_SNAPSHOT_EXPR`'s weak diagnostic signature uses — so a custom option
|
|
3110
|
+
* widget that flips a class on ITSELF (`class="option selected"`, no role/
|
|
3111
|
+
* aria-state) gets its own baseline entry: without it,
|
|
3112
|
+
* {@link selectionAncestorChanged}'s ancestor-diff would have nothing to
|
|
3113
|
+
* compare against for a widget that never writes a hidden sibling control.
|
|
2985
3114
|
*/
|
|
2986
3115
|
const SELECTION_STATE_MAP_EXPR = `(() => {
|
|
2987
3116
|
const b = document.body;
|
|
2988
3117
|
if (!b) return {};
|
|
2989
3118
|
const xpathOf = ${XPATH_OF_FN_SRC};
|
|
3119
|
+
const nearbySelectionContainer = ${NEARBY_SELECTION_CONTAINER_FN_SRC};
|
|
2990
3120
|
const visible = (el) => {
|
|
2991
3121
|
const rect = el.getBoundingClientRect();
|
|
2992
3122
|
if (rect.width === 0 && rect.height === 0) return false;
|
|
2993
3123
|
const style = getComputedStyle(el);
|
|
2994
3124
|
return style.display !== "none" && style.visibility !== "hidden";
|
|
2995
3125
|
};
|
|
2996
|
-
const sel = "button,[role=button],a,[tabindex],input,select,textarea,[role=option],[role=tab],[role=switch],[role=checkbox],[role=menuitemcheckbox]";
|
|
3126
|
+
const sel = "button,[role=button],a,li,[tabindex],input,select,textarea,[role=option],[role=tab],[role=switch],[role=checkbox],[role=menuitemcheckbox]," + ${JSON.stringify(browser_click_expr_1.SELECTION_MARKER_CLASS_SELECTOR_SRC)};
|
|
2997
3127
|
const skip = (el) => el.closest("[role=dialog],[role=tooltip],[aria-live]") !== null;
|
|
3128
|
+
const isCommittedValueControl = (el) =>
|
|
3129
|
+
(el.tagName === "INPUT" || el.tagName === "SELECT") &&
|
|
3130
|
+
nearbySelectionContainer(el) !== null;
|
|
2998
3131
|
const out = {};
|
|
2999
3132
|
for (const el of b.querySelectorAll(sel)) {
|
|
3000
|
-
if (!visible(el) || skip(el)) continue;
|
|
3133
|
+
if ((!visible(el) && !isCommittedValueControl(el)) || skip(el)) continue;
|
|
3001
3134
|
const ds = el.getAttribute("data-state") || "";
|
|
3002
3135
|
out[xpathOf(el)] = ${selectionFingerprintObjSrc("el", "ds")};
|
|
3003
3136
|
}
|
|
@@ -3075,30 +3208,6 @@ async function readElementSelectionFingerprint(target, selector) {
|
|
|
3075
3208
|
return null;
|
|
3076
3209
|
}
|
|
3077
3210
|
}
|
|
3078
|
-
/**
|
|
3079
|
-
* How far up from the clicked leaf {@link selectionAncestorChanged} walks
|
|
3080
|
-
* looking for the option/toggle that carries the selection. Design-system
|
|
3081
|
-
* options nest their label 1-2 levels deep (a `<span title>` inside a
|
|
3082
|
-
* `role="option"`, plus the odd icon/wrapper); 6 matches the vacuous-click
|
|
3083
|
-
* ancestor guard in {@link verifyDomEffect}'s click branch and covers that
|
|
3084
|
-
* nesting without over-reaching into an outer listbox/group.
|
|
3085
|
-
*/
|
|
3086
|
-
const MAX_SELECTION_ANCESTOR_DEPTH = 6;
|
|
3087
|
-
/**
|
|
3088
|
-
* Cross-vendor selector union for a selection-state widget that carries NO
|
|
3089
|
-
* standard selection `role` or `aria-*`/`data-state` marker — a component-kit
|
|
3090
|
-
* container whose selected-ness lives only in the library's own private
|
|
3091
|
-
* attribute. Same multi-vendor-union discipline as {@link INVALID_MARKER_CLASS_SOURCE}
|
|
3092
|
-
* and the `PROMPT_*_SELECTORS` unions: standards are checked FIRST (see
|
|
3093
|
-
* `hasMarker` in {@link selectionAncestorChanged}); this union is the fallback
|
|
3094
|
-
* for widgets that under-annotate ARIA, and no member is a per-site branch —
|
|
3095
|
-
* each is one component library's signature. Grows by a one-line edit.
|
|
3096
|
-
*
|
|
3097
|
-
* Members: `data-baseweb` (Uber Base Web — verified in a real capture to mark
|
|
3098
|
-
* 150 selection elements that expose no role/aria-state, so dropping it loses
|
|
3099
|
-
* real coverage). Add other under-annotating kits here as they surface.
|
|
3100
|
-
*/
|
|
3101
|
-
const WIDGET_KIT_SELECTION_MARKER_SELECTORS = ["[data-baseweb]"].join(",");
|
|
3102
3211
|
/**
|
|
3103
3212
|
* Element-scoped selection read-back for the case the clicked node's OWN
|
|
3104
3213
|
* fingerprint can't credit: a design-system option that wraps its label in a
|
|
@@ -3131,13 +3240,26 @@ async function selectionAncestorChanged(target, leafXpath, preSelectionState) {
|
|
|
3131
3240
|
const xpathOf = ${XPATH_OF_FN_SRC};
|
|
3132
3241
|
const fp = (el) => { const ds = el.getAttribute("data-state") || ""; return ${selectionFingerprintObjSrc("el", "ds")}; };
|
|
3133
3242
|
const SELECTION_ROLES = new Set(["option", "tab", "switch", "radio", "checkbox", "menuitemcheckbox"]);
|
|
3134
|
-
const KIT_MARKER_SEL = ${JSON.stringify(WIDGET_KIT_SELECTION_MARKER_SELECTORS)};
|
|
3243
|
+
const KIT_MARKER_SEL = ${JSON.stringify(browser_click_expr_1.WIDGET_KIT_SELECTION_MARKER_SELECTORS)};
|
|
3244
|
+
const CLASS_TOKEN_RX = ${browser_click_expr_1.SELECTION_MARKER_CLASS_TOKEN_REGEX_SRC};
|
|
3135
3245
|
const hasMarker = (el, f) => {
|
|
3136
3246
|
// Standards first: fingerprint fields / aria-states, then a selection role.
|
|
3137
3247
|
if (f.kind || f.ariaPressed || f.ariaChecked || f.ariaSelected || f.dataState || f.dataSelected || f.dataChecked || f.checked) return true;
|
|
3138
3248
|
if (SELECTION_ROLES.has((el.getAttribute("role") || "").toLowerCase())) return true;
|
|
3139
3249
|
// Fallback: a component-kit selection widget that exposes no standard marker.
|
|
3140
3250
|
if (el.matches(KIT_MARKER_SEL)) return true;
|
|
3251
|
+
// Fallback: a widget authored with no role/aria-state, marked purely by a class token.
|
|
3252
|
+
if (CLASS_TOKEN_RX.test(el.getAttribute("class") || "")) return true;
|
|
3253
|
+
// Fallback: a broken click handler never adds the token to THIS node, but a
|
|
3254
|
+
// sibling still carrying it (the untouched prior selection) proves the
|
|
3255
|
+
// group uses the class-token convention — so this node is a member of it.
|
|
3256
|
+
if (el.parentElement && el.parentElement.children) {
|
|
3257
|
+
const siblings = el.parentElement.children;
|
|
3258
|
+
for (let i = 0; i < siblings.length; i++) {
|
|
3259
|
+
const sib = siblings[i];
|
|
3260
|
+
if (sib !== el && CLASS_TOKEN_RX.test(sib.getAttribute("class") || "")) return true;
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3141
3263
|
return false;
|
|
3142
3264
|
};
|
|
3143
3265
|
const changed = (a, b) =>
|
|
@@ -3148,7 +3270,7 @@ async function selectionAncestorChanged(target, leafXpath, preSelectionState) {
|
|
|
3148
3270
|
const r = document.evaluate(LEAF, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
3149
3271
|
let node = r.singleNodeValue;
|
|
3150
3272
|
if (!node) return false;
|
|
3151
|
-
for (let depth = 0; depth < ${MAX_SELECTION_ANCESTOR_DEPTH} && node; depth++) {
|
|
3273
|
+
for (let depth = 0; depth < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && node; depth++) {
|
|
3152
3274
|
if (node.getAttribute) {
|
|
3153
3275
|
const now = fp(node);
|
|
3154
3276
|
if (hasMarker(node, now)) {
|
|
@@ -3168,6 +3290,100 @@ async function selectionAncestorChanged(target, leafXpath, preSelectionState) {
|
|
|
3168
3290
|
return false;
|
|
3169
3291
|
}
|
|
3170
3292
|
}
|
|
3293
|
+
/**
|
|
3294
|
+
* Structural marker probe for the n+16 fallback's weak-signal gate — reuses
|
|
3295
|
+
* the SAME `hasMarker` predicate {@link selectionAncestorChanged} diffs
|
|
3296
|
+
* against, but only asks presence, not before/after equality. A click that
|
|
3297
|
+
* resolves onto (or under) a `role=option`/aria-selection/component-kit
|
|
3298
|
+
* marker element must clear the strict {@link selectionAncestorChanged} /
|
|
3299
|
+
* {@link selectionSiblingCommittedValueChanged} signal to be credited —
|
|
3300
|
+
* html-byte-delta/text-change/form-value alone (a label re-render with no
|
|
3301
|
+
* committed-control change) is exactly the reported defect
|
|
3302
|
+
* (textChanged=true, selectionStateChanged=false, verified=true) this
|
|
3303
|
+
* closes off. Returns `false` on any miss / malformed result / evaluate
|
|
3304
|
+
* throw (defer to the strict signal already gating `retryVerified`).
|
|
3305
|
+
*/
|
|
3306
|
+
async function clickTargetHasSelectionMarker(target, leafXpath) {
|
|
3307
|
+
const expr = `(() => {
|
|
3308
|
+
const LEAF = ${JSON.stringify(leafXpath)};
|
|
3309
|
+
const fp = (el) => { const ds = el.getAttribute("data-state") || ""; return ${selectionFingerprintObjSrc("el", "ds")}; };
|
|
3310
|
+
const SELECTION_ROLES = new Set(["option", "tab", "switch", "radio", "checkbox", "menuitemcheckbox"]);
|
|
3311
|
+
const KIT_MARKER_SEL = ${JSON.stringify(browser_click_expr_1.WIDGET_KIT_SELECTION_MARKER_SELECTORS)};
|
|
3312
|
+
const CLASS_TOKEN_RX = ${browser_click_expr_1.SELECTION_MARKER_CLASS_TOKEN_REGEX_SRC};
|
|
3313
|
+
const hasMarker = (el, f) => {
|
|
3314
|
+
if (f.kind || f.ariaPressed || f.ariaChecked || f.ariaSelected || f.dataState || f.dataSelected || f.dataChecked || f.checked) return true;
|
|
3315
|
+
if (SELECTION_ROLES.has((el.getAttribute("role") || "").toLowerCase())) return true;
|
|
3316
|
+
if (el.matches(KIT_MARKER_SEL)) return true;
|
|
3317
|
+
if (CLASS_TOKEN_RX.test(el.getAttribute("class") || "")) return true;
|
|
3318
|
+
if (el.parentElement && el.parentElement.children) {
|
|
3319
|
+
const siblings = el.parentElement.children;
|
|
3320
|
+
for (let i = 0; i < siblings.length; i++) {
|
|
3321
|
+
const sib = siblings[i];
|
|
3322
|
+
if (sib !== el && CLASS_TOKEN_RX.test(sib.getAttribute("class") || "")) return true;
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
return false;
|
|
3326
|
+
};
|
|
3327
|
+
const r = document.evaluate(LEAF, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
3328
|
+
let node = r.singleNodeValue;
|
|
3329
|
+
if (!node) return false;
|
|
3330
|
+
for (let depth = 0; depth < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && node; depth++) {
|
|
3331
|
+
if (node.getAttribute && hasMarker(node, fp(node))) return true;
|
|
3332
|
+
node = node.parentElement;
|
|
3333
|
+
}
|
|
3334
|
+
return false;
|
|
3335
|
+
})()`;
|
|
3336
|
+
try {
|
|
3337
|
+
return (await target.evaluate(expr)) === true;
|
|
3338
|
+
}
|
|
3339
|
+
catch {
|
|
3340
|
+
return false;
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
/**
|
|
3344
|
+
* Sibling read-back for the case NEITHER the clicked leaf NOR any of its
|
|
3345
|
+
* ancestors ({@link selectionAncestorChanged}) ever change: a design-system
|
|
3346
|
+
* combobox where the click re-renders only the visible trigger label, and the
|
|
3347
|
+
* REAL committed selection lives on a hidden associated `<input>`/`<select>`
|
|
3348
|
+
* that is a sibling/cousin of the leaf, not an ancestor — so an UP-only walk
|
|
3349
|
+
* can never reach it. Finds the nearest combobox/listbox/group container
|
|
3350
|
+
* around the leaf (the same idiom the `selectOption` verifier uses at the
|
|
3351
|
+
* nested-input search), diffs every `input`/`select` inside it against the
|
|
3352
|
+
* baseline entry {@link SELECTION_STATE_MAP_EXPR} captured for that control's
|
|
3353
|
+
* OWN xpath (visibility-gate-waived there for exactly this control class).
|
|
3354
|
+
* Returns `false` on any miss / malformed result / evaluate throw (defer to
|
|
3355
|
+
* the ancestor and network/URL signals).
|
|
3356
|
+
*/
|
|
3357
|
+
async function selectionSiblingCommittedValueChanged(target, leafXpath, preSelectionState) {
|
|
3358
|
+
const expr = `(() => {
|
|
3359
|
+
const LEAF = ${JSON.stringify(leafXpath)};
|
|
3360
|
+
const BASE = ${JSON.stringify(preSelectionState)};
|
|
3361
|
+
const xpathOf = ${XPATH_OF_FN_SRC};
|
|
3362
|
+
const nearbySelectionContainer = ${NEARBY_SELECTION_CONTAINER_FN_SRC};
|
|
3363
|
+
const r = document.evaluate(LEAF, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
3364
|
+
const leaf = r.singleNodeValue;
|
|
3365
|
+
if (!leaf) return false;
|
|
3366
|
+
// The hidden committed-value control's marker (the role=combobox trigger)
|
|
3367
|
+
// is commonly a SIBLING of the leaf and the control, not an ancestor of
|
|
3368
|
+
// either — climb bounded ancestor levels for the nearest subtree that
|
|
3369
|
+
// contains a marker, rather than a single self-and-ancestors closest().
|
|
3370
|
+
const container = nearbySelectionContainer(leaf) || leaf.parentElement;
|
|
3371
|
+
if (!container || !container.querySelectorAll) return false;
|
|
3372
|
+
for (const control of container.querySelectorAll("input,select")) {
|
|
3373
|
+
const pre = BASE[xpathOf(control)];
|
|
3374
|
+
if (!pre) continue;
|
|
3375
|
+
const now = typeof control.value === "string" ? control.value.slice(0, 200) : "";
|
|
3376
|
+
if (pre.value !== now) return true;
|
|
3377
|
+
}
|
|
3378
|
+
return false;
|
|
3379
|
+
})()`;
|
|
3380
|
+
try {
|
|
3381
|
+
return (await target.evaluate(expr)) === true;
|
|
3382
|
+
}
|
|
3383
|
+
catch {
|
|
3384
|
+
return false;
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3171
3387
|
/** How long the upload primitive waits for a post-setInputFiles network POST. */
|
|
3172
3388
|
const UPLOAD_NETWORK_TIMEOUT_MS = 5_000;
|
|
3173
3389
|
/** Polling interval while waiting for the upload's network signal. */
|
|
@@ -5906,6 +6122,19 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
|
|
|
5906
6122
|
const xpath = xpathBody(selector);
|
|
5907
6123
|
if (!xpath)
|
|
5908
6124
|
return false;
|
|
6125
|
+
// A click that resolved to a disabled (or aria-disabled) target can't
|
|
6126
|
+
// have done anything — veto before trusting any other signal below.
|
|
6127
|
+
const targetDisabledExpr = `(() => {
|
|
6128
|
+
const isDisabled = ${DISABLED_MARKER_EL_EXPR};
|
|
6129
|
+
const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
6130
|
+
const el = r.singleNodeValue;
|
|
6131
|
+
return el ? isDisabled(el) : false;
|
|
6132
|
+
})()`;
|
|
6133
|
+
const targetDisabled = await target
|
|
6134
|
+
.evaluate(targetDisabledExpr)
|
|
6135
|
+
.catch(() => false);
|
|
6136
|
+
if (targetDisabled)
|
|
6137
|
+
return false;
|
|
5909
6138
|
let inputType = null;
|
|
5910
6139
|
try {
|
|
5911
6140
|
// Trust boundary: xpath comes from Stagehand's own resolved selector
|
|
@@ -5942,7 +6171,14 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
|
|
|
5942
6171
|
// selection on the ancestor `role="option"`, not the clicked leaf —
|
|
5943
6172
|
// so walk up to the nearest baseline-present selection ancestor and
|
|
5944
6173
|
// diff THAT. No eligible ancestor → false (defer to network/URL).
|
|
5945
|
-
|
|
6174
|
+
if (await selectionAncestorChanged(target, xpath, preSelectionState)) {
|
|
6175
|
+
return true;
|
|
6176
|
+
}
|
|
6177
|
+
// Neither the leaf nor any ancestor moved: the widget's real commit
|
|
6178
|
+
// may live on a hidden sibling `<input>`/`<select>` the click never
|
|
6179
|
+
// re-renders visibly. Diff that control against its own baseline
|
|
6180
|
+
// entry instead.
|
|
6181
|
+
return await selectionSiblingCommittedValueChanged(target, xpath, preSelectionState);
|
|
5946
6182
|
}
|
|
5947
6183
|
const isCheckedNow = await locator.isChecked();
|
|
5948
6184
|
if (!isCheckedNow)
|
|
@@ -8230,7 +8466,14 @@ async function executeStepWithHealing(params) {
|
|
|
8230
8466
|
// default action, but isolated-world page.evaluate() click()s don't
|
|
8231
8467
|
// reliably trigger that default action — same gap N+42 documented
|
|
8232
8468
|
// for direct checkbox/radio clicks.
|
|
8233
|
-
|
|
8469
|
+
// xpathTail: see xpathTailForRetarget's docblock — Stagehand's
|
|
8470
|
+
// absolute xpath is pure sibling-position, so a re-render that
|
|
8471
|
+
// shifted an ANCESTOR's index (a validation message inserted by an
|
|
8472
|
+
// earlier field's blur) leaves the primary evaluate with no match
|
|
8473
|
+
// even though the leaf element is still live; re-anchor on the
|
|
8474
|
+
// leaf's own last two steps before giving up.
|
|
8475
|
+
const xpathTail = xpathTailForRetarget(xpath);
|
|
8476
|
+
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)}) { const r2 = document.evaluate("//" + ${JSON.stringify(xpathTail)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); el = r2.singleNodeValue; } 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")} ${(0, browser_click_expr_1.clickActivationExpr)("el")} if (__n16SmMatched) { el.dispatchEvent(new Event("change", { bubbles: true })); } return { fired: true, kind: "click" }; })()`;
|
|
8234
8477
|
const n16FallbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
|
|
8235
8478
|
const probeResult = (await n16FallbackTarget.evaluate(clickExpr));
|
|
8236
8479
|
const fired = probeResult.fired;
|
|
@@ -8248,6 +8491,10 @@ async function executeStepWithHealing(params) {
|
|
|
8248
8491
|
const isInvalid = ${INVALID_MARKER_EL_EXPR};
|
|
8249
8492
|
const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
8250
8493
|
let node = r.singleNodeValue;
|
|
8494
|
+
if (!node && ${JSON.stringify(xpathTail)}) {
|
|
8495
|
+
const r2 = document.evaluate("//" + ${JSON.stringify(xpathTail)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
8496
|
+
node = r2.singleNodeValue;
|
|
8497
|
+
}
|
|
8251
8498
|
if (!node) return false;
|
|
8252
8499
|
for (let depth = 0; depth < 6 && node; depth++) {
|
|
8253
8500
|
if (node.getAttribute && isInvalid(node)) return true;
|
|
@@ -8297,7 +8544,14 @@ async function executeStepWithHealing(params) {
|
|
|
8297
8544
|
// to the nearest baseline-present selection ancestor and diff THAT,
|
|
8298
8545
|
// the same fallback `verifyDomEffect`'s primary read-back uses. No
|
|
8299
8546
|
// eligible ancestor → false (defer to the other retry signals).
|
|
8300
|
-
|
|
8547
|
+
if (await selectionAncestorChanged(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), xpath, pre.selectionStateByXpath)) {
|
|
8548
|
+
return true;
|
|
8549
|
+
}
|
|
8550
|
+
// Neither the leaf nor any ancestor moved: the widget's real
|
|
8551
|
+
// commit may live on a hidden sibling `<input>`/`<select>` the
|
|
8552
|
+
// click never re-renders visibly. Diff that control against its
|
|
8553
|
+
// own baseline entry instead.
|
|
8554
|
+
return await selectionSiblingCommittedValueChanged(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), xpath, pre.selectionStateByXpath);
|
|
8301
8555
|
})());
|
|
8302
8556
|
// RC2: an advance/`kind=click` "Next" that only grew the DOM
|
|
8303
8557
|
// (validation errors rendered) with NO network/URL change is a
|
|
@@ -8310,6 +8564,27 @@ async function executeStepWithHealing(params) {
|
|
|
8310
8564
|
// verifying so the cascade routes to the fill-invalid-fields replan.
|
|
8311
8565
|
// Confirmed no-op signature on the wizard ATS's COMPENSATION page (network=false
|
|
8312
8566
|
// url=false htmlDelta>0 textChanged) mis-scored verified=true(dom).
|
|
8567
|
+
// A click that resolved to a disabled (or aria-disabled) target
|
|
8568
|
+
// can't have done anything — same veto as verifyDomEffect's click
|
|
8569
|
+
// branch, applied here so the n+16 fallback can't ride past it on
|
|
8570
|
+
// a weak htmlDelta/textChanged signal.
|
|
8571
|
+
const clickBlockedByDisabled = probeResult.kind === "click" &&
|
|
8572
|
+
xpath !== null &&
|
|
8573
|
+
(await (async () => {
|
|
8574
|
+
const disabledExpr = `(() => {
|
|
8575
|
+
const isDisabled = ${DISABLED_MARKER_EL_EXPR};
|
|
8576
|
+
const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
8577
|
+
let el = r.singleNodeValue;
|
|
8578
|
+
if (!el && ${JSON.stringify(xpathTail)}) {
|
|
8579
|
+
const r2 = document.evaluate("//" + ${JSON.stringify(xpathTail)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
8580
|
+
el = r2.singleNodeValue;
|
|
8581
|
+
}
|
|
8582
|
+
return el ? isDisabled(el) : false;
|
|
8583
|
+
})()`;
|
|
8584
|
+
return await (frameTarget ?? (0, frame_target_1.mainFrameTarget)(page))
|
|
8585
|
+
.evaluate(disabledExpr)
|
|
8586
|
+
.catch(() => false);
|
|
8587
|
+
})());
|
|
8313
8588
|
const clickWasDomOnly = probeResult.kind === "click" && !retryNetworkFired && !retryUrlChanged;
|
|
8314
8589
|
const clickBlockedByInvalid = clickWasDomOnly &&
|
|
8315
8590
|
(await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).catch(() => 0)) >
|
|
@@ -8359,8 +8634,24 @@ async function executeStepWithHealing(params) {
|
|
|
8359
8634
|
// actually run below (requireSubmitEndpoint) to corroborate — otherwise
|
|
8360
8635
|
// only a strong signal (network/url) or a verified checkbox state may
|
|
8361
8636
|
// pass. Non-final/submit steps are unaffected.
|
|
8362
|
-
|
|
8363
|
-
|
|
8637
|
+
// Also excludes a checkbox/radio-intent step: `retryFormValueChanged`
|
|
8638
|
+
// is a page-wide signature that moves whenever ANY field changes (an
|
|
8639
|
+
// earlier fill included), so it says nothing about whether THIS
|
|
8640
|
+
// control committed — only the element's own `checkboxStateVerified`
|
|
8641
|
+
// (or a real network/url/selection-state signal) may verify it.
|
|
8642
|
+
// A click that resolved onto (or under) a selection-marker element
|
|
8643
|
+
// (role=option, aria-selected/checked/pressed, a component-kit
|
|
8644
|
+
// marker) must clear the strict retrySelectionStateChanged signal —
|
|
8645
|
+
// the weak html/text/form-value OR-branch alone can never credit
|
|
8646
|
+
// it, closing the reported textChanged=true/selectionStateChanged=
|
|
8647
|
+
// false/verified=true defect.
|
|
8648
|
+
const clickTargetIsSelectionMarker = xpath !== null &&
|
|
8649
|
+
(await clickTargetHasSelectionMarker(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), xpath));
|
|
8650
|
+
const weakDomSignalsAllowed = ((!isFinalStep && !submitStep) || requireSubmitEndpoint) &&
|
|
8651
|
+
!isCheckboxOrRadioIntentStep(step) &&
|
|
8652
|
+
!clickTargetIsSelectionMarker;
|
|
8653
|
+
let retryVerified = !clickBlockedByDisabled &&
|
|
8654
|
+
!clickBlockedByInvalid &&
|
|
8364
8655
|
!fallbackDomOnlyAdvance &&
|
|
8365
8656
|
(retryNetworkFired ||
|
|
8366
8657
|
retryUrlChanged ||
|