@enricai/barnacle 1.12.38 → 1.12.39

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 +73 -0
  25. package/dist/scraper/flow-runner.d.ts.map +1 -1
  26. package/dist/scraper/flow-runner.js +210 -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 +16 -2
  39. package/dist/scripts/recon-generate.d.ts.map +1 -1
  40. package/dist/scripts/recon-generate.js +23 -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;
@@ -461,6 +463,36 @@ const PROMPT_TRIGGER_SELECTORS = [
461
463
  "[data-automation-id='multiSelectContainer']",
462
464
  "[data-automation-id='promptSelectionLabel']",
463
465
  ].join(",");
466
+ /**
467
+ * In-page predicate source for `(el) => boolean`: true when `el` is a
468
+ * `<select>` that is a design-system combobox opener's paired-but-hidden
469
+ * shadow control (Base Web `bb-customSelect` and any other vendor matching
470
+ * {@link PROMPT_TRIGGER_SELECTORS}) — a `<select>` with no layout box
471
+ * (`offsetParent === null`, the same idiom `DOM_SNAPSHOT_EXPR` uses for
472
+ * `display:none`-driven hiding classes like `dropdown-hide`) whose nearby
473
+ * container also holds a {@link PROMPT_TRIGGER_SELECTORS} opener element
474
+ * other than itself. The bounded-ancestor-climb-then-subtree-search shape
475
+ * mirrors {@link NEARBY_SELECTION_CONTAINER_FN_SRC}, but searches for
476
+ * {@link PROMPT_TRIGGER_SELECTORS} instead of the selection-marker union —
477
+ * this ties "opener" to the exact same union `tryPromptSelectorPrimitive`
478
+ * recognizes, so a select is excluded precisely when the primitive that
479
+ * should own it would actually claim it. Wired into every `<select>` list
480
+ * `trySelectPrimitive` and `applySelectValue` build, so those primitives fall
481
+ * through and let `tryPromptSelectorPrimitive` claim the opener instead — and
482
+ * consumed by {@link commitPromptOption}'s hidden-select-value corroboration
483
+ * when the opener's own readback is inconclusive.
484
+ */
485
+ exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR = `(el) => {
486
+ if (!el || el.tagName !== "SELECT" || el.offsetParent !== null) return false;
487
+ const triggerSel = ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)};
488
+ let node = el.parentElement;
489
+ for (let depth = 0; depth < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && node; depth++) {
490
+ const openers = node.querySelectorAll ? Array.from(node.querySelectorAll(triggerSel)) : [];
491
+ if (openers.some((opener) => opener !== el)) return true;
492
+ node = node.parentElement;
493
+ }
494
+ return false;
495
+ }`;
464
496
  /**
465
497
  * Cross-vendor selector union for an OPTION rendered inside the opened popup —
466
498
  * standards first (`role=option`), then the widget-kit option markers. Sibling
@@ -3060,7 +3092,7 @@ const XPATH_OF_FN_SRC = `(node) => {
3060
3092
  * popover isn't mistaken for a selection.
3061
3093
  */
3062
3094
  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) : "" }`;
3095
+ 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
3096
  }
3065
3097
  /**
3066
3098
  * Browser-side expression body that resolves `xpath` and returns that one
@@ -3145,7 +3177,7 @@ const SELECTION_STATE_MAP_EXPR = `(() => {
3145
3177
  const style = getComputedStyle(el);
3146
3178
  return style.display !== "none" && style.visibility !== "hidden";
3147
3179
  };
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)};
3180
+ 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
3181
  const skip = (el) => el.closest("[role=dialog],[role=tooltip],[aria-live]") !== null;
3150
3182
  const isCommittedValueControl = (el) =>
3151
3183
  (el.tagName === "INPUT" || el.tagName === "SELECT") &&
@@ -3210,6 +3242,7 @@ function asSelectionFingerprint(raw) {
3210
3242
  dataChecked: typeof r.dataChecked === "string" ? r.dataChecked : "",
3211
3243
  checked: typeof r.checked === "string" ? r.checked : "",
3212
3244
  value: typeof r.value === "string" ? r.value : "",
3245
+ ariaExpanded: typeof r.ariaExpanded === "string" ? r.ariaExpanded : "",
3213
3246
  };
3214
3247
  }
