@validate.qa/runner 1.0.4 → 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 +2611 -852
  2. package/dist/cli.mjs +2621 -862
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -620,12 +620,19 @@ var require_ai_provider = __commonJS({
620
620
  function getMobileAIClient() {
621
621
  if ((process.env.SERVER_URL ?? "").trim().length > 0)
622
622
  return getServerProxiedAIClient();
623
- return getAIClient();
623
+ if (isMobileAiUnitTestMode())
624
+ return getAIClient();
625
+ throw new Error("Mobile AI requires the platform server URL (SERVER_URL). Customer runners do not provision provider API keys \u2014 the platform proxies every mobile AI completion through /api/runner/ai/execute, which lives on the shared server with the keys. If you reached this from `validate-runner start`, the credentials file is missing `serverUrl` or the install is broken; re-run `npx @validate.qa/runner login`. If you are calling the runner-core library directly, pass `options.aiClient` in tests or set VALIDATEQA_TEST_MODE=1 to opt into the local-provider fallback.");
624
626
  }
625
627
  function getMobileClientForModel(modelId) {
626
628
  if ((process.env.SERVER_URL ?? "").trim().length > 0)
627
629
  return getServerProxiedClientForModel(modelId);
628
- return getClientForModel(modelId);
630
+ if (isMobileAiUnitTestMode())
631
+ return getClientForModel(modelId);
632
+ throw new Error(`Mobile AI for model "${modelId}" requires the platform server URL (SERVER_URL). Customer runners do not provision provider API keys \u2014 the platform proxies every mobile AI completion through /api/runner/ai/execute. See getMobileAIClient() for remediation steps.`);
633
+ }
634
+ function isMobileAiUnitTestMode() {
635
+ return (process.env.VALIDATEQA_TEST_MODE ?? "").trim() === "1" || (process.env.NODE_ENV ?? "").trim() === "test";
629
636
  }
630
637
  }
631
638
  });
@@ -1035,6 +1042,65 @@ var require_mobile_source_parser = __commonJS({
1035
1042
  }
1036
1043
  });
1037
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
+
1038
1104
  // ../shared/dist/types.js
1039
1105
  function isUIVariant(value) {
1040
1106
  return typeof value === "string" && UI_VARIANTS.includes(value);
@@ -1470,6 +1536,18 @@ function buildAppiumCode(testName, target, steps) {
1470
1536
  ` }`,
1471
1537
  ` throw new Error('No locator resolved. Tried: ' + JSON.stringify(selectors) + (__lastError ? ' (last error: ' + String(__lastError) + ')' : ''));`,
1472
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
+ `}`,
1473
1551
  ...hasSwipe ? [
1474
1552
  ``,
1475
1553
  `// Read the device window with a few retries (matches the runtime executor).`,
@@ -1528,13 +1606,25 @@ function buildAppiumCode(testName, target, steps) {
1528
1606
  const y = Math.round(bounds.y + bounds.height / 2);
1529
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 }] }]);`);
1530
1608
  } else if (step.action === "type" && hasSelector) {
1531
- 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
+ }
1532
1622
  } else if (step.action === "type" && bounds) {
1533
1623
  const x = Math.round(bounds.x + bounds.width / 2);
1534
1624
  const y = Math.round(bounds.y + bounds.height / 2);
1535
- 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});`);
1536
1626
  lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1537
- lines.push(` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).addValue(${toJsStringLiteral(step.value ?? "")});`);
1627
+ lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1538
1628
  } else if (step.action === "clear" && hasSelector) {
1539
1629
  lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
1540
1630
  } else if (step.action === "assertVisible" && hasSelector) {
@@ -4431,6 +4521,7 @@ var require_mobile_explorer = __commonJS({
4431
4521
  var ai_retry_js_1 = require_ai_retry();
4432
4522
  var mobile_source_parser_js_1 = require_mobile_source_parser();
4433
4523
  var mobile_observation_js_1 = require_mobile_observation();
4524
+ var mobile_boundary_js_1 = require_mobile_boundary();
4434
4525
  var MAX_MOBILE_ITERATIONS_SURVEY = 40;
4435
4526
  var MAX_MOBILE_ITERATIONS_DEEP = 60;
4436
4527
  var MAX_SCREENSHOTS = 15;
@@ -4739,8 +4830,9 @@ var require_mobile_explorer = __commonJS({
4739
4830
  }
4740
4831
  return sections.join("\n");
4741
4832
  }
4742
- function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief) {
4833
+ function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId) {
4743
4834
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
4835
+ const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
4744
4836
  const surveyBody = `You are a mobile QA exploration agent driving a real ${platformLabel} app on a connected device. Your goal: MAP every reachable screen so downstream agents know what to test. You are NOT testing \u2014 you are building a complete screen map.
4745
4837
 
4746
4838
  ## How the loop works
@@ -4758,7 +4850,7 @@ var require_mobile_explorer = __commonJS({
4758
4850
 
4759
4851
  ## Boundaries (survey = map, do not mutate)
4760
4852
  - Do NOT submit forms, save, delete, purchase, or send anything. You MAY open modals/menus to catalog them, then dismiss.
4761
- - Stay inside this app. Do not leave to the system home screen or other apps (mobile_home backgrounds \u2014 avoid it).
4853
+ - Stay inside this app.${targetLine} Do not leave to the system home screen or other apps (mobile_home backgrounds \u2014 avoid it). If a tap opens an app store, settings, browser, or another app, do not explore it; return with mobile_back or mobile_relaunch.
4762
4854
  - If you hit a login wall and have no credentials, capture the auth screen and explore what is reachable.
4763
4855
 
4764
4856
  Max iterations: ${maxIterations}. You will be warned near the end.`;
@@ -4776,6 +4868,7 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
4776
4868
  4. Use mobile_relaunch for a clean, hermetic restart before testing a distinct flow that needs fresh state.
4777
4869
  5. mobile_swipe to scroll; mobile_back to return.
4778
4870
  6. Do NOT trigger irreversible side effects (real payments, sending messages/invites to others). Note them instead.
4871
+ 7. Stay inside this app.${targetLine} If a tap opens an app store, settings, browser, or another app, do not explore it; return with mobile_back or mobile_relaunch.
4779
4872
 
4780
4873
  Max iterations: ${maxIterations}. You will be warned near the end.`;
4781
4874
  const briefBlock = appBrief ? `
@@ -4801,6 +4894,7 @@ Use this to prioritize which screens and flows to cover.` : "";
4801
4894
  const mode = ctx.mode === "deep" ? "deep" : "survey";
4802
4895
  const platform3 = platformForMedium(ctx.medium);
4803
4896
  const enableRecordJourney = config?.enableRecordJourney ?? mode !== "survey";
4897
+ const targetAppId = ctx.target?.appId?.trim() || void 0;
4804
4898
  const defaultBudget = mode === "deep" ? MAX_MOBILE_ITERATIONS_DEEP : MAX_MOBILE_ITERATIONS_SURVEY;
4805
4899
  const maxIterations = config?.maxIterations && config.maxIterations > 0 ? Math.floor(config.maxIterations) : defaultBudget;
4806
4900
  const modelOverride = ctx.exploreModel ?? null;
@@ -4851,6 +4945,81 @@ Use this to prioritize which screens and flows to cover.` : "";
4851
4945
  return null;
4852
4946
  return resolveSnapshot(xml, windowSize, driverSignal, platform3);
4853
4947
  };
4948
+ const currentBoundary = () => {
4949
+ const current = getLatest();
4950
+ return current ? (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(current.signal, targetAppId, platform3) : { kind: "unknown" };
4951
+ };
4952
+ const absorbResolvedObservation = async (resolved, screenshot, iterationForObservation, source) => {
4953
+ const boundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(resolved.signal, targetAppId, platform3);
4954
+ if (boundary.kind === "target" || boundary.kind === "unknown") {
4955
+ const screenId = ingestSnapshot(resolved, iterationForObservation);
4956
+ captureScreenshot(screenshot, screenId);
4957
+ return { observation: resolved.observation, screenId };
4958
+ }
4959
+ latest = resolved;
4960
+ lastSnapshotIteration = iterationForObservation;
4961
+ if (boundary.kind === "transient_external") {
4962
+ log2(` System overlay detected from ${source}: ${boundary.owner}; not adding it to target-app survey evidence.`);
4963
+ return {
4964
+ observation: `${resolved.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(boundary)}`,
4965
+ screenId: null,
4966
+ transientExternal: true,
4967
+ externalOwner: boundary.owner
4968
+ };
4969
+ }
4970
+ log2(` External app detected from ${source}: ${boundary.owner}; excluding it from survey evidence and relaunching ${boundary.targetAppId}.`);
4971
+ try {
4972
+ await driver.relaunch();
4973
+ const snap = await driver.snapshot();
4974
+ const recovered = buildResolved(snap.xml, snap.windowSize, snap.screenSignal);
4975
+ if (!recovered) {
4976
+ return {
4977
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunched target app, but no UI tree was returned)`,
4978
+ screenId: null,
4979
+ externalBlocked: true,
4980
+ externalOwner: boundary.owner
4981
+ };
4982
+ }
4983
+ const recoveredBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(recovered.signal, targetAppId, platform3);
4984
+ latest = recovered;
4985
+ lastSnapshotIteration = iterationForObservation;
4986
+ if (recoveredBoundary.kind === "target" || recoveredBoundary.kind === "unknown") {
4987
+ const screenId = ingestSnapshot(recovered, iterationForObservation);
4988
+ captureScreenshot(snap.screenshot, screenId);
4989
+ return {
4990
+ observation: recovered.observation,
4991
+ screenId,
4992
+ externalBlocked: true,
4993
+ externalOwner: boundary.owner
4994
+ };
4995
+ }
4996
+ if (recoveredBoundary.kind === "transient_external") {
4997
+ log2(` Relaunch surfaced system overlay ${recoveredBoundary.owner}; not adding it to target-app survey evidence.`);
4998
+ return {
4999
+ observation: `${recovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(recoveredBoundary)}`,
5000
+ screenId: null,
5001
+ externalBlocked: true,
5002
+ transientExternal: true,
5003
+ externalOwner: boundary.owner
5004
+ };
5005
+ }
5006
+ log2(` Relaunch still outside target app (${recoveredBoundary.owner}); no screen recorded.`);
5007
+ return {
5008
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no screen was recorded)`,
5009
+ screenId: null,
5010
+ externalBlocked: true,
5011
+ externalOwner: boundary.owner
5012
+ };
5013
+ } catch (err) {
5014
+ const message = err instanceof Error ? err.message : String(err);
5015
+ return {
5016
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; failed to relaunch target app: ${message})`,
5017
+ screenId: null,
5018
+ externalBlocked: true,
5019
+ externalOwner: boundary.owner
5020
+ };
5021
+ }
5022
+ };
4854
5023
  const resolveRef = (ref) => {
4855
5024
  if (typeof ref !== "string" || !latest)
4856
5025
  return null;
@@ -4859,7 +5028,7 @@ Use this to prioritize which screens and flows to cover.` : "";
4859
5028
  return null;
4860
5029
  return { element, bounds: element.bounds };
4861
5030
  };
4862
- const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief);
5031
+ const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId);
4863
5032
  const kickoff = buildKickoffMessage(mode);
4864
5033
  const recentExchanges = [];
4865
5034
  const transientDirectives = [];
@@ -4889,9 +5058,13 @@ Use this to prioritize which screens and flows to cover.` : "";
4889
5058
  const seed = await driver.snapshot();
4890
5059
  const resolved = buildResolved(seed.xml, seed.windowSize, seed.screenSignal);
4891
5060
  if (resolved) {
4892
- const screenId = ingestSnapshot(resolved, 0);
4893
- captureScreenshot(seed.screenshot, screenId);
4894
- log2(`Seed snapshot: screen ${screenId} (${resolved.screen.elements.length} elements)`);
5061
+ const absorbed = await absorbResolvedObservation(resolved, seed.screenshot, 0, "seed snapshot");
5062
+ if (absorbed.screenId) {
5063
+ const elementCount = state2.getScreen(absorbed.screenId)?.elements.length ?? resolved.screen.elements.length;
5064
+ log2(`Seed snapshot: screen ${absorbed.screenId} (${elementCount} elements)`);
5065
+ } else {
5066
+ log2("Seed snapshot was outside the target app and was not recorded.");
5067
+ }
4895
5068
  } else {
4896
5069
  log2("Seed snapshot returned no XML \u2014 the agent must call mobile_snapshot.");
4897
5070
  }
@@ -5032,9 +5205,9 @@ Exploration complete: ${summary}`);
5032
5205
  mode,
5033
5206
  latest: getLatest,
5034
5207
  buildResolved,
5035
- ingestSnapshot,
5208
+ absorbResolvedObservation,
5036
5209
  resolveRef,
5037
- captureScreenshot,
5210
+ currentBoundary,
5038
5211
  collectedTransitions,
5039
5212
  counters,
5040
5213
  journeys,
@@ -5069,9 +5242,21 @@ Exploration complete: ${summary}`);
5069
5242
  counters.pagesDiscovered = collectedPages.length;
5070
5243
  }
5071
5244
  log2(`Mobile explore phase done \u2014 ${collectedPages.length} screens, ${collectedTransitions.length} transitions, ${journeys.length} journeys, ${counters.elementsFound} elements observed.`);
5245
+ const recordedJourneys = journeys.map((journey) => {
5246
+ const routes = journey.screenIds && journey.screenIds.length > 0 ? journey.screenIds : [journey.screenId];
5247
+ return {
5248
+ name: journey.name,
5249
+ route: journey.screenId,
5250
+ routes,
5251
+ steps: journey.steps,
5252
+ expectedOutcome: journey.expectedOutcome,
5253
+ iteration: journey.iteration
5254
+ };
5255
+ });
5072
5256
  const result = {
5073
5257
  collectedPages,
5074
5258
  collectedTransitions,
5259
+ recordedJourneys,
5075
5260
  screenshots,
5076
5261
  exploreStats: {
5077
5262
  pagesDiscovered: counters.pagesDiscovered,
@@ -5092,15 +5277,13 @@ Exploration complete: ${summary}`);
5092
5277
  return result;
5093
5278
  }
5094
5279
  async function dispatchMobileTool(args) {
5095
- const { toolName, toolArgs, iteration, driver, state: state2, enableRecordJourney, latest, buildResolved, ingestSnapshot, resolveRef, captureScreenshot, collectedTransitions, counters, journeys, log: log2 } = args;
5096
- const absorbActionResult = (xml, screenshot) => {
5097
- const resolved = buildResolved(xml, latest()?.screen.windowSize ?? null, void 0);
5280
+ const { toolName, toolArgs, iteration, driver, state: state2, enableRecordJourney, latest, buildResolved, absorbResolvedObservation, resolveRef, currentBoundary, collectedTransitions, counters, journeys, log: log2 } = args;
5281
+ const absorbActionResult = (actionResult, source) => {
5282
+ const resolved = buildResolved(actionResult.source, latest()?.screen.windowSize ?? null, actionResult.screenSignal);
5098
5283
  if (!resolved) {
5099
- return { observation: "(no UI tree returned)", screenId: null };
5284
+ return Promise.resolve({ observation: "(no UI tree returned)", screenId: null });
5100
5285
  }
5101
- const screenId = ingestSnapshot(resolved, iteration);
5102
- captureScreenshot(screenshot, screenId);
5103
- return { observation: resolved.observation, screenId };
5286
+ return absorbResolvedObservation(resolved, actionResult.screenshot, iteration, source);
5104
5287
  };
5105
5288
  switch (toolName) {
5106
5289
  // ----- Observation -----
@@ -5110,12 +5293,16 @@ Exploration complete: ${summary}`);
5110
5293
  if (!resolved) {
5111
5294
  return { ok: false, error: "snapshot returned no UI tree" };
5112
5295
  }
5113
- const screenId = ingestSnapshot(resolved, iteration);
5114
- captureScreenshot(snap.screenshot, screenId);
5115
- log2(` Snapshot: screen ${screenId} (${resolved.screen.elements.length} elements)`);
5296
+ const absorbed = await absorbResolvedObservation(resolved, snap.screenshot, iteration, "mobile_snapshot");
5297
+ if (absorbed.screenId) {
5298
+ const elementCount = state2.getScreen(absorbed.screenId)?.elements.length ?? resolved.screen.elements.length;
5299
+ log2(` Snapshot: screen ${absorbed.screenId} (${elementCount} elements)`);
5300
+ }
5116
5301
  return {
5117
- screenId,
5118
- observation: resolved.observation,
5302
+ screenId: absorbed.screenId,
5303
+ observation: absorbed.observation,
5304
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5305
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
5119
5306
  progress: `${state2.getScreenCount()} screens, ${collectedTransitions.length} transitions, ${journeys.length} journeys`
5120
5307
  };
5121
5308
  }
@@ -5126,14 +5313,21 @@ Exploration complete: ${summary}`);
5126
5313
  return { ok: false, error: `Unknown element ref "${String(toolArgs.ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.` };
5127
5314
  }
5128
5315
  const fromScreenId = state2.getCurrentScreenId();
5316
+ const boundaryBeforeAction = currentBoundary();
5317
+ const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
5129
5318
  const actionResult = await driver.tap(resolved.bounds);
5130
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5131
- autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, String(toolArgs.ref), "tap");
5319
+ const absorbed = await absorbActionResult(actionResult, "mobile_tap");
5320
+ const { observation, screenId } = absorbed;
5321
+ if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
5322
+ autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, String(toolArgs.ref), "tap");
5323
+ }
5132
5324
  return {
5133
5325
  ok: actionResult.ok,
5134
5326
  ...actionResult.error ? { error: actionResult.error } : {},
5135
5327
  screenId,
5136
- observation
5328
+ observation,
5329
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5330
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5137
5331
  };
5138
5332
  }
5139
5333
  // ----- Type -----
@@ -5144,12 +5338,15 @@ Exploration complete: ${summary}`);
5144
5338
  }
5145
5339
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
5146
5340
  const actionResult = await driver.type(value, resolved.bounds);
5147
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5341
+ const absorbed = await absorbActionResult(actionResult, "mobile_type");
5342
+ const { observation, screenId } = absorbed;
5148
5343
  return {
5149
5344
  ok: actionResult.ok,
5150
5345
  ...actionResult.error ? { error: actionResult.error } : {},
5151
5346
  screenId,
5152
- observation
5347
+ observation,
5348
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5349
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5153
5350
  };
5154
5351
  }
5155
5352
  // ----- Clear -----
@@ -5159,12 +5356,15 @@ Exploration complete: ${summary}`);
5159
5356
  return { ok: false, error: `Unknown element ref "${String(toolArgs.ref)}". Call mobile_snapshot first.` };
5160
5357
  }
5161
5358
  const actionResult = await driver.clear(resolved.bounds);
5162
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5359
+ const absorbed = await absorbActionResult(actionResult, "mobile_clear");
5360
+ const { observation, screenId } = absorbed;
5163
5361
  return {
5164
5362
  ok: actionResult.ok,
5165
5363
  ...actionResult.error ? { error: actionResult.error } : {},
5166
5364
  screenId,
5167
- observation
5365
+ observation,
5366
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5367
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5168
5368
  };
5169
5369
  }
5170
5370
  // ----- Swipe -----
@@ -5175,36 +5375,49 @@ Exploration complete: ${summary}`);
5175
5375
  }
5176
5376
  const distance = typeof toolArgs.distance === "number" ? toolArgs.distance : void 0;
5177
5377
  const actionResult = await driver.swipe(direction, distance);
5178
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5378
+ const absorbed = await absorbActionResult(actionResult, "mobile_swipe");
5379
+ const { observation, screenId } = absorbed;
5179
5380
  return {
5180
5381
  ok: actionResult.ok,
5181
5382
  ...actionResult.error ? { error: actionResult.error } : {},
5182
5383
  screenId,
5183
- observation
5384
+ observation,
5385
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5386
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5184
5387
  };
5185
5388
  }
5186
5389
  // ----- Back -----
5187
5390
  case "mobile_back": {
5188
5391
  const fromScreenId = state2.getCurrentScreenId();
5392
+ const boundaryBeforeAction = currentBoundary();
5393
+ const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
5189
5394
  const actionResult = await driver.back();
5190
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5191
- autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "back");
5395
+ const absorbed = await absorbActionResult(actionResult, "mobile_back");
5396
+ const { observation, screenId } = absorbed;
5397
+ if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
5398
+ autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "back");
5399
+ }
5192
5400
  return {
5193
5401
  ok: actionResult.ok,
5194
5402
  ...actionResult.error ? { error: actionResult.error } : {},
5195
5403
  screenId,
5196
- observation
5404
+ observation,
5405
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5406
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5197
5407
  };
5198
5408
  }
5199
5409
  // ----- Home (backgrounds only) -----
5200
5410
  case "mobile_home": {
5201
5411
  const actionResult = await driver.home();
5202
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5412
+ const absorbed = await absorbActionResult(actionResult, "mobile_home");
5413
+ const { observation, screenId } = absorbed;
5203
5414
  return {
5204
5415
  ok: actionResult.ok,
5205
5416
  ...actionResult.error ? { error: actionResult.error } : {},
5206
5417
  screenId,
5207
5418
  observation,
5419
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5420
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
5208
5421
  note: "Home only backgrounds the app. Use mobile_relaunch for a clean restart."
5209
5422
  };
5210
5423
  }
@@ -5217,9 +5430,15 @@ Exploration complete: ${summary}`);
5217
5430
  if (!resolved) {
5218
5431
  return { ok: true, note: "relaunched; no UI tree returned \u2014 call mobile_snapshot." };
5219
5432
  }
5220
- const screenId = ingestSnapshot(resolved, iteration);
5221
- captureScreenshot(snap.screenshot, screenId);
5222
- return { ok: true, screenId, observation: resolved.observation, note: "App relaunched (clean state)." };
5433
+ const absorbed = await absorbResolvedObservation(resolved, snap.screenshot, iteration, "mobile_relaunch");
5434
+ return {
5435
+ ok: true,
5436
+ screenId: absorbed.screenId,
5437
+ observation: absorbed.observation,
5438
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5439
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
5440
+ note: "App relaunched (clean state)."
5441
+ };
5223
5442
  }
5224
5443
  // ----- Deep-link (guarded) -----
5225
5444
  case "mobile_open_deeplink": {
@@ -5228,18 +5447,32 @@ Exploration complete: ${summary}`);
5228
5447
  return { ok: false, error: `Refused to open deep link "${deeplink}" (empty or dangerous scheme).` };
5229
5448
  }
5230
5449
  const fromScreenId = state2.getCurrentScreenId();
5450
+ const boundaryBeforeAction = currentBoundary();
5451
+ const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
5231
5452
  const actionResult = await driver.openDeepLink(deeplink);
5232
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5233
- autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "deeplink");
5453
+ const absorbed = await absorbActionResult(actionResult, "mobile_open_deeplink");
5454
+ const { observation, screenId } = absorbed;
5455
+ if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
5456
+ autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "deeplink");
5457
+ }
5234
5458
  return {
5235
5459
  ok: actionResult.ok,
5236
5460
  ...actionResult.error ? { error: actionResult.error } : {},
5237
5461
  screenId,
5238
- observation
5462
+ observation,
5463
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5464
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5239
5465
  };
5240
5466
  }
5241
5467
  // ----- capture_screen (mark + annotate current screen) -----
5242
5468
  case "capture_screen": {
5469
+ const boundary = currentBoundary();
5470
+ if (boundary.kind === "external" || boundary.kind === "transient_external") {
5471
+ return {
5472
+ ok: false,
5473
+ error: `Current foreground app is ${boundary.owner}, outside target app ${boundary.targetAppId}. It was not captured. Return to the target app with mobile_back or mobile_relaunch.`
5474
+ };
5475
+ }
5243
5476
  const screenId = state2.getCurrentScreenId();
5244
5477
  if (!screenId) {
5245
5478
  return { ok: false, error: "No current screen \u2014 call mobile_snapshot first." };
@@ -5290,6 +5523,13 @@ Exploration complete: ${summary}`);
5290
5523
  if (!enableRecordJourney) {
5291
5524
  return { ok: false, error: "record_journey is not available in survey mode." };
5292
5525
  }
5526
+ const boundary = currentBoundary();
5527
+ if (boundary.kind === "external" || boundary.kind === "transient_external") {
5528
+ return {
5529
+ ok: false,
5530
+ error: `Current foreground app is ${boundary.owner}, outside target app ${boundary.targetAppId}. External app screens cannot be recorded as a target-app journey.`
5531
+ };
5532
+ }
5293
5533
  const name = typeof toolArgs.name === "string" ? toolArgs.name : "";
5294
5534
  const screenId = typeof toolArgs.screenId === "string" && toolArgs.screenId ? toolArgs.screenId : state2.getCurrentScreenId() ?? "";
5295
5535
  const steps = Array.isArray(toolArgs.steps) ? toolArgs.steps.filter((s) => typeof s === "string") : [];
@@ -7080,7 +7320,7 @@ Each entry is exactly one of:
7080
7320
  const existingNamesLower = new Set(existingTestNames.map((n) => n.toLowerCase()));
7081
7321
  const usedNames = /* @__PURE__ */ new Set();
7082
7322
  const validTypes = /* @__PURE__ */ new Set(["HAPPY_PATH", "EDGE_CASE", "USER_JOURNEY", "REGRESSION", "BUG_REPORT"]);
7083
- const validOutcomeTypes = /* @__PURE__ */ new Set(["url", "element-visible", "text-visible", "value-match", "element-hidden"]);
7323
+ const validOutcomeTypes = /* @__PURE__ */ new Set(["url", "screen-visible", "element-visible", "text-visible", "value-match", "element-hidden"]);
7084
7324
  const validVariants = /* @__PURE__ */ new Set(["happy", "validation", "bug"]);
7085
7325
  const validCriticalities = /* @__PURE__ */ new Set(["critical", "high", "medium", "low"]);
7086
7326
  const inferOutcomeTypeFromDescription = (description) => /\b(?:heading|button|link|menu\s*item|menuitem|tab|card|modal|dialog|sidebar(?:\s+item)?|nav\s+item|navigation\s+link|form\s+label|field\s+label|avatar|banner)\b/i.test(description) ? "element-visible" : "text-visible";
@@ -7554,8 +7794,8 @@ Each entry is exactly one of:
7554
7794
  const agentModel = options.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
7555
7795
  const plannerMaxTokens = getAIPlannerMaxTokens(agentModel);
7556
7796
  logs2.push(`AI planner [${context.mode}]: ${siteMap.pages.length} pages, ${evidence.recordedJourneys.length} journeys, budget ${context.testBudget}`);
7557
- const systemPrompt = context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports.SCENARIO_SYSTEM_PROMPT;
7558
- const userPrompt = formatEvidenceForPlanner(compressed, evidence, context);
7797
+ const systemPrompt = options.systemPromptOverride ?? (context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports.SCENARIO_SYSTEM_PROMPT);
7798
+ const userPrompt = options.userPromptOverride ?? formatEvidenceForPlanner(compressed, evidence, context);
7559
7799
  logs2.push(`AI planner: ~${Math.ceil(userPrompt.length / 3.5)} input tokens`);
7560
7800
  const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)());
