@validate.qa/runner 1.0.5 → 1.0.6

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 +462 -147
  2. package/dist/cli.mjs +462 -147
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1037,6 +1037,65 @@ var require_mobile_source_parser = __commonJS({
1037
1037
  }
1038
1038
  });
1039
1039
 
1040
+ // ../runner-core/dist/services/mobile/mobile-boundary.js
1041
+ var require_mobile_boundary = __commonJS({
1042
+ "../runner-core/dist/services/mobile/mobile-boundary.js"(exports2) {
1043
+ "use strict";
1044
+ Object.defineProperty(exports2, "__esModule", { value: true });
1045
+ exports2.mobileOwnerFromScreenSignal = mobileOwnerFromScreenSignal;
1046
+ exports2.isTransientMobileSystemOwner = isTransientMobileSystemOwner;
1047
+ exports2.classifyMobileScreenBoundary = classifyMobileScreenBoundary2;
1048
+ exports2.isTargetLikeMobileBoundary = isTargetLikeMobileBoundary2;
1049
+ exports2.mobileBoundaryNote = mobileBoundaryNote;
1050
+ var ANDROID_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
1051
+ "com.android.permissioncontroller",
1052
+ "com.google.android.permissioncontroller",
1053
+ "com.android.packageinstaller",
1054
+ "com.google.android.packageinstaller"
1055
+ ]);
1056
+ var IOS_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
1057
+ "com.apple.springboard"
1058
+ ]);
1059
+ function mobileOwnerFromScreenSignal(signal, targetAppId) {
1060
+ const bundleId = signal.bundleId?.trim();
1061
+ if (bundleId)
1062
+ return bundleId;
1063
+ const activity = signal.activity?.trim();
1064
+ if (activity && targetAppId && (activity === targetAppId || activity.startsWith(`${targetAppId}.`))) {
1065
+ return targetAppId;
1066
+ }
1067
+ return null;
1068
+ }
1069
+ function isTransientMobileSystemOwner(owner, platform3) {
1070
+ return platform3 === "ANDROID" ? ANDROID_TRANSIENT_SYSTEM_APP_IDS.has(owner) : IOS_TRANSIENT_SYSTEM_APP_IDS.has(owner);
1071
+ }
1072
+ function classifyMobileScreenBoundary2(signal, targetAppId, platform3) {
1073
+ if (!targetAppId)
1074
+ return { kind: "target" };
1075
+ const owner = mobileOwnerFromScreenSignal(signal, targetAppId);
1076
+ if (!owner)
1077
+ return { kind: "unknown" };
1078
+ if (owner === targetAppId)
1079
+ return { kind: "target" };
1080
+ if (isTransientMobileSystemOwner(owner, platform3)) {
1081
+ return { kind: "transient_external", owner, targetAppId };
1082
+ }
1083
+ return { kind: "external", owner, targetAppId };
1084
+ }
1085
+ function isTargetLikeMobileBoundary2(boundary) {
1086
+ return boundary.kind === "target" || boundary.kind === "unknown";
1087
+ }
1088
+ function mobileBoundaryNote(boundary) {
1089
+ return [
1090
+ "",
1091
+ "## App Boundary",
1092
+ `Foreground app is ${boundary.owner}, outside target app ${boundary.targetAppId}.`,
1093
+ boundary.kind === "transient_external" ? "This looks like a system overlay. Handle or dismiss it if needed, but do not capture it as an app screen." : "The runner excluded this external app screen from the target app evidence and relaunched the target app."
1094
+ ].join("\n");
1095
+ }
1096
+ }
1097
+ });
1098
+
1040
1099
  // ../shared/dist/types.js
1041
1100
  function isUIVariant(value) {
1042
1101
  return typeof value === "string" && UI_VARIANTS.includes(value);
@@ -1472,6 +1531,18 @@ function buildAppiumCode(testName, target, steps) {
1472
1531
  ` }`,
1473
1532
  ` throw new Error('No locator resolved. Tried: ' + JSON.stringify(selectors) + (__lastError ? ' (last error: ' + String(__lastError) + ')' : ''));`,
1474
1533
  `}`,
1534
+ ``,
1535
+ `async function __tapAt(driver: WebdriverIO.Browser, x: number, y: number) {`,
1536
+ ` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [{ type: 'pointerMove', duration: 0, x, y }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 }] }]);`,
1537
+ `}`,
1538
+ ``,
1539
+ `async function __typeIntoFocused(driver: WebdriverIO.Browser, text: string) {`,
1540
+ ` try {`,
1541
+ ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).addValue(text);`,
1542
+ ` } catch {`,
1543
+ ` await driver.keys(text);`,
1544
+ ` }`,
1545
+ `}`,
1475
1546
  ...hasSwipe ? [
1476
1547
  ``,
1477
1548
  `// Read the device window with a few retries (matches the runtime executor).`,
@@ -1530,13 +1601,25 @@ function buildAppiumCode(testName, target, steps) {
1530
1601
  const y = Math.round(bounds.y + bounds.height / 2);
1531
1602
  lines.push(` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [{ type: 'pointerMove', duration: 0, x: ${x}, y: ${y} }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 }] }]);`);
1532
1603
  } else if (step.action === "type" && hasSelector) {
1533
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).addValue(${toJsStringLiteral(step.value ?? "")});`);
1604
+ if (bounds) {
1605
+ const x = Math.round(bounds.x + bounds.width / 2);
1606
+ const y = Math.round(bounds.y + bounds.height / 2);
1607
+ lines.push(` try {`);
1608
+ lines.push(` await (await findFirst(driver, ${candidatesLiteral})).addValue(${toJsStringLiteral(step.value ?? "")});`);
1609
+ lines.push(` } catch {`);
1610
+ lines.push(` await __tapAt(driver, ${x}, ${y});`);
1611
+ lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1612
+ lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1613
+ lines.push(` }`);
1614
+ } else {
1615
+ lines.push(` await (await findFirst(driver, ${candidatesLiteral})).addValue(${toJsStringLiteral(step.value ?? "")});`);
1616
+ }
1534
1617
  } else if (step.action === "type" && bounds) {
1535
1618
  const x = Math.round(bounds.x + bounds.width / 2);
1536
1619
  const y = Math.round(bounds.y + bounds.height / 2);
1537
- lines.push(` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [{ type: 'pointerMove', duration: 0, x: ${x}, y: ${y} }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 }] }]);`);
1620
+ lines.push(` await __tapAt(driver, ${x}, ${y});`);
1538
1621
  lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1539
- lines.push(` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).addValue(${toJsStringLiteral(step.value ?? "")});`);
1622
+ lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1540
1623
  } else if (step.action === "clear" && hasSelector) {
1541
1624
  lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
1542
1625
  } else if (step.action === "assertVisible" && hasSelector) {
@@ -4433,21 +4516,13 @@ var require_mobile_explorer = __commonJS({
4433
4516
  var ai_retry_js_1 = require_ai_retry();
4434
4517
  var mobile_source_parser_js_1 = require_mobile_source_parser();
4435
4518
  var mobile_observation_js_1 = require_mobile_observation();
4519
+ var mobile_boundary_js_1 = require_mobile_boundary();
4436
4520
  var MAX_MOBILE_ITERATIONS_SURVEY = 40;
4437
4521
  var MAX_MOBILE_ITERATIONS_DEEP = 60;
4438
4522
  var MAX_SCREENSHOTS = 15;
4439
4523
  var MAX_RECENT_EXCHANGES = 24;
4440
4524
  var OBSERVATION_MAX_ELEMENTS = 50;
4441
4525
  var NO_SNAPSHOT_NUDGE_THRESHOLD = 4;
4442
- var ANDROID_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
4443
- "com.android.permissioncontroller",
4444
- "com.google.android.permissioncontroller",
4445
- "com.android.packageinstaller",
4446
- "com.google.android.packageinstaller"
4447
- ]);
4448
- var IOS_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
4449
- "com.apple.springboard"
4450
- ]);
4451
4526
  var MOBILE_SNAPSHOT_TOOL = {
4452
4527
  type: "function",
4453
4528
  function: {
@@ -4670,40 +4745,6 @@ var require_mobile_explorer = __commonJS({
4670
4745
  return "timeout";
4671
4746
  return "ai_error";
4672
4747
  }
4673
- function ownerFromScreenSignal(signal, targetAppId) {
4674
- const bundleId = signal.bundleId?.trim();
4675
- if (bundleId)
4676
- return bundleId;
4677
- const activity = signal.activity?.trim();
4678
- if (activity && targetAppId && (activity === targetAppId || activity.startsWith(`${targetAppId}.`))) {
4679
- return targetAppId;
4680
- }
4681
- return null;
4682
- }
4683
- function isTransientSystemOwner(owner, platform3) {
4684
- return platform3 === "ANDROID" ? ANDROID_TRANSIENT_SYSTEM_APP_IDS.has(owner) : IOS_TRANSIENT_SYSTEM_APP_IDS.has(owner);
4685
- }
4686
- function classifyMobileScreenBoundary(signal, targetAppId, platform3) {
4687
- if (!targetAppId)
4688
- return { kind: "target" };
4689
- const owner = ownerFromScreenSignal(signal, targetAppId);
4690
- if (!owner)
4691
- return { kind: "unknown" };
4692
- if (owner === targetAppId)
4693
- return { kind: "target" };
4694
- if (isTransientSystemOwner(owner, platform3)) {
4695
- return { kind: "transient_external", owner, targetAppId };
4696
- }
4697
- return { kind: "external", owner, targetAppId };
4698
- }
4699
- function boundaryNote(boundary) {
4700
- return [
4701
- "",
4702
- "## App Boundary",
4703
- `Foreground app is ${boundary.owner}, outside target app ${boundary.targetAppId}.`,
4704
- boundary.kind === "transient_external" ? "This looks like a system overlay. Handle or dismiss it if needed, but do not capture it as an app screen." : "The runner excluded this external app screen from the survey evidence."
4705
- ].join("\n");
4706
- }
4707
4748
  function resolveSnapshot(xml, windowSize, driverSignal, platform3) {
4708
4749
  const parsed = (0, mobile_source_parser_js_1.parseMobileSource)(xml, platform3);
4709
4750
  const signal = {
@@ -4901,10 +4942,10 @@ Use this to prioritize which screens and flows to cover.` : "";
4901
4942
  };
