@enricai/barnacle 1.12.80 → 1.12.82

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 (55) hide show
  1. package/dist/lib/llm/judges/verify-step-advanced.d.ts +1 -1
  2. package/dist/lib/llm/judges/verify-step-advanced.d.ts.map +1 -1
  3. package/dist/lib/llm/judges/verify-step-advanced.js +2 -1
  4. package/dist/lib/llm/judges/verify-step-advanced.js.map +1 -1
  5. package/dist/plugins/config-plugin.d.ts +1 -0
  6. package/dist/plugins/config-plugin.d.ts.map +1 -1
  7. package/dist/plugins/config-plugin.js +7 -1
  8. package/dist/plugins/config-plugin.js.map +1 -1
  9. package/dist/plugins/loader.d.ts +2 -2
  10. package/dist/plugins/loader.d.ts.map +1 -1
  11. package/dist/plugins/loader.js +6 -3
  12. package/dist/plugins/loader.js.map +1 -1
  13. package/dist/scraper/click-settle.d.ts +41 -1
  14. package/dist/scraper/click-settle.d.ts.map +1 -1
  15. package/dist/scraper/click-settle.js +84 -15
  16. package/dist/scraper/click-settle.js.map +1 -1
  17. package/dist/scraper/errors.d.ts +23 -2
  18. package/dist/scraper/errors.d.ts.map +1 -1
  19. package/dist/scraper/errors.js +32 -4
  20. package/dist/scraper/errors.js.map +1 -1
  21. package/dist/scraper/flow-runner.d.ts +68 -33
  22. package/dist/scraper/flow-runner.d.ts.map +1 -1
  23. package/dist/scraper/flow-runner.js +610 -198
  24. package/dist/scraper/flow-runner.js.map +1 -1
  25. package/dist/scraper/http-client.d.ts +7 -2
  26. package/dist/scraper/http-client.d.ts.map +1 -1
  27. package/dist/scraper/http-client.js +132 -19
  28. package/dist/scraper/http-client.js.map +1 -1
  29. package/dist/scraper/metrics.d.ts +2 -2
  30. package/dist/scraper/metrics.js +2 -2
  31. package/dist/scraper/phantom-click.d.ts +6 -0
  32. package/dist/scraper/phantom-click.d.ts.map +1 -1
  33. package/dist/scraper/phantom-click.js +62 -15
  34. package/dist/scraper/phantom-click.js.map +1 -1
  35. package/dist/scraper/stagehand-guard.d.ts +12 -2
  36. package/dist/scraper/stagehand-guard.d.ts.map +1 -1
  37. package/dist/scraper/stagehand-guard.js +51 -33
  38. package/dist/scraper/stagehand-guard.js.map +1 -1
  39. package/dist/scraper/trusted-click.d.ts +12 -1
  40. package/dist/scraper/trusted-click.d.ts.map +1 -1
  41. package/dist/scraper/trusted-click.js +8 -4
  42. package/dist/scraper/trusted-click.js.map +1 -1
  43. package/dist/scraper/upload-affordance-expr.js +1 -1
  44. package/dist/scraper/upload-affordance-expr.js.map +1 -1
  45. package/dist/scripts/recon-generate.d.ts +5 -7
  46. package/dist/scripts/recon-generate.d.ts.map +1 -1
  47. package/dist/scripts/recon-generate.js +123 -52
  48. package/dist/scripts/recon-generate.js.map +1 -1
  49. package/dist/scripts/recon-shared.d.ts +3 -2
  50. package/dist/scripts/recon-shared.d.ts.map +1 -1
  51. package/dist/scripts/recon-shared.js +6 -11
  52. package/dist/scripts/recon-shared.js.map +1 -1
  53. package/dist/site-plugin.d.ts +7 -4
  54. package/dist/site-plugin.d.ts.map +1 -1
  55. package/package.json +1 -1
@@ -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;
@@ -77,6 +78,7 @@ exports.verifyFillReadback = verifyFillReadback;
77
78
  exports.verifyPromptSelectorCommitted = verifyPromptSelectorCommitted;
78
79
  exports.tailRetargetPhrasesForStep = tailRetargetPhrasesForStep;
79
80
  exports.stepQuotesValueNotControlName = stepQuotesValueNotControlName;
81
+ exports.waitForUploadNetworkSignal = waitForUploadNetworkSignal;
80
82
  exports.isUploadAffordanceLabel = isUploadAffordanceLabel;
81
83
  exports.writeFixtureToTempFile = writeFixtureToTempFile;
82
84
  exports.attachToSurfacedInput = attachToSurfacedInput;
@@ -88,6 +90,7 @@ exports.parseFillStep = parseFillStep;
88
90
  exports.parseFillValueIntent = parseFillValueIntent;
89
91
  exports.parseSegmentedDateValue = parseSegmentedDateValue;
90
92
  exports.resolveSegmentedDateGroup = resolveSegmentedDateGroup;
93
+ exports.parseSegmentedPartStep = parseSegmentedPartStep;
91
94
  exports.trySegmentedDateSpinbuttonPrimitive = trySegmentedDateSpinbuttonPrimitive;
92
95
  exports.parseRadioStep = parseRadioStep;
93
96
  exports.parseWidgetOptionClickStep = parseWidgetOptionClickStep;
@@ -391,6 +394,8 @@ function wireSignalCapture(page, params) {
391
394
  method: capture.method,
392
395
  status: capture.status,
393
396
  url: capture.url,
397
+ hasBody: (req.requestPostData ?? "").length > 0,
398
+ multipart: /^multipart\//i.test(requestContentType(req.requestHeaders)),
394
399
  });