3215
3248
  /**
@@ -3406,6 +3439,76 @@ async function selectionSiblingCommittedValueChanged(target, leafXpath, preSelec
3406
3439
  return false;
3407
3440
  }
3408
3441
  }
3442
+ /**
3443
+ * Dedicated pre/post check crediting exactly one case none of the fingerprint
3444
+ * diffs above can see: a raw click resolved onto (or under) a
3445
+ * {@link PROMPT_TRIGGER_SELECTORS}-shaped combobox opener that GENUINELY opened
3446
+ * its owned option panel. Opening a widget only flips `aria-expanded`, which
3447
+ * {@link selectionFingerprintObjSrc}'s shared fingerprint deliberately blanks out
3448
+ * of `dataState` and which {@link selectionFingerprintChanged} /
3449
+ * {@link selectionAncestorChanged}'s `changed()` never compare — by design, so a
3450
+ * bare accordion/tooltip disclosure is never mistaken for a selection. That
3451
+ * leaves a replanner-generated two-step "click to open the dropdown"
3452
+ * instruction with no signal at all: the fingerprint tiers above never move,
3453
+ * so the open-click is scored phantom even though it genuinely exposed the
3454
+ * option panel.
3455
+ *
3456
+ * This function is independent of those tiers rather than folded into their
3457
+ * `changed()`: it reads the new {@link ElementSelectionFingerprint.ariaExpanded}
3458
+ * field, but ONLY on a node that itself matches {@link PROMPT_TRIGGER_SELECTORS},
3459
+ * and ONLY credits it when the opener's `aria-owns`/`aria-controls` scope (the
3460
+ * SAME {@link PROMPT_SCOPE_ROOT_EXPR} resolution `tryPromptSelectorPrimitive`
3461
+ * uses) now actually contains {@link PROMPT_OPTION_SELECTORS} content — never a
3462
+ * bare `aria-expanded` flip with nothing rendered underneath. That second
3463
+ * condition is what keeps a bare accordion/tooltip trigger (which flips
3464
+ * `aria-expanded` but owns no option panel) excluded, preserving the existing
3465
+ * deliberate exclusion.
3466
+ *
3467
+ * Walks from the leaf up to {@link MAX_SELECTION_ANCESTOR_DEPTH} for the NEAREST
3468
+ * trigger match (mirrors {@link selectionAncestorChanged}'s climb, since
3469
+ * Stagehand may resolve the click onto a label/icon child of the opener rather
3470
+ * than the opener itself). No baseline entry for that trigger, no trigger
3471
+ * match, a trigger that was already expanded pre-click, or a trigger that's
3472
+ * still collapsed post-click → `false` (defer to the network/URL signal).
3473
+ * Returns `false` on any evaluate throw.
3474
+ */
3475
+ async function comboboxOpenerPanelOpened(target, leafXpath, preSelectionState) {
3476
+ const expr = `(() => {
3477
+ const LEAF = ${JSON.stringify(leafXpath)};
3478
+ const BASE = ${JSON.stringify(preSelectionState)};
3479
+ const TRIGGER_SEL = ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)};
3480
+ const OPTION_SEL = ${JSON.stringify(PROMPT_OPTION_SELECTORS)};
3481
+ const xpathOf = ${XPATH_OF_FN_SRC};
3482
+ const scopeRootOf = ${PROMPT_SCOPE_ROOT_EXPR};
3483
+ const r = document.evaluate(LEAF, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3484
+ let node = r.singleNodeValue;
3485
+ for (let depth = 0; depth < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && node; depth++) {
3486
+ if (node.getAttribute && node.matches && node.matches(TRIGGER_SEL)) {
3487
+ const pre = BASE[xpathOf(node)];
3488
+ if (!pre) return false;
3489
+ const wasCollapsed = pre.ariaExpanded !== "true";
3490
+ const nowExpanded = (node.getAttribute("aria-expanded") || "") === "true";
3491
+ if (!wasCollapsed || !nowExpanded) return false;
3492
+ // scopeRootOf falls back to \`document\` when the trigger has no
3493
+ // aria-controls/aria-owns ref AND no inline popup in its own subtree.
3494
+ // That fallback is a page-wide search, not the opener's OWNED scope —
3495
+ // crediting through it would let an unrelated widget's already-open
3496
+ // options (elsewhere on the page) falsely credit THIS trigger's click.
3497
+ const scope = scopeRootOf(node);
3498
+ if (!scope || scope === document || !scope.querySelector) return false;
3499
+ return !!scope.querySelector(OPTION_SEL);
3500
+ }
3501
+ node = node.parentElement;
3502
+ }
3503
+ return false;
3504
+ })()`;
3505
+ try {
3506
+ return (await target.evaluate(expr)) === true;
3507
+ }
3508
+ catch {
3509
+ return false;
3510
+ }
3511
+ }
3409
3512
  /** How long the upload primitive waits for a post-setInputFiles network POST. */
