@enricai/barnacle 1.10.0 → 1.11.0
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.
- package/dist/scraper/deep-locator-click.d.ts +5 -5
- package/dist/scraper/flow-runner.d.ts +92 -62
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +378 -193
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scraper/phantom-click.d.ts +12 -16
- package/dist/scraper/phantom-click.d.ts.map +1 -1
- package/dist/scraper/phantom-click.js +11 -14
- package/dist/scraper/phantom-click.js.map +1 -1
- package/dist/scripts/recon-generate.js +2 -2
- package/package.json +1 -1
|
@@ -23,6 +23,7 @@ exports.findRecentBackendError = findRecentBackendError;
|
|
|
23
23
|
exports.findRecentPageTransition = findRecentPageTransition;
|
|
24
24
|
exports.isWizardExitAction = isWizardExitAction;
|
|
25
25
|
exports.isAdvanceStep = isAdvanceStep;
|
|
26
|
+
exports.shouldCaptureSelectionState = shouldCaptureSelectionState;
|
|
26
27
|
exports.shouldWarnMissingAdvancePattern = shouldWarnMissingAdvancePattern;
|
|
27
28
|
exports.parseCaptureIndex = parseCaptureIndex;
|
|
28
29
|
exports.latestCaptureIndex = latestCaptureIndex;
|
|
@@ -32,9 +33,7 @@ exports.windowHasAdvanceTransition = windowHasAdvanceTransition;
|
|
|
32
33
|
exports.shouldVetoFallbackAdvance = shouldVetoFallbackAdvance;
|
|
33
34
|
exports.isDomOnlyAdvanceVerified = isDomOnlyAdvanceVerified;
|
|
34
35
|
exports.isClickViewSwapVerified = isClickViewSwapVerified;
|
|
35
|
-
exports.isClickStateToggleVerified = isClickStateToggleVerified;
|
|
36
36
|
exports.selectionCountFromSignature = selectionCountFromSignature;
|
|
37
|
-
exports.isSelectionCounterStalled = isSelectionCounterStalled;
|
|
38
37
|
exports.shouldReadbackFillOnActSuccess = shouldReadbackFillOnActSuccess;
|
|
39
38
|
exports.countNgInvalidContainers = countNgInvalidContainers;
|
|
40
39
|
exports.describeAttemptEffectSignals = describeAttemptEffectSignals;
|
|
@@ -526,8 +525,15 @@ const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { ht
|
|
|
526
525
|
* `url()` rejects (OOPIF detached by the submit it just fired) can still
|
|
527
526
|
* report the main frame's post-navigation URL instead of throwing the
|
|
528
527
|
* whole attempt out of `executeStepWithHealing`.
|
|
528
|
+
*
|
|
529
|
+
* `captureSelectionState` (default false) additionally builds the per-element
|
|
530
|
+
* `selectionStateByXpath` baseline for the element-scoped click verifier. The
|
|
531
|
+
* caller sets it only for the pre/post pair of a selection/field-answer click
|
|
532
|
+
* step — never for submit/advance steps (so a self-toggling submit/Next button
|
|
533
|
+
* can't false-credit) nor for the mid-probe/retry snapshots — which also keeps
|
|
534
|
+
* this second full-DOM evaluate off the common (non-selection) path.
|
|
529
535
|
*/
|
|
530
|
-
async function snapshotPage(target, signalCounter, page) {
|
|
536
|
+
async function snapshotPage(target, signalCounter, page, captureSelectionState = false) {
|
|
531
537
|
let bodyHtmlLength = 0;
|
|
532
538
|
let visibleTextSignature = "";
|
|
533
539
|
let formValueSignature = "";
|
|
@@ -552,6 +558,25 @@ async function snapshotPage(target, signalCounter, page) {
|
|
|
552
558
|
// Snapshot is observational; on failure, defaults to 0/"" so the verifier
|
|
553
559
|
// sees no delta. Real state-class checks already cover the verified path.
|
|
554
560
|
}
|
|
561
|
+
// Per-element selection baseline for the element-scoped click verifier
|
|
562
|
+
// (see StepSnapshot.selectionStateByXpath). Captured ONLY when the caller
|
|
563
|
+
// asks (`captureSelectionState`) — the pre/post pair of a selection/field-
|
|
564
|
+
// answer click step. Skipping it for submit/advance steps is both the perf
|
|
565
|
+
// guard (this is a second full-DOM evaluate on a hot function) AND the
|
|
566
|
+
// correctness gate: with no baseline, verifyDomEffect's element read-back
|
|
567
|
+
// finds nothing to diff and defers to network/URL, so a submit/advance
|
|
568
|
+
// button's own self-toggle can never false-credit the step. Its own
|
|
569
|
+
// try/catch so a failure here can't lose the coarse signals above; an empty
|
|
570
|
+
// map leaves the verifier deferring to network/URL, never a false credit.
|
|
571
|
+
let selectionStateByXpath = {};
|
|
572
|
+
if (captureSelectionState) {
|
|
573
|
+
try {
|
|
574
|
+
selectionStateByXpath = asSelectionStateMap(await target.evaluate(SELECTION_STATE_MAP_EXPR));
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
// observational — empty baseline on failure
|
|
578
|
+
}
|
|
579
|
+
}
|
|
555
580
|
// A resolved child FrameTarget's url() reads location.href off the CDP
|
|
556
581
|
// frame session (frame-target.ts's childFrameTarget), which rejects (or
|
|
557
582
|
// trips its watchdog) once the OOPIF detaches — most commonly right after
|
|
@@ -566,6 +591,7 @@ async function snapshotPage(target, signalCounter, page) {
|
|
|
566
591
|
visibleTextSignature,
|
|
567
592
|
formValueSignature,
|
|
568
593
|
selectionStateSignature,
|
|
594
|
+
selectionStateByXpath,
|
|
569
595
|
};
|
|
570
596
|
}
|
|
571
597
|
/**
|
|
@@ -737,6 +763,22 @@ function isAdvanceStep(instruction) {
|
|
|
737
763
|
const haystack = instruction.toLowerCase();
|
|
738
764
|
return ADVANCE_STEP_PHRASES.some((p) => haystack.includes(p));
|
|
739
765
|
}
|
|
766
|
+
/**
|
|
767
|
+
* Whether `snapshotPage` should build the per-element selection baseline
|
|
768
|
+
* (`StepSnapshot.selectionStateByXpath`) for this step — i.e. whether
|
|
769
|
+
* `verifyDomEffect`'s element-scoped click read-back is allowed to credit it.
|
|
770
|
+
* True ONLY for a field-answer/selection step: a submit, a final, or an advance
|
|
771
|
+
* step must be verified by a real network/URL transition, so its own
|
|
772
|
+
* self-toggling button (a submit flipping to a loading/pressed class, a "Next"
|
|
773
|
+
* flipping `aria-pressed`) must never earn an element-scoped credit — matching
|
|
774
|
+
* the `!submit`/`!final`/`!advance` exclusions the former
|
|
775
|
+
* `isClickStateToggleVerified` gate enforced. Pure + exported so the gate the
|
|
776
|
+
* cascade depends on is unit-testable, not buried in `executeStepWithHealing`.
|
|
777
|
+
*/
|
|
778
|
+
function shouldCaptureSelectionState(params) {
|
|
779
|
+
const { step, isFinalStep, submitStep } = params;
|
|
780
|
+
return !(isFinalStep || submitStep || isAdvanceStep(step));
|
|
781
|
+
}
|
|
740
782
|
/**
|
|
741
783
|
* Whether a flow should be WARNed that its DOM-only advance guard is disarmed.
|
|
742
784
|
* `isDomOnlyAdvanceVerified` only vetoes a DOM-only "advance" when
|
|
@@ -1009,38 +1051,6 @@ function isClickViewSwapVerified(params) {
|
|
|
1009
1051
|
return true;
|
|
1010
1052
|
return textChanged && bytesDelta >= VIEW_SWAP_REVEAL_MIN_BYTES;
|
|
1011
1053
|
}
|
|
1012
|
-
/**
|
|
1013
|
-
* Credits a network-free click when it flipped a client-side SELECTION state —
|
|
1014
|
-
* an `aria-pressed`/`aria-checked`/`aria-selected` attribute, a `data-state`,
|
|
1015
|
-
* or a `selected`/`active`/`checked` class on the toggled control (captured by
|
|
1016
|
-
* `StepSnapshot.selectionStateSignature`). React/SPA multi-select wizards toggle
|
|
1017
|
-
* options this way with a trivial or NEGATIVE byte delta and no
|
|
1018
|
-
* network/URL/innerText/input-value change, so `isClickViewSwapVerified` and
|
|
1019
|
-
* the byte-floor phantom check both miss them and score the real selection a
|
|
1020
|
-
* phantom no-op. This is the size-independent counterpart to that gate. Scope
|
|
1021
|
-
* guards: only a plain `click`, never a final/submit step (those keep their
|
|
1022
|
-
* stronger submit-judge gate), and only when zero network fired (a network POST
|
|
1023
|
-
* is authoritative). **Stricter than `isClickViewSwapVerified` on advance
|
|
1024
|
-
* steps:** it excludes ANY advance/"Next" step (`isAdvance`), not just an
|
|
1025
|
-
* advance-WITH-pattern step. The selection signal fires on a validation-render
|
|
1026
|
-
* that toggles a control's own state, and an advance without a configured
|
|
1027
|
-
* `advanceTransitionBodyPattern` has no real-transition veto
|
|
1028
|
-
* (see `shouldWarnMissingAdvancePattern`) — so crediting a bare selection change
|
|
1029
|
-
* would desync the wizard step pointer. A real advance still verifies via
|
|
1030
|
-
* network/URL; a field-toggle answer step (not an advance) keeps this credit.
|
|
1031
|
-
*/
|
|
1032
|
-
function isClickStateToggleVerified(params) {
|
|
1033
|
-
const { resolvedAction, isFinalStep, submitStep, isAdvance, networkDelta, selectionStateChanged, } = params;
|
|
1034
|
-
if (resolvedAction?.method !== "click")
|
|
1035
|
-
return false;
|
|
1036
|
-
if (isFinalStep || submitStep)
|
|
1037
|
-
return false;
|
|
1038
|
-
if (isAdvance)
|
|
1039
|
-
return false;
|
|
1040
|
-
if (networkDelta !== 0)
|
|
1041
|
-
return false;
|
|
1042
|
-
return selectionStateChanged;
|
|
1043
|
-
}
|
|
1044
1054
|
/**
|
|
1045
1055
|
* Reads the "N settings/items selected" running count that multi-select wizard
|
|
1046
1056
|
* steps render as a live heading above the option grid. Returns the integer, or
|
|
@@ -1049,11 +1059,12 @@ function isClickStateToggleVerified(params) {
|
|
|
1049
1059
|
* `"<len>:<first-200-chars-of-innerText>"` — the counter idiom sits at the top
|
|
1050
1060
|
* of these steps, so it is always inside the 200-char window.
|
|
1051
1061
|
*
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1062
|
+
* Used by the deep-locator candidate walk to disambiguate WHICH of several
|
|
1063
|
+
* option candidates registered a selection: after clicking a candidate, the
|
|
1064
|
+
* running counter rising confirms that candidate was the real option (a
|
|
1065
|
+
* candidate-selection signal, distinct from the click verifier — which now
|
|
1066
|
+
* reads the resolved element's own committed state directly via
|
|
1067
|
+
* {@link verifyDomEffect}).
|
|
1057
1068
|
*/
|
|
1058
1069
|
function selectionCountFromSignature(visibleTextSignature) {
|
|
1059
1070
|
const colon = visibleTextSignature.indexOf(":");
|
|
@@ -1062,27 +1073,6 @@ function selectionCountFromSignature(visibleTextSignature) {
|
|
|
1062
1073
|
const digits = match?.[1];
|
|
1063
1074
|
return digits === undefined ? null : Number.parseInt(digits, 10);
|
|
1064
1075
|
}
|
|
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
|
-
}
|
|
1086
1076
|
/**
|
|
1087
1077
|
* Whether an act-success step warrants a committed-value read-back before the
|
|
1088
1078
|
* weak view-swap/form-value signals are allowed to accept it. A controlled
|
|
@@ -2454,13 +2444,12 @@ async function fillTextDatepickerInput(target, selector, value) {
|
|
|
2454
2444
|
// Month/year-picker variant: click the target month cell (0-based month
|
|
2455
2445
|
// index maps to the __month-N class) — THIS is the commit. The year/month
|
|
2456
2446
|
// dropdowns are set first only so the calendar shows the right year's month
|
|
2457
|
-
// page.
|
|
2458
|
-
//
|
|
2459
|
-
// other wrappers.
|
|
2447
|
+
// page. The bare "select" query covers every wrapper generically (site CSS
|
|
2448
|
+
// classes are never named here).
|
|
2460
2449
|
// Stock react-datepicker renders its month/year dropdowns as clickable divs,
|
|
2461
2450
|
// so on those the select loop is inert and the month-cell click carries it.
|
|
2462
2451
|
if (monthCells.length > 0) {
|
|
2463
|
-
const selects = cal.querySelectorAll("select
|
|
2452
|
+
const selects = cal.querySelectorAll("select");
|
|
2464
2453
|
for (const sel of selects) {
|
|
2465
2454
|
const opts = Array.from(sel.options).map((o) => (o.value || "").trim());
|
|
2466
2455
|
const yearOpt = opts.indexOf(String(year));
|
|
@@ -2660,6 +2649,150 @@ function xpathBodyForEvaluate(selector) {
|
|
|
2660
2649
|
const stripped = selector.startsWith("xpath=") ? selector.slice("xpath=".length) : selector;
|
|
2661
2650
|
return stripped.startsWith("/") || stripped.startsWith("(") ? stripped : null;
|
|
2662
2651
|
}
|
|
2652
|
+
/**
|
|
2653
|
+
* Browser-side expression body that resolves `xpath` and returns that one
|
|
2654
|
+
* element's {@link ElementSelectionFingerprint} (or `null` when the node is
|
|
2655
|
+
* absent). Trust boundary: `xpath` is a Stagehand-resolved selector, and
|
|
2656
|
+
* `JSON.stringify` produces a safe JS string literal, so composing it cannot
|
|
2657
|
+
* inject behavior. The `data-state` disclosure values `open`/`closed` (the
|
|
2658
|
+
* `aria-expanded` equivalent) are blanked so opening a popover isn't mistaken
|
|
2659
|
+
* for a selection.
|
|
2660
|
+
*/
|
|
2661
|
+
function elementSelectionFingerprintExpr(xpath) {
|
|
2662
|
+
return `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); const el = r.singleNodeValue; if (!el) return null; const ds = el.getAttribute("data-state") || ""; return { kind: el.getAttribute("kind") || "", cls: el.getAttribute("class") || "", ariaPressed: el.getAttribute("aria-pressed") || "", ariaChecked: el.getAttribute("aria-checked") || "", ariaSelected: el.getAttribute("aria-selected") || "", dataState: (ds === "open" || ds === "closed") ? "" : ds, dataSelected: el.hasAttribute("data-selected") ? "1" : "", dataChecked: el.hasAttribute("data-checked") ? "1" : "", checked: (el.type === "checkbox" || el.type === "radio") ? (el.checked ? "1" : "0") : "", value: typeof el.value === "string" ? el.value.slice(0, 200) : "" }; })()`;
|
|
2663
|
+
}
|
|
2664
|
+
/**
|
|
2665
|
+
* Browser-side expression: build `{ absolutePositionalXpath →
|
|
2666
|
+
* ElementSelectionFingerprint }` for every VISIBLE interactive element, the
|
|
2667
|
+
* pre-action baseline {@link verifyDomEffect} diffs the resolved element
|
|
2668
|
+
* against. Trust boundary: static string literal, no interpolation. The xpath
|
|
2669
|
+
* is generated by `xpathOf`, character-identical to the two other in-page
|
|
2670
|
+
* copies in this file (the ng-invalid probe and the field-label scan) so it
|
|
2671
|
+
* byte-matches Stagehand's resolved `xpath=/html[1]/…` selectors: hardcoded
|
|
2672
|
+
* `/html[1]/body[1]/` prefix, walk `!== document.body`, `nodeName.toLowerCase()`,
|
|
2673
|
+
* `parentElement`. `[role=dialog],[role=tooltip],[aria-live]` subtrees are
|
|
2674
|
+
* skipped — they churn without a selection change. Visibility uses the same
|
|
2675
|
+
* rect + `getComputedStyle` idiom as `deep-locator-scan.ts`'s `IS_VISIBLE_EXPR`
|
|
2676
|
+
* (rather than `offsetParent`), so on-screen `position:fixed` controls — a
|
|
2677
|
+
* sticky Next/Submit bar, whose `offsetParent` is `null` — still enter the map.
|
|
2678
|
+
*/
|
|
2679
|
+
const SELECTION_STATE_MAP_EXPR = `(() => {
|
|
2680
|
+
const b = document.body;
|
|
2681
|
+
if (!b) return {};
|
|
2682
|
+
const xpathOf = (node) => {
|
|
2683
|
+
const parts = [];
|
|
2684
|
+
while (node && node.nodeType === 1 && node !== document.body) {
|
|
2685
|
+
const tag = node.nodeName.toLowerCase();
|
|
2686
|
+
let idx = 1;
|
|
2687
|
+
let sib = node.previousElementSibling;
|
|
2688
|
+
while (sib) {
|
|
2689
|
+
if (sib.nodeName.toLowerCase() === tag) idx++;
|
|
2690
|
+
sib = sib.previousElementSibling;
|
|
2691
|
+
}
|
|
2692
|
+
parts.unshift(tag + "[" + idx + "]");
|
|
2693
|
+
node = node.parentElement;
|
|
2694
|
+
}
|
|
2695
|
+
return "/html[1]/body[1]/" + parts.join("/");
|
|
2696
|
+
};
|
|
2697
|
+
const visible = (el) => {
|
|
2698
|
+
const rect = el.getBoundingClientRect();
|
|
2699
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
2700
|
+
const style = getComputedStyle(el);
|
|
2701
|
+
return style.display !== "none" && style.visibility !== "hidden";
|
|
2702
|
+
};
|
|
2703
|
+
const sel = "button,[role=button],a,[tabindex],input,select,textarea,[role=option],[role=tab],[role=switch],[role=checkbox],[role=menuitemcheckbox]";
|
|
2704
|
+
const skip = (el) => el.closest("[role=dialog],[role=tooltip],[aria-live]") !== null;
|
|
2705
|
+
const out = {};
|
|
2706
|
+
for (const el of b.querySelectorAll(sel)) {
|
|
2707
|
+
if (!visible(el) || skip(el)) continue;
|
|
2708
|
+
const ds = el.getAttribute("data-state") || "";
|
|
2709
|
+
out[xpathOf(el)] = {
|
|
2710
|
+
kind: el.getAttribute("kind") || "",
|
|
2711
|
+
cls: el.getAttribute("class") || "",
|
|
2712
|
+
ariaPressed: el.getAttribute("aria-pressed") || "",
|
|
2713
|
+
ariaChecked: el.getAttribute("aria-checked") || "",
|
|
2714
|
+
ariaSelected: el.getAttribute("aria-selected") || "",
|
|
2715
|
+
dataState: (ds === "open" || ds === "closed") ? "" : ds,
|
|
2716
|
+
dataSelected: el.hasAttribute("data-selected") ? "1" : "",
|
|
2717
|
+
dataChecked: el.hasAttribute("data-checked") ? "1" : "",
|
|
2718
|
+
checked: (el.type === "checkbox" || el.type === "radio") ? (el.checked ? "1" : "0") : "",
|
|
2719
|
+
value: typeof el.value === "string" ? el.value.slice(0, 200) : "",
|
|
2720
|
+
};
|
|
2721
|
+
}
|
|
2722
|
+
return out;
|
|
2723
|
+
})()`;
|
|
2724
|
+
/**
|
|
2725
|
+
* Narrows an unknown `evaluate` result into the
|
|
2726
|
+
* {@link StepSnapshot.selectionStateByXpath} map, discarding any malformed
|
|
2727
|
+
* entry. Defaults to `{}` so a snapshot failure leaves the verifier with an
|
|
2728
|
+
* empty baseline (defer-to-network), never a throw.
|
|
2729
|
+
*/
|
|
2730
|
+
function asSelectionStateMap(raw) {
|
|
2731
|
+
if (raw === null || typeof raw !== "object")
|
|
2732
|
+
return {};
|
|
2733
|
+
const out = {};
|
|
2734
|
+
for (const [xpath, value] of Object.entries(raw)) {
|
|
2735
|
+
const fp = asSelectionFingerprint(value);
|
|
2736
|
+
if (fp)
|
|
2737
|
+
out[xpath] = fp;
|
|
2738
|
+
}
|
|
2739
|
+
return out;
|
|
2740
|
+
}
|
|
2741
|
+
/** True when two {@link ElementSelectionFingerprint}s differ in any tracked field. */
|
|
2742
|
+
function selectionFingerprintChanged(pre, post) {
|
|
2743
|
+
return (pre.kind !== post.kind ||
|
|
2744
|
+
pre.cls !== post.cls ||
|
|
2745
|
+
pre.ariaPressed !== post.ariaPressed ||
|
|
2746
|
+
pre.ariaChecked !== post.ariaChecked ||
|
|
2747
|
+
pre.ariaSelected !== post.ariaSelected ||
|
|
2748
|
+
pre.dataState !== post.dataState ||
|
|
2749
|
+
pre.dataSelected !== post.dataSelected ||
|
|
2750
|
+
pre.dataChecked !== post.dataChecked ||
|
|
2751
|
+
pre.checked !== post.checked ||
|
|
2752
|
+
pre.value !== post.value);
|
|
2753
|
+
}
|
|
2754
|
+
/**
|
|
2755
|
+
* Narrows an unknown `evaluate` result to an {@link ElementSelectionFingerprint}.
|
|
2756
|
+
* Every field is emitted as a string by {@link elementSelectionFingerprintExpr},
|
|
2757
|
+
* so a present object with a string `kind` is a sufficient shape check.
|
|
2758
|
+
*/
|
|
2759
|
+
function asSelectionFingerprint(raw) {
|
|
2760
|
+
if (raw === null || typeof raw !== "object")
|
|
2761
|
+
return null;
|
|
2762
|
+
const r = raw;
|
|
2763
|
+
if (typeof r.kind !== "string")
|
|
2764
|
+
return null;
|
|
2765
|
+
return {
|
|
2766
|
+
kind: r.kind,
|
|
2767
|
+
cls: typeof r.cls === "string" ? r.cls : "",
|
|
2768
|
+
ariaPressed: typeof r.ariaPressed === "string" ? r.ariaPressed : "",
|
|
2769
|
+
ariaChecked: typeof r.ariaChecked === "string" ? r.ariaChecked : "",
|
|
2770
|
+
ariaSelected: typeof r.ariaSelected === "string" ? r.ariaSelected : "",
|
|
2771
|
+
dataState: typeof r.dataState === "string" ? r.dataState : "",
|
|
2772
|
+
dataSelected: typeof r.dataSelected === "string" ? r.dataSelected : "",
|
|
2773
|
+
dataChecked: typeof r.dataChecked === "string" ? r.dataChecked : "",
|
|
2774
|
+
checked: typeof r.checked === "string" ? r.checked : "",
|
|
2775
|
+
value: typeof r.value === "string" ? r.value : "",
|
|
2776
|
+
};
|
|
2777
|
+
}
|
|
2778
|
+
/**
|
|
2779
|
+
* Reads one element's {@link ElementSelectionFingerprint} off `target` by
|
|
2780
|
+
* xpath, or `null` when the selector isn't an xpath / the node is absent / the
|
|
2781
|
+
* evaluate throws. Used by the click branch of {@link verifyDomEffect} to
|
|
2782
|
+
* compare the resolved element's committed selection state against the
|
|
2783
|
+
* pre-action baseline captured in {@link StepSnapshot.selectionStateByXpath}.
|
|
2784
|
+
*/
|
|
2785
|
+
async function readElementSelectionFingerprint(target, selector) {
|
|
2786
|
+
const xpath = xpathBodyForEvaluate(selector);
|
|
2787
|
+
if (!xpath)
|
|
2788
|
+
return null;
|
|
2789
|
+
try {
|
|
2790
|
+
return asSelectionFingerprint(await target.evaluate(elementSelectionFingerprintExpr(xpath)));
|
|
2791
|
+
}
|
|
2792
|
+
catch {
|
|
2793
|
+
return null;
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2663
2796
|
/** How long the upload primitive waits for a post-setInputFiles network POST. */
|
|
2664
2797
|
const UPLOAD_NETWORK_TIMEOUT_MS = 5_000;
|
|
2665
2798
|
/** Polling interval while waiting for the upload's network signal. */
|
|
@@ -4572,8 +4705,16 @@ async function dispatchJqueryChangeEvent(target, selector) {
|
|
|
4572
4705
|
*
|
|
4573
4706
|
* `target` scopes both the locator/evaluate reads and the jQuery-change
|
|
4574
4707
|
* dispatch to the resolved frame (main or a cross-origin child).
|
|
4708
|
+
*
|
|
4709
|
+
* `preSelectionState` is the pre-action per-element baseline
|
|
4710
|
+
* ({@link StepSnapshot.selectionStateByXpath}) the click branch diffs the
|
|
4711
|
+
* resolved element against — the element-scoped authoritative "did this
|
|
4712
|
+
* selection register" signal for design-system option/toggle buttons that
|
|
4713
|
+
* expose no native `checked` (Base Web `kind`, hashed styletron class, ARIA).
|
|
4714
|
+
* Defaults to `{}` so callers/tests that don't supply it keep the prior
|
|
4715
|
+
* radio/checkbox-and-network behavior.
|
|
4575
4716
|
*/
|
|
4576
|
-
async function verifyDomEffect(target, action) {
|
|
4717
|
+
async function verifyDomEffect(target, action, preSelectionState = {}) {
|
|
4577
4718
|
const selector = action.selector;
|
|
4578
4719
|
const method = action.method;
|
|
4579
4720
|
if (!selector || !method)
|
|
@@ -4717,9 +4858,15 @@ async function verifyDomEffect(target, action) {
|
|
|
4717
4858
|
return true;
|
|
4718
4859
|
case "click": {
|
|
4719
4860
|
// Clicks on radios and checkboxes toggle `:checked` without firing a
|
|
4720
|
-
// network request — same false-fail class as fill.
|
|
4721
|
-
//
|
|
4722
|
-
//
|
|
4861
|
+
// network request — same false-fail class as fill. Every OTHER click
|
|
4862
|
+
// (design-system option/toggle buttons, links, custom controls) is
|
|
4863
|
+
// verified element-scoped: compare the RESOLVED element's own committed
|
|
4864
|
+
// selection state before vs. after the click. A Base Web option flips
|
|
4865
|
+
// its `kind` (`tertiary`→`primary`) + a hashed styletron class with no
|
|
4866
|
+
// network, no URL change, and a trivial/negative byte delta — the
|
|
4867
|
+
// authoritative signal is that the clicked element's own fingerprint
|
|
4868
|
+
// moved, which this reads directly instead of guessing from page-wide
|
|
4869
|
+
// DOM deltas.
|
|
4723
4870
|
const xpath = xpathBody(selector);
|
|
4724
4871
|
if (!xpath)
|
|
4725
4872
|
return false;
|
|
@@ -4740,8 +4887,21 @@ async function verifyDomEffect(target, action) {
|
|
|
4740
4887
|
return false;
|
|
4741
4888
|
}
|
|
4742
4889
|
if (inputType !== "radio" && inputType !== "checkbox") {
|
|
4743
|
-
//
|
|
4744
|
-
|
|
4890
|
+
// Element-scoped selection read-back. The pre-map is keyed by the
|
|
4891
|
+
// same absolute positional xpath Stagehand resolves to, so a lookup
|
|
4892
|
+
// by the resolved element's xpath body yields THAT element's baseline.
|
|
4893
|
+
// Credit the click iff the element's own committed state moved across
|
|
4894
|
+
// it. No baseline (element newly mounted, or an xpath the generator
|
|
4895
|
+
// and Stagehand index differently) → hold no opinion and let the
|
|
4896
|
+
// network/URL signal decide: a miss is never a false credit, and a
|
|
4897
|
+
// selection change on any OTHER element can never credit this step.
|
|
4898
|
+
const preFingerprint = preSelectionState[xpath];
|
|
4899
|
+
if (!preFingerprint)
|
|
4900
|
+
return false;
|
|
4901
|
+
const postFingerprint = await readElementSelectionFingerprint(target, selector);
|
|
4902
|
+
if (!postFingerprint)
|
|
4903
|
+
return false;
|
|
4904
|
+
return selectionFingerprintChanged(preFingerprint, postFingerprint);
|
|
4745
4905
|
}
|
|
4746
4906
|
const isCheckedNow = await locator.isChecked();
|
|
4747
4907
|
if (!isCheckedNow)
|
|
@@ -5281,6 +5441,13 @@ async function executeStepWithHealing(params) {
|
|
|
5281
5441
|
// (verified 2026-06-15 on one measured tenant's telemetry). Site-agnostic: any flow
|
|
5282
5442
|
// whose submit is mid-list can mark its submit step explicitly.
|
|
5283
5443
|
const requireSubmitEndpoint = (isFinalStep || submitStep) && submitEndpointPattern !== null;
|
|
5444
|
+
// Capture the per-element selection baseline (for verifyDomEffect's element-
|
|
5445
|
+
// scoped click read-back) ONLY for selection/field-answer click steps — never
|
|
5446
|
+
// for submit/advance steps, whose own self-toggling buttons must not
|
|
5447
|
+
// false-credit the step, and whose advance/submit verdicts require a real
|
|
5448
|
+
// network/URL transition. Also keeps the extra full-DOM evaluate off the
|
|
5449
|
+
// submit/advance path. Step-level intent (available before the attempt loop).
|
|
5450
|
+
const captureSelectionState = shouldCaptureSelectionState({ step, isFinalStep, submitStep });
|
|
5284
5451
|
const attempts = [];
|
|
5285
5452
|
const triedSelectors = [];
|
|
5286
5453
|
const failureReasons = [];
|
|
@@ -5538,6 +5705,7 @@ async function executeStepWithHealing(params) {
|
|
|
5538
5705
|
visibleTextSignature: "",
|
|
5539
5706
|
selectionStateSignature: "",
|
|
5540
5707
|
formValueSignature: "",
|
|
5708
|
+
selectionStateByXpath: {},
|
|
5541
5709
|
};
|
|
5542
5710
|
attempts.push({
|
|
5543
5711
|
attempt: 0,
|
|
@@ -5719,7 +5887,7 @@ async function executeStepWithHealing(params) {
|
|
|
5719
5887
|
if (attempt > 1) {
|
|
5720
5888
|
await page.waitForTimeout(attempt * ATTEMPT_BACKOFF_MS);
|
|
5721
5889
|
}
|
|
5722
|
-
const pre = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
|
|
5890
|
+
const pre = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page, captureSelectionState);
|
|
5723
5891
|
// Snapshot the meta-tail length so the final-step pattern gate can scope
|
|
5724
5892
|
// its URL scan to captures added DURING this attempt (not historical
|
|
5725
5893
|
// tail from earlier steps).
|
|
@@ -5959,9 +6127,12 @@ async function executeStepWithHealing(params) {
|
|
|
5959
6127
|
// event-bound wrappers) the likely cause is that the attempt-1 click was
|
|
5960
6128
|
// an in-page, `isTrusted=false` activation the handler ignores — the
|
|
5961
6129
|
// element resolves fine, but only a REAL user gesture registers. So
|
|
5962
|
-
// re-
|
|
5963
|
-
//
|
|
5964
|
-
//
|
|
6130
|
+
// re-click the target with a TRUSTED gesture. Two arms by page shape:
|
|
6131
|
+
// a top-window (no frame seam) page re-clicks attempt-1's resolved xpath
|
|
6132
|
+
// via a Playwright `.locator().first().click()` on the main frame; an
|
|
6133
|
+
// OOPIF/cross-origin page re-resolves via the frame-seam deepLocator and
|
|
6134
|
+
// clicks through `clickDeepLocatorCandidate` → `deepLocator().nth().click()`
|
|
6135
|
+
// (an `Input.dispatchMouseEvent`). Both deliver `isTrusted=true`. This is
|
|
5965
6136
|
// the non-submit sibling of the deep-submit-locator escalation above.
|
|
5966
6137
|
record.technique = "trusted-click-retry";
|
|
5967
6138
|
// Carry forward any selector prior attempts already resolved (e.g.
|
|
@@ -5971,42 +6142,90 @@ async function executeStepWithHealing(params) {
|
|
|
5971
6142
|
// this attempt is an ADDITIONAL recovery, not a replacement that
|
|
5972
6143
|
// demotes the rest of the ladder.
|
|
5973
6144
|
if (!frameTarget?.frame) {
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
6145
|
+
// Top-window path: this wizard renders in the top document with no
|
|
6146
|
+
// cross-origin OOPIF, so `deepLocator` (which is built entirely on a
|
|
6147
|
+
// frame seam — deep-locator-candidates.ts's resolveScanFrameTarget
|
|
6148
|
+
// returns null when no frameSelector resolves) yields nothing. The
|
|
6149
|
+
// trusted-click primitive `deepLocator().nth().click()` is therefore
|
|
6150
|
+
// unreachable, but the ACTIVATION we need — a real `isTrusted=true`
|
|
6151
|
+
// gesture the Base Web handler honours — is still deliverable via a
|
|
6152
|
+
// Playwright Locator click on the top frame (the same trusted click
|
|
6153
|
+
// applyRadioSelection uses at Tier A). Re-click attempt-1's resolved
|
|
6154
|
+
// xpath through the main-frame FrameTarget's `.locator()` instead of
|
|
6155
|
+
// bailing; the synthesized click action flows through the standard
|
|
6156
|
+
// verifier exactly like the frame-seam path's result.
|
|
6157
|
+
//
|
|
6158
|
+
// FIRST xpath, not last: attempt-1's act pushes every resolved
|
|
6159
|
+
// action's selector into `triedSelectors` in order but binds the
|
|
6160
|
+
// phantom-classified `resolvedAction` to the FIRST (see the
|
|
6161
|
+
// `if (!resolvedAction)` at the attempt-1 branch). On a multi-action
|
|
6162
|
+
// attempt-1 the last entry is a different control than the one that
|
|
6163
|
+
// was clicked, so the first match is the phantomed target.
|
|
6164
|
+
const topWindowSelector = triedSelectors.find((sel) => xpathBodyForEvaluate(sel) !== null);
|
|
6165
|
+
if (!topWindowSelector) {
|
|
6166
|
+
const failureMessage = "trusted-click-retry: no top-window selector resolved for the phantomed target";
|
|
6167
|
+
record.actResultSuccess = false;
|
|
6168
|
+
record.errorMessage = failureMessage;
|
|
6169
|
+
record.triedSelectors = [...triedSelectors];
|
|
6170
|
+
attempts.push(record);
|
|
6171
|
+
failureReasons.push(failureMessage);
|
|
6172
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
|
|
6173
|
+
continue;
|
|
6174
|
+
}
|
|
6175
|
+
const topWindowTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
|
|
6176
|
+
try {
|
|
6177
|
+
await topWindowTarget.locator(topWindowSelector).first().click();
|
|
6178
|
+
}
|
|
6179
|
+
catch (err) {
|
|
6180
|
+
const failureMessage = `trusted-click-retry: top-window trusted click threw ${(0, errors_1.toErrorMessage)(err)}`;
|
|
6181
|
+
record.actResultSuccess = false;
|
|
6182
|
+
record.errorMessage = failureMessage;
|
|
6183
|
+
record.triedSelectors = [...triedSelectors];
|
|
6184
|
+
attempts.push(record);
|
|
6185
|
+
failureReasons.push(failureMessage);
|
|
6186
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
|
|
6187
|
+
continue;
|
|
6188
|
+
}
|
|
6189
|
+
record.instruction = `trusted-click-retry (top-window): ${topWindowSelector}`;
|
|
6190
|
+
record.actResultSuccess = true;
|
|
6191
|
+
record.actResultDescription = `trusted-click-retry clicked "${topWindowSelector}" via top-window locator`;
|
|
5977
6192
|
record.triedSelectors = [...triedSelectors];
|
|
5978
|
-
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
6193
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: trusted-click-retry: top-window trusted locator click on the resolved target`);
|
|
6194
|
+
resolvedAction = {
|
|
6195
|
+
selector: topWindowSelector,
|
|
6196
|
+
description: record.actResultDescription,
|
|
6197
|
+
method: "click",
|
|
6198
|
+
};
|
|
5982
6199
|
}
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
frameTarget,
|
|
5986
|
-
|
|
5987
|
-
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
6200
|
+
else {
|
|
6201
|
+
await reresolveFrameTargetIfLost();
|
|
6202
|
+
const { candidates: retryCandidates, innerSelector: retryInnerSelector } = await resolveDeepLocatorCandidatesWithWidening(page, frameTarget.frameSelector, step, {
|
|
6203
|
+
frameTarget,
|
|
6204
|
+
});
|
|
6205
|
+
const deepLocatorCandidates = retryCandidates.filter((c) => !triedSelectors.includes(c.selector));
|
|
6206
|
+
if (deepLocatorCandidates.length === 0) {
|
|
6207
|
+
const failureMessage = "trusted-click-retry: no deepLocator candidate resolved for the phantomed target";
|
|
6208
|
+
record.actResultSuccess = false;
|
|
6209
|
+
record.errorMessage = failureMessage;
|
|
6210
|
+
record.triedSelectors = [...triedSelectors];
|
|
6211
|
+
attempts.push(record);
|
|
6212
|
+
failureReasons.push(failureMessage);
|
|
6213
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
|
|
6214
|
+
continue;
|
|
6215
|
+
}
|
|
6216
|
+
const { resolvedAction: retryResolvedAction } = await runDeepLocatorClickWalk({
|
|
6217
|
+
candidates: deepLocatorCandidates,
|
|
6218
|
+
innerSelector: retryInnerSelector,
|
|
6219
|
+
preferTrustedClick: true,
|
|
6220
|
+
labelPrefix: "trusted-click-retry",
|
|
6221
|
+
record,
|
|
6222
|
+
attempt,
|
|
6223
|
+
pre,
|
|
6224
|
+
});
|
|
6225
|
+
if (!retryResolvedAction)
|
|
6226
|
+
continue;
|
|
6227
|
+
resolvedAction = retryResolvedAction;
|
|
5997
6228
|
}
|
|
5998
|
-
const { resolvedAction: retryResolvedAction } = await runDeepLocatorClickWalk({
|
|
5999
|
-
candidates: deepLocatorCandidates,
|
|
6000
|
-
innerSelector: retryInnerSelector,
|
|
6001
|
-
preferTrustedClick: true,
|
|
6002
|
-
labelPrefix: "trusted-click-retry",
|
|
6003
|
-
record,
|
|
6004
|
-
attempt,
|
|
6005
|
-
pre,
|
|
6006
|
-
});
|
|
6007
|
-
if (!retryResolvedAction)
|
|
6008
|
-
continue;
|
|
6009
|
-
resolvedAction = retryResolvedAction;
|
|
6010
6229
|
}
|
|
6011
6230
|
else if (attempt === 2 || attempt === 4) {
|
|
6012
6231
|
record.technique = attempt === 2 ? "observe-act" : "observe-act-exclude";
|
|
@@ -6525,11 +6744,14 @@ async function executeStepWithHealing(params) {
|
|
|
6525
6744
|
// State-class actions (fill/check/etc.) never move the network counter or URL,
|
|
6526
6745
|
// so the legacy heuristic false-negatived every form fill. Re-read DOM state
|
|
6527
6746
|
// for those; keep the navigation signal authoritative for clicks/links —
|
|
6528
|
-
// but ALSO route clicks through verifyDomEffect, which
|
|
6529
|
-
//
|
|
6530
|
-
//
|
|
6747
|
+
// but ALSO route clicks through verifyDomEffect, which authoritatively
|
|
6748
|
+
// credits a radio/checkbox toggle OR (via the pre/post element fingerprint
|
|
6749
|
+
// baseline `pre.selectionStateByXpath`) a design-system option/toggle whose
|
|
6750
|
+
// own committed state changed; otherwise it returns false and the
|
|
6751
|
+
// network/URL signal decides. Radios/checkboxes are click-but-no-network
|
|
6752
|
+
// just like fills.
|
|
6531
6753
|
const domVerified = resolvedAction !== null && (isStateClass || isClick)
|
|
6532
|
-
? await verifyDomEffect(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction)
|
|
6754
|
+
? await verifyDomEffect(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction, pre.selectionStateByXpath)
|
|
6533
6755
|
: false;
|
|
6534
6756
|
// Interior-advance transition gate (opt-in). On SPAs where a page advance
|
|
6535
6757
|
// and a mere field-edit share one endpoint URL (the wizard ATS's `/gq`:
|
|
@@ -6603,20 +6825,15 @@ async function executeStepWithHealing(params) {
|
|
|
6603
6825
|
bytesDelta: post.bodyHtmlLength - pre.bodyHtmlLength,
|
|
6604
6826
|
textChanged: post.visibleTextSignature !== pre.visibleTextSignature,
|
|
6605
6827
|
});
|
|
6606
|
-
//
|
|
6607
|
-
//
|
|
6608
|
-
//
|
|
6609
|
-
//
|
|
6610
|
-
//
|
|
6611
|
-
//
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
submitStep,
|
|
6616
|
-
isAdvance: isAdvanceStep(step),
|
|
6617
|
-
networkDelta: post.networkCount - pre.networkCount,
|
|
6618
|
-
selectionStateChanged: post.selectionStateSignature !== pre.selectionStateSignature,
|
|
6619
|
-
});
|
|
6828
|
+
// A network-free selection/option toggle — including a Base Web `kind`
|
|
6829
|
+
// flip or hashed-class swap that exposes no aria/data-state marker and
|
|
6830
|
+
// moves a trivial or NEGATIVE byte delta — is now credited authoritatively
|
|
6831
|
+
// and element-scoped by `verifyDomEffect` (via the pre/post per-element
|
|
6832
|
+
// fingerprint baseline), flowing through `domVerified`. The former
|
|
6833
|
+
// page-wide `clickStateToggleVerified` guess and its `selectionCounterStalled`
|
|
6834
|
+
// veto (which only existed to suppress that guess's false positives) are
|
|
6835
|
+
// removed: there is nothing to guess or veto when the resolved element's own
|
|
6836
|
+
// committed state is read directly.
|
|
6620
6837
|
// Form-value-diff signal: `visibleTextSignature` never reflects a plain
|
|
6621
6838
|
// <input>/<textarea>/<select>'s `value` property, so a fill with no
|
|
6622
6839
|
// secondary UI side effect (no toggle, no formatted display) had ZERO
|
|
@@ -6680,33 +6897,11 @@ async function executeStepWithHealing(params) {
|
|
|
6680
6897
|
}
|
|
6681
6898
|
}
|
|
6682
6899
|
}
|
|
6683
|
-
// Selection-counter veto. A multi-select option click whose widget exposes
|
|
6684
|
-
// no aria/data-state marker (obfuscated hashed classes) leaves
|
|
6685
|
-
// `clickStateToggleVerified` blind, so a phantom that merely reflowed the
|
|
6686
|
-
// DOM would ride the weak `domVerifiedForStep`/`clickViewSwapVerified`
|
|
6687
|
-
// signals to a FALSE credit — the flow then advances to a "Next" that
|
|
6688
|
-
// no-ops because nothing was actually selected. When the step's running
|
|
6689
|
-
// "N selected" counter did not rise, suppress those weak DOM-delta signals
|
|
6690
|
-
// so the cascade keeps trying a real selection. Strong signals (real
|
|
6691
|
-
// network/URL transition) and the counter-independent state-toggle /
|
|
6692
|
-
// form-value signals — each of which IS the selection registering — are
|
|
6693
|
-
// never vetoed; counter-less widgets are untouched (the helper no-ops).
|
|
6694
|
-
const selectionCounterStalled = isSelectionCounterStalled({
|
|
6695
|
-
isSelectionStep: parseSelectStep(step) !== null,
|
|
6696
|
-
preVisibleTextSignature: pre.visibleTextSignature,
|
|
6697
|
-
postVisibleTextSignature: post.visibleTextSignature,
|
|
6698
|
-
});
|
|
6699
|
-
if (selectionCounterStalled && (domVerifiedForStep || clickViewSwapVerified)) {
|
|
6700
|
-
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`);
|
|
6701
|
-
}
|
|
6702
|
-
const domVerifiedAfterCounter = selectionCounterStalled ? false : domVerifiedForStep;
|
|
6703
|
-
const clickViewSwapAfterCounter = selectionCounterStalled ? false : clickViewSwapVerified;
|
|
6704
6900
|
let verified = networkIsRealAdvance ||
|
|
6705
6901
|
urlChanged ||
|
|
6706
|
-
|
|
6902
|
+
domVerifiedForStep ||
|
|
6707
6903
|
datepickerCommitted ||
|
|
6708
|
-
(!datepickerRejected &&
|
|
6709
|
-
(clickViewSwapAfterCounter || formValueVerified || clickStateToggleVerified));
|
|
6904
|
+
(!datepickerRejected && (clickViewSwapVerified || formValueVerified));
|
|
6710
6905
|
// Final-step submit-verification gate. Replaces the deterministic
|
|
6711
6906
|
// submitEndpointPattern regex with a Haiku 4.5 LLM judgment over
|
|
6712
6907
|
// multi-signal evidence (network captures, page URL/title, DOM
|
|
@@ -6833,9 +7028,7 @@ async function executeStepWithHealing(params) {
|
|
|
6833
7028
|
? "dom"
|
|
6834
7029
|
: formValueVerified
|
|
6835
7030
|
? "form-value"
|
|
6836
|
-
:
|
|
6837
|
-
? "state-toggle"
|
|
6838
|
-
: "dom";
|
|
7031
|
+
: "dom";
|
|
6839
7032
|
}
|
|
6840
7033
|
// N+16 probe: Stagehand's CDP click sometimes lands on the button without
|
|
6841
7034
|
// triggering React's SyntheticEvent layer (or jQuery delegated handlers).
|
|
@@ -6923,13 +7116,26 @@ async function executeStepWithHealing(params) {
|
|
|
6923
7116
|
const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
|
|
6924
7117
|
const retryTextChanged = retryPost.visibleTextSignature !== pre.visibleTextSignature;
|
|
6925
7118
|
const retryFormValueChanged = retryPost.formValueSignature !== pre.formValueSignature;
|
|
6926
|
-
//
|
|
6927
|
-
//
|
|
6928
|
-
//
|
|
6929
|
-
//
|
|
6930
|
-
//
|
|
7119
|
+
// Element-scoped selection read-back for the n+16 fallback — the same
|
|
7120
|
+
// authoritative signal the primary verifier uses via `verifyDomEffect`,
|
|
7121
|
+
// applied to the element this fallback just re-clicked. Credits only
|
|
7122
|
+
// when the RESOLVED element's own committed state moved across the
|
|
7123
|
+
// fallback click (Base Web `kind`/class, ARIA, native checked), read
|
|
7124
|
+
// against the pre-action baseline. Excludes advance/"Next" steps: an
|
|
7125
|
+
// advance without a configured transition pattern has no real-transition
|
|
7126
|
+
// veto, so crediting a bare selection change would desync the step
|
|
7127
|
+
// pointer. No page-wide signature, no counter veto — nothing to guess
|
|
7128
|
+
// or suppress when the element's own state is read directly.
|
|
6931
7129
|
const retrySelectionStateChanged = !isAdvanceStep(step) &&
|
|
6932
|
-
|
|
7130
|
+
(await (async () => {
|
|
7131
|
+
const preFingerprint = xpath ? pre.selectionStateByXpath[xpath] : undefined;
|
|
7132
|
+
if (!preFingerprint || !resolvedAction?.selector)
|
|
7133
|
+
return false;
|
|
7134
|
+
const postFingerprint = await readElementSelectionFingerprint(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector);
|
|
7135
|
+
return postFingerprint !== null
|
|
7136
|
+
? selectionFingerprintChanged(preFingerprint, postFingerprint)
|
|
7137
|
+
: false;
|
|
7138
|
+
})());
|
|
6933
7139
|
// RC2: an advance/`kind=click` "Next" that only grew the DOM
|
|
6934
7140
|
// (validation errors rendered) with NO network/URL change is a
|
|
6935
7141
|
// validation-blocked no-op, not a real transition — but the
|
|
@@ -6980,41 +7186,25 @@ async function executeStepWithHealing(params) {
|
|
|
6980
7186
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} n+16 fallback advanced but no real transition (non-advancing POST / field toggle); not treating as verified`);
|
|
6981
7187
|
}
|
|
6982
7188
|
// Weak DOM-only signals (an html-byte delta, a visible-text change, a
|
|
6983
|
-
// form-value change
|
|
7189
|
+
// form-value change) are NOT sufficient to
|
|
6984
7190
|
// verify a final/submit step on their own: a validation re-render
|
|
6985
7191
|
// produces exactly these without the submit landing. The primary
|
|
6986
|
-
// verifier is safe here because clickViewSwapVerified/formValueVerified
|
|
6987
|
-
//
|
|
7192
|
+
// verifier is safe here because clickViewSwapVerified/formValueVerified
|
|
7193
|
+
// self-exclude final/submit; the n+16
|
|
6988
7194
|
// fallback ORs the raw deltas, so gate them explicitly. On a
|
|
6989
7195
|
// final/submit step they only count when the submit-endpoint judge will
|
|
6990
7196
|
// actually run below (requireSubmitEndpoint) to corroborate — otherwise
|
|
6991
7197
|
// only a strong signal (network/url) or a verified checkbox state may
|
|
6992
7198
|
// pass. Non-final/submit steps are unaffected.
|
|
6993
7199
|
const weakDomSignalsAllowed = (!isFinalStep && !submitStep) || requireSubmitEndpoint;
|
|
6994
|
-
// Selection-counter veto for the n+16 fallback — the same gate the
|
|
6995
|
-
// primary verifier applies, recomputed against `retryPost`. Without
|
|
6996
|
-
// it this path would re-credit a stalled selection the primary veto
|
|
6997
|
-
// already suppressed: the fallback `el.click()` can reflow the DOM
|
|
6998
|
-
// (html/text/selection-state delta) without registering the option,
|
|
6999
|
-
// and those weak deltas are exactly what the OR below admits for a
|
|
7000
|
-
// non-final selection step. Only the weak-delta disjunct is vetoed;
|
|
7001
|
-
// strong signals (network/url) and a verified checkbox state pass.
|
|
7002
|
-
const retrySelectionCounterStalled = isSelectionCounterStalled({
|
|
7003
|
-
isSelectionStep: parseSelectStep(step) !== null,
|
|
7004
|
-
preVisibleTextSignature: pre.visibleTextSignature,
|
|
7005
|
-
postVisibleTextSignature: retryPost.visibleTextSignature,
|
|
7006
|
-
});
|
|
7007
7200
|
let retryVerified = !clickBlockedByInvalid &&
|
|
7008
7201
|
!fallbackDomOnlyAdvance &&
|
|
7009
7202
|
(retryNetworkFired ||
|
|
7010
7203
|
retryUrlChanged ||
|
|
7011
7204
|
checkboxStateVerified ||
|
|
7205
|
+
retrySelectionStateChanged ||
|
|
7012
7206
|
(weakDomSignalsAllowed &&
|
|
7013
|
-
|
|
7014
|
-
(retryHtmlDelta !== 0 ||
|
|
7015
|
-
retryTextChanged ||
|
|
7016
|
-
retryFormValueChanged ||
|
|
7017
|
-
retrySelectionStateChanged)));
|
|
7207
|
+
(retryHtmlDelta !== 0 || retryTextChanged || retryFormValueChanged)));
|
|
7018
7208
|
// Apply the same submit-endpoint gate the primary verifier uses.
|
|
7019
7209
|
// Without this, the n+16 fallback would still ride past a
|
|
7020
7210
|
// tracking-pixel-only click on the final step. Same Haiku LLM
|
|
@@ -7090,17 +7280,7 @@ async function executeStepWithHealing(params) {
|
|
|
7090
7280
|
logger.info(`n+16 probe: step=${stepIndex + 1}/${totalSteps?.() ?? "?"} attempt=${attempt} el.click() fallback fired=${fired === true} kind=${probeResult.kind ?? "none"} checkboxStateVerified=${checkboxStateVerified} ancestorStillInvalid=${ancestorStillInvalid}; network=${retryNetworkFired} url=${retryUrlChanged} htmlDelta=${retryHtmlDelta} textChanged=${retryTextChanged} formValueChanged=${retryFormValueChanged} selectionStateChanged=${retrySelectionStateChanged} verified=${retryVerified}`);
|
|
7091
7281
|
if (retryVerified) {
|
|
7092
7282
|
if (record.verifiedBy === null) {
|
|
7093
|
-
record.verifiedBy = retryUrlChanged
|
|
7094
|
-
? "url"
|
|
7095
|
-
: retryNetworkFired
|
|
7096
|
-
? "network"
|
|
7097
|
-
: retryHtmlDelta === 0 &&
|
|
7098
|
-
!retryTextChanged &&
|
|
7099
|
-
!retryFormValueChanged &&
|
|
7100
|
-
!checkboxStateVerified &&
|
|
7101
|
-
retrySelectionStateChanged
|
|
7102
|
-
? "state-toggle"
|
|
7103
|
-
: "dom";
|
|
7283
|
+
record.verifiedBy = retryUrlChanged ? "url" : retryNetworkFired ? "network" : "dom";
|
|
7104
7284
|
}
|
|
7105
7285
|
record.post = retryPost;
|
|
7106
7286
|
attempts.push(record);
|
|
@@ -7122,7 +7302,7 @@ async function executeStepWithHealing(params) {
|
|
|
7122
7302
|
attempts.push(record);
|
|
7123
7303
|
if (verified) {
|
|
7124
7304
|
if (attempt > 1) {
|
|
7125
|
-
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} healed on attempt ${attempt} via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified}
|
|
7305
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} healed on attempt ${attempt} via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified} verifiedBy=${record.verifiedBy})`);
|
|
7126
7306
|
}
|
|
7127
7307
|
else {
|
|
7128
7308
|
// Why log first-try wins explicitly: prior to this change, attempt-1
|
|
@@ -7132,7 +7312,7 @@ async function executeStepWithHealing(params) {
|
|
|
7132
7312
|
// collapse" (log showed 2 heals) but telemetry calls.ndjson showed
|
|
7133
7313
|
// 32 successful Stagehand acts. Surfacing attempt-1 wins lets the
|
|
7134
7314
|
// log match telemetry and prevents the same false alarm.
|
|
7135
|
-
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified}
|
|
7315
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified} verifiedBy=${record.verifiedBy})`);
|
|
7136
7316
|
}
|
|
7137
7317
|
trajectory?.push({ stepIndex, verifiedBy: record.verifiedBy });
|
|
7138
7318
|
return "completed";
|
|
@@ -7147,6 +7327,11 @@ async function executeStepWithHealing(params) {
|
|
|
7147
7327
|
actResultSuccess: record.actResultSuccess,
|
|
7148
7328
|
pre,
|
|
7149
7329
|
post,
|
|
7330
|
+
// Authoritative element-scoped signal: `verifyDomEffect` read the resolved
|
|
7331
|
+
// element's own committed-state delta (Base Web `kind`/class, ARIA, native
|
|
7332
|
+
// checked) into `domVerified`. A registered selection toggle no longer
|
|
7333
|
+
// reads as a phantom just because it moved no network/URL/bytes.
|
|
7334
|
+
elementStateChanged: domVerified,
|
|
7150
7335
|
isSubmitShapedStep: isFinalStep || submitStep,
|
|
7151
7336
|
});
|
|
7152
7337
|
const reason = record.errorMessage
|