@enricai/barnacle 1.12.24 → 1.12.26

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.
@@ -670,6 +670,30 @@ const INVALID_MARKER_EL_EXPR = `((el) => {
670
670
  if (el.getAttribute && el.getAttribute("aria-invalid") === "true") return true;
671
671
  return false;
672
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
+ })`;
673
697
  /**
674
698
  * How many steps from the end of the flow are considered "trailing" for the
675
699
  * Tier 1 grace path. A verification failure on an optional step within this
@@ -709,11 +733,17 @@ function prepareFailureDumpBody(raw) {
709
733
  return pruned.slice(0, FAILURE_DUMP_MAX_BODY_LENGTH);
710
734
  }
711
735
  /**
712
- * Trust boundary: static string literal, fixed at compile time. No interpolation
713
- * means no injection surface. Runs in browser context and returns a typed-narrow
714
- * 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.
715
745
  */
716
- 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 }; })()`;
717
747
  /**
718
748
  * Captures the pre/post signal triple the submit-verify cascade diffs.
719
749
  * Accepts the optional `page` so a resolved child `FrameTarget` whose
@@ -3022,6 +3052,37 @@ function selectionFingerprintObjSrc(elVar, dsVar) {
3022
3052
  function elementSelectionFingerprintExpr(xpath) {
3023
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")}; })()`;
3024
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
+ }`;
3025
3086
  /**
3026
3087
  * Browser-side expression: build `{ absolutePositionalXpath →
3027
3088
  * ElementSelectionFingerprint }` for every VISIBLE interactive element, the
@@ -3036,22 +3097,40 @@ function elementSelectionFingerprintExpr(xpath) {
3036
3097
  * rect + `getComputedStyle` idiom as `deep-locator-scan.ts`'s `IS_VISIBLE_EXPR`
3037
3098
  * (rather than `offsetParent`), so on-screen `position:fixed` controls — a
3038
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.
3039
3114
  */
3040
3115
  const SELECTION_STATE_MAP_EXPR = `(() => {
3041
3116
  const b = document.body;
3042
3117
  if (!b) return {};
3043
3118
  const xpathOf = ${XPATH_OF_FN_SRC};
3119
+ const nearbySelectionContainer = ${NEARBY_SELECTION_CONTAINER_FN_SRC};
3044
3120
  const visible = (el) => {
3045
3121
  const rect = el.getBoundingClientRect();
3046
3122
  if (rect.width === 0 && rect.height === 0) return false;
3047
3123
  const style = getComputedStyle(el);
3048
3124
  return style.display !== "none" && style.visibility !== "hidden";
3049
3125
  };
3050
- 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)};
3051
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;
3052
3131
  const out = {};
3053
3132
  for (const el of b.querySelectorAll(sel)) {
3054
- if (!visible(el) || skip(el)) continue;
3133
+ if ((!visible(el) && !isCommittedValueControl(el)) || skip(el)) continue;
3055
3134
  const ds = el.getAttribute("data-state") || "";
3056
3135
  out[xpathOf(el)] = ${selectionFingerprintObjSrc("el", "ds")};
3057
3136
  }
@@ -3129,30 +3208,6 @@ async function readElementSelectionFingerprint(target, selector) {
3129
3208
  return null;
3130
3209
  }
3131
3210
  }
3132
- /**
3133
- * How far up from the clicked leaf {@link selectionAncestorChanged} walks
3134
- * looking for the option/toggle that carries the selection. Design-system
3135
- * options nest their label 1-2 levels deep (a `<span title>` inside a
3136
- * `role="option"`, plus the odd icon/wrapper); 6 matches the vacuous-click
3137
- * ancestor guard in {@link verifyDomEffect}'s click branch and covers that
3138
- * nesting without over-reaching into an outer listbox/group.
3139
- */
3140
- const MAX_SELECTION_ANCESTOR_DEPTH = 6;
3141
- /**
3142
- * Cross-vendor selector union for a selection-state widget that carries NO
3143
- * standard selection `role` or `aria-*`/`data-state` marker — a component-kit
3144
- * container whose selected-ness lives only in the library's own private
3145
- * attribute. Same multi-vendor-union discipline as {@link INVALID_MARKER_CLASS_SOURCE}
3146
- * and the `PROMPT_*_SELECTORS` unions: standards are checked FIRST (see
3147
- * `hasMarker` in {@link selectionAncestorChanged}); this union is the fallback
3148
- * for widgets that under-annotate ARIA, and no member is a per-site branch —
3149
- * each is one component library's signature. Grows by a one-line edit.
3150
- *
3151
- * Members: `data-baseweb` (Uber Base Web — verified in a real capture to mark
3152
- * 150 selection elements that expose no role/aria-state, so dropping it loses
3153
- * real coverage). Add other under-annotating kits here as they surface.
3154
- */
3155
- const WIDGET_KIT_SELECTION_MARKER_SELECTORS = ["[data-baseweb]"].join(",");
3156
3211
  /**
3157
3212
  * Element-scoped selection read-back for the case the clicked node's OWN
3158
3213
  * fingerprint can't credit: a design-system option that wraps its label in a
@@ -3185,13 +3240,26 @@ async function selectionAncestorChanged(target, leafXpath, preSelectionState) {
3185
3240
  const xpathOf = ${XPATH_OF_FN_SRC};
3186
3241
  const fp = (el) => { const ds = el.getAttribute("data-state") || ""; return ${selectionFingerprintObjSrc("el", "ds")}; };
3187
3242
  const SELECTION_ROLES = new Set(["option", "tab", "switch", "radio", "checkbox", "menuitemcheckbox"]);
3188
- 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};
3189
3245
  const hasMarker = (el, f) => {
3190
3246
  // Standards first: fingerprint fields / aria-states, then a selection role.
3191
3247
  if (f.kind || f.ariaPressed || f.ariaChecked || f.ariaSelected || f.dataState || f.dataSelected || f.dataChecked || f.checked) return true;
3192
3248
  if (SELECTION_ROLES.has((el.getAttribute("role") || "").toLowerCase())) return true;
3193
3249
  // Fallback: a component-kit selection widget that exposes no standard marker.
3194
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
+ }
3195
3263
  return false;
3196
3264
  };
3197
3265
  const changed = (a, b) =>
@@ -3202,7 +3270,7 @@ async function selectionAncestorChanged(target, leafXpath, preSelectionState) {
3202
3270
  const r = document.evaluate(LEAF, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3203
3271
  let node = r.singleNodeValue;
3204
3272
  if (!node) return false;
3205
- 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++) {
3206
3274
  if (node.getAttribute) {
3207
3275
  const now = fp(node);
3208
3276
  if (hasMarker(node, now)) {
@@ -3222,6 +3290,100 @@ async function selectionAncestorChanged(target, leafXpath, preSelectionState) {
3222
3290
  return false;
3223
3291
  }
3224
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
+ }
3225
3387
  /** How long the upload primitive waits for a post-setInputFiles network POST. */