395
400
  if (recentCaptureMeta.length > RECENT_CAPTURES_WINDOW) {
396
401
  recentCaptureMeta.splice(0, recentCaptureMeta.length - RECENT_CAPTURES_WINDOW);
@@ -733,6 +738,18 @@ const PROMPT_CURRENT_TEXT_EXPR = `((w, valueSel, emptyRx) => {
733
738
  if (raw.trim() && !emptyRx.test(raw.trim())) return norm(raw);
734
739
  return "";
735
740
  })`;
741
+ /**
742
+ * Browser-side predicate: does a widget's committed text name the requested
743
+ * option as a whole phrase? A bare substring test credits "Astrophysics" for
744
+ * "Physics"; comparing on word boundaries (punctuation folded to spaces) keeps
745
+ * chip lists and "N items selected, X" announcements matching while a longer,
746
+ * different option no longer does.
747
+ */
748
+ const PROMPT_NAMES_OPTION_EXPR = `((committed, wanted) => {
749
+ const words = (s) => " " + (s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim() + " ";
750
+ const want = words(wanted);
751
+ return want.trim() === "" || words(committed).includes(want);
752
+ })`;
736
753
  /**
737
754
  * Browser-side expression reading what a prompt-selector widget ANNOUNCES it
738
755
  * committed, lowercased and whitespace-collapsed, or `""` when nothing is
@@ -764,6 +781,8 @@ const PROMPT_ANNOUNCED_VALUE_EXPR = `((w) => {
764
781
  let anode = w;
765
782
  for (let d = 0; d < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && anode; d++) {
766
783
  const list = anode.querySelector ? anode.querySelector('[data-automation-id="selectedItemList"]') : null;
784
+ const entries = list ? Array.from(list.querySelectorAll("*")).filter((e) => e.children.length === 0).map((e) => norm(e.textContent)).filter(Boolean) : [];
785
+ if (entries.length > 0) return entries.join(" , ");
767
786
  if (list && norm(list.textContent)) return norm(list.textContent);
768
787
  anode = anode.parentElement;
769
788
  }
@@ -933,26 +952,15 @@ const N16_TRUSTED_CLICK_TIMEOUT_MS = 3_000;
933
952
  /**
934
953
  * Shared pre-click centre + hit-test for a Stagehand-resolved selector, so
935
954
  * observe-act and the natural-language act attempts share one routine.
936
- * Returns a failure message when the control stays covered, null otherwise.
955
+ * Returns a failure message when the control cannot be centred and hit-tested
956
+ * (covered, unresolved, or the page cannot answer the probe), null otherwise.
937
957
  */
938
- async function centreObservedClickTarget(target, selector, onUnavailable, deferWhenMissing = false) {
958
+ async function centreObservedClickTarget(target, selector) {
939
959
  try {
940
960
  await (0, trusted_click_1.centreSelectorForClick)(target, selector);
941
961
  return null;
942
962
  }
943
963
  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
964
  return `observed click target not clickable: ${(0, errors_1.toErrorMessage)(err)}`;
957
965
  }
958
966
  }
@@ -1151,6 +1159,61 @@ function fieldValuesAtFailureExpr() {
1151
1159
  return out;
1152
1160
  })()`;
1153
1161
  }
1162
+ /**
1163
+ * Reads the in-page quiescence probe as a typed, non-null value. A detached
1164
+ * frame or malformed probe result reads as idle so settle consumes the reader
1165
+ * directly and needs no guard of its own.
1166
+ */
1167
+ async function readPageQuiescence(target) {
1168
+ const idle = { pending: 0, mutations: 0 };
1169
+ const result = await Promise.resolve(target.evaluate(click_settle_1.QUIESCENCE_PROBE_EXPR)).catch(() => idle);
1170
+ if (result === null || typeof result !== "object")
1171
+ return idle;
1172
+ const { pending, mutations } = result;
1173
+ return typeof pending === "number" && typeof mutations === "number"
1174
+ ? { pending, mutations }
1175
+ : idle;
1176
+ }
1177
+ /**
1178
+ * Reads only the visible-text signature settle polls on. Going through
1179
+ * {@link snapshotPage} also read the frame URL, which rejects once a submit
1180
+ * detaches the OOPIF and would abort the settle mid-poll; a failed read here
1181
+ * degrades to the empty signature like the snapshot's own text read does.
1182
+ */
1183
+ async function readSettleTextSignature(target) {
1184
+ const result = await Promise.resolve(target.evaluate(DOM_SNAPSHOT_EXPR)).catch(() => null);
1185
+ if (result === null || typeof result !== "object")
1186
+ return "";
1187
+ const { text } = result;
1188
+ return typeof text === "string" ? text : "";
1189
+ }
1190
+ /**
1191
+ * Settles after a click using the target's own text signature and in-page
1192
+ * quiescence reader, so every post-click site decides from the same settle
1193
+ * signal instead of hand-building the readers.
1194
+ */
1195
+ async function settleWithPageQuiescence(page, target, signalCounter, preNetworkCount, submitShaped, preVisibleTextSignature, advanced) {
1196
+ await (0, click_settle_1.settleAfterClick)(page, signalCounter, preNetworkCount, submitShaped, () => readSettleTextSignature(target), preVisibleTextSignature, () => readPageQuiescence(target), advanced);
1197
+ }
1198
+ /**
1199
+ * Builds the "has the page finished advancing" test a mid-flow submit settles
1200
+ * against. It is the body-swap half of the {@link hasPageAdvanced} the phantom
1201
+ * classifier credits with, so a header that flipped before its body re-rendered
1202
+ * keeps settle polling while the classifier still credits whatever is left once
1203
+ * the wait is spent.
1204
+ * Undefined for any other step, which keeps the plain quiet-window settle.
1205
+ */
1206
+ function pageAdvancedSince(preVisibleTextSignature, midFlowSubmit) {
1207
+ if (!midFlowSubmit)
1208
+ return undefined;
1209
+ const pre = {
1210
+ networkCount: 0,
1211
+ url: "",
1212
+ bodyHtmlLength: 0,
1213
+ visibleTextSignature: preVisibleTextSignature,
1214
+ };
1215
+ return (visibleTextSignature) => (0, phantom_click_1.hasPageBodySwapped)(pre, { ...pre, visibleTextSignature });
1216
+ }
1154
1217
  /**
1155
1218
  * Captures the pre/post signal triple the submit-verify cascade diffs.
1156
1219
  * Accepts the optional `page` so a resolved child `FrameTarget` whose
@@ -2902,10 +2965,18 @@ async function judgeStepOutcome(params) {
2902
2965
  captureFn,
2903
2966
  });
2904
2967
  }
2905
- await (0, click_settle_1.settleAfterClick)(params.page, params.signalCounter, params.preNetworkCount, isStepSettleWaitShaped({ step, submitStep: false, isFinalStep, flowHasSubmitSemantics: false }), async () => {
2906
- const text = await probeRawVisibleText(target);
2907
- return `${text.length}:${text.slice(0, 200)}`;
2908
- }, params.preVisibleTextSignature);
2968
+ await settleWithPageQuiescence(params.page, target, params.signalCounter, params.preNetworkCount, isStepSettleWaitShaped({
2969
+ step,
2970
+ submitStep: params.submitStep,
2971
+ isFinalStep,
2972
+ flowHasSubmitSemantics: params.flowHasSubmitSemantics,
2973
+ }) || params.elementSubmitShaped === true, params.preVisibleTextSignature, pageAdvancedSince(params.preVisibleTextSignature, isMidFlowSubmitShapedStep({
2974
+ step,
2975
+ submitStep: params.submitStep,
2976
+ isFinalStep,
2977
+ flowHasSubmitSemantics: params.flowHasSubmitSemantics,
2978
+ elementSubmitShaped: params.elementSubmitShaped,
2979
+ })));
2909
2980
  const colon = params.preVisibleTextSignature.indexOf(":");
2910
2981
  const beforeText = (colon === -1 ? params.preVisibleTextSignature : params.preVisibleTextSignature.slice(colon + 1))
2911
2982
  .replace(/\s+/g, " ")
@@ -3557,8 +3628,8 @@ async function verifyFillReadback(target, selector, expectedValue) {
3557
3628
  if (!el) return null;
3558
3629
  const tag = el.tagName ? el.tagName.toLowerCase() : "";
3559
3630
  let actual = "";
3560
- const siblings = el.parentElement ? el.parentElement.querySelectorAll("[role='spinbutton']").length : 0;
3561
- if (el.getAttribute && el.getAttribute("role") === "spinbutton" && siblings > 1) {
3631
+ ${SEGMENT_OWNER_JS}
3632
+ if (isDateSegment(el)) {
3562
3633
  return { outcome: "segment", postValue: el.value || el.getAttribute("aria-valuenow") || "", tag };
3563
3634
  }
3564
3635
  if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
@@ -3624,8 +3695,8 @@ async function verifyPromptSelectorCommitted(target, selector, requestedOption)
3624
3695
  const emptyRx = new RegExp(${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)});
3625
3696
  const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
3626
3697
  const announcedValue = ${PROMPT_ANNOUNCED_VALUE_EXPR};
3627
- const squash = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
3628
- const want = squash(${JSON.stringify(requestedOption ?? "")});
3698
+ const namesOption = ${PROMPT_NAMES_OPTION_EXPR};
3699
+ const want = ${JSON.stringify(requestedOption ?? "")};
3629
3700
  const xpath = ${JSON.stringify(xpath)};
3630
3701
  const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3631
3702
  const el = result.singleNodeValue;
@@ -3633,7 +3704,7 @@ async function verifyPromptSelectorCommitted(target, selector, requestedOption)
3633
3704
  const widget = el.matches && el.matches(triggerSel) ? el : el.closest(triggerSel);
3634
3705
  if (!widget) return { isPromptWidget: false, committed: false };
3635
3706
  const value = announcedValue(widget) || currentText(widget, valueSel, emptyRx);
3636
- return { isPromptWidget: true, committed: value !== "" && (!want || squash(value).includes(want)) };
3707
+ return { isPromptWidget: true, committed: value !== "" && (!want || namesOption(value, want)) };
3637
3708
  })()`;
