@enricai/barnacle 1.12.39 → 1.12.41

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 (117) hide show
  1. package/dist/api/errors.d.ts +1 -1
  2. package/dist/api/errors.js +1 -1
  3. package/dist/api/helpers/reply.d.ts +1 -1
  4. package/dist/api/helpers/reply.js +1 -1
  5. package/dist/api/plugins/auth.js +1 -1
  6. package/dist/api/plugins/error-handler.js +3 -3
  7. package/dist/api/routes/submissions.js +2 -2
  8. package/dist/api/schemas/submissions.js +1 -1
  9. package/dist/lib/applicant-payload.js +3 -3
  10. package/dist/lib/datadog.js +2 -2
  11. package/dist/lib/dd-metrics.js +1 -1
  12. package/dist/lib/llm/anthropic-client.js +1 -1
  13. package/dist/lib/llm/judge.d.ts +1 -1
  14. package/dist/lib/llm/judge.js +2 -2
  15. package/dist/lib/llm/judges/error-messages.d.ts +2 -2
  16. package/dist/lib/llm/judges/error-messages.js +3 -3
  17. package/dist/lib/llm/judges/invalid-fields.d.ts +2 -2
  18. package/dist/lib/llm/judges/invalid-fields.js +3 -3
  19. package/dist/lib/llm/judges/modal-priority.d.ts +2 -2
  20. package/dist/lib/llm/judges/modal-priority.js +3 -3
  21. package/dist/lib/llm/judges/select-option.d.ts +2 -2
  22. package/dist/lib/llm/judges/select-option.js +3 -3
  23. package/dist/lib/llm/judges/verify-submit.d.ts +2 -2
  24. package/dist/lib/llm/judges/verify-submit.js +3 -3
  25. package/dist/lib/logging.js +2 -2
  26. package/dist/lib/option-matcher.js +2 -2
  27. package/dist/lib/statsd.js +3 -3
  28. package/dist/lib/telemetry/beacon-capture.d.ts +1 -1
  29. package/dist/lib/telemetry/beacon-capture.js +2 -2
  30. package/dist/lib/telemetry/call-capture.js +2 -2
  31. package/dist/lib/telemetry/reconciliation-source.d.ts +2 -2
  32. package/dist/lib/telemetry/reconciliation-source.js +5 -5
  33. package/dist/lib/telemetry/s3-sink.js +2 -2
  34. package/dist/lib/telemetry/submission-capture.js +3 -3
  35. package/dist/lib/telemetry/submission-query.d.ts +2 -2
  36. package/dist/lib/telemetry/submission-reader.d.ts +1 -1
  37. package/dist/lib/telemetry/submission-reader.js +2 -2
  38. package/dist/lib/telemetry/submissions-s3-objects.js +1 -1
  39. package/dist/lib/telemetry/submissions-s3-reader.d.ts +1 -1
  40. package/dist/lib/telemetry/submissions-s3-reader.js +3 -3
  41. package/dist/lib/tracking-click.js +4 -4
  42. package/dist/plugins/config-plugin.js +3 -3
  43. package/dist/plugins/discover.js +2 -2
  44. package/dist/recon/load-form-schema.d.ts +1 -1
  45. package/dist/recon/load-vocabulary.d.ts +1 -1
  46. package/dist/recon/load-vocabulary.js +1 -1
  47. package/dist/scraper/behavioral-signals.d.ts +1 -1
  48. package/dist/scraper/captcha-solver.d.ts +1 -1
  49. package/dist/scraper/captcha-solver.js +2 -2
  50. package/dist/scraper/cookie-jar.js +1 -1
  51. package/dist/scraper/deep-locator-actuate.d.ts +1 -1
  52. package/dist/scraper/deep-locator-actuate.js +3 -3
  53. package/dist/scraper/deep-locator-candidates.d.ts +1 -1
  54. package/dist/scraper/deep-locator-candidates.js +3 -3
  55. package/dist/scraper/deep-locator-click.d.ts +1 -1
  56. package/dist/scraper/deep-locator-click.js +1 -1
  57. package/dist/scraper/deep-locator-fake.d.ts +1 -1
  58. package/dist/scraper/deep-locator-scan.js +1 -1
  59. package/dist/scraper/deep-query.js +1 -1
  60. package/dist/scraper/flow-runner.d.ts +50 -10
  61. package/dist/scraper/flow-runner.d.ts.map +1 -1
  62. package/dist/scraper/flow-runner.js +289 -90
  63. package/dist/scraper/flow-runner.js.map +1 -1
  64. package/dist/scraper/frame-target.js +1 -1
  65. package/dist/scraper/graphql-client.js +1 -1
  66. package/dist/scraper/http-client.d.ts +1 -1
  67. package/dist/scraper/http-client.js +2 -2
  68. package/dist/scraper/http-status-classifier.js +1 -1
  69. package/dist/scraper/parse-json-response.d.ts +1 -1
  70. package/dist/scraper/parse-json-response.js +1 -1
  71. package/dist/scraper/pool.d.ts +3 -3
  72. package/dist/scraper/pool.js +3 -3
  73. package/dist/scraper/rate-limited-json-client.d.ts +1 -1
  74. package/dist/scraper/rate-limited-json-client.js +1 -1
  75. package/dist/scraper/raw-fetch.js +2 -2
  76. package/dist/scraper/require-response-field.js +1 -1
  77. package/dist/scraper/retry.d.ts +1 -1
  78. package/dist/scraper/retry.js +1 -1
  79. package/dist/scraper/session-browserbase.d.ts +1 -1
  80. package/dist/scraper/session-browserbase.js +6 -6
  81. package/dist/scraper/session-create-limiter.js +1 -1
  82. package/dist/scraper/session-ip.js +1 -1
  83. package/dist/scraper/session-shared.d.ts +1 -1
  84. package/dist/scraper/session-steel.d.ts +2 -2
  85. package/dist/scraper/session-steel.js +5 -5
  86. package/dist/scraper/session-teardown.d.ts +1 -1
  87. package/dist/scraper/session-teardown.js +1 -1
  88. package/dist/scraper/session-warmup.d.ts +1 -1
  89. package/dist/scraper/session.d.ts +2 -2
  90. package/dist/scraper/session.js +4 -4
  91. package/dist/scraper/spa-readiness.js +1 -1
  92. package/dist/scraper/stagehand-guard.d.ts +1 -1
  93. package/dist/scraper/stagehand-guard.js +2 -2
  94. package/dist/scraper/submit-control.js +1 -1
  95. package/dist/scripts/llm-heal.d.ts +2 -2
  96. package/dist/scripts/llm-heal.js +2 -2
  97. package/dist/scripts/recon-browser.d.ts +15 -0
  98. package/dist/scripts/recon-browser.d.ts.map +1 -1
  99. package/dist/scripts/recon-browser.js +56 -3
  100. package/dist/scripts/recon-browser.js.map +1 -1
  101. package/dist/scripts/recon-generate-multicall-fixture.d.ts +111 -2
  102. package/dist/scripts/recon-generate-multicall-fixture.d.ts.map +1 -1
  103. package/dist/scripts/recon-generate-multicall-fixture.js +311 -0
  104. package/dist/scripts/recon-generate-multicall-fixture.js.map +1 -1
  105. package/dist/scripts/recon-generate-multiendpoint-fixture.d.ts +1 -1
  106. package/dist/scripts/recon-generate-repeated-section-fixture.d.ts +1 -1
  107. package/dist/scripts/recon-generate-repeated-section-fixture.js +1 -1
  108. package/dist/scripts/recon-generate.d.ts +9 -4
  109. package/dist/scripts/recon-generate.d.ts.map +1 -1
  110. package/dist/scripts/recon-generate.js +235 -32
  111. package/dist/scripts/recon-generate.js.map +1 -1
  112. package/dist/scripts/recon-http.js +1 -1
  113. package/dist/scripts/recon-replay-jobs.js +2 -2
  114. package/dist/scripts/recon-summarize.js +1 -1
  115. package/dist/testing/replay-integration-suite.mjs +1 -1
  116. package/dist/testmail/client.js +1 -1
  117. package/package.json +2 -2
