@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.mjs CHANGED
@@ -1042,6 +1042,65 @@ var require_mobile_source_parser = __commonJS({
1042
1042
  }
1043
1043
  });
1044
1044
 
1045
+ // ../runner-core/dist/services/mobile/mobile-boundary.js
1046
+ var require_mobile_boundary = __commonJS({
1047
+ "../runner-core/dist/services/mobile/mobile-boundary.js"(exports) {
1048
+ "use strict";
1049
+ Object.defineProperty(exports, "__esModule", { value: true });
1050
+ exports.mobileOwnerFromScreenSignal = mobileOwnerFromScreenSignal;
1051
+ exports.isTransientMobileSystemOwner = isTransientMobileSystemOwner;
1052
+ exports.classifyMobileScreenBoundary = classifyMobileScreenBoundary2;
1053
+ exports.isTargetLikeMobileBoundary = isTargetLikeMobileBoundary2;
1054
+ exports.mobileBoundaryNote = mobileBoundaryNote;
1055
+ var ANDROID_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
1056
+ "com.android.permissioncontroller",
1057
+ "com.google.android.permissioncontroller",
1058
+ "com.android.packageinstaller",
1059
+ "com.google.android.packageinstaller"
1060
+ ]);
1061
+ var IOS_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
1062
+ "com.apple.springboard"
1063
+ ]);
1064
+ function mobileOwnerFromScreenSignal(signal, targetAppId) {
1065
+ const bundleId = signal.bundleId?.trim();
1066
+ if (bundleId)
1067
+ return bundleId;
1068
+ const activity = signal.activity?.trim();
1069
+ if (activity && targetAppId && (activity === targetAppId || activity.startsWith(`${targetAppId}.`))) {
1070
+ return targetAppId;
1071
+ }
1072
+ return null;
1073
+ }
1074
+ function isTransientMobileSystemOwner(owner, platform3) {
1075
+ return platform3 === "ANDROID" ? ANDROID_TRANSIENT_SYSTEM_APP_IDS.has(owner) : IOS_TRANSIENT_SYSTEM_APP_IDS.has(owner);
1076
+ }
1077
+ function classifyMobileScreenBoundary2(signal, targetAppId, platform3) {
1078
+ if (!targetAppId)
1079
+ return { kind: "target" };
1080
+ const owner = mobileOwnerFromScreenSignal(signal, targetAppId);
1081
+ if (!owner)
1082
+ return { kind: "unknown" };
1083
+ if (owner === targetAppId)
1084
+ return { kind: "target" };
1085
+ if (isTransientMobileSystemOwner(owner, platform3)) {
1086
+ return { kind: "transient_external", owner, targetAppId };
1087
+ }
1088
+ return { kind: "external", owner, targetAppId };
1089
+ }
1090
+ function isTargetLikeMobileBoundary2(boundary) {
1091
+ return boundary.kind === "target" || boundary.kind === "unknown";
1092
+ }
1093
+ function mobileBoundaryNote(boundary) {
1094
+ return [
1095
+ "",
1096
+ "## App Boundary",
1097
+ `Foreground app is ${boundary.owner}, outside target app ${boundary.targetAppId}.`,
1098
+ 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."
1099
+ ].join("\n");
1100
+ }
1101
+ }
1102
+ });
1103
+
1045
1104
  // ../shared/dist/types.js
1046
1105
  function isUIVariant(value) {
1047
1106
  return typeof value === "string" && UI_VARIANTS.includes(value);
@@ -1477,6 +1536,18 @@ function buildAppiumCode(testName, target, steps) {
1477
1536
  ` }`,
1478
1537
  ` throw new Error('No locator resolved. Tried: ' + JSON.stringify(selectors) + (__lastError ? ' (last error: ' + String(__lastError) + ')' : ''));`,
1479
1538
  `}`,
1539
+ ``,
1540
+ `async function __tapAt(driver: WebdriverIO.Browser, x: number, y: number) {`,
1541
+ ` 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 }] }]);`,
1542
+ `}`,
1543
+ ``,
1544
+ `async function __typeIntoFocused(driver: WebdriverIO.Browser, text: string) {`,
1545
+ ` try {`,
1546
+ ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).addValue(text);`,
1547
+ ` } catch {`,
1548
+ ` await driver.keys(text);`,
1549
+ ` }`,
1550
+ `}`,
1480
1551
  ...hasSwipe ? [
1481
1552
  ``,
1482
1553
  `// Read the device window with a few retries (matches the runtime executor).`,
@@ -1535,13 +1606,25 @@ function buildAppiumCode(testName, target, steps) {
1535
1606
  const y = Math.round(bounds.y + bounds.height / 2);
1536
1607
  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 }] }]);`);
1537
1608
  } else if (step.action === "type" && hasSelector) {
1538
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).addValue(${toJsStringLiteral(step.value ?? "")});`);
1609
+ if (bounds) {
1610
+ const x = Math.round(bounds.x + bounds.width / 2);
1611
+ const y = Math.round(bounds.y + bounds.height / 2);
1612
+ lines.push(` try {`);
1613
+ lines.push(` await (await findFirst(driver, ${candidatesLiteral})).addValue(${toJsStringLiteral(step.value ?? "")});`);
1614
+ lines.push(` } catch {`);
1615
+ lines.push(` await __tapAt(driver, ${x}, ${y});`);
1616
+ lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1617
+ lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1618
+ lines.push(` }`);
1619
+ } else {
1620
+ lines.push(` await (await findFirst(driver, ${candidatesLiteral})).addValue(${toJsStringLiteral(step.value ?? "")});`);
1621
+ }
1539
1622
  } else if (step.action === "type" && bounds) {
1540
1623
  const x = Math.round(bounds.x + bounds.width / 2);
1541
1624
  const y = Math.round(bounds.y + bounds.height / 2);
1542
- 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 }] }]);`);
1625
+ lines.push(` await __tapAt(driver, ${x}, ${y});`);
1543
1626
  lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1544
- lines.push(` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).addValue(${toJsStringLiteral(step.value ?? "")});`);
1627
+ lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1545
1628
  } else if (step.action === "clear" && hasSelector) {
1546
1629
  lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
1547
1630
  } else if (step.action === "assertVisible" && hasSelector) {
@@ -4438,21 +4521,13 @@ var require_mobile_explorer = __commonJS({
4438
4521
  var ai_retry_js_1 = require_ai_retry();
4439
4522
  var mobile_source_parser_js_1 = require_mobile_source_parser();
4440
4523
  var mobile_observation_js_1 = require_mobile_observation();
4524
+ var mobile_boundary_js_1 = require_mobile_boundary();
4441
4525
  var MAX_MOBILE_ITERATIONS_SURVEY = 40;
4442
4526
  var MAX_MOBILE_ITERATIONS_DEEP = 60;
4443
4527
  var MAX_SCREENSHOTS = 15;
4444
4528
  var MAX_RECENT_EXCHANGES = 24;
4445
4529
  var OBSERVATION_MAX_ELEMENTS = 50;
4446
4530
  var NO_SNAPSHOT_NUDGE_THRESHOLD = 4;
4447
- var ANDROID_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
4448
- "com.android.permissioncontroller",
4449
- "com.google.android.permissioncontroller",
4450
- "com.android.packageinstaller",
4451
- "com.google.android.packageinstaller"
4452
- ]);
4453
- var IOS_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
4454
- "com.apple.springboard"
4455
- ]);
4456
4531
  var MOBILE_SNAPSHOT_TOOL = {
4457
4532
  type: "function",
4458
4533
  function: {
@@ -4675,40 +4750,6 @@ var require_mobile_explorer = __commonJS({
4675
4750
  return "timeout";
4676
4751
  return "ai_error";
4677
4752
  }
4678
- function ownerFromScreenSignal(signal, targetAppId) {
4679
- const bundleId = signal.bundleId?.trim();
4680
- if (bundleId)
4681
- return bundleId;
4682
- const activity = signal.activity?.trim();
4683
- if (activity && targetAppId && (activity === targetAppId || activity.startsWith(`${targetAppId}.`))) {
4684
- return targetAppId;
4685
- }
4686
- return null;
4687
- }
4688
- function isTransientSystemOwner(owner, platform3) {
4689
- return platform3 === "ANDROID" ? ANDROID_TRANSIENT_SYSTEM_APP_IDS.has(owner) : IOS_TRANSIENT_SYSTEM_APP_IDS.has(owner);
4690
- }
4691
- function classifyMobileScreenBoundary(signal, targetAppId, platform3) {
4692
- if (!targetAppId)
4693
- return { kind: "target" };
4694
- const owner = ownerFromScreenSignal(signal, targetAppId);
4695
- if (!owner)
4696
- return { kind: "unknown" };
4697
- if (owner === targetAppId)
4698
- return { kind: "target" };
4699
- if (isTransientSystemOwner(owner, platform3)) {
4700
- return { kind: "transient_external", owner, targetAppId };
4701
- }
4702
- return { kind: "external", owner, targetAppId };
4703
- }
4704
- function boundaryNote(boundary) {
4705
- return [
4706
- "",
4707
- "## App Boundary",
4708
- `Foreground app is ${boundary.owner}, outside target app ${boundary.targetAppId}.`,
4709
- 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."
4710
- ].join("\n");
4711
- }
4712
4753
  function resolveSnapshot(xml, windowSize, driverSignal, platform3) {
4713
4754
  const parsed = (0, mobile_source_parser_js_1.parseMobileSource)(xml, platform3);
4714
4755
  const signal = {
@@ -4906,10 +4947,10 @@ Use this to prioritize which screens and flows to cover.` : "";
4906
4947
  };