7561
7801
  const messages = [
@@ -7771,6 +8011,14 @@ var require_mobile_discovery_prompts = __commonJS({
7771
8011
  exports.runMobileAIPlanner = runMobileAIPlanner;
7772
8012
  var ai_provider_js_1 = require_ai_provider();
7773
8013
  var discover_ai_planner_js_1 = require_discover_ai_planner();
8014
+ function isTargetAppScreenId(screenId, appId) {
8015
+ if (!appId)
8016
+ return true;
8017
+ return screenId === appId || screenId.startsWith(`${appId}#`) || screenId.startsWith(`${appId}.`);
8018
+ }
8019
+ function targetAppScreens(graph, appId) {
8020
+ return [...graph.screens.values()].filter((state2) => isTargetAppScreenId(state2.screenId, appId));
8021
+ }
7774
8022
  exports.MOBILE_SURVEY_SYSTEM_PROMPT = `You are a feature analyst for a native mobile application (iOS or Android). You receive evidence from an AI explorer that mapped the app's screens by tapping, typing, and swiping through the live UI. Your job is to organize screens into meaningful feature groups.
7775
8023
 
7776
8024
  ## Output Discipline
@@ -8000,7 +8248,8 @@ ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
8000
8248
  if (context.featureName) {
8001
8249
  sections.push(`## Feature: ${context.featureName}`);
8002
8250
  }
8003
- const orderedScreens = [...graph.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8251
+ const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8252
+ const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
8004
8253
  sections.push(`## Screens Discovered (${orderedScreens.length})`);
8005
8254
  for (const state2 of orderedScreens) {
8006
8255
  const name = screenDisplayName(state2);
@@ -8022,7 +8271,7 @@ ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
8022
8271
  for (const state2 of orderedScreens)
8023
8272
  nameById.set(state2.screenId, screenDisplayName(state2));
8024
8273
  const lines = ["## Observed Screen Transitions"];
8025
- for (const t of graph.transitions.slice(0, MAX_TRANSITIONS)) {
8274
+ for (const t of graph.transitions.filter((transition) => includedScreenIds.has(transition.fromScreenId) && includedScreenIds.has(transition.toScreenId)).slice(0, MAX_TRANSITIONS)) {
8026
8275
  const from = nameById.get(t.fromScreenId) ?? t.fromScreenId;
8027
8276
  const to = nameById.get(t.toScreenId) ?? t.toScreenId;
8028
8277
  const via = t.viaRef ? ` via ${t.viaRef}` : "";
@@ -8108,7 +8357,7 @@ ${constraints.join("\n")}`);
8108
8357
  const now = /* @__PURE__ */ new Date();
8109
8358
  const idByScreen = /* @__PURE__ */ new Map();
8110
8359
  let pageSeq = 0;
8111
- const orderedScreens = [...graph.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8360
+ const orderedScreens = targetAppScreens(graph, meta.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8112
8361
  for (const state2 of orderedScreens) {
8113
8362
  idByScreen.set(state2.screenId, `mpage-${pageSeq++}`);
8114
8363
  }
@@ -8167,6 +8416,135 @@ ${constraints.join("\n")}`);
8167
8416
  seedFixtures: []
8168
8417
  };
8169
8418
  }
8419
+ function buildMobileFeatureGroupsFromGraph(graph, appId) {
8420
+ const groups = /* @__PURE__ */ new Map();
8421
+ const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8422
+ const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
8423
+ for (const state2 of orderedScreens) {
8424
+ const groupName = state2.screenType === "auth" ? "Authentication" : screenDisplayName(state2);
8425
+ const group = groups.get(groupName) ?? {
8426
+ name: groupName,
8427
+ description: `Native mobile screens related to ${groupName}.`,
8428
+ routes: [],
8429
+ criticality: state2.screenType === "auth" ? "critical" : "medium",
8430
+ pages: [],
8431
+ flows: []
8432
+ };
8433
+ group.routes.push(state2.screenId);
8434
+ group.pages?.push({
8435
+ route: state2.screenId,
8436
+ type: state2.screenType ?? "UNKNOWN",
8437
+ summary: `${state2.elements.filter((el) => el.actionable).length} actionable element(s)`
8438
+ });
8439
+ groups.set(groupName, group);
8440
+ }
8441
+ const nameById = /* @__PURE__ */ new Map();
8442
+ for (const state2 of graph.screens.values()) {
8443
+ nameById.set(state2.screenId, screenDisplayName(state2));
8444
+ }
8445
+ for (const transition of graph.transitions) {
8446
+ if (!includedScreenIds.has(transition.fromScreenId) || !includedScreenIds.has(transition.toScreenId))
8447
+ continue;
8448
+ const from = graph.screens.get(transition.fromScreenId);
8449
+ const to = graph.screens.get(transition.toScreenId);
8450
+ if (!from || !to)
8451
+ continue;
8452
+ const groupName = from.screenType === "auth" ? "Authentication" : screenDisplayName(from);
8453
+ const group = groups.get(groupName);
8454
+ if (!group)
8455
+ continue;
8456
+ const via = transition.viaRef ? ` via ${transition.viaRef}` : "";
8457
+ group.flows?.push(`${nameById.get(transition.fromScreenId) ?? transition.fromScreenId} \u2192 ${nameById.get(transition.toScreenId) ?? transition.toScreenId} (${transition.action}${via})`);
8458
+ }
8459
+ return [...groups.values()];
8460
+ }
8461
+ function buildMobileFallbackScenarios(input) {
8462
+ if (input.mode === "survey")
8463
+ return [];
8464
+ const orderedScreens = targetAppScreens(input.graph, input.appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").filter((state2) => state2.visited || state2.firstSeenIteration === 0).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8465
+ if (orderedScreens.length === 0)
8466
+ return [];
8467
+ const entryScreenId = orderedScreens[0].screenId;
8468
+ const usedNames = new Set((input.existingTestNames ?? []).map((name) => name.toLowerCase()));
8469
+ const scenarios = [];
8470
+ const budget = Math.max(1, input.testBudget || orderedScreens.length);
8471
+ for (const state2 of orderedScreens) {
8472
+ if (scenarios.length >= budget)
8473
+ break;
8474
+ const screenName = screenDisplayName(state2);
8475
+ const baseName = `${screenName} - Screen Reachability`;
8476
+ const name = usedNames.has(baseName.toLowerCase()) ? `${baseName} ${state2.firstSeenIteration + 1}` : baseName;
8477
+ if (usedNames.has(name.toLowerCase()))
8478
+ continue;
8479
+ usedNames.add(name.toLowerCase());
8480
+ const actionableLabels = state2.elements.filter((el) => el.actionable).map((el) => el.label?.trim() || el.text?.trim() || el.accessibilityId || shortResourceId(el.resourceId ?? "") || compactRole(el.role)).filter((label) => Boolean(label)).slice(0, 3);
8481
+ const steps = [
8482
+ { action: "Relaunch the app from a clean state", target: entryScreenId },
8483
+ {
8484
+ action: state2.screenId === entryScreenId ? `Verify the launch screen is ${screenName}` : `Navigate through the app until the ${screenName} screen is visible`,
8485
+ target: state2.screenId
8486
+ }
8487
+ ];
8488
+ if (actionableLabels.length > 0) {
8489
+ steps.push({
8490
+ action: `Verify expected native controls are available: ${actionableLabels.join(", ")}`,
8491
+ target: state2.screenId
8492
+ });
8493
+ }
8494
+ scenarios.push({
8495
+ name,
8496
+ type: "HAPPY_PATH",
8497
+ priority: state2.screenType === "auth" ? 1 : 3,
8498
+ variant: "happy",
8499
+ featureGroup: state2.screenType === "auth" ? "Authentication" : screenName,
8500
+ goal: `User can reach and inspect the ${screenName} screen`,
8501
+ steps,
8502
+ expectedOutcomes: [{
8503
+ type: "screen-visible",
8504
+ description: `${screenName} screen is visible`,
8505
+ reliability: "medium"
8506
+ }],
8507
+ entryRoute: entryScreenId,
8508
+ startFromLanding: true,
8509
+ targetPages: [state2.screenId],
8510
+ evidence: { observedLocators: [], observedTransitions: [] },
8511
+ prerequisites: []
8512
+ });
8513
+ }
8514
+ return scenarios;
8515
+ }
8516
+ function filterRecordedJourneysToSiteMap(journeys, siteMap) {
8517
+ if (!journeys?.length)
8518
+ return [];
8519
+ const validRoutes = new Set(siteMap.pages.map((page) => page.route));
8520
+ const filtered = [];
8521
+ for (const journey of journeys) {
8522
+ const routes = (journey.routes?.length ? journey.routes : [journey.route]).filter((route2) => validRoutes.has(route2));
8523
+ if (routes.length === 0)
8524
+ continue;
8525
+ filtered.push({
8526
+ ...journey,
8527
+ route: validRoutes.has(journey.route) ? journey.route : routes[0],
8528
+ routes
8529
+ });
8530
+ }
8531
+ return filtered;
8532
+ }
8533
+ function filterFeatureGroupsToSiteMap(groups, siteMap) {
8534
+ if (groups.length === 0)
8535
+ return [];
8536
+ const validRoutes = new Set(siteMap.pages.map((page) => page.route));
8537
+ return groups.flatMap((group) => {
8538
+ const routes = group.routes.filter((route2) => validRoutes.has(route2));
8539
+ if (routes.length === 0)
8540
+ return [];
8541
+ return [{
8542
+ ...group,
8543
+ routes,
8544
+ pages: group.pages?.filter((page) => validRoutes.has(page.route))
8545
+ }];
8546
+ });
8547
+ }
8170
8548
  async function runMobileAIPlanner(input, options = {}) {
8171
8549
  const planner = options.planner ?? discover_ai_planner_js_1.runAIPlanner;
8172
8550
  const shouldBuildMobileAIClient = (process.env.SERVER_URL ?? "").trim().length > 0 || !options.planner;
@@ -8175,11 +8553,12 @@ ${constraints.join("\n")}`);
8175
8553
  projectId: input.projectId,
8176
8554
  appId: input.appId
8177
8555
  });
8556
+ const recordedJourneys = filterRecordedJourneysToSiteMap(input.recordedJourneys, siteMap);
8178
8557
  const collectedPages = input.collectedPages ?? [];
8179
8558
  const collectedTransitions = input.collectedTransitions ?? [];
8180
- const visitedRoutes = [...input.graph.screens.values()].filter((s) => s.visited).map((s) => s.screenId);
8181
- return planner(siteMap, {
8182
- recordedJourneys: input.recordedJourneys ?? [],
8559
+ const visitedRoutes = targetAppScreens(input.graph, input.appId).filter((s) => s.visited).map((s) => s.screenId);
8560
+ const result = await planner(siteMap, {
8561
+ recordedJourneys,
8183
8562
  collectedPages,
8184
8563
  collectedTransitions,
8185
8564
  visitedRoutes
@@ -8197,8 +8576,52 @@ ${constraints.join("\n")}`);
8197
8576
  }, {
8198
8577
  modelOverride: options.modelOverride,
8199
8578
  onAICall: options.onAICall,
8200
- ...aiClient ? { aiClient } : {}
8579
+ ...aiClient ? { aiClient } : {},
8580
+ systemPromptOverride: input.mode === "survey" ? exports.MOBILE_SURVEY_SYSTEM_PROMPT : exports.MOBILE_SCENARIO_SYSTEM_PROMPT,
8581
+ userPromptOverride: formatMobileEvidenceForPlanner(input.graph, {
8582
+ appBrief: input.appBrief,
8583
+ appId: input.appId,
8584
+ platform: input.platform,
8585
+ featureName: input.featureName,
8586
+ existingTestNames: input.existingTestNames,
8587
+ existingFeatureNames: input.existingFeatureNames,
8588
+ recordedJourneys,
8589
+ credentials: input.credentials,
8590
+ testBudget: input.testBudget,
8591
+ incremental: input.incremental
8592
+ })
8201
8593
  });
8594
+ const plannerFeatureGroups = filterFeatureGroupsToSiteMap(result.featureGroups, siteMap);
8595
+ const mobileFeatureGroups = plannerFeatureGroups.length > 0 ? plannerFeatureGroups : buildMobileFeatureGroupsFromGraph(input.graph, input.appId);
8596
+ if (input.mode !== "survey" && result.scenarios.length === 0 && input.graph.screens.size > 0) {
8597
+ const fallbackScenarios = buildMobileFallbackScenarios(input);
8598
+ if (fallbackScenarios.length > 0) {
8599
+ return {
8600
+ ...result,
8601
+ scenarios: fallbackScenarios,
8602
+ featureGroups: mobileFeatureGroups,
8603
+ logs: [
8604
+ ...result.logs,
8605
+ `Mobile fallback: synthesized ${fallbackScenarios.length} screen-reachability scenario(s) from the native screen graph`
8606
+ ],
8607
+ fallbackUsed: true,
8608
+ fallbackReason: result.fallbackReason ?? "Mobile planner produced no scenarios"
8609
+ };
8610
+ }
8611
+ }
8612
+ if (input.mode === "survey" && result.featureGroups.length === 0 && mobileFeatureGroups.length > 0) {
8613
+ return {
8614
+ ...result,
8615
+ featureGroups: mobileFeatureGroups,
8616
+ logs: [
8617
+ ...result.logs,
8618
+ `Mobile fallback: synthesized ${mobileFeatureGroups.length} feature group(s) from the native screen graph`
8619
+ ],
8620
+ fallbackUsed: true,
8621
+ fallbackReason: result.fallbackReason ?? "Mobile planner produced no feature groups"
8622
+ };
8623
+ }
8624
+ return { ...result, featureGroups: mobileFeatureGroups };
8202
8625
  }
8203
8626
  }
8204
8627
  });
@@ -8217,6 +8640,7 @@ var require_mobile_generator = __commonJS({
8217
8640
  var mobile_source_parser_js_1 = require_mobile_source_parser();
8218
8641
  var mobile_source_parser_js_2 = require_mobile_source_parser();
8219
8642
  var mobile_observation_js_1 = require_mobile_observation();
8643
+ var mobile_boundary_js_1 = require_mobile_boundary();
8220
8644
  var MAX_SCENARIO_ITERATIONS = 30;
8221
8645
  var MAX_RECENT_EXCHANGES = 24;
8222
8646
  var OBSERVATION_MAX_ELEMENTS = 50;
@@ -8402,8 +8826,9 @@ var require_mobile_generator = __commonJS({
8402
8826
  function hasStableId(element) {
8403
8827
  return !!nonEmpty2(element.accessibilityId) || !!nonEmpty2(element.resourceId);
8404
8828
  }
8405
- function buildSystemPrompt(platform3) {
8829
+ function buildSystemPrompt(platform3, targetAppId) {
8406
8830
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
8831
+ const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
8407
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.
8408
8833
 
8409
8834
  ## How the loop works
@@ -8423,7 +8848,8 @@ var require_mobile_generator = __commonJS({
8423
8848
  - Re-snapshot before every assertion so the ref is fresh.
8424
8849
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
8425
8850
  - Do NOT trigger irreversible side effects (real payments, sending messages to others) \u2014 note them and assert what you safely can.
8426
- - 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.
8427
8853
 
8428
8854
  Max iterations: ${MAX_SCENARIO_ITERATIONS}. You will be warned near the end.`;
8429
8855
  }
@@ -8496,7 +8922,7 @@ Start by calling mobile_snapshot, then execute the steps. Finish with at least o
8496
8922
  }
8497
8923
  }
8498
8924
  async function runScenarioLoop(args) {
8499
- 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;
8500
8926
  const actions = [];
8501
8927
  let assertCount = 0;
8502
8928
  let finish = null;
@@ -8510,14 +8936,98 @@ Start by calling mobile_snapshot, then execute the steps. Finish with at least o
8510
8936
  const fromActivity = signal.activity ? signal.activity.split(".").pop() : void 0;
8511
8937
  return nonEmpty2(fromActivity) ?? nonEmpty2(signal.bundleId) ?? `screen-${signal.screenHash.slice(0, 8)}`;
8512
8938
  };
8513
- const ingest = (xml, driverSignal, windowSize) => {
8514
- if (!xml)
8515
- return null;
8516
- const resolved = resolveSnapshot(xml, windowSize ?? latest?.screen.windowSize ?? null, driverSignal, platform3);
8939
+ const setLatest = (resolved) => {
8517
8940
  latest = resolved;
8518
8941
  lastScreenName = screenNameFor(resolved);
8519
8942
  return resolved;
8520
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
+ };
8521
9031
  const resolveRef = (ref) => {
8522
9032
  if (typeof ref !== "string" || !latest)
8523
9033
  return null;
@@ -8525,11 +9035,11 @@ Start by calling mobile_snapshot, then execute the steps. Finish with at least o
8525
9035
  };
8526
9036
  try {
8527
9037
  const seed = await driver.snapshot();
8528
- ingest(seed.xml, seed.screenSignal, seed.windowSize);
9038
+ await absorbObservation(seed.xml, seed.screenSignal, seed.windowSize, seed.screenshot, "seed snapshot");
8529
9039
  } catch (err) {
8530
9040
  log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
8531
9041
  }
8532
- const systemPrompt = buildSystemPrompt(platform3);
9042
+ const systemPrompt = buildSystemPrompt(platform3, targetAppId);
8533
9043
  const kickoff = buildScenarioKickoff(scenario);
8534
9044
  const recentExchanges = [];
8535
9045
  let iteration = 0;
@@ -8640,7 +9150,7 @@ ${statePacket}` },
8640
9150
  driver,
8641
9151
  platform: platform3,
8642
9152
  latest: getLatest,
8643
- ingest,
9153
+ absorbObservation,
8644
9154
  resolveRef,
8645
9155
  screenName: () => lastScreenName || "UnknownScreen",
8646
9156
  recordAction: (action) => {
@@ -8663,60 +9173,85 @@ ${statePacket}` },
8663
9173
  return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
8664
9174
  }
8665
9175
  async function dispatchGenerateTool(args) {
8666
- 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;
8667
9177
  const unknownRef = (ref) => ({
8668
9178
  ok: false,
8669
9179
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
8670
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;
8671
9193
  switch (toolName) {
8672
9194
  case "mobile_snapshot": {
8673
9195
  const snap = await driver.snapshot();
8674
- const resolved = ingest(snap.xml, snap.screenSignal, snap.windowSize);
8675
- if (!resolved)
9196
+ const absorbed = await absorbObservation(snap.xml, snap.screenSignal, snap.windowSize, snap.screenshot, "mobile_snapshot");
9197
+ if (!absorbed.resolved)
8676
9198
  return { ok: false, error: "snapshot returned no UI tree" };
8677
- log2(` snapshot: ${resolved.screen.elements.length} elements`);
8678
- 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
+ };
8679
9205
  }
8680
9206
  case "mobile_tap": {
8681
9207
  const element = resolveRef(toolArgs.ref);
8682
9208
  if (!element)
8683
9209
  return unknownRef(toolArgs.ref);
9210
+ const beforeScreenName = screenName();
8684
9211
  const result = await driver.tap(element.bounds);
8685
- recordAction({
8686
- type: "TAP",
8687
- selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
8688
- metadata: metadataForElement(element, screenName(), platform3)
8689
- });
8690
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
8691
- 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);
8692
9221
  }
8693
9222
  case "mobile_type": {
8694
9223
  const element = resolveRef(toolArgs.ref);
8695
9224
  if (!element)
8696
9225
  return unknownRef(toolArgs.ref);
8697
9226
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
9227
+ const beforeScreenName = screenName();
8698
9228
  const result = await driver.type(value, element.bounds);
8699
- recordAction({
8700
- type: "TYPE",
8701
- value,
8702
- selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
8703
- metadata: metadataForElement(element, screenName(), platform3)
8704
- });
8705
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
8706
- 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);
8707
9239
  }
8708
9240
  case "mobile_clear": {
8709
9241
  const element = resolveRef(toolArgs.ref);
8710
9242
  if (!element)
8711
9243
  return unknownRef(toolArgs.ref);
9244
+ const beforeScreenName = screenName();
8712
9245
  const result = await driver.clear(element.bounds);
8713
- recordAction({
8714
- type: "CLEAR",
8715
- selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
8716
- metadata: metadataForElement(element, screenName(), platform3)
8717
- });
8718
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
8719
- 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);
8720
9255
  }
8721
9256
  case "mobile_swipe": {
8722
9257
  const direction = toolArgs.direction;
@@ -8724,30 +9259,42 @@ ${statePacket}` },
8724
9259
  return { ok: false, error: "direction must be one of up|down|left|right" };
8725
9260
  }
8726
9261
  const distance = typeof toolArgs.distance === "number" ? toolArgs.distance : void 0;
9262
+ const beforeScreenName = screenName();
8727
9263
  const result = await driver.swipe(direction, distance);
8728
- recordAction({
8729
- type: "SWIPE",
8730
- metadata: {
8731
- screenName: screenName(),
8732
- direction,
8733
- ...distance !== void 0 ? { distance } : {}
8734
- }
8735
- });
8736
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
8737
- 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);
8738
9276
  }
8739
9277
  case "mobile_back": {
9278
+ const beforeScreenName = screenName();
8740
9279
  const result = await driver.back();
8741
- recordAction({ type: "BACK", metadata: { screenName: screenName() } });
8742
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
8743
- 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);
8744
9285
  }
8745
9286
  case "mobile_relaunch": {
8746
9287
  await driver.relaunch();
8747
9288
  const snap = await driver.snapshot();
8748
- const resolved = ingest(snap.xml, snap.screenSignal, snap.windowSize);
9289
+ const absorbed = await absorbObservation(snap.xml, snap.screenSignal, snap.windowSize, snap.screenshot, "mobile_relaunch");
8749
9290
  log2(" relaunched app for hermetic reset.");
8750
- 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
+ };
8751
9298
  }
8752
9299
  case "mobile_assert_visible": {
8753
9300
  const recorded = recordAssertVisible(toolArgs, { resolveRef, screenName, platform: platform3, recordAction });
@@ -8885,7 +9432,7 @@ ${statePacket}` },
8885
9432
  return test;
8886
9433
  }
8887
9434
  async function runMobileGeneratePhase(args) {
8888
- const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated } = args;
9435
+ const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot } = args;
8889
9436
  const log2 = (line) => {
8890
9437
  try {
8891
9438
  onLog?.(line);
@@ -8899,6 +9446,7 @@ ${statePacket}` },
8899
9446
  const provider = (0, ai_provider_js_1.getProviderForModel)(agentModel);
8900
9447
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
8901
9448
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
9449
+ const targetAppId = ctx.target?.appId?.trim() || void 0;
8902
9450
  log2(`Mobile generator starting \u2014 platform: ${platform3}, provider: ${provider}, model: ${agentModel}, scenarios: ${scenarios.length}`);
8903
9451
  const tests = [];
8904
9452
  for (let i = 0; i < scenarios.length; i++) {
@@ -8917,6 +9465,8 @@ ${statePacket}` },
8917
9465
  aiClient,
8918
9466
  auditGroupId,
8919
9467
  onAICall,
9468
+ onScreenshot,
9469
+ targetAppId,
8920
9470
  log: log2
8921
9471
  });
8922
9472
  const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
@@ -8924,13 +9474,17 @@ ${statePacket}` },
8924
9474
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
8925
9475
  continue;
8926
9476
  }
8927
- tests.push(test);
8928
9477
  log2(` \u2713 generated "${test.name}" (verified: ${test.verified}, steps: ${test.steps?.length ?? 0})`);
8929
9478
  try {
8930
- onTestGenerated?.(test);
9479
+ await onTestGenerated?.(test);
8931
9480
  } catch (cbErr) {
9481
+ if (cbErr?.isPlanLimitReached) {
9482
+ log2(" Plan limit reached \u2014 stopping further generation");
9483
+ break;
9484
+ }
8932
9485
  log2(` onTestGenerated callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
8933
9486
  }
9487
+ tests.push(test);
8934
9488
  } catch (err) {
8935
9489
  log2(` scenario "${scenario.name}" failed: ${err instanceof Error ? err.message : String(err)} \u2014 skipping.`);
8936
9490
  continue;
@@ -12611,6 +13165,17 @@ var require_snapshot_observation = __commonJS({
12611
13165
  for (const container of group.containers) {
12612
13166
  lines.push(` - ${yamlScalar(container ?? "(none)")}`);
12613
13167
  }
13168
+ const hasRowContext = group.rowContexts.some((rc) => !!rc);
13169
+ if (hasRowContext) {
13170
+ lines.push(' distinguish_by_row: # same name in different rows \u2014 scope by this text, e.g. getByRole(role, { name }).filter({ has: getByText("<row>") })');
13171
+ for (let i = 0; i < group.rowContexts.length; i++) {
13172
+ const rowContext = group.rowContexts[i];
13173
+ if (!rowContext)
13174
+ continue;
13175
+ const ref = group.refs[i];
13176
+ lines.push(` - ${yamlScalar(`${ref ? `[${ref}] ` : ""}${rowContext}`)}`);
13177
+ }
13178
+ }
12614
13179
  }
12615
13180
  }
12616
13181
  lines.push("interactive_elements:");
@@ -12642,6 +13207,29 @@ var require_snapshot_observation = __commonJS({
12642
13207
  if (topLocator) {
12643
13208
  lines.push(` locator: ${yamlScalar((0, snapshot_parser_js_1.formatLocator)(topLocator))}`);
12644
13209
  }
13210
+ const enr = rankedElement.element.enrichment;
13211
+ if (enr?.rowContext) {
13212
+ lines.push(` row_context: ${yamlScalar(enr.rowContext)}`);
13213
+ }
13214
+ if (enr && (enr.occluded || enr.position && enr.position !== "in-view")) {
13215
+ const vis = [];
13216
+ if (enr.position && enr.position !== "in-view")
13217
+ vis.push(`off-screen-${enr.position}`);
13218
+ if (enr.occluded)
13219
+ vis.push("occluded");
13220
+ lines.push(` visibility: [${vis.map((token) => yamlScalar(token)).join(", ")}]`);
13221
+ }
13222
+ }
13223
+ }
13224
+ const recoveredClickables = options.recoveredClickables ?? [];
13225
+ if (recoveredClickables.length > 0) {
13226
+ lines.push("non_semantic_clickables:");
13227
+ lines.push(' # Clickable elements with NO accessibility role (e.g. <div onclick> / cursor:pointer) that the a11y snapshot drops. Some may be decorative \u2014 verify before relying on them, and prefer a scoped locator (e.g. page.getByRole("main").getByText("...")) over a bare getByText to avoid strict-mode ambiguity.');
13228
+ for (const clickable of recoveredClickables.slice(0, 8)) {
13229
+ lines.push(` - text: ${yamlScalar(clickable.text)}`);
13230
+ if (clickable.rowContext) {
13231
+ lines.push(` row_context: ${yamlScalar(clickable.rowContext)}`);
13232
+ }
12645
13233
  }
12646
13234
  }
12647
13235
  lines.push("observation_cues:");
@@ -12815,6 +13403,9 @@ var require_snapshot_observation = __commonJS({
12815
13403
  score += 12;
12816
13404
  }
12817
13405
  }
13406
+ const enr = element.enrichment;
13407
+ if (enr?.rowContext)
13408
+ score += 4;
12818
13409
  return score;
12819
13410
  }
12820
13411
  function buildStateSummary(element) {
@@ -12990,10 +13581,12 @@ var require_snapshot_observation = __commonJS({
12990
13581
  const key = `${role}\0${name}`;
12991
13582
  let group = groups.get(key);
12992
13583
  if (!group) {
12993
- group = { role, name, containers: [] };
13584
+ group = { role, name, containers: [], rowContexts: [], refs: [] };
12994
13585
  groups.set(key, group);
12995
13586
  }
12996
13587
  group.containers.push(element.containerPath ?? null);
13588
+ group.rowContexts.push(element.enrichment?.rowContext ?? null);
13589
+ group.refs.push(element.ref ?? null);
12997
13590
  }
12998
13591
  return [...groups.values()].filter((group) => group.containers.length > 1);
12999
13592
  }
@@ -234647,6 +235240,154 @@ var require_preflight_syntax = __commonJS({
234647
235240
  }
234648
235241
  });
234649
235242
 
235243
+ // ../runner-core/dist/services/selector-registry-context.js
235244
+ var require_selector_registry_context = __commonJS({
235245
+ "../runner-core/dist/services/selector-registry-context.js"(exports) {
235246
+ "use strict";
235247
+ Object.defineProperty(exports, "__esModule", { value: true });
235248
+ exports.formatSelectorRegistryContextForPrompt = formatSelectorRegistryContextForPrompt;
235249
+ function isRecord(value) {
235250
+ return value !== null && typeof value === "object" && !Array.isArray(value);
235251
+ }
235252
+ function asString(value, fallback = "") {
235253
+ return typeof value === "string" ? value : fallback;
235254
+ }
235255
+ function asNumber(value, fallback = 0) {
235256
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
235257
+ }
235258
+ function asStringArray(value) {
235259
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
235260
+ }
235261
+ function truncateForSelectorPrompt(value, maxLength = 180) {
235262
+ const normalized = (value ?? "").replace(/\s+/g, " ").trim();
235263
+ if (normalized.length <= maxLength)
235264
+ return normalized;
235265
+ return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
235266
+ }
235267
+ function inlineCode(value, maxLength = 180) {
235268
+ return `\`${truncateForSelectorPrompt(value, maxLength).replace(/`/g, "'")}\``;
235269
+ }
235270
+ function normalizeRef(value) {
235271
+ if (!isRecord(value))
235272
+ return null;
235273
+ const expression = asString(value.expression).trim();
235274
+ if (!expression)
235275
+ return null;
235276
+ return {
235277
+ route: asString(value.route, "*"),
235278
+ usage: asString(value.usage, "locator"),
235279
+ source: asString(value.source, "generated"),
235280
+ expression,
235281
+ occurrenceIndex: asNumber(value.occurrenceIndex, 0)
235282
+ };
235283
+ }
235284
+ function normalizeSibling(value) {
235285
+ if (!isRecord(value))
235286
+ return null;
235287
+ const testCaseId = asString(value.testCaseId).trim();
235288
+ const expression = asString(value.expression).trim();
235289
+ if (!testCaseId || !expression)
235290
+ return null;
235291
+ return {
235292
+ testCaseId,
235293
+ name: asString(value.name, testCaseId),
235294
+ suite: typeof value.suite === "string" ? value.suite : null,
235295
+ lastPassedAt: typeof value.lastPassedAt === "string" ? value.lastPassedAt : null,
235296
+ expression,
235297
+ usage: asString(value.usage, "locator"),
235298
+ route: asString(value.route, "*"),
235299
+ source: asString(value.source, "generated")
235300
+ };
235301
+ }
235302
+ function normalizeEntry(value) {
235303
+ if (!isRecord(value))
235304
+ return null;
235305
+ const id = asString(value.id).trim();
235306
+ const expression = asString(value.expression).trim();
235307
+ if (!id || !expression)
235308
+ return null;
235309
+ return {
235310
+ id,
235311
+ key: asString(value.key, id),
235312
+ route: asString(value.route, "*"),
235313
+ scope: asString(value.scope, "page"),
235314
+ status: asString(value.status, "ACTIVE"),
235315
+ stability: asNumber(value.stability, 1),
235316
+ label: asString(value.label),
235317
+ locatorKind: asString(value.locatorKind),
235318
+ locatorRole: typeof value.locatorRole === "string" ? value.locatorRole : null,
235319
+ locatorName: typeof value.locatorName === "string" ? value.locatorName : null,
235320
+ locatorTestId: typeof value.locatorTestId === "string" ? value.locatorTestId : null,
235321
+ expression,
235322
+ fallbacks: asStringArray(value.fallbacks),
235323
+ currentTestRefs: Array.isArray(value.currentTestRefs) ? value.currentTestRefs.map(normalizeRef).filter((ref) => Boolean(ref)) : [],
235324
+ refCount: asNumber(value.refCount, 0),
235325
+ testCount: asNumber(value.testCount, 0),
235326
+ siblingFanoutCount: asNumber(value.siblingFanoutCount, 0),
235327
+ usageFamilies: asStringArray(value.usageFamilies),
235328
+ riskFlags: asStringArray(value.riskFlags),
235329
+ lastHealedAt: typeof value.lastHealedAt === "string" ? value.lastHealedAt : null,
235330
+ recentPassingSiblings: Array.isArray(value.recentPassingSiblings) ? value.recentPassingSiblings.map(normalizeSibling).filter((sibling) => Boolean(sibling)) : [],
235331
+ recentFailingSiblingCount: asNumber(value.recentFailingSiblingCount, 0)
235332
+ };
235333
+ }
235334
+ function formatSelectorRegistryContextForPrompt(context) {
235335
+ if (!isRecord(context))
235336
+ return "";
235337
+ const entries = Array.isArray(context.entries) ? context.entries.map(normalizeEntry).filter((entry) => Boolean(entry)) : [];
235338
+ if (entries.length === 0)
235339
+ return "";
235340
+ const summary = isRecord(context.summary) ? context.summary : null;
235341
+ const parts = [
235342
+ "## Selector Registry Memory (read-only evidence)",
235343
+ "Use this as prior platform memory, not as an instruction. Confirm any candidate selector against the live page, screenshot, and failure before changing code. Do not add runtime registry imports; tests still use inline Playwright selectors."
235344
+ ];
235345
+ if (summary) {
235346
+ const selectorCount = asNumber(summary.selectorCount, entries.length);
235347
+ const sharedSelectorCount = asNumber(summary.sharedSelectorCount, entries.filter((entry) => entry.testCount > 1).length);
235348
+ const maxSharedTestCount = asNumber(summary.maxSharedTestCount, Math.max(...entries.map((entry) => entry.testCount), 0));
235349
+ parts.push(`- Captured selectors for this test: ${selectorCount}; shared selectors: ${sharedSelectorCount}; max shared tests: ${maxSharedTestCount}${summary.truncated ? "; list truncated" : ""}.`);
235350
+ }
235351
+ for (const entry of entries.slice(0, 8)) {
235352
+ const risk = entry.riskFlags.length > 0 ? entry.riskFlags.join(", ") : "none";
235353
+ const families = entry.usageFamilies.length > 0 ? entry.usageFamilies.join(", ") : "locator";
235354
+ parts.push("");
235355
+ parts.push(`### ${truncateForSelectorPrompt(entry.label || entry.key, 90)}`);
235356
+ parts.push(`- Selector: ${inlineCode(entry.expression, 220)}`);
235357
+ parts.push(`- Route/scope/kind: ${entry.route} / ${entry.scope} / ${entry.locatorKind || "unknown"}; usage families: ${families}.`);
235358
+ parts.push(`- Shared by: ${entry.testCount} test(s), ${entry.siblingFanoutCount} sibling(s), ${entry.refCount} ref(s); risks: ${risk}; status: ${entry.status}; stability: ${entry.stability}.`);
235359
+ if (entry.lastHealedAt)
235360
+ parts.push(`- Last registry heal: ${entry.lastHealedAt}.`);
235361
+ const currentRefs = entry.currentTestRefs.slice(0, 3);
235362
+ if (currentRefs.length > 0) {
235363
+ parts.push("- Current test occurrences:");
235364
+ for (const ref of currentRefs) {
235365
+ parts.push(` - ${ref.usage} on ${ref.route} from ${ref.source}: ${inlineCode(ref.expression, 180)}`);
235366
+ }
235367
+ }
235368
+ const siblings = entry.recentPassingSiblings.slice(0, 3);
235369
+ if (siblings.length > 0) {
235370
+ parts.push("- Recent passing sibling tests using this registry entry:");
235371
+ for (const sibling of siblings) {
235372
+ const suite = sibling.suite ? ` (${truncateForSelectorPrompt(sibling.suite, 50)})` : "";
235373
+ const passed = sibling.lastPassedAt ? ` passed ${sibling.lastPassedAt}` : "";
235374
+ parts.push(` - ${truncateForSelectorPrompt(sibling.name, 80)}${suite}${passed}: ${inlineCode(sibling.expression, 180)}`);
235375
+ }
235376
+ }
235377
+ if (entry.recentFailingSiblingCount && entry.recentFailingSiblingCount > 0) {
235378
+ parts.push(`- Other recent failing siblings on this entry: ${entry.recentFailingSiblingCount}.`);
235379
+ }
235380
+ const fallbacks = (entry.fallbacks ?? []).slice(0, 3);
235381
+ if (fallbacks.length > 0) {
235382
+ parts.push(`- Registry fallbacks: ${fallbacks.map((fallback) => inlineCode(fallback, 120)).join(", ")}.`);
235383
+ }
235384
+ }
235385
+ return `${parts.join("\n")}
235386
+ `;
235387
+ }
235388
+ }
235389
+ });
235390
+
234650
235391
  // ../runner-core/dist/services/mcp-healer.js