@@ -17,6 +17,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.TRAILING_GRACE_WINDOW = exports.STEP_WATCHDOG_MS = exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR = exports.GOTO_TIMEOUT_MS = exports.waitForSpaReady = void 0;
18
18
  exports.mergeResponseHeaders = mergeResponseHeaders;
19
19
  exports.wireSignalCapture = wireSignalCapture;
20
+ exports.shouldRetryCaptchaRegistry = shouldRetryCaptchaRegistry;
20
21
  exports.prepareFailureDumpBody = prepareFailureDumpBody;
21
22
  exports.snapshotPage = snapshotPage;
22
23
  exports.findRecentBackendError = findRecentBackendError;
@@ -73,6 +74,7 @@ exports.parseWidgetOptionClickStep = parseWidgetOptionClickStep;
73
74
  exports.parseCheckStep = parseCheckStep;
74
75
  exports.pollEnumerate = pollEnumerate;
75
76
  exports.waitForTransitionBody = waitForTransitionBody;
77
+ exports.hasOriginOrPathChanged = hasOriginOrPathChanged;
76
78
  exports.injectCaptchaTokenAndSubmit = injectCaptchaTokenAndSubmit;
77
79
  exports.submitCaptchaGatedForm = submitCaptchaGatedForm;
78
80
  exports.trySelectPrimitive = trySelectPrimitive;
@@ -110,24 +112,24 @@ const logging_1 = require("../lib/logging");
110
112
  const call_capture_1 = require("../lib/telemetry/call-capture");
111
113
  const call_types_1 = require("../lib/telemetry/call-types");
112
114
  const capture_filters_1 = require("../recon/capture-filters");
