@enricai/barnacle 1.12.72 → 1.12.74

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.
@@ -14,11 +14,12 @@
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.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR = exports.GOTO_TIMEOUT_MS = exports.waitForSpaReady = void 0;
17
+ exports.hasOriginOrPathChanged = 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.shouldRetryCaptchaRegistry = shouldRetryCaptchaRegistry;
21
21
  exports.prepareFailureDumpBody = prepareFailureDumpBody;
22
+ exports.fieldValuesAtFailureExpr = fieldValuesAtFailureExpr;
22
23
  exports.snapshotPage = snapshotPage;
23
24
  exports.resolveDumpPageIdentity = resolveDumpPageIdentity;
24
25
  exports.findRecentBackendError = findRecentBackendError;
@@ -76,7 +77,6 @@ exports.parseWidgetOptionClickStep = parseWidgetOptionClickStep;
76
77
  exports.parseCheckStep = parseCheckStep;
77
78
  exports.pollEnumerate = pollEnumerate;
78
79
  exports.waitForTransitionBody = waitForTransitionBody;
79
- exports.hasOriginOrPathChanged = hasOriginOrPathChanged;
80
80
  exports.injectCaptchaTokenAndSubmit = injectCaptchaTokenAndSubmit;
81
81
  exports.submitCaptchaGatedForm = submitCaptchaGatedForm;
82
82
  exports.trySelectPrimitive = trySelectPrimitive;
@@ -88,6 +88,7 @@ exports.hasUnfilledRequiredControlForStep = hasUnfilledRequiredControlForStep;
88
88
  exports.simulateDragDropUpload = simulateDragDropUpload;
89
89
  exports.dispatchJqueryChangeEvent = dispatchJqueryChangeEvent;
90
90
  exports.verifyDomEffect = verifyDomEffect;
91
+ exports.formValidityProbeExpr = formValidityProbeExpr;
91
92
  exports.narrowInvalidFormControl = narrowInvalidFormControl;
92
93
  exports.formatStepPrefix = formatStepPrefix;
93
94
  exports.tryDeterministicFieldLabelActuation = tryDeterministicFieldLabelActuation;
