@enricai/barnacle 1.9.10 → 1.9.12

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 (32) hide show
  1. package/dist/scraper/browser-click-expr.d.ts +44 -0
  2. package/dist/scraper/browser-click-expr.d.ts.map +1 -0
  3. package/dist/scraper/browser-click-expr.js +69 -0
  4. package/dist/scraper/browser-click-expr.js.map +1 -0
  5. package/dist/scraper/deep-locator-candidates.d.ts +43 -11
  6. package/dist/scraper/deep-locator-candidates.d.ts.map +1 -1
  7. package/dist/scraper/deep-locator-candidates.js +72 -19
  8. package/dist/scraper/deep-locator-candidates.js.map +1 -1
  9. package/dist/scraper/deep-locator-click.d.ts +40 -2
  10. package/dist/scraper/deep-locator-click.d.ts.map +1 -1
  11. package/dist/scraper/deep-locator-click.js +29 -3
  12. package/dist/scraper/deep-locator-click.js.map +1 -1
  13. package/dist/scraper/deep-locator-fake.d.ts.map +1 -1
  14. package/dist/scraper/deep-locator-fake.js +1 -0
  15. package/dist/scraper/deep-locator-fake.js.map +1 -1
  16. package/dist/scraper/deep-locator-scan.d.ts +9 -5
  17. package/dist/scraper/deep-locator-scan.d.ts.map +1 -1
  18. package/dist/scraper/deep-locator-scan.js +44 -14
  19. package/dist/scraper/deep-locator-scan.js.map +1 -1
  20. package/dist/scraper/deep-query.d.ts +5 -5
  21. package/dist/scraper/deep-query.d.ts.map +1 -1
  22. package/dist/scraper/deep-query.js +7 -9
  23. package/dist/scraper/deep-query.js.map +1 -1
  24. package/dist/scraper/flow-runner.d.ts +46 -0
  25. package/dist/scraper/flow-runner.d.ts.map +1 -1
  26. package/dist/scraper/flow-runner.js +149 -10
  27. package/dist/scraper/flow-runner.js.map +1 -1
  28. package/dist/scraper/submit-control.d.ts +5 -3
  29. package/dist/scraper/submit-control.d.ts.map +1 -1
  30. package/dist/scraper/submit-control.js +7 -7
  31. package/dist/scraper/submit-control.js.map +1 -1
  32. package/package.json +1 -1
@@ -17,6 +17,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.TRAILING_GRACE_WINDOW = exports.STEP_WATCHDOG_MS = exports.GOTO_TIMEOUT_MS = void 0;
18
18
  exports.mergeResponseHeaders = mergeResponseHeaders;
19
19
  exports.wireSignalCapture = wireSignalCapture;
20
+ exports.prepareFailureDumpBody = prepareFailureDumpBody;
20
21
  exports.snapshotPage = snapshotPage;
21
22
  exports.findRecentBackendError = findRecentBackendError;
22
23
  exports.findRecentPageTransition = findRecentPageTransition;
@@ -32,6 +33,8 @@ exports.shouldVetoFallbackAdvance = shouldVetoFallbackAdvance;
32
33
  exports.isDomOnlyAdvanceVerified = isDomOnlyAdvanceVerified;
33
34
  exports.isClickViewSwapVerified = isClickViewSwapVerified;
34
35
  exports.isClickStateToggleVerified = isClickStateToggleVerified;
36
+ exports.selectionCountFromSignature = selectionCountFromSignature;
37
+ exports.isSelectionCounterStalled = isSelectionCounterStalled;
35
38
  exports.shouldReadbackFillOnActSuccess = shouldReadbackFillOnActSuccess;
36
39
  exports.countNgInvalidContainers = countNgInvalidContainers;
37
40
  exports.describeAttemptEffectSignals = describeAttemptEffectSignals;
@@ -95,6 +98,7 @@ const schemas_1 = require("../lib/llm/schemas");
95
98
  const logging_1 = require("../lib/logging");
96
99
  const call_capture_1 = require("../lib/telemetry/call-capture");
97
100
  const call_types_1 = require("../lib/telemetry/call-types");
101
+ const browser_click_expr_1 = require("../scraper/browser-click-expr");
98
102
  const deep_locator_actuate_1 = require("../scraper/deep-locator-actuate");
99
103
  const deep_locator_candidates_1 = require("../scraper/deep-locator-candidates");
100
104
  const deep_locator_click_1 = require("../scraper/deep-locator-click");