113
- const browser_click_expr_1 = require("../scraper/browser-click-expr");
114
- const captcha_callback_capture_1 = require("../scraper/captcha-callback-capture");
115
- const captcha_solver_1 = require("../scraper/captcha-solver");
116
- const deep_locator_actuate_1 = require("../scraper/deep-locator-actuate");
117
- const deep_locator_candidates_1 = require("../scraper/deep-locator-candidates");
118
- const deep_locator_click_1 = require("../scraper/deep-locator-click");
119
- const deep_locator_scan_1 = require("../scraper/deep-locator-scan");
120
- const errors_2 = require("../scraper/errors");
121
- const frame_target_1 = require("../scraper/frame-target");
122
- const phantom_click_1 = require("../scraper/phantom-click");
123
- const session_teardown_1 = require("../scraper/session-teardown");
124
- const spa_readiness_1 = require("../scraper/spa-readiness");
125
- const stagehand_guard_1 = require("../scraper/stagehand-guard");
126
- const submit_control_1 = require("../scraper/submit-control");
127
- const watchdog_1 = require("../scraper/watchdog");
115
+ const browser_click_expr_1 = require("./browser-click-expr");
116
+ const captcha_callback_capture_1 = require("./captcha-callback-capture");
117
+ const captcha_solver_1 = require("./captcha-solver");
118
+ const deep_locator_actuate_1 = require("./deep-locator-actuate");
119
+ const deep_locator_candidates_1 = require("./deep-locator-candidates");
120
+ const deep_locator_click_1 = require("./deep-locator-click");
121
+ const deep_locator_scan_1 = require("./deep-locator-scan");
122
+ const errors_2 = require("./errors");
123
+ const frame_target_1 = require("./frame-target");
124
+ const phantom_click_1 = require("./phantom-click");
125
+ const session_teardown_1 = require("./session-teardown");
126
+ const spa_readiness_1 = require("./spa-readiness");
127
+ const stagehand_guard_1 = require("./stagehand-guard");
128
+ const submit_control_1 = require("./submit-control");
129
+ const watchdog_1 = require("./watchdog");
128
130
  const recon_shared_1 = require("../scripts/recon-shared");
129
131
  const client_1 = require("../testmail/client");
130
- var spa_readiness_2 = require("../scraper/spa-readiness");
132
+ var spa_readiness_2 = require("./spa-readiness");
131
133
  Object.defineProperty(exports, "waitForSpaReady", { enumerable: true, get: function () { return spa_readiness_2.waitForSpaReady; } });
132
134
  const logger = (0, logging_1.getLogger)({ name: "scraper/flow-runner" });
133
135
  /** Cap on the rolling capture-filename window held in memory for failure dumps. */
@@ -463,16 +465,52 @@ const PROMPT_TRIGGER_SELECTORS = [
463
465
  "[data-automation-id='multiSelectContainer']",
464
466
  "[data-automation-id='promptSelectionLabel']",
465
467
  ].join(",");