3226
3388
  const UPLOAD_NETWORK_TIMEOUT_MS = 5_000;
3227
3389
  /** Polling interval while waiting for the upload's network signal. */
@@ -5960,6 +6122,19 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
5960
6122
  const xpath = xpathBody(selector);
5961
6123
  if (!xpath)
5962
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;
5963
6138
  let inputType = null;
5964
6139
  try {
5965
6140
  // Trust boundary: xpath comes from Stagehand's own resolved selector
@@ -5996,7 +6171,14 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
5996
6171
  // selection on the ancestor `role="option"`, not the clicked leaf —
5997
6172
  // so walk up to the nearest baseline-present selection ancestor and
5998
6173
  // diff THAT. No eligible ancestor → false (defer to network/URL).
5999
- 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);
6000
6182
  }
6001
6183
  const isCheckedNow = await locator.isChecked();
6002
6184
  if (!isCheckedNow)
@@ -8291,7 +8473,7 @@ async function executeStepWithHealing(params) {
8291
8473
  // even though the leaf element is still live; re-anchor on the
8292
8474
  // leaf's own last two steps before giving up.
8293
8475
  const xpathTail = xpathTailForRetarget(xpath);
8294
- 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 }; } ${(0, browser_click_expr_1.clickActivationExpr)("el")} return { fired: true, kind: "click" }; })()`;
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" }; })()`;
8295
8477
  const n16FallbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
8296
8478
  const probeResult = (await n16FallbackTarget.evaluate(clickExpr));
8297
8479
  const fired = probeResult.fired;
@@ -8362,7 +8544,14 @@ async function executeStepWithHealing(params) {
8362
8544
  // to the nearest baseline-present selection ancestor and diff THAT,
8363
8545
  // the same fallback `verifyDomEffect`'s primary read-back uses. No
8364
8546
  // eligible ancestor → false (defer to the other retry signals).
8365
- 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);
8366
8555
  })());
8367
8556
  // RC2: an advance/`kind=click` "Next" that only grew the DOM
8368
8557
  // (validation errors rendered) with NO network/URL change is a
@@ -8375,6 +8564,27 @@ async function executeStepWithHealing(params) {
8375
8564
  // verifying so the cascade routes to the fill-invalid-fields replan.
8376
8565
  // Confirmed no-op signature on the wizard ATS's COMPENSATION page (network=false
8377
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
+ })());
8378
8588
  const clickWasDomOnly = probeResult.kind === "click" && !retryNetworkFired && !retryUrlChanged;
8379
8589
  const clickBlockedByInvalid = clickWasDomOnly &&
8380
8590
  (await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).catch(() => 0)) >
@@ -8429,9 +8639,19 @@ async function executeStepWithHealing(params) {
8429
8639
  // earlier fill included), so it says nothing about whether THIS
8430
8640
  // control committed — only the element's own `checkboxStateVerified`
8431
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));
8432
8650
  const weakDomSignalsAllowed = ((!isFinalStep && !submitStep) || requireSubmitEndpoint) &&
8433
- !isCheckboxOrRadioIntentStep(step);
8434
- let retryVerified = !clickBlockedByInvalid &&
8651
+ !isCheckboxOrRadioIntentStep(step) &&
8652
+ !clickTargetIsSelectionMarker;
8653
+ let retryVerified = !clickBlockedByDisabled &&
8654
+ !clickBlockedByInvalid &&
8435
8655
  !fallbackDomOnlyAdvance &&
8436
8656
  (retryNetworkFired ||
8437
8657
  retryUrlChanged ||