@enricai/barnacle 1.11.0 → 1.12.1
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-fake.d.ts.map +1 -1
- package/dist/scraper/deep-locator-fake.js +7 -0
- package/dist/scraper/deep-locator-fake.js.map +1 -1
- package/dist/scraper/flow-runner.d.ts +33 -0
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +251 -59
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scraper/frame-target.d.ts.map +1 -1
- package/dist/scraper/frame-target.js +124 -22
- package/dist/scraper/frame-target.js.map +1 -1
- package/dist/scripts/recon-browser.d.ts +15 -0
- package/dist/scripts/recon-browser.d.ts.map +1 -1
- package/dist/scripts/recon-browser.js +46 -0
- package/dist/scripts/recon-browser.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +104 -29
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +2 -2
|
@@ -66,6 +66,7 @@ exports.parseSelectStep = parseSelectStep;
|
|
|
66
66
|
exports.parseFillStep = parseFillStep;
|
|
67
67
|
exports.parseFillValueIntent = parseFillValueIntent;
|
|
68
68
|
exports.parseRadioStep = parseRadioStep;
|
|
69
|
+
exports.parseCheckStep = parseCheckStep;
|
|
69
70
|
exports.pollEnumerate = pollEnumerate;
|
|
70
71
|
exports.waitForTransitionBody = waitForTransitionBody;
|
|
71
72
|
exports.chooseRequiredSelectOption = chooseRequiredSelectOption;
|
|
@@ -1034,11 +1035,26 @@ function isDomOnlyAdvanceVerified(params) {
|
|
|
1034
1035
|
* `describeAttemptEffectSignals`'s own reflow-vs-reveal boundary). Requiring
|
|
1035
1036
|
* `textChanged` keeps a trivial reflow/tooltip (DOM churn with no new visible
|
|
1036
1037
|
* content) from being credited — those still cascade to failure as before.
|
|
1038
|
+
*
|
|
1039
|
+
* **Blocked-submit veto:** a form-submit/advance click that a wizard-style
|
|
1040
|
+
* client-side validation rejects renders inline error text — exactly the
|
|
1041
|
+
* `textChanged` + DOM-growth shape the reveal-credit branch above rewards —
|
|
1042
|
+
* with zero network, zero URL change. The reported false positive
|
|
1043
|
+
* (`recon-viewswap-false-positive-on-blocked-form-submit.md`): a "Create
|
|
1044
|
+
* Account" click with two empty required fields was scored
|
|
1045
|
+
* `verifiedBy=view-swap` on `network=false url=false dom=false`, and recon
|
|
1046
|
+
* advanced past a step the wizard never left. When `invalidMarkerDelta`
|
|
1047
|
+
* (post-click minus pre-click ng-invalid-style container count, from
|
|
1048
|
+
* {@link countNgInvalidContainers}) is positive, the click revealed NEW
|
|
1049
|
+
* validation errors rather than a legitimate view transition — veto the
|
|
1050
|
+
* credit regardless of which growth branch would otherwise fire. Optional
|
|
1051
|
+
* and defaults to 0 (no veto) so existing callers that don't thread the
|
|
1052
|
+
* marker count are unaffected.
|
|
1037
1053
|
*/
|
|
1038
1054
|
function isClickViewSwapVerified(params) {
|
|
1039
1055
|
const VIEW_SWAP_MIN_BYTES = config_1.config.scraper.viewSwapMinBytesThreshold;
|
|
1040
1056
|
const VIEW_SWAP_REVEAL_MIN_BYTES = config_1.config.scraper.viewSwapRevealMinBytesThreshold;
|
|
1041
|
-
const { resolvedAction, isFinalStep, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, } = params;
|
|
1057
|
+
const { resolvedAction, isFinalStep, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, } = params;
|
|
1042
1058
|
if (resolvedAction?.method !== "click")
|
|
1043
1059
|
return false;
|
|
1044
1060
|
if (isFinalStep || submitStep)
|
|
@@ -1047,6 +1063,8 @@ function isClickViewSwapVerified(params) {
|
|
|
1047
1063
|
return false;
|
|
1048
1064
|
if (networkDelta !== 0)
|
|
1049
1065
|
return false;
|
|
1066
|
+
if (invalidMarkerDelta > 0)
|
|
1067
|
+
return false;
|
|
1050
1068
|
if (bytesDelta >= VIEW_SWAP_MIN_BYTES)
|
|
1051
1069
|
return true;
|
|
1052
1070
|
return textChanged && bytesDelta >= VIEW_SWAP_REVEAL_MIN_BYTES;
|
|
@@ -2649,6 +2667,46 @@ function xpathBodyForEvaluate(selector) {
|
|
|
2649
2667
|
const stripped = selector.startsWith("xpath=") ? selector.slice("xpath=".length) : selector;
|
|
2650
2668
|
return stripped.startsWith("/") || stripped.startsWith("(") ? stripped : null;
|
|
2651
2669
|
}
|
|
2670
|
+
/**
|
|
2671
|
+
* In-page source for the absolute positional xpath walk, shared by every
|
|
2672
|
+
* expression that must produce keys byte-identical to Stagehand's resolved
|
|
2673
|
+
* `xpath=/html[1]/…` selectors AND to each other: the baseline map
|
|
2674
|
+
* ({@link SELECTION_STATE_MAP_EXPR}) and the ancestor read-back
|
|
2675
|
+
* ({@link selectionAncestorChanged}). Kept as one constant so a future edit
|
|
2676
|
+
* can't drift one copy — a divergent walk would silently produce keys that miss
|
|
2677
|
+
* the baseline, making the ancestor read-back a no-op. Hardcoded
|
|
2678
|
+
* `/html[1]/body[1]/` prefix, `previousElementSibling` index,
|
|
2679
|
+
* `nodeName.toLowerCase()`, stop at `document.body`.
|
|
2680
|
+
*/
|
|
2681
|
+
const XPATH_OF_FN_SRC = `(node) => {
|
|
2682
|
+
const parts = [];
|
|
2683
|
+
while (node && node.nodeType === 1 && node !== document.body) {
|
|
2684
|
+
const tag = node.nodeName.toLowerCase();
|
|
2685
|
+
let idx = 1;
|
|
2686
|
+
let sib = node.previousElementSibling;
|
|
2687
|
+
while (sib) {
|
|
2688
|
+
if (sib.nodeName.toLowerCase() === tag) idx++;
|
|
2689
|
+
sib = sib.previousElementSibling;
|
|
2690
|
+
}
|
|
2691
|
+
parts.unshift(tag + "[" + idx + "]");
|
|
2692
|
+
node = node.parentElement;
|
|
2693
|
+
}
|
|
2694
|
+
return "/html[1]/body[1]/" + parts.join("/");
|
|
2695
|
+
}`;
|
|
2696
|
+
/**
|
|
2697
|
+
* In-page source for one element's {@link ElementSelectionFingerprint} object
|
|
2698
|
+
* literal, given the name of the element variable (`elVar`) and a variable
|
|
2699
|
+
* (`dsVar`) already bound to `elVar.getAttribute("data-state") || ""`. Shared by
|
|
2700
|
+
* {@link elementSelectionFingerprintExpr}, {@link SELECTION_STATE_MAP_EXPR}, and
|
|
2701
|
+
* {@link selectionAncestorChanged} so all three compute the SAME fingerprint —
|
|
2702
|
+
* a field drifting in one copy would make the pre/post diff compare mismatched
|
|
2703
|
+
* shapes. The `data-state` disclosure values `open`/`closed` (the
|
|
2704
|
+
* `aria-expanded` equivalent) are blanked by the caller's `dsVar` so opening a
|
|
2705
|
+
* popover isn't mistaken for a selection.
|
|
2706
|
+
*/
|
|
2707
|
+
function selectionFingerprintObjSrc(elVar, dsVar) {
|
|
2708
|
+
return `{ kind: ${elVar}.getAttribute("kind") || "", cls: ${elVar}.getAttribute("class") || "", ariaPressed: ${elVar}.getAttribute("aria-pressed") || "", ariaChecked: ${elVar}.getAttribute("aria-checked") || "", ariaSelected: ${elVar}.getAttribute("aria-selected") || "", dataState: (${dsVar} === "open" || ${dsVar} === "closed") ? "" : ${dsVar}, dataSelected: ${elVar}.hasAttribute("data-selected") ? "1" : "", dataChecked: ${elVar}.hasAttribute("data-checked") ? "1" : "", checked: (${elVar}.type === "checkbox" || ${elVar}.type === "radio") ? (${elVar}.checked ? "1" : "0") : "", value: typeof ${elVar}.value === "string" ? ${elVar}.value.slice(0, 200) : "" }`;
|
|
2709
|
+
}
|
|
2652
2710
|
/**
|
|
2653
2711
|
* Browser-side expression body that resolves `xpath` and returns that one
|
|
2654
2712
|
* element's {@link ElementSelectionFingerprint} (or `null` when the node is
|
|
@@ -2659,18 +2717,18 @@ function xpathBodyForEvaluate(selector) {
|
|
|
2659
2717
|
* for a selection.
|
|
2660
2718
|
*/
|
|
2661
2719
|
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 {
|
|
2720
|
+
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 ${selectionFingerprintObjSrc("el", "ds")}; })()`;
|
|
2663
2721
|
}
|
|
2664
2722
|
/**
|
|
2665
2723
|
* Browser-side expression: build `{ absolutePositionalXpath →
|
|
2666
2724
|
* ElementSelectionFingerprint }` for every VISIBLE interactive element, the
|
|
2667
2725
|
* pre-action baseline {@link verifyDomEffect} diffs the resolved element
|
|
2668
|
-
* against. Trust boundary:
|
|
2669
|
-
*
|
|
2670
|
-
*
|
|
2671
|
-
*
|
|
2672
|
-
*
|
|
2673
|
-
*
|
|
2726
|
+
* against. Trust boundary: composed only from the {@link XPATH_OF_FN_SRC} and
|
|
2727
|
+
* {@link selectionFingerprintObjSrc} constants (no external interpolation). The
|
|
2728
|
+
* xpath is generated by the shared {@link XPATH_OF_FN_SRC} walk — the SAME
|
|
2729
|
+
* source {@link selectionAncestorChanged} recomputes ancestor keys with, so the
|
|
2730
|
+
* two byte-match each other and Stagehand's resolved `xpath=/html[1]/…`
|
|
2731
|
+
* selectors. `[role=dialog],[role=tooltip],[aria-live]` subtrees are
|
|
2674
2732
|
* skipped — they churn without a selection change. Visibility uses the same
|
|
2675
2733
|
* rect + `getComputedStyle` idiom as `deep-locator-scan.ts`'s `IS_VISIBLE_EXPR`
|
|
2676
2734
|
* (rather than `offsetParent`), so on-screen `position:fixed` controls — a
|
|
@@ -2679,21 +2737,7 @@ function elementSelectionFingerprintExpr(xpath) {
|
|
|
2679
2737
|
const SELECTION_STATE_MAP_EXPR = `(() => {
|
|
2680
2738
|
const b = document.body;
|
|
2681
2739
|
if (!b) return {};
|
|
2682
|
-
const xpathOf =
|
|
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
|
-
};
|
|
2740
|
+
const xpathOf = ${XPATH_OF_FN_SRC};
|
|
2697
2741
|
const visible = (el) => {
|
|
2698
2742
|
const rect = el.getBoundingClientRect();
|
|
2699
2743
|
if (rect.width === 0 && rect.height === 0) return false;
|
|
@@ -2706,18 +2750,7 @@ const SELECTION_STATE_MAP_EXPR = `(() => {
|
|
|
2706
2750
|
for (const el of b.querySelectorAll(sel)) {
|
|
2707
2751
|
if (!visible(el) || skip(el)) continue;
|
|
2708
2752
|
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
|
-
};
|
|
2753
|
+
out[xpathOf(el)] = ${selectionFingerprintObjSrc("el", "ds")};
|
|
2721
2754
|
}
|
|
2722
2755
|
return out;
|
|
2723
2756
|
})()`;
|
|
@@ -2793,6 +2826,80 @@ async function readElementSelectionFingerprint(target, selector) {
|
|
|
2793
2826
|
return null;
|
|
2794
2827
|
}
|
|
2795
2828
|
}
|
|
2829
|
+
/**
|
|
2830
|
+
* How far up from the clicked leaf {@link selectionAncestorChanged} walks
|
|
2831
|
+
* looking for the option/toggle that carries the selection. Design-system
|
|
2832
|
+
* options nest their label 1-2 levels deep (a `<span title>` inside a
|
|
2833
|
+
* `role="option"`, plus the odd icon/wrapper); 6 matches the vacuous-click
|
|
2834
|
+
* ancestor guard in {@link verifyDomEffect}'s click branch and covers that
|
|
2835
|
+
* nesting without over-reaching into an outer listbox/group.
|
|
2836
|
+
*/
|
|
2837
|
+
const MAX_SELECTION_ANCESTOR_DEPTH = 6;
|
|
2838
|
+
/**
|
|
2839
|
+
* Element-scoped selection read-back for the case the clicked node's OWN
|
|
2840
|
+
* fingerprint can't credit: a design-system option that wraps its label in a
|
|
2841
|
+
* child element (Base Web `tag`, and the standard listbox/combobox idiom where
|
|
2842
|
+
* an option's accessible name comes from its descendant content). Stagehand
|
|
2843
|
+
* resolves the click to the label leaf, but `aria-selected` / the hashed class
|
|
2844
|
+
* flips on the ancestor `role="option"` — so the leaf has no baseline entry and
|
|
2845
|
+
* never changes state, and the leaf-only read-back mis-scores a genuine
|
|
2846
|
+
* selection as a phantom click.
|
|
2847
|
+
*
|
|
2848
|
+
* Walks from the leaf up to {@link MAX_SELECTION_ANCESTOR_DEPTH}, and on the
|
|
2849
|
+
* NEAREST ancestor that (a) carries a selection marker (a non-empty fingerprint
|
|
2850
|
+
* field, a selection `role`, or a `data-baseweb` attribute — `aria-expanded` is
|
|
2851
|
+
* deliberately NOT a marker so a bare disclosure/expander is skipped) AND (b) is
|
|
2852
|
+
* present in the pre-baseline map, diffs that ancestor's current fingerprint
|
|
2853
|
+
* against its baseline. Nearest-wins and returns even when unchanged, so a
|
|
2854
|
+
* re-click of an already-selected option (or a decoy marker on an outer group)
|
|
2855
|
+
* can never be laundered into a credit. Authoritative and element-scoped: only
|
|
2856
|
+
* baseline-keyed ancestors are consulted, so an unrelated element's change can
|
|
2857
|
+
* never credit the step. Recomputes ancestor xpaths with the SAME
|
|
2858
|
+
* {@link XPATH_OF_FN_SRC} walk that built the baseline so the keys byte-match.
|
|
2859
|
+
* Returns `false` on any miss / malformed result / evaluate throw (defer to the
|
|
2860
|
+
* network/URL signal).
|
|
2861
|
+
*/
|
|
2862
|
+
async function selectionAncestorChanged(target, leafXpath, preSelectionState) {
|
|
2863
|
+
const expr = `(() => {
|
|
2864
|
+
const LEAF = ${JSON.stringify(leafXpath)};
|
|
2865
|
+
const BASE = ${JSON.stringify(preSelectionState)};
|
|
2866
|
+
const xpathOf = ${XPATH_OF_FN_SRC};
|
|
2867
|
+
const fp = (el) => { const ds = el.getAttribute("data-state") || ""; return ${selectionFingerprintObjSrc("el", "ds")}; };
|
|
2868
|
+
const SELECTION_ROLES = new Set(["option", "tab", "switch", "radio", "checkbox", "menuitemcheckbox"]);
|
|
2869
|
+
const hasMarker = (el, f) => {
|
|
2870
|
+
if (f.kind || f.ariaPressed || f.ariaChecked || f.ariaSelected || f.dataState || f.dataSelected || f.dataChecked || f.checked) return true;
|
|
2871
|
+
if (SELECTION_ROLES.has((el.getAttribute("role") || "").toLowerCase())) return true;
|
|
2872
|
+
if (el.hasAttribute("data-baseweb")) return true;
|
|
2873
|
+
return false;
|
|
2874
|
+
};
|
|
2875
|
+
const changed = (a, b) =>
|
|
2876
|
+
a.kind !== b.kind || a.cls !== b.cls || a.ariaPressed !== b.ariaPressed ||
|
|
2877
|
+
a.ariaChecked !== b.ariaChecked || a.ariaSelected !== b.ariaSelected ||
|
|
2878
|
+
a.dataState !== b.dataState || a.dataSelected !== b.dataSelected ||
|
|
2879
|
+
a.dataChecked !== b.dataChecked || a.checked !== b.checked || a.value !== b.value;
|
|
2880
|
+
const r = document.evaluate(LEAF, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
2881
|
+
let node = r.singleNodeValue;
|
|
2882
|
+
if (!node) return false;
|
|
2883
|
+
for (let depth = 0; depth < ${MAX_SELECTION_ANCESTOR_DEPTH} && node; depth++) {
|
|
2884
|
+
if (node.getAttribute) {
|
|
2885
|
+
const now = fp(node);
|
|
2886
|
+
if (hasMarker(node, now)) {
|
|
2887
|
+
const pre = BASE[xpathOf(node)];
|
|
2888
|
+
if (!pre) return false;
|
|
2889
|
+
return changed(pre, now);
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
node = node.parentElement;
|
|
2893
|
+
}
|
|
2894
|
+
return false;
|
|
2895
|
+
})()`;
|
|
2896
|
+
try {
|
|
2897
|
+
return (await target.evaluate(expr)) === true;
|
|
2898
|
+
}
|
|
2899
|
+
catch {
|
|
2900
|
+
return false;
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2796
2903
|
/** How long the upload primitive waits for a post-setInputFiles network POST. */
|
|
2797
2904
|
const UPLOAD_NETWORK_TIMEOUT_MS = 5_000;
|
|
2798
2905
|
/** Polling interval while waiting for the upload's network signal. */
|
|
@@ -3400,6 +3507,49 @@ function parseRadioStep(instruction) {
|
|
|
3400
3507
|
const questionLabel = quoted.find((q) => q.trim() !== option)?.trim() ?? null;
|
|
3401
3508
|
return { option, questionLabel };
|
|
3402
3509
|
}
|
|
3510
|
+
/**
|
|
3511
|
+
* Parse a CHECKBOX flow step into the label of the checkbox it targets.
|
|
3512
|
+
*
|
|
3513
|
+
* Why this exists (sibling of `parseSelectStep`/`parseFillStep`): checkbox
|
|
3514
|
+
* steps are phrased as "Check the '…' checkbox" or "Click the '…' checkbox"
|
|
3515
|
+
* (see `src/recon/fixtures/shipped-ats-flow-steps.json`), neither of which
|
|
3516
|
+
* `parseSelectStep` recognizes (no "select" verb) and neither of which
|
|
3517
|
+
* `parseRadioStep` recognizes (no "answer"/"radio" noun) — so a checkbox
|
|
3518
|
+
* step's label was previously unextractable by any parser.
|
|
3519
|
+
*
|
|
3520
|
+
* Recognizes: "Check the 'Nights' workshift checkbox", "Click the 'No'
|
|
3521
|
+
* checkbox for 'Please indicate if you are Hispanic or Latino'". Returns
|
|
3522
|
+
* null when the step isn't checkbox-shaped or has no quoted label.
|
|
3523
|
+
*/
|
|
3524
|
+
function parseCheckStep(instruction) {
|
|
3525
|
+
const lower = instruction.toLowerCase();
|
|
3526
|
+
if (/\bany\s+remaining\b/.test(lower))
|
|
3527
|
+
return null;
|
|
3528
|
+
const match = instruction.match(/\b(?:check|click)\s+(?:the\s+)?'([^']+)'.*?\bcheckbox\b/i);
|
|
3529
|
+
const label = match?.[1]?.trim();
|
|
3530
|
+
return label ? { label } : null;
|
|
3531
|
+
}
|
|
3532
|
+
/** Strips wrapping single quotes a parser's capture group may include (e.g. when a flow author quotes the field name itself), so a probe label compares plain text against plain text. */
|
|
3533
|
+
function stripQuotedLabel(label) {
|
|
3534
|
+
return label.replace(/^'+|'+$/g, "").trim();
|
|
3535
|
+
}
|
|
3536
|
+
/**
|
|
3537
|
+
* Extracts the DOM label {@link hasUnfilledRequiredControlForStep}'s probe
|
|
3538
|
+
* should match against, widened beyond `parseSelectStep`'s literal "select"
|
|
3539
|
+
* verb (which never matches fill or checkbox phrasing) to also try
|
|
3540
|
+
* `parseFillStep` and `parseCheckStep` — see that function's docblock for
|
|
3541
|
+
* why generalizing the label source, not just the select-step case, matters.
|
|
3542
|
+
*/
|
|
3543
|
+
function extractRequiredControlProbeLabel(instruction) {
|
|
3544
|
+
const selectLabel = parseSelectStep(instruction)?.questionLabel;
|
|
3545
|
+
if (selectLabel)
|
|
3546
|
+
return selectLabel;
|
|
3547
|
+
const fillLabel = parseFillStep(instruction)?.fieldLabel;
|
|
3548
|
+
if (fillLabel)
|
|
3549
|
+
return stripQuotedLabel(fillLabel);
|
|
3550
|
+
const checkLabel = parseCheckStep(instruction)?.label;
|
|
3551
|
+
return checkLabel ? stripQuotedLabel(checkLabel) : null;
|
|
3552
|
+
}
|
|
3403
3553
|
function resolveDeepLocatorActuation(step) {
|
|
3404
3554
|
const selectParsed = parseSelectStep(step);
|
|
3405
3555
|
if (selectParsed)
|
|
@@ -4513,14 +4663,15 @@ async function applyRadioSelection(target, gi, ri, hint) {
|
|
|
4513
4663
|
* should fall through to the cascade/replan instead.
|
|
4514
4664
|
*
|
|
4515
4665
|
* Conservative by construction: returns false unless the step parses as a
|
|
4516
|
-
* select/
|
|
4517
|
-
*
|
|
4518
|
-
* step (e.g. "dismiss modal" on
|
|
4519
|
-
* fast-skip the comments call
|
|
4666
|
+
* select/fill/check step (via {@link extractRequiredControlProbeLabel}) AND a
|
|
4667
|
+
* required-and-unsatisfied control with a label matching that target is
|
|
4668
|
+
* actually present. A genuinely-absent optional step (e.g. "dismiss modal" on
|
|
4669
|
+
* a modal-less page) has no such control, so the fast-skip the comments call
|
|
4670
|
+
* essential is preserved.
|
|
4520
4671
|
*/
|
|
4521
4672
|
async function hasUnfilledRequiredControlForStep(target, instruction) {
|
|
4522
|
-
const
|
|
4523
|
-
if (!
|
|
4673
|
+
const label = extractRequiredControlProbeLabel(instruction);
|
|
4674
|
+
if (!label)
|
|
4524
4675
|
return false;
|
|
4525
4676
|
const expr = `((label) => {
|
|
4526
4677
|
const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
|
|
@@ -4565,7 +4716,7 @@ async function hasUnfilledRequiredControlForStep(target, instruction) {
|
|
|
4565
4716
|
}
|
|
4566
4717
|
}
|
|
4567
4718
|
return false;
|
|
4568
|
-
})(${JSON.stringify(
|
|
4719
|
+
})(${JSON.stringify(label)})`;
|
|
4569
4720
|
try {
|
|
4570
4721
|
return (await target.evaluate(expr)) === true;
|
|
4571
4722
|
}
|
|
@@ -4891,17 +5042,22 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
|
|
|
4891
5042
|
// same absolute positional xpath Stagehand resolves to, so a lookup
|
|
4892
5043
|
// by the resolved element's xpath body yields THAT element's baseline.
|
|
4893
5044
|
// Credit the click iff the element's own committed state moved across
|
|
4894
|
-
// it.
|
|
4895
|
-
//
|
|
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.
|
|
5045
|
+
// it. A selection change on any OTHER element can never credit this
|
|
5046
|
+
// step.
|
|
4898
5047
|
const preFingerprint = preSelectionState[xpath];
|
|
4899
|
-
if (
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
5048
|
+
if (preFingerprint) {
|
|
5049
|
+
const postFingerprint = await readElementSelectionFingerprint(target, selector);
|
|
5050
|
+
if (postFingerprint && selectionFingerprintChanged(preFingerprint, postFingerprint)) {
|
|
5051
|
+
return true;
|
|
5052
|
+
}
|
|
5053
|
+
}
|
|
5054
|
+
// The clicked node's own state didn't credit it (no baseline for the
|
|
5055
|
+
// leaf, or the leaf carries no selection state). A design-system
|
|
5056
|
+
// option that wraps its label in a child element commits its
|
|
5057
|
+
// selection on the ancestor `role="option"`, not the clicked leaf —
|
|
5058
|
+
// so walk up to the nearest baseline-present selection ancestor and
|
|
5059
|
+
// diff THAT. No eligible ancestor → false (defer to network/URL).
|
|
5060
|
+
return await selectionAncestorChanged(target, xpath, preSelectionState);
|
|
4905
5061
|
}
|
|
4906
5062
|
const isCheckedNow = await locator.isChecked();
|
|
4907
5063
|
if (!isCheckedNow)
|
|
@@ -5897,6 +6053,12 @@ async function executeStepWithHealing(params) {
|
|
|
5897
6053
|
// disk for entries indexed after this point, which is eviction-proof when
|
|
5898
6054
|
// >RECENT_CAPTURES_WINDOW captures flood during the step.
|
|
5899
6055
|
const preCaptureIdx = latestCaptureIndex(recentCaptures);
|
|
6056
|
+
// Pre-click ng-invalid baseline for the view-swap veto below
|
|
6057
|
+
// (isClickViewSwapVerified's invalidMarkerDelta). Read unconditionally —
|
|
6058
|
+
// cheap DOM query, and unlike `preSubmitInvalidCount` (final/submit-only)
|
|
6059
|
+
// this must cover ANY click step: the reported false positive was an
|
|
6060
|
+
// interior "Create Account" click, not the flow's final step.
|
|
6061
|
+
const preInvalidMarkerCount = await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).catch(() => 0);
|
|
5900
6062
|
const record = {
|
|
5901
6063
|
attempt,
|
|
5902
6064
|
technique: "act-string",
|
|
@@ -6809,13 +6971,24 @@ async function executeStepWithHealing(params) {
|
|
|
6809
6971
|
if (domVerified && !domVerifiedForStep) {
|
|
6810
6972
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} advance step succeeded only via DOM state change (field toggle / non-advancing POST), not a real transition; not treating as verified`);
|
|
6811
6973
|
}
|
|
6974
|
+
// Blocked-submit veto input for the view-swap gate below. Read post-click
|
|
6975
|
+
// ng-invalid count only for a resolved click — the veto is meaningless
|
|
6976
|
+
// for fill/select attempts, and this avoids a page.evaluate on every
|
|
6977
|
+
// non-click attempt. See recon-viewswap-false-positive-on-blocked-form-submit.md:
|
|
6978
|
+
// a "Create Account" click blocked by required-field validation grew the
|
|
6979
|
+
// DOM (inline error text) with network=false url=false, which the reveal
|
|
6980
|
+
// branch below alone would credit as verified.
|
|
6981
|
+
const postInvalidMarkerCount = isClick
|
|
6982
|
+
? await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).catch(() => 0)
|
|
6983
|
+
: preInvalidMarkerCount;
|
|
6812
6984
|
// Client-side view-swap gate: credit a click that produces substantial
|
|
6813
6985
|
// DOM growth (≥5KB) with zero network when it's NOT a submit/final step
|
|
6814
6986
|
// and NOT an advance-pattern step. Fixes the top-window site "Manual Application"
|
|
6815
6987
|
// case where a +49KB DOM-only view swap was scored as "no observable effect".
|
|
6816
6988
|
// Below that threshold, also credits a smaller text-changing reveal (≥500B) —
|
|
6817
6989
|
// fixes the top-window site Work-History gate-message reveal (+789B) that used to
|
|
6818
|
-
// cascade to a 5-attempt failure and global replan.
|
|
6990
|
+
// cascade to a 5-attempt failure and global replan. Vetoed when the click's
|
|
6991
|
+
// ng-invalid marker count grew (see isClickViewSwapVerified's doc comment).
|
|
6819
6992
|
const clickViewSwapVerified = isClickViewSwapVerified({
|
|
6820
6993
|
resolvedAction,
|
|
6821
6994
|
isFinalStep,
|
|
@@ -6824,7 +6997,16 @@ async function executeStepWithHealing(params) {
|
|
|
6824
6997
|
networkDelta: post.networkCount - pre.networkCount,
|
|
6825
6998
|
bytesDelta: post.bodyHtmlLength - pre.bodyHtmlLength,
|
|
6826
6999
|
textChanged: post.visibleTextSignature !== pre.visibleTextSignature,
|
|
7000
|
+
invalidMarkerDelta: postInvalidMarkerCount - preInvalidMarkerCount,
|
|
6827
7001
|
});
|
|
7002
|
+
if (clickViewSwapVerified === false &&
|
|
7003
|
+
isClick &&
|
|
7004
|
+
postInvalidMarkerCount > preInvalidMarkerCount &&
|
|
7005
|
+
post.networkCount - pre.networkCount === 0 &&
|
|
7006
|
+
post.url === pre.url &&
|
|
7007
|
+
post.bodyHtmlLength - pre.bodyHtmlLength >= config_1.config.scraper.viewSwapRevealMinBytesThreshold) {
|
|
7008
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} click grew the DOM (view-swap shape) but ng-invalid markers grew from ${preInvalidMarkerCount} to ${postInvalidMarkerCount} — a blocked form submit, not a real view transition; not treating as verified`);
|
|
7009
|
+
}
|
|
6828
7010
|
// A network-free selection/option toggle — including a Base Web `kind`
|
|
6829
7011
|
// flip or hashed-class swap that exposes no aria/data-state marker and
|
|
6830
7012
|
// moves a trivial or NEGATIVE byte delta — is now credited authoritatively
|
|
@@ -7128,13 +7310,23 @@ async function executeStepWithHealing(params) {
|
|
|
7128
7310
|
// or suppress when the element's own state is read directly.
|
|
7129
7311
|
const retrySelectionStateChanged = !isAdvanceStep(step) &&
|
|
7130
7312
|
(await (async () => {
|
|
7131
|
-
|
|
7132
|
-
if (!preFingerprint || !resolvedAction?.selector)
|
|
7313
|
+
if (!xpath || !resolvedAction?.selector)
|
|
7133
7314
|
return false;
|
|
7134
|
-
const
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7315
|
+
const preFingerprint = pre.selectionStateByXpath[xpath];
|
|
7316
|
+
if (preFingerprint) {
|
|
7317
|
+
const postFingerprint = await readElementSelectionFingerprint(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector);
|
|
7318
|
+
if (postFingerprint !== null &&
|
|
7319
|
+
selectionFingerprintChanged(preFingerprint, postFingerprint)) {
|
|
7320
|
+
return true;
|
|
7321
|
+
}
|
|
7322
|
+
}
|
|
7323
|
+
// Leaf had no baseline entry (or carried no selection state): a
|
|
7324
|
+
// design-system option that wraps its label commits its selection
|
|
7325
|
+
// on the ancestor `role="option"`, not the clicked leaf — walk up
|
|
7326
|
+
// to the nearest baseline-present selection ancestor and diff THAT,
|
|
7327
|
+
// the same fallback `verifyDomEffect`'s primary read-back uses. No
|
|
7328
|
+
// eligible ancestor → false (defer to the other retry signals).
|
|
7329
|
+
return await selectionAncestorChanged(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), xpath, pre.selectionStateByXpath);
|
|
7138
7330
|
})());
|
|
7139
7331
|
// RC2: an advance/`kind=click` "Next" that only grew the DOM
|
|
7140
7332
|
// (validation errors rendered) with NO network/URL change is a
|