@enricai/barnacle 1.6.1 → 1.6.3
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/README.md +36 -29
- package/dist/api/errors.d.ts +3 -3
- package/dist/api/errors.d.ts.map +1 -1
- package/dist/api/errors.js +4 -4
- package/dist/api/errors.js.map +1 -1
- package/dist/api/helpers/reply.d.ts +1 -1
- package/dist/api/helpers/reply.js +1 -1
- package/dist/cache/keyed-ttl-cache.d.ts +4 -4
- package/dist/cache/keyed-ttl-cache.js +2 -2
- package/dist/lib/applicant-payload.d.ts +4 -4
- package/dist/lib/applicant-payload.js +4 -4
- package/dist/lib/application-address.d.ts +3 -3
- package/dist/lib/application-address.js +5 -5
- package/dist/lib/application-address.js.map +1 -1
- package/dist/lib/application-resume.d.ts +4 -4
- package/dist/lib/application-resume.js +4 -4
- package/dist/lib/dispatch-metrics.js +1 -1
- package/dist/lib/llm/judges/verify-submit.d.ts +1 -1
- package/dist/lib/llm/judges/verify-submit.js +1 -1
- package/dist/lib/llm/schemas.js +3 -3
- package/dist/lib/phone.d.ts +1 -1
- package/dist/lib/phone.js +1 -1
- package/dist/lib/telemetry/call-types.d.ts +1 -1
- package/dist/lib/telemetry/call-types.js +1 -1
- package/dist/lib/tracking-click.d.ts +5 -3
- package/dist/lib/tracking-click.d.ts.map +1 -1
- package/dist/lib/tracking-click.js +5 -3
- package/dist/lib/tracking-click.js.map +1 -1
- package/dist/lib/us-states.d.ts +1 -1
- package/dist/lib/us-states.js +1 -1
- package/dist/plugins/loader.js +2 -2
- package/dist/scraper/cookie-jar.d.ts +2 -2
- package/dist/scraper/cookie-jar.js +2 -2
- package/dist/scraper/deep-query.d.ts +36 -0
- package/dist/scraper/deep-query.d.ts.map +1 -0
- package/dist/scraper/deep-query.js +87 -0
- package/dist/scraper/deep-query.js.map +1 -0
- package/dist/scraper/errors.d.ts +12 -14
- package/dist/scraper/errors.d.ts.map +1 -1
- package/dist/scraper/errors.js +12 -17
- package/dist/scraper/errors.js.map +1 -1
- package/dist/scraper/flow-runner.d.ts +97 -30
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +304 -104
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scraper/http-client.d.ts +11 -1
- package/dist/scraper/http-client.d.ts.map +1 -1
- package/dist/scraper/http-client.js +90 -3
- package/dist/scraper/http-client.js.map +1 -1
- package/dist/scraper/phantom-click.d.ts +45 -0
- package/dist/scraper/phantom-click.d.ts.map +1 -0
- package/dist/scraper/phantom-click.js +37 -0
- package/dist/scraper/phantom-click.js.map +1 -0
- package/dist/scraper/raw-fetch.d.ts +2 -2
- package/dist/scraper/raw-fetch.js +1 -1
- package/dist/scraper/require-response-field.d.ts +1 -1
- package/dist/scraper/require-response-field.js +1 -1
- package/dist/scraper/session-browserbase.d.ts +48 -0
- package/dist/scraper/session-browserbase.d.ts.map +1 -1
- package/dist/scraper/session-browserbase.js +10 -5
- package/dist/scraper/session-browserbase.js.map +1 -1
- package/dist/scraper/session-shared.d.ts +6 -0
- package/dist/scraper/session-shared.d.ts.map +1 -1
- package/dist/scraper/session-shared.js.map +1 -1
- package/dist/scraper/session-warmup.d.ts +1 -1
- package/dist/scraper/session-warmup.js +1 -1
- package/dist/scraper/submit-control.d.ts +51 -0
- package/dist/scraper/submit-control.d.ts.map +1 -0
- package/dist/scraper/submit-control.js +142 -0
- package/dist/scraper/submit-control.js.map +1 -0
- package/dist/scripts/migrate-telemetry-dir-names.d.ts +2 -2
- package/dist/scripts/migrate-telemetry-dir-names.d.ts.map +1 -1
- package/dist/scripts/migrate-telemetry-dir-names.js +1 -1
- package/dist/scripts/recon-browser.d.ts +25 -20
- package/dist/scripts/recon-browser.d.ts.map +1 -1
- package/dist/scripts/recon-browser.js +65 -56
- package/dist/scripts/recon-browser.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +52 -7
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +73 -31
- package/dist/scripts/recon-generate.js.map +1 -1
- package/dist/scripts/recon-http.d.ts +36 -5
- package/dist/scripts/recon-http.d.ts.map +1 -1
- package/dist/scripts/recon-http.js +77 -47
- package/dist/scripts/recon-http.js.map +1 -1
- package/dist/scripts/recon-replay-jobs.d.ts +32 -3
- package/dist/scripts/recon-replay-jobs.d.ts.map +1 -1
- package/dist/scripts/recon-replay-jobs.js +46 -29
- package/dist/scripts/recon-replay-jobs.js.map +1 -1
- package/dist/scripts/recon-shared.d.ts +45 -0
- package/dist/scripts/recon-shared.d.ts.map +1 -1
- package/dist/scripts/recon-shared.js +117 -0
- package/dist/scripts/recon-shared.js.map +1 -1
- package/dist/scripts/recon-summarize.d.ts +5 -1
- package/dist/scripts/recon-summarize.d.ts.map +1 -1
- package/dist/scripts/recon-summarize.js +19 -7
- package/dist/scripts/recon-summarize.js.map +1 -1
- package/dist/site-plugin.d.ts +4 -4
- package/dist/testing/persona-fixture.d.ts +4 -4
- package/dist/testing/persona-fixture.d.ts.map +1 -1
- package/dist/testing/persona-fixture.js +4 -4
- package/dist/testing/persona-fixture.js.map +1 -1
- package/dist/types/dispatch-metrics.d.ts +1 -1
- package/dist/types/dispatch-metrics.js +1 -1
- package/package.json +1 -1
|
@@ -60,6 +60,7 @@ exports.chooseRequiredSelectOption = chooseRequiredSelectOption;
|
|
|
60
60
|
exports.buildRadioIdXPath = buildRadioIdXPath;
|
|
61
61
|
exports.selectRadioGroupOption = selectRadioGroupOption;
|
|
62
62
|
exports.narrowInvalidFormControl = narrowInvalidFormControl;
|
|
63
|
+
exports.formatStepPrefix = formatStepPrefix;
|
|
63
64
|
exports.probeStepBeforeAttempts = probeStepBeforeAttempts;
|
|
64
65
|
exports.executeStepWithHealing = executeStepWithHealing;
|
|
65
66
|
exports.waitForSpaReady = waitForSpaReady;
|
|
@@ -81,7 +82,9 @@ const logging_1 = require("../lib/logging");
|
|
|
81
82
|
const call_capture_1 = require("../lib/telemetry/call-capture");
|
|
82
83
|
const call_types_1 = require("../lib/telemetry/call-types");
|
|
83
84
|
const errors_2 = require("../scraper/errors");
|
|
85
|
+
const phantom_click_1 = require("../scraper/phantom-click");
|
|
84
86
|
const stagehand_guard_1 = require("../scraper/stagehand-guard");
|
|
87
|
+
const submit_control_1 = require("../scraper/submit-control");
|
|
85
88
|
const recon_shared_1 = require("../scripts/recon-shared");
|
|
86
89
|
const logger = (0, logging_1.getLogger)({ name: "scraper/flow-runner" });
|
|
87
90
|
/** Cap on the rolling capture-filename window held in memory for failure dumps. */
|
|
@@ -352,7 +355,7 @@ const SELECT_SETTLE_MS = 400;
|
|
|
352
355
|
/**
|
|
353
356
|
* Extra bounded poll window for a network-only advance whose real
|
|
354
357
|
* `TransitionWorklet(type="next")` POST lands AFTER the `STEP_PAUSE_MS`
|
|
355
|
-
* snapshot.
|
|
358
|
+
* snapshot. The wizard ATS's "Next" click fires a fast `WorkletPayload` autosave
|
|
356
359
|
* first, then the actual transition ~0.6-2s+ later — so a one-shot check
|
|
357
360
|
* false-negatives the advance, retries the click, and the stale retry fires a
|
|
358
361
|
* `back` that bounces the wizard. Additive to `STEP_PAUSE_MS`; only spent on an
|
|
@@ -426,7 +429,7 @@ exports.STEP_WATCHDOG_MS = 120_000;
|
|
|
426
429
|
* browser-context `page.evaluate` expression string, where a Node RegExp can't
|
|
427
430
|
* cross the boundary.
|
|
428
431
|
*
|
|
429
|
-
* Why the additions beyond the original Angular/Bootstrap set:
|
|
432
|
+
* Why the additions beyond the original Angular/Bootstrap set: the Angular/MUI
|
|
430
433
|
* wizard mixes Angular pages AND Material-UI (React) pages (Review, self-ID,
|
|
431
434
|
* COMPENSATION). MUI marks invalid controls with `Mui-error` (class) +
|
|
432
435
|
* `aria-invalid="true"` (attribute), NONE of which the ng-only regex matched —
|
|
@@ -493,11 +496,11 @@ async function snapshotPage(page, signalCounter) {
|
|
|
493
496
|
/**
|
|
494
497
|
* Detect whether a 2xx response body indicates the server REJECTED the
|
|
495
498
|
* application despite returning a 2xx HTTP status. Many ATSs use a "200 OK
|
|
496
|
-
* with rejection envelope" pattern instead of a 4xx:
|
|
499
|
+
* with rejection envelope" pattern instead of a 4xx: one JSON-envelope ATS returns
|
|
497
500
|
* `{not_qualified: true, error: "Not qualified reason: <field>"}`,
|
|
498
501
|
* Greenhouse uses `{rejected: true, reason: "..."}`, Lever uses
|
|
499
502
|
* `{qualified: false, reason: "..."}`, Workday uses
|
|
500
|
-
* `{status: "rejected"}`. Empirically verified on
|
|
503
|
+
* `{status: "rejected"}`. Empirically verified on a JSON-envelope ATS: 4/6 historical
|
|
501
504
|
* /integrated_apply 200s on this codebase had `not_qualified: true` and
|
|
502
505
|
* we treated them as wins because the audit only checked HTTP status.
|
|
503
506
|
*
|
|
@@ -685,7 +688,7 @@ function latestCaptureIndex(recentCaptures) {
|
|
|
685
688
|
* read straight from `capturesDir` — NOT from the in-memory `recentCaptures`
|
|
686
689
|
* array, which is front-evicted to `RECENT_CAPTURES_WINDOW` and therefore drops
|
|
687
690
|
* a step's transition when >20 captures flood during the step (measured 43
|
|
688
|
-
* across one
|
|
691
|
+
* across one wizard-ATS cascade). Scanning disk by filename index is eviction-proof:
|
|
689
692
|
* the transition file is always on disk regardless of array churn. `.decoded.json`
|
|
690
693
|
* sidecars are excluded (the raw file carries `requestPostData`). Sorted by index
|
|
691
694
|
* so callers scan in capture order. Returns [] when the dir is unreadable.
|
|
@@ -709,7 +712,7 @@ function capturesAfterIndex(preIdx, capturesDir) {
|
|
|
709
712
|
* Detects a multi-page-wizard RESTART / backward navigation by scanning the
|
|
710
713
|
* captures written during this step for a configured restart-signal pattern
|
|
711
714
|
* (e.g. `init-apply`, `application_canceled=true`). The restart signal is often
|
|
712
|
-
* a plain GET (
|
|
715
|
+
* a plain GET (the wizard ATS's `GET .../init-apply?...&application_canceled=true`),
|
|
713
716
|
* so it scans the raw capture files' `url` field (GETs are written to disk even
|
|
714
717
|
* though they're dropped from `recentCaptureMeta`). Window scoped by
|
|
715
718
|
* `preIdx` via {@link capturesAfterIndex} (eviction-proof). Returns the matching
|
|
@@ -719,7 +722,7 @@ function capturesAfterIndex(preIdx, capturesDir) {
|
|
|
719
722
|
* Does any same-window network capture's REQUEST BODY match the configured
|
|
720
723
|
* transition pattern? Proves an interior "advance"/"Next" step really moved the
|
|
721
724
|
* wizard forward when advance and non-advance mutations share one endpoint URL
|
|
722
|
-
* (e.g.
|
|
725
|
+
* (e.g. the wizard ATS's `/gq`: a real advance is a `TransitionWorklet` mutation, a
|
|
723
726
|
* field edit is `EditQuestionItem` — same URL, only the body differs, so a
|
|
724
727
|
* URL/meta-based check can't tell them apart). Window scoped by `preIdx` via
|
|
725
728
|
* {@link capturesAfterIndex} (disk-scan by filename index — eviction-proof, so a
|
|
@@ -730,7 +733,7 @@ function windowHasTransitionBody(params) {
|
|
|
730
733
|
const { preIdx, advanceTransitionBodyPattern } = params;
|
|
731
734
|
if (!advanceTransitionBodyPattern)
|
|
732
735
|
return false;
|
|
733
|
-
const capturesDir = params.capturesDir ?? recon_shared_1.
|
|
736
|
+
const capturesDir = params.capturesDir ?? (0, recon_shared_1.resolveReconRunDir)().graphqlDir;
|
|
734
737
|
let rx;
|
|
735
738
|
try {
|
|
736
739
|
rx = new RegExp(advanceTransitionBodyPattern);
|
|
@@ -758,7 +761,7 @@ function windowHasTransitionBody(params) {
|
|
|
758
761
|
* Stricter sibling of {@link windowHasTransitionBody}: a same-window capture
|
|
759
762
|
* whose request body matches the transition pattern AND whose parsed
|
|
760
763
|
* `variables.input.type === "next"`. The mutation NAME alone is a weak
|
|
761
|
-
* distinguisher — on
|
|
764
|
+
* distinguisher — on the wizard ATS a `back` bounce is ALSO a `TransitionWorklet`
|
|
762
765
|
* mutation (its body contains the pattern too) and would wrongly count as an
|
|
763
766
|
* advance; and the fast `WorkletPayload` autosave that precedes the real
|
|
764
767
|
* transition is a different mutation with no `input.type`. Requiring the parsed
|
|
@@ -772,7 +775,7 @@ function windowHasAdvanceTransition(params) {
|
|
|
772
775
|
const { preIdx, advanceTransitionBodyPattern } = params;
|
|
773
776
|
if (!advanceTransitionBodyPattern)
|
|
774
777
|
return false;
|
|
775
|
-
const capturesDir = params.capturesDir ?? recon_shared_1.
|
|
778
|
+
const capturesDir = params.capturesDir ?? (0, recon_shared_1.resolveReconRunDir)().graphqlDir;
|
|
776
779
|
let rx;
|
|
777
780
|
try {
|
|
778
781
|
rx = new RegExp(advanceTransitionBodyPattern);
|
|
@@ -805,7 +808,7 @@ function windowHasAdvanceTransition(params) {
|
|
|
805
808
|
* the RC2 gate is unit-testable.
|
|
806
809
|
*
|
|
807
810
|
* An interior "Next" on an SPA where an advance and a mere field-edit share one
|
|
808
|
-
* endpoint (
|
|
811
|
+
* endpoint (the wizard ATS's `/gq`: TransitionWorklet vs EditQuestionItem — same URL,
|
|
809
812
|
* different body) can fire a network POST that is NOT a real advance; the
|
|
810
813
|
* fallback's htmlDelta/textChanged/checked-radio signals are then validation
|
|
811
814
|
* re-renders / field toggles that don't move the wizard. So for an opted-in
|
|
@@ -830,7 +833,7 @@ function shouldVetoFallbackAdvance(params) {
|
|
|
830
833
|
* transition fired — a URL change OR a real `type=next` (`networkIsRealAdvance`).
|
|
831
834
|
* A DOM change alone is a validation re-render / field toggle that never moves
|
|
832
835
|
* the wizard. Crucially this must veto even when a NON-advancing network POST
|
|
833
|
-
* fired (
|
|
836
|
+
* fired (the wizard ATS's `WorkletPayload` autosave): keying the veto on "no network
|
|
834
837
|
* at all" let a rephrase that triggered an autosave + DOM reflow false-verify an
|
|
835
838
|
* advance, desyncing the flow from the wizard. Returns whether the DOM signal is
|
|
836
839
|
* ALLOWED to verify: false = veto it. Non-advance/field steps, sites without the
|
|
@@ -921,7 +924,7 @@ function describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLengt
|
|
|
921
924
|
* keeps healing opportunistically.
|
|
922
925
|
*/
|
|
923
926
|
function shouldSkipTechnique(params) {
|
|
924
|
-
const { technique, priorAttempts, advanceUnmovedAfterAttempt1 } = params;
|
|
927
|
+
const { technique, priorAttempts, advanceUnmovedAfterAttempt1, phantomClickAfterAttempt1, submitShapedStep, } = params;
|
|
925
928
|
// Unmoved-advance short-circuit (measured: attempts 2-4 recovered a stuck
|
|
926
929
|
// advance 0 times in 289 steps). When attempt-1's act-string clicked the Next
|
|
927
930
|
// and the wizard did NOT move forward (non-advancing POST, or no effect),
|
|
@@ -942,6 +945,31 @@ function shouldSkipTechnique(params) {
|
|
|
942
945
|
reason: "advance step did not move the wizard on attempt 1; re-observe/re-click cannot advance it — skipping to rephrase/replan",
|
|
943
946
|
};
|
|
944
947
|
}
|
|
948
|
+
// Phantom-click short-circuit: attempt 1 clicked something Stagehand
|
|
949
|
+
// believes exists, but pre/post shows zero network, zero URL change, and
|
|
950
|
+
// no real DOM growth — the click almost certainly landed on nothing (the
|
|
951
|
+
// recon-submit-phantom-click bug report's light-DOM resolver can't see
|
|
952
|
+
// into a shadow root / web component). Repeating observe-act /
|
|
953
|
+
// structured-click / observe-act-exclude re-resolves the SAME
|
|
954
|
+
// light-DOM-only view of the page and would no-op identically, so skip
|
|
955
|
+
// straight to deep-submit-locator (attempt 2) instead. llm-rephrase
|
|
956
|
+
// (attempt 5) is never skipped — a differently-worded instruction is still
|
|
957
|
+
// a distinct attempt worth trying if the deep locator also fails. Gated on
|
|
958
|
+
// submitShapedStep: the deep submit-control locator ranks submit-shaped
|
|
959
|
+
// candidates only, so on a non-submit control (e.g. a radio/checkbox) it
|
|
960
|
+
// is a guaranteed no-op — leave the normal ladder intact so
|
|
961
|
+
// structured-click / observe-act-exclude, the techniques that can
|
|
962
|
+
// actually click it, still run.
|
|
963
|
+
if (phantomClickAfterAttempt1 === true &&
|
|
964
|
+
submitShapedStep === true &&
|
|
965
|
+
(technique === "observe-act" ||
|
|
966
|
+
technique === "structured-click" ||
|
|
967
|
+
technique === "observe-act-exclude")) {
|
|
968
|
+
return {
|
|
969
|
+
skip: true,
|
|
970
|
+
reason: "attempt 1 was a phantom click (reported success, zero observable effect) on a submit-shaped step; re-observe/re-click cannot reach a target the light-DOM resolver can't see — escalating to the deep submit-control locator",
|
|
971
|
+
};
|
|
972
|
+
}
|
|
945
973
|
if (technique === "structured-click") {
|
|
946
974
|
const anyXpathResolved = priorAttempts.some((a) => a.triedSelectors.length > 0);
|
|
947
975
|
if (!anyXpathResolved) {
|
|
@@ -1013,7 +1041,7 @@ function isSubmitRevealedInvalid(params) {
|
|
|
1013
1041
|
* landed within the poll window (`networkIsRealAdvance` false), the button works
|
|
1014
1042
|
* but the wizard is refusing to advance (a precondition isn't met, e.g. a
|
|
1015
1043
|
* required field the flow answers on a LATER step). Re-clicking the same button
|
|
1016
|
-
* only re-fires the autosave / a `back` bounce — measured across
|
|
1044
|
+
* only re-fires the autosave / a `back` bounce — measured across wizard-ATS runs as the
|
|
1017
1045
|
* next→back oscillation. Break to replan instead, which can reorder a later step
|
|
1018
1046
|
* forward. Conservative: any condition unmet → run the full cascade as before.
|
|
1019
1047
|
* Never fires on final/submit steps (they own `isSubmitRevealedInvalid`) or on
|
|
@@ -1066,7 +1094,7 @@ async function rephraseWithLLM(client, originalStep, triedSelectors, observeCand
|
|
|
1066
1094
|
* invalid form fields and visible error messages so the rephrase LLM can
|
|
1067
1095
|
* propose corrective fills instead of
|
|
1068
1096
|
* just "click harder" — the previous limitation observed in the
|
|
1069
|
-
* telemetry of the
|
|
1097
|
+
* telemetry of the JSON-envelope ATS run (every rephrase converged on
|
|
1070
1098
|
* "Click Submit Application using JavaScript" because the prompt had no
|
|
1071
1099
|
* signal that the form was invalid).
|
|
1072
1100
|
*/
|
|
@@ -1411,8 +1439,8 @@ const BODY_EXCERPT_FORM_WINDOW = 16_000;
|
|
|
1411
1439
|
* Default: first 8KB. That window held for tenants whose form was at the
|
|
1412
1440
|
* top of the page (early sweeps in 2026-06).
|
|
1413
1441
|
*
|
|
1414
|
-
* For pages where the form HTML lives below 8KB (verified on
|
|
1415
|
-
*
|
|
1442
|
+
* For pages where the form HTML lives below 8KB (verified on an Angular
|
|
1443
|
+
* apply SPA: ng-invalid first appears at byte ~15,500 after a header
|
|
1416
1444
|
* of Angular hydration JS + chrome), the 8KB cap silently produced
|
|
1417
1445
|
* "FORM FIELDS CURRENTLY MARKED INVALID: (none)" in the replan prompt,
|
|
1418
1446
|
* leaving the LLM with no evidence and causing it to hallucinate steps
|
|
@@ -1423,7 +1451,7 @@ const BODY_EXCERPT_FORM_WINDOW = 16_000;
|
|
|
1423
1451
|
*
|
|
1424
1452
|
* Site-agnostic: the markers we look for (ng-invalid, mat-form-field-
|
|
1425
1453
|
* invalid, is-invalid, <form) are framework-level CSS-class conventions
|
|
1426
|
-
* used across countless SPAs, not
|
|
1454
|
+
* used across countless SPAs, not specific to any one ATS.
|
|
1427
1455
|
*/
|
|
1428
1456
|
function selectBodyExcerpt(body) {
|
|
1429
1457
|
if (body.length <= BODY_EXCERPT_DEFAULT_CAP)
|
|
@@ -1454,7 +1482,7 @@ async function extractLivePageFormEvidence(page, options) {
|
|
|
1454
1482
|
}
|
|
1455
1483
|
// Pick a body excerpt that's likely to contain the form's invalid-field
|
|
1456
1484
|
// markers. Default 8KB cap unless the body has ng-invalid / <form past
|
|
1457
|
-
// the cap (typical of
|
|
1485
|
+
// the cap (typical of an Angular apply SPA, where the form starts
|
|
1458
1486
|
// ~15KB into a page of Angular hydration scaffolding). See
|
|
1459
1487
|
// selectBodyExcerpt for details.
|
|
1460
1488
|
const bodyExcerpt = selectBodyExcerpt(body);
|
|
@@ -1520,7 +1548,7 @@ async function extractLivePageFormEvidence(page, options) {
|
|
|
1520
1548
|
* matches `ng-invalid` because of bubbling, but its child `<app-input
|
|
1521
1549
|
* class="ng-invalid">` ALSO matches; `:not(:has(...))` filters out the parent.
|
|
1522
1550
|
*
|
|
1523
|
-
*
|
|
1551
|
+
* A JSON-envelope ATS tenant's smoke (run 1781478440322) showed E1's prompt
|
|
1524
1552
|
* instruction ("prefer the leaf, not the bubbled parent") only got Haiku from
|
|
1525
1553
|
* 5 wrong fields → 1 wrong field — still surfaced `(unlabeled) <ol>` instead
|
|
1526
1554
|
* of `<app-input autocomplete="zip-code">` at byte 95,033. All 3 replans + 4
|
|
@@ -1710,8 +1738,9 @@ async function extractInteractiveTargetsNearInvalid(page) {
|
|
|
1710
1738
|
/**
|
|
1711
1739
|
* Scan recent capture files for failed submit-endpoint requests and pull
|
|
1712
1740
|
* out structured field-level errors from the response body. The cascade
|
|
1713
|
-
* already saves every captured request to
|
|
1714
|
-
*
|
|
1741
|
+
* already saves every captured request to the run's graphql capture dir
|
|
1742
|
+
* (see {@link resolveReconRunDir}) with its parsed `responseBody`; this
|
|
1743
|
+
* helper reads those files back, filters to captures
|
|
1715
1744
|
* matching the configured submit pattern with status >= 400, and walks
|
|
1716
1745
|
* common error-shape conventions (`{ errors: [{ field, message }] }`,
|
|
1717
1746
|
* `{ validation/fieldErrors: { … } }`, `{ message }`).
|
|
@@ -1728,7 +1757,7 @@ function extractSubmitFailureEvidence(recentCaptureFilenames,
|
|
|
1728
1757
|
* deterministic hostname equality. Empty list / "any-4xx" mode disables
|
|
1729
1758
|
* the host filter and returns any 4xx in the window.
|
|
1730
1759
|
*/
|
|
1731
|
-
ownBackendHostnames, capturesDir = recon_shared_1.
|
|
1760
|
+
ownBackendHostnames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir, mode = "strict") {
|
|
1732
1761
|
if (recentCaptureFilenames.length === 0)
|
|
1733
1762
|
return "";
|
|
1734
1763
|
if (mode === "strict" && ownBackendHostnames.length === 0)
|
|
@@ -1805,7 +1834,7 @@ ownBackendHostnames, capturesDir = recon_shared_1.CAPTURES_DIR, mode = "strict")
|
|
|
1805
1834
|
}
|
|
1806
1835
|
/**
|
|
1807
1836
|
* Surface Google Analytics Measurement Protocol events captured during a
|
|
1808
|
-
* step's attempt window.
|
|
1837
|
+
* step's attempt window. A JSON-envelope ATS (and most GA4-instrumented SPAs) emits
|
|
1809
1838
|
* `view_secondPage`, `view_thankYouPage`, `form_submit` and similar events
|
|
1810
1839
|
* via `https://www.google-analytics.com/g/collect` — the engine already
|
|
1811
1840
|
* stores these in `recentCaptures[]` but no code reads them. Without
|
|
@@ -1819,7 +1848,7 @@ ownBackendHostnames, capturesDir = recon_shared_1.CAPTURES_DIR, mode = "strict")
|
|
|
1819
1848
|
* Returns a numbered evidence list. Empty string when no GA collect
|
|
1820
1849
|
* captures are present. Advisory — never load-bearing.
|
|
1821
1850
|
*/
|
|
1822
|
-
function extractGaEventEvidence(recentCaptureFilenames, capturesDir = recon_shared_1.
|
|
1851
|
+
function extractGaEventEvidence(recentCaptureFilenames, capturesDir = (0, recon_shared_1.resolveReconRunDir)().graphqlDir) {
|
|
1823
1852
|
if (recentCaptureFilenames.length === 0)
|
|
1824
1853
|
return "";
|
|
1825
1854
|
const records = [];
|
|
@@ -2078,9 +2107,9 @@ function harvestFieldErrors(body) {
|
|
|
2078
2107
|
return [];
|
|
2079
2108
|
const out = [];
|
|
2080
2109
|
const rec = body;
|
|
2081
|
-
// Singular `{error: "message"}` shape used by
|
|
2110
|
+
// Singular `{error: "message"}` shape used by JSON-envelope ATSs, Lever, Greenhouse,
|
|
2082
2111
|
// and any REST API following the {error:string} terse-error convention.
|
|
2083
|
-
// Verified on
|
|
2112
|
+
// Verified on a JSON-envelope ATS tenant: /integrated_apply 422 body is
|
|
2084
2113
|
// exactly {"error":"Resume is blank"} — no `errors`, no `message`. Before
|
|
2085
2114
|
// J', this was caught by neither the array branch below nor the
|
|
2086
2115
|
// extractSubmitFailureEvidence fallback at line 2028, resulting in
|
|
@@ -2157,7 +2186,7 @@ const UPLOAD_NETWORK_POLL_INTERVAL_MS = 250;
|
|
|
2157
2186
|
/**
|
|
2158
2187
|
* How long the upload primitive waits for the async ResumeUpload widget (and its
|
|
2159
2188
|
* lazily-mounted `<input type=file>`) to render before deciding the input is
|
|
2160
|
-
* absent.
|
|
2189
|
+
* absent. The wizard ATS mounts the MUI/react-dropzone widget ~5s after the
|
|
2161
2190
|
* wizard lands on the Apply page, so a single probe races that mount and the
|
|
2162
2191
|
* primitive wrongly falls into the click-to-surface path (or skips). Only
|
|
2163
2192
|
* reached on `upload:true` steps, so it never slows a page with no upload step.
|
|
@@ -2167,7 +2196,7 @@ const UPLOAD_WIDGET_RENDER_ATTEMPTS = 17;
|
|
|
2167
2196
|
/**
|
|
2168
2197
|
* URL substrings that mark a POST as an actual resume/attachment upload rather
|
|
2169
2198
|
* than coincidental traffic (analytics beacons, `/interruption_check`,
|
|
2170
|
-
* geocoders).
|
|
2199
|
+
* geocoders). The wizard ATS posts the resume to an `attachment_upload_url` that
|
|
2171
2200
|
* matches `/attachment`; the others cover the other ATS upload sinks. Module-
|
|
2172
2201
|
* level so both the raw-input path and the click-to-surface path share one list.
|
|
2173
2202
|
*/
|
|
@@ -2206,7 +2235,7 @@ async function waitForUploadNetworkSignal(params) {
|
|
|
2206
2235
|
* True when a control's text/aria-label denotes a resume-upload affordance
|
|
2207
2236
|
* (the button that surfaces a hidden `<input type=file>` or opens a chooser).
|
|
2208
2237
|
* Pure + exported for unit tests; the vocabulary is intentionally generic so it
|
|
2209
|
-
* benefits any MUI/React/dropzone ATS, not just
|
|
2238
|
+
* benefits any MUI/React/dropzone ATS, not just the wizard ATS. Rejects negative
|
|
2210
2239
|
* lookalikes ("upload later", "no file", a bare "submit") so the click-to-
|
|
2211
2240
|
* surface path never fires a skip/decline/submit control.
|
|
2212
2241
|
*/
|
|
@@ -2280,7 +2309,7 @@ async function tryUploadPrimitive(params) {
|
|
|
2280
2309
|
return false;
|
|
2281
2310
|
}
|
|
2282
2311
|
if ((inputCount ?? 0) === 0) {
|
|
2283
|
-
//
|
|
2312
|
+
// MUI wizard and other react-dropzone widgets can render NO <input type=file>
|
|
2284
2313
|
// at all (a click surfaces it, or a native chooser opens). Try to surface it
|
|
2285
2314
|
// (click-to-mount or CDP native-chooser interception) before giving up.
|
|
2286
2315
|
logger.info("upload primitive: no <input type=file> after render wait; attempting click-to-surface");
|
|
@@ -2322,7 +2351,7 @@ async function attachToSurfacedInput(params) {
|
|
|
2322
2351
|
// ControlValueAccessor binds at the wrapper level — and the wrapper's
|
|
2323
2352
|
// change handler doesn't observe input.files mutations directly.
|
|
2324
2353
|
//
|
|
2325
|
-
// Verified on
|
|
2354
|
+
// Verified on a JSON-envelope ATS tenant: setInputFiles
|
|
2326
2355
|
// populated input.files but the subsequent /integrated_apply submit
|
|
2327
2356
|
// had no `apply[resume]` multipart field — the framework wrapper
|
|
2328
2357
|
// never registered the file in its internal state. Server returned
|
|
@@ -2385,7 +2414,7 @@ async function attachToSurfacedInput(params) {
|
|
|
2385
2414
|
}
|
|
2386
2415
|
/**
|
|
2387
2416
|
* Recover an upload for widgets that mount NO `<input type=file>` until a button
|
|
2388
|
-
* is clicked (
|
|
2417
|
+
* is clicked (the MUI wizard's `ResumeUpload`, react-dropzone). Ordered cheapest-
|
|
2389
2418
|
* first: (DZ) a synthetic drag-drop on the dropzone; then, arming CDP native-
|
|
2390
2419
|
* chooser interception BEFORE any click (a chooser-opening click with no
|
|
2391
2420
|
* interception blocks the run — the single biggest risk), click the upload
|
|
@@ -2572,7 +2601,7 @@ async function setFilesViaCdp(params) {
|
|
|
2572
2601
|
* Parse a select/dropdown flow step into the option to choose and (when
|
|
2573
2602
|
* present) the question label that scopes which dropdown it targets.
|
|
2574
2603
|
*
|
|
2575
|
-
* Why:
|
|
2604
|
+
* Why: the Angular/MUI wizard ATS renders dropdowns as `MuiNativeSelect` native `<select>`
|
|
2576
2605
|
* with `tabindex="-1"` — removed from the accessibility tree, so Stagehand
|
|
2577
2606
|
* observe returns `[]` and the cascade can never select an option. The select
|
|
2578
2607
|
* primitive answers these directly from the DOM, but needs the target option
|
|
@@ -2621,7 +2650,7 @@ function parseSelectStep(instruction) {
|
|
|
2621
2650
|
* Why this exists (sibling of `parseSelectStep`): `parseSelectStep`
|
|
2622
2651
|
* deliberately excludes bare radio steps ("a bare 'click the Yes answer' is a
|
|
2623
2652
|
* radio"), leaving radios with no DOM-direct primitive — they fall to the
|
|
2624
|
-
* observe cascade, which on
|
|
2653
|
+
* observe cascade, which on the wizard ATS's MUI radio markup resolves the step
|
|
2625
2654
|
* to a wrapper `<div>`/`<span>` (not the `<input type=radio>`) and commits via
|
|
2626
2655
|
* a bare `el.click()` that never triggers React's controlled-state `onChange`.
|
|
2627
2656
|
* The field stays `Mui-error` "required", Next no-ops, and the wizard walls at
|
|
@@ -2673,7 +2702,7 @@ const PRIMITIVE_ENUMERATE_ATTEMPTS = 5;
|
|
|
2673
2702
|
const PRIMITIVE_ENUMERATE_RETRY_MS = 600;
|
|
2674
2703
|
/**
|
|
2675
2704
|
* Run a primitive's read-only DOM enumerate with a bounded settle-retry. SPA
|
|
2676
|
-
* wizards
|
|
2705
|
+
* wizards frequently render the target widget a beat AFTER the flow
|
|
2677
2706
|
* step fires — the first evaluate sees an empty page, so the primitive would
|
|
2678
2707
|
* give up even though the widget appears moments later. Re-run the enumerate up
|
|
2679
2708
|
* to `PRIMITIVE_ENUMERATE_ATTEMPTS` times, waiting `PRIMITIVE_ENUMERATE_RETRY_MS`
|
|
@@ -2700,7 +2729,7 @@ async function pollEnumerate(page, expr, isPresent, opts) {
|
|
|
2700
2729
|
* Bounded poll for the real advance-transition POST to appear in this step's
|
|
2701
2730
|
* capture window. The verifiers snapshot once after `STEP_PAUSE_MS`, but the
|
|
2702
2731
|
* genuine `TransitionWorklet(type="next")` POST can land hundreds of ms to 2s+
|
|
2703
|
-
* AFTER that snapshot (
|
|
2732
|
+
* AFTER that snapshot (the wizard ATS fires a fast `WorkletPayload` autosave first). A
|
|
2704
2733
|
* one-shot check false-negatives the advance, retries the click, and the stale
|
|
2705
2734
|
* retry fires a `back` — a next→back oscillation that never leaves the page.
|
|
2706
2735
|
* Re-check {@link windowHasAdvanceTransition} every `intervalMs` until it matches
|
|
@@ -2734,7 +2763,7 @@ async function waitForTransitionBody(params) {
|
|
|
2734
2763
|
* Site-agnostic select primitive: answer a native `<select>` dropdown by
|
|
2735
2764
|
* directly setting its value in the DOM, bypassing Stagehand observe/act.
|
|
2736
2765
|
*
|
|
2737
|
-
* Why this exists (parallels `tryUploadPrimitive`):
|
|
2766
|
+
* Why this exists (parallels `tryUploadPrimitive`): MUI wizard dropdowns are
|
|
2738
2767
|
* `MuiNativeSelect` native `<select>` elements carrying `tabindex="-1"`, which
|
|
2739
2768
|
* removes them from the accessibility tree. Stagehand observe returns `[]` for
|
|
2740
2769
|
* them, so the cascade's `selectOption` (which needs a resolved locator) never
|
|
@@ -2764,7 +2793,7 @@ async function waitForTransitionBody(params) {
|
|
|
2764
2793
|
* validation tick can be awaited: MUI `NativeSelect` re-runs required-validation
|
|
2765
2794
|
* a beat after the synthetic `change`, so an immediate `sel.value === value`
|
|
2766
2795
|
* readback passes even when the FormControl still flags the field required (and
|
|
2767
|
-
* a later worklet re-render then wipes the DOM-only value — the exact
|
|
2796
|
+
* a later worklet re-render then wipes the DOM-only value — the exact wizard-ATS
|
|
2768
2797
|
* Job-Related failure). Returns `stillInvalid` so {@link trySelectPrimitive} can
|
|
2769
2798
|
* refuse to claim success on an uncommitted select, routing to the cascade/replan
|
|
2770
2799
|
* instead of silently advancing. Walks ≤6 ancestors for the invalid marker, same
|
|
@@ -2963,7 +2992,7 @@ const DECLINE_OPTION_MARKERS = [
|
|
|
2963
2992
|
* Pick an option to satisfy a REQUIRED select on a catch-all step, from the
|
|
2964
2993
|
* select's option TEXTS (placeholder already excluded upstream). Policy: take
|
|
2965
2994
|
* the first non-decline option (a plausible substantive answer — the operator
|
|
2966
|
-
* accepts LLM-plausible answers reaching
|
|
2995
|
+
* accepts LLM-plausible answers reaching ATS prod); fall back to the first
|
|
2967
2996
|
* option only if every option is a decline/placeholder. Returns null when there
|
|
2968
2997
|
* is nothing selectable. Pure + exported so the policy is unit-testable; the LLM
|
|
2969
2998
|
* path ({@link judgeSelectOptionWithLLM}) is preferred when a client is present,
|
|
@@ -3110,7 +3139,7 @@ async function tryFillRequiredSelectsPrimitive(params) {
|
|
|
3110
3139
|
* question by directly checking the matching option in the DOM, bypassing
|
|
3111
3140
|
* Stagehand observe/act.
|
|
3112
3141
|
*
|
|
3113
|
-
* Why this exists (parallels `trySelectPrimitive`):
|
|
3142
|
+
* Why this exists (parallels `trySelectPrimitive`): the Angular/MUI wizard ATS renders
|
|
3114
3143
|
* multi-select screening questions ("In which settings have you worked…",
|
|
3115
3144
|
* certifications) as `c-MultiCheckboxInput` groups — a `<fieldset>`/`<legend>`
|
|
3116
3145
|
* question with `<input type=checkbox>` options, each tied to its text by a
|
|
@@ -3278,7 +3307,7 @@ async function tryCheckboxPrimitive(params) {
|
|
|
3278
3307
|
}
|
|
3279
3308
|
/**
|
|
3280
3309
|
* Build an XPath predicate that matches an `<input>` by its `id`, safe for any
|
|
3281
|
-
* id value. MUI
|
|
3310
|
+
* id value. MUI wizard radio ids are base64-ish (no double-quote), so a plain
|
|
3282
3311
|
* quoted literal suffices — but if an id ever contains a `"`, fall back to
|
|
3283
3312
|
* `concat(...)` so the XPath stays valid. Pure + exported for unit tests.
|
|
3284
3313
|
*/
|
|
@@ -3293,7 +3322,7 @@ function buildRadioIdXPath(id) {
|
|
|
3293
3322
|
* positionally. Pure (no DOM/LLM) so it is unit-testable — the crux of the
|
|
3294
3323
|
* unlabeled-radio disambiguation.
|
|
3295
3324
|
*
|
|
3296
|
-
* Why this exists:
|
|
3325
|
+
* Why this exists: the wizard ATS's Basic Info has multiple UNLABELED yes/no groups
|
|
3297
3326
|
* (visa-sponsorship, common-domicile), answered by consecutive flow steps. The
|
|
3298
3327
|
* old in-browser matcher treated an unlabeled group (`label===""`) as matching
|
|
3299
3328
|
* ANY question (`"".includes(q)`/`q.includes("")===0`), so two unlabeled "No"
|
|
@@ -3435,12 +3464,12 @@ function selectRadioGroupOption(params) {
|
|
|
3435
3464
|
* observe/act.
|
|
3436
3465
|
*
|
|
3437
3466
|
* Why this exists (parallels `trySelectPrimitive`/`tryCheckboxPrimitive`):
|
|
3438
|
-
*
|
|
3467
|
+
* The Angular/MUI wizard ATS renders eligibility/screening questions as MUI radio groups.
|
|
3439
3468
|
* Stagehand observe resolves the step to a wrapper `<div>`/`<span>`, not the
|
|
3440
3469
|
* `<input type=radio>`, so the cascade's `el.click()` fallback fires on the
|
|
3441
3470
|
* wrapper (or sets `.checked` without triggering React's `onChange`) and the
|
|
3442
3471
|
* controlled value never commits — the field stays `Mui-error` "required", Next
|
|
3443
|
-
* no-ops, and the wizard walls (measured on
|
|
3472
|
+
* no-ops, and the wizard walls (measured on the wizard ATS: the "Are you at least 18?"
|
|
3444
3473
|
* radio was the sole unfilled field blocking Basic Information → Step 2 of 10).
|
|
3445
3474
|
* This primitive finds the radio group by raw DOM, matches the requested option
|
|
3446
3475
|
* by its `<label for>` text, and commits via the React-safe native `checked`
|
|
@@ -4123,7 +4152,7 @@ const FORM_VALIDITY_PROBE_EXPR = `(() => {
|
|
|
4123
4152
|
const leafInvalid = INVALID_CLASS_RX.test(ctrlClass);
|
|
4124
4153
|
// A <select> whose currently-selected option is .disabled is the
|
|
4125
4154
|
// Angular "Please select..." placeholder state. Some custom-element
|
|
4126
|
-
// dropdowns (e.g.
|
|
4155
|
+
// dropdowns (e.g. an Angular apply SPA's app-dropdown) bind a truthy sentinel value
|
|
4127
4156
|
// like "0: null" to the disabled placeholder, so ctrl.value !== ""
|
|
4128
4157
|
// even though no real option is chosen. Without this branch the empty
|
|
4129
4158
|
// check below silently drops every such wrapper and the pre-submit
|
|
@@ -4284,8 +4313,23 @@ function narrowInvalidFormControl(entry) {
|
|
|
4284
4313
|
autoFilled: narrowedAutoFilled,
|
|
4285
4314
|
};
|
|
4286
4315
|
}
|
|
4316
|
+
/**
|
|
4317
|
+
* Single source of truth for step-line prefixes. Exists because the cascade
|
|
4318
|
+
* here has only ever received `stepIndex`, while the orchestrator loop in
|
|
4319
|
+
* recon-browser owns the plan array — so half the step lines in a run printed
|
|
4320
|
+
* a `N/total` denominator and half printed a bare `N`.
|
|
4321
|
+
*
|
|
4322
|
+
* Takes a getter rather than a number: a global replan splices new steps into
|
|
4323
|
+
* the live plan array mid-run, so the total must be read when the line is
|
|
4324
|
+
* emitted, not when the step started. Callers with no total omit it and get
|
|
4325
|
+
* the bare form.
|
|
4326
|
+
*/
|
|
4327
|
+
function formatStepPrefix(stepIndex, totalSteps) {
|
|
4328
|
+
const total = totalSteps?.();
|
|
4329
|
+
return total === undefined ? `step ${stepIndex + 1}` : `step ${stepIndex + 1}/${total}`;
|
|
4330
|
+
}
|
|
4287
4331
|
async function probeFormValidityBeforeSubmit(params) {
|
|
4288
|
-
const { page, stepIndex, logger } = params;
|
|
4332
|
+
const { page, stepIndex, totalSteps, logger } = params;
|
|
4289
4333
|
try {
|
|
4290
4334
|
const raw = await page.evaluate(FORM_VALIDITY_PROBE_EXPR);
|
|
4291
4335
|
if (!Array.isArray(raw))
|
|
@@ -4299,15 +4343,15 @@ async function probeFormValidityBeforeSubmit(params) {
|
|
|
4299
4343
|
}
|
|
4300
4344
|
const autoCount = out.filter((e) => e.autoFilled !== null).length;
|
|
4301
4345
|
if (out.length > 0) {
|
|
4302
|
-
logger.info(
|
|
4346
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} pre-submit probe: ${out.length} ng-invalid form control(s) detected; empty=${out.filter((e) => e.emptyOrUnchecked).length}; auto-picked=${autoCount}`);
|
|
4303
4347
|
}
|
|
4304
4348
|
else {
|
|
4305
|
-
logger.info(
|
|
4349
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} pre-submit probe: no ng-invalid form controls detected`);
|
|
4306
4350
|
}
|
|
4307
4351
|
return out;
|
|
4308
4352
|
}
|
|
4309
4353
|
catch (err) {
|
|
4310
|
-
logger.warn(
|
|
4354
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} pre-submit probe threw: ${(0, errors_1.toErrorMessage)(err)} — proceeding without pre-flight evidence`);
|
|
4311
4355
|
return [];
|
|
4312
4356
|
}
|
|
4313
4357
|
}
|
|
@@ -4321,14 +4365,14 @@ async function probeFormValidityBeforeSubmit(params) {
|
|
|
4321
4365
|
* step is declared "absent". Exported for tests.
|
|
4322
4366
|
*/
|
|
4323
4367
|
async function probeStepBeforeAttempts(params) {
|
|
4324
|
-
const { stagehand, step, stepIndex, logger, captureFn } = params;
|
|
4368
|
+
const { stagehand, step, stepIndex, totalSteps, logger, captureFn } = params;
|
|
4325
4369
|
try {
|
|
4326
4370
|
const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
|
|
4327
4371
|
if (candidates.length === 0) {
|
|
4328
4372
|
// Focused observe under-returns on some controlled-component forms:
|
|
4329
4373
|
// Stagehand's instruction-scoped observe can resolve zero candidates for a
|
|
4330
4374
|
// declarative "Fill in the X field with 'Y'" step even when the field is
|
|
4331
|
-
// present and actionable (confirmed on
|
|
4375
|
+
// present and actionable (confirmed on the MUI/React wizard ATS's form — an
|
|
4332
4376
|
// unfocused observe enumerated every field and a direct act filled them).
|
|
4333
4377
|
// Treating focused-empty as hard "absent" skips the whole cascade and burns
|
|
4334
4378
|
// a global replan per step. Fall back to an UNFOCUSED observe as a
|
|
@@ -4338,24 +4382,24 @@ async function probeStepBeforeAttempts(params) {
|
|
|
4338
4382
|
// where the unfocused observe is also empty — stay "absent".
|
|
4339
4383
|
const unfocused = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: exports.STEP_WATCHDOG_MS }, captureFn);
|
|
4340
4384
|
if (unfocused.length > 0) {
|
|
4341
|
-
logger.info(
|
|
4385
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: focused probe found 0 candidates but unfocused observe found ${unfocused.length} — treating as present (let cascade resolve)`);
|
|
4342
4386
|
return "present";
|
|
4343
4387
|
}
|
|
4344
|
-
logger.info(
|
|
4388
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: probe found 0 candidates (focused and unfocused) — treating as absent (skip cascade, route to replan if required)`);
|
|
4345
4389
|
return "absent";
|
|
4346
4390
|
}
|
|
4347
|
-
logger.info(
|
|
4391
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)}: probe found ${candidates.length} candidate(s)`);
|
|
4348
4392
|
return "present";
|
|
4349
4393
|
}
|
|
4350
4394
|
catch (err) {
|
|
4351
4395
|
// Bias toward the existing behavior on errors: don't trigger a spurious
|
|
4352
4396
|
// replan when the probe itself is the broken thing.
|
|
4353
|
-
logger.warn(
|
|
4397
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)}: probe threw ${(0, errors_1.toErrorMessage)(err)} — treating as present (cascade will run)`);
|
|
4354
4398
|
return "present";
|
|
4355
4399
|
}
|
|
4356
4400
|
}
|
|
4357
4401
|
async function executeStepWithHealing(params) {
|
|
4358
|
-
const { stagehand, page, step, optional, upload, submitStep, stepIndex, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, logger, captureFn, resumeFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, trajectory, onStepFailure, } = 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;
|
|
4359
4403
|
// Read-once to suppress "unused" — knownErrorClassPrefixes is threaded
|
|
4360
4404
|
// through executeStepWithHealing's signature so the cascade has it in
|
|
4361
4405
|
// scope when the invalid-fields judge migration (Task #43) lands. The
|
|
@@ -4371,12 +4415,12 @@ async function executeStepWithHealing(params) {
|
|
|
4371
4415
|
// multi-signal evidence with strict prompting instead.
|
|
4372
4416
|
//
|
|
4373
4417
|
// Gate accepts (isFinalStep || submitStep): flows whose canonical Submit
|
|
4374
|
-
// click lives mid-list (e.g.
|
|
4418
|
+
// click lives mid-list (e.g. one JSON-envelope ATS flow has Submit at step 55/328 with
|
|
4375
4419
|
// 273 post-submit verification steps) opt in via the per-step
|
|
4376
4420
|
// `submitStep: true` flag in the flow file. Without this opt-in, the pre-
|
|
4377
4421
|
// submit DOM probe gated solely on `isFinalStep` never fires on the real
|
|
4378
4422
|
// Submit, and unfilled required fields produce silent submit failures
|
|
4379
|
-
// (verified 2026-06-15 on
|
|
4423
|
+
// (verified 2026-06-15 on one measured tenant's telemetry). Site-agnostic: any flow
|
|
4380
4424
|
// whose submit is mid-list can mark its submit step explicitly.
|
|
4381
4425
|
const requireSubmitEndpoint = (isFinalStep || submitStep) && submitEndpointPattern !== null;
|
|
4382
4426
|
const attempts = [];
|
|
@@ -4395,28 +4439,28 @@ async function executeStepWithHealing(params) {
|
|
|
4395
4439
|
signalCounter,
|
|
4396
4440
|
recentCaptureMeta,
|
|
4397
4441
|
})) {
|
|
4398
|
-
logger.info(
|
|
4442
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by upload primitive`);
|
|
4399
4443
|
trajectory?.push({ stepIndex, verifiedBy: "network" });
|
|
4400
4444
|
return "completed";
|
|
4401
4445
|
}
|
|
4402
4446
|
// When the step is a native-<select> dropdown selection, answer it directly
|
|
4403
4447
|
// in the DOM ahead of the cascade. Critical for MuiNativeSelect/tabindex=-1
|
|
4404
|
-
// dropdowns (
|
|
4448
|
+
// dropdowns (the MUI wizard ATS) that Stagehand observe can't surface — the
|
|
4405
4449
|
// cascade would otherwise skip them ("no candidates") and leave a required
|
|
4406
4450
|
// question unanswered. No-op (returns false → falls through) when the step
|
|
4407
4451
|
// isn't a single-dropdown select or no option matches.
|
|
4408
4452
|
if (await trySelectPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
|
|
4409
|
-
logger.info(
|
|
4453
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by select primitive`);
|
|
4410
4454
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4411
4455
|
return "completed";
|
|
4412
4456
|
}
|
|
4413
4457
|
// When the step is a "select 'X'" against a multi-select CHECKBOX group
|
|
4414
|
-
// (c-MultiCheckboxInput) rather than a <select> —
|
|
4458
|
+
// (c-MultiCheckboxInput) rather than a <select> — the wizard ATS renders some
|
|
4415
4459
|
// screening questions this way — answer it directly in the DOM. Runs AFTER
|
|
4416
4460
|
// trySelectPrimitive (which handles <select> and no-ops on checkbox-only
|
|
4417
4461
|
// pages). No-op (falls through) when there's no checkbox group or no match.
|
|
4418
4462
|
if (await tryCheckboxPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
|
|
4419
|
-
logger.info(
|
|
4463
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by checkbox primitive`);
|
|
4420
4464
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4421
4465
|
return "completed";
|
|
4422
4466
|
}
|
|
@@ -4424,10 +4468,10 @@ async function executeStepWithHealing(params) {
|
|
|
4424
4468
|
// the question '…'"), commit it directly in the DOM. Runs AFTER select/
|
|
4425
4469
|
// checkbox (which own their verbs) and BEFORE the cascade, so radios never
|
|
4426
4470
|
// reach the observe cascade's el.click() fallback that fails to commit MUI/
|
|
4427
|
-
// React controlled state (the
|
|
4471
|
+
// React controlled state (the wizard ATS's Basic-Info Step-2 wall). No-op (falls
|
|
4428
4472
|
// through) when there's no radio group or no confident option match.
|
|
4429
4473
|
if (await tryRadioPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
|
|
4430
|
-
logger.info(
|
|
4474
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by radio primitive`);
|
|
4431
4475
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4432
4476
|
return "completed";
|
|
4433
4477
|
}
|
|
@@ -4438,7 +4482,7 @@ async function executeStepWithHealing(params) {
|
|
|
4438
4482
|
// catch-all (parseSelectStep returns null there, so trySelectPrimitive above
|
|
4439
4483
|
// skipped it) and no-ops when the page has no required-empty select.
|
|
4440
4484
|
if (await tryFillRequiredSelectsPrimitive({ page, instruction: step, logger, anthropic, captureFn })) {
|
|
4441
|
-
logger.info(
|
|
4485
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by required-select primitive`);
|
|
4442
4486
|
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
4443
4487
|
return "completed";
|
|
4444
4488
|
}
|
|
@@ -4454,6 +4498,7 @@ async function executeStepWithHealing(params) {
|
|
|
4454
4498
|
stagehand,
|
|
4455
4499
|
step,
|
|
4456
4500
|
stepIndex,
|
|
4501
|
+
totalSteps,
|
|
4457
4502
|
logger,
|
|
4458
4503
|
captureFn,
|
|
4459
4504
|
});
|
|
@@ -4464,10 +4509,10 @@ async function executeStepWithHealing(params) {
|
|
|
4464
4509
|
// can't resolve the widget) — skipping would leave a required field empty
|
|
4465
4510
|
// and silently doom the later submit. Fall through to the cascade instead.
|
|
4466
4511
|
if (await hasUnfilledRequiredControlForStep(page, step)) {
|
|
4467
|
-
logger.info(
|
|
4512
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} probe-absent but a required unfilled control matches the question; NOT skipping (escalating to cascade)`);
|
|
4468
4513
|
}
|
|
4469
4514
|
else {
|
|
4470
|
-
logger.info(
|
|
4515
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (optional, probe found no candidates)`);
|
|
4471
4516
|
return "skipped";
|
|
4472
4517
|
}
|
|
4473
4518
|
}
|
|
@@ -4482,7 +4527,7 @@ async function executeStepWithHealing(params) {
|
|
|
4482
4527
|
preMetaLength: stepStartMetaLength,
|
|
4483
4528
|
});
|
|
4484
4529
|
if (transitionUrl !== null) {
|
|
4485
|
-
logger.info(
|
|
4530
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (probe absent but recent transition detected: ${transitionUrl})`);
|
|
4486
4531
|
trajectory?.push({ stepIndex, verifiedBy: "url" });
|
|
4487
4532
|
return "completed";
|
|
4488
4533
|
}
|
|
@@ -4497,8 +4542,8 @@ async function executeStepWithHealing(params) {
|
|
|
4497
4542
|
ownBackendHostnames,
|
|
4498
4543
|
});
|
|
4499
4544
|
if (backendErrorUrl !== null) {
|
|
4500
|
-
logger.error(
|
|
4501
|
-
throw new errors_2.StepVerificationError(
|
|
4545
|
+
logger.error(`${formatStepPrefix(stepIndex, totalSteps)} backend error detected (submit endpoint returned 5xx: ${backendErrorUrl}); aborting cascade`);
|
|
4546
|
+
throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
|
|
4502
4547
|
}
|
|
4503
4548
|
// Capture diagnostics + write a failure dump BEFORE throwing so the
|
|
4504
4549
|
// global replan path's `readFailureDumpEvidence` can populate the
|
|
@@ -4526,7 +4571,7 @@ async function executeStepWithHealing(params) {
|
|
|
4526
4571
|
bodyOuterHtml,
|
|
4527
4572
|
unfocusedObserve,
|
|
4528
4573
|
}) ?? null;
|
|
4529
|
-
throw new errors_2.StepVerificationError(
|
|
4574
|
+
throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
|
|
4530
4575
|
}
|
|
4531
4576
|
// Pre-submit form-validity probe. Fires on the canonical submit step
|
|
4532
4577
|
// (either the final flow step OR a step explicitly flagged `submitStep:
|
|
@@ -4551,6 +4596,7 @@ async function executeStepWithHealing(params) {
|
|
|
4551
4596
|
const invalidControls = await probeFormValidityBeforeSubmit({
|
|
4552
4597
|
page,
|
|
4553
4598
|
stepIndex,
|
|
4599
|
+
totalSteps,
|
|
4554
4600
|
logger,
|
|
4555
4601
|
});
|
|
4556
4602
|
for (const c of invalidControls) {
|
|
@@ -4592,6 +4638,7 @@ async function executeStepWithHealing(params) {
|
|
|
4592
4638
|
resolvedMethod: null,
|
|
4593
4639
|
resolvedArguments: null,
|
|
4594
4640
|
verifiedBy: null,
|
|
4641
|
+
phantomClickVerdict: null,
|
|
4595
4642
|
});
|
|
4596
4643
|
}
|
|
4597
4644
|
// Brief settle window after auto-picks so Angular's change-detection
|
|
@@ -4607,6 +4654,11 @@ async function executeStepWithHealing(params) {
|
|
|
4607
4654
|
// NOT move the wizard forward. Read at the top of attempts 2-4 to skip the
|
|
4608
4655
|
// proven-dead re-observe/re-click techniques (see shouldSkipTechnique).
|
|
4609
4656
|
let advanceUnmovedAfterAttempt1 = false;
|
|
4657
|
+
// Set after attempt 1: Stagehand reported success but pre/post shows zero
|
|
4658
|
+
// observable effect (see classifyPhantomClick). Reroutes attempt 2 to
|
|
4659
|
+
// deep-submit-locator instead of observe-act, since re-resolving via the
|
|
4660
|
+
// same light-DOM view cannot reach a target the resolver can't see.
|
|
4661
|
+
let phantomClickAfterAttempt1 = false;
|
|
4610
4662
|
for (let attempt = 1; attempt <= MAX_STEP_ATTEMPTS; attempt++) {
|
|
4611
4663
|
// Telemetry-driven technique-skip: when a cascade technique's
|
|
4612
4664
|
// preconditions cannot be met by the prior attempts' state, running
|
|
@@ -4615,7 +4667,9 @@ async function executeStepWithHealing(params) {
|
|
|
4615
4667
|
// techniques faster.
|
|
4616
4668
|
if (attempt > 1) {
|
|
4617
4669
|
const wouldBeTechnique = attempt === 2
|
|
4618
|
-
?
|
|
4670
|
+
? phantomClickAfterAttempt1 && (isFinalStep || submitStep)
|
|
4671
|
+
? "deep-submit-locator"
|
|
4672
|
+
: "observe-act"
|
|
4619
4673
|
: attempt === 3
|
|
4620
4674
|
? "structured-click"
|
|
4621
4675
|
: attempt === 4
|
|
@@ -4629,9 +4683,11 @@ async function executeStepWithHealing(params) {
|
|
|
4629
4683
|
errorMessage: a.errorMessage,
|
|
4630
4684
|
})),
|
|
4631
4685
|
advanceUnmovedAfterAttempt1,
|
|
4686
|
+
phantomClickAfterAttempt1,
|
|
4687
|
+
submitShapedStep: isFinalStep || submitStep,
|
|
4632
4688
|
});
|
|
4633
4689
|
if (decision.skip) {
|
|
4634
|
-
logger.info(
|
|
4690
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
|
|
4635
4691
|
failureReasons.push(`attempt ${attempt} skipped: ${decision.reason}`);
|
|
4636
4692
|
continue;
|
|
4637
4693
|
}
|
|
@@ -4662,6 +4718,7 @@ async function executeStepWithHealing(params) {
|
|
|
4662
4718
|
resolvedMethod: null,
|
|
4663
4719
|
resolvedArguments: null,
|
|
4664
4720
|
verifiedBy: null,
|
|
4721
|
+
phantomClickVerdict: null,
|
|
4665
4722
|
};
|
|
4666
4723
|
// First resolved action from Stagehand's `act` result — used to decide
|
|
4667
4724
|
// whether this attempt's signal should come from the network/URL pair or
|
|
@@ -4698,6 +4755,124 @@ async function executeStepWithHealing(params) {
|
|
|
4698
4755
|
}
|
|
4699
4756
|
}
|
|
4700
4757
|
}
|
|
4758
|
+
else if (attempt === 2 && phantomClickAfterAttempt1 && (isFinalStep || submitStep)) {
|
|
4759
|
+
// Deep submit-control locator: attempt 1 phantom-clicked (Stagehand
|
|
4760
|
+
// reported success but pre/post showed zero effect), so the target is
|
|
4761
|
+
// almost certainly unreachable via document.querySelectorAll — most
|
|
4762
|
+
// likely rendered inside an open shadow root by a web-component /
|
|
4763
|
+
// framework-native submit control (see recon-submit-phantom-click bug
|
|
4764
|
+
// report). Rank every submit-shaped candidate the deep traversal can
|
|
4765
|
+
// reach and click the top-ranked one; the ranking already excludes
|
|
4766
|
+
// Back/Cancel/Save-draft-shaped controls, so a false-positive submit
|
|
4767
|
+
// click cannot fire.
|
|
4768
|
+
//
|
|
4769
|
+
// Rank and click are two separate page.evaluate round trips over a
|
|
4770
|
+
// live Angular page, so any re-render between them shifts every
|
|
4771
|
+
// deepIndex — the click then lands on nothing (`{clicked: false}`),
|
|
4772
|
+
// not because the page is broken but because the snapshot the index
|
|
4773
|
+
// was computed from is already gone. That's a transient, self-clearing
|
|
4774
|
+
// condition, so re-rank against the CURRENT DOM and click once more
|
|
4775
|
+
// before giving up. Capped at one retry (two rank+click rounds total)
|
|
4776
|
+
// so a page re-rendering on every tick can't turn this into a loop.
|
|
4777
|
+
record.technique = "deep-submit-locator";
|
|
4778
|
+
for (let deepAttempt = 1; deepAttempt <= 2; deepAttempt++) {
|
|
4779
|
+
let ranked;
|
|
4780
|
+
try {
|
|
4781
|
+
ranked = (await page.evaluate((0, submit_control_1.buildRankSubmitCandidatesExpr)()));
|
|
4782
|
+
}
|
|
4783
|
+
catch (err) {
|
|
4784
|
+
// A thrown evaluate (page navigated away / frame detached) is not
|
|
4785
|
+
// a stale-index race — re-ranking a detached page will throw
|
|
4786
|
+
// again, so don't retry; record it and let the cascade move on.
|
|
4787
|
+
record.errorMessage = `deep-submit-locator: rank evaluate threw ${(0, errors_1.toErrorMessage)(err)}`;
|
|
4788
|
+
break;
|
|
4789
|
+
}
|
|
4790
|
+
if (ranked.length === 0) {
|
|
4791
|
+
record.errorMessage = "deep-submit-locator: no submit-shaped candidate found";
|
|
4792
|
+
break;
|
|
4793
|
+
}
|
|
4794
|
+
// biome-ignore lint/style/noNonNullAssertion: guarded by the length check above
|
|
4795
|
+
const top = ranked[0];
|
|
4796
|
+
record.instruction = `deep-submit-locator: ${top.tag} "${top.accessibleName}" (tier ${top.tier})`;
|
|
4797
|
+
record.triedSelectors = [`deep-index:${top.deepIndex}`];
|
|
4798
|
+
triedSelectors.push(`deep-index:${top.deepIndex}`);
|
|
4799
|
+
let clickResult;
|
|
4800
|
+
try {
|
|
4801
|
+
clickResult = (await page.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex)));
|
|
4802
|
+
}
|
|
4803
|
+
catch (err) {
|
|
4804
|
+
record.errorMessage = `deep-submit-locator: click evaluate threw ${(0, errors_1.toErrorMessage)(err)}`;
|
|
4805
|
+
break;
|
|
4806
|
+
}
|
|
4807
|
+
record.actResultSuccess = clickResult.clicked;
|
|
4808
|
+
record.actResultDescription = clickResult.clicked
|
|
4809
|
+
? `deep-submit-locator clicked ${top.tag} "${top.accessibleName}"`
|
|
4810
|
+
: "deep-submit-locator: candidate vanished before click (deepIndex stale)";
|
|
4811
|
+
if (clickResult.clicked) {
|
|
4812
|
+
// Synthesize a click action so downstream verification (network /
|
|
4813
|
+
// url / dom) treats this exactly like any other resolved click.
|
|
4814
|
+
resolvedAction = {
|
|
4815
|
+
selector: `deep-index:${top.deepIndex}`,
|
|
4816
|
+
description: record.actResultDescription,
|
|
4817
|
+
method: "click",
|
|
4818
|
+
};
|
|
4819
|
+
// Runner-up retry: the top pick can itself phantom-click (a
|
|
4820
|
+
// second web-component / shadow-root control that LOOKS
|
|
4821
|
+
// submit-shaped but doesn't wire a real handler — see
|
|
4822
|
+
// submit-control.ts's module docblock). Re-snapshot right here
|
|
4823
|
+
// and re-classify with the same classifyPhantomClick primitive
|
|
4824
|
+
// that escalated attempt 1, so a phantom top pick doesn't fall
|
|
4825
|
+
// through to attempt 3's structured-click, which can't reach a
|
|
4826
|
+
// shadow-root control either. Cap at ranked[1] only (not the
|
|
4827
|
+
// full list) so a page with many submit-shaped candidates can't
|
|
4828
|
+
// burn the step budget probing all of them.
|
|
4829
|
+
const runnerUp = ranked[1];
|
|
4830
|
+
if (runnerUp) {
|
|
4831
|
+
const midPost = await snapshotPage(page, signalCounter);
|
|
4832
|
+
const topVerdict = (0, phantom_click_1.classifyPhantomClick)({
|
|
4833
|
+
actResultSuccess: true,
|
|
4834
|
+
pre,
|
|
4835
|
+
post: midPost,
|
|
4836
|
+
});
|
|
4837
|
+
if (topVerdict === "phantom") {
|
|
4838
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} deep-submit-locator top pick (${top.tag} "${top.accessibleName}") phantom-clicked; retrying runner-up ${runnerUp.tag} "${runnerUp.accessibleName}" (tier ${runnerUp.tier})`);
|
|
4839
|
+
record.instruction = `deep-submit-locator: ${runnerUp.tag} "${runnerUp.accessibleName}" (tier ${runnerUp.tier}), runner-up after top pick ${top.tag} "${top.accessibleName}" phantomed`;
|
|
4840
|
+
record.triedSelectors = [
|
|
4841
|
+
`deep-index:${top.deepIndex}`,
|
|
4842
|
+
`deep-index:${runnerUp.deepIndex}`,
|
|
4843
|
+
];
|
|
4844
|
+
triedSelectors.push(`deep-index:${runnerUp.deepIndex}`);
|
|
4845
|
+
const runnerUpClickResult = (await page
|
|
4846
|
+
.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))
|
|
4847
|
+
.catch(() => ({ clicked: false })));
|
|
4848
|
+
record.actResultSuccess = runnerUpClickResult.clicked;
|
|
4849
|
+
record.actResultDescription = runnerUpClickResult.clicked
|
|
4850
|
+
? `deep-submit-locator clicked runner-up ${runnerUp.tag} "${runnerUp.accessibleName}" after top pick phantomed`
|
|
4851
|
+
: "deep-submit-locator: runner-up candidate vanished before click (deepIndex stale)";
|
|
4852
|
+
resolvedAction = runnerUpClickResult.clicked
|
|
4853
|
+
? {
|
|
4854
|
+
selector: `deep-index:${runnerUp.deepIndex}`,
|
|
4855
|
+
description: record.actResultDescription,
|
|
4856
|
+
method: "click",
|
|
4857
|
+
}
|
|
4858
|
+
: null;
|
|
4859
|
+
if (!runnerUpClickResult.clicked) {
|
|
4860
|
+
record.errorMessage = record.actResultDescription;
|
|
4861
|
+
}
|
|
4862
|
+
}
|
|
4863
|
+
}
|
|
4864
|
+
// The top pick (or its runner-up) was reached and clicked. A
|
|
4865
|
+
// runner-up that itself vanished is not the stale-index race the
|
|
4866
|
+
// re-rank exists for — the rank was fresh enough to click the top
|
|
4867
|
+
// pick — so exit either way and let the cascade classify.
|
|
4868
|
+
break;
|
|
4869
|
+
}
|
|
4870
|
+
record.errorMessage = record.actResultDescription;
|
|
4871
|
+
if (deepAttempt === 1) {
|
|
4872
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} deep-submit-locator: deepIndex stale on first click, re-ranking once`);
|
|
4873
|
+
}
|
|
4874
|
+
}
|
|
4875
|
+
}
|
|
4701
4876
|
else if (attempt === 2 || attempt === 4) {
|
|
4702
4877
|
record.technique = attempt === 2 ? "observe-act" : "observe-act-exclude";
|
|
4703
4878
|
const observeOptions = attempt === 4 && triedSelectors.length > 0
|
|
@@ -4725,12 +4900,12 @@ async function executeStepWithHealing(params) {
|
|
|
4725
4900
|
// don't fast-skip — let the healing cascade continue so the
|
|
4726
4901
|
// required field gets answered instead of silently doomed.
|
|
4727
4902
|
if (await hasUnfilledRequiredControlForStep(page, step)) {
|
|
4728
|
-
logger.info(
|
|
4903
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} no candidates after act+observe but a required unfilled control matches; NOT skipping (continuing cascade)`);
|
|
4729
4904
|
}
|
|
4730
4905
|
else {
|
|
4731
4906
|
record.verifiedBy = null;
|
|
4732
4907
|
attempts.push(record);
|
|
4733
|
-
logger.info(
|
|
4908
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} skipped (optional, no candidates after act+observe)`);
|
|
4734
4909
|
return "skipped";
|
|
4735
4910
|
}
|
|
4736
4911
|
}
|
|
@@ -4748,7 +4923,7 @@ async function executeStepWithHealing(params) {
|
|
|
4748
4923
|
record.triedSelectors = [target.selector];
|
|
4749
4924
|
attempts.push(record);
|
|
4750
4925
|
failureReasons.push(record.errorMessage);
|
|
4751
|
-
logger.info(
|
|
4926
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.errorMessage}`);
|
|
4752
4927
|
continue;
|
|
4753
4928
|
}
|
|
4754
4929
|
record.instruction = target.description;
|
|
@@ -4808,7 +4983,7 @@ async function executeStepWithHealing(params) {
|
|
|
4808
4983
|
record.actResultSuccess = false;
|
|
4809
4984
|
}
|
|
4810
4985
|
else if (readback.outcome === "differs") {
|
|
4811
|
-
logger.info(
|
|
4986
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} fill-value-differs: tried "${fillValue.slice(0, 60)}" got "${readback.postValue.slice(0, 60)}" (framework reformatted)`);
|
|
4812
4987
|
}
|
|
4813
4988
|
}
|
|
4814
4989
|
}
|
|
@@ -4824,7 +4999,7 @@ async function executeStepWithHealing(params) {
|
|
|
4824
4999
|
// browser's default label-click action does NOT toggle .checked for
|
|
4825
5000
|
// hidden inputs (Bootstrap, Tailwind, plain HTML labels-control,
|
|
4826
5001
|
// Angular Material's mat-radio-button, PrimeNG's p-radioButton, and
|
|
4827
|
-
// custom Angular components like
|
|
5002
|
+
// custom Angular components like an apply SPA's app-radio-button all fit
|
|
4828
5003
|
// this shape). The framework's FormControl/state listens on the
|
|
4829
5004
|
// input's `change` event, which never fires when the visible click
|
|
4830
5005
|
// never reaches a clickable input — so the form stays invalid even
|
|
@@ -5053,7 +5228,7 @@ async function executeStepWithHealing(params) {
|
|
|
5053
5228
|
? await verifyDomEffect(page, resolvedAction)
|
|
5054
5229
|
: false;
|
|
5055
5230
|
// Interior-advance transition gate (opt-in). On SPAs where a page advance
|
|
5056
|
-
// and a mere field-edit share one endpoint URL (
|
|
5231
|
+
// and a mere field-edit share one endpoint URL (the wizard ATS's `/gq`:
|
|
5057
5232
|
// TransitionWorklet vs EditQuestionItem — identical URLs, only the body
|
|
5058
5233
|
// differs), a `networkFired` signal on an interior "Next" can be a
|
|
5059
5234
|
// non-advancing POST. When the flow configures `advanceTransitionBodyPattern`
|
|
@@ -5080,7 +5255,7 @@ async function executeStepWithHealing(params) {
|
|
|
5080
5255
|
intervalMs: ADVANCE_TRANSITION_POLL_INTERVAL_MS,
|
|
5081
5256
|
});
|
|
5082
5257
|
if (advanceGateActive && !networkIsRealAdvance) {
|
|
5083
|
-
logger.info(
|
|
5258
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} network fired but no advance-transition (type=next) body matched within ${ADVANCE_TRANSITION_POLL_MS}ms poll (non-advancing POST); not treating as verified`);
|
|
5084
5259
|
}
|
|
5085
5260
|
// DOM-only-advance veto (opt-in). A rephrase can turn an advance/"Next" step
|
|
5086
5261
|
// into a field click (e.g. "click the Yes radio for '18?' then Next"), which
|
|
@@ -5089,7 +5264,7 @@ async function executeStepWithHealing(params) {
|
|
|
5089
5264
|
// instruction) whose ONLY signal is that DOM state change, do NOT count it
|
|
5090
5265
|
// verified: an advance requires a REAL transition (a `type=next` per
|
|
5091
5266
|
// `networkIsRealAdvance`, or a URL change). Keyed on `networkIsRealAdvance`,
|
|
5092
|
-
// NOT `!networkFired` — a non-advancing autosave POST (
|
|
5267
|
+
// NOT `!networkFired` — a non-advancing autosave POST (the wizard ATS's
|
|
5093
5268
|
// `WorkletPayload`) fires network=true while the wizard doesn't move, and the
|
|
5094
5269
|
// old `!networkFired` guard let a rephrase ride that autosave + a DOM reflow
|
|
5095
5270
|
// to a FALSE advance, desyncing the flow from the wizard. Field-answer steps
|
|
@@ -5106,14 +5281,14 @@ async function executeStepWithHealing(params) {
|
|
|
5106
5281
|
? domVerified
|
|
5107
5282
|
: false;
|
|
5108
5283
|
if (domVerified && !domVerifiedForStep) {
|
|
5109
|
-
logger.info(
|
|
5284
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} advance step succeeded only via DOM state change (field toggle / non-advancing POST), not a real transition; not treating as verified`);
|
|
5110
5285
|
}
|
|
5111
5286
|
let verified = networkIsRealAdvance || urlChanged || domVerifiedForStep;
|
|
5112
5287
|
// Final-step submit-verification gate. Replaces the deterministic
|
|
5113
5288
|
// submitEndpointPattern regex with a Haiku 4.5 LLM judgment over
|
|
5114
5289
|
// multi-signal evidence (network captures, page URL/title, DOM
|
|
5115
5290
|
// submitted-state probe, site-supplied criteria). The regex
|
|
5116
|
-
// mislabeled successful submits as failures whenever
|
|
5291
|
+
// mislabeled successful submits as failures whenever an ATS used a
|
|
5117
5292
|
// POST URL outside the regex's narrow expectation — empirically
|
|
5118
5293
|
// observed in last night's sweep and validated 2026-06-11 against
|
|
5119
5294
|
// claude-haiku-4-5-20251001 with the SUBMIT_VERDICT_SCHEMA.
|
|
@@ -5208,7 +5383,7 @@ async function executeStepWithHealing(params) {
|
|
|
5208
5383
|
// Any-4xx fallback. The judge said failed; surface any captured 4xx
|
|
5209
5384
|
// body's field-level error JSON for the rephrase prompt downstream
|
|
5210
5385
|
// — same role as before the migration.
|
|
5211
|
-
const fallbackEvidence = extractSubmitFailureEvidence(recentCaptures.slice(-tail.length), [], recon_shared_1.
|
|
5386
|
+
const fallbackEvidence = extractSubmitFailureEvidence(recentCaptures.slice(-tail.length), [], (0, recon_shared_1.resolveReconRunDir)().graphqlDir, "any-4xx");
|
|
5212
5387
|
if (fallbackEvidence.length > 0) {
|
|
5213
5388
|
failureReasons.push(`any-4xx fallback: ${fallbackEvidence.split("\n")[0]}`.slice(0, 240));
|
|
5214
5389
|
}
|
|
@@ -5312,7 +5487,7 @@ async function executeStepWithHealing(params) {
|
|
|
5312
5487
|
// change AND the page still has required-invalid controls (MUI-aware
|
|
5313
5488
|
// via countNgInvalidContainers), treat the DOM-only signal as NOT
|
|
5314
5489
|
// verifying so the cascade routes to the fill-invalid-fields replan.
|
|
5315
|
-
// Confirmed no-op signature on
|
|
5490
|
+
// Confirmed no-op signature on the wizard ATS's COMPENSATION page (network=false
|
|
5316
5491
|
// url=false htmlDelta>0 textChanged) mis-scored verified=true(dom).
|
|
5317
5492
|
const clickWasDomOnly = probeResult.kind === "click" && !retryNetworkFired && !retryUrlChanged;
|
|
5318
5493
|
const clickBlockedByInvalid = clickWasDomOnly && (await countNgInvalidContainers(page).catch(() => 0)) > 0;
|
|
@@ -5326,7 +5501,7 @@ async function executeStepWithHealing(params) {
|
|
|
5326
5501
|
// `advanceTransitionBodyPattern`. Without this, a non-advancing POST
|
|
5327
5502
|
// (retryNetworkFired=true) both satisfied retryVerified AND disarmed the
|
|
5328
5503
|
// old !retryNetworkFired-gated veto, so the fallback rode past a
|
|
5329
|
-
// validation-blocked Next while the page stayed put (
|
|
5504
|
+
// validation-blocked Next while the page stayed put (the wizard ATS's Basic Info
|
|
5330
5505
|
// stage1d). Only arms when the site opted into the pattern; field-answer
|
|
5331
5506
|
// (non-advance) steps keep their checkboxStateVerified/DOM path.
|
|
5332
5507
|
// Poll for the real TransitionWorklet(type="next") like the primary
|
|
@@ -5348,7 +5523,7 @@ async function executeStepWithHealing(params) {
|
|
|
5348
5523
|
retryNetworkIsRealAdvance,
|
|
5349
5524
|
});
|
|
5350
5525
|
if (fallbackDomOnlyAdvance) {
|
|
5351
|
-
logger.info(
|
|
5526
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} n+16 fallback advanced but no real transition (non-advancing POST / field toggle); not treating as verified`);
|
|
5352
5527
|
}
|
|
5353
5528
|
let retryVerified = !clickBlockedByInvalid &&
|
|
5354
5529
|
!fallbackDomOnlyAdvance &&
|
|
@@ -5426,7 +5601,7 @@ async function executeStepWithHealing(params) {
|
|
|
5426
5601
|
failureReasons.push(`n+16 fallback: submit-judge-rejected: ${judgeVerdict.reason}`);
|
|
5427
5602
|
}
|
|
5428
5603
|
}
|
|
5429
|
-
logger.info(`n+16 probe: step=${stepIndex + 1} attempt=${attempt} el.click() fallback fired=${fired === true} kind=${probeResult.kind ?? "none"} checkboxStateVerified=${checkboxStateVerified} ancestorStillInvalid=${ancestorStillInvalid}; network=${retryNetworkFired} url=${retryUrlChanged} htmlDelta=${retryHtmlDelta} textChanged=${retryTextChanged} verified=${retryVerified}`);
|
|
5604
|
+
logger.info(`n+16 probe: step=${stepIndex + 1}/${totalSteps?.() ?? "?"} attempt=${attempt} el.click() fallback fired=${fired === true} kind=${probeResult.kind ?? "none"} checkboxStateVerified=${checkboxStateVerified} ancestorStillInvalid=${ancestorStillInvalid}; network=${retryNetworkFired} url=${retryUrlChanged} htmlDelta=${retryHtmlDelta} textChanged=${retryTextChanged} verified=${retryVerified}`);
|
|
5430
5605
|
if (retryVerified) {
|
|
5431
5606
|
if (record.verifiedBy === null) {
|
|
5432
5607
|
record.verifiedBy = retryUrlChanged ? "url" : retryNetworkFired ? "network" : "dom";
|
|
@@ -5434,46 +5609,56 @@ async function executeStepWithHealing(params) {
|
|
|
5434
5609
|
record.post = retryPost;
|
|
5435
5610
|
attempts.push(record);
|
|
5436
5611
|
if (attempt > 1) {
|
|
5437
|
-
logger.info(
|
|
5612
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} healed on attempt ${attempt} via ${record.technique} + el.click() fallback`);
|
|
5438
5613
|
}
|
|
5439
5614
|
else {
|
|
5440
|
-
logger.info(
|
|
5615
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} + el.click() fallback`);
|
|
5441
5616
|
}
|
|
5442
5617
|
trajectory?.push({ stepIndex, verifiedBy: record.verifiedBy });
|
|
5443
5618
|
return "completed";
|
|
5444
5619
|
}
|
|
5445
5620
|
}
|
|
5446
5621
|
catch (probeErr) {
|
|
5447
|
-
logger.warn(`n+16 probe: step=${stepIndex + 1} attempt=${attempt} el.click() fallback threw: ${(0, errors_1.toErrorMessage)(probeErr)}`);
|
|
5622
|
+
logger.warn(`n+16 probe: step=${stepIndex + 1}/${totalSteps?.() ?? "?"} attempt=${attempt} el.click() fallback threw: ${(0, errors_1.toErrorMessage)(probeErr)}`);
|
|
5448
5623
|
}
|
|
5449
5624
|
}
|
|
5450
5625
|
}
|
|
5451
5626
|
attempts.push(record);
|
|
5452
5627
|
if (verified) {
|
|
5453
5628
|
if (attempt > 1) {
|
|
5454
|
-
logger.info(
|
|
5629
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} healed on attempt ${attempt} via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
|
|
5455
5630
|
}
|
|
5456
5631
|
else {
|
|
5457
5632
|
// Why log first-try wins explicitly: prior to this change, attempt-1
|
|
5458
5633
|
// successes were silent — only attempts 2+ emitted "healed on attempt
|
|
5459
5634
|
// N" lines. That under-reporting caused a 2026-06-15 telemetry-vs-log
|
|
5460
|
-
// contradiction where
|
|
5635
|
+
// contradiction where one measured tenant's run looked like a "cascade
|
|
5461
5636
|
// collapse" (log showed 2 heals) but telemetry calls.ndjson showed
|
|
5462
5637
|
// 32 successful Stagehand acts. Surfacing attempt-1 wins lets the
|
|
5463
5638
|
// log match telemetry and prevents the same false alarm.
|
|
5464
|
-
logger.info(
|
|
5639
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified})`);
|
|
5465
5640
|
}
|
|
5466
5641
|
trajectory?.push({ stepIndex, verifiedBy: record.verifiedBy });
|
|
5467
5642
|
return "completed";
|
|
5468
5643
|
}
|
|
5469
5644
|
const effectSignals = describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLength);
|
|
5645
|
+
// Phantom-click verdict, computed from the SAME pre/post pair
|
|
5646
|
+
// describeAttemptEffectSignals just rendered — not recomputed deltas.
|
|
5647
|
+
// Recorded on every unverified attempt (not just attempt 1) so the
|
|
5648
|
+
// failure dump's attempts[] always carries the classification; only
|
|
5649
|
+
// attempt 1's verdict drives the escalation flag below.
|
|
5650
|
+
record.phantomClickVerdict = (0, phantom_click_1.classifyPhantomClick)({
|
|
5651
|
+
actResultSuccess: record.actResultSuccess,
|
|
5652
|
+
pre,
|
|
5653
|
+
post,
|
|
5654
|
+
});
|
|
5470
5655
|
const reason = record.errorMessage
|
|
5471
5656
|
? effectSignals
|
|
5472
5657
|
? `${record.errorMessage}; ${effectSignals}`
|
|
5473
5658
|
: record.errorMessage
|
|
5474
5659
|
: effectSignals || "no observable effect (no network, url, or dom change)";
|
|
5475
5660
|
failureReasons.push(reason);
|
|
5476
|
-
logger.warn(
|
|
5661
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${record.technique}) produced no observable effect — ${reason}`);
|
|
5477
5662
|
// One additive strategy among many: when a click on a final-step
|
|
5478
5663
|
// submit/continue button fails AND the page surfaces a visible
|
|
5479
5664
|
// <error> sibling next to a touched+dirty ng-invalid wrapper, surface
|
|
@@ -5481,7 +5666,7 @@ async function executeStepWithHealing(params) {
|
|
|
5481
5666
|
// outcome="impossible" instead of looping on the same plan. Silent
|
|
5482
5667
|
// no-op on sites where the DOM pattern doesn't match — the existing
|
|
5483
5668
|
// failure-reason and replan/cycle-detection paths still fire.
|
|
5484
|
-
// Empirically grounded: 22 of 22
|
|
5669
|
+
// Empirically grounded: 22 of 22 JSON-envelope ATS Continue/Submit step-failure
|
|
5485
5670
|
// dumps in a 2026-06-10 survey had the paired touched+dirty + visible
|
|
5486
5671
|
// error text pattern with 3 distinct rejection messages.
|
|
5487
5672
|
if (record.resolvedMethod === "click" && (isFinalStep || submitStep)) {
|
|
@@ -5498,7 +5683,7 @@ async function executeStepWithHealing(params) {
|
|
|
5498
5683
|
for (const p of pairs) {
|
|
5499
5684
|
const validationReason = formatValidationRejectedReason(p);
|
|
5500
5685
|
failureReasons.push(validationReason);
|
|
5501
|
-
logger.warn(
|
|
5686
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${validationReason}`);
|
|
5502
5687
|
}
|
|
5503
5688
|
}
|
|
5504
5689
|
// Telemetry-driven early-exit: when attempt 1 on a final-Submit click
|
|
@@ -5520,6 +5705,20 @@ async function executeStepWithHealing(params) {
|
|
|
5520
5705
|
isAdvanceStep(step) &&
|
|
5521
5706
|
!urlChanged &&
|
|
5522
5707
|
!networkIsRealAdvance;
|
|
5708
|
+
// Attempt 1 phantom-clicked: Stagehand reported success but pre/post
|
|
5709
|
+
// shows zero observable effect. The zero-effect delta is the primary
|
|
5710
|
+
// signal (classifyPhantomClick above); the live AISDK elementId
|
|
5711
|
+
// suppression counter is corroborating evidence only — logged, never
|
|
5712
|
+
// gating, since a nonzero count alone is too weak a signal on a run
|
|
5713
|
+
// that sees dozens of suppressions across hundreds of unrelated steps.
|
|
5714
|
+
phantomClickAfterAttempt1 = record.phantomClickVerdict === "phantom";
|
|
5715
|
+
if (phantomClickAfterAttempt1) {
|
|
5716
|
+
const suppressedCount = getSuppressedAisdkElementIdErrorCount?.();
|
|
5717
|
+
const escalationTarget = isFinalStep || submitStep
|
|
5718
|
+
? "escalating attempt 2 to deep-submit-locator"
|
|
5719
|
+
: "non-submit step — leaving the normal structured-click/observe-act-exclude ladder intact";
|
|
5720
|
+
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
|
+
}
|
|
5523
5722
|
const postAttemptInvalidCount = await countNgInvalidContainers(page);
|
|
5524
5723
|
const earlyExit = isSubmitRevealedInvalid({
|
|
5525
5724
|
// Treat the canonical submit click as "final" for this predicate
|
|
@@ -5535,13 +5734,13 @@ async function executeStepWithHealing(params) {
|
|
|
5535
5734
|
if (earlyExit) {
|
|
5536
5735
|
const exitReason = `submit-revealed-invalid: click surfaced ${postAttemptInvalidCount - preSubmitInvalidCount} new ng-invalid container(s) (was ${preSubmitInvalidCount}, now ${postAttemptInvalidCount}); attempts 2-${MAX_STEP_ATTEMPTS} cannot heal a form that needs answers — routing to replan`;
|
|
5537
5736
|
failureReasons.push(exitReason);
|
|
5538
|
-
logger.warn(
|
|
5737
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${exitReason}`);
|
|
5539
5738
|
break;
|
|
5540
5739
|
}
|
|
5541
5740
|
// Telemetry-driven early-exit for interior ADVANCE steps: if the Next
|
|
5542
5741
|
// click fired and hit the network but produced no real forward
|
|
5543
5742
|
// transition, attempts 2-N only re-fire the autosave / bounce the wizard
|
|
5544
|
-
// back (the measured
|
|
5743
|
+
// back (the measured wizard-ATS next→back oscillation). Route to replan — which
|
|
5545
5744
|
// can reorder a later step forward — instead of burning the cascade.
|
|
5546
5745
|
const advanceStalled = isAdvanceStalled({
|
|
5547
5746
|
isAdvance: isAdvanceStep(step),
|
|
@@ -5555,7 +5754,7 @@ async function executeStepWithHealing(params) {
|
|
|
5555
5754
|
if (advanceStalled) {
|
|
5556
5755
|
const exitReason = `advance-stalled: the Next click fired network but no real transition (type=next) landed within the poll window; attempts 2-${MAX_STEP_ATTEMPTS} would only re-bounce the wizard — routing to replan`;
|
|
5557
5756
|
failureReasons.push(exitReason);
|
|
5558
|
-
logger.warn(
|
|
5757
|
+
logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${exitReason}`);
|
|
5559
5758
|
break;
|
|
5560
5759
|
}
|
|
5561
5760
|
}
|
|
@@ -5583,9 +5782,9 @@ async function executeStepWithHealing(params) {
|
|
|
5583
5782
|
unfocusedObserve,
|
|
5584
5783
|
}) ?? null;
|
|
5585
5784
|
if (dumpPath !== null) {
|
|
5586
|
-
logger.error(
|
|
5785
|
+
logger.error(`${formatStepPrefix(stepIndex, totalSteps)} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);
|
|
5587
5786
|
}
|
|
5588
|
-
throw new errors_2.StepVerificationError(
|
|
5787
|
+
throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) failed verification after ${MAX_STEP_ATTEMPTS} attempts${dumpPath ? `; see ${dumpPath}` : ""}`, phantomClickAfterAttempt1 ? "phantom-click-exhausted" : "cascade-exhausted");
|
|
5589
5788
|
}
|
|
5590
5789
|
/** SPA-readiness gate defaults — mirror the recon CLI's post-navigation wait. */
|
|
5591
5790
|
const SPA_READINESS_TIMEOUT_MS = 15_000;
|
|
@@ -5667,6 +5866,7 @@ async function runHealingFlow(deps) {
|
|
|
5667
5866
|
upload: s.upload,
|
|
5668
5867
|
submitStep: s.submitStep,
|
|
5669
5868
|
stepIndex: i,
|
|
5869
|
+
totalSteps: () => steps.length,
|
|
5670
5870
|
phase: "flow",
|
|
5671
5871
|
signalCounter,
|
|
5672
5872
|
recentCaptures,
|