@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.js CHANGED
@@ -615,12 +615,19 @@ var require_ai_provider = __commonJS({
615
615
  function getMobileAIClient() {
616
616
  if ((process.env.SERVER_URL ?? "").trim().length > 0)
617
617
  return getServerProxiedAIClient();
618
- return getAIClient();
618
+ if (isMobileAiUnitTestMode())
619
+ return getAIClient();
620
+ 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.");
619
621
  }
620
622
  function getMobileClientForModel(modelId) {
621
623
  if ((process.env.SERVER_URL ?? "").trim().length > 0)
622
624
  return getServerProxiedClientForModel(modelId);
623
- return getClientForModel(modelId);
625
+ if (isMobileAiUnitTestMode())
626
+ return getClientForModel(modelId);
627
+ 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.`);
628
+ }
629
+ function isMobileAiUnitTestMode() {
630
+ return (process.env.VALIDATEQA_TEST_MODE ?? "").trim() === "1" || (process.env.NODE_ENV ?? "").trim() === "test";
624
631
  }
625
632
  }
626
633
  });
@@ -1030,6 +1037,65 @@ var require_mobile_source_parser = __commonJS({
1030
1037
  }
1031
1038
  });
1032
1039
 
1040
+ // ../runner-core/dist/services/mobile/mobile-boundary.js
1041
+ var require_mobile_boundary = __commonJS({
1042
+ "../runner-core/dist/services/mobile/mobile-boundary.js"(exports2) {
1043
+ "use strict";
1044
+ Object.defineProperty(exports2, "__esModule", { value: true });
1045
+ exports2.mobileOwnerFromScreenSignal = mobileOwnerFromScreenSignal;
1046
+ exports2.isTransientMobileSystemOwner = isTransientMobileSystemOwner;
1047
+ exports2.classifyMobileScreenBoundary = classifyMobileScreenBoundary2;
1048
+ exports2.isTargetLikeMobileBoundary = isTargetLikeMobileBoundary2;
1049
+ exports2.mobileBoundaryNote = mobileBoundaryNote;
1050
+ var ANDROID_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
1051
+ "com.android.permissioncontroller",
1052
+ "com.google.android.permissioncontroller",
1053
+ "com.android.packageinstaller",
1054
+ "com.google.android.packageinstaller"
1055
+ ]);
1056
+ var IOS_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
1057
+ "com.apple.springboard"
1058
+ ]);
1059
+ function mobileOwnerFromScreenSignal(signal, targetAppId) {
1060
+ const bundleId = signal.bundleId?.trim();
1061
+ if (bundleId)
1062
+ return bundleId;
1063
+ const activity = signal.activity?.trim();
1064
+ if (activity && targetAppId && (activity === targetAppId || activity.startsWith(`${targetAppId}.`))) {
1065
+ return targetAppId;
1066
+ }
1067
+ return null;
1068
+ }
1069
+ function isTransientMobileSystemOwner(owner, platform3) {
1070
+ return platform3 === "ANDROID" ? ANDROID_TRANSIENT_SYSTEM_APP_IDS.has(owner) : IOS_TRANSIENT_SYSTEM_APP_IDS.has(owner);
1071
+ }
1072
+ function classifyMobileScreenBoundary2(signal, targetAppId, platform3) {
1073
+ if (!targetAppId)
1074
+ return { kind: "target" };
1075
+ const owner = mobileOwnerFromScreenSignal(signal, targetAppId);
1076
+ if (!owner)
1077
+ return { kind: "unknown" };
1078
+ if (owner === targetAppId)
1079
+ return { kind: "target" };
1080
+ if (isTransientMobileSystemOwner(owner, platform3)) {
1081
+ return { kind: "transient_external", owner, targetAppId };
1082
+ }
1083
+ return { kind: "external", owner, targetAppId };
1084
+ }
1085
+ function isTargetLikeMobileBoundary2(boundary) {
1086
+ return boundary.kind === "target" || boundary.kind === "unknown";
1087
+ }
1088
+ function mobileBoundaryNote(boundary) {
1089
+ return [
1090
+ "",
1091
+ "## App Boundary",
1092
+ `Foreground app is ${boundary.owner}, outside target app ${boundary.targetAppId}.`,
1093
+ boundary.kind === "transient_external" ? "This looks like a system overlay. Handle or dismiss it if needed, but do not capture it as an app screen." : "The runner excluded this external app screen from the target app evidence and relaunched the target app."
1094
+ ].join("\n");
1095
+ }
1096
+ }
1097
+ });
1098
+
1033
1099
  // ../shared/dist/types.js
1034
1100
  function isUIVariant(value) {
1035
1101
  return typeof value === "string" && UI_VARIANTS.includes(value);
@@ -1465,6 +1531,18 @@ function buildAppiumCode(testName, target, steps) {
1465
1531
  ` }`,
1466
1532
  ` throw new Error('No locator resolved. Tried: ' + JSON.stringify(selectors) + (__lastError ? ' (last error: ' + String(__lastError) + ')' : ''));`,
1467
1533
  `}`,
1534
+ ``,
1535
+ `async function __tapAt(driver: WebdriverIO.Browser, x: number, y: number) {`,
1536
+ ` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [{ type: 'pointerMove', duration: 0, x, y }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 }] }]);`,
1537
+ `}`,
1538
+ ``,
1539
+ `async function __typeIntoFocused(driver: WebdriverIO.Browser, text: string) {`,
1540
+ ` try {`,
1541
+ ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).addValue(text);`,
1542
+ ` } catch {`,
1543
+ ` await driver.keys(text);`,
1544
+ ` }`,
1545
+ `}`,
1468
1546
  ...hasSwipe ? [
1469
1547
  ``,
1470
1548
  `// Read the device window with a few retries (matches the runtime executor).`,
@@ -1523,13 +1601,25 @@ function buildAppiumCode(testName, target, steps) {
1523
1601
  const y = Math.round(bounds.y + bounds.height / 2);
1524
1602
  lines.push(` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [{ type: 'pointerMove', duration: 0, x: ${x}, y: ${y} }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 }] }]);`);
1525
1603
  } else if (step.action === "type" && hasSelector) {
1526
- lines.push(` await (await findFirst(driver, ${candidatesLiteral})).addValue(${toJsStringLiteral(step.value ?? "")});`);
1604
+ if (bounds) {
1605
+ const x = Math.round(bounds.x + bounds.width / 2);
1606
+ const y = Math.round(bounds.y + bounds.height / 2);
1607
+ lines.push(` try {`);
1608
+ lines.push(` await (await findFirst(driver, ${candidatesLiteral})).addValue(${toJsStringLiteral(step.value ?? "")});`);
1609
+ lines.push(` } catch {`);
1610
+ lines.push(` await __tapAt(driver, ${x}, ${y});`);
1611
+ lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1612
+ lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1613
+ lines.push(` }`);
1614
+ } else {
1615
+ lines.push(` await (await findFirst(driver, ${candidatesLiteral})).addValue(${toJsStringLiteral(step.value ?? "")});`);
1616
+ }
1527
1617
  } else if (step.action === "type" && bounds) {
1528
1618
  const x = Math.round(bounds.x + bounds.width / 2);
1529
1619
  const y = Math.round(bounds.y + bounds.height / 2);
1530
- lines.push(` await driver.performActions([{ type: 'pointer', id: 'finger1', parameters: { pointerType: 'touch' }, actions: [{ type: 'pointerMove', duration: 0, x: ${x}, y: ${y} }, { type: 'pointerDown', button: 0 }, { type: 'pause', duration: 100 }, { type: 'pointerUp', button: 0 }] }]);`);
1620
+ lines.push(` await __tapAt(driver, ${x}, ${y});`);
1531
1621
  lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1532
- lines.push(` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).addValue(${toJsStringLiteral(step.value ?? "")});`);
1622
+ lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1533
1623
  } else if (step.action === "clear" && hasSelector) {
1534
1624
  lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
1535
1625
  } else if (step.action === "assertVisible" && hasSelector) {
@@ -4426,6 +4516,7 @@ var require_mobile_explorer = __commonJS({
4426
4516
  var ai_retry_js_1 = require_ai_retry();
4427
4517
  var mobile_source_parser_js_1 = require_mobile_source_parser();
4428
4518
  var mobile_observation_js_1 = require_mobile_observation();
4519
+ var mobile_boundary_js_1 = require_mobile_boundary();
4429
4520
  var MAX_MOBILE_ITERATIONS_SURVEY = 40;
4430
4521
  var MAX_MOBILE_ITERATIONS_DEEP = 60;
4431
4522
  var MAX_SCREENSHOTS = 15;
@@ -4734,8 +4825,9 @@ var require_mobile_explorer = __commonJS({
4734
4825
  }
4735
4826
  return sections.join("\n");
4736
4827
  }
4737
- function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief) {
4828
+ function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId) {
4738
4829
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
4830
+ const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
4739
4831
  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.
4740
4832
 
4741
4833
  ## How the loop works
@@ -4753,7 +4845,7 @@ var require_mobile_explorer = __commonJS({
4753
4845
 
4754
4846
  ## Boundaries (survey = map, do not mutate)
4755
4847
  - Do NOT submit forms, save, delete, purchase, or send anything. You MAY open modals/menus to catalog them, then dismiss.
4756
- - Stay inside this app. Do not leave to the system home screen or other apps (mobile_home backgrounds \u2014 avoid it).
4848
+ - 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.
4757
4849
  - If you hit a login wall and have no credentials, capture the auth screen and explore what is reachable.
4758
4850
 
4759
4851
  Max iterations: ${maxIterations}. You will be warned near the end.`;
@@ -4771,6 +4863,7 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
4771
4863
  4. Use mobile_relaunch for a clean, hermetic restart before testing a distinct flow that needs fresh state.
4772
4864
  5. mobile_swipe to scroll; mobile_back to return.
4773
4865
  6. Do NOT trigger irreversible side effects (real payments, sending messages/invites to others). Note them instead.
4866
+ 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.
4774
4867
 
4775
4868
  Max iterations: ${maxIterations}. You will be warned near the end.`;
4776
4869
  const briefBlock = appBrief ? `
@@ -4796,6 +4889,7 @@ Use this to prioritize which screens and flows to cover.` : "";
4796
4889
  const mode = ctx.mode === "deep" ? "deep" : "survey";
4797
4890
  const platform3 = platformForMedium(ctx.medium);
4798
4891
  const enableRecordJourney = config?.enableRecordJourney ?? mode !== "survey";
4892
+ const targetAppId = ctx.target?.appId?.trim() || void 0;
4799
4893
  const defaultBudget = mode === "deep" ? MAX_MOBILE_ITERATIONS_DEEP : MAX_MOBILE_ITERATIONS_SURVEY;
4800
4894
  const maxIterations = config?.maxIterations && config.maxIterations > 0 ? Math.floor(config.maxIterations) : defaultBudget;
4801
4895
  const modelOverride = ctx.exploreModel ?? null;
@@ -4846,6 +4940,81 @@ Use this to prioritize which screens and flows to cover.` : "";
4846
4940
  return null;
4847
4941
  return resolveSnapshot(xml, windowSize, driverSignal, platform3);
4848
4942
  };
4943
+ const currentBoundary = () => {
4944
+ const current = getLatest();
4945
+ return current ? (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(current.signal, targetAppId, platform3) : { kind: "unknown" };
4946
+ };
4947
+ const absorbResolvedObservation = async (resolved, screenshot, iterationForObservation, source) => {
4948
+ const boundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(resolved.signal, targetAppId, platform3);
4949
+ if (boundary.kind === "target" || boundary.kind === "unknown") {
4950
+ const screenId = ingestSnapshot(resolved, iterationForObservation);
4951
+ captureScreenshot(screenshot, screenId);
4952
+ return { observation: resolved.observation, screenId };
4953
+ }
4954
+ latest = resolved;
4955
+ lastSnapshotIteration = iterationForObservation;
4956
+ if (boundary.kind === "transient_external") {
4957
+ log2(` System overlay detected from ${source}: ${boundary.owner}; not adding it to target-app survey evidence.`);
4958
+ return {
4959
+ observation: `${resolved.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(boundary)}`,
4960
+ screenId: null,
4961
+ transientExternal: true,
4962
+ externalOwner: boundary.owner
4963
+ };
4964
+ }
4965
+ log2(` External app detected from ${source}: ${boundary.owner}; excluding it from survey evidence and relaunching ${boundary.targetAppId}.`);
4966
+ try {
4967
+ await driver.relaunch();
4968
+ const snap = await driver.snapshot();
4969
+ const recovered = buildResolved(snap.xml, snap.windowSize, snap.screenSignal);
4970
+ if (!recovered) {
4971
+ return {
4972
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunched target app, but no UI tree was returned)`,
4973
+ screenId: null,
4974
+ externalBlocked: true,
4975
+ externalOwner: boundary.owner
4976
+ };
4977
+ }
4978
+ const recoveredBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(recovered.signal, targetAppId, platform3);
4979
+ latest = recovered;
4980
+ lastSnapshotIteration = iterationForObservation;
4981
+ if (recoveredBoundary.kind === "target" || recoveredBoundary.kind === "unknown") {
4982
+ const screenId = ingestSnapshot(recovered, iterationForObservation);
4983
+ captureScreenshot(snap.screenshot, screenId);
4984
+ return {
4985
+ observation: recovered.observation,
4986
+ screenId,
4987
+ externalBlocked: true,
4988
+ externalOwner: boundary.owner
4989
+ };
4990
+ }
4991
+ if (recoveredBoundary.kind === "transient_external") {
4992
+ log2(` Relaunch surfaced system overlay ${recoveredBoundary.owner}; not adding it to target-app survey evidence.`);
4993
+ return {
4994
+ observation: `${recovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(recoveredBoundary)}`,
4995
+ screenId: null,
4996
+ externalBlocked: true,
4997
+ transientExternal: true,
4998
+ externalOwner: boundary.owner
4999
+ };
5000
+ }
5001
+ log2(` Relaunch still outside target app (${recoveredBoundary.owner}); no screen recorded.`);
5002
+ return {
5003
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no screen was recorded)`,
5004
+ screenId: null,
5005
+ externalBlocked: true,
5006
+ externalOwner: boundary.owner
5007
+ };
5008
+ } catch (err) {
5009
+ const message = err instanceof Error ? err.message : String(err);
5010
+ return {
5011
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; failed to relaunch target app: ${message})`,
5012
+ screenId: null,
5013
+ externalBlocked: true,
5014
+ externalOwner: boundary.owner
5015
+ };
5016
+ }
5017
+ };
4849
5018
  const resolveRef = (ref) => {
4850
5019
  if (typeof ref !== "string" || !latest)
4851
5020
  return null;
@@ -4854,7 +5023,7 @@ Use this to prioritize which screens and flows to cover.` : "";
4854
5023
  return null;
4855
5024
  return { element, bounds: element.bounds };
4856
5025
  };
4857
- const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief);
5026
+ const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId);
4858
5027
  const kickoff = buildKickoffMessage(mode);
4859
5028
  const recentExchanges = [];
4860
5029
  const transientDirectives = [];
@@ -4884,9 +5053,13 @@ Use this to prioritize which screens and flows to cover.` : "";
4884
5053
  const seed = await driver.snapshot();
4885
5054
  const resolved = buildResolved(seed.xml, seed.windowSize, seed.screenSignal);
4886
5055
  if (resolved) {
4887
- const screenId = ingestSnapshot(resolved, 0);
4888
- captureScreenshot(seed.screenshot, screenId);
4889
- log2(`Seed snapshot: screen ${screenId} (${resolved.screen.elements.length} elements)`);
5056
+ const absorbed = await absorbResolvedObservation(resolved, seed.screenshot, 0, "seed snapshot");
5057
+ if (absorbed.screenId) {
5058
+ const elementCount = state2.getScreen(absorbed.screenId)?.elements.length ?? resolved.screen.elements.length;
5059
+ log2(`Seed snapshot: screen ${absorbed.screenId} (${elementCount} elements)`);
5060
+ } else {
5061
+ log2("Seed snapshot was outside the target app and was not recorded.");
5062
+ }
4890
5063
  } else {
4891
5064
  log2("Seed snapshot returned no XML \u2014 the agent must call mobile_snapshot.");
4892
5065
  }
@@ -5027,9 +5200,9 @@ Exploration complete: ${summary}`);
5027
5200
  mode,
5028
5201
  latest: getLatest,
5029
5202
  buildResolved,
5030
- ingestSnapshot,
5203
+ absorbResolvedObservation,
5031
5204
  resolveRef,
5032
- captureScreenshot,
5205
+ currentBoundary,
5033
5206
  collectedTransitions,
5034
5207
  counters,
5035
5208
  journeys,
@@ -5064,9 +5237,21 @@ Exploration complete: ${summary}`);
5064
5237
  counters.pagesDiscovered = collectedPages.length;
5065
5238
  }
5066
5239
  log2(`Mobile explore phase done \u2014 ${collectedPages.length} screens, ${collectedTransitions.length} transitions, ${journeys.length} journeys, ${counters.elementsFound} elements observed.`);
5240
+ const recordedJourneys = journeys.map((journey) => {
5241
+ const routes = journey.screenIds && journey.screenIds.length > 0 ? journey.screenIds : [journey.screenId];
5242
+ return {
5243
+ name: journey.name,
5244
+ route: journey.screenId,
5245
+ routes,
5246
+ steps: journey.steps,
5247
+ expectedOutcome: journey.expectedOutcome,
5248
+ iteration: journey.iteration
5249
+ };
5250
+ });
5067
5251
  const result = {
5068
5252
  collectedPages,
5069
5253
  collectedTransitions,
5254
+ recordedJourneys,
5070
5255
  screenshots,
5071
5256
  exploreStats: {
5072
5257
  pagesDiscovered: counters.pagesDiscovered,
@@ -5087,15 +5272,13 @@ Exploration complete: ${summary}`);
5087
5272
  return result;
5088
5273
  }
5089
5274
  async function dispatchMobileTool(args) {
5090
- const { toolName, toolArgs, iteration, driver, state: state2, enableRecordJourney, latest, buildResolved, ingestSnapshot, resolveRef, captureScreenshot, collectedTransitions, counters, journeys, log: log2 } = args;
5091
- const absorbActionResult = (xml, screenshot) => {
5092
- const resolved = buildResolved(xml, latest()?.screen.windowSize ?? null, void 0);
5275
+ const { toolName, toolArgs, iteration, driver, state: state2, enableRecordJourney, latest, buildResolved, absorbResolvedObservation, resolveRef, currentBoundary, collectedTransitions, counters, journeys, log: log2 } = args;
5276
+ const absorbActionResult = (actionResult, source) => {
5277
+ const resolved = buildResolved(actionResult.source, latest()?.screen.windowSize ?? null, actionResult.screenSignal);
5093
5278
  if (!resolved) {
5094
- return { observation: "(no UI tree returned)", screenId: null };
5279
+ return Promise.resolve({ observation: "(no UI tree returned)", screenId: null });
5095
5280
  }
5096
- const screenId = ingestSnapshot(resolved, iteration);
5097
- captureScreenshot(screenshot, screenId);
5098
- return { observation: resolved.observation, screenId };
5281
+ return absorbResolvedObservation(resolved, actionResult.screenshot, iteration, source);
5099
5282
  };
5100
5283
  switch (toolName) {
5101
5284
  // ----- Observation -----
@@ -5105,12 +5288,16 @@ Exploration complete: ${summary}`);
5105
5288
  if (!resolved) {
5106
5289
  return { ok: false, error: "snapshot returned no UI tree" };
5107
5290
  }
5108
- const screenId = ingestSnapshot(resolved, iteration);
5109
- captureScreenshot(snap.screenshot, screenId);
5110
- log2(` Snapshot: screen ${screenId} (${resolved.screen.elements.length} elements)`);
5291
+ const absorbed = await absorbResolvedObservation(resolved, snap.screenshot, iteration, "mobile_snapshot");
5292
+ if (absorbed.screenId) {
5293
+ const elementCount = state2.getScreen(absorbed.screenId)?.elements.length ?? resolved.screen.elements.length;
5294
+ log2(` Snapshot: screen ${absorbed.screenId} (${elementCount} elements)`);
5295
+ }
5111
5296
  return {
5112
- screenId,
5113
- observation: resolved.observation,
5297
+ screenId: absorbed.screenId,
5298
+ observation: absorbed.observation,
5299
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5300
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
5114
5301
  progress: `${state2.getScreenCount()} screens, ${collectedTransitions.length} transitions, ${journeys.length} journeys`
5115
5302
  };
5116
5303
  }
@@ -5121,14 +5308,21 @@ Exploration complete: ${summary}`);
5121
5308
  return { ok: false, error: `Unknown element ref "${String(toolArgs.ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.` };
5122
5309
  }
5123
5310
  const fromScreenId = state2.getCurrentScreenId();
5311
+ const boundaryBeforeAction = currentBoundary();
5312
+ const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
5124
5313
  const actionResult = await driver.tap(resolved.bounds);
5125
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5126
- autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, String(toolArgs.ref), "tap");
5314
+ const absorbed = await absorbActionResult(actionResult, "mobile_tap");
5315
+ const { observation, screenId } = absorbed;
5316
+ if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
5317
+ autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, String(toolArgs.ref), "tap");
5318
+ }
5127
5319
  return {
5128
5320
  ok: actionResult.ok,
5129
5321
  ...actionResult.error ? { error: actionResult.error } : {},
5130
5322
  screenId,
5131
- observation
5323
+ observation,
5324
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5325
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5132
5326
  };
5133
5327
  }
5134
5328
  // ----- Type -----
@@ -5139,12 +5333,15 @@ Exploration complete: ${summary}`);
5139
5333
  }
5140
5334
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
5141
5335
  const actionResult = await driver.type(value, resolved.bounds);
5142
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5336
+ const absorbed = await absorbActionResult(actionResult, "mobile_type");
5337
+ const { observation, screenId } = absorbed;
5143
5338
  return {
5144
5339
  ok: actionResult.ok,
5145
5340
  ...actionResult.error ? { error: actionResult.error } : {},
5146
5341
  screenId,
5147
- observation
5342
+ observation,
5343
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5344
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5148
5345
  };
5149
5346
  }
5150
5347
  // ----- Clear -----
@@ -5154,12 +5351,15 @@ Exploration complete: ${summary}`);
5154
5351
  return { ok: false, error: `Unknown element ref "${String(toolArgs.ref)}". Call mobile_snapshot first.` };
5155
5352
  }
5156
5353
  const actionResult = await driver.clear(resolved.bounds);
5157
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5354
+ const absorbed = await absorbActionResult(actionResult, "mobile_clear");
5355
+ const { observation, screenId } = absorbed;
5158
5356
  return {
5159
5357
  ok: actionResult.ok,
5160
5358
  ...actionResult.error ? { error: actionResult.error } : {},
5161
5359
  screenId,
5162
- observation
5360
+ observation,
5361
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5362
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5163
5363
  };
5164
5364
  }
5165
5365
  // ----- Swipe -----
@@ -5170,36 +5370,49 @@ Exploration complete: ${summary}`);
5170
5370
  }
5171
5371
  const distance = typeof toolArgs.distance === "number" ? toolArgs.distance : void 0;
5172
5372
  const actionResult = await driver.swipe(direction, distance);
5173
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5373
+ const absorbed = await absorbActionResult(actionResult, "mobile_swipe");
5374
+ const { observation, screenId } = absorbed;
5174
5375
  return {
5175
5376
  ok: actionResult.ok,
5176
5377
  ...actionResult.error ? { error: actionResult.error } : {},
5177
5378
  screenId,
5178
- observation
5379
+ observation,
5380
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5381
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5179
5382
  };
5180
5383
  }
5181
5384
  // ----- Back -----
5182
5385
  case "mobile_back": {
5183
5386
  const fromScreenId = state2.getCurrentScreenId();
5387
+ const boundaryBeforeAction = currentBoundary();
5388
+ const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
5184
5389
  const actionResult = await driver.back();
5185
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5186
- autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "back");
5390
+ const absorbed = await absorbActionResult(actionResult, "mobile_back");
5391
+ const { observation, screenId } = absorbed;
5392
+ if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
5393
+ autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "back");
5394
+ }
5187
5395
  return {
5188
5396
  ok: actionResult.ok,
5189
5397
  ...actionResult.error ? { error: actionResult.error } : {},
5190
5398
  screenId,
5191
- observation
5399
+ observation,
5400
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5401
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5192
5402
  };
5193
5403
  }
5194
5404
  // ----- Home (backgrounds only) -----
5195
5405
  case "mobile_home": {
5196
5406
  const actionResult = await driver.home();
5197
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5407
+ const absorbed = await absorbActionResult(actionResult, "mobile_home");
5408
+ const { observation, screenId } = absorbed;
5198
5409
  return {
5199
5410
  ok: actionResult.ok,
5200
5411
  ...actionResult.error ? { error: actionResult.error } : {},
5201
5412
  screenId,
5202
5413
  observation,
5414
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5415
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
5203
5416
  note: "Home only backgrounds the app. Use mobile_relaunch for a clean restart."
5204
5417
  };
5205
5418
  }
@@ -5212,9 +5425,15 @@ Exploration complete: ${summary}`);
5212
5425
  if (!resolved) {
5213
5426
  return { ok: true, note: "relaunched; no UI tree returned \u2014 call mobile_snapshot." };
5214
5427
  }
5215
- const screenId = ingestSnapshot(resolved, iteration);
5216
- captureScreenshot(snap.screenshot, screenId);
5217
- return { ok: true, screenId, observation: resolved.observation, note: "App relaunched (clean state)." };
5428
+ const absorbed = await absorbResolvedObservation(resolved, snap.screenshot, iteration, "mobile_relaunch");
5429
+ return {
5430
+ ok: true,
5431
+ screenId: absorbed.screenId,
5432
+ observation: absorbed.observation,
5433
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5434
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
5435
+ note: "App relaunched (clean state)."
5436
+ };
5218
5437
  }
5219
5438
  // ----- Deep-link (guarded) -----
5220
5439
  case "mobile_open_deeplink": {
@@ -5223,18 +5442,32 @@ Exploration complete: ${summary}`);
5223
5442
  return { ok: false, error: `Refused to open deep link "${deeplink}" (empty or dangerous scheme).` };
5224
5443
  }
5225
5444
  const fromScreenId = state2.getCurrentScreenId();
5445
+ const boundaryBeforeAction = currentBoundary();
5446
+ const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
5226
5447
  const actionResult = await driver.openDeepLink(deeplink);
5227
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5228
- autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "deeplink");
5448
+ const absorbed = await absorbActionResult(actionResult, "mobile_open_deeplink");
5449
+ const { observation, screenId } = absorbed;
5450
+ if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
5451
+ autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "deeplink");
5452
+ }
5229
5453
  return {
5230
5454
  ok: actionResult.ok,
5231
5455
  ...actionResult.error ? { error: actionResult.error } : {},
5232
5456
  screenId,
5233
- observation
5457
+ observation,
5458
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5459
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5234
5460
  };
5235
5461
  }
5236
5462
  // ----- capture_screen (mark + annotate current screen) -----
5237
5463
  case "capture_screen": {
5464
+ const boundary = currentBoundary();
5465
+ if (boundary.kind === "external" || boundary.kind === "transient_external") {
5466
+ return {
5467
+ ok: false,
5468
+ 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.`
5469
+ };
5470
+ }
5238
5471
  const screenId = state2.getCurrentScreenId();