4907
4948
  const currentBoundary = () => {
4908
4949
  const current = getLatest();
4909
- return current ? classifyMobileScreenBoundary(current.signal, targetAppId, platform3) : { kind: "unknown" };
4950
+ return current ? (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(current.signal, targetAppId, platform3) : { kind: "unknown" };
4910
4951
  };
4911
4952
  const absorbResolvedObservation = async (resolved, screenshot, iterationForObservation, source) => {
4912
- const boundary = classifyMobileScreenBoundary(resolved.signal, targetAppId, platform3);
4953
+ const boundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(resolved.signal, targetAppId, platform3);
4913
4954
  if (boundary.kind === "target" || boundary.kind === "unknown") {
4914
4955
  const screenId = ingestSnapshot(resolved, iterationForObservation);
4915
4956
  captureScreenshot(screenshot, screenId);
@@ -4920,7 +4961,7 @@ Use this to prioritize which screens and flows to cover.` : "";
4920
4961
  if (boundary.kind === "transient_external") {
4921
4962
  log2(` System overlay detected from ${source}: ${boundary.owner}; not adding it to target-app survey evidence.`);
4922
4963
  return {
4923
- observation: `${resolved.observation}${boundaryNote(boundary)}`,
4964
+ observation: `${resolved.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(boundary)}`,
4924
4965
  screenId: null,
4925
4966
  transientExternal: true,
4926
4967
  externalOwner: boundary.owner
@@ -4939,7 +4980,7 @@ Use this to prioritize which screens and flows to cover.` : "";
4939
4980
  externalOwner: boundary.owner
4940
4981
  };
4941
4982
  }
4942
- const recoveredBoundary = classifyMobileScreenBoundary(recovered.signal, targetAppId, platform3);
4983
+ const recoveredBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(recovered.signal, targetAppId, platform3);
4943
4984
  latest = recovered;
4944
4985
  lastSnapshotIteration = iterationForObservation;
4945
4986
  if (recoveredBoundary.kind === "target" || recoveredBoundary.kind === "unknown") {
@@ -4955,7 +4996,7 @@ Use this to prioritize which screens and flows to cover.` : "";
4955
4996
  if (recoveredBoundary.kind === "transient_external") {
4956
4997
  log2(` Relaunch surfaced system overlay ${recoveredBoundary.owner}; not adding it to target-app survey evidence.`);
4957
4998
  return {
4958
- observation: `${recovered.observation}${boundaryNote(recoveredBoundary)}`,
4999
+ observation: `${recovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(recoveredBoundary)}`,
4959
5000
  screenId: null,
4960
5001
  externalBlocked: true,
4961
5002
  transientExternal: true,
@@ -8599,6 +8640,7 @@ var require_mobile_generator = __commonJS({
8599
8640
  var mobile_source_parser_js_1 = require_mobile_source_parser();
8600
8641
  var mobile_source_parser_js_2 = require_mobile_source_parser();
8601
8642
  var mobile_observation_js_1 = require_mobile_observation();
8643
+ var mobile_boundary_js_1 = require_mobile_boundary();
8602
8644
  var MAX_SCENARIO_ITERATIONS = 30;
8603
8645
  var MAX_RECENT_EXCHANGES = 24;
8604
8646
  var OBSERVATION_MAX_ELEMENTS = 50;
@@ -8784,8 +8826,9 @@ var require_mobile_generator = __commonJS({
8784
8826
  function hasStableId(element) {
8785
8827
  return !!nonEmpty2(element.accessibilityId) || !!nonEmpty2(element.resourceId);
8786
8828
  }
8787
- function buildSystemPrompt(platform3) {
8829
+ function buildSystemPrompt(platform3, targetAppId) {
8788
8830
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
8831
+ const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
8789
8832
  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.
8790
8833
 
8791
8834
  ## How the loop works
@@ -8805,7 +8848,8 @@ var require_mobile_generator = __commonJS({
8805
8848
  - Re-snapshot before every assertion so the ref is fresh.
8806
8849
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
8807
8850
  - Do NOT trigger irreversible side effects (real payments, sending messages to others) \u2014 note them and assert what you safely can.
8808
- - Stay inside this app. Finish promptly once the outcome is proven.
8851
+ - 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.
8852
+ - Finish promptly once the outcome is proven.
8809
8853
 
8810
8854
  Max iterations: ${MAX_SCENARIO_ITERATIONS}. You will be warned near the end.`;
8811
8855
  }
@@ -8878,7 +8922,7 @@ Start by calling mobile_snapshot, then execute the steps. Finish with at least o
8878
8922
  }
8879
8923
  }
8880
8924
  async function runScenarioLoop(args) {
8881
- const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, log: log2 } = args;
8925
+ const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, log: log2 } = args;
8882
8926
  const actions = [];
8883
8927
  let assertCount = 0;
8884
8928
  let finish = null;
@@ -8892,14 +8936,98 @@ Start by calling mobile_snapshot, then execute the steps. Finish with at least o
8892
8936
  const fromActivity = signal.activity ? signal.activity.split(".").pop() : void 0;
8893
8937
  return nonEmpty2(fromActivity) ?? nonEmpty2(signal.bundleId) ?? `screen-${signal.screenHash.slice(0, 8)}`;
8894
8938
  };
