@validate.qa/runner 1.0.23 → 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 +278 -10
  2. package/dist/cli.mjs +278 -10
  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.23",
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"
@@ -330552,6 +330552,14 @@ async function takeScreenshot(sessionId) {
330552
330552
  return null;
330553
330553
  }
330554
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
+ }
330555
330563
  function mobileRecordingEnabled() {
330556
330564
  const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
330557
330565
  return value !== "0" && value !== "false";
@@ -330681,9 +330689,12 @@ function identityTextXpath(value) {
330681
330689
  const lit = xpathLiteral(value);
330682
330690
  return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
330683
330691
  }
330692
+ function isIdentityXpath(value) {
330693
+ return value.includes("@") && !/(?:contains|starts-with)\s*\(/i.test(value);
330694
+ }
330684
330695
  function isIdentityStrategy(strategy) {
330685
330696
  if (strategy.using === "accessibility id" || strategy.using === "id") return true;
330686
- if (strategy.using === "xpath") return strategy.value.includes("@");
330697
+ if (strategy.using === "xpath") return isIdentityXpath(strategy.value);
330687
330698
  return false;
330688
330699
  }
330689
330700
  function buildIdentityLocatorStrategies(target, platform3) {
@@ -330697,7 +330708,7 @@ function buildIdentityLocatorStrategies(target, platform3) {
330697
330708
  const trimmed = value?.trim();
330698
330709
  if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
330699
330710
  }
330700
- 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 });
330701
330712
  }