234651
235392
  var require_mcp_healer = __commonJS({
234652
235393
  "../runner-core/dist/services/mcp-healer.js"(exports) {
@@ -234741,6 +235482,7 @@ var require_mcp_healer = __commonJS({
234741
235482
  var snapshot_context_compactor_js_1 = require_snapshot_context_compactor();
234742
235483
  var rate_limit_detector_js_1 = require_rate_limit_detector();
234743
235484
  var preflight_syntax_js_1 = require_preflight_syntax();
235485
+ var selector_registry_context_js_1 = require_selector_registry_context();
234744
235486
  function stripCodeFences(code) {
234745
235487
  let cleaned = code.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
234746
235488
  cleaned = cleaned.replace(/^\s*```(?:typescript|ts|javascript|js)?\s*\n?/, "").replace(/\n?\s*```\s*$/, "");
@@ -235302,7 +236044,7 @@ ${lines.join("\n")}
235302
236044
  }).join("\n");
235303
236045
  }
235304
236046
  async function executeScriptDrivenHeal(playwrightCode, baseUrl, options) {
235305
- const { testName, testVariables, surfaceIntelligence, healingContext, lastFailureError, failureScreenshot, lastHealAttempt, consecutiveFails, modelOverride, onAICall, stepResults, tags } = options;
236047
+ const { testName, testVariables, surfaceIntelligence, healingContext, selectorRegistryContext, lastFailureError, failureScreenshot, lastHealAttempt, consecutiveFails, modelOverride, onAICall, stepResults, tags } = options;
235306
236048
  const landingViolation = (tags ?? []).includes("@landing-violation");
235307
236049
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "strong") : (0, ai_provider_js_1.getModel)("strong");
235308
236050
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
@@ -235365,6 +236107,11 @@ ${lastHealAttempt.healReason.slice(0, 800)}`);
235365
236107
  if (healingContext) {
235366
236108
  parts.push(`
235367
236109
  ${formatHealingContextForPrompt(healingContext)}`);
236110
+ }
236111
+ const selectorRegistryPrompt = (0, selector_registry_context_js_1.formatSelectorRegistryContextForPrompt)(selectorRegistryContext);
236112
+ if (selectorRegistryPrompt) {
236113
+ parts.push(`
236114
+ ${selectorRegistryPrompt}`);
235368
236115
  }
235369
236116
  if (surfaceIntelligence) {
235370
236117
  parts.push(`
@@ -235972,6 +236719,7 @@ ${(verifyResult.logs ?? "").slice(0, 2e3)}`;
235972
236719
  testVariables: options?.testVariables,
235973
236720
  surfaceIntelligence: options?.surfaceIntelligence,
235974
236721
  healingContext: options?.healingContext,
236722
+ selectorRegistryContext: options?.selectorRegistryContext,
235975
236723
  lastFailureError: options?.lastFailureError,
235976
236724
  failureScreenshot: options?.failureScreenshot,
235977
236725
  lastHealAttempt: options?.lastHealAttempt,
@@ -236512,6 +237260,7 @@ ${publicLines}` : ""}${credBlock}
236512
237260
  `;
236513
237261
  })()}
236514
237262
  ${options?.healingContext ? `${formatHealingContextForPrompt(options.healingContext)}` : ""}
237263
+ ${(0, selector_registry_context_js_1.formatSelectorRegistryContextForPrompt)(options?.selectorRegistryContext)}
236515
237264
  ${surfaceIntelligence ? `## Original Recorded Actions (what the test was trying to do)
236516
237265
  ${surfaceIntelligence.slice(0, 1500)}
236517
237266
  ` : ""}${landingViolation ? `## STRUCTURAL REQUIREMENT
@@ -238558,6 +239307,7 @@ var require_grok_per_test_healer = __commonJS({
238558
239307
  "use strict";
238559
239308
  Object.defineProperty(exports, "__esModule", { value: true });
238560
239309
  exports.executeGrokPerTestHeal = executeGrokPerTestHeal2;
239310
+ exports.buildHealBrief = buildHealBrief;
238561
239311
  var node_child_process_1 = __require("child_process");
238562
239312
  var promises_1 = __require("fs/promises");
238563
239313
  var node_os_1 = __require("os");
@@ -238574,6 +239324,7 @@ var require_grok_per_test_healer = __commonJS({
238574
239324
  var playwright_mcp_config_js_1 = require_playwright_mcp_config();
238575
239325
  var heal_session_tailer_js_1 = require_heal_session_tailer();
238576
239326
  var proven_actions_adapter_js_1 = require_proven_actions_adapter();
239327
+ var selector_registry_context_js_1 = require_selector_registry_context();
238577
239328
  var HEAL_MCP_CAPABILITIES = ["core", "core-navigation", "core-input", "core-tabs", "testing"];
238578
239329
  var grok_cli_js_1 = require_grok_cli();
238579
239330
  var DEFAULT_MAX_ATTEMPTS = 3;
@@ -238679,6 +239430,7 @@ var require_grok_per_test_healer = __commonJS({
238679
239430
  lastDiagnosis,
238680
239431
  publicVars: publics,
238681
239432
  secretKeys: Object.keys(secrets),
239433
+ selectorRegistryContext: options.selectorRegistryContext ?? null,
238682
239434
  attempt,
238683
239435
  maxAttempts
238684
239436
  }), "utf8");
@@ -238906,6 +239658,7 @@ ${input.lastDiagnosis ? `
238906
239658
  ## Your prior diagnosis (do not repeat the same fix)
238907
239659
  ${input.lastDiagnosis}
238908
239660
  ` : ""}
239661
+ ${(0, selector_registry_context_js_1.formatSelectorRegistryContextForPrompt)(input.selectorRegistryContext)}
238909
239662
 
238910
239663
  ## Tools available
238911
239664
  - **bash** \u2014 \`curl\`, \`sed\`, \`grep\` for static inspection of the app under test.
@@ -299620,6 +300373,699 @@ var require_capture_page_normalizer = __commonJS({
299620
300373
  }
299621
300374
  });
299622
300375
 
300376
+ // ../runner-core/dist/services/perception-enricher.js
300377
+ var require_perception_enricher = __commonJS({
300378
+ "../runner-core/dist/services/perception-enricher.js"(exports) {
300379
+ "use strict";
300380
+ Object.defineProperty(exports, "__esModule", { value: true });
300381
+ exports.enrichCapturedPage = enrichCapturedPage;
300382
+ exports.mergePerceptionOntoElements = mergePerceptionOntoElements;
300383
+ exports.elementRoleClass = elementRoleClass;
300384
+ exports.normalizeName = normalizeName;
300385
+ exports.urlMatchesRoute = urlMatchesRoute;
300386
+ var DEFAULT_CONFIG = {
300387
+ maxCandidates: 220,
300388
+ maxScan: 4e3,
300389
+ recoverNonSemantic: false,
300390
+ budgetMs: 1500
300391
+ };
300392
+ var BLACKOUT_THRESHOLD = 8;
300393
+ var EVALUATE_TIMEOUT_MS = 1800;
300394
+ var MAX_RECOVERED = 12;
300395
+ async function enrichCapturedPage(page, input, opts = {}) {
300396
+ const empty = { recovered: [], enriched: 0, perceived: 0 };
300397
+ if (!page)
300398
+ return { ...empty, skipReason: "no-page" };
300399
+ try {
300400
+ const url = page.url();
300401
+ if (url && input.route && !urlMatchesRoute(url, input.route)) {
300402
+ opts.onLog?.(`[perception] skip: page ${shortUrl(url)} != captured route ${input.route}`);
300403
+ return { ...empty, skipReason: "route-mismatch" };
300404
+ }
300405
+ } catch {
300406
+ }
300407
+ const elements = input.elements ?? [];
300408
+ const parsedInteractive = input.parsedInteractiveCount ?? elements.length;
300409
+ const cfg = {
300410
+ ...DEFAULT_CONFIG,
300411
+ recoverNonSemantic: parsedInteractive < BLACKOUT_THRESHOLD
300412
+ };
300413
+ let perceived;
300414
+ try {
300415
+ const raw = await withTimeout(page.evaluate(collectPerception, cfg), opts.timeoutMs ?? EVALUATE_TIMEOUT_MS);
300416
+ if (!Array.isArray(raw))
300417
+ return { ...empty, skipReason: "bad-result" };
300418
+ perceived = raw;
300419
+ } catch (err) {
300420
+ opts.onLog?.(`[perception] evaluate failed \u2014 ${err instanceof Error ? err.message : String(err)}`);
300421
+ return { ...empty, skipReason: "evaluate-error" };
300422
+ }
300423
+ const { enriched } = mergePerceptionOntoElements(elements, perceived);
300424
+ const recovered = collectRecovered(perceived);
300425
+ opts.onLog?.(`[perception] enriched ${enriched}/${elements.length} elements, ${recovered.length} non-semantic clickables (${perceived.length} perceived)`);
300426
+ return { recovered, enriched, perceived: perceived.length };
300427
+ }
300428
+ function mergePerceptionOntoElements(elements, perceived) {
300429
+ const perceivedGroups = /* @__PURE__ */ new Map();
300430
+ for (const p of perceived) {
300431
+ if (p.nonSemantic || !p.roleClass || !p.name)
300432
+ continue;
300433
+ const key = `${p.roleClass}\0${p.name}`;
300434
+ let arr = perceivedGroups.get(key);
300435
+ if (!arr) {
300436
+ arr = [];
300437
+ perceivedGroups.set(key, arr);
300438
+ }
300439
+ arr.push(p);
300440
+ }
300441
+ for (const arr of perceivedGroups.values())
300442
+ arr.sort((a, b) => a.domOrder - b.domOrder);
300443
+ const elementGroups = /* @__PURE__ */ new Map();
300444
+ for (const el of elements) {
300445
+ const roleClass = elementRoleClass(el);
300446
+ const name = normalizeName(el.label);
300447
+ if (!roleClass || !name)
300448
+ continue;
300449
+ const key = `${roleClass}\0${name}`;
300450
+ let arr = elementGroups.get(key);
300451
+ if (!arr) {
300452
+ arr = [];
300453
+ elementGroups.set(key, arr);
300454
+ }
300455
+ arr.push(el);
300456
+ }
300457
+ let enriched = 0;
300458
+ for (const [key, els] of elementGroups) {
300459
+ const ps = perceivedGroups.get(key);
300460
+ if (!ps)
300461
+ continue;
300462
+ if (ps.length !== els.length)
300463
+ continue;
300464
+ for (let i = 0; i < els.length; i++) {
300465
+ const enrichment = toEnrichment(ps[i]);
300466
+ if (enrichment) {
300467
+ els[i].enrichment = enrichment;
300468
+ enriched++;
300469
+ }
300470
+ }
300471
+ }
300472
+ return { enriched };
300473
+ }
300474
+ function toEnrichment(p) {
300475
+ const e = {};
300476
+ if (p.rowContext)
300477
+ e.rowContext = p.rowContext;
300478
+ if (p.position && p.position !== "in-view")
300479
+ e.position = p.position;
300480
+ if (p.occluded)
300481
+ e.occluded = true;
300482
+ if (p.cursorPointer)
300483
+ e.cursorPointer = true;
300484
+ return Object.keys(e).length > 0 ? e : null;
300485
+ }
300486
+ function collectRecovered(perceived) {
300487
+ const out = [];
300488
+ const seen = /* @__PURE__ */ new Set();
300489
+ for (const p of perceived) {
300490
+ if (!p.nonSemantic || !p.text)
300491
+ continue;
300492
+ const key = `${p.text}\0${p.rowContext ?? ""}`;
300493
+ if (seen.has(key))
300494
+ continue;
300495
+ seen.add(key);
300496
+ const rc = { text: p.text };
300497
+ if (p.rowContext)
300498
+ rc.rowContext = p.rowContext;
300499
+ out.push(rc);
300500
+ if (out.length >= MAX_RECOVERED)
300501
+ break;
300502
+ }
300503
+ return out;
300504
+ }
300505
+ function elementRoleClass(el) {
300506
+ const r = (el.role ?? "").toLowerCase().trim();
300507
+ const mapped = roleBucket(r);
300508
+ if (mapped)
300509
+ return mapped;
300510
+ const t = (el.elementType ?? "").toLowerCase();
300511
+ if (t === "link")
300512
+ return "link";
300513
+ if (t === "button")
300514
+ return "button";
300515
+ if (t.startsWith("input")) {
300516
+ const it = (el.inputType ?? "").toLowerCase();
300517
+ if (it === "checkbox")
300518
+ return "checkbox";
300519
+ if (it === "radio")
300520
+ return "radio";
300521
+ if (it === "submit" || it === "button")
300522
+ return "button";
300523
+ return "textbox";
300524
+ }
300525
+ return t || "";
300526
+ }
300527
+ function roleBucket(r) {
300528
+ if (!r)
300529
+ return null;
300530
+ if (r === "link")
300531
+ return "link";
300532
+ if (r === "button")
300533
+ return "button";
300534
+ if (r === "textbox" || r === "searchbox" || r === "spinbutton")
300535
+ return "textbox";
300536
+ if (r === "combobox" || r === "listbox")
300537
+ return "combobox";
300538
+ if (r === "checkbox" || r === "switch")
300539
+ return "checkbox";
300540
+ if (r === "radio")
300541
+ return "radio";
300542
+ if (r === "tab")
300543
+ return "tab";
300544
+ if (r === "menuitem" || r === "menuitemcheckbox" || r === "menuitemradio")
300545
+ return "menuitem";
300546
+ if (r === "option")
300547
+ return "option";
300548
+ if (r === "slider")
300549
+ return "slider";
300550
+ return r;
300551
+ }
300552
+ function normalizeName(name) {
300553
+ return (name ?? "").replace(/\s+/g, " ").trim().toLowerCase().slice(0, 80);
300554
+ }
300555
+ function withTimeout(p, ms) {
300556
+ return new Promise((resolve, reject) => {
300557
+ const t = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms);
300558
+ p.then((v) => {
300559
+ clearTimeout(t);
300560
+ resolve(v);
300561
+ }, (e) => {
300562
+ clearTimeout(t);
300563
+ reject(e);
300564
+ });
300565
+ });
300566
+ }
300567
+ function parseLoc(input) {
300568
+ try {
300569
+ const u = new URL(input, "http://__rel__");
300570
+ return {
300571
+ origin: u.host === "__rel__" ? "" : u.host,
300572
+ // '' when input was path-relative
300573
+ path: (u.pathname || "/").replace(/\/+$/, "") || "/",
300574
+ search: u.search,
300575
+ hash: u.hash
300576
+ };
300577
+ } catch {
300578
+ const hashI = input.indexOf("#");
300579
+ const hash = hashI >= 0 ? input.slice(hashI) : "";
300580
+ const beforeHash = hashI >= 0 ? input.slice(0, hashI) : input;
300581
+ const qi = beforeHash.indexOf("?");
300582
+ const search = qi >= 0 ? beforeHash.slice(qi) : "";
300583
+ const path = ((qi >= 0 ? beforeHash.slice(0, qi) : beforeHash) || "/").replace(/\/+$/, "") || "/";
300584
+ return { origin: "", path, search, hash };
300585
+ }
300586
+ }
300587
+ function urlMatchesRoute(url, route2) {
300588
+ const u = parseLoc(url);
300589
+ const r = parseLoc(route2);
300590
+ if (u.path !== r.path)
300591
+ return false;
300592
+ if (r.origin && u.origin && r.origin !== u.origin)
300593
+ return false;
300594
+ if (r.search && u.search !== r.search)
300595
+ return false;
300596
+ if (r.hash && u.hash !== r.hash)
300597
+ return false;
300598
+ return true;
300599
+ }
300600
+ function shortUrl(url) {
300601
+ return url.length > 80 ? `${url.slice(0, 77)}...` : url;
300602
+ }
300603
+ function collectPerception(cfg) {
300604
+ const NATIVE = {
300605
+ A: true,
300606
+ BUTTON: true,
300607
+ INPUT: true,
300608
+ SELECT: true,
300609
+ TEXTAREA: true,
300610
+ SUMMARY: true,
300611
+ OPTION: true
300612
+ };
300613
+ const INTERACTIVE_ROLE = {
300614
+ button: true,
300615
+ link: true,
300616
+ textbox: true,
300617
+ searchbox: true,
300618
+ spinbutton: true,
300619
+ combobox: true,
300620
+ listbox: true,
300621
+ checkbox: true,
300622
+ radio: true,
300623
+ switch: true,
300624
+ menuitem: true,
300625
+ menuitemcheckbox: true,
300626
+ menuitemradio: true,
300627
+ tab: true,
300628
+ option: true,
300629
+ slider: true
300630
+ };
300631
+ const norm = (s) => (s || "").replace(/\s+/g, " ").trim();
300632
+ const lower = (s) => s.toLowerCase().slice(0, 80);
300633
+ const roleClassOf = (tag, roleAttr, inputType) => {
300634
+ const r = roleAttr.toLowerCase();
300635
+ if (r) {
300636
+ if (r === "link")
300637
+ return "link";
300638
+ if (r === "button")
300639
+ return "button";
300640
+ if (r === "textbox" || r === "searchbox" || r === "spinbutton")
300641
+ return "textbox";
300642
+ if (r === "combobox" || r === "listbox")
300643
+ return "combobox";
300644
+ if (r === "switch" || r === "checkbox")
300645
+ return "checkbox";
300646
+ if (r === "radio")
300647
+ return "radio";
300648
+ if (r === "tab")
300649
+ return "tab";
300650
+ if (r === "menuitem" || r === "menuitemcheckbox" || r === "menuitemradio")
300651
+ return "menuitem";
300652
+ if (r === "option")
300653
+ return "option";
300654
+ if (r === "slider")
300655
+ return "slider";
300656
+ return r;
300657
+ }
300658
+ if (tag === "A")
300659
+ return "link";
300660
+ if (tag === "BUTTON" || tag === "SUMMARY")
300661
+ return "button";
300662
+ if (tag === "SELECT")
300663
+ return "combobox";
300664
+ if (tag === "TEXTAREA")
300665
+ return "textbox";
300666
+ if (tag === "OPTION")
300667
+ return "option";
300668
+ if (tag === "INPUT") {
300669
+ const t = (inputType || "text").toLowerCase();
300670
+ if (t === "checkbox")
300671
+ return "checkbox";
300672
+ if (t === "radio")
300673
+ return "radio";
300674
+ if (t === "submit" || t === "button" || t === "image" || t === "reset")
300675
+ return "button";
300676
+ if (t === "range")
300677
+ return "slider";
300678
+ return "textbox";
300679
+ }
300680
+ return "other";
300681
+ };
300682
+ const accName = (el) => {
300683
+ const al = norm(el.getAttribute("aria-label"));
300684
+ if (al)
300685
+ return al;
300686
+ const lb = el.getAttribute("aria-labelledby");
300687
+ if (lb) {
300688
+ const ids = lb.split(/\s+/);
300689
+ let txt = "";
300690
+ for (let i = 0; i < ids.length; i++) {
300691
+ const t = el.ownerDocument.getElementById(ids[i]);
300692
+ if (t)
300693
+ txt += (txt ? " " : "") + norm(t.textContent);
300694
+ }
300695
+ if (txt)
300696
+ return txt;
300697
+ }
300698
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT") {
300699
+ const id = el.getAttribute("id");
300700
+ if (id) {
300701
+ try {
300702
+ const lab = el.ownerDocument.querySelector('label[for="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]');
300703
+ const t = lab ? norm(lab.textContent) : "";
300704
+ if (t)
300705
+ return t;
300706
+ } catch {
300707
+ }
300708
+ }
300709
+ const val = norm(el.value);
300710
+ if (val)
300711
+ return val;
300712
+ const ph = norm(el.getAttribute("placeholder"));
300713
+ if (ph)
300714
+ return ph;
300715
+ return norm(el.getAttribute("name"));
300716
+ }
300717
+ const text = norm(el.textContent);
300718
+ if (text)
300719
+ return text;
300720
+ return norm(el.getAttribute("title")) || norm(el.getAttribute("alt"));
300721
+ };
300722
+ const ROW_SEL = 'tr,li,[role="row"],[role="listitem"],[role="article"],article,[role="option"]';
300723
+ const isCardish = (el) => {
300724
+ const cls = (el.getAttribute("class") || "").toLowerCase();
300725
+ return /(^|[\s_-])(card|row|item|listitem|cell|tile)([\s_-]|$)/.test(cls);
300726
+ };
300727
+ const rowContextOf = (el, ownNameLower) => {
300728
+ let cur = el.parentElement;
300729
+ let depth = 0;
300730
+ while (cur && depth < 8) {
300731
+ let isRow = false;
300732
+ try {
300733
+ isRow = cur.matches(ROW_SEL);
300734
+ } catch {
300735
+ isRow = false;
300736
+ }
300737
+ if (isRow || isCardish(cur)) {
300738
+ const head = cur.querySelector('h1,h2,h3,h4,h5,h6,[role="heading"],strong,b,th,td');
300739
+ let txt = head ? norm(head.textContent) : "";
300740
+ if (!txt)
300741
+ txt = norm(cur.textContent);
300742
+ txt = txt.slice(0, 80);
300743
+ const low = txt.toLowerCase();
300744
+ if (txt && low !== ownNameLower && low.length > 1)
300745
+ return txt;
300746
+ return null;
300747
+ }
300748
+ cur = cur.parentElement;
300749
+ depth++;
300750
+ }
300751
+ return null;
300752
+ };
300753
+ const vh = window.innerHeight || 0;
300754
+ const vw = window.innerWidth || 0;
300755
+ const visibleRect = (el) => {
300756
+ let rect;
300757
+ let style;
300758
+ try {
300759
+ rect = el.getBoundingClientRect();
300760
+ style = window.getComputedStyle(el);
300761
+ } catch {
300762
+ return null;
300763
+ }
300764
+ if (style.display === "none" || style.visibility === "hidden")
300765
+ return null;
300766
+ if (parseFloat(style.opacity || "1") === 0)
300767
+ return null;
300768
+ if (rect.width <= 0 || rect.height <= 0)
300769
+ return null;
300770
+ try {
300771
+ if (el.closest('[aria-hidden="true"],[inert]'))
300772
+ return null;
300773
+ } catch {
300774
+ }
300775
+ return { rect, style };
300776
+ };
300777
+ const occlusionOf = (el, rect) => {
300778
+ const cx = rect.left + rect.width / 2;
300779
+ const cy = rect.top + rect.height / 2;
300780
+ if (cx < 0 || cy < 0 || cx > vw || cy > vh)
300781
+ return false;
300782
+ try {
300783
+ const hit = document.elementFromPoint(cx, cy);
300784
+ if (!hit || hit === el || el.contains(hit) || hit.contains(el))
300785
+ return false;
300786
+ let anc = hit;
300787
+ let guard = 0;
300788
+ while (anc && guard < 12) {
300789
+ const pos = window.getComputedStyle(anc).position;
300790
+ if (pos === "fixed" || pos === "sticky")
300791
+ return false;
300792
+ anc = anc.parentElement;
300793
+ guard++;
300794
+ }
300795
+ return true;
300796
+ } catch {
300797
+ return false;
300798
+ }
300799
+ };
300800
+ const positionOf = (rect) => rect.bottom < 0 ? "above" : rect.top > vh ? "below" : "in-view";
300801
+ const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : 0;
300802
+ const overBudget = () => t0 > 0 && performance.now() - t0 > cfg.budgetMs;
300803
+ const out = [];
300804
+ let order = 0;
300805
+ const SEL = 'a[href],button,input:not([type="hidden"]),textarea,select,summary,option,[role],[onclick],[tabindex]';
300806
+ const all = document.querySelectorAll(SEL);
300807
+ const limit = all.length < cfg.maxScan ? all.length : cfg.maxScan;
300808
+ for (let i = 0; i < limit; i++) {
300809
+ if (out.length >= cfg.maxCandidates)
300810
+ break;
300811
+ if ((i & 63) === 0 && overBudget())
300812
+ break;
300813
+ const el = all[i];
300814
+ const vis = visibleRect(el);
300815
+ if (!vis)
300816
+ continue;
300817
+ const tag = el.tagName;
300818
+ const roleAttr = el.getAttribute("role") || "";
300819
+ const inputType = tag === "INPUT" ? el.type || "" : "";
300820
+ const cursorPointer = vis.style.cursor === "pointer";
300821
+ const native = NATIVE[tag] === true;
300822
+ const roleLower = roleAttr.toLowerCase();
300823
+ const hasInteractiveRole = !!roleLower && INTERACTIVE_ROLE[roleLower] === true;
300824
+ const hasOnclick = el.hasAttribute("onclick");
300825
+ const tabAttr = el.getAttribute("tabindex");
300826
+ const tabbable = tabAttr != null && tabAttr !== "-1";
300827
+ const semantic = native || hasInteractiveRole;
300828
+ const nonSemantic = !semantic && (cursorPointer || hasOnclick || tabbable);
300829
+ if (!semantic && !nonSemantic)
300830
+ continue;
300831
+ const myOrder = order++;
300832
+ const name = accName(el);
300833
+ const lname = lower(name);
300834
+ const rect = vis.rect;
300835
+ out.push({
300836
+ domOrder: myOrder,
300837
+ roleClass: roleClassOf(tag, roleAttr, inputType),
300838
+ name: lname,
300839
+ rowContext: rowContextOf(el, lname),
300840
+ position: positionOf(rect),
300841
+ occluded: occlusionOf(el, rect),
300842
+ cursorPointer,
300843
+ nonSemantic,
300844
+ text: nonSemantic ? name.slice(0, 80) || null : null
300845
+ });
300846
+ }
300847
+ if (cfg.recoverNonSemantic) {
300848
+ const extra = document.querySelectorAll("div,span,li,td,p");
300849
+ const extraLimit = extra.length < cfg.maxScan ? extra.length : cfg.maxScan;
300850
+ for (let i = 0; i < extraLimit; i++) {
300851
+ if (out.length >= cfg.maxCandidates)
300852
+ break;
300853
+ if ((i & 63) === 0 && overBudget())
300854
+ break;
300855
+ const el = extra[i];
300856
+ const vis = visibleRect(el);
300857
+ if (!vis)
300858
+ continue;
300859
+ if (vis.style.cursor !== "pointer")
300860
+ continue;
300861
+ if (el.querySelector('a,button,input,select,textarea,[role="button"],[role="link"]'))
300862
+ continue;
300863
+ const rect = vis.rect;
300864
+ if (rect.width < 6 || rect.height < 6)
300865
+ continue;
300866
+ if (rect.width > vw * 0.98)
300867
+ continue;
300868
+ if (positionOf(rect) !== "in-view")
300869
+ continue;
300870
+ if (occlusionOf(el, rect))
300871
+ continue;
300872
+ const name = accName(el);
300873
+ if (!name)
300874
+ continue;
300875
+ const lname = lower(name);
300876
+ out.push({
300877
+ domOrder: order++,
300878
+ roleClass: "button",
300879
+ name: lname,
300880
+ rowContext: rowContextOf(el, lname),
300881
+ position: "in-view",
300882
+ occluded: false,
300883
+ cursorPointer: true,
300884
+ nonSemantic: true,
300885
+ text: name.slice(0, 80) || null
300886
+ });
300887
+ }
300888
+ }
300889
+ return out;
300890
+ }
300891
+ }
300892
+ });
300893
+
300894
+ // ../runner-core/dist/services/stuck-detector.js
300895
+ var require_stuck_detector = __commonJS({
300896
+ "../runner-core/dist/services/stuck-detector.js"(exports) {
300897
+ "use strict";
300898
+ Object.defineProperty(exports, "__esModule", { value: true });
300899
+ exports.StuckDetector = void 0;
300900
+ exports.isPageAffectingTool = isPageAffectingTool;
300901
+ exports.normalizeStuckSignature = normalizeStuckSignature;
300902
+ var PAGE_AFFECTING_TOOLS = /* @__PURE__ */ new Set([
300903
+ "browser_click",
300904
+ "browser_type",
300905
+ "browser_fill_form",
300906
+ "browser_select_option",
300907
+ "browser_navigate",
300908
+ "browser_press_key",
300909
+ "browser_hover",
300910
+ "browser_drag"
300911
+ ]);
300912
+ function isPageAffectingTool(toolName) {
300913
+ return PAGE_AFFECTING_TOOLS.has(toolName);
300914
+ }
300915
+ function normalizeStuckSignature(toolName, toolArgs) {
300916
+ const rawRef = typeof toolArgs.ref === "string" ? toolArgs.ref.trim() : "";
300917
+ const rawElement = typeof toolArgs.element === "string" ? toolArgs.element.trim() : "";
300918
+ const elementKey = rawRef ? rawRef : rawElement ? rawElement.toLowerCase().replace(/\S+@\S+/g, "[email]").slice(0, 60) : "";
300919
+ switch (toolName) {
300920
+ case "browser_click":
300921
+ case "browser_hover":
300922
+ case "browser_select_option":
300923
+ case "browser_type":
300924
+ case "browser_fill_form":
300925
+ case "browser_drag":
300926
+ return elementKey ? `${toolName}:${elementKey}` : null;
300927
+ case "browser_navigate": {
300928
+ const url = typeof toolArgs.url === "string" ? canonicalizeUrlPath(toolArgs.url) : "";
300929
+ return `browser_navigate:${url}`;
300930
+ }
300931
+ case "browser_press_key": {
300932
+ const key = typeof toolArgs.key === "string" ? toolArgs.key : "";
300933
+ return `browser_press_key:${key}`;
300934
+ }
300935
+ default:
300936
+ return null;
300937
+ }
300938
+ }
300939
+ function canonicalizeUrlPath(url) {
300940
+ try {
300941
+ const u = new URL(url, "http://x");
300942
+ return u.pathname.replace(/\/+$/, "") || "/";
300943
+ } catch {
300944
+ return url.trim().split(/[?#]/)[0] || url.trim();
300945
+ }
300946
+ }
300947
+ var NO_VERDICT = Object.freeze({
300948
+ tier: 0,
300949
+ kind: null,
300950
+ repeatedAction: null,
300951
+ recover: false,
300952
+ yieldRun: false
300953
+ });
300954
+ var DEFAULTS = {
300955
+ window: 8,
300956
+ repeatThreshold: 3,
300957
+ stagnationThreshold: 4,
300958
+ tier2After: 3,
300959
+ tier3After: 5,
300960
+ maxRecoveries: 2,
300961
+ repetitionReachesRecovery: true
300962
+ };
300963
+ var StuckDetector = class {
300964
+ opts;
300965
+ records = [];
300966
+ stallStreak = 0;
300967
+ recoveryCount = 0;
300968
+ lastEmittedTier = 0;
300969
+ constructor(options = {}) {
300970
+ this.opts = { ...DEFAULTS, ...options };
300971
+ }
300972
+ record(input) {
300973
+ this.records.push(input);
300974
+ if (this.records.length > this.opts.window) {
300975
+ this.records.splice(0, this.records.length - this.opts.window);
300976
+ }
300977
+ const stall = this.detectStall();
300978
+ if (!stall.stalled) {
300979
+ this.stallStreak = 0;
300980
+ this.lastEmittedTier = 0;
300981
+ return NO_VERDICT;
300982
+ }
300983
+ this.stallStreak += 1;
300984
+ let desired = 1;
300985
+ if (this.stallStreak >= this.opts.tier3After)
300986
+ desired = 3;
300987
+ else if (this.stallStreak >= this.opts.tier2After)
300988
+ desired = 2;
300989
+ if (stall.kind === "stagnation" && desired > 2)
300990
+ desired = 2;
300991
+ if (!this.opts.repetitionReachesRecovery && stall.kind === "repetition" && desired > 2)
300992
+ desired = 2;
300993
+ if (desired <= this.lastEmittedTier)
300994
+ return NO_VERDICT;
300995
+ if (desired === 3) {
300996
+ if (this.recoveryCount >= this.opts.maxRecoveries) {
300997
+ this.lastEmittedTier = 4;
300998
+ return {
300999
+ tier: 4,
301000
+ kind: stall.kind,
301001
+ repeatedAction: stall.repeatedAction,
301002
+ recover: false,
301003
+ yieldRun: true
301004
+ };
301005
+ }
301006
+ this.recoveryCount += 1;
301007
+ this.records = [];
301008
+ this.stallStreak = 0;
301009
+ this.lastEmittedTier = 0;
301010
+ return {
301011
+ tier: 3,
301012
+ kind: stall.kind,
301013
+ repeatedAction: stall.repeatedAction,
301014
+ recover: true,
301015
+ yieldRun: false
301016
+ };
301017
+ }
301018
+ this.lastEmittedTier = desired;
301019
+ return {
301020
+ tier: desired,
301021
+ kind: stall.kind,
301022
+ repeatedAction: stall.repeatedAction,
301023
+ recover: false,
301024
+ yieldRun: false
301025
+ };
301026
+ }
301027
+ detectStall() {
301028
+ const { stagnationThreshold, repeatThreshold } = this.opts;
301029
+ if (this.records.length < stagnationThreshold) {
301030
+ return { stalled: false, kind: null, repeatedAction: null };
301031
+ }
301032
+ const recent = this.records.slice(-stagnationThreshold);
301033
+ const progressStalled = recent.length >= stagnationThreshold && recent.every((r) => r.progressKey === recent[recent.length - 1].progressKey);
301034
+ if (progressStalled) {
301035
+ const counts = /* @__PURE__ */ new Map();
301036
+ for (const rec of this.records) {
301037
+ for (const sig of rec.actionSignatures) {
301038
+ counts.set(sig, (counts.get(sig) ?? 0) + 1);
301039
+ }
301040
+ }
301041
+ let repeatedAction = null;
301042
+ let max = 0;
301043
+ for (const [sig, count] of counts) {
301044
+ if (count > max) {
301045
+ max = count;
301046
+ repeatedAction = sig;
301047
+ }
301048
+ }
301049
+ if (repeatedAction && max >= repeatThreshold) {
301050
+ return { stalled: true, kind: "repetition", repeatedAction };
301051
+ }
301052
+ }
301053
+ if (progressStalled) {
301054
+ const sameRoute = recent.every((r) => r.route === recent[recent.length - 1].route);
301055
+ const pageAffectingCount = recent.filter((r) => r.pageAffecting).length;
301056
+ const distinctSignatures = new Set(recent.flatMap((r) => r.actionSignatures));
301057
+ const lowDiversity = distinctSignatures.size >= 1 && distinctSignatures.size <= Math.max(1, Math.floor(pageAffectingCount / 2));
301058
+ if (sameRoute && pageAffectingCount >= Math.ceil(stagnationThreshold / 2) && lowDiversity) {
301059
+ return { stalled: true, kind: "stagnation", repeatedAction: null };
301060
+ }
301061
+ }
301062
+ return { stalled: false, kind: null, repeatedAction: null };
301063
+ }
301064
+ };
301065
+ exports.StuckDetector = StuckDetector;
301066
+ }
301067
+ });
301068
+
299623
301069
  // ../runner-core/dist/services/evidence-harvester.js
299624
301070
  var require_evidence_harvester = __commonJS({
299625
301071
  "../runner-core/dist/services/evidence-harvester.js"(exports) {
@@ -299916,8 +301362,10 @@ var require_discover_explorer = __commonJS({
299916
301362
  var capture_page_normalizer_js_1 = require_capture_page_normalizer();
299917
301363
  var snapshot_observation_js_1 = require_snapshot_observation();
299918
301364
  var snapshot_context_compactor_js_1 = require_snapshot_context_compactor();
301365
+ var perception_enricher_js_1 = require_perception_enricher();
299919
301366
  var exploration_prompt_builder_js_2 = require_exploration_prompt_builder();
299920
301367
  var exploration_state_js_1 = require_exploration_state();
301368
+ var stuck_detector_js_1 = require_stuck_detector();
299921
301369
  var evidence_harvester_js_1 = require_evidence_harvester();
299922
301370
  var exploration_parser_js_1 = require_exploration_parser();
299923
301371
  var snapshot_context_compactor_js_2 = require_snapshot_context_compactor();
@@ -300700,6 +302148,7 @@ ${credentialBlock}${redactedBlock}${inboxBlock}`;
300700
302148
  const v3Env = process.env.DISCOVERY_PROMPT_V3;
300701
302149
  const useV3 = v3Env !== "false";
300702
302150
  const useV2 = true;
302151
+ const perceptionEnricherEnabled = (process.env.DISCOVERY_PERCEPTION_ENRICHER === "true" || process.env.DISCOVERY_PERCEPTION_ENRICHER === "1") && typeof options?.getPerceptionPage === "function";
300703
302152
  const state2 = (0, exploration_state_js_1.createExplorationState)();
300704
302153
  const recentExchanges = [];
300705
302154
  const transientDirectives = [];
@@ -300824,6 +302273,8 @@ ${inboxPromptBlock}`;
300824
302273
  let lastBriefRoute = "";
300825
302274
  let lastBriefIteration = 0;
300826
302275
  const BRIEF_INTERVAL = 5;
302276
+ const stuckDetectorEnabled = process.env.DISCOVERY_STUCK_DETECTOR === "1" || process.env.DISCOVERY_STUCK_DETECTOR === "true";
302277
+ const stuckDetector = stuckDetectorEnabled ? new stuck_detector_js_1.StuckDetector({ repetitionReachesRecovery: !isDeepMode }) : null;
300827
302278
  while (iteration < maxIterations && !explorationComplete) {
300828
302279
  iteration++;
300829
302280
  (0, exploration_state_js_1.reduceEvent)(state2, { type: "ITERATION_STARTED", iteration });
@@ -301295,6 +302746,19 @@ ${currentSummary}`;
301295
302746
  const elapsed = Date.now() - readinessStartedAt;
301296
302747
  logs2.push(` [discover-explorer] proceeded with not-ready snapshot after ${readinessRetries} retries / ${elapsed}ms`);
301297
302748
  }
302749
+ let recoveredClickables = [];
302750
+ if (perceptionEnricherEnabled) {
302751
+ try {
302752
+ const result = await (0, perception_enricher_js_1.enrichCapturedPage)(options?.getPerceptionPage?.(), {
302753
+ route: normalized.route,
302754
+ elements: normalized.elements,
302755
+ parsedInteractiveCount: normalized.snapshotMetrics?.parsedInteractiveCount
302756
+ }, { onLog: (msg) => logs2.push(` ${msg}`) });
302757
+ recoveredClickables = result.recovered;
302758
+ } catch (perceptionErr) {
302759
+ logs2.push(` [perception] enrich skipped \u2014 ${perceptionErr instanceof Error ? perceptionErr.message : String(perceptionErr)}`);
302760
+ }
302761
+ }
301298
302762
  (0, exploration_state_js_1.reduceEvent)(state2, {
301299
302763
  type: "PAGE_CAPTURED",
301300
302764
  route: normalized.route,
@@ -301363,7 +302827,7 @@ ${currentSummary}`;
301363
302827
  logs2.push(` Captured page: ${normalized.route}, ${normalized.provenCommands.length} proven commands${normalized.alreadyCaptured ? " (RE-VISIT)" : ""}`);
301364
302828
  const transitionHint = !normalized.alreadyCaptured && isSurveyMode ? { tip: `Also call record_transition with fromRoute, toRoute, triggerType, and triggerLabel (the exact link text you clicked to get here).` } : {};
301365
302829
  const lastAction = normalized.observations.length > 0 ? normalized.observations[normalized.observations.length - 1].action : void 0;
301366
- const snapshotBlock = (0, snapshot_observation_js_1.renderCompactSnapshotBlock)(normalized, { lastAction }, { elementLimit: 6, formLimit: 3, observationLimit: 3, mode: options?.mode ?? "full" });
302830
+ const snapshotBlock = (0, snapshot_observation_js_1.renderCompactSnapshotBlock)(normalized, { lastAction }, { elementLimit: 6, formLimit: 3, observationLimit: 3, mode: options?.mode ?? "full", recoveredClickables });
301367
302831
  toolResults.push({
301368
302832
  role: "tool",
301369
302833
  tool_call_id: toolCall.id,
@@ -302302,6 +303766,59 @@ Exploration complete: ${summary}`);
302302
303766
  }
302303
303767
  delete toolResults.__accountAlreadyExistsDirective;
302304
303768
  }
303769
+ if (stuckDetector && assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
303770
+ const actionSignatures = [];
303771
+ let pageAffecting = false;
303772
+ for (const tc of assistantMessage.tool_calls) {
303773
+ if ((0, stuck_detector_js_1.isPageAffectingTool)(tc.function.name))
303774
+ pageAffecting = true;
303775
+ let parsedArgs = {};
303776
+ try {
303777
+ parsedArgs = JSON.parse(tc.function.arguments || "{}");
303778
+ } catch {
303779
+ }
303780
+ const sig = (0, stuck_detector_js_1.normalizeStuckSignature)(tc.function.name, parsedArgs);
303781
+ if (sig)
303782
+ actionSignatures.push(sig);
303783
+ }
303784
+ const progressKey = `${state2.currentRoute}|${state2.visitedRoutes.size}|${state2.recordedJourneys.length}|${lastCaptureIteration}`;
303785
+ const verdict = stuckDetector.record({
303786
+ iteration,
303787
+ actionSignatures,
303788
+ route: state2.currentRoute,
303789
+ progressKey,
303790
+ pageAffecting
303791
+ });
303792
+ if (verdict.tier > 0) {
303793
+ const topFrontier = (0, exploration_state_js_1.getRankedFrontier)(state2).slice(0, 3);
303794
+ const frontierHint = topFrontier.length > 0 ? ` (e.g. ${topFrontier.join(", ")})` : "";
303795
+ const repeated = verdict.repeatedAction ? verdict.repeatedAction.slice(0, 80) : null;
303796
+ const act = repeated ? `"${repeated}"` : "the same actions";
303797
+ let directive = null;
303798
+ if (verdict.yieldRun) {
303799
+ logs2.push(` Stuck detector: yielding after repeated local loop (${verdict.kind}, ${act})`);
303800
+ explorationComplete = true;
303801
+ summary = `Exploration stopped after ${iteration} iterations - the agent was stuck in a local loop (${verdict.kind}${repeated ? `: ${repeated}` : ""}) and harness recovery did not break it. Visited ${state2.visitedRoutes.size} pages, recorded ${state2.recordedJourneys.length} journeys.`;
303802
+ break;
303803
+ } else if (verdict.recover) {
303804
+ directive = `You are stuck in a loop on ${state2.currentRoute}: you repeated ${act} several times with no new progress, and it is not working. Stop retrying it. You MUST now do something different - navigate to an unvisited route${frontierHint}, open an unexplored tab/menu, or call finish_exploration if you have explored everything reachable. Check the "Already interacted with" and "Do Not Retry" lists before acting.`;
303805
+ logs2.push(` Stuck detector: harness recovery (tier 3, ${verdict.kind}, ${act})`);
303806
+ } else if (verdict.tier === 2) {
303807
+ directive = `STOP repeating ${act} - you have made no new progress for several iterations on ${state2.currentRoute}. ${topFrontier.length > 0 ? `Navigate to one of these unvisited routes now: ${topFrontier.join(", ")}.` : "Open an unexplored tab/menu, or call capture_page for this page and then move on."} If you have explored everything reachable, call finish_exploration. Check the "Already interacted with" and "Do Not Retry" lists before acting.`;
303808
+ logs2.push(` Stuck detector: firm nudge (tier 2, ${verdict.kind}, ${act})`);
303809
+ } else {
303810
+ directive = `You appear to be repeating ${act} on ${state2.currentRoute} without making progress. Try a different element, open an unexplored tab/menu, or navigate to an unvisited route${frontierHint}. Check the "Already interacted with" and "Do Not Retry" lists before acting.`;
303811
+ logs2.push(` Stuck detector: soft nudge (tier 1, ${verdict.kind}, ${act})`);
303812
+ }
303813
+ if (directive) {
303814
+ if (useV3) {
303815
+ transientDirectives.push(directive);
303816
+ } else {
303817
+ messages.push({ role: "user", content: directive });
303818
+ }
303819
+ }
303820
+ }
303821
+ }
302305
303822
  }
302306
303823
  if (!explorationComplete) {
302307
303824
  logs2.push(`
@@ -303010,7 +304527,8 @@ ${credentialLines}
303010
304527
  userNotes: featureOpts.userNotes,
303011
304528
  onAICall: featureOpts.onAICall,
303012
304529
  onProgress: featureOpts.onProgress,
303013
- onCredentialUpdate: featureOpts.onCredentialUpdate
304530
+ onCredentialUpdate: featureOpts.onCredentialUpdate,
304531
+ getPerceptionPage: featureOpts.getPerceptionPage
303014
304532
  }, config);
303015
304533
  }
303016
304534
  }
@@ -310054,54 +311572,13 @@ var require_mobile_exploration_state = __commonJS({
310054
311572
  var require_mobile_discovery_agent = __commonJS({
310055
311573
  "../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports) {
310056
311574
  "use strict";
310057
- var __createBinding = exports && exports.__createBinding || (Object.create ? (function(o, m, k, k2) {
310058
- if (k2 === void 0) k2 = k;
310059
- var desc = Object.getOwnPropertyDescriptor(m, k);
310060
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
310061
- desc = { enumerable: true, get: function() {
310062
- return m[k];
310063
- } };
310064
- }
310065
- Object.defineProperty(o, k2, desc);
310066
- }) : (function(o, m, k, k2) {
310067
- if (k2 === void 0) k2 = k;
310068
- o[k2] = m[k];
310069
- }));
310070
- var __setModuleDefault = exports && exports.__setModuleDefault || (Object.create ? (function(o, v) {
310071
- Object.defineProperty(o, "default", { enumerable: true, value: v });
310072
- }) : function(o, v) {
310073
- o["default"] = v;
310074
- });
310075
- var __importStar = exports && exports.__importStar || /* @__PURE__ */ (function() {
310076
- var ownKeys = function(o) {
310077
- ownKeys = Object.getOwnPropertyNames || function(o2) {
310078
- var ar = [];
310079
- for (var k in o2) if (Object.prototype.hasOwnProperty.call(o2, k)) ar[ar.length] = k;
310080
- return ar;
310081
- };
310082
- return ownKeys(o);
310083
- };
310084
- return function(mod) {
310085
- if (mod && mod.__esModule) return mod;
310086
- var result = {};
310087
- if (mod != null) {
310088
- for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
310089
- }
310090
- __setModuleDefault(result, mod);
310091
- return result;
310092
- };
310093
- })();
310094
311575
  Object.defineProperty(exports, "__esModule", { value: true });
310095
311576
  exports.runMobileDiscoveryOnRunner = runMobileDiscoveryOnRunner2;
310096
311577
  var mobile_explorer_js_1 = require_mobile_explorer();
310097
311578
  var mobile_discovery_prompts_js_1 = require_mobile_discovery_prompts();
311579
+ var mobile_generator_js_1 = require_mobile_generator();
310098
311580
  var mobile_exploration_state_js_1 = require_mobile_exploration_state();
310099
311581
  var discover_planner_js_1 = require_discover_planner();
310100
- var GENERATOR_MODULE = "./mobile-generator.js";
310101
- var defaultRunGenerate = async (args) => {
310102
- const mod = await Promise.resolve(`${GENERATOR_MODULE}`).then((s) => __importStar(__require(s)));
310103
- return mod.runMobileGeneratePhase(args);
310104
- };
310105
311582
  var EXPLORE_BUDGET_DEEP = 60;
310106
311583
  var EXPLORE_BUDGET_SURVEY = 40;
310107
311584
  function platformForMedium(medium) {
@@ -310119,7 +311596,9 @@ var require_mobile_discovery_agent = __commonJS({
310119
311596
  return {
310120
311597
  collectedPages: explore.collectedPages,
310121
311598
  collectedTransitions,
310122
- 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.
310123
311602
  exploreStats: explore.exploreStats,
310124
311603
  healthObservations: explore.healthObservations
310125
311604
  };
@@ -310136,7 +311615,7 @@ var require_mobile_discovery_agent = __commonJS({
310136
311615
  const onScreenshot = callbacks?.onScreenshot;
310137
311616
  const runExplore = callbacks?.runExplore ?? mobile_explorer_js_1.runMobileExplorePhase;
310138
311617
  const runPlan = callbacks?.runPlan ?? mobile_discovery_prompts_js_1.runMobileAIPlanner;
310139
- const runGenerate = callbacks?.runGenerate ?? defaultRunGenerate;
311618
+ const runGenerate = callbacks?.runGenerate ?? mobile_generator_js_1.runMobileGeneratePhase;
310140
311619
  const log2 = (line) => {
310141
311620
  logs2.push(line);
310142
311621
  try {
@@ -310209,6 +311688,7 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
310209
311688
  featureName: ctx.featureName,
310210
311689
  existingTestNames: ctx.existingTestNames,
310211
311690
  existingFeatureNames: ctx.existingFeatureNames,
311691
+ recordedJourneys: explore.recordedJourneys,
310212
311692
  collectedPages: screenMap,
310213
311693
  collectedTransitions,
310214
311694
  credentials: {
@@ -310276,9 +311756,8 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
310276
311756
  ctx,
310277
311757
  onLog: (line) => log2(line),
310278
311758
  onAICall,
310279
- onTestGenerated: (test) => {
310280
- callbacks?.onTestGenerated?.(test, { phase: "verified" });
310281
- }
311759
+ onScreenshot,
311760
+ onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
310282
311761
  });
310283
311762
  const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
310284
311763
  log2(`
@@ -310802,7 +312281,8 @@ Respond with ONLY valid JSON:
310802
312281
  userNotes: ctx.userNotes,
310803
312282
  onAICall,
310804
312283
  onProgress,
310805
- onCredentialUpdate: ctx.onCredentialUpdate
312284
+ onCredentialUpdate: ctx.onCredentialUpdate,
312285
+ getPerceptionPage: () => handle?.page ?? null
310806
312286
  });
310807
312287
  logs2.push(...exploreResult.logs);
310808
312288
  screenshots.push(...exploreResult.screenshots);
@@ -311341,6 +312821,7 @@ Fatal error: ${msg}`);
311341
312821
  onAICall,
311342
312822
  onProgress,
311343
312823
  onCredentialUpdate: ctx.onCredentialUpdate,
312824
+ getPerceptionPage: () => handle?.page ?? null,
311344
312825
  onTestPhaseChange: (phase) => {
311345
312826
  healthObserver?.setTestPhase(phase);
311346
312827
  liveCollector?.setTestPhase(phase);
@@ -323980,6 +325461,8 @@ var require_live_browser_registry = __commonJS({
323980
325461
  exports.liveBrowserRegistry = void 0;
323981
325462
  var THUMBNAIL_INTERVAL_MS = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_INTERVAL_MS ?? "4000", 10) || 4e3;
323982
325463
  var THUMBNAIL_QUALITY = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_QUALITY ?? "60", 10) || 60;
325464
+ var BROWSER_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_MAX_BASE64_BYTES ?? "400000", 10) || 4e5;
325465
+ var MOBILE_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_MOBILE_THUMBNAIL_MAX_BASE64_BYTES ?? "4500000", 10) || 45e5;
323983
325466
  var STALE_SESSION_MS = parseInt(process.env.LIVE_BROWSER_STALE_MS ?? `${10 * 60 * 1e3}`, 10) || 10 * 60 * 1e3;
323984
325467
  var MAX_SESSIONS = parseInt(process.env.LIVE_BROWSER_MAX_SESSIONS ?? "32", 10) || 32;
323985
325468
  var LiveBrowserRegistry = class {
@@ -324074,7 +325557,8 @@ var require_live_browser_registry = __commonJS({
324074
325557
  entry.currentUrl = snapshot.currentUrl;
324075
325558
  }
324076
325559
  const thumbnail = snapshot.thumbnailJpegBase64;
324077
- if (thumbnail && thumbnail.length <= 16e4) {
325560
+ const thumbnailLimit = entry.meta.surface === "mobile" ? MOBILE_THUMBNAIL_MAX_BASE64_BYTES : BROWSER_THUMBNAIL_MAX_BASE64_BYTES;
325561
+ if (thumbnail && thumbnail.length <= thumbnailLimit) {
324078
325562
  entry.thumbnailJpegBase64 = thumbnail;
324079
325563
  entry.thumbnailCapturedAt = typeof snapshot.thumbnailCapturedAt === "number" && Number.isFinite(snapshot.thumbnailCapturedAt) ? snapshot.thumbnailCapturedAt : Date.now();
324080
325564
  entry.thumbnailWidth = typeof snapshot.thumbnailWidth === "number" && Number.isFinite(snapshot.thumbnailWidth) ? snapshot.thumbnailWidth : void 0;
@@ -324208,7 +325692,7 @@ var require_live_browser_registry = __commonJS({
324208
325692
  scale: "css"
324209
325693
  });
324210
325694
  const base64 = buf.toString("base64");
324211
- if (base64.length > 16e4) {
325695
+ if (base64.length > BROWSER_THUMBNAIL_MAX_BASE64_BYTES) {
324212
325696
  return;
324213
325697
  }
324214
325698
  entry.thumbnailJpegBase64 = base64;
@@ -324474,6 +325958,7 @@ var require_dist2 = __commonJS({
324474
325958
  __exportStar(require_ai_provider(), exports);
324475
325959
  __exportStar(require_mobile_types(), exports);
324476
325960
  __exportStar(require_mobile_source_parser(), exports);
325961
+ __exportStar(require_mobile_boundary(), exports);
324477
325962
  var mobile_explorer_js_1 = require_mobile_explorer();
324478
325963
  Object.defineProperty(exports, "runMobileExplorePhase", { enumerable: true, get: function() {
324479
325964
  return mobile_explorer_js_1.runMobileExplorePhase;
@@ -324520,6 +326005,7 @@ var require_dist2 = __commonJS({
324520
326005
  return api_call_redaction_js_1.headerVal;
324521
326006
  } });
324522
326007
  __exportStar(require_mcp_healer(), exports);
326008
+ __exportStar(require_selector_registry_context(), exports);
324523
326009
  var grok_per_test_healer_js_1 = require_grok_per_test_healer();
324524
326010
  Object.defineProperty(exports, "executeGrokPerTestHeal", { enumerable: true, get: function() {
324525
326011
  return grok_per_test_healer_js_1.executeGrokPerTestHeal;
@@ -324805,7 +326291,7 @@ var require_package4 = __commonJS({
324805
326291
  "package.json"(exports, module) {
324806
326292
  module.exports = {
324807
326293
  name: "@validate.qa/runner",
324808
- version: "1.0.4",
326294
+ version: "1.0.6",
324809
326295
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
324810
326296
  bin: {
324811
326297
  "validate-runner": "dist/cli.js"
@@ -324903,7 +326389,7 @@ var import_runner_core = __toESM(require_dist2());
324903
326389
  var import_runner_core2 = __toESM(require_dist2());
324904
326390
 
324905
326391
  // src/runner.ts
324906
- var import_runner_core9 = __toESM(require_dist2());
326392
+ var import_runner_core10 = __toESM(require_dist2());
324907
326393
 
324908
326394
  // src/services/regression-runner.ts
324909
326395
  var import_runner_core3 = __toESM(require_dist2());
@@ -324918,7 +326404,7 @@ var import_runner_core5 = __toESM(require_dist2());
324918
326404
  var import_runner_core6 = __toESM(require_dist2());
324919
326405
 
324920
326406
  // src/runner.ts
324921
- var import_runner_core10 = __toESM(require_dist2());
326407
+ var import_runner_core11 = __toESM(require_dist2());
324922
326408
 
324923
326409
  // src/services/auth-state.ts
324924
326410
  var INLINE_LOGIN_TAG = "@inline-login";
@@ -324938,6 +326424,7 @@ function shouldUseAuthState(tags, playwrightCode) {
324938
326424
 
324939
326425
  // src/services/appium-executor.ts
324940
326426
  init_dist();
326427
+ var import_runner_core8 = __toESM(require_dist2());
324941
326428
 
324942
326429
  // src/services/appium-lifecycle.ts
324943
326430
  import { spawn, execFileSync } from "child_process";
@@ -325477,6 +326964,647 @@ function getRunnerCapabilities() {
325477
326964
  };
325478
326965
  }
325479
326966
 
326967
+ // src/services/mobile/mobile-network-capture.ts
326968
+ var import_runner_core7 = __toESM(require_dist2());
326969
+ import { execFile, execFileSync as execFileSync3 } from "child_process";
326970
+ import { randomUUID } from "crypto";
326971
+ import { mkdtemp, rm, writeFile, readFile, readdir, mkdir, unlink } from "fs/promises";
326972
+ import { tmpdir, networkInterfaces } from "os";
326973
+ import { join as join2 } from "path";
326974
+ import { promisify } from "util";
326975
+ import {
326976
+ getLocal,
326977
+ generateCACertificate
326978
+ } from "mockttp";
326979
+ var execFileAsync = promisify(execFile);
326980
+ var MAX_CALLS = 1e3;
326981
+ var MAX_BODY_CAPTURE_BYTES = 64 * 1024;
326982
+ var DEVICE_CMD_TIMEOUT_MS = 15e3;
326983
+ var CA_KEY_BITS = 2048;
326984
+ var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
326985
+ var PENDING_CLEANUP_DIR = join2(tmpdir(), "validateqa-mobile-capture-pending");
326986
+ function envFlag(name) {
326987
+ const value = (process.env[name] ?? "").trim().toLowerCase();
326988
+ return value === "1" || value === "true" || value === "yes" || value === "on";
326989
+ }
326990
+ function rawHeadersToPairs(rawHeaders) {
326991
+ return rawHeaders.map(([name, value]) => ({ name, value }));
326992
+ }
326993
+ function headerValue(pairs, name) {
326994
+ const lower = name.toLowerCase();
326995
+ return pairs.find((h) => h.name.toLowerCase() === lower)?.value;
326996
+ }
326997
+ function resolveHostIp() {
326998
+ const ifaces = networkInterfaces();
326999
+ for (const addrs of Object.values(ifaces)) {
327000
+ if (!addrs) continue;
327001
+ for (const addr of addrs) {
327002
+ if (addr.family === "IPv4" && !addr.internal) return addr.address;
327003
+ }
327004
+ }
327005
+ return "127.0.0.1";
327006
+ }
327007
+ function resolveProxyHostForDevice(platform3, isPhysical) {
327008
+ if (platform3 === "ANDROID" && !isPhysical) return ANDROID_EMULATOR_HOST_LOOPBACK;
327009
+ if (platform3 === "IOS" && !isPhysical) return "127.0.0.1";
327010
+ return resolveHostIp();
327011
+ }
327012
+ function normalizeRemoteAddress(addr) {
327013
+ if (!addr) return "";
327014
+ return addr.startsWith("::ffff:") ? addr.slice("::ffff:".length) : addr;
327015
+ }
327016
+ function isLoopbackAddress(addr) {
327017
+ return addr === "127.0.0.1" || addr.startsWith("127.") || addr === "::1" || addr === "";
327018
+ }
327019
+ function isPrivateAddress(addr) {
327020
+ if (/^10\./.test(addr)) return true;
327021
+ if (/^192\.168\./.test(addr)) return true;
327022
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
327023
+ if (/^169\.254\./.test(addr)) return true;
327024
+ const lower = addr.toLowerCase();
327025
+ if (lower.startsWith("fe80:")) return true;
327026
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
327027
+ return false;
327028
+ }
327029
+ function isAllowedProxyPeer(remoteAddress, isPhysical) {
327030
+ const addr = normalizeRemoteAddress(remoteAddress);
327031
+ if (isLoopbackAddress(addr)) return true;
327032
+ return isPhysical && isPrivateAddress(addr);
327033
+ }
327034
+ function installSourceIpGuard(server3, isPhysical, onLog) {
327035
+ const underlying = server3.server;
327036
+ if (!underlying || typeof underlying.on !== "function") {
327037
+ onLog?.(
327038
+ "[mobile-net] WARNING: could not attach source-IP guard to the proxy listener; the port is bound to all interfaces \u2014 firewall it to the device subnet."
327039
+ );
327040
+ return;
327041
+ }
327042
+ underlying.on("connection", (socket) => {
327043
+ if (!isAllowedProxyPeer(socket.remoteAddress, isPhysical)) {
327044
+ onLog?.(`[mobile-net] rejected proxy connection from disallowed source ${socket.remoteAddress}`);
327045
+ socket.destroy();
327046
+ }
327047
+ });
327048
+ }
327049
+ function isTextish(contentType) {
327050
+ if (!contentType) return false;
327051
+ return /json|text|xml|graphql|html|csv|javascript|urlencoded/i.test(contentType);
327052
+ }
327053
+ async function readBodyText(body, contentType) {
327054
+ if (!isTextish(contentType)) return void 0;
327055
+ try {
327056
+ const decoded = await body.getDecodedBuffer();
327057
+ if (!decoded || decoded.length === 0) return void 0;
327058
+ if (decoded.length > MAX_BODY_CAPTURE_BYTES) {
327059
+ return decoded.subarray(0, MAX_BODY_CAPTURE_BYTES).toString("utf-8");
327060
+ }
327061
+ return decoded.toString("utf-8");
327062
+ } catch {
327063
+ return void 0;
327064
+ }
327065
+ }
327066
+ var MitmProxy = class {
327067
+ constructor(caKeyPem, caCertPem, caCertPath, isPhysical, tlsPassthroughAll, onLog) {
327068
+ this.caKeyPem = caKeyPem;
327069
+ this.caCertPem = caCertPem;
327070
+ this.caCertPath = caCertPath;
327071
+ this.isPhysical = isPhysical;
327072
+ this.tlsPassthroughAll = tlsPassthroughAll;
327073
+ this.onLog = onLog;
327074
+ }
327075
+ server = null;
327076
+ inFlight = /* @__PURE__ */ new Map();
327077
+ calls = [];
327078
+ dropped = 0;
327079
+ /** Flips true once any HTTPS response is successfully MITM-ed. */
327080
+ httpsIntercepted = false;
327081
+ /** Best-known reason interception is unavailable, refined by TLS failures. */
327082
+ tlsFailureReason = null;
327083
+ /** Count of opaque HTTPS tunnels when TLS passthrough is enabled. */
327084
+ tlsPassthroughCount = 0;
327085
+ /** Start listening; resolves with the actual bound port. */
327086
+ async listen(preferredPort) {
327087
+ const server3 = getLocal({
327088
+ // Our generated CA — mockttp mints per-host leaf certs signed by it.
327089
+ https: {
327090
+ key: this.caKeyPem,
327091
+ cert: this.caCertPem,
327092
+ ...this.tlsPassthroughAll ? { tlsPassthrough: [{ hostname: "*" }] } : {}
327093
+ },
327094
+ // HTTP/2 with HTTP/1.1 fallback; mobile clients negotiate either.
327095
+ http2: "fallback",
327096
+ // Keep the proxy quiet unless explicitly debugging.
327097
+ recordTraffic: false
327098
+ });
327099
+ await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
327100
+ await server3.on("request", (req) => this.onRequest(req));
327101
+ await server3.on("response", (res) => this.onResponse(res));
327102
+ await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
327103
+ await server3.on("tls-passthrough-opened", (event) => this.onTlsPassthroughOpened(event));
327104
+ await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
327105
+ installSourceIpGuard(server3, this.isPhysical, this.onLog);
327106
+ this.server = server3;
327107
+ return server3.port;
327108
+ }
327109
+ /** Captured calls, finalized through the shared redaction constructor. */
327110
+ finalize() {
327111
+ return this.calls.map(
327112
+ (c) => (0, import_runner_core7.buildApiCallSummary)({
327113
+ method: c.method,
327114
+ url: c.url,
327115
+ status: c.status,
327116
+ durationMs: c.durationMs,
327117
+ authHeader: c.authHeader,
327118
+ cookieHeader: c.cookieHeader,
327119
+ requestContentType: c.requestContentType,
327120
+ responseContentType: c.responseContentType,
327121
+ respHeaders: c.respHeaders,
327122
+ reqBodyText: c.reqBodyText,
327123
+ respBodyText: c.respBodyText,
327124
+ responseSize: c.responseSize,
327125
+ timestamp: c.startedAt,
327126
+ testPhase: "UNKNOWN"
327127
+ })
327128
+ );
327129
+ }
327130
+ /** True once at least one HTTPS exchange was decrypted (CA trusted). */
327131
+ get isIntercepting() {
327132
+ return this.httpsIntercepted;
327133
+ }
327134
+ /** A precise reason interception is (still) unavailable, or null if it works. */
327135
+ get interceptionReason() {
327136
+ if (this.httpsIntercepted) return null;
327137
+ if (this.tlsPassthroughAll) {
327138
+ return "HTTPS TLS passthrough active: HTTPS bodies are not decrypted, but app traffic continues. Set VALIDATEQA_MOBILE_FORCE_TLS_MITM=1 for debug apps/devices that trust the validate.qa CA" + (this.caCertPath ? ` at ${this.caCertPath}.` : ".");
327139
+ }
327140
+ return this.tlsFailureReason;
327141
+ }
327142
+ async close() {
327143
+ const server3 = this.server;
327144
+ this.server = null;
327145
+ this.inFlight.clear();
327146
+ if (!server3) return;
327147
+ try {
327148
+ await server3.stop();
327149
+ } catch (err) {
327150
+ this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg(err)}`);
327151
+ }
327152
+ this.onLog?.(
327153
+ `[mobile-net] proxy closed: ${this.calls.length} calls captured` + (this.dropped > 0 ? ` (${this.dropped} dropped over cap)` : "")
327154
+ );
327155
+ }
327156
+ // ── request → buffer by id ────────────────────────────────────────────────
327157
+ async onRequest(req) {
327158
+ try {
327159
+ const reqHeaders = rawHeadersToPairs(req.rawHeaders);
327160
+ const requestContentType = headerValue(reqHeaders, "content-type");
327161
+ const isHttps = isHttpsUrl(req.url);
327162
+ const reqBodyText = (0, import_runner_core7.isApiCall)(req.url, requestContentType) ? await readBodyText(req.body, requestContentType) : void 0;
327163
+ this.inFlight.set(req.id, {
327164
+ method: req.method,
327165
+ url: req.url,
327166
+ startedAt: req.timingEvents.startTime,
327167
+ reqHeaders,
327168
+ requestContentType,
327169
+ authHeader: headerValue(reqHeaders, "authorization"),
327170
+ cookieHeader: headerValue(reqHeaders, "cookie"),
327171
+ reqBodyText,
327172
+ isHttps
327173
+ });
327174
+ } catch (err) {
327175
+ this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg(err)}`);
327176
+ }
327177
+ }
327178
+ // ── response → finalize the matched request ───────────────────────────────
327179
+ async onResponse(res) {
327180
+ const pending = this.inFlight.get(res.id);
327181
+ if (!pending) return;
327182
+ this.inFlight.delete(res.id);
327183
+ try {
327184
+ if (pending.isHttps && !this.httpsIntercepted) {
327185
+ this.httpsIntercepted = true;
327186
+ this.tlsFailureReason = null;
327187
+ this.onLog?.("[mobile-net] HTTPS interception confirmed (CA trusted; bodies decrypted).");
327188
+ }
327189
+ const respHeaders = rawHeadersToPairs(res.rawHeaders);
327190
+ const responseContentType = headerValue(respHeaders, "content-type");
327191
+ const api = (0, import_runner_core7.isApiCall)(pending.url, responseContentType);
327192
+ if (!api && (0, import_runner_core7.isStaticAssetByExt)(pending.url)) return;
327193
+ if (this.calls.length >= MAX_CALLS) {
327194
+ this.dropped++;
327195
+ return;
327196
+ }
327197
+ const respBodyText = api ? await readBodyText(res.body, responseContentType) : void 0;
327198
+ const responseSize = await measureBody(res.body);
327199
+ const durationMs = computeDuration(pending.startedAt, res);
327200
+ this.calls.push({
327201
+ method: pending.method,
327202
+ url: pending.url,
327203
+ status: res.statusCode,
327204
+ startedAt: pending.startedAt,
327205
+ durationMs,
327206
+ reqHeaders: pending.reqHeaders,
327207
+ respHeaders,
327208
+ requestContentType: pending.requestContentType,
327209
+ responseContentType,
327210
+ authHeader: pending.authHeader,
327211
+ cookieHeader: pending.cookieHeader,
327212
+ reqBodyText: pending.reqBodyText,
327213
+ respBodyText,
327214
+ responseSize,
327215
+ intercepted: true
327216
+ });
327217
+ } catch (err) {
327218
+ this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg(err)}`);
327219
+ }
327220
+ }
327221
+ // ── tls-client-error → record WHY interception failed ─────────────────────
327222
+ onTlsClientError(failure) {
327223
+ if (this.httpsIntercepted) return;
327224
+ const host = failure.destination?.hostname ?? failure.tlsMetadata.sniHostname ?? "unknown host";
327225
+ const trustHint = this.caCertPath ? `Trust the validate.qa CA at ${this.caCertPath} to enable HTTPS body capture.` : "Trust the generated validate.qa CA to enable HTTPS body capture.";
327226
+ let reason;
327227
+ switch (failure.failureCause) {
327228
+ case "cert-rejected":
327229
+ reason = `TLS handshake to ${host} rejected our certificate: the device does not trust the validate.qa CA, so HTTPS bodies cannot be decrypted (plaintext HTTP still captured). ` + trustHint;
327230
+ break;
327231
+ case "closed":
327232
+ case "reset":
327233
+ reason = `TLS handshake to ${host} was dropped after our certificate was offered \u2014 this is the signature of certificate pinning in the app. HTTPS bodies for pinned hosts cannot be decrypted (plaintext HTTP and connect-level host metadata still captured).`;
327234
+ break;
327235
+ case "no-shared-cipher":
327236
+ reason = `TLS handshake to ${host} failed: no shared cipher with the client.`;
327237
+ break;
327238
+ case "handshake-timeout":
327239
+ reason = `TLS handshake to ${host} timed out before completing.`;
327240
+ break;
327241
+ default:
327242
+ reason = `TLS handshake to ${host} failed (${failure.failureCause}); HTTPS not intercepted for it.`;
327243
+ }
327244
+ if (!this.tlsFailureReason || failure.failureCause === "cert-rejected" || failure.failureCause === "closed" || failure.failureCause === "reset") {
327245
+ this.tlsFailureReason = reason;
327246
+ }
327247
+ this.onLog?.(`[mobile-net] ${reason}`);
327248
+ }
327249
+ onTlsPassthroughOpened(event) {
327250
+ this.tlsPassthroughCount++;
327251
+ if (this.tlsPassthroughCount === 1) {
327252
+ const host = event.destination?.hostname ?? event.hostname ?? "unknown host";
327253
+ this.onLog?.(
327254
+ `[mobile-net] HTTPS TLS passthrough active for ${host}; app traffic is preserved, but HTTPS bodies are not decrypted.`
327255
+ );
327256
+ }
327257
+ }
327258
+ };
327259
+ function isHttpsUrl(url) {
327260
+ try {
327261
+ return new URL(url).protocol === "https:";
327262
+ } catch {
327263
+ return false;
327264
+ }
327265
+ }
327266
+ async function measureBody(body) {
327267
+ try {
327268
+ const decoded = await body.getDecodedBuffer();
327269
+ return decoded ? decoded.length : 0;
327270
+ } catch {
327271
+ return 0;
327272
+ }
327273
+ }
327274
+ function computeDuration(startedAt, res) {
327275
+ const sent = res.timingEvents.responseSentTimestamp;
327276
+ const start = res.timingEvents.startTimestamp;
327277
+ if (typeof sent === "number" && typeof start === "number" && sent >= start) {
327278
+ return Math.round(sent - start);
327279
+ }
327280
+ const elapsed = Date.now() - startedAt;
327281
+ return elapsed > 0 ? elapsed : 0;
327282
+ }
327283
+ async function generateCaCert(dir, onLog) {
327284
+ try {
327285
+ const { key, cert } = await generateCACertificate({
327286
+ subject: {
327287
+ commonName: "validate.qa Mobile Capture CA",
327288
+ organizationName: "validate.qa"
327289
+ },
327290
+ bits: CA_KEY_BITS
327291
+ // mockttp's generated CA carries its own (multi-year) validity window; we
327292
+ // rely on stop() to remove the cert from the device promptly after the run
327293
+ // rather than on a short expiry.
327294
+ });
327295
+ const caCertPath = join2(dir, "validateqa-mobile-ca.pem");
327296
+ await writeFile(caCertPath, cert, "utf-8");
327297
+ return { caCertPath, caKeyPem: key, caCertPem: cert };
327298
+ } catch (err) {
327299
+ onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg(err)}`);
327300
+ return null;
327301
+ }
327302
+ }
327303
+ async function androidReadProxy(deviceId) {
327304
+ try {
327305
+ const { stdout } = await execFileAsync(
327306
+ "adb",
327307
+ ["-s", deviceId, "shell", "settings", "get", "global", "http_proxy"],
327308
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327309
+ );
327310
+ const value = stdout.trim();
327311
+ return value === "" || value === "null" ? null : value;
327312
+ } catch {
327313
+ return null;
327314
+ }
327315
+ }
327316
+ async function androidSetProxy(deviceId, hostPort, onLog) {
327317
+ try {
327318
+ await execFileAsync(
327319
+ "adb",
327320
+ ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", hostPort],
327321
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327322
+ );
327323
+ return true;
327324
+ } catch (err) {
327325
+ onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg(err)}`);
327326
+ return false;
327327
+ }
327328
+ }
327329
+ async function androidClearProxy(deviceId, originalProxy, onLog) {
327330
+ const restoreValue = originalProxy && originalProxy.length > 0 ? originalProxy : ":0";
327331
+ try {
327332
+ await execFileAsync(
327333
+ "adb",
327334
+ ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue],
327335
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327336
+ );
327337
+ } catch (err) {
327338
+ onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg(err)}`);
327339
+ }
327340
+ }
327341
+ async function androidInstallCa(deviceId, caCertPath) {
327342
+ const devicePath = "/sdcard/validateqa-mobile-ca.pem";
327343
+ try {
327344
+ await execFileAsync("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
327345
+ timeout: DEVICE_CMD_TIMEOUT_MS
327346
+ });
327347
+ return {
327348
+ installed: false,
327349
+ note: `CA copied from ${caCertPath} to ${devicePath} but NOT auto-trusted: Android 7+ apps must opt into user CAs (network_security_config), and the system store requires a rooted/writable image. HTTPS bodies decrypt only for apps that trust user CAs; pinned apps never decrypt.`
327350
+ };
327351
+ } catch (err) {
327352
+ return { installed: false, note: `CA push failed: ${errMsg(err)}` };
327353
+ }
327354
+ }
327355
+ async function androidRemoveCa(deviceId, onLog) {
327356
+ try {
327357
+ await execFileAsync(
327358
+ "adb",
327359
+ ["-s", deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"],
327360
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327361
+ );
327362
+ } catch (err) {
327363
+ onLog?.(`[mobile-net] adb remove CA failed: ${errMsg(err)}`);
327364
+ }
327365
+ }
327366
+ async function iosSimTrustCa(deviceId, caCertPath) {
327367
+ try {
327368
+ await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
327369
+ timeout: DEVICE_CMD_TIMEOUT_MS
327370
+ });
327371
+ return {
327372
+ trusted: true,
327373
+ note: "CA added to simulator keychain (add-root-cert). NOTE: the simulator has no per-app HTTP proxy setting controllable via simctl \u2014 it shares the host network, so only traffic that actually routes through the proxy is intercepted; pinned apps still never decrypt."
327374
+ };
327375
+ } catch (err) {
327376
+ return { trusted: false, note: `simctl add-root-cert failed: ${errMsg(err)}` };
327377
+ }
327378
+ }
327379
+ async function iosSimRemoveCa(deviceId, onLog) {
327380
+ if (process.env.VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET === "1") {
327381
+ try {
327382
+ await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "reset"], {
327383
+ timeout: DEVICE_CMD_TIMEOUT_MS
327384
+ });
327385
+ onLog?.(`[mobile-net] simctl keychain reset run for ${deviceId} (opt-in; cleared ALL sim certs).`);
327386
+ } catch (err) {
327387
+ onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg(err)}`);
327388
+ }
327389
+ return;
327390
+ }
327391
+ onLog?.(
327392
+ `[mobile-net] NOTE: the validate.qa capture CA remains TRUSTED in simulator ${deviceId}. simctl has no scoped remove (only a full keychain reset that wipes ALL certs), so we leave the single CA in place. Use a throwaway simulator, or set VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET=1 to reset the whole sim keychain on teardown.`
327393
+ );
327394
+ }
327395
+ function errMsg(err) {
327396
+ return err instanceof Error ? err.message : String(err);
327397
+ }
327398
+ var activeCleanups = /* @__PURE__ */ new Map();
327399
+ var exitHandlersInstalled = false;
327400
+ function markerPath(sessionKey) {
327401
+ return join2(PENDING_CLEANUP_DIR, `${sessionKey}.json`);
327402
+ }
327403
+ async function writePendingCleanup(sessionKey, marker) {
327404
+ try {
327405
+ await mkdir(PENDING_CLEANUP_DIR, { recursive: true });
327406
+ await writeFile(markerPath(sessionKey), JSON.stringify(marker), "utf-8");
327407
+ } catch {
327408
+ }
327409
+ }
327410
+ async function removePendingCleanup(sessionKey) {
327411
+ try {
327412
+ await unlink(markerPath(sessionKey));
327413
+ } catch {
327414
+ }
327415
+ }
327416
+ function restoreDeviceSync(marker, onLog) {
327417
+ const run2 = (args) => {
327418
+ try {
327419
+ execFileSync3("adb", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
327420
+ } catch {
327421
+ }
327422
+ };
327423
+ if (marker.platform === "ANDROID") {
327424
+ if (marker.proxySet) {
327425
+ const restoreValue = marker.originalProxy && marker.originalProxy.length > 0 ? marker.originalProxy : ":0";
327426
+ run2(["-s", marker.deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue]);
327427
+ }
327428
+ if (marker.caInstalledOnDevice) {
327429
+ run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
327430
+ }
327431
+ }
327432
+ onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
327433
+ }
327434
+ async function reconcilePendingCleanups(onLog) {
327435
+ let files;
327436
+ try {
327437
+ files = await readdir(PENDING_CLEANUP_DIR);
327438
+ } catch {
327439
+ return;
327440
+ }
327441
+ for (const file of files) {
327442
+ if (!file.endsWith(".json")) continue;
327443
+ const full = join2(PENDING_CLEANUP_DIR, file);
327444
+ try {
327445
+ const raw = await readFile(full, "utf-8");
327446
+ const marker = JSON.parse(raw);
327447
+ if (marker && typeof marker.deviceId === "string" && marker.platform) {
327448
+ onLog?.(`[mobile-net] reconciling orphaned capture cleanup for ${marker.deviceId}.`);
327449
+ restoreDeviceSync(marker, onLog);
327450
+ }
327451
+ } catch {
327452
+ }
327453
+ try {
327454
+ await unlink(full);
327455
+ } catch {
327456
+ }
327457
+ }
327458
+ }
327459
+ function ensureExitHandlers() {
327460
+ if (exitHandlersInstalled) return;
327461
+ exitHandlersInstalled = true;
327462
+ const drain = () => {
327463
+ for (const marker of activeCleanups.values()) {
327464
+ restoreDeviceSync(marker);
327465
+ }
327466
+ };
327467
+ process.on("beforeExit", drain);
327468
+ process.on("exit", drain);
327469
+ for (const signal of ["SIGTERM", "SIGINT"]) {
327470
+ process.on(signal, () => {
327471
+ drain();
327472
+ process.removeAllListeners(signal);
327473
+ process.kill(process.pid, signal);
327474
+ });
327475
+ }
327476
+ }
327477
+ async function startMobileNetworkCapture(options) {
327478
+ const { platform: platform3, deviceId, isPhysical = false, preferredPort = 0, onLog } = options;
327479
+ const forceTlsMitm = options.forceTlsMitm ?? envFlag("VALIDATEQA_MOBILE_FORCE_TLS_MITM");
327480
+ const tlsPassthroughAll = !forceTlsMitm && (platform3 === "ANDROID" || platform3 === "IOS" && isPhysical);
327481
+ await reconcilePendingCleanups(onLog);
327482
+ ensureExitHandlers();
327483
+ const sessionKey = randomUUID();
327484
+ const originalProxy = platform3 === "ANDROID" ? await androidReadProxy(deviceId) : null;
327485
+ let tmpDir = null;
327486
+ let caCertPath = null;
327487
+ let caKeyPem = null;
327488
+ let caCertPem = null;
327489
+ try {
327490
+ tmpDir = await mkdtemp(join2(tmpdir(), "validateqa-mobile-ca-"));
327491
+ const ca = await generateCaCert(tmpDir, onLog);
327492
+ if (ca) {
327493
+ caCertPath = ca.caCertPath;
327494
+ caKeyPem = ca.caKeyPem;
327495
+ caCertPem = ca.caCertPem;
327496
+ }
327497
+ } catch (err) {
327498
+ onLog?.(`[mobile-net] CA setup degraded: ${errMsg(err)}`);
327499
+ }
327500
+ if (!caKeyPem || !caCertPem) {
327501
+ try {
327502
+ const fallback = await generateCACertificate();
327503
+ caKeyPem = fallback.key;
327504
+ caCertPem = fallback.cert;
327505
+ } catch (err) {
327506
+ onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg(err)}`);
327507
+ if (tmpDir) {
327508
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => void 0);
327509
+ }
327510
+ throw err instanceof Error ? err : new Error(String(err));
327511
+ }
327512
+ }
327513
+ const proxy = new MitmProxy(caKeyPem, caCertPem, caCertPath, isPhysical, tlsPassthroughAll, onLog);
327514
+ const proxyPort = await proxy.listen(preferredPort);
327515
+ const proxyHost = resolveProxyHostForDevice(platform3, isPhysical);
327516
+ const hostPort = `${proxyHost}:${proxyPort}`;
327517
+ onLog?.(
327518
+ `[mobile-net] ${tlsPassthroughAll ? "capture proxy" : "MITM proxy"} listening; device target ${hostPort} (${platform3}${isPhysical ? " physical" : ""})`
327519
+ );
327520
+ let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
327521
+ let proxySet = false;
327522
+ let caInstalledOnDevice = false;
327523
+ try {
327524
+ if (platform3 === "ANDROID") {
327525
+ proxySet = await androidSetProxy(deviceId, hostPort, onLog);
327526
+ if (!proxySet) {
327527
+ wiringReason = "adb proxy configuration failed; no device traffic will be routed through the proxy.";
327528
+ } else if (tlsPassthroughAll) {
327529
+ wiringReason = "Android HTTPS is tunneled without MITM by default because release apps usually do not trust user-installed CAs. Plaintext HTTP is still captured; HTTPS bodies are not decrypted. For debug apps/devices that trust the CA, set VALIDATEQA_MOBILE_FORCE_TLS_MITM=1" + (caCertPath ? ` and trust ${caCertPath}.` : ".");
327530
+ } else if (caCertPath) {
327531
+ const { note } = await androidInstallCa(deviceId, caCertPath);
327532
+ caInstalledOnDevice = true;
327533
+ wiringReason = note;
327534
+ }
327535
+ } else {
327536
+ if (!isPhysical && caCertPath) {
327537
+ const { trusted, note } = await iosSimTrustCa(deviceId, caCertPath);
327538
+ caInstalledOnDevice = trusted;
327539
+ wiringReason = note;
327540
+ } else if (isPhysical) {
327541
+ wiringReason = "Physical iOS HTTPS is tunneled without MITM by default. Set the Wi-Fi HTTP proxy and trust the CA manually, then run with VALIDATEQA_MOBILE_FORCE_TLS_MITM=1 to decrypt HTTPS bodies" + (caCertPath ? ` (${caCertPath}).` : ".");
327542
+ }
327543
+ }
327544
+ } catch (err) {
327545
+ wiringReason = `device wiring degraded: ${errMsg(err)}`;
327546
+ onLog?.(`[mobile-net] ${wiringReason}`);
327547
+ }
327548
+ if (proxySet || caInstalledOnDevice) {
327549
+ const marker = {
327550
+ platform: platform3,
327551
+ deviceId,
327552
+ isPhysical,
327553
+ proxySet,
327554
+ originalProxy,
327555
+ caInstalledOnDevice
327556
+ };
327557
+ activeCleanups.set(sessionKey, marker);
327558
+ await writePendingCleanup(sessionKey, marker);
327559
+ }
327560
+ const capturedTmpDir = tmpDir;
327561
+ const capturedCaCertPath = caCertPath;
327562
+ let stopped = false;
327563
+ let finalCalls = [];
327564
+ const getCapabilities = () => {
327565
+ if (proxy.isIntercepting) return { intercepting: true };
327566
+ const reason = proxy.interceptionReason ?? wiringReason;
327567
+ return reason ? { intercepting: false, reason } : { intercepting: false };
327568
+ };
327569
+ const stop = async () => {
327570
+ if (stopped) return finalCalls;
327571
+ stopped = true;
327572
+ if (proxySet && platform3 === "ANDROID") {
327573
+ await androidClearProxy(deviceId, originalProxy, onLog);
327574
+ }
327575
+ if (caInstalledOnDevice && capturedCaCertPath) {
327576
+ if (platform3 === "ANDROID") {
327577
+ await androidRemoveCa(deviceId, onLog);
327578
+ } else if (!isPhysical) {
327579
+ await iosSimRemoveCa(deviceId, onLog);
327580
+ }
327581
+ }
327582
+ activeCleanups.delete(sessionKey);
327583
+ await removePendingCleanup(sessionKey);
327584
+ finalCalls = proxy.finalize();
327585
+ await proxy.close();
327586
+ if (capturedTmpDir) {
327587
+ try {
327588
+ await rm(capturedTmpDir, { recursive: true, force: true });
327589
+ } catch (err) {
327590
+ onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg(err)}`);
327591
+ }
327592
+ }
327593
+ const apiCount = finalCalls.filter((c) => c.isApi).length;
327594
+ onLog?.(
327595
+ `[mobile-net] stopped: ${finalCalls.length} calls captured (${apiCount} API; HTTPS interception ${proxy.isIntercepting ? "ACTIVE" : "inactive"}).`
327596
+ );
327597
+ return finalCalls;
327598
+ };
327599
+ return {
327600
+ proxyPort,
327601
+ proxyHost,
327602
+ caCertPath: capturedCaCertPath,
327603
+ getCapabilities,
327604
+ stop
327605
+ };
327606
+ }
327607
+
325480
327608
  // src/services/appium-executor.ts