8895
- const ingest = (xml, driverSignal, windowSize) => {
8896
- if (!xml)
8897
- return null;
8898
- const resolved = resolveSnapshot(xml, windowSize ?? latest?.screen.windowSize ?? null, driverSignal, platform3);
8939
+ const setLatest = (resolved) => {
8899
8940
  latest = resolved;
8900
8941
  lastScreenName = screenNameFor(resolved);
8901
8942
  return resolved;
8902
8943
  };
8944
+ const buildResolved = (xml, driverSignal, windowSize) => {
8945
+ if (!xml)
8946
+ return null;
8947
+ return resolveSnapshot(xml, windowSize ?? latest?.screen.windowSize ?? null, driverSignal, platform3);
8948
+ };
8949
+ const emitScreenshot = (base64) => {
8950
+ if (!base64)
8951
+ return;
8952
+ try {
8953
+ onScreenshot?.(base64);
8954
+ } catch {
8955
+ }
8956
+ };
8957
+ const absorbResolvedObservation = async (resolved, screenshot, source) => {
8958
+ const boundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(resolved.signal, targetAppId, platform3);
8959
+ if ((0, mobile_boundary_js_1.isTargetLikeMobileBoundary)(boundary)) {
8960
+ setLatest(resolved);
8961
+ emitScreenshot(screenshot);
8962
+ return { resolved, observation: resolved.observation };
8963
+ }
8964
+ setLatest(resolved);
8965
+ if (boundary.kind === "transient_external") {
8966
+ log2(` system overlay detected from ${source}: ${boundary.owner}; not recording it as a target-app step.`);
8967
+ return {
8968
+ resolved,
8969
+ observation: `${resolved.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(boundary)}`,
8970
+ transientExternal: true,
8971
+ externalOwner: boundary.owner
8972
+ };
8973
+ }
8974
+ log2(` external app detected from ${source}: ${boundary.owner}; rejecting the action and relaunching ${boundary.targetAppId}.`);
8975
+ try {
8976
+ await driver.relaunch();
8977
+ const snap = await driver.snapshot();
8978
+ const recovered = buildResolved(snap.xml, snap.screenSignal, snap.windowSize);
8979
+ if (!recovered) {
8980
+ return {
8981
+ resolved: null,
8982
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunched target app, but no UI tree was returned)`,
8983
+ externalBlocked: true,
8984
+ externalOwner: boundary.owner
8985
+ };
8986
+ }
8987
+ const recoveredBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(recovered.signal, targetAppId, platform3);
8988
+ setLatest(recovered);
8989
+ if ((0, mobile_boundary_js_1.isTargetLikeMobileBoundary)(recoveredBoundary)) {
8990
+ emitScreenshot(snap.screenshot);
8991
+ return {
8992
+ resolved: recovered,
8993
+ observation: recovered.observation,
8994
+ externalBlocked: true,
8995
+ externalOwner: boundary.owner
8996
+ };
8997
+ }
8998
+ if (recoveredBoundary.kind === "transient_external") {
8999
+ log2(` relaunch surfaced system overlay ${recoveredBoundary.owner}; not recording it as a target-app step.`);
9000
+ return {
9001
+ resolved: recovered,
9002
+ observation: `${recovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(recoveredBoundary)}`,
9003
+ externalBlocked: true,
9004
+ transientExternal: true,
9005
+ externalOwner: boundary.owner
9006
+ };
9007
+ }
9008
+ log2(` relaunch still outside target app (${recoveredBoundary.owner}); no target-app step recorded.`);
9009
+ return {
9010
+ resolved: recovered,
9011
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no target-app step was recorded)`,
9012
+ externalBlocked: true,
9013
+ externalOwner: boundary.owner
9014
+ };
9015
+ } catch (err) {
9016
+ const message = err instanceof Error ? err.message : String(err);
9017
+ return {
9018
+ resolved: null,
9019
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; failed to relaunch target app: ${message})`,
9020
+ externalBlocked: true,
9021
+ externalOwner: boundary.owner
9022
+ };
9023
+ }
9024
+ };
9025
+ const absorbObservation = async (xml, driverSignal, windowSize, screenshot, source) => {
9026
+ const resolved = buildResolved(xml, driverSignal, windowSize);
9027
+ if (!resolved)
9028
+ return { resolved: null, observation: null };
9029
+ return absorbResolvedObservation(resolved, screenshot, source);
9030
+ };
8903
9031
  const resolveRef = (ref) => {
8904
9032
  if (typeof ref !== "string" || !latest)
8905
9033
  return null;
@@ -8907,11 +9035,11 @@ Start by calling mobile_snapshot, then execute the steps. Finish with at least o
8907
9035
  };
8908
9036
  try {
8909
9037
  const seed = await driver.snapshot();
8910
- ingest(seed.xml, seed.screenSignal, seed.windowSize);
9038
+ await absorbObservation(seed.xml, seed.screenSignal, seed.windowSize, seed.screenshot, "seed snapshot");
8911
9039
  } catch (err) {
8912
9040
  log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
8913
9041
  }
8914
- const systemPrompt = buildSystemPrompt(platform3);
9042
+ const systemPrompt = buildSystemPrompt(platform3, targetAppId);
8915
9043
  const kickoff = buildScenarioKickoff(scenario);
8916
9044
  const recentExchanges = [];
8917
9045
  let iteration = 0;
@@ -9022,7 +9150,7 @@ ${statePacket}` },
9022
9150
  driver,
9023
9151
  platform: platform3,
9024
9152
  latest: getLatest,
9025
- ingest,
9153
+ absorbObservation,
9026
9154
  resolveRef,
9027
9155
  screenName: () => lastScreenName || "UnknownScreen",
9028
9156
  recordAction: (action) => {
@@ -9045,60 +9173,85 @@ ${statePacket}` },
9045
9173
  return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
9046
9174
  }
9047
9175
  async function dispatchGenerateTool(args) {
9048
- const { toolName, toolArgs, driver, platform: platform3, latest, ingest, resolveRef, screenName, recordAction, log: log2 } = args;
9176
+ const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, screenName, recordAction, log: log2 } = args;
9049
9177
  const unknownRef = (ref) => ({
9050
9178
  ok: false,
9051
9179
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
9052
9180
  });
9181
+ const actionPayload = (result, absorbed) => {
9182
+ const blocked = Boolean(absorbed.externalBlocked || absorbed.transientExternal);
9183
+ return {
9184
+ ok: result.ok && !blocked,
9185
+ ...result.error ? { error: result.error } : {},
9186
+ ...blocked ? { error: "Action left the target app and was not recorded. Continue from the relaunched target app." } : {},
9187
+ observation: absorbed.observation ?? void 0,
9188
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
9189
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
9190
+ };
9191
+ };
9192
+ const shouldRecordAction = (result, absorbed) => result.ok && !absorbed.externalBlocked && !absorbed.transientExternal;
9053
9193
  switch (toolName) {
9054
9194
  case "mobile_snapshot": {
9055
9195
  const snap = await driver.snapshot();
9056
- const resolved = ingest(snap.xml, snap.screenSignal, snap.windowSize);
9057
- if (!resolved)
9196
+ const absorbed = await absorbObservation(snap.xml, snap.screenSignal, snap.windowSize, snap.screenshot, "mobile_snapshot");
9197
+ if (!absorbed.resolved)
9058
9198
  return { ok: false, error: "snapshot returned no UI tree" };
9059
- log2(` snapshot: ${resolved.screen.elements.length} elements`);
9060
- return { observation: resolved.observation };
9199
+ log2(` snapshot: ${absorbed.resolved.screen.elements.length} elements`);
9200
+ return {
9201
+ observation: absorbed.observation,
9202
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
9203
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
9204
+ };
9061
9205
  }
9062
9206
  case "mobile_tap": {
9063
9207
  const element = resolveRef(toolArgs.ref);
9064
9208
  if (!element)
9065
9209
  return unknownRef(toolArgs.ref);
9210
+ const beforeScreenName = screenName();
9066
9211
  const result = await driver.tap(element.bounds);
9067
- recordAction({
9068
- type: "TAP",
9069
- selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9070
- metadata: metadataForElement(element, screenName(), platform3)
9071
- });
9072
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
9073
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9212
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_tap");
9213
+ if (shouldRecordAction(result, absorbed)) {
9214
+ recordAction({
9215
+ type: "TAP",
9216
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9217
+ metadata: metadataForElement(element, beforeScreenName, platform3)
9218
+ });
9219
+ }
9220
+ return actionPayload(result, absorbed);
9074
9221
  }
9075
9222
  case "mobile_type": {
9076
9223
  const element = resolveRef(toolArgs.ref);
9077
9224
  if (!element)
9078
9225
  return unknownRef(toolArgs.ref);
9079
9226
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
9227
+ const beforeScreenName = screenName();
9080
9228
  const result = await driver.type(value, element.bounds);
9081
- recordAction({
9082
- type: "TYPE",
9083
- value,
9084
- selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9085
- metadata: metadataForElement(element, screenName(), platform3)
9086
- });
9087
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
9088
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9229
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_type");
9230
+ if (shouldRecordAction(result, absorbed)) {
9231
+ recordAction({
9232
+ type: "TYPE",
9233
+ value,
9234
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9235
+ metadata: metadataForElement(element, beforeScreenName, platform3)
9236
+ });
9237
+ }
9238
+ return actionPayload(result, absorbed);
9089
9239
  }
9090
9240
  case "mobile_clear": {
9091
9241
  const element = resolveRef(toolArgs.ref);
9092
9242
  if (!element)
9093
9243
  return unknownRef(toolArgs.ref);
9244
+ const beforeScreenName = screenName();
9094
9245
  const result = await driver.clear(element.bounds);
9095
- recordAction({
9096
- type: "CLEAR",
9097
- selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9098
- metadata: metadataForElement(element, screenName(), platform3)
9099
- });
9100
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
9101
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9246
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_clear");
9247
+ if (shouldRecordAction(result, absorbed)) {
9248
+ recordAction({
9249
+ type: "CLEAR",
9250
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9251
+ metadata: metadataForElement(element, beforeScreenName, platform3)
9252
+ });
9253
+ }
9254
+ return actionPayload(result, absorbed);
9102
9255
  }
9103
9256
  case "mobile_swipe": {
9104
9257
  const direction = toolArgs.direction;
@@ -9106,30 +9259,42 @@ ${statePacket}` },
9106
9259
  return { ok: false, error: "direction must be one of up|down|left|right" };
9107
9260
  }
9108
9261
  const distance = typeof toolArgs.distance === "number" ? toolArgs.distance : void 0;
9262
+ const beforeScreenName = screenName();
9109
9263
  const result = await driver.swipe(direction, distance);
9110
- recordAction({
9111
- type: "SWIPE",
9112
- metadata: {
9113
- screenName: screenName(),
9114
- direction,
9115
- ...distance !== void 0 ? { distance } : {}
9116
- }
9117
- });
9118
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
9119
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9264
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_swipe");
9265
+ if (shouldRecordAction(result, absorbed)) {
9266
+ recordAction({
9267
+ type: "SWIPE",
9268
+ metadata: {
9269
+ screenName: beforeScreenName,
9270
+ direction,
9271
+ ...distance !== void 0 ? { distance } : {}
9272
+ }
9273
+ });
9274
+ }
9275
+ return actionPayload(result, absorbed);
9120
9276
  }