468
+ /**
469
+ * Cross-vendor selector union for an OPTION rendered inside the opened popup —
470
+ * standards first (`role=option`), then the widget-kit option markers. Sibling
471
+ * of {@link PROMPT_TRIGGER_SELECTORS}; same union discipline and guard treatment.
472
+ */
473
+ const PROMPT_OPTION_SELECTORS = [
474
+ "[role='option']",
475
+ "[data-automation-id='promptOption']",
476
+ "[data-uxi-widget-type='selectinputlistitem']",
477
+ ].join(",");
466
478
  /**
467
479
  * In-page predicate source for `(el) => boolean`: true when `el` is a
468
480
  * `<select>` that is a design-system combobox opener's paired-but-hidden
469
481
  * shadow control (Base Web `bb-customSelect` and any other vendor matching
470
- * {@link PROMPT_TRIGGER_SELECTORS}) — a `<select>` with no layout box
471
- * (`offsetParent === null`, the same idiom `DOM_SNAPSHOT_EXPR` uses for
472
- * `display:none`-driven hiding classes like `dropdown-hide`) whose nearby
473
- * container also holds a {@link PROMPT_TRIGGER_SELECTORS} opener element
474
- * other than itself. The bounded-ancestor-climb-then-subtree-search shape
475
- * mirrors {@link NEARBY_SELECTION_CONTAINER_FN_SRC}, but searches for
482
+ * {@link PROMPT_TRIGGER_SELECTORS}). Two independent signals both have to
483
+ * hold, and each is deliberately technique-agnostic rather than an exact
484
+ * enumeration: (1) hiddenness — `offsetParent === null`, a zero-size rect,
485
+ * `display:none`/`visibility:hidden`/`opacity:0`, off-screen positioning, OR
486
+ * any of the layout-fingerprint checks that generalize across the remaining
487
+ * CSS hiding techniques (`hasTinyFootprint` for the sr-only clip idiom and
488
+ * `transform:scale(0)`'s collapsed rect, a parsed `clip:` rect of zero area,
489
+ * an active `clip-path`, a `scale(0)`/zero `matrix()` transform, or an
490
+ * ancestor whose own box is collapsed to near-zero while clipping overflow)
491
+ * — and (2) opener-pairing — described below — which is what makes the
492
+ * hiddenness check safe to broaden: a false-positive on hiddenness alone
493
+ * (e.g. some other reason a real, standalone select reports a tiny rect)
494
+ * still can't misfire unless that same select also sits paired with a
495
+ * rendered combobox opener, which an ordinary select never does. Signal (2)
496
+ * requires the select's nearby container to also hold a
497
+ * {@link PROMPT_TRIGGER_SELECTORS} opener element, other than itself, that is
498
+ * ARIA-wired to a listbox panel — via `aria-owns`/`aria-controls`/
499
+ * `aria-haspopup="listbox"` declared on the opener (accepted unresolved only
500
+ * when the opener sits in the select's own immediate container, since many
501
+ * widgets mount their popup lazily on first open and haven't rendered it
502
+ * yet), or, once the referenced id does resolve (at any climbed depth), only
503
+ * when the target actually is `role=listbox` or contains a
504
+ * {@link PROMPT_OPTION_SELECTORS} match, or, for an opener declaring no
505
+ * owns/controls/haspopup at all, a {@link PROMPT_OPTION_SELECTORS} match
506
+ * rendered in THAT OPENER's own immediate container (never the whole climbed
507
+ * ancestor — scanning the ancestor would credit an unrelated already-open
508
+ * widget elsewhere in a shared ancestor as this select's pairing). Requiring
509
+ * resolution past the immediate container keeps the broadened hidden test
510
+ * from misclassifying an unrelated widget elsewhere in a shared ancestor as
511
+ * a paired shadow select.
512
+ * The bounded-ancestor-climb-then-subtree-search shape mirrors
513
+ * {@link NEARBY_SELECTION_CONTAINER_FN_SRC}, but searches for
476
514
  * {@link PROMPT_TRIGGER_SELECTORS} instead of the selection-marker union —
477
515
  * this ties "opener" to the exact same union `tryPromptSelectorPrimitive`
478
516
  * recognizes, so a select is excluded precisely when the primitive that
@@ -483,26 +521,70 @@ const PROMPT_TRIGGER_SELECTORS = [
483
521
  * when the opener's own readback is inconclusive.
484
522
  */