@@ -952,6 +953,55 @@ function prepareFailureDumpBody(raw) {
952
953
  * agree on exactly what counts as a class-token selection marker.
953
954
  */
954
955
  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 }; })()`;
956
+ /** Caps mirroring {@link DOM_SNAPSHOT_EXPR}'s truncation so a pathological page can't bloat the failure bundle. */
957
+ const FIELD_VALUES_AT_FAILURE_MAX_CONTROLS = 200;
958
+ const FIELD_VALUES_AT_FAILURE_MAX_VALUE_LENGTH = 500;
959
+ /**
960
+ * Browser-side expression source returning `{label, value}[]` for every
961
+ * visible input/textarea/select, read via the live `.value`/`.checked`
962
+ * property rather than the serialized attribute. Exists because every
963
+ * DOM-direct fill path sets a control's value with `el.value = value` (the
964
+ * JS property), which is never reflected back into the `value=` content
965
+ * attribute `document.body.outerHTML` serializes — so a reparsed HTML
966
+ * snapshot can structurally never prove what a text field currently holds.
967
+ * The accessible-name resolution (`aria-labelledby`, then `aria-label`, then
968
+ * `label[for]`/wrapping `label`, then `name`/`id`/`placeholder`) mirrors
969
+ * `recon-browser.ts`'s `accessibleNameForControl` so the label a step
970
+ * targets and the label this capture keys on agree.
971
+ */
972
+ function fieldValuesAtFailureExpr() {
973
+ return `(() => {
974
+ const b = document.body;
975
+ if (!b) return [];
976
+ const accessibleName = (el) => {
977
+ const labelledBy = el.getAttribute("aria-labelledby");
978
+ if (labelledBy) {
979
+ const text = labelledBy.split(/\\s+/).map((id) => document.getElementById(id)?.textContent ?? "").join(" ").trim();
980
+ if (text) return text;
981
+ }
982
+ const ariaLabel = el.getAttribute("aria-label");
983
+ if (ariaLabel && ariaLabel.trim()) return ariaLabel;
984
+ const id = el.getAttribute("id");
985
+ if (id) {
986
+ const label = document.querySelector("label[for=\\"" + CSS.escape(id) + "\\"]");
987
+ if (label && label.textContent && label.textContent.trim()) return label.textContent;
988
+ }
989
+ const wrappingLabel = el.closest("label");
990
+ if (wrappingLabel && wrappingLabel.textContent && wrappingLabel.textContent.trim()) return wrappingLabel.textContent;
991
+ return el.getAttribute("name") || id || el.getAttribute("placeholder") || "";
992
+ };
993
+ const controls = Array.from(b.querySelectorAll("input, textarea, select")).filter((el) => el.offsetParent !== null);
994
+ const out = [];
995
+ for (const el of controls) {
996
+ if (out.length >= ${FIELD_VALUES_AT_FAILURE_MAX_CONTROLS}) break;
997
+ const label = accessibleName(el).trim();
998
+ if (!label) continue;
999
+ const value = (el.type === "checkbox" || el.type === "radio") ? (el.checked ? "1" : "0") : (el.value || "");
1000
+ out.push({ label: label.slice(0, 200), value: String(value).slice(0, ${FIELD_VALUES_AT_FAILURE_MAX_VALUE_LENGTH}) });
1001
+ }
1002
+ return out;
1003
+ })()`;
1004
+ }
955
1005
  /**
956
1006
  * Captures the pre/post signal triple the submit-verify cascade diffs.
957
1007
  * Accepts the optional `page` so a resolved child `FrameTarget` whose
@@ -3375,17 +3425,39 @@ function elementSelectionFingerprintExpr(xpath) {
3375
3425
  * In-page source for `(el) => Element | null`: the nearest ancestor of `el`,
3376
3426
  * within {@link MAX_SELECTION_ANCESTOR_DEPTH} levels, whose subtree contains a
3377
3427
  * selection-marker element (`role="combobox"`/`role="listbox"`, or a
3378
- * {@link WIDGET_KIT_SELECTION_MARKER_SELECTORS} match) — climbing ancestor
3379
- * levels rather than a single `closest()` call, because a design-system
3380
- * combobox's marker (the `role="combobox"` trigger) and its committed-value
3381
- * control (a hidden `<input>`) are commonly SIBLINGS under a shared wrapper
3382
- * that itself carries no role/class marker: `closest()` only searches
3383
- * self-and-ancestors of `el`, so it can never see a marker that lives on a
3384
- * cousin subtree. At each level, `querySelectorAll` searches the WHOLE
3385
- * subtree (not just direct children), so the marker may be nested arbitrarily
3386
- * deep under that ancestor. Returns the FIRST (nearest) qualifying ancestor,
3387
- * so a same-shaped input/select outside that common ancestor is never
3388
- * mistaken for the same control's committed-value sibling. Shared by
3428
+ * {@link WIDGET_KIT_SELECTION_MARKER_SELECTORS} match) AND at most one
3429
+ * independent WIDGET ROOT (each `role="combobox"`/marker trigger is its own
3430
+ * root; a `role="listbox"` is a root TOO unless some trigger in the subtree
3431
+ * actually declares ownership of it via `aria-owns`/`aria-controls` — a
3432
+ * trigger merely co-existing in the same subtree does NOT make every listbox
3433
+ * its panel, since a real trigger plus an unrelated, un-owned bare listbox
3434
+ * (its own standalone multi-select, no opener) are still two independent
3435
+ * widgets, not one; with NO trigger anywhere in the subtree, each bare
3436
+ * `role="listbox"` is likewise its own standalone widget root, so two such
3437
+ * triggerless listboxes sharing an ancestor are still counted as two
3438
+ * independent roots rather than silently collapsing to zero — EXCEPT when
3439
+ * the scope holds exactly one trigger and exactly one listbox and neither
3440
+ * declares ownership of the other: that pairing is unambiguous regardless of
3441
+ * missing ARIA wiring, so it still counts as a single root) — climbing
3442
+ * ancestor levels rather than a single `closest()` call, because a design-system combobox's
3443
+ * marker (the `role="combobox"` trigger) and its committed-value control (a
3444
+ * hidden `<input>`) are commonly SIBLINGS under a shared wrapper that itself
3445
+ * carries no role/class marker: `closest()` only searches self-and-ancestors
3446
+ * of `el`, so it can never see a marker that lives on a cousin subtree. At
3447
+ * each level, `querySelectorAll` searches the WHOLE subtree (not just direct
3448
+ * children), so the marker may be nested arbitrarily deep under that
3449
+ * ancestor. The widget-root cap is deliberate: a candidate ancestor whose
3450
+ * subtree contains TWO OR MORE independent widget roots (e.g. two unrelated
3451
+ * toggle-shaped rows sharing a parent) is ambiguous about which widget —
3452
+ * and therefore which committed-value control — actually belongs to `el`, so
3453
+ * it is skipped rather than returned; climbing continues to look for an
3454
+ * unambiguous (single-widget) ancestor instead of exposing the whole
3455
+ * multi-widget subtree to the caller's input/select scan, which would let an
3456
+ * UNRELATED sibling widget's own state change masquerade as `el`'s. A single
3457
+ * trigger + its own owned listbox panel (one widget root) still passes.
3458
+ * Returns the FIRST (nearest) unambiguous qualifying ancestor, so a
3459
+ * same-shaped input/select outside that common ancestor is never mistaken for
3460
+ * the same control's committed-value sibling. Shared by
3389
3461
  * {@link SELECTION_STATE_MAP_EXPR}'s `isCommittedValueControl` and
3390
3462
  * {@link selectionSiblingCommittedValueChanged}'s container resolution so the
3391
3463
  * baseline capture and the read-back agree on exactly what counts as
@@ -3395,9 +3467,42 @@ function elementSelectionFingerprintExpr(xpath) {
3395
3467
  */
3396
3468
  const NEARBY_SELECTION_CONTAINER_FN_SRC = `(el) => {
3397
3469
  const MARKER_SEL = '[role="combobox"],[role="listbox"],' + ${JSON.stringify(browser_click_expr_1.WIDGET_KIT_SELECTION_MARKER_SELECTORS)};
3470
+ const WIDGET_ROOT_SEL = '[role="combobox"],' + ${JSON.stringify(browser_click_expr_1.WIDGET_KIT_SELECTION_MARKER_SELECTORS)};
3471
+ const isOwnedListbox = (listbox, triggers) => {
3472
+ if (!listbox.id) return false;
3473
+ return triggers.some((trigger) => {
3474
+ const ownsId = trigger.getAttribute("aria-owns") || trigger.getAttribute("aria-controls");
3475
+ return ownsId === listbox.id;
3476
+ });
3477
+ };
3478
+ const countWidgetRoots = (scope) => {
3479
+ // Each combobox-like trigger is its own widget root. A role=listbox is
3480
+ // that widget's owned PANEL only when SOME trigger in scope actually
3481
+ // declares ownership of it via aria-owns/aria-controls — a trigger
3482
+ // merely co-existing in the same scope does not make every listbox its
3483
+ // panel, since a trigger plus an unrelated, un-owned bare listbox (its
3484
+ // own standalone multi-select, no opener) are still two independent
3485
+ // widgets. Any listbox left un-owned by every trigger — including when
3486
+ // there is no trigger in scope at all — counts as its own widget root.
3487
+ const triggers = Array.from(scope.querySelectorAll(WIDGET_ROOT_SEL));
3488
+ const listboxes = Array.from(scope.querySelectorAll('[role="listbox"]'));
3489
+ const unownedListboxes = listboxes.filter((lb) => !isOwnedListbox(lb, triggers));
3490
+ // A lone trigger next to a lone listbox, neither declaring aria-owns/
3491
+ // aria-controls, is still an unambiguous single widget (a real-world
3492
+ // combobox that never bothered to wire up ARIA ownership) — only
3493
+ // count it as two independent roots once there is more than one
3494
+ // trigger or more than one listbox in scope to actually be ambiguous
3495
+ // about pairing.
3496
+ if (triggers.length === 1 && listboxes.length === 1 && unownedListboxes.length === 1) {
3497
+ return 1;
3498
+ }
3499
+ return triggers.length + unownedListboxes.length;
3500
+ };
3398
3501
  let node = el.parentElement;
3399
3502
  for (let depth = 0; depth < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && node; depth++) {
3400
- if (node.querySelectorAll && node.querySelectorAll(MARKER_SEL).length > 0) return node;
3503
+ if (node.querySelectorAll && node.querySelectorAll(MARKER_SEL).length > 0) {
3504
+ if (countWidgetRoots(node) <= 1) return node;
3505
+ }
3401
3506
  node = node.parentElement;
3402
3507
  }
3403
3508
  return null;
@@ -4662,9 +4767,12 @@ function parseFieldLabelTarget(step) {
4662
4767
  * Name"); otherwise a substring match either direction (a `fieldLabel` of
4663
4768
  * "Acme Non-Employee ID" against an accessible name of "Non-Employee ID", or
4664
4769
  * the reverse) so minor phrasing drift between the flow's field noun and the
4665
- * control's own label still resolves. Returns `null` — never a guess — when
4666
- * no candidate's accessible name relates to `fieldLabel` at all, so the
4667
- * caller can refuse to act rather than fill/click the wrong control.
4770
+ * control's own label still resolves; when multiple candidates qualify via
4771
+ * substring, the one whose length is closest to `fieldLabel`'s (the most
4772
+ * specific match) wins, not whichever happened to come first. Returns
4773
+ * `null` — never a guess — when no candidate's accessible name relates to
4774
+ * `fieldLabel` at all, so the caller can refuse to act rather than
4775
+ * fill/click the wrong control.
4668
4776
  */