5239
5472
  if (!screenId) {
5240
5473
  return { ok: false, error: "No current screen \u2014 call mobile_snapshot first." };
@@ -5285,6 +5518,13 @@ Exploration complete: ${summary}`);
5285
5518
  if (!enableRecordJourney) {
5286
5519
  return { ok: false, error: "record_journey is not available in survey mode." };
5287
5520
  }
5521
+ const boundary = currentBoundary();
5522
+ if (boundary.kind === "external" || boundary.kind === "transient_external") {
5523
+ return {
5524
+ ok: false,
5525
+ error: `Current foreground app is ${boundary.owner}, outside target app ${boundary.targetAppId}. External app screens cannot be recorded as a target-app journey.`
5526
+ };
5527
+ }
5288
5528
  const name = typeof toolArgs.name === "string" ? toolArgs.name : "";
5289
5529
  const screenId = typeof toolArgs.screenId === "string" && toolArgs.screenId ? toolArgs.screenId : state2.getCurrentScreenId() ?? "";
5290
5530
  const steps = Array.isArray(toolArgs.steps) ? toolArgs.steps.filter((s) => typeof s === "string") : [];
@@ -7075,7 +7315,7 @@ Each entry is exactly one of:
7075
7315
  const existingNamesLower = new Set(existingTestNames.map((n) => n.toLowerCase()));
7076
7316
  const usedNames = /* @__PURE__ */ new Set();
7077
7317
  const validTypes = /* @__PURE__ */ new Set(["HAPPY_PATH", "EDGE_CASE", "USER_JOURNEY", "REGRESSION", "BUG_REPORT"]);
7078
- const validOutcomeTypes = /* @__PURE__ */ new Set(["url", "element-visible", "text-visible", "value-match", "element-hidden"]);
7318
+ const validOutcomeTypes = /* @__PURE__ */ new Set(["url", "screen-visible", "element-visible", "text-visible", "value-match", "element-hidden"]);
7079
7319
  const validVariants = /* @__PURE__ */ new Set(["happy", "validation", "bug"]);
7080
7320
  const validCriticalities = /* @__PURE__ */ new Set(["critical", "high", "medium", "low"]);
7081
7321
  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";
@@ -7549,8 +7789,8 @@ Each entry is exactly one of:
7549
7789
  const agentModel = options.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
7550
7790
  const plannerMaxTokens = getAIPlannerMaxTokens(agentModel);
7551
7791
  logs2.push(`AI planner [${context.mode}]: ${siteMap.pages.length} pages, ${evidence.recordedJourneys.length} journeys, budget ${context.testBudget}`);
7552
- const systemPrompt = context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports2.SCENARIO_SYSTEM_PROMPT;
7553
- const userPrompt = formatEvidenceForPlanner(compressed, evidence, context);
7792
+ const systemPrompt = options.systemPromptOverride ?? (context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports2.SCENARIO_SYSTEM_PROMPT);
7793
+ const userPrompt = options.userPromptOverride ?? formatEvidenceForPlanner(compressed, evidence, context);
7554
7794
  logs2.push(`AI planner: ~${Math.ceil(userPrompt.length / 3.5)} input tokens`);
7555
7795
  const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)());
7556
7796
  const messages = [
@@ -7766,6 +8006,14 @@ var require_mobile_discovery_prompts = __commonJS({
7766
8006
  exports2.runMobileAIPlanner = runMobileAIPlanner;
7767
8007
  var ai_provider_js_1 = require_ai_provider();
7768
8008
  var discover_ai_planner_js_1 = require_discover_ai_planner();
8009
+ function isTargetAppScreenId(screenId, appId) {
8010
+ if (!appId)
8011
+ return true;
8012
+ return screenId === appId || screenId.startsWith(`${appId}#`) || screenId.startsWith(`${appId}.`);
8013
+ }
8014
+ function targetAppScreens(graph, appId) {
8015
+ return [...graph.screens.values()].filter((state2) => isTargetAppScreenId(state2.screenId, appId));
8016
+ }
7769
8017
  exports2.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.
7770
8018
 
7771
8019
  ## Output Discipline
@@ -7995,7 +8243,8 @@ ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
7995
8243
  if (context.featureName) {
7996
8244
  sections.push(`## Feature: ${context.featureName}`);
7997
8245
  }
7998
- const orderedScreens = [...graph.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8246
+ const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8247
+ const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
7999
8248
  sections.push(`## Screens Discovered (${orderedScreens.length})`);
8000
8249
  for (const state2 of orderedScreens) {
8001
8250
  const name = screenDisplayName(state2);
@@ -8017,7 +8266,7 @@ ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
8017
8266
  for (const state2 of orderedScreens)
8018
8267
  nameById.set(state2.screenId, screenDisplayName(state2));
8019
8268
  const lines = ["## Observed Screen Transitions"];
8020
- for (const t of graph.transitions.slice(0, MAX_TRANSITIONS)) {
8269
+ for (const t of graph.transitions.filter((transition) => includedScreenIds.has(transition.fromScreenId) && includedScreenIds.has(transition.toScreenId)).slice(0, MAX_TRANSITIONS)) {
8021
8270
  const from = nameById.get(t.fromScreenId) ?? t.fromScreenId;
8022
8271
  const to = nameById.get(t.toScreenId) ?? t.toScreenId;
8023
8272
  const via = t.viaRef ? ` via ${t.viaRef}` : "";
@@ -8103,7 +8352,7 @@ ${constraints.join("\n")}`);
8103
8352
  const now = /* @__PURE__ */ new Date();
8104
8353
  const idByScreen = /* @__PURE__ */ new Map();
8105
8354
  let pageSeq = 0;
8106
- const orderedScreens = [...graph.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8355
+ const orderedScreens = targetAppScreens(graph, meta.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8107
8356
  for (const state2 of orderedScreens) {
8108
8357
  idByScreen.set(state2.screenId, `mpage-${pageSeq++}`);
8109
8358
  }
@@ -8162,6 +8411,135 @@ ${constraints.join("\n")}`);
8162
8411
  seedFixtures: []
8163
8412
  };
8164
8413
  }
8414
+ function buildMobileFeatureGroupsFromGraph(graph, appId) {
8415
+ const groups = /* @__PURE__ */ new Map();
8416
+ const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8417
+ const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
8418
+ for (const state2 of orderedScreens) {
8419
+ const groupName = state2.screenType === "auth" ? "Authentication" : screenDisplayName(state2);
8420
+ const group = groups.get(groupName) ?? {
8421
+ name: groupName,
8422
+ description: `Native mobile screens related to ${groupName}.`,
8423
+ routes: [],
8424
+ criticality: state2.screenType === "auth" ? "critical" : "medium",
8425
+ pages: [],
8426
+ flows: []
8427
+ };
8428
+ group.routes.push(state2.screenId);
8429
+ group.pages?.push({
8430
+ route: state2.screenId,
8431
+ type: state2.screenType ?? "UNKNOWN",
8432
+ summary: `${state2.elements.filter((el) => el.actionable).length} actionable element(s)`
8433
+ });
8434
+ groups.set(groupName, group);
8435
+ }
8436
+ const nameById = /* @__PURE__ */ new Map();
8437
+ for (const state2 of graph.screens.values()) {
8438
+ nameById.set(state2.screenId, screenDisplayName(state2));
8439
+ }
8440
+ for (const transition of graph.transitions) {
8441
+ if (!includedScreenIds.has(transition.fromScreenId) || !includedScreenIds.has(transition.toScreenId))
8442
+ continue;
8443
+ const from = graph.screens.get(transition.fromScreenId);
8444
+ const to = graph.screens.get(transition.toScreenId);
8445
+ if (!from || !to)
8446
+ continue;
8447
+ const groupName = from.screenType === "auth" ? "Authentication" : screenDisplayName(from);
8448
+ const group = groups.get(groupName);
8449
+ if (!group)
8450
+ continue;
8451
+ const via = transition.viaRef ? ` via ${transition.viaRef}` : "";
8452
+ group.flows?.push(`${nameById.get(transition.fromScreenId) ?? transition.fromScreenId} \u2192 ${nameById.get(transition.toScreenId) ?? transition.toScreenId} (${transition.action}${via})`);
8453
+ }
8454
+ return [...groups.values()];
8455
+ }
8456
+ function buildMobileFallbackScenarios(input) {
8457
+ if (input.mode === "survey")
8458
+ return [];
8459
+ 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);
8460
+ if (orderedScreens.length === 0)
8461
+ return [];
8462
+ const entryScreenId = orderedScreens[0].screenId;
8463
+ const usedNames = new Set((input.existingTestNames ?? []).map((name) => name.toLowerCase()));
8464
+ const scenarios = [];
8465
+ const budget = Math.max(1, input.testBudget || orderedScreens.length);
8466
+ for (const state2 of orderedScreens) {
8467
+ if (scenarios.length >= budget)
8468
+ break;
8469
+ const screenName = screenDisplayName(state2);
8470
+ const baseName = `${screenName} - Screen Reachability`;
8471
+ const name = usedNames.has(baseName.toLowerCase()) ? `${baseName} ${state2.firstSeenIteration + 1}` : baseName;
8472
+ if (usedNames.has(name.toLowerCase()))
8473
+ continue;
8474
+ usedNames.add(name.toLowerCase());
8475
+ 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);
8476
+ const steps = [
8477
+ { action: "Relaunch the app from a clean state", target: entryScreenId },
8478
+ {
8479
+ action: state2.screenId === entryScreenId ? `Verify the launch screen is ${screenName}` : `Navigate through the app until the ${screenName} screen is visible`,
8480
+ target: state2.screenId
8481
+ }
8482
+ ];
8483
+ if (actionableLabels.length > 0) {
8484
+ steps.push({
8485
+ action: `Verify expected native controls are available: ${actionableLabels.join(", ")}`,
8486
+ target: state2.screenId
8487
+ });
8488
+ }
8489
+ scenarios.push({
8490
+ name,
8491
+ type: "HAPPY_PATH",
8492
+ priority: state2.screenType === "auth" ? 1 : 3,
8493
+ variant: "happy",
8494
+ featureGroup: state2.screenType === "auth" ? "Authentication" : screenName,
8495
+ goal: `User can reach and inspect the ${screenName} screen`,
8496
+ steps,
8497
+ expectedOutcomes: [{
8498
+ type: "screen-visible",
8499
+ description: `${screenName} screen is visible`,
8500
+ reliability: "medium"
8501
+ }],
8502
+ entryRoute: entryScreenId,
8503
+ startFromLanding: true,
8504
+ targetPages: [state2.screenId],
8505
+ evidence: { observedLocators: [], observedTransitions: [] },
8506
+ prerequisites: []
8507
+ });
8508
+ }
8509
+ return scenarios;
8510
+ }
8511
+ function filterRecordedJourneysToSiteMap(journeys, siteMap) {
8512
+ if (!journeys?.length)
8513
+ return [];
8514
+ const validRoutes = new Set(siteMap.pages.map((page) => page.route));
8515
+ const filtered = [];
8516
+ for (const journey of journeys) {
8517
+ const routes = (journey.routes?.length ? journey.routes : [journey.route]).filter((route2) => validRoutes.has(route2));
8518
+ if (routes.length === 0)
8519
+ continue;
8520
+ filtered.push({
8521
+ ...journey,
8522
+ route: validRoutes.has(journey.route) ? journey.route : routes[0],
8523
+ routes
8524
+ });
8525
+ }
8526
+ return filtered;
8527
+ }
8528
+ function filterFeatureGroupsToSiteMap(groups, siteMap) {
8529
+ if (groups.length === 0)
8530
+ return [];
8531
+ const validRoutes = new Set(siteMap.pages.map((page) => page.route));
8532
+ return groups.flatMap((group) => {
8533
+ const routes = group.routes.filter((route2) => validRoutes.has(route2));
8534
+ if (routes.length === 0)
8535
+ return [];
8536
+ return [{
8537
+ ...group,
8538
+ routes,
8539
+ pages: group.pages?.filter((page) => validRoutes.has(page.route))
8540
+ }];
8541
+ });
8542
+ }
8165
8543
  async function runMobileAIPlanner(input, options = {}) {
8166
8544
  const planner = options.planner ?? discover_ai_planner_js_1.runAIPlanner;
8167
8545
  const shouldBuildMobileAIClient = (process.env.SERVER_URL ?? "").trim().length > 0 || !options.planner;
@@ -8170,11 +8548,12 @@ ${constraints.join("\n")}`);
8170
8548
  projectId: input.projectId,
8171
8549
  appId: input.appId
8172
8550
  });
8551
+ const recordedJourneys = filterRecordedJourneysToSiteMap(input.recordedJourneys, siteMap);
8173
8552
  const collectedPages = input.collectedPages ?? [];
8174
8553
  const collectedTransitions = input.collectedTransitions ?? [];
8175
- const visitedRoutes = [...input.graph.screens.values()].filter((s) => s.visited).map((s) => s.screenId);
8176
- return planner(siteMap, {
8177
- recordedJourneys: input.recordedJourneys ?? [],
8554
+ const visitedRoutes = targetAppScreens(input.graph, input.appId).filter((s) => s.visited).map((s) => s.screenId);
8555
+ const result = await planner(siteMap, {
8556
+ recordedJourneys,
8178
8557
  collectedPages,
8179
8558
  collectedTransitions,
8180
8559
  visitedRoutes
@@ -8192,8 +8571,52 @@ ${constraints.join("\n")}`);
8192
8571
  }, {
8193
8572
  modelOverride: options.modelOverride,
8194
8573
  onAICall: options.onAICall,
8195
- ...aiClient ? { aiClient } : {}
8574
+ ...aiClient ? { aiClient } : {},
8575
+ systemPromptOverride: input.mode === "survey" ? exports2.MOBILE_SURVEY_SYSTEM_PROMPT : exports2.MOBILE_SCENARIO_SYSTEM_PROMPT,
8576
+ userPromptOverride: formatMobileEvidenceForPlanner(input.graph, {
8577
+ appBrief: input.appBrief,
8578
+ appId: input.appId,
8579
+ platform: input.platform,
8580
+ featureName: input.featureName,
8581
+ existingTestNames: input.existingTestNames,
8582
+ existingFeatureNames: input.existingFeatureNames,
8583
+ recordedJourneys,
8584
+ credentials: input.credentials,
8585
+ testBudget: input.testBudget,
8586
+ incremental: input.incremental
8587
+ })
8196
8588
  });
8589
+ const plannerFeatureGroups = filterFeatureGroupsToSiteMap(result.featureGroups, siteMap);
8590
+ const mobileFeatureGroups = plannerFeatureGroups.length > 0 ? plannerFeatureGroups : buildMobileFeatureGroupsFromGraph(input.graph, input.appId);
8591
+ if (input.mode !== "survey" && result.scenarios.length === 0 && input.graph.screens.size > 0) {
8592
+ const fallbackScenarios = buildMobileFallbackScenarios(input);
8593
+ if (fallbackScenarios.length > 0) {
8594
+ return {
8595
+ ...result,
8596
+ scenarios: fallbackScenarios,
8597
+ featureGroups: mobileFeatureGroups,
8598
+ logs: [
8599
+ ...result.logs,
8600
+ `Mobile fallback: synthesized ${fallbackScenarios.length} screen-reachability scenario(s) from the native screen graph`
8601
+ ],
8602
+ fallbackUsed: true,
8603
+ fallbackReason: result.fallbackReason ?? "Mobile planner produced no scenarios"
8604
+ };
8605
+ }
8606
+ }
8607
+ if (input.mode === "survey" && result.featureGroups.length === 0 && mobileFeatureGroups.length > 0) {
8608
+ return {
8609
+ ...result,
8610
+ featureGroups: mobileFeatureGroups,
8611
+ logs: [
8612
+ ...result.logs,
8613
+ `Mobile fallback: synthesized ${mobileFeatureGroups.length} feature group(s) from the native screen graph`
8614
+ ],
8615
+ fallbackUsed: true,
8616
+ fallbackReason: result.fallbackReason ?? "Mobile planner produced no feature groups"
8617
+ };
8618
+ }
8619
+ return { ...result, featureGroups: mobileFeatureGroups };
8197
8620
  }
8198
8621
  }
8199
8622
  });
@@ -8212,6 +8635,7 @@ var require_mobile_generator = __commonJS({
8212
8635
  var mobile_source_parser_js_1 = require_mobile_source_parser();
8213
8636
  var mobile_source_parser_js_2 = require_mobile_source_parser();
8214
8637
  var mobile_observation_js_1 = require_mobile_observation();
8638
+ var mobile_boundary_js_1 = require_mobile_boundary();
8215
8639
  var MAX_SCENARIO_ITERATIONS = 30;
8216
8640
  var MAX_RECENT_EXCHANGES = 24;
8217
8641
  var OBSERVATION_MAX_ELEMENTS = 50;
@@ -8397,8 +8821,9 @@ var require_mobile_generator = __commonJS({
8397
8821
  function hasStableId(element) {
8398
8822
  return !!nonEmpty2(element.accessibilityId) || !!nonEmpty2(element.resourceId);
8399
8823
  }
8400
- function buildSystemPrompt(platform3) {
8824
+ function buildSystemPrompt(platform3, targetAppId) {
8401
8825
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
8826
+ const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
8402
8827
  return `You are an expert mobile QA agent generating ONE automated test by driving a real ${platformLabel} app on a connected device. The app has just been relaunched into a clean state.
8403
8828
 
8404
8829
  ## How the loop works
@@ -8418,7 +8843,8 @@ var require_mobile_generator = __commonJS({
8418
8843
  - Re-snapshot before every assertion so the ref is fresh.
8419
8844
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
8420
8845
  - Do NOT trigger irreversible side effects (real payments, sending messages to others) \u2014 note them and assert what you safely can.
8421
- - Stay inside this app. Finish promptly once the outcome is proven.
8846
+ - Stay inside this app.${targetLine} Do not open or use app stores, browsers, email apps, settings, or any other app. If an action leaves the target app, the runner will relaunch the target app and reject that action.
8847
+ - Finish promptly once the outcome is proven.
8422
8848
 
8423
8849
  Max iterations: ${MAX_SCENARIO_ITERATIONS}. You will be warned near the end.`;
8424
8850
  }
@@ -8491,7 +8917,7 @@ Start by calling mobile_snapshot, then execute the steps. Finish with at least o
8491
8917
  }
8492
8918
  }
8493
8919
  async function runScenarioLoop(args) {
8494
- const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, log: log2 } = args;
8920
+ const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, log: log2 } = args;
8495
8921
  const actions = [];
8496
8922
  let assertCount = 0;
8497
8923
  let finish = null;
@@ -8505,14 +8931,98 @@ Start by calling mobile_snapshot, then execute the steps. Finish with at least o
8505
8931
  const fromActivity = signal.activity ? signal.activity.split(".").pop() : void 0;
8506
8932
  return nonEmpty2(fromActivity) ?? nonEmpty2(signal.bundleId) ?? `screen-${signal.screenHash.slice(0, 8)}`;
8507
8933
  };
8508
- const ingest = (xml, driverSignal, windowSize) => {
8509
- if (!xml)
8510
- return null;
8511
- const resolved = resolveSnapshot(xml, windowSize ?? latest?.screen.windowSize ?? null, driverSignal, platform3);
8934
+ const setLatest = (resolved) => {
8512
8935
  latest = resolved;
8513
8936
  lastScreenName = screenNameFor(resolved);
8514
8937
  return resolved;
8515
8938
  };
8939
+ const buildResolved = (xml, driverSignal, windowSize) => {
8940
+ if (!xml)
8941
+ return null;
8942
+ return resolveSnapshot(xml, windowSize ?? latest?.screen.windowSize ?? null, driverSignal, platform3);
8943
+ };
8944
+ const emitScreenshot = (base64) => {
8945
+ if (!base64)
8946
+ return;
8947
+ try {
8948
+ onScreenshot?.(base64);
8949
+ } catch {
8950
+ }
8951
+ };
8952
+ const absorbResolvedObservation = async (resolved, screenshot, source) => {
8953
+ const boundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(resolved.signal, targetAppId, platform3);
8954
+ if ((0, mobile_boundary_js_1.isTargetLikeMobileBoundary)(boundary)) {
8955
+ setLatest(resolved);
8956
+ emitScreenshot(screenshot);
8957
+ return { resolved, observation: resolved.observation };
8958
+ }
8959
+ setLatest(resolved);
8960
+ if (boundary.kind === "transient_external") {
8961
+ log2(` system overlay detected from ${source}: ${boundary.owner}; not recording it as a target-app step.`);
8962
+ return {
8963
+ resolved,
8964
+ observation: `${resolved.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(boundary)}`,
8965
+ transientExternal: true,
8966
+ externalOwner: boundary.owner
8967
+ };
8968
+ }
8969
+ log2(` external app detected from ${source}: ${boundary.owner}; rejecting the action and relaunching ${boundary.targetAppId}.`);
8970
+ try {
8971
+ await driver.relaunch();
8972
+ const snap = await driver.snapshot();
8973
+ const recovered = buildResolved(snap.xml, snap.screenSignal, snap.windowSize);
8974
+ if (!recovered) {
8975
+ return {
8976
+ resolved: null,
8977
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunched target app, but no UI tree was returned)`,
8978
+ externalBlocked: true,
8979
+ externalOwner: boundary.owner
8980
+ };
8981
+ }
8982
+ const recoveredBoundary = (0, mobile_boundary_js_1.classifyMobileScreenBoundary)(recovered.signal, targetAppId, platform3);
8983
+ setLatest(recovered);
8984
+ if ((0, mobile_boundary_js_1.isTargetLikeMobileBoundary)(recoveredBoundary)) {
8985
+ emitScreenshot(snap.screenshot);
8986
+ return {
8987
+ resolved: recovered,
8988
+ observation: recovered.observation,
8989
+ externalBlocked: true,
8990
+ externalOwner: boundary.owner
8991
+ };
8992
+ }
8993
+ if (recoveredBoundary.kind === "transient_external") {
8994
+ log2(` relaunch surfaced system overlay ${recoveredBoundary.owner}; not recording it as a target-app step.`);
8995
+ return {
8996
+ resolved: recovered,
8997
+ observation: `${recovered.observation}${(0, mobile_boundary_js_1.mobileBoundaryNote)(recoveredBoundary)}`,
8998
+ externalBlocked: true,
8999
+ transientExternal: true,
9000
+ externalOwner: boundary.owner
9001
+ };
9002
+ }
9003
+ log2(` relaunch still outside target app (${recoveredBoundary.owner}); no target-app step recorded.`);
9004
+ return {
9005
+ resolved: recovered,
9006
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no target-app step was recorded)`,
9007
+ externalBlocked: true,
9008
+ externalOwner: boundary.owner
9009
+ };
9010
+ } catch (err) {
9011
+ const message = err instanceof Error ? err.message : String(err);
9012
+ return {
9013
+ resolved: null,
9014
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; failed to relaunch target app: ${message})`,
9015
+ externalBlocked: true,
9016
+ externalOwner: boundary.owner
9017
+ };
9018
+ }
9019
+ };
9020
+ const absorbObservation = async (xml, driverSignal, windowSize, screenshot, source) => {
9021
+ const resolved = buildResolved(xml, driverSignal, windowSize);
9022
+ if (!resolved)
9023
+ return { resolved: null, observation: null };
9024
+ return absorbResolvedObservation(resolved, screenshot, source);
9025
+ };
8516
9026
  const resolveRef = (ref) => {
8517
9027
  if (typeof ref !== "string" || !latest)
8518
9028
  return null;
@@ -8520,11 +9030,11 @@ Start by calling mobile_snapshot, then execute the steps. Finish with at least o
8520
9030
  };
8521
9031
  try {
8522
9032
  const seed = await driver.snapshot();
8523
- ingest(seed.xml, seed.screenSignal, seed.windowSize);
9033
+ await absorbObservation(seed.xml, seed.screenSignal, seed.windowSize, seed.screenshot, "seed snapshot");
8524
9034
  } catch (err) {
8525
9035
  log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
8526
9036
  }
8527
- const systemPrompt = buildSystemPrompt(platform3);
9037
+ const systemPrompt = buildSystemPrompt(platform3, targetAppId);
8528
9038
  const kickoff = buildScenarioKickoff(scenario);
8529
9039
  const recentExchanges = [];
8530
9040
  let iteration = 0;
@@ -8635,7 +9145,7 @@ ${statePacket}` },
8635
9145
  driver,
8636
9146
  platform: platform3,
8637
9147
  latest: getLatest,
8638
- ingest,
9148
+ absorbObservation,
8639
9149
  resolveRef,
8640
9150
  screenName: () => lastScreenName || "UnknownScreen",
8641
9151
  recordAction: (action) => {
@@ -8658,60 +9168,85 @@ ${statePacket}` },
8658
9168
  return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
8659
9169
  }
8660
9170
  async function dispatchGenerateTool(args) {
8661
- const { toolName, toolArgs, driver, platform: platform3, latest, ingest, resolveRef, screenName, recordAction, log: log2 } = args;
9171
+ const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, screenName, recordAction, log: log2 } = args;
8662
9172
  const unknownRef = (ref) => ({
8663
9173
  ok: false,
8664
9174
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
8665
9175
  });
9176
+ const actionPayload = (result, absorbed) => {
9177
+ const blocked = Boolean(absorbed.externalBlocked || absorbed.transientExternal);
9178
+ return {
9179
+ ok: result.ok && !blocked,
9180
+ ...result.error ? { error: result.error } : {},
9181
+ ...blocked ? { error: "Action left the target app and was not recorded. Continue from the relaunched target app." } : {},
9182
+ observation: absorbed.observation ?? void 0,
9183
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
9184
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
9185
+ };
9186
+ };
9187
+ const shouldRecordAction = (result, absorbed) => result.ok && !absorbed.externalBlocked && !absorbed.transientExternal;
8666
9188
  switch (toolName) {
8667
9189
  case "mobile_snapshot": {
8668
9190
  const snap = await driver.snapshot();
8669
- const resolved = ingest(snap.xml, snap.screenSignal, snap.windowSize);
8670
- if (!resolved)
9191
+ const absorbed = await absorbObservation(snap.xml, snap.screenSignal, snap.windowSize, snap.screenshot, "mobile_snapshot");
9192
+ if (!absorbed.resolved)
8671
9193
  return { ok: false, error: "snapshot returned no UI tree" };
8672
- log2(` snapshot: ${resolved.screen.elements.length} elements`);
8673
- return { observation: resolved.observation };
9194
+ log2(` snapshot: ${absorbed.resolved.screen.elements.length} elements`);
9195
+ return {
9196
+ observation: absorbed.observation,
9197
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
9198
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
9199
+ };
8674
9200
  }
8675
9201
  case "mobile_tap": {
8676
9202
  const element = resolveRef(toolArgs.ref);
8677
9203
  if (!element)
8678
9204
  return unknownRef(toolArgs.ref);
9205
+ const beforeScreenName = screenName();
8679
9206
  const result = await driver.tap(element.bounds);
8680
- recordAction({
8681
- type: "TAP",
8682
- selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
8683
- metadata: metadataForElement(element, screenName(), platform3)
8684
- });
8685
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
8686
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9207
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_tap");
9208
+ if (shouldRecordAction(result, absorbed)) {
9209
+ recordAction({
9210
+ type: "TAP",
9211
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9212
+ metadata: metadataForElement(element, beforeScreenName, platform3)
9213
+ });
9214
+ }
9215
+ return actionPayload(result, absorbed);
8687
9216
  }
8688
9217
  case "mobile_type": {
8689
9218
  const element = resolveRef(toolArgs.ref);
8690
9219
  if (!element)
8691
9220
  return unknownRef(toolArgs.ref);
8692
9221
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
9222
+ const beforeScreenName = screenName();
8693
9223
  const result = await driver.type(value, element.bounds);
8694
- recordAction({
8695
- type: "TYPE",
8696
- value,
8697
- selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
8698
- metadata: metadataForElement(element, screenName(), platform3)
8699
- });
8700
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
8701
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9224
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_type");
9225
+ if (shouldRecordAction(result, absorbed)) {
9226
+ recordAction({
9227
+ type: "TYPE",
9228
+ value,
9229
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9230
+ metadata: metadataForElement(element, beforeScreenName, platform3)
9231
+ });
9232
+ }
9233
+ return actionPayload(result, absorbed);
8702
9234
  }
8703
9235
  case "mobile_clear": {
8704
9236
  const element = resolveRef(toolArgs.ref);
8705
9237
  if (!element)
8706
9238
  return unknownRef(toolArgs.ref);
9239
+ const beforeScreenName = screenName();
8707
9240
  const result = await driver.clear(element.bounds);
8708
- recordAction({
8709
- type: "CLEAR",
8710
- selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
8711
- metadata: metadataForElement(element, screenName(), platform3)
8712
- });
8713
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
8714
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9241
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_clear");
9242
+ if (shouldRecordAction(result, absorbed)) {
9243
+ recordAction({
9244
+ type: "CLEAR",
9245
+ selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
9246
+ metadata: metadataForElement(element, beforeScreenName, platform3)
9247
+ });
9248
+ }
9249
+ return actionPayload(result, absorbed);
8715
9250
  }
8716
9251
  case "mobile_swipe": {
8717
9252
  const direction = toolArgs.direction;
@@ -8719,30 +9254,42 @@ ${statePacket}` },
8719
9254
  return { ok: false, error: "direction must be one of up|down|left|right" };
8720
9255
  }
8721
9256
  const distance = typeof toolArgs.distance === "number" ? toolArgs.distance : void 0;
9257
+ const beforeScreenName = screenName();
8722
9258
  const result = await driver.swipe(direction, distance);
8723
- recordAction({
8724
- type: "SWIPE",
8725
- metadata: {
8726
- screenName: screenName(),
8727
- direction,
8728
- ...distance !== void 0 ? { distance } : {}
8729
- }
8730
- });
8731
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
8732
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9259
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_swipe");
9260
+ if (shouldRecordAction(result, absorbed)) {
9261
+ recordAction({
9262
+ type: "SWIPE",
9263
+ metadata: {
9264
+ screenName: beforeScreenName,
9265
+ direction,
9266
+ ...distance !== void 0 ? { distance } : {}
9267
+ }
9268
+ });
9269
+ }
9270
+ return actionPayload(result, absorbed);
8733
9271
  }
8734
9272
  case "mobile_back": {
9273
+ const beforeScreenName = screenName();
8735
9274
  const result = await driver.back();
8736
- recordAction({ type: "BACK", metadata: { screenName: screenName() } });
8737
- const resolved = ingest(result.source, void 0, latest()?.screen.windowSize ?? null);
8738
- return { ok: result.ok, ...result.error ? { error: result.error } : {}, observation: resolved?.observation };
9275
+ const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_back");
9276
+ if (shouldRecordAction(result, absorbed)) {
9277
+ recordAction({ type: "BACK", metadata: { screenName: beforeScreenName } });
9278
+ }
9279
+ return actionPayload(result, absorbed);
8739
9280
  }
8740
9281
  case "mobile_relaunch": {
8741
9282
  await driver.relaunch();
8742
9283
  const snap = await driver.snapshot();
8743
- const resolved = ingest(snap.xml, snap.screenSignal, snap.windowSize);
9284
+ const absorbed = await absorbObservation(snap.xml, snap.screenSignal, snap.windowSize, snap.screenshot, "mobile_relaunch");
8744
9285
  log2(" relaunched app for hermetic reset.");
8745
- return { ok: true, observation: resolved?.observation, note: "App relaunched (clean state). Not recorded as a step." };
9286
+ return {
9287
+ ok: true,
9288
+ observation: absorbed.observation,
9289
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
9290
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
9291
+ note: "App relaunched (clean state). Not recorded as a step."
9292
+ };
8746
9293
  }
8747
9294
  case "mobile_assert_visible": {
8748
9295
  const recorded = recordAssertVisible(toolArgs, { resolveRef, screenName, platform: platform3, recordAction });
@@ -8880,7 +9427,7 @@ ${statePacket}` },
8880
9427
  return test;
8881
9428
  }
8882
9429
  async function runMobileGeneratePhase(args) {
8883
- const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated } = args;
9430
+ const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot } = args;
8884
9431
  const log2 = (line) => {
8885
9432
  try {
8886
9433
  onLog?.(line);
@@ -8894,6 +9441,7 @@ ${statePacket}` },
8894
9441
  const provider = (0, ai_provider_js_1.getProviderForModel)(agentModel);
8895
9442
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
8896
9443
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
9444
+ const targetAppId = ctx.target?.appId?.trim() || void 0;
8897
9445
  log2(`Mobile generator starting \u2014 platform: ${platform3}, provider: ${provider}, model: ${agentModel}, scenarios: ${scenarios.length}`);
8898
9446
  const tests = [];
8899
9447
  for (let i = 0; i < scenarios.length; i++) {
@@ -8912,6 +9460,8 @@ ${statePacket}` },
8912
9460
  aiClient,
8913
9461
  auditGroupId,
8914
9462
  onAICall,
9463
+ onScreenshot,
9464
+ targetAppId,
8915
9465
  log: log2
8916
9466
  });
8917
9467
  const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
@@ -8919,13 +9469,17 @@ ${statePacket}` },
8919
9469
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
8920
9470
  continue;
8921
9471
  }