3638
3709
  try {
3639
3710
  const raw = await target.evaluate(expr);
@@ -4510,12 +4581,17 @@ const UPLOAD_URL_PATTERNS = [
4510
4581
  "/media",
4511
4582
  "/import",
4512
4583
  ];
4584
+ /** Case-insensitive Content-Type lookup; CDP preserves the page's header casing. */
4585
+ function requestContentType(headers) {
4586
+ const key = Object.keys(headers).find((k) => k.toLowerCase() === "content-type");
4587
+ return key ? (headers[key] ?? "") : "";
4588
+ }
4513
4589
  /**
4514
4590
  * Poll the recent-capture window for an upload-related POST after a file has
4515
4591
  * been attached. Extracted from `tryUploadPrimitive` so the raw-input path,
4516
4592
  * the click-to-surface path, and the CDP native-chooser path all verify the
4517
- * same way. Returns true as soon as a non-GET capture whose URL matches
4518
- * {@link UPLOAD_URL_PATTERNS} lands; false if the timeout elapses first.
4593
+ * same way. Returns true as soon as a non-GET capture that is multipart, or is not
4594
+ * known to be body-less and has a URL matching {@link UPLOAD_URL_PATTERNS}, lands; false if the timeout elapses first.
4519
4595
  */
4520
4596
  async function waitForUploadNetworkSignal(params) {
4521
4597
  const { page, fixture, logger, signalCounter, recentCaptureMeta, stopWhen } = params;
@@ -4528,6 +4604,10 @@ async function waitForUploadNetworkSignal(params) {
4528
4604
  const uploadCapture = newCaptures.find((cap) => {
4529
4605
  if (cap.method === "GET")
4530
4606
  return false;
4607
+ if (cap.multipart)
4608
+ return true;
4609
+ if (cap.hasBody === false)
4610
+ return false;
4531
4611
  const lowerUrl = cap.url.toLowerCase();
4532
4612
  return UPLOAD_URL_PATTERNS.some((p) => lowerUrl.includes(p));
4533
4613
  });
@@ -4659,8 +4739,9 @@ async function tryUploadPrimitive(params) {
4659
4739
  * Attach the fixture to an already-surfaced `<input type=file>` (raw or freshly
4660
4740
  * mounted after a click) and verify. Extracted verbatim from the original
4661
4741
  * `tryUploadPrimitive` body so the raw-input path and the click-to-surface
4662
- * path share one setInputFiles + framework-change-dispatch + network/DOM verify
4663
- * + drag-drop-fallback implementation.
4742
+ * path share one setInputFiles + framework-change-dispatch + network/DOM verify implementation.
4743
+ * Returns true only on a verified signal
4744
+ * (upload POST or populated `.files`).
4664
4745
  */
4665
4746
  async function attachToSurfacedInput(params) {
4666
4747
  const { page, target, fixture, logger, signalCounter, recentCaptureMeta } = params;
@@ -4727,18 +4808,10 @@ async function attachToSurfacedInput(params) {
4727
4808
  .evaluate("(() => { const els = document.querySelectorAll('input[type=file]'); for (const el of els) { if (el.files && el.files.length > 0) return el.files.length; } return 0; })()")
4728
4809
  .catch(() => 0);
4729
4810
  if (typeof attachedLength !== "number" || attachedLength === 0) {
4730
- logger.warn("upload primitive: no network activity within timeout and no file attached in DOM; attempting drag-drop fallback");
4731
- // K'4: fall back to simulated drag-drop on the most-likely drop zone.
4732
- // Some custom upload widgets (Material Dropzone, react-dropzone, custom
4733
- // <uapp-upload> wrappers) only register files when a `drop` event with
4734
- // a DataTransfer fires on the visible drop area — they don't observe
4735
- // the hidden input's `files[]` mutations even with synthetic `change`
4736
- // dispatches. This is the documented Playwright community workaround.
4737
- const dragDropOk = await simulateDragDropUpload(target, fixture, logger);
4738
- if (dragDropOk) {
4739
- logger.info(`upload primitive: drag-drop fallback succeeded (name=${fixture.name}, size=${fixture.buffer.length}b)`);
4740
- return true;
4741
- }
4811
+ // A dispatched drop is unverified (no network signal, no .files), so it is
4812
+ // not success here: return false and let surfaceAndUpload run the verified
4813
+ // drop and the trusted-click/chooser routes.
4814
+ logger.warn("upload primitive: no network activity within timeout and no file attached in DOM; deferring to surface-and-upload");
4742
4815
  return false;
4743
4816
  }
4744
4817
  logger.info(`upload primitive: file attached in DOM after setInputFiles (deferred-upload widget; name=${fixture.name}, filesLength=${attachedLength})`);
@@ -5269,30 +5342,108 @@ function segmentedDateStepTokens(text) {
5269
5342
  return named ? [token] : [];
5270
5343
  });
5271
5344
  }
5345
+ const SEGMENTED_DATE_STEP_NOISE = new Set([
5346
+ "barnaclevalue",
5347
+ "click",
5348
+ "type",
5349
+ "enter",
5350
+ "fill",
5351
+ "set",
5352
+ "select",
5353
+ "input",
5354
+ "write",
5355
+ "put",
5356
+ "choose",
5357
+ "use",
5358
+ "press",
5359
+ "focus",
5360
+ "spinbutton",
5361
+ "part",
5362
+ "segment",
5363
+ "digits",
5364
+ "box",
5365
+ "first",
5366
+ "second",
5367
+ "into",
5368
+ "as",
5369
+ "by",
5370
+ "from",
5371
+ "to",
5372
+ "is",
5373
+ "be",
5374
+ "it",
5375
+ "its",
5376
+ "this",
5377
+ "that",
5378
+ "my",
5379
+ "your",
5380
+ ...SEGMENTED_DATE_STOPWORDS,
5381
+ ...SEGMENTED_DATE_FIELD_WORDS,
5382
+ ...SEGMENTED_DATE_PART_LABELS,
5383
+ ...SEGMENTED_DATE_FIELD_NOUNS,
5384
+ ]);
5272
5385
  /**
5273
5386
  * Pick which spinbutton group a segmented-date step targets. Groups whose
5274
5387
  * segment widths (digit count of `aria-valuemax`) don't sum to the value's
5275
5388
  * digit count are dropped. A group is only eligible when a field the step names
5276
5389
  * (quoted, e.g. 'From', or unquoted in field position, e.g. "the To date")
5277
5390
  * 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
5391
+ * its whole `context` — and every other quoted phrase is found in the group's
5392
+ * label or section. The section the step names ("Work Experience 1") is
5279
5393
  * 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.
5394
+ * section words better than any eligible group does, or the group's own
5395
+ * section shares none of the step's remaining words (or numbering), the named
5396
+ * section renders no such field and the step resolves to nothing rather than to
5397
+ * another section's. Returns the index into `groups`, or `null` when nothing
5398
+ * matches or the best match is tied — writing a date into a group that merely
5399
+ * fits the width would corrupt another field. Groups that hold the same
5400
+ * segments under different names never tie.
5285
5401
  */
5286
5402
  function resolveSegmentedDateGroup(instruction, groups) {
5287
5403
  const value = parseSegmentedDateValue(instruction);
5288
5404
  if (!value)
5289
5405
  return null;
5406
+ const compatible = (group) => group.segmentMaxes.map((max) => String(max).length).reduce((a, b) => a + b, 0) ===
5407
+ value.digits.length;
5408
+ return pickSegmentedDateGroup(instruction, groups, value, compatible);
5409
+ }
5410
+ const SEGMENTED_DATE_PART_BY_LABEL = {
5411
+ month: "month",
5412
+ mm: "month",
5413
+ day: "day",
5414
+ dd: "day",
5415
+ year: "year",
5416
+ yyyy: "year",
5417
+ yy: "year",
5418
+ };
5419
+ /**
5420
+ * Parse a per-segment step such as "type '01' into Month". Requires a quoted
5421
+ * 1-4 digit value AND exactly one part label (Month/MM, Day/DD, Year/YYYY), so
5422
+ * a bare short numeric (ZIP fragment, age) is never claimed.
5423
+ */
5424
+ function parseSegmentedPartStep(instruction) {
5425
+ const raw = parseFillValueIntent(instruction)?.value;
5426
+ if (!raw || !/^\d{1,4}$/.test(raw))
5427
+ return null;
5428
+ const parts = new Set(segmentedDateTokens(instruction.replace(raw, " ")).flatMap((t) => {
5429
+ const part = SEGMENTED_DATE_PART_BY_LABEL[t];
5430
+ return part ? [part] : [];
5431
+ }));
5432
+ const [part] = [...parts];
5433
+ return parts.size === 1 && part ? { digits: raw, valueSpan: raw, part } : null;
5434
+ }
5435
+ /** Index of the single segment in `group` whose own label names `part`, or null when none/ambiguous. */
5436
+ function segmentIndexForPart(group, part) {
5437
+ const hits = (group.segmentLabels ?? []).flatMap((label, si) => segmentedDateTokens(label).some((t) => SEGMENTED_DATE_PART_BY_LABEL[t] === part) ? [si] : []);
5438
+ return hits.length === 1 ? (hits[0] ?? null) : null;
5439
+ }
5440
+ function pickSegmentedDateGroup(instruction, groups, value, compatible) {
5290
5441
  const valueTokens = segmentedDateTokens(value.valueSpan).join(" ");
5291
5442
  const quoted = (0, quoted_spans_1.findQuotedSpans)(instruction)
5292
5443
  .map((s) => segmentedDateTokens(s.text.trim()))
5293
5444
  .filter((t) => t.length > 0 &&
5294
5445
  t.join(" ") !== valueTokens &&
5295
- !(t.length === 1 && SEGMENTED_DATE_PART_LABELS.has(t[0] ?? "")));
5446
+ !t.every((token) => SEGMENTED_DATE_PART_LABELS.has(token)));
5296
5447
  const stepTokens = segmentedDateStepTokens(instruction.replace(value.valueSpan, " barnaclevalue "));
5297
5448
  const targets = quoted.length > 0 ? quoted : [stepTokens];
5298
5449
  const contains = (hay, needle) => {
@@ -5302,8 +5453,6 @@ function resolveSegmentedDateGroup(instruction, groups) {
5302
5453
  }
5303
5454
  return false;
5304
5455
  };
5305
- const compatible = (group) => group.segmentMaxes.map((max) => String(max).length).reduce((a, b) => a + b, 0) ===
5306
- value.digits.length;
5307
5456
  const stepSet = new Set(stepTokens);
5308
5457
  const sectionScore = (group) => {
5309
5458
  const own = new Set(segmentedDateTokens(group.label ?? ""));
@@ -5313,10 +5462,33 @@ function resolveSegmentedDateGroup(instruction, groups) {
5313
5462
  !SEGMENTED_DATE_STOPWORDS.has(t) &&
5314
5463
  !SEGMENTED_DATE_FIELD_WORDS.has(t)).length;
5315
5464
  };
5465
+ const quotedTokens = new Set(quoted.flat());
5466
+ const leftover = stepTokens.filter((t) => !SEGMENTED_DATE_STEP_NOISE.has(t) && !quotedTokens.has(t));
5467
+ const isNumber = (t) => /^\d+$/.test(t);
5468
+ // A group under another heading is not the field the step names, however
5469
+ // well its label matches: the step's own words must find the section.
5470
+ // A section that only repeats the group's own label is no heading at all
5471
+ // (Workday enumerates "From*" as both), so it cannot name another section.
5472
+ const namesOtherSection = (group) => {
5473
+ const section = segmentedDateTokens(group.section ?? "");
5474
+ const label = segmentedDateTokens(group.label ?? "");
5475
+ if (section.length === 0 || contains(label, section))
5476
+ return false;
5477
+ const known = new Set([...section, ...label]);
5478
+ const words = leftover.filter((t) => !isNumber(t));
5479
+ const numbers = leftover.filter(isNumber);
5480
+ const sectionNumbers = section.filter(isNumber);
5481
+ if (numbers.length > 0 && sectionNumbers.length > 0) {
5482
+ if (!numbers.some((n) => sectionNumbers.includes(n)))
5483
+ return true;
5484
+ }
5485
+ if (words.some((t) => section.includes(t)))
5486
+ return false;
5487
+ return words.some((t) => !known.has(t));
5488
+ };
5316
5489
  const bestSection = Math.max(0, ...groups.filter(compatible).map((group) => sectionScore(group)));
5317
- let best = -1;
5318
5490
  let bestScore = -1;
5319
- let tied = false;
5491
+ let leaders = [];
5320
5492
  groups.forEach((group, index) => {
5321
5493
  if (!compatible(group))
5322
5494
  return;
@@ -5326,24 +5498,43 @@ function resolveSegmentedDateGroup(instruction, groups) {
5326
5498
  const namesOwnField = group.label === undefined &&
5327
5499
  quoted.length === 0 &&
5328
5500
  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)))) {
5501
+ const namesOwnLabel = (t) => contains(t, own) || (quoted.length > 0 && contains(own, t));
5502
+ if (!namesOwnField && !targets.some(namesOwnLabel))
5503
+ return;
5504
+ const named = segmentedDateTokens(`${group.section ?? ""} ${group.context}`);
5505
+ if (quoted.some((t) => !namesOwnLabel(t) && !contains(named, t)))
5506
+ return;
5507
+ if (namesOtherSection(group))
5331
5508
  return;
5332
- }
5333
5509
  const score = sectionScore(group);
5334
5510
  if (score < bestSection)
5335
5511
  return;
5336
5512
  if (score > bestScore) {
5337
- best = index;
5338
5513
  bestScore = score;
5339
- tied = false;
5514
+ leaders = [index];
5340
5515
  }
5341
5516
  else if (score === bestScore) {
5342
- tied = true;
5517
+ const alias = (a, b) => (groups[a]?.sameAs ?? a) === (groups[b]?.sameAs ?? b) ||
5518
+ groups[a]?.sameAs === b ||
5519
+ groups[b]?.sameAs === a;
5520
+ const twin = leaders.findIndex((leader) => alias(leader, index));
5521
+ if (twin < 0)
5522
+ leaders.push(index);
5523
+ else
5524
+ leaders[twin] = Math.min(leaders[twin] ?? index, index);
5343
5525
  }
5344
5526
  });
5345
- if (best >= 0)
5346
- return tied ? null : best;
5527
+ if (leaders.length === 1)
5528
+ return leaders[0] ?? null;
5529
+ if (leaders.length > 1) {
5530
+ // A nested group is one wrapper's slice of a larger date widget (e.g. the
5531
+ // Year half of "From MM/YYYY"), so it only ties with a standalone group of
5532
+ // the same label when no section text separates them — Workday renders
5533
+ // "Work Experience 1 From" and "Education 1 From" with no heading the
5534
+ // enumerator can see. The standalone group is the field the step names.
5535
+ const standalone = leaders.filter((i) => !groups[i]?.nested);
5536
+ return standalone.length === 1 ? (standalone[0] ?? null) : null;
5537
+ }
5347
5538
  // A step that names no field at all can only mean the one date widget on the
5348
5539
  // page; a named field that matched no label must never fall back to it.
5349
5540
  if (quoted.length > 0 || stepTokens.some((t) => SEGMENTED_DATE_FIELD_WORDS.has(t)))
@@ -5351,12 +5542,12 @@ function resolveSegmentedDateGroup(instruction, groups) {
5351
5542
  const only = groups.flatMap((g, i) => (compatible(g) && !g.nested ? [i] : []));
5352
5543
  return only.length === 1 ? (only[0] ?? null) : null;
5353
5544
  }
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));
5545
+ /**
5546
+ * In-page helpers shared by the date-segment enumeration and the fill
5547
+ * readback so "is this spinbutton one segment of a multi-segment widget" has a
5548
+ * single definition; defines `spins`, `ownerOf` and `isDateSegment`.
5549
+ */
5550
+ const SEGMENT_OWNER_JS = `
5360
5551
  const hiddenAttr = (node) => node.hasAttribute("hidden") || node.getAttribute("aria-hidden") === "true";
5361
5552
  const styleOf = (node) => {
5362
5553
  try { return typeof getComputedStyle === "function" ? getComputedStyle(node) : window.getComputedStyle(node); } catch { return null; }
@@ -5370,18 +5561,53 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5370
5561
  return false;
5371
5562
  };
5372
5563
  const spins = Array.from(document.querySelectorAll("input[role='spinbutton']")).filter((el) => !isHidden(el));
5373
- if (spins.length === 0) return { groups: [] };
5374
5564
  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
5565
  const idText = (ids) => (ids || "").split(/\\s+/).map((id) => {
5382
5566
  const ref = id ? document.getElementById(id) : null;
5383
5567
  return ref ? norm(ref.textContent) : "";
5384
5568
  }).join(" ");
5569
+ const legendOf = (node) => {
5570
+ const legend = node.tagName === "FIELDSET" ? node.querySelector(":scope > legend") : null;
5571
+ return legend ? norm(legend.textContent) : "";
5572
+ };
5573
+ const namedBy = (node) => [legendOf(node), norm(node.getAttribute("aria-label")), idText(node.getAttribute("aria-labelledby"))].filter(Boolean).join(" ");
5574
+ const tiedToSpin = (l) => l.querySelector("input,[role='spinbutton']") || (l.getAttribute("for") && spins.some((sp) => sp.id === l.getAttribute("for")));
5575
+ const hasCaption = (node) => namedBy(node) !== "" || Array.from(node.querySelectorAll("label,legend")).some((l) => !tiedToSpin(l));
5576
+ const captionOwnerOf = (el) => {
5577
+ const explicit = el.closest("fieldset,[role='group']");
5578
+ if (explicit) return explicit;
5579
+ for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
5580
+ if (hasCaption(node)) return node;
5581
+ }
5582
+ return null;
5583
+ };
5584
+ const bareSpins = spins.filter((sp) => !captionOwnerOf(sp));
5585
+ const hasOtherControl = (node) => Array.from(node.querySelectorAll("input,select,textarea")).some((c) => !spins.includes(c) && c.type !== "hidden");
5586
+ // Segments that each sit in their own uncaptioned wrapper share no
5587
+ // fieldset/caption, so the nearest ancestor holding several bare spinbuttons
5588
+ // (and no other form control) is the widget they belong to.
5589
+ const bareOwnerOf = (el) => {
5590
+ for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
5591
+ if (hasOtherControl(node)) break;
5592
+ if (bareSpins.filter((sp) => node.contains(sp)).length > 1) return node;
5593
+ }
5594
+ return el.parentElement;
5595
+ };
5596
+ const ownerOf = (el) => captionOwnerOf(el) || bareOwnerOf(el);
5597
+ const isDateSegment = (el) => {
5598
+ if (!el.getAttribute || el.getAttribute("role") !== "spinbutton" || !spins.includes(el)) return false;
5599
+ const box = ownerOf(el);
5600
+ return spins.filter((sp) => ownerOf(sp) === box).length > 1;
5601
+ };
5602
+ `;
5603
+ const SEGMENTED_DATE_MARKER_ATTR = "data-barnacle-segdate";
5604
+ /** Exported so tests can run the real DOM enumeration against markup. */
5605
+ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5606
+ const attr = ${JSON.stringify(SEGMENTED_DATE_MARKER_ATTR)};
5607
+ const stale = document.querySelectorAll("[" + attr + "]");
5608
+ stale.forEach((el) => el.removeAttribute(attr));
5609
+ ${SEGMENT_OWNER_JS}
5610
+ if (spins.length === 0) return { groups: [] };
5385
5611
  const headingSibling = (node) => {
5386
5612
  let prevSib = node.previousElementSibling;
5387
5613
  for (let hop = 0; hop < 3 && prevSib; hop++, prevSib = prevSib.previousElementSibling) {
@@ -5389,11 +5615,6 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5389
5615
  }
5390
5616
  return "";
5391
5617
  };
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
5618
  const sectionOf = (box, includeSelf) => {
5398
5619
  const parts = [includeSelf ? namedBy(box) : "", headingSibling(box)];
5399
5620
  let node = box.parentElement;
@@ -5408,11 +5629,12 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5408
5629
  const prev = box.previousElementSibling;
5409
5630
  return prev && prev.matches("label,legend") && !prev.querySelector("input,[role='spinbutton']") ? norm(prev.textContent) : "";
5410
5631
  };
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
- };
5632
+ const byContainer = new Map();
5633
+ for (const el of spins) {
5634
+ const box = ownerOf(el);
5635
+ if (!byContainer.has(box)) byContainer.set(box, []);
5636
+ byContainer.get(box).push(el);
5637
+ }
5416
5638
  const labelText = (seg) => {
5417
5639
  const parts = [idText(seg.getAttribute("aria-labelledby")), norm(seg.getAttribute("aria-label")), norm(seg.getAttribute("placeholder"))];
5418
5640
  if (seg.id) {
@@ -5441,21 +5663,65 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5441
5663
  const addGroup = (segs, box, nested) => {
5442
5664
  const gi = groups.length;
5443
5665
  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));
5666
+ let inner = null;
5667
+ for (let node = segs[0].parentElement; node && node !== box && box.contains(node) && !inner; node = node.parentElement) {
5668
+ if (segs.every((seg) => node.contains(seg)) && hasCaption(node)) inner = node;
5669
+ }
5670
+ const labelBox = inner || box;
5671
+ const own = namedBy(labelBox) || ownCaption(labelBox) || segs.map((seg) => idText(seg.getAttribute("aria-labelledby"))).filter(Boolean).join(" ");
5672
+ const section = sectionOf(labelBox, Boolean(inner));
5448
5673
  groups.push({
5449
5674
  segmentMaxes: segs.map((seg, si) => maxOf(seg, si, segs.length)),
5450
5675
  context: [section, own].filter(Boolean).join(" "),
5451
5676
  label: own,
5452
5677
  section,
5453
5678
  nested,
5679
+ segmentLabels: segs.map((seg) => labelText(seg)),
5454
5680
  });
5455
5681
  };