330702
330713
  if (!(platform3 && hasPlatformLocator(target, platform3))) {
330703
330714
  for (const value of [target.primary, ...target.fallbacks ?? []]) {
@@ -331044,6 +331055,7 @@ async function executeMobileTest(options) {
331044
331055
  let recordingStarted = false;
331045
331056
  let recordingStopped = false;
331046
331057
  let video;
331058
+ let failingSource;
331047
331059
  const stopCapture = async () => {
331048
331060
  if (!capture || captureStopped) return apiCalls;
331049
331061
  captureStopped = true;
@@ -331067,6 +331079,10 @@ async function executeMobileTest(options) {
331067
331079
  }
331068
331080
  return video;
331069
331081
  };
331082
+ const recordFirstFailingSource = async () => {
331083
+ if (failingSource != null || !sessionId) return;
331084
+ failingSource = await captureFailingSource(sessionId);
331085
+ };
331070
331086
  try {
331071
331087
  log2("Checking Appium server health...");
331072
331088
  const health = await checkAppiumHealth();
@@ -331200,6 +331216,7 @@ async function executeMobileTest(options) {
331200
331216
  entryDrift = true;
331201
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).`;
331202
331218
  log2(` \u2717 Entry-screen drift: ${driftMsg}`);
331219
+ await recordFirstFailingSource();
331203
331220
  stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
331204
331221
  const driftShot = await takeScreenshot(sessionId).catch(() => null);
331205
331222
  screenshots.push(driftShot ?? "");
@@ -331276,6 +331293,9 @@ async function executeMobileTest(options) {
331276
331293
  }
331277
331294
  const screenshot = await takeScreenshot(sessionId).catch(() => null);
331278
331295
  screenshots.push(screenshot ?? "");
331296
+ if (status === "failed") {
331297
+ await recordFirstFailingSource();
331298
+ }
331279
331299
  stepResults.push({
331280
331300
  stepOrder: step.order,
331281
331301
  status,
@@ -331313,7 +331333,8 @@ async function executeMobileTest(options) {
331313
331333
  duration: Date.now() - startTime,
331314
331334
  deviceId: selectedDevice?.id,
331315
331335
  deviceName: selectedDevice?.name,
331316
- environmental: environmental ?? void 0
331336
+ environmental: environmental ?? void 0,
331337
+ failingSource
331317
331338
  };
331318
331339
  } catch (err) {
331319
331340
  const errorMsg = err instanceof Error ? err.message : String(err);
@@ -331334,7 +331355,8 @@ async function executeMobileTest(options) {
331334
331355
  // A throw before/around the step loop is a setup/environment problem
331335
331356
  // (no device booted, app not installed, session could not be created,
331336
331357
  // Appium unreachable) — never a genuine test assertion failure.
331337
- environmental: errorMsg
331358
+ environmental: errorMsg,
331359
+ failingSource
331338
331360
  };
331339
331361
  } finally {
331340
331362
  await stopCapture();
@@ -333303,6 +333325,9 @@ function budgetResultPayload(result, maxBytes = RESULT_BODY_BUDGET_BYTES, videoS
333303
333325
  if ((bytes > maxBytes || oversizedVideo) && payload.video != null) {
333304
333326
  drop(["video", "videoMimeType"], "video");
333305
333327
  }
333328
+ if (bytes > maxBytes && payload.failingSource != null) {
333329
+ drop(["failingSource"], "failingSource");
333330
+ }
333306
333331
  if (bytes > maxBytes && Array.isArray(payload.screenshots) && payload.screenshots.length > 0) {
333307
333332
  const shots = [...payload.screenshots];
333308
333333
  const bySizeDesc = shots.map((shot, i) => ({ i, len: typeof shot === "string" ? shot.length : 0 })).sort((a, b) => b.len - a.len);
@@ -333354,6 +333379,7 @@ ${tail}` };
333354
333379
  // src/runner.ts
333355
333380
  init_dist();
333356
333381
  var import_runner_core12 = __toESM(require_dist2());
333382
+ var import_runner_core13 = __toESM(require_dist2());
333357
333383
  function fingerprintTest(test) {
333358
333384
  const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
333359
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);
@@ -333591,6 +333617,33 @@ async function postHealResult(serverUrl, headers, runId, healedCode, testCaseId,
333591
333617
  }
333592
333618
  throw new Error(`Failed to post heal result after ${MAX_ATTEMPTS} attempts: ${lastError?.message}`);
333593
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
+ }
333594
333647
  async function postBatchHealResult(serverUrl, headers, runId, body) {
333595
333648
  const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/batch-heal-result`, {
333596
333649
  method: "POST",
@@ -335499,6 +335552,218 @@ ${finalVerifyResult.logs ?? ""}`;
335499
335552
  log.fail("ERROR - No mobile steps");
335500
335553
  return;
335501
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
+ }
335502
335767
  const maxRetries = typeof run2.retryCount === "number" && Number.isFinite(run2.retryCount) ? Math.max(0, Math.min(2, Math.floor(run2.retryCount))) : 0;
335503
335768
  setExecutorBusy(true);
335504
335769
  let result2 = null;
@@ -335568,7 +335833,10 @@ ${result2.logs}`);
335568
335833
  apiCalls: result2.apiCalls,
335569
335834
  logs: result2.logs,
335570
335835
  error: reportedError,
335571
- 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
335572
335840
  });
335573
335841
  log[result2.passed ? "ok" : "fail"](
335574
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.23",
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"
@@ -330560,6 +330560,14 @@ async function takeScreenshot(sessionId) {
330560
330560
  return null;
330561
330561
  }
330562
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
+ }
330563
330571
  function mobileRecordingEnabled() {
330564
330572
  const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
330565
330573
  return value !== "0" && value !== "false";
@@ -330689,9 +330697,12 @@ function identityTextXpath(value) {
330689
330697
  const lit = xpathLiteral(value);
330690
330698
  return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
330691
330699
  }
330700
+ function isIdentityXpath(value) {
330701
+ return value.includes("@") && !/(?:contains|starts-with)\s*\(/i.test(value);
330702
+ }
330692
330703
  function isIdentityStrategy(strategy) {
330693
330704
  if (strategy.using === "accessibility id" || strategy.using === "id") return true;
330694
- if (strategy.using === "xpath") return strategy.value.includes("@");
330705
+ if (strategy.using === "xpath") return isIdentityXpath(strategy.value);
330695
330706
  return false;
330696
330707
  }
330697
330708
  function buildIdentityLocatorStrategies(target, platform3) {
@@ -330705,7 +330716,7 @@ function buildIdentityLocatorStrategies(target, platform3) {
330705
330716
  const trimmed = value?.trim();
330706
330717
  if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
330707
330718
  }
330708
- 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 });
330709
330720
  }
330710
330721
  if (!(platform3 && hasPlatformLocator(target, platform3))) {
330711
330722
  for (const value of [target.primary, ...target.fallbacks ?? []]) {
@@ -331052,6 +331063,7 @@ async function executeMobileTest(options) {
331052
331063
  let recordingStarted = false;
331053
331064
  let recordingStopped = false;
331054
331065
  let video;
331066
+ let failingSource;
331055
331067
  const stopCapture = async () => {
331056
331068
  if (!capture || captureStopped) return apiCalls;
331057
331069
  captureStopped = true;
@@ -331075,6 +331087,10 @@ async function executeMobileTest(options) {
331075
331087
  }
331076
331088
  return video;
331077
331089
  };
331090
+ const recordFirstFailingSource = async () => {
331091
+ if (failingSource != null || !sessionId) return;
331092
+ failingSource = await captureFailingSource(sessionId);
331093
+ };
331078
331094
  try {
331079
331095
  log2("Checking Appium server health...");
331080
331096
  const health = await checkAppiumHealth();
@@ -331208,6 +331224,7 @@ async function executeMobileTest(options) {
331208
331224
  entryDrift = true;
331209
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).`;
331210
331226
  log2(` \u2717 Entry-screen drift: ${driftMsg}`);
331227
+ await recordFirstFailingSource();
331211
331228
  stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
331212
331229
  const driftShot = await takeScreenshot(sessionId).catch(() => null);
331213
331230
  screenshots.push(driftShot ?? "");
@@ -331284,6 +331301,9 @@ async function executeMobileTest(options) {
331284
331301
  }
331285
331302
  const screenshot = await takeScreenshot(sessionId).catch(() => null);
331286
331303
  screenshots.push(screenshot ?? "");
331304
+ if (status === "failed") {
331305
+ await recordFirstFailingSource();
331306
+ }
331287
331307
  stepResults.push({
331288
331308
  stepOrder: step.order,
331289
331309
  status,
@@ -331321,7 +331341,8 @@ async function executeMobileTest(options) {
331321
331341
  duration: Date.now() - startTime,
331322
331342
  deviceId: selectedDevice?.id,
331323
331343
  deviceName: selectedDevice?.name,
331324
- environmental: environmental ?? void 0
331344
+ environmental: environmental ?? void 0,
331345
+ failingSource
331325
331346
  };
331326
331347
  } catch (err) {
331327
331348
  const errorMsg = err instanceof Error ? err.message : String(err);
@@ -331342,7 +331363,8 @@ async function executeMobileTest(options) {
331342
331363
  // A throw before/around the step loop is a setup/environment problem
331343
331364
  // (no device booted, app not installed, session could not be created,
331344
331365
  // Appium unreachable) — never a genuine test assertion failure.
331345
- environmental: errorMsg
331366
+ environmental: errorMsg,
331367
+ failingSource
331346
331368
  };
331347
331369
  } finally {
331348
331370
  await stopCapture();
@@ -333311,6 +333333,9 @@ function budgetResultPayload(result, maxBytes = RESULT_BODY_BUDGET_BYTES, videoS
333311
333333
  if ((bytes > maxBytes || oversizedVideo) && payload.video != null) {
333312
333334
  drop(["video", "videoMimeType"], "video");
333313
333335
  }
333336
+ if (bytes > maxBytes && payload.failingSource != null) {
333337
+ drop(["failingSource"], "failingSource");
333338
+ }
333314
333339
  if (bytes > maxBytes && Array.isArray(payload.screenshots) && payload.screenshots.length > 0) {
333315
333340
  const shots = [...payload.screenshots];
333316
333341
  const bySizeDesc = shots.map((shot, i) => ({ i, len: typeof shot === "string" ? shot.length : 0 })).sort((a, b) => b.len - a.len);
@@ -333362,6 +333387,7 @@ ${tail}` };
333362
333387
  // src/runner.ts
333363
333388
  init_dist();
333364
333389
  var import_runner_core12 = __toESM(require_dist2());
333390
+ var import_runner_core13 = __toESM(require_dist2());
333365
333391
  function fingerprintTest(test) {
333366
333392
  const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
333367
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);
@@ -333599,6 +333625,33 @@ async function postHealResult(serverUrl, headers, runId, healedCode, testCaseId,
333599
333625
  }
333600
333626
  throw new Error(`Failed to post heal result after ${MAX_ATTEMPTS} attempts: ${lastError?.message}`);
333601
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
+ }
333602
333655
  async function postBatchHealResult(serverUrl, headers, runId, body) {
333603
333656
  const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/batch-heal-result`, {
333604
333657
  method: "POST",
@@ -335507,6 +335560,218 @@ ${finalVerifyResult.logs ?? ""}`;
335507
335560
  log.fail("ERROR - No mobile steps");
335508
335561
  return;
335509
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
+ }
335510
335775
  const maxRetries = typeof run2.retryCount === "number" && Number.isFinite(run2.retryCount) ? Math.max(0, Math.min(2, Math.floor(run2.retryCount))) : 0;
335511
335776
  setExecutorBusy(true);
335512
335777
  let result2 = null;
@@ -335576,7 +335841,10 @@ ${result2.logs}`);
335576
335841
  apiCalls: result2.apiCalls,
335577
335842
  logs: result2.logs,
335578
335843
  error: reportedError,
335579
- 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
335580
335848
  });
335581
335849
  log[result2.passed ? "ok" : "fail"](
335582
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.23",
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"