@enricai/barnacle 1.12.34 → 1.12.36

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.
@@ -88,7 +88,7 @@ exports.probeStepBeforeAttempts = probeStepBeforeAttempts;
88
88
  exports.extractLinkFromMessage = extractLinkFromMessage;
89
89
  exports.extractCodeFromMessage = extractCodeFromMessage;
90
90
  exports.executeStepWithHealing = executeStepWithHealing;
91
- exports.waitForSpaReady = waitForSpaReady;
91
+ exports.executeNavigateStep = executeNavigateStep;
92
92
  exports.runHealingFlow = runHealingFlow;
93
93
  const node_crypto_1 = require("node:crypto");
94
94
  const node_fs_1 = require("node:fs");
@@ -118,6 +118,7 @@ const errors_2 = require("../scraper/errors");
118
118
  const frame_target_1 = require("../scraper/frame-target");
119
119
  const phantom_click_1 = require("../scraper/phantom-click");
120
120
  const session_teardown_1 = require("../scraper/session-teardown");
121
+ const spa_readiness_1 = require("../scraper/spa-readiness");
121
122
  const stagehand_guard_1 = require("../scraper/stagehand-guard");
122
123
  const submit_control_1 = require("../scraper/submit-control");
123
124
  const watchdog_1 = require("../scraper/watchdog");
@@ -4268,18 +4269,25 @@ async function waitForTransitionBody(params) {
4268
4269
  return false;
4269
4270
  }
4270
4271
  /**
4271
- * Site-agnostic captcha-solve hand-off: given an already-solved token, commit
4272
- * it into the page's hidden response field and dispatch the framework-visible
4273
- * `change` event so the field's own value listener observes it.
4272
+ * Site-agnostic captcha-solve hand-off: given an already-solved token,
4273
+ * discover the widget's registered `data-callback` and invoke it with the
4274
+ * token so the site assembles its own submit (its own hidden fields, its own
4275
+ * submit path); only when no callback is discoverable does this fall back to
4276
+ * setting the hidden response field directly and dispatching the
4277
+ * framework-visible `change` event so the field's own value listener
4278
+ * observes it.
4274
4279
  *
4275
- * Widgets of this shape create a hidden `<input name="{responseField}">`
4276
- * carrying the solved token, and some widgets' own render callback submits
4277
- * the enclosing form as soon as a token lands assigning the field's value
4278
- * can therefore itself navigate the frame synchronously, mid-evaluate. No
4279
- * eval this function issues after the precheck is depended on for a return
4280
- * value: only the precheck (a pure DOM query, which cannot navigate) is
4281
- * awaited for its result; the value-set and change-dispatch evals are fired
4282
- * and their rejections discarded, exactly like a navigating `form.submit()`
4280
+ * Widgets of this shape anchor a `[data-sitekey]` element whose
4281
+ * `data-callback` attribute names a function on `window`; that callback (not
4282
+ * a bare field assignment) is what some sites depend on to append companion
4283
+ * hidden fields before submitting. Invoking it — or, absent one, assigning
4284
+ * the response field's value can itself navigate the frame synchronously,
4285
+ * mid-evaluate, on widgets whose callback (or value-set side effect) submits
4286
+ * the enclosing form as soon as a token lands. No eval this function issues
4287
+ * after the precheck is depended on for a return value: only the precheck (a
4288
+ * pure DOM query, which cannot navigate) is awaited for its result; the
4289
+ * callback-invoke and the value-set/change-dispatch evals are fired and
4290
+ * their rejections discarded, exactly like a navigating `form.submit()`
4283
4291
  * would be.
4284
4292
  *
4285
4293
  * Deliberately narrow: no polling, no verification, no wait budget, and no
@@ -4299,18 +4307,35 @@ async function injectCaptchaTokenAndSubmit(target, token, responseField = "h-cap
4299
4307
  const precheckExpr = `(() => {
4300
4308
  const responseField = ${JSON.stringify(responseField)};
4301
4309
  const field = document.querySelector('[name="' + responseField + '"]');
4302
- if (field) return { fieldExists: true, hasForm: Boolean(field.closest("form")) };
4310
+ const sitekeyEl = document.querySelector("[data-sitekey]");
4311
+ const callbackName = sitekeyEl ? sitekeyEl.getAttribute("data-callback") : null;
4312
+ const callbackExists = Boolean(callbackName) && typeof window[callbackName] === "function";
4313
+ const callback = callbackExists ? callbackName : null;
4314
+ if (field) return { fieldExists: true, hasForm: Boolean(field.closest("form")), callback };
4303
4315
  const forms = Array.from(document.querySelectorAll("form"));
4304
4316
  const form = forms.find((candidate) => candidate.querySelector("[data-sitekey]")) ?? forms[0];
4305
- return { fieldExists: false, hasForm: Boolean(form) };
4317
+ return { fieldExists: false, hasForm: Boolean(form), callback };
4306
4318
  })()`;
4307
4319
  // Purely a DOM query — reads but never mutates the page, so it cannot
4308
4320
  // itself trigger navigation. This is the only evaluate call in this
4309
4321
  // function whose return value is depended on.
4310
- const { fieldExists, hasForm } = await target.evaluate(precheckExpr);
4322
+ const { fieldExists, hasForm, callback } = await target.evaluate(precheckExpr);
4311
4323
  const injected = fieldExists || hasForm;
4312
4324
  if (!injected)
4313
4325
  return { injected, hasForm };
4326
+ if (callback) {
4327
+ const invokeCallbackExpr = `(() => {
4328
+ const token = ${JSON.stringify(token)};
4329
+ window[${JSON.stringify(callback)}](token);
4330
+ })()`;
4331
+ // Invoking the widget's own callback can navigate the frame synchronously
4332
+ // from within this call (the callback is free to build its own fields and
4333
+ // submit), tearing down the execution context before a return value
4334
+ // marshals — that rejection is the expected outcome, not a real failure,
4335
+ // so it's discarded here rather than depended on.
4336
+ await target.evaluate(invokeCallbackExpr).catch(() => undefined);
4337
+ return { injected, hasForm };
4338
+ }
4314
4339
  const setValueExpr = `(() => {
4315
4340
  const responseField = ${JSON.stringify(responseField)};
4316
4341
  const token = ${JSON.stringify(token)};
@@ -4357,14 +4382,18 @@ async function injectCaptchaTokenAndSubmit(target, token, responseField = "h-cap
4357
4382
  * used only when the caller's own transition poll observed no advance after
4358
4383
  * the inject, so the widget's callback (if any) evidently didn't submit for
4359
4384
  * us. Kept separate from the inject primitive so the caller can gate its use
4360
- * on an observed transition rather than firing it unconditionally.
4385
+ * on an observed transition rather than firing it unconditionally. Prefers
4386
+ * `form.requestSubmit()` so any submit-event listener (including one that
4387
+ * calls `preventDefault()` and drives its own submit logic) and native form
4388
+ * validation still run, matching real-browser submit semantics; falls back
4389
+ * to the bare `form.submit()` only when `requestSubmit` isn't available.
4361
4390
  */
