@enricai/barnacle 1.12.80 → 1.12.81

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.
@@ -21,6 +21,7 @@ exports.shouldRetryCaptchaRegistry = shouldRetryCaptchaRegistry;
21
21
  exports.buildDeepMarkerCentreExpr = buildDeepMarkerCentreExpr;
22
22
  exports.prepareFailureDumpBody = prepareFailureDumpBody;
23
23
  exports.fieldValuesAtFailureExpr = fieldValuesAtFailureExpr;
24
+ exports.settleWithPageQuiescence = settleWithPageQuiescence;
24
25
  exports.snapshotPage = snapshotPage;
25
26
  exports.resolveDumpPageIdentity = resolveDumpPageIdentity;
26
27
  exports.findRecentBackendError = findRecentBackendError;
@@ -88,6 +89,7 @@ exports.parseFillStep = parseFillStep;
88
89
  exports.parseFillValueIntent = parseFillValueIntent;
89
90
  exports.parseSegmentedDateValue = parseSegmentedDateValue;
90
91
  exports.resolveSegmentedDateGroup = resolveSegmentedDateGroup;
92
+ exports.parseSegmentedPartStep = parseSegmentedPartStep;
91
93
  exports.trySegmentedDateSpinbuttonPrimitive = trySegmentedDateSpinbuttonPrimitive;
92
94
  exports.parseRadioStep = parseRadioStep;
93
95
  exports.parseWidgetOptionClickStep = parseWidgetOptionClickStep;
@@ -733,6 +735,18 @@ const PROMPT_CURRENT_TEXT_EXPR = `((w, valueSel, emptyRx) => {
733
735
  if (raw.trim() && !emptyRx.test(raw.trim())) return norm(raw);
734
736
  return "";
735
737
  })`;
738
+ /**
739
+ * Browser-side predicate: does a widget's committed text name the requested
740
+ * option as a whole phrase? A bare substring test credits "Astrophysics" for
741
+ * "Physics"; comparing on word boundaries (punctuation folded to spaces) keeps
742
+ * chip lists and "N items selected, X" announcements matching while a longer,
743
+ * different option no longer does.
744
+ */
745
+ const PROMPT_NAMES_OPTION_EXPR = `((committed, wanted) => {
746
+ const words = (s) => " " + (s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim() + " ";
747
+ const want = words(wanted);
748
+ return want.trim() === "" || words(committed).includes(want);
749
+ })`;
736
750
  /**
737
751
  * Browser-side expression reading what a prompt-selector widget ANNOUNCES it
738
752
  * committed, lowercased and whitespace-collapsed, or `""` when nothing is
@@ -764,6 +778,8 @@ const PROMPT_ANNOUNCED_VALUE_EXPR = `((w) => {
764
778
  let anode = w;
765
779
  for (let d = 0; d < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && anode; d++) {
766
780
  const list = anode.querySelector ? anode.querySelector('[data-automation-id="selectedItemList"]') : null;
781
+ const entries = list ? Array.from(list.querySelectorAll("*")).filter((e) => e.children.length === 0).map((e) => norm(e.textContent)).filter(Boolean) : [];
782
+ if (entries.length > 0) return entries.join(" , ");
767
783
  if (list && norm(list.textContent)) return norm(list.textContent);
768
784
  anode = anode.parentElement;
769
785
  }
@@ -933,26 +949,15 @@ const N16_TRUSTED_CLICK_TIMEOUT_MS = 3_000;
933
949
  /**
934
950
  * Shared pre-click centre + hit-test for a Stagehand-resolved selector, so
935
951
  * observe-act and the natural-language act attempts share one routine.
936
- * Returns a failure message when the control stays covered, null otherwise.
952
+ * Returns a failure message when the control cannot be centred and hit-tested
953
+ * (covered, unresolved, or the page cannot answer the probe), null otherwise.
937
954
  */
938
- async function centreObservedClickTarget(target, selector, onUnavailable, deferWhenMissing = false) {
955
+ async function centreObservedClickTarget(target, selector) {
939
956
  try {
940
957
  await (0, trusted_click_1.centreSelectorForClick)(target, selector);
941
958
  return null;
942
959
  }
943
960
  catch (err) {
944
- // A TypeError means the page cannot answer the hit-test probe (no
945
- // evaluate seam), not that the control is covered; defer to the act.
946
- if (err instanceof TypeError) {
947
- onUnavailable(`centre hit-test unavailable: ${(0, errors_1.toErrorMessage)(err)}`);
948
- return null;
949
- }
950
- // Stagehand's own resolution of a natural-language act can differ from the
951
- // observe result, so an unresolvable element there is the act's to judge.
952
- if (deferWhenMissing && (0, errors_1.toErrorMessage)(err).includes("is not in the document")) {
953
- onUnavailable(`centre target unresolved: ${(0, errors_1.toErrorMessage)(err)}`);
954
- return null;
955
- }
956
961
  return `observed click target not clickable: ${(0, errors_1.toErrorMessage)(err)}`;
957
962
  }
958
963
  }
@@ -1151,6 +1156,14 @@ function fieldValuesAtFailureExpr() {
1151
1156
  return out;
1152
1157
  })()`;
1153
1158
  }
1159
+ /**
1160
+ * Settles after a click using the target's own text signature and in-page
1161
+ * quiescence reader, so every post-click site decides from the same settle
1162
+ * signal instead of hand-building the readers.
1163
+ */
1164
+ async function settleWithPageQuiescence(page, target, signalCounter, preNetworkCount, submitShaped, preVisibleTextSignature) {
1165
+ await (0, click_settle_1.settleAfterClick)(page, signalCounter, preNetworkCount, submitShaped, async () => (await snapshotPage(target, signalCounter)).visibleTextSignature, preVisibleTextSignature, async () => target.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR));
1166
+ }
1154
1167
  /**
1155
1168
  * Captures the pre/post signal triple the submit-verify cascade diffs.
1156
1169
  * Accepts the optional `page` so a resolved child `FrameTarget` whose
@@ -2905,7 +2918,7 @@ async function judgeStepOutcome(params) {
2905
2918
  await (0, click_settle_1.settleAfterClick)(params.page, params.signalCounter, params.preNetworkCount, isStepSettleWaitShaped({ step, submitStep: false, isFinalStep, flowHasSubmitSemantics: false }), async () => {
2906
2919
  const text = await probeRawVisibleText(target);
2907
2920
  return `${text.length}:${text.slice(0, 200)}`;
2908
- }, params.preVisibleTextSignature);
2921
+ }, params.preVisibleTextSignature, async () => target.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR));
2909
2922
  const colon = params.preVisibleTextSignature.indexOf(":");
2910
2923
  const beforeText = (colon === -1 ? params.preVisibleTextSignature : params.preVisibleTextSignature.slice(colon + 1))
2911
2924
  .replace(/\s+/g, " ")
@@ -3557,8 +3570,8 @@ async function verifyFillReadback(target, selector, expectedValue) {
3557
3570
  if (!el) return null;
3558
3571
  const tag = el.tagName ? el.tagName.toLowerCase() : "";
3559
3572
  let actual = "";
3560
- const siblings = el.parentElement ? el.parentElement.querySelectorAll("[role='spinbutton']").length : 0;
3561
- if (el.getAttribute && el.getAttribute("role") === "spinbutton" && siblings > 1) {
3573
+ ${SEGMENT_OWNER_JS}
3574
+ if (isDateSegment(el)) {
3562
3575
  return { outcome: "segment", postValue: el.value || el.getAttribute("aria-valuenow") || "", tag };
3563
3576
  }
3564
3577
  if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
@@ -3624,8 +3637,8 @@ async function verifyPromptSelectorCommitted(target, selector, requestedOption)
3624
3637
  const emptyRx = new RegExp(${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)});
3625
3638
  const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
3626
3639
  const announcedValue = ${PROMPT_ANNOUNCED_VALUE_EXPR};
3627
- const squash = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
3628
- const want = squash(${JSON.stringify(requestedOption ?? "")});
3640
+ const namesOption = ${PROMPT_NAMES_OPTION_EXPR};
3641
+ const want = ${JSON.stringify(requestedOption ?? "")};
3629
3642
  const xpath = ${JSON.stringify(xpath)};
3630
3643
  const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3631
3644
  const el = result.singleNodeValue;
@@ -3633,7 +3646,7 @@ async function verifyPromptSelectorCommitted(target, selector, requestedOption)
3633
3646
  const widget = el.matches && el.matches(triggerSel) ? el : el.closest(triggerSel);
3634
3647
  if (!widget) return { isPromptWidget: false, committed: false };
3635
3648
  const value = announcedValue(widget) || currentText(widget, valueSel, emptyRx);
3636
- return { isPromptWidget: true, committed: value !== "" && (!want || squash(value).includes(want)) };
3649
+ return { isPromptWidget: true, committed: value !== "" && (!want || namesOption(value, want)) };
3637
3650
  })()`;
