@enricai/barnacle 1.12.38 → 1.12.40

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.
Files changed (44) hide show
  1. package/dist/api/schemas/submissions.d.ts +10 -0
  2. package/dist/api/schemas/submissions.d.ts.map +1 -1
  3. package/dist/config.d.ts +14 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +3 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/lib/telemetry/reconciliation-record.d.ts +28 -0
  8. package/dist/lib/telemetry/reconciliation-record.d.ts.map +1 -1
  9. package/dist/lib/telemetry/reconciliation-record.js +19 -1
  10. package/dist/lib/telemetry/reconciliation-record.js.map +1 -1
  11. package/dist/lib/telemetry/run-telemetry.d.ts +15 -0
  12. package/dist/lib/telemetry/run-telemetry.d.ts.map +1 -1
  13. package/dist/lib/telemetry/run-telemetry.js +10 -0
  14. package/dist/lib/telemetry/run-telemetry.js.map +1 -1
  15. package/dist/lib/telemetry/submission-capture.d.ts +5 -0
  16. package/dist/lib/telemetry/submission-capture.d.ts.map +1 -1
  17. package/dist/plugins/loader.d.ts.map +1 -1
  18. package/dist/plugins/loader.js +35 -0
  19. package/dist/plugins/loader.js.map +1 -1
  20. package/dist/scraper/errors.d.ts +39 -6
  21. package/dist/scraper/errors.d.ts.map +1 -1
  22. package/dist/scraper/errors.js +51 -7
  23. package/dist/scraper/errors.js.map +1 -1
  24. package/dist/scraper/flow-runner.d.ts +99 -0
  25. package/dist/scraper/flow-runner.d.ts.map +1 -1
  26. package/dist/scraper/flow-runner.js +290 -15
  27. package/dist/scraper/flow-runner.js.map +1 -1
  28. package/dist/scraper/http-client.d.ts.map +1 -1
  29. package/dist/scraper/http-client.js +40 -3
  30. package/dist/scraper/http-client.js.map +1 -1
  31. package/dist/scraper/session-create-limiter.d.ts +8 -0
  32. package/dist/scraper/session-create-limiter.d.ts.map +1 -0
  33. package/dist/scraper/session-create-limiter.js +80 -0
  34. package/dist/scraper/session-create-limiter.js.map +1 -0
  35. package/dist/scraper/session.d.ts.map +1 -1
  36. package/dist/scraper/session.js +4 -3
  37. package/dist/scraper/session.js.map +1 -1
  38. package/dist/scripts/recon-generate.d.ts +21 -2
  39. package/dist/scripts/recon-generate.d.ts.map +1 -1
  40. package/dist/scripts/recon-generate.js +51 -3
  41. package/dist/scripts/recon-generate.js.map +1 -1
  42. package/dist/site-plugin.d.ts +10 -0
  43. package/dist/site-plugin.d.ts.map +1 -1
  44. package/package.json +1 -1
@@ -14,7 +14,7 @@
14
14
  * the terminal failure dump.
15
15
  */
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.TRAILING_GRACE_WINDOW = exports.STEP_WATCHDOG_MS = exports.GOTO_TIMEOUT_MS = exports.waitForSpaReady = void 0;
17
+ exports.TRAILING_GRACE_WINDOW = exports.STEP_WATCHDOG_MS = exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR = exports.GOTO_TIMEOUT_MS = exports.waitForSpaReady = void 0;
18
18
  exports.mergeResponseHeaders = mergeResponseHeaders;
19
19
  exports.wireSignalCapture = wireSignalCapture;
20
20
  exports.prepareFailureDumpBody = prepareFailureDumpBody;
@@ -75,7 +75,9 @@ exports.pollEnumerate = pollEnumerate;
75
75
  exports.waitForTransitionBody = waitForTransitionBody;
76
76
  exports.injectCaptchaTokenAndSubmit = injectCaptchaTokenAndSubmit;
77
77
  exports.submitCaptchaGatedForm = submitCaptchaGatedForm;
78
+ exports.trySelectPrimitive = trySelectPrimitive;
78
79
  exports.chooseRequiredSelectOption = chooseRequiredSelectOption;
80
+ exports.tryFillRequiredSelectsPrimitive = tryFillRequiredSelectsPrimitive;
79
81
  exports.buildRadioIdXPath = buildRadioIdXPath;
80
82
  exports.selectRadioGroupOption = selectRadioGroupOption;
81
83
  exports.hasUnfilledRequiredControlForStep = hasUnfilledRequiredControlForStep;