4902
4943
  const currentBoundary = () => {
4903
4944
  const current = getLatest();
4904
- return current ? classifyMobileScreenBoundary(current.signal, targetAppId, platform3) : { kind: "unknown" };
4945
+ return current ? (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(current.signal, targetAppId, platform3) : { kind: "unknown" };
4905
4946
  };
4906
4947
  const absorbResolvedObservation = async (resolved, screenshot, iterationForObservation, source) => {
4907
- const boundary = classifyMobileScreenBoundary(resolved.signal, targetAppId, platform3);
4948
+ const boundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(resolved.signal, targetAppId, platform3);
4908
4949
  if (boundary.kind === "target" || boundary.kind === "unknown") {
4909
4950
  const screenId = ingestSnapshot(resolved, iterationForObservation);
4910
4951
  captureScreenshot(screenshot, screenId);
@@ -4915,7 +4956,7 @@ Use this to prioritize which screens and flows to cover.` : "";
4915
4956
  if (boundary.kind === "transient_external") {
4916
4957
  log2(` System overlay detected from ${source}: ${boundary.owner}; not adding it to target-app survey evidence.`);
4917
4958
  return {
4918
- observation: `${resolved.observation}${boundaryNote(boundary)}`,
4959
+ observation: `${resolved.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(boundary)}`,
4919
4960
  screenId: null,
4920
4961
  transientExternal: true,
4921
4962
  externalOwner: boundary.owner
@@ -4934,7 +4975,7 @@ Use this to prioritize which screens and flows to cover.` : "";
4934
4975
  externalOwner: boundary.owner
4935
4976
  };
4936
4977
  }
4937
- const recoveredBoundary = classifyMobileScreenBoundary(recovered.signal, targetAppId, platform3);
4978
+ const recoveredBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(recovered.signal, targetAppId, platform3);
4938
4979
  latest = recovered;
4939
4980
  lastSnapshotIteration = iterationForObservation;
4940
4981
  if (recoveredBoundary.kind === "target" || recoveredBoundary.kind === "unknown") {
@@ -4950,7 +4991,7 @@ Use this to prioritize which screens and flows to cover.` : "";
4950
4991
  if (recoveredBoundary.kind === "transient_external") {
4951
4992
  log2(` Relaunch surfaced system overlay ${recoveredBoundary.owner}; not adding it to target-app survey evidence.`);
4952
4993
  return {
4953
- observation: `${recovered.observation}${boundaryNote(recoveredBoundary)}`,
4994
+ observation: `${recovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(recoveredBoundary)}`,
4954
4995
  screenId: null,
4955
4996
  externalBlocked: true,
4956
4997
  transientExternal: true,
@@ -8594,6 +8635,7 @@ var require_mobile_generator = __commonJS({
8594
8635
  var mobile_source_parser_js_1 = require_mobile_source_parser();
8595
8636
  var mobile_source_parser_js_2 = require_mobile_source_parser();
8596
8637
  var mobile_observation_js_1 = require_mobile_observation();
8638
+ var mobile_boundary_js_1 = require_mobile_boundary();
8597
8639
  var MAX_SCENARIO_ITERATIONS = 30;
8598
8640
  var MAX_RECENT_EXCHANGES = 24;
8599
8641
  var OBSERVATION_MAX_ELEMENTS = 50;
@@ -8779,8 +8821,9 @@ var require_mobile_generator = __commonJS({
8779
8821
  function hasStableId(element) {
8780
8822
  return !!nonEmpty2(element.accessibilityId) || !!nonEmpty2(element.resourceId);
8781
8823
  }
8782
- function buildSystemPrompt(platform3) {
8824
+ function buildSystemPrompt(platform3, targetAppId) {
8783
8825
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
8826
+ const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
8784
8827
  return `You are an expert mobile QA agent generating ONE automated test by driving a real ${platformLabel} app on a connected device. The app has just been relaunched into a clean state.
8785
8828
 
8786
8829
  ## How the loop works
@@ -8800,7 +8843,8 @@ var require_mobile_generator = __commonJS({
8800
8843
  - Re-snapshot before every assertion so the ref is fresh.
8801
8844
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
8802
8845
  - Do NOT trigger irreversible side effects (real payments, sending messages to others) \u2014 note them and assert what you safely can.
8803
- - Stay inside this app. Finish promptly once the outcome is proven.
8846
+ - Stay inside this app.${targetLine} Do not open or use app stores, browsers, email apps, settings, or any other app. If an action leaves the target app, the runner will relaunch the target app and reject that action.
8847
+ - Finish promptly once the outcome is proven.
8804
8848
 
8805
8849
  Max iterations: ${MAX_SCENARIO_ITERATIONS}. You will be warned near the end.`;
8806
8850
  }
@@ -8873,7 +8917,7 @@ Start by calling mobile_snapshot, then execute the steps. Finish with at least o
8873
8917
  }
8874
8918
  }
8875
8919
  async function runScenarioLoop(args) {
8876
- const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, log: log2 } = args;
8920
+ const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, log: log2 } = args;
8877
8921
  const actions = [];
8878
8922
  let assertCount = 0;
8879
8923
  let finish = null;
@@ -8887,14 +8931,98 @@ Start by calling mobile_snapshot, then execute the steps. Finish with at least o
8887
8931
  const fromActivity = signal.activity ? signal.activity.split(".").pop() : void 0;
8888
8932
  return nonEmpty2(fromActivity) ?? nonEmpty2(signal.bundleId) ?? `screen-${signal.screenHash.slice(0, 8)}`;
8889
8933
  };
8890
- const ingest = (xml, driverSignal, windowSize) => {
8891
- if (!xml)
8892
- return null;
8893
- const resolved = resolveSnapshot(xml, windowSize ?? latest?.screen.windowSize ?? null, driverSignal, platform3);
8934
+ const setLatest = (resolved) => {
8894
8935
  latest = resolved;
8895
8936
  lastScreenName = screenNameFor(resolved);
8896
8937
  return resolved;
8897
8938
  };
8939
+ const buildResolved = (xml, driverSignal, windowSize) => {
8940
+ if (!xml)
8941
+ return null;
8942
+ return resolveSnapshot(xml, windowSize ?? latest?.screen.windowSize ?? null, driverSignal, platform3);
8943
+ };
8944
+ const emitScreenshot = (base64) => {
8945
+ if (!base64)
8946
+ return;
8947
+ try {
8948
+ onScreenshot?.(base64);
8949
+ } catch {
8950
+ }
8951
+ };
8952
+ const absorbResolvedObservation = async (resolved, screenshot, source) => {
8953
+ const boundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(resolved.signal, targetAppId, platform3);
8954
+ if ((0, mobile_boundary_js_1.isTargetLikeMobileBoundary)(boundary)) {
8955
+ setLatest(resolved);
8956
+ emitScreenshot(screenshot);
8957
+ return { resolved, observation: resolved.observation };
8958
+ }
8959
+ setLatest(resolved);
8960
+ if (boundary.kind === "transient_external") {
8961
+ log2(` system overlay detected from ${source}: ${boundary.owner}; not recording it as a target-app step.`);
8962
+ return {
8963
+ resolved,
8964
+ observation: `${resolved.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(boundary)}`,
8965
+ transientExternal: true,
8966
+ externalOwner: boundary.owner
8967
+ };
8968
+ }
8969
+ log2(` external app detected from ${source}: ${boundary.owner}; rejecting the action and relaunching ${boundary.targetAppId}.`);
8970
+ try {
8971
+ await driver.relaunch();
8972
+ const snap = await driver.snapshot();
8973
+ const recovered = buildResolved(snap.xml, snap.screenSignal, snap.windowSize);
8974
+ if (!recovered) {
8975
+ return {
8976
+ resolved: null,
8977
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunched target app, but no UI tree was returned)`,
8978
+ externalBlocked: true,
8979
+ externalOwner: boundary.owner
8980
+ };
8981
+ }
8982
+ const recoveredBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(recovered.signal, targetAppId, platform3);
8983
+ setLatest(recovered);
8984
+ if ((0, mobile_boundary_js_1.isTargetLikeMobileBoundary)(recoveredBoundary)) {
8985
+ emitScreenshot(snap.screenshot);
8986
+ return {
8987
+ resolved: recovered,
8988
+ observation: recovered.observation,
8989
+ externalBlocked: true,
8990
+ externalOwner: boundary.owner
8991
+ };
8992
+ }
8993
+ if (recoveredBoundary.kind === "transient_external") {
8994
+ log2(` relaunch surfaced system overlay ${recoveredBoundary.owner}; not recording it as a target-app step.`);
8995
+ return {
8996
+ resolved: recovered,
8997
+ observation: `${recovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(recoveredBoundary)}`,
8998
+ externalBlocked: true,
8999
+ transientExternal: true,
9000
+ externalOwner: boundary.owner
9001
+ };
9002
+ }
9003
+ log2(` relaunch still outside target app (${recoveredBoundary.owner}); no target-app step recorded.`);
9004
+ return {
9005
+ resolved: recovered,
9006
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no target-app step was recorded)`,
9007
+ externalBlocked: true,
9008
+ externalOwner: boundary.owner
9009
+ };
9010
+ } catch (err) {
9011
+ const message = err instanceof Error ? err.message : String(err);
9012
+ return {
9013
+ resolved: null,
9014
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; failed to relaunch target app: ${message})`,
9015
+ externalBlocked: true,
9016
+ externalOwner: boundary.owner
9017
+ };
9018
+ }
9019
+ };
9020
+ const absorbObservation = async (xml, driverSignal, windowSize, screenshot, source) => {
9021
+ const resolved = buildResolved(xml, driverSignal, windowSize);
9022
+ if (!resolved)
9023
+ return { resolved: null, observation: null };
9024
+ return absorbResolvedObservation(resolved, screenshot, source);
9025
+ };
8898
9026
  const resolveRef = (ref) => {
8899
9027
  if (typeof ref !== "string" || !latest)
8900
9028
  return null;
@@ -8902,11 +9030,11 @@ Start by calling mobile_snapshot, then execute the steps. Finish with at least o
8902
9030
  };