325481
327609
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
325482
327610
  var WebDriverTransportError = class extends Error {
@@ -325588,6 +327716,86 @@ async function openDeepLink(sessionId, platform3, deeplink, appId) {
325588
327716
  body: JSON.stringify({ script: "mobile: deepLink", args })
325589
327717
  });
325590
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
+ }
325591
327799
  async function deleteSession(sessionId) {
325592
327800
  try {
325593
327801
  await wdRequest(`/session/${sessionId}`, { method: "DELETE" });
@@ -325714,6 +327922,29 @@ async function getActiveElementId(sessionId) {
325714
327922
  return null;
325715
327923
  }
325716
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
+ }
325717
327948
  async function executeTap(sessionId, step) {
325718
327949
  const bounds = boundsFromStep(step);
325719
327950
  if (step.target) {
@@ -325735,8 +327966,10 @@ async function executeTap(sessionId, step) {
325735
327966
  }
325736
327967
  async function executeType(sessionId, step) {
325737
327968
  if (step.value === void 0 || step.value === null || step.value === "") return;
327969
+ const value = String(step.value);
325738
327970
  const bounds = boundsFromStep(step);
325739
327971
  let elementId = null;
327972
+ let tappedBounds = false;
325740
327973
  if (step.target) {
325741
327974
  try {
325742
327975
  elementId = await findElement(sessionId, step.target, readStepTimeout(step));
@@ -325746,19 +327979,33 @@ async function executeType(sessionId, step) {
325746
327979
  }
325747
327980
  if (!elementId && bounds) {
325748
327981
  await tapCoordinates(sessionId, bounds);
327982
+ tappedBounds = true;
325749
327983
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
325750
327984
  elementId = await getActiveElementId(sessionId);
325751
327985
  }
325752
327986
  if (!elementId) {
325753
327987
  elementId = await getActiveElementId(sessionId);
325754
327988
  }
325755
- if (!elementId) {
325756
- 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
+ }
325757
327999
  }
325758
- await wdRequest(`/session/${sessionId}/element/${elementId}/value`, {
325759
- method: "POST",
325760
- body: JSON.stringify({ text: step.value })
325761
- });
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");
325762
328009
  }
325763
328010
  async function executeClear(sessionId, step) {
325764
328011
  const bounds = boundsFromStep(step);
@@ -325862,7 +328109,24 @@ async function executeMobileTest(options) {
325862
328109
  const startTime = Date.now();
325863
328110
  const stepResults = [];
325864
328111
  const screenshots = [];
328112
+ let apiCalls = [];
325865
328113
  let sessionId = null;
328114
+ let capture = null;
328115
+ let captureStopped = false;
328116
+ const stopCapture = async () => {
328117
+ if (!capture || captureStopped) return apiCalls;
328118
+ captureStopped = true;
328119
+ try {
328120
+ const captured = await capture.stop();
328121
+ apiCalls = captured.map((call) => ({
328122
+ ...call,
328123
+ pageUrl: call.pageUrl ?? `mobile-test:${options.target.appId}`
328124
+ }));
328125
+ } catch (err) {
328126
+ log2(`[mobile-net] capture stop failed: ${err instanceof Error ? err.message : String(err)}`);
328127
+ }
328128
+ return apiCalls;
328129
+ };
325866
328130
  try {
325867
328131
  log2("Checking Appium server health...");
325868
328132
  const health = await checkAppiumHealth();
@@ -325890,6 +328154,21 @@ async function executeMobileTest(options) {
325890
328154
  selectedDevice = matchingDevices[0];
325891
328155
  }
325892
328156
  log2(`Using device: ${selectedDevice.name} (${selectedDevice.id})`);
328157
+ try {
328158
+ capture = await startMobileNetworkCapture({
328159
+ platform: options.target.platform,
328160
+ deviceId: selectedDevice.id,
328161
+ appId: options.target.appId,
328162
+ isPhysical: selectedDevice.isPhysical,
328163
+ onLog: (line) => log2(line)
328164
+ });
328165
+ const caps = capture.getCapabilities();
328166
+ if (!caps.intercepting) {
328167
+ log2(`[mobile-net] HTTPS interception unavailable: ${caps.reason ?? "unknown reason"} (continuing - plaintext/metadata only)`);
328168
+ }
328169
+ } catch (err) {
328170
+ log2(`[mobile-net] capture unavailable: ${err instanceof Error ? err.message : String(err)} (continuing without network log)`);
328171
+ }
325893
328172
  if (selectedDevice.installedApps && selectedDevice.installedApps.length > 0) {
325894
328173
  if (!selectedDevice.installedApps.includes(options.target.appId)) {
325895
328174
  throw new Error(
@@ -325930,6 +328209,7 @@ async function executeMobileTest(options) {
325930
328209
  log2(` \u2717 Deep link failed: ${err instanceof Error ? err.message : String(err)}`);
325931
328210
  }
325932
328211
  }
328212
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
325933
328213
  let environmental = null;
325934
328214
  for (const step of options.steps) {
325935
328215
  const stepStart = Date.now();
@@ -325937,6 +328217,7 @@ async function executeMobileTest(options) {
325937
328217
  let status = "passed";
325938
328218
  let stepError;
325939
328219
  try {
328220
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} pre-check`, false);
325940
328221
  switch (step.action) {
325941
328222
  case "launchApp":
325942
328223
  log2(" App launched (handled by session creation)");
@@ -325976,6 +328257,7 @@ async function executeMobileTest(options) {
325976
328257
  break;
325977
328258
  }
325978
328259
  }
328260
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
325979
328261
  } catch (err) {
325980
328262
  status = "failed";
325981
328263
  stepError = err instanceof Error ? err.message : String(err);
@@ -325996,13 +328278,19 @@ async function executeMobileTest(options) {
325996
328278
  log2(`Aborting remaining steps \u2014 Appium session is no longer usable: ${environmental}`);
325997
328279
  break;
325998
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
+ }
325999
328285
  }
326000
328286
  const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
326001
328287
  const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
328288
+ await stopCapture();
326002
328289
  return {
326003
328290
  passed: allPassed,
326004
328291
  stepResults,
326005
328292
  screenshots,
328293
+ apiCalls,
326006
328294
  logs: logLines.join("\n"),
326007
328295
  error: firstError,
326008
328296
  duration: Date.now() - startTime,
@@ -326011,10 +328299,12 @@ async function executeMobileTest(options) {
326011
328299
  } catch (err) {
326012
328300
  const errorMsg = err instanceof Error ? err.message : String(err);
326013
328301
  log2(`Fatal error: ${errorMsg}`);
328302
+ await stopCapture();
326014
328303
  return {
326015
328304
  passed: false,
326016
328305
  stepResults,
326017
328306
  screenshots,
328307
+ apiCalls,
326018
328308
  logs: logLines.join("\n"),
326019
328309
  error: errorMsg,
326020
328310
  duration: Date.now() - startTime,
@@ -326024,6 +328314,7 @@ async function executeMobileTest(options) {
326024
328314
  environmental: errorMsg
326025
328315
  };
326026
328316
  } finally {
328317
+ await stopCapture();
326027
328318
  if (sessionId) {
326028
328319
  log2("Tearing down Appium session...");
326029
328320
  await deleteSession(sessionId);
@@ -326031,7 +328322,7 @@ async function executeMobileTest(options) {
326031
328322
  }
326032
328323
  }
326033
328324
  }
326034
- async function createMobileDiscoverySession(target) {
328325
+ async function createMobileDiscoverySession(target, options) {
326035
328326
  const health = await checkAppiumHealth();
326036
328327
  if (!health.ok) {
326037
328328
  await startAppiumServer();
@@ -326058,6 +328349,12 @@ async function createMobileDiscoverySession(target) {
326058
328349
  `App ${target.appId} not installed on ${selectedDevice.name}. Install it first.`
326059
328350
  );
326060
328351
  }
328352
+ await options?.onDeviceSelected?.({
328353
+ deviceId: selectedDevice.id,
328354
+ name: selectedDevice.name,
328355
+ isPhysical: selectedDevice.isPhysical === true,
328356
+ platformVersion: selectedDevice.platformVersion
328357
+ });
326061
328358
  const sessionId = await createSession({
326062
328359
  appId: target.appId,
326063
328360
  platform: target.platform,
@@ -326088,10 +328385,10 @@ async function createMobileDiscoverySession(target) {
326088
328385
 
326089
328386
  // src/services/mobile-bridge.ts
326090
328387
  import { createServer } from "http";
326091
- import { execFileSync as execFileSync3 } from "child_process";
328388
+ import { execFileSync as execFileSync4 } from "child_process";
326092
328389
  import { randomBytes, timingSafeEqual } from "crypto";
326093
328390
  import { homedir as homedir2 } from "os";
326094
- import { join as join2, dirname } from "path";
328391
+ import { join as join3, dirname } from "path";
326095
328392
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, mkdirSync as mkdirSync2 } from "fs";
326096
328393
  init_dist();
326097
328394
 
@@ -326380,7 +328677,7 @@ async function getWindowSize3() {
326380
328677
  }
326381
328678
  var BRIDGE_PROCESS_MARKER = "validateqa-mobile-bridge";
326382
328679
  var BRIDGE_PORT = MOBILE_BRIDGE_PORT2;
326383
- var BRIDGE_PID_FILE = join2(homedir2(), ".validate.qa", "mobile-bridge.pid");
328680
+ var BRIDGE_PID_FILE = join3(homedir2(), ".validate.qa", "mobile-bridge.pid");
326384
328681
  function readBridgePidFile() {
326385
328682
  try {
326386
328683
  const raw = readFileSync2(BRIDGE_PID_FILE, "utf-8").trim();
@@ -326407,19 +328704,19 @@ function killStaleBridge() {
326407
328704
  const pid = readBridgePidFile();
326408
328705
  if (!pid) return;
326409
328706
  try {
326410
- execFileSync3("kill", ["-0", String(pid)], { stdio: "ignore" });
328707
+ execFileSync4("kill", ["-0", String(pid)], { stdio: "ignore" });
326411
328708
  } catch {
326412
328709
  clearBridgePidFile();
326413
328710
  return;
326414
328711
  }
326415
328712
  try {
326416
- const pidsOnPort = execFileSync3("lsof", ["-ti", `:${BRIDGE_PORT}`], { encoding: "utf-8" }).trim();
328713
+ const pidsOnPort = execFileSync4("lsof", ["-ti", `:${BRIDGE_PORT}`], { encoding: "utf-8" }).trim();
326417
328714
  const pidList = pidsOnPort.split("\n").filter(Boolean);
326418
328715
  if (!pidList.includes(String(pid))) {
326419
328716
  return;
326420
328717
  }
326421
- execFileSync3("kill", ["-9", String(pid)], { stdio: "ignore" });
326422
- execFileSync3("sleep", ["1"], { stdio: "ignore" });
328718
+ execFileSync4("kill", ["-9", String(pid)], { stdio: "ignore" });
328719
+ execFileSync4("sleep", ["1"], { stdio: "ignore" });
326423
328720
  } catch {
326424
328721
  } finally {
326425
328722
  clearBridgePidFile();
@@ -327250,7 +329547,7 @@ function detachMirrorSession(sessionId) {
327250
329547
  }
327251
329548
 
327252
329549
  // src/services/mobile/mobile-bridge-driver.ts
327253
- var import_runner_core7 = __toESM(require_dist2());
329550
+ var import_runner_core9 = __toESM(require_dist2());
327254
329551
  init_dist();
327255
329552
  var APPIUM_SESSION_ID_PATTERN2 = /^[a-f0-9-]{1,64}$/i;
327256
329553
  function readStringValue(result) {
@@ -327260,6 +329557,15 @@ function readStringValue(result) {
327260
329557
  }
327261
329558
  return void 0;
327262
329559
  }
329560
+ function normalizeAndroidActivity2(activity, pkg2) {
329561
+ const trimmed = activity?.trim();
329562
+ if (!trimmed) return void 0;
329563
+ const packageName = pkg2?.trim();
329564
+ if (!packageName) return trimmed;
329565
+ if (trimmed.startsWith(".")) return `${packageName}${trimmed}`;
329566
+ if (!trimmed.includes(".")) return `${packageName}.${trimmed}`;
329567
+ return trimmed;
329568
+ }
327263
329569
  var MobileBridgeDriver = class {
327264
329570
  sessionId;
327265
329571
  platform;
@@ -327286,6 +329592,38 @@ var MobileBridgeDriver = class {
327286
329592
  appLifecycleArgs() {
327287
329593
  return this.platform === "ANDROID" ? { appId: this.appId } : { bundleId: this.appId };
327288
329594
  }
329595
+ baseScreenSignal(source) {
329596
+ const parsed = (0, import_runner_core9.parseMobileSource)(source, this.platform);
329597
+ return {
329598
+ platform: this.platform,
329599
+ screenHash: parsed.screenSignal.screenHash,
329600
+ ...parsed.screenSignal.activity ? { activity: parsed.screenSignal.activity } : {},
329601
+ ...parsed.screenSignal.bundleId ? { bundleId: parsed.screenSignal.bundleId } : {}
329602
+ };
329603
+ }
329604
+ async enrichScreenSignal(source) {
329605
+ const signal = this.baseScreenSignal(source);
329606
+ try {
329607
+ if (this.platform === "ANDROID") {
329608
+ const [activityRes, packageRes] = await Promise.all([
329609
+ this.executeScript("mobile: getCurrentActivity", []).catch(() => void 0),
329610
+ this.executeScript("mobile: getCurrentPackage", []).catch(() => void 0)
329611
+ ]);
329612
+ const activity = readStringValue(activityRes);
329613
+ const pkg2 = readStringValue(packageRes);
329614
+ if (pkg2) signal.bundleId = pkg2;
329615
+ const normalizedActivity = normalizeAndroidActivity2(activity, pkg2);
329616
+ if (normalizedActivity) signal.activity = normalizedActivity;
329617
+ } else {
329618
+ const infoRes = await this.executeScript("mobile: activeAppInfo", []).catch(() => void 0);
329619
+ const bundleId = readActiveBundleId2(infoRes);
329620
+ if (bundleId) signal.bundleId = bundleId;
329621
+ }
329622
+ } catch {
329623
+ }
329624
+ if (!signal.bundleId) signal.bundleId = this.appId;
329625
+ return signal;
329626
+ }
327289
329627
  /**
327290
329628
  * Run a `mobile:` script via execute/sync. Used by the NEW lifecycle
327291
329629
  * primitives (terminateApp/activateApp/deepLink/getCurrentActivity) that have
@@ -327300,9 +329638,7 @@ var MobileBridgeDriver = class {
327300
329638
  // ── Observation ────────────────────────────────────────
327301
329639
  /**
327302
329640
  * One round-trip observation: UI-tree XML + base64 screenshot + window size,
327303
- * plus a best-effort screen signal derived from the parsed XML (cheap — no
327304
- * extra device round-trip; the activity/bundleId enrichment is left to
327305
- * getScreenSignal()).
329641
+ * plus a best-effort screen signal enriched with the foreground app identity.
327306
329642
  */
327307
329643
  async snapshot() {
327308
329644
  const [xml, screenshot, windowSize] = await Promise.all([
@@ -327311,10 +329647,7 @@ var MobileBridgeDriver = class {
327311
329647
  getWindowSize2(this.sessionPath)
327312
329648
  ]);
327313
329649
  const source = xml ?? "";
327314
- const screenSignal = {
327315
- platform: this.platform,
327316
- screenHash: (0, import_runner_core7.parseMobileSource)(source, this.platform).screenSignal.screenHash
327317
- };
329650
+ const screenSignal = await this.enrichScreenSignal(source);
327318
329651
  return {
327319
329652
  xml: source,
327320
329653
  screenshot,
@@ -327343,7 +329676,8 @@ var MobileBridgeDriver = class {
327343
329676
  getScreenshot(this.sessionPath),
327344
329677
  getPageSource(this.sessionPath)
327345
329678
  ]);
327346
- return { ok: true, screenshot, source };
329679
+ const screenSignal = await this.enrichScreenSignal(source ?? "");
329680
+ return { ok: true, screenshot, source, screenSignal };
327347
329681
  }
327348
329682
  // ── Action primitives (1:1 with the bridge handleAction verbs) ──
327349
329683
  /** Center-tap the given bounds (coordinate-only; the loop always supplies bounds). */
@@ -327421,30 +329755,10 @@ var MobileBridgeDriver = class {
327421
329755
  */
327422
329756
  async getScreenSignal() {
327423
329757
  const source = await getPageSource(this.sessionPath) ?? "";
327424
- const screenHash = (0, import_runner_core7.parseMobileSource)(source, this.platform).screenSignal.screenHash;
327425
- const signal = { platform: this.platform, screenHash };
327426
- try {
327427
- if (this.platform === "ANDROID") {
327428
- const [activityRes, packageRes] = await Promise.all([
327429
- this.executeScript("mobile: getCurrentActivity", []).catch(() => void 0),
327430
- this.executeScript("mobile: getCurrentPackage", []).catch(() => void 0)
327431
- ]);
327432
- const activity = readStringValue(activityRes);
327433
- const pkg2 = readStringValue(packageRes);
327434
- if (activity) signal.activity = activity;
327435
- signal.bundleId = pkg2 ?? this.appId;
327436
- } else {
327437
- const infoRes = await this.executeScript("mobile: activeAppInfo", []).catch(() => void 0);
327438
- const bundleId = readActiveBundleId(infoRes);
327439
- signal.bundleId = bundleId ?? this.appId;
327440
- }
327441
- } catch {
327442
- if (!signal.bundleId) signal.bundleId = this.appId;
327443
- }
327444
- return signal;
329758
+ return this.enrichScreenSignal(source);
327445
329759
  }
327446
329760
  };
327447
- function readActiveBundleId(result) {
329761
+ function readActiveBundleId2(result) {
327448
329762
  if (result && typeof result === "object" && "value" in result) {
327449
329763
  const value = result.value;
327450
329764
  if (value && typeof value === "object" && "bundleId" in value) {
@@ -327455,617 +329769,6 @@ function readActiveBundleId(result) {
327455
329769
  return void 0;
327456
329770
  }
327457
329771
 
327458
- // src/services/mobile/mobile-network-capture.ts
327459
- var import_runner_core8 = __toESM(require_dist2());
327460
- import { execFile, execFileSync as execFileSync4 } from "child_process";
327461
- import { randomUUID } from "crypto";
327462
- import { mkdtemp, rm, writeFile, readFile, readdir, mkdir, unlink } from "fs/promises";
327463
- import { tmpdir, networkInterfaces } from "os";
327464
- import { join as join3 } from "path";
327465
- import { promisify } from "util";
327466
- import {
327467
- getLocal,
327468
- generateCACertificate
327469
- } from "mockttp";
327470
- var execFileAsync = promisify(execFile);
327471
- var MAX_CALLS = 1e3;
327472
- var MAX_BODY_CAPTURE_BYTES = 64 * 1024;
327473
- var DEVICE_CMD_TIMEOUT_MS = 15e3;
327474
- var CA_KEY_BITS = 2048;
327475
- var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
327476
- var PENDING_CLEANUP_DIR = join3(tmpdir(), "validateqa-mobile-capture-pending");
327477
- function rawHeadersToPairs(rawHeaders) {
327478
- return rawHeaders.map(([name, value]) => ({ name, value }));
327479
- }
327480
- function headerValue(pairs, name) {
327481
- const lower = name.toLowerCase();
327482
- return pairs.find((h) => h.name.toLowerCase() === lower)?.value;
327483
- }
327484
- function resolveHostIp() {
327485
- const ifaces = networkInterfaces();
327486
- for (const addrs of Object.values(ifaces)) {
327487
- if (!addrs) continue;
327488
- for (const addr of addrs) {
327489
- if (addr.family === "IPv4" && !addr.internal) return addr.address;
327490
- }
327491
- }
327492
- return "127.0.0.1";
327493
- }
327494
- function resolveProxyHostForDevice(platform3, isPhysical) {
327495
- if (platform3 === "ANDROID" && !isPhysical) return ANDROID_EMULATOR_HOST_LOOPBACK;
327496
- if (platform3 === "IOS" && !isPhysical) return "127.0.0.1";
327497
- return resolveHostIp();
327498
- }
327499
- function normalizeRemoteAddress(addr) {
327500
- if (!addr) return "";
327501
- return addr.startsWith("::ffff:") ? addr.slice("::ffff:".length) : addr;
327502
- }
327503
- function isLoopbackAddress(addr) {
327504
- return addr === "127.0.0.1" || addr.startsWith("127.") || addr === "::1" || addr === "";
327505
- }
327506
- function isPrivateAddress(addr) {
327507
- if (/^10\./.test(addr)) return true;
327508
- if (/^192\.168\./.test(addr)) return true;
327509
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
327510
- if (/^169\.254\./.test(addr)) return true;
327511
- const lower = addr.toLowerCase();
327512
- if (lower.startsWith("fe80:")) return true;
327513
- if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
327514
- return false;
327515
- }
327516
- function isAllowedProxyPeer(remoteAddress, isPhysical) {
327517
- const addr = normalizeRemoteAddress(remoteAddress);
327518
- if (isLoopbackAddress(addr)) return true;
327519
- return isPhysical && isPrivateAddress(addr);
327520
- }
327521
- function installSourceIpGuard(server3, isPhysical, onLog) {
327522
- const underlying = server3.server;
327523
- if (!underlying || typeof underlying.on !== "function") {
327524
- onLog?.(
327525
- "[mobile-net] WARNING: could not attach source-IP guard to the proxy listener; the port is bound to all interfaces \u2014 firewall it to the device subnet."
327526
- );
327527
- return;
327528
- }
327529
- underlying.on("connection", (socket) => {
327530
- if (!isAllowedProxyPeer(socket.remoteAddress, isPhysical)) {
327531
- onLog?.(`[mobile-net] rejected proxy connection from disallowed source ${socket.remoteAddress}`);
327532
- socket.destroy();
327533
- }
327534
- });
327535
- }
327536
- function isTextish(contentType) {
327537
- if (!contentType) return false;
327538
- return /json|text|xml|graphql|html|csv|javascript|urlencoded/i.test(contentType);
327539
- }
327540
- async function readBodyText(body, contentType) {
327541
- if (!isTextish(contentType)) return void 0;
327542
- try {
327543
- const decoded = await body.getDecodedBuffer();
327544
- if (!decoded || decoded.length === 0) return void 0;
327545
- if (decoded.length > MAX_BODY_CAPTURE_BYTES) {
327546
- return decoded.subarray(0, MAX_BODY_CAPTURE_BYTES).toString("utf-8");
327547
- }
327548
- return decoded.toString("utf-8");
327549
- } catch {
327550
- return void 0;
327551
- }
327552
- }
327553
- var MitmProxy = class {
327554
- constructor(caKeyPem, caCertPem, isPhysical, onLog) {
327555
- this.caKeyPem = caKeyPem;
327556
- this.caCertPem = caCertPem;
327557
- this.isPhysical = isPhysical;
327558
- this.onLog = onLog;
327559
- }
327560
- server = null;
327561
- inFlight = /* @__PURE__ */ new Map();
327562
- calls = [];
327563
- dropped = 0;
327564
- /** Flips true once any HTTPS response is successfully MITM-ed. */
327565
- httpsIntercepted = false;
327566
- /** Best-known reason interception is unavailable, refined by TLS failures. */
327567
- tlsFailureReason = null;
327568
- /** Start listening; resolves with the actual bound port. */
327569
- async listen(preferredPort) {
327570
- const server3 = getLocal({
327571
- // Our generated CA — mockttp mints per-host leaf certs signed by it.
327572
- https: { key: this.caKeyPem, cert: this.caCertPem },
327573
- // HTTP/2 with HTTP/1.1 fallback; mobile clients negotiate either.
327574
- http2: "fallback",
327575
- // Keep the proxy quiet unless explicitly debugging.
327576
- recordTraffic: false
327577
- });
327578
- await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
327579
- await server3.on("request", (req) => this.onRequest(req));
327580
- await server3.on("response", (res) => this.onResponse(res));
327581
- await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
327582
- await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
327583
- installSourceIpGuard(server3, this.isPhysical, this.onLog);
327584
- this.server = server3;
327585
- return server3.port;
327586
- }
327587
- /** Captured calls, finalized through the shared redaction constructor. */
327588
- finalize() {
327589
- return this.calls.map(
327590
- (c) => (0, import_runner_core8.buildApiCallSummary)({
327591
- method: c.method,
327592
- url: c.url,
327593
- status: c.status,
327594
- durationMs: c.durationMs,
327595
- authHeader: c.authHeader,
327596
- cookieHeader: c.cookieHeader,
327597
- requestContentType: c.requestContentType,
327598
- responseContentType: c.responseContentType,
327599
- respHeaders: c.respHeaders,
327600
- reqBodyText: c.reqBodyText,
327601
- respBodyText: c.respBodyText,
327602
- responseSize: c.responseSize,
327603
- timestamp: c.startedAt,
327604
- testPhase: "UNKNOWN"
327605
- })
327606
- );
327607
- }
327608
- /** True once at least one HTTPS exchange was decrypted (CA trusted). */
327609
- get isIntercepting() {
327610
- return this.httpsIntercepted;
327611
- }
327612
- /** A precise reason interception is (still) unavailable, or null if it works. */
327613
- get interceptionReason() {
327614
- if (this.httpsIntercepted) return null;
327615
- return this.tlsFailureReason;
327616
- }
327617
- async close() {
327618
- const server3 = this.server;
327619
- this.server = null;
327620
- this.inFlight.clear();
327621
- if (!server3) return;
327622
- try {
327623
- await server3.stop();
327624
- } catch (err) {
327625
- this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg(err)}`);
327626
- }
327627
- this.onLog?.(
327628
- `[mobile-net] proxy closed: ${this.calls.length} calls captured` + (this.dropped > 0 ? ` (${this.dropped} dropped over cap)` : "")
327629
- );
327630
- }
327631
- // ── request → buffer by id ────────────────────────────────────────────────
327632
- async onRequest(req) {
327633
- try {
327634
- const reqHeaders = rawHeadersToPairs(req.rawHeaders);
327635
- const requestContentType = headerValue(reqHeaders, "content-type");
327636
- const isHttps = isHttpsUrl(req.url);
327637
- const reqBodyText = (0, import_runner_core8.isApiCall)(req.url, requestContentType) ? await readBodyText(req.body, requestContentType) : void 0;
327638
- this.inFlight.set(req.id, {
327639
- method: req.method,
327640
- url: req.url,
327641
- startedAt: req.timingEvents.startTime,
327642
- reqHeaders,
327643
- requestContentType,
327644
- authHeader: headerValue(reqHeaders, "authorization"),
327645
- cookieHeader: headerValue(reqHeaders, "cookie"),
327646
- reqBodyText,
327647
- isHttps
327648
- });
327649
- } catch (err) {
327650
- this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg(err)}`);
327651
- }
327652
- }
327653
- // ── response → finalize the matched request ───────────────────────────────
327654
- async onResponse(res) {
327655
- const pending = this.inFlight.get(res.id);
327656
- if (!pending) return;
327657
- this.inFlight.delete(res.id);
327658
- try {
327659
- if (pending.isHttps && !this.httpsIntercepted) {
327660
- this.httpsIntercepted = true;
327661
- this.tlsFailureReason = null;
327662
- this.onLog?.("[mobile-net] HTTPS interception confirmed (CA trusted; bodies decrypted).");
327663
- }
327664
- const respHeaders = rawHeadersToPairs(res.rawHeaders);
327665
- const responseContentType = headerValue(respHeaders, "content-type");
327666
- const api = (0, import_runner_core8.isApiCall)(pending.url, responseContentType);
327667
- if (!api && (0, import_runner_core8.isStaticAssetByExt)(pending.url)) return;
327668
- if (this.calls.length >= MAX_CALLS) {
327669
- this.dropped++;
327670
- return;
327671
- }
327672
- const respBodyText = api ? await readBodyText(res.body, responseContentType) : void 0;
327673
- const responseSize = await measureBody(res.body);
327674
- const durationMs = computeDuration(pending.startedAt, res);
327675
- this.calls.push({
327676
- method: pending.method,
327677
- url: pending.url,
327678
- status: res.statusCode,
327679
- startedAt: pending.startedAt,
327680
- durationMs,
327681
- reqHeaders: pending.reqHeaders,
327682
- respHeaders,
327683
- requestContentType: pending.requestContentType,
327684
- responseContentType,
327685
- authHeader: pending.authHeader,
327686
- cookieHeader: pending.cookieHeader,
327687
- reqBodyText: pending.reqBodyText,
327688
- respBodyText,
327689
- responseSize,
327690
- intercepted: true
327691
- });
327692
- } catch (err) {
327693
- this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg(err)}`);
327694
- }
327695
- }
327696
- // ── tls-client-error → record WHY interception failed ─────────────────────
327697
- onTlsClientError(failure) {
327698
- if (this.httpsIntercepted) return;
327699
- const host = failure.destination?.hostname ?? failure.tlsMetadata.sniHostname ?? "unknown host";
327700
- let reason;
327701
- switch (failure.failureCause) {
327702
- case "cert-rejected":
327703
- reason = `TLS handshake to ${host} rejected our certificate: the device does not trust the validate.qa CA, so HTTPS bodies cannot be decrypted (plaintext HTTP still captured). Install + trust the CA at caCertPath to enable interception.`;
327704
- break;
327705
- case "closed":
327706
- case "reset":
327707
- reason = `TLS handshake to ${host} was dropped after our certificate was offered \u2014 this is the signature of certificate pinning in the app. HTTPS bodies for pinned hosts cannot be decrypted (plaintext HTTP and connect-level host metadata still captured).`;
327708
- break;
327709
- case "no-shared-cipher":
327710
- reason = `TLS handshake to ${host} failed: no shared cipher with the client.`;
327711
- break;
327712
- case "handshake-timeout":
327713
- reason = `TLS handshake to ${host} timed out before completing.`;
327714
- break;
327715
- default:
327716
- reason = `TLS handshake to ${host} failed (${failure.failureCause}); HTTPS not intercepted for it.`;
327717
- }
327718
- if (!this.tlsFailureReason || failure.failureCause === "cert-rejected" || failure.failureCause === "closed" || failure.failureCause === "reset") {
327719
- this.tlsFailureReason = reason;
327720
- }
327721
- this.onLog?.(`[mobile-net] ${reason}`);
327722
- }
327723
- };
327724
- function isHttpsUrl(url) {
327725
- try {
327726
- return new URL(url).protocol === "https:";
327727
- } catch {
327728
- return false;
327729
- }
327730
- }
327731
- async function measureBody(body) {
327732
- try {
327733
- const decoded = await body.getDecodedBuffer();
327734
- return decoded ? decoded.length : 0;
327735
- } catch {
327736
- return 0;
327737
- }
327738
- }
327739
- function computeDuration(startedAt, res) {
327740
- const sent = res.timingEvents.responseSentTimestamp;
327741
- const start = res.timingEvents.startTimestamp;
327742
- if (typeof sent === "number" && typeof start === "number" && sent >= start) {
327743
- return Math.round(sent - start);
327744
- }
327745
- const elapsed = Date.now() - startedAt;
327746
- return elapsed > 0 ? elapsed : 0;
327747
- }
327748
- async function generateCaCert(dir, onLog) {
327749
- try {
327750
- const { key, cert } = await generateCACertificate({
327751
- subject: {
327752
- commonName: "validate.qa Mobile Capture CA",
327753
- organizationName: "validate.qa"
327754
- },
327755
- bits: CA_KEY_BITS
327756
- // mockttp's generated CA carries its own (multi-year) validity window; we
327757
- // rely on stop() to remove the cert from the device promptly after the run
327758
- // rather than on a short expiry.
327759
- });
327760
- const caCertPath = join3(dir, "validateqa-mobile-ca.pem");
327761
- await writeFile(caCertPath, cert, "utf-8");
327762
- return { caCertPath, caKeyPem: key, caCertPem: cert };
327763
- } catch (err) {
327764
- onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg(err)}`);
327765
- return null;
327766
- }
327767
- }
327768
- async function androidReadProxy(deviceId) {
327769
- try {
327770
- const { stdout } = await execFileAsync(
327771
- "adb",
327772
- ["-s", deviceId, "shell", "settings", "get", "global", "http_proxy"],
327773
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327774
- );
327775
- const value = stdout.trim();
327776
- return value === "" || value === "null" ? null : value;
327777
- } catch {
327778
- return null;
327779
- }
327780
- }
327781
- async function androidSetProxy(deviceId, hostPort, onLog) {
327782
- try {
327783
- await execFileAsync(
327784
- "adb",
327785
- ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", hostPort],
327786
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327787
- );
327788
- return true;
327789
- } catch (err) {
327790
- onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg(err)}`);
327791
- return false;
327792
- }
327793
- }
327794
- async function androidClearProxy(deviceId, originalProxy, onLog) {
327795
- const restoreValue = originalProxy && originalProxy.length > 0 ? originalProxy : ":0";
327796
- try {
327797
- await execFileAsync(
327798
- "adb",
327799
- ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue],
327800
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327801
- );
327802
- } catch (err) {
327803
- onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg(err)}`);
327804
- }
327805
- }
327806
- async function androidInstallCa(deviceId, caCertPath) {
327807
- const devicePath = "/sdcard/validateqa-mobile-ca.pem";
327808
- try {
327809
- await execFileAsync("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
327810
- timeout: DEVICE_CMD_TIMEOUT_MS
327811
- });
327812
- return {
327813
- installed: false,
327814
- note: "CA pushed to /sdcard but NOT auto-trusted: Android 7+ apps must opt into user CAs (network_security_config), and the system store requires a rooted/writable image. HTTPS bodies decrypt only for apps that trust user CAs; pinned apps never decrypt."
327815
- };
327816
- } catch (err) {
327817
- return { installed: false, note: `CA push failed: ${errMsg(err)}` };
327818
- }
327819
- }
327820
- async function androidRemoveCa(deviceId, onLog) {
327821
- try {
327822
- await execFileAsync(
327823
- "adb",
327824
- ["-s", deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"],
327825
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327826
- );
327827
- } catch (err) {
327828
- onLog?.(`[mobile-net] adb remove CA failed: ${errMsg(err)}`);
327829
- }
327830
- }
327831
- async function iosSimTrustCa(deviceId, caCertPath) {
327832
- try {
327833
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
327834
- timeout: DEVICE_CMD_TIMEOUT_MS
327835
- });
327836
- return {
327837
- trusted: true,
327838
- note: "CA added to simulator keychain (add-root-cert). NOTE: the simulator has no per-app HTTP proxy setting controllable via simctl \u2014 it shares the host network, so only traffic that actually routes through the proxy is intercepted; pinned apps still never decrypt."
327839
- };
327840
- } catch (err) {
327841
- return { trusted: false, note: `simctl add-root-cert failed: ${errMsg(err)}` };
327842
- }
327843
- }
327844
- async function iosSimRemoveCa(deviceId, onLog) {
327845
- if (process.env.VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET === "1") {
327846
- try {
327847
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "reset"], {
327848
- timeout: DEVICE_CMD_TIMEOUT_MS
327849
- });
327850
- onLog?.(`[mobile-net] simctl keychain reset run for ${deviceId} (opt-in; cleared ALL sim certs).`);
327851
- } catch (err) {
327852
- onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg(err)}`);
327853
- }
327854
- return;
327855
- }
327856
- onLog?.(
327857
- `[mobile-net] NOTE: the validate.qa capture CA remains TRUSTED in simulator ${deviceId}. simctl has no scoped remove (only a full keychain reset that wipes ALL certs), so we leave the single CA in place. Use a throwaway simulator, or set VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET=1 to reset the whole sim keychain on teardown.`
327858
- );
327859
- }
327860
- function errMsg(err) {
327861
- return err instanceof Error ? err.message : String(err);
327862
- }
327863
- var activeCleanups = /* @__PURE__ */ new Map();
327864
- var exitHandlersInstalled = false;
327865
- function markerPath(sessionKey) {
327866
- return join3(PENDING_CLEANUP_DIR, `${sessionKey}.json`);
327867
- }
327868
- async function writePendingCleanup(sessionKey, marker) {
327869
- try {
327870
- await mkdir(PENDING_CLEANUP_DIR, { recursive: true });
327871
- await writeFile(markerPath(sessionKey), JSON.stringify(marker), "utf-8");
327872
- } catch {
327873
- }
327874
- }
327875
- async function removePendingCleanup(sessionKey) {
327876
- try {
327877
- await unlink(markerPath(sessionKey));
327878
- } catch {
327879
- }
327880
- }
327881
- function restoreDeviceSync(marker, onLog) {
327882
- const run2 = (args) => {
327883
- try {
327884
- execFileSync4("adb", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
327885
- } catch {
327886
- }
327887
- };
327888
- if (marker.platform === "ANDROID") {
327889
- if (marker.proxySet) {
327890
- const restoreValue = marker.originalProxy && marker.originalProxy.length > 0 ? marker.originalProxy : ":0";
327891
- run2(["-s", marker.deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue]);
327892
- }
327893
- if (marker.caInstalledOnDevice) {
327894
- run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
327895
- }
327896
- }
327897
- onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
327898
- }
327899
- async function reconcilePendingCleanups(onLog) {
327900
- let files;
327901
- try {
327902
- files = await readdir(PENDING_CLEANUP_DIR);
327903
- } catch {
327904
- return;
327905
- }
327906
- for (const file of files) {
327907
- if (!file.endsWith(".json")) continue;
327908
- const full = join3(PENDING_CLEANUP_DIR, file);
327909
- try {
327910
- const raw = await readFile(full, "utf-8");
327911
- const marker = JSON.parse(raw);
327912
- if (marker && typeof marker.deviceId === "string" && marker.platform) {
327913
- onLog?.(`[mobile-net] reconciling orphaned capture cleanup for ${marker.deviceId}.`);
327914
- restoreDeviceSync(marker, onLog);
327915
- }
327916
- } catch {
327917
- }
327918
- try {
327919
- await unlink(full);
327920
- } catch {
327921
- }
327922
- }
327923
- }
327924
- function ensureExitHandlers() {
327925
- if (exitHandlersInstalled) return;
327926
- exitHandlersInstalled = true;
327927
- const drain = () => {
327928
- for (const marker of activeCleanups.values()) {
327929
- restoreDeviceSync(marker);
327930
- }
327931
- };
327932
- process.on("beforeExit", drain);
327933
- process.on("exit", drain);
327934
- for (const signal of ["SIGTERM", "SIGINT"]) {
327935
- process.on(signal, () => {
327936
- drain();
327937
- process.removeAllListeners(signal);
327938
- process.kill(process.pid, signal);
327939
- });
327940
- }
327941
- }
327942
- async function startMobileNetworkCapture(options) {
327943
- const { platform: platform3, deviceId, isPhysical = false, preferredPort = 0, onLog } = options;
327944
- await reconcilePendingCleanups(onLog);
327945
- ensureExitHandlers();
327946
- const sessionKey = randomUUID();
327947
- const originalProxy = platform3 === "ANDROID" ? await androidReadProxy(deviceId) : null;
327948
- let tmpDir = null;
327949
- let caCertPath = null;
327950
- let caKeyPem = null;
327951
- let caCertPem = null;
327952
- try {
327953
- tmpDir = await mkdtemp(join3(tmpdir(), "validateqa-mobile-ca-"));
327954
- const ca = await generateCaCert(tmpDir, onLog);
327955
- if (ca) {
327956
- caCertPath = ca.caCertPath;
327957
- caKeyPem = ca.caKeyPem;
327958
- caCertPem = ca.caCertPem;
327959
- }
327960
- } catch (err) {
327961
- onLog?.(`[mobile-net] CA setup degraded: ${errMsg(err)}`);
327962
- }
327963
- if (!caKeyPem || !caCertPem) {
327964
- try {
327965
- const fallback = await generateCACertificate();
327966
- caKeyPem = fallback.key;
327967
- caCertPem = fallback.cert;
327968
- } catch (err) {
327969
- onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg(err)}`);
327970
- if (tmpDir) {
327971
- await rm(tmpDir, { recursive: true, force: true }).catch(() => void 0);
327972
- }
327973
- throw err instanceof Error ? err : new Error(String(err));
327974
- }
327975
- }
327976
- const proxy = new MitmProxy(caKeyPem, caCertPem, isPhysical, onLog);
327977
- const proxyPort = await proxy.listen(preferredPort);
327978
- const proxyHost = resolveProxyHostForDevice(platform3, isPhysical);
327979
- const hostPort = `${proxyHost}:${proxyPort}`;
327980
- onLog?.(
327981
- `[mobile-net] MITM proxy listening; device target ${hostPort} (${platform3}${isPhysical ? " physical" : ""})`
327982
- );
327983
- let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
327984
- let proxySet = false;
327985
- let caInstalledOnDevice = false;
327986
- try {
327987
- if (platform3 === "ANDROID") {
327988
- proxySet = await androidSetProxy(deviceId, hostPort, onLog);
327989
- if (!proxySet) {
327990
- wiringReason = "adb proxy configuration failed; no device traffic will be routed through the proxy.";
327991
- } else if (caCertPath) {
327992
- const { note } = await androidInstallCa(deviceId, caCertPath);
327993
- caInstalledOnDevice = true;
327994
- wiringReason = note;
327995
- }
327996
- } else {
327997
- if (!isPhysical && caCertPath) {
327998
- const { trusted, note } = await iosSimTrustCa(deviceId, caCertPath);
327999
- caInstalledOnDevice = trusted;
328000
- wiringReason = note;
328001
- } else if (isPhysical) {
328002
- wiringReason = "Physical iOS device: set the Wi-Fi HTTP proxy + trust the CA in Settings \u2192 General \u2192 About \u2192 Certificate Trust Settings manually; capture is limited to whatever routes through.";
328003
- }
328004
- }
328005
- } catch (err) {
328006
- wiringReason = `device wiring degraded: ${errMsg(err)}`;
328007
- onLog?.(`[mobile-net] ${wiringReason}`);
328008
- }
328009
- if (proxySet || caInstalledOnDevice) {
328010
- const marker = {
328011
- platform: platform3,
328012
- deviceId,
328013
- isPhysical,
328014
- proxySet,
328015
- originalProxy,
328016
- caInstalledOnDevice
328017
- };
328018
- activeCleanups.set(sessionKey, marker);
328019
- await writePendingCleanup(sessionKey, marker);
328020
- }
328021
- const capturedTmpDir = tmpDir;
328022
- const capturedCaCertPath = caCertPath;
328023
- let stopped = false;
328024
- let finalCalls = [];
328025
- const getCapabilities = () => {
328026
- if (proxy.isIntercepting) return { intercepting: true };
328027
- const reason = proxy.interceptionReason ?? wiringReason;
328028
- return reason ? { intercepting: false, reason } : { intercepting: false };
328029
- };
328030
- const stop = async () => {
328031
- if (stopped) return finalCalls;
328032
- stopped = true;
328033
- if (proxySet && platform3 === "ANDROID") {
328034
- await androidClearProxy(deviceId, originalProxy, onLog);
328035
- }
328036
- if (caInstalledOnDevice && capturedCaCertPath) {
328037
- if (platform3 === "ANDROID") {
328038
- await androidRemoveCa(deviceId, onLog);
328039
- } else if (!isPhysical) {
328040
- await iosSimRemoveCa(deviceId, onLog);
328041
- }
328042
- }
328043
- activeCleanups.delete(sessionKey);
328044
- await removePendingCleanup(sessionKey);
328045
- finalCalls = proxy.finalize();
328046
- await proxy.close();
328047
- if (capturedTmpDir) {
328048
- try {
328049
- await rm(capturedTmpDir, { recursive: true, force: true });
328050
- } catch (err) {
328051
- onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg(err)}`);
328052
- }
328053
- }
328054
- const apiCount = finalCalls.filter((c) => c.isApi).length;
328055
- onLog?.(
328056
- `[mobile-net] stopped: ${finalCalls.length} calls captured (${apiCount} API; HTTPS interception ${proxy.isIntercepting ? "ACTIVE" : "inactive"}).`
328057
- );
328058
- return finalCalls;
328059
- };
328060
- return {
328061
- proxyPort,
328062
- proxyHost,
328063
- caCertPath: capturedCaCertPath,
328064
- getCapabilities,
328065
- stop
328066
- };
328067
- }
328068
-
328069
329772
  // src/services/doctor.ts