@@ -471,6 +473,116 @@ const PROMPT_OPTION_SELECTORS = [
471
473
  "[data-automation-id='promptOption']",
472
474
  "[data-uxi-widget-type='selectinputlistitem']",
473
475
  ].join(",");
476
+ /**
477
+ * In-page predicate source for `(el) => boolean`: true when `el` is a
478
+ * `<select>` that is a design-system combobox opener's paired-but-hidden
479
+ * shadow control (Base Web `bb-customSelect` and any other vendor matching
480
+ * {@link PROMPT_TRIGGER_SELECTORS}). Two independent signals both have to
481
+ * hold, and each is deliberately technique-agnostic rather than an exact
482
+ * enumeration: (1) hiddenness — `offsetParent === null`, a zero-size rect,
483
+ * `display:none`/`visibility:hidden`/`opacity:0`, off-screen positioning, OR
484
+ * any of the layout-fingerprint checks that generalize across the remaining
485
+ * CSS hiding techniques (`hasTinyFootprint` for the sr-only clip idiom and
486
+ * `transform:scale(0)`'s collapsed rect, a parsed `clip:` rect of zero area,
487
+ * an active `clip-path`, a `scale(0)`/zero `matrix()` transform, or an
488
+ * ancestor whose own box is collapsed to near-zero while clipping overflow)
489
+ * — and (2) opener-pairing — described below — which is what makes the
490
+ * hiddenness check safe to broaden: a false-positive on hiddenness alone
491
+ * (e.g. some other reason a real, standalone select reports a tiny rect)
492
+ * still can't misfire unless that same select also sits paired with a
493
+ * rendered combobox opener, which an ordinary select never does. Signal (2)
494
+ * requires the select's nearby container to also hold a
495
+ * {@link PROMPT_TRIGGER_SELECTORS} opener element, other than itself, that is
496
+ * ARIA-wired to a listbox panel — via `aria-owns`/`aria-controls`/
497
+ * `aria-haspopup="listbox"` declared on the opener (accepted unresolved only
498
+ * when the opener sits in the select's own immediate container, since many
499
+ * widgets mount their popup lazily on first open and haven't rendered it
500
+ * yet), or, once the referenced id does resolve (at any climbed depth), only
501
+ * when the target actually is `role=listbox` or contains a
502
+ * {@link PROMPT_OPTION_SELECTORS} match, or, for an opener declaring no
503
+ * owns/controls/haspopup at all, a {@link PROMPT_OPTION_SELECTORS} match
504
+ * rendered in THAT OPENER's own immediate container (never the whole climbed
505
+ * ancestor — scanning the ancestor would credit an unrelated already-open
506
+ * widget elsewhere in a shared ancestor as this select's pairing). Requiring
507
+ * resolution past the immediate container keeps the broadened hidden test
508
+ * from misclassifying an unrelated widget elsewhere in a shared ancestor as
509
+ * a paired shadow select.
510
+ * The bounded-ancestor-climb-then-subtree-search shape mirrors
511
+ * {@link NEARBY_SELECTION_CONTAINER_FN_SRC}, but searches for
512
+ * {@link PROMPT_TRIGGER_SELECTORS} instead of the selection-marker union —
513
+ * this ties "opener" to the exact same union `tryPromptSelectorPrimitive`
514
+ * recognizes, so a select is excluded precisely when the primitive that
515
+ * should own it would actually claim it. Wired into every `<select>` list
516
+ * `trySelectPrimitive` and `applySelectValue` build, so those primitives fall
517
+ * through and let `tryPromptSelectorPrimitive` claim the opener instead — and
518
+ * consumed by {@link commitPromptOption}'s hidden-select-value corroboration
519
+ * when the opener's own readback is inconclusive.
520
+ */
521
+ exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR = `(el) => {
522
+ if (!el || el.tagName !== "SELECT") return false;
523
+ const rect = el.getBoundingClientRect();
524
+ const style = getComputedStyle(el);
525
+ const isOffscreen = rect.right <= 0 || rect.bottom <= 0 || rect.left >= window.innerWidth || rect.top >= window.innerHeight;
526
+ // A near-zero footprint (<=1px either dimension) is the layout-level fingerprint
527
+ // shared by the classic sr-only clip technique (explicit width:1px;height:1px) and
528
+ // a real browser's transform:scale(0) (which collapses the bounding rect itself) —
529
+ // technique-agnostic, unlike enumerating every CSS property that can shrink a box.
530
+ const hasTinyFootprint = rect.width <= 1 || rect.height <= 1;
531
+ const clipMatch = /rect\\(\\s*([\\d.]+)px[,\\s]+([\\d.]+)px[,\\s]+([\\d.]+)px[,\\s]+([\\d.]+)px\\s*\\)/.exec(style.clip || "");
532
+ const isClipZeroArea = !!clipMatch && (parseFloat(clipMatch[3]) - parseFloat(clipMatch[1]) <= 0 || parseFloat(clipMatch[2]) - parseFloat(clipMatch[4]) <= 0);
533
+ // Only treat clip-path as a hiding signal when it collapses the element to
534
+ // zero visible area (circle(0), inset(50%), a degenerate polygon whose
535
+ // points share a single coordinate) — a decorative clip-path (rounded
536
+ // corners, an avatar circle) leaves the element fully visible and must
537
+ // not be misread as hidden.
538
+ const cp = style.clipPath || "";
539
+ const hasClipPath = /circle\\(\\s*0(?:px|%)?\\s*(?:at\\s[^)]*)?\\)/i.test(cp) || /ellipse\\(\\s*0(?:px|%)?\\s+0(?:px|%)?\\s*(?:at\\s[^)]*)?\\)/i.test(cp) || /inset\\(\\s*50%\\s*\\)/i.test(cp) || /polygon\\(\\s*(?:0%?\\s+0%?\\s*,?\\s*)+\\)/i.test(cp);
540
+ const hasZeroScale = /scale\\(\\s*0(?:\\.0+)?\\s*(?:,\\s*0(?:\\.0+)?\\s*)?\\)/.test(style.transform || "") || /matrix\\(\\s*0\\s*,\\s*0\\s*,\\s*0\\s*,\\s*0\\s*,/.test(style.transform || "");
541
+ // Ancestor overflow-clipping: a wrapper with overflow:hidden collapsed to a
542
+ // near-zero box clips its whole subtree to invisibility even though the
543
+ // select's own box, style and offsetParent all look ordinary.
544
+ const isAncestorClipped = (() => {
545
+ let anc = el.parentElement;
546
+ for (let d = 0; d < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && anc; d++) {
547
+ const ancStyle = getComputedStyle(anc);
548
+ const overflowHides = ancStyle.overflow === "hidden" || ancStyle.overflow === "clip" || ancStyle.overflowX === "hidden" || ancStyle.overflowY === "hidden";
549
+ if (overflowHides) {
550
+ const ancRect = anc.getBoundingClientRect();
551
+ if (ancRect.width <= 1 || ancRect.height <= 1) return true;
552
+ }
553
+ anc = anc.parentElement;
554
+ }
555
+ return false;
556
+ })();
557
+ const isHidden = el.offsetParent === null || (rect.width === 0 && rect.height === 0) || style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity) === 0 || isOffscreen || hasTinyFootprint || isClipZeroArea || hasClipPath || hasZeroScale || isAncestorClipped;
558
+ if (!isHidden) return false;
559
+ const triggerSel = ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)};
560
+ const optionSel = ${JSON.stringify(PROMPT_OPTION_SELECTORS)};
561
+ const hasRenderedPanel = (opener, allowUnresolvedDeclaration) => {
562
+ const ownsId = opener.getAttribute("aria-owns") || opener.getAttribute("aria-controls");
563
+ const target = ownsId ? document.getElementById(ownsId) : null;
564
+ if (target) return target.getAttribute("role") === "listbox" || target.querySelector(optionSel) !== null;
565
+ if (!allowUnresolvedDeclaration) return false;
566
+ if (!!ownsId || opener.getAttribute("aria-haspopup") === "listbox") return true;
567
+ return !!(opener.parentElement && opener.parentElement.querySelector(optionSel));
568
+ };
569
+ let node = el.parentElement;
570
+ for (let depth = 0; depth < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && node; depth++) {
571
+ const openers = node.querySelectorAll ? Array.from(node.querySelectorAll(triggerSel)) : [];
572
+ // Only the select's own immediate container may pair on a merely-declared
573
+ // (not-yet-rendered) aria-owns/aria-controls/aria-haspopup target — an
574
+ // opener found only by climbing further up must have its panel actually
575
+ // resolve, or an unrelated sibling widget elsewhere in a shared ancestor
576
+ // (e.g. a distant visible field's own opener) would false-positive. An
577
+ // opener with no ARIA pairing declaration at all is credited only when
578
+ // an option-shaped element sits in ITS OWN immediate container — never
579
+ // by scanning the whole climbed ancestor, which would credit an
580
+ // unrelated already-open widget elsewhere in a shared ancestor.
581
+ if (openers.some((opener) => opener !== el && hasRenderedPanel(opener, depth === 0))) return true;
582
+ node = node.parentElement;
583
+ }
584
+ return false;
585
+ }`;
474
586
  /**
475
587
  * Cross-vendor selector union for an in-popup filter/typeahead input — standards
476
588
  * first, then the widget-kit's bare filter `<input>` (which carries no
@@ -3060,7 +3172,7 @@ const XPATH_OF_FN_SRC = `(node) => {
3060
3172
  * popover isn't mistaken for a selection.
3061
3173
  */