8922
- tests.push(test);
8923
9472
  log2(` \u2713 generated "${test.name}" (verified: ${test.verified}, steps: ${test.steps?.length ?? 0})`);
8924
9473
  try {
8925
- onTestGenerated?.(test);
9474
+ await onTestGenerated?.(test);
8926
9475
  } catch (cbErr) {
9476
+ if (cbErr?.isPlanLimitReached) {
9477
+ log2(" Plan limit reached \u2014 stopping further generation");
9478
+ break;
9479
+ }
8927
9480
  log2(` onTestGenerated callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
8928
9481
  }
9482
+ tests.push(test);
8929
9483
  } catch (err) {
8930
9484
  log2(` scenario "${scenario.name}" failed: ${err instanceof Error ? err.message : String(err)} \u2014 skipping.`);
8931
9485
  continue;
@@ -12606,6 +13160,17 @@ var require_snapshot_observation = __commonJS({
12606
13160
  for (const container of group.containers) {
12607
13161
  lines.push(` - ${yamlScalar(container ?? "(none)")}`);
12608
13162
  }
13163
+ const hasRowContext = group.rowContexts.some((rc) => !!rc);
13164
+ if (hasRowContext) {
13165
+ lines.push(' distinguish_by_row: # same name in different rows \u2014 scope by this text, e.g. getByRole(role, { name }).filter({ has: getByText("<row>") })');
13166
+ for (let i = 0; i < group.rowContexts.length; i++) {
13167
+ const rowContext = group.rowContexts[i];
13168
+ if (!rowContext)
13169
+ continue;
13170
+ const ref = group.refs[i];
13171
+ lines.push(` - ${yamlScalar(`${ref ? `[${ref}] ` : ""}${rowContext}`)}`);
13172
+ }
13173
+ }
12609
13174
  }
12610
13175
  }
12611
13176
  lines.push("interactive_elements:");
@@ -12637,6 +13202,29 @@ var require_snapshot_observation = __commonJS({
12637
13202
  if (topLocator) {
12638
13203
  lines.push(` locator: ${yamlScalar((0, snapshot_parser_js_1.formatLocator)(topLocator))}`);
12639
13204
  }
13205
+ const enr = rankedElement.element.enrichment;
13206
+ if (enr?.rowContext) {
13207
+ lines.push(` row_context: ${yamlScalar(enr.rowContext)}`);
13208
+ }
13209
+ if (enr && (enr.occluded || enr.position && enr.position !== "in-view")) {
13210
+ const vis = [];
13211
+ if (enr.position && enr.position !== "in-view")
13212
+ vis.push(`off-screen-${enr.position}`);
13213
+ if (enr.occluded)
13214
+ vis.push("occluded");
13215
+ lines.push(` visibility: [${vis.map((token) => yamlScalar(token)).join(", ")}]`);
13216
+ }
13217
+ }
13218
+ }
13219
+ const recoveredClickables = options.recoveredClickables ?? [];
13220
+ if (recoveredClickables.length > 0) {
13221
+ lines.push("non_semantic_clickables:");
13222
+ 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.');
13223
+ for (const clickable of recoveredClickables.slice(0, 8)) {
13224
+ lines.push(` - text: ${yamlScalar(clickable.text)}`);
13225
+ if (clickable.rowContext) {
13226
+ lines.push(` row_context: ${yamlScalar(clickable.rowContext)}`);
13227
+ }
12640
13228
  }
12641
13229
  }
12642
13230
  lines.push("observation_cues:");
@@ -12810,6 +13398,9 @@ var require_snapshot_observation = __commonJS({
12810
13398
  score += 12;
12811
13399
  }
12812
13400
  }
13401
+ const enr = element.enrichment;
13402
+ if (enr?.rowContext)
13403
+ score += 4;
12813
13404
  return score;
12814
13405
  }
12815
13406
  function buildStateSummary(element) {
@@ -12985,10 +13576,12 @@ var require_snapshot_observation = __commonJS({
12985
13576
  const key = `${role}\0${name}`;
12986
13577
  let group = groups.get(key);
12987
13578
  if (!group) {
12988
- group = { role, name, containers: [] };
13579
+ group = { role, name, containers: [], rowContexts: [], refs: [] };
12989
13580
  groups.set(key, group);
12990
13581
  }
12991
13582
  group.containers.push(element.containerPath ?? null);
13583
+ group.rowContexts.push(element.enrichment?.rowContext ?? null);
13584
+ group.refs.push(element.ref ?? null);
12992
13585
  }
12993
13586
  return [...groups.values()].filter((group) => group.containers.length > 1);
12994
13587
  }
@@ -234642,6 +235235,154 @@ var require_preflight_syntax = __commonJS({
234642
235235
  }
234643
235236
  });
234644
235237
 
235238
+ // ../runner-core/dist/services/selector-registry-context.js
235239
+ var require_selector_registry_context = __commonJS({
235240
+ "../runner-core/dist/services/selector-registry-context.js"(exports2) {
235241
+ "use strict";
235242
+ Object.defineProperty(exports2, "__esModule", { value: true });
235243
+ exports2.formatSelectorRegistryContextForPrompt = formatSelectorRegistryContextForPrompt;
235244
+ function isRecord(value) {
235245
+ return value !== null && typeof value === "object" && !Array.isArray(value);
235246
+ }
235247
+ function asString(value, fallback = "") {
235248
+ return typeof value === "string" ? value : fallback;
235249
+ }
235250
+ function asNumber(value, fallback = 0) {
235251
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
235252
+ }
235253
+ function asStringArray(value) {
235254
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
235255
+ }
235256
+ function truncateForSelectorPrompt(value, maxLength = 180) {
235257
+ const normalized = (value ?? "").replace(/\s+/g, " ").trim();
235258
+ if (normalized.length <= maxLength)
235259
+ return normalized;
235260
+ return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
235261
+ }
235262
+ function inlineCode(value, maxLength = 180) {
235263
+ return `\`${truncateForSelectorPrompt(value, maxLength).replace(/`/g, "'")}\``;
235264
+ }
235265
+ function normalizeRef(value) {
235266
+ if (!isRecord(value))
235267
+ return null;
235268
+ const expression = asString(value.expression).trim();
235269
+ if (!expression)
235270
+ return null;
235271
+ return {
235272
+ route: asString(value.route, "*"),
235273
+ usage: asString(value.usage, "locator"),
235274
+ source: asString(value.source, "generated"),
235275
+ expression,
235276
+ occurrenceIndex: asNumber(value.occurrenceIndex, 0)
235277
+ };
235278
+ }
235279
+ function normalizeSibling(value) {
235280
+ if (!isRecord(value))
235281
+ return null;
235282
+ const testCaseId = asString(value.testCaseId).trim();
235283
+ const expression = asString(value.expression).trim();
235284
+ if (!testCaseId || !expression)
235285
+ return null;
235286
+ return {
235287
+ testCaseId,
235288
+ name: asString(value.name, testCaseId),
235289
+ suite: typeof value.suite === "string" ? value.suite : null,
235290
+ lastPassedAt: typeof value.lastPassedAt === "string" ? value.lastPassedAt : null,
235291
+ expression,
235292
+ usage: asString(value.usage, "locator"),
235293
+ route: asString(value.route, "*"),
235294
+ source: asString(value.source, "generated")
235295
+ };
235296
+ }
235297
+ function normalizeEntry(value) {
235298
+ if (!isRecord(value))
235299
+ return null;
235300
+ const id = asString(value.id).trim();
235301
+ const expression = asString(value.expression).trim();
235302
+ if (!id || !expression)
235303
+ return null;
235304
+ return {
235305
+ id,
235306
+ key: asString(value.key, id),
235307
+ route: asString(value.route, "*"),
235308
+ scope: asString(value.scope, "page"),
235309
+ status: asString(value.status, "ACTIVE"),
235310
+ stability: asNumber(value.stability, 1),
235311
+ label: asString(value.label),
235312
+ locatorKind: asString(value.locatorKind),
235313
+ locatorRole: typeof value.locatorRole === "string" ? value.locatorRole : null,
235314
+ locatorName: typeof value.locatorName === "string" ? value.locatorName : null,
235315
+ locatorTestId: typeof value.locatorTestId === "string" ? value.locatorTestId : null,
235316
+ expression,
235317
+ fallbacks: asStringArray(value.fallbacks),
235318
+ currentTestRefs: Array.isArray(value.currentTestRefs) ? value.currentTestRefs.map(normalizeRef).filter((ref) => Boolean(ref)) : [],
235319
+ refCount: asNumber(value.refCount, 0),
235320
+ testCount: asNumber(value.testCount, 0),
235321
+ siblingFanoutCount: asNumber(value.siblingFanoutCount, 0),
235322
+ usageFamilies: asStringArray(value.usageFamilies),
235323
+ riskFlags: asStringArray(value.riskFlags),
235324
+ lastHealedAt: typeof value.lastHealedAt === "string" ? value.lastHealedAt : null,
235325
+ recentPassingSiblings: Array.isArray(value.recentPassingSiblings) ? value.recentPassingSiblings.map(normalizeSibling).filter((sibling) => Boolean(sibling)) : [],
235326
+ recentFailingSiblingCount: asNumber(value.recentFailingSiblingCount, 0)
235327
+ };
235328
+ }
235329
+ function formatSelectorRegistryContextForPrompt(context) {
235330
+ if (!isRecord(context))
235331
+ return "";
235332
+ const entries = Array.isArray(context.entries) ? context.entries.map(normalizeEntry).filter((entry) => Boolean(entry)) : [];
235333
+ if (entries.length === 0)
235334
+ return "";
235335
+ const summary = isRecord(context.summary) ? context.summary : null;
235336
+ const parts = [
235337
+ "## Selector Registry Memory (read-only evidence)",
235338
+ "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."
235339
+ ];
235340
+ if (summary) {
235341
+ const selectorCount = asNumber(summary.selectorCount, entries.length);
235342
+ const sharedSelectorCount = asNumber(summary.sharedSelectorCount, entries.filter((entry) => entry.testCount > 1).length);
235343
+ const maxSharedTestCount = asNumber(summary.maxSharedTestCount, Math.max(...entries.map((entry) => entry.testCount), 0));
235344
+ parts.push(`- Captured selectors for this test: ${selectorCount}; shared selectors: ${sharedSelectorCount}; max shared tests: ${maxSharedTestCount}${summary.truncated ? "; list truncated" : ""}.`);
235345
+ }
235346
+ for (const entry of entries.slice(0, 8)) {
235347
+ const risk = entry.riskFlags.length > 0 ? entry.riskFlags.join(", ") : "none";
235348
+ const families = entry.usageFamilies.length > 0 ? entry.usageFamilies.join(", ") : "locator";
235349
+ parts.push("");
235350
+ parts.push(`### ${truncateForSelectorPrompt(entry.label || entry.key, 90)}`);
235351
+ parts.push(`- Selector: ${inlineCode(entry.expression, 220)}`);
235352
+ parts.push(`- Route/scope/kind: ${entry.route} / ${entry.scope} / ${entry.locatorKind || "unknown"}; usage families: ${families}.`);
235353
+ parts.push(`- Shared by: ${entry.testCount} test(s), ${entry.siblingFanoutCount} sibling(s), ${entry.refCount} ref(s); risks: ${risk}; status: ${entry.status}; stability: ${entry.stability}.`);
235354
+ if (entry.lastHealedAt)
235355
+ parts.push(`- Last registry heal: ${entry.lastHealedAt}.`);
235356
+ const currentRefs = entry.currentTestRefs.slice(0, 3);
235357
+ if (currentRefs.length > 0) {
235358
+ parts.push("- Current test occurrences:");
235359
+ for (const ref of currentRefs) {
235360
+ parts.push(` - ${ref.usage} on ${ref.route} from ${ref.source}: ${inlineCode(ref.expression, 180)}`);
235361
+ }
235362
+ }
235363
+ const siblings = entry.recentPassingSiblings.slice(0, 3);
235364
+ if (siblings.length > 0) {
235365
+ parts.push("- Recent passing sibling tests using this registry entry:");
235366
+ for (const sibling of siblings) {
235367
+ const suite = sibling.suite ? ` (${truncateForSelectorPrompt(sibling.suite, 50)})` : "";
235368
+ const passed = sibling.lastPassedAt ? ` passed ${sibling.lastPassedAt}` : "";
235369
+ parts.push(` - ${truncateForSelectorPrompt(sibling.name, 80)}${suite}${passed}: ${inlineCode(sibling.expression, 180)}`);
235370
+ }
235371
+ }
235372
+ if (entry.recentFailingSiblingCount && entry.recentFailingSiblingCount > 0) {
235373
+ parts.push(`- Other recent failing siblings on this entry: ${entry.recentFailingSiblingCount}.`);
235374
+ }
235375
+ const fallbacks = (entry.fallbacks ?? []).slice(0, 3);
235376
+ if (fallbacks.length > 0) {
235377
+ parts.push(`- Registry fallbacks: ${fallbacks.map((fallback) => inlineCode(fallback, 120)).join(", ")}.`);
235378
+ }
235379
+ }
235380
+ return `${parts.join("\n")}
235381
+ `;
235382
+ }
235383
+ }
235384
+ });
235385
+
234645
235386
  // ../runner-core/dist/services/mcp-healer.js