328070
329773
  import { execSync } from "child_process";
328071
329774
  import { existsSync as existsSync2 } from "fs";
@@ -328281,9 +329984,22 @@ function printDoctorReport(report) {
328281
329984
 
328282
329985
  // src/runner.ts
328283
329986
  init_dist();
328284
- var import_runner_core11 = __toESM(require_dist2());
329987
+ var import_runner_core12 = __toESM(require_dist2());
328285
329988
  function fingerprintTest(test) {
328286
- return createHash("sha256").update(test.name).update("\n--suite--\n").update(test.suiteName ?? "").update("\n--type--\n").update(test.testType ?? "").update("\n--code--\n").update(test.playwrightCode).digest("hex").slice(0, 24);
329989
+ const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
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);
329991
+ }
329992
+ function mobileDiscoveryNetworkContext(result, appId) {
329993
+ const page = result?.collectedPages?.find((p) => p.screenId || p.route);
329994
+ const screenId = page?.screenId ?? page?.route;
329995
+ return screenId ? `mobile:${screenId}` : `mobile:${appId}`;
329996
+ }
329997
+ function attachMobileDiscoveryNetworkContext(calls, result, appId) {
329998
+ const pageUrl = mobileDiscoveryNetworkContext(result, appId);
329999
+ return calls.map((call) => ({
330000
+ ...call,
330001
+ pageUrl: call.pageUrl ?? pageUrl
330002
+ }));
328287
330003
  }