4669
4777
  function findDeepLocatorCandidateByFieldLabel(candidates, fieldLabel) {
4670
4778
  const normalizedLabel = normalizeFieldLabel(fieldLabel);
@@ -4676,8 +4784,15 @@ function findDeepLocatorCandidateByFieldLabel(candidates, fieldLabel) {
4676
4784
  const exact = named.find((entry) => entry.text === normalizedLabel);
4677
4785
  if (exact)
4678
4786
  return exact.candidate;
4679
- const partial = named.find((entry) => entry.text.includes(normalizedLabel) || normalizedLabel.includes(entry.text));
4680
- return partial?.candidate ?? null;
4787
+ const partials = named.filter((entry) => entry.text.includes(normalizedLabel) || normalizedLabel.includes(entry.text));
4788
+ const mostSpecific = partials.reduce((closest, entry) => {
4789
+ if (!closest)
4790
+ return entry;
4791
+ const entryDistance = Math.abs(entry.text.length - normalizedLabel.length);
4792
+ const closestDistance = Math.abs(closest.text.length - normalizedLabel.length);
4793
+ return entryDistance < closestDistance ? entry : closest;
4794
+ }, null);
4795
+ return mostSpecific?.candidate ?? null;
4681
4796
  }
4682
4797
  /** Max settle-retry attempts for a primitive's DOM enumerate (see `pollEnumerate`). */
4683
4798
  const PRIMITIVE_ENUMERATE_ATTEMPTS = 5;
@@ -4748,29 +4863,8 @@ async function waitForTransitionBody(params) {
4748
4863
  }
4749
4864
  return false;
4750
4865
  }