3062
3174
  function selectionFingerprintObjSrc(elVar, dsVar) {
3063
- return `{ kind: ${elVar}.getAttribute("kind") || "", cls: ${elVar}.getAttribute("class") || "", ariaPressed: ${elVar}.getAttribute("aria-pressed") || "", ariaChecked: ${elVar}.getAttribute("aria-checked") || "", ariaSelected: ${elVar}.getAttribute("aria-selected") || "", dataState: (${dsVar} === "open" || ${dsVar} === "closed") ? "" : ${dsVar}, dataSelected: ${elVar}.hasAttribute("data-selected") ? "1" : "", dataChecked: ${elVar}.hasAttribute("data-checked") ? "1" : "", checked: (${elVar}.type === "checkbox" || ${elVar}.type === "radio") ? (${elVar}.checked ? "1" : "0") : "", value: typeof ${elVar}.value === "string" ? ${elVar}.value.slice(0, 200) : "" }`;
3175
+ return `{ kind: ${elVar}.getAttribute("kind") || "", cls: ${elVar}.getAttribute("class") || "", ariaPressed: ${elVar}.getAttribute("aria-pressed") || "", ariaChecked: ${elVar}.getAttribute("aria-checked") || "", ariaSelected: ${elVar}.getAttribute("aria-selected") || "", dataState: (${dsVar} === "open" || ${dsVar} === "closed") ? "" : ${dsVar}, dataSelected: ${elVar}.hasAttribute("data-selected") ? "1" : "", dataChecked: ${elVar}.hasAttribute("data-checked") ? "1" : "", checked: (${elVar}.type === "checkbox" || ${elVar}.type === "radio") ? (${elVar}.checked ? "1" : "0") : "", value: typeof ${elVar}.value === "string" ? ${elVar}.value.slice(0, 200) : "", ariaExpanded: ${elVar}.getAttribute("aria-expanded") || "" }`;
3064
3176
  }