5682
+ const PART_WORD = /^(mm|dd|yyyy|yy|month|day|year)$/i;
5683
+ const keyOf = (text) => norm(text).split(" ").filter((word) => {
5684
+ const parts = word.split(/[^A-Za-z0-9]+/).filter(Boolean);
5685
+ return parts.length > 0 && !parts.every((part) => PART_WORD.test(part));
5686
+ }).join(" ");
5687
+ const ownKey = (seg) => {
5688
+ const parts = [idText(seg.getAttribute("aria-labelledby")), norm(seg.getAttribute("aria-label"))];
5689
+ if (seg.id) {
5690
+ document.querySelectorAll("label").forEach((l) => { if (l.getAttribute("for") === seg.id) parts.push(norm(l.textContent)); });
5691
+ }
5692
+ const wrap = seg.closest("label");
5693
+ if (wrap) parts.push(norm(wrap.textContent));
5694
+ return keyOf(parts.filter(Boolean).join(" "));
5695
+ };
5696
+ // A container's legend names the whole section; the field a segment belongs
5697
+ // to is the caption naming it, or the caption nearest before it with no other
5698
+ // control in between.
5699
+ const fieldKeys = (segs, box) => {
5700
+ const own = new Set(segs);
5701
+ const boxLegend = box.tagName === "FIELDSET" ? box.querySelector(":scope > legend") : null;
5702
+ const keys = new Map();
5703
+ let current = "";
5704
+ box.querySelectorAll("label,legend,input,select,textarea,button").forEach((el) => {
5705
+ if (el === boxLegend) return;
5706
+ if (el.matches("label,legend")) {
5707
+ const target = el.getAttribute("for");
5708
+ if (isHidden(el) || el.querySelector("input,select,textarea,[role='spinbutton']") || (target && box.querySelector("[id='" + target + "']"))) return;
5709
+ current = keyOf(el.textContent);
5710
+ return;
5711
+ }
5712
+ if (!own.has(el)) {
5713
+ if (!isHidden(el)) current = "";
5714
+ return;
5715
+ }
5716
+ keys.set(el, ownKey(el) || current);
5717
+ });
5718
+ return segs.map((seg) => keys.get(seg) || "");
5719
+ };
5720
+ const captionRuns = [];
5456
5721
  const nestedCandidates = [];
