@validate.qa/runner 1.0.55 → 1.0.56

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.
Files changed (3) hide show
  1. package/dist/cli.js +317 -42
  2. package/dist/cli.mjs +317 -42
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2253,7 +2253,8 @@ function selectorCandidatesForStep(step) {
2253
2253
  const hasExplicitResourceId = !!nonEmpty(bundle?.resourceId);
2254
2254
  const pushValueCandidates = (value) => {
2255
2255
  candidates.push(selectorForValue(value));
2256
- if (!hasExplicitResourceId && !/^(~|id=|css=|\/|\()/.test(value) && !value.includes(":id/")) {
2256
+ const looksLikeIdToken = /^[A-Za-z_][\w.:-]*$/.test(value);
2257
+ if (!hasExplicitResourceId && looksLikeIdToken && !/^(~|id=|css=|\/|\()/.test(value) && !value.includes(":id/")) {
2257
2258
  candidates.push(`id=${value}`);
2258
2259
  }
2259
2260
  };
@@ -7783,12 +7784,16 @@ var require_mobile_keyboard = __commonJS({
7783
7784
  "use strict";
7784
7785
  Object.defineProperty(exports2, "__esModule", { value: true });
7785
7786
  exports2.shouldRetryTapAfterKeyboardDismiss = shouldRetryTapAfterKeyboardDismiss;
7787
+ exports2.hasFocusedEditableElement = hasFocusedEditableElement;
7786
7788
  exports2.dismissKeyboardForRetry = dismissKeyboardForRetry;
7787
7789
  var EDITABLE_FOCUSED_ROLE_RE = /(EditText|TextField|SecureTextField|SearchField|TextView)$/i;
7788
7790
  var OUTSIDE_VIEWPORT_ERROR_RE = /(outside|out of).*?(bounds|viewport|visible|screen|window)|coordinate.*?(invalid|outside)/i;
7789
7791
  function shouldRetryTapAfterKeyboardDismiss(error2, elements) {
7790
7792
  if (!error2 || !OUTSIDE_VIEWPORT_ERROR_RE.test(error2))
7791
7793
  return false;
7794
+ return hasFocusedEditableElement(elements);
7795
+ }
7796
+ function hasFocusedEditableElement(elements) {
7792
7797
  return elements.some((el) => el.focused === true && EDITABLE_FOCUSED_ROLE_RE.test(el.role));
7793
7798
  }
7794
7799
  async function dismissKeyboardForRetry(driver) {
@@ -75293,6 +75298,9 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
75293
75298
  let totalBreadthContinuationGranted = 0;
75294
75299
  let progressMarkAtLastBreadthContinuation = null;
75295
75300
  let lastPostSubmitConfirmationToken = null;
75301
+ let keyboardRecoveryScreenId = null;
75302
+ let keyboardRecoverySameScreenTurns = 0;
75303
+ let lastKeyboardRecoveryIteration = -Infinity;
75296
75304
  const maybeGrantBreadthContinuation = (iteration, breadthCoverage) => {
75297
75305
  if (mode !== "survey" || breadthCoverage.complete || iteration < Math.floor(maxIterations * 0.8))
75298
75306
  return 0;
@@ -75363,6 +75371,29 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
75363
75371
  log2(` Post-submit confirmation snapshot failed non-fatally: ${error2 instanceof Error ? error2.message : String(error2)}`);
75364
75372
  }
75365
75373
  }
75374
+ {
75375
+ const rawScreenId = state2.getCurrentScreenId();
75376
+ const webIdx = rawScreenId ? rawScreenId.indexOf(".web-") : -1;
75377
+ const hashIdx = rawScreenId ? rawScreenId.indexOf("#") : -1;
75378
+ const currentScreenId = rawScreenId && webIdx >= 0 ? `${rawScreenId.slice(0, hashIdx >= 0 && hashIdx < webIdx ? hashIdx : webIdx)}${rawScreenId.slice(webIdx)}` : rawScreenId;
75379
+ if (currentScreenId && currentScreenId === keyboardRecoveryScreenId) {
75380
+ keyboardRecoverySameScreenTurns += 1;
75381
+ } else {
75382
+ keyboardRecoveryScreenId = currentScreenId ?? null;
75383
+ keyboardRecoverySameScreenTurns = 0;
75384
+ }
75385
+ const keyboardLikelyOpen = (0, mobile_keyboard_js_1.hasFocusedEditableElement)(getLatest()?.screen.elements ?? []);
75386
+ if (keyboardRecoverySameScreenTurns >= 4 && keyboardLikelyOpen && iteration - lastKeyboardRecoveryIteration > 4) {
75387
+ lastKeyboardRecoveryIteration = iteration;
75388
+ try {
75389
+ await (0, mobile_keyboard_js_1.dismissKeyboardForRetry)(driver);
75390
+ log2(" Auto-dismissed the on-screen keyboard (several turns stalled on this screen with a focused input).");
75391
+ transientDirectives.push("HARNESS NOTE: the on-screen keyboard was just auto-dismissed because multiple turns stalled on this screen \u2014 controls it occluded (e.g. the form submit button) may now be visible. Call mobile_snapshot, then TAP the commit/submit control instead of re-filling fields.");
75392
+ } catch (error2) {
75393
+ (0, mobile_driver_errors_js_1.rethrowIfTerminalMobileDriverError)(error2);
75394
+ }
75395
+ }
75396
+ }
75366
75397
  if (iteration > 6 && state2.isStaleProgress()) {
75367
75398
  transientDirectives.push("You have made no screen-graph progress recently. If the accessible content is covered, call finish_exploration. Otherwise swipe to reveal off-screen content or tap a not-yet-explored element.");
75368
75399
  }
@@ -79945,10 +79976,34 @@ ${constraints.join("\n")}`);
79945
79976
  const via = triggerLabel ? ` via ${JSON.stringify(triggerLabel)}` : "";
79946
79977
  group.flows?.push(`${nameById.get(transition.fromScreenId) ?? transition.fromScreenId} \u2192 ${nameById.get(transition.toScreenId) ?? transition.toScreenId} (${transition.action}${via})`);
79947
79978
  }
79948
- return [...groups.values()].map(({ realm: _realm, ...group }) => ({
79949
- ...group,
79950
- flows: [...new Set(group.flows ?? [])]
79951
- }));
79979
+ return [...groups.values()].map(({ realm, ...group }) => {
79980
+ const seenPageKeys = /* @__PURE__ */ new Set();
79981
+ const routes = [];
79982
+ const pages = [];
79983
+ group.routes.forEach((route2, index) => {
79984
+ const webIdx = route2.indexOf(".web-");
79985
+ const hashIdx = route2.indexOf("#");
79986
+ const key = webIdx >= 0 ? `${route2.slice(0, hashIdx >= 0 && hashIdx < webIdx ? hashIdx : webIdx)}${route2.slice(webIdx)}` : route2;
79987
+ if (seenPageKeys.has(key))
79988
+ return;
79989
+ seenPageKeys.add(key);
79990
+ routes.push(route2);
79991
+ const page = group.pages?.[index];
79992
+ if (page)
79993
+ pages.push(page);
79994
+ });
79995
+ const flows = [...new Set(group.flows ?? [])];
79996
+ const screenNames = [...new Set(routes.map((route2) => nameById.get(route2) ?? route2))];
79997
+ const preview = screenNames.slice(0, 3).join(", ");
79998
+ const description = `${realm === "authenticated" ? "Authenticated" : "Public"} mobile area "${group.name}" \u2014 ${routes.length} screen(s)${preview ? ` (${preview}${screenNames.length > 3 ? ", \u2026" : ""})` : ""}${flows.length > 0 ? `, ${flows.length} observed transition(s)` : ""}.`;
79999
+ return {
80000
+ ...group,
80001
+ routes,
80002
+ pages,
80003
+ description,
80004
+ flows
80005
+ };
80006
+ });
79952
80007
  }
79953
80008
  function normalizeMobileSurveyFeatureGroups(groups, graph, appId, existingFeatureNames = []) {
79954
80009
  const orderedScreens = plannableTargetAppScreens(graph, appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration || a.screenId.localeCompare(b.screenId));
@@ -82130,6 +82185,9 @@ ${statePacket}`, redactionCredentials) },
82130
82185
  function weakAssertionTargetReason(element) {
82131
82186
  if (isAlwaysPresentContainer(element))
82132
82187
  return "always-present app/system container";
82188
+ const hasIdentitySignal = Boolean(nonEmpty3(element.accessibilityId) || nonEmpty3(element.resourceId) || nonEmpty3(element.text) || nonEmpty3(element.label));
82189
+ if (!hasIdentitySignal)
82190
+ return "no identity locator (accessibility id / resource id / text) for the assert gate";
82133
82191
  const structuralIdentity = [
82134
82192
  element.accessibilityId,
82135
82193
  element.resourceId,
@@ -83632,6 +83690,94 @@ For a fix, return the COMPLETE steps array (not a diff).`;
83632
83690
  function detectInvalidMobileAuthSetup(steps, error2) {
83633
83691
  return detectInvalidExternalAuthSetup(steps, error2) ?? detectInvalidAuthValidationSetup(steps, error2);
83634
83692
  }
83693
+ var IDENTITY_GATE_ERROR_RE = /No identity locator \(accessibility id \/ resource id \/ text\) available for this step/;
83694
+ function isIdentityEqualityXpath(value) {
83695
+ return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
83696
+ }
83697
+ function stepLacksIdentityAnchor(step) {
83698
+ const metadata = step.metadata;
83699
+ if (metadata && (metadata.appContext === "WEBVIEW" || typeof metadata.webCss === "string"))
83700
+ return false;
83701
+ const target = step.target;
83702
+ if (!target)
83703
+ return true;
83704
+ const bundle = target.locatorBundle;
83705
+ if (bundle?.accessibilityId || bundle?.resourceId)
83706
+ return false;
83707
+ if (bundle?.text?.trim() || bundle?.contentDesc?.trim())
83708
+ return false;
83709
+ if (bundle?.xpath && isIdentityEqualityXpath(bundle.xpath))
83710
+ return false;
83711
+ for (const raw of [target.primary, ...target.fallbacks ?? []]) {
83712
+ if (typeof raw !== "string")
83713
+ continue;
83714
+ const value = raw.trim();
83715
+ if (!value)
83716
+ continue;
83717
+ if (value.startsWith("css="))
83718
+ continue;
83719
+ if (value.startsWith("~") && value.length > 1)
83720
+ return false;
83721
+ if (value.startsWith("id=") && value.length > 3)
83722
+ return false;
83723
+ if (value.startsWith("//") || value.startsWith("(")) {
83724
+ if (isIdentityEqualityXpath(value))
83725
+ return false;
83726
+ continue;
83727
+ }
83728
+ if (value.startsWith("/"))
83729
+ continue;
83730
+ return false;
83731
+ }
83732
+ return true;
83733
+ }
83734
+ async function tryIdentityGateStaticRepair(steps, target, ctx, options) {
83735
+ if (!ctx.error || !IDENTITY_GATE_ERROR_RE.test(ctx.error))
83736
+ return null;
83737
+ const identityless = steps.filter(stepLacksIdentityAnchor);
83738
+ if (identityless.length === 0)
83739
+ return null;
83740
+ const isAssert = (step) => /^assert/i.test(step.action);
83741
+ if (!identityless.every(isAssert)) {
83742
+ return {
83743
+ passed: false,
83744
+ verdict: "unhealable_dynamic",
83745
+ healReason: "identity gate: an action step carries no identity locator (accessibility id / resource id / text); the recording lacks the anchors replay requires \u2014 re-record the step"
83746
+ };
83747
+ }
83748
+ const pruned = steps.filter((step) => !stepLacksIdentityAnchor(step));
83749
+ if (pruned.length === 0 || !pruned.some(isAssert) || !options.runNativeTest) {
83750
+ return {
83751
+ passed: false,
83752
+ verdict: "unhealable_dynamic",
83753
+ healReason: "identity gate: the only assertion(s) carry no identity locator, so the test cannot prove its outcome as recorded \u2014 re-record with an identifiable anchor"
83754
+ };
83755
+ }
83756
+ const reindexed = pruned.map((step, order) => ({ ...step, order }));
83757
+ try {
83758
+ const verify = await options.runNativeTest({ steps: reindexed, target, testVariables: ctx.testVariables });
83759
+ if (!verify.passed)
83760
+ return null;
83761
+ let healedAppiumCode;
83762
+ if (ctx.testName) {
83763
+ try {
83764
+ healedAppiumCode = (0, shared_1.buildAppiumCode)(ctx.testName, toDeterministicTarget(target), reindexed);
83765
+ } catch {
83766
+ }
83767
+ }
83768
+ return {
83769
+ passed: true,
83770
+ verdict: "fix",
83771
+ healedSteps: reindexed,
83772
+ ...healedAppiumCode ? { healedAppiumCode } : {},
83773
+ healReason: `identity gate: dropped ${identityless.length} assert step(s) recorded without any identity locator; pruned test verified green in one replay`,
83774
+ stepResults: verify.stepResults,
83775
+ logs: verify.logs
83776
+ };
83777
+ } catch {
83778
+ return null;
83779
+ }
83780
+ }
83635
83781
  function stripCodeFences(text) {
83636
83782
  const trimmed = text.trim();
83637
83783
  const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/m.exec(trimmed);
@@ -83972,7 +84118,17 @@ ${args.directive}`);
83972
84118
  break;
83973
84119
  }
83974
84120
  }
84121
+ if (options.deadlineAt && Date.now() >= options.deadlineAt) {
84122
+ return {
84123
+ passed: false,
84124
+ verdict: "config_issue",
84125
+ healReason: `mobile heal deadline exceeded during Phase 1 (after ${attempt} attempt(s)) \u2014 aborting before further model turns`,
84126
+ attemptsUsed: attempt,
84127
+ lastError
84128
+ };
84129
+ }
83975
84130
  attempt += 1;
84131
+ options.onProgress?.(`Phase 1 attempt ${attempt}/${attemptCap} \u2014 asking the model for a script fix...`);
83976
84132
  const userContent = buildUserContent({
83977
84133
  attempt,
83978
84134
  baseSteps,
@@ -83999,6 +84155,7 @@ ${args.directive}`);
83999
84155
  }), { label: `mobile-heal-attempt-${attempt}`, deferTimeoutUntilArmed: true });
84000
84156
  content = response?.choices?.[0]?.message?.content ?? "";
84001
84157
  } catch {
84158
+ options.onProgress?.(`Phase 1 attempt ${attempt}: model call failed in transport \u2014 granting a recovery turn`);
84002
84159
  markRecoverableTurn();
84003
84160
  pendingDirective = "The previous attempt did not reach the model. Re-analyze and return one valid JSON verdict.";
84004
84161
  continue;
@@ -84173,6 +84330,9 @@ ${args.directive}`);
84173
84330
  const preflight = await tryRegistryPreflight(steps, target, ctx, options);
84174
84331
  if (preflight)
84175
84332
  return preflight;
84333
+ const identityRepair = await tryIdentityGateStaticRepair(steps, target, ctx, options);
84334
+ if (identityRepair)
84335
+ return identityRepair;
84176
84336
  const script = await executeMobileScriptDrivenHeal(steps, target, ctx, options);
84177
84337
  if (script.providerUnavailable) {
84178
84338
  const fallback = await tryDeterministicProviderFallback({ steps, target, ctx, options });
@@ -84346,6 +84506,14 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
84346
84506
  let bestStepResults;
84347
84507
  let aiResponseCount = 0;
84348
84508
  for (let pass = 1; pass <= maxPasses; pass += 1) {
84509
+ if (options.deadlineAt && Date.now() >= options.deadlineAt) {
84510
+ return {
84511
+ passed: false,
84512
+ verdict: "config_issue",
84513
+ healReason: `mobile heal deadline exceeded during live (Phase 2) healing at pass ${pass}/${maxPasses}`
84514
+ };
84515
+ }
84516
+ options.onProgress?.(`Phase 2 pass ${pass}/${maxPasses} \u2014 observing the live screen...`);
84349
84517
  let snap;
84350
84518
  try {
84351
84519
  snap = await liveDriver.snapshot();
@@ -319090,7 +319258,10 @@ var require_survey_link_frontier = __commonJS({
319090
319258
  "../runner-core/dist/services/survey-link-frontier.js"(exports2) {
319091
319259
  "use strict";
319092
319260
  Object.defineProperty(exports2, "__esModule", { value: true });
319093
- exports2.FINISH_GATE_MIN_BUDGET_PCT = exports2.MAX_FINISH_REDIRECTS = void 0;
319261
+ exports2.FINISH_ESCAPE_BOUNCE_STREAK = exports2.FINISH_GATE_MIN_BUDGET_PCT = exports2.MAX_FINISH_REDIRECTS = void 0;
319262
+ exports2.initialFinishBounceState = initialFinishBounceState;
319263
+ exports2.evaluateFinishBounce = evaluateFinishBounce;
319264
+ exports2.recordFinishBounce = recordFinishBounce;
319094
319265
  exports2.isSurveyLinkFrontierEnabled = isSurveyLinkFrontierEnabled;
319095
319266
  exports2.extractSnapshotLinks = extractSnapshotLinks;
319096
319267
  exports2.deriveLinkScope = deriveLinkScope;
@@ -319107,6 +319278,17 @@ var require_survey_link_frontier = __commonJS({
319107
319278
  var exploration_parser_js_1 = require_exploration_parser();
319108
319279
  exports2.MAX_FINISH_REDIRECTS = 2;
319109
319280
  exports2.FINISH_GATE_MIN_BUDGET_PCT = 0.2;
319281
+ exports2.FINISH_ESCAPE_BOUNCE_STREAK = 3;
319282
+ function initialFinishBounceState() {
319283
+ return { streak: 0, pagesBaseline: 0 };
319284
+ }
319285
+ function evaluateFinishBounce(prev, pagesVisited, threshold = exports2.FINISH_ESCAPE_BOUNCE_STREAK) {
319286
+ const base2 = pagesVisited > prev.pagesBaseline ? { streak: 0, pagesBaseline: pagesVisited } : prev;
319287
+ return { state: base2, escape: base2.streak >= threshold };
319288
+ }
319289
+ function recordFinishBounce(prev) {
319290
+ return { streak: prev.streak + 1, pagesBaseline: prev.pagesBaseline };
319291
+ }
319110
319292
  function isSurveyLinkFrontierEnabled() {
319111
319293
  const v = process.env.DISCOVERY_SURVEY_LINK_FRONTIER;
319112
319294
  return v === "true" || v === "1";
@@ -323295,6 +323477,7 @@ ${inbox_tools_js_1.EMAIL_VERIFICATION_UNAVAILABLE_OVERRIDE_BLOCK}` : ""}`;
323295
323477
  const linkScope = (0, survey_link_frontier_js_1.deriveLinkScope)(baseUrl);
323296
323478
  const surveyNavScope = options?.mode === "survey" ? (0, survey_scope_js_1.deriveSurveyPathScope)(baseUrl) : null;
323297
323479
  let finishRedirects = 0;
323480
+ let finishBounce = (0, survey_link_frontier_js_1.initialFinishBounceState)();
323298
323481
  let lastLandedPageUrl = null;
323299
323482
  const state2 = (0, exploration_state_js_1.createExplorationState)();
323300
323483
  function ingestLinksFromText(rawText) {
@@ -323532,12 +323715,14 @@ ${inboxPromptBlock}`;
323532
323715
  const item = state2.frontier.get(r);
323533
323716
  return item?.source === "link-seen" && item.attempted === true;
323534
323717
  };
323535
- const ranked = (0, exploration_state_js_1.getRankedFrontier)(state2, { preferShallow: true }).filter((r) => r !== "/" && !isAttemptedLinkEntry(r));
323718
+ const isSatisfied = (r) => (0, survey_link_frontier_js_1.visitedSatisfies)(state2, r);
323719
+ const ranked = (0, exploration_state_js_1.getRankedFrontier)(state2, { preferShallow: true }).filter((r) => r !== "/" && !isAttemptedLinkEntry(r) && !isSatisfied(r));
323536
323720
  const rankedSet = new Set(ranked);
323537
323721
  const navigatedUncaptured = [];
323538
323722
  for (const r of state2.frontier.keys()) {
323539
- if (r !== "/" && !rankedSet.has(r) && !isAttemptedLinkEntry(r))
323723
+ if (r !== "/" && !rankedSet.has(r) && !isAttemptedLinkEntry(r) && !isSatisfied(r)) {
323540
323724
  navigatedUncaptured.push(r);
323725
+ }
323541
323726
  }
323542
323727
  return ranked.concat(navigatedUncaptured);
323543
323728
  };
@@ -324090,6 +324275,7 @@ ${currentSummary}`;
324090
324275
  }
324091
324276
  const toolResults = [];
324092
324277
  let finishBouncedThisBatch = false;
324278
+ let finishStreakCountedThisBatch = false;
324093
324279
  const fullVarMapForGuard = variableContext.allTestVariables ?? variableContext.publicTestVariables;
324094
324280
  const { secrets: secretsOnlyForGuard } = (0, credential_tools_js_1.partitionTestVariables)(fullVarMapForGuard);
324095
324281
  const allowedCredentialValues = inboxState ? [inboxState.email] : [];
@@ -324268,25 +324454,41 @@ ${currentSummary}`;
324268
324454
  }
324269
324455
  logs2.push(` Captured page: ${normalized.route}, ${normalized.provenCommands.length} proven commands${normalized.alreadyCaptured ? " (RE-VISIT)" : ""}`);
324270
324456
  const transitionHint = !normalized.alreadyCaptured && isSurveyMode ? { tip: `Also call record_transition with fromRoute, toRoute, triggerType, and triggerLabel (the exact link text you clicked to get here).` } : {};
324271
- const lastAction = normalized.observations.length > 0 ? normalized.observations[normalized.observations.length - 1].action : void 0;
324272
- const snapshotBlock = (0, snapshot_observation_js_1.renderCompactSnapshotBlock)(
324273
- normalized,
324274
- { lastAction },
324275
- // G10: no explicit elementLimit — let the flag-aware deep default govern
324276
- // (flags-off 6 = byte-identical; richRender 8, so demoted form controls fit).
324277
- { formLimit: 3, observationLimit: 3, mode: options?.mode ?? "full", recoveredClickables }
324278
- );
324279
- toolResults.push({
324280
- role: "tool",
324281
- tool_call_id: toolCall.id,
324282
- content: JSON.stringify({
324283
- acknowledged: true,
324284
- ...normalized.alreadyCaptured ? { note: "Already captured - move on to unvisited pages." } : {},
324285
- ...transitionHint,
324286
- snapshot: snapshotBlock,
324287
- progress: `${state2.pagesVisited} pages, ${state2.transitionsFound} transitions, ${state2.recordedJourneys.length} tests | iteration ${iteration}/${maxIterations} (${maxIterations - iteration} remaining)`
324288
- })
324289
- });
324457
+ const progressLine = `${state2.pagesVisited} pages, ${state2.transitionsFound} transitions, ${state2.recordedJourneys.length} tests | iteration ${iteration}/${maxIterations} (${maxIterations - iteration} remaining)`;
324458
+ if (isSurveyMode && normalized.alreadyCaptured && normalized.observations.length === 0 && (adaptiveActive || linkFrontierEnabled)) {
324459
+ const nextRoutes = (adaptiveActive ? adaptiveDebtRoutes() : (0, survey_link_frontier_js_1.getFinishBlockingFrontier)(state2).map((i) => i.route)).slice(0, 6);
324460
+ const steer = nextRoutes.length > 0 ? `Already mapped \u2014 no new intel on this page. Still un-captured: ${nextRoutes.join(", ")}. Navigate to one and capture_page, or call finish_exploration if none remain.` : "Already mapped and no un-captured routes remain. Call finish_exploration.";
324461
+ toolResults.push({
324462
+ role: "tool",
324463
+ tool_call_id: toolCall.id,
324464
+ content: JSON.stringify({
324465
+ acknowledged: true,
324466
+ alreadyCaptured: true,
324467
+ note: steer,
324468
+ progress: progressLine
324469
+ })
324470
+ });
324471
+ } else {
324472
+ const lastAction = normalized.observations.length > 0 ? normalized.observations[normalized.observations.length - 1].action : void 0;
324473
+ const snapshotBlock = (0, snapshot_observation_js_1.renderCompactSnapshotBlock)(
324474
+ normalized,
324475
+ { lastAction },
324476
+ // G10: no explicit elementLimit — let the flag-aware deep default govern
324477
+ // (flags-off 6 = byte-identical; richRender 8, so demoted form controls fit).
324478
+ { formLimit: 3, observationLimit: 3, mode: options?.mode ?? "full", recoveredClickables }
324479
+ );
324480
+ toolResults.push({
324481
+ role: "tool",
324482
+ tool_call_id: toolCall.id,
324483
+ content: JSON.stringify({
324484
+ acknowledged: true,
324485
+ ...normalized.alreadyCaptured ? { note: "Already captured - move on to unvisited pages." } : {},
324486
+ ...transitionHint,
324487
+ snapshot: snapshotBlock,
324488
+ progress: progressLine
324489
+ })
324490
+ });
324491
+ }
324290
324492
  } catch (err) {
324291
324493
  const errMsg2 = err instanceof Error ? err.message : String(err);
324292
324494
  logs2.push(` Error persisting page: ${errMsg2}`);
@@ -324703,7 +324905,18 @@ ${currentSummary}`;
324703
324905
  continue;
324704
324906
  }
324705
324907
  if (toolName === "finish_exploration") {
324706
- if (linkFrontierEnabled) {
324908
+ const distinctPathsVisited = new Set([...state2.visitedRoutes].map((r) => (0, exploration_parser_js_1.normalizeRouteForFocusScope)(r))).size;
324909
+ const fb = (0, survey_link_frontier_js_1.evaluateFinishBounce)(finishBounce, distinctPathsVisited);
324910
+ finishBounce = fb.state;
324911
+ const forceAcceptFinish = fb.escape;
324912
+ if (forceAcceptFinish) {
324913
+ const stuck = adaptiveActive ? adaptiveDebtRoutes() : (0, survey_link_frontier_js_1.getFinishBlockingFrontier)(state2).map((i) => i.route);
324914
+ const reasonText = String(toolArgs.summary || "").replace(/\s+/g, " ").trim().slice(0, 400);
324915
+ logs2.push(` [finish-escape] accepting finish after ${finishBounce.streak} consecutive no-progress bounce(s)` + (stuck.length > 0 ? `; ${stuck.length} route(s) unresolved: ${stuck.slice(0, 6).join(", ")}${stuck.length > 6 ? " \u2026" : ""}` : ""));
324916
+ if (reasonText)
324917
+ logs2.push(` [finish-escape] agent's stated reason: ${reasonText}`);
324918
+ }
324919
+ if (!forceAcceptFinish && linkFrontierEnabled) {
324707
324920
  const blocking = (0, survey_link_frontier_js_1.getFinishBlockingFrontier)(state2);
324708
324921
  const budgetRemainingPct = maxIterations > 0 ? (maxIterations - iteration) / maxIterations : 0;
324709
324922
  if (blocking.length > 0 && budgetRemainingPct >= survey_link_frontier_js_1.FINISH_GATE_MIN_BUDGET_PCT && (finishRedirects < survey_link_frontier_js_1.MAX_FINISH_REDIRECTS || finishBouncedThisBatch)) {
@@ -324722,13 +324935,17 @@ finish_exploration bounced (${finishRedirects}/${survey_link_frontier_js_1.MAX_F
324722
324935
  reason: `Not done yet - ${blocking.length} route(s) were visible as links but never visited or captured: ${targets}. A survey must map every reachable top-level page. Navigate to each, capture_page + record_transition, then call finish_exploration. If one is genuinely unreachable (requires credentials you don't have, or leaves the app), note that in your summary and move on.`
324723
324936
  })
324724
324937
  });
324938
+ if (!finishStreakCountedThisBatch) {
324939
+ finishBounce = (0, survey_link_frontier_js_1.recordFinishBounce)(finishBounce);
324940
+ finishStreakCountedThisBatch = true;
324941
+ }
324725
324942
  continue;
324726
324943
  }
324727
324944
  if (blocking.length > 0) {
324728
324945
  logs2.push(` finish accepted with ${blocking.length} uncovered discovered route(s): ${(0, survey_link_frontier_js_1.describeBlockingRoutes)(blocking, 10)}`);
324729
324946
  }
324730
324947
  }
324731
- if (adaptiveActive && !adaptiveComplete) {
324948
+ if (!forceAcceptFinish && adaptiveActive && !adaptiveComplete) {
324732
324949
  refreshAdaptiveDiscovery();
324733
324950
  const remainingList = adaptiveDebtRoutes();
324734
324951
  if (remainingList.length > 0 && iteration < adaptiveHardCap) {
@@ -324736,10 +324953,10 @@ finish_exploration bounced (${finishRedirects}/${survey_link_frontier_js_1.MAX_F
324736
324953
  if (iteration >= adaptiveDecisionArmedAt) {
324737
324954
  const { directive, keepGoing } = runBudgetController();
324738
324955
  if (keepGoing) {
324739
- reason = directive ?? `Not done yet \u2014 ${remainingList.length} discovered route(s) were never captured: ${describeRankedRoutes(remainingList, 6)}. Visit and capture_page each (via the app's links), then call finish_exploration.`;
324956
+ reason = directive ?? `Not done yet \u2014 ${remainingList.length} discovered route(s) were never captured: ${describeRankedRoutes(remainingList, 6)}. Visit and capture_page each (via the app's links), then call finish_exploration. If one is genuinely unreachable, say so in your summary and finish.`;
324740
324957
  }
324741
324958
  } else {
324742
- reason = `Not done yet \u2014 ${remainingList.length} discovered route(s) were never captured: ${describeRankedRoutes(remainingList, 6)}. Visit and capture_page each (via the app's links), then call finish_exploration.`;
324959
+ reason = `Not done yet \u2014 ${remainingList.length} discovered route(s) were never captured: ${describeRankedRoutes(remainingList, 6)}. Visit and capture_page each (via the app's links), then call finish_exploration. If one is genuinely unreachable, say so in your summary and finish.`;
324743
324960
  }
324744
324961
  if (reason) {
324745
324962
  toolResults.push({
@@ -324748,6 +324965,10 @@ finish_exploration bounced (${finishRedirects}/${survey_link_frontier_js_1.MAX_F
324748
324965
  content: JSON.stringify({ acknowledged: false, keepExploring: true, reason })
324749
324966
  });
324750
324967
  logs2.push(` [adaptive-budget] finish bounced: ${remainingList.length} route(s) still un-captured`);
324968
+ if (!finishStreakCountedThisBatch) {
324969
+ finishBounce = (0, survey_link_frontier_js_1.recordFinishBounce)(finishBounce);
324970
+ finishStreakCountedThisBatch = true;
324971
+ }
324751
324972
  continue;
324752
324973
  }
324753
324974
  }
@@ -349375,7 +349596,7 @@ var require_package4 = __commonJS({
349375
349596
  "package.json"(exports2, module2) {
349376
349597
  module2.exports = {
349377
349598
  name: "@validate.qa/runner",
349378
- version: "1.0.55",
349599
+ version: "1.0.56",
349379
349600
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
349380
349601
  bin: {
349381
349602
  "validate-runner": "dist/cli.js"
@@ -350989,6 +351210,17 @@ async function submitCssKeyboardInCurrentContext(sessionPath, css) {
350989
351210
  if (typeof elementId !== "string" || !ELEMENT_ID_PATTERN.test(elementId)) {
350990
351211
  throw new Error(`WEBVIEW element not found for css=${css}`);
350991
351212
  }
351213
+ try {
351214
+ const scriptResult = await appiumRequest(`${sessionPath}/execute/sync`, {
351215
+ method: "POST",
351216
+ body: JSON.stringify({
351217
+ script: "var el = arguments[0];var form = el && (el.form || (el.closest ? el.closest('form') : null));if (form && typeof form.requestSubmit === 'function') { form.requestSubmit(); return 'request-submit'; }return 'no-form';",
351218
+ args: [{ ELEMENT: elementId, "element-6066-11e4-a52e-4f735466cecf": elementId }]
351219
+ })
351220
+ });
351221
+ if (readValue(scriptResult) === "request-submit") return;
351222
+ } catch {
351223
+ }
350992
351224
  await appiumRequest(`${sessionPath}/element/${elementId}/click`, {
350993
351225
  method: "POST",
350994
351226
  body: "{}"
@@ -350998,6 +351230,14 @@ async function submitCssKeyboardInCurrentContext(sessionPath, css) {
350998
351230
  body: JSON.stringify({ text: WEBDRIVER_ENTER_KEY, value: [WEBDRIVER_ENTER_KEY] })
350999
351231
  });
351000
351232
  }
351233
+ async function hideKeyboardIfShown(sessionPath) {
351234
+ try {
351235
+ const shown = await appiumRequest(`${sessionPath}/appium/device/is_keyboard_shown`, { method: "GET" });
351236
+ if (readValue(shown) !== true) return;
351237
+ await appiumRequest(`${sessionPath}/appium/device/hide_keyboard`, { method: "POST", body: "{}" });
351238
+ } catch {
351239
+ }
351240
+ }
351001
351241
  async function withWebViewContext(sessionPath, webviewName, body, options = {}) {
351002
351242
  const previous = await getAppContext(sessionPath) ?? "NATIVE_APP";
351003
351243
  let bodyCompleted = false;
@@ -355799,6 +356039,7 @@ var MobileBridgeDriver = class {
355799
356039
  () => typeCssInCurrentContext(this.sessionPath, css, value),
355800
356040
  this.webViewEntryOptions(webviewContext)
355801
356041
  );
356042
+ await hideKeyboardIfShown(this.sessionPath);
355802
356043
  });
355803
356044
  }
355804
356045
  async webSubmitKeyboard(webviewContext, css) {
@@ -355809,6 +356050,7 @@ var MobileBridgeDriver = class {
355809
356050
  () => submitCssKeyboardInCurrentContext(this.sessionPath, css),
355810
356051
  this.webViewEntryOptions(webviewContext)
355811
356052
  );
356053
+ await hideKeyboardIfShown(this.sessionPath);
355812
356054
  });
355813
356055
  }
355814
356056
  };
@@ -356248,7 +356490,7 @@ var SECRET_VAR_PATTERN = /password|secret|token|api[_]?key|pass\b|credential|bea
356248
356490
  var IS_CLOUD = parseBoolFlag(process.env.RUNNER_CLOUD);
356249
356491
  var RUNNER_NAME = runnerPackage.name ?? "@validate.qa/runner";
356250
356492
  var RUNNER_VERSION = runnerPackage.version ?? "0.0.0";
356251
- var RUNNER_BUILD_ID = process.env.VALIDATE_RUNNER_BUILD_ID ?? process.env.RAILWAY_GIT_COMMIT_SHA ?? process.env.GIT_COMMIT_SHA ?? process.env.SOURCE_VERSION;
356493
+ var RUNNER_BUILD_ID = process.env.VALIDATE_RUNNER_BUILD_ID ?? process.env.RAILWAY_GIT_COMMIT_SHA ?? process.env.GIT_COMMIT_SHA ?? process.env.SOURCE_VERSION ?? (RUNNER_VERSION !== "0.0.0" ? `npm-v${RUNNER_VERSION}` : void 0);
356252
356494
  var DEFAULT_MAX_CONCURRENT = IS_CLOUD ? 4 : 40;
356253
356495
  var DEFAULT_TYPE_LIMITS = {
356254
356496
  discovery: IS_CLOUD ? 2 : 15,
@@ -356899,7 +357141,9 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
356899
357141
  }, assignedDeviceId, mobileLeaseError) {
356900
357142
  const runMode = run2.mode ?? "run";
356901
357143
  const RUNNER_MODE = opts.runnerMode ?? "playwright-native";
356902
- log.info(`Running test: ${run2.testCaseId ?? "auth-setup"} (run ${run2.runId}) [mode: ${runMode === "heal" ? "heal" : runMode === "auth-setup" ? "auth-setup" : RUNNER_MODE}]`);
357144
+ const runLabel = run2.testCaseId ?? run2.testName ?? (runMode === "run" ? "auth-setup" : runMode);
357145
+ const modeLabel = runMode === "run" ? RUNNER_MODE : runMode;
357146
+ log.info(`Running test: ${runLabel} (run ${run2.runId}) [mode: ${modeLabel}]`);
356903
357147
  const liveBrowserHandle = import_runner_core11.liveBrowserRegistry.register({
356904
357148
  sessionId: `${run2.runId}`,
356905
357149
  mode: toLiveBrowserMode(runMode),
@@ -358733,9 +358977,15 @@ ${finalVerifyResult.logs ?? ""}`;
358733
358977
  }
358734
358978
  const reproduceError = reproduce.error;
358735
358979
  const modelOverride = run2.healerModel ?? void 0;
358980
+ const healDeadlineAt = Date.now() + Math.max(6e4, envInt("MOBILE_HEAL_DEADLINE_MS", 20 * 6e4));
358736
358981
  const healerOptions = {
358737
358982
  ...modelOverride ? { model: modelOverride } : {},
358983
+ deadlineAt: healDeadlineAt,
358738
358984
  runNativeTest,
358985
+ // Surface heal liveness: a Phase-1/Phase-2 heal can legitimately run
358986
+ // for many minutes of model calls with zero device traffic, which
358987
+ // previously printed nothing between the start banner and the verdict.
358988
+ onProgress: (message) => log.info(`\u{1FA7A} ${message}`),
358739
358989
  selectorRegistryContext: run2.selectorRegistryContext ?? null,
358740
358990
  runLiveHeal: async (a) => {
358741
358991
  let liveSession = null;
@@ -358778,6 +359028,14 @@ ${finalVerifyResult.logs ?? ""}`;
358778
359028
  }
358779
359029
  return bridgeDriver;
358780
359030
  };
359031
+ if (Date.now() >= healDeadlineAt) {
359032
+ return {
359033
+ passed: false,
359034
+ verdict: "config_issue",
359035
+ healReason: "mobile heal deadline exceeded before opening the live Phase-2 session"
359036
+ };
359037
+ }
359038
+ log.info("\u{1FA7A} Phase 2 (live device) \u2014 opening the live observation session...");
358781
359039
  try {
358782
359040
  await openLiveSession();
358783
359041
  const liveDriver = {
@@ -359623,12 +359881,29 @@ async function startRunner(opts) {
359623
359881
  }
359624
359882
  }, PIDS_WATCHDOG_INTERVAL_MS);
359625
359883
  pidsWatchdog.unref();
359884
+ const deferralLogged = /* @__PURE__ */ new Map();
359885
+ const logDeferredOnce = (runId, line) => {
359886
+ if (deferralLogged.get(runId) === line) return;
359887
+ deferralLogged.set(runId, line);
359888
+ log.dim(line);
359889
+ };
359890
+ let lastPendingSummary = "";
359626
359891
  while (!shuttingDown) {
359627
359892
  try {
359628
359893
  if (activeRuns < MAX_CONCURRENT) {
359629
359894
  const pending = await fetchPendingRuns(serverUrl, headers);
359895
+ {
359896
+ const pendingIds = new Set(pending.map((r) => r.runId));
359897
+ for (const id of [...deferralLogged.keys()]) {
359898
+ if (!pendingIds.has(id)) deferralLogged.delete(id);
359899
+ }
359900
+ }
359630
359901
  if (pending.length > 0) {
359631
- log.info(`${pending.length} pending run(s) found (${activeRuns}/${MAX_CONCURRENT} slots used)`);
359902
+ const pendingSummary = `${pending.length} pending run(s) found (${activeRuns}/${MAX_CONCURRENT} slots used)`;
359903
+ if (pendingSummary !== lastPendingSummary) {
359904
+ lastPendingSummary = pendingSummary;
359905
+ log.info(pendingSummary);
359906
+ }
359632
359907
  const staggerMs = envInt("RUN_STAGGER_MS", DEFAULT_RUN_STAGGER_MS);
359633
359908
  let claimedThisPoll = 0;
359634
359909
  for (const run2 of pending) {
@@ -359641,7 +359916,7 @@ async function startRunner(opts) {
359641
359916
  const runProject = projectKey(run2);
359642
359917
  const projectLimit = projectCapacityFor(runProject, pending);
359643
359918
  if (getActiveForProject(runProject) >= projectLimit) {
359644
- log.dim(` Deferred ${run2.runId} (project ${runProject} at capacity ${getActiveForProject(runProject)}/${projectLimit})`);
359919
+ logDeferredOnce(run2.runId, ` Deferred ${run2.runId} (project ${runProject} at capacity ${getActiveForProject(runProject)}/${projectLimit})`);
359645
359920
  continue;
359646
359921
  }
359647
359922
  const admission = (0, import_runner_core13.evaluateRunAdmission)(RESOURCE_GOVERNOR_OPTIONS, {
@@ -359651,27 +359926,27 @@ async function startRunner(opts) {
359651
359926
  browserPool: browserPool.stats()
359652
359927
  });
359653
359928
  if (!admission.allowed) {
359654
- log.dim(` Deferred ${run2.runId} (${admission.reason})`);
359929
+ logDeferredOnce(run2.runId, ` Deferred ${run2.runId} (${admission.reason})`);
359655
359930
  continue;
359656
359931
  }
359657
359932
  if (enableMobile && run2.framework === "APPIUM" && getBridgeState().recordingActive) {
359658
- log.dim(` Deferred ${run2.runId} (mobile recording session active)`);
359933
+ logDeferredOnce(run2.runId, ` Deferred ${run2.runId} (mobile recording session active)`);
359659
359934
  continue;
359660
359935
  }
359661
359936
  const discoveryMedium = runMode === "discovery" ? run2.discoveryContext?.medium : void 0;
359662
359937
  if (discoveryMedium === "IOS_NATIVE" || discoveryMedium === "ANDROID_NATIVE") {
359663
359938
  if (!enableMobile) {
359664
- log.dim(` Left PENDING ${run2.runId} (native mobile discovery needs a runner started with --mobile)`);
359939
+ logDeferredOnce(run2.runId, ` Left PENDING ${run2.runId} (native mobile discovery needs a runner started with --mobile)`);
359665
359940
  continue;
359666
359941
  }
359667
359942
  const caps = getRunnerCapabilities();
359668
359943
  const hasDeviceCap = discoveryMedium === "IOS_NATIVE" ? caps.ios : caps.android;
359669
359944
  if (!hasDeviceCap) {
359670
- log.dim(` Left PENDING ${run2.runId} (no booted ${discoveryMedium === "IOS_NATIVE" ? "iOS" : "Android"} device for native mobile discovery)`);
359945
+ logDeferredOnce(run2.runId, ` Left PENDING ${run2.runId} (no booted ${discoveryMedium === "IOS_NATIVE" ? "iOS" : "Android"} device for native mobile discovery)`);
359671
359946
  continue;
359672
359947
  }
359673
359948
  if (getBridgeState().hasActiveSession) {
359674
- log.dim(` Deferred ${run2.runId} (mobile recording session active on shared device)`);
359949
+ logDeferredOnce(run2.runId, ` Deferred ${run2.runId} (mobile recording session active on shared device)`);
359675
359950
  continue;
359676
359951
  }
359677
359952
  }
@@ -359716,7 +359991,7 @@ async function startRunner(opts) {
359716
359991
  activeMobileDiscoveries
359717
359992
  });
359718
359993
  if (!decision.claim) {
359719
- log.dim(` ${decision.disposition === "pending" ? "Left PENDING" : "Deferred"} ${run2.runId} (${decision.reason})`);
359994
+ logDeferredOnce(run2.runId, ` ${decision.disposition === "pending" ? "Left PENDING" : "Deferred"} ${run2.runId} (${decision.reason})`);
359720
359995
  continue;
359721
359996
  }
359722
359997
  leasedDeviceId = freeDeviceId;
package/dist/cli.mjs CHANGED
@@ -2258,7 +2258,8 @@ function selectorCandidatesForStep(step) {
2258
2258
  const hasExplicitResourceId = !!nonEmpty(bundle?.resourceId);
2259
2259
  const pushValueCandidates = (value) => {
2260
2260
  candidates.push(selectorForValue(value));
2261
- if (!hasExplicitResourceId && !/^(~|id=|css=|\/|\()/.test(value) && !value.includes(":id/")) {
2261
+ const looksLikeIdToken = /^[A-Za-z_][\w.:-]*$/.test(value);
2262
+ if (!hasExplicitResourceId && looksLikeIdToken && !/^(~|id=|css=|\/|\()/.test(value) && !value.includes(":id/")) {
2262
2263
  candidates.push(`id=${value}`);
2263
2264
  }
2264
2265
  };
@@ -7788,12 +7789,16 @@ var require_mobile_keyboard = __commonJS({
7788
7789
  "use strict";
7789
7790
  Object.defineProperty(exports, "__esModule", { value: true });
7790
7791
  exports.shouldRetryTapAfterKeyboardDismiss = shouldRetryTapAfterKeyboardDismiss;
7792
+ exports.hasFocusedEditableElement = hasFocusedEditableElement;
7791
7793
  exports.dismissKeyboardForRetry = dismissKeyboardForRetry;
7792
7794
  var EDITABLE_FOCUSED_ROLE_RE = /(EditText|TextField|SecureTextField|SearchField|TextView)$/i;
7793
7795
  var OUTSIDE_VIEWPORT_ERROR_RE = /(outside|out of).*?(bounds|viewport|visible|screen|window)|coordinate.*?(invalid|outside)/i;
7794
7796
  function shouldRetryTapAfterKeyboardDismiss(error2, elements) {
7795
7797
  if (!error2 || !OUTSIDE_VIEWPORT_ERROR_RE.test(error2))
7796
7798
  return false;
7799
+ return hasFocusedEditableElement(elements);
7800
+ }
7801
+ function hasFocusedEditableElement(elements) {
7797
7802
  return elements.some((el) => el.focused === true && EDITABLE_FOCUSED_ROLE_RE.test(el.role));
7798
7803
  }
7799
7804
  async function dismissKeyboardForRetry(driver) {
@@ -75298,6 +75303,9 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
75298
75303
  let totalBreadthContinuationGranted = 0;
75299
75304
  let progressMarkAtLastBreadthContinuation = null;
75300
75305
  let lastPostSubmitConfirmationToken = null;
75306
+ let keyboardRecoveryScreenId = null;
75307
+ let keyboardRecoverySameScreenTurns = 0;
75308
+ let lastKeyboardRecoveryIteration = -Infinity;
75301
75309
  const maybeGrantBreadthContinuation = (iteration, breadthCoverage) => {
75302
75310
  if (mode !== "survey" || breadthCoverage.complete || iteration < Math.floor(maxIterations * 0.8))
75303
75311
  return 0;
@@ -75368,6 +75376,29 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
75368
75376
  log2(` Post-submit confirmation snapshot failed non-fatally: ${error2 instanceof Error ? error2.message : String(error2)}`);
75369
75377
  }
75370
75378
  }
75379
+ {
75380
+ const rawScreenId = state2.getCurrentScreenId();
75381
+ const webIdx = rawScreenId ? rawScreenId.indexOf(".web-") : -1;
75382
+ const hashIdx = rawScreenId ? rawScreenId.indexOf("#") : -1;
75383
+ const currentScreenId = rawScreenId && webIdx >= 0 ? `${rawScreenId.slice(0, hashIdx >= 0 && hashIdx < webIdx ? hashIdx : webIdx)}${rawScreenId.slice(webIdx)}` : rawScreenId;
75384
+ if (currentScreenId && currentScreenId === keyboardRecoveryScreenId) {
75385
+ keyboardRecoverySameScreenTurns += 1;
75386
+ } else {
75387
+ keyboardRecoveryScreenId = currentScreenId ?? null;
75388
+ keyboardRecoverySameScreenTurns = 0;
75389
+ }
75390
+ const keyboardLikelyOpen = (0, mobile_keyboard_js_1.hasFocusedEditableElement)(getLatest()?.screen.elements ?? []);
75391
+ if (keyboardRecoverySameScreenTurns >= 4 && keyboardLikelyOpen && iteration - lastKeyboardRecoveryIteration > 4) {
75392
+ lastKeyboardRecoveryIteration = iteration;
75393
+ try {
75394
+ await (0, mobile_keyboard_js_1.dismissKeyboardForRetry)(driver);
75395
+ log2(" Auto-dismissed the on-screen keyboard (several turns stalled on this screen with a focused input).");
75396
+ transientDirectives.push("HARNESS NOTE: the on-screen keyboard was just auto-dismissed because multiple turns stalled on this screen \u2014 controls it occluded (e.g. the form submit button) may now be visible. Call mobile_snapshot, then TAP the commit/submit control instead of re-filling fields.");
75397
+ } catch (error2) {
75398
+ (0, mobile_driver_errors_js_1.rethrowIfTerminalMobileDriverError)(error2);
75399
+ }
75400
+ }
75401
+ }
75371
75402
  if (iteration > 6 && state2.isStaleProgress()) {
75372
75403
  transientDirectives.push("You have made no screen-graph progress recently. If the accessible content is covered, call finish_exploration. Otherwise swipe to reveal off-screen content or tap a not-yet-explored element.");
75373
75404
  }
@@ -79950,10 +79981,34 @@ ${constraints.join("\n")}`);
79950
79981
  const via = triggerLabel ? ` via ${JSON.stringify(triggerLabel)}` : "";
79951
79982
  group.flows?.push(`${nameById.get(transition.fromScreenId) ?? transition.fromScreenId} \u2192 ${nameById.get(transition.toScreenId) ?? transition.toScreenId} (${transition.action}${via})`);
79952
79983
  }
79953
- return [...groups.values()].map(({ realm: _realm, ...group }) => ({
79954
- ...group,
79955
- flows: [...new Set(group.flows ?? [])]
79956
- }));
79984
+ return [...groups.values()].map(({ realm, ...group }) => {
79985
+ const seenPageKeys = /* @__PURE__ */ new Set();
79986
+ const routes = [];
79987
+ const pages = [];
79988
+ group.routes.forEach((route2, index) => {
79989
+ const webIdx = route2.indexOf(".web-");
79990
+ const hashIdx = route2.indexOf("#");
79991
+ const key = webIdx >= 0 ? `${route2.slice(0, hashIdx >= 0 && hashIdx < webIdx ? hashIdx : webIdx)}${route2.slice(webIdx)}` : route2;
79992
+ if (seenPageKeys.has(key))
79993
+ return;
79994
+ seenPageKeys.add(key);
79995
+ routes.push(route2);
79996
+ const page = group.pages?.[index];
79997
+ if (page)
79998
+ pages.push(page);
79999
+ });
80000
+ const flows = [...new Set(group.flows ?? [])];
80001
+ const screenNames = [...new Set(routes.map((route2) => nameById.get(route2) ?? route2))];
80002
+ const preview = screenNames.slice(0, 3).join(", ");
80003
+ const description = `${realm === "authenticated" ? "Authenticated" : "Public"} mobile area "${group.name}" \u2014 ${routes.length} screen(s)${preview ? ` (${preview}${screenNames.length > 3 ? ", \u2026" : ""})` : ""}${flows.length > 0 ? `, ${flows.length} observed transition(s)` : ""}.`;
80004
+ return {
80005
+ ...group,
80006
+ routes,
80007
+ pages,
80008
+ description,
80009
+ flows
80010
+ };
80011
+ });
79957
80012
  }
79958
80013
  function normalizeMobileSurveyFeatureGroups(groups, graph, appId, existingFeatureNames = []) {
79959
80014
  const orderedScreens = plannableTargetAppScreens(graph, appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration || a.screenId.localeCompare(b.screenId));
@@ -82135,6 +82190,9 @@ ${statePacket}`, redactionCredentials) },
82135
82190
  function weakAssertionTargetReason(element) {
82136
82191
  if (isAlwaysPresentContainer(element))
82137
82192
  return "always-present app/system container";
82193
+ const hasIdentitySignal = Boolean(nonEmpty3(element.accessibilityId) || nonEmpty3(element.resourceId) || nonEmpty3(element.text) || nonEmpty3(element.label));
82194
+ if (!hasIdentitySignal)
82195
+ return "no identity locator (accessibility id / resource id / text) for the assert gate";
82138
82196
  const structuralIdentity = [
82139
82197
  element.accessibilityId,
82140
82198
  element.resourceId,
@@ -83637,6 +83695,94 @@ For a fix, return the COMPLETE steps array (not a diff).`;
83637
83695
  function detectInvalidMobileAuthSetup(steps, error2) {
83638
83696
  return detectInvalidExternalAuthSetup(steps, error2) ?? detectInvalidAuthValidationSetup(steps, error2);
83639
83697
  }
83698
+ var IDENTITY_GATE_ERROR_RE = /No identity locator \(accessibility id \/ resource id \/ text\) available for this step/;
83699
+ function isIdentityEqualityXpath(value) {
83700
+ return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
83701
+ }
83702
+ function stepLacksIdentityAnchor(step) {
83703
+ const metadata = step.metadata;
83704
+ if (metadata && (metadata.appContext === "WEBVIEW" || typeof metadata.webCss === "string"))
83705
+ return false;
83706
+ const target = step.target;
83707
+ if (!target)
83708
+ return true;
83709
+ const bundle = target.locatorBundle;
83710
+ if (bundle?.accessibilityId || bundle?.resourceId)
83711
+ return false;
83712
+ if (bundle?.text?.trim() || bundle?.contentDesc?.trim())
83713
+ return false;
83714
+ if (bundle?.xpath && isIdentityEqualityXpath(bundle.xpath))
83715
+ return false;
83716
+ for (const raw of [target.primary, ...target.fallbacks ?? []]) {
83717
+ if (typeof raw !== "string")
83718
+ continue;
83719
+ const value = raw.trim();
83720
+ if (!value)
83721
+ continue;
83722
+ if (value.startsWith("css="))
83723
+ continue;
83724
+ if (value.startsWith("~") && value.length > 1)
83725
+ return false;
83726
+ if (value.startsWith("id=") && value.length > 3)
83727
+ return false;
83728
+ if (value.startsWith("//") || value.startsWith("(")) {
83729
+ if (isIdentityEqualityXpath(value))
83730
+ return false;
83731
+ continue;
83732
+ }
83733
+ if (value.startsWith("/"))
83734
+ continue;
83735
+ return false;
83736
+ }
83737
+ return true;
83738
+ }
83739
+ async function tryIdentityGateStaticRepair(steps, target, ctx, options) {
83740
+ if (!ctx.error || !IDENTITY_GATE_ERROR_RE.test(ctx.error))
83741
+ return null;
83742
+ const identityless = steps.filter(stepLacksIdentityAnchor);
83743
+ if (identityless.length === 0)
83744
+ return null;
83745
+ const isAssert = (step) => /^assert/i.test(step.action);
83746
+ if (!identityless.every(isAssert)) {
83747
+ return {
83748
+ passed: false,
83749
+ verdict: "unhealable_dynamic",
83750
+ healReason: "identity gate: an action step carries no identity locator (accessibility id / resource id / text); the recording lacks the anchors replay requires \u2014 re-record the step"
83751
+ };
83752
+ }
83753
+ const pruned = steps.filter((step) => !stepLacksIdentityAnchor(step));
83754
+ if (pruned.length === 0 || !pruned.some(isAssert) || !options.runNativeTest) {
83755
+ return {
83756
+ passed: false,
83757
+ verdict: "unhealable_dynamic",
83758
+ healReason: "identity gate: the only assertion(s) carry no identity locator, so the test cannot prove its outcome as recorded \u2014 re-record with an identifiable anchor"
83759
+ };
83760
+ }
83761
+ const reindexed = pruned.map((step, order) => ({ ...step, order }));
83762
+ try {
83763
+ const verify = await options.runNativeTest({ steps: reindexed, target, testVariables: ctx.testVariables });
83764
+ if (!verify.passed)
83765
+ return null;
83766
+ let healedAppiumCode;
83767
+ if (ctx.testName) {
83768
+ try {
83769
+ healedAppiumCode = (0, shared_1.buildAppiumCode)(ctx.testName, toDeterministicTarget(target), reindexed);
83770
+ } catch {
83771
+ }
83772
+ }
83773
+ return {
83774
+ passed: true,
83775
+ verdict: "fix",
83776
+ healedSteps: reindexed,
83777
+ ...healedAppiumCode ? { healedAppiumCode } : {},
83778
+ healReason: `identity gate: dropped ${identityless.length} assert step(s) recorded without any identity locator; pruned test verified green in one replay`,
83779
+ stepResults: verify.stepResults,
83780
+ logs: verify.logs
83781
+ };
83782
+ } catch {
83783
+ return null;
83784
+ }
83785
+ }
83640
83786
  function stripCodeFences(text) {
83641
83787
  const trimmed = text.trim();
83642
83788
  const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/m.exec(trimmed);
@@ -83977,7 +84123,17 @@ ${args.directive}`);
83977
84123
  break;
83978
84124
  }
83979
84125
  }
84126
+ if (options.deadlineAt && Date.now() >= options.deadlineAt) {
84127
+ return {
84128
+ passed: false,
84129
+ verdict: "config_issue",
84130
+ healReason: `mobile heal deadline exceeded during Phase 1 (after ${attempt} attempt(s)) \u2014 aborting before further model turns`,
84131
+ attemptsUsed: attempt,
84132
+ lastError
84133
+ };
84134
+ }
83980
84135
  attempt += 1;
84136
+ options.onProgress?.(`Phase 1 attempt ${attempt}/${attemptCap} \u2014 asking the model for a script fix...`);
83981
84137
  const userContent = buildUserContent({
83982
84138
  attempt,
83983
84139
  baseSteps,
@@ -84004,6 +84160,7 @@ ${args.directive}`);
84004
84160
  }), { label: `mobile-heal-attempt-${attempt}`, deferTimeoutUntilArmed: true });
84005
84161
  content = response?.choices?.[0]?.message?.content ?? "";
84006
84162
  } catch {
84163
+ options.onProgress?.(`Phase 1 attempt ${attempt}: model call failed in transport \u2014 granting a recovery turn`);
84007
84164
  markRecoverableTurn();
84008
84165
  pendingDirective = "The previous attempt did not reach the model. Re-analyze and return one valid JSON verdict.";
84009
84166
  continue;
@@ -84178,6 +84335,9 @@ ${args.directive}`);
84178
84335
  const preflight = await tryRegistryPreflight(steps, target, ctx, options);
84179
84336
  if (preflight)
84180
84337
  return preflight;
84338
+ const identityRepair = await tryIdentityGateStaticRepair(steps, target, ctx, options);
84339
+ if (identityRepair)
84340
+ return identityRepair;
84181
84341
  const script = await executeMobileScriptDrivenHeal(steps, target, ctx, options);
84182
84342
  if (script.providerUnavailable) {
84183
84343
  const fallback = await tryDeterministicProviderFallback({ steps, target, ctx, options });
@@ -84351,6 +84511,14 @@ For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to r
84351
84511
  let bestStepResults;
84352
84512
  let aiResponseCount = 0;
84353
84513
  for (let pass = 1; pass <= maxPasses; pass += 1) {
84514
+ if (options.deadlineAt && Date.now() >= options.deadlineAt) {
84515
+ return {
84516
+ passed: false,
84517
+ verdict: "config_issue",
84518
+ healReason: `mobile heal deadline exceeded during live (Phase 2) healing at pass ${pass}/${maxPasses}`
84519
+ };
84520
+ }
84521
+ options.onProgress?.(`Phase 2 pass ${pass}/${maxPasses} \u2014 observing the live screen...`);
84354
84522
  let snap;
84355
84523
  try {
84356
84524
  snap = await liveDriver.snapshot();
@@ -319095,7 +319263,10 @@ var require_survey_link_frontier = __commonJS({
319095
319263
  "../runner-core/dist/services/survey-link-frontier.js"(exports) {
319096
319264
  "use strict";
319097
319265
  Object.defineProperty(exports, "__esModule", { value: true });
319098
- exports.FINISH_GATE_MIN_BUDGET_PCT = exports.MAX_FINISH_REDIRECTS = void 0;
319266
+ exports.FINISH_ESCAPE_BOUNCE_STREAK = exports.FINISH_GATE_MIN_BUDGET_PCT = exports.MAX_FINISH_REDIRECTS = void 0;
319267
+ exports.initialFinishBounceState = initialFinishBounceState;
319268
+ exports.evaluateFinishBounce = evaluateFinishBounce;
319269
+ exports.recordFinishBounce = recordFinishBounce;
319099
319270
  exports.isSurveyLinkFrontierEnabled = isSurveyLinkFrontierEnabled;
319100
319271
  exports.extractSnapshotLinks = extractSnapshotLinks;
319101
319272
  exports.deriveLinkScope = deriveLinkScope;
@@ -319112,6 +319283,17 @@ var require_survey_link_frontier = __commonJS({
319112
319283
  var exploration_parser_js_1 = require_exploration_parser();
319113
319284
  exports.MAX_FINISH_REDIRECTS = 2;
319114
319285
  exports.FINISH_GATE_MIN_BUDGET_PCT = 0.2;
319286
+ exports.FINISH_ESCAPE_BOUNCE_STREAK = 3;
319287
+ function initialFinishBounceState() {
319288
+ return { streak: 0, pagesBaseline: 0 };
319289
+ }
319290
+ function evaluateFinishBounce(prev, pagesVisited, threshold = exports.FINISH_ESCAPE_BOUNCE_STREAK) {
319291
+ const base2 = pagesVisited > prev.pagesBaseline ? { streak: 0, pagesBaseline: pagesVisited } : prev;
319292
+ return { state: base2, escape: base2.streak >= threshold };
319293
+ }
319294
+ function recordFinishBounce(prev) {
319295
+ return { streak: prev.streak + 1, pagesBaseline: prev.pagesBaseline };
319296
+ }
319115
319297
  function isSurveyLinkFrontierEnabled() {
319116
319298
  const v = process.env.DISCOVERY_SURVEY_LINK_FRONTIER;
319117
319299
  return v === "true" || v === "1";
@@ -323300,6 +323482,7 @@ ${inbox_tools_js_1.EMAIL_VERIFICATION_UNAVAILABLE_OVERRIDE_BLOCK}` : ""}`;
323300
323482
  const linkScope = (0, survey_link_frontier_js_1.deriveLinkScope)(baseUrl);
323301
323483
  const surveyNavScope = options?.mode === "survey" ? (0, survey_scope_js_1.deriveSurveyPathScope)(baseUrl) : null;
323302
323484
  let finishRedirects = 0;
323485
+ let finishBounce = (0, survey_link_frontier_js_1.initialFinishBounceState)();
323303
323486
  let lastLandedPageUrl = null;
323304
323487
  const state2 = (0, exploration_state_js_1.createExplorationState)();
323305
323488
  function ingestLinksFromText(rawText) {
@@ -323537,12 +323720,14 @@ ${inboxPromptBlock}`;
323537
323720
  const item = state2.frontier.get(r);
323538
323721
  return item?.source === "link-seen" && item.attempted === true;
323539
323722
  };
323540
- const ranked = (0, exploration_state_js_1.getRankedFrontier)(state2, { preferShallow: true }).filter((r) => r !== "/" && !isAttemptedLinkEntry(r));
323723
+ const isSatisfied = (r) => (0, survey_link_frontier_js_1.visitedSatisfies)(state2, r);
323724
+ const ranked = (0, exploration_state_js_1.getRankedFrontier)(state2, { preferShallow: true }).filter((r) => r !== "/" && !isAttemptedLinkEntry(r) && !isSatisfied(r));
323541
323725
  const rankedSet = new Set(ranked);
323542
323726
  const navigatedUncaptured = [];
323543
323727
  for (const r of state2.frontier.keys()) {
323544
- if (r !== "/" && !rankedSet.has(r) && !isAttemptedLinkEntry(r))
323728
+ if (r !== "/" && !rankedSet.has(r) && !isAttemptedLinkEntry(r) && !isSatisfied(r)) {
323545
323729
  navigatedUncaptured.push(r);
323730
+ }
323546
323731
  }
323547
323732
  return ranked.concat(navigatedUncaptured);
323548
323733
  };
@@ -324095,6 +324280,7 @@ ${currentSummary}`;
324095
324280
  }
324096
324281
  const toolResults = [];
324097
324282
  let finishBouncedThisBatch = false;
324283
+ let finishStreakCountedThisBatch = false;
324098
324284
  const fullVarMapForGuard = variableContext.allTestVariables ?? variableContext.publicTestVariables;
324099
324285
  const { secrets: secretsOnlyForGuard } = (0, credential_tools_js_1.partitionTestVariables)(fullVarMapForGuard);
324100
324286
  const allowedCredentialValues = inboxState ? [inboxState.email] : [];
@@ -324273,25 +324459,41 @@ ${currentSummary}`;
324273
324459
  }
324274
324460
  logs2.push(` Captured page: ${normalized.route}, ${normalized.provenCommands.length} proven commands${normalized.alreadyCaptured ? " (RE-VISIT)" : ""}`);
324275
324461
  const transitionHint = !normalized.alreadyCaptured && isSurveyMode ? { tip: `Also call record_transition with fromRoute, toRoute, triggerType, and triggerLabel (the exact link text you clicked to get here).` } : {};
324276
- const lastAction = normalized.observations.length > 0 ? normalized.observations[normalized.observations.length - 1].action : void 0;
324277
- const snapshotBlock = (0, snapshot_observation_js_1.renderCompactSnapshotBlock)(
324278
- normalized,
324279
- { lastAction },
324280
- // G10: no explicit elementLimit — let the flag-aware deep default govern
324281
- // (flags-off 6 = byte-identical; richRender 8, so demoted form controls fit).
324282
- { formLimit: 3, observationLimit: 3, mode: options?.mode ?? "full", recoveredClickables }
324283
- );
324284
- toolResults.push({
324285
- role: "tool",
324286
- tool_call_id: toolCall.id,
324287
- content: JSON.stringify({
324288
- acknowledged: true,
324289
- ...normalized.alreadyCaptured ? { note: "Already captured - move on to unvisited pages." } : {},
324290
- ...transitionHint,
324291
- snapshot: snapshotBlock,
324292
- progress: `${state2.pagesVisited} pages, ${state2.transitionsFound} transitions, ${state2.recordedJourneys.length} tests | iteration ${iteration}/${maxIterations} (${maxIterations - iteration} remaining)`
324293
- })
324294
- });
324462
+ const progressLine = `${state2.pagesVisited} pages, ${state2.transitionsFound} transitions, ${state2.recordedJourneys.length} tests | iteration ${iteration}/${maxIterations} (${maxIterations - iteration} remaining)`;
324463
+ if (isSurveyMode && normalized.alreadyCaptured && normalized.observations.length === 0 && (adaptiveActive || linkFrontierEnabled)) {
324464
+ const nextRoutes = (adaptiveActive ? adaptiveDebtRoutes() : (0, survey_link_frontier_js_1.getFinishBlockingFrontier)(state2).map((i) => i.route)).slice(0, 6);
324465
+ const steer = nextRoutes.length > 0 ? `Already mapped \u2014 no new intel on this page. Still un-captured: ${nextRoutes.join(", ")}. Navigate to one and capture_page, or call finish_exploration if none remain.` : "Already mapped and no un-captured routes remain. Call finish_exploration.";
324466
+ toolResults.push({
324467
+ role: "tool",
324468
+ tool_call_id: toolCall.id,
324469
+ content: JSON.stringify({
324470
+ acknowledged: true,
324471
+ alreadyCaptured: true,
324472
+ note: steer,
324473
+ progress: progressLine
324474
+ })
324475
+ });
324476
+ } else {
324477
+ const lastAction = normalized.observations.length > 0 ? normalized.observations[normalized.observations.length - 1].action : void 0;
324478
+ const snapshotBlock = (0, snapshot_observation_js_1.renderCompactSnapshotBlock)(
324479
+ normalized,
324480
+ { lastAction },
324481
+ // G10: no explicit elementLimit — let the flag-aware deep default govern
324482
+ // (flags-off 6 = byte-identical; richRender 8, so demoted form controls fit).
324483
+ { formLimit: 3, observationLimit: 3, mode: options?.mode ?? "full", recoveredClickables }
324484
+ );
324485
+ toolResults.push({
324486
+ role: "tool",
324487
+ tool_call_id: toolCall.id,
324488
+ content: JSON.stringify({
324489
+ acknowledged: true,
324490
+ ...normalized.alreadyCaptured ? { note: "Already captured - move on to unvisited pages." } : {},
324491
+ ...transitionHint,
324492
+ snapshot: snapshotBlock,
324493
+ progress: progressLine
324494
+ })
324495
+ });
324496
+ }
324295
324497
  } catch (err) {
324296
324498
  const errMsg2 = err instanceof Error ? err.message : String(err);
324297
324499
  logs2.push(` Error persisting page: ${errMsg2}`);
@@ -324708,7 +324910,18 @@ ${currentSummary}`;
324708
324910
  continue;
324709
324911
  }
324710
324912
  if (toolName === "finish_exploration") {
324711
- if (linkFrontierEnabled) {
324913
+ const distinctPathsVisited = new Set([...state2.visitedRoutes].map((r) => (0, exploration_parser_js_1.normalizeRouteForFocusScope)(r))).size;
324914
+ const fb = (0, survey_link_frontier_js_1.evaluateFinishBounce)(finishBounce, distinctPathsVisited);
324915
+ finishBounce = fb.state;
324916
+ const forceAcceptFinish = fb.escape;
324917
+ if (forceAcceptFinish) {
324918
+ const stuck = adaptiveActive ? adaptiveDebtRoutes() : (0, survey_link_frontier_js_1.getFinishBlockingFrontier)(state2).map((i) => i.route);
324919
+ const reasonText = String(toolArgs.summary || "").replace(/\s+/g, " ").trim().slice(0, 400);
324920
+ logs2.push(` [finish-escape] accepting finish after ${finishBounce.streak} consecutive no-progress bounce(s)` + (stuck.length > 0 ? `; ${stuck.length} route(s) unresolved: ${stuck.slice(0, 6).join(", ")}${stuck.length > 6 ? " \u2026" : ""}` : ""));
324921
+ if (reasonText)
324922
+ logs2.push(` [finish-escape] agent's stated reason: ${reasonText}`);
324923
+ }
324924
+ if (!forceAcceptFinish && linkFrontierEnabled) {
324712
324925
  const blocking = (0, survey_link_frontier_js_1.getFinishBlockingFrontier)(state2);
324713
324926
  const budgetRemainingPct = maxIterations > 0 ? (maxIterations - iteration) / maxIterations : 0;
324714
324927
  if (blocking.length > 0 && budgetRemainingPct >= survey_link_frontier_js_1.FINISH_GATE_MIN_BUDGET_PCT && (finishRedirects < survey_link_frontier_js_1.MAX_FINISH_REDIRECTS || finishBouncedThisBatch)) {
@@ -324727,13 +324940,17 @@ finish_exploration bounced (${finishRedirects}/${survey_link_frontier_js_1.MAX_F
324727
324940
  reason: `Not done yet - ${blocking.length} route(s) were visible as links but never visited or captured: ${targets}. A survey must map every reachable top-level page. Navigate to each, capture_page + record_transition, then call finish_exploration. If one is genuinely unreachable (requires credentials you don't have, or leaves the app), note that in your summary and move on.`
324728
324941
  })
324729
324942
  });
324943
+ if (!finishStreakCountedThisBatch) {
324944
+ finishBounce = (0, survey_link_frontier_js_1.recordFinishBounce)(finishBounce);
324945
+ finishStreakCountedThisBatch = true;
324946
+ }
324730
324947
  continue;
324731
324948
  }
324732
324949
  if (blocking.length > 0) {
324733
324950
  logs2.push(` finish accepted with ${blocking.length} uncovered discovered route(s): ${(0, survey_link_frontier_js_1.describeBlockingRoutes)(blocking, 10)}`);
324734
324951
  }
324735
324952
  }
324736
- if (adaptiveActive && !adaptiveComplete) {
324953
+ if (!forceAcceptFinish && adaptiveActive && !adaptiveComplete) {
324737
324954
  refreshAdaptiveDiscovery();
324738
324955
  const remainingList = adaptiveDebtRoutes();
324739
324956
  if (remainingList.length > 0 && iteration < adaptiveHardCap) {
@@ -324741,10 +324958,10 @@ finish_exploration bounced (${finishRedirects}/${survey_link_frontier_js_1.MAX_F
324741
324958
  if (iteration >= adaptiveDecisionArmedAt) {
324742
324959
  const { directive, keepGoing } = runBudgetController();
324743
324960
  if (keepGoing) {
324744
- reason = directive ?? `Not done yet \u2014 ${remainingList.length} discovered route(s) were never captured: ${describeRankedRoutes(remainingList, 6)}. Visit and capture_page each (via the app's links), then call finish_exploration.`;
324961
+ reason = directive ?? `Not done yet \u2014 ${remainingList.length} discovered route(s) were never captured: ${describeRankedRoutes(remainingList, 6)}. Visit and capture_page each (via the app's links), then call finish_exploration. If one is genuinely unreachable, say so in your summary and finish.`;
324745
324962
  }
324746
324963
  } else {
324747
- reason = `Not done yet \u2014 ${remainingList.length} discovered route(s) were never captured: ${describeRankedRoutes(remainingList, 6)}. Visit and capture_page each (via the app's links), then call finish_exploration.`;
324964
+ reason = `Not done yet \u2014 ${remainingList.length} discovered route(s) were never captured: ${describeRankedRoutes(remainingList, 6)}. Visit and capture_page each (via the app's links), then call finish_exploration. If one is genuinely unreachable, say so in your summary and finish.`;
324748
324965
  }
324749
324966
  if (reason) {
324750
324967
  toolResults.push({
@@ -324753,6 +324970,10 @@ finish_exploration bounced (${finishRedirects}/${survey_link_frontier_js_1.MAX_F
324753
324970
  content: JSON.stringify({ acknowledged: false, keepExploring: true, reason })
324754
324971
  });
324755
324972
  logs2.push(` [adaptive-budget] finish bounced: ${remainingList.length} route(s) still un-captured`);
324973
+ if (!finishStreakCountedThisBatch) {
324974
+ finishBounce = (0, survey_link_frontier_js_1.recordFinishBounce)(finishBounce);
324975
+ finishStreakCountedThisBatch = true;
324976
+ }
324756
324977
  continue;
324757
324978
  }
324758
324979
  }
@@ -349380,7 +349601,7 @@ var require_package4 = __commonJS({
349380
349601
  "package.json"(exports, module) {
349381
349602
  module.exports = {
349382
349603
  name: "@validate.qa/runner",
349383
- version: "1.0.55",
349604
+ version: "1.0.56",
349384
349605
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
349385
349606
  bin: {
349386
349607
  "validate-runner": "dist/cli.js"
@@ -350994,6 +351215,17 @@ async function submitCssKeyboardInCurrentContext(sessionPath, css) {
350994
351215
  if (typeof elementId !== "string" || !ELEMENT_ID_PATTERN.test(elementId)) {
350995
351216
  throw new Error(`WEBVIEW element not found for css=${css}`);
350996
351217
  }
351218
+ try {
351219
+ const scriptResult = await appiumRequest(`${sessionPath}/execute/sync`, {
351220
+ method: "POST",
351221
+ body: JSON.stringify({
351222
+ script: "var el = arguments[0];var form = el && (el.form || (el.closest ? el.closest('form') : null));if (form && typeof form.requestSubmit === 'function') { form.requestSubmit(); return 'request-submit'; }return 'no-form';",
351223
+ args: [{ ELEMENT: elementId, "element-6066-11e4-a52e-4f735466cecf": elementId }]
351224
+ })
351225
+ });
351226
+ if (readValue(scriptResult) === "request-submit") return;
351227
+ } catch {
351228
+ }
350997
351229
  await appiumRequest(`${sessionPath}/element/${elementId}/click`, {
350998
351230
  method: "POST",
350999
351231
  body: "{}"
@@ -351003,6 +351235,14 @@ async function submitCssKeyboardInCurrentContext(sessionPath, css) {
351003
351235
  body: JSON.stringify({ text: WEBDRIVER_ENTER_KEY, value: [WEBDRIVER_ENTER_KEY] })
351004
351236
  });
351005
351237
  }
351238
+ async function hideKeyboardIfShown(sessionPath) {
351239
+ try {
351240
+ const shown = await appiumRequest(`${sessionPath}/appium/device/is_keyboard_shown`, { method: "GET" });
351241
+ if (readValue(shown) !== true) return;
351242
+ await appiumRequest(`${sessionPath}/appium/device/hide_keyboard`, { method: "POST", body: "{}" });
351243
+ } catch {
351244
+ }
351245
+ }
351006
351246
  async function withWebViewContext(sessionPath, webviewName, body, options = {}) {
351007
351247
  const previous = await getAppContext(sessionPath) ?? "NATIVE_APP";
351008
351248
  let bodyCompleted = false;
@@ -355807,6 +356047,7 @@ var MobileBridgeDriver = class {
355807
356047
  () => typeCssInCurrentContext(this.sessionPath, css, value),
355808
356048
  this.webViewEntryOptions(webviewContext)
355809
356049
  );
356050
+ await hideKeyboardIfShown(this.sessionPath);
355810
356051
  });
355811
356052
  }
355812
356053
  async webSubmitKeyboard(webviewContext, css) {
@@ -355817,6 +356058,7 @@ var MobileBridgeDriver = class {
355817
356058
  () => submitCssKeyboardInCurrentContext(this.sessionPath, css),
355818
356059
  this.webViewEntryOptions(webviewContext)
355819
356060
  );
356061
+ await hideKeyboardIfShown(this.sessionPath);
355820
356062
  });
355821
356063
  }
355822
356064
  };
@@ -356256,7 +356498,7 @@ var SECRET_VAR_PATTERN = /password|secret|token|api[_]?key|pass\b|credential|bea
356256
356498
  var IS_CLOUD = parseBoolFlag(process.env.RUNNER_CLOUD);
356257
356499
  var RUNNER_NAME = runnerPackage.name ?? "@validate.qa/runner";
356258
356500
  var RUNNER_VERSION = runnerPackage.version ?? "0.0.0";
356259
- var RUNNER_BUILD_ID = process.env.VALIDATE_RUNNER_BUILD_ID ?? process.env.RAILWAY_GIT_COMMIT_SHA ?? process.env.GIT_COMMIT_SHA ?? process.env.SOURCE_VERSION;
356501
+ var RUNNER_BUILD_ID = process.env.VALIDATE_RUNNER_BUILD_ID ?? process.env.RAILWAY_GIT_COMMIT_SHA ?? process.env.GIT_COMMIT_SHA ?? process.env.SOURCE_VERSION ?? (RUNNER_VERSION !== "0.0.0" ? `npm-v${RUNNER_VERSION}` : void 0);
356260
356502
  var DEFAULT_MAX_CONCURRENT = IS_CLOUD ? 4 : 40;
356261
356503
  var DEFAULT_TYPE_LIMITS = {
356262
356504
  discovery: IS_CLOUD ? 2 : 15,
@@ -356907,7 +357149,9 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
356907
357149
  }, assignedDeviceId, mobileLeaseError) {
356908
357150
  const runMode = run2.mode ?? "run";
356909
357151
  const RUNNER_MODE = opts.runnerMode ?? "playwright-native";
356910
- log.info(`Running test: ${run2.testCaseId ?? "auth-setup"} (run ${run2.runId}) [mode: ${runMode === "heal" ? "heal" : runMode === "auth-setup" ? "auth-setup" : RUNNER_MODE}]`);
357152
+ const runLabel = run2.testCaseId ?? run2.testName ?? (runMode === "run" ? "auth-setup" : runMode);
357153
+ const modeLabel = runMode === "run" ? RUNNER_MODE : runMode;
357154
+ log.info(`Running test: ${runLabel} (run ${run2.runId}) [mode: ${modeLabel}]`);
356911
357155
  const liveBrowserHandle = import_runner_core11.liveBrowserRegistry.register({
356912
357156
  sessionId: `${run2.runId}`,
356913
357157
  mode: toLiveBrowserMode(runMode),
@@ -358741,9 +358985,15 @@ ${finalVerifyResult.logs ?? ""}`;
358741
358985
  }
358742
358986
  const reproduceError = reproduce.error;
358743
358987
  const modelOverride = run2.healerModel ?? void 0;
358988
+ const healDeadlineAt = Date.now() + Math.max(6e4, envInt("MOBILE_HEAL_DEADLINE_MS", 20 * 6e4));
358744
358989
  const healerOptions = {
358745
358990
  ...modelOverride ? { model: modelOverride } : {},
358991
+ deadlineAt: healDeadlineAt,
358746
358992
  runNativeTest,
358993
+ // Surface heal liveness: a Phase-1/Phase-2 heal can legitimately run
358994
+ // for many minutes of model calls with zero device traffic, which
358995
+ // previously printed nothing between the start banner and the verdict.
358996
+ onProgress: (message) => log.info(`\u{1FA7A} ${message}`),
358747
358997
  selectorRegistryContext: run2.selectorRegistryContext ?? null,
358748
358998
  runLiveHeal: async (a) => {
358749
358999
  let liveSession = null;
@@ -358786,6 +359036,14 @@ ${finalVerifyResult.logs ?? ""}`;
358786
359036
  }
358787
359037
  return bridgeDriver;
358788
359038
  };
359039
+ if (Date.now() >= healDeadlineAt) {
359040
+ return {
359041
+ passed: false,
359042
+ verdict: "config_issue",
359043
+ healReason: "mobile heal deadline exceeded before opening the live Phase-2 session"
359044
+ };
359045
+ }
359046
+ log.info("\u{1FA7A} Phase 2 (live device) \u2014 opening the live observation session...");
358789
359047
  try {
358790
359048
  await openLiveSession();
358791
359049
  const liveDriver = {
@@ -359631,12 +359889,29 @@ async function startRunner(opts) {
359631
359889
  }
359632
359890
  }, PIDS_WATCHDOG_INTERVAL_MS);
359633
359891
  pidsWatchdog.unref();
359892
+ const deferralLogged = /* @__PURE__ */ new Map();
359893
+ const logDeferredOnce = (runId, line) => {
359894
+ if (deferralLogged.get(runId) === line) return;
359895
+ deferralLogged.set(runId, line);
359896
+ log.dim(line);
359897
+ };
359898
+ let lastPendingSummary = "";
359634
359899
  while (!shuttingDown) {
359635
359900
  try {
359636
359901
  if (activeRuns < MAX_CONCURRENT) {
359637
359902
  const pending = await fetchPendingRuns(serverUrl, headers);
359903
+ {
359904
+ const pendingIds = new Set(pending.map((r) => r.runId));
359905
+ for (const id of [...deferralLogged.keys()]) {
359906
+ if (!pendingIds.has(id)) deferralLogged.delete(id);
359907
+ }
359908
+ }
359638
359909
  if (pending.length > 0) {
359639
- log.info(`${pending.length} pending run(s) found (${activeRuns}/${MAX_CONCURRENT} slots used)`);
359910
+ const pendingSummary = `${pending.length} pending run(s) found (${activeRuns}/${MAX_CONCURRENT} slots used)`;
359911
+ if (pendingSummary !== lastPendingSummary) {
359912
+ lastPendingSummary = pendingSummary;
359913
+ log.info(pendingSummary);
359914
+ }
359640
359915
  const staggerMs = envInt("RUN_STAGGER_MS", DEFAULT_RUN_STAGGER_MS);
359641
359916
  let claimedThisPoll = 0;
359642
359917
  for (const run2 of pending) {
@@ -359649,7 +359924,7 @@ async function startRunner(opts) {
359649
359924
  const runProject = projectKey(run2);
359650
359925
  const projectLimit = projectCapacityFor(runProject, pending);
359651
359926
  if (getActiveForProject(runProject) >= projectLimit) {
359652
- log.dim(` Deferred ${run2.runId} (project ${runProject} at capacity ${getActiveForProject(runProject)}/${projectLimit})`);
359927
+ logDeferredOnce(run2.runId, ` Deferred ${run2.runId} (project ${runProject} at capacity ${getActiveForProject(runProject)}/${projectLimit})`);
359653
359928
  continue;
359654
359929
  }
359655
359930
  const admission = (0, import_runner_core13.evaluateRunAdmission)(RESOURCE_GOVERNOR_OPTIONS, {
@@ -359659,27 +359934,27 @@ async function startRunner(opts) {
359659
359934
  browserPool: browserPool.stats()
359660
359935
  });
359661
359936
  if (!admission.allowed) {
359662
- log.dim(` Deferred ${run2.runId} (${admission.reason})`);
359937
+ logDeferredOnce(run2.runId, ` Deferred ${run2.runId} (${admission.reason})`);
359663
359938
  continue;
359664
359939
  }
359665
359940
  if (enableMobile && run2.framework === "APPIUM" && getBridgeState().recordingActive) {
359666
- log.dim(` Deferred ${run2.runId} (mobile recording session active)`);
359941
+ logDeferredOnce(run2.runId, ` Deferred ${run2.runId} (mobile recording session active)`);
359667
359942
  continue;
359668
359943
  }
359669
359944
  const discoveryMedium = runMode === "discovery" ? run2.discoveryContext?.medium : void 0;
359670
359945
  if (discoveryMedium === "IOS_NATIVE" || discoveryMedium === "ANDROID_NATIVE") {
359671
359946
  if (!enableMobile) {
359672
- log.dim(` Left PENDING ${run2.runId} (native mobile discovery needs a runner started with --mobile)`);
359947
+ logDeferredOnce(run2.runId, ` Left PENDING ${run2.runId} (native mobile discovery needs a runner started with --mobile)`);
359673
359948
  continue;
359674
359949
  }
359675
359950
  const caps = getRunnerCapabilities();
359676
359951
  const hasDeviceCap = discoveryMedium === "IOS_NATIVE" ? caps.ios : caps.android;
359677
359952
  if (!hasDeviceCap) {
359678
- log.dim(` Left PENDING ${run2.runId} (no booted ${discoveryMedium === "IOS_NATIVE" ? "iOS" : "Android"} device for native mobile discovery)`);
359953
+ logDeferredOnce(run2.runId, ` Left PENDING ${run2.runId} (no booted ${discoveryMedium === "IOS_NATIVE" ? "iOS" : "Android"} device for native mobile discovery)`);
359679
359954
  continue;
359680
359955
  }
359681
359956
  if (getBridgeState().hasActiveSession) {
359682
- log.dim(` Deferred ${run2.runId} (mobile recording session active on shared device)`);
359957
+ logDeferredOnce(run2.runId, ` Deferred ${run2.runId} (mobile recording session active on shared device)`);
359683
359958
  continue;
359684
359959
  }
359685
359960
  }
@@ -359724,7 +359999,7 @@ async function startRunner(opts) {
359724
359999
  activeMobileDiscoveries
359725
360000
  });
359726
360001
  if (!decision.claim) {
359727
- log.dim(` ${decision.disposition === "pending" ? "Left PENDING" : "Deferred"} ${run2.runId} (${decision.reason})`);
360002
+ logDeferredOnce(run2.runId, ` ${decision.disposition === "pending" ? "Left PENDING" : "Deferred"} ${run2.runId} (${decision.reason})`);
359728
360003
  continue;
359729
360004
  }
359730
360005
  leasedDeviceId = freeDeviceId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@validate.qa/runner",
3
- "version": "1.0.55",
3
+ "version": "1.0.56",
4
4
  "description": "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
5
5
  "bin": {
6
6
  "validate-runner": "dist/cli.js"