3065
3177
  /**
3066
3178
  * Browser-side expression body that resolves `xpath` and returns that one
@@ -3145,7 +3257,7 @@ const SELECTION_STATE_MAP_EXPR = `(() => {
3145
3257
  const style = getComputedStyle(el);
3146
3258
  return style.display !== "none" && style.visibility !== "hidden";
3147
3259
  };
3148
- 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)};
3260
+ const sel = "button,[role=button],a,li,[tabindex],input,select,textarea,[role=option],[role=tab],[role=switch],[role=checkbox],[role=menuitemcheckbox],[role=combobox]," + ${JSON.stringify(browser_click_expr_1.SELECTION_MARKER_CLASS_SELECTOR_SRC)};
3149
3261
  const skip = (el) => el.closest("[role=dialog],[role=tooltip],[aria-live]") !== null;
3150
3262
  const isCommittedValueControl = (el) =>
3151
3263
  (el.tagName === "INPUT" || el.tagName === "SELECT") &&
@@ -3210,6 +3322,7 @@ function asSelectionFingerprint(raw) {
3210
3322
  dataChecked: typeof r.dataChecked === "string" ? r.dataChecked : "",
3211
3323
  checked: typeof r.checked === "string" ? r.checked : "",
3212
3324
  value: typeof r.value === "string" ? r.value : "",
3325
+ ariaExpanded: typeof r.ariaExpanded === "string" ? r.ariaExpanded : "",
3213
3326
  };
3214
3327
  }
3215
3328
  /**
@@ -3406,6 +3519,76 @@ async function selectionSiblingCommittedValueChanged(target, leafXpath, preSelec
3406
3519
  return false;
3407
3520
  }
3408
3521
  }
3522
+ /**
3523
+ * Dedicated pre/post check crediting exactly one case none of the fingerprint
3524
+ * diffs above can see: a raw click resolved onto (or under) a
3525
+ * {@link PROMPT_TRIGGER_SELECTORS}-shaped combobox opener that GENUINELY opened
3526
+ * its owned option panel. Opening a widget only flips `aria-expanded`, which
3527
+ * {@link selectionFingerprintObjSrc}'s shared fingerprint deliberately blanks out
3528
+ * of `dataState` and which {@link selectionFingerprintChanged} /
3529
+ * {@link selectionAncestorChanged}'s `changed()` never compare — by design, so a
3530
+ * bare accordion/tooltip disclosure is never mistaken for a selection. That
3531
+ * leaves a replanner-generated two-step "click to open the dropdown"
3532
+ * instruction with no signal at all: the fingerprint tiers above never move,
3533
+ * so the open-click is scored phantom even though it genuinely exposed the
3534
+ * option panel.
3535
+ *
3536
+ * This function is independent of those tiers rather than folded into their
3537
+ * `changed()`: it reads the new {@link ElementSelectionFingerprint.ariaExpanded}
3538
+ * field, but ONLY on a node that itself matches {@link PROMPT_TRIGGER_SELECTORS},
3539
+ * and ONLY credits it when the opener's `aria-owns`/`aria-controls` scope (the
3540
+ * SAME {@link PROMPT_SCOPE_ROOT_EXPR} resolution `tryPromptSelectorPrimitive`
3541
+ * uses) now actually contains {@link PROMPT_OPTION_SELECTORS} content — never a
3542
+ * bare `aria-expanded` flip with nothing rendered underneath. That second
3543
+ * condition is what keeps a bare accordion/tooltip trigger (which flips
3544
+ * `aria-expanded` but owns no option panel) excluded, preserving the existing
3545
+ * deliberate exclusion.
3546
+ *
3547
+ * Walks from the leaf up to {@link MAX_SELECTION_ANCESTOR_DEPTH} for the NEAREST
3548
+ * trigger match (mirrors {@link selectionAncestorChanged}'s climb, since
3549
+ * Stagehand may resolve the click onto a label/icon child of the opener rather
3550
+ * than the opener itself). No baseline entry for that trigger, no trigger
3551
+ * match, a trigger that was already expanded pre-click, or a trigger that's
3552
+ * still collapsed post-click → `false` (defer to the network/URL signal).
3553
+ * Returns `false` on any evaluate throw.
3554
+ */
3555
+ async function comboboxOpenerPanelOpened(target, leafXpath, preSelectionState) {
3556
+ const expr = `(() => {
3557
+ const LEAF = ${JSON.stringify(leafXpath)};
3558
+ const BASE = ${JSON.stringify(preSelectionState)};
3559
+ const TRIGGER_SEL = ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)};
3560
+ const OPTION_SEL = ${JSON.stringify(PROMPT_OPTION_SELECTORS)};
3561
+ const xpathOf = ${XPATH_OF_FN_SRC};
3562
+ const scopeRootOf = ${PROMPT_SCOPE_ROOT_EXPR};
3563
+ const r = document.evaluate(LEAF, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3564
+ let node = r.singleNodeValue;
3565
+ for (let depth = 0; depth < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && node; depth++) {
3566
+ if (node.getAttribute && node.matches && node.matches(TRIGGER_SEL)) {
3567
+ const pre = BASE[xpathOf(node)];
3568
+ if (!pre) return false;
3569
+ const wasCollapsed = pre.ariaExpanded !== "true";
3570
+ const nowExpanded = (node.getAttribute("aria-expanded") || "") === "true";
3571
+ if (!wasCollapsed || !nowExpanded) return false;
3572
+ // scopeRootOf falls back to \`document\` when the trigger has no
3573
+ // aria-controls/aria-owns ref AND no inline popup in its own subtree.
3574
+ // That fallback is a page-wide search, not the opener's OWNED scope —
3575
+ // crediting through it would let an unrelated widget's already-open
3576
+ // options (elsewhere on the page) falsely credit THIS trigger's click.
3577
+ const scope = scopeRootOf(node);
3578
+ if (!scope || scope === document || !scope.querySelector) return false;
3579
+ return !!scope.querySelector(OPTION_SEL);
3580
+ }
3581
+ node = node.parentElement;
3582
+ }
3583
+ return false;
3584
+ })()`;
3585
+ try {
3586
+ return (await target.evaluate(expr)) === true;
3587
+ }
3588
+ catch {
3589
+ return false;
3590
+ }
3591
+ }
3409
3592
  /** How long the upload primitive waits for a post-setInputFiles network POST. */
