@enricai/barnacle 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,7 @@ exports.findRecentBackendError = findRecentBackendError;
22
22
  exports.findRecentPageTransition = findRecentPageTransition;
23
23
  exports.isWizardExitAction = isWizardExitAction;
24
24
  exports.isAdvanceStep = isAdvanceStep;
25
+ exports.shouldWarnMissingAdvancePattern = shouldWarnMissingAdvancePattern;
25
26
  exports.parseCaptureIndex = parseCaptureIndex;
26
27
  exports.latestCaptureIndex = latestCaptureIndex;
27
28
  exports.capturesAfterIndex = capturesAfterIndex;
@@ -30,6 +31,7 @@ exports.windowHasAdvanceTransition = windowHasAdvanceTransition;
30
31
  exports.shouldVetoFallbackAdvance = shouldVetoFallbackAdvance;
31
32
  exports.isDomOnlyAdvanceVerified = isDomOnlyAdvanceVerified;
32
33
  exports.isClickViewSwapVerified = isClickViewSwapVerified;
34
+ exports.shouldReadbackFillOnActSuccess = shouldReadbackFillOnActSuccess;
33
35
  exports.countNgInvalidContainers = countNgInvalidContainers;
34
36
  exports.describeAttemptEffectSignals = describeAttemptEffectSignals;
35
37
  exports.shouldSkipTechnique = shouldSkipTechnique;
@@ -51,6 +53,7 @@ exports.extractSubmitFailureEvidence = extractSubmitFailureEvidence;
51
53
  exports.extractGaEventEvidence = extractGaEventEvidence;
52
54
  exports.normalizeDateValue = normalizeDateValue;
53
55
  exports.fillHtml5DateTimeInput = fillHtml5DateTimeInput;
56
+ exports.fillTextDatepickerInput = fillTextDatepickerInput;
54
57
  exports.verifyFillReadback = verifyFillReadback;
55
58
  exports.isUploadAffordanceLabel = isUploadAffordanceLabel;
56
59
  exports.writeFixtureToTempFile = writeFixtureToTempFile;
@@ -58,6 +61,7 @@ exports.attachToSurfacedInput = attachToSurfacedInput;
58
61
  exports.surfaceAndUpload = surfaceAndUpload;
59
62
  exports.parseSelectStep = parseSelectStep;
60
63
  exports.parseFillStep = parseFillStep;
64
+ exports.parseFillValueIntent = parseFillValueIntent;
61
65
  exports.parseRadioStep = parseRadioStep;
62
66
  exports.pollEnumerate = pollEnumerate;
63
67
  exports.waitForTransitionBody = waitForTransitionBody;
@@ -78,6 +82,7 @@ const node_fs_1 = require("node:fs");
78
82
  const node_os_1 = require("node:os");
79
83
  const node_path_1 = require("node:path");
80
84
  const ai_1 = require("ai");
85
+ const date_fns_1 = require("date-fns");
81
86
  const config_1 = require("../config");
82
87
  const errors_1 = require("../lib/errors");
83
88
  const error_messages_1 = require("../lib/llm/judges/error-messages");
@@ -694,6 +699,20 @@ function isAdvanceStep(instruction) {
694
699
  const haystack = instruction.toLowerCase();
695
700
  return ADVANCE_STEP_PHRASES.some((p) => haystack.includes(p));
696
701
  }