234646
235387
  var require_mcp_healer = __commonJS({
234647
235388
  "../runner-core/dist/services/mcp-healer.js"(exports2) {
@@ -234736,6 +235477,7 @@ var require_mcp_healer = __commonJS({
234736
235477
  var snapshot_context_compactor_js_1 = require_snapshot_context_compactor();
234737
235478
  var rate_limit_detector_js_1 = require_rate_limit_detector();
234738
235479
  var preflight_syntax_js_1 = require_preflight_syntax();
235480
+ var selector_registry_context_js_1 = require_selector_registry_context();
234739
235481
  function stripCodeFences(code) {
234740
235482
  let cleaned = code.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
234741
235483
  cleaned = cleaned.replace(/^\s*```(?:typescript|ts|javascript|js)?\s*\n?/, "").replace(/\n?\s*```\s*$/, "");
@@ -235297,7 +236039,7 @@ ${lines.join("\n")}
235297
236039
  }).join("\n");
235298
236040
  }
235299
236041
  async function executeScriptDrivenHeal(playwrightCode, baseUrl, options) {
235300
- const { testName, testVariables, surfaceIntelligence, healingContext, lastFailureError, failureScreenshot, lastHealAttempt, consecutiveFails, modelOverride, onAICall, stepResults, tags } = options;
236042
+ const { testName, testVariables, surfaceIntelligence, healingContext, selectorRegistryContext, lastFailureError, failureScreenshot, lastHealAttempt, consecutiveFails, modelOverride, onAICall, stepResults, tags } = options;
235301
236043
  const landingViolation = (tags ?? []).includes("@landing-violation");
235302
236044
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "strong") : (0, ai_provider_js_1.getModel)("strong");
235303
236045
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
@@ -235360,6 +236102,11 @@ ${lastHealAttempt.healReason.slice(0, 800)}`);
235360
236102
  if (healingContext) {
235361
236103
  parts.push(`
235362
236104
  ${formatHealingContextForPrompt(healingContext)}`);
236105
+ }
236106
+ const selectorRegistryPrompt = (0, selector_registry_context_js_1.formatSelectorRegistryContextForPrompt)(selectorRegistryContext);
236107
+ if (selectorRegistryPrompt) {
236108
+ parts.push(`
236109
+ ${selectorRegistryPrompt}`);
235363
236110
  }
235364
236111
  if (surfaceIntelligence) {
235365
236112
  parts.push(`
@@ -235967,6 +236714,7 @@ ${(verifyResult.logs ?? "").slice(0, 2e3)}`;
235967
236714
  testVariables: options?.testVariables,
235968
236715
  surfaceIntelligence: options?.surfaceIntelligence,
235969
236716
  healingContext: options?.healingContext,
236717
+ selectorRegistryContext: options?.selectorRegistryContext,
235970
236718
  lastFailureError: options?.lastFailureError,
235971
236719
  failureScreenshot: options?.failureScreenshot,
235972
236720
  lastHealAttempt: options?.lastHealAttempt,
@@ -236507,6 +237255,7 @@ ${publicLines}` : ""}${credBlock}
236507
237255
  `;
236508
237256
  })()}
236509
237257
  ${options?.healingContext ? `${formatHealingContextForPrompt(options.healingContext)}` : ""}
237258
+ ${(0, selector_registry_context_js_1.formatSelectorRegistryContextForPrompt)(options?.selectorRegistryContext)}
236510
237259
  ${surfaceIntelligence ? `## Original Recorded Actions (what the test was trying to do)
236511
237260
  ${surfaceIntelligence.slice(0, 1500)}
236512
237261
  ` : ""}${landingViolation ? `## STRUCTURAL REQUIREMENT
@@ -238553,6 +239302,7 @@ var require_grok_per_test_healer = __commonJS({
238553
239302
  "use strict";
238554
239303
  Object.defineProperty(exports2, "__esModule", { value: true });
238555
239304
  exports2.executeGrokPerTestHeal = executeGrokPerTestHeal2;
239305
+ exports2.buildHealBrief = buildHealBrief;
238556
239306
  var node_child_process_1 = require("child_process");
238557
239307
  var promises_1 = require("fs/promises");
238558
239308
  var node_os_1 = require("os");
@@ -238569,6 +239319,7 @@ var require_grok_per_test_healer = __commonJS({
238569
239319
  var playwright_mcp_config_js_1 = require_playwright_mcp_config();
238570
239320
  var heal_session_tailer_js_1 = require_heal_session_tailer();
238571
239321
  var proven_actions_adapter_js_1 = require_proven_actions_adapter();
239322
+ var selector_registry_context_js_1 = require_selector_registry_context();
238572
239323
  var HEAL_MCP_CAPABILITIES = ["core", "core-navigation", "core-input", "core-tabs", "testing"];
238573
239324
  var grok_cli_js_1 = require_grok_cli();
238574
239325
  var DEFAULT_MAX_ATTEMPTS = 3;
@@ -238674,6 +239425,7 @@ var require_grok_per_test_healer = __commonJS({
238674
239425
  lastDiagnosis,
238675
239426
  publicVars: publics,
238676
239427
  secretKeys: Object.keys(secrets),
239428
+ selectorRegistryContext: options.selectorRegistryContext ?? null,
238677
239429
  attempt,
238678
239430
  maxAttempts
238679
239431
  }), "utf8");
@@ -238901,6 +239653,7 @@ ${input.lastDiagnosis ? `
238901
239653
  ## Your prior diagnosis (do not repeat the same fix)
238902
239654
  ${input.lastDiagnosis}
238903
239655
  ` : ""}
239656
+ ${(0, selector_registry_context_js_1.formatSelectorRegistryContextForPrompt)(input.selectorRegistryContext)}
238904
239657
 
238905
239658
  ## Tools available
238906
239659
  - **bash** \u2014 \`curl\`, \`sed\`, \`grep\` for static inspection of the app under test.
@@ -299615,6 +300368,699 @@ var require_capture_page_normalizer = __commonJS({
299615
300368
  }
299616
300369
  });
299617
300370
 
300371
+ // ../runner-core/dist/services/perception-enricher.js
300372
+ var require_perception_enricher = __commonJS({
300373
+ "../runner-core/dist/services/perception-enricher.js"(exports2) {
300374
+ "use strict";
300375
+ Object.defineProperty(exports2, "__esModule", { value: true });
300376
+ exports2.enrichCapturedPage = enrichCapturedPage;
300377
+ exports2.mergePerceptionOntoElements = mergePerceptionOntoElements;
300378
+ exports2.elementRoleClass = elementRoleClass;
300379
+ exports2.normalizeName = normalizeName;
300380
+ exports2.urlMatchesRoute = urlMatchesRoute;
300381
+ var DEFAULT_CONFIG = {
300382
+ maxCandidates: 220,
300383
+ maxScan: 4e3,
300384
+ recoverNonSemantic: false,
300385
+ budgetMs: 1500
300386
+ };
300387
+ var BLACKOUT_THRESHOLD = 8;
300388
+ var EVALUATE_TIMEOUT_MS = 1800;
300389
+ var MAX_RECOVERED = 12;
300390
+ async function enrichCapturedPage(page, input, opts = {}) {
300391
+ const empty = { recovered: [], enriched: 0, perceived: 0 };
300392
+ if (!page)
300393
+ return { ...empty, skipReason: "no-page" };
300394
+ try {
300395
+ const url = page.url();
300396
+ if (url && input.route && !urlMatchesRoute(url, input.route)) {
300397
+ opts.onLog?.(`[perception] skip: page ${shortUrl(url)} != captured route ${input.route}`);
300398
+ return { ...empty, skipReason: "route-mismatch" };
300399
+ }
300400
+ } catch {
300401
+ }
300402
+ const elements = input.elements ?? [];
300403
+ const parsedInteractive = input.parsedInteractiveCount ?? elements.length;
300404
+ const cfg = {
300405
+ ...DEFAULT_CONFIG,
300406
+ recoverNonSemantic: parsedInteractive < BLACKOUT_THRESHOLD
300407
+ };
300408
+ let perceived;
300409
+ try {
300410
+ const raw = await withTimeout(page.evaluate(collectPerception, cfg), opts.timeoutMs ?? EVALUATE_TIMEOUT_MS);
300411
+ if (!Array.isArray(raw))
300412
+ return { ...empty, skipReason: "bad-result" };
300413
+ perceived = raw;
300414
+ } catch (err) {
300415
+ opts.onLog?.(`[perception] evaluate failed \u2014 ${err instanceof Error ? err.message : String(err)}`);
300416
+ return { ...empty, skipReason: "evaluate-error" };
300417
+ }
300418
+ const { enriched } = mergePerceptionOntoElements(elements, perceived);
300419
+ const recovered = collectRecovered(perceived);
300420
+ opts.onLog?.(`[perception] enriched ${enriched}/${elements.length} elements, ${recovered.length} non-semantic clickables (${perceived.length} perceived)`);
300421
+ return { recovered, enriched, perceived: perceived.length };
300422
+ }
300423
+ function mergePerceptionOntoElements(elements, perceived) {
300424
+ const perceivedGroups = /* @__PURE__ */ new Map();
300425
+ for (const p of perceived) {
300426
+ if (p.nonSemantic || !p.roleClass || !p.name)
300427
+ continue;
300428
+ const key = `${p.roleClass}\0${p.name}`;
300429
+ let arr = perceivedGroups.get(key);
300430
+ if (!arr) {
300431
+ arr = [];
300432
+ perceivedGroups.set(key, arr);
300433
+ }
300434
+ arr.push(p);
300435
+ }
300436
+ for (const arr of perceivedGroups.values())
300437
+ arr.sort((a, b) => a.domOrder - b.domOrder);
300438
+ const elementGroups = /* @__PURE__ */ new Map();
300439
+ for (const el of elements) {
300440
+ const roleClass = elementRoleClass(el);
300441
+ const name = normalizeName(el.label);
300442
+ if (!roleClass || !name)
300443
+ continue;
300444
+ const key = `${roleClass}\0${name}`;
300445
+ let arr = elementGroups.get(key);
300446
+ if (!arr) {
300447
+ arr = [];
300448
+ elementGroups.set(key, arr);
300449
+ }
300450
+ arr.push(el);
300451
+ }
300452
+ let enriched = 0;
300453
+ for (const [key, els] of elementGroups) {
300454
+ const ps = perceivedGroups.get(key);
300455
+ if (!ps)
300456
+ continue;
300457
+ if (ps.length !== els.length)
300458
+ continue;
300459
+ for (let i = 0; i < els.length; i++) {
300460
+ const enrichment = toEnrichment(ps[i]);
300461
+ if (enrichment) {
300462
+ els[i].enrichment = enrichment;
300463
+ enriched++;
300464
+ }
300465
+ }
300466
+ }
300467
+ return { enriched };
300468
+ }
300469
+ function toEnrichment(p) {
300470
+ const e = {};
300471
+ if (p.rowContext)
300472
+ e.rowContext = p.rowContext;
300473
+ if (p.position && p.position !== "in-view")
300474
+ e.position = p.position;
300475
+ if (p.occluded)
300476
+ e.occluded = true;
300477
+ if (p.cursorPointer)
300478
+ e.cursorPointer = true;
300479
+ return Object.keys(e).length > 0 ? e : null;
300480
+ }
300481
+ function collectRecovered(perceived) {
300482
+ const out = [];
300483
+ const seen = /* @__PURE__ */ new Set();
300484
+ for (const p of perceived) {
300485
+ if (!p.nonSemantic || !p.text)
300486
+ continue;
300487
+ const key = `${p.text}\0${p.rowContext ?? ""}`;
300488
+ if (seen.has(key))
300489
+ continue;
300490
+ seen.add(key);
300491
+ const rc = { text: p.text };
300492
+ if (p.rowContext)
300493
+ rc.rowContext = p.rowContext;
300494
+ out.push(rc);
300495
+ if (out.length >= MAX_RECOVERED)
300496
+ break;
300497
+ }
300498
+ return out;
300499
+ }
300500
+ function elementRoleClass(el) {
300501
+ const r = (el.role ?? "").toLowerCase().trim();
300502
+ const mapped = roleBucket(r);
300503
+ if (mapped)
300504
+ return mapped;
300505
+ const t = (el.elementType ?? "").toLowerCase();
300506
+ if (t === "link")
300507
+ return "link";
300508
+ if (t === "button")
300509
+ return "button";
300510
+ if (t.startsWith("input")) {
300511
+ const it = (el.inputType ?? "").toLowerCase();
300512
+ if (it === "checkbox")
300513
+ return "checkbox";
300514
+ if (it === "radio")
300515
+ return "radio";
300516
+ if (it === "submit" || it === "button")
300517
+ return "button";
300518
+ return "textbox";
300519
+ }
300520
+ return t || "";
300521
+ }
300522
+ function roleBucket(r) {
300523
+ if (!r)
300524
+ return null;
300525
+ if (r === "link")
300526
+ return "link";
300527
+ if (r === "button")
300528
+ return "button";
300529
+ if (r === "textbox" || r === "searchbox" || r === "spinbutton")
300530
+ return "textbox";
300531
+ if (r === "combobox" || r === "listbox")
300532
+ return "combobox";
300533
+ if (r === "checkbox" || r === "switch")
300534
+ return "checkbox";
300535
+ if (r === "radio")
300536
+ return "radio";
300537
+ if (r === "tab")
300538
+ return "tab";
300539
+ if (r === "menuitem" || r === "menuitemcheckbox" || r === "menuitemradio")
300540
+ return "menuitem";
300541
+ if (r === "option")
300542
+ return "option";
300543
+ if (r === "slider")
300544
+ return "slider";
300545
+ return r;
300546
+ }
300547
+ function normalizeName(name) {
300548
+ return (name ?? "").replace(/\s+/g, " ").trim().toLowerCase().slice(0, 80);
300549
+ }
300550
+ function withTimeout(p, ms) {
300551
+ return new Promise((resolve, reject) => {
300552
+ const t = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms);
300553
+ p.then((v) => {
300554
+ clearTimeout(t);
300555
+ resolve(v);
300556
+ }, (e) => {
300557
+ clearTimeout(t);
300558
+ reject(e);
300559
+ });
300560
+ });
300561
+ }
300562
+ function parseLoc(input) {
300563
+ try {
300564
+ const u = new URL(input, "http://__rel__");
300565
+ return {
300566
+ origin: u.host === "__rel__" ? "" : u.host,
300567
+ // '' when input was path-relative
300568
+ path: (u.pathname || "/").replace(/\/+$/, "") || "/",
300569
+ search: u.search,
300570
+ hash: u.hash
300571
+ };
300572
+ } catch {
300573
+ const hashI = input.indexOf("#");
300574
+ const hash = hashI >= 0 ? input.slice(hashI) : "";
300575
+ const beforeHash = hashI >= 0 ? input.slice(0, hashI) : input;
300576
+ const qi = beforeHash.indexOf("?");
300577
+ const search = qi >= 0 ? beforeHash.slice(qi) : "";
300578
+ const path = ((qi >= 0 ? beforeHash.slice(0, qi) : beforeHash) || "/").replace(/\/+$/, "") || "/";
300579
+ return { origin: "", path, search, hash };
300580
+ }
300581
+ }
300582
+ function urlMatchesRoute(url, route2) {
300583
+ const u = parseLoc(url);
300584
+ const r = parseLoc(route2);
300585
+ if (u.path !== r.path)
300586
+ return false;
300587
+ if (r.origin && u.origin && r.origin !== u.origin)
300588
+ return false;
300589
+ if (r.search && u.search !== r.search)
300590
+ return false;
300591
+ if (r.hash && u.hash !== r.hash)
300592
+ return false;
300593
+ return true;
300594
+ }
300595
+ function shortUrl(url) {
300596
+ return url.length > 80 ? `${url.slice(0, 77)}...` : url;
300597
+ }
300598
+ function collectPerception(cfg) {
300599
+ const NATIVE = {
300600
+ A: true,
300601
+ BUTTON: true,
300602
+ INPUT: true,
300603
+ SELECT: true,
300604
+ TEXTAREA: true,
300605
+ SUMMARY: true,
300606
+ OPTION: true
300607
+ };
300608
+ const INTERACTIVE_ROLE = {
300609
+ button: true,
300610
+ link: true,
300611
+ textbox: true,
300612
+ searchbox: true,
300613
+ spinbutton: true,
300614
+ combobox: true,
300615
+ listbox: true,
300616
+ checkbox: true,
300617
+ radio: true,
300618
+ switch: true,
300619
+ menuitem: true,
300620
+ menuitemcheckbox: true,
300621
+ menuitemradio: true,
300622
+ tab: true,
300623
+ option: true,
300624
+ slider: true
300625
+ };
300626
+ const norm = (s) => (s || "").replace(/\s+/g, " ").trim();
300627
+ const lower = (s) => s.toLowerCase().slice(0, 80);
300628
+ const roleClassOf = (tag, roleAttr, inputType) => {
300629
+ const r = roleAttr.toLowerCase();
300630
+ if (r) {
300631
+ if (r === "link")
300632
+ return "link";
300633
+ if (r === "button")
300634
+ return "button";
300635
+ if (r === "textbox" || r === "searchbox" || r === "spinbutton")
300636
+ return "textbox";
300637
+ if (r === "combobox" || r === "listbox")
300638
+ return "combobox";
300639
+ if (r === "switch" || r === "checkbox")
300640
+ return "checkbox";
300641
+ if (r === "radio")
300642
+ return "radio";
300643
+ if (r === "tab")
300644
+ return "tab";
300645
+ if (r === "menuitem" || r === "menuitemcheckbox" || r === "menuitemradio")
300646
+ return "menuitem";
300647
+ if (r === "option")
300648
+ return "option";
300649
+ if (r === "slider")
300650
+ return "slider";
300651
+ return r;
300652
+ }
300653
+ if (tag === "A")
300654
+ return "link";
300655
+ if (tag === "BUTTON" || tag === "SUMMARY")
300656
+ return "button";
300657
+ if (tag === "SELECT")
300658
+ return "combobox";
300659
+ if (tag === "TEXTAREA")
300660
+ return "textbox";
300661
+ if (tag === "OPTION")
300662
+ return "option";
300663
+ if (tag === "INPUT") {
300664
+ const t = (inputType || "text").toLowerCase();
300665
+ if (t === "checkbox")
300666
+ return "checkbox";
300667
+ if (t === "radio")
300668
+ return "radio";
300669
+ if (t === "submit" || t === "button" || t === "image" || t === "reset")
300670
+ return "button";
300671
+ if (t === "range")
300672
+ return "slider";
300673
+ return "textbox";
300674
+ }
300675
+ return "other";
300676
+ };
300677
+ const accName = (el) => {
300678
+ const al = norm(el.getAttribute("aria-label"));
300679
+ if (al)
300680
+ return al;
300681
+ const lb = el.getAttribute("aria-labelledby");
300682
+ if (lb) {
300683
+ const ids = lb.split(/\s+/);
300684
+ let txt = "";
300685
+ for (let i = 0; i < ids.length; i++) {
300686
+ const t = el.ownerDocument.getElementById(ids[i]);
300687
+ if (t)
300688
+ txt += (txt ? " " : "") + norm(t.textContent);
300689
+ }
300690
+ if (txt)
300691
+ return txt;
300692
+ }
300693
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT") {
300694
+ const id = el.getAttribute("id");
300695
+ if (id) {
300696
+ try {
300697
+ const lab = el.ownerDocument.querySelector('label[for="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]');
300698
+ const t = lab ? norm(lab.textContent) : "";
300699
+ if (t)
300700
+ return t;
300701
+ } catch {
300702
+ }
300703
+ }
300704
+ const val = norm(el.value);
300705
+ if (val)
300706
+ return val;
300707
+ const ph = norm(el.getAttribute("placeholder"));
300708
+ if (ph)
300709
+ return ph;
300710
+ return norm(el.getAttribute("name"));
300711
+ }
300712
+ const text = norm(el.textContent);
300713
+ if (text)
300714
+ return text;
300715
+ return norm(el.getAttribute("title")) || norm(el.getAttribute("alt"));
300716
+ };
300717
+ const ROW_SEL = 'tr,li,[role="row"],[role="listitem"],[role="article"],article,[role="option"]';
300718
+ const isCardish = (el) => {
300719
+ const cls = (el.getAttribute("class") || "").toLowerCase();
300720
+ return /(^|[\s_-])(card|row|item|listitem|cell|tile)([\s_-]|$)/.test(cls);
300721
+ };
300722
+ const rowContextOf = (el, ownNameLower) => {
300723
+ let cur = el.parentElement;
300724
+ let depth = 0;
300725
+ while (cur && depth < 8) {
300726
+ let isRow = false;
300727
+ try {
300728
+ isRow = cur.matches(ROW_SEL);
300729
+ } catch {
300730
+ isRow = false;
300731
+ }
300732
+ if (isRow || isCardish(cur)) {
300733
+ const head = cur.querySelector('h1,h2,h3,h4,h5,h6,[role="heading"],strong,b,th,td');
300734
+ let txt = head ? norm(head.textContent) : "";
300735
+ if (!txt)
300736
+ txt = norm(cur.textContent);
300737
+ txt = txt.slice(0, 80);
300738
+ const low = txt.toLowerCase();
300739
+ if (txt && low !== ownNameLower && low.length > 1)
300740
+ return txt;
300741
+ return null;
300742
+ }
300743
+ cur = cur.parentElement;
300744
+ depth++;
300745
+ }
300746
+ return null;
300747
+ };
300748
+ const vh = window.innerHeight || 0;
300749
+ const vw = window.innerWidth || 0;
300750
+ const visibleRect = (el) => {
300751
+ let rect;
300752
+ let style;
300753
+ try {
300754
+ rect = el.getBoundingClientRect();
300755
+ style = window.getComputedStyle(el);
300756
+ } catch {
300757
+ return null;
300758
+ }
300759
+ if (style.display === "none" || style.visibility === "hidden")
300760
+ return null;
300761
+ if (parseFloat(style.opacity || "1") === 0)
300762
+ return null;
300763
+ if (rect.width <= 0 || rect.height <= 0)
300764
+ return null;
300765
+ try {
300766
+ if (el.closest('[aria-hidden="true"],[inert]'))
300767
+ return null;
300768
+ } catch {
300769
+ }
300770
+ return { rect, style };
300771
+ };
300772
+ const occlusionOf = (el, rect) => {
300773
+ const cx = rect.left + rect.width / 2;
300774
+ const cy = rect.top + rect.height / 2;
300775
+ if (cx < 0 || cy < 0 || cx > vw || cy > vh)
300776
+ return false;
300777
+ try {
300778
+ const hit = document.elementFromPoint(cx, cy);
300779
+ if (!hit || hit === el || el.contains(hit) || hit.contains(el))
300780
+ return false;
300781
+ let anc = hit;
300782
+ let guard = 0;
300783
+ while (anc && guard < 12) {
300784
+ const pos = window.getComputedStyle(anc).position;
300785
+ if (pos === "fixed" || pos === "sticky")
300786
+ return false;
300787
+ anc = anc.parentElement;
300788
+ guard++;
300789
+ }
300790
+ return true;
300791
+ } catch {
300792
+ return false;
300793
+ }
300794
+ };
300795
+ const positionOf = (rect) => rect.bottom < 0 ? "above" : rect.top > vh ? "below" : "in-view";
300796
+ const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : 0;
300797
+ const overBudget = () => t0 > 0 && performance.now() - t0 > cfg.budgetMs;
300798
+ const out = [];
300799
+ let order = 0;
300800
+ const SEL = 'a[href],button,input:not([type="hidden"]),textarea,select,summary,option,[role],[onclick],[tabindex]';
300801
+ const all = document.querySelectorAll(SEL);
300802
+ const limit = all.length < cfg.maxScan ? all.length : cfg.maxScan;
300803
+ for (let i = 0; i < limit; i++) {
300804
+ if (out.length >= cfg.maxCandidates)
300805
+ break;
300806
+ if ((i & 63) === 0 && overBudget())
300807
+ break;
300808
+ const el = all[i];
300809
+ const vis = visibleRect(el);
300810
+ if (!vis)
300811
+ continue;
300812
+ const tag = el.tagName;
300813
+ const roleAttr = el.getAttribute("role") || "";
300814
+ const inputType = tag === "INPUT" ? el.type || "" : "";
300815
+ const cursorPointer = vis.style.cursor === "pointer";
300816
+ const native = NATIVE[tag] === true;
300817
+ const roleLower = roleAttr.toLowerCase();
300818
+ const hasInteractiveRole = !!roleLower && INTERACTIVE_ROLE[roleLower] === true;
300819
+ const hasOnclick = el.hasAttribute("onclick");
300820
+ const tabAttr = el.getAttribute("tabindex");
300821
+ const tabbable = tabAttr != null && tabAttr !== "-1";
300822
+ const semantic = native || hasInteractiveRole;
300823
+ const nonSemantic = !semantic && (cursorPointer || hasOnclick || tabbable);
300824
+ if (!semantic && !nonSemantic)
300825
+ continue;
300826
+ const myOrder = order++;
300827
+ const name = accName(el);
300828
+ const lname = lower(name);
300829
+ const rect = vis.rect;
300830
+ out.push({
300831
+ domOrder: myOrder,
300832
+ roleClass: roleClassOf(tag, roleAttr, inputType),
300833
+ name: lname,
300834
+ rowContext: rowContextOf(el, lname),
300835
+ position: positionOf(rect),
300836
+ occluded: occlusionOf(el, rect),
300837
+ cursorPointer,
300838
+ nonSemantic,
300839
+ text: nonSemantic ? name.slice(0, 80) || null : null
300840
+ });
300841
+ }
300842
+ if (cfg.recoverNonSemantic) {
300843
+ const extra = document.querySelectorAll("div,span,li,td,p");
300844
+ const extraLimit = extra.length < cfg.maxScan ? extra.length : cfg.maxScan;
300845
+ for (let i = 0; i < extraLimit; i++) {
300846
+ if (out.length >= cfg.maxCandidates)
300847
+ break;
300848
+ if ((i & 63) === 0 && overBudget())
300849
+ break;
300850
+ const el = extra[i];
300851
+ const vis = visibleRect(el);
300852
+ if (!vis)
300853
+ continue;
300854
+ if (vis.style.cursor !== "pointer")
300855
+ continue;
300856
+ if (el.querySelector('a,button,input,select,textarea,[role="button"],[role="link"]'))
300857
+ continue;
300858
+ const rect = vis.rect;
300859
+ if (rect.width < 6 || rect.height < 6)
300860
+ continue;
300861
+ if (rect.width > vw * 0.98)
300862
+ continue;
300863
+ if (positionOf(rect) !== "in-view")
300864
+ continue;
300865
+ if (occlusionOf(el, rect))
300866
+ continue;
300867
+ const name = accName(el);
300868
+ if (!name)
300869
+ continue;
300870
+ const lname = lower(name);
300871
+ out.push({
300872
+ domOrder: order++,
300873
+ roleClass: "button",
300874
+ name: lname,
300875
+ rowContext: rowContextOf(el, lname),
300876
+ position: "in-view",
300877
+ occluded: false,
300878
+ cursorPointer: true,
300879
+ nonSemantic: true,
300880
+ text: name.slice(0, 80) || null
300881
+ });
300882
+ }
300883
+ }
300884
+ return out;
300885
+ }
300886
+ }
300887
+ });
300888
+
300889
+ // ../runner-core/dist/services/stuck-detector.js
300890
+ var require_stuck_detector = __commonJS({
300891
+ "../runner-core/dist/services/stuck-detector.js"(exports2) {
300892
+ "use strict";
300893
+ Object.defineProperty(exports2, "__esModule", { value: true });
300894
+ exports2.StuckDetector = void 0;
300895
+ exports2.isPageAffectingTool = isPageAffectingTool;
300896
+ exports2.normalizeStuckSignature = normalizeStuckSignature;
300897
+ var PAGE_AFFECTING_TOOLS = /* @__PURE__ */ new Set([
300898
+ "browser_click",
300899
+ "browser_type",
300900
+ "browser_fill_form",
300901
+ "browser_select_option",
300902
+ "browser_navigate",
300903
+ "browser_press_key",
300904
+ "browser_hover",
300905
+ "browser_drag"
300906
+ ]);
300907
+ function isPageAffectingTool(toolName) {
300908
+ return PAGE_AFFECTING_TOOLS.has(toolName);
300909
+ }
300910
+ function normalizeStuckSignature(toolName, toolArgs) {
300911
+ const rawRef = typeof toolArgs.ref === "string" ? toolArgs.ref.trim() : "";
300912
+ const rawElement = typeof toolArgs.element === "string" ? toolArgs.element.trim() : "";
300913
+ const elementKey = rawRef ? rawRef : rawElement ? rawElement.toLowerCase().replace(/\S+@\S+/g, "[email]").slice(0, 60) : "";
300914
+ switch (toolName) {
300915
+ case "browser_click":
300916
+ case "browser_hover":
300917
+ case "browser_select_option":
300918
+ case "browser_type":
300919
+ case "browser_fill_form":
300920
+ case "browser_drag":
300921
+ return elementKey ? `${toolName}:${elementKey}` : null;
300922
+ case "browser_navigate": {
300923
+ const url = typeof toolArgs.url === "string" ? canonicalizeUrlPath(toolArgs.url) : "";
300924
+ return `browser_navigate:${url}`;
300925
+ }
300926
+ case "browser_press_key": {
300927
+ const key = typeof toolArgs.key === "string" ? toolArgs.key : "";
300928
+ return `browser_press_key:${key}`;
300929
+ }
300930
+ default:
300931
+ return null;
300932
+ }
300933
+ }
300934
+ function canonicalizeUrlPath(url) {
300935
+ try {
300936
+ const u = new URL(url, "http://x");
300937
+ return u.pathname.replace(/\/+$/, "") || "/";
300938
+ } catch {
300939
+ return url.trim().split(/[?#]/)[0] || url.trim();
300940
+ }
300941
+ }
300942
+ var NO_VERDICT = Object.freeze({
300943
+ tier: 0,
300944
+ kind: null,
300945
+ repeatedAction: null,
300946
+ recover: false,
300947
+ yieldRun: false
300948
+ });
300949
+ var DEFAULTS = {
300950
+ window: 8,
300951
+ repeatThreshold: 3,
300952
+ stagnationThreshold: 4,
300953
+ tier2After: 3,
300954
+ tier3After: 5,
300955
+ maxRecoveries: 2,
300956
+ repetitionReachesRecovery: true
300957
+ };
300958
+ var StuckDetector = class {
300959
+ opts;
300960
+ records = [];
300961
+ stallStreak = 0;
300962
+ recoveryCount = 0;
300963
+ lastEmittedTier = 0;
300964
+ constructor(options = {}) {
300965
+ this.opts = { ...DEFAULTS, ...options };
300966
+ }
300967
+ record(input) {
300968
+ this.records.push(input);
300969
+ if (this.records.length > this.opts.window) {
300970
+ this.records.splice(0, this.records.length - this.opts.window);
300971
+ }
300972
+ const stall = this.detectStall();
300973
+ if (!stall.stalled) {
300974
+ this.stallStreak = 0;
300975
+ this.lastEmittedTier = 0;
300976
+ return NO_VERDICT;
300977
+ }
300978
+ this.stallStreak += 1;
300979
+ let desired = 1;
300980
+ if (this.stallStreak >= this.opts.tier3After)
300981
+ desired = 3;
300982
+ else if (this.stallStreak >= this.opts.tier2After)
300983
+ desired = 2;
300984
+ if (stall.kind === "stagnation" && desired > 2)
300985
+ desired = 2;
300986
+ if (!this.opts.repetitionReachesRecovery && stall.kind === "repetition" && desired > 2)
300987
+ desired = 2;
300988
+ if (desired <= this.lastEmittedTier)
300989
+ return NO_VERDICT;
300990
+ if (desired === 3) {
300991
+ if (this.recoveryCount >= this.opts.maxRecoveries) {
300992
+ this.lastEmittedTier = 4;
300993
+ return {
300994
+ tier: 4,
300995
+ kind: stall.kind,
300996
+ repeatedAction: stall.repeatedAction,
300997
+ recover: false,
300998
+ yieldRun: true
300999
+ };
301000
+ }
301001
+ this.recoveryCount += 1;
301002
+ this.records = [];
301003
+ this.stallStreak = 0;
301004
+ this.lastEmittedTier = 0;
301005
+ return {
301006
+ tier: 3,
301007
+ kind: stall.kind,
301008
+ repeatedAction: stall.repeatedAction,
301009
+ recover: true,
301010
+ yieldRun: false
301011
+ };
301012
+ }
301013
+ this.lastEmittedTier = desired;
301014
+ return {
301015
+ tier: desired,
301016
+ kind: stall.kind,
301017
+ repeatedAction: stall.repeatedAction,
301018
+ recover: false,
301019
+ yieldRun: false
301020
+ };
301021
+ }
301022
+ detectStall() {
301023
+ const { stagnationThreshold, repeatThreshold } = this.opts;
301024
+ if (this.records.length < stagnationThreshold) {
301025
+ return { stalled: false, kind: null, repeatedAction: null };
301026
+ }
301027
+ const recent = this.records.slice(-stagnationThreshold);
301028
+ const progressStalled = recent.length >= stagnationThreshold && recent.every((r) => r.progressKey === recent[recent.length - 1].progressKey);
301029
+ if (progressStalled) {
301030
+ const counts = /* @__PURE__ */ new Map();
301031
+ for (const rec of this.records) {
301032
+ for (const sig of rec.actionSignatures) {
301033
+ counts.set(sig, (counts.get(sig) ?? 0) + 1);
301034
+ }
301035
+ }
301036
+ let repeatedAction = null;
301037
+ let max = 0;
301038
+ for (const [sig, count] of counts) {
301039
+ if (count > max) {
301040
+ max = count;
301041
+ repeatedAction = sig;
301042
+ }
301043
+ }
301044
+ if (repeatedAction && max >= repeatThreshold) {
301045
+ return { stalled: true, kind: "repetition", repeatedAction };
301046
+ }
301047
+ }
301048
+ if (progressStalled) {
301049
+ const sameRoute = recent.every((r) => r.route === recent[recent.length - 1].route);
301050
+ const pageAffectingCount = recent.filter((r) => r.pageAffecting).length;
301051
+ const distinctSignatures = new Set(recent.flatMap((r) => r.actionSignatures));
301052
+ const lowDiversity = distinctSignatures.size >= 1 && distinctSignatures.size <= Math.max(1, Math.floor(pageAffectingCount / 2));
301053
+ if (sameRoute && pageAffectingCount >= Math.ceil(stagnationThreshold / 2) && lowDiversity) {
301054
+ return { stalled: true, kind: "stagnation", repeatedAction: null };
301055
+ }
301056
+ }
301057
+ return { stalled: false, kind: null, repeatedAction: null };
301058
+ }
301059
+ };
301060
+ exports2.StuckDetector = StuckDetector;
301061
+ }
301062
+ });
301063
+
299618
301064
  // ../runner-core/dist/services/evidence-harvester.js
299619
301065
  var require_evidence_harvester = __commonJS({
299620
301066
  "../runner-core/dist/services/evidence-harvester.js"(exports2) {
@@ -299911,8 +301357,10 @@ var require_discover_explorer = __commonJS({
299911
301357
  var capture_page_normalizer_js_1 = require_capture_page_normalizer();
299912
301358
  var snapshot_observation_js_1 = require_snapshot_observation();
299913
301359
  var snapshot_context_compactor_js_1 = require_snapshot_context_compactor();
301360
+ var perception_enricher_js_1 = require_perception_enricher();
299914
301361
  var exploration_prompt_builder_js_2 = require_exploration_prompt_builder();
299915
301362
  var exploration_state_js_1 = require_exploration_state();
301363
+ var stuck_detector_js_1 = require_stuck_detector();
299916
301364
  var evidence_harvester_js_1 = require_evidence_harvester();
299917
301365
  var exploration_parser_js_1 = require_exploration_parser();
299918
301366
  var snapshot_context_compactor_js_2 = require_snapshot_context_compactor();
@@ -300695,6 +302143,7 @@ ${credentialBlock}${redactedBlock}${inboxBlock}`;
300695
302143
  const v3Env = process.env.DISCOVERY_PROMPT_V3;
300696
302144
  const useV3 = v3Env !== "false";
300697
302145
  const useV2 = true;
302146
+ const perceptionEnricherEnabled = (process.env.DISCOVERY_PERCEPTION_ENRICHER === "true" || process.env.DISCOVERY_PERCEPTION_ENRICHER === "1") && typeof options?.getPerceptionPage === "function";
300698
302147
  const state2 = (0, exploration_state_js_1.createExplorationState)();
300699
302148
  const recentExchanges = [];
300700
302149
  const transientDirectives = [];
@@ -300819,6 +302268,8 @@ ${inboxPromptBlock}`;
300819
302268
  let lastBriefRoute = "";
300820
302269
  let lastBriefIteration = 0;
300821
302270
  const BRIEF_INTERVAL = 5;
302271
+ const stuckDetectorEnabled = process.env.DISCOVERY_STUCK_DETECTOR === "1" || process.env.DISCOVERY_STUCK_DETECTOR === "true";
302272
+ const stuckDetector = stuckDetectorEnabled ? new stuck_detector_js_1.StuckDetector({ repetitionReachesRecovery: !isDeepMode }) : null;
300822
302273
  while (iteration < maxIterations && !explorationComplete) {
300823
302274
  iteration++;
300824
302275
  (0, exploration_state_js_1.reduceEvent)(state2, { type: "ITERATION_STARTED", iteration });
@@ -301290,6 +302741,19 @@ ${currentSummary}`;
301290
302741
  const elapsed = Date.now() - readinessStartedAt;
301291
302742
  logs2.push(` [discover-explorer] proceeded with not-ready snapshot after ${readinessRetries} retries / ${elapsed}ms`);
301292
302743
  }
302744
+ let recoveredClickables = [];
302745
+ if (perceptionEnricherEnabled) {
302746
+ try {
302747
+ const result = await (0, perception_enricher_js_1.enrichCapturedPage)(options?.getPerceptionPage?.(), {
302748
+ route: normalized.route,
302749
+ elements: normalized.elements,
302750
+ parsedInteractiveCount: normalized.snapshotMetrics?.parsedInteractiveCount
302751
+ }, { onLog: (msg) => logs2.push(` ${msg}`) });
302752
+ recoveredClickables = result.recovered;
302753
+ } catch (perceptionErr) {
302754
+ logs2.push(` [perception] enrich skipped \u2014 ${perceptionErr instanceof Error ? perceptionErr.message : String(perceptionErr)}`);
302755
+ }
302756
+ }
301293
302757
  (0, exploration_state_js_1.reduceEvent)(state2, {
301294
302758
  type: "PAGE_CAPTURED",
301295
302759
  route: normalized.route,
@@ -301358,7 +302822,7 @@ ${currentSummary}`;
301358
302822
  logs2.push(` Captured page: ${normalized.route}, ${normalized.provenCommands.length} proven commands${normalized.alreadyCaptured ? " (RE-VISIT)" : ""}`);
301359
302823
  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).` } : {};
301360
302824
  const lastAction = normalized.observations.length > 0 ? normalized.observations[normalized.observations.length - 1].action : void 0;
301361
- const snapshotBlock = (0, snapshot_observation_js_1.renderCompactSnapshotBlock)(normalized, { lastAction }, { elementLimit: 6, formLimit: 3, observationLimit: 3, mode: options?.mode ?? "full" });
302825
+ const snapshotBlock = (0, snapshot_observation_js_1.renderCompactSnapshotBlock)(normalized, { lastAction }, { elementLimit: 6, formLimit: 3, observationLimit: 3, mode: options?.mode ?? "full", recoveredClickables });
301362
302826
  toolResults.push({
301363
302827
  role: "tool",
301364
302828
  tool_call_id: toolCall.id,
@@ -302297,6 +303761,59 @@ Exploration complete: ${summary}`);
302297
303761
  }
302298
303762
  delete toolResults.__accountAlreadyExistsDirective;
302299
303763
  }
303764
+ if (stuckDetector && assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
303765
+ const actionSignatures = [];
303766
+ let pageAffecting = false;
303767
+ for (const tc of assistantMessage.tool_calls) {
303768
+ if ((0, stuck_detector_js_1.isPageAffectingTool)(tc.function.name))
303769
+ pageAffecting = true;
303770
+ let parsedArgs = {};
303771
+ try {
303772
+ parsedArgs = JSON.parse(tc.function.arguments || "{}");
303773
+ } catch {
303774
+ }
303775
+ const sig = (0, stuck_detector_js_1.normalizeStuckSignature)(tc.function.name, parsedArgs);
303776
+ if (sig)
303777
+ actionSignatures.push(sig);
303778
+ }
303779
+ const progressKey = `${state2.currentRoute}|${state2.visitedRoutes.size}|${state2.recordedJourneys.length}|${lastCaptureIteration}`;
303780
+ const verdict = stuckDetector.record({
303781
+ iteration,
303782
+ actionSignatures,
303783
+ route: state2.currentRoute,
303784
+ progressKey,
303785
+ pageAffecting
303786
+ });
303787
+ if (verdict.tier > 0) {
303788
+ const topFrontier = (0, exploration_state_js_1.getRankedFrontier)(state2).slice(0, 3);
303789
+ const frontierHint = topFrontier.length > 0 ? ` (e.g. ${topFrontier.join(", ")})` : "";
303790
+ const repeated = verdict.repeatedAction ? verdict.repeatedAction.slice(0, 80) : null;
303791
+ const act = repeated ? `"${repeated}"` : "the same actions";
303792
+ let directive = null;
303793
+ if (verdict.yieldRun) {
303794
+ logs2.push(` Stuck detector: yielding after repeated local loop (${verdict.kind}, ${act})`);
303795
+ explorationComplete = true;
303796
+ 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.`;
303797
+ break;
303798
+ } else if (verdict.recover) {
303799
+ 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.`;
303800
+ logs2.push(` Stuck detector: harness recovery (tier 3, ${verdict.kind}, ${act})`);
303801
+ } else if (verdict.tier === 2) {
303802
+ 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.`;
303803
+ logs2.push(` Stuck detector: firm nudge (tier 2, ${verdict.kind}, ${act})`);
303804
+ } else {
303805
+ 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.`;
303806
+ logs2.push(` Stuck detector: soft nudge (tier 1, ${verdict.kind}, ${act})`);
303807
+ }
303808
+ if (directive) {
303809
+ if (useV3) {
303810
+ transientDirectives.push(directive);
303811
+ } else {
303812
+ messages.push({ role: "user", content: directive });
303813
+ }
303814
+ }
303815
+ }
303816
+ }
302300
303817
  }
302301
303818
  if (!explorationComplete) {
302302
303819
  logs2.push(`
@@ -303005,7 +304522,8 @@ ${credentialLines}
303005
304522
  userNotes: featureOpts.userNotes,
303006
304523
  onAICall: featureOpts.onAICall,
303007
304524
  onProgress: featureOpts.onProgress,
303008
- onCredentialUpdate: featureOpts.onCredentialUpdate
304525
+ onCredentialUpdate: featureOpts.onCredentialUpdate,
304526
+ getPerceptionPage: featureOpts.getPerceptionPage
303009
304527
  }, config);
303010
304528
  }
303011
304529
  }
@@ -310049,54 +311567,13 @@ var require_mobile_exploration_state = __commonJS({
310049
311567
  var require_mobile_discovery_agent = __commonJS({
310050
311568
  "../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports2) {
310051
311569
  "use strict";
310052
- var __createBinding = exports2 && exports2.__createBinding || (Object.create ? (function(o, m, k, k2) {
310053
- if (k2 === void 0) k2 = k;
310054
- var desc = Object.getOwnPropertyDescriptor(m, k);
310055
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
310056
- desc = { enumerable: true, get: function() {
310057
- return m[k];
310058
- } };
310059
- }
310060
- Object.defineProperty(o, k2, desc);
310061
- }) : (function(o, m, k, k2) {
310062
- if (k2 === void 0) k2 = k;
310063
- o[k2] = m[k];
310064
- }));
310065
- var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? (function(o, v) {
310066
- Object.defineProperty(o, "default", { enumerable: true, value: v });
310067
- }) : function(o, v) {
310068
- o["default"] = v;
310069
- });
310070
- var __importStar = exports2 && exports2.__importStar || /* @__PURE__ */ (function() {
310071
- var ownKeys = function(o) {
310072
- ownKeys = Object.getOwnPropertyNames || function(o2) {
310073
- var ar = [];
310074
- for (var k in o2) if (Object.prototype.hasOwnProperty.call(o2, k)) ar[ar.length] = k;
310075
- return ar;
310076
- };
310077
- return ownKeys(o);
310078
- };
310079
- return function(mod) {
310080
- if (mod && mod.__esModule) return mod;
310081
- var result = {};
310082
- if (mod != null) {
310083
- for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
310084
- }
310085
- __setModuleDefault(result, mod);
310086
- return result;
310087
- };
310088
- })();
310089
311570
  Object.defineProperty(exports2, "__esModule", { value: true });
310090
311571
  exports2.runMobileDiscoveryOnRunner = runMobileDiscoveryOnRunner2;
310091
311572
  var mobile_explorer_js_1 = require_mobile_explorer();
310092
311573
  var mobile_discovery_prompts_js_1 = require_mobile_discovery_prompts();
311574
+ var mobile_generator_js_1 = require_mobile_generator();
310093
311575
  var mobile_exploration_state_js_1 = require_mobile_exploration_state();
310094
311576
  var discover_planner_js_1 = require_discover_planner();
310095
- var GENERATOR_MODULE = "./mobile-generator.js";
310096
- var defaultRunGenerate = async (args) => {
310097
- const mod = await Promise.resolve(`${GENERATOR_MODULE}`).then((s) => __importStar(require(s)));
310098
- return mod.runMobileGeneratePhase(args);
310099
- };
310100
311577
  var EXPLORE_BUDGET_DEEP = 60;
310101
311578
  var EXPLORE_BUDGET_SURVEY = 40;
310102
311579
  function platformForMedium(medium) {
@@ -310114,7 +311591,9 @@ var require_mobile_discovery_agent = __commonJS({
310114
311591
  return {
310115
311592
  collectedPages: explore.collectedPages,
310116
311593
  collectedTransitions,
310117
- pageScreenshots: explore.pageScreenshots,
311594
+ // Native screenshots are multi-megabyte PNGs. The live mirror streams them
311595
+ // through heartbeat telemetry; discovery checkpoints keep the screen graph
311596
+ // and transitions so large Android surveys cannot exceed server body limits.
310118
311597
  exploreStats: explore.exploreStats,
310119
311598
  healthObservations: explore.healthObservations
310120
311599
  };
@@ -310131,7 +311610,7 @@ var require_mobile_discovery_agent = __commonJS({
310131
311610
  const onScreenshot = callbacks?.onScreenshot;
310132
311611
  const runExplore = callbacks?.runExplore ?? mobile_explorer_js_1.runMobileExplorePhase;
310133
311612
  const runPlan = callbacks?.runPlan ?? mobile_discovery_prompts_js_1.runMobileAIPlanner;
310134
- const runGenerate = callbacks?.runGenerate ?? defaultRunGenerate;
311613
+ const runGenerate = callbacks?.runGenerate ?? mobile_generator_js_1.runMobileGeneratePhase;
310135
311614
  const log2 = (line) => {
310136
311615
  logs2.push(line);
310137
311616
  try {
@@ -310204,6 +311683,7 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
310204
311683
  featureName: ctx.featureName,
310205
311684
  existingTestNames: ctx.existingTestNames,
310206
311685
  existingFeatureNames: ctx.existingFeatureNames,
311686
+ recordedJourneys: explore.recordedJourneys,
310207
311687
  collectedPages: screenMap,
310208
311688
  collectedTransitions,
310209
311689
  credentials: {
@@ -310271,9 +311751,8 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
310271
311751
  ctx,
310272
311752
  onLog: (line) => log2(line),
310273
311753
  onAICall,
310274
- onTestGenerated: (test) => {
310275
- callbacks?.onTestGenerated?.(test, { phase: "verified" });
310276
- }
311754
+ onScreenshot,
311755
+ onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
310277
311756
  });
310278
311757
  const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
310279
311758
  log2(`
@@ -310797,7 +312276,8 @@ Respond with ONLY valid JSON:
310797
312276
  userNotes: ctx.userNotes,
310798
312277
  onAICall,
310799
312278
  onProgress,
310800
- onCredentialUpdate: ctx.onCredentialUpdate
312279
+ onCredentialUpdate: ctx.onCredentialUpdate,
312280
+ getPerceptionPage: () => handle?.page ?? null
310801
312281
  });
310802
312282
  logs2.push(...exploreResult.logs);
310803
312283
  screenshots.push(...exploreResult.screenshots);
@@ -311336,6 +312816,7 @@ Fatal error: ${msg}`);
311336
312816
  onAICall,
311337
312817
  onProgress,
311338
312818
  onCredentialUpdate: ctx.onCredentialUpdate,
312819
+ getPerceptionPage: () => handle?.page ?? null,
311339
312820
  onTestPhaseChange: (phase) => {
311340
312821
  healthObserver?.setTestPhase(phase);
311341
312822
  liveCollector?.setTestPhase(phase);
@@ -323975,6 +325456,8 @@ var require_live_browser_registry = __commonJS({
323975
325456
  exports2.liveBrowserRegistry = void 0;
323976
325457
  var THUMBNAIL_INTERVAL_MS = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_INTERVAL_MS ?? "4000", 10) || 4e3;
323977
325458
  var THUMBNAIL_QUALITY = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_QUALITY ?? "60", 10) || 60;
325459
+ var BROWSER_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_MAX_BASE64_BYTES ?? "400000", 10) || 4e5;
325460
+ var MOBILE_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_MOBILE_THUMBNAIL_MAX_BASE64_BYTES ?? "4500000", 10) || 45e5;
323978
325461
  var STALE_SESSION_MS = parseInt(process.env.LIVE_BROWSER_STALE_MS ?? `${10 * 60 * 1e3}`, 10) || 10 * 60 * 1e3;
323979
325462
  var MAX_SESSIONS = parseInt(process.env.LIVE_BROWSER_MAX_SESSIONS ?? "32", 10) || 32;
323980
325463
  var LiveBrowserRegistry = class {
@@ -324069,7 +325552,8 @@ var require_live_browser_registry = __commonJS({
324069
325552
  entry.currentUrl = snapshot.currentUrl;
324070
325553
  }
324071
325554
  const thumbnail = snapshot.thumbnailJpegBase64;
324072
- if (thumbnail && thumbnail.length <= 16e4) {
325555
+ const thumbnailLimit = entry.meta.surface === "mobile" ? MOBILE_THUMBNAIL_MAX_BASE64_BYTES : BROWSER_THUMBNAIL_MAX_BASE64_BYTES;
325556
+ if (thumbnail && thumbnail.length <= thumbnailLimit) {
324073
325557
  entry.thumbnailJpegBase64 = thumbnail;
324074
325558
  entry.thumbnailCapturedAt = typeof snapshot.thumbnailCapturedAt === "number" && Number.isFinite(snapshot.thumbnailCapturedAt) ? snapshot.thumbnailCapturedAt : Date.now();
324075
325559
  entry.thumbnailWidth = typeof snapshot.thumbnailWidth === "number" && Number.isFinite(snapshot.thumbnailWidth) ? snapshot.thumbnailWidth : void 0;
@@ -324203,7 +325687,7 @@ var require_live_browser_registry = __commonJS({
324203
325687
  scale: "css"
324204
325688
  });
324205
325689
  const base64 = buf.toString("base64");
324206
- if (base64.length > 16e4) {
325690
+ if (base64.length > BROWSER_THUMBNAIL_MAX_BASE64_BYTES) {
324207
325691
  return;
324208
325692
  }
324209
325693
  entry.thumbnailJpegBase64 = base64;
@@ -324469,6 +325953,7 @@ var require_dist2 = __commonJS({
324469
325953
  __exportStar(require_ai_provider(), exports2);
324470
325954
  __exportStar(require_mobile_types(), exports2);
324471
325955
  __exportStar(require_mobile_source_parser(), exports2);
325956
+ __exportStar(require_mobile_boundary(), exports2);
324472
325957
  var mobile_explorer_js_1 = require_mobile_explorer();
324473
325958
  Object.defineProperty(exports2, "runMobileExplorePhase", { enumerable: true, get: function() {
324474
325959
  return mobile_explorer_js_1.runMobileExplorePhase;
@@ -324515,6 +326000,7 @@ var require_dist2 = __commonJS({
324515
326000
  return api_call_redaction_js_1.headerVal;
324516
326001
  } });
324517
326002
  __exportStar(require_mcp_healer(), exports2);
326003
+ __exportStar(require_selector_registry_context(), exports2);
324518
326004
  var grok_per_test_healer_js_1 = require_grok_per_test_healer();
324519
326005
  Object.defineProperty(exports2, "executeGrokPerTestHeal", { enumerable: true, get: function() {
324520
326006
  return grok_per_test_healer_js_1.executeGrokPerTestHeal;
@@ -324800,7 +326286,7 @@ var require_package4 = __commonJS({
324800
326286
  "package.json"(exports2, module2) {
324801
326287
  module2.exports = {
324802
326288
  name: "@validate.qa/runner",
324803
- version: "1.0.4",
326289
+ version: "1.0.6",
324804
326290
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
324805
326291
  bin: {
324806
326292
  "validate-runner": "dist/cli.js"
@@ -324898,7 +326384,7 @@ var import_runner_core = __toESM(require_dist2());
324898
326384
  var import_runner_core2 = __toESM(require_dist2());
324899
326385
 
324900
326386
  // src/runner.ts
324901
- var import_runner_core9 = __toESM(require_dist2());
326387
+ var import_runner_core10 = __toESM(require_dist2());
324902
326388
 
324903
326389
  // src/services/regression-runner.ts
324904
326390
  var import_runner_core3 = __toESM(require_dist2());
@@ -324913,7 +326399,7 @@ var import_runner_core5 = __toESM(require_dist2());
324913
326399
  var import_runner_core6 = __toESM(require_dist2());
324914
326400
 
324915
326401
  // src/runner.ts
324916
- var import_runner_core10 = __toESM(require_dist2());
326402
+ var import_runner_core11 = __toESM(require_dist2());
324917
326403
 
324918
326404
  // src/services/auth-state.ts
324919
326405
  var INLINE_LOGIN_TAG = "@inline-login";
@@ -324933,6 +326419,7 @@ function shouldUseAuthState(tags, playwrightCode) {
324933
326419
 
324934
326420
  // src/services/appium-executor.ts
324935
326421
  init_dist();
326422
+ var import_runner_core8 = __toESM(require_dist2());
324936
326423
 
324937
326424
  // src/services/appium-lifecycle.ts
324938
326425
  var import_child_process = require("child_process");
@@ -325472,6 +326959,644 @@ function getRunnerCapabilities() {
325472
326959
  };
325473
326960
  }
325474
326961
 
326962
+ // src/services/mobile/mobile-network-capture.ts
326963
+ var import_node_child_process = require("child_process");
326964
+ var import_node_crypto = require("crypto");
326965
+ var import_promises = require("fs/promises");
326966
+ var import_node_os = require("os");
326967
+ var import_node_path = require("path");
326968
+ var import_node_util = require("util");
326969
+ var import_mockttp = require("mockttp");
326970
+ var import_runner_core7 = __toESM(require_dist2());
326971
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
326972
+ var MAX_CALLS = 1e3;
326973
+ var MAX_BODY_CAPTURE_BYTES = 64 * 1024;
326974
+ var DEVICE_CMD_TIMEOUT_MS = 15e3;
326975
+ var CA_KEY_BITS = 2048;
326976
+ var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
326977
+ var PENDING_CLEANUP_DIR = (0, import_node_path.join)((0, import_node_os.tmpdir)(), "validateqa-mobile-capture-pending");
326978
+ function envFlag(name) {
326979
+ const value = (process.env[name] ?? "").trim().toLowerCase();
326980
+ return value === "1" || value === "true" || value === "yes" || value === "on";
326981
+ }
326982
+ function rawHeadersToPairs(rawHeaders) {
326983
+ return rawHeaders.map(([name, value]) => ({ name, value }));
326984
+ }
326985
+ function headerValue(pairs, name) {
326986
+ const lower = name.toLowerCase();
326987
+ return pairs.find((h) => h.name.toLowerCase() === lower)?.value;
326988
+ }
326989
+ function resolveHostIp() {
326990
+ const ifaces = (0, import_node_os.networkInterfaces)();
326991
+ for (const addrs of Object.values(ifaces)) {
326992
+ if (!addrs) continue;
326993
+ for (const addr of addrs) {
326994
+ if (addr.family === "IPv4" && !addr.internal) return addr.address;
326995
+ }
326996
+ }
326997
+ return "127.0.0.1";
326998
+ }
326999
+ function resolveProxyHostForDevice(platform3, isPhysical) {
327000
+ if (platform3 === "ANDROID" && !isPhysical) return ANDROID_EMULATOR_HOST_LOOPBACK;
327001
+ if (platform3 === "IOS" && !isPhysical) return "127.0.0.1";
327002
+ return resolveHostIp();
327003
+ }
327004
+ function normalizeRemoteAddress(addr) {
327005
+ if (!addr) return "";
327006
+ return addr.startsWith("::ffff:") ? addr.slice("::ffff:".length) : addr;
327007
+ }
327008
+ function isLoopbackAddress(addr) {
327009
+ return addr === "127.0.0.1" || addr.startsWith("127.") || addr === "::1" || addr === "";
327010
+ }
327011
+ function isPrivateAddress(addr) {
327012
+ if (/^10\./.test(addr)) return true;
327013
+ if (/^192\.168\./.test(addr)) return true;
327014
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
327015
+ if (/^169\.254\./.test(addr)) return true;
327016
+ const lower = addr.toLowerCase();
327017
+ if (lower.startsWith("fe80:")) return true;
327018
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
327019
+ return false;
327020
+ }
327021
+ function isAllowedProxyPeer(remoteAddress, isPhysical) {
327022
+ const addr = normalizeRemoteAddress(remoteAddress);
327023
+ if (isLoopbackAddress(addr)) return true;
327024
+ return isPhysical && isPrivateAddress(addr);
327025
+ }
327026
+ function installSourceIpGuard(server3, isPhysical, onLog) {
327027
+ const underlying = server3.server;
327028
+ if (!underlying || typeof underlying.on !== "function") {
327029
+ onLog?.(
327030
+ "[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."
327031
+ );
327032
+ return;
327033
+ }
327034
+ underlying.on("connection", (socket) => {
327035
+ if (!isAllowedProxyPeer(socket.remoteAddress, isPhysical)) {
327036
+ onLog?.(`[mobile-net] rejected proxy connection from disallowed source ${socket.remoteAddress}`);
327037
+ socket.destroy();
327038
+ }
327039
+ });
327040
+ }
327041
+ function isTextish(contentType) {
327042
+ if (!contentType) return false;
327043
+ return /json|text|xml|graphql|html|csv|javascript|urlencoded/i.test(contentType);
327044
+ }
327045
+ async function readBodyText(body, contentType) {
327046
+ if (!isTextish(contentType)) return void 0;
327047
+ try {
327048
+ const decoded = await body.getDecodedBuffer();
327049
+ if (!decoded || decoded.length === 0) return void 0;
327050
+ if (decoded.length > MAX_BODY_CAPTURE_BYTES) {
327051
+ return decoded.subarray(0, MAX_BODY_CAPTURE_BYTES).toString("utf-8");
327052
+ }
327053
+ return decoded.toString("utf-8");
327054
+ } catch {
327055
+ return void 0;
327056
+ }
327057
+ }
327058
+ var MitmProxy = class {
327059
+ constructor(caKeyPem, caCertPem, caCertPath, isPhysical, tlsPassthroughAll, onLog) {
327060
+ this.caKeyPem = caKeyPem;
327061
+ this.caCertPem = caCertPem;
327062
+ this.caCertPath = caCertPath;
327063
+ this.isPhysical = isPhysical;
327064
+ this.tlsPassthroughAll = tlsPassthroughAll;
327065
+ this.onLog = onLog;
327066
+ }
327067
+ server = null;
327068
+ inFlight = /* @__PURE__ */ new Map();
327069
+ calls = [];
327070
+ dropped = 0;
327071
+ /** Flips true once any HTTPS response is successfully MITM-ed. */
327072
+ httpsIntercepted = false;
327073
+ /** Best-known reason interception is unavailable, refined by TLS failures. */
327074
+ tlsFailureReason = null;
327075
+ /** Count of opaque HTTPS tunnels when TLS passthrough is enabled. */
327076
+ tlsPassthroughCount = 0;
327077
+ /** Start listening; resolves with the actual bound port. */
327078
+ async listen(preferredPort) {
327079
+ const server3 = (0, import_mockttp.getLocal)({
327080
+ // Our generated CA — mockttp mints per-host leaf certs signed by it.
327081
+ https: {
327082
+ key: this.caKeyPem,
327083
+ cert: this.caCertPem,
327084
+ ...this.tlsPassthroughAll ? { tlsPassthrough: [{ hostname: "*" }] } : {}
327085
+ },
327086
+ // HTTP/2 with HTTP/1.1 fallback; mobile clients negotiate either.
327087
+ http2: "fallback",
327088
+ // Keep the proxy quiet unless explicitly debugging.
327089
+ recordTraffic: false
327090
+ });
327091
+ await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
327092
+ await server3.on("request", (req) => this.onRequest(req));
327093
+ await server3.on("response", (res) => this.onResponse(res));
327094
+ await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
327095
+ await server3.on("tls-passthrough-opened", (event) => this.onTlsPassthroughOpened(event));
327096
+ await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
327097
+ installSourceIpGuard(server3, this.isPhysical, this.onLog);
327098
+ this.server = server3;
327099
+ return server3.port;
327100
+ }
327101
+ /** Captured calls, finalized through the shared redaction constructor. */
327102
+ finalize() {
327103
+ return this.calls.map(
327104
+ (c) => (0, import_runner_core7.buildApiCallSummary)({
327105
+ method: c.method,
327106
+ url: c.url,
327107
+ status: c.status,
327108
+ durationMs: c.durationMs,
327109
+ authHeader: c.authHeader,
327110
+ cookieHeader: c.cookieHeader,
327111
+ requestContentType: c.requestContentType,
327112
+ responseContentType: c.responseContentType,
327113
+ respHeaders: c.respHeaders,
327114
+ reqBodyText: c.reqBodyText,
327115
+ respBodyText: c.respBodyText,
327116
+ responseSize: c.responseSize,
327117
+ timestamp: c.startedAt,
327118
+ testPhase: "UNKNOWN"
327119
+ })
327120
+ );
327121
+ }
327122
+ /** True once at least one HTTPS exchange was decrypted (CA trusted). */
327123
+ get isIntercepting() {
327124
+ return this.httpsIntercepted;
327125
+ }
327126
+ /** A precise reason interception is (still) unavailable, or null if it works. */
327127
+ get interceptionReason() {
327128
+ if (this.httpsIntercepted) return null;
327129
+ if (this.tlsPassthroughAll) {
327130
+ 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}.` : ".");
327131
+ }
327132
+ return this.tlsFailureReason;
327133
+ }
327134
+ async close() {
327135
+ const server3 = this.server;
327136
+ this.server = null;
327137
+ this.inFlight.clear();
327138
+ if (!server3) return;
327139
+ try {
327140
+ await server3.stop();
327141
+ } catch (err) {
327142
+ this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg(err)}`);
327143
+ }
327144
+ this.onLog?.(
327145
+ `[mobile-net] proxy closed: ${this.calls.length} calls captured` + (this.dropped > 0 ? ` (${this.dropped} dropped over cap)` : "")
327146
+ );
327147
+ }
327148
+ // ── request → buffer by id ────────────────────────────────────────────────
327149
+ async onRequest(req) {
327150
+ try {
327151
+ const reqHeaders = rawHeadersToPairs(req.rawHeaders);
327152
+ const requestContentType = headerValue(reqHeaders, "content-type");
327153
+ const isHttps = isHttpsUrl(req.url);
327154
+ const reqBodyText = (0, import_runner_core7.isApiCall)(req.url, requestContentType) ? await readBodyText(req.body, requestContentType) : void 0;
327155
+ this.inFlight.set(req.id, {
327156
+ method: req.method,
327157
+ url: req.url,
327158
+ startedAt: req.timingEvents.startTime,
327159
+ reqHeaders,
327160
+ requestContentType,
327161
+ authHeader: headerValue(reqHeaders, "authorization"),
327162
+ cookieHeader: headerValue(reqHeaders, "cookie"),
327163
+ reqBodyText,
327164
+ isHttps
327165
+ });
327166
+ } catch (err) {
327167
+ this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg(err)}`);
327168
+ }
327169
+ }
327170
+ // ── response → finalize the matched request ───────────────────────────────
327171
+ async onResponse(res) {
327172
+ const pending = this.inFlight.get(res.id);
327173
+ if (!pending) return;
327174
+ this.inFlight.delete(res.id);
327175
+ try {
327176
+ if (pending.isHttps && !this.httpsIntercepted) {
327177
+ this.httpsIntercepted = true;
327178
+ this.tlsFailureReason = null;
327179
+ this.onLog?.("[mobile-net] HTTPS interception confirmed (CA trusted; bodies decrypted).");
327180
+ }
327181
+ const respHeaders = rawHeadersToPairs(res.rawHeaders);
327182
+ const responseContentType = headerValue(respHeaders, "content-type");
327183
+ const api = (0, import_runner_core7.isApiCall)(pending.url, responseContentType);
327184
+ if (!api && (0, import_runner_core7.isStaticAssetByExt)(pending.url)) return;
327185
+ if (this.calls.length >= MAX_CALLS) {
327186
+ this.dropped++;
327187
+ return;
327188
+ }
327189
+ const respBodyText = api ? await readBodyText(res.body, responseContentType) : void 0;
327190
+ const responseSize = await measureBody(res.body);
327191
+ const durationMs = computeDuration(pending.startedAt, res);
327192
+ this.calls.push({
327193
+ method: pending.method,
327194
+ url: pending.url,
327195
+ status: res.statusCode,
327196
+ startedAt: pending.startedAt,
327197
+ durationMs,
327198
+ reqHeaders: pending.reqHeaders,
327199
+ respHeaders,
327200
+ requestContentType: pending.requestContentType,
327201
+ responseContentType,
327202
+ authHeader: pending.authHeader,
327203
+ cookieHeader: pending.cookieHeader,
327204
+ reqBodyText: pending.reqBodyText,
327205
+ respBodyText,
327206
+ responseSize,
327207
+ intercepted: true
327208
+ });
327209
+ } catch (err) {
327210
+ this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg(err)}`);
327211
+ }
327212
+ }
327213
+ // ── tls-client-error → record WHY interception failed ─────────────────────
327214
+ onTlsClientError(failure) {
327215
+ if (this.httpsIntercepted) return;
327216
+ const host = failure.destination?.hostname ?? failure.tlsMetadata.sniHostname ?? "unknown host";
327217
+ 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.";
327218
+ let reason;
327219
+ switch (failure.failureCause) {
327220
+ case "cert-rejected":
327221
+ 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;
327222
+ break;
327223
+ case "closed":
327224
+ case "reset":
327225
+ 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).`;
327226
+ break;
327227
+ case "no-shared-cipher":
327228
+ reason = `TLS handshake to ${host} failed: no shared cipher with the client.`;
327229
+ break;
327230
+ case "handshake-timeout":
327231
+ reason = `TLS handshake to ${host} timed out before completing.`;
327232
+ break;
327233
+ default:
327234
+ reason = `TLS handshake to ${host} failed (${failure.failureCause}); HTTPS not intercepted for it.`;
327235
+ }
327236
+ if (!this.tlsFailureReason || failure.failureCause === "cert-rejected" || failure.failureCause === "closed" || failure.failureCause === "reset") {
327237
+ this.tlsFailureReason = reason;
327238
+ }
327239
+ this.onLog?.(`[mobile-net] ${reason}`);
327240
+ }
327241
+ onTlsPassthroughOpened(event) {
327242
+ this.tlsPassthroughCount++;
327243
+ if (this.tlsPassthroughCount === 1) {
327244
+ const host = event.destination?.hostname ?? event.hostname ?? "unknown host";
327245
+ this.onLog?.(
327246
+ `[mobile-net] HTTPS TLS passthrough active for ${host}; app traffic is preserved, but HTTPS bodies are not decrypted.`
327247
+ );
327248
+ }
327249
+ }
327250
+ };
327251
+ function isHttpsUrl(url) {
327252
+ try {
327253
+ return new URL(url).protocol === "https:";
327254
+ } catch {
327255
+ return false;
327256
+ }
327257
+ }
327258
+ async function measureBody(body) {
327259
+ try {
327260
+ const decoded = await body.getDecodedBuffer();
327261
+ return decoded ? decoded.length : 0;
327262
+ } catch {
327263
+ return 0;
327264
+ }
327265
+ }
327266
+ function computeDuration(startedAt, res) {
327267
+ const sent = res.timingEvents.responseSentTimestamp;
327268
+ const start = res.timingEvents.startTimestamp;
327269
+ if (typeof sent === "number" && typeof start === "number" && sent >= start) {
327270
+ return Math.round(sent - start);
327271
+ }
327272
+ const elapsed = Date.now() - startedAt;
327273
+ return elapsed > 0 ? elapsed : 0;
327274
+ }
327275
+ async function generateCaCert(dir, onLog) {
327276
+ try {
327277
+ const { key, cert } = await (0, import_mockttp.generateCACertificate)({
327278
+ subject: {
327279
+ commonName: "validate.qa Mobile Capture CA",
327280
+ organizationName: "validate.qa"
327281
+ },
327282
+ bits: CA_KEY_BITS
327283
+ // mockttp's generated CA carries its own (multi-year) validity window; we
327284
+ // rely on stop() to remove the cert from the device promptly after the run
327285
+ // rather than on a short expiry.
327286
+ });
327287
+ const caCertPath = (0, import_node_path.join)(dir, "validateqa-mobile-ca.pem");
327288
+ await (0, import_promises.writeFile)(caCertPath, cert, "utf-8");
327289
+ return { caCertPath, caKeyPem: key, caCertPem: cert };
327290
+ } catch (err) {
327291
+ onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg(err)}`);
327292
+ return null;
327293
+ }
327294
+ }
327295
+ async function androidReadProxy(deviceId) {
327296
+ try {
327297
+ const { stdout } = await execFileAsync(
327298
+ "adb",
327299
+ ["-s", deviceId, "shell", "settings", "get", "global", "http_proxy"],
327300
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327301
+ );
327302
+ const value = stdout.trim();
327303
+ return value === "" || value === "null" ? null : value;
327304
+ } catch {
327305
+ return null;
327306
+ }
327307
+ }
327308
+ async function androidSetProxy(deviceId, hostPort, onLog) {
327309
+ try {
327310
+ await execFileAsync(
327311
+ "adb",
327312
+ ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", hostPort],
327313
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327314
+ );
327315
+ return true;
327316
+ } catch (err) {
327317
+ onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg(err)}`);
327318
+ return false;
327319
+ }
327320
+ }
327321
+ async function androidClearProxy(deviceId, originalProxy, onLog) {
327322
+ const restoreValue = originalProxy && originalProxy.length > 0 ? originalProxy : ":0";
327323
+ try {
327324
+ await execFileAsync(
327325
+ "adb",
327326
+ ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue],
327327
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327328
+ );
327329
+ } catch (err) {
327330
+ onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg(err)}`);
327331
+ }
327332
+ }
327333
+ async function androidInstallCa(deviceId, caCertPath) {
327334
+ const devicePath = "/sdcard/validateqa-mobile-ca.pem";
327335
+ try {
327336
+ await execFileAsync("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
327337
+ timeout: DEVICE_CMD_TIMEOUT_MS
327338
+ });
327339
+ return {
327340
+ installed: false,
327341
+ 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.`
327342
+ };
327343
+ } catch (err) {
327344
+ return { installed: false, note: `CA push failed: ${errMsg(err)}` };
327345
+ }
327346
+ }
327347
+ async function androidRemoveCa(deviceId, onLog) {
327348
+ try {
327349
+ await execFileAsync(
327350
+ "adb",
327351
+ ["-s", deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"],
327352
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327353
+ );
327354
+ } catch (err) {
327355
+ onLog?.(`[mobile-net] adb remove CA failed: ${errMsg(err)}`);
327356
+ }
327357
+ }
327358
+ async function iosSimTrustCa(deviceId, caCertPath) {
327359
+ try {
327360
+ await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
327361
+ timeout: DEVICE_CMD_TIMEOUT_MS
327362
+ });
327363
+ return {
327364
+ trusted: true,
327365
+ 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."
327366
+ };
327367
+ } catch (err) {
327368
+ return { trusted: false, note: `simctl add-root-cert failed: ${errMsg(err)}` };
327369
+ }
327370
+ }
327371
+ async function iosSimRemoveCa(deviceId, onLog) {
327372
+ if (process.env.VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET === "1") {
327373
+ try {
327374
+ await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "reset"], {
327375
+ timeout: DEVICE_CMD_TIMEOUT_MS
327376
+ });
327377
+ onLog?.(`[mobile-net] simctl keychain reset run for ${deviceId} (opt-in; cleared ALL sim certs).`);
327378
+ } catch (err) {
327379
+ onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg(err)}`);
327380
+ }
327381
+ return;
327382
+ }
327383
+ onLog?.(
327384
+ `[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.`
327385
+ );
327386
+ }
327387
+ function errMsg(err) {
327388
+ return err instanceof Error ? err.message : String(err);
327389
+ }
327390
+ var activeCleanups = /* @__PURE__ */ new Map();
327391
+ var exitHandlersInstalled = false;
327392
+ function markerPath(sessionKey) {
327393
+ return (0, import_node_path.join)(PENDING_CLEANUP_DIR, `${sessionKey}.json`);
327394
+ }
327395
+ async function writePendingCleanup(sessionKey, marker) {
327396
+ try {
327397
+ await (0, import_promises.mkdir)(PENDING_CLEANUP_DIR, { recursive: true });
327398
+ await (0, import_promises.writeFile)(markerPath(sessionKey), JSON.stringify(marker), "utf-8");
327399
+ } catch {
327400
+ }
327401
+ }
327402
+ async function removePendingCleanup(sessionKey) {
327403
+ try {
327404
+ await (0, import_promises.unlink)(markerPath(sessionKey));
327405
+ } catch {
327406
+ }
327407
+ }
327408
+ function restoreDeviceSync(marker, onLog) {
327409
+ const run2 = (args) => {
327410
+ try {
327411
+ (0, import_node_child_process.execFileSync)("adb", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
327412
+ } catch {
327413
+ }
327414
+ };
327415
+ if (marker.platform === "ANDROID") {
327416
+ if (marker.proxySet) {
327417
+ const restoreValue = marker.originalProxy && marker.originalProxy.length > 0 ? marker.originalProxy : ":0";
327418
+ run2(["-s", marker.deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue]);
327419
+ }
327420
+ if (marker.caInstalledOnDevice) {
327421
+ run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
327422
+ }
327423
+ }
327424
+ onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
327425
+ }
327426
+ async function reconcilePendingCleanups(onLog) {
327427
+ let files;
327428
+ try {
327429
+ files = await (0, import_promises.readdir)(PENDING_CLEANUP_DIR);
327430
+ } catch {
327431
+ return;
327432
+ }
327433
+ for (const file of files) {
327434
+ if (!file.endsWith(".json")) continue;
327435
+ const full = (0, import_node_path.join)(PENDING_CLEANUP_DIR, file);
327436
+ try {
327437
+ const raw = await (0, import_promises.readFile)(full, "utf-8");
327438
+ const marker = JSON.parse(raw);
327439
+ if (marker && typeof marker.deviceId === "string" && marker.platform) {
327440
+ onLog?.(`[mobile-net] reconciling orphaned capture cleanup for ${marker.deviceId}.`);
327441
+ restoreDeviceSync(marker, onLog);
327442
+ }
327443
+ } catch {
327444
+ }
327445
+ try {
327446
+ await (0, import_promises.unlink)(full);
327447
+ } catch {
327448
+ }
327449
+ }
327450
+ }
327451
+ function ensureExitHandlers() {
327452
+ if (exitHandlersInstalled) return;
327453
+ exitHandlersInstalled = true;
327454
+ const drain = () => {
327455
+ for (const marker of activeCleanups.values()) {
327456
+ restoreDeviceSync(marker);
327457
+ }
327458
+ };
327459
+ process.on("beforeExit", drain);
327460
+ process.on("exit", drain);
327461
+ for (const signal of ["SIGTERM", "SIGINT"]) {
327462
+ process.on(signal, () => {
327463
+ drain();
327464
+ process.removeAllListeners(signal);
327465
+ process.kill(process.pid, signal);
327466
+ });
327467
+ }
327468
+ }
327469
+ async function startMobileNetworkCapture(options) {
327470
+ const { platform: platform3, deviceId, isPhysical = false, preferredPort = 0, onLog } = options;
327471
+ const forceTlsMitm = options.forceTlsMitm ?? envFlag("VALIDATEQA_MOBILE_FORCE_TLS_MITM");
327472
+ const tlsPassthroughAll = !forceTlsMitm && (platform3 === "ANDROID" || platform3 === "IOS" && isPhysical);
327473
+ await reconcilePendingCleanups(onLog);
327474
+ ensureExitHandlers();
327475
+ const sessionKey = (0, import_node_crypto.randomUUID)();
327476
+ const originalProxy = platform3 === "ANDROID" ? await androidReadProxy(deviceId) : null;
327477
+ let tmpDir = null;
327478
+ let caCertPath = null;
327479
+ let caKeyPem = null;
327480
+ let caCertPem = null;
327481
+ try {
327482
+ tmpDir = await (0, import_promises.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "validateqa-mobile-ca-"));
327483
+ const ca = await generateCaCert(tmpDir, onLog);
327484
+ if (ca) {
327485
+ caCertPath = ca.caCertPath;
327486
+ caKeyPem = ca.caKeyPem;
327487
+ caCertPem = ca.caCertPem;
327488
+ }
327489
+ } catch (err) {
327490
+ onLog?.(`[mobile-net] CA setup degraded: ${errMsg(err)}`);
327491
+ }
327492
+ if (!caKeyPem || !caCertPem) {
327493
+ try {
327494
+ const fallback = await (0, import_mockttp.generateCACertificate)();
327495
+ caKeyPem = fallback.key;
327496
+ caCertPem = fallback.cert;
327497
+ } catch (err) {
327498
+ onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg(err)}`);
327499
+ if (tmpDir) {
327500
+ await (0, import_promises.rm)(tmpDir, { recursive: true, force: true }).catch(() => void 0);
327501
+ }
327502
+ throw err instanceof Error ? err : new Error(String(err));
327503
+ }
327504
+ }
327505
+ const proxy = new MitmProxy(caKeyPem, caCertPem, caCertPath, isPhysical, tlsPassthroughAll, onLog);
327506
+ const proxyPort = await proxy.listen(preferredPort);
327507
+ const proxyHost = resolveProxyHostForDevice(platform3, isPhysical);
327508
+ const hostPort = `${proxyHost}:${proxyPort}`;
327509
+ onLog?.(
327510
+ `[mobile-net] ${tlsPassthroughAll ? "capture proxy" : "MITM proxy"} listening; device target ${hostPort} (${platform3}${isPhysical ? " physical" : ""})`
327511
+ );
327512
+ let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
327513
+ let proxySet = false;
327514
+ let caInstalledOnDevice = false;
327515
+ try {
327516
+ if (platform3 === "ANDROID") {
327517
+ proxySet = await androidSetProxy(deviceId, hostPort, onLog);
327518
+ if (!proxySet) {
327519
+ wiringReason = "adb proxy configuration failed; no device traffic will be routed through the proxy.";
327520
+ } else if (tlsPassthroughAll) {
327521
+ 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}.` : ".");
327522
+ } else if (caCertPath) {
327523
+ const { note } = await androidInstallCa(deviceId, caCertPath);
327524
+ caInstalledOnDevice = true;
327525
+ wiringReason = note;
327526
+ }
327527
+ } else {
327528
+ if (!isPhysical && caCertPath) {
327529
+ const { trusted, note } = await iosSimTrustCa(deviceId, caCertPath);
327530
+ caInstalledOnDevice = trusted;
327531
+ wiringReason = note;
327532
+ } else if (isPhysical) {
327533
+ 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}).` : ".");
327534
+ }
327535
+ }
327536
+ } catch (err) {
327537
+ wiringReason = `device wiring degraded: ${errMsg(err)}`;
327538
+ onLog?.(`[mobile-net] ${wiringReason}`);
327539
+ }
327540
+ if (proxySet || caInstalledOnDevice) {
327541
+ const marker = {
327542
+ platform: platform3,
327543
+ deviceId,
327544
+ isPhysical,
327545
+ proxySet,
327546
+ originalProxy,
327547
+ caInstalledOnDevice
327548
+ };
327549
+ activeCleanups.set(sessionKey, marker);
327550
+ await writePendingCleanup(sessionKey, marker);
327551
+ }
327552
+ const capturedTmpDir = tmpDir;
327553
+ const capturedCaCertPath = caCertPath;
327554
+ let stopped = false;
327555
+ let finalCalls = [];
327556
+ const getCapabilities = () => {
327557
+ if (proxy.isIntercepting) return { intercepting: true };
327558
+ const reason = proxy.interceptionReason ?? wiringReason;
327559
+ return reason ? { intercepting: false, reason } : { intercepting: false };
327560
+ };
327561
+ const stop = async () => {
327562
+ if (stopped) return finalCalls;
327563
+ stopped = true;
327564
+ if (proxySet && platform3 === "ANDROID") {
327565
+ await androidClearProxy(deviceId, originalProxy, onLog);
327566
+ }
327567
+ if (caInstalledOnDevice && capturedCaCertPath) {
327568
+ if (platform3 === "ANDROID") {
327569
+ await androidRemoveCa(deviceId, onLog);
327570
+ } else if (!isPhysical) {
327571
+ await iosSimRemoveCa(deviceId, onLog);
327572
+ }
327573
+ }
327574
+ activeCleanups.delete(sessionKey);
327575
+ await removePendingCleanup(sessionKey);
327576
+ finalCalls = proxy.finalize();
327577
+ await proxy.close();
327578
+ if (capturedTmpDir) {
327579
+ try {
327580
+ await (0, import_promises.rm)(capturedTmpDir, { recursive: true, force: true });
327581
+ } catch (err) {
327582
+ onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg(err)}`);
327583
+ }
327584
+ }
327585
+ const apiCount = finalCalls.filter((c) => c.isApi).length;
327586
+ onLog?.(
327587
+ `[mobile-net] stopped: ${finalCalls.length} calls captured (${apiCount} API; HTTPS interception ${proxy.isIntercepting ? "ACTIVE" : "inactive"}).`
327588
+ );
327589
+ return finalCalls;
327590
+ };
327591
+ return {
327592
+ proxyPort,
327593
+ proxyHost,
327594
+ caCertPath: capturedCaCertPath,
327595
+ getCapabilities,
327596
+ stop
327597
+ };
327598
+ }
327599
+
325475
327600
  // src/services/appium-executor.ts
325476
327601
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
325477
327602
  var WebDriverTransportError = class extends Error {
@@ -325583,6 +327708,86 @@ async function openDeepLink(sessionId, platform3, deeplink, appId) {
325583
327708
  body: JSON.stringify({ script: "mobile: deepLink", args })
325584
327709
  });
325585
327710
  }
327711
+ function readExecuteStringValue(result) {
327712
+ if (result && typeof result === "object" && "value" in result) {
327713
+ const value = result.value;
327714
+ if (typeof value === "string" && value.trim()) return value.trim();
327715
+ }
327716
+ return void 0;
327717
+ }
327718
+ function readActiveBundleId(result) {
327719
+ if (result && typeof result === "object" && "value" in result) {
327720
+ const value = result.value;
327721
+ if (value && typeof value === "object" && "bundleId" in value) {
327722
+ const bundleId = value.bundleId;
327723
+ if (typeof bundleId === "string" && bundleId.trim()) return bundleId.trim();
327724
+ }
327725
+ }
327726
+ return void 0;
327727
+ }
327728
+ function normalizeAndroidActivity(activity, pkg2) {
327729
+ const trimmed = activity?.trim();
327730
+ if (!trimmed) return void 0;
327731
+ const packageName = pkg2?.trim();
327732
+ if (!packageName) return trimmed;
327733
+ if (trimmed.startsWith(".")) return `${packageName}${trimmed}`;
327734
+ if (!trimmed.includes(".")) return `${packageName}.${trimmed}`;
327735
+ return trimmed;
327736
+ }
327737
+ async function executeMobileScript(sessionId, script, args) {
327738
+ return wdRequest(`/session/${sessionId}/execute/sync`, {
327739
+ method: "POST",
327740
+ body: JSON.stringify({ script, args })
327741
+ });
327742
+ }
327743
+ async function readForegroundScreenSignal(sessionId, platform3) {
327744
+ if (platform3 === "ANDROID") {
327745
+ const [packageResult, activityResult] = await Promise.all([
327746
+ executeMobileScript(sessionId, "mobile: getCurrentPackage", []),
327747
+ executeMobileScript(sessionId, "mobile: getCurrentActivity", []).catch(() => void 0)
327748
+ ]);
327749
+ const pkg2 = readExecuteStringValue(packageResult);
327750
+ if (!pkg2) return null;
327751
+ const activity = normalizeAndroidActivity(readExecuteStringValue(activityResult), pkg2);
327752
+ return {
327753
+ platform: platform3,
327754
+ bundleId: pkg2,
327755
+ ...activity ? { activity } : {},
327756
+ screenHash: "foreground"
327757
+ };
327758
+ }
327759
+ const infoResult = await executeMobileScript(sessionId, "mobile: activeAppInfo", []);
327760
+ const bundleId = readActiveBundleId(infoResult);
327761
+ if (!bundleId) return null;
327762
+ return { platform: platform3, bundleId, screenHash: "foreground" };
327763
+ }
327764
+ async function activateTargetApp(sessionId, platform3, appId) {
327765
+ const args = platform3 === "IOS" ? [{ bundleId: appId }] : [{ appId }];
327766
+ await executeMobileScript(sessionId, "mobile: activateApp", args);
327767
+ }
327768
+ async function restoreTargetAppIfExternal(sessionId, target, log2, source, failOnExternal) {
327769
+ let boundary = null;
327770
+ try {
327771
+ const signal = await readForegroundScreenSignal(sessionId, target.platform);
327772
+ boundary = signal ? (0, import_runner_core8.classifyMobileScreenBoundary)(signal, target.appId, target.platform) : null;
327773
+ } catch (err) {
327774
+ log2(` App boundary check skipped after ${source}: ${err instanceof Error ? err.message : String(err)}`);
327775
+ return;
327776
+ }
327777
+ if (!boundary || (0, import_runner_core8.isTargetLikeMobileBoundary)(boundary) || boundary.kind === "transient_external") {
327778
+ return;
327779
+ }
327780
+ const message = `Foreground app changed to ${boundary.owner}, outside target app ${boundary.targetAppId}`;
327781
+ log2(` ${message}; activating target app.`);
327782
+ try {
327783
+ await activateTargetApp(sessionId, target.platform, target.appId);
327784
+ } catch (err) {
327785
+ log2(` Could not reactivate ${target.appId}: ${err instanceof Error ? err.message : String(err)}`);
327786
+ }
327787
+ if (failOnExternal) {
327788
+ throw new Error(`${message}; relaunched target app and failed the step so mobile tests do not continue in another app.`);
327789
+ }
327790
+ }
325586
327791
  async function deleteSession(sessionId) {
325587
327792
  try {
325588
327793
  await wdRequest(`/session/${sessionId}`, { method: "DELETE" });
@@ -325709,6 +327914,29 @@ async function getActiveElementId(sessionId) {
325709
327914
  return null;
325710
327915
  }
325711
327916
  }
327917
+ async function sendKeysToFocusedInput(sessionId, value) {
327918
+ await wdRequest(`/session/${sessionId}/keys`, {
327919
+ method: "POST",
327920
+ body: JSON.stringify({ text: value, value: Array.from(value) })
327921
+ });
327922
+ }
327923
+ function shouldAbortAfterStepFailure(action) {
327924
+ switch (action) {
327925
+ case "launchApp":
327926
+ case "tap":
327927
+ case "type":
327928
+ case "clear":
327929
+ case "swipe":
327930
+ case "scroll":
327931
+ case "back":
327932
+ case "home":
327933
+ case "waitForElement":
327934
+ return true;
327935
+ case "assertVisible":
327936
+ case "assertText":
327937
+ return false;
327938
+ }
327939
+ }
325712
327940
  async function executeTap(sessionId, step) {
325713
327941
  const bounds = boundsFromStep(step);
325714
327942
  if (step.target) {
@@ -325730,8 +327958,10 @@ async function executeTap(sessionId, step) {
325730
327958
  }
325731
327959
  async function executeType(sessionId, step) {
325732
327960
  if (step.value === void 0 || step.value === null || step.value === "") return;
327961
+ const value = String(step.value);
325733
327962
  const bounds = boundsFromStep(step);
325734
327963
  let elementId = null;
327964
+ let tappedBounds = false;
325735
327965
  if (step.target) {
325736
327966
  try {
325737
327967
  elementId = await findElement(sessionId, step.target, readStepTimeout(step));
@@ -325741,19 +327971,33 @@ async function executeType(sessionId, step) {
325741
327971
  }
325742
327972
  if (!elementId && bounds) {
325743
327973
  await tapCoordinates(sessionId, bounds);
327974
+ tappedBounds = true;
325744
327975
  await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
325745
327976
  elementId = await getActiveElementId(sessionId);
325746
327977
  }
325747
327978
  if (!elementId) {
325748
327979
  elementId = await getActiveElementId(sessionId);
325749
327980
  }
325750
- if (!elementId) {
325751
- throw new Error("TYPE step could not resolve a target or focused element");
327981
+ if (elementId) {
327982
+ try {
327983
+ await wdRequest(`/session/${sessionId}/element/${elementId}/value`, {
327984
+ method: "POST",
327985
+ body: JSON.stringify({ text: value })
327986
+ });
327987
+ return;
327988
+ } catch (error2) {
327989
+ if (!bounds) throw error2;
327990
+ }
325752
327991
  }
325753
- await wdRequest(`/session/${sessionId}/element/${elementId}/value`, {
325754
- method: "POST",
325755
- body: JSON.stringify({ text: step.value })
325756
- });
327992
+ if (bounds) {
327993
+ if (!tappedBounds) {
327994
+ await tapCoordinates(sessionId, bounds);
327995
+ await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
327996
+ }
327997
+ await sendKeysToFocusedInput(sessionId, value);
327998
+ return;
327999
+ }
328000
+ throw new Error("TYPE step could not resolve a target or focused element");
325757
328001
  }
325758
328002
  async function executeClear(sessionId, step) {
325759
328003
  const bounds = boundsFromStep(step);
@@ -325857,7 +328101,24 @@ async function executeMobileTest(options) {
325857
328101
  const startTime = Date.now();
325858
328102
  const stepResults = [];
325859
328103
  const screenshots = [];
328104
+ let apiCalls = [];
325860
328105
  let sessionId = null;
328106
+ let capture = null;
328107
+ let captureStopped = false;
328108
+ const stopCapture = async () => {
328109
+ if (!capture || captureStopped) return apiCalls;
328110
+ captureStopped = true;
328111
+ try {
328112
+ const captured = await capture.stop();
328113
+ apiCalls = captured.map((call) => ({
328114
+ ...call,
328115
+ pageUrl: call.pageUrl ?? `mobile-test:${options.target.appId}`
328116
+ }));
328117
+ } catch (err) {
328118
+ log2(`[mobile-net] capture stop failed: ${err instanceof Error ? err.message : String(err)}`);
328119
+ }
328120
+ return apiCalls;
328121
+ };
325861
328122
  try {
325862
328123
  log2("Checking Appium server health...");
325863
328124
  const health = await checkAppiumHealth();
@@ -325885,6 +328146,21 @@ async function executeMobileTest(options) {
325885
328146
  selectedDevice = matchingDevices[0];
325886
328147
  }
325887
328148
  log2(`Using device: ${selectedDevice.name} (${selectedDevice.id})`);
328149
+ try {
328150
+ capture = await startMobileNetworkCapture({
328151
+ platform: options.target.platform,
328152
+ deviceId: selectedDevice.id,
328153
+ appId: options.target.appId,
328154
+ isPhysical: selectedDevice.isPhysical,
328155
+ onLog: (line) => log2(line)
328156
+ });
328157
+ const caps = capture.getCapabilities();
328158
+ if (!caps.intercepting) {
328159
+ log2(`[mobile-net] HTTPS interception unavailable: ${caps.reason ?? "unknown reason"} (continuing - plaintext/metadata only)`);
328160
+ }
328161
+ } catch (err) {
328162
+ log2(`[mobile-net] capture unavailable: ${err instanceof Error ? err.message : String(err)} (continuing without network log)`);
328163
+ }
325888
328164
  if (selectedDevice.installedApps && selectedDevice.installedApps.length > 0) {
325889
328165
  if (!selectedDevice.installedApps.includes(options.target.appId)) {
325890
328166
  throw new Error(
@@ -325925,6 +328201,7 @@ async function executeMobileTest(options) {
325925
328201
  log2(` \u2717 Deep link failed: ${err instanceof Error ? err.message : String(err)}`);
325926
328202
  }
325927
328203
  }
328204
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
325928
328205
  let environmental = null;
325929
328206
  for (const step of options.steps) {
325930
328207
  const stepStart = Date.now();
@@ -325932,6 +328209,7 @@ async function executeMobileTest(options) {
325932
328209
  let status = "passed";
325933
328210
  let stepError;
325934
328211
  try {
328212
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} pre-check`, false);
325935
328213
  switch (step.action) {
325936
328214
  case "launchApp":
325937
328215
  log2(" App launched (handled by session creation)");
@@ -325971,6 +328249,7 @@ async function executeMobileTest(options) {
325971
328249
  break;
325972
328250
  }
325973
328251
  }
328252
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
325974
328253
  } catch (err) {
325975
328254
  status = "failed";
325976
328255
  stepError = err instanceof Error ? err.message : String(err);
@@ -325991,13 +328270,19 @@ async function executeMobileTest(options) {
325991
328270
  log2(`Aborting remaining steps \u2014 Appium session is no longer usable: ${environmental}`);
325992
328271
  break;
325993
328272
  }
328273
+ if (status === "failed" && shouldAbortAfterStepFailure(step.action)) {
328274
+ log2(`Aborting remaining steps \u2014 ${step.action} failed, so downstream mobile steps are no longer reliable.`);
328275
+ break;
328276
+ }
325994
328277
  }
325995
328278
  const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
325996
328279
  const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
328280
+ await stopCapture();
325997
328281
  return {
325998
328282
  passed: allPassed,
325999
328283
  stepResults,
326000
328284
  screenshots,
328285
+ apiCalls,
326001
328286
  logs: logLines.join("\n"),
326002
328287
  error: firstError,
326003
328288
  duration: Date.now() - startTime,
@@ -326006,10 +328291,12 @@ async function executeMobileTest(options) {
326006
328291
  } catch (err) {
326007
328292
  const errorMsg = err instanceof Error ? err.message : String(err);
326008
328293
  log2(`Fatal error: ${errorMsg}`);
328294
+ await stopCapture();
326009
328295
  return {
326010
328296
  passed: false,
326011
328297
  stepResults,
326012
328298
  screenshots,
328299
+ apiCalls,
326013
328300
  logs: logLines.join("\n"),
326014
328301
  error: errorMsg,
326015
328302
  duration: Date.now() - startTime,
@@ -326019,6 +328306,7 @@ async function executeMobileTest(options) {
326019
328306
  environmental: errorMsg
326020
328307
  };
326021
328308
  } finally {
328309
+ await stopCapture();
326022
328310
  if (sessionId) {
326023
328311
  log2("Tearing down Appium session...");
326024
328312
  await deleteSession(sessionId);
@@ -326026,7 +328314,7 @@ async function executeMobileTest(options) {
326026
328314
  }
326027
328315
  }
326028
328316
  }
326029
- async function createMobileDiscoverySession(target) {
328317
+ async function createMobileDiscoverySession(target, options) {
326030
328318
  const health = await checkAppiumHealth();
326031
328319
  if (!health.ok) {
326032
328320
  await startAppiumServer();
@@ -326053,6 +328341,12 @@ async function createMobileDiscoverySession(target) {
326053
328341
  `App ${target.appId} not installed on ${selectedDevice.name}. Install it first.`
326054
328342
  );
326055
328343
  }
328344
+ await options?.onDeviceSelected?.({
328345
+ deviceId: selectedDevice.id,
328346
+ name: selectedDevice.name,
328347
+ isPhysical: selectedDevice.isPhysical === true,
328348
+ platformVersion: selectedDevice.platformVersion
328349
+ });
326056
328350
  const sessionId = await createSession({
326057
328351
  appId: target.appId,
326058
328352
  platform: target.platform,
@@ -327245,7 +329539,7 @@ function detachMirrorSession(sessionId) {
327245
329539
  }
327246
329540
 
327247
329541
  // src/services/mobile/mobile-bridge-driver.ts
327248
- var import_runner_core7 = __toESM(require_dist2());
329542
+ var import_runner_core9 = __toESM(require_dist2());
327249
329543
  init_dist();
327250
329544
  var APPIUM_SESSION_ID_PATTERN2 = /^[a-f0-9-]{1,64}$/i;
327251
329545
  function readStringValue(result) {
@@ -327255,6 +329549,15 @@ function readStringValue(result) {
327255
329549
  }
327256
329550
  return void 0;
327257
329551
  }
329552
+ function normalizeAndroidActivity2(activity, pkg2) {
329553
+ const trimmed = activity?.trim();
329554
+ if (!trimmed) return void 0;
329555
+ const packageName = pkg2?.trim();
329556
+ if (!packageName) return trimmed;
329557
+ if (trimmed.startsWith(".")) return `${packageName}${trimmed}`;
329558
+ if (!trimmed.includes(".")) return `${packageName}.${trimmed}`;
329559
+ return trimmed;
329560
+ }
327258
329561
  var MobileBridgeDriver = class {
327259
329562
  sessionId;
327260
329563
  platform;
@@ -327281,6 +329584,38 @@ var MobileBridgeDriver = class {
327281
329584
  appLifecycleArgs() {
327282
329585
  return this.platform === "ANDROID" ? { appId: this.appId } : { bundleId: this.appId };
327283
329586
  }
329587
+ baseScreenSignal(source) {
329588
+ const parsed = (0, import_runner_core9.parseMobileSource)(source, this.platform);
329589
+ return {
329590
+ platform: this.platform,
329591
+ screenHash: parsed.screenSignal.screenHash,
329592
+ ...parsed.screenSignal.activity ? { activity: parsed.screenSignal.activity } : {},
329593
+ ...parsed.screenSignal.bundleId ? { bundleId: parsed.screenSignal.bundleId } : {}
329594
+ };
329595
+ }
329596
+ async enrichScreenSignal(source) {
329597
+ const signal = this.baseScreenSignal(source);
329598
+ try {
329599
+ if (this.platform === "ANDROID") {
329600
+ const [activityRes, packageRes] = await Promise.all([
329601
+ this.executeScript("mobile: getCurrentActivity", []).catch(() => void 0),
329602
+ this.executeScript("mobile: getCurrentPackage", []).catch(() => void 0)
329603
+ ]);
329604
+ const activity = readStringValue(activityRes);
329605
+ const pkg2 = readStringValue(packageRes);
329606
+ if (pkg2) signal.bundleId = pkg2;
329607
+ const normalizedActivity = normalizeAndroidActivity2(activity, pkg2);
329608
+ if (normalizedActivity) signal.activity = normalizedActivity;
329609
+ } else {
329610
+ const infoRes = await this.executeScript("mobile: activeAppInfo", []).catch(() => void 0);
329611
+ const bundleId = readActiveBundleId2(infoRes);
329612
+ if (bundleId) signal.bundleId = bundleId;
329613
+ }
329614
+ } catch {
329615
+ }
329616
+ if (!signal.bundleId) signal.bundleId = this.appId;
329617
+ return signal;
329618
+ }
327284
329619
  /**
327285
329620
  * Run a `mobile:` script via execute/sync. Used by the NEW lifecycle
327286
329621
  * primitives (terminateApp/activateApp/deepLink/getCurrentActivity) that have
@@ -327295,9 +329630,7 @@ var MobileBridgeDriver = class {
327295
329630
  // ── Observation ────────────────────────────────────────
327296
329631
  /**
327297
329632
  * One round-trip observation: UI-tree XML + base64 screenshot + window size,
327298
- * plus a best-effort screen signal derived from the parsed XML (cheap — no
327299
- * extra device round-trip; the activity/bundleId enrichment is left to
327300
- * getScreenSignal()).
329633
+ * plus a best-effort screen signal enriched with the foreground app identity.
327301
329634
  */
327302
329635
  async snapshot() {
327303
329636
  const [xml, screenshot, windowSize] = await Promise.all([
@@ -327306,10 +329639,7 @@ var MobileBridgeDriver = class {
327306
329639
  getWindowSize2(this.sessionPath)
327307
329640
  ]);
327308
329641
  const source = xml ?? "";
327309
- const screenSignal = {
327310
- platform: this.platform,
327311
- screenHash: (0, import_runner_core7.parseMobileSource)(source, this.platform).screenSignal.screenHash
327312
- };
329642
+ const screenSignal = await this.enrichScreenSignal(source);
327313
329643
  return {
327314
329644
  xml: source,
327315
329645
  screenshot,
@@ -327338,7 +329668,8 @@ var MobileBridgeDriver = class {
327338
329668
  getScreenshot(this.sessionPath),
327339
329669
  getPageSource(this.sessionPath)
327340
329670
  ]);
327341
- return { ok: true, screenshot, source };
329671
+ const screenSignal = await this.enrichScreenSignal(source ?? "");
329672
+ return { ok: true, screenshot, source, screenSignal };
327342
329673
  }
327343
329674
  // ── Action primitives (1:1 with the bridge handleAction verbs) ──
327344
329675
  /** Center-tap the given bounds (coordinate-only; the loop always supplies bounds). */
@@ -327416,30 +329747,10 @@ var MobileBridgeDriver = class {
327416
329747
  */
327417
329748
  async getScreenSignal() {
327418
329749
  const source = await getPageSource(this.sessionPath) ?? "";
327419
- const screenHash = (0, import_runner_core7.parseMobileSource)(source, this.platform).screenSignal.screenHash;
327420
- const signal = { platform: this.platform, screenHash };
327421
- try {
327422
- if (this.platform === "ANDROID") {
327423
- const [activityRes, packageRes] = await Promise.all([
327424
- this.executeScript("mobile: getCurrentActivity", []).catch(() => void 0),
327425
- this.executeScript("mobile: getCurrentPackage", []).catch(() => void 0)
327426
- ]);
327427
- const activity = readStringValue(activityRes);
327428
- const pkg2 = readStringValue(packageRes);
327429
- if (activity) signal.activity = activity;
327430
- signal.bundleId = pkg2 ?? this.appId;
327431
- } else {
327432
- const infoRes = await this.executeScript("mobile: activeAppInfo", []).catch(() => void 0);
327433
- const bundleId = readActiveBundleId(infoRes);
327434
- signal.bundleId = bundleId ?? this.appId;
327435
- }
327436
- } catch {
327437
- if (!signal.bundleId) signal.bundleId = this.appId;
327438
- }
327439
- return signal;
329750
+ return this.enrichScreenSignal(source);
327440
329751
  }
327441
329752
  };
327442
- function readActiveBundleId(result) {
329753
+ function readActiveBundleId2(result) {
327443
329754
  if (result && typeof result === "object" && "value" in result) {
327444
329755
  const value = result.value;
327445
329756
  if (value && typeof value === "object" && "bundleId" in value) {
@@ -327450,614 +329761,6 @@ function readActiveBundleId(result) {
327450
329761
  return void 0;
327451
329762
  }
327452
329763
 
327453
- // src/services/mobile/mobile-network-capture.ts
327454
- var import_node_child_process = require("child_process");
327455
- var import_node_crypto = require("crypto");
327456
- var import_promises = require("fs/promises");
327457
- var import_node_os = require("os");
327458
- var import_node_path = require("path");
327459
- var import_node_util = require("util");
327460
- var import_mockttp = require("mockttp");
327461
- var import_runner_core8 = __toESM(require_dist2());
327462
- var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
327463
- var MAX_CALLS = 1e3;
327464
- var MAX_BODY_CAPTURE_BYTES = 64 * 1024;
327465
- var DEVICE_CMD_TIMEOUT_MS = 15e3;
327466
- var CA_KEY_BITS = 2048;
327467
- var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
327468
- var PENDING_CLEANUP_DIR = (0, import_node_path.join)((0, import_node_os.tmpdir)(), "validateqa-mobile-capture-pending");
327469
- function rawHeadersToPairs(rawHeaders) {
327470
- return rawHeaders.map(([name, value]) => ({ name, value }));
327471
- }
327472
- function headerValue(pairs, name) {
327473
- const lower = name.toLowerCase();
327474
- return pairs.find((h) => h.name.toLowerCase() === lower)?.value;
327475
- }
327476
- function resolveHostIp() {
327477
- const ifaces = (0, import_node_os.networkInterfaces)();
327478
- for (const addrs of Object.values(ifaces)) {
327479
- if (!addrs) continue;
327480
- for (const addr of addrs) {
327481
- if (addr.family === "IPv4" && !addr.internal) return addr.address;
327482
- }
327483
- }
327484
- return "127.0.0.1";
327485
- }
327486
- function resolveProxyHostForDevice(platform3, isPhysical) {
327487
- if (platform3 === "ANDROID" && !isPhysical) return ANDROID_EMULATOR_HOST_LOOPBACK;
327488
- if (platform3 === "IOS" && !isPhysical) return "127.0.0.1";
327489
- return resolveHostIp();
327490
- }
327491
- function normalizeRemoteAddress(addr) {
327492
- if (!addr) return "";
327493
- return addr.startsWith("::ffff:") ? addr.slice("::ffff:".length) : addr;
327494
- }
327495
- function isLoopbackAddress(addr) {
327496
- return addr === "127.0.0.1" || addr.startsWith("127.") || addr === "::1" || addr === "";
327497
- }
327498
- function isPrivateAddress(addr) {
327499
- if (/^10\./.test(addr)) return true;
327500
- if (/^192\.168\./.test(addr)) return true;
327501
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
327502
- if (/^169\.254\./.test(addr)) return true;
327503
- const lower = addr.toLowerCase();
327504
- if (lower.startsWith("fe80:")) return true;
327505
- if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
327506
- return false;
327507
- }
327508
- function isAllowedProxyPeer(remoteAddress, isPhysical) {
327509
- const addr = normalizeRemoteAddress(remoteAddress);
327510
- if (isLoopbackAddress(addr)) return true;
327511
- return isPhysical && isPrivateAddress(addr);
327512
- }
327513
- function installSourceIpGuard(server3, isPhysical, onLog) {
327514
- const underlying = server3.server;
327515
- if (!underlying || typeof underlying.on !== "function") {
327516
- onLog?.(
327517
- "[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."
327518
- );
327519
- return;
327520
- }
327521
- underlying.on("connection", (socket) => {
327522
- if (!isAllowedProxyPeer(socket.remoteAddress, isPhysical)) {
327523
- onLog?.(`[mobile-net] rejected proxy connection from disallowed source ${socket.remoteAddress}`);
327524
- socket.destroy();
327525
- }
327526
- });
327527
- }
327528
- function isTextish(contentType) {
327529
- if (!contentType) return false;
327530
- return /json|text|xml|graphql|html|csv|javascript|urlencoded/i.test(contentType);
327531
- }
327532
- async function readBodyText(body, contentType) {
327533
- if (!isTextish(contentType)) return void 0;
327534
- try {
327535
- const decoded = await body.getDecodedBuffer();
327536
- if (!decoded || decoded.length === 0) return void 0;
327537
- if (decoded.length > MAX_BODY_CAPTURE_BYTES) {
327538
- return decoded.subarray(0, MAX_BODY_CAPTURE_BYTES).toString("utf-8");
327539
- }
327540
- return decoded.toString("utf-8");
327541
- } catch {
327542
- return void 0;
327543
- }
327544
- }
327545
- var MitmProxy = class {
327546
- constructor(caKeyPem, caCertPem, isPhysical, onLog) {
327547
- this.caKeyPem = caKeyPem;
327548
- this.caCertPem = caCertPem;
327549
- this.isPhysical = isPhysical;
327550
- this.onLog = onLog;
327551
- }
327552
- server = null;
327553
- inFlight = /* @__PURE__ */ new Map();
327554
- calls = [];
327555
- dropped = 0;
327556
- /** Flips true once any HTTPS response is successfully MITM-ed. */
327557
- httpsIntercepted = false;
327558
- /** Best-known reason interception is unavailable, refined by TLS failures. */
327559
- tlsFailureReason = null;
327560
- /** Start listening; resolves with the actual bound port. */
327561
- async listen(preferredPort) {
327562
- const server3 = (0, import_mockttp.getLocal)({
327563
- // Our generated CA — mockttp mints per-host leaf certs signed by it.
327564
- https: { key: this.caKeyPem, cert: this.caCertPem },
327565
- // HTTP/2 with HTTP/1.1 fallback; mobile clients negotiate either.
327566
- http2: "fallback",
327567
- // Keep the proxy quiet unless explicitly debugging.
327568
- recordTraffic: false
327569
- });
327570
- await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
327571
- await server3.on("request", (req) => this.onRequest(req));
327572
- await server3.on("response", (res) => this.onResponse(res));
327573
- await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
327574
- await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
327575
- installSourceIpGuard(server3, this.isPhysical, this.onLog);
327576
- this.server = server3;
327577
- return server3.port;
327578
- }
327579
- /** Captured calls, finalized through the shared redaction constructor. */
327580
- finalize() {
327581
- return this.calls.map(
327582
- (c) => (0, import_runner_core8.buildApiCallSummary)({
327583
- method: c.method,
327584
- url: c.url,
327585
- status: c.status,
327586
- durationMs: c.durationMs,
327587
- authHeader: c.authHeader,
327588
- cookieHeader: c.cookieHeader,
327589
- requestContentType: c.requestContentType,
327590
- responseContentType: c.responseContentType,
327591
- respHeaders: c.respHeaders,
327592
- reqBodyText: c.reqBodyText,
327593
- respBodyText: c.respBodyText,
327594
- responseSize: c.responseSize,
327595
- timestamp: c.startedAt,
327596
- testPhase: "UNKNOWN"
327597
- })
327598
- );
327599
- }
327600
- /** True once at least one HTTPS exchange was decrypted (CA trusted). */
327601
- get isIntercepting() {
327602
- return this.httpsIntercepted;
327603
- }
327604
- /** A precise reason interception is (still) unavailable, or null if it works. */
327605
- get interceptionReason() {
327606
- if (this.httpsIntercepted) return null;
327607
- return this.tlsFailureReason;
327608
- }
327609
- async close() {
327610
- const server3 = this.server;
327611
- this.server = null;
327612
- this.inFlight.clear();
327613
- if (!server3) return;
327614
- try {
327615
- await server3.stop();
327616
- } catch (err) {
327617
- this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg(err)}`);
327618
- }
327619
- this.onLog?.(
327620
- `[mobile-net] proxy closed: ${this.calls.length} calls captured` + (this.dropped > 0 ? ` (${this.dropped} dropped over cap)` : "")
327621
- );
327622
- }
327623
- // ── request → buffer by id ────────────────────────────────────────────────
327624
- async onRequest(req) {
327625
- try {
327626
- const reqHeaders = rawHeadersToPairs(req.rawHeaders);
327627
- const requestContentType = headerValue(reqHeaders, "content-type");
327628
- const isHttps = isHttpsUrl(req.url);
327629
- const reqBodyText = (0, import_runner_core8.isApiCall)(req.url, requestContentType) ? await readBodyText(req.body, requestContentType) : void 0;
327630
- this.inFlight.set(req.id, {
327631
- method: req.method,
327632
- url: req.url,
327633
- startedAt: req.timingEvents.startTime,
327634
- reqHeaders,
327635
- requestContentType,
327636
- authHeader: headerValue(reqHeaders, "authorization"),
327637
- cookieHeader: headerValue(reqHeaders, "cookie"),
327638
- reqBodyText,
327639
- isHttps
327640
- });
327641
- } catch (err) {
327642
- this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg(err)}`);
327643
- }
327644
- }
327645
- // ── response → finalize the matched request ───────────────────────────────
327646
- async onResponse(res) {
327647
- const pending = this.inFlight.get(res.id);
327648
- if (!pending) return;
327649
- this.inFlight.delete(res.id);
327650
- try {
327651
- if (pending.isHttps && !this.httpsIntercepted) {
327652
- this.httpsIntercepted = true;
327653
- this.tlsFailureReason = null;
327654
- this.onLog?.("[mobile-net] HTTPS interception confirmed (CA trusted; bodies decrypted).");
327655
- }
327656
- const respHeaders = rawHeadersToPairs(res.rawHeaders);
327657
- const responseContentType = headerValue(respHeaders, "content-type");
327658
- const api = (0, import_runner_core8.isApiCall)(pending.url, responseContentType);
327659
- if (!api && (0, import_runner_core8.isStaticAssetByExt)(pending.url)) return;
327660
- if (this.calls.length >= MAX_CALLS) {
327661
- this.dropped++;
327662
- return;
327663
- }
327664
- const respBodyText = api ? await readBodyText(res.body, responseContentType) : void 0;
327665
- const responseSize = await measureBody(res.body);
327666
- const durationMs = computeDuration(pending.startedAt, res);
327667
- this.calls.push({
327668
- method: pending.method,
327669
- url: pending.url,
327670
- status: res.statusCode,
327671
- startedAt: pending.startedAt,
327672
- durationMs,
327673
- reqHeaders: pending.reqHeaders,
327674
- respHeaders,
327675
- requestContentType: pending.requestContentType,
327676
- responseContentType,
327677
- authHeader: pending.authHeader,
327678
- cookieHeader: pending.cookieHeader,
327679
- reqBodyText: pending.reqBodyText,
327680
- respBodyText,
327681
- responseSize,
327682
- intercepted: true
327683
- });
327684
- } catch (err) {
327685
- this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg(err)}`);
327686
- }
327687
- }
327688
- // ── tls-client-error → record WHY interception failed ─────────────────────
327689
- onTlsClientError(failure) {
327690
- if (this.httpsIntercepted) return;
327691
- const host = failure.destination?.hostname ?? failure.tlsMetadata.sniHostname ?? "unknown host";
327692
- let reason;
327693
- switch (failure.failureCause) {
327694
- case "cert-rejected":
327695
- 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.`;
327696
- break;
327697
- case "closed":
327698
- case "reset":
327699
- 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).`;
327700
- break;
327701
- case "no-shared-cipher":
327702
- reason = `TLS handshake to ${host} failed: no shared cipher with the client.`;
327703
- break;
327704
- case "handshake-timeout":
327705
- reason = `TLS handshake to ${host} timed out before completing.`;
327706
- break;
327707
- default:
327708
- reason = `TLS handshake to ${host} failed (${failure.failureCause}); HTTPS not intercepted for it.`;
327709
- }
327710
- if (!this.tlsFailureReason || failure.failureCause === "cert-rejected" || failure.failureCause === "closed" || failure.failureCause === "reset") {
327711
- this.tlsFailureReason = reason;
327712
- }
327713
- this.onLog?.(`[mobile-net] ${reason}`);
327714
- }
327715
- };
327716
- function isHttpsUrl(url) {
327717
- try {
327718
- return new URL(url).protocol === "https:";
327719
- } catch {
327720
- return false;
327721
- }
327722
- }
327723
- async function measureBody(body) {
327724
- try {
327725
- const decoded = await body.getDecodedBuffer();
327726
- return decoded ? decoded.length : 0;
327727
- } catch {
327728
- return 0;
327729
- }
327730
- }
327731
- function computeDuration(startedAt, res) {
327732
- const sent = res.timingEvents.responseSentTimestamp;
327733
- const start = res.timingEvents.startTimestamp;
327734
- if (typeof sent === "number" && typeof start === "number" && sent >= start) {
327735
- return Math.round(sent - start);
327736
- }
327737
- const elapsed = Date.now() - startedAt;
327738
- return elapsed > 0 ? elapsed : 0;
327739
- }
327740
- async function generateCaCert(dir, onLog) {
327741
- try {
327742
- const { key, cert } = await (0, import_mockttp.generateCACertificate)({
327743
- subject: {
327744
- commonName: "validate.qa Mobile Capture CA",
327745
- organizationName: "validate.qa"
327746
- },
327747
- bits: CA_KEY_BITS
327748
- // mockttp's generated CA carries its own (multi-year) validity window; we
327749
- // rely on stop() to remove the cert from the device promptly after the run
327750
- // rather than on a short expiry.
327751
- });
327752
- const caCertPath = (0, import_node_path.join)(dir, "validateqa-mobile-ca.pem");
327753
- await (0, import_promises.writeFile)(caCertPath, cert, "utf-8");
327754
- return { caCertPath, caKeyPem: key, caCertPem: cert };
327755
- } catch (err) {
327756
- onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg(err)}`);
327757
- return null;
327758
- }
327759
- }
327760
- async function androidReadProxy(deviceId) {
327761
- try {
327762
- const { stdout } = await execFileAsync(
327763
- "adb",
327764
- ["-s", deviceId, "shell", "settings", "get", "global", "http_proxy"],
327765
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327766
- );
327767
- const value = stdout.trim();
327768
- return value === "" || value === "null" ? null : value;
327769
- } catch {
327770
- return null;
327771
- }
327772
- }
327773
- async function androidSetProxy(deviceId, hostPort, onLog) {
327774
- try {
327775
- await execFileAsync(
327776
- "adb",
327777
- ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", hostPort],
327778
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327779
- );
327780
- return true;
327781
- } catch (err) {
327782
- onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg(err)}`);
327783
- return false;
327784
- }
327785
- }
327786
- async function androidClearProxy(deviceId, originalProxy, onLog) {
327787
- const restoreValue = originalProxy && originalProxy.length > 0 ? originalProxy : ":0";
327788
- try {
327789
- await execFileAsync(
327790
- "adb",
327791
- ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue],
327792
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327793
- );
327794
- } catch (err) {
327795
- onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg(err)}`);
327796
- }
327797
- }
327798
- async function androidInstallCa(deviceId, caCertPath) {
327799
- const devicePath = "/sdcard/validateqa-mobile-ca.pem";
327800
- try {
327801
- await execFileAsync("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
327802
- timeout: DEVICE_CMD_TIMEOUT_MS
327803
- });
327804
- return {
327805
- installed: false,
327806
- 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."
327807
- };
327808
- } catch (err) {
327809
- return { installed: false, note: `CA push failed: ${errMsg(err)}` };
327810
- }
327811
- }
327812
- async function androidRemoveCa(deviceId, onLog) {
327813
- try {
327814
- await execFileAsync(
327815
- "adb",
327816
- ["-s", deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"],
327817
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327818
- );
327819
- } catch (err) {
327820
- onLog?.(`[mobile-net] adb remove CA failed: ${errMsg(err)}`);
327821
- }
327822
- }
327823
- async function iosSimTrustCa(deviceId, caCertPath) {
327824
- try {
327825
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
327826
- timeout: DEVICE_CMD_TIMEOUT_MS
327827
- });
327828
- return {
327829
- trusted: true,
327830
- 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."
327831
- };
327832
- } catch (err) {
327833
- return { trusted: false, note: `simctl add-root-cert failed: ${errMsg(err)}` };
327834
- }
327835
- }
327836
- async function iosSimRemoveCa(deviceId, onLog) {
327837
- if (process.env.VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET === "1") {
327838
- try {
327839
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "reset"], {
327840
- timeout: DEVICE_CMD_TIMEOUT_MS
327841
- });
327842
- onLog?.(`[mobile-net] simctl keychain reset run for ${deviceId} (opt-in; cleared ALL sim certs).`);
327843
- } catch (err) {
327844
- onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg(err)}`);
327845
- }
327846
- return;
327847
- }
327848
- onLog?.(
327849
- `[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.`
327850
- );
327851
- }
327852
- function errMsg(err) {
327853
- return err instanceof Error ? err.message : String(err);
327854
- }
327855
- var activeCleanups = /* @__PURE__ */ new Map();
327856
- var exitHandlersInstalled = false;
327857
- function markerPath(sessionKey) {
327858
- return (0, import_node_path.join)(PENDING_CLEANUP_DIR, `${sessionKey}.json`);
327859
- }
327860
- async function writePendingCleanup(sessionKey, marker) {
327861
- try {
327862
- await (0, import_promises.mkdir)(PENDING_CLEANUP_DIR, { recursive: true });
327863
- await (0, import_promises.writeFile)(markerPath(sessionKey), JSON.stringify(marker), "utf-8");
327864
- } catch {
327865
- }
327866
- }
327867
- async function removePendingCleanup(sessionKey) {
327868
- try {
327869
- await (0, import_promises.unlink)(markerPath(sessionKey));
327870
- } catch {
327871
- }
327872
- }
327873
- function restoreDeviceSync(marker, onLog) {
327874
- const run2 = (args) => {
327875
- try {
327876
- (0, import_node_child_process.execFileSync)("adb", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
327877
- } catch {
327878
- }
327879
- };
327880
- if (marker.platform === "ANDROID") {
327881
- if (marker.proxySet) {
327882
- const restoreValue = marker.originalProxy && marker.originalProxy.length > 0 ? marker.originalProxy : ":0";
327883
- run2(["-s", marker.deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue]);
327884
- }
327885
- if (marker.caInstalledOnDevice) {
327886
- run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
327887
- }
327888
- }
327889
- onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
327890
- }
327891
- async function reconcilePendingCleanups(onLog) {
327892
- let files;
327893
- try {
327894
- files = await (0, import_promises.readdir)(PENDING_CLEANUP_DIR);
327895
- } catch {
327896
- return;
327897
- }
327898
- for (const file of files) {
327899
- if (!file.endsWith(".json")) continue;
327900
- const full = (0, import_node_path.join)(PENDING_CLEANUP_DIR, file);
327901
- try {
327902
- const raw = await (0, import_promises.readFile)(full, "utf-8");
327903
- const marker = JSON.parse(raw);
327904
- if (marker && typeof marker.deviceId === "string" && marker.platform) {
327905
- onLog?.(`[mobile-net] reconciling orphaned capture cleanup for ${marker.deviceId}.`);
327906
- restoreDeviceSync(marker, onLog);
327907
- }
327908
- } catch {
327909
- }
327910
- try {
327911
- await (0, import_promises.unlink)(full);
327912
- } catch {
327913
- }
327914
- }
327915
- }
327916
- function ensureExitHandlers() {
327917
- if (exitHandlersInstalled) return;
327918
- exitHandlersInstalled = true;
327919
- const drain = () => {
327920
- for (const marker of activeCleanups.values()) {
327921
- restoreDeviceSync(marker);
327922
- }
327923
- };
327924
- process.on("beforeExit", drain);
327925
- process.on("exit", drain);
327926
- for (const signal of ["SIGTERM", "SIGINT"]) {
327927
- process.on(signal, () => {
327928
- drain();
327929
- process.removeAllListeners(signal);
327930
- process.kill(process.pid, signal);
327931
- });
327932
- }
327933
- }
327934
- async function startMobileNetworkCapture(options) {
327935
- const { platform: platform3, deviceId, isPhysical = false, preferredPort = 0, onLog } = options;
327936
- await reconcilePendingCleanups(onLog);
327937
- ensureExitHandlers();
327938
- const sessionKey = (0, import_node_crypto.randomUUID)();
327939
- const originalProxy = platform3 === "ANDROID" ? await androidReadProxy(deviceId) : null;
327940
- let tmpDir = null;
327941
- let caCertPath = null;
327942
- let caKeyPem = null;
327943
- let caCertPem = null;
327944
- try {
327945
- tmpDir = await (0, import_promises.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "validateqa-mobile-ca-"));
327946
- const ca = await generateCaCert(tmpDir, onLog);
327947
- if (ca) {
327948
- caCertPath = ca.caCertPath;
327949
- caKeyPem = ca.caKeyPem;
327950
- caCertPem = ca.caCertPem;
327951
- }
327952
- } catch (err) {
327953
- onLog?.(`[mobile-net] CA setup degraded: ${errMsg(err)}`);
327954
- }
327955
- if (!caKeyPem || !caCertPem) {
327956
- try {
327957
- const fallback = await (0, import_mockttp.generateCACertificate)();
327958
- caKeyPem = fallback.key;
327959
- caCertPem = fallback.cert;
327960
- } catch (err) {
327961
- onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg(err)}`);
327962
- if (tmpDir) {
327963
- await (0, import_promises.rm)(tmpDir, { recursive: true, force: true }).catch(() => void 0);
327964
- }
327965
- throw err instanceof Error ? err : new Error(String(err));
327966
- }
327967
- }
327968
- const proxy = new MitmProxy(caKeyPem, caCertPem, isPhysical, onLog);
327969
- const proxyPort = await proxy.listen(preferredPort);
327970
- const proxyHost = resolveProxyHostForDevice(platform3, isPhysical);
327971
- const hostPort = `${proxyHost}:${proxyPort}`;
327972
- onLog?.(
327973
- `[mobile-net] MITM proxy listening; device target ${hostPort} (${platform3}${isPhysical ? " physical" : ""})`
327974
- );
327975
- let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
327976
- let proxySet = false;
327977
- let caInstalledOnDevice = false;
327978
- try {
327979
- if (platform3 === "ANDROID") {
327980
- proxySet = await androidSetProxy(deviceId, hostPort, onLog);
327981
- if (!proxySet) {
327982
- wiringReason = "adb proxy configuration failed; no device traffic will be routed through the proxy.";
327983
- } else if (caCertPath) {
327984
- const { note } = await androidInstallCa(deviceId, caCertPath);
327985
- caInstalledOnDevice = true;
327986
- wiringReason = note;
327987
- }
327988
- } else {
327989
- if (!isPhysical && caCertPath) {
327990
- const { trusted, note } = await iosSimTrustCa(deviceId, caCertPath);
327991
- caInstalledOnDevice = trusted;
327992
- wiringReason = note;
327993
- } else if (isPhysical) {
327994
- 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.";
327995
- }
327996
- }
327997
- } catch (err) {
327998
- wiringReason = `device wiring degraded: ${errMsg(err)}`;
327999
- onLog?.(`[mobile-net] ${wiringReason}`);
328000
- }
328001
- if (proxySet || caInstalledOnDevice) {
328002
- const marker = {
328003
- platform: platform3,
328004
- deviceId,
328005
- isPhysical,
328006
- proxySet,
328007
- originalProxy,
328008
- caInstalledOnDevice
328009
- };
328010
- activeCleanups.set(sessionKey, marker);
328011
- await writePendingCleanup(sessionKey, marker);
328012
- }
328013
- const capturedTmpDir = tmpDir;
328014
- const capturedCaCertPath = caCertPath;
328015
- let stopped = false;
328016
- let finalCalls = [];
328017
- const getCapabilities = () => {
328018
- if (proxy.isIntercepting) return { intercepting: true };
328019
- const reason = proxy.interceptionReason ?? wiringReason;
328020
- return reason ? { intercepting: false, reason } : { intercepting: false };
328021
- };
328022
- const stop = async () => {
328023
- if (stopped) return finalCalls;
328024
- stopped = true;
328025
- if (proxySet && platform3 === "ANDROID") {
328026
- await androidClearProxy(deviceId, originalProxy, onLog);
328027
- }
328028
- if (caInstalledOnDevice && capturedCaCertPath) {
328029
- if (platform3 === "ANDROID") {
328030
- await androidRemoveCa(deviceId, onLog);
328031
- } else if (!isPhysical) {
328032
- await iosSimRemoveCa(deviceId, onLog);
328033
- }
328034
- }
328035
- activeCleanups.delete(sessionKey);
328036
- await removePendingCleanup(sessionKey);
328037
- finalCalls = proxy.finalize();
328038
- await proxy.close();
328039
- if (capturedTmpDir) {
328040
- try {
328041
- await (0, import_promises.rm)(capturedTmpDir, { recursive: true, force: true });
328042
- } catch (err) {
328043
- onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg(err)}`);
328044
- }
328045
- }
328046
- const apiCount = finalCalls.filter((c) => c.isApi).length;
328047
- onLog?.(
328048
- `[mobile-net] stopped: ${finalCalls.length} calls captured (${apiCount} API; HTTPS interception ${proxy.isIntercepting ? "ACTIVE" : "inactive"}).`
328049
- );
328050
- return finalCalls;
328051
- };
328052
- return {
328053
- proxyPort,
328054
- proxyHost,
328055
- caCertPath: capturedCaCertPath,
328056
- getCapabilities,
328057
- stop
328058
- };
328059
- }
328060
-
328061
329764
  // src/services/doctor.ts
328062
329765
  var import_child_process4 = require("child_process");
328063
329766
  var import_fs3 = require("fs");
@@ -328273,9 +329976,22 @@ function printDoctorReport(report) {
328273
329976
 
328274
329977
  // src/runner.ts
328275
329978
  init_dist();
328276
- var import_runner_core11 = __toESM(require_dist2());
329979
+ var import_runner_core12 = __toESM(require_dist2());
328277
329980
  function fingerprintTest(test) {
328278
- return (0, import_node_crypto2.createHash)("sha256").update(test.name).update("\n--suite--\n").update(test.suiteName ?? "").update("\n--type--\n").update(test.testType ?? "").update("\n--code--\n").update(test.playwrightCode).digest("hex").slice(0, 24);
329981
+ const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
329982
+ return (0, import_node_crypto2.createHash)("sha256").update(test.name).update("\n--suite--\n").update(test.suiteName ?? "").update("\n--type--\n").update(test.testType ?? "").update("\n--framework--\n").update(test.framework ?? "").update("\n--code--\n").update(code).digest("hex").slice(0, 24);
329983
+ }
329984
+ function mobileDiscoveryNetworkContext(result, appId) {
329985
+ const page = result?.collectedPages?.find((p) => p.screenId || p.route);
329986
+ const screenId = page?.screenId ?? page?.route;
329987
+ return screenId ? `mobile:${screenId}` : `mobile:${appId}`;
329988
+ }
329989
+ function attachMobileDiscoveryNetworkContext(calls, result, appId) {
329990
+ const pageUrl = mobileDiscoveryNetworkContext(result, appId);
329991
+ return calls.map((call) => ({
329992
+ ...call,
329993
+ pageUrl: call.pageUrl ?? pageUrl
329994
+ }));
328279
329995
  }
328280
329996
  var SECRET_VAR_PATTERN = /password|secret|token|api[_]?key|pass\b|credential|bearer|pat\b/i;
328281
329997
  var IS_CLOUD = process.env.RUNNER_CLOUD === "true";
@@ -328705,7 +330421,7 @@ function pruneCollectedPageForPost(page) {
328705
330421
  if (Array.isArray(out.observations)) out.observations = out.observations.slice(-200).map((obs) => compactForAudit(obs));
328706
330422
  return out;
328707
330423
  }
328708
- function prepareDiscoveryResultForPost(result, logs2) {
330424
+ function prepareDiscoveryResultForPost(result, logs2, options = {}) {
328709
330425
  const safe = { ...result, logs: truncateTextTail(logs2, RESULT_LOG_MAX_CHARS, "discovery result log") };
328710
330426
  if (Array.isArray(safe.collectedPages)) safe.collectedPages = safe.collectedPages.map(pruneCollectedPageForPost);
328711
330427
  if (Array.isArray(safe.apiCalls)) safe.apiCalls = safe.apiCalls.map((call) => compactForAudit(call));
@@ -328721,6 +330437,10 @@ function prepareDiscoveryResultForPost(result, logs2) {
328721
330437
  if (safe.snapshotEvidence) safe.snapshotEvidence = compactForAudit(safe.snapshotEvidence);
328722
330438
  if (safe.discoveryCheckpoints) safe.discoveryCheckpoints = compactForAudit(safe.discoveryCheckpoints);
328723
330439
  if (safe.pageScreenshotKeys && safe.pageScreenshots) delete safe.pageScreenshots;
330440
+ if (options.omitScreenshots) {
330441
+ if (Array.isArray(safe.screenshots)) delete safe.screenshots;
330442
+ if (safe.pageScreenshots) delete safe.pageScreenshots;
330443
+ }
328724
330444
  if (safe.exploreAlreadySaved === true) {
328725
330445
  if (Array.isArray(safe.screenshots)) delete safe.screenshots;
328726
330446
  if (safe.pageScreenshots) delete safe.pageScreenshots;
@@ -328820,7 +330540,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
328820
330540
  const runMode = run2.mode ?? "run";
328821
330541
  const RUNNER_MODE = opts.runnerMode ?? "playwright-native";
328822
330542
  log.info(`Running test: ${run2.testCaseId ?? "auth-setup"} (run ${run2.runId}) [mode: ${runMode === "heal" ? "heal" : runMode === "auth-setup" ? "auth-setup" : RUNNER_MODE}]`);
328823
- const liveBrowserHandle = import_runner_core10.liveBrowserRegistry.register({
330543
+ const liveBrowserHandle = import_runner_core11.liveBrowserRegistry.register({
328824
330544
  sessionId: `${run2.runId}`,
328825
330545
  mode: toLiveBrowserMode(runMode),
328826
330546
  runId: run2.runId,
@@ -328829,7 +330549,10 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
328829
330549
  testCaseId: run2.testCaseId ?? void 0
328830
330550
  });
328831
330551
  requestLiveBrowserHeartbeat(true);
328832
- const liveBrowserHeartbeatInterval = setInterval(() => requestLiveBrowserHeartbeat(), 5e3);
330552
+ const liveBrowserHeartbeatInterval = setInterval(() => {
330553
+ liveBrowserHandle.patch({});
330554
+ requestLiveBrowserHeartbeat();
330555
+ }, 5e3);
328833
330556
  liveBrowserHeartbeatInterval.unref?.();
328834
330557
  const runNativeWithLiveBrowser = (nativeOptions) => (0, import_runner_core.executeTestViaNativePlaywright)({
328835
330558
  ...nativeOptions,
@@ -328856,7 +330579,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
328856
330579
  log.fail(`PRE-FLIGHT FAILED - ${preflightError}`);
328857
330580
  return;
328858
330581
  }
328859
- const preflight = await (0, import_runner_core11.resolveBaseUrlPreflight)(run2.baseUrl);
330582
+ const preflight = await (0, import_runner_core12.resolveBaseUrlPreflight)(run2.baseUrl);
328860
330583
  if (preflight.error) {
328861
330584
  await postResult(opts.serverUrl, headers, run2.runId, { status: "ERROR", error: preflight.error });
328862
330585
  log.fail(`PRE-FLIGHT FAILED - ${preflight.error}`);
@@ -328913,7 +330636,7 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
328913
330636
  return;
328914
330637
  }
328915
330638
  const healLogStream = createHealLogStreamer(opts.serverUrl, headers, run2.runId);
328916
- const batchResult = await (0, import_runner_core9.executeGrokPerBatchHeal)(tests, run2.baseUrl, {
330639
+ const batchResult = await (0, import_runner_core10.executeGrokPerBatchHeal)(tests, run2.baseUrl, {
328917
330640
  testVariables: run2.testVariables,
328918
330641
  runNativeTest: runNativeWithLiveBrowser,
328919
330642
  validateContext: { runId: run2.runId, serverUrl: opts.serverUrl, authHeader: headers.Authorization },
@@ -329147,7 +330870,7 @@ ${finalVerifyResult.logs ?? ""}`;
329147
330870
  if (classifyErrorMessage(composedError ?? null) === "config_issue") {
329148
330871
  apiHealStatus = "ERROR";
329149
330872
  } else {
329150
- const rlDetection = (0, import_runner_core11.detectRateLimitBlock)({
330873
+ const rlDetection = (0, import_runner_core12.detectRateLimitBlock)({
329151
330874
  testName: run2.testName ?? run2.testCaseId ?? void 0,
329152
330875
  tags: run2.tags ?? [],
329153
330876
  error: composedError ?? finalVerifyResult?.error ?? null,
@@ -329235,6 +330958,7 @@ ${finalVerifyResult.logs ?? ""}`;
329235
330958
  tags: run2.tags ?? [],
329236
330959
  surfaceIntelligence: run2.surfaceIntelligence,
329237
330960
  healingContext: run2.healingContext ?? null,
330961
+ selectorRegistryContext: run2.selectorRegistryContext ?? null,
329238
330962
  lastFailureError: run2.lastFailureError,
329239
330963
  testVariables: run2.testVariables,
329240
330964
  onAICall: healAudit,
@@ -329253,7 +330977,7 @@ ${finalVerifyResult.logs ?? ""}`;
329253
330977
  log.info(`Heal engine: GROK (${source})`);
329254
330978
  }
329255
330979
  const grokHealLogStream = useGrokHeal ? createHealLogStreamer(opts.serverUrl, headers, run2.runId) : null;
329256
- const result2 = useGrokHeal ? await (0, import_runner_core9.executeGrokPerTestHeal)(run2.playwrightCode, run2.steps, run2.baseUrl, {
330980
+ const result2 = useGrokHeal ? await (0, import_runner_core10.executeGrokPerTestHeal)(run2.playwrightCode, run2.steps, run2.baseUrl, {
329257
330981
  ...healOpts,
329258
330982
  onLog: grokHealLogStream.onLog
329259
330983
  }) : await (0, import_runner_core2.executeTestWithHealing)(run2.playwrightCode, run2.steps, run2.baseUrl, healOpts);
@@ -329286,7 +331010,7 @@ ${finalVerifyResult.logs ?? ""}`;
329286
331010
  let healStatus = result2.passed ? "PASSED" : isConfigIssue ? "ERROR" : "FAILED";
329287
331011
  let healError = result2.error;
329288
331012
  if (!result2.passed && !isConfigIssue) {
329289
- const rlDetection = (0, import_runner_core11.detectRateLimitBlock)({
331013
+ const rlDetection = (0, import_runner_core12.detectRateLimitBlock)({
329290
331014
  testName: run2.testName ?? run2.testCaseId ?? void 0,
329291
331015
  tags: run2.tags ?? [],
329292
331016
  error: result2.error,
@@ -329469,6 +331193,7 @@ ${finalVerifyResult.logs ?? ""}`;
329469
331193
  tags: failedTest.tags ?? [],
329470
331194
  surfaceIntelligence: failedTest.healingContext ? null : run2.surfaceIntelligence,
329471
331195
  healingContext: failedTest.healingContext ?? null,
331196
+ selectorRegistryContext: failedTest.selectorRegistryContext ?? null,
329472
331197
  lastFailureError: failedTest.error,
329473
331198
  testVariables: run2.testVariables,
329474
331199
  onAICall: healAudit,
@@ -329479,7 +331204,7 @@ ${finalVerifyResult.logs ?? ""}`;
329479
331204
  liveBrowserHandle,
329480
331205
  runNativeTest: runNativeWithLiveBrowser
329481
331206
  };
329482
- const healResult = useGrokHealSuite ? await (0, import_runner_core9.executeGrokPerTestHeal)(
331207
+ const healResult = useGrokHealSuite ? await (0, import_runner_core10.executeGrokPerTestHeal)(
329483
331208
  failedTest.playwrightCode,
329484
331209
  failedTest.steps ?? [],
329485
331210
  run2.baseUrl,
@@ -329767,9 +331492,16 @@ ${finalVerifyResult.logs ?? ""}`;
329767
331492
  signal: AbortSignal.timeout(3e4)
329768
331493
  });
329769
331494
  if (res.ok) return;
329770
- log.warn(`[stream] discovery-plan attempt ${attempt + 1} returned ${res.status}`);
331495
+ const responseText = await res.text().catch(() => "");
331496
+ const statusMessage = responseText ? `${res.status}: ${responseText.slice(0, 500)}` : String(res.status);
331497
+ if (res.status === 409) {
331498
+ throw new Error(`Discovery plan checkpoint rejected: ${statusMessage}`);
331499
+ }
331500
+ log.warn(`[stream] discovery-plan attempt ${attempt + 1} returned ${statusMessage}`);
329771
331501
  } catch (err) {
329772
- log.warn(`[stream] discovery-plan attempt ${attempt + 1} failed: ${err instanceof Error ? err.message : String(err)}`);
331502
+ const message = err instanceof Error ? err.message : String(err);
331503
+ if (/Discovery plan checkpoint rejected/i.test(message)) throw err;
331504
+ log.warn(`[stream] discovery-plan attempt ${attempt + 1} failed: ${message}`);
329773
331505
  }
329774
331506
  if (attempt < 2) await new Promise((r) => setTimeout(r, 3e3 * (attempt + 1)));
329775
331507
  }
@@ -329777,6 +331509,7 @@ ${finalVerifyResult.logs ?? ""}`;
329777
331509
  };
329778
331510
  let discoveryResult;
329779
331511
  const buildFinalBody = (r, overrideError, auditWarning) => {
331512
+ const isNativeDiscovery = ctx.medium === "IOS_NATIVE" || ctx.medium === "ANDROID_NATIVE";
329780
331513
  const { exploreFinalMessages: _exploreFinalMessages, ...rest } = r;
329781
331514
  void _exploreFinalMessages;
329782
331515
  const logs2 = auditWarning ? [rest.logs, auditWarning].filter(Boolean).join("\n") : rest.logs;
@@ -329826,7 +331559,7 @@ ${finalVerifyResult.logs ?? ""}`;
329826
331559
  resolvedAppOrigin: rest.resolvedAppOrigin,
329827
331560
  plans: rest.plans,
329828
331561
  exploreStats: rest.exploreStats,
329829
- screenshots: rest.screenshots,
331562
+ ...isNativeDiscovery ? {} : { screenshots: rest.screenshots },
329830
331563
  snapshotEvidence: rest.snapshotEvidence,
329831
331564
  discoveryCheckpoints: rest.discoveryCheckpoints,
329832
331565
  streamedTestCount: streamedTestKeys.size,
@@ -329835,8 +331568,10 @@ ${finalVerifyResult.logs ?? ""}`;
329835
331568
  ...explorePosted ? {} : {
329836
331569
  collectedPages: rest.collectedPages,
329837
331570
  collectedTransitions: rest.collectedTransitions,
329838
- pageScreenshots: rest.pageScreenshots,
329839
- pageScreenshotKeys: rest.pageScreenshotKeys
331571
+ ...isNativeDiscovery ? {} : {
331572
+ pageScreenshots: rest.pageScreenshots,
331573
+ pageScreenshotKeys: rest.pageScreenshotKeys
331574
+ }
329840
331575
  },
329841
331576
  ...harPosted ? {} : {
329842
331577
  apiCalls: rest.apiCalls,
@@ -329844,7 +331579,7 @@ ${finalVerifyResult.logs ?? ""}`;
329844
331579
  },
329845
331580
  // Only send tests that were NOT confirmed streamed to avoid duplicates
329846
331581
  tests: rest.tests.filter((t) => !streamedTestKeys.has(fingerprintTest(t)))
329847
- }, logs2);
331582
+ }, logs2, { omitScreenshots: isNativeDiscovery });
329848
331583
  };
329849
331584
  const acquireLoginBrowser = async () => {
329850
331585
  const browser = await browserPool.acquireAsync(12e3);
@@ -329887,6 +331622,14 @@ ${finalVerifyResult.logs ?? ""}`;
329887
331622
  throw new Error("CONFIG_ISSUE: the shared mobile device is busy with an active recording session; mobile discovery will retry once it ends.");
329888
331623
  }
329889
331624
  const mobileOnLog = (line) => onLog("progress", line);
331625
+ liveBrowserHandle.patch({
331626
+ mode: ctx.mode === "survey" ? "survey" : "discovery",
331627
+ testName: ctx.featureName ?? "Mobile discovery",
331628
+ note: `${platform3} mobile discovery`,
331629
+ surface: "mobile",
331630
+ mobilePlatform: platform3
331631
+ });
331632
+ requestLiveBrowserHeartbeat(true);
329890
331633
  mobileOnLog(`Mobile discovery (${ctx.mode}) \u2014 ${platform3} / ${mobileTarget.appId}`);
329891
331634
  setExecutorBusy(true);
329892
331635
  let session = null;
@@ -329899,6 +331642,24 @@ ${finalVerifyResult.logs ?? ""}`;
329899
331642
  recordedDeviceId: mobileTarget.recordedDeviceId,
329900
331643
  launchMode: mobileTarget.launchMode,
329901
331644
  deeplink: mobileTarget.deeplink
331645
+ }, {
331646
+ onDeviceSelected: async (device) => {
331647
+ try {
331648
+ capture = await startMobileNetworkCapture({
331649
+ platform: platform3,
331650
+ deviceId: device.deviceId,
331651
+ appId: mobileTarget.appId,
331652
+ isPhysical: device.isPhysical,
331653
+ onLog: mobileOnLog
331654
+ });
331655
+ const caps = capture.getCapabilities();
331656
+ if (!caps.intercepting) {
331657
+ mobileOnLog(`[mobile-net] HTTPS interception unavailable: ${caps.reason ?? "unknown reason"} (continuing \u2014 plaintext/metadata only)`);
331658
+ }
331659
+ } catch (captureErr) {
331660
+ mobileOnLog(`[mobile-net] capture unavailable: ${captureErr instanceof Error ? captureErr.message : String(captureErr)} (continuing without network log)`);
331661
+ }
331662
+ }
329902
331663
  });
329903
331664
  mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
329904
331665
  attachMirrorSession(session.sessionId, platform3);
@@ -329907,24 +331668,21 @@ ${finalVerifyResult.logs ?? ""}`;
329907
331668
  platform: platform3,
329908
331669
  appId: session.appId
329909
331670
  });
329910
- capture = await startMobileNetworkCapture({
329911
- platform: platform3,
329912
- deviceId: session.deviceId,
329913
- appId: session.appId,
329914
- isPhysical: session.isPhysical,
329915
- onLog: mobileOnLog
329916
- });
329917
- const caps = capture.getCapabilities();
329918
- if (!caps.intercepting) {
329919
- mobileOnLog(`[mobile-net] HTTPS interception unavailable: ${caps.reason ?? "unknown reason"} (continuing \u2014 plaintext/metadata only)`);
329920
- }
329921
331671
  discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
329922
331672
  onLog: mobileOnLog,
329923
331673
  onAICall: discoveryAudit,
329924
331674
  // Stream the live device mirror through the existing page-screenshot
329925
- // relay (mobile has no route, so key it as 'mobile'). Fire-and-forget.
331675
+ // relay (mobile has no route, so key it as 'mobile') and the
331676
+ // heartbeat live-session feed. The heartbeat frame gives the web
331677
+ // UI a reliable mobile fallback when the direct bridge SSE is
331678
+ // still opening or blocked by the user's network/tunnel.
329926
331679
  onScreenshot: (base64) => {
329927
- void onScreenshot("mobile", base64);
331680
+ liveBrowserHandle.updateSnapshot({
331681
+ currentUrl: mobileTarget.appId,
331682
+ thumbnailJpegBase64: base64,
331683
+ thumbnailCapturedAt: Date.now()
331684
+ });
331685
+ requestLiveBrowserHeartbeat();
329928
331686
  },
329929
331687
  onExploreComplete,
329930
331688
  onTestGenerated,
@@ -329935,7 +331693,7 @@ ${finalVerifyResult.logs ?? ""}`;
329935
331693
  try {
329936
331694
  const apiCalls = await capture.stop();
329937
331695
  if (discoveryResult) {
329938
- discoveryResult.apiCalls = apiCalls.length ? apiCalls : void 0;
331696
+ discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
329939
331697
  }
329940
331698
  } catch (capErr) {
329941
331699
  mobileOnLog(`[mobile-net] capture stop failed: ${capErr instanceof Error ? capErr.message : String(capErr)}`);
@@ -329951,7 +331709,7 @@ ${finalVerifyResult.logs ?? ""}`;
329951
331709
  setExecutorBusy(false);
329952
331710
  }
329953
331711
  } else if (ctx.mode === "import") {
329954
- const runRepoImport = ctx.frameworkImport?.kind === "repo-analysis" ? import_runner_core9.runRepoAnalysisOnRunner : import_runner_core9.runFrameworkImportOnRunner;
331712
+ const runRepoImport = ctx.frameworkImport?.kind === "repo-analysis" ? import_runner_core10.runRepoAnalysisOnRunner : import_runner_core10.runFrameworkImportOnRunner;
329955
331713
  discoveryResult = await runRepoImport(ctx, { onTestGenerated, acquireLoginBrowser, onAudit: discoveryAudit }, {
329956
331714
  archiveUrl: ctx.frameworkImport?.source === "upload" ? `${opts.serverUrl}/api/runner/runs/${run2.runId}/import-archive` : void 0,
329957
331715
  archiveAuthHeader: headers.Authorization,
@@ -330252,6 +332010,7 @@ ${finalVerifyResult.logs ?? ""}`;
330252
332010
  status,
330253
332011
  stepResults: result2.stepResults,
330254
332012
  screenshots: result2.screenshots,
332013
+ apiCalls: result2.apiCalls,
330255
332014
  logs: result2.logs,
330256
332015
  error: reportedError,
330257
332016
  duration: duration2
@@ -330280,7 +332039,7 @@ ${finalVerifyResult.logs ?? ""}`;
330280
332039
  let runStatus = result.passed ? "PASSED" : "FAILED";
330281
332040
  let runError = result.error;
330282
332041
  if (!result.passed) {
330283
- const rlDetection = (0, import_runner_core11.detectRateLimitBlock)({
332042
+ const rlDetection = (0, import_runner_core12.detectRateLimitBlock)({
330284
332043
  testName: run2.testName ?? run2.testCaseId ?? void 0,
330285
332044
  tags: run2.tags ?? [],
330286
332045
  error: result.error,
@@ -330418,7 +332177,7 @@ async function startRunner(opts) {
330418
332177
  capture: envInt("MAX_CAPTURE", DEFAULT_TYPE_LIMITS.capture),
330419
332178
  run: envInt("MAX_RUN", DEFAULT_TYPE_LIMITS.run)
330420
332179
  };
330421
- const RESOURCE_GOVERNOR_OPTIONS = (0, import_runner_core11.createResourceGovernorOptions)({
332180
+ const RESOURCE_GOVERNOR_OPTIONS = (0, import_runner_core12.createResourceGovernorOptions)({
330422
332181
  isCloud: IS_CLOUD,
330423
332182
  maxConcurrent: MAX_CONCURRENT,
330424
332183
  typeLimits: TYPE_LIMITS
@@ -330569,8 +332328,8 @@ async function startRunner(opts) {
330569
332328
  android: devices.some((d) => d.platform === "ANDROID" && d.isAvailable && d.state === "BOOTED")
330570
332329
  } : { web: true, ios: false, android: false };
330571
332330
  const bridge = enableMobile ? getBridgeState() : void 0;
330572
- const resourceStatus = (0, import_runner_core11.getResourceGovernorStatus)(RESOURCE_GOVERNOR_OPTIONS, Object.fromEntries(activeByType));
330573
- const processInventory = await (0, import_runner_core11.getBrowserProcessInventory)();
332331
+ const resourceStatus = (0, import_runner_core12.getResourceGovernorStatus)(RESOURCE_GOVERNOR_OPTIONS, Object.fromEntries(activeByType));
332332
+ const processInventory = await (0, import_runner_core12.getBrowserProcessInventory)();
330574
332333
  lastInventoryPids = new Set(processInventory.processes.map((p) => p.pid));
330575
332334
  lastInventoryKinds.clear();
330576
332335
  for (const p of processInventory.processes) lastInventoryKinds.set(p.pid, p.kind);
@@ -330597,8 +332356,8 @@ async function startRunner(opts) {
330597
332356
  resourceSnapshot: resourceStatus.snapshot,
330598
332357
  resourceGovernor: resourceStatus.governor,
330599
332358
  processInventory,
330600
- aiQueue: (0, import_runner_core11.getRunnerAIQueueStats)(),
330601
- liveBrowsers: import_runner_core10.liveBrowserRegistry.snapshotForHeartbeat(),
332359
+ aiQueue: (0, import_runner_core12.getRunnerAIQueueStats)(),
332360
+ liveBrowsers: import_runner_core11.liveBrowserRegistry.snapshotForHeartbeat(),
330602
332361
  // Pre-flight checks (ANDROID_HOME, Xcode, Appium drivers, adb, etc).
330603
332362
  // Cached & cheap — see getCachedDoctor above.
330604
332363
  doctor: getCachedDoctor()
@@ -330692,7 +332451,7 @@ async function startRunner(opts) {
330692
332451
  let pidsWatchdogWarned = false;
330693
332452
  const pidsWatchdog = setInterval(() => {
330694
332453
  if (shuttingDown) return;
330695
- const snapshot = (0, import_runner_core11.getResourceSnapshot)();
332454
+ const snapshot = (0, import_runner_core12.getResourceSnapshot)();
330696
332455
  const pct = snapshot.pidsPct;
330697
332456
  if (pct == null || !Number.isFinite(pct)) return;
330698
332457
  if (pct >= PIDS_RECYCLE_PCT) {
@@ -330728,7 +332487,7 @@ async function startRunner(opts) {
330728
332487
  log.dim(` Deferred ${run2.runId} (project ${runProject} at capacity ${getActiveForProject(runProject)}/${projectLimit})`);
330729
332488
  continue;
330730
332489
  }
330731
- const admission = (0, import_runner_core11.evaluateRunAdmission)(RESOURCE_GOVERNOR_OPTIONS, {
332490
+ const admission = (0, import_runner_core12.evaluateRunAdmission)(RESOURCE_GOVERNOR_OPTIONS, {
330732
332491
  mode: runMode,
330733
332492
  activeRuns,
330734
332493
  activeByType: Object.fromEntries(activeByType),