@validate.qa/runner 1.0.22 → 1.0.24

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 +292 -16
  2. package/dist/cli.mjs +292 -16
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -245,8 +245,8 @@ var require_ai_provider = __commonJS({
245
245
  exports2.resetAIClient = resetAIClient;
246
246
  exports2.getServerProxiedAIClient = getServerProxiedAIClient;
247
247
  exports2.getServerProxiedClientForModel = getServerProxiedClientForModel;
248
- exports2.getMobileAIClient = getMobileAIClient;
249
- exports2.getMobileClientForModel = getMobileClientForModel;
248
+ exports2.getMobileAIClient = getMobileAIClient2;
249
+ exports2.getMobileClientForModel = getMobileClientForModel2;
250
250
  var openai_1 = __importDefault(require("openai"));
251
251
  var async_hooks_1 = require("async_hooks");
252
252
  var ai_retry_js_1 = require_ai_retry();
@@ -643,14 +643,14 @@ var require_ai_provider = __commonJS({
643
643
  function getServerProxiedClientForModel(_modelId) {
644
644
  return getServerProxiedAIClient();
645
645
  }
646
- function getMobileAIClient() {
646
+ function getMobileAIClient2() {
647
647
  if ((process.env.SERVER_URL ?? "").trim().length > 0)
648
648
  return getServerProxiedAIClient();
649
649
  if (isMobileAiUnitTestMode())
650
650
  return getAIClient();
651
651
  throw new Error("Mobile AI requires the platform server URL (SERVER_URL). Customer runners do not provision provider API keys \u2014 the platform proxies every mobile AI completion through /api/runner/ai/execute, which lives on the shared server with the keys. If you reached this from `validate-runner start`, the credentials file is missing `serverUrl` or the install is broken; re-run `npx @validate.qa/runner login`. If you are calling the runner-core library directly, pass `options.aiClient` in tests or set VALIDATEQA_TEST_MODE=1 to opt into the local-provider fallback.");
652
652
  }
653
- function getMobileClientForModel(modelId) {
653
+ function getMobileClientForModel2(modelId) {
654
654
  if ((process.env.SERVER_URL ?? "").trim().length > 0)
655
655
  return getServerProxiedClientForModel(modelId);
656
656
  if (isMobileAiUnitTestMode())
@@ -328483,7 +328483,7 @@ var require_package4 = __commonJS({
328483
328483
  "package.json"(exports2, module2) {
328484
328484
  module2.exports = {
328485
328485
  name: "@validate.qa/runner",
328486
- version: "1.0.22",
328486
+ version: "1.0.24",
328487
328487
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328488
328488
  bin: {
328489
328489
  "validate-runner": "dist/cli.js"
@@ -330256,6 +330256,7 @@ var MOBILE_NEW_COMMAND_TIMEOUT_SECONDS = (() => {
330256
330256
  const raw = Number(process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT);
330257
330257
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 600;
330258
330258
  })();
330259
+ var COLD_BOOT_ENTRY_TIMEOUT_MS = 3e4;
330259
330260
  var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
330260
330261
  var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
330261
330262
  var WD_ACTION_TIMEOUT_MS = 3e4;
@@ -330490,18 +330491,24 @@ async function freshLaunchTargetApp(sessionId, platform3, appId, log2) {
330490
330491
  }
330491
330492
  async function waitForColdBootContent(sessionId, log2, timeoutMs = 2e4) {
330492
330493
  const deadline = Date.now() + timeoutMs;
330493
- const MIN_ELEMENTS = 3;
330494
+ const MIN_ELEMENTS = 12;
330494
330495
  let lastCount = 0;
330496
+ let stableFrames = 0;
330495
330497
  while (Date.now() < deadline) {
330496
330498
  try {
330497
330499
  const res = await wdRequest(`/session/${sessionId}/source`);
330498
330500
  const xml = typeof res?.value === "string" ? res.value : "";
330499
330501
  const count = (xml.match(/<(android\.|XCUIElementType)/g) ?? []).length;
330500
- lastCount = count;
330501
- if (count >= MIN_ELEMENTS) {
330502
- log2(` Cold-boot ready: UI tree has ${count} node(s).`);
330503
- return;
330502
+ if (count >= MIN_ELEMENTS && count === lastCount) {
330503
+ stableFrames++;
330504
+ if (stableFrames >= 2) {
330505
+ log2(` Cold-boot ready: UI tree settled at ${count} node(s).`);
330506
+ return;
330507
+ }
330508
+ } else {
330509
+ stableFrames = 0;
330504
330510
  }
330511
+ lastCount = count;
330505
330512
  } catch {
330506
330513
  }
330507
330514
  await new Promise((r) => setTimeout(r, 750));
@@ -330545,6 +330552,14 @@ async function takeScreenshot(sessionId) {
330545
330552
  return null;
330546
330553
  }
330547
330554
  }
330555
+ async function captureFailingSource(sessionId) {
330556
+ try {
330557
+ const result = await wdRequest(`/session/${sessionId}/source`);
330558
+ return typeof result?.value === "string" ? result.value : void 0;
330559
+ } catch {
330560
+ return void 0;
330561
+ }
330562
+ }
330548
330563
  function mobileRecordingEnabled() {
330549
330564
  const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
330550
330565
  return value !== "0" && value !== "false";
@@ -330674,9 +330689,12 @@ function identityTextXpath(value) {
330674
330689
  const lit = xpathLiteral(value);
330675
330690
  return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
330676
330691
  }
330692
+ function isIdentityXpath(value) {
330693
+ return value.includes("@") && !/(?:contains|starts-with)\s*\(/i.test(value);
330694
+ }
330677
330695
  function isIdentityStrategy(strategy) {
330678
330696
  if (strategy.using === "accessibility id" || strategy.using === "id") return true;
330679
- if (strategy.using === "xpath") return strategy.value.includes("@");
330697
+ if (strategy.using === "xpath") return isIdentityXpath(strategy.value);
330680
330698
  return false;
330681
330699
  }
330682
330700
  function buildIdentityLocatorStrategies(target, platform3) {
@@ -330690,7 +330708,7 @@ function buildIdentityLocatorStrategies(target, platform3) {
330690
330708
  const trimmed = value?.trim();
330691
330709
  if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
330692
330710
  }
330693
- if (bundle.xpath && bundle.xpath.includes("@")) strategies.push({ using: "xpath", value: bundle.xpath });
330711
+ if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
330694
330712
  }
330695
330713
  if (!(platform3 && hasPlatformLocator(target, platform3))) {
330696
330714
  for (const value of [target.primary, ...target.fallbacks ?? []]) {
@@ -331037,6 +331055,7 @@ async function executeMobileTest(options) {
331037
331055
  let recordingStarted = false;
331038
331056
  let recordingStopped = false;
331039
331057
  let video;
331058
+ let failingSource;
331040
331059
  const stopCapture = async () => {
331041
331060
  if (!capture || captureStopped) return apiCalls;
331042
331061
  captureStopped = true;
@@ -331060,6 +331079,10 @@ async function executeMobileTest(options) {
331060
331079
  }
331061
331080
  return video;
331062
331081
  };
331082
+ const recordFirstFailingSource = async () => {
331083
+ if (failingSource != null || !sessionId) return;
331084
+ failingSource = await captureFailingSource(sessionId);
331085
+ };
331063
331086
  try {
331064
331087
  log2("Checking Appium server health...");
331065
331088
  const health = await checkAppiumHealth();
@@ -331186,12 +331209,14 @@ async function executeMobileTest(options) {
331186
331209
  );
331187
331210
  if (entryStep) {
331188
331211
  try {
331189
- await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true, platform: runPlatform });
331212
+ const entryTimeout = recordedFromClearedState ? Math.max(DEFAULT_STEP_TIMEOUT_MS, COLD_BOOT_ENTRY_TIMEOUT_MS) : DEFAULT_STEP_TIMEOUT_MS;
331213
+ await findElement(sessionId, entryStep.target, entryTimeout, { identityOnly: true, platform: runPlatform });
331190
331214
  } catch (err) {
331191
331215
  if (isTransportError(err)) throw err;
331192
331216
  entryDrift = true;
331193
331217
  const driftMsg = `App started on an unexpected screen \u2014 the recorded flow's first element ("${entryStep.target?.primary ?? entryStep.description}") is not present, so the replay was aborted before it could blind-tap stale coordinates (screen drift; the app may be on onboarding or a different screen than when the test was recorded).`;
331194
331218
  log2(` \u2717 Entry-screen drift: ${driftMsg}`);
331219
+ await recordFirstFailingSource();
331195
331220
  stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
331196
331221
  const driftShot = await takeScreenshot(sessionId).catch(() => null);
331197
331222
  screenshots.push(driftShot ?? "");
@@ -331268,6 +331293,9 @@ async function executeMobileTest(options) {
331268
331293
  }
331269
331294
  const screenshot = await takeScreenshot(sessionId).catch(() => null);
331270
331295
  screenshots.push(screenshot ?? "");
331296
+ if (status === "failed") {
331297
+ await recordFirstFailingSource();
331298
+ }
331271
331299
  stepResults.push({
331272
331300
  stepOrder: step.order,
331273
331301
  status,
@@ -331305,7 +331333,8 @@ async function executeMobileTest(options) {
331305
331333
  duration: Date.now() - startTime,
331306
331334
  deviceId: selectedDevice?.id,
331307
331335
  deviceName: selectedDevice?.name,
331308
- environmental: environmental ?? void 0
331336
+ environmental: environmental ?? void 0,
331337
+ failingSource
331309
331338
  };
331310
331339
  } catch (err) {
331311
331340
  const errorMsg = err instanceof Error ? err.message : String(err);
@@ -331326,7 +331355,8 @@ async function executeMobileTest(options) {
331326
331355
  // A throw before/around the step loop is a setup/environment problem
331327
331356
  // (no device booted, app not installed, session could not be created,
331328
331357
  // Appium unreachable) — never a genuine test assertion failure.
331329
- environmental: errorMsg
331358
+ environmental: errorMsg,
331359
+ failingSource
331330
331360
  };
331331
331361
  } finally {
331332
331362
  await stopCapture();
@@ -333295,6 +333325,9 @@ function budgetResultPayload(result, maxBytes = RESULT_BODY_BUDGET_BYTES, videoS
333295
333325
  if ((bytes > maxBytes || oversizedVideo) && payload.video != null) {
333296
333326
  drop(["video", "videoMimeType"], "video");
333297
333327
  }
333328
+ if (bytes > maxBytes && payload.failingSource != null) {
333329
+ drop(["failingSource"], "failingSource");
333330
+ }
333298
333331
  if (bytes > maxBytes && Array.isArray(payload.screenshots) && payload.screenshots.length > 0) {
333299
333332
  const shots = [...payload.screenshots];
333300
333333
  const bySizeDesc = shots.map((shot, i) => ({ i, len: typeof shot === "string" ? shot.length : 0 })).sort((a, b) => b.len - a.len);
@@ -333346,6 +333379,7 @@ ${tail}` };
333346
333379
  // src/runner.ts
333347
333380
  init_dist();
333348
333381
  var import_runner_core12 = __toESM(require_dist2());
333382
+ var import_runner_core13 = __toESM(require_dist2());
333349
333383
  function fingerprintTest(test) {
333350
333384
  const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
333351
333385
  return (0, import_node_crypto2.createHash)("sha256").update(test.name).update("\n--suite--\n").update(test.suiteName ?? "").update("\n--type--\n").update(test.testType ?? "").update("\n--framework--\n").update(test.framework ?? "").update("\n--code--\n").update(code).digest("hex").slice(0, 24);
@@ -333583,6 +333617,33 @@ async function postHealResult(serverUrl, headers, runId, healedCode, testCaseId,
333583
333617
  }
333584
333618
  throw new Error(`Failed to post heal result after ${MAX_ATTEMPTS} attempts: ${lastError?.message}`);
333585
333619
  }
333620
+ async function postMobileHealResult(serverUrl, headers, runId, body) {
333621
+ const MAX_ATTEMPTS = 3;
333622
+ let lastError;
333623
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
333624
+ try {
333625
+ const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/mobile-heal-result`, {
333626
+ method: "POST",
333627
+ headers,
333628
+ body: JSON.stringify(body),
333629
+ signal: AbortSignal.timeout(6e4)
333630
+ });
333631
+ if (res.ok) return;
333632
+ lastError = new Error(`HTTP ${res.status}`);
333633
+ } catch (err) {
333634
+ lastError = err instanceof Error ? err : new Error(String(err));
333635
+ }
333636
+ if (attempt < MAX_ATTEMPTS) {
333637
+ const delay = Math.pow(2, attempt) * 1e3;
333638
+ console.warn(`[runner] postMobileHealResult attempt ${attempt} failed (${lastError.message}), retrying in ${delay}ms...`);
333639
+ await new Promise((resolve) => setTimeout(resolve, delay));
333640
+ }
333641
+ }
333642
+ throw new Error(`Failed to post mobile heal result after ${MAX_ATTEMPTS} attempts: ${lastError?.message}`);
333643
+ }
333644
+ function narrowMobileLaunchMode(mode) {
333645
+ return mode === "LAUNCH_APP" || mode === "DEEP_LINK" || mode === "RESUME" ? mode : void 0;
333646
+ }
333586
333647
  async function postBatchHealResult(serverUrl, headers, runId, body) {
333587
333648
  const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/batch-heal-result`, {
333588
333649
  method: "POST",
@@ -335491,6 +335552,218 @@ ${finalVerifyResult.logs ?? ""}`;
335491
335552
  log.fail("ERROR - No mobile steps");
335492
335553
  return;
335493
335554
  }
335555
+ if (runMode === "heal" && run2.mobileAiHeal === true) {
335556
+ if (getBridgeState().hasActiveSession) {
335557
+ await postResult(opts.serverUrl, headers, run2.runId, {
335558
+ status: "ERROR",
335559
+ error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; the AI heal will retry once it ends.",
335560
+ duration: Date.now() - startTime
335561
+ });
335562
+ log.warn("Skipped mobile AI heal (device busy with a recording)");
335563
+ return;
335564
+ }
335565
+ const healTarget = {
335566
+ appId: target.appId,
335567
+ platform: target.platform,
335568
+ platformVersion: target.platformVersion,
335569
+ recordedDeviceId: target.recordedDeviceId,
335570
+ assignedDeviceId,
335571
+ launchMode: target.launchMode,
335572
+ deeplink: target.deeplink,
335573
+ authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
335574
+ };
335575
+ const nativeVerify = { last: null };
335576
+ const runNativeTest = async ({ steps, target: t, testVariables }) => {
335577
+ const res = await executeMobileTest({
335578
+ steps,
335579
+ target: {
335580
+ appId: t.appId,
335581
+ platform: t.platform,
335582
+ platformVersion: t.platformVersion,
335583
+ recordedDeviceId: t.recordedDeviceId,
335584
+ assignedDeviceId: t.assignedDeviceId,
335585
+ launchMode: narrowMobileLaunchMode(t.launchMode),
335586
+ deeplink: t.deeplink,
335587
+ authoringPlatform: t.authoringPlatform
335588
+ },
335589
+ testVariables
335590
+ });
335591
+ nativeVerify.last = res;
335592
+ return {
335593
+ passed: res.passed,
335594
+ error: res.error,
335595
+ stepResults: res.stepResults.map((s) => ({ stepOrder: s.stepOrder, status: s.status, error: s.error })),
335596
+ failingSource: res.failingSource,
335597
+ logs: res.logs
335598
+ };
335599
+ };
335600
+ setExecutorBusy(true);
335601
+ try {
335602
+ log.info("\u{1FA7A} Mobile AI heal \u2014 reproducing the failure on device...");
335603
+ const reproduce = await runNativeTest({ steps: mobileSteps, target: healTarget, testVariables: run2.testVariables });
335604
+ if (reproduce.passed) {
335605
+ const recovered = nativeVerify.last;
335606
+ await postResult(opts.serverUrl, headers, run2.runId, {
335607
+ status: "PASSED",
335608
+ stepResults: recovered?.stepResults,
335609
+ screenshots: recovered?.screenshots,
335610
+ video: recovered?.video,
335611
+ videoMimeType: recovered?.videoMimeType,
335612
+ apiCalls: recovered?.apiCalls,
335613
+ logs: recovered?.logs,
335614
+ duration: Date.now() - startTime
335615
+ });
335616
+ try {
335617
+ await postMobileHealResult(opts.serverUrl, headers, run2.runId, {
335618
+ healedSteps: mobileSteps,
335619
+ healedAppiumCode: "",
335620
+ healReason: "recovered on reproduce",
335621
+ verdict: "fix",
335622
+ recovered: true,
335623
+ priorStepsHash: run2.priorStepsHash
335624
+ });
335625
+ log.ok("\u{1FA7A} Reproduce PASSED \u2014 test recovered (flaky); promotion posted");
335626
+ } catch (patchErr) {
335627
+ log.warn(`Failed to post recovered heal result: ${patchErr instanceof Error ? patchErr.message : String(patchErr)}`);
335628
+ }
335629
+ return;
335630
+ }
335631
+ const reproduceError = reproduce.error;
335632
+ const modelOverride = run2.healerModel ?? void 0;
335633
+ const healerOptions = {
335634
+ aiClient: modelOverride ? (0, import_runner_core13.getMobileClientForModel)(modelOverride) : (0, import_runner_core13.getMobileAIClient)(),
335635
+ ...modelOverride ? { model: modelOverride } : {},
335636
+ runNativeTest,
335637
+ selectorRegistryContext: run2.selectorRegistryContext ?? null,
335638
+ runLiveHeal: async (a) => {
335639
+ let liveSession = null;
335640
+ try {
335641
+ liveSession = await createMobileDiscoverySession({
335642
+ appId: target.appId,
335643
+ platform: target.platform,
335644
+ platformVersion: target.platformVersion,
335645
+ recordedDeviceId: target.recordedDeviceId,
335646
+ assignedDeviceId,
335647
+ launchMode: target.launchMode,
335648
+ deeplink: target.deeplink
335649
+ });
335650
+ attachMirrorSession(liveSession.sessionId, target.platform, liveSession.deviceId);
335651
+ const bridgeDriver = new MobileBridgeDriver({
335652
+ sessionId: liveSession.sessionId,
335653
+ platform: target.platform,
335654
+ appId: liveSession.appId,
335655
+ allowAppReset: shouldResetMobileAppData(target.platform, liveSession.deviceId)
335656
+ });
335657
+ const liveDriver = {
335658
+ snapshot: async () => {
335659
+ const snap = await bridgeDriver.snapshot();
335660
+ return { xml: snap.xml, screenshot: snap.screenshot, windowSize: snap.windowSize };
335661
+ },
335662
+ tap: async (bounds) => {
335663
+ await bridgeDriver.tap(bounds);
335664
+ },
335665
+ type: async (value, bounds) => {
335666
+ await bridgeDriver.type(value, bounds);
335667
+ },
335668
+ clear: async (bounds) => {
335669
+ await bridgeDriver.clear(bounds);
335670
+ },
335671
+ swipe: async (direction, distance) => {
335672
+ await bridgeDriver.swipe(direction, distance);
335673
+ },
335674
+ back: async () => {
335675
+ await bridgeDriver.back();
335676
+ },
335677
+ home: async () => {
335678
+ await bridgeDriver.home();
335679
+ },
335680
+ relaunch: async () => {
335681
+ await bridgeDriver.relaunch();
335682
+ },
335683
+ resetAppState: async () => bridgeDriver.resetAppState()
335684
+ };
335685
+ return await (0, import_runner_core13.executeMobileLiveHeal)({
335686
+ steps: a.steps,
335687
+ target: a.target,
335688
+ testVariables: a.testVariables,
335689
+ priorError: reproduceError,
335690
+ options: { ...a.options ?? healerOptions, liveDriver }
335691
+ });
335692
+ } finally {
335693
+ if (liveSession) {
335694
+ detachMirrorSession(liveSession.deviceId);
335695
+ try {
335696
+ await liveSession.stop();
335697
+ } catch {
335698
+ }
335699
+ }
335700
+ }
335701
+ }
335702
+ };
335703
+ log.info("\u{1FA7A} Mobile AI heal \u2014 Phase 1 (script) \u2192 Phase 2 (live device, lazy)...");
335704
+ const heal = await (0, import_runner_core13.executeMobileTestWithHealing)(
335705
+ mobileSteps,
335706
+ healTarget,
335707
+ { error: reproduceError, logs: reproduce.logs, failingSource: reproduce.failingSource, testVariables: run2.testVariables },
335708
+ healerOptions
335709
+ );
335710
+ const finalRes = nativeVerify.last;
335711
+ const outcome = (0, import_runner_core13.computeMobileHealOutcome)({
335712
+ heal,
335713
+ finalError: finalRes?.error ?? null,
335714
+ reproduceError,
335715
+ testVariables: run2.testVariables
335716
+ });
335717
+ await postResult(opts.serverUrl, headers, run2.runId, {
335718
+ status: outcome.status,
335719
+ stepResults: finalRes?.stepResults,
335720
+ screenshots: finalRes?.screenshots,
335721
+ video: finalRes?.video,
335722
+ videoMimeType: finalRes?.videoMimeType,
335723
+ apiCalls: finalRes?.apiCalls,
335724
+ logs: finalRes?.logs,
335725
+ error: outcome.error,
335726
+ healReason: heal.healReason,
335727
+ // NB: the loop's numeric `confidence` (0-1) is intentionally NOT posted
335728
+ // here — RunnerResultPayload.confidence is the web healer's categorical
335729
+ // 'high'|'medium'|'low'. The numeric value rides /mobile-heal-result via
335730
+ // the healReason instead. Mapping the two would be lossy and misleading.
335731
+ // extraTags carries @needs-review for the non-fix verdicts (applied by
335732
+ // the server's mobileAiHeal /result tag hook); PASSED heals apply tags
335733
+ // via /mobile-heal-result instead.
335734
+ extraTags: outcome.extraTags,
335735
+ removeTags: outcome.removeTags,
335736
+ duration: Date.now() - startTime,
335737
+ failingSource: finalRes?.failingSource
335738
+ });
335739
+ if (outcome.shouldPatch && heal.healedSteps && heal.healedSteps.length > 0) {
335740
+ const healedAppiumCode = heal.healedAppiumCode ?? buildAppiumCode(run2.testName ?? run2.testCaseId ?? "Healed mobile test", run2.target ?? null, heal.healedSteps);
335741
+ try {
335742
+ await postMobileHealResult(opts.serverUrl, headers, run2.runId, {
335743
+ healedSteps: heal.healedSteps,
335744
+ healedAppiumCode,
335745
+ healReason: heal.healReason ?? "",
335746
+ verdict: heal.verdict,
335747
+ extraTags: heal.extraTags,
335748
+ removeTags: heal.removeTags,
335749
+ priorStepsHash: run2.priorStepsHash
335750
+ });
335751
+ log.ok(`\u{1FA7A} Mobile heal patch posted for test ${run2.testCaseId ?? "(unknown)"} (verdict: ${heal.verdict})`);
335752
+ } catch (patchErr) {
335753
+ log.warn(`Failed to post mobile heal patch: ${patchErr instanceof Error ? patchErr.message : String(patchErr)}`);
335754
+ }
335755
+ } else if (outcome.secretLeak) {
335756
+ log.warn(`\u{1FA7A} Mobile heal patch DROPPED \u2014 healed steps contained a secret (${outcome.secretLeak}); tagged @needs-review`);
335757
+ }
335758
+ const duration3 = Date.now() - startTime;
335759
+ log[outcome.status === "PASSED" ? "ok" : "fail"](
335760
+ `\u{1FA7A} Mobile AI heal ${outcome.status} (verdict: ${heal.verdict}, ${duration3}ms)${outcome.error ? ` - ${outcome.error}` : ""}`
335761
+ );
335762
+ } finally {
335763
+ setExecutorBusy(false);
335764
+ }
335765
+ return;
335766
+ }
335494
335767
  const maxRetries = typeof run2.retryCount === "number" && Number.isFinite(run2.retryCount) ? Math.max(0, Math.min(2, Math.floor(run2.retryCount))) : 0;
335495
335768
  setExecutorBusy(true);
335496
335769
  let result2 = null;
@@ -335560,7 +335833,10 @@ ${result2.logs}`);
335560
335833
  apiCalls: result2.apiCalls,
335561
335834
  logs: result2.logs,
335562
335835
  error: reportedError,
335563
- duration: duration2
335836
+ duration: duration2,
335837
+ // Point-of-failure page-source XML (mobile) for the AI healer. Budget-
335838
+ // droppable (see result-payload-budget.ts); the verdict is never dropped.
335839
+ failingSource: result2.failingSource
335564
335840
  });
335565
335841
  log[result2.passed ? "ok" : "fail"](
335566
335842
  `\u{1F4F1} ${status} (${duration2}ms)${reportedError ? ` - ${reportedError}` : ""}`
package/dist/cli.mjs CHANGED
@@ -250,8 +250,8 @@ var require_ai_provider = __commonJS({
250
250
  exports.resetAIClient = resetAIClient;
251
251
  exports.getServerProxiedAIClient = getServerProxiedAIClient;
252
252
  exports.getServerProxiedClientForModel = getServerProxiedClientForModel;
253
- exports.getMobileAIClient = getMobileAIClient;
254
- exports.getMobileClientForModel = getMobileClientForModel;
253
+ exports.getMobileAIClient = getMobileAIClient2;
254
+ exports.getMobileClientForModel = getMobileClientForModel2;
255
255
  var openai_1 = __importDefault(__require("openai"));
256
256
  var async_hooks_1 = __require("async_hooks");
257
257
  var ai_retry_js_1 = require_ai_retry();
@@ -648,14 +648,14 @@ var require_ai_provider = __commonJS({
648
648
  function getServerProxiedClientForModel(_modelId) {
649
649
  return getServerProxiedAIClient();
650
650
  }
651
- function getMobileAIClient() {
651
+ function getMobileAIClient2() {
652
652
  if ((process.env.SERVER_URL ?? "").trim().length > 0)
653
653
  return getServerProxiedAIClient();
654
654
  if (isMobileAiUnitTestMode())
655
655
  return getAIClient();
656
656
  throw new Error("Mobile AI requires the platform server URL (SERVER_URL). Customer runners do not provision provider API keys \u2014 the platform proxies every mobile AI completion through /api/runner/ai/execute, which lives on the shared server with the keys. If you reached this from `validate-runner start`, the credentials file is missing `serverUrl` or the install is broken; re-run `npx @validate.qa/runner login`. If you are calling the runner-core library directly, pass `options.aiClient` in tests or set VALIDATEQA_TEST_MODE=1 to opt into the local-provider fallback.");
657
657
  }
658
- function getMobileClientForModel(modelId) {
658
+ function getMobileClientForModel2(modelId) {
659
659
  if ((process.env.SERVER_URL ?? "").trim().length > 0)
660
660
  return getServerProxiedClientForModel(modelId);
661
661
  if (isMobileAiUnitTestMode())
@@ -328488,7 +328488,7 @@ var require_package4 = __commonJS({
328488
328488
  "package.json"(exports, module) {
328489
328489
  module.exports = {
328490
328490
  name: "@validate.qa/runner",
328491
- version: "1.0.22",
328491
+ version: "1.0.24",
328492
328492
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328493
328493
  bin: {
328494
328494
  "validate-runner": "dist/cli.js"
@@ -330264,6 +330264,7 @@ var MOBILE_NEW_COMMAND_TIMEOUT_SECONDS = (() => {
330264
330264
  const raw = Number(process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT);
330265
330265
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 600;
330266
330266
  })();
330267
+ var COLD_BOOT_ENTRY_TIMEOUT_MS = 3e4;
330267
330268
  var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
330268
330269
  var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
330269
330270
  var WD_ACTION_TIMEOUT_MS = 3e4;
@@ -330498,18 +330499,24 @@ async function freshLaunchTargetApp(sessionId, platform3, appId, log2) {
330498
330499
  }
330499
330500
  async function waitForColdBootContent(sessionId, log2, timeoutMs = 2e4) {
330500
330501
  const deadline = Date.now() + timeoutMs;
330501
- const MIN_ELEMENTS = 3;
330502
+ const MIN_ELEMENTS = 12;
330502
330503
  let lastCount = 0;
330504
+ let stableFrames = 0;
330503
330505
  while (Date.now() < deadline) {
330504
330506
  try {
330505
330507
  const res = await wdRequest(`/session/${sessionId}/source`);
330506
330508
  const xml = typeof res?.value === "string" ? res.value : "";
330507
330509
  const count = (xml.match(/<(android\.|XCUIElementType)/g) ?? []).length;
330508
- lastCount = count;
330509
- if (count >= MIN_ELEMENTS) {
330510
- log2(` Cold-boot ready: UI tree has ${count} node(s).`);
330511
- return;
330510
+ if (count >= MIN_ELEMENTS && count === lastCount) {
330511
+ stableFrames++;
330512
+ if (stableFrames >= 2) {
330513
+ log2(` Cold-boot ready: UI tree settled at ${count} node(s).`);
330514
+ return;
330515
+ }
330516
+ } else {
330517
+ stableFrames = 0;
330512
330518
  }
330519
+ lastCount = count;
330513
330520
  } catch {
330514
330521
  }
330515
330522
  await new Promise((r) => setTimeout(r, 750));
@@ -330553,6 +330560,14 @@ async function takeScreenshot(sessionId) {
330553
330560
  return null;
330554
330561
  }
330555
330562
  }
330563
+ async function captureFailingSource(sessionId) {
330564
+ try {
330565
+ const result = await wdRequest(`/session/${sessionId}/source`);
330566
+ return typeof result?.value === "string" ? result.value : void 0;
330567
+ } catch {
330568
+ return void 0;
330569
+ }
330570
+ }
330556
330571
  function mobileRecordingEnabled() {
330557
330572
  const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
330558
330573
  return value !== "0" && value !== "false";
@@ -330682,9 +330697,12 @@ function identityTextXpath(value) {
330682
330697
  const lit = xpathLiteral(value);
330683
330698
  return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
330684
330699
  }
330700
+ function isIdentityXpath(value) {
330701
+ return value.includes("@") && !/(?:contains|starts-with)\s*\(/i.test(value);
330702
+ }
330685
330703
  function isIdentityStrategy(strategy) {
330686
330704
  if (strategy.using === "accessibility id" || strategy.using === "id") return true;
330687
- if (strategy.using === "xpath") return strategy.value.includes("@");
330705
+ if (strategy.using === "xpath") return isIdentityXpath(strategy.value);
330688
330706
  return false;
330689
330707
  }
330690
330708
  function buildIdentityLocatorStrategies(target, platform3) {
@@ -330698,7 +330716,7 @@ function buildIdentityLocatorStrategies(target, platform3) {
330698
330716
  const trimmed = value?.trim();
330699
330717
  if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
330700
330718
  }
330701
- if (bundle.xpath && bundle.xpath.includes("@")) strategies.push({ using: "xpath", value: bundle.xpath });
330719
+ if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
330702
330720
  }
330703
330721
  if (!(platform3 && hasPlatformLocator(target, platform3))) {
330704
330722
  for (const value of [target.primary, ...target.fallbacks ?? []]) {
@@ -331045,6 +331063,7 @@ async function executeMobileTest(options) {
331045
331063
  let recordingStarted = false;
331046
331064
  let recordingStopped = false;
331047
331065
  let video;
331066
+ let failingSource;
331048
331067
  const stopCapture = async () => {
331049
331068
  if (!capture || captureStopped) return apiCalls;
331050
331069
  captureStopped = true;
@@ -331068,6 +331087,10 @@ async function executeMobileTest(options) {
331068
331087
  }
331069
331088
  return video;
331070
331089
  };
331090
+ const recordFirstFailingSource = async () => {
331091
+ if (failingSource != null || !sessionId) return;
331092
+ failingSource = await captureFailingSource(sessionId);
331093
+ };
331071
331094
  try {
331072
331095
  log2("Checking Appium server health...");
331073
331096
  const health = await checkAppiumHealth();
@@ -331194,12 +331217,14 @@ async function executeMobileTest(options) {
331194
331217
  );
331195
331218
  if (entryStep) {
331196
331219
  try {
331197
- await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true, platform: runPlatform });
331220
+ const entryTimeout = recordedFromClearedState ? Math.max(DEFAULT_STEP_TIMEOUT_MS, COLD_BOOT_ENTRY_TIMEOUT_MS) : DEFAULT_STEP_TIMEOUT_MS;
331221
+ await findElement(sessionId, entryStep.target, entryTimeout, { identityOnly: true, platform: runPlatform });
331198
331222
  } catch (err) {
331199
331223
  if (isTransportError(err)) throw err;
331200
331224
  entryDrift = true;
331201
331225
  const driftMsg = `App started on an unexpected screen \u2014 the recorded flow's first element ("${entryStep.target?.primary ?? entryStep.description}") is not present, so the replay was aborted before it could blind-tap stale coordinates (screen drift; the app may be on onboarding or a different screen than when the test was recorded).`;
331202
331226
  log2(` \u2717 Entry-screen drift: ${driftMsg}`);
331227
+ await recordFirstFailingSource();
331203
331228
  stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
331204
331229
  const driftShot = await takeScreenshot(sessionId).catch(() => null);
331205
331230
  screenshots.push(driftShot ?? "");
@@ -331276,6 +331301,9 @@ async function executeMobileTest(options) {
331276
331301
  }
331277
331302
  const screenshot = await takeScreenshot(sessionId).catch(() => null);
331278
331303
  screenshots.push(screenshot ?? "");
331304
+ if (status === "failed") {
331305
+ await recordFirstFailingSource();
331306
+ }
331279
331307
  stepResults.push({
331280
331308
  stepOrder: step.order,
331281
331309
  status,
@@ -331313,7 +331341,8 @@ async function executeMobileTest(options) {
331313
331341
  duration: Date.now() - startTime,
331314
331342
  deviceId: selectedDevice?.id,
331315
331343
  deviceName: selectedDevice?.name,
331316
- environmental: environmental ?? void 0
331344
+ environmental: environmental ?? void 0,
331345
+ failingSource
331317
331346
  };
331318
331347
  } catch (err) {
331319
331348
  const errorMsg = err instanceof Error ? err.message : String(err);
@@ -331334,7 +331363,8 @@ async function executeMobileTest(options) {
331334
331363
  // A throw before/around the step loop is a setup/environment problem
331335
331364
  // (no device booted, app not installed, session could not be created,
331336
331365
  // Appium unreachable) — never a genuine test assertion failure.
331337
- environmental: errorMsg
331366
+ environmental: errorMsg,
331367
+ failingSource
331338
331368
  };
331339
331369
  } finally {
331340
331370
  await stopCapture();
@@ -333303,6 +333333,9 @@ function budgetResultPayload(result, maxBytes = RESULT_BODY_BUDGET_BYTES, videoS
333303
333333
  if ((bytes > maxBytes || oversizedVideo) && payload.video != null) {
333304
333334
  drop(["video", "videoMimeType"], "video");
333305
333335
  }
333336
+ if (bytes > maxBytes && payload.failingSource != null) {
333337
+ drop(["failingSource"], "failingSource");
333338
+ }
333306
333339
  if (bytes > maxBytes && Array.isArray(payload.screenshots) && payload.screenshots.length > 0) {
333307
333340
  const shots = [...payload.screenshots];
333308
333341
  const bySizeDesc = shots.map((shot, i) => ({ i, len: typeof shot === "string" ? shot.length : 0 })).sort((a, b) => b.len - a.len);
@@ -333354,6 +333387,7 @@ ${tail}` };
333354
333387
  // src/runner.ts
333355
333388
  init_dist();
333356
333389
  var import_runner_core12 = __toESM(require_dist2());
333390
+ var import_runner_core13 = __toESM(require_dist2());
333357
333391
  function fingerprintTest(test) {
333358
333392
  const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
333359
333393
  return createHash("sha256").update(test.name).update("\n--suite--\n").update(test.suiteName ?? "").update("\n--type--\n").update(test.testType ?? "").update("\n--framework--\n").update(test.framework ?? "").update("\n--code--\n").update(code).digest("hex").slice(0, 24);
@@ -333591,6 +333625,33 @@ async function postHealResult(serverUrl, headers, runId, healedCode, testCaseId,
333591
333625
  }
333592
333626
  throw new Error(`Failed to post heal result after ${MAX_ATTEMPTS} attempts: ${lastError?.message}`);
333593
333627
  }
333628
+ async function postMobileHealResult(serverUrl, headers, runId, body) {
333629
+ const MAX_ATTEMPTS = 3;
333630
+ let lastError;
333631
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
333632
+ try {
333633
+ const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/mobile-heal-result`, {
333634
+ method: "POST",
333635
+ headers,
333636
+ body: JSON.stringify(body),
333637
+ signal: AbortSignal.timeout(6e4)
333638
+ });
333639
+ if (res.ok) return;
333640
+ lastError = new Error(`HTTP ${res.status}`);
333641
+ } catch (err) {
333642
+ lastError = err instanceof Error ? err : new Error(String(err));
333643
+ }
333644
+ if (attempt < MAX_ATTEMPTS) {
333645
+ const delay = Math.pow(2, attempt) * 1e3;
333646
+ console.warn(`[runner] postMobileHealResult attempt ${attempt} failed (${lastError.message}), retrying in ${delay}ms...`);
333647
+ await new Promise((resolve) => setTimeout(resolve, delay));
333648
+ }
333649
+ }
333650
+ throw new Error(`Failed to post mobile heal result after ${MAX_ATTEMPTS} attempts: ${lastError?.message}`);
333651
+ }
333652
+ function narrowMobileLaunchMode(mode) {
333653
+ return mode === "LAUNCH_APP" || mode === "DEEP_LINK" || mode === "RESUME" ? mode : void 0;
333654
+ }
333594
333655
  async function postBatchHealResult(serverUrl, headers, runId, body) {
333595
333656
  const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/batch-heal-result`, {
333596
333657
  method: "POST",
@@ -335499,6 +335560,218 @@ ${finalVerifyResult.logs ?? ""}`;
335499
335560
  log.fail("ERROR - No mobile steps");
335500
335561
  return;
335501
335562
  }
335563
+ if (runMode === "heal" && run2.mobileAiHeal === true) {
335564
+ if (getBridgeState().hasActiveSession) {
335565
+ await postResult(opts.serverUrl, headers, run2.runId, {
335566
+ status: "ERROR",
335567
+ error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; the AI heal will retry once it ends.",
335568
+ duration: Date.now() - startTime
335569
+ });
335570
+ log.warn("Skipped mobile AI heal (device busy with a recording)");
335571
+ return;
335572
+ }
335573
+ const healTarget = {
335574
+ appId: target.appId,
335575
+ platform: target.platform,
335576
+ platformVersion: target.platformVersion,
335577
+ recordedDeviceId: target.recordedDeviceId,
335578
+ assignedDeviceId,
335579
+ launchMode: target.launchMode,
335580
+ deeplink: target.deeplink,
335581
+ authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
335582
+ };
335583
+ const nativeVerify = { last: null };
335584
+ const runNativeTest = async ({ steps, target: t, testVariables }) => {
335585
+ const res = await executeMobileTest({
335586
+ steps,
335587
+ target: {
335588
+ appId: t.appId,
335589
+ platform: t.platform,
335590
+ platformVersion: t.platformVersion,
335591
+ recordedDeviceId: t.recordedDeviceId,
335592
+ assignedDeviceId: t.assignedDeviceId,
335593
+ launchMode: narrowMobileLaunchMode(t.launchMode),
335594
+ deeplink: t.deeplink,
335595
+ authoringPlatform: t.authoringPlatform
335596
+ },
335597
+ testVariables
335598
+ });
335599
+ nativeVerify.last = res;
335600
+ return {
335601
+ passed: res.passed,
335602
+ error: res.error,
335603
+ stepResults: res.stepResults.map((s) => ({ stepOrder: s.stepOrder, status: s.status, error: s.error })),
335604
+ failingSource: res.failingSource,
335605
+ logs: res.logs
335606
+ };
335607
+ };
335608
+ setExecutorBusy(true);
335609
+ try {
335610
+ log.info("\u{1FA7A} Mobile AI heal \u2014 reproducing the failure on device...");
335611
+ const reproduce = await runNativeTest({ steps: mobileSteps, target: healTarget, testVariables: run2.testVariables });
335612
+ if (reproduce.passed) {
335613
+ const recovered = nativeVerify.last;
335614
+ await postResult(opts.serverUrl, headers, run2.runId, {
335615
+ status: "PASSED",
335616
+ stepResults: recovered?.stepResults,
335617
+ screenshots: recovered?.screenshots,
335618
+ video: recovered?.video,
335619
+ videoMimeType: recovered?.videoMimeType,
335620
+ apiCalls: recovered?.apiCalls,
335621
+ logs: recovered?.logs,
335622
+ duration: Date.now() - startTime
335623
+ });
335624
+ try {
335625
+ await postMobileHealResult(opts.serverUrl, headers, run2.runId, {
335626
+ healedSteps: mobileSteps,
335627
+ healedAppiumCode: "",
335628
+ healReason: "recovered on reproduce",
335629
+ verdict: "fix",
335630
+ recovered: true,
335631
+ priorStepsHash: run2.priorStepsHash
335632
+ });
335633
+ log.ok("\u{1FA7A} Reproduce PASSED \u2014 test recovered (flaky); promotion posted");
335634
+ } catch (patchErr) {
335635
+ log.warn(`Failed to post recovered heal result: ${patchErr instanceof Error ? patchErr.message : String(patchErr)}`);
335636
+ }
335637
+ return;
335638
+ }
335639
+ const reproduceError = reproduce.error;
335640
+ const modelOverride = run2.healerModel ?? void 0;
335641
+ const healerOptions = {
335642
+ aiClient: modelOverride ? (0, import_runner_core13.getMobileClientForModel)(modelOverride) : (0, import_runner_core13.getMobileAIClient)(),
335643
+ ...modelOverride ? { model: modelOverride } : {},
335644
+ runNativeTest,
335645
+ selectorRegistryContext: run2.selectorRegistryContext ?? null,
335646
+ runLiveHeal: async (a) => {
335647
+ let liveSession = null;
335648
+ try {
335649
+ liveSession = await createMobileDiscoverySession({
335650
+ appId: target.appId,
335651
+ platform: target.platform,
335652
+ platformVersion: target.platformVersion,
335653
+ recordedDeviceId: target.recordedDeviceId,
335654
+ assignedDeviceId,
335655
+ launchMode: target.launchMode,
335656
+ deeplink: target.deeplink
335657
+ });
335658
+ attachMirrorSession(liveSession.sessionId, target.platform, liveSession.deviceId);
335659
+ const bridgeDriver = new MobileBridgeDriver({
335660
+ sessionId: liveSession.sessionId,
335661
+ platform: target.platform,
335662
+ appId: liveSession.appId,
335663
+ allowAppReset: shouldResetMobileAppData(target.platform, liveSession.deviceId)
335664
+ });
335665
+ const liveDriver = {
335666
+ snapshot: async () => {
335667
+ const snap = await bridgeDriver.snapshot();
335668
+ return { xml: snap.xml, screenshot: snap.screenshot, windowSize: snap.windowSize };
335669
+ },
335670
+ tap: async (bounds) => {
335671
+ await bridgeDriver.tap(bounds);
335672
+ },
335673
+ type: async (value, bounds) => {
335674
+ await bridgeDriver.type(value, bounds);
335675
+ },
335676
+ clear: async (bounds) => {
335677
+ await bridgeDriver.clear(bounds);
335678
+ },
335679
+ swipe: async (direction, distance) => {
335680
+ await bridgeDriver.swipe(direction, distance);
335681
+ },
335682
+ back: async () => {
335683
+ await bridgeDriver.back();
335684
+ },
335685
+ home: async () => {
335686
+ await bridgeDriver.home();
335687
+ },
335688
+ relaunch: async () => {
335689
+ await bridgeDriver.relaunch();
335690
+ },
335691
+ resetAppState: async () => bridgeDriver.resetAppState()
335692
+ };
335693
+ return await (0, import_runner_core13.executeMobileLiveHeal)({
335694
+ steps: a.steps,
335695
+ target: a.target,
335696
+ testVariables: a.testVariables,
335697
+ priorError: reproduceError,
335698
+ options: { ...a.options ?? healerOptions, liveDriver }
335699
+ });
335700
+ } finally {
335701
+ if (liveSession) {
335702
+ detachMirrorSession(liveSession.deviceId);
335703
+ try {
335704
+ await liveSession.stop();
335705
+ } catch {
335706
+ }
335707
+ }
335708
+ }
335709
+ }
335710
+ };
335711
+ log.info("\u{1FA7A} Mobile AI heal \u2014 Phase 1 (script) \u2192 Phase 2 (live device, lazy)...");
335712
+ const heal = await (0, import_runner_core13.executeMobileTestWithHealing)(
335713
+ mobileSteps,
335714
+ healTarget,
335715
+ { error: reproduceError, logs: reproduce.logs, failingSource: reproduce.failingSource, testVariables: run2.testVariables },
335716
+ healerOptions
335717
+ );
335718
+ const finalRes = nativeVerify.last;
335719
+ const outcome = (0, import_runner_core13.computeMobileHealOutcome)({
335720
+ heal,
335721
+ finalError: finalRes?.error ?? null,
335722
+ reproduceError,
335723
+ testVariables: run2.testVariables
335724
+ });
335725
+ await postResult(opts.serverUrl, headers, run2.runId, {
335726
+ status: outcome.status,
335727
+ stepResults: finalRes?.stepResults,
335728
+ screenshots: finalRes?.screenshots,
335729
+ video: finalRes?.video,
335730
+ videoMimeType: finalRes?.videoMimeType,
335731
+ apiCalls: finalRes?.apiCalls,
335732
+ logs: finalRes?.logs,
335733
+ error: outcome.error,
335734
+ healReason: heal.healReason,
335735
+ // NB: the loop's numeric `confidence` (0-1) is intentionally NOT posted
335736
+ // here — RunnerResultPayload.confidence is the web healer's categorical
335737
+ // 'high'|'medium'|'low'. The numeric value rides /mobile-heal-result via
335738
+ // the healReason instead. Mapping the two would be lossy and misleading.
335739
+ // extraTags carries @needs-review for the non-fix verdicts (applied by
335740
+ // the server's mobileAiHeal /result tag hook); PASSED heals apply tags
335741
+ // via /mobile-heal-result instead.
335742
+ extraTags: outcome.extraTags,
335743
+ removeTags: outcome.removeTags,
335744
+ duration: Date.now() - startTime,
335745
+ failingSource: finalRes?.failingSource
335746
+ });
335747
+ if (outcome.shouldPatch && heal.healedSteps && heal.healedSteps.length > 0) {
335748
+ const healedAppiumCode = heal.healedAppiumCode ?? buildAppiumCode(run2.testName ?? run2.testCaseId ?? "Healed mobile test", run2.target ?? null, heal.healedSteps);
335749
+ try {
335750
+ await postMobileHealResult(opts.serverUrl, headers, run2.runId, {
335751
+ healedSteps: heal.healedSteps,
335752
+ healedAppiumCode,
335753
+ healReason: heal.healReason ?? "",
335754
+ verdict: heal.verdict,
335755
+ extraTags: heal.extraTags,
335756
+ removeTags: heal.removeTags,
335757
+ priorStepsHash: run2.priorStepsHash
335758
+ });
335759
+ log.ok(`\u{1FA7A} Mobile heal patch posted for test ${run2.testCaseId ?? "(unknown)"} (verdict: ${heal.verdict})`);
335760
+ } catch (patchErr) {
335761
+ log.warn(`Failed to post mobile heal patch: ${patchErr instanceof Error ? patchErr.message : String(patchErr)}`);
335762
+ }
335763
+ } else if (outcome.secretLeak) {
335764
+ log.warn(`\u{1FA7A} Mobile heal patch DROPPED \u2014 healed steps contained a secret (${outcome.secretLeak}); tagged @needs-review`);
335765
+ }
335766
+ const duration3 = Date.now() - startTime;
335767
+ log[outcome.status === "PASSED" ? "ok" : "fail"](
335768
+ `\u{1FA7A} Mobile AI heal ${outcome.status} (verdict: ${heal.verdict}, ${duration3}ms)${outcome.error ? ` - ${outcome.error}` : ""}`
335769
+ );
335770
+ } finally {
335771
+ setExecutorBusy(false);
335772
+ }
335773
+ return;
335774
+ }
335502
335775
  const maxRetries = typeof run2.retryCount === "number" && Number.isFinite(run2.retryCount) ? Math.max(0, Math.min(2, Math.floor(run2.retryCount))) : 0;
335503
335776
  setExecutorBusy(true);
335504
335777
  let result2 = null;
@@ -335568,7 +335841,10 @@ ${result2.logs}`);
335568
335841
  apiCalls: result2.apiCalls,
335569
335842
  logs: result2.logs,
335570
335843
  error: reportedError,
335571
- duration: duration2
335844
+ duration: duration2,
335845
+ // Point-of-failure page-source XML (mobile) for the AI healer. Budget-
335846
+ // droppable (see result-payload-budget.ts); the verdict is never dropped.
335847
+ failingSource: result2.failingSource
335572
335848
  });
335573
335849
  log[result2.passed ? "ok" : "fail"](
335574
335850
  `\u{1F4F1} ${status} (${duration2}ms)${reportedError ? ` - ${reportedError}` : ""}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@validate.qa/runner",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
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"