3638
3651
  try {
3639
3652
  const raw = await target.evaluate(expr);
@@ -5269,30 +5282,108 @@ function segmentedDateStepTokens(text) {
5269
5282
  return named ? [token] : [];
5270
5283
  });
5271
5284
  }
5285
+ const SEGMENTED_DATE_STEP_NOISE = new Set([
5286
+ "barnaclevalue",
5287
+ "click",
5288
+ "type",
5289
+ "enter",
5290
+ "fill",
5291
+ "set",
5292
+ "select",
5293
+ "input",
5294
+ "write",
5295
+ "put",
5296
+ "choose",
5297
+ "use",
5298
+ "press",
5299
+ "focus",
5300
+ "spinbutton",
5301
+ "part",
5302
+ "segment",
5303
+ "digits",
5304
+ "box",
5305
+ "first",
5306
+ "second",
5307
+ "into",
5308
+ "as",
5309
+ "by",
5310
+ "from",
5311
+ "to",
5312
+ "is",
5313
+ "be",
5314
+ "it",
5315
+ "its",
5316
+ "this",
5317
+ "that",
5318
+ "my",
5319
+ "your",
5320
+ ...SEGMENTED_DATE_STOPWORDS,
5321
+ ...SEGMENTED_DATE_FIELD_WORDS,
5322
+ ...SEGMENTED_DATE_PART_LABELS,
5323
+ ...SEGMENTED_DATE_FIELD_NOUNS,
5324
+ ]);
5272
5325
  /**
5273
5326
  * Pick which spinbutton group a segmented-date step targets. Groups whose
5274
5327
  * segment widths (digit count of `aria-valuemax`) don't sum to the value's
5275
5328
  * digit count are dropped. A group is only eligible when a field the step names
5276
5329
  * (quoted, e.g. 'From', or unquoted in field position, e.g. "the To date")
5277
5330
  * matches its OWN label as whole words — a group with no `label` is judged by
5278
- * its whole `context`. The section the step names ("Work Experience 1") is
5331
+ * its whole `context` — and every other quoted phrase is found in the group's
5332
+ * label or section. The section the step names ("Work Experience 1") is
5279
5333
  * then resolved: when a width-fitting group elsewhere matches the step's
5280
- * section words better than any eligible group does, the named section renders
5281
- * no such field and the step resolves to nothing rather than to another
5282
- * section's. Returns the index into `groups`, or `null` when nothing matches
5283
- * or the best match is tied — writing a date into a group that merely fits the
5284
- * width would corrupt another field.
5334
+ * section words better than any eligible group does, or the group's own
5335
+ * section shares none of the step's remaining words (or numbering), the named
5336
+ * section renders no such field and the step resolves to nothing rather than to
5337
+ * another section's. Returns the index into `groups`, or `null` when nothing
5338
+ * matches or the best match is tied — writing a date into a group that merely
5339
+ * fits the width would corrupt another field. Groups that hold the same
5340
+ * segments under different names never tie.
5285
5341
  */
5286
5342
  function resolveSegmentedDateGroup(instruction, groups) {
5287
5343
  const value = parseSegmentedDateValue(instruction);
5288
5344
  if (!value)
5289
5345
  return null;
5346
+ const compatible = (group) => group.segmentMaxes.map((max) => String(max).length).reduce((a, b) => a + b, 0) ===
5347
+ value.digits.length;
5348
+ return pickSegmentedDateGroup(instruction, groups, value, compatible);
5349
+ }
5350
+ const SEGMENTED_DATE_PART_BY_LABEL = {
5351
+ month: "month",
5352
+ mm: "month",
5353
+ day: "day",
5354
+ dd: "day",
5355
+ year: "year",
5356
+ yyyy: "year",
5357
+ yy: "year",
5358
+ };
5359
+ /**
5360
+ * Parse a per-segment step such as "type '01' into Month". Requires a quoted
5361
+ * 1-4 digit value AND exactly one part label (Month/MM, Day/DD, Year/YYYY), so
5362
+ * a bare short numeric (ZIP fragment, age) is never claimed.
5363
+ */
5364
+ function parseSegmentedPartStep(instruction) {
5365
+ const raw = parseFillValueIntent(instruction)?.value;
5366
+ if (!raw || !/^\d{1,4}$/.test(raw))
5367
+ return null;
5368
+ const parts = new Set(segmentedDateTokens(instruction.replace(raw, " ")).flatMap((t) => {
5369
+ const part = SEGMENTED_DATE_PART_BY_LABEL[t];
5370
+ return part ? [part] : [];
5371
+ }));
5372
+ const [part] = [...parts];
5373
+ return parts.size === 1 && part ? { digits: raw, valueSpan: raw, part } : null;
5374
+ }
5375
+ /** Index of the single segment in `group` whose own label names `part`, or null when none/ambiguous. */
5376
+ function segmentIndexForPart(group, part) {
5377
+ const hits = (group.segmentLabels ?? []).flatMap((label, si) => segmentedDateTokens(label).some((t) => SEGMENTED_DATE_PART_BY_LABEL[t] === part) ? [si] : []);
5378
+ return hits.length === 1 ? (hits[0] ?? null) : null;
5379
+ }
5380
+ function pickSegmentedDateGroup(instruction, groups, value, compatible) {
5290
5381
  const valueTokens = segmentedDateTokens(value.valueSpan).join(" ");
5291
5382
  const quoted = (0, quoted_spans_1.findQuotedSpans)(instruction)
5292
5383
  .map((s) => segmentedDateTokens(s.text.trim()))
5293
5384
  .filter((t) => t.length > 0 &&
5294
5385
  t.join(" ") !== valueTokens &&
5295
- !(t.length === 1 && SEGMENTED_DATE_PART_LABELS.has(t[0] ?? "")));
5386
+ !t.every((token) => SEGMENTED_DATE_PART_LABELS.has(token)));
5296
5387
  const stepTokens = segmentedDateStepTokens(instruction.replace(value.valueSpan, " barnaclevalue "));
5297
5388
  const targets = quoted.length > 0 ? quoted : [stepTokens];
5298
5389
  const contains = (hay, needle) => {
@@ -5302,8 +5393,6 @@ function resolveSegmentedDateGroup(instruction, groups) {
5302
5393
  }
5303
5394
  return false;
5304
5395
  };
5305
- const compatible = (group) => group.segmentMaxes.map((max) => String(max).length).reduce((a, b) => a + b, 0) ===
5306
- value.digits.length;
5307
5396
  const stepSet = new Set(stepTokens);
5308
5397
  const sectionScore = (group) => {
5309
5398
  const own = new Set(segmentedDateTokens(group.label ?? ""));
@@ -5313,10 +5402,33 @@ function resolveSegmentedDateGroup(instruction, groups) {
5313
5402
  !SEGMENTED_DATE_STOPWORDS.has(t) &&
5314
5403
  !SEGMENTED_DATE_FIELD_WORDS.has(t)).length;
5315
5404
  };
