@enricai/barnacle 1.12.72 → 1.12.74
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/browser-click-expr.d.ts +68 -0
- package/dist/scraper/browser-click-expr.d.ts.map +1 -1
- package/dist/scraper/browser-click-expr.js +121 -1
- package/dist/scraper/browser-click-expr.js.map +1 -1
- package/dist/scraper/flow-runner.d.ts +62 -6
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +225 -51
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scraper/phantom-click.d.ts +8 -0
- package/dist/scraper/phantom-click.d.ts.map +1 -1
- package/dist/scraper/phantom-click.js +21 -1
- package/dist/scraper/phantom-click.js.map +1 -1
- package/dist/scraper/submit-control.d.ts.map +1 -1
- package/dist/scraper/submit-control.js +2 -19
- package/dist/scraper/submit-control.js.map +1 -1
- package/dist/scripts/recon-browser.d.ts +10 -2
- package/dist/scripts/recon-browser.d.ts.map +1 -1
- package/dist/scripts/recon-browser.js +93 -27
- 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 +65 -13
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +1 -1
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
* the terminal failure dump.
|
|
15
15
|
*/
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.TRAILING_GRACE_WINDOW = exports.STEP_WATCHDOG_MS = exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR = exports.GOTO_TIMEOUT_MS = exports.waitForSpaReady = void 0;
|
|
17
|
+
exports.hasOriginOrPathChanged = exports.TRAILING_GRACE_WINDOW = exports.STEP_WATCHDOG_MS = exports.OPENER_PAIRED_HIDDEN_SELECT_EL_EXPR = exports.GOTO_TIMEOUT_MS = exports.waitForSpaReady = void 0;
|
|
18
18
|
exports.mergeResponseHeaders = mergeResponseHeaders;
|
|
19
19
|
exports.wireSignalCapture = wireSignalCapture;
|
|
20
20
|
exports.shouldRetryCaptchaRegistry = shouldRetryCaptchaRegistry;
|
|
21
21
|
exports.prepareFailureDumpBody = prepareFailureDumpBody;
|
|
22
|
+
exports.fieldValuesAtFailureExpr = fieldValuesAtFailureExpr;
|
|
22
23
|
exports.snapshotPage = snapshotPage;
|
|
23
24
|
exports.resolveDumpPageIdentity = resolveDumpPageIdentity;
|
|
24
25
|
exports.findRecentBackendError = findRecentBackendError;
|
|
@@ -76,7 +77,6 @@ exports.parseWidgetOptionClickStep = parseWidgetOptionClickStep;
|
|
|
76
77
|
exports.parseCheckStep = parseCheckStep;
|
|
77
78
|
exports.pollEnumerate = pollEnumerate;
|
|
78
79
|
exports.waitForTransitionBody = waitForTransitionBody;
|
|
79
|
-
exports.hasOriginOrPathChanged = hasOriginOrPathChanged;
|
|
80
80
|
exports.injectCaptchaTokenAndSubmit = injectCaptchaTokenAndSubmit;
|
|
81
81
|
exports.submitCaptchaGatedForm = submitCaptchaGatedForm;
|
|
82
82
|
exports.trySelectPrimitive = trySelectPrimitive;
|
|
@@ -88,6 +88,7 @@ exports.hasUnfilledRequiredControlForStep = hasUnfilledRequiredControlForStep;
|
|
|
88
88
|
exports.simulateDragDropUpload = simulateDragDropUpload;
|
|
89
89
|
exports.dispatchJqueryChangeEvent = dispatchJqueryChangeEvent;
|
|
90
90
|
exports.verifyDomEffect = verifyDomEffect;
|
|
91
|
+
exports.formValidityProbeExpr = formValidityProbeExpr;
|
|
91
92
|
exports.narrowInvalidFormControl = narrowInvalidFormControl;
|
|
92
93
|
exports.formatStepPrefix = formatStepPrefix;
|
|
93
94
|
exports.tryDeterministicFieldLabelActuation = tryDeterministicFieldLabelActuation;
|
|
@@ -952,6 +953,55 @@ function prepareFailureDumpBody(raw) {
|
|
|
952
953
|
* agree on exactly what counts as a class-token selection marker.
|
|
953
954
|
*/
|
|
954
955
|
const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { html: 0, text: "", values: "", state: "" }; const t = b.innerText || ""; const controls = Array.from(b.querySelectorAll("input, textarea, select")).filter((el) => el.offsetParent !== null); const values = controls.map((el) => { if (el.type === "checkbox" || el.type === "radio") return el.checked ? "1" : "0"; return el.value || ""; }).join("|").slice(0, 2000); const ariaSel = "[aria-pressed],[aria-checked],[aria-selected],[data-state],[data-selected],[data-checked],[role=option],[role=switch],[role=checkbox],[role=menuitemcheckbox]"; const classSel = ${JSON.stringify(browser_click_expr_1.SELECTION_MARKER_CLASS_SELECTOR_SRC)}; const classRx = ${browser_click_expr_1.SELECTION_MARKER_CLASS_TOKEN_REGEX_SRC}; const skip = (el) => el.closest("[role=dialog],[role=tooltip],[aria-live]") !== null; const seen = new Set(); for (const el of b.querySelectorAll(ariaSel + "," + classSel)) { if (el.offsetParent !== null && !skip(el)) seen.add(el); } let idx = 0; const parts = []; for (const el of seen) { const ap = el.getAttribute("aria-pressed") || ""; const ac = el.getAttribute("aria-checked") || ""; const as = el.getAttribute("aria-selected") || ""; const dsRaw = el.getAttribute("data-state") || ""; const ds = (dsRaw === "open" || dsRaw === "closed") ? "" : dsRaw; const dsel = el.hasAttribute("data-selected") ? "1" : ""; const dchk = el.hasAttribute("data-checked") ? "1" : ""; const clsHit = classRx.test(el.getAttribute("class") || "") ? "1" : "0"; const i = idx++; if (!ap && !ac && !as && !ds && !dsel && !dchk && clsHit === "0") continue; parts.push(i + ":" + ap + "," + ac + "," + as + "," + ds + "," + dsel + "," + dchk + "," + clsHit); } const joined = parts.join("|"); let h = 2166136261; for (let k = 0; k < joined.length; k++) { h ^= joined.charCodeAt(k); h = Math.imul(h, 16777619); } const state = (h >>> 0).toString(36) + ":" + parts.length; return { html: (b.outerHTML || "").length, text: t.length + ":" + t.slice(0, 200), values, state }; })()`;
|
|
956
|
+
/** Caps mirroring {@link DOM_SNAPSHOT_EXPR}'s truncation so a pathological page can't bloat the failure bundle. */
|
|
957
|
+
const FIELD_VALUES_AT_FAILURE_MAX_CONTROLS = 200;
|
|
958
|
+
const FIELD_VALUES_AT_FAILURE_MAX_VALUE_LENGTH = 500;
|
|
959
|
+
/**
|
|
960
|
+
* Browser-side expression source returning `{label, value}[]` for every
|
|
961
|
+
* visible input/textarea/select, read via the live `.value`/`.checked`
|
|
962
|
+
* property rather than the serialized attribute. Exists because every
|
|
963
|
+
* DOM-direct fill path sets a control's value with `el.value = value` (the
|
|
964
|
+
* JS property), which is never reflected back into the `value=` content
|
|
965
|
+
* attribute `document.body.outerHTML` serializes — so a reparsed HTML
|
|
966
|
+
* snapshot can structurally never prove what a text field currently holds.
|
|
967
|
+
* The accessible-name resolution (`aria-labelledby`, then `aria-label`, then
|
|
968
|
+
* `label[for]`/wrapping `label`, then `name`/`id`/`placeholder`) mirrors
|
|
969
|
+
* `recon-browser.ts`'s `accessibleNameForControl` so the label a step
|
|
970
|
+
* targets and the label this capture keys on agree.
|
|
971
|
+
*/
|
|
972
|
+
function fieldValuesAtFailureExpr() {
|
|
973
|
+
return `(() => {
|
|
974
|
+
const b = document.body;
|
|
975
|
+
if (!b) return [];
|
|
976
|
+
const accessibleName = (el) => {
|
|
977
|
+
const labelledBy = el.getAttribute("aria-labelledby");
|
|
978
|
+
if (labelledBy) {
|
|
979
|
+
const text = labelledBy.split(/\\s+/).map((id) => document.getElementById(id)?.textContent ?? "").join(" ").trim();
|
|
980
|
+
if (text) return text;
|
|
981
|
+
}
|
|
982
|
+
const ariaLabel = el.getAttribute("aria-label");
|
|
983
|
+
if (ariaLabel && ariaLabel.trim()) return ariaLabel;
|
|
984
|
+
const id = el.getAttribute("id");
|
|
985
|
+
if (id) {
|
|
986
|
+
const label = document.querySelector("label[for=\\"" + CSS.escape(id) + "\\"]");
|
|
987
|
+
if (label && label.textContent && label.textContent.trim()) return label.textContent;
|
|
988
|
+
}
|
|
989
|
+
const wrappingLabel = el.closest("label");
|
|
990
|
+
if (wrappingLabel && wrappingLabel.textContent && wrappingLabel.textContent.trim()) return wrappingLabel.textContent;
|
|
991
|
+
return el.getAttribute("name") || id || el.getAttribute("placeholder") || "";
|
|
992
|
+
};
|
|
993
|
+
const controls = Array.from(b.querySelectorAll("input, textarea, select")).filter((el) => el.offsetParent !== null);
|
|
994
|
+
const out = [];
|
|
995
|
+
for (const el of controls) {
|
|
996
|
+
if (out.length >= ${FIELD_VALUES_AT_FAILURE_MAX_CONTROLS}) break;
|
|
997
|
+
const label = accessibleName(el).trim();
|
|
998
|
+
if (!label) continue;
|
|
999
|
+
const value = (el.type === "checkbox" || el.type === "radio") ? (el.checked ? "1" : "0") : (el.value || "");
|
|
1000
|
+
out.push({ label: label.slice(0, 200), value: String(value).slice(0, ${FIELD_VALUES_AT_FAILURE_MAX_VALUE_LENGTH}) });
|
|
1001
|
+
}
|
|
1002
|
+
return out;
|
|
1003
|
+
})()`;
|
|
1004
|
+
}
|
|
955
1005
|
/**
|
|
956
1006
|
* Captures the pre/post signal triple the submit-verify cascade diffs.
|
|
957
1007
|
* Accepts the optional `page` so a resolved child `FrameTarget` whose
|
|
@@ -3375,17 +3425,39 @@ function elementSelectionFingerprintExpr(xpath) {
|
|
|
3375
3425
|
* In-page source for `(el) => Element | null`: the nearest ancestor of `el`,
|
|
3376
3426
|
* within {@link MAX_SELECTION_ANCESTOR_DEPTH} levels, whose subtree contains a
|
|
3377
3427
|
* selection-marker element (`role="combobox"`/`role="listbox"`, or a
|
|
3378
|
-
* {@link WIDGET_KIT_SELECTION_MARKER_SELECTORS} match)
|
|
3379
|
-
*
|
|
3380
|
-
*
|
|
3381
|
-
*
|
|
3382
|
-
*
|
|
3383
|
-
*
|
|
3384
|
-
*
|
|
3385
|
-
*
|
|
3386
|
-
*
|
|
3387
|
-
*
|
|
3388
|
-
*
|
|
3428
|
+
* {@link WIDGET_KIT_SELECTION_MARKER_SELECTORS} match) AND at most one
|
|
3429
|
+
* independent WIDGET ROOT (each `role="combobox"`/marker trigger is its own
|
|
3430
|
+
* root; a `role="listbox"` is a root TOO unless some trigger in the subtree
|
|
3431
|
+
* actually declares ownership of it via `aria-owns`/`aria-controls` — a
|
|
3432
|
+
* trigger merely co-existing in the same subtree does NOT make every listbox
|
|
3433
|
+
* its panel, since a real trigger plus an unrelated, un-owned bare listbox
|
|
3434
|
+
* (its own standalone multi-select, no opener) are still two independent
|
|
3435
|
+
* widgets, not one; with NO trigger anywhere in the subtree, each bare
|
|
3436
|
+
* `role="listbox"` is likewise its own standalone widget root, so two such
|
|
3437
|
+
* triggerless listboxes sharing an ancestor are still counted as two
|
|
3438
|
+
* independent roots rather than silently collapsing to zero — EXCEPT when
|
|
3439
|
+
* the scope holds exactly one trigger and exactly one listbox and neither
|
|
3440
|
+
* declares ownership of the other: that pairing is unambiguous regardless of
|
|
3441
|
+
* missing ARIA wiring, so it still counts as a single root) — climbing
|
|
3442
|
+
* ancestor levels rather than a single `closest()` call, because a design-system combobox's
|
|
3443
|
+
* marker (the `role="combobox"` trigger) and its committed-value control (a
|
|
3444
|
+
* hidden `<input>`) are commonly SIBLINGS under a shared wrapper that itself
|
|
3445
|
+
* carries no role/class marker: `closest()` only searches self-and-ancestors
|
|
3446
|
+
* of `el`, so it can never see a marker that lives on a cousin subtree. At
|
|
3447
|
+
* each level, `querySelectorAll` searches the WHOLE subtree (not just direct
|
|
3448
|
+
* children), so the marker may be nested arbitrarily deep under that
|
|
3449
|
+
* ancestor. The widget-root cap is deliberate: a candidate ancestor whose
|
|
3450
|
+
* subtree contains TWO OR MORE independent widget roots (e.g. two unrelated
|
|
3451
|
+
* toggle-shaped rows sharing a parent) is ambiguous about which widget —
|
|
3452
|
+
* and therefore which committed-value control — actually belongs to `el`, so
|
|
3453
|
+
* it is skipped rather than returned; climbing continues to look for an
|
|
3454
|
+
* unambiguous (single-widget) ancestor instead of exposing the whole
|
|
3455
|
+
* multi-widget subtree to the caller's input/select scan, which would let an
|
|
3456
|
+
* UNRELATED sibling widget's own state change masquerade as `el`'s. A single
|
|
3457
|
+
* trigger + its own owned listbox panel (one widget root) still passes.
|
|
3458
|
+
* Returns the FIRST (nearest) unambiguous qualifying ancestor, so a
|
|
3459
|
+
* same-shaped input/select outside that common ancestor is never mistaken for
|
|
3460
|
+
* the same control's committed-value sibling. Shared by
|
|
3389
3461
|
* {@link SELECTION_STATE_MAP_EXPR}'s `isCommittedValueControl` and
|
|
3390
3462
|
* {@link selectionSiblingCommittedValueChanged}'s container resolution so the
|
|
3391
3463
|
* baseline capture and the read-back agree on exactly what counts as
|
|
@@ -3395,9 +3467,42 @@ function elementSelectionFingerprintExpr(xpath) {
|
|
|
3395
3467
|
*/
|
|
3396
3468
|
const NEARBY_SELECTION_CONTAINER_FN_SRC = `(el) => {
|
|
3397
3469
|
const MARKER_SEL = '[role="combobox"],[role="listbox"],' + ${JSON.stringify(browser_click_expr_1.WIDGET_KIT_SELECTION_MARKER_SELECTORS)};
|
|
3470
|
+
const WIDGET_ROOT_SEL = '[role="combobox"],' + ${JSON.stringify(browser_click_expr_1.WIDGET_KIT_SELECTION_MARKER_SELECTORS)};
|
|
3471
|
+
const isOwnedListbox = (listbox, triggers) => {
|
|
3472
|
+
if (!listbox.id) return false;
|
|
3473
|
+
return triggers.some((trigger) => {
|
|
3474
|
+
const ownsId = trigger.getAttribute("aria-owns") || trigger.getAttribute("aria-controls");
|
|
3475
|
+
return ownsId === listbox.id;
|
|
3476
|
+
});
|
|
3477
|
+
};
|
|
3478
|
+
const countWidgetRoots = (scope) => {
|
|
3479
|
+
// Each combobox-like trigger is its own widget root. A role=listbox is
|
|
3480
|
+
// that widget's owned PANEL only when SOME trigger in scope actually
|
|
3481
|
+
// declares ownership of it via aria-owns/aria-controls — a trigger
|
|
3482
|
+
// merely co-existing in the same scope does not make every listbox its
|
|
3483
|
+
// panel, since a trigger plus an unrelated, un-owned bare listbox (its
|
|
3484
|
+
// own standalone multi-select, no opener) are still two independent
|
|
3485
|
+
// widgets. Any listbox left un-owned by every trigger — including when
|
|
3486
|
+
// there is no trigger in scope at all — counts as its own widget root.
|
|
3487
|
+
const triggers = Array.from(scope.querySelectorAll(WIDGET_ROOT_SEL));
|
|
3488
|
+
const listboxes = Array.from(scope.querySelectorAll('[role="listbox"]'));
|
|
3489
|
+
const unownedListboxes = listboxes.filter((lb) => !isOwnedListbox(lb, triggers));
|
|
3490
|
+
// A lone trigger next to a lone listbox, neither declaring aria-owns/
|
|
3491
|
+
// aria-controls, is still an unambiguous single widget (a real-world
|
|
3492
|
+
// combobox that never bothered to wire up ARIA ownership) — only
|
|
3493
|
+
// count it as two independent roots once there is more than one
|
|
3494
|
+
// trigger or more than one listbox in scope to actually be ambiguous
|
|
3495
|
+
// about pairing.
|
|
3496
|
+
if (triggers.length === 1 && listboxes.length === 1 && unownedListboxes.length === 1) {
|
|
3497
|
+
return 1;
|
|
3498
|
+
}
|
|
3499
|
+
return triggers.length + unownedListboxes.length;
|
|
3500
|
+
};
|
|
3398
3501
|
let node = el.parentElement;
|
|
3399
3502
|
for (let depth = 0; depth < ${browser_click_expr_1.MAX_SELECTION_ANCESTOR_DEPTH} && node; depth++) {
|
|
3400
|
-
if (node.querySelectorAll && node.querySelectorAll(MARKER_SEL).length > 0)
|
|
3503
|
+
if (node.querySelectorAll && node.querySelectorAll(MARKER_SEL).length > 0) {
|
|
3504
|
+
if (countWidgetRoots(node) <= 1) return node;
|
|
3505
|
+
}
|
|
3401
3506
|
node = node.parentElement;
|
|
3402
3507
|
}
|
|
3403
3508
|
return null;
|
|
@@ -4662,9 +4767,12 @@ function parseFieldLabelTarget(step) {
|
|
|
4662
4767
|
* Name"); otherwise a substring match either direction (a `fieldLabel` of
|
|
4663
4768
|
* "Acme Non-Employee ID" against an accessible name of "Non-Employee ID", or
|
|
4664
4769
|
* the reverse) so minor phrasing drift between the flow's field noun and the
|
|
4665
|
-
* control's own label still resolves
|
|
4666
|
-
*
|
|
4667
|
-
*
|
|
4770
|
+
* control's own label still resolves; when multiple candidates qualify via
|
|
4771
|
+
* substring, the one whose length is closest to `fieldLabel`'s (the most
|
|
4772
|
+
* specific match) wins, not whichever happened to come first. Returns
|
|
4773
|
+
* `null` — never a guess — when no candidate's accessible name relates to
|
|
4774
|
+
* `fieldLabel` at all, so the caller can refuse to act rather than
|
|
4775
|
+
* fill/click the wrong control.
|
|
4668
4776
|
*/
|
|
4669
4777
|
function findDeepLocatorCandidateByFieldLabel(candidates, fieldLabel) {
|
|
4670
4778
|
const normalizedLabel = normalizeFieldLabel(fieldLabel);
|
|
@@ -4676,8 +4784,15 @@ function findDeepLocatorCandidateByFieldLabel(candidates, fieldLabel) {
|
|
|
4676
4784
|
const exact = named.find((entry) => entry.text === normalizedLabel);
|
|
4677
4785
|
if (exact)
|
|
4678
4786
|
return exact.candidate;
|
|
4679
|
-
const
|
|
4680
|
-
|
|
4787
|
+
const partials = named.filter((entry) => entry.text.includes(normalizedLabel) || normalizedLabel.includes(entry.text));
|
|
4788
|
+
const mostSpecific = partials.reduce((closest, entry) => {
|
|
4789
|
+
if (!closest)
|
|
4790
|
+
return entry;
|
|
4791
|
+
const entryDistance = Math.abs(entry.text.length - normalizedLabel.length);
|
|
4792
|
+
const closestDistance = Math.abs(closest.text.length - normalizedLabel.length);
|
|
4793
|
+
return entryDistance < closestDistance ? entry : closest;
|
|
4794
|
+
}, null);
|
|
4795
|
+
return mostSpecific?.candidate ?? null;
|
|
4681
4796
|
}
|
|
4682
4797
|
/** Max settle-retry attempts for a primitive's DOM enumerate (see `pollEnumerate`). */
|
|
4683
4798
|
const PRIMITIVE_ENUMERATE_ATTEMPTS = 5;
|
|
@@ -4748,29 +4863,8 @@ async function waitForTransitionBody(params) {
|
|
|
4748
4863
|
}
|
|
4749
4864
|
return false;
|
|
4750
4865
|
}
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
* target's post-submit `url()` can be compared to its pre-submit baseline
|
|
4754
|
-
* without a same-page query-string change (e.g. a step counter) registering
|
|
4755
|
-
* as a false advance. Unparseable URLs compare equal only to themselves.
|
|
4756
|
-
*/
|
|
4757
|
-
function originAndPathOf(url) {
|
|
4758
|
-
try {
|
|
4759
|
-
const parsed = new URL(url);
|
|
4760
|
-
return `${parsed.origin}${parsed.pathname}`;
|
|
4761
|
-
}
|
|
4762
|
-
catch {
|
|
4763
|
-
return url;
|
|
4764
|
-
}
|
|
4765
|
-
}
|
|
4766
|
-
/**
|
|
4767
|
-
* Credits a captchaGated advance from an observed navigation: true only
|
|
4768
|
-
* when origin or pathname actually changed, so a same-page query-string
|
|
4769
|
-
* mutation (e.g. a step counter) never counts as an advance.
|
|
4770
|
-
*/
|
|
4771
|
-
function hasOriginOrPathChanged(preUrl, postUrl) {
|
|
4772
|
-
return originAndPathOf(preUrl) !== originAndPathOf(postUrl);
|
|
4773
|
-
}
|
|
4866
|
+
var phantom_click_2 = require("./phantom-click");
|
|
4867
|
+
Object.defineProperty(exports, "hasOriginOrPathChanged", { enumerable: true, get: function () { return phantom_click_2.hasOriginOrPathChanged; } });
|
|
4774
4868
|
/**
|
|
4775
4869
|
* Polls the captcha target's URL for an origin/path change after a
|
|
4776
4870
|
* multipart form submit, which navigates the page without producing any
|
|
@@ -4782,7 +4876,7 @@ async function waitForCaptchaNavigation(params) {
|
|
|
4782
4876
|
const { page, captchaTarget, baselineUrl, timeoutMs, intervalMs } = params;
|
|
4783
4877
|
const check = async () => {
|
|
4784
4878
|
const currentUrl = await (0, frame_target_1.readCurrentFrameUrl)(page, captchaTarget);
|
|
4785
|
-
return hasOriginOrPathChanged(baselineUrl, currentUrl);
|
|
4879
|
+
return (0, phantom_click_1.hasOriginOrPathChanged)(baselineUrl, currentUrl);
|
|
4786
4880
|
};
|
|
4787
4881
|
if (await check())
|
|
4788
4882
|
return true;
|
|
@@ -7269,11 +7363,21 @@ async function verifyDomEffect(target, action, preSelectionState = {}) {
|
|
|
7269
7363
|
* - select: pick the first non-empty option.
|
|
7270
7364
|
* Each auto-pick is recorded so the cascade can surface a self-heal
|
|
7271
7365
|
* warning ("you auto-picked X; consider adding an explicit step").
|
|
7366
|
+
* 3. NATIVE-VALIDITY FALLBACK — independent of the class-marker scan:
|
|
7367
|
+
* any `required` control whose native `validity.valueMissing` (or,
|
|
7368
|
+
* absent a `validity` object, an empty `.value`) is true gets flagged
|
|
7369
|
+
* even when no framework has applied an invalid-marker class yet
|
|
7370
|
+
* (e.g. a pristine, untouched required field).
|
|
7272
7371
|
*
|
|
7273
7372
|
* Label resolution checks (in order) the nearest `<label>`, `aria-label`,
|
|
7274
7373
|
* `data-id`, and `name`.
|
|
7374
|
+
*
|
|
7375
|
+
* Exported (mirroring {@link fieldValuesAtFailureExpr}) so the real
|
|
7376
|
+
* native-validity-fallback logic can be exercised against a genuine DOM in
|
|
7377
|
+
* unit tests, rather than only through a mocked `evaluate`.
|
|
7275
7378
|
*/
|
|
7276
|
-
|
|
7379
|
+
function formValidityProbeExpr() {
|
|
7380
|
+
return `(() => {
|
|
7277
7381
|
const INVALID_CLASS_RX = /(${INVALID_MARKER_CLASS_SOURCE})/;
|
|
7278
7382
|
const MARKERS = ["ng-invalid", "mat-form-field-invalid", "is-invalid", "field-invalid", "input-invalid", "Mui-error", "ng-touched", "ng-dirty"];
|
|
7279
7383
|
function fire(el, ev) {
|
|
@@ -7431,9 +7535,35 @@ const FORM_VALIDITY_PROBE_EXPR = `(() => {
|
|
|
7431
7535
|
}
|
|
7432
7536
|
out.push({ label, classSignature, emptyOrUnchecked, autoFilled, _el: el });
|
|
7433
7537
|
}
|
|
7538
|
+
// Native-validity fallback: the class-marker scan above is blind to a
|
|
7539
|
+
// required field that is genuinely empty but PRISTINE -- a framework
|
|
7540
|
+
// that never applies ng-invalid/Mui-error/etc. until after user
|
|
7541
|
+
// interaction, or one that doesn't use any of the listed conventions
|
|
7542
|
+
// at all. This pass is independent of INVALID_CLASS_RX and relies
|
|
7543
|
+
// solely on the native HTML5 constraint-validation API, so it catches
|
|
7544
|
+
// required-and-empty controls no CSS framework has marked invalid yet.
|
|
7545
|
+
const requiredEls = Array.from(document.querySelectorAll("input,select,textarea"));
|
|
7546
|
+
for (const ctrl of requiredEls) {
|
|
7547
|
+
if (!ctrl.required) continue;
|
|
7548
|
+
if (out.some((e) => e._el === ctrl || (e._el && e._el.contains(ctrl)))) continue;
|
|
7549
|
+
const valueMissing = ctrl.validity ? ctrl.validity.valueMissing : !ctrl.value;
|
|
7550
|
+
if (!valueMissing) continue;
|
|
7551
|
+
const byFor = ctrl.id && document.querySelector ? document.querySelector("label[for=\\"" + ctrl.id + "\\"]") : null;
|
|
7552
|
+
let label = byFor && byFor.textContent ? byFor.textContent.trim() : "";
|
|
7553
|
+
let scan = ctrl;
|
|
7554
|
+
for (let i = 0; i < 4 && scan && !label; i++) {
|
|
7555
|
+
const ownLabel = scan.closest ? scan.closest("label") : null;
|
|
7556
|
+
if (ownLabel && ownLabel.textContent) label = ownLabel.textContent.trim();
|
|
7557
|
+
scan = scan.parentElement;
|
|
7558
|
+
}
|
|
7559
|
+
if (!label) label = ctrl.getAttribute("aria-label") || ctrl.getAttribute("data-id") || ctrl.getAttribute("name") || ctrl.getAttribute("id") || "(unlabeled)";
|
|
7560
|
+
label = label.replace(/\\s+/g, " ").slice(0, 80);
|
|
7561
|
+
out.push({ label, classSignature: "", emptyOrUnchecked: true, autoFilled: null, _el: ctrl });
|
|
7562
|
+
}
|
|
7434
7563
|
// Strip the DOM reference before serialization.
|
|
7435
7564
|
return out.slice(0, 12).map((e) => ({ label: e.label, classSignature: e.classSignature, emptyOrUnchecked: e.emptyOrUnchecked, autoFilled: e.autoFilled }));
|
|
7436
7565
|
})()`;
|
|
7566
|
+
}
|
|
7437
7567
|
/**
|
|
7438
7568
|
* Runs ONLY on the cascade's final step when a submitEndpointPattern is
|
|
7439
7569
|
* declared. Surfaces ng-invalid form controls (and whether each is empty)
|
|
@@ -7502,7 +7632,7 @@ function formatStepPrefix(stepIndex, totalSteps) {
|
|
|
7502
7632
|
async function probeFormValidityBeforeSubmit(params) {
|
|
7503
7633
|
const { target, stepIndex, totalSteps, logger } = params;
|
|
7504
7634
|
try {
|
|
7505
|
-
const raw = await target.evaluate(
|
|
7635
|
+
const raw = await target.evaluate(formValidityProbeExpr());
|
|
7506
7636
|
if (!Array.isArray(raw))
|
|
7507
7637
|
return [];
|
|
7508
7638
|
const out = [];
|
|
@@ -8624,6 +8754,9 @@ async function executeStepWithHealing(params) {
|
|
|
8624
8754
|
.evaluate("document.body ? document.body.outerHTML : null")
|
|
8625
8755
|
.catch(() => null);
|
|
8626
8756
|
const bodyOuterHtml = prepareFailureDumpBody(bodyOuterHtmlRaw);
|
|
8757
|
+
const fieldValuesAtFailure = await (frameTarget ?? page)
|
|
8758
|
+
.evaluate(fieldValuesAtFailureExpr())
|
|
8759
|
+
.catch(() => null);
|
|
8627
8760
|
const probeAbsentObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
|
|
8628
8761
|
// observe() is blind to a cross-origin OOPIF, so a frame-scoped empty
|
|
8629
8762
|
// result degrades to the deep-locator resolver for dump evidence — this
|
|
@@ -8649,6 +8782,7 @@ async function executeStepWithHealing(params) {
|
|
|
8649
8782
|
bodyOuterHtml,
|
|
8650
8783
|
unfocusedObserve,
|
|
8651
8784
|
targetResolutionDiagnostic,
|
|
8785
|
+
fieldValuesAtFailure,
|
|
8652
8786
|
}) ?? null;
|
|
8653
8787
|
throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
|
|
8654
8788
|
}
|
|
@@ -9262,8 +9396,42 @@ async function executeStepWithHealing(params) {
|
|
|
9262
9396
|
continue;
|
|
9263
9397
|
}
|
|
9264
9398
|
const topWindowTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
|
|
9399
|
+
let clickedShadowDescendant = false;
|
|
9265
9400
|
try {
|
|
9266
|
-
|
|
9401
|
+
// `document.evaluate` (what the xpath-resolved locator below is
|
|
9402
|
+
// built on) cannot cross a shadow boundary, so on a custom-
|
|
9403
|
+
// element host whose real activation lives on an interior
|
|
9404
|
+
// shadow-DOM descendant, the xpath resolves to the host itself —
|
|
9405
|
+
// the phantomed target from attempt 1. Resolve into the host's
|
|
9406
|
+
// own (open) shadow root for the real interactive descendant and
|
|
9407
|
+
// stamp it with a throwaway marker attribute; a CSS attribute
|
|
9408
|
+
// selector (unlike xpath) pierces an open shadow root, so
|
|
9409
|
+
// re-locating via that marker and clicking delivers a genuinely
|
|
9410
|
+
// trusted click at the real target instead of the host's own
|
|
9411
|
+
// bounding box. No shadow root, or no qualifying descendant,
|
|
9412
|
+
// falls back unchanged to clicking the host via its xpath.
|
|
9413
|
+
const xpathBody = xpathBodyForEvaluate(topWindowSelector);
|
|
9414
|
+
const markerAttr = "data-barnacle-shadow-click-target";
|
|
9415
|
+
const markerValue = `t${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
|
9416
|
+
const markResult = xpathBody
|
|
9417
|
+
? await topWindowTarget
|
|
9418
|
+
.evaluate((0, browser_click_expr_1.buildMarkShadowInteractiveDescendantExpr)(xpathBody, markerAttr, markerValue))
|
|
9419
|
+
.catch(() => ({ found: false }))
|
|
9420
|
+
: { found: false };
|
|
9421
|
+
if (markResult.found) {
|
|
9422
|
+
try {
|
|
9423
|
+
await topWindowTarget.locator(`[${markerAttr}="${markerValue}"]`).first().click();
|
|
9424
|
+
clickedShadowDescendant = true;
|
|
9425
|
+
}
|
|
9426
|
+
finally {
|
|
9427
|
+
await topWindowTarget
|
|
9428
|
+
.evaluate((0, browser_click_expr_1.buildUnmarkShadowDescendantExpr)(xpathBody, markerAttr, markerValue))
|
|
9429
|
+
.catch(() => undefined);
|
|
9430
|
+
}
|
|
9431
|
+
}
|
|
9432
|
+
else {
|
|
9433
|
+
await topWindowTarget.locator(topWindowSelector).first().click();
|
|
9434
|
+
}
|
|
9267
9435
|
}
|
|
9268
9436
|
catch (err) {
|
|
9269
9437
|
const failureMessage = `trusted-click-retry: top-window trusted click threw ${(0, errors_1.toErrorMessage)(err)}`;
|
|
@@ -9275,11 +9443,13 @@ async function executeStepWithHealing(params) {
|
|
|
9275
9443
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
|
|
9276
9444
|
continue;
|
|
9277
9445
|
}
|
|
9278
|
-
record.instruction = `trusted-click-retry (top-window): ${topWindowSelector}`;
|
|
9446
|
+
record.instruction = `trusted-click-retry (top-window): ${topWindowSelector}${clickedShadowDescendant ? " (shadow descendant)" : ""}`;
|
|
9279
9447
|
record.actResultSuccess = true;
|
|
9280
|
-
record.actResultDescription =
|
|
9448
|
+
record.actResultDescription = clickedShadowDescendant
|
|
9449
|
+
? `trusted-click-retry clicked the shadow-DOM descendant of "${topWindowSelector}" via top-window locator`
|
|
9450
|
+
: `trusted-click-retry clicked "${topWindowSelector}" via top-window locator`;
|
|
9281
9451
|
record.triedSelectors = [...triedSelectors];
|
|
9282
|
-
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: trusted-click-retry: top-window trusted locator click on the resolved target`);
|
|
9452
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: trusted-click-retry: top-window trusted locator click on the resolved target${clickedShadowDescendant ? " (shadow descendant)" : ""}`);
|
|
9283
9453
|
resolvedAction = {
|
|
9284
9454
|
selector: topWindowSelector,
|
|
9285
9455
|
description: record.actResultDescription,
|
|
@@ -9827,7 +9997,7 @@ async function executeStepWithHealing(params) {
|
|
|
9827
9997
|
record.resolvedArguments = (resolvedAction.arguments ?? []).map(String);
|
|
9828
9998
|
}
|
|
9829
9999
|
const networkFired = post.networkCount > pre.networkCount;
|
|
9830
|
-
const urlChanged =
|
|
10000
|
+
const urlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, post.url);
|
|
9831
10001
|
const isStateClass = resolvedAction !== null && STATE_CLASS_METHODS.has(resolvedAction.method ?? "");
|
|
9832
10002
|
const isClick = resolvedAction !== null && resolvedAction.method === "click";
|
|
9833
10003
|
// State-class actions (fill/check/etc.) never move the network counter or URL,
|
|
@@ -10395,7 +10565,7 @@ async function executeStepWithHealing(params) {
|
|
|
10395
10565
|
await page.waitForTimeout(STEP_PAUSE_MS);
|
|
10396
10566
|
const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
|
|
10397
10567
|
const retryNetworkFired = retryPost.networkCount > pre.networkCount;
|
|
10398
|
-
const retryUrlChanged =
|
|
10568
|
+
const retryUrlChanged = (0, phantom_click_1.hasOriginOrPathChanged)(pre.url, retryPost.url);
|
|
10399
10569
|
const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
|
|
10400
10570
|
const retryTextChanged = retryPost.visibleTextSignature !== pre.visibleTextSignature;
|
|
10401
10571
|
const retryFormValueChanged = retryPost.formValueSignature !== pre.formValueSignature;
|
|
@@ -10930,6 +11100,9 @@ async function executeStepWithHealing(params) {
|
|
|
10930
11100
|
.evaluate("document.body ? document.body.outerHTML : null")
|
|
10931
11101
|
.catch(() => null);
|
|
10932
11102
|
const bodyOuterHtml = prepareFailureDumpBody(bodyOuterHtmlRaw);
|
|
11103
|
+
const fieldValuesAtFailure = await (frameTarget ?? (0, frame_target_1.mainFrameTarget)(page))
|
|
11104
|
+
.evaluate(fieldValuesAtFailureExpr())
|
|
11105
|
+
.catch(() => null);
|
|
10933
11106
|
const cascadeExhaustObservedUnfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
|
|
10934
11107
|
const unfocusedObserve = cascadeExhaustObservedUnfocused.length === 0 && frameTarget?.frame
|
|
10935
11108
|
? await deepLocatorCandidatesAsActions(page, frameTarget)
|
|
@@ -10949,6 +11122,7 @@ async function executeStepWithHealing(params) {
|
|
|
10949
11122
|
bodyOuterHtml,
|
|
10950
11123
|
unfocusedObserve,
|
|
10951
11124
|
targetResolutionDiagnostic,
|
|
11125
|
+
fieldValuesAtFailure,
|
|
10952
11126
|
}) ?? null;
|
|
10953
11127
|
if (dumpPath !== null) {
|
|
10954
11128
|
logger.error(`${formatStepPrefix(stepIndex, totalSteps)} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);
|