4362
4391
  async function submitCaptchaGatedForm(target, responseField = "h-captcha-response") {
4363
4392
  const submitExpr = `(() => {
4364
4393
  const responseField = ${JSON.stringify(responseField)};
4365
4394
  const field = document.querySelector('[name="' + responseField + '"]');
4366
4395
  const form = field ? field.closest("form") : null;
4367
- if (form) form.submit();
4396
+ if (form) (form.requestSubmit ? form.requestSubmit() : form.submit());
4368
4397
  })()`;
4369
4398
  // form.submit() navigates the frame synchronously, tearing down the execution
4370
4399
  // context before Runtime.evaluate can marshal a return value for this call —
@@ -9331,57 +9360,32 @@ async function executeStepWithHealing(params) {
9331
9360
  }
9332
9361
  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");
9333
9362
  }
9334
- /** SPA-readiness gate defaults — match the recon CLI's post-navigation wait. */
9335
- const SPA_READINESS_TIMEOUT_MS = 15_000;
9336
- const SPA_READINESS_POLL_MS = 500;
9337
- const SPA_MIN_BODY_LENGTH = 5_000;
9338
- /**
9339
- * Block until a just-navigated SPA has actually hydrated, so the flow does not
9340
- * begin stepping against a shell page. `page.goto(..., "networkidle")` on a
9341
- * bot-managed/CDN-fronted single-page app resolves during the challenge/redirect
9342
- * before the client framework renders the real DOM — so the first steps would
9343
- * otherwise probe an empty page, find no candidates, and (being optional) skip
9344
- * the entire flow. The recon CLI has this gate inline; generated plugins call it
9345
- * here so they inherit the same behavior. Polls `document.body.outerHTML.length`
9346
- * up to a threshold, then proceeds regardless (best-effort, never throws).
9347
- *
9348
- * Each `document.body.outerHTML.length` read is itself bounded by a watchdog
9349
- * (capped to one poll interval) and treated as "still 0 chars" on timeout —
9350
- * same pattern as `waitForChildFrameReady`'s `isReady()` probe. Without this,
9351
- * a single wedged CDP round-trip inside `readBodyLength` would pend forever
9352
- * and the `while (Date.now() < deadline)` loop below would never be
9353
- * re-entered, defeating the very deadline it exists to enforce.
9354
- * `page.waitForTimeout(pollMs)` is left unguarded: it is a plain delay (no
9355
- * DOM/network read whose response can be lost), so it does not carry the
9356
- * "wedged read" failure mode this fix targets.
9357
- */
9358
- async function waitForSpaReady(page, logger, opts = {}) {
9359
- const timeoutMs = opts.timeoutMs ?? SPA_READINESS_TIMEOUT_MS;
9360
- const pollMs = opts.pollMs ?? SPA_READINESS_POLL_MS;
9361
- const minBodyLength = opts.minBodyLength ?? SPA_MIN_BODY_LENGTH;
9362
- const bodyLengthExpr = "document.body ? document.body.outerHTML.length : 0";
9363
- const readBodyLength = async () => {
9364
- const raw = await (0, watchdog_1.withWatchdog)(() => page.evaluate(bodyLengthExpr), {
9365
- timeoutMs: pollMs,
9366
- label: "flow-runner: spa readiness body-length probe",
9367
- }).catch(() => 0);
9368
- return typeof raw === "number" ? raw : 0;
9369
- };
9370
- let bodyLength = await readBodyLength();
9371
- if (bodyLength >= minBodyLength) {
9372
- return;
9363
+ /**
9364
+ * Navigates `page` directly to `url` and waits for the SPA to hydrate,
9365
+ * bypassing the self-heal cascade entirely — a multi-page capture flow's
9366
+ * page transitions are a known URL, not an element to locate and click, so
9367
+ * there is nothing for the cascade's DOM-healing attempts to add. Matches
9368
+ * {@link executeStepWithHealing}'s `"completed"|"skipped"` outcome contract
9369
+ * so `runHealingFlow`'s step loop (stuck-detection, `submitStep` gating,
9370
+ * trajectory) needs zero changes to also handle navigate steps.
9371
+ */
9372
+ async function executeNavigateStep(params) {
9373
+ const { page, url, optional, logger: log, waitUntil, timeoutMs } = params;
9374
+ try {
9375
+ await page.goto(url, {
9376
+ waitUntil: waitUntil ?? "domcontentloaded",
9377
+ timeoutMs: timeoutMs ?? exports.GOTO_TIMEOUT_MS,
9378
+ });
9373
9379
  }
9374
- logger.info(`spa readiness: body ${bodyLength} chars < ${minBodyLength} threshold — waiting for SPA to render`);
9375
- const deadline = Date.now() + timeoutMs;
9376
- while (Date.now() < deadline) {
9377
- await page.waitForTimeout(pollMs);
9378
- bodyLength = await readBodyLength();
9379
- if (bodyLength >= minBodyLength) {
9380
- logger.info(`spa readiness: body grew to ${bodyLength} chars — SPA rendered`);
9381
- return;
9380
+ catch (err) {
9381
+ if (optional) {
9382
+ log.warn(`navigate step: goto failed for optional step, skipping: ${(0, errors_1.toErrorMessage)(err)}`);
9383
+ return "skipped";
9382
9384
  }
9385
+ throw new errors_2.StepVerificationError(`navigate step failed: goto to ${url} threw: ${(0, errors_1.toErrorMessage)(err)}`, "navigate-failed");
9383
9386
  }
9384
- logger.warn(`spa readiness: body still ${bodyLength} chars after ${timeoutMs}ms — proceeding with possibly incomplete page`);
9387
+ await (0, spa_readiness_1.waitForSpaReady)(page, log);
9388
+ return "completed";
9385
9389
  }
9386
9390
  /**
9387
9391
  * Plugin-facing wrapper that drives a recon flow's steps through the SAME
@@ -9455,48 +9459,62 @@ async function runHealingFlow(deps) {
9455
9459
  throw new errors_2.SessionTimeoutError(`${formatStepPrefix(i, () => steps.length)} session appears closed/dead (page.url() threw: ${(0, errors_1.toErrorMessage)(err)}) — aborting after ${i} of ${steps.length} steps completed`);
9456
9460
  }
9457
9461
  lastStepIndex = i;
9458
- // Resolved fresh per step (not cached across the run) so a cross-origin
9459
- // iframe that attaches mid-flow (e.g. after an "Apply" click reveals a
9460
- // wizard embedded later in the DOM) is picked up as soon as it's
9461
- // reachable, paralleling the recon CLI's per-step resolution. `resolveFrameTarget`
9462
- // falls back to the main-frame target when `frameSelector` is null/unresolvable,
9463
- // so this is a no-op for every flow that doesn't declare one.
9464
- const frameTarget = await (0, frame_target_1.resolveFrameTarget)(page, deps.frameSelector);
9465
- await (0, frame_target_1.waitForChildFrameReady)(frameTarget);
9466
- const stepPromise = executeStepWithHealing({
9467
- stagehand,
9468
- page,
9469
- step: s.instruction,
9470
- optional: s.optional,
9471
- upload: s.upload,
9472
- submitStep: s.submitStep,
9473
- captchaGated: s.captchaGated === true,
9474
- emailStep: s.emailStep === true,
9475
- emailStepConfig: s.emailStepConfig,
9476
- allocatedInbox: deps.allocatedInbox ?? null,
9477
- flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
9478
- stepIndex: i,
9479
- totalSteps: () => steps.length,
9480
- phase: "flow",
9481
- signalCounter,
9482
- recentCaptures,
9483
- recentCaptureMeta,
9484
- anthropic,
9485
- rephraseModel,
9486
- logger,
9487
- uploadFixture,
9488
- frameTarget,
9489
- isFinalStep: i === steps.length - 1,
9490
- submitEndpointPattern: deps.submitEndpointPattern ?? null,
9491
- submittedStateSelectors: deps.submittedStateSelectors ?? [],
9492
- requireSubmitEndpointMatch: deps.requireSubmitEndpointMatch ?? false,
9493
- advanceTransitionBodyPattern: deps.advanceTransitionBodyPattern ?? null,
9494
- successUrlFragments: deps.successUrlFragments ?? [],
9495
- successPageTitleHints: deps.successPageTitleHints ?? [],
9496
- ownBackendHostnames: deps.ownBackendHostnames ?? [],
9497
- knownErrorClassPrefixes: deps.knownErrorClassPrefixes ?? [],
9498
- wizardExitButtonLabels: deps.wizardExitButtonLabels ?? [],
9499
- });
9462
+ // A navigateTo step is a direct page.goto, not an act/observe against
9463
+ // the current DOM, so it has no target frame to resolve and skips
9464
+ // straight to executeNavigateStep instead of the self-heal cascade.
9465
+ let stepPromise;
9466
+ if (s.navigateTo !== undefined) {
9467
+ stepPromise = executeNavigateStep({
9468
+ page,
9469
+ url: s.navigateTo,
9470
+ optional: s.optional,
9471
+ logger,
9472
+ });
9473
+ }
9474
+ else {
9475
+ // Resolved fresh per step (not cached across the run) so a cross-origin
9476
+ // iframe that attaches mid-flow (e.g. after an "Apply" click reveals a
9477
+ // wizard embedded later in the DOM) is picked up as soon as it's
9478
+ // reachable, paralleling the recon CLI's per-step resolution. `resolveFrameTarget`
9479
+ // falls back to the main-frame target when `frameSelector` is null/unresolvable,
9480
+ // so this is a no-op for every flow that doesn't declare one.
9481
+ const frameTarget = await (0, frame_target_1.resolveFrameTarget)(page, deps.frameSelector);
9482
+ await (0, frame_target_1.waitForChildFrameReady)(frameTarget);
9483
+ stepPromise = executeStepWithHealing({
9484
+ stagehand,
9485
+ page,
9486
+ step: s.instruction,
9487
+ optional: s.optional,
9488
+ upload: s.upload,
9489
+ submitStep: s.submitStep,
9490
+ captchaGated: s.captchaGated === true,
9491
+ emailStep: s.emailStep === true,
9492
+ emailStepConfig: s.emailStepConfig,
9493
+ allocatedInbox: deps.allocatedInbox ?? null,
9494
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
9495
+ stepIndex: i,
9496
+ totalSteps: () => steps.length,
9497
+ phase: "flow",
9498
+ signalCounter,
9499
+ recentCaptures,
9500
+ recentCaptureMeta,
9501
+ anthropic,
9502
+ rephraseModel,
9503
+ logger,
9504
+ uploadFixture,
9505
+ frameTarget,
9506
+ isFinalStep: i === steps.length - 1,
9507
+ submitEndpointPattern: deps.submitEndpointPattern ?? null,
9508
+ submittedStateSelectors: deps.submittedStateSelectors ?? [],
9509
+ requireSubmitEndpointMatch: deps.requireSubmitEndpointMatch ?? false,
9510
+ advanceTransitionBodyPattern: deps.advanceTransitionBodyPattern ?? null,
9511
+ successUrlFragments: deps.successUrlFragments ?? [],
9512
+ successPageTitleHints: deps.successPageTitleHints ?? [],
9513
+ ownBackendHostnames: deps.ownBackendHostnames ?? [],
9514
+ knownErrorClassPrefixes: deps.knownErrorClassPrefixes ?? [],
9515
+ wizardExitButtonLabels: deps.wizardExitButtonLabels ?? [],
9516
+ });
9517
+ }
9500
9518
  const outcome = deps.deathSignal
9501
9519
  ? await (0, session_teardown_1.raceAgainstTeardown)(stepPromise, deps.deathSignal)
9502
9520
  : await stepPromise;