8903
9031
  try {
8904
9032
  const seed = await driver.snapshot();
8905
- ingest(seed.xml, seed.screenSignal, seed.windowSize);
9033
+ await absorbObservation(seed.xml, seed.screenSignal, seed.windowSize, seed.screenshot, "seed snapshot");
8906
9034
  } catch (err) {
8907
9035
  log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
8908
9036
  }
8909
- const systemPrompt = buildSystemPrompt(platform3);
9037
+ const systemPrompt = buildSystemPrompt(platform3, targetAppId);
8910
9038
  const kickoff = buildScenarioKickoff(scenario);
8911
9039
  const recentExchanges = [];
8912
9040
  let iteration = 0;
@@ -9017,7 +9145,7 @@ ${statePacket}` },
9017
9145
  driver,
9018
9146
  platform: platform3,
9019
9147
  latest: getLatest,
9020
- ingest,
9148
+ absorbObservation,
9021
9149
  resolveRef,
9022
9150
  screenName: () => lastScreenName || "UnknownScreen",
9023
9151
  recordAction: (action) => {
@@ -9040,60 +9168,85 @@ ${statePacket}` },
9040
9168
  return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
9041
9169
  }
9042
9170
  async function dispatchGenerateTool(args) {
9043
- const { toolName, toolArgs, driver, platform: platform3, latest, ingest, resolveRef, screenName, recordAction, log: log2 } = args;
9171
+ const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, screenName, recordAction, log: log2 } = args;
9044
9172
  const unknownRef = (ref) => ({
9045
9173
  ok: false,
9046
9174
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
9047
9175
  });