3410
3593
  const UPLOAD_NETWORK_TIMEOUT_MS = 5_000;
3411
3594
  /** Polling interval while waiting for the upload's network signal. */
@@ -4556,7 +4739,8 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
4556
4739
  */
4557
4740
  async function applySelectValue(page, target, selIdx, value) {
4558
4741
  const setExpr = `((selIdx, value) => {
4559
- const sel = Array.from(document.querySelectorAll("select"))[selIdx];
4742
+ const isOpenerPairedHidden = ${exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR};
4743
+ const sel = Array.from(document.querySelectorAll("select")).filter((s) => !isOpenerPairedHidden(s))[selIdx];
4560
4744
  if (!sel) return { ok: false };
4561
4745
  const desc = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value");
4562
4746
  if (desc && desc.set) { desc.set.call(sel, value); } else { sel.value = value; }
@@ -4571,7 +4755,8 @@ async function applySelectValue(page, target, selIdx, value) {
4571
4755
  await page.waitForTimeout(SELECT_SETTLE_MS);
4572
4756
  const invalidExpr = `((selIdx) => {
4573
4757
  const isInvalid = ${INVALID_MARKER_EL_EXPR};
4574
- const sel = Array.from(document.querySelectorAll("select"))[selIdx];
4758
+ const isOpenerPairedHidden = ${exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR};
4759
+ const sel = Array.from(document.querySelectorAll("select")).filter((s) => !isOpenerPairedHidden(s))[selIdx];
4575
4760
  if (!sel) return false;
4576
4761
  let node = sel;
4577
4762
  for (let depth = 0; depth < 6 && node; depth++) {
@@ -4609,8 +4794,12 @@ async function trySelectPrimitive(params) {
4609
4794
  const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
4610
4795
  const wantOpt = norm(option);
4611
4796
  // All native selects, INCLUDING tabindex=-1 (MuiNativeSelect) which the
4612
- // a11y tree — and therefore Stagehand observe — never surfaces.
4613
- const selects = Array.from(document.querySelectorAll("select"));
4797
+ // a11y tree — and therefore Stagehand observe — never surfaces. Excludes
4798
+ // a design-system combobox opener's paired-but-hidden shadow <select>
4799
+ // (Base Web etc.) so tryPromptSelectorPrimitive gets first refusal on it
4800
+ // instead of this primitive silently writing a value the opener never sees.
4801
+ const isOpenerPairedHidden = ${exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR};
4802
+ const selects = Array.from(document.querySelectorAll("select")).filter((s) => !isOpenerPairedHidden(s));
4614
4803
  if (selects.length === 0) return { selectPresent: false };
4615
4804
  // FAST PATH — deterministic option match: exactly one select has an option
4616
4805
  // matching the flow's answer text. Unambiguous (the option text itself
@@ -4800,7 +4989,8 @@ async function tryFillRequiredSelectsPrimitive(params) {
4800
4989
  // (MUI marks NativeSelect via any of these). Reuses the isUnfilled / selLabelText
4801
4990
  // shape from trySelectPrimitive.
4802
4991
  const enumerateExpr = `(() => {
4803
- const selects = Array.from(document.querySelectorAll("select"));
4992
+ const isOpenerPairedHidden = ${exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR};
4993
+ const selects = Array.from(document.querySelectorAll("select")).filter((s) => !isOpenerPairedHidden(s));
4804
4994
  if (selects.length === 0) return { candidates: [] };
4805
4995
  const selLabelText = (sel) => {
4806
4996
  const parts = [];
@@ -5936,6 +6126,7 @@ async function commitPromptOption(params) {
5936
6126
  const isRequestedLeaf = norm(chosenOption.text) === wantOpt;
5937
6127
  const readbackExpr = `((wIdx, markAttr, valueSel, emptyRxSrc, emptyRxFlags, wantText, isRequestedLeaf) => {
5938
6128
  const isInvalid = ${INVALID_MARKER_EL_EXPR};
6129
+ const isPairedHiddenSelect = ${exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR};
5939
6130
  const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
5940
6131
  const buttonValue = ${BUTTON_VALUE_EXPR};
5941
6132
  const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
@@ -5958,10 +6149,55 @@ async function commitPromptOption(params) {
5958
6149
  if (node.getAttribute && isInvalid(node)) { stillInvalid = true; break; }
5959
6150
  node = node.parentElement;
5960
6151
  }
6152
+ // Corroborating signal when the opener's own text/activedescendant read is
6153
+ // inconclusive: the paired native <select> Base Web (and similar vendors)
6154
+ // keeps in sync BEHIND the real widget now holds a non-empty CURRENT value
6155
+ // (read-only — never written here) that, when its selected option's own
6156
+ // text is available, also matches the requested option.
6157
+ let hiddenSelectMatches = false;
6158
+ if (!textMatches) {
6159
+ let cnode = w;
6160
+ for (let d = 0; d < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && cnode && !hiddenSelectMatches; d++) {
6161
+ const selects = cnode.querySelectorAll ? Array.from(cnode.querySelectorAll("select")) : [];
6162
+ for (const sel of selects) {
6163
+ if (!isPairedHiddenSelect(sel)) continue;
6164
+ // isPairedHiddenSelect only proves SOME opener sits near this select —
6165
+ // in a form with multiple prompt-selector fields sharing an outer
6166
+ // ancestor within MAX_SELECTION_ANCESTOR_DEPTH, that opener can be a
6167
+ // SIBLING widget's, not w's. Mirror OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR's
6168
+ // own climb-then-scan-openers shape, but require w itself to be among
6169
+ // the openers found at the NEAREST level that has any — the same level
6170
+ // isPairedHiddenSelect stopped at — so a neighboring field's
6171
+ // already-committed hidden select never corroborates this widget's commit.
6172
+ let snode = sel.parentElement;
6173
+ let pairedWithW = false;
6174
+ for (let sd = 0; sd < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && snode && !pairedWithW; sd++) {
6175
+ const openersHere = snode.querySelectorAll
6176
+ ? Array.from(snode.querySelectorAll(${JSON.stringify(PROMPT_TRIGGER_SELECTORS)})).filter((o) => o !== sel)
6177
+ : [];
6178
+ if (openersHere.length > 0) {
6179
+ pairedWithW = openersHere.includes(w);
6180
+ break;
6181
+ }
6182
+ snode = snode.parentElement;
6183
+ }
6184
+ if (!pairedWithW) continue;
6185
+ const v = (sel.value || "").trim();
6186
+ if (!v) continue;
6187
+ const selOpt = sel.options && sel.selectedIndex >= 0 ? sel.options[sel.selectedIndex] : null;
6188
+ const optText = selOpt ? norm(selOpt.textContent || selOpt.label || "") : "";
6189
+ // A selected option with no visible text (icon-only, value-only markup)
6190
+ // carries no corroborating signal — treating it as a match would credit
6191
+ // ANY selection change, including a different option than requested.
6192
+ if (wantText && optText && optText.includes(wantText)) { hiddenSelectMatches = true; break; }
6193
+ }
6194
+ cnode = cnode.parentElement;
6195
+ }
6196
+ }
5961
6197
  // A category click may leave the widget's aria-invalid unset until final
5962
6198
  // submit, so !stillInvalid alone only counts as success when this click
5963
6199
  // landed on the requested leaf option, not an intermediate category.
5964
- return { ok: textMatches || (isRequestedLeaf && !stillInvalid), id: w.id || "" };
6200
+ return { ok: textMatches || (isRequestedLeaf && !stillInvalid) || hiddenSelectMatches, id: w.id || "" };
5965
6201
  })(${JSON.stringify(chosen.wIdx)}, ${JSON.stringify(PROMPT_WIDGET_MARK_ATTR)}, ${JSON.stringify(PROMPT_VALUE_SELECTORS)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)}, ${JSON.stringify(norm(chosenOption.text))}, ${JSON.stringify(isRequestedLeaf)})`;
5966
6202
  const readback = (await target.evaluate(readbackExpr).catch(() => ({ ok: false, id: "" })));
5967
6203
  if (readback?.ok) {
@@ -6063,12 +6299,14 @@ async function hasUnfilledRequiredControlForStep(target, instruction) {
6063
6299
  // (button/aria-haspopup shapes with no native input/select at all) to
6064
6300
  // the container-widget union already covered.
6065
6301
  const controls = Array.from(document.querySelectorAll(
6066
- "input,select,textarea,[role=combobox],[role=listbox],.bb-custom-select-container,[class*='MultiCheckboxInput']," + triggerSel
6302
+ "input,select,textarea,[role=combobox],[role=listbox]," + triggerSel
6067
6303
  ));
6068
6304
  for (const el of controls) {
6069
6305
  if (!isRequired(el) && !(el.querySelector && el.querySelector("[required],[aria-required=true]"))) {
6070
6306
  // container widgets carry required on an inner element; allow those
6071
- if (!/bb-custom-select-container|MultiCheckboxInput/.test((el.getAttribute && el.getAttribute("class")) || "")) continue;
6307
+ const isTriggerWidget =
6308
+ (el.matches && el.matches(triggerSel)) || (el.querySelector && !!el.querySelector(triggerSel));
6309
+ if (!isTriggerWidget) continue;
6072
6310
  }
6073
6311
  // is it empty/invalid?
6074
6312
  const emptyOrInvalid = isEmptyish(el) || (el.querySelector && !!el.querySelector("[aria-invalid=true]"));
@@ -6329,7 +6567,15 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
6329
6567
  // ancestor, and — since some resolved xpaths land on a sibling label rather
6330
6568
  // than the combobox itself — the nearest combobox within the same
6331
6569
  // field-group container, for a nested input value, falling back to visible
6332
- // text or an aria label/valuetext.
6570
+ // text or an aria label/valuetext. `combo.textContent`/`el.textContent`
6571
+ // is NEVER read directly: for a composite opener+hidden-`<select>`
6572
+ // widget (role="combobox" on the shared container, resolved xpath
6573
+ // landing on the sibling hidden select) that concatenates EVERY
6574
+ // option's text, so it "matches" whatever the requested option is
6575
+ // regardless of what actually got selected — read only the
6576
+ // COMMITTED signal (aria-activedescendant's referenced node, the
6577
+ // select's own selectedIndex option, or the opener's displayed text
6578
+ // with any nested select/listbox option list stripped out first).
6333
6579
  const exprCustom = `(() => {
6334
6580
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
6335
6581
  const el = r.singleNodeValue;
@@ -6342,9 +6588,28 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
6342
6588
  combo = combo || el;
6343
6589
  const nestedInput = combo.querySelector && combo.querySelector('input');
6344
6590
  const value = (nestedInput && nestedInput.value) || el.value || "";
6345
- const text = (combo.textContent || el.textContent || "").trim();
6591
+ const adid = (combo.getAttribute && combo.getAttribute('aria-activedescendant')) ||
6592
+ (el.getAttribute && el.getAttribute('aria-activedescendant'));
6593
+ const adNode = adid && document.getElementById(adid);
6594
+ const adText = adNode ? (adNode.textContent || "").trim() : "";
6595
+ const selectEl = el.tagName === "SELECT" ? el : (combo.tagName === "SELECT" ? combo : null);
6596
+ const selectedOptText = selectEl && selectEl.selectedIndex >= 0 && selectEl.options[selectEl.selectedIndex]
6597
+ ? (selectEl.options[selectEl.selectedIndex].textContent || "").trim()
6598
+ : "";
6599
+ // If combo itself IS the <select> (role="combobox" placed directly
6600
+ // on the native select rather than a wrapping container),
6601
+ // querySelectorAll('select,...') below only prunes DESCENDANTS —
6602
+ // combo's own concatenated option text survives untouched. Skip
6603
+ // the whole-text fallback entirely for that shape; selectedOptText
6604
+ // above already reads its single committed option.
6605
+ const clone = combo.tagName !== "SELECT" && combo.cloneNode ? combo.cloneNode(true) : null;
6606
+ if (clone && clone.querySelectorAll) {
6607
+ clone.querySelectorAll('select,[role="listbox"]').forEach((n) => n.remove());
6608
+ }
6609
+ const ownText = clone ? (clone.textContent || "").trim() : "";
6346
6610
  const ariaText = (el.getAttribute && (el.getAttribute('aria-label') || el.getAttribute('aria-valuetext'))) || "";
6347
- return { value: String(value).trim(), text: String(text || ariaText).trim() };
6611
+ const text = adText || selectedOptText || ownText || ariaText;
6612
+ return { value: String(value).trim(), text: String(text || "").trim() };
6348
6613
  })()`;
6349
6614
  try {
6350
6615
  const customResult = await target.evaluate(exprCustom);
@@ -6438,7 +6703,17 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
6438
6703
  // may live on a hidden sibling `<input>`/`<select>` the click never
6439
6704
  // re-renders visibly. Diff that control against its own baseline
6440
6705
  // entry instead.
6441
- return await selectionSiblingCommittedValueChanged(target, xpath, preSelectionState);
6706
+ if (await selectionSiblingCommittedValueChanged(target, xpath, preSelectionState)) {
6707
+ return true;
6708
+ }
6709
+ // No selection fingerprint moved anywhere: the click may still have
6710
+ // genuinely opened a combobox's owned option panel (a two-step
6711
+ // "click to open the dropdown" instruction), which flips only
6712
+ // `aria-expanded` — deliberately excluded from every fingerprint
6713
+ // diff above so a bare accordion/tooltip disclosure is never
6714
+ // credited. This dedicated check additionally requires the opener's
6715
+ // owned panel to now render option content before crediting it.
6716
+ return await comboboxOpenerPanelOpened(target, xpath, preSelectionState);
6442
6717
  }
6443
6718
  const isCheckedNow = await locator.isChecked();
6444
6719
  if (!isCheckedNow)