@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.
@@ -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: static string literal, fixed at compile time. No interpolation
712
- * means no injection surface. Runs in browser context and returns a typed-narrow
713
- * shape via Runtime.callFunctionOn.
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 = "button.selected,button.active,button.checked,button.Mui-selected,button.is-selected,a.selected,a.active,a.checked,[role=button].selected,[role=button].active,[role=button].checked,[role=option].selected,[role=tab].active,[tabindex].selected,[tabindex].active,[tabindex].checked,input.selected,input.active"; const classRx = /(?:^|\\s)(selected|active|checked|Mui-selected|is-selected)(?:\\s|$)/; 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 }; })()`;
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
- return await selectionAncestorChanged(target, xpath, preSelectionState);
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
- const clickExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); let el = r.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 }; } ${(0, browser_click_expr_1.clickActivationExpr)("el")} return { fired: true, kind: "click" }; })()`;
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
- return await selectionAncestorChanged(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), xpath, pre.selectionStateByXpath);
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
- const weakDomSignalsAllowed = (!isFinalStep && !submitStep) || requireSubmitEndpoint;
8363
- let retryVerified = !clickBlockedByInvalid &&
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 ||