5457
5722
  byContainer.forEach((segs, box) => {
5458
5723
  addGroup(segs, box, false);
5724
+ const outerIndex = groups.length - 1;
5459
5725
  const byParent = new Map();
5460
5726
  for (const seg of segs) {
5461
5727
  if (!byParent.has(seg.parentElement)) byParent.set(seg.parentElement, []);
@@ -5463,9 +5729,36 @@ exports.SEGMENTED_DATE_ENUMERATE_EXPR = `(() => {
5463
5729
  }
5464
5730
  // A shared fieldset can hold several independent dates (From and To year
5465
5731
  // 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]));
5732
+ if (byParent.size > 1) {
5733
+ byParent.forEach((inner, parent) => nestedCandidates.push([inner, parent]));
5734
+ return;
5735
+ }
5736
+ const keys = fieldKeys(segs, box);
5737
+ const runs = [];
5738
+ segs.forEach((seg, si) => {
5739
+ const last = runs[runs.length - 1];
5740
+ if (last && last.key === keys[si]) last.segs.push(seg);
5741
+ else runs.push({ key: keys[si], segs: [seg] });
5742
+ });
5743
+ runs.filter((run) => run.key).forEach((run) => captionRuns.push({ run, box, outerIndex, whole: run.segs.length === segs.length }));
5467
5744
  });
5468
5745
  nestedCandidates.forEach(([inner, parent]) => addGroup(inner, parent, true));
5746
+ captionRuns.forEach(({ run, box, outerIndex, whole }) => {
5747
+ const outer = groups[outerIndex];
5748
+ const same = (a, b) => a.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim() === b.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
5749
+ if (whole && same(outer.label || "", run.key)) return;
5750
+ const gi = groups.length;
5751
+ run.segs.forEach((seg, si) => mark(seg, gi + ":" + si));
5752
+ const section = sectionOf(box, true);
5753
+ groups.push({
5754
+ segmentMaxes: run.segs.map((seg, si) => maxOf(seg, si, run.segs.length)),
5755
+ context: [section, run.key].filter(Boolean).join(" "),
5756
+ label: run.key,
5757
+ section,
5758
+ nested: true,
5759
+ sameAs: whole ? outerIndex : undefined,
5760
+ });
5761
+ });
5469
5762
  return { groups };
5470
5763
  })()`;
5471
5764
  const SEGMENTED_DATE_CLEAR_EXPR = `(() => {
@@ -5484,15 +5777,29 @@ const SEGMENTED_DATE_CLEAR_EXPR = `(() => {
5484
5777
  */
5485
5778
  async function trySegmentedDateSpinbuttonPrimitive(params) {
5486
5779
  const { page, target, instruction, logger } = params;
5487
- const value = parseSegmentedDateValue(instruction);
5488
- if (!value)
5780
+ const whole = parseSegmentedDateValue(instruction);
5781
+ const part = parseSegmentedPartStep(instruction);
5782
+ if (!whole && !part)
5489
5783
  return null;
5490
- const sendCDP = (method, args) => target.frame?.session ? target.frame.session.send(method, args) : page.sendCDP(method, args);
5784
+ const sendCDP = frameCdpSender(page, target);
5491
5785
  try {
5492
5786
  const enumerated = (await target.evaluate(exports.SEGMENTED_DATE_ENUMERATE_EXPR));
5493
5787
  if (!enumerated?.groups?.length)
5494
5788
  return null;
5495
- const gi = resolveSegmentedDateGroup(instruction, enumerated.groups);
5789
+ const wholeGi = whole ? resolveSegmentedDateGroup(instruction, enumerated.groups) : null;
5790
+ const partStep = wholeGi === null ? part : null;
5791
+ const value = (wholeGi !== null ? whole : part);
5792
+ const partOf = (group) => partStep ? segmentIndexForPart(group, partStep.part) : null;
5793
+ const partFits = (group) => {
5794
+ const si = partOf(group);
5795
+ return (!group.nested &&
5796
+ si !== null &&
5797
+ value.digits.length <= String(group.segmentMaxes[si] ?? 0).length);
5798
+ };
5799
+ const gi = wholeGi ??
5800
+ (partStep
5801
+ ? pickSegmentedDateGroup(instruction, enumerated.groups, partStep, partFits)
5802
+ : null);
5496
5803
  if (gi === null) {
5497
5804
  logger.info("segmented date primitive: no unambiguous group; falling through");
5498
5805
  await target.evaluate(SEGMENTED_DATE_CLEAR_EXPR);
@@ -5500,10 +5807,15 @@ async function trySegmentedDateSpinbuttonPrimitive(params) {
5500
5807
  }
5501
5808
  const maxes = enumerated.groups[gi]?.segmentMaxes ?? [];
5502
5809
  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
- });
5810
+ const onlySegment = partStep ? partOf(enumerated.groups[gi]) : null;
5811
+ const active = widths.flatMap((_, si) => onlySegment === null || si === onlySegment ? [si] : []);
5812
+ const sliceFor = (si) => {
5813
+ if (onlySegment !== null)
5814
+ return value.digits;
5815
+ const start = widths.slice(0, si).reduce((a, b) => a + b, 0);
5816
+ return value.digits.slice(start, start + (widths[si] ?? 0));
5817
+ };
5818
+ const expected = active.map((si) => Number(sliceFor(si)));
5507
5819
  const segmentSelector = (si) => `[${SEGMENTED_DATE_MARKER_ATTR}~="${gi}:${si}"]`;
5508
5820
  const focusSegment = async (si) => {
5509
5821
  const selector = segmentSelector(si);
@@ -5516,7 +5828,9 @@ async function trySegmentedDateSpinbuttonPrimitive(params) {
5516
5828
  // scrolling is best-effort; the key events only need the segment focused
5517
5829
  }
5518
5830
  try {
5519
- if (await (0, trusted_click_1.isClickPointUnobscured)(target, selector)) {
5831
+ // An open option popup can cover the field; clear it so the trusted
5832
+ // click lands on the segment instead of falling back to a bare focus().
5833
+ if (await clearObscuringPopup(page, target, selector)) {
5520
5834
  await (0, trusted_click_1.clickCentredTrusted)(target, selector, { label: "date segment" });
5521
5835
  }
5522
5836
  }
@@ -5536,12 +5850,9 @@ async function trySegmentedDateSpinbuttonPrimitive(params) {
5536
5850
  await sendCDP("Input.dispatchKeyEvent", { type: "keyUp", ...keyArgs });
5537
5851
  }
5538
5852
  };
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
- });
5853
+ const segmentDigits = active.map((si) => sliceFor(si));
5543
5854
  const readSegments = async () => (await target.evaluate(`(() => {
5544
- const sels = ${JSON.stringify(widths.map((_, si) => segmentSelector(si)))};
5855
+ const sels = ${JSON.stringify(active.map((si) => segmentSelector(si)))};
5545
5856
  return sels.map((sel) => {
5546
5857
  const s = document.querySelector(sel);
5547
5858
  if (!s) return NaN;
@@ -5558,15 +5869,18 @@ async function trySegmentedDateSpinbuttonPrimitive(params) {
5558
5869
  // locator.type (per-character key events, never Input.insertText) covers a
5559
5870
  // page whose CDP channel does not reach the widget.
5560
5871
  const typeViaLocator = async () => {
5561
- for (const [si, digits] of segmentDigits.entries()) {
5562
- await target.locator(segmentSelector(si)).first().type(digits, {
5872
+ for (const [k, digits] of segmentDigits.entries()) {
5873
+ await target
5874
+ .locator(segmentSelector(active[k] ?? 0))
5875
+ .first()
5876
+ .type(digits, {
5563
5877
  delay: SEGMENTED_DATE_KEY_DELAY_MS,
5564
5878
  });
5565
5879
  }
5566
5880
  };
5567
5881
  const typeViaKeyEvents = async () => {
5568
- for (const [si, digits] of segmentDigits.entries()) {
5569
- await focusSegment(si);
5882
+ for (const [k, digits] of segmentDigits.entries()) {
5883
+ await focusSegment(active[k] ?? 0);
5570
5884
  await typeViaCdp(digits);
5571
5885
  }
5572
5886
  };
@@ -5961,6 +6275,16 @@ function isNavigatingEvaluateRejection(error) {
5961
6275
  message.includes("no frame for given id found") ||
5962
6276
  GENERIC_CONTENT_FREE_EVAL_REJECTION_MESSAGES.has(message.trim()));
5963
6277
  }
6278
+ /**
6279
+ * Settles after a click and returns the post-click snapshot, so the settle
6280
+ * wait and the phantom classifier decide from the same reader and baseline.
6281
+ * Without a page there is nothing to wait on and the snapshot is taken as-is.
6282
+ */
6283
+ async function settleAndSnapshotPostClick(target, signalCounter, page, pre, midFlowSubmit = false) {
6284
+ if (page)
6285
+ await settleWithPageQuiescence(page, target, signalCounter, pre.networkCount, true, pre.visibleTextSignature, pageAdvancedSince(pre.visibleTextSignature, midFlowSubmit));
6286
+ return snapshotPage(target, signalCounter, page);
6287
+ }
5964
6288
  /**
5965
6289
  * Shared in-page discovery of the widget's callback, used identically by
5966
6290
  * `injectCaptchaTokenAndSubmit`'s precheck (to decide the branch) and its
@@ -6268,9 +6592,7 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
6268
6592
  if (clickResult.clicked) {
6269
6593
  if (!pre || !verification)
6270
6594
  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);
6595
+ const post = await settleAndSnapshotPostClick(target, verification.signalCounter, verification.page, pre, verification.isMidFlowSubmitStep);
6274
6596
  const verdict = (0, phantom_click_1.classifyPhantomClick)({
6275
6597
  actResultSuccess: true,
6276
6598
  pre,
@@ -6291,9 +6613,7 @@ async function submitCaptchaGatedForm(target, responseField = "h-captcha-respons
6291
6613
  throw err;
6292
6614
  });
6293
6615
  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);
6616
+ const runnerUpPost = await settleAndSnapshotPostClick(target, verification.signalCounter, verification.page, post, verification.isMidFlowSubmitStep);
6297
6617
  const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
6298
6618
  actResultSuccess: true,
6299
6619
  pre: post,
@@ -6839,7 +7159,10 @@ async function tryCheckboxStatePrimitive(params) {
6839
7159
  let overlaid = false;
6840
7160
  let trustedDelivered = false;
6841
7161
  for (const sel of clickSels) {
6842
- const probe = await (0, trusted_click_1.probeClickPoint)(target, sel);
7162
+ const firstProbe = await (0, trusted_click_1.probeClickPoint)(target, sel);
7163
+ const probe = firstProbe?.state === "occluded" && firstProbe.occluder !== "none"
7164
+ ? await dismissLeftoverPromptPopups(page, target).then(() => (0, trusted_click_1.probeClickPoint)(target, sel), () => firstProbe)
7165
+ : firstProbe;
6843
7166
  if (probe?.state === "occluded" && probe.occluder !== "none") {
6844
7167
  overlaid = true;
6845
7168
  continue;
@@ -7897,7 +8220,7 @@ async function tryPromptSelectorPrimitive(params) {
7897
8220
  logger.info(`prompt-selector primitive: filtered list for ${optLabel} names no matching option; submitting with Enter`);
7898
8221
  try {
7899
8222
  await target.evaluate(`(() => { const el = document.querySelector('[${PROMPT_OPTION_MARK_ATTR}-search="1"]'); if (el) el.focus(); })()`);
7900
- await sendTrustedEnter(page);
8223
+ await sendTrustedEnter(page, target);
7901
8224
  }
7902
8225
  catch (err) {
7903
8226
  logger.info(`prompt-selector primitive: enter submit failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}`);
@@ -7940,7 +8263,7 @@ const PROMPT_SELECTOR_MAX_STABLE_READS = 5;
7940
8263
  * committer is never handed an unsettled list.
7941
8264
  */
7942
8265
  async function readStablePromptOptions(page, target, enumerateOptionsExpr, initial) {
7943
- const key = (r) => JSON.stringify((r?.options ?? []).map((o) => [o.text, o.nodeId ?? null]));
8266
+ const key = (r) => JSON.stringify((r?.options ?? []).map((o) => [o.text, o.domText ?? null]));
7944
8267
  let current = initial;
7945
8268
  for (let read = 0; read < PROMPT_SELECTOR_MAX_STABLE_READS; read++) {
7946
8269
  await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
@@ -7979,33 +8302,78 @@ function promptOptionsNameOption(result, option) {
7979
8302
  const want = normPromptOptionText(option);
7980
8303
  if (want === "")
7981
8304
  return true;
7982
- return (result?.options ?? []).some((o) => {
8305
+ return realPromptOptions(result?.options ?? [], option).some((o) => {
8306
+ const text = normPromptOptionText(o.text);
8307
+ return text === want || text.includes(want);
8308
+ });
8309
+ }
8310
+ /**
8311
+ * Single classification of which rendered entries are real options, shared by
8312
+ * the submit decision and the committer so they cannot disagree. Placeholders
8313
+ * ("No Items.", "No results for x") are dropped, except an entry that is
8314
+ * exactly the requested text, which is the user's own option.
8315
+ */
8316
+ function realPromptOptions(options, option) {
8317
+ const want = normPromptOptionText(option);
8318
+ return options.filter((o) => {
7983
8319
  const text = normPromptOptionText(o.text);
7984
8320
  if (text === "")
7985
8321
  return false;
7986
- if (text === want)
7987
- return true;
7988
- if (PROMPT_PLACEHOLDER_RE.test(text))
7989
- return false;
7990
- return text.includes(want);
8322
+ return text === want || !PROMPT_PLACEHOLDER_RE.test(text);
7991
8323
  });
7992
8324
  }
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 });
8325
+ /**
8326
+ * Single owner of CDP delivery for trusted input: a child-frame target's own
8327
+ * session when it has one, otherwise the page's, so keys reach the frame that
8328
+ * holds the focused element.
8329
+ */
8330
+ function frameCdpSender(page, target) {
8331
+ return (method, args) => target.frame?.session ? target.frame.session.send(method, args) : page.sendCDP(method, args);
7998
8332
  }
7999
- /** Dispatches a trusted Escape to the focused element via CDP, closing a popup left open. */
8000
- async function sendTrustedEscape(page) {
8001
- const key = {
8333
+ /**
8334
+ * Clicks at a frame-local viewport point through the same CDP route as keys, so
8335
+ * the press lands where the hit test just ran instead of re-resolving a locator.
8336
+ * A same-process child frame has no session of its own, so its offset in the
8337
+ * page is added to reach page coordinates.
8338
+ */
8339
+ async function dispatchTrustedClick(page, target, x, y) {
8340
+ if (target.frame === null) {
8341
+ await page.click(x, y);
8342
+ return;
8343
+ }
8344
+ const send = frameCdpSender(page, target);
8345
+ const offset = target.frame.session
8346
+ ? { x: 0, y: 0 }
8347
+ : ((await target
8348
+ .evaluate(`(() => {
8349
+ const f = window.frameElement;
8350
+ if (!f) return null;
8351
+ const r = f.getBoundingClientRect();
8352
+ return { x: r.left, y: r.top };
8353
+ })()`)
8354
+ .catch(() => null)) ?? { x: 0, y: 0 });
8355
+ const point = { x: x + offset.x, y: y + offset.y, button: "left", clickCount: 1 };
8356
+ await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y });
8357
+ await send("Input.dispatchMouseEvent", { type: "mousePressed", ...point });
8358
+ await send("Input.dispatchMouseEvent", { type: "mouseReleased", ...point });
8359
+ }
8360
+ async function sendTrustedKey(page, target, key, text) {
8361
+ const send = frameCdpSender(page, target);
8362
+ await send("Input.dispatchKeyEvent", { type: "keyDown", ...(text ? { text } : {}), ...key });
8363
+ await send("Input.dispatchKeyEvent", { type: "keyUp", ...key });
8364
+ }
8365
+ /** Dispatches a trusted Enter to the focused element (the filter input) via the target frame's CDP session. */
8366
+ async function sendTrustedEnter(page, target) {
8367
+ await sendTrustedKey(page, target, { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, "\r");
8368
+ }
8369
+ /** Dispatches a trusted Escape to the focused element via the target frame's CDP session, closing a popup left open. */
8370
+ async function sendTrustedEscape(page, target) {
8371
+ await sendTrustedKey(page, target, {
8002
8372
  key: "Escape",
8003
8373
  code: "Escape",
8004
8374
  windowsVirtualKeyCode: 27,
8005
8375
  nativeVirtualKeyCode: 27,
8006
- };
8007
- await page.sendCDP("Input.dispatchKeyEvent", { type: "keyDown", ...key });
8008
- await page.sendCDP("Input.dispatchKeyEvent", { type: "keyUp", ...key });
8376
+ });
8009
8377
  }
8010
8378
  /**
8011
8379
  * Counts rendered listbox / activeListContainer popups other than the chip
@@ -8049,18 +8417,52 @@ async function dismissLeftoverPromptPopups(page, target) {
8049
8417
  .catch(() => 0));
8050
8418
  if (!count)
8051
8419
  return;
8052
- logger.info(`prompt-selector primitive: dismissing ${count} leftover popup(s) via Escape`);
8053
- const trusted = await sendTrustedEscape(page)
8420
+ logger.info(`popup dismissal: dismissing ${count} leftover popup(s) via Escape`);
8421
+ const trusted = await sendTrustedEscape(page, target)
8054
8422
  .then(() => true)
8055
8423
  .catch((err) => {
8056
- logger.info(`prompt-selector primitive: Escape dispatch failed: ${(0, errors_1.toErrorMessage)(err)}`);
8424
+ logger.info(`popup dismissal: Escape dispatch failed: ${(0, errors_1.toErrorMessage)(err)}`);
8057
8425
  return false;
8058
8426
  });
8059
8427
  if (!trusted || attempt > 0)
8060
8428
  await target.evaluate(IN_PAGE_ESCAPE_EXPR).catch(() => undefined);
8061
- await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS).catch(() => undefined);
8429
+ await settleAfterEscape(page);
8062
8430
  }
8063
8431
  }
8432
+ /** Waits for a dismissed popup to unpaint; a page without a timer seam just proceeds. */
8433
+ async function settleAfterEscape(page) {
8434
+ await Promise.resolve()
8435
+ .then(() => page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS))
8436
+ .catch(() => undefined);
8437
+ }
8438
+ /**
8439
+ * Clears whatever popup paints over `selector`'s click point so the caller's
8440
+ * trusted click lands on the field. The hit test, not the popup's markup, is
8441
+ * the evidence: a named occluder gets the listbox-aware dismissal first and
8442
+ * then a plain Escape for popups that dismissal does not recognise. Resolves
8443
+ * true only when a fresh hit test reaches the field; an indeterminate probe
8444
+ * (no layout, nothing at the point) only ever gets the listbox-aware
8445
+ * dismissal, never a blind Escape.
8446
+ */
8447
+ async function clearObscuringPopup(page, target, selector) {
8448
+ const covered = (probe) => probe?.state === "occluded" && probe.occluder !== undefined && probe.occluder !== "none";
8449
+ const first = await (0, trusted_click_1.probeClickPoint)(target, selector);
8450
+ if (first?.state === "hit")
8451
+ return true;
8452
+ await dismissLeftoverPromptPopups(page, target).catch(() => undefined);
8453
+ const afterListbox = await (0, trusted_click_1.probeClickPoint)(target, selector);
8454
+ if (afterListbox?.state === "hit")
8455
+ return true;
8456
+ if (!covered(afterListbox))
8457
+ return false;
8458
+ const trusted = await sendTrustedEscape(page, target)
8459
+ .then(() => true)
8460
+ .catch(() => false);
8461
+ if (!trusted)
8462
+ await target.evaluate(IN_PAGE_ESCAPE_EXPR).catch(() => undefined);
8463
+ await settleAfterEscape(page);
8464
+ return (await (0, trusted_click_1.probeClickPoint)(target, selector))?.state === "hit";
8465
+ }
8064
8466
  /**
8065
8467
  * Phase 3 of {@link tryPromptSelectorPrimitive}: given the popup's rendered
8066
8468
  * (possibly filtered) option list, match the requested option — deterministic
@@ -8091,7 +8493,7 @@ async function commitPromptOption(params) {
8091
8493
  .join("");
8092
8494
  let optionsResult = params.optionsResult;
8093
8495
  for (let depth = 0; depth <= PROMPT_SELECTOR_MAX_DRILL_DEPTH; depth++) {
8094
- const options = optionsResult.options ?? [];
8496
+ const options = realPromptOptions(optionsResult.options ?? [], option);
8095
8497
  const detMatch = options.find((o) => norm(o.text) === wantOpt) ?? null;
8096
8498
  if (detMatch === null && (anthropic === null || options.length === 0)) {
8097
8499
  logger.info(`prompt-selector primitive: no option match for ${optLabel}${anthropic === null ? " (no LLM client)" : ""}; falling through to cascade`);
@@ -8129,7 +8531,7 @@ async function commitPromptOption(params) {
8129
8531
  let clicked = false;
8130
8532
  for (let attempt = 0; attempt < PROMPT_SELECTOR_MAX_CLICK_RESOLVES && !clicked; attempt++) {
8131
8533
  const fresh = (await target.evaluate(enumerateOptionsExpr).catch(() => null));
8132
- const freshMatches = (fresh?.options ?? []).filter((o) => norm(o.text) === wantChosenText);
8534
+ const freshMatches = realPromptOptions(fresh?.options ?? [], option).filter((o) => norm(o.text) === wantChosenText);
8133
8535
  const freshOption = freshMatches.find((o) => o.nodeId !== undefined && o.nodeId === chosenOption.nodeId) ??
8134
8536
  freshMatches[0];
8135
8537
  if (!freshOption) {
@@ -8152,6 +8554,19 @@ async function commitPromptOption(params) {
8152
8554
  await (0, trusted_click_1.clickCentredTrusted)(target, optionSel, {
8153
8555
  label: "prompt-selector option",
8154
8556
  expectText: expectedDomText,
8557
+ clickAtPoint: async (x, y) => {
8558
+ const stillThere = await target
8559
+ .evaluate(`((x, y, want) => {
8560
+ const hit = document.elementFromPoint(x, y);
8561
+ const el = hit && hit.closest(${JSON.stringify(optionSel)});
8562
+ return !!el && (el.textContent || "").replace(/\\s+/g, " ").trim() === want;
8563
+ })(${x}, ${y}, ${JSON.stringify(expectedDomText)})`)
8564
+ .catch(() => false);
8565
+ if (!stillThere) {
8566
+ throw new trusted_click_1.CentredClickError("centred click: prompt-selector option identity mismatch at click point, click withheld", "mismatch");
8567
+ }
8568
+ await dispatchTrustedClick(page, target, x, y);
8569
+ },
8155
8570
  verifyBeforeClick: async () => (await target
8156
8571
  .evaluate(`((sel, want) => {
8157
8572
  const el = document.querySelector(sel);
@@ -8180,6 +8595,7 @@ async function commitPromptOption(params) {
8180
8595
  const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
8181
8596
  const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
8182
8597
  const announcedValue = ${PROMPT_ANNOUNCED_VALUE_EXPR};
8598
+ const namesOption = ${PROMPT_NAMES_OPTION_EXPR};
8183
8599
  const w = document.querySelector("[" + markAttr + '="' + wIdx + '"]');
8184
8600
  if (!w) return { ok: false, id: "", stillInvalid: false };
8185
8601
  const text = currentText(w, valueSel, emptyRx);
@@ -8238,7 +8654,7 @@ async function commitPromptOption(params) {
8238
8654
  // The widget's own announcement of what it committed is ground truth when readable.
8239
8655
  const committed = announcedValue(w);
8240
8656
  if (committed) {
8241
- return { ok: !!wantText && committed.includes(wantText), id: w.id || "", stillInvalid, committed };
8657
+ return { ok: !!wantText && namesOption(committed, wantText), id: w.id || "", stillInvalid, committed };
8242
8658
  }
8243
8659
  // A wrong option leaves the widget valid, so !stillInvalid proves nothing
8244
8660
  // once the widget's own value is readable: a readable value naming another
@@ -8410,6 +8826,9 @@ async function hasUnfilledRequiredControlForStep(target, instruction) {
8410
8826
  * upload/dropzone/file-related class or tag names. Tries multiple
8411
8827
  * candidates in order. Returns true on first success.
8412
8828
  *
8829
+ * Dispatch-only: a true return means the drop events fired, not that the
8830
+ * widget accepted the file; callers must verify.
8831
+ *
8413
8832
  * Site-agnostic — DataTransfer + DragEvent dispatch is universal HTML5
8414
8833
  * drag-and-drop API. Works on react-dropzone, Material Dropzone, custom
8415
8834
  * <uapp-upload>/<app-upload>, and any other drop-zone-based upload UI.
@@ -9517,11 +9936,7 @@ async function probeChildFrameSubmitFallback(params) {
9517
9936
  const clickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex))).catch(() => ({ clicked: false })));
9518
9937
  if (!clickResult.clicked)
9519
9938
  continue;
9520
- await (0, click_settle_1.settleAfterClick)(page, signalCounter, pre.networkCount, true, async () => {
9521
- const snap = await snapshotPage(target, signalCounter);
9522
- return snap.visibleTextSignature;
9523
- }, pre.visibleTextSignature);
9524
- const post = await snapshotPage(target, signalCounter, page);
9939
+ const post = await settleAndSnapshotPostClick(target, signalCounter, page, pre, isMidFlowSubmitStep);
9525
9940
  const verdict = (0, phantom_click_1.classifyPhantomClick)({
9526
9941
  actResultSuccess: true,
9527
9942
  pre,
@@ -9538,11 +9953,7 @@ async function probeChildFrameSubmitFallback(params) {
9538
9953
  const runnerUpClickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))).catch(() => ({ clicked: false })));
9539
9954
  if (!runnerUpClickResult.clicked)
9540
9955
  continue;
9541
- await (0, click_settle_1.settleAfterClick)(page, signalCounter, post.networkCount, true, async () => {
9542
- const snap = await snapshotPage(target, signalCounter);
9543
- return snap.visibleTextSignature;
9544
- }, post.visibleTextSignature);
9545
- const runnerUpPost = await snapshotPage(target, signalCounter, page);
9956
+ const runnerUpPost = await settleAndSnapshotPostClick(target, signalCounter, page, post, isMidFlowSubmitStep);
9546
9957
  const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
9547
9958
  actResultSuccess: true,
9548
9959
  pre,
@@ -10299,7 +10710,7 @@ async function executeStepWithHealing(params) {
10299
10710
  });
10300
10711
  if (backendErrorUrl !== null) {
10301
10712
  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");
10713
+ 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
10714
  }
10304
10715
  // Bounded same-origin child-iframe fallback: the probe above only ever
10305
10716
  // looked at the resolved (declared-or-main) frame's own light+shadow DOM,
@@ -10384,7 +10795,7 @@ async function executeStepWithHealing(params) {
10384
10795
  targetResolutionDiagnostic,
10385
10796
  fieldValuesAtFailure,
10386
10797
  }) ?? 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");
10798
+ 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
10799
  }
10389
10800
  // Pre-submit form-validity probe. Fires on the canonical submit step
10390
10801
  // (either the final flow step OR a step explicitly flagged `submitStep:
@@ -10766,13 +11177,13 @@ async function executeStepWithHealing(params) {
10766
11177
  // control stays covered so the attempt fails instead of clicking an overlay.
10767
11178
  // Resolves true once the observe ran, so guardedAct skips its own pass.
10768
11179
  const preCentreNaturalLanguageClick = async (instruction) => {
10769
- if (!stagehand_guard_1.CLICK_INTENT_RE.test(instruction))
11180
+ if (!(0, stagehand_guard_1.hasClickIntent)(instruction))
10770
11181
  return false;
10771
- const observed = await (0, stagehand_guard_1.guardedObserve)(stagehand, instruction, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
11182
+ const observed = await (0, stagehand_guard_1.guardedObserve)(stagehand, instruction, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget);
10772
11183
  const first = observed[0];
10773
- if (first?.method !== "click" || !first.selector)
11184
+ if (!(0, stagehand_guard_1.isTrustedClickMethod)(first?.method) || !first?.selector)
10774
11185
  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);
11186
+ const failure = await centreObservedClickTarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), first.selector);
10776
11187
  if (failure)
10777
11188
  throw new Error(failure);
10778
11189
  return true;
@@ -10951,11 +11362,15 @@ async function executeStepWithHealing(params) {
10951
11362
  // burn the step budget probing all of them.
10952
11363
  const runnerUp = ranked[1];
10953
11364
  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);
11365
+ const midTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
11366
+ const deepMidFlowSubmit = isMidFlowSubmitShapedStep({
11367
+ step,
11368
+ submitStep,
11369
+ isFinalStep,
11370
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11371
+ elementSubmitShaped: !stepDeclaredNonSubmit,
11372
+ });
11373
+ const midPost = await settleAndSnapshotPostClick(midTarget, signalCounter, page, pre, deepMidFlowSubmit);
10959
11374
  const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
10960
11375
  actResultSuccess: true,
10961
11376
  pre,
@@ -10964,20 +11379,8 @@ async function executeStepWithHealing(params) {
10964
11379
  // stray selection flip must not mask a top-pick phantom and skip
10965
11380
  // the runner-up retry.
10966
11381
  isSubmitShapedStep: true,
10967
- isMidFlowSubmitStep: isMidFlowSubmitShapedStep({
10968
- step,
10969
- submitStep,
10970
- isFinalStep,
10971
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10972
- }),
10973
- destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, midPost.url, isMidFlowSubmitShapedStep({
10974
- step,
10975
- submitStep,
10976
- isFinalStep,
10977
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
10978
- })
10979
- ? pre.url
10980
- : undefined),
11382
+ isMidFlowSubmitStep: deepMidFlowSubmit,
11383
+ destinationPlausible: (0, phantom_click_1.isPlausibleStepDestination)(step, midPost.url, deepMidFlowSubmit ? pre.url : undefined),
10981
11384
  });
10982
11385
  if (topVerdict === "phantom") {
10983
11386
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} deep-submit-locator top pick (${top.tag} "${top.accessibleName}") phantom-clicked; retrying runner-up ${runnerUp.tag} "${runnerUp.accessibleName}" (tier ${runnerUp.tier})`);
@@ -11370,8 +11773,8 @@ async function executeStepWithHealing(params) {
11370
11773
  // bars, so centre + hit-test the observed control first; an
11371
11774
  // unrecoverable occlusion fails the attempt instead of clicking an
11372
11775
  // 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}`));