485
523
  exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR = `(el) => {
486
- if (!el || el.tagName !== "SELECT" || el.offsetParent !== null) return false;
524
+ if (!el || el.tagName !== "SELECT") return false;
525
+ const rect = el.getBoundingClientRect();
526
+ const style = getComputedStyle(el);
527
+ const isOffscreen = rect.right <= 0 || rect.bottom <= 0 || rect.left >= window.innerWidth || rect.top >= window.innerHeight;
528
+ // A near-zero footprint (<=1px either dimension) is the layout-level fingerprint
529
+ // shared by the classic sr-only clip technique (explicit width:1px;height:1px) and
530
+ // a real browser's transform:scale(0) (which collapses the bounding rect itself) —
531
+ // technique-agnostic, unlike enumerating every CSS property that can shrink a box.
532
+ const hasTinyFootprint = rect.width <= 1 || rect.height <= 1;
533
+ const clipMatch = /rect\\(\\s*([\\d.]+)px[,\\s]+([\\d.]+)px[,\\s]+([\\d.]+)px[,\\s]+([\\d.]+)px\\s*\\)/.exec(style.clip || "");
534
+ const isClipZeroArea = !!clipMatch && (parseFloat(clipMatch[3]) - parseFloat(clipMatch[1]) <= 0 || parseFloat(clipMatch[2]) - parseFloat(clipMatch[4]) <= 0);
535
+ // Only treat clip-path as a hiding signal when it collapses the element to
536
+ // zero visible area (circle(0), inset(50%), a degenerate polygon whose
537
+ // points share a single coordinate) — a decorative clip-path (rounded
538
+ // corners, an avatar circle) leaves the element fully visible and must
539
+ // not be misread as hidden.
540
+ const cp = style.clipPath || "";
541
+ const hasClipPath = /circle\\(\\s*0(?:px|%)?\\s*(?:at\\s[^)]*)?\\)/i.test(cp) || /ellipse\\(\\s*0(?:px|%)?\\s+0(?:px|%)?\\s*(?:at\\s[^)]*)?\\)/i.test(cp) || /inset\\(\\s*50%\\s*\\)/i.test(cp) || /polygon\\(\\s*(?:0%?\\s+0%?\\s*,?\\s*)+\\)/i.test(cp);
542
+ const hasZeroScale = /scale\\(\\s*0(?:\\.0+)?\\s*(?:,\\s*0(?:\\.0+)?\\s*)?\\)/.test(style.transform || "") || /matrix\\(\\s*0\\s*,\\s*0\\s*,\\s*0\\s*,\\s*0\\s*,/.test(style.transform || "");
543
+ // Ancestor overflow-clipping: a wrapper with overflow:hidden collapsed to a
544
+ // near-zero box clips its whole subtree to invisibility even though the
545
+ // select's own box, style and offsetParent all look ordinary.
546
+ const isAncestorClipped = (() => {
547
+ let anc = el.parentElement;
548
+ for (let d = 0; d < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && anc; d++) {
549
+ const ancStyle = getComputedStyle(anc);
550
+ const overflowHides = ancStyle.overflow === "hidden" || ancStyle.overflow === "clip" || ancStyle.overflowX === "hidden" || ancStyle.overflowY === "hidden";
551
+ if (overflowHides) {
552
+ const ancRect = anc.getBoundingClientRect();
553
+ if (ancRect.width <= 1 || ancRect.height <= 1) return true;
554
+ }
555
+ anc = anc.parentElement;
556
+ }
557
+ return false;
558
+ })();
559
+ const isHidden = el.offsetParent === null || (rect.width === 0 && rect.height === 0) || style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity) === 0 || isOffscreen || hasTinyFootprint || isClipZeroArea || hasClipPath || hasZeroScale || isAncestorClipped;
560
+ if (!isHidden) return false;
487
561
  const triggerSel = ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)};
562
+ const optionSel = ${JSON.stringify(PROMPT_OPTION_SELECTORS)};
563
+ const hasRenderedPanel = (opener, allowUnresolvedDeclaration) => {
564
+ const ownsId = opener.getAttribute("aria-owns") || opener.getAttribute("aria-controls");
565
+ const target = ownsId ? document.getElementById(ownsId) : null;
566
+ if (target) return target.getAttribute("role") === "listbox" || target.querySelector(optionSel) !== null;
567
+ if (!allowUnresolvedDeclaration) return false;
568
+ if (!!ownsId || opener.getAttribute("aria-haspopup") === "listbox") return true;
569
+ return !!(opener.parentElement && opener.parentElement.querySelector(optionSel));
570
+ };
488
571
  let node = el.parentElement;
489
572
  for (let depth = 0; depth < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && node; depth++) {
490
573
  const openers = node.querySelectorAll ? Array.from(node.querySelectorAll(triggerSel)) : [];
491
- if (openers.some((opener) => opener !== el)) return true;
574
+ // Only the select's own immediate container may pair on a merely-declared
575
+ // (not-yet-rendered) aria-owns/aria-controls/aria-haspopup target — an
576
+ // opener found only by climbing further up must have its panel actually
577
+ // resolve, or an unrelated sibling widget elsewhere in a shared ancestor
578
+ // (e.g. a distant visible field's own opener) would false-positive. An
579
+ // opener with no ARIA pairing declaration at all is credited only when
580
+ // an option-shaped element sits in ITS OWN immediate container — never
581
+ // by scanning the whole climbed ancestor, which would credit an
582
+ // unrelated already-open widget elsewhere in a shared ancestor.
583
+ if (openers.some((opener) => opener !== el && hasRenderedPanel(opener, depth === 0))) return true;
492
584
  node = node.parentElement;
493
585
  }
494
586
  return false;
495
587
  }`;
496
- /**
497
- * Cross-vendor selector union for an OPTION rendered inside the opened popup —
498
- * standards first (`role=option`), then the widget-kit option markers. Sibling
499
- * of {@link PROMPT_TRIGGER_SELECTORS}; same union discipline and guard treatment.
500
- */
501
- const PROMPT_OPTION_SELECTORS = [
502
- "[role='option']",
503
- "[data-automation-id='promptOption']",
504
- "[data-uxi-widget-type='selectinputlistitem']",
505
- ].join(",");
506
588
  /**
507
589
  * Cross-vendor selector union for an in-popup filter/typeahead input — standards
508
590
  * first, then the widget-kit's bare filter `<input>` (which carries no
@@ -636,6 +718,31 @@ const ADVANCE_TRANSITION_POLL_INTERVAL_MS = 350;
636
718
  * {@link ADVANCE_TRANSITION_POLL_MS} unchanged.
637
719
  */
638
720
  const CAPTCHA_TRANSITION_POLL_MS = 45_000;