9176
+ const actionPayload = (result, absorbed) => {
9177
+ const blocked = Boolean(absorbed.externalBlocked || absorbed.transientExternal);
9178
+ return {
9179
+ ok: result.ok && !blocked,
9180
+ ...result.error ? { error: result.error } : {},
9181
+ ...blocked ? { error: "Action left the target app and was not recorded. Continue from the relaunched target app." } : {},
9182
+ observation: absorbed.observation ?? void 0,
9183
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
9184
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
9185
+ };
9186
+ };
9187
+ const shouldRecordAction = (result, absorbed) => result.ok && !absorbed.externalBlocked && !absorbed.transientExternal;
9048
9188
  switch (toolName) {
9049
9189
  case "mobile_snapshot": {
9050
9190
  const snap = await driver.snapshot();
9051
- const resolved = ingest(snap.xml, snap.screenSignal, snap.windowSize);
9052
- if (!resolved)
9191
+ const absorbed = await absorbObservation(snap.xml, snap.screenSignal, snap.windowSize, snap.screenshot, "mobile_snapshot");
9192
+ if (!absorbed.resolved)
9053
9193
  return { ok: false, error: "snapshot returned no UI tree" };
9054
- log2(` snapshot: ${resolved.screen.elements.length} elements`);
9055
- return { observation: resolved.observation };
9194
+ log2(` snapshot: ${absorbed.resolved.screen.elements.length} elements`);
9195
+ return {
9196
+ observation: absorbed.observation,
9197
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
9198
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
9199
+ };
9056
9200
  }
9057
9201
  case "mobile_tap": {
9058
9202
  const element = resolveRef(toolArgs.ref);
9059
9203
  if (!element)
9060
9204
  return unknownRef(toolArgs.ref);
9205
+ const beforeScreenName = screenName();
9061
9206
  const result = await driver.tap(element.bounds);
9062
- recordAction({
9063
- type: "TAP",
9064
- selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9065
- metadata: metadataForElement(element, screenName(), platform3)
9066
- });
9067
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
9068
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9207
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_tap");
9208
+ if (shouldRecordAction(result, absorbed)) {
9209
+ recordAction({
9210
+ type: "TAP",
9211
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9212
+ metadata: metadataForElement(element, beforeScreenName, platform3)
9213
+ });
9214
+ }
9215
+ return actionPayload(result, absorbed);
9069
9216
  }
9070
9217
  case "mobile_type": {
9071
9218
  const element = resolveRef(toolArgs.ref);
9072
9219
  if (!element)
9073
9220
  return unknownRef(toolArgs.ref);
9074
9221
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
9222
+ const beforeScreenName = screenName();
9075
9223
  const result = await driver.type(value, element.bounds);
9076
- recordAction({
9077
- type: "TYPE",
9078
- value,
9079
- selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9080
- metadata: metadataForElement(element, screenName(), platform3)
9081
- });
9082
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
9083
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9224
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_type");
9225
+ if (shouldRecordAction(result, absorbed)) {
9226
+ recordAction({
9227
+ type: "TYPE",
9228
+ value,
9229
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9230
+ metadata: metadataForElement(element, beforeScreenName, platform3)
9231
+ });
9232
+ }
9233
+ return actionPayload(result, absorbed);
9084
9234
  }
9085
9235
  case "mobile_clear": {
9086
9236
  const element = resolveRef(toolArgs.ref);
9087
9237
  if (!element)
9088
9238
  return unknownRef(toolArgs.ref);
9239
+ const beforeScreenName = screenName();
9089
9240
  const result = await driver.clear(element.bounds);
9090
- recordAction({
9091
- type: "CLEAR",
9092
- selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9093
- metadata: metadataForElement(element, screenName(), platform3)
9094
- });
9095
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
9096
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9241
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_clear");
9242
+ if (shouldRecordAction(result, absorbed)) {
9243
+ recordAction({
9244
+ type: "CLEAR",
9245
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9246
+ metadata: metadataForElement(element, beforeScreenName, platform3)
9247
+ });
9248
+ }
9249
+ return actionPayload(result, absorbed);
9097
9250
  }
9098
9251
  case "mobile_swipe": {
9099
9252
  const direction = toolArgs.direction;
@@ -9101,30 +9254,42 @@ ${statePacket}` },
9101
9254
  return { ok: false, error: "direction must be one of up|down|left|right" };
9102
9255
  }
9103
9256
  const distance = typeof toolArgs.distance === "number" ? toolArgs.distance : void 0;
9257
+ const beforeScreenName = screenName();
9104
9258
  const result = await driver.swipe(direction, distance);
9105
- recordAction({
9106
- type: "SWIPE",
9107
- metadata: {
9108
- screenName: screenName(),
9109
- direction,
9110
- ...distance !== void 0 ? { distance } : {}
9111
- }
9112
- });
9113
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
9114
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9259
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_swipe");
9260
+ if (shouldRecordAction(result, absorbed)) {
9261
+ recordAction({
9262
+ type: "SWIPE",
9263
+ metadata: {
9264
+ screenName: beforeScreenName,
9265
+ direction,
9266
+ ...distance !== void 0 ? { distance } : {}
9267
+ }
9268
+ });
9269
+ }
9270
+ return actionPayload(result, absorbed);
9115
9271
  }
9116
9272
  case "mobile_back": {
9273
+ const beforeScreenName = screenName();
9117
9274
  const result = await driver.back();
9118
- recordAction({ type: "BACK", metadata: { screenName: screenName() } });
9119
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
9120
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9275
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_back");
9276
+ if (shouldRecordAction(result, absorbed)) {
9277
+ recordAction({ type: "BACK", metadata: { screenName: beforeScreenName } });
9278
+ }
9279
+ return actionPayload(result, absorbed);
9121
9280
  }
9122
9281
  case "mobile_relaunch": {
9123
9282
  await driver.relaunch();
9124
9283
  const snap = await driver.snapshot();
9125
- const resolved = ingest(snap.xml, snap.screenSignal, snap.windowSize);
9284
+ const absorbed = await absorbObservation(snap.xml, snap.screenSignal, snap.windowSize, snap.screenshot, "mobile_relaunch");
9126
9285
  log2(" relaunched app for hermetic reset.");
9127
- return { ok: true, observation: resolved?.observation, note: "App relaunched (clean state). Not recorded as a step." };
9286
+ return {
9287
+ ok: true,
9288
+ observation: absorbed.observation,
9289
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
9290
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
9291
+ note: "App relaunched (clean state). Not recorded as a step."
9292
+ };
9128
9293
  }
9129
9294
  case "mobile_assert_visible": {
9130
9295
  const recorded = recordAssertVisible(toolArgs, { resolveRef, screenName, platform: platform3, recordAction });
@@ -9262,7 +9427,7 @@ ${statePacket}` },
9262
9427
  return test;
9263
9428
  }
9264
9429
  async function runMobileGeneratePhase(args) {
9265
- const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated } = args;
9430
+ const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot } = args;
9266
9431
  const log2 = (line) => {
9267
9432
  try {
9268
9433
  onLog?.(line);
@@ -9276,6 +9441,7 @@ ${statePacket}` },
9276
9441
  const provider = (0, ai_provider_js_1.getProviderForModel)(agentModel);
9277
9442
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
9278
9443
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
9444
+ const targetAppId = ctx.target?.appId?.trim() || void 0;
9279
9445
  log2(`Mobile generator starting \u2014 platform: ${platform3}, provider: ${provider}, model: ${agentModel}, scenarios: ${scenarios.length}`);
9280
9446
  const tests = [];
9281
9447
  for (let i = 0; i < scenarios.length; i++) {
@@ -9294,6 +9460,8 @@ ${statePacket}` },
9294
9460
  aiClient,
9295
9461
  auditGroupId,
9296
9462
  onAICall,
9463
+ onScreenshot,
9464
+ targetAppId,
9297
9465
  log: log2
9298
9466
  });
9299
9467
  const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
@@ -311423,7 +311591,9 @@ var require_mobile_discovery_agent = __commonJS({
311423
311591
  return {
311424
311592
  collectedPages: explore.collectedPages,
311425
311593
  collectedTransitions,
311426
- pageScreenshots: explore.pageScreenshots,
311594
+ // Native screenshots are multi-megabyte PNGs. The live mirror streams them
311595
+ // through heartbeat telemetry; discovery checkpoints keep the screen graph
311596
+ // and transitions so large Android surveys cannot exceed server body limits.
311427
311597
  exploreStats: explore.exploreStats,
311428
311598
  healthObservations: explore.healthObservations
311429
311599
  };
@@ -311581,6 +311751,7 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311581
311751
  ctx,
311582
311752
  onLog: (line) => log2(line),
311583
311753
  onAICall,
311754
+ onScreenshot,
311584
311755
  onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
311585
311756
  });
311586
311757
  const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
@@ -325286,7 +325457,7 @@ var require_live_browser_registry = __commonJS({
325286
325457
  var THUMBNAIL_INTERVAL_MS = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_INTERVAL_MS ?? "4000", 10) || 4e3;
325287
325458
  var THUMBNAIL_QUALITY = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_QUALITY ?? "60", 10) || 60;
325288
325459
  var BROWSER_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_MAX_BASE64_BYTES ?? "400000", 10) || 4e5;
325289
- var MOBILE_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_MOBILE_THUMBNAIL_MAX_BASE64_BYTES ?? "2000000", 10) || 2e6;
325460
+ var MOBILE_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_MOBILE_THUMBNAIL_MAX_BASE64_BYTES ?? "4500000", 10) || 45e5;
325290
325461
  var STALE_SESSION_MS = parseInt(process.env.LIVE_BROWSER_STALE_MS ?? `${10 * 60 * 1e3}`, 10) || 10 * 60 * 1e3;
325291
325462
  var MAX_SESSIONS = parseInt(process.env.LIVE_BROWSER_MAX_SESSIONS ?? "32", 10) || 32;