11776
+ if ((0, stagehand_guard_1.isTrustedClickMethod)(overriddenTarget.method)) {
11777
+ const centreFailure = await centreObservedClickTarget(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), overriddenTarget.selector);
11375
11778
  if (centreFailure) {
11376
11779
  record.errorMessage = centreFailure;
11377
11780
  attempts.push(record);
@@ -11708,6 +12111,24 @@ async function executeStepWithHealing(params) {
11708
12111
  }
11709
12112
  // A fill/select/type action cannot open a tab, so skip the discovery wait for it.
11710
12113
  await adoptOpenedPage(resolvedAction && STATE_CLASS_METHODS.has(resolvedAction.method ?? "") ? 0 : undefined);
12114
+ const isClick = resolvedAction !== null && resolvedAction.method === "click";
12115
+ // Attribute-based submit-shape read of the resolved click target itself —
12116
+ // independent of the flow-authored `submitStep` flag. Read before the
12117
+ // settle: a view swap removes the clicked control, so a post-swap read
12118
+ // would blind the mid-flow exemption on exactly the case it exists for.
12119
+ // See resolvedClickTargetIsSubmitShaped's doc comment.
12120
+ const resolvedElementIsSubmitShaped = isClick && !stepDeclaredNonSubmit && resolvedAction?.selector
12121
+ ? await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector)
12122
+ : false;
12123
+ // Computed once: gates every credit signal that takes no post-click URL
12124
+ // (network, DOM read-back) and classifyPhantomClick's destinationPlausible.
12125
+ const stepIsMidFlowSubmit = isMidFlowSubmitShapedStep({
12126
+ step,
12127
+ submitStep,
12128
+ isFinalStep,
12129
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12130
+ elementSubmitShaped: resolvedElementIsSubmitShaped,
12131
+ });
11711
12132
  await page.waitForTimeout(STEP_PAUSE_MS);