9121
9277
  case "mobile_back": {
9278
+ const beforeScreenName = screenName();
9122
9279
  const result = await driver.back();
9123
- recordAction({ type: "BACK", metadata: { screenName: screenName() } });
9124
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
9125
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9280
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_back");
9281
+ if (shouldRecordAction(result, absorbed)) {
9282
+ recordAction({ type: "BACK", metadata: { screenName: beforeScreenName } });
9283
+ }
9284
+ return actionPayload(result, absorbed);
9126
9285
  }
9127
9286
  case "mobile_relaunch": {
9128
9287
  await driver.relaunch();
9129
9288
  const snap = await driver.snapshot();
9130
- const resolved = ingest(snap.xml, snap.screenSignal, snap.windowSize);
9289
+ const absorbed = await absorbObservation(snap.xml, snap.screenSignal, snap.windowSize, snap.screenshot, "mobile_relaunch");
9131
9290
  log2(" relaunched app for hermetic reset.");
9132
- return { ok: true, observation: resolved?.observation, note: "App relaunched (clean state). Not recorded as a step." };
9291
+ return {
9292
+ ok: true,
9293
+ observation: absorbed.observation,
9294
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
9295
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
9296
+ note: "App relaunched (clean state). Not recorded as a step."
9297
+ };
9133
9298
  }
9134
9299
  case "mobile_assert_visible": {
9135
9300
  const recorded = recordAssertVisible(toolArgs, { resolveRef, screenName, platform: platform3, recordAction });
@@ -9267,7 +9432,7 @@ ${statePacket}` },
9267
9432
  return test;
9268
9433
  }
9269
9434
  async function runMobileGeneratePhase(args) {
9270
- const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated } = args;
9435
+ const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot } = args;
9271
9436
  const log2 = (line) => {
9272
9437
  try {
9273
9438
  onLog?.(line);
@@ -9281,6 +9446,7 @@ ${statePacket}` },
9281
9446
  const provider = (0, ai_provider_js_1.getProviderForModel)(agentModel);
9282
9447
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
9283
9448
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
9449
+ const targetAppId = ctx.target?.appId?.trim() || void 0;
9284
9450
  log2(`Mobile generator starting \u2014 platform: ${platform3}, provider: ${provider}, model: ${agentModel}, scenarios: ${scenarios.length}`);
9285
9451
  const tests = [];