328288
330004
  var SECRET_VAR_PATTERN = /password|secret|token|api[_]?key|pass\b|credential|bearer|pat\b/i;
328289
330005
  var IS_CLOUD = process.env.RUNNER_CLOUD === "true";
@@ -328713,7 +330429,7 @@ function pruneCollectedPageForPost(page) {
328713
330429
  if (Array.isArray(out.observations)) out.observations = out.observations.slice(-200).map((obs) => compactForAudit(obs));
328714
330430
  return out;
328715
330431
  }
328716
- function prepareDiscoveryResultForPost(result, logs2) {
330432
+ function prepareDiscoveryResultForPost(result, logs2, options = {}) {
328717
330433
  const safe = { ...result, logs: truncateTextTail(logs2, RESULT_LOG_MAX_CHARS, "discovery result log") };
328718
330434
  if (Array.isArray(safe.collectedPages)) safe.collectedPages = safe.collectedPages.map(pruneCollectedPageForPost);
328719
330435
  if (Array.isArray(safe.apiCalls)) safe.apiCalls = safe.apiCalls.map((call) => compactForAudit(call));
@@ -328729,6 +330445,10 @@ function prepareDiscoveryResultForPost(result, logs2) {
328729
330445
  if (safe.snapshotEvidence) safe.snapshotEvidence = compactForAudit(safe.snapshotEvidence);
328730
330446
  if (safe.discoveryCheckpoints) safe.discoveryCheckpoints = compactForAudit(safe.discoveryCheckpoints);
328731
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
+ }
328732
330452
  if (safe.exploreAlreadySaved === true) {
328733
330453
  if (Array.isArray(safe.screenshots)) delete safe.screenshots;
328734
330454
  if (safe.pageScreenshots) delete safe.pageScreenshots;
@@ -328828,7 +330548,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
328828
330548
  const runMode = run2.mode ?? "run";
328829
330549
  const RUNNER_MODE = opts.runnerMode ?? "playwright-native";
328830
330550
  log.info(`Running test: ${run2.testCaseId ?? "auth-setup"} (run ${run2.runId}) [mode: ${runMode === "heal" ? "heal" : runMode === "auth-setup" ? "auth-setup" : RUNNER_MODE}]`);
328831
- const liveBrowserHandle = import_runner_core10.liveBrowserRegistry.register({
330551
+ const liveBrowserHandle = import_runner_core11.liveBrowserRegistry.register({
328832
330552
  sessionId: `${run2.runId}`,
328833
330553
  mode: toLiveBrowserMode(runMode),
328834
330554
  runId: run2.runId,
@@ -328837,7 +330557,10 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
328837
330557
  testCaseId: run2.testCaseId ?? void 0
328838
330558
  });
328839
330559
  requestLiveBrowserHeartbeat(true);
328840
- const liveBrowserHeartbeatInterval = setInterval(() => requestLiveBrowserHeartbeat(), 5e3);
330560
+ const liveBrowserHeartbeatInterval = setInterval(() => {
330561
+ liveBrowserHandle.patch({});
330562
+ requestLiveBrowserHeartbeat();
330563
+ }, 5e3);
328841
330564
  liveBrowserHeartbeatInterval.unref?.();
328842
330565
  const runNativeWithLiveBrowser = (nativeOptions) => (0, import_runner_core.executeTestViaNativePlaywright)({
328843
330566
  ...nativeOptions,
@@ -328864,7 +330587,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
328864
330587
  log.fail(`PRE-FLIGHT FAILED - ${preflightError}`);
328865
330588
  return;
328866
330589
  }
328867
- const preflight = await (0, import_runner_core11.resolveBaseUrlPreflight)(run2.baseUrl);
330590
+ const preflight = await (0, import_runner_core12.resolveBaseUrlPreflight)(run2.baseUrl);
328868
330591
  if (preflight.error) {
328869
330592
  await postResult(opts.serverUrl, headers, run2.runId, { status: "ERROR", error: preflight.error });
328870
330593
  log.fail(`PRE-FLIGHT FAILED - ${preflight.error}`);
@@ -328921,7 +330644,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
328921
330644
  return;
328922
330645
  }
328923
330646
  const healLogStream = createHealLogStreamer(opts.serverUrl, headers, run2.runId);
328924
- const batchResult = await (0, import_runner_core9.executeGrokPerBatchHeal)(tests, run2.baseUrl, {
330647
+ const batchResult = await (0, import_runner_core10.executeGrokPerBatchHeal)(tests, run2.baseUrl, {
328925
330648
  testVariables: run2.testVariables,
328926
330649
  runNativeTest: runNativeWithLiveBrowser,
328927
330650
  validateContext: { runId: run2.runId, serverUrl: opts.serverUrl, authHeader: headers.Authorization },
@@ -329155,7 +330878,7 @@ ${finalVerifyResult.logs ?? ""}`;
329155
330878
  if (classifyErrorMessage(composedError ?? null) === "config_issue") {
329156
330879
  apiHealStatus = "ERROR";
329157
330880
  } else {
329158
- const rlDetection = (0, import_runner_core11.detectRateLimitBlock)({
330881
+ const rlDetection = (0, import_runner_core12.detectRateLimitBlock)({
329159
330882
  testName: run2.testName ?? run2.testCaseId ?? void 0,
329160
330883
  tags: run2.tags ?? [],
329161
330884
  error: composedError ?? finalVerifyResult?.error ?? null,
@@ -329243,6 +330966,7 @@ ${finalVerifyResult.logs ?? ""}`;
329243
330966
  tags: run2.tags ?? [],
329244
330967
  surfaceIntelligence: run2.surfaceIntelligence,
329245
330968
  healingContext: run2.healingContext ?? null,
330969
+ selectorRegistryContext: run2.selectorRegistryContext ?? null,
329246
330970
  lastFailureError: run2.lastFailureError,
329247
330971
  testVariables: run2.testVariables,
329248
330972
  onAICall: healAudit,
@@ -329261,7 +330985,7 @@ ${finalVerifyResult.logs ?? ""}`;
329261
330985
  log.info(`Heal engine: GROK (${source})`);
329262
330986
  }
329263
330987
  const grokHealLogStream = useGrokHeal ? createHealLogStreamer(opts.serverUrl, headers, run2.runId) : null;
329264
- 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, {
329265
330989
  ...healOpts,
329266
330990
  onLog: grokHealLogStream.onLog
329267
330991
  }) : await (0, import_runner_core2.executeTestWithHealing)(run2.playwrightCode, run2.steps, run2.baseUrl, healOpts);
@@ -329294,7 +331018,7 @@ ${finalVerifyResult.logs ?? ""}`;
329294
331018
  let healStatus = result2.passed ? "PASSED" : isConfigIssue ? "ERROR" : "FAILED";
329295
331019
  let healError = result2.error;
329296
331020
  if (!result2.passed && !isConfigIssue) {
329297
- const rlDetection = (0, import_runner_core11.detectRateLimitBlock)({
331021
+ const rlDetection = (0, import_runner_core12.detectRateLimitBlock)({
329298
331022
  testName: run2.testName ?? run2.testCaseId ?? void 0,
329299
331023
  tags: run2.tags ?? [],
329300
331024
  error: result2.error,
@@ -329477,6 +331201,7 @@ ${finalVerifyResult.logs ?? ""}`;
329477
331201
  tags: failedTest.tags ?? [],
329478
331202
  surfaceIntelligence: failedTest.healingContext ? null : run2.surfaceIntelligence,
329479
331203
  healingContext: failedTest.healingContext ?? null,
331204
+ selectorRegistryContext: failedTest.selectorRegistryContext ?? null,
329480
331205
  lastFailureError: failedTest.error,
329481
331206
  testVariables: run2.testVariables,
329482
331207
  onAICall: healAudit,
@@ -329487,7 +331212,7 @@ ${finalVerifyResult.logs ?? ""}`;
329487
331212
  liveBrowserHandle,
329488
331213
  runNativeTest: runNativeWithLiveBrowser
329489
331214
  };
329490
- const healResult = useGrokHealSuite ? await (0, import_runner_core9.executeGrokPerTestHeal)(
331215
+ const healResult = useGrokHealSuite ? await (0, import_runner_core10.executeGrokPerTestHeal)(
329491
331216
  failedTest.playwrightCode,
329492
331217
  failedTest.steps ?? [],
329493
331218
  run2.baseUrl,
@@ -329775,9 +331500,16 @@ ${finalVerifyResult.logs ?? ""}`;
329775
331500
  signal: AbortSignal.timeout(3e4)
329776
331501
  });
329777
331502
  if (res.ok) return;
329778
- 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}`);
329779
331509
  } catch (err) {
329780
- 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}`);
329781
331513
  }
329782
331514
  if (attempt < 2) await new Promise((r) => setTimeout(r, 3e3 * (attempt + 1)));
329783
331515
  }
@@ -329785,6 +331517,7 @@ ${finalVerifyResult.logs ?? ""}`;
329785
331517
  };
329786
331518
  let discoveryResult;
329787
331519
  const buildFinalBody = (r, overrideError, auditWarning) => {
331520
+ const isNativeDiscovery = ctx.medium === "IOS_NATIVE" || ctx.medium === "ANDROID_NATIVE";
329788
331521
  const { exploreFinalMessages: _exploreFinalMessages, ...rest } = r;
329789
331522
  void _exploreFinalMessages;
329790
331523
  const logs2 = auditWarning ? [rest.logs, auditWarning].filter(Boolean).join("\n") : rest.logs;
@@ -329834,7 +331567,7 @@ ${finalVerifyResult.logs ?? ""}`;
329834
331567
  resolvedAppOrigin: rest.resolvedAppOrigin,
329835
331568
  plans: rest.plans,
329836
331569
  exploreStats: rest.exploreStats,
329837
- screenshots: rest.screenshots,
331570
+ ...isNativeDiscovery ? {} : { screenshots: rest.screenshots },
329838
331571
  snapshotEvidence: rest.snapshotEvidence,
329839
331572
  discoveryCheckpoints: rest.discoveryCheckpoints,
329840
331573
  streamedTestCount: streamedTestKeys.size,
@@ -329843,8 +331576,10 @@ ${finalVerifyResult.logs ?? ""}`;
329843
331576
  ...explorePosted ? {} : {
329844
331577
  collectedPages: rest.collectedPages,
329845
331578
  collectedTransitions: rest.collectedTransitions,
329846
- pageScreenshots: rest.pageScreenshots,
329847
- pageScreenshotKeys: rest.pageScreenshotKeys
331579
+ ...isNativeDiscovery ? {} : {
331580
+ pageScreenshots: rest.pageScreenshots,
331581
+ pageScreenshotKeys: rest.pageScreenshotKeys
331582
+ }
329848
331583
  },
329849
331584
  ...harPosted ? {} : {
329850
331585
  apiCalls: rest.apiCalls,
@@ -329852,7 +331587,7 @@ ${finalVerifyResult.logs ?? ""}`;
329852
331587
  },
329853
331588
  // Only send tests that were NOT confirmed streamed to avoid duplicates
329854
331589
  tests: rest.tests.filter((t) => !streamedTestKeys.has(fingerprintTest(t)))
329855
- }, logs2);
331590
+ }, logs2, { omitScreenshots: isNativeDiscovery });
329856
331591
  };
329857
331592
  const acquireLoginBrowser = async () => {
329858
331593
  const browser = await browserPool.acquireAsync(12e3);
@@ -329895,6 +331630,14 @@ ${finalVerifyResult.logs ?? ""}`;
329895
331630
  throw new Error("CONFIG_ISSUE: the shared mobile device is busy with an active recording session; mobile discovery will retry once it ends.");
329896
331631
  }
329897
331632
  const mobileOnLog = (line) => onLog("progress", line);
331633
+ liveBrowserHandle.patch({
331634
+ mode: ctx.mode === "survey" ? "survey" : "discovery",
331635
+ testName: ctx.featureName ?? "Mobile discovery",
331636
+ note: `${platform3} mobile discovery`,
331637
+ surface: "mobile",
331638
+ mobilePlatform: platform3
331639
+ });
331640
+ requestLiveBrowserHeartbeat(true);
329898
331641
  mobileOnLog(`Mobile discovery (${ctx.mode}) \u2014 ${platform3} / ${mobileTarget.appId}`);
329899
331642
  setExecutorBusy(true);
329900
331643
  let session = null;
@@ -329907,6 +331650,24 @@ ${finalVerifyResult.logs ?? ""}`;
329907
331650
  recordedDeviceId: mobileTarget.recordedDeviceId,
329908
331651
  launchMode: mobileTarget.launchMode,
329909
331652
  deeplink: mobileTarget.deeplink
331653
+ }, {
331654
+ onDeviceSelected: async (device) => {
331655
+ try {
331656
+ capture = await startMobileNetworkCapture({
331657
+ platform: platform3,
331658
+ deviceId: device.deviceId,
331659
+ appId: mobileTarget.appId,
331660
+ isPhysical: device.isPhysical,
331661
+ onLog: mobileOnLog
331662
+ });
331663
+ const caps = capture.getCapabilities();
331664
+ if (!caps.intercepting) {
331665
+ mobileOnLog(`[mobile-net] HTTPS interception unavailable: ${caps.reason ?? "unknown reason"} (continuing \u2014 plaintext/metadata only)`);
331666
+ }
331667
+ } catch (captureErr) {
331668
+ mobileOnLog(`[mobile-net] capture unavailable: ${captureErr instanceof Error ? captureErr.message : String(captureErr)} (continuing without network log)`);
331669
+ }
331670
+ }
329910
331671
  });
329911
331672
  mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
329912
331673
  attachMirrorSession(session.sessionId, platform3);
@@ -329915,24 +331676,21 @@ ${finalVerifyResult.logs ?? ""}`;
329915
331676
  platform: platform3,
329916
331677
  appId: session.appId
329917
331678
  });
329918
- capture = await startMobileNetworkCapture({
329919
- platform: platform3,
329920
- deviceId: session.deviceId,
329921
- appId: session.appId,
329922
- isPhysical: session.isPhysical,
329923
- onLog: mobileOnLog
329924
- });
329925
- const caps = capture.getCapabilities();
329926
- if (!caps.intercepting) {
329927
- mobileOnLog(`[mobile-net] HTTPS interception unavailable: ${caps.reason ?? "unknown reason"} (continuing \u2014 plaintext/metadata only)`);
329928
- }
329929
331679
  discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
329930
331680
  onLog: mobileOnLog,
329931
331681
  onAICall: discoveryAudit,
329932
331682
  // Stream the live device mirror through the existing page-screenshot
329933
- // relay (mobile has no route, so key it as 'mobile'). Fire-and-forget.
331683
+ // relay (mobile has no route, so key it as 'mobile') and the
331684
+ // heartbeat live-session feed. The heartbeat frame gives the web
331685
+ // UI a reliable mobile fallback when the direct bridge SSE is
331686
+ // still opening or blocked by the user's network/tunnel.
329934
331687
  onScreenshot: (base64) => {
329935
- void onScreenshot("mobile", base64);
331688
+ liveBrowserHandle.updateSnapshot({
331689
+ currentUrl: mobileTarget.appId,
331690
+ thumbnailJpegBase64: base64,
331691
+ thumbnailCapturedAt: Date.now()
331692
+ });
331693
+ requestLiveBrowserHeartbeat();
329936
331694
  },
329937
331695
  onExploreComplete,
329938
331696
  onTestGenerated,
@@ -329943,7 +331701,7 @@ ${finalVerifyResult.logs ?? ""}`;
329943
331701
  try {
329944
331702
  const apiCalls = await capture.stop();
329945
331703
  if (discoveryResult) {
329946
- discoveryResult.apiCalls = apiCalls.length ? apiCalls : void 0;
331704
+ discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
329947
331705
  }
329948
331706
  } catch (capErr) {
329949
331707
  mobileOnLog(`[mobile-net] capture stop failed: ${capErr instanceof Error ? capErr.message : String(capErr)}`);
@@ -329959,7 +331717,7 @@ ${finalVerifyResult.logs ?? ""}`;
329959
331717
  setExecutorBusy(false);
329960
331718
  }
329961
331719
  } else if (ctx.mode === "import") {
329962
- 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;
329963
331721
  discoveryResult = await runRepoImport(ctx, { onTestGenerated, acquireLoginBrowser, onAudit: discoveryAudit }, {
329964
331722
  archiveUrl: ctx.frameworkImport?.source === "upload" ? `${opts.serverUrl}/api/runner/runs/${run2.runId}/import-archive` : void 0,
329965
331723
  archiveAuthHeader: headers.Authorization,
@@ -330260,6 +332018,7 @@ ${finalVerifyResult.logs ?? ""}`;
330260
332018
  status,
330261
332019
  stepResults: result2.stepResults,
330262
332020
  screenshots: result2.screenshots,
332021
+ apiCalls: result2.apiCalls,
330263
332022
  logs: result2.logs,
330264
332023
  error: reportedError,
330265
332024
  duration: duration2
@@ -330288,7 +332047,7 @@ ${finalVerifyResult.logs ?? ""}`;
330288
332047
  let runStatus = result.passed ? "PASSED" : "FAILED";
330289
332048
  let runError = result.error;
330290
332049
  if (!result.passed) {
330291
- const rlDetection = (0, import_runner_core11.detectRateLimitBlock)({
332050
+ const rlDetection = (0, import_runner_core12.detectRateLimitBlock)({
330292
332051
  testName: run2.testName ?? run2.testCaseId ?? void 0,
330293
332052
  tags: run2.tags ?? [],
330294
332053
  error: result.error,
@@ -330426,7 +332185,7 @@ async function startRunner(opts) {
330426
332185
  capture: envInt("MAX_CAPTURE", DEFAULT_TYPE_LIMITS.capture),
330427
332186
  run: envInt("MAX_RUN", DEFAULT_TYPE_LIMITS.run)
330428
332187
  };
330429
- const RESOURCE_GOVERNOR_OPTIONS = (0, import_runner_core11.createResourceGovernorOptions)({
332188
+ const RESOURCE_GOVERNOR_OPTIONS = (0, import_runner_core12.createResourceGovernorOptions)({
330430
332189
  isCloud: IS_CLOUD,
330431
332190
  maxConcurrent: MAX_CONCURRENT,
330432
332191
  typeLimits: TYPE_LIMITS
@@ -330577,8 +332336,8 @@ async function startRunner(opts) {
330577
332336
  android: devices.some((d) => d.platform === "ANDROID" && d.isAvailable && d.state === "BOOTED")
330578
332337
  } : { web: true, ios: false, android: false };
330579
332338
  const bridge = enableMobile ? getBridgeState() : void 0;
330580
- const resourceStatus = (0, import_runner_core11.getResourceGovernorStatus)(RESOURCE_GOVERNOR_OPTIONS, Object.fromEntries(activeByType));
330581
- 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)();
330582
332341
  lastInventoryPids = new Set(processInventory.processes.map((p) => p.pid));
330583
332342
  lastInventoryKinds.clear();
330584
332343
  for (const p of processInventory.processes) lastInventoryKinds.set(p.pid, p.kind);
@@ -330605,8 +332364,8 @@ async function startRunner(opts) {
330605
332364
  resourceSnapshot: resourceStatus.snapshot,
330606
332365
  resourceGovernor: resourceStatus.governor,
330607
332366
  processInventory,
330608
- aiQueue: (0, import_runner_core11.getRunnerAIQueueStats)(),
330609
- liveBrowsers: import_runner_core10.liveBrowserRegistry.snapshotForHeartbeat(),
332367
+ aiQueue: (0, import_runner_core12.getRunnerAIQueueStats)(),
332368
+ liveBrowsers: import_runner_core11.liveBrowserRegistry.snapshotForHeartbeat(),
330610
332369
  // Pre-flight checks (ANDROID_HOME, Xcode, Appium drivers, adb, etc).
330611
332370
  // Cached & cheap — see getCachedDoctor above.
330612
332371
  doctor: getCachedDoctor()
@@ -330700,7 +332459,7 @@ async function startRunner(opts) {
330700
332459
  let pidsWatchdogWarned = false;
330701
332460
  const pidsWatchdog = setInterval(() => {
330702
332461
  if (shuttingDown) return;
330703
- const snapshot = (0, import_runner_core11.getResourceSnapshot)();
332462
+ const snapshot = (0, import_runner_core12.getResourceSnapshot)();
330704
332463
  const pct = snapshot.pidsPct;
330705
332464
  if (pct == null || !Number.isFinite(pct)) return;
330706
332465
  if (pct >= PIDS_RECYCLE_PCT) {
@@ -330736,7 +332495,7 @@ async function startRunner(opts) {
330736
332495
  log.dim(` Deferred ${run2.runId} (project ${runProject} at capacity ${getActiveForProject(runProject)}/${projectLimit})`);
330737
332496
  continue;
330738
332497
  }
330739
- const admission = (0, import_runner_core11.evaluateRunAdmission)(RESOURCE_GOVERNOR_OPTIONS, {
332498
+ const admission = (0, import_runner_core12.evaluateRunAdmission)(RESOURCE_GOVERNOR_OPTIONS, {
330740
332499
  mode: runMode,
330741
332500
  activeRuns,
330742
332501
  activeByType: Object.fromEntries(activeByType),