@@ -481,6 +485,35 @@ const INVALID_MARKER_EL_EXPR = `((el) => {
481
485
  * final Continue/Submit click.
482
486
  */
483
487
  exports.TRAILING_GRACE_WINDOW = 2;
488
+ /** Cap on a single attribute value before a failure-bundle DOM capture keeps it. */
489
+ const FAILURE_DUMP_MAX_ATTR_LENGTH = 2_000;
490
+ /** Byte budget for a failure-bundle `bodyOuterHtml` capture after attribute pruning. */
491
+ const FAILURE_DUMP_MAX_BODY_LENGTH = 100_000;
492
+ const FAILURE_DUMP_ATTR_RX = new RegExp(`="([^"]{${FAILURE_DUMP_MAX_ATTR_LENGTH},})"|='([^']{${FAILURE_DUMP_MAX_ATTR_LENGTH},})'`, "g");
493
+ /**
494
+ * Prepares a `document.body.outerHTML` string for a step-failure bundle so it
495
+ * shows RENDERED controls, not a cut-off shell. A single data-carrying attribute
496
+ * (a SPA mount point's `data="{…}"` state blob, a long `value=`) can exceed the
497
+ * whole body budget on its own, pushing the actual `<button>`/`<input>` markup
498
+ * past the truncation point — which reads in the bundle as "empty DOM / 0
499
+ * buttons" when the page is fully hydrated. Collapse any attribute value longer
500
+ * than {@link FAILURE_DUMP_MAX_ATTR_LENGTH} to an elided marker BEFORE the length
501
+ * cap, then slice to {@link FAILURE_DUMP_MAX_BODY_LENGTH}. The double- and
502
+ * single-quoted alternatives are kept separate (rather than a `\1` backreference)
503
+ * so a double-quoted value may contain apostrophes — and vice versa — without
504
+ * ending the match early, which a shared `[^"']` class would wrongly do. Returns
505
+ * null passthrough so callers keep their "evaluate failed → null" contract.
506
+ */
507
+ function prepareFailureDumpBody(raw) {
508
+ if (typeof raw !== "string")
509
+ return null;
510
+ const pruned = raw.replace(FAILURE_DUMP_ATTR_RX, (_m, dq, sq) => {
511
+ const value = dq ?? sq ?? "";
512
+ const quote = dq !== undefined ? '"' : "'";
513
+ return `=${quote}[elided ${value.length} chars]${quote}`;
514
+ });
515
+ return pruned.slice(0, FAILURE_DUMP_MAX_BODY_LENGTH);
516
+ }
484
517
  /**
485
518
  * Trust boundary: static string literal, fixed at compile time. No interpolation
486
519
  * means no injection surface. Runs in browser context and returns a typed-narrow
@@ -1008,6 +1041,48 @@ function isClickStateToggleVerified(params) {
1008
1041
  return false;
1009
1042
  return selectionStateChanged;
1010
1043
  }
1044
+ /**
1045
+ * Reads the "N settings/items selected" running count that multi-select wizard
1046
+ * steps render as a live heading above the option grid. Returns the integer, or
1047
+ * `null` when the snapshot has no such counter. Operates on a
1048
+ * {@link StepSnapshot.visibleTextSignature}, which is
1049
+ * `"<len>:<first-200-chars-of-innerText>"` — the counter idiom sits at the top
1050
+ * of these steps, so it is always inside the 200-char window.
1051
+ *
1052
+ * Exists because some SPA multi-selects (obfuscated hashed classes, no
1053
+ * `aria-pressed`/`data-state`) expose no selection-state attribute at all, so
1054
+ * {@link isClickStateToggleVerified}'s fingerprint is structurally blind and a
1055
+ * phantom option click rides the generic DOM-delta gate to a FALSE credit — the
1056
+ * running counter is the only trustworthy "did the selection register" signal.
1057
+ */
1058
+ function selectionCountFromSignature(visibleTextSignature) {
1059
+ const colon = visibleTextSignature.indexOf(":");
1060
+ const text = colon === -1 ? visibleTextSignature : visibleTextSignature.slice(colon + 1);
1061
+ const match = text.match(/(\d+)\s+(?:(?:settings?|items?|options?)\s+)?selected\b/i);
1062
+ const digits = match?.[1];
1063
+ return digits === undefined ? null : Number.parseInt(digits, 10);
1064
+ }
1065
+ /**
1066
+ * Veto for a SELECTION-intent step whose weak DOM signals (view-swap byte delta,
1067
+ * a non-advance `domVerified` reflow) would otherwise credit a click that did
1068
+ * NOT register the selection. When the step's snapshot exposes a running
1069
+ * "N selected" counter (see {@link selectionCountFromSignature}) and that count
1070
+ * did not increase across the click, the option was not actually selected — a
1071
+ * phantom the fingerprint can't catch — so the caller must NOT treat the weak
1072
+ * signals as verification and should keep healing. Returns `true` only when a
1073
+ * counter is present on BOTH snapshots and did not rise; absent a counter it
1074
+ * returns `false` (no veto), so counter-less selection widgets are untouched.
1075
+ */
1076
+ function isSelectionCounterStalled(params) {
1077
+ const { isSelectionStep, preVisibleTextSignature, postVisibleTextSignature } = params;
1078
+ if (!isSelectionStep)
1079
+ return false;
1080
+ const pre = selectionCountFromSignature(preVisibleTextSignature);
1081
+ const post = selectionCountFromSignature(postVisibleTextSignature);
1082
+ if (pre === null || post === null)
1083
+ return false;
1084
+ return post <= pre;
1085
+ }
1011
1086
  /**
1012
1087
  * Whether an act-success step warrants a committed-value read-back before the
1013
1088
  * weak view-swap/form-value signals are allowed to accept it. A controlled
@@ -5383,7 +5458,7 @@ async function executeStepWithHealing(params) {
5383
5458
  const bodyOuterHtmlRaw = await (frameTarget ?? page)
5384
5459
  .evaluate("document.body ? document.body.outerHTML : null")
5385
5460
  .catch(() => null);
5386
- const bodyOuterHtml = typeof bodyOuterHtmlRaw === "string" ? bodyOuterHtmlRaw.slice(0, 100_000) : null;
5461
+ const bodyOuterHtml = prepareFailureDumpBody(bodyOuterHtmlRaw);
5387
5462
  const probeAbsentObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
5388
5463
  // observe() is blind to a cross-origin OOPIF, so a frame-scoped empty
5389
5464
  // result degrades to the deep-locator resolver for dump evidence — this
@@ -5841,7 +5916,7 @@ async function executeStepWithHealing(params) {
5841
5916
  if (selectStep && !selectStep.questionLabel) {
5842
5917
  const selectCandidates = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameTarget?.frameSelector, "select", null, { frameTarget });
5843
5918
  const optionPhrases = (0, deep_locator_candidates_1.extractTaggedPhrases)(step);
5844
- const optionScores = selectCandidates.map((candidate) => (0, deep_locator_candidates_1.scoreCandidate)(candidate.accessibleText, optionPhrases));
5919
+ const optionScores = selectCandidates.map((candidate) => (0, deep_locator_candidates_1.scoreCandidate)(candidate.accessibleText, optionPhrases, candidate.isNav));
5845
5920
  const topScore = optionScores.length > 0 ? Math.max(...optionScores) : 0;
5846
5921
  const tiedForTop = optionScores.filter((score) => score === topScore).length;
5847
5922
  if (tiedForTop > 1) {
@@ -5878,6 +5953,32 @@ async function executeStepWithHealing(params) {
5878
5953
  // attempt (not per candidate) — every candidate in this walk is a
5879
5954
  // guess at the SAME step's target, so they all actuate the same way.
5880
5955
  const actuation = resolveDeepLocatorActuation(step);
5956
+ // Next-best-candidate recovery for a markerless multi-select phantom:
5957
+ // a click-to-toggle option (not a native <select> write, which
5958
+ // already read-back-verifies) whose "N selected" counter doesn't rise
5959
+ // means the click landed but didn't register — so the walk should try
5960
+ // the next real option before this attempt is scored a failure and a
5961
+ // replan is spent. Only wired for a selection-intent CLICK step; the
5962
+ // reader re-reads the same counter `pre` captured (via
5963
+ // `selectionCountFromSignature`) so no extra pre-snapshot is paid, and
5964
+ // returns `null` on any frame hiccup so a read failure never converts
5965
+ // to a walk-stopping throw. Non-selection steps pass `undefined` and
5966
+ // the walk behaves exactly as before.
5967
+ const isSelectionIntentClick = actuation.kind === "click" && parseSelectStep(step) !== null;
5968
+ const baselineSelectionCount = isSelectionIntentClick
5969
+ ? selectionCountFromSignature(pre.visibleTextSignature)
5970
+ : null;
5971
+ const readSelectionCount = isSelectionIntentClick
5972
+ ? async () => {
5973
+ try {
5974
+ const snap = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
5975
+ return selectionCountFromSignature(snap.visibleTextSignature);
5976
+ }
5977
+ catch {
5978
+ return null;
5979
+ }
5980
+ }
5981
+ : undefined;
5881
5982
  const cascadeResult = await (0, deep_locator_click_1.clickFirstActionableCandidate)(deepLocatorCandidates, async (candidate) => {
5882
5983
  attemptTriedSelectors.push(candidate.selector);
5883
5984
  if (actuation.kind === "select") {
@@ -5900,7 +6001,7 @@ async function executeStepWithHealing(params) {
5900
6001
  else {
5901
6002
  await (0, deep_locator_candidates_1.clickDeepLocatorCandidate)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, candidate.index, { frameTarget });
5902
6003
  }
5903
- }, { denyCandidate })
6004
+ }, { denyCandidate, readSelectionCount, baselineSelectionCount })
5904
6005
  .then((outcome) => ({ outcome, error: null }))
5905
6006
  .catch((error) => ({ outcome: null, error }));
5906
6007
  triedSelectors.push(...attemptTriedSelectors);
@@ -5935,11 +6036,14 @@ async function executeStepWithHealing(params) {
5935
6036
  };
5936
6037
  }
5937
6038
  else {
6039
+ const counterStalledCount = cascadeResult.outcome?.counterStalledSelectors.length ?? 0;
5938
6040
  const failureMessage = cascadeResult.error
5939
6041
  ? `deepLocator: ${actuation.kind} threw ${(0, errors_1.toErrorMessage)(cascadeResult.error)}`
5940
- : attemptTriedSelectors.length > 0
5941
- ? `deepLocator: no actionable candidate (${attemptTriedSelectors.length} not-actionable)`
5942
- : "deepLocator: no actionable candidate (every candidate refused by the wizard-exit deny-list)";
6042
+ : counterStalledCount > 0
6043
+ ? `deepLocator: clicked ${counterStalledCount} candidate(s) but the "N selected" counter never rose (no selection registered)`
6044
+ : attemptTriedSelectors.length > 0
6045
+ ? `deepLocator: no actionable candidate (${attemptTriedSelectors.length} not-actionable)`
6046
+ : "deepLocator: no actionable candidate (every candidate refused by the wizard-exit deny-list)";
5943
6047
  record.actResultSuccess = false;
5944
6048
  record.errorMessage = failureMessage;
5945
6049
  attempts.push(record);
@@ -6512,12 +6616,33 @@ async function executeStepWithHealing(params) {
6512
6616
  }
6513
6617
  }
6514
6618
  }
6619
+ // Selection-counter veto. A multi-select option click whose widget exposes
6620
+ // no aria/data-state marker (obfuscated hashed classes) leaves
6621
+ // `clickStateToggleVerified` blind, so a phantom that merely reflowed the
6622
+ // DOM would ride the weak `domVerifiedForStep`/`clickViewSwapVerified`
6623
+ // signals to a FALSE credit — the flow then advances to a "Next" that
6624
+ // no-ops because nothing was actually selected. When the step's running
6625
+ // "N selected" counter did not rise, suppress those weak DOM-delta signals
6626
+ // so the cascade keeps trying a real selection. Strong signals (real
6627
+ // network/URL transition) and the counter-independent state-toggle /
6628
+ // form-value signals — each of which IS the selection registering — are
6629
+ // never vetoed; counter-less widgets are untouched (the helper no-ops).
6630
+ const selectionCounterStalled = isSelectionCounterStalled({
6631
+ isSelectionStep: parseSelectStep(step) !== null,
6632
+ preVisibleTextSignature: pre.visibleTextSignature,
6633
+ postVisibleTextSignature: post.visibleTextSignature,
6634
+ });
6635
+ if (selectionCounterStalled && (domVerifiedForStep || clickViewSwapVerified)) {
6636
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} selection step credited only via DOM reflow but the "N selected" counter did not rise — the option did not register; not treating the weak DOM signal as verified`);
6637
+ }
6638
+ const domVerifiedAfterCounter = selectionCounterStalled ? false : domVerifiedForStep;
6639
+ const clickViewSwapAfterCounter = selectionCounterStalled ? false : clickViewSwapVerified;
6515
6640
  let verified = networkIsRealAdvance ||
6516
6641
  urlChanged ||
6517
- domVerifiedForStep ||
6642
+ domVerifiedAfterCounter ||
6518
6643
  datepickerCommitted ||
6519
6644
  (!datepickerRejected &&
6520
- (clickViewSwapVerified || formValueVerified || clickStateToggleVerified));
6645
+ (clickViewSwapAfterCounter || formValueVerified || clickStateToggleVerified));
6521
6646
  // Final-step submit-verification gate. Replaces the deterministic
6522
6647
  // submitEndpointPattern regex with a Haiku 4.5 LLM judgment over
6523
6648
  // multi-signal evidence (network captures, page URL/title, DOM
@@ -6695,7 +6820,7 @@ async function executeStepWithHealing(params) {
6695
6820
  // default action, but isolated-world page.evaluate() click()s don't
6696
6821
  // reliably trigger that default action — same gap N+42 documented
6697
6822
  // for direct checkbox/radio clicks.
6698
- const clickExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); let el = r.singleNodeValue; if (!el || typeof el.click !== "function") return { fired: false }; if (el.tagName === "LABEL") { const wrapped = el.querySelector("input[type=checkbox], input[type=radio]"); if (wrapped) el = wrapped; } if (el.type === "checkbox" || el.type === "radio") { el.checked = true; el.dispatchEvent(new Event("click", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { fired: true, kind: "checkbox", checked: el.checked }; } el.click(); return { fired: true, kind: "click" }; })()`;
6823
+ const clickExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); let el = r.singleNodeValue; if (!el || typeof el.click !== "function") return { fired: false }; if (el.tagName === "LABEL") { const wrapped = el.querySelector("input[type=checkbox], input[type=radio]"); if (wrapped) el = wrapped; } if (el.type === "checkbox" || el.type === "radio") { el.checked = true; el.dispatchEvent(new Event("click", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { fired: true, kind: "checkbox", checked: el.checked }; } ${(0, browser_click_expr_1.clickActivationExpr)("el")} return { fired: true, kind: "click" }; })()`;
6699
6824
  const n16FallbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
6700
6825
  const probeResult = (await n16FallbackTarget.evaluate(clickExpr));
6701
6826
  const fired = probeResult.fired;
@@ -6802,12 +6927,26 @@ async function executeStepWithHealing(params) {
6802
6927
  // only a strong signal (network/url) or a verified checkbox state may
6803
6928
  // pass. Non-final/submit steps are unaffected.
6804
6929
  const weakDomSignalsAllowed = (!isFinalStep && !submitStep) || requireSubmitEndpoint;
6930
+ // Selection-counter veto for the n+16 fallback — the same gate the
6931
+ // primary verifier applies, recomputed against `retryPost`. Without
6932
+ // it this path would re-credit a stalled selection the primary veto
6933
+ // already suppressed: the fallback `el.click()` can reflow the DOM
6934
+ // (html/text/selection-state delta) without registering the option,
6935
+ // and those weak deltas are exactly what the OR below admits for a
6936
+ // non-final selection step. Only the weak-delta disjunct is vetoed;
6937
+ // strong signals (network/url) and a verified checkbox state pass.
6938
+ const retrySelectionCounterStalled = isSelectionCounterStalled({
6939
+ isSelectionStep: parseSelectStep(step) !== null,
6940
+ preVisibleTextSignature: pre.visibleTextSignature,
6941
+ postVisibleTextSignature: retryPost.visibleTextSignature,
6942
+ });
6805
6943
  let retryVerified = !clickBlockedByInvalid &&
6806
6944
  !fallbackDomOnlyAdvance &&
6807
6945
  (retryNetworkFired ||
6808
6946
  retryUrlChanged ||
6809
6947
  checkboxStateVerified ||
6810
6948
  (weakDomSignalsAllowed &&
6949
+ !retrySelectionCounterStalled &&
6811
6950
  (retryHtmlDelta !== 0 ||
6812
6951
  retryTextChanged ||
6813
6952
  retryFormValueChanged ||
@@ -7070,7 +7209,7 @@ async function executeStepWithHealing(params) {
7070
7209
  const bodyOuterHtmlRaw = await (frameTarget ?? (0, frame_target_1.mainFrameTarget)(page))
7071
7210
  .evaluate("document.body ? document.body.outerHTML : null")
7072
7211
  .catch(() => null);
7073
- const bodyOuterHtml = typeof bodyOuterHtmlRaw === "string" ? bodyOuterHtmlRaw.slice(0, 100_000) : null;
7212
+ const bodyOuterHtml = prepareFailureDumpBody(bodyOuterHtmlRaw);
7074
7213
  const cascadeExhaustObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
7075
7214
  const unfocusedObserve = cascadeExhaustObservedUnfocused.length === 0 && frameTarget?.frame
7076
7215
  ? await deepLocatorCandidatesAsActions(page, frameTarget)