3410
3513
  const UPLOAD_NETWORK_TIMEOUT_MS = 5_000;
3411
3514
  /** Polling interval while waiting for the upload's network signal. */
@@ -4556,7 +4659,8 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
4556
4659
  */
4557
4660
  async function applySelectValue(page, target, selIdx, value) {
4558
4661
  const setExpr = `((selIdx, value) => {
4559
- const sel = Array.from(document.querySelectorAll("select"))[selIdx];
4662
+ const isOpenerPairedHidden = ${exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR};
4663
+ const sel = Array.from(document.querySelectorAll("select")).filter((s) => !isOpenerPairedHidden(s))[selIdx];
4560
4664
  if (!sel) return { ok: false };
4561
4665
  const desc = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value");
4562
4666
  if (desc && desc.set) { desc.set.call(sel, value); } else { sel.value = value; }
@@ -4571,7 +4675,8 @@ async function applySelectValue(page, target, selIdx, value) {
4571
4675
  await page.waitForTimeout(SELECT_SETTLE_MS);
4572
4676
  const invalidExpr = `((selIdx) => {
4573
4677
  const isInvalid = ${INVALID_MARKER_EL_EXPR};
4574
- const sel = Array.from(document.querySelectorAll("select"))[selIdx];
4678
+ const isOpenerPairedHidden = ${exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR};
4679
+ const sel = Array.from(document.querySelectorAll("select")).filter((s) => !isOpenerPairedHidden(s))[selIdx];
4575
4680
  if (!sel) return false;
4576
4681
  let node = sel;
4577
4682
  for (let depth = 0; depth < 6 && node; depth++) {
@@ -4609,8 +4714,12 @@ async function trySelectPrimitive(params) {
4609
4714
  const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
4610
4715
  const wantOpt = norm(option);
4611
4716
  // 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"));
4717
+ // a11y tree — and therefore Stagehand observe — never surfaces. Excludes
4718
+ // a design-system combobox opener's paired-but-hidden shadow <select>
4719
+ // (Base Web etc.) so tryPromptSelectorPrimitive gets first refusal on it
4720
+ // instead of this primitive silently writing a value the opener never sees.
4721
+ const isOpenerPairedHidden = ${exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR};
4722
+ const selects = Array.from(document.querySelectorAll("select")).filter((s) => !isOpenerPairedHidden(s));
4614
4723
  if (selects.length === 0) return { selectPresent: false };
4615
4724
  // FAST PATH — deterministic option match: exactly one select has an option
4616
4725
  // matching the flow's answer text. Unambiguous (the option text itself
@@ -4800,7 +4909,8 @@ async function tryFillRequiredSelectsPrimitive(params) {
4800
4909
  // (MUI marks NativeSelect via any of these). Reuses the isUnfilled / selLabelText
4801
4910
  // shape from trySelectPrimitive.
4802
4911
  const enumerateExpr = `(() => {
4803
- const selects = Array.from(document.querySelectorAll("select"));
4912
+ const isOpenerPairedHidden = ${exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR};
4913
+ const selects = Array.from(document.querySelectorAll("select")).filter((s) => !isOpenerPairedHidden(s));
4804
4914
  if (selects.length === 0) return { candidates: [] };
4805
4915
  const selLabelText = (sel) => {
4806
4916
  const parts = [];
@@ -5936,6 +6046,7 @@ async function commitPromptOption(params) {
5936
6046
  const isRequestedLeaf = norm(chosenOption.text) === wantOpt;
5937
6047
  const readbackExpr = `((wIdx, markAttr, valueSel, emptyRxSrc, emptyRxFlags, wantText, isRequestedLeaf) => {
5938
6048
  const isInvalid = ${INVALID_MARKER_EL_EXPR};
6049
+ const isPairedHiddenSelect = ${exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR};
5939
6050
  const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
5940
6051
  const buttonValue = ${BUTTON_VALUE_EXPR};
5941
6052
  const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
@@ -5958,10 +6069,55 @@ async function commitPromptOption(params) {
5958
6069
  if (node.getAttribute && isInvalid(node)) { stillInvalid = true; break; }
5959
6070
  node = node.parentElement;
5960
6071
  }
6072
+ // Corroborating signal when the opener's own text/activedescendant read is
6073
+ // inconclusive: the paired native <select> Base Web (and similar vendors)
6074
+ // keeps in sync BEHIND the real widget now holds a non-empty CURRENT value
6075
+ // (read-only — never written here) that, when its selected option's own
6076
+ // text is available, also matches the requested option.
6077
+ let hiddenSelectMatches = false;
6078
+ if (!textMatches) {
6079
+ let cnode = w;
6080
+ for (let d = 0; d < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && cnode && !hiddenSelectMatches; d++) {
6081
+ const selects = cnode.querySelectorAll ? Array.from(cnode.querySelectorAll("select")) : [];
6082
+ for (const sel of selects) {
6083
+ if (!isPairedHiddenSelect(sel)) continue;
6084
+ // isPairedHiddenSelect only proves SOME opener sits near this select —
6085
+ // in a form with multiple prompt-selector fields sharing an outer
6086
+ // ancestor within MAX_SELECTION_ANCESTOR_DEPTH, that opener can be a
6087
+ // SIBLING widget's, not w's. Mirror OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR's
6088
+ // own climb-then-scan-openers shape, but require w itself to be among
6089
+ // the openers found at the NEAREST level that has any — the same level
6090
+ // isPairedHiddenSelect stopped at — so a neighboring field's
6091
+ // already-committed hidden select never corroborates this widget's commit.
6092
+ let snode = sel.parentElement;
6093
+ let pairedWithW = false;
6094
+ for (let sd = 0; sd < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && snode && !pairedWithW; sd++) {
6095
+ const openersHere = snode.querySelectorAll
6096
+ ? Array.from(snode.querySelectorAll(${JSON.stringify(PROMPT_TRIGGER_SELECTORS)})).filter((o) => o !== sel)
6097
+ : [];
6098
+ if (openersHere.length > 0) {
6099
+ pairedWithW = openersHere.includes(w);
6100
+ break;
6101
+ }
6102
+ snode = snode.parentElement;
6103
+ }
6104
+ if (!pairedWithW) continue;
6105
+ const v = (sel.value || "").trim();
6106
+ if (!v) continue;
6107
+ const selOpt = sel.options && sel.selectedIndex >= 0 ? sel.options[sel.selectedIndex] : null;
6108
+ const optText = selOpt ? norm(selOpt.textContent || selOpt.label || "") : "";
6109
+ // A selected option with no visible text (icon-only, value-only markup)
6110
+ // carries no corroborating signal — treating it as a match would credit
6111
+ // ANY selection change, including a different option than requested.
6112
+ if (wantText && optText && optText.includes(wantText)) { hiddenSelectMatches = true; break; }
6113
+ }
6114
+ cnode = cnode.parentElement;
6115
+ }
6116
+ }
5961
6117
  // A category click may leave the widget's aria-invalid unset until final
5962
6118
  // submit, so !stillInvalid alone only counts as success when this click
5963
6119
  // landed on the requested leaf option, not an intermediate category.
5964
- return { ok: textMatches || (isRequestedLeaf && !stillInvalid), id: w.id || "" };
6120
+ return { ok: textMatches || (isRequestedLeaf && !stillInvalid) || hiddenSelectMatches, id: w.id || "" };
5965
6121
  })(${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
6122
  const readback = (await target.evaluate(readbackExpr).catch(() => ({ ok: false, id: "" })));
5967
6123
  if (readback?.ok) {
@@ -6063,12 +6219,14 @@ async function hasUnfilledRequiredControlForStep(target, instruction) {
6063
6219
  // (button/aria-haspopup shapes with no native input/select at all) to
6064
6220
  // the container-widget union already covered.
6065
6221
  const controls = Array.from(document.querySelectorAll(
6066
- "input,select,textarea,[role=combobox],[role=listbox],.bb-custom-select-container,[class*='MultiCheckboxInput']," + triggerSel
6222
+ "input,select,textarea,[role=combobox],[role=listbox]," + triggerSel
6067
6223
  ));
6068
6224
  for (const el of controls) {
6069
6225
  if (!isRequired(el) && !(el.querySelector && el.querySelector("[required],[aria-required=true]"))) {
6070
6226
  // container widgets carry required on an inner element; allow those
6071
- if (!/bb-custom-select-container|MultiCheckboxInput/.test((el.getAttribute && el.getAttribute("class")) || "")) continue;
6227
+ const isTriggerWidget =
6228
+ (el.matches && el.matches(triggerSel)) || (el.querySelector && !!el.querySelector(triggerSel));
6229
+ if (!isTriggerWidget) continue;
6072
6230
  }
6073
6231
  // is it empty/invalid?
6074
6232
  const emptyOrInvalid = isEmptyish(el) || (el.querySelector && !!el.querySelector("[aria-invalid=true]"));
@@ -6329,7 +6487,15 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
6329
6487
  // ancestor, and — since some resolved xpaths land on a sibling label rather
6330
6488
  // than the combobox itself — the nearest combobox within the same
6331
6489
  // field-group container, for a nested input value, falling back to visible
6332
- // text or an aria label/valuetext.
6490
+ // text or an aria label/valuetext. `combo.textContent`/`el.textContent`
6491
+ // is NEVER read directly: for a composite opener+hidden-`<select>`
6492
+ // widget (role="combobox" on the shared container, resolved xpath
6493
+ // landing on the sibling hidden select) that concatenates EVERY
6494
+ // option's text, so it "matches" whatever the requested option is
6495
+ // regardless of what actually got selected — read only the
6496
+ // COMMITTED signal (aria-activedescendant's referenced node, the
6497
+ // select's own selectedIndex option, or the opener's displayed text
6498
+ // with any nested select/listbox option list stripped out first).
6333
6499
  const exprCustom = `(() => {
6334
6500
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
6335
6501
  const el = r.singleNodeValue;
@@ -6342,9 +6508,28 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
6342
6508
  combo = combo || el;
6343
6509
  const nestedInput = combo.querySelector && combo.querySelector('input');
6344
6510
  const value = (nestedInput && nestedInput.value) || el.value || "";
6345
- const text = (combo.textContent || el.textContent || "").trim();
6511
+ const adid = (combo.getAttribute && combo.getAttribute('aria-activedescendant')) ||
6512
+ (el.getAttribute && el.getAttribute('aria-activedescendant'));
6513
+ const adNode = adid && document.getElementById(adid);
6514
+ const adText = adNode ? (adNode.textContent || "").trim() : "";
6515
+ const selectEl = el.tagName === "SELECT" ? el : (combo.tagName === "SELECT" ? combo : null);
6516
+ const selectedOptText = selectEl && selectEl.selectedIndex >= 0 && selectEl.options[selectEl.selectedIndex]
6517
+ ? (selectEl.options[selectEl.selectedIndex].textContent || "").trim()
6518
+ : "";
6519
+ // If combo itself IS the <select> (role="combobox" placed directly
6520
+ // on the native select rather than a wrapping container),
6521
+ // querySelectorAll('select,...') below only prunes DESCENDANTS —
6522
+ // combo's own concatenated option text survives untouched. Skip
6523
+ // the whole-text fallback entirely for that shape; selectedOptText
6524
+ // above already reads its single committed option.
6525
+ const clone = combo.tagName !== "SELECT" && combo.cloneNode ? combo.cloneNode(true) : null;
6526
+ if (clone && clone.querySelectorAll) {
6527
+ clone.querySelectorAll('select,[role="listbox"]').forEach((n) => n.remove());
6528
+ }
6529
+ const ownText = clone ? (clone.textContent || "").trim() : "";
6346
6530
  const ariaText = (el.getAttribute && (el.getAttribute('aria-label') || el.getAttribute('aria-valuetext'))) || "";
6347
- return { value: String(value).trim(), text: String(text || ariaText).trim() };
6531
+ const text = adText || selectedOptText || ownText || ariaText;
6532
+ return { value: String(value).trim(), text: String(text || "").trim() };
6348
6533
  })()`;
6349
6534
  try {
6350
6535
  const customResult = await target.evaluate(exprCustom);
@@ -6438,7 +6623,17 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
6438
6623
  // may live on a hidden sibling `<input>`/`<select>` the click never
6439
6624
  // re-renders visibly. Diff that control against its own baseline
6440
6625
  // entry instead.
6441
- return await selectionSiblingCommittedValueChanged(target, xpath, preSelectionState);
6626
+ if (await selectionSiblingCommittedValueChanged(target, xpath, preSelectionState)) {
6627
+ return true;
6628
+ }
6629
+ // No selection fingerprint moved anywhere: the click may still have
6630
+ // genuinely opened a combobox's owned option panel (a two-step
6631
+ // "click to open the dropdown" instruction), which flips only
6632
+ // `aria-expanded` — deliberately excluded from every fingerprint
6633
+ // diff above so a bare accordion/tooltip disclosure is never
6634
+ // credited. This dedicated check additionally requires the opener's
6635
+ // owned panel to now render option content before crediting it.
6636
+ return await comboboxOpenerPanelOpened(target, xpath, preSelectionState);
6442
6637
  }
6443
6638
  const isCheckedNow = await locator.isChecked();
6444
6639
  if (!isCheckedNow)