721
+ /**
722
+ * Bounded retry budget for the captchaGated solve+inject+registryState-check
723
+ * sequence. The registry-empty/absent condition (the hCaptcha render callback
724
+ * hasn't attached to `HCAPTCHA_CALLBACK_REGISTRY_GLOBAL` yet when the solve
725
+ * finishes) is an intermittent attach-timing race, not a deterministic
726
+ * failure, so a single attempt is too eager to give up. Mirrors the
727
+ * MAX_STEP_ATTEMPTS/PRIMITIVE_ENUMERATE_ATTEMPTS=5 naming precedent, but a
728
+ * captcha solve is expensive (10-30s+ round trip via a real solver service),
729
+ * so the budget is kept small.
730
+ */
731
+ const CAPTCHA_REGISTRY_RETRY_ATTEMPTS = 3;
732
+ /**
733
+ * Pure decision gate for the captchaGated registry-retry loop: isolates the
734
+ * retry-vs-give-up call from the solve+inject+poll I/O so the attach-timing
735
+ * race (registry empty/absent, or a callback that fired without a confirmed
736
+ * transition) can be pinned with a unit test independent of any browser
737
+ * evaluate() call.
738
+ */
739
+ function shouldRetryCaptchaRegistry(attemptNumber, maxAttempts, registryState, callbackDiscovered, confirmed) {
740
+ if (confirmed)
741
+ return false;
742
+ const registryRace = registryState === "empty" || registryState === "absent";
743
+ const callbackFiredButNoNav = callbackDiscovered && !confirmed;
744
+ return (registryRace || callbackFiredButNoNav) && attemptNumber < maxAttempts;
745
+ }
639
746
  /**
640
747
  * Attempts to decode opaque request parameters: tries JSON parse, then
641
748
  * URL-decode, then base64. Returns the decoded value or null if none worked.
@@ -4374,6 +4481,52 @@ async function waitForTransitionBody(params) {
4374
4481
  }
4375
4482
  return false;
4376
4483
  }
4484
+ /**
4485
+ * Reduces a URL to origin+pathname (query/hash ignored) so a captcha
4486
+ * target's post-submit `url()` can be compared to its pre-submit baseline
4487
+ * without a same-page query-string change (e.g. a step counter) registering
4488
+ * as a false advance. Unparseable URLs compare equal only to themselves.
4489
+ */
4490
+ function originAndPathOf(url) {
4491
+ try {
4492
+ const parsed = new URL(url);
4493
+ return `${parsed.origin}${parsed.pathname}`;
4494
+ }
4495
+ catch {
4496
+ return url;
4497
+ }
4498
+ }
4499
+ /**
4500
+ * Credits a captchaGated advance from an observed navigation: true only
4501
+ * when origin or pathname actually changed, so a same-page query-string
4502
+ * mutation (e.g. a step counter) never counts as an advance.
4503
+ */
4504
+ function hasOriginOrPathChanged(preUrl, postUrl) {
4505
+ return originAndPathOf(preUrl) !== originAndPathOf(postUrl);
4506
+ }
4507
+ /**
4508
+ * Polls the captcha target's URL for an origin/path change after a
4509
+ * multipart form submit, which navigates the page without producing any
4510
+ * matchable XHR body for {@link waitForTransitionBody} to catch — the
4511
+ * regex-based poll alone silently misses a full-page submit advance. Runs
4512
+ * independently of whether `advanceTransitionBodyPattern` is configured.
4513
+ */
4514
+ async function waitForCaptchaNavigation(params) {
4515
+ const { page, captchaTarget, baselineUrl, timeoutMs, intervalMs } = params;
4516
+ const check = async () => {
4517
+ const currentUrl = await captchaTarget.url().catch(() => baselineUrl);
4518
+ return hasOriginOrPathChanged(baselineUrl, currentUrl);
4519
+ };
4520
+ if (await check())
4521
+ return true;
4522
+ const deadline = performance.now() + timeoutMs;
4523
+ while (performance.now() < deadline) {
4524
+ await page.waitForTimeout(intervalMs);
4525
+ if (await check())
4526
+ return true;
4527
+ }
4528
+ return false;
4529
+ }
4377
4530
  /**
4378
4531
  * Shared in-page discovery of the widget's callback, used identically by
4379
4532
  * `injectCaptchaTokenAndSubmit`'s precheck (to decide the branch) and its
@@ -7552,49 +7705,48 @@ async function executeStepWithHealing(params) {
7552
7705
  const pageUrl = await captchaTarget.url();
7553
7706
  const uaRaw = await page.evaluate("navigator.userAgent").catch(() => null);
7554
7707
  const userAgent = typeof uaRaw === "string" ? uaRaw : undefined;
7555
- const solved = await (0, captcha_solver_1.solveCaptcha)({
7556
- type: "hcaptcha",
7557
- siteKey,
7558
- pageUrl,
7559
- isInvisible,
7560
- userAgent,
7561
- }).catch((err) => {
7562
- logger.error(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: solve failed (${(0, errors_1.toErrorMessage)(err)}); failing the step rather than silently proceeding`);
7563
- throw err;
7564
- });
7565
- const preCaptchaCaptureIdx = latestCaptureIndex(recentCaptures);
7566
- const injectResult = await injectCaptchaTokenAndSubmit(captchaTarget, solved.token);
7567
- const registryState = await captchaTarget.evaluate(`(() => {
7708
+ // Bounded retry around the solve+inject+registryState-check+poll unit:
7709
+ // the registry-empty/absent condition is an intermittent attach-timing
7710
+ // race (the render callback hasn't registered by the time the solve
7711
+ // finishes), not a deterministic failure, so a single attempt is too
7712
+ // eager to give up and route straight to a global replan. Also retries
7713
+ // when the widget's own callback fired (callbackDiscovered) but neither
7714
+ // transition poll below confirmed an advance — the callback-fired-but-
7715
+ // no-nav case is the same underlying race from the other side.
7716
+ for (let captchaAttempt = 1; captchaAttempt <= CAPTCHA_REGISTRY_RETRY_ATTEMPTS; captchaAttempt++) {
7717
+ if (captchaAttempt > 1) {
7718
+ // Re-assert the capture install in the target frame before
7719
+ // retrying: a late-attaching registry may not exist yet on the
7720
+ // first attempt, and re-running this idempotent install gives a
7721
+ // widget that renders on demand another chance to be caught.
7722
+ await captchaTarget.evaluate((0, captcha_callback_capture_1.buildHcaptchaCallbackCaptureScript)()).catch(() => undefined);
7723
+ }
7724
+ const solved = await (0, captcha_solver_1.solveCaptcha)({
7725
+ type: "hcaptcha",
7726
+ siteKey,
7727
+ pageUrl,
7728
+ isInvisible,
7729
+ userAgent,
7730
+ }).catch((err) => {
7731
+ logger.error(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: solve failed (${(0, errors_1.toErrorMessage)(err)}); failing the step rather than silently proceeding`);
7732
+ throw err;
7733
+ });
7734
+ const preCaptchaCaptureIdx = latestCaptureIndex(recentCaptures);
7735
+ const injectResult = await injectCaptchaTokenAndSubmit(captchaTarget, solved.token);
7736
+ const registryState = await captchaTarget.evaluate(`(() => {
7568
7737
  const registry = window[${JSON.stringify(captcha_callback_capture_1.HCAPTCHA_CALLBACK_REGISTRY_GLOBAL)}];
7569
7738
  if (!registry) return "absent";
7570
7739
  return Object.keys(registry).length === 0 ? "empty" : "populated";
7571
7740
  })()`);
7572
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: token injected=${injectResult.injected} hasForm=${injectResult.hasForm} callbackDiscovered=${injectResult.callbackDiscovered} registryState=${registryState}`);
7573
- // Reuse the EXISTING waitForTransitionBody poll (no new captcha-specific
7574
- // poll loop), just with a widened budget so the solve+submit round trip
7575
- // doesn't eat into the plain ADVANCE_TRANSITION_POLL_MS window. A
7576
- // pattern-confirmed transition here is treated as verified the same way
7577
- // the probe-absent branch below treats a detected transition — the
7578
- // normal cascade/verifier still runs when the pattern doesn't confirm
7579
- // (or none is configured), so this never replaces that verifier.
7580
- let confirmed = false;
7581
- if (advanceTransitionBodyPattern) {
7582
- confirmed = await waitForTransitionBody({
7583
- page,
7584
- preIdx: preCaptchaCaptureIdx,
7585
- advanceTransitionBodyPattern,
7586
- timeoutMs: CAPTCHA_TRANSITION_POLL_MS,
7587
- intervalMs: ADVANCE_TRANSITION_POLL_INTERVAL_MS,
7588
- });
7589
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: post-submit transition poll confirmed=${confirmed}`);
7590
- }
7591
- // Some widgets' own render callback already submitted the form as a
7592
- // side effect of the inject (the poll above would have confirmed it);
7593
- // only issue an explicit submit when no transition was observed AND a
7594
- // form is known to exist to submit — otherwise this would double-submit
7595
- // a form the widget's own callback already advanced.
7596
- if (!confirmed && injectResult.hasForm) {
7597
- await submitCaptchaGatedForm(captchaTarget);
7741
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: attempt=${captchaAttempt}/${CAPTCHA_REGISTRY_RETRY_ATTEMPTS} token injected=${injectResult.injected} hasForm=${injectResult.hasForm} callbackDiscovered=${injectResult.callbackDiscovered} registryState=${registryState}`);
7742
+ // Reuse the EXISTING waitForTransitionBody poll (no new captcha-specific
7743
+ // poll loop), just with a widened budget so the solve+submit round trip
7744
+ // doesn't eat into the plain ADVANCE_TRANSITION_POLL_MS window. A
7745
+ // pattern-confirmed transition here is treated as verified the same way
7746
+ // the probe-absent branch below treats a detected transition — the
7747
+ // normal cascade/verifier still runs when the pattern doesn't confirm
7748
+ // (or none is configured), so this never replaces that verifier.
7749
+ let confirmed = false;
7598
7750
  if (advanceTransitionBodyPattern) {
7599
7751
  confirmed = await waitForTransitionBody({
7600
7752
  page,
@@ -7603,24 +7755,71 @@ async function executeStepWithHealing(params) {
7603
7755
  timeoutMs: CAPTCHA_TRANSITION_POLL_MS,
7604
7756
  intervalMs: ADVANCE_TRANSITION_POLL_INTERVAL_MS,
7605
7757
  });
7606
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: post-fallback-submit transition poll confirmed=${confirmed}`);
7758
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: post-submit transition poll confirmed=${confirmed}`);
7607
7759
  }
7608
- }
7609
- if (confirmed) {
7610
- trajectory?.push({ stepIndex, verifiedBy: "network" });
7611
- return "completed";
7612
- }
7613
- // No render-config callback was discoverable AND neither the inject's
7614
- // own submit path nor the explicit fallback submit produced a
7615
- // confirmed transition: the token was never actually delivered
7616
- // through the site's own submit machinery. Fail loudly instead of
7617
- // falling through to the normal cascade as if the solve had worked —
7618
- // that's exactly the misleading "token injected=true" silent no-op
7619
- // this hook must not produce. Only applies when a pattern is actually
7620
- // configured to poll for — with none configured, no poll ever ran, so
7621
- // there's nothing here to contradict the normal cascade/verifier.
7622
- if (advanceTransitionBodyPattern && !injectResult.callbackDiscovered) {
7623
- throw new errors_2.CaptchaError(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: no render-config callback could be found and delivered, and no transition was confirmed after the solve`);
7760
+ // Some widgets' own render callback already submitted the form as a
7761
+ // side effect of the inject (the poll above would have confirmed it);
7762
+ // only issue an explicit submit when no transition was observed AND a
7763
+ // form is known to exist to submit — otherwise this would double-submit
7764
+ // a form the widget's own callback already advanced.
7765
+ if (!confirmed && injectResult.hasForm) {
7766
+ await submitCaptchaGatedForm(captchaTarget);
7767
+ if (advanceTransitionBodyPattern) {
7768
+ confirmed = await waitForTransitionBody({
7769
+ page,
7770
+ preIdx: preCaptchaCaptureIdx,
7771
+ advanceTransitionBodyPattern,
7772
+ timeoutMs: CAPTCHA_TRANSITION_POLL_MS,
7773
+ intervalMs: ADVANCE_TRANSITION_POLL_INTERVAL_MS,
7774
+ });
7775
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: post-fallback-submit transition poll confirmed=${confirmed}`);
7776
+ }
7777
+ }
7778
+ if (confirmed) {
7779
+ trajectory?.push({ stepIndex, verifiedBy: "network" });
7780
+ return "completed";
7781
+ }
7782
+ // A full-page multipart form submit (no matching XHR body, or no
7783
+ // pattern configured at all) still navigates the page — that's a
7784
+ // real advance the requestPostData-based poll above can never see.
7785
+ // This poll runs regardless of whether advanceTransitionBodyPattern
7786
+ // is set: it's an additional signal, not a replacement for the
7787
+ // pattern-based one.
7788
+ const navigationConfirmed = await waitForCaptchaNavigation({
7789
+ page,
7790
+ captchaTarget,
7791
+ baselineUrl: pageUrl,
7792
+ timeoutMs: CAPTCHA_TRANSITION_POLL_MS,
7793
+ intervalMs: ADVANCE_TRANSITION_POLL_INTERVAL_MS,
7794
+ });
7795
+ if (navigationConfirmed) {
7796
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: post-submit navigation to a new origin/path confirmed the advance`);
7797
+ trajectory?.push({ stepIndex, verifiedBy: "url" });
7798
+ return "completed";
7799
+ }
7800
+ // The intermittent race this loop exists for: a still-empty/absent
7801
+ // registry means the callback attach hadn't landed when this attempt
7802
+ // solved+injected, and a discovered-but-unconfirmed callback means it
7803
+ // fired without a poll seeing the resulting transition. Both are
7804
+ // worth another attempt within budget rather than an immediate throw.
7805
+ if (shouldRetryCaptchaRegistry(captchaAttempt, CAPTCHA_REGISTRY_RETRY_ATTEMPTS, registryState, injectResult.callbackDiscovered, confirmed)) {
7806
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: registryState=${registryState} callbackDiscovered=${injectResult.callbackDiscovered} with no confirmed transition on attempt ${captchaAttempt}; retrying`);
7807
+ continue;
7808
+ }
7809
+ // No render-config callback was discoverable AND neither the inject's
7810
+ // own submit path nor the explicit fallback submit produced a
7811
+ // confirmed transition, network or navigation: the token was never
7812
+ // actually delivered through the site's own submit machinery. Fail
7813
+ // loudly instead of falling through to the normal cascade as if the
7814
+ // solve had worked — that's exactly the misleading "token
7815
+ // injected=true" silent no-op this hook must not produce. Only
7816
+ // applies when a pattern is actually configured to poll for — with
7817
+ // none configured, no network poll ever ran, so there's nothing here
7818
+ // to contradict the normal cascade/verifier.
7819
+ if (advanceTransitionBodyPattern && !injectResult.callbackDiscovered) {
7820
+ throw new errors_2.CaptchaError(`${formatStepPrefix(stepIndex, totalSteps)} captchaGated step: no render-config callback could be found and delivered, and no transition was confirmed after the solve`);
7821
+ }
7822
+ break;
7624
7823
  }
7625
7824
  }
7626
7825
  }