4751
- /**
4752
- * Reduces a URL to origin+pathname (query/hash ignored) so a captcha
4753
- * target's post-submit `url()` can be compared to its pre-submit baseline
4754
- * without a same-page query-string change (e.g. a step counter) registering
4755
- * as a false advance. Unparseable URLs compare equal only to themselves.
4756
- */
4757
- function originAndPathOf(url) {
4758
- try {
4759
- const parsed = new URL(url);
4760
- return `${parsed.origin}${parsed.pathname}`;
4761
- }
4762
- catch {
4763
- return url;
4764
- }
4765
- }
4766
- /**
4767
- * Credits a captchaGated advance from an observed navigation: true only
4768
- * when origin or pathname actually changed, so a same-page query-string
4769
- * mutation (e.g. a step counter) never counts as an advance.
4770
- */
4771
- function hasOriginOrPathChanged(preUrl, postUrl) {
4772
- return originAndPathOf(preUrl) !== originAndPathOf(postUrl);
4773
- }
4866
+ var phantom_click_2 = require("./phantom-click");
4867
+ Object.defineProperty(exports, "hasOriginOrPathChanged", { enumerable: true, get: function () { return phantom_click_2.hasOriginOrPathChanged; } });
4774
4868
  /**
4775
4869
  * Polls the captcha target's URL for an origin/path change after a
4776
4870
  * multipart form submit, which navigates the page without producing any
@@ -4782,7 +4876,7 @@ async function waitForCaptchaNavigation(params) {
4782
4876
  const { page, captchaTarget, baselineUrl, timeoutMs, intervalMs } = params;
4783
4877
  const check = async () => {
4784
4878
  const currentUrl = await (0, frame_target_1.readCurrentFrameUrl)(page, captchaTarget);
4785
- return hasOriginOrPathChanged(baselineUrl, currentUrl);
4879
+ return (0, phantom_click_1.hasOriginOrPathChanged)(baselineUrl, currentUrl);
4786
4880
  };
4787
4881
  if (await check())
4788
4882
  return true;
@@ -7269,11 +7363,21 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
7269
7363
  * - select: pick the first non-empty option.
7270
7364
  * Each auto-pick is recorded so the cascade can surface a self-heal
7271
7365
  * warning ("you auto-picked X; consider adding an explicit step").
7366
+ * 3. NATIVE-VALIDITY FALLBACK — independent of the class-marker scan:
7367
+ * any `required` control whose native `validity.valueMissing` (or,
7368
+ * absent a `validity` object, an empty `.value`) is true gets flagged
7369
+ * even when no framework has applied an invalid-marker class yet
7370
+ * (e.g. a pristine, untouched required field).
7272
7371
  *
7273
7372
  * Label resolution checks (in order) the nearest `<label>`, `aria-label`,
7274
7373
  * `data-id`, and `name`.
7374
+ *
7375
+ * Exported (mirroring {@link fieldValuesAtFailureExpr}) so the real
7376
+ * native-validity-fallback logic can be exercised against a genuine DOM in
7377
+ * unit tests, rather than only through a mocked `evaluate`.
7275
7378
  */
