@enricai/barnacle 1.6.7 → 1.6.8
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/plugins/config-plugin.d.ts +1 -0
- package/dist/plugins/config-plugin.d.ts.map +1 -1
- package/dist/plugins/config-plugin.js +2 -0
- package/dist/plugins/config-plugin.js.map +1 -1
- package/dist/scraper/behavioral-signals.d.ts +10 -3
- package/dist/scraper/behavioral-signals.d.ts.map +1 -1
- package/dist/scraper/behavioral-signals.js +17 -6
- package/dist/scraper/behavioral-signals.js.map +1 -1
- package/dist/scraper/deep-query.d.ts +8 -0
- package/dist/scraper/deep-query.d.ts.map +1 -1
- package/dist/scraper/deep-query.js +9 -1
- package/dist/scraper/deep-query.js.map +1 -1
- package/dist/scraper/flow-runner.d.ts +156 -24
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +234 -130
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scraper/frame-target.d.ts +88 -0
- package/dist/scraper/frame-target.d.ts.map +1 -0
- package/dist/scraper/frame-target.js +164 -0
- package/dist/scraper/frame-target.js.map +1 -0
- package/dist/scraper/stagehand-guard.d.ts +31 -3
- package/dist/scraper/stagehand-guard.d.ts.map +1 -1
- package/dist/scraper/stagehand-guard.js +53 -7
- package/dist/scraper/stagehand-guard.js.map +1 -1
- package/dist/scraper/submit-control.d.ts +15 -2
- package/dist/scraper/submit-control.d.ts.map +1 -1
- package/dist/scraper/submit-control.js +17 -4
- package/dist/scraper/submit-control.js.map +1 -1
- package/dist/scripts/recon-browser.d.ts +80 -1
- package/dist/scripts/recon-browser.d.ts.map +1 -1
- package/dist/scripts/recon-browser.js +36 -8
- package/dist/scripts/recon-browser.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +6 -0
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +9 -7
- package/dist/scripts/recon-generate.js.map +1 -1
- package/package.json +1 -1
|
@@ -43,6 +43,7 @@ exports.renderUnfocusedObserve = renderUnfocusedObserve;
|
|
|
43
43
|
exports.pairInvalidWithErrors = pairInvalidWithErrors;
|
|
44
44
|
exports.formatValidationRejectedReason = formatValidationRejectedReason;
|
|
45
45
|
exports.selectBodyExcerpt = selectBodyExcerpt;
|
|
46
|
+
exports.extractLivePageFormEvidence = extractLivePageFormEvidence;
|
|
46
47
|
exports.probeLeafInvalidContainers = probeLeafInvalidContainers;
|
|
47
48
|
exports.renderLeafInvalidFields = renderLeafInvalidFields;
|
|
48
49
|
exports.extractSubmitFailureEvidence = extractSubmitFailureEvidence;
|
|
@@ -52,6 +53,8 @@ exports.fillHtml5DateTimeInput = fillHtml5DateTimeInput;
|
|
|
52
53
|
exports.verifyFillReadback = verifyFillReadback;
|
|
53
54
|
exports.isUploadAffordanceLabel = isUploadAffordanceLabel;
|
|
54
55
|
exports.writeFixtureToTempFile = writeFixtureToTempFile;
|
|
56
|
+
exports.attachToSurfacedInput = attachToSurfacedInput;
|
|
57
|
+
exports.surfaceAndUpload = surfaceAndUpload;
|
|
55
58
|
exports.parseSelectStep = parseSelectStep;
|
|
56
59
|
exports.parseRadioStep = parseRadioStep;
|
|
57
60
|
exports.pollEnumerate = pollEnumerate;
|
|
@@ -59,6 +62,9 @@ exports.waitForTransitionBody = waitForTransitionBody;
|
|
|
59
62
|
exports.chooseRequiredSelectOption = chooseRequiredSelectOption;
|
|
60
63
|
exports.buildRadioIdXPath = buildRadioIdXPath;
|
|
61
64
|
exports.selectRadioGroupOption = selectRadioGroupOption;
|
|
65
|
+
exports.simulateDragDropUpload = simulateDragDropUpload;
|
|
66
|
+
exports.dispatchJqueryChangeEvent = dispatchJqueryChangeEvent;
|
|
67
|
+
exports.verifyDomEffect = verifyDomEffect;
|
|
62
68
|
exports.narrowInvalidFormControl = narrowInvalidFormControl;
|
|
63
69
|
exports.formatStepPrefix = formatStepPrefix;
|
|
64
70
|
exports.probeStepBeforeAttempts = probeStepBeforeAttempts;
|
|
@@ -82,6 +88,7 @@ const logging_1 = require("../lib/logging");
|
|
|
82
88
|
const call_capture_1 = require("../lib/telemetry/call-capture");
|
|
83
89
|
const call_types_1 = require("../lib/telemetry/call-types");
|
|
84
90
|
const errors_2 = require("../scraper/errors");
|
|
91
|
+
const frame_target_1 = require("../scraper/frame-target");
|
|
85
92
|
const phantom_click_1 = require("../scraper/phantom-click");
|
|
86
93
|
const stagehand_guard_1 = require("../scraper/stagehand-guard");
|
|
87
94
|
const submit_control_1 = require("../scraper/submit-control");
|
|
@@ -467,11 +474,11 @@ exports.TRAILING_GRACE_WINDOW = 2;
|
|
|
467
474
|
* shape via Runtime.callFunctionOn.
|
|
468
475
|
*/
|
|
469
476
|
const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { html: 0, text: "" }; const t = b.innerText || ""; return { html: (b.outerHTML || "").length, text: t.length + ":" + t.slice(0, 200) }; })()`;
|
|
470
|
-
async function snapshotPage(
|
|
477
|
+
async function snapshotPage(target, signalCounter) {
|
|
471
478
|
let bodyHtmlLength = 0;
|
|
472
479
|
let visibleTextSignature = "";
|
|
473
480
|
try {
|
|
474
|
-
const result = await
|
|
481
|
+
const result = await target.evaluate(DOM_SNAPSHOT_EXPR);
|
|
475
482
|
if (result !== null &&
|
|
476
483
|
typeof result === "object" &&
|
|
477
484
|
"html" in result &&
|
|
@@ -488,7 +495,7 @@ async function snapshotPage(page, signalCounter) {
|
|
|
488
495
|
}
|
|
489
496
|
return {
|
|
490
497
|
networkCount: signalCounter.n,
|
|
491
|
-
url:
|
|
498
|
+
url: await target.url(),
|
|
492
499
|
bodyHtmlLength,
|
|
493
500
|
visibleTextSignature,
|
|
494
501
|
};
|
|
@@ -508,6 +515,18 @@ async function snapshotPage(page, signalCounter) {
|
|
|
508
515
|
* envelope shapes. New ATSs can be added by extending the recognized
|
|
509
516
|
* keys; the existing ones cover the four most common patterns.
|
|
510
517
|
*/
|
|
518
|
+
/**
|
|
519
|
+
* Reads the title/url pair for a step-failure dump, scoped to whichever
|
|
520
|
+
* frame the step actually ran against — so a triager sees the same frame's
|
|
521
|
+
* body HTML and url instead of pairing a child frame's DOM with the top
|
|
522
|
+
* document's url. `title()` intentionally still reads the top document for
|
|
523
|
+
* a child frame (see `frame-target.ts`); `url()` is the frame discriminator.
|
|
524
|
+
*/
|
|
525
|
+
async function resolveDumpPageIdentity(page, frameTarget) {
|
|
526
|
+
const pageTitle = await (frameTarget ?? page).title().catch(() => "");
|
|
527
|
+
const pageUrl = await (frameTarget ? frameTarget.url() : Promise.resolve(page.url())).catch(() => page.url());
|
|
528
|
+
return { pageTitle, pageUrl };
|
|
529
|
+
}
|
|
511
530
|
/**
|
|
512
531
|
* Detect whether the supplied capture-meta window contains a backend
|
|
513
532
|
* 5xx response that matches the configured submit endpoint pattern.
|
|
@@ -851,15 +870,18 @@ function isDomOnlyAdvanceVerified(params) {
|
|
|
851
870
|
return networkIsRealAdvance || urlChanged;
|
|
852
871
|
}
|
|
853
872
|
/**
|
|
854
|
-
* Read-only count of ng-invalid form controls on the
|
|
873
|
+
* Read-only count of ng-invalid form controls on the resolved frame. Side-effect-free
|
|
855
874
|
* counterpart to `probeFormValidityBeforeSubmit` (which also auto-fills
|
|
856
875
|
* unselected radio groups via element.click()). Used by the cascade's
|
|
857
876
|
* early-exit predicate to detect "the Submit click revealed new required
|
|
858
877
|
* questions" — when this count grows from 0 (pre-submit) to ≥1 (post-attempt-1),
|
|
859
878
|
* attempts 2-5 cannot succeed and the cascade should route to replan
|
|
860
|
-
* immediately instead of burning Stagehand calls.
|
|
879
|
+
* immediately instead of burning Stagehand calls. Accepts a `FrameTarget` so a
|
|
880
|
+
* wizard embedded in a cross-origin iframe (e.g. UCHealth's Talemetry form) is
|
|
881
|
+
* scanned on its own frame; a main-frame target delegates straight to
|
|
882
|
+
* `page.evaluate`, matching today's behavior byte-for-byte.
|
|
861
883
|
*/
|
|
862
|
-
async function countNgInvalidContainers(
|
|
884
|
+
async function countNgInvalidContainers(target) {
|
|
863
885
|
const expr = `(() => {
|
|
864
886
|
const isInvalid = ${INVALID_MARKER_EL_EXPR};
|
|
865
887
|
let n = 0;
|
|
@@ -869,7 +891,7 @@ async function countNgInvalidContainers(page) {
|
|
|
869
891
|
return n;
|
|
870
892
|
})()`;
|
|
871
893
|
try {
|
|
872
|
-
const raw = await
|
|
894
|
+
const raw = await target.evaluate(expr);
|
|
873
895
|
return typeof raw === "number" ? raw : 0;
|
|
874
896
|
}
|
|
875
897
|
catch {
|
|
@@ -1470,10 +1492,10 @@ function selectBodyExcerpt(body) {
|
|
|
1470
1492
|
const start = Math.max(0, absoluteIndex - BODY_EXCERPT_FORM_WINDOW / 4);
|
|
1471
1493
|
return body.slice(start, start + BODY_EXCERPT_FORM_WINDOW);
|
|
1472
1494
|
}
|
|
1473
|
-
async function extractLivePageFormEvidence(
|
|
1495
|
+
async function extractLivePageFormEvidence(_page, target, options) {
|
|
1474
1496
|
let body = "";
|
|
1475
1497
|
try {
|
|
1476
|
-
const raw = await
|
|
1498
|
+
const raw = await target.evaluate("document.body ? document.body.outerHTML : null");
|
|
1477
1499
|
if (typeof raw === "string")
|
|
1478
1500
|
body = raw;
|
|
1479
1501
|
}
|
|
@@ -1502,13 +1524,13 @@ async function extractLivePageFormEvidence(page, options) {
|
|
|
1502
1524
|
// field was named in FORM FIELDS. Deterministic extraction gives the
|
|
1503
1525
|
// LLM `"Address" <app-input> — error: "This field is required"` instead.
|
|
1504
1526
|
const [leafFields, errorVerdict, interactiveTargets] = await Promise.all([
|
|
1505
|
-
probeLeafInvalidContainers(
|
|
1527
|
+
probeLeafInvalidContainers(target),
|
|
1506
1528
|
(0, error_messages_1.judgeErrorMessagesWithLLM)({
|
|
1507
1529
|
client,
|
|
1508
1530
|
input: { bodyHtmlExcerpt: bodyExcerpt },
|
|
1509
1531
|
captureFn,
|
|
1510
1532
|
}),
|
|
1511
|
-
extractInteractiveTargetsNearInvalid(
|
|
1533
|
+
extractInteractiveTargetsNearInvalid(target).catch(() => []),
|
|
1512
1534
|
]);
|
|
1513
1535
|
// Probe is the primary signal. Judge only runs if probe is empty AND a
|
|
1514
1536
|
// client is available — so the fallback semantics match today's behavior
|
|
@@ -1558,13 +1580,13 @@ async function extractLivePageFormEvidence(page, options) {
|
|
|
1558
1580
|
* LLM for fuzzy judgment, deterministic extraction for structurally-derivable
|
|
1559
1581
|
* signals" — DOM tree walking is the latter.
|
|
1560
1582
|
*
|
|
1561
|
-
* Returns up to 12 leaf records. Empty array on
|
|
1583
|
+
* Returns up to 12 leaf records. Empty array on evaluate failure (safe
|
|
1562
1584
|
* fallback to the existing Haiku judge upstream). The `inputTag` and
|
|
1563
1585
|
* `visibleErrorText` fields let the prompt distinguish a smart-address
|
|
1564
1586
|
* autocomplete (where typing-only fails and the cascade needs dropdown
|
|
1565
1587
|
* selection) from a plain text input.
|
|
1566
1588
|
*/
|
|
1567
|
-
async function probeLeafInvalidContainers(
|
|
1589
|
+
async function probeLeafInvalidContainers(target) {
|
|
1568
1590
|
const expr = `(() => {
|
|
1569
1591
|
const SELECTOR =
|
|
1570
1592
|
"[class*='ng-invalid']:not(:has([class*='ng-invalid'])), " +
|
|
@@ -1649,11 +1671,11 @@ async function probeLeafInvalidContainers(page) {
|
|
|
1649
1671
|
return out;
|
|
1650
1672
|
})()`;
|
|
1651
1673
|
try {
|
|
1652
|
-
const result = await
|
|
1674
|
+
const result = await target.evaluate(expr);
|
|
1653
1675
|
return Array.isArray(result) ? result : [];
|
|
1654
1676
|
}
|
|
1655
1677
|
catch {
|
|
1656
|
-
//
|
|
1678
|
+
// evaluate failure (navigation in-flight, CSP, browser detached)
|
|
1657
1679
|
// is non-fatal — caller falls back to the Haiku judge.
|
|
1658
1680
|
return [];
|
|
1659
1681
|
}
|
|
@@ -1676,7 +1698,7 @@ function renderLeafInvalidFields(fields) {
|
|
|
1676
1698
|
});
|
|
1677
1699
|
return lines.join("\n");
|
|
1678
1700
|
}
|
|
1679
|
-
async function extractInteractiveTargetsNearInvalid(
|
|
1701
|
+
async function extractInteractiveTargetsNearInvalid(target) {
|
|
1680
1702
|
const expr = `(() => {
|
|
1681
1703
|
const out = [];
|
|
1682
1704
|
const containers = document.querySelectorAll(
|
|
@@ -1732,7 +1754,7 @@ async function extractInteractiveTargetsNearInvalid(page) {
|
|
|
1732
1754
|
}
|
|
1733
1755
|
return out;
|
|
1734
1756
|
})()`;
|
|
1735
|
-
const result = await
|
|
1757
|
+
const result = await target.evaluate(expr);
|
|
1736
1758
|
return Array.isArray(result) ? result : [];
|
|
1737
1759
|
}
|
|
1738
1760
|
/**
|
|
@@ -1988,12 +2010,12 @@ function normalizeDateValue(raw, inputType) {
|
|
|
1988
2010
|
}
|
|
1989
2011
|
return null;
|
|
1990
2012
|
}
|
|
1991
|
-
async function fillHtml5DateTimeInput(
|
|
2013
|
+
async function fillHtml5DateTimeInput(target, xpath, value) {
|
|
1992
2014
|
const HTML5_DATE_TYPES = new Set(["date", "time", "datetime-local", "month", "week"]);
|
|
1993
2015
|
// K'/H' Change 1: pre-normalize the value before dispatching to the page
|
|
1994
2016
|
// evaluator. The HTML5 spec rejects programmatic .value writes that don't
|
|
1995
2017
|
// match the canonical format — see normalizeDateValue TSDoc.
|
|
1996
|
-
// We don't yet know the input type until the
|
|
2018
|
+
// We don't yet know the input type until the evaluate runs (we'd
|
|
1997
2019
|
// have to probe it first), so we try BOTH the raw value AND a normalized
|
|
1998
2020
|
// pass: if raw works, fine; if raw fails (post-value mismatch), the
|
|
1999
2021
|
// returned filled=false signal tells the caller to retry with a normalized
|
|
@@ -2022,7 +2044,7 @@ async function fillHtml5DateTimeInput(page, xpath, value) {
|
|
|
2022
2044
|
return { filled: el.value === value, postValue: el.value || "", inputType };
|
|
2023
2045
|
})()`;
|
|
2024
2046
|
try {
|
|
2025
|
-
const raw = await
|
|
2047
|
+
const raw = await target.evaluate(expr);
|
|
2026
2048
|
if (raw === null || typeof raw !== "object")
|
|
2027
2049
|
return null;
|
|
2028
2050
|
const r = raw;
|
|
@@ -2058,7 +2080,7 @@ async function fillHtml5DateTimeInput(page, xpath, value) {
|
|
|
2058
2080
|
* element regardless of framework wrapping. Industry-standard pattern
|
|
2059
2081
|
* (react-testing-library's `getByDisplayValue` does the same readback).
|
|
2060
2082
|
*/
|
|
2061
|
-
async function verifyFillReadback(
|
|
2083
|
+
async function verifyFillReadback(target, xpath, expectedValue) {
|
|
2062
2084
|
const expr = `(() => {
|
|
2063
2085
|
const xpath = ${JSON.stringify(xpath)};
|
|
2064
2086
|
const expected = ${JSON.stringify(expectedValue)};
|
|
@@ -2081,7 +2103,7 @@ async function verifyFillReadback(page, xpath, expectedValue) {
|
|
|
2081
2103
|
return { outcome, postValue: actual, tag };
|
|
2082
2104
|
})()`;
|
|
2083
2105
|
try {
|
|
2084
|
-
const raw = await
|
|
2106
|
+
const raw = await target.evaluate(expr);
|
|
2085
2107
|
if (raw === null || typeof raw !== "object")
|
|
2086
2108
|
return null;
|
|
2087
2109
|
const r = raw;
|
|
@@ -2285,7 +2307,7 @@ function writeFixtureToTempFile(fixture) {
|
|
|
2285
2307
|
* existing cascade in that case).
|
|
2286
2308
|
*/
|
|
2287
2309
|
async function tryUploadPrimitive(params) {
|
|
2288
|
-
const { page, isUploadStep, fixture, logger, signalCounter, recentCaptureMeta } = params;
|
|
2310
|
+
const { page, target, isUploadStep, fixture, logger, signalCounter, recentCaptureMeta } = params;
|
|
2289
2311
|
if (!isUploadStep) {
|
|
2290
2312
|
return false;
|
|
2291
2313
|
}
|
|
@@ -2302,7 +2324,7 @@ async function tryUploadPrimitive(params) {
|
|
|
2302
2324
|
// already rendered it pay nothing.
|
|
2303
2325
|
let inputCount = 0;
|
|
2304
2326
|
try {
|
|
2305
|
-
inputCount = await pollEnumerate(page, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0, { attempts: UPLOAD_WIDGET_RENDER_ATTEMPTS, intervalMs: UPLOAD_WIDGET_RENDER_INTERVAL_MS });
|
|
2327
|
+
inputCount = await pollEnumerate(page, target, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0, { attempts: UPLOAD_WIDGET_RENDER_ATTEMPTS, intervalMs: UPLOAD_WIDGET_RENDER_INTERVAL_MS });
|
|
2306
2328
|
}
|
|
2307
2329
|
catch (err) {
|
|
2308
2330
|
logger.warn(`upload primitive: file-input probe threw: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
@@ -2315,6 +2337,7 @@ async function tryUploadPrimitive(params) {
|
|
|
2315
2337
|
logger.info("upload primitive: no <input type=file> after render wait; attempting click-to-surface");
|
|
2316
2338
|
const surfaced = await surfaceAndUpload({
|
|
2317
2339
|
page,
|
|
2340
|
+
target,
|
|
2318
2341
|
fixture,
|
|
2319
2342
|
logger,
|
|
2320
2343
|
signalCounter,
|
|
@@ -2325,7 +2348,7 @@ async function tryUploadPrimitive(params) {
|
|
|
2325
2348
|
logger.info("upload primitive: click-to-surface failed; falling through to cascade");
|
|
2326
2349
|
return false;
|
|
2327
2350
|
}
|
|
2328
|
-
return attachToSurfacedInput({ page, fixture, logger, signalCounter, recentCaptureMeta });
|
|
2351
|
+
return attachToSurfacedInput({ page, target, fixture, logger, signalCounter, recentCaptureMeta });
|
|
2329
2352
|
}
|
|
2330
2353
|
/**
|
|
2331
2354
|
* Attach the fixture to an already-surfaced `<input type=file>` (raw or freshly
|
|
@@ -2335,10 +2358,10 @@ async function tryUploadPrimitive(params) {
|
|
|
2335
2358
|
* + drag-drop-fallback implementation.
|
|
2336
2359
|
*/
|
|
2337
2360
|
async function attachToSurfacedInput(params) {
|
|
2338
|
-
const { page, fixture, logger, signalCounter, recentCaptureMeta } = params;
|
|
2339
|
-
const
|
|
2361
|
+
const { page, target, fixture, logger, signalCounter, recentCaptureMeta } = params;
|
|
2362
|
+
const inputLocator = target.locator("xpath=//input[@type='file']").first();
|
|
2340
2363
|
try {
|
|
2341
|
-
await
|
|
2364
|
+
await inputLocator.setInputFiles({
|
|
2342
2365
|
name: fixture.name,
|
|
2343
2366
|
mimeType: fixture.mimeType,
|
|
2344
2367
|
buffer: fixture.buffer,
|
|
@@ -2365,7 +2388,7 @@ async function attachToSurfacedInput(params) {
|
|
|
2365
2388
|
// Industry-standard workaround documented across Playwright
|
|
2366
2389
|
// community. Site-agnostic — works for any tenant with framework-
|
|
2367
2390
|
// wrapped file inputs.
|
|
2368
|
-
await
|
|
2391
|
+
await target
|
|
2369
2392
|
.evaluate("(() => { const els = document.querySelectorAll('input[type=file]'); for (const el of els) { if (el.files && el.files.length > 0) { el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); return true; } } return false; })()")
|
|
2370
2393
|
.catch((err) => {
|
|
2371
2394
|
logger.warn(`upload primitive: change dispatch failed: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
@@ -2391,7 +2414,7 @@ async function attachToSurfacedInput(params) {
|
|
|
2391
2414
|
// interpolation from external data, no risk of injecting attacker-controlled
|
|
2392
2415
|
// values into the browser-side JS. Same trust posture as the type-probe
|
|
2393
2416
|
// expression in verifyDomEffect's click case.
|
|
2394
|
-
const attachedLength = await
|
|
2417
|
+
const attachedLength = await target
|
|
2395
2418
|
.evaluate("(() => { const els = document.querySelectorAll('input[type=file]'); for (const el of els) { if (el.files && el.files.length > 0) return el.files.length; } return 0; })()")
|
|
2396
2419
|
.catch(() => 0);
|
|
2397
2420
|
if (typeof attachedLength !== "number" || attachedLength === 0) {
|
|
@@ -2402,7 +2425,7 @@ async function attachToSurfacedInput(params) {
|
|
|
2402
2425
|
// a DataTransfer fires on the visible drop area — they don't observe
|
|
2403
2426
|
// the hidden input's `files[]` mutations even with synthetic `change`
|
|
2404
2427
|
// dispatches. This is the documented Playwright community workaround.
|
|
2405
|
-
const dragDropOk = await simulateDragDropUpload(
|
|
2428
|
+
const dragDropOk = await simulateDragDropUpload(target, fixture, logger);
|
|
2406
2429
|
if (dragDropOk) {
|
|
2407
2430
|
logger.info(`upload primitive: drag-drop fallback succeeded (name=${fixture.name}, size=${fixture.buffer.length}b)`);
|
|
2408
2431
|
return true;
|
|
@@ -2425,7 +2448,7 @@ async function attachToSurfacedInput(params) {
|
|
|
2425
2448
|
* was attached.
|
|
2426
2449
|
*/
|
|
2427
2450
|
async function surfaceAndUpload(params) {
|
|
2428
|
-
const { page, fixture, logger, signalCounter, recentCaptureMeta } = params;
|
|
2451
|
+
const { page, target, fixture, logger, signalCounter, recentCaptureMeta } = params;
|
|
2429
2452
|
// Render-gate: the input-less strategies below (drag-drop is one-shot, the
|
|
2430
2453
|
// affordance click resolves what's in the DOM) all race the async widget
|
|
2431
2454
|
// mount. Wait (bounded, same window as the raw-input probe) for ANY upload
|
|
@@ -2449,7 +2472,7 @@ async function surfaceAndUpload(params) {
|
|
|
2449
2472
|
if (btns.some((el) => isUpload(el.getAttribute("aria-label") || el.textContent || ""))) return { present: true };
|
|
2450
2473
|
return { present: false };
|
|
2451
2474
|
})()`;
|
|
2452
|
-
const gate = await pollEnumerate(page, targetExpr, (r) => r?.present === true, {
|
|
2475
|
+
const gate = await pollEnumerate(page, target, targetExpr, (r) => r?.present === true, {
|
|
2453
2476
|
attempts: UPLOAD_WIDGET_RENDER_ATTEMPTS,
|
|
2454
2477
|
intervalMs: UPLOAD_WIDGET_RENDER_INTERVAL_MS,
|
|
2455
2478
|
});
|
|
@@ -2461,13 +2484,18 @@ async function surfaceAndUpload(params) {
|
|
|
2461
2484
|
// Strategy DZ: a synthetic drop is cheap, needs no click/chooser, and the
|
|
2462
2485
|
// widget IS a dropzone. If it registers the file (upload POST or attached
|
|
2463
2486
|
// input), we're done without touching CDP.
|
|
2464
|
-
if (await simulateDragDropUpload(
|
|
2487
|
+
if (await simulateDragDropUpload(target, fixture, logger)) {
|
|
2465
2488
|
if (await waitForUploadNetworkSignal({ page, fixture, logger, signalCounter, recentCaptureMeta })) {
|
|
2466
2489
|
logger.info("upload primitive: resolved via drag-drop onto dropzone");
|
|
2467
2490
|
return true;
|
|
2468
2491
|
}
|
|
2469
2492
|
}
|
|
2470
|
-
|
|
2493
|
+
// The file-chooser CDP interception below must run on the upload target's
|
|
2494
|
+
// OWN session — an OOPIF (e.g. UCHealth's Talemetry wizard) has its own CDP
|
|
2495
|
+
// target, and a chooser it opens is only observable via that frame's
|
|
2496
|
+
// session, not the main session. Main-frame targets fall back to
|
|
2497
|
+
// page.getSessionForFrame(page.mainFrameId()), matching today's behavior.
|
|
2498
|
+
const session = target.frame ? target.frame.session : page.getSessionForFrame(page.mainFrameId());
|
|
2471
2499
|
let chooserBackendNodeId = null;
|
|
2472
2500
|
const onChooser = (paramsIn) => {
|
|
2473
2501
|
const p = paramsIn;
|
|
@@ -2477,13 +2505,13 @@ async function surfaceAndUpload(params) {
|
|
|
2477
2505
|
// ARM native-chooser interception BEFORE the click. Page.fileChooserOpened
|
|
2478
2506
|
// only carries a backendNodeId while interception is enabled; without it a
|
|
2479
2507
|
// chooser-opening click would pop a real OS dialog and hang the run.
|
|
2480
|
-
await
|
|
2481
|
-
await
|
|
2482
|
-
.
|
|
2508
|
+
await session.send("Page.enable").catch(() => { });
|
|
2509
|
+
await session
|
|
2510
|
+
.send("Page.setInterceptFileChooserDialog", { enabled: true })
|
|
2483
2511
|
.catch((e) => logger.warn(`upload primitive: chooser-intercept arm failed: ${(0, errors_1.toErrorMessage)(e)}`));
|
|
2484
2512
|
session.on("Page.fileChooserOpened", onChooser);
|
|
2485
2513
|
try {
|
|
2486
|
-
if (!(await clickUploadAffordance(page, logger)))
|
|
2514
|
+
if (!(await clickUploadAffordance(page, target, logger)))
|
|
2487
2515
|
return false;
|
|
2488
2516
|
// Strategy 0: some MUI widgets XHR straight to attachment_upload_url on
|
|
2489
2517
|
// click, no chooser, no input.
|
|
@@ -2492,10 +2520,17 @@ async function surfaceAndUpload(params) {
|
|
|
2492
2520
|
return true;
|
|
2493
2521
|
}
|
|
2494
2522
|
// Strategy A: the click lazily mounted a hidden <input type=file>.
|
|
2495
|
-
const appeared = await pollEnumerate(page, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0);
|
|
2523
|
+
const appeared = await pollEnumerate(page, target, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0);
|
|
2496
2524
|
if ((appeared ?? 0) > 0) {
|
|
2497
2525
|
logger.info("upload primitive: click surfaced a hidden <input type=file>");
|
|
2498
|
-
if (await attachToSurfacedInput({
|
|
2526
|
+
if (await attachToSurfacedInput({
|
|
2527
|
+
page,
|
|
2528
|
+
target,
|
|
2529
|
+
fixture,
|
|
2530
|
+
logger,
|
|
2531
|
+
signalCounter,
|
|
2532
|
+
recentCaptureMeta,
|
|
2533
|
+
})) {
|
|
2499
2534
|
return true;
|
|
2500
2535
|
}
|
|
2501
2536
|
}
|
|
@@ -2504,6 +2539,7 @@ async function surfaceAndUpload(params) {
|
|
|
2504
2539
|
logger.info(`upload primitive: native file chooser intercepted (backendNodeId=${chooserBackendNodeId}); setting files via CDP`);
|
|
2505
2540
|
return setFilesViaCdp({
|
|
2506
2541
|
page,
|
|
2542
|
+
target,
|
|
2507
2543
|
session,
|
|
2508
2544
|
backendNodeId: chooserBackendNodeId,
|
|
2509
2545
|
fixture,
|
|
@@ -2516,7 +2552,7 @@ async function surfaceAndUpload(params) {
|
|
|
2516
2552
|
}
|
|
2517
2553
|
finally {
|
|
2518
2554
|
session.off("Page.fileChooserOpened", onChooser);
|
|
2519
|
-
await
|
|
2555
|
+
await session.send("Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => { });
|
|
2520
2556
|
}
|
|
2521
2557
|
}
|
|
2522
2558
|
/**
|
|
@@ -2526,8 +2562,11 @@ async function surfaceAndUpload(params) {
|
|
|
2526
2562
|
* upload vocabulary as {@link isUploadAffordanceLabel}, preferring controls
|
|
2527
2563
|
* scoped inside an attachment/upload/resume container. Returns whether a
|
|
2528
2564
|
* matching control was clicked.
|
|
2565
|
+
*
|
|
2566
|
+
* Takes both `page` (for `pollEnumerate`'s `waitForTimeout`) and `target`
|
|
2567
|
+
* (the frame the enumerate/click runs against).
|
|
2529
2568
|
*/
|
|
2530
|
-
async function clickUploadAffordance(page, logger) {
|
|
2569
|
+
async function clickUploadAffordance(page, target, logger) {
|
|
2531
2570
|
// The browser-side matcher mirrors isUploadAffordanceLabel; kept as a literal
|
|
2532
2571
|
// so the enumerate is a static string (same trust posture as the other
|
|
2533
2572
|
// primitives). No external interpolation.
|
|
@@ -2549,7 +2588,7 @@ async function clickUploadAffordance(page, logger) {
|
|
|
2549
2588
|
return { clicked: true, text: norm(chosen.getAttribute("aria-label") || chosen.textContent || "").slice(0, 50) };
|
|
2550
2589
|
})()`;
|
|
2551
2590
|
try {
|
|
2552
|
-
const result = (await pollEnumerate(page, expr, (r) => r?.clicked === true)) ?? { clicked: false };
|
|
2591
|
+
const result = (await pollEnumerate(page, target, expr, (r) => r?.clicked === true)) ?? { clicked: false };
|
|
2553
2592
|
if (result.clicked) {
|
|
2554
2593
|
logger.info(`upload primitive: clicked upload affordance "${result.text ?? ""}"`);
|
|
2555
2594
|
return true;
|
|
@@ -2570,7 +2609,7 @@ async function clickUploadAffordance(page, logger) {
|
|
|
2570
2609
|
* shared upload-network signal.
|
|
2571
2610
|
*/
|
|
2572
2611
|
async function setFilesViaCdp(params) {
|
|
2573
|
-
const { page, session, backendNodeId, fixture, logger, signalCounter, recentCaptureMeta } = params;
|
|
2612
|
+
const { page, target, session, backendNodeId, fixture, logger, signalCounter, recentCaptureMeta, } = params;
|
|
2574
2613
|
// CDP needs a filesystem path; the fixture is an in-memory buffer. Write it
|
|
2575
2614
|
// to a temp file (tiny — a few KB) so the path is always valid regardless of
|
|
2576
2615
|
// where the recon loaded the fixture from.
|
|
@@ -2588,7 +2627,7 @@ async function setFilesViaCdp(params) {
|
|
|
2588
2627
|
// CDP-set files don't surface via input.files, so the DOM-attached-files
|
|
2589
2628
|
// check can't confirm; treat a filename chip appearing in the DOM as the
|
|
2590
2629
|
// secondary success signal (the MUI widget renders the chosen filename).
|
|
2591
|
-
const nameShown = await
|
|
2630
|
+
const nameShown = await target
|
|
2592
2631
|
.evaluate(`document.body && document.body.textContent && document.body.textContent.indexOf(${JSON.stringify(fixture.name)}) !== -1`)
|
|
2593
2632
|
.catch(() => false);
|
|
2594
2633
|
if (nameShown === true) {
|
|
@@ -2714,14 +2753,20 @@ const PRIMITIVE_ENUMERATE_RETRY_MS = 600;
|
|
|
2714
2753
|
* `opts` overrides the attempt count / interval for callers that need a longer
|
|
2715
2754
|
* window (the resume-upload widget can take 5s+ to mount); omitting it keeps the
|
|
2716
2755
|
* default ~3s window so every existing caller is unchanged.
|
|
2756
|
+
*
|
|
2757
|
+
* Takes both `page` (for `waitForTimeout`, which `FrameTarget` has no
|
|
2758
|
+
* equivalent of) and `target` (for the enumerate itself), so a widget
|
|
2759
|
+
* rendered inside a resolved cross-origin child frame is polled on its own
|
|
2760
|
+
* frame; a main-frame `target` delegates straight to `page.evaluate`,
|
|
2761
|
+
* matching today's behavior byte-for-byte.
|
|
2717
2762
|
*/
|
|
2718
|
-
async function pollEnumerate(page, expr, isPresent, opts) {
|
|
2763
|
+
async function pollEnumerate(page, target, expr, isPresent, opts) {
|
|
2719
2764
|
const attempts = opts?.attempts ?? PRIMITIVE_ENUMERATE_ATTEMPTS;
|
|
2720
2765
|
const intervalMs = opts?.intervalMs ?? PRIMITIVE_ENUMERATE_RETRY_MS;
|
|
2721
|
-
let result = (await
|
|
2766
|
+
let result = (await target.evaluate(expr));
|
|
2722
2767
|
for (let attempt = 1; attempt < attempts && !isPresent(result); attempt++) {
|
|
2723
2768
|
await page.waitForTimeout(intervalMs);
|
|
2724
|
-
result = (await
|
|
2769
|
+
result = (await target.evaluate(expr));
|
|
2725
2770
|
}
|
|
2726
2771
|
return result;
|
|
2727
2772
|
}
|
|
@@ -2798,8 +2843,13 @@ async function waitForTransitionBody(params) {
|
|
|
2798
2843
|
* refuse to claim success on an uncommitted select, routing to the cascade/replan
|
|
2799
2844
|
* instead of silently advancing. Walks ≤6 ancestors for the invalid marker, same
|
|
2800
2845
|
* as the radio/checkbox primitives.
|
|
2846
|
+
*
|
|
2847
|
+
* Accepts a `FrameTarget` (plus the underlying `page` for `waitForTimeout`,
|
|
2848
|
+
* which `FrameTarget` has no equivalent of) so a wizard embedded in a
|
|
2849
|
+
* cross-origin iframe is set on its own frame; a main-frame target delegates
|
|
2850
|
+
* straight to `page.evaluate`, matching today's behavior byte-for-byte.
|
|
2801
2851
|
*/
|
|
2802
|
-
async function applySelectValue(page, selIdx, value) {
|
|
2852
|
+
async function applySelectValue(page, target, selIdx, value) {
|
|
2803
2853
|
const setExpr = `((selIdx, value) => {
|
|
2804
2854
|
const sel = Array.from(document.querySelectorAll("select"))[selIdx];
|
|
2805
2855
|
if (!sel) return { ok: false };
|
|
@@ -2810,7 +2860,7 @@ async function applySelectValue(page, selIdx, value) {
|
|
|
2810
2860
|
sel.dispatchEvent(new Event("blur", { bubbles: true }));
|
|
2811
2861
|
return { ok: sel.value === value };
|
|
2812
2862
|
})(${JSON.stringify(selIdx)}, ${JSON.stringify(value)})`;
|
|
2813
|
-
const setResult = (await
|
|
2863
|
+
const setResult = (await target.evaluate(setExpr));
|
|
2814
2864
|
if (!setResult?.ok)
|
|
2815
2865
|
return { ok: false, stillInvalid: false };
|
|
2816
2866
|
await page.waitForTimeout(SELECT_SETTLE_MS);
|
|
@@ -2825,11 +2875,11 @@ async function applySelectValue(page, selIdx, value) {
|
|
|
2825
2875
|
}
|
|
2826
2876
|
return false;
|
|
2827
2877
|
})(${JSON.stringify(selIdx)})`;
|
|
2828
|
-
const stillInvalid = (await
|
|
2878
|
+
const stillInvalid = (await target.evaluate(invalidExpr).catch(() => false));
|
|
2829
2879
|
return { ok: true, stillInvalid };
|
|
2830
2880
|
}
|
|
2831
2881
|
async function trySelectPrimitive(params) {
|
|
2832
|
-
const { page, instruction, logger, anthropic, captureFn } = params;
|
|
2882
|
+
const { page, target, instruction, logger, anthropic, captureFn } = params;
|
|
2833
2883
|
const parsed = parseSelectStep(instruction);
|
|
2834
2884
|
if (!parsed)
|
|
2835
2885
|
return false;
|
|
@@ -2914,7 +2964,7 @@ async function trySelectPrimitive(params) {
|
|
|
2914
2964
|
try {
|
|
2915
2965
|
// Settle-retry: the <select> may render a beat after the step fires (SPA
|
|
2916
2966
|
// render-lag); poll until it appears or the cap is hit.
|
|
2917
|
-
const enumResult = await pollEnumerate(page, enumerateExpr, (r) => r?.selectPresent === true);
|
|
2967
|
+
const enumResult = await pollEnumerate(page, target, enumerateExpr, (r) => r?.selectPresent === true);
|
|
2918
2968
|
// No <select> on the page at all (e.g. the question is a radio group) —
|
|
2919
2969
|
// fall through to the cascade unchanged; the LLM picker can't help here.
|
|
2920
2970
|
if (!enumResult?.selectPresent) {
|
|
@@ -2926,7 +2976,7 @@ async function trySelectPrimitive(params) {
|
|
|
2926
2976
|
// is uncommitted (a later worklet re-render will wipe it) — refuse to claim
|
|
2927
2977
|
// success so the cascade/replan can retry rather than silently advancing.
|
|
2928
2978
|
if (enumResult.detMatch) {
|
|
2929
|
-
const { ok, stillInvalid } = await applySelectValue(page, enumResult.detMatch.selIdx, enumResult.detMatch.value);
|
|
2979
|
+
const { ok, stillInvalid } = await applySelectValue(page, target, enumResult.detMatch.selIdx, enumResult.detMatch.value);
|
|
2930
2980
|
if (ok && !stillInvalid) {
|
|
2931
2981
|
logger.info(`select primitive: set dropdown to "${enumResult.detMatch.text.trim().slice(0, 40)}" (${optLabel})`);
|
|
2932
2982
|
return true;
|
|
@@ -2963,7 +3013,7 @@ async function trySelectPrimitive(params) {
|
|
|
2963
3013
|
// Apply pass (set + settle + invalid-readback): set the chosen select by its
|
|
2964
3014
|
// ORIGINAL DOM index, then confirm it committed (cleared the invalid marker),
|
|
2965
3015
|
// same as the fast path.
|
|
2966
|
-
const { ok, stillInvalid } = await applySelectValue(page, chosenCandidate.selIdx, chosenOption.value);
|
|
3016
|
+
const { ok, stillInvalid } = await applySelectValue(page, target, chosenCandidate.selIdx, chosenOption.value);
|
|
2967
3017
|
if (ok && !stillInvalid) {
|
|
2968
3018
|
logger.info(`select primitive: LLM chose "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
|
|
2969
3019
|
return true;
|
|
@@ -3027,7 +3077,7 @@ function chooseRequiredSelectOption(options) {
|
|
|
3027
3077
|
* required-empty select is present, so radio/checkbox catch-alls are unaffected.
|
|
3028
3078
|
*/
|
|
3029
3079
|
async function tryFillRequiredSelectsPrimitive(params) {
|
|
3030
|
-
const { page, instruction, logger, anthropic, captureFn } = params;
|
|
3080
|
+
const { page, target, instruction, logger, anthropic, captureFn } = params;
|
|
3031
3081
|
// Gate: catch-all steps only. parseSelectStep returns null for these (its
|
|
3032
3082
|
// `any remaining` guard), so this never collides with the single-target
|
|
3033
3083
|
// trySelectPrimitive that owns concrete "select 'X'" steps.
|
|
@@ -3085,7 +3135,7 @@ async function tryFillRequiredSelectsPrimitive(params) {
|
|
|
3085
3135
|
return { candidates };
|
|
3086
3136
|
})()`;
|
|
3087
3137
|
try {
|
|
3088
|
-
const enumResult = await pollEnumerate(page, enumerateExpr, (r) => Array.isArray(r?.candidates));
|
|
3138
|
+
const enumResult = await pollEnumerate(page, target, enumerateExpr, (r) => Array.isArray(r?.candidates));
|
|
3089
3139
|
const candidates = enumResult?.candidates ?? [];
|
|
3090
3140
|
if (candidates.length === 0)
|
|
3091
3141
|
return false;
|
|
@@ -3118,7 +3168,7 @@ async function tryFillRequiredSelectsPrimitive(params) {
|
|
|
3118
3168
|
allCommitted = false;
|
|
3119
3169
|
continue;
|
|
3120
3170
|
}
|
|
3121
|
-
const { ok, stillInvalid } = await applySelectValue(page, cand.selIdx, chosen.value);
|
|
3171
|
+
const { ok, stillInvalid } = await applySelectValue(page, target, cand.selIdx, chosen.value);
|
|
3122
3172
|
if (ok && !stillInvalid) {
|
|
3123
3173
|
logger.info(`required-select primitive: filled "${cand.label.slice(0, 40)}" with "${chosen.text.slice(0, 40)}"`);
|
|
3124
3174
|
}
|
|
@@ -3158,7 +3208,7 @@ async function tryFillRequiredSelectsPrimitive(params) {
|
|
|
3158
3208
|
* also where `<select>`-only pages go, since trySelectPrimitive runs first).
|
|
3159
3209
|
*/
|
|
3160
3210
|
async function tryCheckboxPrimitive(params) {
|
|
3161
|
-
const { page, instruction, logger, anthropic, captureFn } = params;
|
|
3211
|
+
const { page, target, instruction, logger, anthropic, captureFn } = params;
|
|
3162
3212
|
const parsed = parseSelectStep(instruction);
|
|
3163
3213
|
if (!parsed)
|
|
3164
3214
|
return false;
|
|
@@ -3243,7 +3293,7 @@ async function tryCheckboxPrimitive(params) {
|
|
|
3243
3293
|
try {
|
|
3244
3294
|
// Settle-retry: the checkbox group may render a beat after the step fires
|
|
3245
3295
|
// (SPA render-lag); poll until it appears or the cap is hit.
|
|
3246
|
-
const enumResult = await pollEnumerate(page, enumerateExpr, (r) => r?.groupPresent === true);
|
|
3296
|
+
const enumResult = await pollEnumerate(page, target, enumerateExpr, (r) => r?.groupPresent === true);
|
|
3247
3297
|
if (!enumResult?.groupPresent)
|
|
3248
3298
|
return false; // no checkbox groups → cascade
|
|
3249
3299
|
if (enumResult.applied && enumResult.ok) {
|
|
@@ -3292,7 +3342,7 @@ async function tryCheckboxPrimitive(params) {
|
|
|
3292
3342
|
}
|
|
3293
3343
|
return { ok: cb.checked === true };
|
|
3294
3344
|
})(${JSON.stringify(chosenGroup.gi)}, ${JSON.stringify(chosenOption.bi)})`;
|
|
3295
|
-
const applyResult = (await
|
|
3345
|
+
const applyResult = (await target.evaluate(applyExpr));
|
|
3296
3346
|
if (applyResult?.ok) {
|
|
3297
3347
|
logger.info(`checkbox primitive: LLM checked "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
|
|
3298
3348
|
return true;
|
|
@@ -3485,7 +3535,7 @@ function selectRadioGroupOption(params) {
|
|
|
3485
3535
|
* (checkbox/select/absent) falls through to the cascade.
|
|
3486
3536
|
*/
|
|
3487
3537
|
async function tryRadioPrimitive(params) {
|
|
3488
|
-
const { page, instruction, logger, anthropic, captureFn } = params;
|
|
3538
|
+
const { page, target, instruction, logger, anthropic, captureFn } = params;
|
|
3489
3539
|
const parsed = parseRadioStep(instruction);
|
|
3490
3540
|
if (!parsed)
|
|
3491
3541
|
return false;
|
|
@@ -3558,7 +3608,7 @@ async function tryRadioPrimitive(params) {
|
|
|
3558
3608
|
return { groupPresent: true, groups };
|
|
3559
3609
|
})(${JSON.stringify(option)})`;
|
|
3560
3610
|
try {
|
|
3561
|
-
const enumResult = await pollEnumerate(page, enumerateExpr, (r) => r?.groupPresent === true);
|
|
3611
|
+
const enumResult = await pollEnumerate(page, target, enumerateExpr, (r) => r?.groupPresent === true);
|
|
3562
3612
|
if (!enumResult?.groupPresent)
|
|
3563
3613
|
return false; // no radio group → cascade
|
|
3564
3614
|
const groups = enumResult.groups ?? [];
|
|
@@ -3570,7 +3620,7 @@ async function tryRadioPrimitive(params) {
|
|
|
3570
3620
|
const selection = selectRadioGroupOption({ groups, wantOption: option, questionLabel });
|
|
3571
3621
|
if (selection !== null && selection !== "ambiguous") {
|
|
3572
3622
|
const chosenOpt = groups[selection.gi]?.options.find((o) => o.ri === selection.ri);
|
|
3573
|
-
const applied = await applyRadioSelection(
|
|
3623
|
+
const applied = await applyRadioSelection(target, selection.gi, selection.ri, {
|
|
3574
3624
|
id: chosenOpt?.id ?? "",
|
|
3575
3625
|
xpath: chosenOpt?.xpath ?? "",
|
|
3576
3626
|
});
|
|
@@ -3615,7 +3665,7 @@ async function tryRadioPrimitive(params) {
|
|
|
3615
3665
|
// biome-ignore lint/style/noNonNullAssertion: guarded above by the verdict.optionIndex === null early-return
|
|
3616
3666
|
const chosenOption = chosenGroup.options[verdict.optionIndex];
|
|
3617
3667
|
const applyResult = {
|
|
3618
|
-
ok: await applyRadioSelection(
|
|
3668
|
+
ok: await applyRadioSelection(target, chosenGroup.gi, chosenOption.ri, {
|
|
3619
3669
|
id: chosenOption.id,
|
|
3620
3670
|
xpath: chosenOption.xpath,
|
|
3621
3671
|
}),
|
|
@@ -3649,7 +3699,7 @@ async function tryRadioPrimitive(params) {
|
|
|
3649
3699
|
* detached / non-MUI radios that already commit that way).
|
|
3650
3700
|
* Returns whether the commit stuck; false → caller falls through to the cascade.
|
|
3651
3701
|
*/
|
|
3652
|
-
async function applyRadioSelection(
|
|
3702
|
+
async function applyRadioSelection(target, gi, ri, hint) {
|
|
3653
3703
|
// Post-settle readback for the input identified by id (preferred) or xpath:
|
|
3654
3704
|
// checked===true AND no INVALID_MARKER_EL_EXPR ancestor within 6 hops.
|
|
3655
3705
|
const readbackExpr = (sel) => `((id, xp) => {
|
|
@@ -3665,15 +3715,15 @@ async function applyRadioSelection(page, gi, ri, hint) {
|
|
|
3665
3715
|
return { ok: true };
|
|
3666
3716
|
})(${JSON.stringify(sel.id)}, ${JSON.stringify(sel.xpath)})`;
|
|
3667
3717
|
const readback = async () => {
|
|
3668
|
-
await
|
|
3669
|
-
const r = (await
|
|
3718
|
+
await (0, frame_target_1.sleep)(RADIO_SETTLE_MS);
|
|
3719
|
+
const r = (await target.evaluate(readbackExpr(hint)).catch(() => ({ ok: false })));
|
|
3670
3720
|
return r?.ok === true;
|
|
3671
3721
|
};
|
|
3672
3722
|
// Tier A — trusted hit-tested click on the input by id (or xpath).
|
|
3673
3723
|
const inputSel = hint.id ? buildRadioIdXPath(hint.id) : hint.xpath ? `xpath=${hint.xpath}` : null;
|
|
3674
3724
|
if (inputSel) {
|
|
3675
3725
|
try {
|
|
3676
|
-
await
|
|
3726
|
+
await target.locator(inputSel).first().click();
|
|
3677
3727
|
if (await readback())
|
|
3678
3728
|
return true;
|
|
3679
3729
|
}
|
|
@@ -3684,7 +3734,7 @@ async function applyRadioSelection(page, gi, ri, hint) {
|
|
|
3684
3734
|
// Tier B — trusted click on the associated label (MUI hides the real input).
|
|
3685
3735
|
if (hint.id) {
|
|
3686
3736
|
try {
|
|
3687
|
-
await
|
|
3737
|
+
await target
|
|
3688
3738
|
.locator(`xpath=//label[@for=${JSON.stringify(hint.id)}]`)
|
|
3689
3739
|
.first()
|
|
3690
3740
|
.click();
|
|
@@ -3717,7 +3767,7 @@ async function applyRadioSelection(page, gi, ri, hint) {
|
|
|
3717
3767
|
}
|
|
3718
3768
|
return { ok: true };
|
|
3719
3769
|
})(${JSON.stringify(gi)}, ${JSON.stringify(ri)})`;
|
|
3720
|
-
await
|
|
3770
|
+
await target.evaluate(applyExpr).catch(() => ({ ok: false }));
|
|
3721
3771
|
return await readback();
|
|
3722
3772
|
}
|
|
3723
3773
|
/**
|
|
@@ -3736,7 +3786,7 @@ async function applyRadioSelection(page, gi, ri, hint) {
|
|
|
3736
3786
|
* step (e.g. "dismiss modal" on a modal-less page) has no such control, so the
|
|
3737
3787
|
* fast-skip the comments call essential is preserved.
|
|
3738
3788
|
*/
|
|
3739
|
-
async function hasUnfilledRequiredControlForStep(
|
|
3789
|
+
async function hasUnfilledRequiredControlForStep(target, instruction) {
|
|
3740
3790
|
const parsed = parseSelectStep(instruction);
|
|
3741
3791
|
if (!parsed?.questionLabel)
|
|
3742
3792
|
return false;
|
|
@@ -3785,7 +3835,7 @@ async function hasUnfilledRequiredControlForStep(page, instruction) {
|
|
|
3785
3835
|
return false;
|
|
3786
3836
|
})(${JSON.stringify(parsed.questionLabel)})`;
|
|
3787
3837
|
try {
|
|
3788
|
-
return (await
|
|
3838
|
+
return (await target.evaluate(expr)) === true;
|
|
3789
3839
|
}
|
|
3790
3840
|
catch {
|
|
3791
3841
|
return false;
|
|
@@ -3807,7 +3857,7 @@ async function hasUnfilledRequiredControlForStep(page, instruction) {
|
|
|
3807
3857
|
* drag-and-drop API. Works on react-dropzone, Material Dropzone, custom
|
|
3808
3858
|
* <uapp-upload>/<app-upload>, and any other drop-zone-based upload UI.
|
|
3809
3859
|
*/
|
|
3810
|
-
async function simulateDragDropUpload(
|
|
3860
|
+
async function simulateDragDropUpload(target, fixture, logger) {
|
|
3811
3861
|
const base64 = fixture.buffer.toString("base64");
|
|
3812
3862
|
const expr = `(async () => {
|
|
3813
3863
|
const fileName = ${JSON.stringify(fixture.name)};
|
|
@@ -3853,7 +3903,7 @@ async function simulateDragDropUpload(page, fixture, logger) {
|
|
|
3853
3903
|
}
|
|
3854
3904
|
})()`;
|
|
3855
3905
|
try {
|
|
3856
|
-
const result = await
|
|
3906
|
+
const result = await target.evaluate(expr);
|
|
3857
3907
|
if (result && typeof result === "object" && "ok" in result && result.ok === true) {
|
|
3858
3908
|
const tag = "dropZoneTag" in result ? String(result.dropZoneTag) : "(unknown)";
|
|
3859
3909
|
logger.info(`upload primitive: drag-drop dispatched on <${tag}>`);
|
|
@@ -3889,7 +3939,7 @@ async function simulateDragDropUpload(page, fixture, logger) {
|
|
|
3889
3939
|
* safely escapes it into a JS string literal. The expression body is a fixed
|
|
3890
3940
|
* literal — no user-controlled JS execution.
|
|
3891
3941
|
*/
|
|
3892
|
-
async function dispatchJqueryChangeEvent(
|
|
3942
|
+
async function dispatchJqueryChangeEvent(target, selector) {
|
|
3893
3943
|
const xpath = xpathBody(selector);
|
|
3894
3944
|
if (!xpath)
|
|
3895
3945
|
return;
|
|
@@ -3908,7 +3958,7 @@ async function dispatchJqueryChangeEvent(page, selector) {
|
|
|
3908
3958
|
}
|
|
3909
3959
|
})()`;
|
|
3910
3960
|
try {
|
|
3911
|
-
await
|
|
3961
|
+
await target.evaluate(expr);
|
|
3912
3962
|
}
|
|
3913
3963
|
catch {
|
|
3914
3964
|
// best-effort: jQuery dispatch failure shouldn't fail the verifier
|
|
@@ -3920,14 +3970,17 @@ async function dispatchJqueryChangeEvent(page, selector) {
|
|
|
3920
3970
|
* Re-read DOM state from the same selector Stagehand acted upon and compare
|
|
3921
3971
|
* against what it tried to write. Falls back to `false` on any locator error
|
|
3922
3972
|
* so the navigation-class signal is still the deciding vote when this returns.
|
|
3973
|
+
*
|
|
3974
|
+
* `target` scopes both the locator/evaluate reads and the jQuery-change
|
|
3975
|
+
* dispatch to the resolved frame (main or a cross-origin child).
|
|
3923
3976
|
*/
|
|
3924
|
-
async function verifyDomEffect(
|
|
3977
|
+
async function verifyDomEffect(target, action) {
|
|
3925
3978
|
const selector = action.selector;
|
|
3926
3979
|
const method = action.method;
|
|
3927
3980
|
if (!selector || !method)
|
|
3928
3981
|
return false;
|
|
3929
3982
|
try {
|
|
3930
|
-
const locator =
|
|
3983
|
+
const locator = target.locator(selector).first();
|
|
3931
3984
|
switch (method) {
|
|
3932
3985
|
case "fill":
|
|
3933
3986
|
case "type": {
|
|
@@ -3943,7 +3996,7 @@ async function verifyDomEffect(page, action) {
|
|
|
3943
3996
|
// delegated handler) record the value
|
|
3944
3997
|
// into their internal data model. Without this, the SPA's next
|
|
3945
3998
|
// re-render wipes the typed value back to empty.
|
|
3946
|
-
await dispatchJqueryChangeEvent(
|
|
3999
|
+
await dispatchJqueryChangeEvent(target, selector);
|
|
3947
4000
|
// Angular reactive forms (e.g. ADP WOTC questionnaire on tcs.adp.com)
|
|
3948
4001
|
// don't pick up CDP Input.insertText OR dispatchEvent('input') —
|
|
3949
4002
|
// their FormControl model updates require real keyboard events.
|
|
@@ -3991,7 +4044,7 @@ async function verifyDomEffect(page, action) {
|
|
|
3991
4044
|
const expr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); const el = r.singleNodeValue; if (!el || el.tagName !== "SELECT") return null; const opt = el.options[el.selectedIndex]; if (!opt) return { value: "", label: "", text: "" }; return { value: (opt.value || "").trim(), label: (opt.label || "").trim(), text: (opt.textContent || "").trim() }; })()`;
|
|
3992
4045
|
let selected = null;
|
|
3993
4046
|
try {
|
|
3994
|
-
const result = await
|
|
4047
|
+
const result = await target.evaluate(expr);
|
|
3995
4048
|
if (result !== null &&
|
|
3996
4049
|
typeof result === "object" &&
|
|
3997
4050
|
"value" in result &&
|
|
@@ -4032,7 +4085,7 @@ async function verifyDomEffect(page, action) {
|
|
|
4032
4085
|
// Node-side typechecking doesn't choke on the browser globals
|
|
4033
4086
|
// `document`/`XPathResult`.
|
|
4034
4087
|
const expr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); const el = r.singleNodeValue; return el ? (el.type || null) : null; })()`;
|
|
4035
|
-
const result = await
|
|
4088
|
+
const result = await target.evaluate(expr);
|
|
4036
4089
|
inputType = typeof result === "string" ? result : null;
|
|
4037
4090
|
}
|
|
4038
4091
|
catch {
|
|
@@ -4066,7 +4119,7 @@ async function verifyDomEffect(page, action) {
|
|
|
4066
4119
|
}
|
|
4067
4120
|
return false;
|
|
4068
4121
|
})()`;
|
|
4069
|
-
const ancestorStillInvalid = await
|
|
4122
|
+
const ancestorStillInvalid = await target.evaluate(ancestorInvalidExpr).catch(() => false);
|
|
4070
4123
|
return !ancestorStillInvalid;
|
|
4071
4124
|
}
|
|
4072
4125
|
default:
|
|
@@ -4329,9 +4382,9 @@ function formatStepPrefix(stepIndex, totalSteps) {
|
|
|
4329
4382
|
return total === undefined ? `step ${stepIndex + 1}` : `step ${stepIndex + 1}/${total}`;
|
|
4330
4383
|
}
|
|
4331
4384
|
async function probeFormValidityBeforeSubmit(params) {
|
|
4332
|
-
const {
|
|
4385
|
+
const { target, stepIndex, totalSteps, logger } = params;
|
|
4333
4386
|
try {
|
|
4334
|
-
const raw = await
|
|
4387
|
+
const raw = await target.evaluate(FORM_VALIDITY_PROBE_EXPR);
|
|
4335
4388
|
if (!Array.isArray(raw))
|
|
4336
4389
|
return [];
|
|
4337
4390
|
const out = [];
|
|
@@ -4363,11 +4416,15 @@ async function probeFormValidityBeforeSubmit(params) {
|
|
|
4363
4416
|
* candidates for a declarative "Fill in X" step even when the field is present —
|
|
4364
4417
|
* so a 0-candidate focused result falls back to an unfocused observe before the
|
|
4365
4418
|
* step is declared "absent". Exported for tests.
|
|
4419
|
+
*
|
|
4420
|
+
* `frameTarget` scopes both observe calls to a resolved cross-origin child
|
|
4421
|
+
* frame when the flow declared `frameSelector`; omitted (main frame) is
|
|
4422
|
+
* byte-identical to today's unscoped calls.
|
|
4366
4423
|
*/
|
|
4367
4424
|
async function probeStepBeforeAttempts(params) {
|
|
4368
|
-
const { stagehand, step, stepIndex, totalSteps, logger, captureFn } = params;
|
|
4425
|
+
const { stagehand, step, stepIndex, totalSteps, logger, captureFn, frameTarget } = params;
|
|
4369
4426
|
try {
|
|
4370
|
-
const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
|
|
4427
|
+
const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget);
|
|
4371
4428
|
if (candidates.length === 0) {
|
|
4372
4429
|
// Focused observe under-returns on some controlled-component forms:
|
|
4373
4430
|
// Stagehand's instruction-scoped observe can resolve zero candidates for a
|
|
@@ -4380,7 +4437,7 @@ async function probeStepBeforeAttempts(params) {
|
|
|
4380
4437
|
// to the cascade (its observe-act / rephrase attempts recover the field
|
|
4381
4438
|
// without spending the scarce replan budget). Only genuinely blank pages —
|
|
4382
4439
|
// where the unfocused observe is also empty — stay "absent".
|
|
4383
|
-
const unfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
|
|
4440
|
+
const unfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget);
|
|
4384
4441
|
if (unfocused.length > 0) {
|
|
4385
4442
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: focused probe found 0 candidates but unfocused observe found ${unfocused.length} — treating as present (let cascade resolve)`);
|
|
4386
4443
|
return "present";
|
|
@@ -4399,7 +4456,7 @@ async function probeStepBeforeAttempts(params) {
|
|
|
4399
4456
|
}
|
|
4400
4457
|
}
|
|
4401
4458
|
async function executeStepWithHealing(params) {
|
|
4402
|
-
const { stagehand, page, step, optional, upload, submitStep, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, logger, captureFn, resumeFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, } = params;
|
|
4459
|
+
const { stagehand, page, frameTarget, step, optional, upload, submitStep, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, logger, captureFn, resumeFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, } = params;
|
|
4403
4460
|
// Read-once to suppress "unused" — knownErrorClassPrefixes is threaded
|
|
4404
4461
|
// through executeStepWithHealing's signature so the cascade has it in
|
|
4405
4462
|
// scope when the invalid-fields judge migration (Task #43) lands. The
|
|
@@ -4433,6 +4490,7 @@ async function executeStepWithHealing(params) {
|
|
|
4433
4490
|
// the existing cascade.
|
|
4434
4491
|
if (await tryUploadPrimitive({
|
|
4435
4492
|
page,
|
|
4493
|
+
target: await (0, frame_target_1.resolveFrameTarget)(page),
|
|
4436
4494
|
isUploadStep: upload,
|
|
4437
4495
|
fixture: resumeFixture,
|
|
4438
4496
|
logger,
|
|
@@ -4449,7 +4507,20 @@ async function executeStepWithHealing(params) {
|
|
|
4449
4507
|
// cascade would otherwise skip them ("no candidates") and leave a required
|
|
4450
4508
|
// question unanswered. No-op (returns false → falls through) when the step
|
|
4451
4509
|
// isn't a single-dropdown select or no option matches.
|
|
4452
|
-
|
|
4510
|
+
//
|
|
4511
|
+
// resolveFrameTarget(page) resolves synchronously to the main-frame target
|
|
4512
|
+
// when no frameSelector is set, so this bridge is behavior-identical for
|
|
4513
|
+
// every existing site until the sibling subtask threads a resolved target
|
|
4514
|
+
// through end-to-end.
|
|
4515
|
+
const selectFrameTarget = await (0, frame_target_1.resolveFrameTarget)(page);
|
|
4516
|
+
if (await trySelectPrimitive({
|
|
4517
|
+
page,
|
|
4518
|
+
target: selectFrameTarget,
|
|
4519
|
+
instruction: step,
|
|
4520
|
+
logger,
|
|
4521
|
+
anthropic,
|
|
4522
|
+
captureFn,
|
|
4523
|
+
})) {
|
|
4453
4524
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by select primitive`);
|
|
4454
4525
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4455
4526
|
return "completed";
|
|
@@ -4459,7 +4530,14 @@ async function executeStepWithHealing(params) {
|
|
|
4459
4530
|
// screening questions this way — answer it directly in the DOM. Runs AFTER
|
|
4460
4531
|
// trySelectPrimitive (which handles <select> and no-ops on checkbox-only
|
|
4461
4532
|
// pages). No-op (falls through) when there's no checkbox group or no match.
|
|
4462
|
-
if (await tryCheckboxPrimitive({
|
|
4533
|
+
if (await tryCheckboxPrimitive({
|
|
4534
|
+
page,
|
|
4535
|
+
target: selectFrameTarget,
|
|
4536
|
+
instruction: step,
|
|
4537
|
+
logger,
|
|
4538
|
+
anthropic,
|
|
4539
|
+
captureFn,
|
|
4540
|
+
})) {
|
|
4463
4541
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by checkbox primitive`);
|
|
4464
4542
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4465
4543
|
return "completed";
|
|
@@ -4470,7 +4548,14 @@ async function executeStepWithHealing(params) {
|
|
|
4470
4548
|
// reach the observe cascade's el.click() fallback that fails to commit MUI/
|
|
4471
4549
|
// React controlled state (the wizard ATS's Basic-Info Step-2 wall). No-op (falls
|
|
4472
4550
|
// through) when there's no radio group or no confident option match.
|
|
4473
|
-
if (await tryRadioPrimitive({
|
|
4551
|
+
if (await tryRadioPrimitive({
|
|
4552
|
+
page,
|
|
4553
|
+
target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
|
|
4554
|
+
instruction: step,
|
|
4555
|
+
logger,
|
|
4556
|
+
anthropic,
|
|
4557
|
+
captureFn,
|
|
4558
|
+
})) {
|
|
4474
4559
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by radio primitive`);
|
|
4475
4560
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4476
4561
|
return "completed";
|
|
@@ -4481,7 +4566,14 @@ async function executeStepWithHealing(params) {
|
|
|
4481
4566
|
// step targets (requisition-specific specialty questions). Runs only on the
|
|
4482
4567
|
// catch-all (parseSelectStep returns null there, so trySelectPrimitive above
|
|
4483
4568
|
// skipped it) and no-ops when the page has no required-empty select.
|
|
4484
|
-
if (await tryFillRequiredSelectsPrimitive({
|
|
4569
|
+
if (await tryFillRequiredSelectsPrimitive({
|
|
4570
|
+
page,
|
|
4571
|
+
target: selectFrameTarget,
|
|
4572
|
+
instruction: step,
|
|
4573
|
+
logger,
|
|
4574
|
+
anthropic,
|
|
4575
|
+
captureFn,
|
|
4576
|
+
})) {
|
|
4485
4577
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by required-select primitive`);
|
|
4486
4578
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4487
4579
|
return "completed";
|
|
@@ -4501,6 +4593,7 @@ async function executeStepWithHealing(params) {
|
|
|
4501
4593
|
totalSteps,
|
|
4502
4594
|
logger,
|
|
4503
4595
|
captureFn,
|
|
4596
|
+
frameTarget,
|
|
4504
4597
|
});
|
|
4505
4598
|
if (probeResult === "absent") {
|
|
4506
4599
|
if (optional) {
|
|
@@ -4508,7 +4601,7 @@ async function executeStepWithHealing(params) {
|
|
|
4508
4601
|
// control this step was meant to answer (SPA hydration lag / observe
|
|
4509
4602
|
// can't resolve the widget) — skipping would leave a required field empty
|
|
4510
4603
|
// and silently doom the later submit. Fall through to the cascade instead.
|
|
4511
|
-
if (await hasUnfilledRequiredControlForStep(page, step)) {
|
|
4604
|
+
if (await hasUnfilledRequiredControlForStep(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), step)) {
|
|
4512
4605
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} probe-absent but a required unfilled control matches the question; NOT skipping (escalating to cascade)`);
|
|
4513
4606
|
}
|
|
4514
4607
|
else {
|
|
@@ -4553,19 +4646,19 @@ async function executeStepWithHealing(params) {
|
|
|
4553
4646
|
// burns the replan budget in seconds. Embed `see <path>` in the
|
|
4554
4647
|
// throw message so the existing regex at the dispatcher (`/see
|
|
4555
4648
|
// (\/[^\s]+)$/`) extracts dumpPath for replanRemainingFlow.
|
|
4556
|
-
const pageTitle = await page
|
|
4557
|
-
const bodyOuterHtmlRaw = await page
|
|
4649
|
+
const { pageTitle, pageUrl } = await resolveDumpPageIdentity(page, frameTarget);
|
|
4650
|
+
const bodyOuterHtmlRaw = await (frameTarget ?? page)
|
|
4558
4651
|
.evaluate("document.body ? document.body.outerHTML : null")
|
|
4559
4652
|
.catch(() => null);
|
|
4560
4653
|
const bodyOuterHtml = typeof bodyOuterHtmlRaw === "string" ? bodyOuterHtmlRaw.slice(0, 100_000) : null;
|
|
4561
|
-
const unfocusedObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn).catch(() => []);
|
|
4654
|
+
const unfocusedObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
|
|
4562
4655
|
const dumpPath = onStepFailure?.({
|
|
4563
4656
|
stepIndex,
|
|
4564
4657
|
phase,
|
|
4565
4658
|
originalStep: step,
|
|
4566
4659
|
attempts: [],
|
|
4567
4660
|
finalObserve: [],
|
|
4568
|
-
pageUrl
|
|
4661
|
+
pageUrl,
|
|
4569
4662
|
pageTitle,
|
|
4570
4663
|
recentCaptures,
|
|
4571
4664
|
bodyOuterHtml,
|
|
@@ -4591,10 +4684,12 @@ async function executeStepWithHealing(params) {
|
|
|
4591
4684
|
// form-validity auto-picker runs. The early-exit predicate compares this
|
|
4592
4685
|
// to the post-attempt-1 count to detect "the click revealed NEW required
|
|
4593
4686
|
// fields" — a state attempts 2-5 mathematically can't clear.
|
|
4594
|
-
const preSubmitInvalidCount = requireSubmitEndpoint
|
|
4687
|
+
const preSubmitInvalidCount = requireSubmitEndpoint
|
|
4688
|
+
? await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page))
|
|
4689
|
+
: 0;
|
|
4595
4690
|
if (requireSubmitEndpoint) {
|
|
4596
4691
|
const invalidControls = await probeFormValidityBeforeSubmit({
|
|
4597
|
-
page,
|
|
4692
|
+
target: await (0, frame_target_1.resolveFrameTarget)(page),
|
|
4598
4693
|
stepIndex,
|
|
4599
4694
|
totalSteps,
|
|
4600
4695
|
logger,
|
|
@@ -4695,7 +4790,7 @@ async function executeStepWithHealing(params) {
|
|
|
4695
4790
|
if (attempt > 1) {
|
|
4696
4791
|
await page.waitForTimeout(attempt * ATTEMPT_BACKOFF_MS);
|
|
4697
4792
|
}
|
|
4698
|
-
const pre = await snapshotPage(page, signalCounter);
|
|
4793
|
+
const pre = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
|
|
4699
4794
|
// Snapshot the meta-tail length so the final-step pattern gate can scope
|
|
4700
4795
|
// its URL scan to captures added DURING this attempt (not historical
|
|
4701
4796
|
// tail from earlier steps).
|
|
@@ -4778,7 +4873,7 @@ async function executeStepWithHealing(params) {
|
|
|
4778
4873
|
for (let deepAttempt = 1; deepAttempt <= 2; deepAttempt++) {
|
|
4779
4874
|
let ranked;
|
|
4780
4875
|
try {
|
|
4781
|
-
ranked = (await page.evaluate((0, submit_control_1.buildRankSubmitCandidatesExpr)()));
|
|
4876
|
+
ranked = (await (frameTarget ?? page).evaluate((0, submit_control_1.buildRankSubmitCandidatesExpr)()));
|
|
4782
4877
|
}
|
|
4783
4878
|
catch (err) {
|
|
4784
4879
|
// A thrown evaluate (page navigated away / frame detached) is not
|
|
@@ -4798,7 +4893,7 @@ async function executeStepWithHealing(params) {
|
|
|
4798
4893
|
triedSelectors.push(`deep-index:${top.deepIndex}`);
|
|
4799
4894
|
let clickResult;
|
|
4800
4895
|
try {
|
|
4801
|
-
clickResult = (await page.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex)));
|
|
4896
|
+
clickResult = (await (frameTarget ?? page).evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex)));
|
|
4802
4897
|
}
|
|
4803
4898
|
catch (err) {
|
|
4804
4899
|
record.errorMessage = `deep-submit-locator: click evaluate threw ${(0, errors_1.toErrorMessage)(err)}`;
|
|
@@ -4828,7 +4923,7 @@ async function executeStepWithHealing(params) {
|
|
|
4828
4923
|
// burn the step budget probing all of them.
|
|
4829
4924
|
const runnerUp = ranked[1];
|
|
4830
4925
|
if (runnerUp) {
|
|
4831
|
-
const midPost = await snapshotPage(page, signalCounter);
|
|
4926
|
+
const midPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
|
|
4832
4927
|
const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
|
|
4833
4928
|
actResultSuccess: true,
|
|
4834
4929
|
pre,
|
|
@@ -4842,7 +4937,7 @@ async function executeStepWithHealing(params) {
|
|
|
4842
4937
|
`deep-index:${runnerUp.deepIndex}`,
|
|
4843
4938
|
];
|
|
4844
4939
|
triedSelectors.push(`deep-index:${runnerUp.deepIndex}`);
|
|
4845
|
-
const runnerUpClickResult = (await page
|
|
4940
|
+
const runnerUpClickResult = (await (frameTarget ?? page)
|
|
4846
4941
|
.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))
|
|
4847
4942
|
.catch(() => ({ clicked: false })));
|
|
4848
4943
|
record.actResultSuccess = runnerUpClickResult.clicked;
|
|
@@ -4878,7 +4973,7 @@ async function executeStepWithHealing(params) {
|
|
|
4878
4973
|
const observeOptions = attempt === 4 && triedSelectors.length > 0
|
|
4879
4974
|
? { ignoreSelectors: [...triedSelectors], timeout: exports.STEP_WATCHDOG_MS }
|
|
4880
4975
|
: { timeout: exports.STEP_WATCHDOG_MS };
|
|
4881
|
-
const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, observeOptions, captureFn);
|
|
4976
|
+
const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, observeOptions, captureFn, frameTarget);
|
|
4882
4977
|
if (candidates.length === 0) {
|
|
4883
4978
|
record.errorMessage = "observe returned no candidates";
|
|
4884
4979
|
// Optional-step short-circuit: when attempt 2 confirms no candidates
|
|
@@ -4899,7 +4994,7 @@ async function executeStepWithHealing(params) {
|
|
|
4899
4994
|
// still-empty control matching this step's question is present,
|
|
4900
4995
|
// don't fast-skip — let the healing cascade continue so the
|
|
4901
4996
|
// required field gets answered instead of silently doomed.
|
|
4902
|
-
if (await hasUnfilledRequiredControlForStep(page, step)) {
|
|
4997
|
+
if (await hasUnfilledRequiredControlForStep(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), step)) {
|
|
4903
4998
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} no candidates after act+observe but a required unfilled control matches; NOT skipping (continuing cascade)`);
|
|
4904
4999
|
}
|
|
4905
5000
|
else {
|
|
@@ -4956,7 +5051,8 @@ async function executeStepWithHealing(params) {
|
|
|
4956
5051
|
target.arguments.length > 0) {
|
|
4957
5052
|
const fillValue = target.arguments[0];
|
|
4958
5053
|
if (typeof fillValue === "string") {
|
|
4959
|
-
const
|
|
5054
|
+
const frameTarget = await (0, frame_target_1.resolveFrameTarget)(page);
|
|
5055
|
+
const dateFill = await fillHtml5DateTimeInput(frameTarget, target.selector, fillValue);
|
|
4960
5056
|
if (dateFill !== null) {
|
|
4961
5057
|
record.errorMessage = dateFill.filled
|
|
4962
5058
|
? `html5-date-fallback: filled ${dateFill.inputType}="${dateFill.postValue}"`
|
|
@@ -4976,7 +5072,7 @@ async function executeStepWithHealing(params) {
|
|
|
4976
5072
|
// component rejection, masked-input library reformatting).
|
|
4977
5073
|
// Generic primitive that the verifier's existing signals
|
|
4978
5074
|
// (network/url/dom/htmlDelta/textChanged) miss.
|
|
4979
|
-
const readback = await verifyFillReadback(
|
|
5075
|
+
const readback = await verifyFillReadback(frameTarget, target.selector, fillValue);
|
|
4980
5076
|
if (readback !== null) {
|
|
4981
5077
|
if (readback.outcome === "rejected") {
|
|
4982
5078
|
record.errorMessage = `fill-value-rejected: tried "${fillValue.slice(0, 60)}" on <${readback.tag}>; element value remains empty (silent rejection — HTML5 type validation, framework controlled-component, or masked-input library)`;
|
|
@@ -5094,7 +5190,8 @@ async function executeStepWithHealing(params) {
|
|
|
5094
5190
|
return { resolved: true, isCheckable: true, checked: false, strategyUsed: null };
|
|
5095
5191
|
})()`;
|
|
5096
5192
|
try {
|
|
5097
|
-
const
|
|
5193
|
+
const structuredClickTarget = await (0, frame_target_1.resolveFrameTarget)(page);
|
|
5194
|
+
const result = await structuredClickTarget.evaluate(probeExpr);
|
|
5098
5195
|
if (result !== null && typeof result === "object" && "resolved" in result) {
|
|
5099
5196
|
const probe = result;
|
|
5100
5197
|
if (probe.resolved !== true || probe.isCheckable !== true) {
|
|
@@ -5148,7 +5245,7 @@ async function executeStepWithHealing(params) {
|
|
|
5148
5245
|
// Fetch live-page evidence so the rephrase prompt can reason about
|
|
5149
5246
|
// form state, not just observe candidates. Mirrors the same
|
|
5150
5247
|
// extraction the cascade-exhaust dump path already does.
|
|
5151
|
-
const livePageEvidence = await extractLivePageFormEvidence(page, {
|
|
5248
|
+
const livePageEvidence = await extractLivePageFormEvidence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), {
|
|
5152
5249
|
client: anthropic,
|
|
5153
5250
|
knownErrorClassPrefixes,
|
|
5154
5251
|
captureFn,
|
|
@@ -5208,7 +5305,7 @@ async function executeStepWithHealing(params) {
|
|
|
5208
5305
|
continue;
|
|
5209
5306
|
}
|
|
5210
5307
|
await page.waitForTimeout(STEP_PAUSE_MS);
|
|
5211
|
-
const post = await snapshotPage(page, signalCounter);
|
|
5308
|
+
const post = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
|
|
5212
5309
|
record.post = post;
|
|
5213
5310
|
if (resolvedAction) {
|
|
5214
5311
|
record.resolvedMethod = resolvedAction.method ?? null;
|
|
@@ -5225,7 +5322,7 @@ async function executeStepWithHealing(params) {
|
|
|
5225
5322
|
// false for non-radio/non-checkbox clicks so the network/URL signal still
|
|
5226
5323
|
// decides those. Radios/checkboxes are click-but-no-network just like fills.
|
|
5227
5324
|
const domVerified = resolvedAction !== null && (isStateClass || isClick)
|
|
5228
|
-
? await verifyDomEffect(page, resolvedAction)
|
|
5325
|
+
? await verifyDomEffect(await (0, frame_target_1.resolveFrameTarget)(page), resolvedAction)
|
|
5229
5326
|
: false;
|
|
5230
5327
|
// Interior-advance transition gate (opt-in). On SPAs where a page advance
|
|
5231
5328
|
// and a mere field-edit share one endpoint URL (the wizard ATS's `/gq`:
|
|
@@ -5318,7 +5415,8 @@ async function executeStepWithHealing(params) {
|
|
|
5318
5415
|
return null;
|
|
5319
5416
|
})()`;
|
|
5320
5417
|
try {
|
|
5321
|
-
|
|
5418
|
+
const submittedStateTarget = await (0, frame_target_1.resolveFrameTarget)(page);
|
|
5419
|
+
domSubmittedMatch = (await submittedStateTarget.evaluate(probeExpr));
|
|
5322
5420
|
}
|
|
5323
5421
|
catch (err) {
|
|
5324
5422
|
logger.warn(`submitted-state DOM probe threw: ${(0, errors_1.toErrorMessage)(err)} — judge will reason without it`);
|
|
@@ -5330,7 +5428,7 @@ async function executeStepWithHealing(params) {
|
|
|
5330
5428
|
// Quick invalid-marker count (deterministic DOM querying — counting
|
|
5331
5429
|
// structural ng-invalid containers is not fuzzy matching, just
|
|
5332
5430
|
// observing existence).
|
|
5333
|
-
const invalidMarkerCount = await countNgInvalidContainers(page).catch(() => 0);
|
|
5431
|
+
const invalidMarkerCount = await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).catch(() => 0);
|
|
5334
5432
|
const pageTitle = await page.title().catch(() => "");
|
|
5335
5433
|
const matchedSubmittedSelectors = domSubmittedMatch !== null ? [domSubmittedMatch] : [];
|
|
5336
5434
|
const judgeVerdict = await (0, verify_submit_1.verifySubmitWithLLM)({
|
|
@@ -5442,7 +5540,8 @@ async function executeStepWithHealing(params) {
|
|
|
5442
5540
|
// reliably trigger that default action — same gap N+42 documented
|
|
5443
5541
|
// for direct checkbox/radio clicks.
|
|
5444
5542
|
const clickExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); let el = r.singleNodeValue; if (!el || typeof el.click !== "function") return { fired: false }; if (el.tagName === "LABEL") { const wrapped = el.querySelector("input[type=checkbox], input[type=radio]"); if (wrapped) el = wrapped; } if (el.type === "checkbox" || el.type === "radio") { el.checked = true; el.dispatchEvent(new Event("click", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { fired: true, kind: "checkbox", checked: el.checked }; } el.click(); return { fired: true, kind: "click" }; })()`;
|
|
5445
|
-
const
|
|
5543
|
+
const n16FallbackTarget = await (0, frame_target_1.resolveFrameTarget)(page);
|
|
5544
|
+
const probeResult = (await n16FallbackTarget.evaluate(clickExpr));
|
|
5446
5545
|
const fired = probeResult.fired;
|
|
5447
5546
|
// Vacuous-click guard for the n+16 fallback. Same rationale as
|
|
5448
5547
|
// verifyDomEffect's click case: a checkbox/radio input's .checked
|
|
@@ -5465,7 +5564,7 @@ async function executeStepWithHealing(params) {
|
|
|
5465
5564
|
}
|
|
5466
5565
|
return false;
|
|
5467
5566
|
})()`;
|
|
5468
|
-
ancestorStillInvalid = (await
|
|
5567
|
+
ancestorStillInvalid = (await n16FallbackTarget
|
|
5469
5568
|
.evaluate(ancestorInvalidExpr)
|
|
5470
5569
|
.catch(() => false));
|
|
5471
5570
|
}
|
|
@@ -5473,7 +5572,7 @@ async function executeStepWithHealing(params) {
|
|
|
5473
5572
|
probeResult.checked === true &&
|
|
5474
5573
|
!ancestorStillInvalid;
|
|
5475
5574
|
await page.waitForTimeout(STEP_PAUSE_MS);
|
|
5476
|
-
const retryPost = await snapshotPage(page, signalCounter);
|
|
5575
|
+
const retryPost = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter);
|
|
5477
5576
|
const retryNetworkFired = retryPost.networkCount > pre.networkCount;
|
|
5478
5577
|
const retryUrlChanged = retryPost.url !== pre.url;
|
|
5479
5578
|
const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
|
|
@@ -5490,7 +5589,9 @@ async function executeStepWithHealing(params) {
|
|
|
5490
5589
|
// Confirmed no-op signature on the wizard ATS's COMPENSATION page (network=false
|
|
5491
5590
|
// url=false htmlDelta>0 textChanged) mis-scored verified=true(dom).
|
|
5492
5591
|
const clickWasDomOnly = probeResult.kind === "click" && !retryNetworkFired && !retryUrlChanged;
|
|
5493
|
-
const clickBlockedByInvalid = clickWasDomOnly &&
|
|
5592
|
+
const clickBlockedByInvalid = clickWasDomOnly &&
|
|
5593
|
+
(await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).catch(() => 0)) >
|
|
5594
|
+
0;
|
|
5494
5595
|
// Advance-transition gate (same as the primary verifier, applied to the
|
|
5495
5596
|
// n+16 fallback). RC2: for a non-submit ADVANCE/"Next" step (per the
|
|
5496
5597
|
// ORIGINAL instruction) the fallback's positive signals — a network POST
|
|
@@ -5553,14 +5654,14 @@ async function executeStepWithHealing(params) {
|
|
|
5553
5654
|
return null;
|
|
5554
5655
|
})()`;
|
|
5555
5656
|
try {
|
|
5556
|
-
domSubmittedMatch = (await page.evaluate(probeExpr));
|
|
5657
|
+
domSubmittedMatch = (await (frameTarget ?? page).evaluate(probeExpr));
|
|
5557
5658
|
}
|
|
5558
5659
|
catch (err) {
|
|
5559
5660
|
logger.warn(`n+16 submitted-state DOM probe threw: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
5560
5661
|
}
|
|
5561
5662
|
}
|
|
5562
|
-
const unfocusedForJudge = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn).catch(() => []);
|
|
5563
|
-
const invalidMarkerCount = await countNgInvalidContainers(page).catch(() => 0);
|
|
5663
|
+
const unfocusedForJudge = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
|
|
5664
|
+
const invalidMarkerCount = await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page)).catch(() => 0);
|
|
5564
5665
|
const pageTitle = await page.title().catch(() => "");
|
|
5565
5666
|
const matchedSubmittedSelectors = domSubmittedMatch !== null ? [domSubmittedMatch] : [];
|
|
5566
5667
|
const judgeVerdict = await (0, verify_submit_1.verifySubmitWithLLM)({
|
|
@@ -5670,7 +5771,7 @@ async function executeStepWithHealing(params) {
|
|
|
5670
5771
|
// dumps in a 2026-06-10 survey had the paired touched+dirty + visible
|
|
5671
5772
|
// error text pattern with 3 distinct rejection messages.
|
|
5672
5773
|
if (record.resolvedMethod === "click" && (isFinalStep || submitStep)) {
|
|
5673
|
-
const live = await extractLivePageFormEvidence(page, {
|
|
5774
|
+
const live = await extractLivePageFormEvidence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), {
|
|
5674
5775
|
client: anthropic,
|
|
5675
5776
|
knownErrorClassPrefixes,
|
|
5676
5777
|
captureFn,
|
|
@@ -5719,7 +5820,7 @@ async function executeStepWithHealing(params) {
|
|
|
5719
5820
|
: "non-submit step — leaving the normal structured-click/observe-act-exclude ladder intact";
|
|
5720
5821
|
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} phantom click detected on attempt 1 (${record.technique}): reported success with no network/url/dom change${suppressedCount !== undefined ? `; ${suppressedCount} AISDK elementId errors suppressed this session (corroborating, not causal)` : ""} — ${escalationTarget}`);
|
|
5721
5822
|
}
|
|
5722
|
-
const postAttemptInvalidCount = await countNgInvalidContainers(page);
|
|
5823
|
+
const postAttemptInvalidCount = await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page));
|
|
5723
5824
|
const earlyExit = isSubmitRevealedInvalid({
|
|
5724
5825
|
// Treat the canonical submit click as "final" for this predicate
|
|
5725
5826
|
// even when it lives mid-flow. See requireSubmitEndpoint derivation
|
|
@@ -5759,23 +5860,23 @@ async function executeStepWithHealing(params) {
|
|
|
5759
5860
|
}
|
|
5760
5861
|
}
|
|
5761
5862
|
}
|
|
5762
|
-
const finalObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn).catch(() => []);
|
|
5763
|
-
const pageTitle = await page
|
|
5863
|
+
const finalObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
|
|
5864
|
+
const { pageTitle, pageUrl } = await resolveDumpPageIdentity(page, frameTarget);
|
|
5764
5865
|
// Discriminator data for "Stagehand sees nothing" failures: capture the raw
|
|
5765
5866
|
// DOM and an unfocused observe so a triager can tell empty-page from
|
|
5766
5867
|
// Stagehand-can't-see-it without reproducing the failure.
|
|
5767
|
-
const bodyOuterHtmlRaw = await page
|
|
5868
|
+
const bodyOuterHtmlRaw = await (frameTarget ?? page)
|
|
5768
5869
|
.evaluate("document.body ? document.body.outerHTML : null")
|
|
5769
5870
|
.catch(() => null);
|
|
5770
5871
|
const bodyOuterHtml = typeof bodyOuterHtmlRaw === "string" ? bodyOuterHtmlRaw.slice(0, 100_000) : null;
|
|
5771
|
-
const unfocusedObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn).catch(() => []);
|
|
5872
|
+
const unfocusedObserve = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
|
|
5772
5873
|
const dumpPath = onStepFailure?.({
|
|
5773
5874
|
stepIndex,
|
|
5774
5875
|
phase,
|
|
5775
5876
|
originalStep: step,
|
|
5776
5877
|
attempts,
|
|
5777
5878
|
finalObserve,
|
|
5778
|
-
pageUrl
|
|
5879
|
+
pageUrl,
|
|
5779
5880
|
pageTitle,
|
|
5780
5881
|
recentCaptures,
|
|
5781
5882
|
bodyOuterHtml,
|
|
@@ -5850,6 +5951,8 @@ async function runHealingFlow(deps) {
|
|
|
5850
5951
|
let submitVerified = false;
|
|
5851
5952
|
let submitStepSkipped = false;
|
|
5852
5953
|
let lastStepIndex = -1;
|
|
5954
|
+
const frameTarget = await (0, frame_target_1.resolveFrameTarget)(page, deps.frameSelector);
|
|
5955
|
+
await (0, frame_target_1.waitForChildFrameReady)(frameTarget);
|
|
5853
5956
|
const stopCapture = wireSignalCapture(page, {
|
|
5854
5957
|
counter,
|
|
5855
5958
|
signalCounter,
|
|
@@ -5887,6 +5990,7 @@ async function runHealingFlow(deps) {
|
|
|
5887
5990
|
anthropic,
|
|
5888
5991
|
logger,
|
|
5889
5992
|
resumeFixture,
|
|
5993
|
+
frameTarget,
|
|
5890
5994
|
isFinalStep: i === steps.length - 1,
|
|
5891
5995
|
submitEndpointPattern: deps.submitEndpointPattern ?? null,
|
|
5892
5996
|
submittedStateSelectors: deps.submittedStateSelectors ?? [],
|