9286
9452
  for (let i = 0; i < scenarios.length; i++) {
@@ -9299,6 +9465,8 @@ ${statePacket}` },
9299
9465
  aiClient,
9300
9466
  auditGroupId,
9301
9467
  onAICall,
9468
+ onScreenshot,
9469
+ targetAppId,
9302
9470
  log: log2
9303
9471
  });
9304
9472
  const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
@@ -311428,7 +311596,9 @@ var require_mobile_discovery_agent = __commonJS({
311428
311596
  return {
311429
311597
  collectedPages: explore.collectedPages,
311430
311598
  collectedTransitions,
311431
- pageScreenshots: explore.pageScreenshots,
311599
+ // Native screenshots are multi-megabyte PNGs. The live mirror streams them
311600
+ // through heartbeat telemetry; discovery checkpoints keep the screen graph
311601
+ // and transitions so large Android surveys cannot exceed server body limits.
311432
311602
  exploreStats: explore.exploreStats,
311433
311603
  healthObservations: explore.healthObservations
311434
311604
  };
@@ -311586,6 +311756,7 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311586
311756
  ctx,
311587
311757
  onLog: (line) => log2(line),
311588
311758
  onAICall,
311759
+ onScreenshot,
311589
311760
  onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
311590
311761
  });
311591
311762
  const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
@@ -325291,7 +325462,7 @@ var require_live_browser_registry = __commonJS({
325291
325462
  var THUMBNAIL_INTERVAL_MS = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_INTERVAL_MS ?? "4000", 10) || 4e3;
325292
325463
  var THUMBNAIL_QUALITY = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_QUALITY ?? "60", 10) || 60;
325293
325464
  var BROWSER_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_MAX_BASE64_BYTES ?? "400000", 10) || 4e5;
325294
- var MOBILE_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_MOBILE_THUMBNAIL_MAX_BASE64_BYTES ?? "2000000", 10) || 2e6;
325465
+ var MOBILE_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_MOBILE_THUMBNAIL_MAX_BASE64_BYTES ?? "4500000", 10) || 45e5;
325295
325466
  var STALE_SESSION_MS = parseInt(process.env.LIVE_BROWSER_STALE_MS ?? `${10 * 60 * 1e3}`, 10) || 10 * 60 * 1e3;
325296
325467
  var MAX_SESSIONS = parseInt(process.env.LIVE_BROWSER_MAX_SESSIONS ?? "32", 10) || 32;
325297
325468
  var LiveBrowserRegistry = class {
@@ -325787,6 +325958,7 @@ var require_dist2 = __commonJS({
325787
325958
  __exportStar(require_ai_provider(), exports);
325788
325959
  __exportStar(require_mobile_types(), exports);
325789
325960
  __exportStar(require_mobile_source_parser(), exports);
325961
+ __exportStar(require_mobile_boundary(), exports);
325790
325962
  var mobile_explorer_js_1 = require_mobile_explorer();
325791
325963
  Object.defineProperty(exports, "runMobileExplorePhase", { enumerable: true, get: function() {
325792
325964
  return mobile_explorer_js_1.runMobileExplorePhase;
@@ -326119,7 +326291,7 @@ var require_package4 = __commonJS({
326119
326291
  "package.json"(exports, module) {
326120
326292
  module.exports = {
326121
326293
  name: "@validate.qa/runner",
326122
- version: "1.0.5",
326294
+ version: "1.0.6",
326123
326295
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
326124
326296
  bin: {
326125
326297
  "validate-runner": "dist/cli.js"
@@ -326217,7 +326389,7 @@ var import_runner_core = __toESM(require_dist2());
326217
326389
  var import_runner_core2 = __toESM(require_dist2());
326218
326390
 
326219
326391
  // src/runner.ts
326220
- var import_runner_core9 = __toESM(require_dist2());
326392
+ var import_runner_core10 = __toESM(require_dist2());
326221
326393
 
326222
326394
  // src/services/regression-runner.ts
326223
326395
  var import_runner_core3 = __toESM(require_dist2());
@@ -326232,7 +326404,7 @@ var import_runner_core5 = __toESM(require_dist2());
326232
326404
  var import_runner_core6 = __toESM(require_dist2());
326233
326405
 
326234
326406
  // src/runner.ts
326235
- var import_runner_core10 = __toESM(require_dist2());
326407
+ var import_runner_core11 = __toESM(require_dist2());
326236
326408
 
326237
326409
  // src/services/auth-state.ts
326238
326410
  var INLINE_LOGIN_TAG = "@inline-login";
@@ -326252,6 +326424,7 @@ function shouldUseAuthState(tags, playwrightCode) {
326252
326424
 
326253
326425
  // src/services/appium-executor.ts
326254
326426
  init_dist();
326427
+ var import_runner_core8 = __toESM(require_dist2());
326255
326428
 
326256
326429
  // src/services/appium-lifecycle.ts
326257
326430
  import { spawn, execFileSync } from "child_process";
@@ -327543,6 +327716,86 @@ async function openDeepLink(sessionId, platform3, deeplink, appId) {
327543
327716
  body: JSON.stringify({ script: "mobile: deepLink", args })
327544
327717
  });
327545
327718
  }
327719
+ function readExecuteStringValue(result) {
327720
+ if (result && typeof result === "object" && "value" in result) {
327721
+ const value = result.value;
327722
+ if (typeof value === "string" && value.trim()) return value.trim();
327723
+ }
327724
+ return void 0;
327725
+ }
327726
+ function readActiveBundleId(result) {
327727
+ if (result && typeof result === "object" && "value" in result) {
327728
+ const value = result.value;
327729
+ if (value && typeof value === "object" && "bundleId" in value) {
327730
+ const bundleId = value.bundleId;
327731
+ if (typeof bundleId === "string" && bundleId.trim()) return bundleId.trim();
327732
+ }
327733
+ }
327734
+ return void 0;
327735
+ }
327736
+ function normalizeAndroidActivity(activity, pkg2) {
327737
+ const trimmed = activity?.trim();
327738
+ if (!trimmed) return void 0;
327739
+ const packageName = pkg2?.trim();
327740
+ if (!packageName) return trimmed;
327741
+ if (trimmed.startsWith(".")) return `${packageName}${trimmed}`;
327742
+ if (!trimmed.includes(".")) return `${packageName}.${trimmed}`;
327743
+ return trimmed;
327744
+ }
327745
+ async function executeMobileScript(sessionId, script, args) {
327746
+ return wdRequest(`/session/${sessionId}/execute/sync`, {
327747
+ method: "POST",
327748
+ body: JSON.stringify({ script, args })
327749
+ });
327750
+ }
327751
+ async function readForegroundScreenSignal(sessionId, platform3) {
327752
+ if (platform3 === "ANDROID") {
327753
+ const [packageResult, activityResult] = await Promise.all([
327754
+ executeMobileScript(sessionId, "mobile: getCurrentPackage", []),
327755
+ executeMobileScript(sessionId, "mobile: getCurrentActivity", []).catch(() => void 0)
327756
+ ]);
327757
+ const pkg2 = readExecuteStringValue(packageResult);
327758
+ if (!pkg2) return null;
327759
+ const activity = normalizeAndroidActivity(readExecuteStringValue(activityResult), pkg2);
327760
+ return {
327761
+ platform: platform3,
327762
+ bundleId: pkg2,
327763
+ ...activity ? { activity } : {},
327764
+ screenHash: "foreground"
327765
+ };
327766
+ }
327767
+ const infoResult = await executeMobileScript(sessionId, "mobile: activeAppInfo", []);
327768
+ const bundleId = readActiveBundleId(infoResult);
327769
+ if (!bundleId) return null;
327770
+ return { platform: platform3, bundleId, screenHash: "foreground" };
327771
+ }
327772
+ async function activateTargetApp(sessionId, platform3, appId) {
327773
+ const args = platform3 === "IOS" ? [{ bundleId: appId }] : [{ appId }];
327774
+ await executeMobileScript(sessionId, "mobile: activateApp", args);
327775
+ }
327776
+ async function restoreTargetAppIfExternal(sessionId, target, log2, source, failOnExternal) {
327777
+ let boundary = null;
327778
+ try {
327779
+ const signal = await readForegroundScreenSignal(sessionId, target.platform);
327780
+ boundary = signal ? (0, import_runner_core8.classifyMobileScreenBoundary)(signal, target.appId, target.platform) : null;
327781
+ } catch (err) {
327782
+ log2(` App boundary check skipped after ${source}: ${err instanceof Error ? err.message : String(err)}`);
327783
+ return;
327784
+ }
327785
+ if (!boundary || (0, import_runner_core8.isTargetLikeMobileBoundary)(boundary) || boundary.kind === "transient_external") {
327786
+ return;
327787
+ }
327788
+ const message = `Foreground app changed to ${boundary.owner}, outside target app ${boundary.targetAppId}`;
327789
+ log2(` ${message}; activating target app.`);
327790
+ try {
327791
+ await activateTargetApp(sessionId, target.platform, target.appId);
327792
+ } catch (err) {
327793
+ log2(` Could not reactivate ${target.appId}: ${err instanceof Error ? err.message : String(err)}`);
327794
+ }
327795
+ if (failOnExternal) {
327796
+ throw new Error(`${message}; relaunched target app and failed the step so mobile tests do not continue in another app.`);
327797
+ }
327798
+ }
327546
327799
  async function deleteSession(sessionId) {
327547
327800
  try {
327548
327801
  await wdRequest(`/session/${sessionId}`, { method: "DELETE" });
@@ -327669,6 +327922,29 @@ async function getActiveElementId(sessionId) {
327669
327922
  return null;
327670
327923
  }
327671
327924
  }
327925
+ async function sendKeysToFocusedInput(sessionId, value) {
327926
+ await wdRequest(`/session/${sessionId}/keys`, {
327927
+ method: "POST",
327928
+ body: JSON.stringify({ text: value, value: Array.from(value) })
327929
+ });
327930
+ }
327931
+ function shouldAbortAfterStepFailure(action) {
327932
+ switch (action) {
327933
+ case "launchApp":
327934
+ case "tap":
327935
+ case "type":
327936
+ case "clear":
327937
+ case "swipe":
327938
+ case "scroll":
327939
+ case "back":
327940
+ case "home":
327941
+ case "waitForElement":
327942
+ return true;
327943
+ case "assertVisible":
327944
+ case "assertText":
327945
+ return false;
327946
+ }
327947
+ }
327672
327948
  async function executeTap(sessionId, step) {
327673
327949
  const bounds = boundsFromStep(step);
327674
327950
  if (step.target) {
@@ -327690,8 +327966,10 @@ async function executeTap(sessionId, step) {
327690
327966
  }
327691
327967
  async function executeType(sessionId, step) {
327692
327968
  if (step.value === void 0 || step.value === null || step.value === "") return;
327969
+ const value = String(step.value);
327693
327970
  const bounds = boundsFromStep(step);
327694
327971
  let elementId = null;
327972
+ let tappedBounds = false;
327695
327973
  if (step.target) {
327696
327974
  try {
327697
327975
  elementId = await findElement(sessionId, step.target, readStepTimeout(step));
@@ -327701,19 +327979,33 @@ async function executeType(sessionId, step) {
327701
327979
  }
327702
327980
  if (!elementId && bounds) {
327703
327981
  await tapCoordinates(sessionId, bounds);
327982
+ tappedBounds = true;
327704
327983
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
327705
327984
  elementId = await getActiveElementId(sessionId);
327706
327985
  }
327707
327986
  if (!elementId) {
327708
327987
  elementId = await getActiveElementId(sessionId);
327709
327988
  }
327710
- if (!elementId) {
327711
- throw new Error("TYPE step could not resolve a target or focused element");
327989
+ if (elementId) {
327990
+ try {
327991
+ await wdRequest(`/session/${sessionId}/element/${elementId}/value`, {
327992
+ method: "POST",
327993
+ body: JSON.stringify({ text: value })
327994
+ });
327995
+ return;
327996
+ } catch (error2) {
327997
+ if (!bounds) throw error2;
327998
+ }
327712
327999
  }
327713
- await wdRequest(`/session/${sessionId}/element/${elementId}/value`, {
327714
- method: "POST",
327715
- body: JSON.stringify({ text: step.value })
327716
- });
328000
+ if (bounds) {
328001
+ if (!tappedBounds) {
328002
+ await tapCoordinates(sessionId, bounds);
328003
+ await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
328004
+ }
328005
+ await sendKeysToFocusedInput(sessionId, value);
328006
+ return;
328007
+ }
328008
+ throw new Error("TYPE step could not resolve a target or focused element");
327717
328009
  }
327718
328010
  async function executeClear(sessionId, step) {
327719
328011
  const bounds = boundsFromStep(step);
@@ -327917,6 +328209,7 @@ async function executeMobileTest(options) {
327917
328209
  log2(` \u2717 Deep link failed: ${err instanceof Error ? err.message : String(err)}`);
327918
328210
  }
327919
328211
  }
328212
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
327920
328213
  let environmental = null;
327921
328214
  for (const step of options.steps) {
327922
328215
  const stepStart = Date.now();
@@ -327924,6 +328217,7 @@ async function executeMobileTest(options) {
327924
328217
  let status = "passed";
327925
328218
  let stepError;
327926
328219
  try {
328220
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} pre-check`, false);
327927
328221
  switch (step.action) {
327928
328222
  case "launchApp":
327929
328223
  log2(" App launched (handled by session creation)");
@@ -327963,6 +328257,7 @@ async function executeMobileTest(options) {
327963
328257
  break;
327964
328258
  }
327965
328259
  }