11712
12133
  // A successful act that reported no actions still ran a click-class action;
11713
12134
  // judging it before its round-trip lands would cascade onto the advanced page.
@@ -11716,10 +12137,7 @@ async function executeStepWithHealing(params) {
11716
12137
  : !STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
11717
12138
  if (mayTriggerRoundTrip) {
11718
12139
  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);
12140
+ await settleWithPageQuiescence(page, settleTarget, signalCounter, pre.networkCount, isSettleWaitStep || resolvedElementIsSubmitShaped, pre.visibleTextSignature, pageAdvancedSince(pre.visibleTextSignature, stepIsMidFlowSubmit));
11723
12141
  }
11724
12142
  const post = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
11725
12143
  record.post = post;
@@ -11728,18 +12146,9 @@ async function executeStepWithHealing(params) {
11728
12146
  record.resolvedArguments = (resolvedAction.arguments ?? []).map(String);
11729
12147
  }
11730
12148
  const networkFired = post.networkCount > pre.networkCount;
11731
- // Computed once: gates every credit signal that takes no post-click URL
11732
- // (network, DOM read-back) and classifyPhantomClick's destinationPlausible.
11733
- const stepIsMidFlowSubmit = isMidFlowSubmitShapedStep({
11734
- step,
11735
- submitStep,
11736
- isFinalStep,
11737
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
11738
- });
11739
12149
  const destinationPlausible = (0, phantom_click_1.isPlausibleStepDestination)(step, post.url, stepIsMidFlowSubmit ? pre.url : undefined);