325292
325463
  var LiveBrowserRegistry = class {
@@ -325782,6 +325953,7 @@ var require_dist2 = __commonJS({
325782
325953
  __exportStar(require_ai_provider(), exports2);
325783
325954
  __exportStar(require_mobile_types(), exports2);
325784
325955
  __exportStar(require_mobile_source_parser(), exports2);
325956
+ __exportStar(require_mobile_boundary(), exports2);
325785
325957
  var mobile_explorer_js_1 = require_mobile_explorer();
325786
325958
  Object.defineProperty(exports2, "runMobileExplorePhase", { enumerable: true, get: function() {
325787
325959
  return mobile_explorer_js_1.runMobileExplorePhase;
@@ -326114,7 +326286,7 @@ var require_package4 = __commonJS({
326114
326286
  "package.json"(exports2, module2) {
326115
326287
  module2.exports = {
326116
326288
  name: "@validate.qa/runner",
326117
- version: "1.0.5",
326289
+ version: "1.0.6",
326118
326290
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
326119
326291
  bin: {
326120
326292
  "validate-runner": "dist/cli.js"
@@ -326212,7 +326384,7 @@ var import_runner_core = __toESM(require_dist2());
326212
326384
  var import_runner_core2 = __toESM(require_dist2());
326213
326385
 
326214
326386
  // src/runner.ts
326215
- var import_runner_core9 = __toESM(require_dist2());
326387
+ var import_runner_core10 = __toESM(require_dist2());
326216
326388
 
326217
326389
  // src/services/regression-runner.ts
326218
326390
  var import_runner_core3 = __toESM(require_dist2());
@@ -326227,7 +326399,7 @@ var import_runner_core5 = __toESM(require_dist2());
326227
326399
  var import_runner_core6 = __toESM(require_dist2());
326228
326400
 
326229
326401
  // src/runner.ts
326230
- var import_runner_core10 = __toESM(require_dist2());
326402
+ var import_runner_core11 = __toESM(require_dist2());
326231
326403
 
326232
326404
  // src/services/auth-state.ts
326233
326405
  var INLINE_LOGIN_TAG = "@inline-login";
@@ -326247,6 +326419,7 @@ function shouldUseAuthState(tags, playwrightCode) {
326247
326419
 
326248
326420
  // src/services/appium-executor.ts
326249
326421
  init_dist();
326422
+ var import_runner_core8 = __toESM(require_dist2());
326250
326423
 
326251
326424
  // src/services/appium-lifecycle.ts
326252
326425
  var import_child_process = require("child_process");
@@ -327535,6 +327708,86 @@ async function openDeepLink(sessionId, platform3, deeplink, appId) {
327535
327708
  body: JSON.stringify({ script: "mobile: deepLink", args })
327536
327709
  });
327537
327710
  }
327711
+ function readExecuteStringValue(result) {
327712
+ if (result && typeof result === "object" && "value" in result) {
327713
+ const value = result.value;
327714
+ if (typeof value === "string" && value.trim()) return value.trim();
327715
+ }
327716
+ return void 0;
327717
+ }
327718
+ function readActiveBundleId(result) {
327719
+ if (result && typeof result === "object" && "value" in result) {
327720
+ const value = result.value;
327721
+ if (value && typeof value === "object" && "bundleId" in value) {
327722
+ const bundleId = value.bundleId;
327723
+ if (typeof bundleId === "string" && bundleId.trim()) return bundleId.trim();
327724
+ }
327725
+ }
327726
+ return void 0;
327727
+ }
327728
+ function normalizeAndroidActivity(activity, pkg2) {
327729
+ const trimmed = activity?.trim();
327730
+ if (!trimmed) return void 0;
327731
+ const packageName = pkg2?.trim();
327732
+ if (!packageName) return trimmed;
327733
+ if (trimmed.startsWith(".")) return `${packageName}${trimmed}`;
327734
+ if (!trimmed.includes(".")) return `${packageName}.${trimmed}`;
327735
+ return trimmed;
327736
+ }
327737
+ async function executeMobileScript(sessionId, script, args) {
327738
+ return wdRequest(`/session/${sessionId}/execute/sync`, {
327739
+ method: "POST",
327740
+ body: JSON.stringify({ script, args })
327741
+ });
327742
+ }
327743
+ async function readForegroundScreenSignal(sessionId, platform3) {
327744
+ if (platform3 === "ANDROID") {
327745
+ const [packageResult, activityResult] = await Promise.all([
327746
+ executeMobileScript(sessionId, "mobile: getCurrentPackage", []),
327747
+ executeMobileScript(sessionId, "mobile: getCurrentActivity", []).catch(() => void 0)
327748
+ ]);
327749
+ const pkg2 = readExecuteStringValue(packageResult);
327750
+ if (!pkg2) return null;
327751
+ const activity = normalizeAndroidActivity(readExecuteStringValue(activityResult), pkg2);
327752
+ return {
327753
+ platform: platform3,
327754
+ bundleId: pkg2,
327755
+ ...activity ? { activity } : {},
327756
+ screenHash: "foreground"
327757
+ };
327758
+ }
327759
+ const infoResult = await executeMobileScript(sessionId, "mobile: activeAppInfo", []);
327760
+ const bundleId = readActiveBundleId(infoResult);
327761
+ if (!bundleId) return null;
327762
+ return { platform: platform3, bundleId, screenHash: "foreground" };
327763
+ }
327764
+ async function activateTargetApp(sessionId, platform3, appId) {
327765
+ const args = platform3 === "IOS" ? [{ bundleId: appId }] : [{ appId }];
327766
+ await executeMobileScript(sessionId, "mobile: activateApp", args);
327767
+ }
327768
+ async function restoreTargetAppIfExternal(sessionId, target, log2, source, failOnExternal) {
327769
+ let boundary = null;
327770
+ try {
327771
+ const signal = await readForegroundScreenSignal(sessionId, target.platform);
327772
+ boundary = signal ? (0, import_runner_core8.classifyMobileScreenBoundary)(signal, target.appId, target.platform) : null;
327773
+ } catch (err) {
327774
+ log2(` App boundary check skipped after ${source}: ${err instanceof Error ? err.message : String(err)}`);
327775
+ return;
327776
+ }
327777
+ if (!boundary || (0, import_runner_core8.isTargetLikeMobileBoundary)(boundary) || boundary.kind === "transient_external") {
327778
+ return;
327779
+ }
327780
+ const message = `Foreground app changed to ${boundary.owner}, outside target app ${boundary.targetAppId}`;
327781
+ log2(` ${message}; activating target app.`);
327782
+ try {
327783
+ await activateTargetApp(sessionId, target.platform, target.appId);
327784
+ } catch (err) {
327785
+ log2(` Could not reactivate ${target.appId}: ${err instanceof Error ? err.message : String(err)}`);
327786
+ }
327787
+ if (failOnExternal) {
327788
+ throw new Error(`${message}; relaunched target app and failed the step so mobile tests do not continue in another app.`);
327789
+ }
327790
+ }
327538
327791
  async function deleteSession(sessionId) {
327539
327792
  try {
327540
327793
  await wdRequest(`/session/${sessionId}`, { method: "DELETE" });
@@ -327661,6 +327914,29 @@ async function getActiveElementId(sessionId) {
327661
327914
  return null;
327662
327915
  }
327663
327916
  }
327917
+ async function sendKeysToFocusedInput(sessionId, value) {
327918
+ await wdRequest(`/session/${sessionId}/keys`, {
327919
+ method: "POST",
327920
+ body: JSON.stringify({ text: value, value: Array.from(value) })
327921
+ });
327922
+ }
327923
+ function shouldAbortAfterStepFailure(action) {
327924
+ switch (action) {
327925
+ case "launchApp":
327926
+ case "tap":
327927
+ case "type":
327928
+ case "clear":
327929
+ case "swipe":
327930
+ case "scroll":
327931
+ case "back":
327932
+ case "home":
327933
+ case "waitForElement":
327934
+ return true;
327935
+ case "assertVisible":
327936
+ case "assertText":
327937
+ return false;
327938
+ }
327939
+ }
327664
327940
  async function executeTap(sessionId, step) {
327665
327941
  const bounds = boundsFromStep(step);
327666
327942
  if (step.target) {
@@ -327682,8 +327958,10 @@ async function executeTap(sessionId, step) {
327682
327958
  }
327683
327959
  async function executeType(sessionId, step) {
327684
327960
  if (step.value === void 0 || step.value === null || step.value === "") return;
327961
+ const value = String(step.value);
327685
327962
  const bounds = boundsFromStep(step);
327686
327963
  let elementId = null;
327964
+ let tappedBounds = false;
327687
327965
  if (step.target) {
327688
327966
  try {
327689
327967
  elementId = await findElement(sessionId, step.target, readStepTimeout(step));
@@ -327693,19 +327971,33 @@ async function executeType(sessionId, step) {
327693
327971
  }
327694
327972
  if (!elementId && bounds) {
327695
327973
  await tapCoordinates(sessionId, bounds);
327974
+ tappedBounds = true;
327696
327975
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
327697
327976
  elementId = await getActiveElementId(sessionId);
327698
327977
  }
327699
327978
  if (!elementId) {
327700
327979
  elementId = await getActiveElementId(sessionId);
327701
327980
  }
327702
- if (!elementId) {
327703
- throw new Error("TYPE step could not resolve a target or focused element");
327981
+ if (elementId) {
327982
+ try {
327983
+ await wdRequest(`/session/${sessionId}/element/${elementId}/value`, {
327984
+ method: "POST",
327985
+ body: JSON.stringify({ text: value })
327986
+ });
327987
+ return;
327988
+ } catch (error2) {
327989
+ if (!bounds) throw error2;
327990
+ }
327704
327991
  }
327705
- await wdRequest(`/session/${sessionId}/element/${elementId}/value`, {
327706
- method: "POST",
327707
- body: JSON.stringify({ text: step.value })
327708
- });
327992
+ if (bounds) {
327993
+ if (!tappedBounds) {
327994
+ await tapCoordinates(sessionId, bounds);
327995
+ await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
327996
+ }
327997
+ await sendKeysToFocusedInput(sessionId, value);
327998
+ return;
327999
+ }
328000
+ throw new Error("TYPE step could not resolve a target or focused element");
327709
328001
  }
327710
328002
  async function executeClear(sessionId, step) {
327711
328003
  const bounds = boundsFromStep(step);
@@ -327909,6 +328201,7 @@ async function executeMobileTest(options) {
327909
328201
  log2(` \u2717 Deep link failed: ${err instanceof Error ? err.message : String(err)}`);
327910
328202
  }
327911
328203
  }
328204
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
327912
328205
  let environmental = null;
327913
328206
  for (const step of options.steps) {
327914
328207
  const stepStart = Date.now();
@@ -327916,6 +328209,7 @@ async function executeMobileTest(options) {
327916
328209
  let status = "passed";
327917
328210
  let stepError;
327918
328211
  try {
328212
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} pre-check`, false);
327919
328213
  switch (step.action) {
327920
328214
  case "launchApp":
327921
328215
  log2(" App launched (handled by session creation)");
@@ -327955,6 +328249,7 @@ async function executeMobileTest(options) {
327955
328249
  break;
327956
328250
  }
327957
328251
  }
328252
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
327958
328253
  } catch (err) {
327959
328254
  status = "failed";
327960
328255
  stepError = err instanceof Error ? err.message : String(err);
@@ -327975,6 +328270,10 @@ async function executeMobileTest(options) {
327975
328270
  log2(`Aborting remaining steps \u2014 Appium session is no longer usable: ${environmental}`);
327976
328271
  break;
327977
328272
  }
328273
+ if (status === "failed" && shouldAbortAfterStepFailure(step.action)) {
328274
+ log2(`Aborting remaining steps \u2014 ${step.action} failed, so downstream mobile steps are no longer reliable.`);
328275
+ break;
328276
+ }
327978
328277
  }
327979
328278
  const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
327980
328279
  const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
@@ -329240,7 +329539,7 @@ function detachMirrorSession(sessionId) {
329240
329539
  }
329241
329540
 
329242
329541
  // src/services/mobile/mobile-bridge-driver.ts
329243
- var import_runner_core8 = __toESM(require_dist2());
329542
+ var import_runner_core9 = __toESM(require_dist2());
329244
329543
  init_dist();
329245
329544
  var APPIUM_SESSION_ID_PATTERN2 = /^[a-f0-9-]{1,64}$/i;
329246
329545
  function readStringValue(result) {
@@ -329250,7 +329549,7 @@ function readStringValue(result) {
329250
329549
  }
329251
329550
  return void 0;
329252
329551
  }
329253
- function normalizeAndroidActivity(activity, pkg2) {
329552
+ function normalizeAndroidActivity2(activity, pkg2) {
329254
329553
  const trimmed = activity?.trim();
329255
329554
  if (!trimmed) return void 0;
329256
329555
  const packageName = pkg2?.trim();
@@ -329286,7 +329585,7 @@ var MobileBridgeDriver = class {
329286
329585
  return this.platform === "ANDROID" ? { appId: this.appId } : { bundleId: this.appId };
329287
329586
  }
329288
329587
  baseScreenSignal(source) {
329289
- const parsed = (0, import_runner_core8.parseMobileSource)(source, this.platform);
329588
+ const parsed = (0, import_runner_core9.parseMobileSource)(source, this.platform);
329290
329589
  return {
329291
329590
  platform: this.platform,
329292
329591
  screenHash: parsed.screenSignal.screenHash,
@@ -329305,11 +329604,11 @@ var MobileBridgeDriver = class {
329305
329604
  const activity = readStringValue(activityRes);
329306
329605
  const pkg2 = readStringValue(packageRes);
329307
329606
  if (pkg2) signal.bundleId = pkg2;
329308
- const normalizedActivity = normalizeAndroidActivity(activity, pkg2);
329607
+ const normalizedActivity = normalizeAndroidActivity2(activity, pkg2);
329309
329608
  if (normalizedActivity) signal.activity = normalizedActivity;
329310
329609
  } else {
329311
329610
  const infoRes = await this.executeScript("mobile: activeAppInfo", []).catch(() => void 0);
329312
- const bundleId = readActiveBundleId(infoRes);
329611
+ const bundleId = readActiveBundleId2(infoRes);
329313
329612
  if (bundleId) signal.bundleId = bundleId;
329314
329613
  }
329315
329614
  } catch {
@@ -329451,7 +329750,7 @@ var MobileBridgeDriver = class {
329451
329750
  return this.enrichScreenSignal(source);
329452
329751
  }
329453
329752
  };
329454
- function readActiveBundleId(result) {
329753
+ function readActiveBundleId2(result) {
329455
329754
  if (result && typeof result === "object" && "value" in result) {
329456
329755
  const value = result.value;
329457
329756
  if (value && typeof value === "object" && "bundleId" in value) {
@@ -329677,7 +329976,7 @@ function printDoctorReport(report) {
329677
329976
 
329678
329977
  // src/runner.ts
329679
329978
  init_dist();
329680
- var import_runner_core11 = __toESM(require_dist2());
329979
+ var import_runner_core12 = __toESM(require_dist2());
329681
329980
  function fingerprintTest(test) {
329682
329981
  const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
329683
329982
  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);
@@ -330122,7 +330421,7 @@ function pruneCollectedPageForPost(page) {
330122
330421
  if (Array.isArray(out.observations)) out.observations = out.observations.slice(-200).map((obs) => compactForAudit(obs));
330123
330422
  return out;
330124
330423
  }
330125
- function prepareDiscoveryResultForPost(result, logs2) {
330424
+ function prepareDiscoveryResultForPost(result, logs2, options = {}) {
330126
330425
  const safe = { ...result, logs: truncateTextTail(logs2, RESULT_LOG_MAX_CHARS, "discovery result log") };
330127
330426
  if (Array.isArray(safe.collectedPages)) safe.collectedPages = safe.collectedPages.map(pruneCollectedPageForPost);
330128
330427
  if (Array.isArray(safe.apiCalls)) safe.apiCalls = safe.apiCalls.map((call) => compactForAudit(call));
@@ -330138,6 +330437,10 @@ function prepareDiscoveryResultForPost(result, logs2) {
330138
330437
  if (safe.snapshotEvidence) safe.snapshotEvidence = compactForAudit(safe.snapshotEvidence);
330139
330438
  if (safe.discoveryCheckpoints) safe.discoveryCheckpoints = compactForAudit(safe.discoveryCheckpoints);
330140
330439
  if (safe.pageScreenshotKeys && safe.pageScreenshots) delete safe.pageScreenshots;
330440
+ if (options.omitScreenshots) {
330441
+ if (Array.isArray(safe.screenshots)) delete safe.screenshots;
330442
+ if (safe.pageScreenshots) delete safe.pageScreenshots;
330443
+ }
330141
330444
  if (safe.exploreAlreadySaved === true) {
330142
330445
  if (Array.isArray(safe.screenshots)) delete safe.screenshots;
330143
330446
  if (safe.pageScreenshots) delete safe.pageScreenshots;
@@ -330237,7 +330540,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
330237
330540
  const runMode = run2.mode ?? "run";
330238
330541
  const RUNNER_MODE = opts.runnerMode ?? "playwright-native";
330239
330542
  log.info(`Running test: ${run2.testCaseId ?? "auth-setup"} (run ${run2.runId}) [mode: ${runMode === "heal" ? "heal" : runMode === "auth-setup" ? "auth-setup" : RUNNER_MODE}]`);
330240
- const liveBrowserHandle = import_runner_core10.liveBrowserRegistry.register({
330543
+ const liveBrowserHandle = import_runner_core11.liveBrowserRegistry.register({
330241
330544
  sessionId: `${run2.runId}`,
330242
330545
  mode: toLiveBrowserMode(runMode),
330243
330546
  runId: run2.runId,
@@ -330246,7 +330549,10 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
330246
330549
  testCaseId: run2.testCaseId ?? void 0
330247
330550
  });
330248
330551
  requestLiveBrowserHeartbeat(true);
330249
- const liveBrowserHeartbeatInterval = setInterval(() => requestLiveBrowserHeartbeat(), 5e3);
330552
+ const liveBrowserHeartbeatInterval = setInterval(() => {
330553
+ liveBrowserHandle.patch({});
330554
+ requestLiveBrowserHeartbeat();
330555
+ }, 5e3);
330250
330556
  liveBrowserHeartbeatInterval.unref?.();
330251
330557
  const runNativeWithLiveBrowser = (nativeOptions) => (0, import_runner_core.executeTestViaNativePlaywright)({
330252
330558
  ...nativeOptions,
@@ -330273,7 +330579,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
330273
330579
  log.fail(`PRE-FLIGHT FAILED - ${preflightError}`);
330274
330580
  return;
330275
330581
  }
330276
- const preflight = await (0, import_runner_core11.resolveBaseUrlPreflight)(run2.baseUrl);
330582
+ const preflight = await (0, import_runner_core12.resolveBaseUrlPreflight)(run2.baseUrl);
330277
330583
  if (preflight.error) {
330278
330584
  await postResult(opts.serverUrl, headers, run2.runId, { status: "ERROR", error: preflight.error });
330279
330585
  log.fail(`PRE-FLIGHT FAILED - ${preflight.error}`);
@@ -330330,7 +330636,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
330330
330636
  return;
330331
330637
  }
330332
330638
  const healLogStream = createHealLogStreamer(opts.serverUrl, headers, run2.runId);
330333
- const batchResult = await (0, import_runner_core9.executeGrokPerBatchHeal)(tests, run2.baseUrl, {
330639
+ const batchResult = await (0, import_runner_core10.executeGrokPerBatchHeal)(tests, run2.baseUrl, {
330334
330640
  testVariables: run2.testVariables,
330335
330641
  runNativeTest: runNativeWithLiveBrowser,
330336
330642
  validateContext: { runId: run2.runId, serverUrl: opts.serverUrl, authHeader: headers.Authorization },
@@ -330564,7 +330870,7 @@ ${finalVerifyResult.logs ?? ""}`;
330564
330870
  if (classifyErrorMessage(composedError ?? null) === "config_issue") {
330565
330871
  apiHealStatus = "ERROR";
330566
330872
  } else {
330567
- const rlDetection = (0, import_runner_core11.detectRateLimitBlock)({
330873
+ const rlDetection = (0, import_runner_core12.detectRateLimitBlock)({
330568
330874
  testName: run2.testName ?? run2.testCaseId ?? void 0,
330569
330875
  tags: run2.tags ?? [],
330570
330876
  error: composedError ?? finalVerifyResult?.error ?? null,
@@ -330671,7 +330977,7 @@ ${finalVerifyResult.logs ?? ""}`;
330671
330977
  log.info(`Heal engine: GROK (${source})`);
330672
330978
  }
330673
330979
  const grokHealLogStream = useGrokHeal ? createHealLogStreamer(opts.serverUrl, headers, run2.runId) : null;
330674
- const result2 = useGrokHeal ? await (0, import_runner_core9.executeGrokPerTestHeal)(run2.playwrightCode, run2.steps, run2.baseUrl, {
330980
+ const result2 = useGrokHeal ? await (0, import_runner_core10.executeGrokPerTestHeal)(run2.playwrightCode, run2.steps, run2.baseUrl, {
330675
330981
  ...healOpts,
330676
330982
  onLog: grokHealLogStream.onLog
330677
330983
  }) : await (0, import_runner_core2.executeTestWithHealing)(run2.playwrightCode, run2.steps, run2.baseUrl, healOpts);
@@ -330704,7 +331010,7 @@ ${finalVerifyResult.logs ?? ""}`;
330704
331010
  let healStatus = result2.passed ? "PASSED" : isConfigIssue ? "ERROR" : "FAILED";
330705
331011
  let healError = result2.error;
330706
331012
  if (!result2.passed && !isConfigIssue) {
330707
- const rlDetection = (0, import_runner_core11.detectRateLimitBlock)({
331013
+ const rlDetection = (0, import_runner_core12.detectRateLimitBlock)({
330708
331014
  testName: run2.testName ?? run2.testCaseId ?? void 0,
330709
331015
  tags: run2.tags ?? [],
330710
331016
  error: result2.error,
@@ -330898,7 +331204,7 @@ ${finalVerifyResult.logs ?? ""}`;
330898
331204
  liveBrowserHandle,
330899
331205
  runNativeTest: runNativeWithLiveBrowser
330900
331206
  };
330901
- const healResult = useGrokHealSuite ? await (0, import_runner_core9.executeGrokPerTestHeal)(
331207
+ const healResult = useGrokHealSuite ? await (0, import_runner_core10.executeGrokPerTestHeal)(
330902
331208
  failedTest.playwrightCode,
330903
331209
  failedTest.steps ?? [],
330904
331210
  run2.baseUrl,
@@ -331186,9 +331492,16 @@ ${finalVerifyResult.logs ?? ""}`;
331186
331492
  signal: AbortSignal.timeout(3e4)
331187
331493
  });
331188
331494
  if (res.ok) return;
331189
- log.warn(`[stream] discovery-plan attempt ${attempt + 1} returned ${res.status}`);
331495
+ const responseText = await res.text().catch(() => "");
331496
+ const statusMessage = responseText ? `${res.status}: ${responseText.slice(0, 500)}` : String(res.status);
331497
+ if (res.status === 409) {
331498
+ throw new Error(`Discovery plan checkpoint rejected: ${statusMessage}`);
331499
+ }
331500
+ log.warn(`[stream] discovery-plan attempt ${attempt + 1} returned ${statusMessage}`);
331190
331501
  } catch (err) {
331191
- log.warn(`[stream] discovery-plan attempt ${attempt + 1} failed: ${err instanceof Error ? err.message : String(err)}`);
331502
+ const message = err instanceof Error ? err.message : String(err);
331503
+ if (/Discovery plan checkpoint rejected/i.test(message)) throw err;
331504
+ log.warn(`[stream] discovery-plan attempt ${attempt + 1} failed: ${message}`);
331192
331505
  }
331193
331506
  if (attempt < 2) await new Promise((r) => setTimeout(r, 3e3 * (attempt + 1)));
331194
331507
  }
@@ -331196,6 +331509,7 @@ ${finalVerifyResult.logs ?? ""}`;
331196
331509
  };
331197
331510
  let discoveryResult;
331198
331511
  const buildFinalBody = (r, overrideError, auditWarning) => {
331512
+ const isNativeDiscovery = ctx.medium === "IOS_NATIVE" || ctx.medium === "ANDROID_NATIVE";
331199
331513
  const { exploreFinalMessages: _exploreFinalMessages, ...rest } = r;
331200
331514
  void _exploreFinalMessages;
331201
331515
  const logs2 = auditWarning ? [rest.logs, auditWarning].filter(Boolean).join("\n") : rest.logs;
@@ -331245,7 +331559,7 @@ ${finalVerifyResult.logs ?? ""}`;
331245
331559
  resolvedAppOrigin: rest.resolvedAppOrigin,
331246
331560
  plans: rest.plans,
331247
331561
  exploreStats: rest.exploreStats,
331248
- screenshots: rest.screenshots,
331562
+ ...isNativeDiscovery ? {} : { screenshots: rest.screenshots },
331249
331563
  snapshotEvidence: rest.snapshotEvidence,
331250
331564
  discoveryCheckpoints: rest.discoveryCheckpoints,
331251
331565
  streamedTestCount: streamedTestKeys.size,
@@ -331254,8 +331568,10 @@ ${finalVerifyResult.logs ?? ""}`;
331254
331568
  ...explorePosted ? {} : {
331255
331569
  collectedPages: rest.collectedPages,
331256
331570
  collectedTransitions: rest.collectedTransitions,
331257
- pageScreenshots: rest.pageScreenshots,
331258
- pageScreenshotKeys: rest.pageScreenshotKeys
331571
+ ...isNativeDiscovery ? {} : {
331572
+ pageScreenshots: rest.pageScreenshots,
331573
+ pageScreenshotKeys: rest.pageScreenshotKeys
331574
+ }
331259
331575
  },
331260
331576
  ...harPosted ? {} : {
331261
331577
  apiCalls: rest.apiCalls,
@@ -331263,7 +331579,7 @@ ${finalVerifyResult.logs ?? ""}`;
331263
331579
  },
331264
331580
  // Only send tests that were NOT confirmed streamed to avoid duplicates
331265
331581
  tests: rest.tests.filter((t) => !streamedTestKeys.has(fingerprintTest(t)))
331266
- }, logs2);
331582
+ }, logs2, { omitScreenshots: isNativeDiscovery });
331267
331583
  };
331268
331584
  const acquireLoginBrowser = async () => {
331269
331585
  const browser = await browserPool.acquireAsync(12e3);
@@ -331367,7 +331683,6 @@ ${finalVerifyResult.logs ?? ""}`;
331367
331683
  thumbnailCapturedAt: Date.now()
331368
331684
  });
331369
331685
  requestLiveBrowserHeartbeat();
331370
- void onScreenshot("mobile", base64);
331371
331686
  },
331372
331687
  onExploreComplete,
331373
331688
  onTestGenerated,
@@ -331394,7 +331709,7 @@ ${finalVerifyResult.logs ?? ""}`;
331394
331709
  setExecutorBusy(false);
331395
331710
  }
331396
331711
  } else if (ctx.mode === "import") {
331397
- const runRepoImport = ctx.frameworkImport?.kind === "repo-analysis" ? import_runner_core9.runRepoAnalysisOnRunner : import_runner_core9.runFrameworkImportOnRunner;
331712
+ const runRepoImport = ctx.frameworkImport?.kind === "repo-analysis" ? import_runner_core10.runRepoAnalysisOnRunner : import_runner_core10.runFrameworkImportOnRunner;
331398
331713
  discoveryResult = await runRepoImport(ctx, { onTestGenerated, acquireLoginBrowser, onAudit: discoveryAudit }, {
331399
331714
  archiveUrl: ctx.frameworkImport?.source === "upload" ? `${opts.serverUrl}/api/runner/runs/${run2.runId}/import-archive` : void 0,
331400
331715
  archiveAuthHeader: headers.Authorization,
@@ -331724,7 +332039,7 @@ ${finalVerifyResult.logs ?? ""}`;
331724
332039
  let runStatus = result.passed ? "PASSED" : "FAILED";
331725
332040
  let runError = result.error;
331726
332041
  if (!result.passed) {
331727
- const rlDetection = (0, import_runner_core11.detectRateLimitBlock)({
332042
+ const rlDetection = (0, import_runner_core12.detectRateLimitBlock)({
331728
332043
  testName: run2.testName ?? run2.testCaseId ?? void 0,
331729
332044
  tags: run2.tags ?? [],
331730
332045
  error: result.error,
@@ -331862,7 +332177,7 @@ async function startRunner(opts) {
331862
332177
  capture: envInt("MAX_CAPTURE", DEFAULT_TYPE_LIMITS.capture),
331863
332178
  run: envInt("MAX_RUN", DEFAULT_TYPE_LIMITS.run)
331864
332179
  };
331865
- const RESOURCE_GOVERNOR_OPTIONS = (0, import_runner_core11.createResourceGovernorOptions)({
332180
+ const RESOURCE_GOVERNOR_OPTIONS = (0, import_runner_core12.createResourceGovernorOptions)({
331866
332181
  isCloud: IS_CLOUD,
331867
332182
  maxConcurrent: MAX_CONCURRENT,
331868
332183
  typeLimits: TYPE_LIMITS
@@ -332013,8 +332328,8 @@ async function startRunner(opts) {
332013
332328
  android: devices.some((d) => d.platform === "ANDROID" && d.isAvailable && d.state === "BOOTED")
332014
332329
  } : { web: true, ios: false, android: false };
332015
332330
  const bridge = enableMobile ? getBridgeState() : void 0;
332016
- const resourceStatus = (0, import_runner_core11.getResourceGovernorStatus)(RESOURCE_GOVERNOR_OPTIONS, Object.fromEntries(activeByType));
332017
- const processInventory = await (0, import_runner_core11.getBrowserProcessInventory)();
332331
+ const resourceStatus = (0, import_runner_core12.getResourceGovernorStatus)(RESOURCE_GOVERNOR_OPTIONS, Object.fromEntries(activeByType));
332332
+ const processInventory = await (0, import_runner_core12.getBrowserProcessInventory)();
332018
332333
  lastInventoryPids = new Set(processInventory.processes.map((p) => p.pid));
332019
332334
  lastInventoryKinds.clear();
332020
332335
  for (const p of processInventory.processes) lastInventoryKinds.set(p.pid, p.kind);
@@ -332041,8 +332356,8 @@ async function startRunner(opts) {
332041
332356
  resourceSnapshot: resourceStatus.snapshot,
332042
332357
  resourceGovernor: resourceStatus.governor,
332043
332358
  processInventory,
332044
- aiQueue: (0, import_runner_core11.getRunnerAIQueueStats)(),
332045
- liveBrowsers: import_runner_core10.liveBrowserRegistry.snapshotForHeartbeat(),
332359
+ aiQueue: (0, import_runner_core12.getRunnerAIQueueStats)(),
332360
+ liveBrowsers: import_runner_core11.liveBrowserRegistry.snapshotForHeartbeat(),
332046
332361
  // Pre-flight checks (ANDROID_HOME, Xcode, Appium drivers, adb, etc).
332047
332362
  // Cached & cheap — see getCachedDoctor above.
332048
332363
  doctor: getCachedDoctor()
@@ -332136,7 +332451,7 @@ async function startRunner(opts) {
332136
332451
  let pidsWatchdogWarned = false;
332137
332452
  const pidsWatchdog = setInterval(() => {
332138
332453
  if (shuttingDown) return;
332139
- const snapshot = (0, import_runner_core11.getResourceSnapshot)();
332454
+ const snapshot = (0, import_runner_core12.getResourceSnapshot)();
332140
332455
  const pct = snapshot.pidsPct;
332141
332456
  if (pct == null || !Number.isFinite(pct)) return;
332142
332457
  if (pct >= PIDS_RECYCLE_PCT) {
@@ -332172,7 +332487,7 @@ async function startRunner(opts) {
332172
332487
  log.dim(` Deferred ${run2.runId} (project ${runProject} at capacity ${getActiveForProject(runProject)}/${projectLimit})`);
332173
332488
  continue;
332174
332489
  }
332175
- const admission = (0, import_runner_core11.evaluateRunAdmission)(RESOURCE_GOVERNOR_OPTIONS, {
332490
+ const admission = (0, import_runner_core12.evaluateRunAdmission)(RESOURCE_GOVERNOR_OPTIONS, {
332176
332491
  mode: runMode,
332177
332492
  activeRuns,
332178
332493
  activeByType: Object.fromEntries(activeByType),