702
+ /**
703
+ * Whether a flow should be WARNed that its DOM-only advance guard is disarmed.
704
+ * `isDomOnlyAdvanceVerified` only vetoes a DOM-only "advance" when
705
+ * `advanceTransitionBodyPattern` is set; a flow with advance steps but no
706
+ * pattern silently loses that veto, so a validation-re-render wizard can desync
707
+ * its step pointer from the page. Pure + exported so the WARN condition is
708
+ * unit-testable without driving the whole runner. Author-side guard-rail only —
709
+ * no behavior change.
710
+ */
711
+ function shouldWarnMissingAdvancePattern(instructions, advanceTransitionBodyPattern) {
712
+ if (advanceTransitionBodyPattern)
713
+ return false;
714
+ return instructions.some((s) => isAdvanceStep(s));
715
+ }
697
716
  /**
698
717
  * Parse the monotonic capture index from a capture filename. Every capture is
699
718
  * written as `<idx>-<phase>-<unix-ms>-<hash>.json` where `<idx>` is a
@@ -952,6 +971,22 @@ function isClickViewSwapVerified(params) {
952
971
  return true;
953
972
  return textChanged && bytesDelta >= VIEW_SWAP_REVEAL_MIN_BYTES;
954
973
  }
974
+ /**
975
+ * Whether an act-success step warrants a committed-value read-back before the
976
+ * weak view-swap/form-value signals are allowed to accept it. A controlled
977
+ * datepicker (react-datepicker) accepts the keystrokes, discards the value on
978
+ * its next render, and — because the resolved action opened its calendar popup
979
+ * as a `click` — rides `isClickViewSwapVerified` to a phantom `dom=false`
980
+ * success without ever committing the date. The read-back (and, on a rejected
981
+ * value, the datepicker fill primitive) is only worth running when the step's
982
+ * PROSE asked for a fill AND no strong signal (network/url/dom) already verified
983
+ * it — so the happy path (a real fill, a real click, an advance) is untouched.
984
+ * Keyed on step intent, not the resolved method, because the widget resolves as
985
+ * a click and its accessible name identifies the popup, not the field.
986
+ */
987
+ function shouldReadbackFillOnActSuccess(params) {
988
+ return params.actReportedSuccess && params.isFillIntent && !params.hasStrongSignal;
989
+ }
955
990
  /**
956
991
  * Read-only count of ng-invalid form controls on the resolved frame. Side-effect-free
957
992
  * counterpart to `probeFormValidityBeforeSubmit` (which also auto-fills
@@ -2150,6 +2185,200 @@ async function fillHtml5DateTimeInput(target, xpath, value) {
2150
2185
  return null;
2151
2186
  }
2152
2187
  }
2188
+ /**
2189
+ * Parse a loosely-formatted date string into calendar parts. The recon flow
2190
+ * passes dates in whatever shape the plan text used (ISO `yyyy-MM`, US
2191
+ * `MM/dd/yyyy`, etc.), but a react-datepicker commits through year/month/day
2192
+ * selection, so we need the numeric parts, not a display string. Uses date-fns
2193
+ * per project convention; returns null when no candidate format matches so the
2194
+ * caller skips the datepicker path rather than picking a wrong month.
2195
+ */
2196
+ function parseDatepickerValue(value) {
2197
+ const candidates = ["yyyy-MM", "yyyy-MM-dd", "MM/dd/yyyy", "MM-dd-yyyy", "MM/yyyy", "M/yyyy"];
2198
+ for (const fmt of candidates) {
2199
+ const parsed = (0, date_fns_1.parse)(value.trim(), fmt, new Date(2000, 0, 1));
2200
+ if ((0, date_fns_1.isValid)(parsed)) {
2201
+ return { year: parsed.getFullYear(), monthIndex: parsed.getMonth(), day: parsed.getDate() };
2202
+ }
2203
+ }
2204
+ return null;
2205
+ }
2206
+ /** Dispatch a real Enter keydown/keyup on the element (Stagehand's Locator has no `press`). */
2207
+ async function dispatchEnterKey(target, xpath) {
2208
+ const expr = `(() => {
2209
+ const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
2210
+ const el = r.singleNodeValue;
2211
+ if (!el) return "no-element";
2212
+ const opts = { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true };
2213
+ el.dispatchEvent(new KeyboardEvent("keydown", opts));
2214
+ el.dispatchEvent(new KeyboardEvent("keyup", opts));
2215
+ return "dispatched";
2216
+ })()`;
2217
+ try {
2218
+ await target.evaluate(expr);
2219
+ }
2220
+ catch {
2221
+ // best-effort: an Enter-dispatch failure shouldn't fail the fill
2222
+ }
2223
+ }
2224
+ /** A readback whose value is a non-empty, date-shaped string counts as committed. */
2225
+ function isCommittedDateReadback(readback) {
2226
+ if (readback === null)
2227
+ return false;
2228
+ if (readback.outcome === "rejected")
2229
+ return false;
2230
+ // A datepicker reformats what it accepts (e.g. "01/2020" → "2020-01"), so
2231
+ // "differs" is a commit, not a rejection — require only a non-empty value
2232
+ // that contains a 4-digit year, ruling out stray placeholder text.
2233
+ return readback.postValue.trim().length > 0 && /\d{4}/.test(readback.postValue);
2234
+ }
2235
+ /**
2236
+ * Fill a `type="text"` datepicker widget (react-datepicker — the standard
2237
+ * ATS start/end-date control), which `fillHtml5DateTimeInput`
2238
+ * cannot: react-datepicker rejects a programmatic `.value` write and commits
2239
+ * only through its own keyboard/calendar `onChange`. Returns `null` for any
2240
+ * input that is NOT a date-like text control, so every other text field stays
2241
+ * on today's exact fill path.
2242
+ *
2243
+ * Two gestures, in order:
2244
+ * 1. Keyboard entry — real keystrokes via `locator.type` (the proven
2245
+ * framework-control path at {@link verifyDomEffect}) + Enter. Works for
2246
+ * *typeable* datepickers.
2247
+ * 2. Open-and-pick — click to open `.react-datepicker`, then either set the
2248
+ * year/month `.range-select` dropdowns and click the `.react-datepicker__month-N`
2249
+ * cell (month/year-picker variant — the reported ATS widget, which is
2250
+ * picker-only and ignores typed input) or click the matching
2251
+ * `.react-datepicker__day` cell (day-grid variant). Commit fires on the cell
2252
+ * click.
2253
+ *
2254
+ * The committed value is confirmed via {@link verifyFillReadback}; a datepicker
2255
+ * reformats what it accepts, so a non-empty date-shaped readback is success.
2256
+ *
2257
+ * Return contract mirrors {@link fillHtml5DateTimeInput}: `null` means "not a
2258
+ * datepicker — caller falls through to the generic readback verifier"; a
2259
+ * non-null object means "this WAS a datepicker, here's the outcome". A non-null
2260
+ * `filled:false` therefore deliberately TERMINATES the cascade (the caller
2261
+ * records the failure and does NOT run the generic verifier) — a gated-in
2262
+ * datepicker that neither gesture commits is a real fill failure, not an
2263
+ * unrecognized field.
2264
+ *
2265
+ * Site-agnostic: react-datepicker's markup + commit model is universal across
2266
+ * ATS tenants using it.
2267
+ */
2268
+ async function fillTextDatepickerInput(target, selector, value) {
2269
+ const xpath = xpathBody(selector);
2270
+ if (!xpath)
2271
+ return null;
2272
+ const parts = parseDatepickerValue(value);
2273
+ if (!parts)
2274
+ return null;
2275
+ // Gate: only proceed for a text input that looks like a datepicker. The
2276
+ // definitive signal is a `.react-datepicker__input-container` wrapper; we
2277
+ // also accept id/name/class/aria date hints for non-react libraries.
2278
+ const gateExpr = `(() => {
2279
+ const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
2280
+ const el = r.singleNodeValue;
2281
+ if (!el || el.tagName !== "INPUT") return { match: false };
2282
+ const type = (el.getAttribute("type") || "text").toLowerCase();
2283
+ if (type !== "text") return { match: false };
2284
+ const inContainer = !!(el.closest && el.closest(".react-datepicker__input-container, .react-datepicker-wrapper"));
2285
+ const hint = ((el.id || "") + " " + (el.name || "") + " " + (el.className || "") + " " + (el.getAttribute("aria-label") || "") + " " + (el.getAttribute("placeholder") || "")).toLowerCase();
2286
+ const looksDate = /date|datepicker/.test(hint) || /\\bmm[\\/-]?(dd[\\/-]?)?yyyy\\b/.test(hint);
2287
+ return { match: inContainer || looksDate };
2288
+ })()`;
2289
+ try {
2290
+ const gate = await target.evaluate(gateExpr);
2291
+ if (!gate || typeof gate !== "object" || gate.match !== true) {
2292
+ return null;
2293
+ }
2294
+ }
2295
+ catch {
2296
+ return null;
2297
+ }
2298
+ // Strategy 1 — keyboard entry (typeable datepickers). Mirrors the Angular
2299
+ // reactive-form re-type at verifyDomEffect: real CDP keystrokes flow through
2300
+ // the widget's own onChange, unlike a programmatic value write.
2301
+ const typed = (0, date_fns_1.format)(new Date(parts.year, parts.monthIndex, parts.day), "MM/dd/yyyy");
2302
+ try {
2303
+ const locator = target.locator(selector).first();
2304
+ await locator.fill("");
2305
+ await locator.type(typed, { delay: 50 });
2306
+ // react-datepicker commits typed input on Enter/blur. The Stagehand Locator
2307
+ // has no `press`, so dispatch the Enter keydown/keyup from page context
2308
+ // (the widget's own keydown handler listens here) then blur via the shared
2309
+ // jQuery-change helper.
2310
+ await dispatchEnterKey(target, xpath);
2311
+ await dispatchJqueryChangeEvent(target, selector);
2312
+ const readback = await verifyFillReadback(target, xpath, value);
2313
+ if (isCommittedDateReadback(readback)) {
2314
+ return { filled: true, postValue: readback.postValue, strategy: "keyboard" };
2315
+ }
2316
+ }
2317
+ catch {
2318
+ // fall through to open-and-pick
2319
+ }
2320
+ // Strategy 2 — open-and-pick. Required for picker-only widgets (the reported
2321
+ // month/year picker ignores typed input entirely).
2322
+ try {
2323
+ const locator = target.locator(selector).first();
2324
+ await locator.click();
2325
+ }
2326
+ catch {
2327
+ return { filled: false, postValue: "", strategy: "none" };
2328
+ }
2329
+ const pickExpr = `(() => {
2330
+ const year = ${parts.year};
2331
+ const monthIndex = ${parts.monthIndex};
2332
+ const day = ${parts.day};
2333
+ const cal = document.querySelector(".react-datepicker");
2334
+ if (!cal) return { picked: false, reason: "no-calendar" };
2335
+ const fireChange = (el) => { el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); };
2336
+ const monthCells = cal.querySelectorAll(".react-datepicker__month-text");
2337
+ const dayGrid = cal.querySelectorAll(".react-datepicker__day:not(.react-datepicker__day--outside-month):not(.react-datepicker__day--disabled)");
2338
+ // Month/year-picker variant: click the target month cell (0-based month
2339
+ // index maps to the __month-N class) — THIS is the commit. The year/month
2340
+ // dropdowns are set first only so the calendar shows the right year's month
2341
+ // page. NOTE: "range-select" is a SITE-SPECIFIC class (the ATS site's own
2342
+ // CSS), not a react-datepicker class; the bare "select" fallback covers
2343
+ // other wrappers.
2344
+ // Stock react-datepicker renders its month/year dropdowns as clickable divs,
2345
+ // so on those the select loop is inert and the month-cell click carries it.
2346
+ if (monthCells.length > 0) {
2347
+ const selects = cal.querySelectorAll("select.range-select, select");
2348
+ for (const sel of selects) {
2349
+ const opts = Array.from(sel.options).map((o) => (o.value || "").trim());
2350
+ const yearOpt = opts.indexOf(String(year));
2351
+ if (yearOpt >= 0) { sel.selectedIndex = yearOpt; fireChange(sel); continue; }
2352
+ const monthOpt = opts.indexOf(String(monthIndex));
2353
+ if (monthOpt >= 0) { sel.selectedIndex = monthOpt; fireChange(sel); }
2354
+ }
2355
+ const monthCell = cal.querySelector(".react-datepicker__month-" + monthIndex + ".react-datepicker__month-text");
2356
+ const cell = monthCell || cal.querySelectorAll(".react-datepicker__month-text")[monthIndex];
2357
+ if (cell) { cell.click(); return { picked: true, variant: "month-year" }; }
2358
+ return { picked: false, reason: "no-month-cell" };
2359
+ }
2360
+ // Day-grid variant: click the day cell matching the parsed day number.
2361
+ if (dayGrid.length > 0) {
2362
+ for (const cell of dayGrid) {
2363
+ if ((cell.textContent || "").trim() === String(day)) { cell.click(); return { picked: true, variant: "day-grid" }; }
2364
+ }
2365
+ return { picked: false, reason: "no-day-cell" };
2366
+ }
2367
+ return { picked: false, reason: "empty-calendar" };
2368
+ })()`;
2369
+ try {
2370
+ const picked = await target.evaluate(pickExpr);
2371
+ const didPick = !!picked && typeof picked === "object" && picked.picked === true;
2372
+ const readback = await verifyFillReadback(target, xpath, value);
2373
+ if (didPick && isCommittedDateReadback(readback)) {
2374
+ return { filled: true, postValue: readback.postValue, strategy: "calendar-pick" };
2375
+ }
2376
+ return { filled: false, postValue: readback?.postValue ?? "", strategy: "none" };
2377
+ }
2378
+ catch {
2379
+ return { filled: false, postValue: "", strategy: "none" };
2380
+ }
2381
+ }
2153
2382
  /**
2154
2383
  * Read back an element's value after a fill action and compare to the
2155
2384
  * expected value. Catches silent-value-rejection cases that the verifier's
@@ -2811,6 +3040,30 @@ function parseFillStep(instruction) {
2811
3040
  return null;
2812
3041
  return { fieldLabel, value };
2813
3042
  }
3043
+ /**
3044
+ * Extract the quoted VALUE a fill step wants written, tolerant of the field-
3045
+ * label phrasing `parseFillStep` is strict about. `parseFillStep` requires the
3046
+ * canonical `<label> field with '<value>'` shape, so real-world prose that
3047
+ * inserts words between the field noun and `with` — e.g. "Fill in the Start
3048
+ * Date field FOR WORK EXPERIENCE with '01/2020'" — parses to `null`, missing
3049
+ * the fill intent entirely. This looser parser recognizes a fill verb plus a
3050
+ * `with '<value>'` clause and returns just the value, so the act-success
3051
+ * datepicker guard can key off intent that survives that phrasing drift.
3052
+ *
3053
+ * Deliberately excludes select steps (checked first, mirroring
3054
+ * `resolveDeepLocatorActuation`'s precedence) so a `select '…'` step whose
3055
+ * prose happens to trail a quoted value is never mistaken for a fill. Returns
3056
+ * `null` for clicks, selects, and any step without a quoted `with '…'` value.
3057
+ */
3058
+ function parseFillValueIntent(instruction) {
3059
+ if (parseSelectStep(instruction))
3060
+ return null;
3061
+ if (!/\bfill(?:\s+in)?\b/i.test(instruction))
3062
+ return null;
3063
+ const match = instruction.match(/\bwith\s+'([^']+)'/i);
3064
+ const value = match?.[1]?.trim();
3065
+ return value ? { value } : null;
3066
+ }
2814
3067
  /**
2815
3068
  * Parse a single-choice RADIO flow step into the option to click and (when
2816
3069
  * present) the question label that scopes which radio group it targets.
@@ -5721,20 +5974,42 @@ async function executeStepWithHealing(params) {
5721
5974
  }
5722
5975
  }
5723
5976
  else {
5724
- // Fix I: not a date input verify the regular fill landed.
5725
- // Catches silent-value-rejection cases (HTML5 type validation
5726
- // on number/email/url/tel inputs, framework-controlled-
5727
- // component rejection, masked-input library reformatting).
5728
- // Generic primitive that the verifier's existing signals
5729
- // (network/url/dom/htmlDelta/textChanged) miss.
5730
- const readback = await verifyFillReadback(dateFillTarget, overriddenTarget.selector, fillValue);
5731
- if (readback !== null) {
5732
- if (readback.outcome === "rejected") {
5733
- 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)`;
5977
+ // Not an HTML5 date input. First try the text-datepicker
5978
+ // primitive (react-datepicker ATS start/end-date
5979
+ // controls): those are `type="text"`, so fillHtml5DateTimeInput
5980
+ // returns null, and the generic fill's value-write never commits
5981
+ // the widget. Returns null for any non-datepicker text input, so
5982
+ // every other field falls through to the readback verifier below
5983
+ // unchanged.
5984
+ const datepickerFill = await fillTextDatepickerInput(dateFillTarget, overriddenTarget.selector, fillValue);
5985
+ if (datepickerFill !== null) {
5986
+ record.errorMessage = datepickerFill.filled
5987
+ ? `text-datepicker-fill: filled via ${datepickerFill.strategy} "${datepickerFill.postValue}"`
5988
+ : `text-datepicker-fill: failed to commit "${fillValue.slice(0, 60)}" (post=${datepickerFill.postValue})`;
5989
+ if (datepickerFill.filled) {
5990
+ record.actResultSuccess = true;
5991
+ resolvedAction = overriddenTarget;
5992
+ }
5993
+ else {
5734
5994
  record.actResultSuccess = false;
5735
5995
  }
5736
- else if (readback.outcome === "differs") {
5737
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} fill-value-differs: tried "${fillValue.slice(0, 60)}" got "${readback.postValue.slice(0, 60)}" (framework reformatted)`);
5996
+ }
5997
+ else {
5998
+ // Fix I: not a date input — verify the regular fill landed.
5999
+ // Catches silent-value-rejection cases (HTML5 type validation
6000
+ // on number/email/url/tel inputs, framework-controlled-
6001
+ // component rejection, masked-input library reformatting).
6002
+ // Generic primitive that the verifier's existing signals
6003
+ // (network/url/dom/htmlDelta/textChanged) miss.
6004
+ const readback = await verifyFillReadback(dateFillTarget, overriddenTarget.selector, fillValue);
6005
+ if (readback !== null) {
6006
+ if (readback.outcome === "rejected") {
6007
+ 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)`;
6008
+ record.actResultSuccess = false;
6009
+ }
6010
+ else if (readback.outcome === "differs") {
6011
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} fill-value-differs: tried "${fillValue.slice(0, 60)}" got "${readback.postValue.slice(0, 60)}" (framework reformatted)`);
6012
+ }
5738
6013
  }
5739
6014
  }
5740
6015
  }
@@ -6074,11 +6349,66 @@ async function executeStepWithHealing(params) {
6074
6349
  // actions (fill/check/etc.), same as domVerified, so it never lets an
6075
6350
  // advance/submit step ride a stray value mutation elsewhere on the page.
6076
6351
  const formValueVerified = isStateClass && post.formValueSignature !== pre.formValueSignature;
6352
+ // Committed-value guard on the act-success path. A controlled datepicker
6353
+ // (react-datepicker) accepts the typed value, discards it on React's next
6354
+ // render, and — since the act resolved as a `click` that opened the calendar
6355
+ // popup — rides `clickViewSwapVerified` to a phantom `dom=false` success
6356
+ // without ever committing the date. The 1.9.0 `fillTextDatepickerInput`
6357
+ // primitive (the open-and-pick gesture that DOES commit) sits in a fallback
6358
+ // block only reached when the act "technically failed", so it never runs on
6359
+ // this path. Route a fill-intent step whose value did NOT land (read-back
6360
+ // rejected) into that primitive before the weak view-swap/form-value signals
6361
+ // accept it — keyed on step PROSE, since the widget resolves as a click and
6362
+ // its accessible name identifies the popup, not the field.
6363
+ const fillIntent = parseFillValueIntent(step);
6364
+ const hasStrongSignal = networkIsRealAdvance || urlChanged || domVerifiedForStep;
6365
+ let datepickerCommitted = false;
6366
+ let datepickerRejected = false;
6367
+ if (shouldReadbackFillOnActSuccess({
6368
+ actReportedSuccess: record.actResultSuccess === true,
6369
+ isFillIntent: fillIntent !== null,
6370
+ hasStrongSignal,
6371
+ })) {
6372
+ const readbackSelector = resolvedAction?.selector ?? triedSelectors[triedSelectors.length - 1] ?? null;
6373
+ if (readbackSelector && fillIntent !== null) {
6374
+ const readbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
6375
+ const readback = await verifyFillReadback(readbackTarget, readbackSelector, fillIntent.value);
6376
+ if (readback?.outcome === "rejected") {
6377
+ // Value didn't land. First try the datepicker primitive; it self-gates
6378
+ // on react-datepicker's `.react-datepicker__input-container` and returns
6379
+ // null for any non-datepicker input, so a plain rejected fill falls
6380
+ // through to the failure path below unchanged.
6381
+ const datepickerFill = await fillTextDatepickerInput(readbackTarget, readbackSelector, fillIntent.value);
6382
+ if (datepickerFill !== null) {
6383
+ if (datepickerFill.filled) {
6384
+ datepickerCommitted = true;
6385
+ record.errorMessage = `text-datepicker-fill: filled via ${datepickerFill.strategy} "${datepickerFill.postValue}"`;
6386
+ }
6387
+ else {
6388
+ // A gated-in datepicker that neither gesture commits is a real fill
6389
+ // failure (per fillTextDatepickerInput's contract) — authoritative,
6390
+ // so suppress the weak view-swap/form-value acceptance and escalate.
6391
+ datepickerRejected = true;
6392
+ record.actResultSuccess = false;
6393
+ record.errorMessage = `text-datepicker-fill: failed to commit "${fillIntent.value.slice(0, 60)}" (post=${datepickerFill.postValue})`;
6394
+ }
6395
+ }
6396
+ else {
6397
+ // Not a datepicker — the fill was genuinely rejected. Same message
6398
+ // shape as the observe-act fill-fallback's readback verifier so the
6399
+ // failure dump reads identically regardless of which path caught it.
6400
+ datepickerRejected = true;
6401
+ record.actResultSuccess = false;
6402
+ record.errorMessage = `fill-value-rejected: tried "${fillIntent.value.slice(0, 60)}" on <${readback.tag}>; element value remains empty (silent rejection — HTML5 type validation, framework controlled-component, or masked-input library)`;
6403
+ }
6404
+ }
6405
+ }
6406
+ }
6077
6407
  let verified = networkIsRealAdvance ||
6078
6408
  urlChanged ||
6079
6409
  domVerifiedForStep ||
6080
- clickViewSwapVerified ||
6081
- formValueVerified;
6410
+ datepickerCommitted ||
6411
+ (!datepickerRejected && (clickViewSwapVerified || formValueVerified));
6082
6412
  // Final-step submit-verification gate. Replaces the deterministic
6083
6413
  // submitEndpointPattern regex with a Haiku 4.5 LLM judgment over
6084
6414
  // multi-signal evidence (network captures, page URL/title, DOM
@@ -6190,18 +6520,22 @@ async function executeStepWithHealing(params) {
6190
6520
  }
6191
6521
  if (verified && record.verifiedBy === null) {
6192
6522
  // Preserve a richer verdict set earlier in this attempt (e.g.
6193
- // "submitted-state-dom" from the final-step DOM fallback).
6194
- record.verifiedBy = clickViewSwapVerified
6195
- ? "view-swap"
6196
- : urlChanged
6197
- ? "url"
6198
- : networkFired
6199
- ? "network"
6200
- : domVerifiedForStep
6201
- ? "dom"
6202
- : formValueVerified
6203
- ? "form-value"
6204
- : "dom";
6523
+ // "submitted-state-dom" from the final-step DOM fallback). A datepicker
6524
+ // commit is a real DOM value change, so it ranks ahead of the weaker
6525
+ // view-swap signal that the same calendar-open click also produces.
6526
+ record.verifiedBy = datepickerCommitted
6527
+ ? "dom"
6528
+ : clickViewSwapVerified
6529
+ ? "view-swap"
6530
+ : urlChanged
6531
+ ? "url"
6532
+ : networkFired
6533
+ ? "network"
6534
+ : domVerifiedForStep
6535
+ ? "dom"
6536
+ : formValueVerified
6537
+ ? "form-value"
6538
+ : "dom";
6205
6539
  }
6206
6540
  // N+16 probe: Stagehand's CDP click sometimes lands on the button without
6207
6541
  // triggering React's SyntheticEvent layer (or jQuery delegated handlers).
@@ -6707,6 +7041,9 @@ async function runHealingFlow(deps) {
6707
7041
  }
6708
7042
  },
6709
7043
  });
7044
+ if (shouldWarnMissingAdvancePattern(steps.map((s) => s.instruction), deps.advanceTransitionBodyPattern ?? null)) {
7045
+ logger.warn("flow has advance steps but no advanceTransitionBodyPattern — DOM-only advance guard is disarmed; pointer/page desync is possible on validation-re-render wizards");
7046
+ }
6710
7047
  try {
6711
7048
  for (const [i, s] of steps.entries()) {
6712
7049
  if (maxFlowMs !== undefined && Date.now() - start > maxFlowMs) {