7276
- const FORM_VALIDITY_PROBE_EXPR = `(() => {
7379
+ function formValidityProbeExpr() {
7380
+ return `(() => {
7277
7381
  const INVALID_CLASS_RX = /(${INVALID_MARKER_CLASS_SOURCE})/;
7278
7382
  const MARKERS = ["ng-invalid", "mat-form-field-invalid", "is-invalid", "field-invalid", "input-invalid", "Mui-error", "ng-touched", "ng-dirty"];
7279
7383
  function fire(el, ev) {
@@ -7431,9 +7535,35 @@ const FORM_VALIDITY_PROBE_EXPR = `(() => {
7431
7535
  }
7432
7536
  out.push({ label, classSignature, emptyOrUnchecked, autoFilled, _el: el });
7433
7537
  }
7538
+ // Native-validity fallback: the class-marker scan above is blind to a
7539
+ // required field that is genuinely empty but PRISTINE -- a framework
7540
+ // that never applies ng-invalid/Mui-error/etc. until after user
7541
+ // interaction, or one that doesn't use any of the listed conventions
7542
+ // at all. This pass is independent of INVALID_CLASS_RX and relies
7543
+ // solely on the native HTML5 constraint-validation API, so it catches
7544
+ // required-and-empty controls no CSS framework has marked invalid yet.
7545
+ const requiredEls = Array.from(document.querySelectorAll("input,select,textarea"));
7546
+ for (const ctrl of requiredEls) {
7547
+ if (!ctrl.required) continue;
7548
+ if (out.some((e) => e._el === ctrl || (e._el && e._el.contains(ctrl)))) continue;
7549
+ const valueMissing = ctrl.validity ? ctrl.validity.valueMissing : !ctrl.value;
7550
+ if (!valueMissing) continue;
7551
+ const byFor = ctrl.id && document.querySelector ? document.querySelector("label[for=\\"" + ctrl.id + "\\"]") : null;
7552
+ let label = byFor && byFor.textContent ? byFor.textContent.trim() : "";
7553
+ let scan = ctrl;
7554
+ for (let i = 0; i < 4 && scan && !label; i++) {
7555
+ const ownLabel = scan.closest ? scan.closest("label") : null;
7556
+ if (ownLabel && ownLabel.textContent) label = ownLabel.textContent.trim();
7557
+ scan = scan.parentElement;
7558
+ }
7559
+ if (!label) label = ctrl.getAttribute("aria-label") || ctrl.getAttribute("data-id") || ctrl.getAttribute("name") || ctrl.getAttribute("id") || "(unlabeled)";
7560
+ label = label.replace(/\\s+/g, " ").slice(0, 80);
7561
+ out.push({ label, classSignature: "", emptyOrUnchecked: true, autoFilled: null, _el: ctrl });
7562
+ }
7434
7563
  // Strip the DOM reference before serialization.
7435
7564
  return out.slice(0, 12).map((e) => ({ label: e.label, classSignature: e.classSignature, emptyOrUnchecked: e.emptyOrUnchecked, autoFilled: e.autoFilled }));
7436
7565
  })()`;
7566
+ }
7437
7567
  /**
7438
7568
  * Runs ONLY on the cascade's final step when a submitEndpointPattern is
7439
7569
  * declared. Surfaces ng-invalid form controls (and whether each is empty)
@@ -7502,7 +7632,7 @@ function formatStepPrefix(stepIndex, totalSteps) {
7502
7632
  async function probeFormValidityBeforeSubmit(params) {
7503
7633
  const { target, stepIndex, totalSteps, logger } = params;
7504
7634
  try {
7505
- const raw = await target.evaluate(FORM_VALIDITY_PROBE_EXPR);
7635
+ const raw = await target.evaluate(formValidityProbeExpr());
7506
7636
  if (!Array.isArray(raw))
7507
7637
  return [];
7508
7638
  const out = [];
@@ -8624,6 +8754,9 @@ async function executeStepWithHealing(params) {
8624
8754
  .evaluate("document.body ? document.body.outerHTML : null")
8625
8755
  .catch(() => null);
8626
8756
  const bodyOuterHtml = prepareFailureDumpBody(bodyOuterHtmlRaw);
8757
+ const fieldValuesAtFailure = await (frameTarget ?? page)
8758
+ .evaluate(fieldValuesAtFailureExpr())
8759
+ .catch(() => null);
8627
8760
  const probeAbsentObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
8628
8761
  // observe() is blind to a cross-origin OOPIF, so a frame-scoped empty
8629
8762
  // result degrades to the deep-locator resolver for dump evidence — this
@@ -8649,6 +8782,7 @@ async function executeStepWithHealing(params) {
8649
8782
  bodyOuterHtml,
8650
8783
  unfocusedObserve,
8651
8784
  targetResolutionDiagnostic,
8785
+ fieldValuesAtFailure,
8652
8786
  }) ?? null;
8653
8787
  throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
8654
8788
  }
@@ -9262,8 +9396,42 @@ async function executeStepWithHealing(params) {
9262
9396
  continue;
9263
9397
  }
9264
9398
  const topWindowTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
9399
+ let clickedShadowDescendant = false;
9265
9400
  try {
9266
- await topWindowTarget.locator(topWindowSelector).first().click();
9401
+ // `document.evaluate` (what the xpath-resolved locator below is
9402
+ // built on) cannot cross a shadow boundary, so on a custom-
9403
+ // element host whose real activation lives on an interior
9404
+ // shadow-DOM descendant, the xpath resolves to the host itself —
9405
+ // the phantomed target from attempt 1. Resolve into the host's
9406
+ // own (open) shadow root for the real interactive descendant and
9407
+ // stamp it with a throwaway marker attribute; a CSS attribute
9408
+ // selector (unlike xpath) pierces an open shadow root, so
9409
+ // re-locating via that marker and clicking delivers a genuinely
9410
+ // trusted click at the real target instead of the host's own
9411
+ // bounding box. No shadow root, or no qualifying descendant,
9412
+ // falls back unchanged to clicking the host via its xpath.
9413
+ const xpathBody = xpathBodyForEvaluate(topWindowSelector);
9414
+ const markerAttr = "data-barnacle-shadow-click-target";
9415
+ const markerValue = `t${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
9416
+ const markResult = xpathBody
9417
+ ? await topWindowTarget
9418
+ .evaluate((0, browser_click_expr_1.buildMarkShadowInteractiveDescendantExpr)(xpathBody, markerAttr, markerValue))
9419
+ .catch(() => ({ found: false }))
9420
+ : { found: false };
9421
+ if (markResult.found) {
9422
+ try {
9423
+ await topWindowTarget.locator(`[${markerAttr}="${markerValue}"]`).first().click();
9424
+ clickedShadowDescendant = true;
9425
+ }
9426
+ finally {
9427
+ await topWindowTarget
9428
+ .evaluate((0, browser_click_expr_1.buildUnmarkShadowDescendantExpr)(xpathBody, markerAttr, markerValue))
9429
+ .catch(() => undefined);
9430
+ }
9431
+ }
9432
+ else {
9433
+ await topWindowTarget.locator(topWindowSelector).first().click();
9434
+ }
9267
9435
  }
9268
9436
  catch (err) {
9269
9437
  const failureMessage = `trusted-click-retry: top-window trusted click threw ${(0, errors_1.toErrorMessage)(err)}`;
@@ -9275,11 +9443,13 @@ async function executeStepWithHealing(params) {
9275
9443
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
9276
9444
  continue;
9277
9445
  }
9278
- record.instruction = `trusted-click-retry (top-window): ${topWindowSelector}`;
9446
+ record.instruction = `trusted-click-retry (top-window): ${topWindowSelector}${clickedShadowDescendant ? " (shadow descendant)" : ""}`;
9279
9447
  record.actResultSuccess = true;
9280
- record.actResultDescription = `trusted-click-retry clicked "${topWindowSelector}" via top-window locator`;
9448
+ record.actResultDescription = clickedShadowDescendant
9449
+ ? `trusted-click-retry clicked the shadow-DOM descendant of "${topWindowSelector}" via top-window locator`
9450
+ : `trusted-click-retry clicked "${topWindowSelector}" via top-window locator`;
9281
9451
  record.triedSelectors = [...triedSelectors];
9282
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: trusted-click-retry: top-window trusted locator click on the resolved target`);
9452
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: trusted-click-retry: top-window trusted locator click on the resolved target${clickedShadowDescendant ? " (shadow descendant)" : ""}`);
9283
9453
  resolvedAction = {
9284
9454
  selector: topWindowSelector,
9285
9455
  description: record.actResultDescription,
@@ -9827,7 +9997,7 @@ async function executeStepWithHealing(params) {
9827
9997
  record.resolvedArguments = (resolvedAction.arguments ?? []).map(String);
9828
9998
  }
9829
9999
  const networkFired = post.networkCount > pre.networkCount;
9830
- const urlChanged = post.url !== pre.url;
10000
+ const urlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, post.url);
9831
10001
  const isStateClass = resolvedAction !== null && STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
9832
10002
  const isClick = resolvedAction !== null && resolvedAction.method === "click";
9833
10003
  // State-class actions (fill/check/etc.) never move the network counter or URL,
@@ -10395,7 +10565,7 @@ async function executeStepWithHealing(params) {
10395
10565
  await page.waitForTimeout(STEP_PAUSE_MS);
10396
10566
  const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
10397
10567
  const retryNetworkFired = retryPost.networkCount > pre.networkCount;
10398
- const retryUrlChanged = retryPost.url !== pre.url;
10568
+ const retryUrlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, retryPost.url);
10399
10569
  const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
10400
10570
  const retryTextChanged = retryPost.visibleTextSignature !== pre.visibleTextSignature;
10401
10571
  const retryFormValueChanged = retryPost.formValueSignature !== pre.formValueSignature;
@@ -10930,6 +11100,9 @@ async function executeStepWithHealing(params) {
10930
11100
  .evaluate("document.body ? document.body.outerHTML : null")
10931
11101
  .catch(() => null);
10932
11102
  const bodyOuterHtml = prepareFailureDumpBody(bodyOuterHtmlRaw);
11103
+ const fieldValuesAtFailure = await (frameTarget ?? (0, frame_target_1.mainFrameTarget)(page))
11104
+ .evaluate(fieldValuesAtFailureExpr())
11105
+ .catch(() => null);
10933
11106
  const cascadeExhaustObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
10934
11107
  const unfocusedObserve = cascadeExhaustObservedUnfocused.length === 0 && frameTarget?.frame
10935
11108
  ? await deepLocatorCandidatesAsActions(page, frameTarget)
@@ -10949,6 +11122,7 @@ async function executeStepWithHealing(params) {
10949
11122
  bodyOuterHtml,
10950
11123
  unfocusedObserve,
10951
11124
  targetResolutionDiagnostic,
11125
+ fieldValuesAtFailure,
10952
11126
  }) ?? null;
10953
11127
  if (dumpPath !== null) {
10954
11128
  logger.error(`${formatStepPrefix(stepIndex, totalSteps)} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);