5405
+ const quotedTokens = new Set(quoted.flat());
5406
+ const leftover = stepTokens.filter((t) => !SEGMENTED_DATE_STEP_NOISE.has(t) && !quotedTokens.has(t));
5407
+ const isNumber = (t) => /^\d+$/.test(t);
5408
+ // A group under another heading is not the field the step names, however
5409
+ // well its label matches: the step's own words must find the section.
5410
+ // A section that only repeats the group's own label is no heading at all
5411
+ // (Workday enumerates "From*" as both), so it cannot name another section.
5412
+ const namesOtherSection = (group) => {
5413
+ const section = segmentedDateTokens(group.section ?? "");
5414
+ const label = segmentedDateTokens(group.label ?? "");
5415
+ if (section.length === 0 || contains(label, section))
5416
+ return false;
5417
+ const known = new Set([...section, ...label]);
5418
+ const words = leftover.filter((t) => !isNumber(t));
5419
+ const numbers = leftover.filter(isNumber);
5420
+ const sectionNumbers = section.filter(isNumber);
5421
+ if (numbers.length > 0 && sectionNumbers.length > 0) {
5422
+ if (!numbers.some((n) => sectionNumbers.includes(n)))
5423
+ return true;
5424
+ }
5425
+ if (words.some((t) => section.includes(t)))
5426
+ return false;
5427
+ return words.some((t) => !known.has(t));
5428
+ };
5316
5429
  const bestSection = Math.max(0, ...groups.filter(compatible).map((group) => sectionScore(group)));
5317
- let best = -1;
5318
5430
  let bestScore = -1;