11740
12150
  const urlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, post.url) && destinationPlausible;
11741
12151
  const isStateClass = resolvedAction !== null && STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
11742
- const isClick = resolvedAction !== null && resolvedAction.method === "click";
11743
12152
  // State-class actions (fill/check/etc.) never move the network counter or URL,
11744
12153
  // so the legacy heuristic false-negatived every form fill. Re-read DOM state
11745
12154
  // for those; keep the navigation signal authoritative for clicks/links —
@@ -11836,12 +12245,6 @@ async function executeStepWithHealing(params) {
11836
12245
  const clickedElementStillPresent = isClick && resolvedAction?.selector
11837
12246
  ? await resolvedClickTargetStillPresent(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector)
11838
12247
  : true;
11839
- // Attribute-based submit-shape read of the resolved click target itself —
11840
- // independent of the flow-authored `submitStep` flag. See
11841
- // resolvedClickTargetIsSubmitShaped's doc comment.
11842
- const resolvedElementIsSubmitShaped = isClick && !stepDeclaredNonSubmit && resolvedAction?.selector
11843
- ? await resolvedClickTargetIsSubmitShaped(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector)
11844
- : false;
11845
12248
  // Client-side view-swap gate: credit a click that produces substantial
11846
12249
  // DOM growth (≥5KB) with zero network when it's NOT a submit/final step
11847
12250
  // and NOT an advance-pattern step. Fixes the top-window site "Manual Application"
@@ -12142,6 +12545,9 @@ async function executeStepWithHealing(params) {
12142
12545
  captureFn,
12143
12546
  step,
12144
12547
  isFinalStep,
12548
+ submitStep,
12549
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12550
+ elementSubmitShaped: resolvedElementIsSubmitShaped,
12145
12551
  page,
12146
12552
  target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
12147
12553
  signalCounter,
@@ -12383,10 +12789,13 @@ async function executeStepWithHealing(params) {
12383
12789
  probeResult.checked === true &&
12384
12790
  (probeResult.ariaManaged === true || !ancestorStillInvalid);
12385
12791
  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);
12792
+ await settleWithPageQuiescence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, pre.networkCount, isSettleWaitStep || retryResolvedElementIsSubmitShaped, pre.visibleTextSignature, pageAdvancedSince(pre.visibleTextSignature, isMidFlowSubmitShapedStep({
12793
+ step,
12794
+ submitStep,
12795
+ isFinalStep,
12796
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
12797
+ elementSubmitShaped: retryResolvedElementIsSubmitShaped,
12798
+ })));
12390
12799
  const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
12391
12800
  const retryNetworkFired = retryPost.networkCount > pre.networkCount;
12392
12801
  const retryMidFlowSubmit = isMidFlowSubmitShapedStep({
@@ -12738,6 +13147,9 @@ async function executeStepWithHealing(params) {
12738
13147
  captureFn,
12739
13148
  step,
12740
13149
  isFinalStep,
13150
+ submitStep,
13151
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
13152
+ elementSubmitShaped: resolvedElementIsSubmitShaped,
12741
13153
  page,
12742
13154
  target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
12743
13155
  signalCounter,
@@ -13093,7 +13505,7 @@ async function executeStepWithHealing(params) {
13093
13505
  if (dumpPath !== null) {
13094
13506
  logger.error(`${formatStepPrefix(stepIndex, totalSteps)} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);
13095
13507
  }
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");
13508
+ 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
13509
  }
13098
13510
  /**
13099
13511
  * Navigates `page` directly to `url` and waits for the SPA to hydrate,