328260
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
327966
328261
  } catch (err) {
327967
328262
  status = "failed";
327968
328263
  stepError = err instanceof Error ? err.message : String(err);
@@ -327983,6 +328278,10 @@ async function executeMobileTest(options) {
327983
328278
  log2(`Aborting remaining steps \u2014 Appium session is no longer usable: ${environmental}`);
327984
328279
  break;
327985
328280
  }
328281
+ if (status === "failed" && shouldAbortAfterStepFailure(step.action)) {
328282
+ log2(`Aborting remaining steps \u2014 ${step.action} failed, so downstream mobile steps are no longer reliable.`);
328283
+ break;
328284
+ }
327986
328285
  }
327987
328286
  const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
327988
328287
  const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
@@ -329248,7 +329547,7 @@ function detachMirrorSession(sessionId) {
329248
329547
  }
329249
329548
 
329250
329549
  // src/services/mobile/mobile-bridge-driver.ts
329251
- var import_runner_core8 = __toESM(require_dist2());
329550
+ var import_runner_core9 = __toESM(require_dist2());
329252
329551
  init_dist();
329253
329552
  var APPIUM_SESSION_ID_PATTERN2 = /^[a-f0-9-]{1,64}$/i;
329254
329553
  function readStringValue(result) {
@@ -329258,7 +329557,7 @@ function readStringValue(result) {
329258
329557
  }
329259
329558
  return void 0;
329260
329559
  }