5319
- let tied = false;
5431
+ let leaders = [];
5320
5432
  groups.forEach((group, index) => {
5321
5433
  if (!compatible(group))
5322
5434
  return;
@@ -5326,24 +5438,43 @@ function resolveSegmentedDateGroup(instruction, groups) {
5326
5438
  const namesOwnField = group.label === undefined &&
5327
5439
  quoted.length === 0 &&
5328
5440
  own.some((t) => SEGMENTED_DATE_FIELD_WORDS.has(t) && stepSet.has(t));
5329
- if (!namesOwnField &&
5330
- !targets.some((t) => contains(t, own) || (quoted.length > 0 && contains(own, t)))) {
5441
+ const namesOwnLabel = (t) => contains(t, own) || (quoted.length > 0 && contains(own, t));
5442
+ if (!namesOwnField && !targets.some(namesOwnLabel))
5443
+ return;
5444
+ const named = segmentedDateTokens(`${group.section ?? ""} ${group.context}`);
5445
+ if (quoted.some((t) => !namesOwnLabel(t) && !contains(named, t)))
5446
+ return;
5447
+ if (namesOtherSection(group))
5331
5448
  return;
5332
- }
5333
5449
  const score = sectionScore(group);
5334
5450
  if (score < bestSection)
5335
5451
  return;
5336
5452
  if (score > bestScore) {
5337
- best = index;
5338
5453
  bestScore = score;
5339
- tied = false;
5454
+ leaders = [index];
5340
5455
  }
5341
5456
  else if (score === bestScore) {
5342
- tied = true;
5457
+ const alias = (a, b) => (groups[a]?.sameAs ?? a) === (groups[b]?.sameAs ?? b) ||
5458
+ groups[a]?.sameAs === b ||
5459
+ groups[b]?.sameAs === a;
5460
+ const twin = leaders.findIndex((leader) => alias(leader, index));
5461
+ if (twin < 0)
5462
+ leaders.push(index);
5463
+ else
5464
+ leaders[twin] = Math.min(leaders[twin] ?? index, index);
5343
5465
  }
5344
5466
  });
5345
- if (best >= 0)
5346
- return tied ? null : best;
5467
+ if (leaders.length === 1)
5468
+ return leaders[0] ?? null;
5469
+ if (leaders.length > 1) {
5470
+ // A nested group is one wrapper's slice of a larger date widget (e.g. the
5471
+ // Year half of "From MM/YYYY"), so it only ties with a standalone group of
5472
+ // the same label when no section text separates them — Workday renders
5473
+ // "Work Experience 1 From" and "Education 1 From" with no heading the
5474
+ // enumerator can see. The standalone group is the field the step names.
5475
+ const standalone = leaders.filter((i) => !groups[i]?.nested);
5476
+ return standalone.length === 1 ? (standalone[0] ?? null) : null;
5477
+ }
5347
5478
  // A step that names no field at all can only mean the one date widget on the
5348
5479
  // page; a named field that matched no label must never fall back to it.
5349
5480
  if (quoted.length > 0 || stepTokens.some((t) => SEGMENTED_DATE_FIELD_WORDS.has(t)))
@@ -5351,12 +5482,12 @@ function resolveSegmentedDateGroup(instruction, groups) {
5351
5482
  const only = groups.flatMap((g, i) => (compatible(g) && !g.nested ? [i] : []));
5352
5483
  return only.length === 1 ? (only[0] ?? null) : null;
5353
5484
  }
5354
- const SEGMENTED_DATE_MARKER_ATTR = "data-barnacle-segdate";
5355
- /** Exported so tests can run the real DOM enumeration against markup. */
5356
- exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5357
- const attr = ${JSON.stringify(SEGMENTED_DATE_MARKER_ATTR)};
5358
- const stale = document.querySelectorAll("[" + attr + "]");
5359
- stale.forEach((el) => el.removeAttribute(attr));
5485
+ /**
5486
+ * In-page helpers shared by the date-segment enumeration and the fill
5487
+ * readback so "is this spinbutton one segment of a multi-segment widget" has a
5488
+ * single definition; defines `spins`, `ownerOf` and `isDateSegment`.
5489
+ */
5490
+ const SEGMENT_OWNER_JS = `
5360
5491
  const hiddenAttr = (node) => node.hasAttribute("hidden") || node.getAttribute("aria-hidden") === "true";
5361
5492
  const styleOf = (node) => {
5362
5493
  try { return typeof getComputedStyle === "function" ? getComputedStyle(node) : window.getComputedStyle(node); } catch { return null; }
@@ -5370,18 +5501,53 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5370
5501
  return false;
5371
5502
  };
5372
5503
  const spins = Array.from(document.querySelectorAll("input[role='spinbutton']")).filter((el) => !isHidden(el));
5373
- if (spins.length === 0) return { groups: [] };
5374
5504
  const norm = (s) => (s || "").replace(/\\s+/g, " ").trim();
5375
- const byContainer = new Map();
5376
- for (const el of spins) {
5377
- const box = el.closest("fieldset,[role='group']") || el.parentElement;
5378
- if (!byContainer.has(box)) byContainer.set(box, []);
5379
- byContainer.get(box).push(el);
5380
- }
5381
5505
  const idText = (ids) => (ids || "").split(/\\s+/).map((id) => {
5382
5506
  const ref = id ? document.getElementById(id) : null;
5383
5507
  return ref ? norm(ref.textContent) : "";
5384
5508
  }).join(" ");
5509
+ const legendOf = (node) => {
5510
+ const legend = node.tagName === "FIELDSET" ? node.querySelector(":scope > legend") : null;
5511
+ return legend ? norm(legend.textContent) : "";
5512
+ };
5513
+ const namedBy = (node) => [legendOf(node), norm(node.getAttribute("aria-label")), idText(node.getAttribute("aria-labelledby"))].filter(Boolean).join(" ");
5514
+ const tiedToSpin = (l) => l.querySelector("input,[role='spinbutton']") || (l.getAttribute("for") && spins.some((sp) => sp.id === l.getAttribute("for")));
5515
+ const hasCaption = (node) => namedBy(node) !== "" || Array.from(node.querySelectorAll("label,legend")).some((l) => !tiedToSpin(l));
5516
+ const captionOwnerOf = (el) => {
5517
+ const explicit = el.closest("fieldset,[role='group']");
5518
+ if (explicit) return explicit;
5519
+ for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
5520
+ if (hasCaption(node)) return node;
5521
+ }
5522
+ return null;
5523
+ };
5524
+ const bareSpins = spins.filter((sp) => !captionOwnerOf(sp));
5525
+ const hasOtherControl = (node) => Array.from(node.querySelectorAll("input,select,textarea")).some((c) => !spins.includes(c) && c.type !== "hidden");
5526
+ // Segments that each sit in their own uncaptioned wrapper share no
5527
+ // fieldset/caption, so the nearest ancestor holding several bare spinbuttons
5528
+ // (and no other form control) is the widget they belong to.
5529
+ const bareOwnerOf = (el) => {
5530
+ for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
5531
+ if (hasOtherControl(node)) break;
5532
+ if (bareSpins.filter((sp) => node.contains(sp)).length > 1) return node;
5533
+ }
5534
+ return el.parentElement;
5535
+ };
5536
+ const ownerOf = (el) => captionOwnerOf(el) || bareOwnerOf(el);
5537
+ const isDateSegment = (el) => {
5538
+ if (!el.getAttribute || el.getAttribute("role") !== "spinbutton" || !spins.includes(el)) return false;
5539
+ const box = ownerOf(el);
5540
+ return spins.filter((sp) => ownerOf(sp) === box).length > 1;
5541
+ };
5542
+ `;
5543
+ const SEGMENTED_DATE_MARKER_ATTR = "data-barnacle-segdate";
5544
+ /** Exported so tests can run the real DOM enumeration against markup. */
5545
+ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5546
+ const attr = ${JSON.stringify(SEGMENTED_DATE_MARKER_ATTR)};
5547
+ const stale = document.querySelectorAll("[" + attr + "]");
5548
+ stale.forEach((el) => el.removeAttribute(attr));
5549
+ ${SEGMENT_OWNER_JS}
5550
+ if (spins.length === 0) return { groups: [] };
5385
5551
  const headingSibling = (node) => {
5386
5552
  let prevSib = node.previousElementSibling;
5387
5553
  for (let hop = 0; hop < 3 && prevSib; hop++, prevSib = prevSib.previousElementSibling) {
@@ -5389,11 +5555,6 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5389
5555
  }
5390
5556
  return "";
5391
5557
  };
5392
- const legendOf = (node) => {
5393
- const legend = node.tagName === "FIELDSET" ? node.querySelector(":scope > legend") : null;
5394
- return legend ? norm(legend.textContent) : "";
5395
- };
5396
- const namedBy = (node) => [legendOf(node), norm(node.getAttribute("aria-label")), idText(node.getAttribute("aria-labelledby"))].filter(Boolean).join(" ");
5397
5558
  const sectionOf = (box, includeSelf) => {
5398
5559
  const parts = [includeSelf ? namedBy(box) : "", headingSibling(box)];
5399
5560
  let node = box.parentElement;
@@ -5408,11 +5569,12 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5408
5569
  const prev = box.previousElementSibling;
5409
5570
  return prev && prev.matches("label,legend") && !prev.querySelector("input,[role='spinbutton']") ? norm(prev.textContent) : "";
5410
5571
  };
5411
- const formFieldOf = (box) => box.closest("[data-automation-id^='formField-']");
5412
- const formFieldLabel = (field) => {
5413
- const label = field.querySelector("label,legend");
5414
- return label ? norm(label.textContent) : "";
5415
- };
5572
+ const byContainer = new Map();
5573
+ for (const el of spins) {
5574
+ const box = ownerOf(el);
5575
+ if (!byContainer.has(box)) byContainer.set(box, []);
5576
+ byContainer.get(box).push(el);
5577
+ }
5416
5578
  const labelText = (seg) => {
5417
5579
  const parts = [idText(seg.getAttribute("aria-labelledby")), norm(seg.getAttribute("aria-label")), norm(seg.getAttribute("placeholder"))];
5418
5580
  if (seg.id) {
@@ -5441,21 +5603,65 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5441
5603
  const addGroup = (segs, box, nested) => {
5442
5604
  const gi = groups.length;
5443
5605
  segs.forEach((seg, si) => mark(seg, gi + ":" + si));
5444
- const field = formFieldOf(box) || segs[0].closest("[data-automation-id^='formField-']");
5445
- const innerField = field && field !== box && box.contains(field) && segs.every((seg) => field.contains(seg)) ? formFieldLabel(field) : "";
5446
- const own = innerField || namedBy(box) || (field ? formFieldLabel(field) : "") || ownCaption(box) || segs.map((seg) => idText(seg.getAttribute("aria-labelledby"))).filter(Boolean).join(" ");
5447
- const section = sectionOf(box, Boolean(innerField));
5606
+ let inner = null;
5607
+ for (let node = segs[0].parentElement; node && node !== box && box.contains(node) && !inner; node = node.parentElement) {
5608
+ if (segs.every((seg) => node.contains(seg)) && hasCaption(node)) inner = node;
5609
+ }
5610
+ const labelBox = inner || box;
5611
+ const own = namedBy(labelBox) || ownCaption(labelBox) || segs.map((seg) => idText(seg.getAttribute("aria-labelledby"))).filter(Boolean).join(" ");
5612
+ const section = sectionOf(labelBox, Boolean(inner));
5448
5613
  groups.push({
5449
5614
  segmentMaxes: segs.map((seg, si) => maxOf(seg, si, segs.length)),
5450
5615
  context: [section, own].filter(Boolean).join(" "),
5451
5616
  label: own,
5452
5617
  section,
5453
5618
  nested,
5619
+ segmentLabels: segs.map((seg) => labelText(seg)),
5454
5620
  });
5455
5621
  };
5622
+ const PART_WORD = /^(mm|dd|yyyy|yy|month|day|year)$/i;
5623
+ const keyOf = (text) => norm(text).split(" ").filter((word) => {
5624
+ const parts = word.split(/[^A-Za-z0-9]+/).filter(Boolean);
5625
+ return parts.length > 0 && !parts.every((part) => PART_WORD.test(part));
5626
+ }).join(" ");
5627
+ const ownKey = (seg) => {
5628
+ const parts = [idText(seg.getAttribute("aria-labelledby")), norm(seg.getAttribute("aria-label"))];
5629
+ if (seg.id) {
5630
+ document.querySelectorAll("label").forEach((l) => { if (l.getAttribute("for") === seg.id) parts.push(norm(l.textContent)); });
5631
+ }
5632
+ const wrap = seg.closest("label");
5633
+ if (wrap) parts.push(norm(wrap.textContent));
5634
+ return keyOf(parts.filter(Boolean).join(" "));
5635
+ };
5636
+ // A container's legend names the whole section; the field a segment belongs
5637
+ // to is the caption naming it, or the caption nearest before it with no other
5638
+ // control in between.
5639
+ const fieldKeys = (segs, box) => {
5640
+ const own = new Set(segs);
5641
+ const boxLegend = box.tagName === "FIELDSET" ? box.querySelector(":scope > legend") : null;
5642
+ const keys = new Map();
5643
+ let current = "";
5644
+ box.querySelectorAll("label,legend,input,select,textarea,button").forEach((el) => {
5645
+ if (el === boxLegend) return;
5646
+ if (el.matches("label,legend")) {
5647
+ const target = el.getAttribute("for");
5648
+ if (isHidden(el) || el.querySelector("input,select,textarea,[role='spinbutton']") || (target && box.querySelector("[id='" + target + "']"))) return;
5649
+ current = keyOf(el.textContent);
5650
+ return;
5651
+ }
5652
+ if (!own.has(el)) {
5653
+ if (!isHidden(el)) current = "";
5654
+ return;
5655
+ }
5656
+ keys.set(el, ownKey(el) || current);
5657
+ });
5658
+ return segs.map((seg) => keys.get(seg) || "");
5659
+ };
5660
+ const captionRuns = [];
5456
5661
  const nestedCandidates = [];
5457
5662
  byContainer.forEach((segs, box) => {
5458
5663
  addGroup(segs, box, false);
5664
+ const outerIndex = groups.length - 1;
5459
5665
  const byParent = new Map();
5460
5666
  for (const seg of segs) {
5461
5667
  if (!byParent.has(seg.parentElement)) byParent.set(seg.parentElement, []);
@@ -5463,9 +5669,36 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5463
5669
  }
5464
5670
  // A shared fieldset can hold several independent dates (From and To year
5465
5671
  // spinbuttons side by side); each wrapper is then its own candidate group.
5466
- if (byParent.size > 1) byParent.forEach((inner, parent) => nestedCandidates.push([inner, parent]));
5672
+ if (byParent.size > 1) {
5673
+ byParent.forEach((inner, parent) => nestedCandidates.push([inner, parent]));
5674
+ return;
5675
+ }
5676
+ const keys = fieldKeys(segs, box);
5677
+ const runs = [];
5678
+ segs.forEach((seg, si) => {
5679
+ const last = runs[runs.length - 1];
5680
+ if (last && last.key === keys[si]) last.segs.push(seg);
5681
+ else runs.push({ key: keys[si], segs: [seg] });
5682
+ });
5683
+ runs.filter((run) => run.key).forEach((run) => captionRuns.push({ run, box, outerIndex, whole: run.segs.length === segs.length }));
5467
5684
  });
5468
5685
  nestedCandidates.forEach(([inner, parent]) => addGroup(inner, parent, true));
5686
+ captionRuns.forEach(({ run, box, outerIndex, whole }) => {
5687
+ const outer = groups[outerIndex];
5688
+ const same = (a, b) => a.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim() === b.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
5689
+ if (whole && same(outer.label || "", run.key)) return;
5690
+ const gi = groups.length;
5691
+ run.segs.forEach((seg, si) => mark(seg, gi + ":" + si));
5692
+ const section = sectionOf(box, true);
5693
+ groups.push({
5694
+ segmentMaxes: run.segs.map((seg, si) => maxOf(seg, si, run.segs.length)),
5695
+ context: [section, run.key].filter(Boolean).join(" "),
5696
+ label: run.key,
5697
+ section,
5698
+ nested: true,
5699
+ sameAs: whole ? outerIndex : undefined,
5700
+ });
5701
+ });
5469
5702
  return { groups };
5470
5703
  })()`;
5471
5704
  const SEGMENTED_DATE_CLEAR_EXPR = `(() => {
@@ -5484,15 +5717,29 @@ const SEGMENTED_DATE_CLEAR_EXPR = `(() => {
5484
5717
  */
5485
5718
  async function trySegmentedDateSpinbuttonPrimitive(params) {
5486
5719
  const { page, target, instruction, logger } = params;
5487
- const value = parseSegmentedDateValue(instruction);
5488
- if (!value)
5720
+ const whole = parseSegmentedDateValue(instruction);
5721
+ const part = parseSegmentedPartStep(instruction);
5722
+ if (!whole && !part)
5489
5723
  return null;
5490
- const sendCDP = (method, args) => target.frame?.session ? target.frame.session.send(method, args) : page.sendCDP(method, args);
5724
+ const sendCDP = frameCdpSender(page, target);
5491
5725
  try {
5492
5726
  const enumerated = (await target.evaluate(exports.SEGMENTED_DATE_ENUMERATE_EXPR));
5493
5727
  if (!enumerated?.groups?.length)
5494
5728
  return null;
5495
- const gi = resolveSegmentedDateGroup(instruction, enumerated.groups);
5729
+ const wholeGi = whole ? resolveSegmentedDateGroup(instruction, enumerated.groups) : null;
5730
+ const partStep = wholeGi === null ? part : null;
5731
+ const value = (wholeGi !== null ? whole : part);
5732
+ const partOf = (group) => partStep ? segmentIndexForPart(group, partStep.part) : null;
5733
+ const partFits = (group) => {
5734
+ const si = partOf(group);
5735
+ return (!group.nested &&
5736
+ si !== null &&
5737
+ value.digits.length <= String(group.segmentMaxes[si] ?? 0).length);
5738
+ };
5739
+ const gi = wholeGi ??
5740
+ (partStep
5741
+ ? pickSegmentedDateGroup(instruction, enumerated.groups, partStep, partFits)
5742
+ : null);
5496
5743
  if (gi === null) {
5497
5744
  logger.info("segmented date primitive: no unambiguous group; falling through");
5498
5745
  await target.evaluate(SEGMENTED_DATE_CLEAR_EXPR);
@@ -5500,10 +5747,15 @@ async function trySegmentedDateSpinbuttonPrimitive(params) {
5500
5747
  }
5501
5748
  const maxes = enumerated.groups[gi]?.segmentMaxes ?? [];
5502
5749
  const widths = maxes.map((max) => String(max).length);
5503
- const expected = widths.map((w, i) => {
5504
- const start = widths.slice(0, i).reduce((a, b) => a + b, 0);
5505
- return Number(value.digits.slice(start, start + w));
5506
- });
5750
+ const onlySegment = partStep ? partOf(enumerated.groups[gi]) : null;
5751
+ const active = widths.flatMap((_, si) => onlySegment === null || si === onlySegment ? [si] : []);
5752
+ const sliceFor = (si) => {
5753
+ if (onlySegment !== null)
5754
+ return value.digits;
5755
+ const start = widths.slice(0, si).reduce((a, b) => a + b, 0);
5756
+ return value.digits.slice(start, start + (widths[si] ?? 0));
5757
+ };
5758
+ const expected = active.map((si) => Number(sliceFor(si)));
5507
5759
  const segmentSelector = (si) => `[${SEGMENTED_DATE_MARKER_ATTR}~="${gi}:${si}"]`;
5508
5760
  const focusSegment = async (si) => {
5509
5761
  const selector = segmentSelector(si);
@@ -5516,7 +5768,9 @@ async function trySegmentedDateSpinbuttonPrimitive(params) {
5516
5768
  // scrolling is best-effort; the key events only need the segment focused
5517
5769
  }
5518
5770
  try {
5519
- if (await (0, trusted_click_1.isClickPointUnobscured)(target, selector)) {
5771
+ // An open option popup can cover the field; clear it so the trusted
5772
+ // click lands on the segment instead of falling back to a bare focus().
5773
+ if (await clearObscuringPopup(page, target, selector)) {
5520
5774
  await (0, trusted_click_1.clickCentredTrusted)(target, selector, { label: "date segment" });
5521
5775
  }
5522
5776
  }
@@ -5536,12 +5790,9 @@ async function trySegmentedDateSpinbuttonPrimitive(params) {
5536
5790
  await sendCDP("Input.dispatchKeyEvent", { type: "keyUp", ...keyArgs });
5537
5791
  }
5538
5792
  };
5539
- const segmentDigits = widths.map((w, i) => {
5540
- const from = widths.slice(0, i).reduce((acc, n) => acc + n, 0);
5541
- return value.digits.slice(from, from + w);
5542
- });
5793
+ const segmentDigits = active.map((si) => sliceFor(si));
5543
5794
  const readSegments = async () => (await target.evaluate(`(() => {
5544
- const sels = ${JSON.stringify(widths.map((_, si) => segmentSelector(si)))};
5795
+ const sels = ${JSON.stringify(active.map((si) => segmentSelector(si)))};
5545
5796
  return sels.map((sel) => {
5546
5797
  const s = document.querySelector(sel);
5547
5798
  if (!s) return NaN;
@@ -5558,15 +5809,18 @@ async function trySegmentedDateSpinbuttonPrimitive(params) {
5558
5809
  // locator.type (per-character key events, never Input.insertText) covers a
5559
5810
  // page whose CDP channel does not reach the widget.
5560
5811
  const typeViaLocator = async () => {
5561
- for (const [si, digits] of segmentDigits.entries()) {
5562
- await target.locator(segmentSelector(si)).first().type(digits, {
5812
+ for (const [k, digits] of segmentDigits.entries()) {
5813
+ await target
5814
+ .locator(segmentSelector(active[k] ?? 0))
5815
+ .first()
5816
+ .type(digits, {
5563
5817
  delay: SEGMENTED_DATE_KEY_DELAY_MS,
5564
5818
  });
5565
5819
  }
5566
5820
  };
5567
5821
  const typeViaKeyEvents = async () => {
5568
- for (const [si, digits] of segmentDigits.entries()) {
5569
- await focusSegment(si);
5822
+ for (const [k, digits] of segmentDigits.entries()) {
5823
+ await focusSegment(active[k] ?? 0);
5570
5824
  await typeViaCdp(digits);
5571
5825
  }
5572
5826
  };
@@ -5961,6 +6215,16 @@ function isNavigatingEvaluateRejection(error) {
5961
6215
  message.includes("no frame for given id found") ||
5962
6216
  GENERIC_CONTENT_FREE_EVAL_REJECTION_MESSAGES.has(message.trim()));
5963
6217
  }
6218
+ /**
6219
+ * Settles after a click and returns the post-click snapshot, so the settle
6220
+ * wait and the phantom classifier decide from the same reader and baseline.
6221
+ * Without a page there is nothing to wait on and the snapshot is taken as-is.
6222
+ */
6223
+ async function settleAndSnapshotPostClick(target, signalCounter, page, pre) {
6224
+ if (page)
6225
+ await settleWithPageQuiescence(page, target, signalCounter, pre.networkCount, true, pre.visibleTextSignature);
6226
+ return snapshotPage(target, signalCounter, page);
6227
+ }
5964
6228
  /**
5965
6229
  * Shared in-page discovery of the widget's callback, used identically by
5966
6230
  * `injectCaptchaTokenAndSubmit`'s precheck (to decide the branch) and its
@@ -6268,9 +6532,7 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
6268
6532
  if (clickResult.clicked) {
6269
6533
  if (!pre || !verification)
6270
6534
  return true;
6271
- if (verification.page)
6272
- await (0, click_settle_1.settleAfterClick)(verification.page, verification.signalCounter, pre.networkCount, true, async () => (await snapshotPage(target, verification.signalCounter)).visibleTextSignature, pre.visibleTextSignature);
6273
- const post = await snapshotPage(target, verification.signalCounter, verification.page);
6535
+ const post = await settleAndSnapshotPostClick(target, verification.signalCounter, verification.page, pre);
6274
6536
  const verdict = (0, phantom_click_1.classifyPhantomClick)({
6275
6537
  actResultSuccess: true,
6276
6538
  pre,
@@ -6291,9 +6553,7 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
6291
6553
  throw err;
6292
6554
  });
6293
6555
  if (runnerUpClickResult.clicked) {
6294
- if (verification.page)
6295
- await (0, click_settle_1.settleAfterClick)(verification.page, verification.signalCounter, post.networkCount, true, async () => (await snapshotPage(target, verification.signalCounter)).visibleTextSignature, post.visibleTextSignature);
6296
- const runnerUpPost = await snapshotPage(target, verification.signalCounter, verification.page);
6556
+ const runnerUpPost = await settleAndSnapshotPostClick(target, verification.signalCounter, verification.page, post);
6297
6557
  const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
6298
6558
  actResultSuccess: true,
6299
6559
  pre: post,
@@ -6839,7 +7099,10 @@ async function tryCheckboxStatePrimitive(params) {
6839
7099
  let overlaid = false;
6840
7100
  let trustedDelivered = false;
6841
7101
  for (const sel of clickSels) {
6842
- const probe = await (0, trusted_click_1.probeClickPoint)(target, sel);
7102
+ const firstProbe = await (0, trusted_click_1.probeClickPoint)(target, sel);
7103
+ const probe = firstProbe?.state === "occluded" && firstProbe.occluder !== "none"
7104
+ ? await dismissLeftoverPromptPopups(page, target).then(() => (0, trusted_click_1.probeClickPoint)(target, sel), () => firstProbe)
7105
+ : firstProbe;
6843
7106
  if (probe?.state === "occluded" && probe.occluder !== "none") {
6844
7107
  overlaid = true;
6845
7108
  continue;
@@ -7897,7 +8160,7 @@ async function tryPromptSelectorPrimitive(params) {
7897
8160
  logger.info(`prompt-selector primitive: filtered list for ${optLabel} names no matching option; submitting with Enter`);
7898
8161
  try {
7899
8162
  await target.evaluate(`(() => { const el = document.querySelector('[${PROMPT_OPTION_MARK_ATTR}-search="1"]'); if (el) el.focus(); })()`);
7900
- await sendTrustedEnter(page);
8163
+ await sendTrustedEnter(page, target);
7901
8164
  }
7902
8165
  catch (err) {
7903
8166
  logger.info(`prompt-selector primitive: enter submit failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}`);
@@ -7940,7 +8203,7 @@ const PROMPT_SELECTOR_MAX_STABLE_READS = 5;
7940
8203
  * committer is never handed an unsettled list.
7941
8204
  */
7942
8205
  async function readStablePromptOptions(page, target, enumerateOptionsExpr, initial) {
7943
- const key = (r) => JSON.stringify((r?.options ?? []).map((o) => [o.text, o.nodeId ?? null]));
8206
+ const key = (r) => JSON.stringify((r?.options ?? []).map((o) => [o.text, o.domText ?? null]));
7944
8207
  let current = initial;
7945
8208
  for (let read = 0; read < PROMPT_SELECTOR_MAX_STABLE_READS; read++) {
7946
8209
  await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
@@ -7979,33 +8242,78 @@ function promptOptionsNameOption(result, option) {
7979
8242
  const want = normPromptOptionText(option);
7980
8243
  if (want === "")
7981
8244
  return true;
7982
- return (result?.options ?? []).some((o) => {
8245
+ return realPromptOptions(result?.options ?? [], option).some((o) => {
8246
+ const text = normPromptOptionText(o.text);
8247
+ return text === want || text.includes(want);
8248
+ });
8249
+ }
8250
+ /**
8251
+ * Single classification of which rendered entries are real options, shared by
8252
+ * the submit decision and the committer so they cannot disagree. Placeholders
8253
+ * ("No Items.", "No results for x") are dropped, except an entry that is
8254
+ * exactly the requested text, which is the user's own option.
8255
+ */
8256
+ function realPromptOptions(options, option) {
8257
+ const want = normPromptOptionText(option);
8258
+ return options.filter((o) => {
7983
8259
  const text = normPromptOptionText(o.text);
7984
8260
  if (text === "")
7985
8261
  return false;
7986
- if (text === want)
7987
- return true;
7988
- if (PROMPT_PLACEHOLDER_RE.test(text))
7989
- return false;
7990
- return text.includes(want);
8262
+ return text === want || !PROMPT_PLACEHOLDER_RE.test(text);
7991
8263
  });
7992
8264
  }
7993
- /** Dispatches a trusted Enter to the focused element (the filter input) via CDP. */
7994
- async function sendTrustedEnter(page) {
7995
- const key = { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 };
7996
- await page.sendCDP("Input.dispatchKeyEvent", { type: "keyDown", text: "\r", ...key });
7997
- await page.sendCDP("Input.dispatchKeyEvent", { type: "keyUp", ...key });
8265
+ /**
8266
+ * Single owner of CDP delivery for trusted input: a child-frame target's own
8267
+ * session when it has one, otherwise the page's, so keys reach the frame that
8268
+ * holds the focused element.
8269
+ */
8270
+ function frameCdpSender(page, target) {
8271
+ return (method, args) => target.frame?.session ? target.frame.session.send(method, args) : page.sendCDP(method, args);
7998
8272
  }
7999
- /** Dispatches a trusted Escape to the focused element via CDP, closing a popup left open. */
8000
- async function sendTrustedEscape(page) {
8001
- const key = {
8273
+ /**
8274
+ * Clicks at a frame-local viewport point through the same CDP route as keys, so
8275
+ * the press lands where the hit test just ran instead of re-resolving a locator.
8276
+ * A same-process child frame has no session of its own, so its offset in the
8277
+ * page is added to reach page coordinates.
8278
+ */
8279
+ async function dispatchTrustedClick(page, target, x, y) {
8280
+ if (target.frame === null) {
8281
+ await page.click(x, y);
8282
+ return;
8283
+ }
8284
+ const send = frameCdpSender(page, target);
8285
+ const offset = target.frame.session
8286
+ ? { x: 0, y: 0 }
8287
+ : ((await target
8288
+ .evaluate(`(() => {
8289
+ const f = window.frameElement;
8290
+ if (!f) return null;
8291
+ const r = f.getBoundingClientRect();
8292
+ return { x: r.left, y: r.top };
8293
+ })()`)
8294
+ .catch(() => null)) ?? { x: 0, y: 0 });
8295
+ const point = { x: x + offset.x, y: y + offset.y, button: "left", clickCount: 1 };
8296
+ await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y });
8297
+ await send("Input.dispatchMouseEvent", { type: "mousePressed", ...point });
8298
+ await send("Input.dispatchMouseEvent", { type: "mouseReleased", ...point });
8299
+ }
8300
+ async function sendTrustedKey(page, target, key, text) {
8301
+ const send = frameCdpSender(page, target);
8302
+ await send("Input.dispatchKeyEvent", { type: "keyDown", ...(text ? { text } : {}), ...key });
8303
+ await send("Input.dispatchKeyEvent", { type: "keyUp", ...key });
8304
+ }
8305
+ /** Dispatches a trusted Enter to the focused element (the filter input) via the target frame's CDP session. */
8306
+ async function sendTrustedEnter(page, target) {
8307
+ await sendTrustedKey(page, target, { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, "\r");
8308
+ }
8309
+ /** Dispatches a trusted Escape to the focused element via the target frame's CDP session, closing a popup left open. */
8310
+ async function sendTrustedEscape(page, target) {
8311
+ await sendTrustedKey(page, target, {
8002
8312
  key: "Escape",
8003
8313
  code: "Escape",
8004
8314
  windowsVirtualKeyCode: 27,
8005
8315
  nativeVirtualKeyCode: 27,
8006
- };
8007
- await page.sendCDP("Input.dispatchKeyEvent", { type: "keyDown", ...key });
8008
- await page.sendCDP("Input.dispatchKeyEvent", { type: "keyUp", ...key });
8316
+ });
8009
8317
  }
8010
8318
  /**
8011
8319
  * Counts rendered listbox / activeListContainer popups other than the chip
@@ -8049,18 +8357,52 @@ async function dismissLeftoverPromptPopups(page, target) {
8049
8357
  .catch(() => 0));
8050
8358
  if (!count)
8051
8359
  return;
8052
- logger.info(`prompt-selector primitive: dismissing ${count} leftover popup(s) via Escape`);
8053
- const trusted = await sendTrustedEscape(page)
8360
+ logger.info(`popup dismissal: dismissing ${count} leftover popup(s) via Escape`);
8361
+ const trusted = await sendTrustedEscape(page, target)
8054
8362
  .then(() => true)
8055
8363
  .catch((err) => {
8056
- logger.info(`prompt-selector primitive: Escape dispatch failed: ${(0, errors_1.toErrorMessage)(err)}`);
8364
+ logger.info(`popup dismissal: Escape dispatch failed: ${(0, errors_1.toErrorMessage)(err)}`);
8057
8365
  return false;
8058
8366
  });
8059
8367
  if (!trusted || attempt > 0)
8060
8368
  await target.evaluate(IN_PAGE_ESCAPE_EXPR).catch(() => undefined);
8061
- await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS).catch(() => undefined);
8369
+ await settleAfterEscape(page);
8062
8370
  }
8063
8371
  }
8372
+ /** Waits for a dismissed popup to unpaint; a page without a timer seam just proceeds. */
8373
+ async function settleAfterEscape(page) {
8374
+ await Promise.resolve()
8375
+ .then(() => page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS))
8376
+ .catch(() => undefined);
8377
+ }
8378
+ /**
8379
+ * Clears whatever popup paints over `selector`'s click point so the caller's
8380
+ * trusted click lands on the field. The hit test, not the popup's markup, is
8381
+ * the evidence: a named occluder gets the listbox-aware dismissal first and
8382
+ * then a plain Escape for popups that dismissal does not recognise. Resolves
8383
+ * true only when a fresh hit test reaches the field; an indeterminate probe
8384
+ * (no layout, nothing at the point) only ever gets the listbox-aware
8385
+ * dismissal, never a blind Escape.
8386
+ */
8387
+ async function clearObscuringPopup(page, target, selector) {
8388
+ const covered = (probe) => probe?.state === "occluded" && probe.occluder !== undefined && probe.occluder !== "none";
8389
+ const first = await (0, trusted_click_1.probeClickPoint)(target, selector);
8390
+ if (first?.state === "hit")
8391
+ return true;
8392
+ await dismissLeftoverPromptPopups(page, target).catch(() => undefined);
8393
+ const afterListbox = await (0, trusted_click_1.probeClickPoint)(target, selector);
8394
+ if (afterListbox?.state === "hit")
8395
+ return true;
8396
+ if (!covered(afterListbox))
8397
+ return false;
8398
+ const trusted = await sendTrustedEscape(page, target)
8399
+ .then(() => true)
8400
+ .catch(() => false);
8401
+ if (!trusted)
8402
+ await target.evaluate(IN_PAGE_ESCAPE_EXPR).catch(() => undefined);
8403
+ await settleAfterEscape(page);
8404
+ return (await (0, trusted_click_1.probeClickPoint)(target, selector))?.state === "hit";
8405
+ }
8064
8406
  /**
8065
8407
  * Phase 3 of {@link tryPromptSelectorPrimitive}: given the popup's rendered
8066
8408
  * (possibly filtered) option list, match the requested option — deterministic
@@ -8091,7 +8433,7 @@ async function commitPromptOption(params) {
8091
8433
  .join("");
8092
8434
  let optionsResult = params.optionsResult;
8093
8435
  for (let depth = 0; depth <= PROMPT_SELECTOR_MAX_DRILL_DEPTH; depth++) {
8094
- const options = optionsResult.options ?? [];
8436
+ const options = realPromptOptions(optionsResult.options ?? [], option);
8095
8437
  const detMatch = options.find((o) => norm(o.text) === wantOpt) ?? null;
8096
8438
  if (detMatch === null && (anthropic === null || options.length === 0)) {
8097
8439
  logger.info(`prompt-selector primitive: no option match for ${optLabel}${anthropic === null ? " (no LLM client)" : ""}; falling through to cascade`);
@@ -8129,7 +8471,7 @@ async function commitPromptOption(params) {
8129
8471
  let clicked = false;
8130
8472
  for (let attempt = 0; attempt < PROMPT_SELECTOR_MAX_CLICK_RESOLVES && !clicked; attempt++) {
8131
8473
  const fresh = (await target.evaluate(enumerateOptionsExpr).catch(() => null));
8132
- const freshMatches = (fresh?.options ?? []).filter((o) => norm(o.text) === wantChosenText);
8474
+ const freshMatches = realPromptOptions(fresh?.options ?? [], option).filter((o) => norm(o.text) === wantChosenText);
8133
8475
  const freshOption = freshMatches.find((o) => o.nodeId !== undefined && o.nodeId === chosenOption.nodeId) ??
8134
8476
  freshMatches[0];
8135
8477
  if (!freshOption) {
@@ -8152,6 +8494,19 @@ async function commitPromptOption(params) {
8152
8494
  await (0, trusted_click_1.clickCentredTrusted)(target, optionSel, {
8153
8495
  label: "prompt-selector option",
8154
8496
  expectText: expectedDomText,
8497
+ clickAtPoint: async (x, y) => {
8498
+ const stillThere = await target
8499
+ .evaluate(`((x, y, want) => {
8500
+ const hit = document.elementFromPoint(x, y);
8501
+ const el = hit && hit.closest(${JSON.stringify(optionSel)});
8502
+ return !!el && (el.textContent || "").replace(/\\s+/g, " ").trim() === want;
8503
+ })(${x}, ${y}, ${JSON.stringify(expectedDomText)})`)
8504
+ .catch(() => false);
8505
+ if (!stillThere) {
8506
+ throw new trusted_click_1.CentredClickError("centred click: prompt-selector option identity mismatch at click point, click withheld", "mismatch");
8507
+ }
8508
+ await dispatchTrustedClick(page, target, x, y);
8509
+ },
8155
8510
  verifyBeforeClick: async () => (await target
8156
8511
  .evaluate(`((sel, want) => {
8157
8512
  const el = document.querySelector(sel);
@@ -8180,6 +8535,7 @@ async function commitPromptOption(params) {
8180
8535
  const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
8181
8536
  const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
8182
8537
  const announcedValue = ${PROMPT_ANNOUNCED_VALUE_EXPR};
8538
+ const namesOption = ${PROMPT_NAMES_OPTION_EXPR};
8183
8539
  const w = document.querySelector("[" + markAttr + '="' + wIdx + '"]');
8184
8540
  if (!w) return { ok: false, id: "", stillInvalid: false };
8185
8541
  const text = currentText(w, valueSel, emptyRx);
@@ -8238,7 +8594,7 @@ async function commitPromptOption(params) {
8238
8594
  // The widget's own announcement of what it committed is ground truth when readable.
8239
8595
  const committed = announcedValue(w);
8240
8596
  if (committed) {
8241
- return { ok: !!wantText && committed.includes(wantText), id: w.id || "", stillInvalid, committed };
8597
+ return { ok: !!wantText && namesOption(committed, wantText), id: w.id || "", stillInvalid, committed };
8242
8598
  }
8243
8599
  // A wrong option leaves the widget valid, so !stillInvalid proves nothing
8244
8600
  // once the widget's own value is readable: a readable value naming another
@@ -9520,7 +9876,7 @@ async function probeChildFrameSubmitFallback(params) {
9520
9876
  await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, true, async () => {
9521
9877
  const snap = await snapshotPage(target, signalCounter);
9522
9878
  return snap.visibleTextSignature;
9523
- }, pre.visibleTextSignature);
9879
+ }, pre.visibleTextSignature, async () => target.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR));
9524
9880
  const post = await snapshotPage(target, signalCounter, page);
9525
9881
  const verdict = (0, phantom_click_1.classifyPhantomClick)({
9526
9882
  actResultSuccess: true,
@@ -9541,7 +9897,7 @@ async function probeChildFrameSubmitFallback(params) {
9541
9897
  await (0, click_settle_1.settleAfterClick)(page, signalCounter, post.networkCount, true, async () => {
9542
9898
  const snap = await snapshotPage(target, signalCounter);
9543
9899
  return snap.visibleTextSignature;
9544
- }, post.visibleTextSignature);
9900
+ }, post.visibleTextSignature, async () => target.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR));
9545
9901
  const runnerUpPost = await snapshotPage(target, signalCounter, page);
9546
9902
  const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
9547
9903
  actResultSuccess: true,
@@ -10299,7 +10655,7 @@ async function executeStepWithHealing(params) {
10299
10655
  });
10300
10656
  if (backendErrorUrl !== null) {
10301
10657
  logger.error(`${formatStepPrefix(stepIndex, totalSteps)} backend error detected (submit endpoint returned 5xx: ${backendErrorUrl}); aborting cascade`);
10302
- throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactSecrets)(step).slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
10658
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactPii)(step).slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
10303
10659
  }
10304
10660
  // Bounded same-origin child-iframe fallback: the probe above only ever
10305
10661
  // looked at the resolved (declared-or-main) frame's own light+shadow DOM,
@@ -10384,7 +10740,7 @@ async function executeStepWithHealing(params) {
10384
10740
  targetResolutionDiagnostic,
10385
10741
  fieldValuesAtFailure,
10386
10742
  }) ?? null;
10387
- throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactSecrets)(step).slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
10743
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactPii)(step).slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
10388
10744
  }
10389
10745
  // Pre-submit form-validity probe. Fires on the canonical submit step
10390
10746
  // (either the final flow step OR a step explicitly flagged `submitStep:
@@ -10766,13 +11122,13 @@ async function executeStepWithHealing(params) {
10766
11122
  // control stays covered so the attempt fails instead of clicking an overlay.
10767
11123
  // Resolves true once the observe ran, so guardedAct skips its own pass.
10768
11124
  const preCentreNaturalLanguageClick = async (instruction) => {
10769
- if (!stagehand_guard_1.CLICK_INTENT_RE.test(instruction))
11125
+ if (!(0, stagehand_guard_1.hasClickIntent)(instruction))
10770
11126
  return false;
10771
- const observed = await (0, stagehand_guard_1.guardedObserve)(stagehand, instruction, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
11127
+ const observed = await (0, stagehand_guard_1.guardedObserve)(stagehand, instruction, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget);
10772
11128
  const first = observed[0];
10773
- if (first?.method !== "click" || !first.selector)
11129
+ if (!(0, stagehand_guard_1.isTrustedClickMethod)(first?.method) || !first?.selector)
10774
11130
  return true;
10775
- const failure = await centreObservedClickTarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), first.selector, (msg) => logger.debug(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${msg}`), true);
11131
+ const failure = await centreObservedClickTarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), first.selector);
10776
11132
  if (failure)
10777
11133
  throw new Error(failure);
10778
11134
  return true;
@@ -10951,11 +11307,15 @@ async function executeStepWithHealing(params) {
10951
11307
  // burn the step budget probing all of them.
10952
11308
  const runnerUp = ranked[1];
10953
11309
  if (runnerUp) {
10954
- await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, true, async () => {
10955
- const snap = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
10956
- return snap.visibleTextSignature;
10957
- }, pre.visibleTextSignature);
10958
- const midPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
11310
+ const midTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
11311
+ const midPost = await (0, click_settle_1.settleAndSnapshot)({
11312
+ page,
11313
+ counter: signalCounter,
11314
+ pre,
11315
+ submitShaped: true,
11316
+ snapshot: (withPage) => snapshotPage(midTarget, signalCounter, withPage ? page : undefined),
11317
+ readQuiescence: async () => midTarget.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR),
11318
+ });
10959
11319
  const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
10960
11320
  actResultSuccess: true,
10961
11321
  pre,
@@ -11370,8 +11730,8 @@ async function executeStepWithHealing(params) {
11370
11730
  // bars, so centre + hit-test the observed control first; an
11371
11731
  // unrecoverable occlusion fails the attempt instead of clicking an
11372
11732
  // overlay.
11373
- if (overriddenTarget.method === "click") {
11374
- const centreFailure = await centreObservedClickTarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), overriddenTarget.selector, (msg) => logger.debug(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${msg}`));
11733
+ if ((0, stagehand_guard_1.isTrustedClickMethod)(overriddenTarget.method)) {
11734
+ const centreFailure = await centreObservedClickTarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), overriddenTarget.selector);
11375
11735
  if (centreFailure) {
11376
11736
  record.errorMessage = centreFailure;
11377
11737
  attempts.push(record);
@@ -11716,10 +12076,7 @@ async function executeStepWithHealing(params) {
11716
12076
  : !STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
11717
12077
  if (mayTriggerRoundTrip) {
11718
12078
  const settleTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
11719
- await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, isSettleWaitStep, async () => {
11720
- const snap = await snapshotPage(settleTarget, signalCounter);
11721
- return snap.visibleTextSignature;
11722
- }, pre.visibleTextSignature);
12079
+ await settleWithPageQuiescence(page, settleTarget, signalCounter, pre.networkCount, isSettleWaitStep, pre.visibleTextSignature);
11723
12080
  }
11724
12081
  const post = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
11725
12082
  record.post = post;
@@ -12383,10 +12740,7 @@ async function executeStepWithHealing(params) {
12383
12740
  probeResult.checked === true &&
12384
12741
  (probeResult.ariaManaged === true || !ancestorStillInvalid);
12385
12742
  await page.waitForTimeout(STEP_PAUSE_MS);
12386
- await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, isSettleWaitStep, async () => {
12387
- const snap = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
12388
- return snap.visibleTextSignature;
12389
- }, pre.visibleTextSignature);
12743
+ await settleWithPageQuiescence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, pre.networkCount, isSettleWaitStep, pre.visibleTextSignature);
12390
12744
  const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
12391
12745
  const retryNetworkFired = retryPost.networkCount > pre.networkCount;
12392
12746
  const retryMidFlowSubmit = isMidFlowSubmitShapedStep({
@@ -13093,7 +13447,7 @@ async function executeStepWithHealing(params) {
13093
13447
  if (dumpPath !== null) {
13094
13448
  logger.error(`${formatStepPrefix(stepIndex, totalSteps)} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);
13095
13449
  }
13096
- throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactSecrets)(step).slice(0, 60)}) failed verification after ${MAX_STEP_ATTEMPTS} attempts${dumpPath ? `; see ${dumpPath}` : ""}`, phantomClickAfterAttempt1 ? "phantom-click-exhausted" : "cascade-exhausted");
13450
+ throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${(0, pii_redaction_1.redactPii)(step).slice(0, 60)}) failed verification after ${MAX_STEP_ATTEMPTS} attempts${dumpPath ? `; see ${dumpPath}` : ""}`, phantomClickAfterAttempt1 ? "phantom-click-exhausted" : "cascade-exhausted");
13097
13451
  }
13098
13452
  /**
13099
13453
  * Navigates `page` directly to `url` and waits for the SPA to hydrate,