329261
- function normalizeAndroidActivity(activity, pkg2) {
329560
+ function normalizeAndroidActivity2(activity, pkg2) {
329262
329561
  const trimmed = activity?.trim();
329263
329562
  if (!trimmed) return void 0;
329264
329563
  const packageName = pkg2?.trim();
@@ -329294,7 +329593,7 @@ var MobileBridgeDriver = class {
329294
329593
  return this.platform === "ANDROID" ? { appId: this.appId } : { bundleId: this.appId };
329295
329594
  }
329296
329595
  baseScreenSignal(source) {
329297
- const parsed = (0, import_runner_core8.parseMobileSource)(source, this.platform);
329596
+ const parsed = (0, import_runner_core9.parseMobileSource)(source, this.platform);
329298
329597
  return {
329299
329598
  platform: this.platform,
329300
329599
  screenHash: parsed.screenSignal.screenHash,
@@ -329313,11 +329612,11 @@ var MobileBridgeDriver = class {
329313
329612
  const activity = readStringValue(activityRes);
329314
329613
  const pkg2 = readStringValue(packageRes);
329315
329614
  if (pkg2) signal.bundleId = pkg2;
329316
- const normalizedActivity = normalizeAndroidActivity(activity, pkg2);
329615
+ const normalizedActivity = normalizeAndroidActivity2(activity, pkg2);
329317
329616
  if (normalizedActivity) signal.activity = normalizedActivity;
329318
329617
  } else {
329319
329618
  const infoRes = await this.executeScript("mobile: activeAppInfo", []).catch(() => void 0);
329320
- const bundleId = readActiveBundleId(infoRes);
329619
+ const bundleId = readActiveBundleId2(infoRes);
329321
329620
  if (bundleId) signal.bundleId = bundleId;
329322
329621
  }
329323
329622
  } catch {
@@ -329459,7 +329758,7 @@ var MobileBridgeDriver = class {
329459
329758
  return this.enrichScreenSignal(source);
329460
329759
  }
329461
329760
  };
329462
- function readActiveBundleId(result) {
329761
+ function readActiveBundleId2(result) {
329463
329762
  if (result && typeof result === "object" && "value" in result) {
329464
329763
  const value = result.value;
329465
329764
  if (value && typeof value === "object" && "bundleId" in value) {
@@ -329685,7 +329984,7 @@ function printDoctorReport(report) {
329685
329984
 
329686
329985
  // src/runner.ts
329687
329986
  init_dist();
329688
- var import_runner_core11 = __toESM(require_dist2());
329987
+ var import_runner_core12 = __toESM(require_dist2());
329689
329988
  function fingerprintTest(test) {
329690
329989
  const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
329691
329990
  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);
@@ -330130,7 +330429,7 @@ function pruneCollectedPageForPost(page) {
330130
330429
  if (Array.isArray(out.observations)) out.observations = out.observations.slice(-200).map((obs) => compactForAudit(obs));
330131
330430
  return out;
330132
330431
  }
330133
- function prepareDiscoveryResultForPost(result, logs2) {
330432
+ function prepareDiscoveryResultForPost(result, logs2, options = {}) {
330134
330433
  const safe = { ...result, logs: truncateTextTail(logs2, RESULT_LOG_MAX_CHARS, "discovery result log") };
330135
330434
  if (Array.isArray(safe.collectedPages)) safe.collectedPages = safe.collectedPages.map(pruneCollectedPageForPost);
330136
330435
  if (Array.isArray(safe.apiCalls)) safe.apiCalls = safe.apiCalls.map((call) => compactForAudit(call));
@@ -330146,6 +330445,10 @@ function prepareDiscoveryResultForPost(result, logs2) {
330146
330445
  if (safe.snapshotEvidence) safe.snapshotEvidence = compactForAudit(safe.snapshotEvidence);
330147
330446
  if (safe.discoveryCheckpoints) safe.discoveryCheckpoints = compactForAudit(safe.discoveryCheckpoints);
330148
330447
  if (safe.pageScreenshotKeys && safe.pageScreenshots) delete safe.pageScreenshots;
330448
+ if (options.omitScreenshots) {
330449
+ if (Array.isArray(safe.screenshots)) delete safe.screenshots;
330450
+ if (safe.pageScreenshots) delete safe.pageScreenshots;
330451
+ }
330149
330452
  if (safe.exploreAlreadySaved === true) {
330150
330453
  if (Array.isArray(safe.screenshots)) delete safe.screenshots;
330151
330454
  if (safe.pageScreenshots) delete safe.pageScreenshots;
@@ -330245,7 +330548,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
330245
330548
  const runMode = run2.mode ?? "run";
330246
330549
  const RUNNER_MODE = opts.runnerMode ?? "playwright-native";
330247
330550
  log.info(`Running test: ${run2.testCaseId ?? "auth-setup"} (run ${run2.runId}) [mode: ${runMode === "heal" ? "heal" : runMode === "auth-setup" ? "auth-setup" : RUNNER_MODE}]`);
330248
- const liveBrowserHandle = import_runner_core10.liveBrowserRegistry.register({
330551
+ const liveBrowserHandle = import_runner_core11.liveBrowserRegistry.register({
330249
330552
  sessionId: `${run2.runId}`,
330250
330553
  mode: toLiveBrowserMode(runMode),
330251
330554
  runId: run2.runId,
@@ -330254,7 +330557,10 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
330254
330557
  testCaseId: run2.testCaseId ?? void 0
330255
330558
  });
330256
330559
  requestLiveBrowserHeartbeat(true);
330257
- const liveBrowserHeartbeatInterval = setInterval(() => requestLiveBrowserHeartbeat(), 5e3);
330560
+ const liveBrowserHeartbeatInterval = setInterval(() => {
330561
+ liveBrowserHandle.patch({});
330562
+ requestLiveBrowserHeartbeat();
330563
+ }, 5e3);
330258
330564
  liveBrowserHeartbeatInterval.unref?.();
330259
330565
  const runNativeWithLiveBrowser = (nativeOptions) => (0, import_runner_core.executeTestViaNativePlaywright)({
330260
330566
  ...nativeOptions,
@@ -330281,7 +330587,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
330281
330587
  log.fail(`PRE-FLIGHT FAILED - ${preflightError}`);
330282
330588
  return;
330283
330589
  }
330284
- const preflight = await (0, import_runner_core11.resolveBaseUrlPreflight)(run2.baseUrl);
330590
+ const preflight = await (0, import_runner_core12.resolveBaseUrlPreflight)(run2.baseUrl);
330285
330591
  if (preflight.error) {
330286
330592
  await postResult(opts.serverUrl, headers, run2.runId, { status: "ERROR", error: preflight.error });
330287
330593
  log.fail(`PRE-FLIGHT FAILED - ${preflight.error}`);
@@ -330338,7 +330644,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
330338
330644
  return;
330339
330645
  }
330340
330646
  const healLogStream = createHealLogStreamer(opts.serverUrl, headers, run2.runId);
330341
- const batchResult = await (0, import_runner_core9.executeGrokPerBatchHeal)(tests, run2.baseUrl, {
330647
+ const batchResult = await (0, import_runner_core10.executeGrokPerBatchHeal)(tests, run2.baseUrl, {
330342
330648
  testVariables: run2.testVariables,
330343
330649
  runNativeTest: runNativeWithLiveBrowser,
330344
330650
  validateContext: { runId: run2.runId, serverUrl: opts.serverUrl, authHeader: headers.Authorization },
@@ -330572,7 +330878,7 @@ ${finalVerifyResult.logs ?? ""}`;
330572
330878
  if (classifyErrorMessage(composedError ?? null) === "config_issue") {
330573
330879
  apiHealStatus = "ERROR";
330574
330880
  } else {
330575
- const rlDetection = (0, import_runner_core11.detectRateLimitBlock)({
330881
+ const rlDetection = (0, import_runner_core12.detectRateLimitBlock)({
330576
330882
  testName: run2.testName ?? run2.testCaseId ?? void 0,
330577
330883
  tags: run2.tags ?? [],
330578
330884
  error: composedError ?? finalVerifyResult?.error ?? null,
@@ -330679,7 +330985,7 @@ ${finalVerifyResult.logs ?? ""}`;
330679
330985
  log.info(`Heal engine: GROK (${source})`);
330680
330986
  }
330681
330987
  const grokHealLogStream = useGrokHeal ? createHealLogStreamer(opts.serverUrl, headers, run2.runId) : null;
330682
- const result2 = useGrokHeal ? await (0, import_runner_core9.executeGrokPerTestHeal)(run2.playwrightCode, run2.steps, run2.baseUrl, {
330988
+ const result2 = useGrokHeal ? await (0, import_runner_core10.executeGrokPerTestHeal)(run2.playwrightCode, run2.steps, run2.baseUrl, {
330683
330989
  ...healOpts,
330684
330990
  onLog: grokHealLogStream.onLog
330685
330991
  }) : await (0, import_runner_core2.executeTestWithHealing)(run2.playwrightCode, run2.steps, run2.baseUrl, healOpts);
@@ -330712,7 +331018,7 @@ ${finalVerifyResult.logs ?? ""}`;
330712
331018
  let healStatus = result2.passed ? "PASSED" : isConfigIssue ? "ERROR" : "FAILED";
330713
331019
  let healError = result2.error;
330714
331020
  if (!result2.passed && !isConfigIssue) {
330715
- const rlDetection = (0, import_runner_core11.detectRateLimitBlock)({
331021
+ const rlDetection = (0, import_runner_core12.detectRateLimitBlock)({
330716
331022
  testName: run2.testName ?? run2.testCaseId ?? void 0,
330717
331023
  tags: run2.tags ?? [],
330718
331024
  error: result2.error,
@@ -330906,7 +331212,7 @@ ${finalVerifyResult.logs ?? ""}`;
330906
331212
  liveBrowserHandle,
330907
331213
  runNativeTest: runNativeWithLiveBrowser
330908
331214
  };
330909
- const healResult = useGrokHealSuite ? await (0, import_runner_core9.executeGrokPerTestHeal)(
331215
+ const healResult = useGrokHealSuite ? await (0, import_runner_core10.executeGrokPerTestHeal)(
330910
331216
  failedTest.playwrightCode,
330911
331217
  failedTest.steps ?? [],
330912
331218
  run2.baseUrl,
@@ -331194,9 +331500,16 @@ ${finalVerifyResult.logs ?? ""}`;
331194
331500
  signal: AbortSignal.timeout(3e4)
331195
331501
  });
331196
331502
  if (res.ok) return;
331197
- log.warn(`[stream] discovery-plan attempt ${attempt + 1} returned ${res.status}`);
331503
+ const responseText = await res.text().catch(() => "");
331504
+ const statusMessage = responseText ? `${res.status}: ${responseText.slice(0, 500)}` : String(res.status);
331505
+ if (res.status === 409) {
331506
+ throw new Error(`Discovery plan checkpoint rejected: ${statusMessage}`);
331507
+ }
331508
+ log.warn(`[stream] discovery-plan attempt ${attempt + 1} returned ${statusMessage}`);
331198
331509
  } catch (err) {
331199
- log.warn(`[stream] discovery-plan attempt ${attempt + 1} failed: ${err instanceof Error ? err.message : String(err)}`);
331510
+ const message = err instanceof Error ? err.message : String(err);
331511
+ if (/Discovery plan checkpoint rejected/i.test(message)) throw err;
331512
+ log.warn(`[stream] discovery-plan attempt ${attempt + 1} failed: ${message}`);
331200
331513
  }
331201
331514
  if (attempt < 2) await new Promise((r) => setTimeout(r, 3e3 * (attempt + 1)));
331202
331515
  }
@@ -331204,6 +331517,7 @@ ${finalVerifyResult.logs ?? ""}`;
331204
331517
  };
331205
331518
  let discoveryResult;
331206
331519
  const buildFinalBody = (r, overrideError, auditWarning) => {
331520
+ const isNativeDiscovery = ctx.medium === "IOS_NATIVE" || ctx.medium === "ANDROID_NATIVE";
331207
331521
  const { exploreFinalMessages: _exploreFinalMessages, ...rest } = r;
331208
331522
  void _exploreFinalMessages;
331209
331523
  const logs2 = auditWarning ? [rest.logs, auditWarning].filter(Boolean).join("\n") : rest.logs;
@@ -331253,7 +331567,7 @@ ${finalVerifyResult.logs ?? ""}`;
331253
331567
  resolvedAppOrigin: rest.resolvedAppOrigin,
331254
331568
  plans: rest.plans,
331255
331569
  exploreStats: rest.exploreStats,
331256
- screenshots: rest.screenshots,
331570
+ ...isNativeDiscovery ? {} : { screenshots: rest.screenshots },
331257
331571
  snapshotEvidence: rest.snapshotEvidence,
331258
331572
  discoveryCheckpoints: rest.discoveryCheckpoints,
331259
331573
  streamedTestCount: streamedTestKeys.size,
@@ -331262,8 +331576,10 @@ ${finalVerifyResult.logs ?? ""}`;
331262
331576
  ...explorePosted ? {} : {
331263
331577
  collectedPages: rest.collectedPages,
331264
331578
  collectedTransitions: rest.collectedTransitions,
331265
- pageScreenshots: rest.pageScreenshots,
331266
- pageScreenshotKeys: rest.pageScreenshotKeys
331579
+ ...isNativeDiscovery ? {} : {
331580
+ pageScreenshots: rest.pageScreenshots,
331581
+ pageScreenshotKeys: rest.pageScreenshotKeys
331582
+ }
331267
331583
  },
331268
331584
  ...harPosted ? {} : {
331269
331585
  apiCalls: rest.apiCalls,
@@ -331271,7 +331587,7 @@ ${finalVerifyResult.logs ?? ""}`;
331271
331587
  },
331272
331588
  // Only send tests that were NOT confirmed streamed to avoid duplicates
331273
331589
  tests: rest.tests.filter((t) => !streamedTestKeys.has(fingerprintTest(t)))
331274
- }, logs2);
331590
+ }, logs2, { omitScreenshots: isNativeDiscovery });
331275
331591
  };
331276
331592
  const acquireLoginBrowser = async () => {
331277
331593
  const browser = await browserPool.acquireAsync(12e3);
@@ -331375,7 +331691,6 @@ ${finalVerifyResult.logs ?? ""}`;
331375
331691
  thumbnailCapturedAt: Date.now()
331376
331692
  });
331377
331693
  requestLiveBrowserHeartbeat();
331378
- void onScreenshot("mobile", base64);
331379
331694
  },
331380
331695
  onExploreComplete,
331381
331696
  onTestGenerated,
@@ -331402,7 +331717,7 @@ ${finalVerifyResult.logs ?? ""}`;
331402
331717
  setExecutorBusy(false);
331403
331718
  }
331404
331719
  } else if (ctx.mode === "import") {
331405
- const runRepoImport = ctx.frameworkImport?.kind === "repo-analysis" ? import_runner_core9.runRepoAnalysisOnRunner : import_runner_core9.runFrameworkImportOnRunner;
331720
+ const runRepoImport = ctx.frameworkImport?.kind === "repo-analysis" ? import_runner_core10.runRepoAnalysisOnRunner : import_runner_core10.runFrameworkImportOnRunner;
331406
331721
  discoveryResult = await runRepoImport(ctx, { onTestGenerated, acquireLoginBrowser, onAudit: discoveryAudit }, {
331407
331722
  archiveUrl: ctx.frameworkImport?.source === "upload" ? `${opts.serverUrl}/api/runner/runs/${run2.runId}/import-archive` : void 0,
331408
331723
  archiveAuthHeader: headers.Authorization,
@@ -331732,7 +332047,7 @@ ${finalVerifyResult.logs ?? ""}`;
331732
332047
  let runStatus = result.passed ? "PASSED" : "FAILED";
331733
332048
  let runError = result.error;
331734
332049
  if (!result.passed) {
331735
- const rlDetection = (0, import_runner_core11.detectRateLimitBlock)({
332050
+ const rlDetection = (0, import_runner_core12.detectRateLimitBlock)({
331736
332051
  testName: run2.testName ?? run2.testCaseId ?? void 0,
331737
332052
  tags: run2.tags ?? [],
331738
332053
  error: result.error,
@@ -331870,7 +332185,7 @@ async function startRunner(opts) {
331870
332185
  capture: envInt("MAX_CAPTURE", DEFAULT_TYPE_LIMITS.capture),
331871
332186
  run: envInt("MAX_RUN", DEFAULT_TYPE_LIMITS.run)
331872
332187
  };
331873
- const RESOURCE_GOVERNOR_OPTIONS = (0, import_runner_core11.createResourceGovernorOptions)({
332188
+ const RESOURCE_GOVERNOR_OPTIONS = (0, import_runner_core12.createResourceGovernorOptions)({
331874
332189
  isCloud: IS_CLOUD,
331875
332190
  maxConcurrent: MAX_CONCURRENT,
331876
332191
  typeLimits: TYPE_LIMITS
@@ -332021,8 +332336,8 @@ async function startRunner(opts) {
332021
332336
  android: devices.some((d) => d.platform === "ANDROID" && d.isAvailable && d.state === "BOOTED")
332022
332337
  } : { web: true, ios: false, android: false };
332023
332338
  const bridge = enableMobile ? getBridgeState() : void 0;
332024
- const resourceStatus = (0, import_runner_core11.getResourceGovernorStatus)(RESOURCE_GOVERNOR_OPTIONS, Object.fromEntries(activeByType));
332025
- const processInventory = await (0, import_runner_core11.getBrowserProcessInventory)();
332339
+ const resourceStatus = (0, import_runner_core12.getResourceGovernorStatus)(RESOURCE_GOVERNOR_OPTIONS, Object.fromEntries(activeByType));
332340
+ const processInventory = await (0, import_runner_core12.getBrowserProcessInventory)();
332026
332341
  lastInventoryPids = new Set(processInventory.processes.map((p) => p.pid));
332027
332342
  lastInventoryKinds.clear();
332028
332343
  for (const p of processInventory.processes) lastInventoryKinds.set(p.pid, p.kind);
@@ -332049,8 +332364,8 @@ async function startRunner(opts) {
332049
332364
  resourceSnapshot: resourceStatus.snapshot,
332050
332365
  resourceGovernor: resourceStatus.governor,
332051
332366
  processInventory,
332052
- aiQueue: (0, import_runner_core11.getRunnerAIQueueStats)(),
332053
- liveBrowsers: import_runner_core10.liveBrowserRegistry.snapshotForHeartbeat(),
332367
+ aiQueue: (0, import_runner_core12.getRunnerAIQueueStats)(),
332368
+ liveBrowsers: import_runner_core11.liveBrowserRegistry.snapshotForHeartbeat(),
332054
332369
  // Pre-flight checks (ANDROID_HOME, Xcode, Appium drivers, adb, etc).
332055
332370
  // Cached & cheap — see getCachedDoctor above.
332056
332371
  doctor: getCachedDoctor()
@@ -332144,7 +332459,7 @@ async function startRunner(opts) {
332144
332459
  let pidsWatchdogWarned = false;
332145
332460
  const pidsWatchdog = setInterval(() => {
332146
332461
  if (shuttingDown) return;
332147
- const snapshot = (0, import_runner_core11.getResourceSnapshot)();
332462
+ const snapshot = (0, import_runner_core12.getResourceSnapshot)();
332148
332463
  const pct = snapshot.pidsPct;
332149
332464
  if (pct == null || !Number.isFinite(pct)) return;
332150
332465
  if (pct >= PIDS_RECYCLE_PCT) {
@@ -332180,7 +332495,7 @@ async function startRunner(opts) {
332180
332495
  log.dim(` Deferred ${run2.runId} (project ${runProject} at capacity ${getActiveForProject(runProject)}/${projectLimit})`);
332181
332496
  continue;
332182
332497
  }
332183
- const admission = (0, import_runner_core11.evaluateRunAdmission)(RESOURCE_GOVERNOR_OPTIONS, {
332498
+ const admission = (0, import_runner_core12.evaluateRunAdmission)(RESOURCE_GOVERNOR_OPTIONS, {
332184
332499
  mode: runMode,
332185
332500
  activeRuns,
332186
332501
  activeByType: Object.fromEntries(activeByType),