@validate.qa/runner 1.0.4 → 1.0.5

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 +2204 -760
  2. package/dist/cli.mjs +2214 -770
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -620,12 +620,19 @@ var require_ai_provider = __commonJS({
620
620
  function getMobileAIClient() {
621
621
  if ((process.env.SERVER_URL ?? "").trim().length > 0)
622
622
  return getServerProxiedAIClient();
623
- return getAIClient();
623
+ if (isMobileAiUnitTestMode())
624
+ return getAIClient();
625
+ throw new Error("Mobile AI requires the platform server URL (SERVER_URL). Customer runners do not provision provider API keys \u2014 the platform proxies every mobile AI completion through /api/runner/ai/execute, which lives on the shared server with the keys. If you reached this from `validate-runner start`, the credentials file is missing `serverUrl` or the install is broken; re-run `npx @validate.qa/runner login`. If you are calling the runner-core library directly, pass `options.aiClient` in tests or set VALIDATEQA_TEST_MODE=1 to opt into the local-provider fallback.");
624
626
  }
625
627
  function getMobileClientForModel(modelId) {
626
628
  if ((process.env.SERVER_URL ?? "").trim().length > 0)
627
629
  return getServerProxiedClientForModel(modelId);
628
- return getClientForModel(modelId);
630
+ if (isMobileAiUnitTestMode())
631
+ return getClientForModel(modelId);
632
+ throw new Error(`Mobile AI for model "${modelId}" requires the platform server URL (SERVER_URL). Customer runners do not provision provider API keys \u2014 the platform proxies every mobile AI completion through /api/runner/ai/execute. See getMobileAIClient() for remediation steps.`);
633
+ }
634
+ function isMobileAiUnitTestMode() {
635
+ return (process.env.VALIDATEQA_TEST_MODE ?? "").trim() === "1" || (process.env.NODE_ENV ?? "").trim() === "test";
629
636
  }
630
637
  }
631
638
  });
@@ -4437,6 +4444,15 @@ var require_mobile_explorer = __commonJS({
4437
4444
  var MAX_RECENT_EXCHANGES = 24;
4438
4445
  var OBSERVATION_MAX_ELEMENTS = 50;
4439
4446
  var NO_SNAPSHOT_NUDGE_THRESHOLD = 4;
4447
+ var ANDROID_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
4448
+ "com.android.permissioncontroller",
4449
+ "com.google.android.permissioncontroller",
4450
+ "com.android.packageinstaller",
4451
+ "com.google.android.packageinstaller"
4452
+ ]);
4453
+ var IOS_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
4454
+ "com.apple.springboard"
4455
+ ]);
4440
4456
  var MOBILE_SNAPSHOT_TOOL = {
4441
4457
  type: "function",
4442
4458
  function: {
@@ -4659,6 +4675,40 @@ var require_mobile_explorer = __commonJS({
4659
4675
  return "timeout";
4660
4676
  return "ai_error";
4661
4677
  }
4678
+ function ownerFromScreenSignal(signal, targetAppId) {
4679
+ const bundleId = signal.bundleId?.trim();
4680
+ if (bundleId)
4681
+ return bundleId;
4682
+ const activity = signal.activity?.trim();
4683
+ if (activity && targetAppId && (activity === targetAppId || activity.startsWith(`${targetAppId}.`))) {
4684
+ return targetAppId;
4685
+ }
4686
+ return null;
4687
+ }
4688
+ function isTransientSystemOwner(owner, platform3) {
4689
+ return platform3 === "ANDROID" ? ANDROID_TRANSIENT_SYSTEM_APP_IDS.has(owner) : IOS_TRANSIENT_SYSTEM_APP_IDS.has(owner);
4690
+ }
4691
+ function classifyMobileScreenBoundary(signal, targetAppId, platform3) {
4692
+ if (!targetAppId)
4693
+ return { kind: "target" };
4694
+ const owner = ownerFromScreenSignal(signal, targetAppId);
4695
+ if (!owner)
4696
+ return { kind: "unknown" };
4697
+ if (owner === targetAppId)
4698
+ return { kind: "target" };
4699
+ if (isTransientSystemOwner(owner, platform3)) {
4700
+ return { kind: "transient_external", owner, targetAppId };
4701
+ }
4702
+ return { kind: "external", owner, targetAppId };
4703
+ }
4704
+ function boundaryNote(boundary) {
4705
+ return [
4706
+ "",
4707
+ "## App Boundary",
4708
+ `Foreground app is ${boundary.owner}, outside target app ${boundary.targetAppId}.`,
4709
+ boundary.kind === "transient_external" ? "This looks like a system overlay. Handle or dismiss it if needed, but do not capture it as an app screen." : "The runner excluded this external app screen from the survey evidence."
4710
+ ].join("\n");
4711
+ }
4662
4712
  function resolveSnapshot(xml, windowSize, driverSignal, platform3) {
4663
4713
  const parsed = (0, mobile_source_parser_js_1.parseMobileSource)(xml, platform3);
4664
4714
  const signal = {
@@ -4739,8 +4789,9 @@ var require_mobile_explorer = __commonJS({
4739
4789
  }
4740
4790
  return sections.join("\n");
4741
4791
  }
4742
- function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief) {
4792
+ function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId) {
4743
4793
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
4794
+ const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
4744
4795
  const surveyBody = `You are a mobile QA exploration agent driving a real ${platformLabel} app on a connected device. Your goal: MAP every reachable screen so downstream agents know what to test. You are NOT testing \u2014 you are building a complete screen map.
4745
4796
 
4746
4797
  ## How the loop works
@@ -4758,7 +4809,7 @@ var require_mobile_explorer = __commonJS({
4758
4809
 
4759
4810
  ## Boundaries (survey = map, do not mutate)
4760
4811
  - Do NOT submit forms, save, delete, purchase, or send anything. You MAY open modals/menus to catalog them, then dismiss.
4761
- - Stay inside this app. Do not leave to the system home screen or other apps (mobile_home backgrounds \u2014 avoid it).
4812
+ - Stay inside this app.${targetLine} Do not leave to the system home screen or other apps (mobile_home backgrounds \u2014 avoid it). If a tap opens an app store, settings, browser, or another app, do not explore it; return with mobile_back or mobile_relaunch.
4762
4813
  - If you hit a login wall and have no credentials, capture the auth screen and explore what is reachable.
4763
4814
 
4764
4815
  Max iterations: ${maxIterations}. You will be warned near the end.`;
@@ -4776,6 +4827,7 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
4776
4827
  4. Use mobile_relaunch for a clean, hermetic restart before testing a distinct flow that needs fresh state.
4777
4828
  5. mobile_swipe to scroll; mobile_back to return.
4778
4829
  6. Do NOT trigger irreversible side effects (real payments, sending messages/invites to others). Note them instead.
4830
+ 7. Stay inside this app.${targetLine} If a tap opens an app store, settings, browser, or another app, do not explore it; return with mobile_back or mobile_relaunch.
4779
4831
 
4780
4832
  Max iterations: ${maxIterations}. You will be warned near the end.`;
4781
4833
  const briefBlock = appBrief ? `
@@ -4801,6 +4853,7 @@ Use this to prioritize which screens and flows to cover.` : "";
4801
4853
  const mode = ctx.mode === "deep" ? "deep" : "survey";
4802
4854
  const platform3 = platformForMedium(ctx.medium);
4803
4855
  const enableRecordJourney = config?.enableRecordJourney ?? mode !== "survey";
4856
+ const targetAppId = ctx.target?.appId?.trim() || void 0;
4804
4857
  const defaultBudget = mode === "deep" ? MAX_MOBILE_ITERATIONS_DEEP : MAX_MOBILE_ITERATIONS_SURVEY;
4805
4858
  const maxIterations = config?.maxIterations && config.maxIterations > 0 ? Math.floor(config.maxIterations) : defaultBudget;
4806
4859
  const modelOverride = ctx.exploreModel ?? null;
@@ -4851,6 +4904,81 @@ Use this to prioritize which screens and flows to cover.` : "";
4851
4904
  return null;
4852
4905
  return resolveSnapshot(xml, windowSize, driverSignal, platform3);
4853
4906
  };
4907
+ const currentBoundary = () => {
4908
+ const current = getLatest();
4909
+ return current ? classifyMobileScreenBoundary(current.signal, targetAppId, platform3) : { kind: "unknown" };
4910
+ };
4911
+ const absorbResolvedObservation = async (resolved, screenshot, iterationForObservation, source) => {
4912
+ const boundary = classifyMobileScreenBoundary(resolved.signal, targetAppId, platform3);
4913
+ if (boundary.kind === "target" || boundary.kind === "unknown") {
4914
+ const screenId = ingestSnapshot(resolved, iterationForObservation);
4915
+ captureScreenshot(screenshot, screenId);
4916
+ return { observation: resolved.observation, screenId };
4917
+ }
4918
+ latest = resolved;
4919
+ lastSnapshotIteration = iterationForObservation;
4920
+ if (boundary.kind === "transient_external") {
4921
+ log2(` System overlay detected from ${source}: ${boundary.owner}; not adding it to target-app survey evidence.`);
4922
+ return {
4923
+ observation: `${resolved.observation}${boundaryNote(boundary)}`,
4924
+ screenId: null,
4925
+ transientExternal: true,
4926
+ externalOwner: boundary.owner
4927
+ };
4928
+ }
4929
+ log2(` External app detected from ${source}: ${boundary.owner}; excluding it from survey evidence and relaunching ${boundary.targetAppId}.`);
4930
+ try {
4931
+ await driver.relaunch();
4932
+ const snap = await driver.snapshot();
4933
+ const recovered = buildResolved(snap.xml, snap.windowSize, snap.screenSignal);
4934
+ if (!recovered) {
4935
+ return {
4936
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunched target app, but no UI tree was returned)`,
4937
+ screenId: null,
4938
+ externalBlocked: true,
4939
+ externalOwner: boundary.owner
4940
+ };
4941
+ }
4942
+ const recoveredBoundary = classifyMobileScreenBoundary(recovered.signal, targetAppId, platform3);
4943
+ latest = recovered;
4944
+ lastSnapshotIteration = iterationForObservation;
4945
+ if (recoveredBoundary.kind === "target" || recoveredBoundary.kind === "unknown") {
4946
+ const screenId = ingestSnapshot(recovered, iterationForObservation);
4947
+ captureScreenshot(snap.screenshot, screenId);
4948
+ return {
4949
+ observation: recovered.observation,
4950
+ screenId,
4951
+ externalBlocked: true,
4952
+ externalOwner: boundary.owner
4953
+ };
4954
+ }
4955
+ if (recoveredBoundary.kind === "transient_external") {
4956
+ log2(` Relaunch surfaced system overlay ${recoveredBoundary.owner}; not adding it to target-app survey evidence.`);
4957
+ return {
4958
+ observation: `${recovered.observation}${boundaryNote(recoveredBoundary)}`,
4959
+ screenId: null,
4960
+ externalBlocked: true,
4961
+ transientExternal: true,
4962
+ externalOwner: boundary.owner
4963
+ };
4964
+ }
4965
+ log2(` Relaunch still outside target app (${recoveredBoundary.owner}); no screen recorded.`);
4966
+ return {
4967
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no screen was recorded)`,
4968
+ screenId: null,
4969
+ externalBlocked: true,
4970
+ externalOwner: boundary.owner
4971
+ };
4972
+ } catch (err) {
4973
+ const message = err instanceof Error ? err.message : String(err);
4974
+ return {
4975
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; failed to relaunch target app: ${message})`,
4976
+ screenId: null,
4977
+ externalBlocked: true,
4978
+ externalOwner: boundary.owner
4979
+ };
4980
+ }
4981
+ };
4854
4982
  const resolveRef = (ref) => {
4855
4983
  if (typeof ref !== "string" || !latest)
4856
4984
  return null;
@@ -4859,7 +4987,7 @@ Use this to prioritize which screens and flows to cover.` : "";
4859
4987
  return null;
4860
4988
  return { element, bounds: element.bounds };
4861
4989
  };
4862
- const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief);
4990
+ const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId);
4863
4991
  const kickoff = buildKickoffMessage(mode);
4864
4992
  const recentExchanges = [];
4865
4993
  const transientDirectives = [];
@@ -4889,9 +5017,13 @@ Use this to prioritize which screens and flows to cover.` : "";
4889
5017
  const seed = await driver.snapshot();
4890
5018
  const resolved = buildResolved(seed.xml, seed.windowSize, seed.screenSignal);
4891
5019
  if (resolved) {
4892
- const screenId = ingestSnapshot(resolved, 0);
4893
- captureScreenshot(seed.screenshot, screenId);
4894
- log2(`Seed snapshot: screen ${screenId} (${resolved.screen.elements.length} elements)`);
5020
+ const absorbed = await absorbResolvedObservation(resolved, seed.screenshot, 0, "seed snapshot");
5021
+ if (absorbed.screenId) {
5022
+ const elementCount = state2.getScreen(absorbed.screenId)?.elements.length ?? resolved.screen.elements.length;
5023
+ log2(`Seed snapshot: screen ${absorbed.screenId} (${elementCount} elements)`);
5024
+ } else {
5025
+ log2("Seed snapshot was outside the target app and was not recorded.");
5026
+ }
4895
5027
  } else {
4896
5028
  log2("Seed snapshot returned no XML \u2014 the agent must call mobile_snapshot.");
4897
5029
  }
@@ -5032,9 +5164,9 @@ Exploration complete: ${summary}`);
5032
5164
  mode,
5033
5165
  latest: getLatest,
5034
5166
  buildResolved,
5035
- ingestSnapshot,
5167
+ absorbResolvedObservation,
5036
5168
  resolveRef,
5037
- captureScreenshot,
5169
+ currentBoundary,
5038
5170
  collectedTransitions,
5039
5171
  counters,
5040
5172
  journeys,
@@ -5069,9 +5201,21 @@ Exploration complete: ${summary}`);
5069
5201
  counters.pagesDiscovered = collectedPages.length;
5070
5202
  }
5071
5203
  log2(`Mobile explore phase done \u2014 ${collectedPages.length} screens, ${collectedTransitions.length} transitions, ${journeys.length} journeys, ${counters.elementsFound} elements observed.`);
5204
+ const recordedJourneys = journeys.map((journey) => {
5205
+ const routes = journey.screenIds && journey.screenIds.length > 0 ? journey.screenIds : [journey.screenId];
5206
+ return {
5207
+ name: journey.name,
5208
+ route: journey.screenId,
5209
+ routes,
5210
+ steps: journey.steps,
5211
+ expectedOutcome: journey.expectedOutcome,
5212
+ iteration: journey.iteration
5213
+ };
5214
+ });
5072
5215
  const result = {
5073
5216
  collectedPages,
5074
5217
  collectedTransitions,
5218
+ recordedJourneys,
5075
5219
  screenshots,
5076
5220
  exploreStats: {
5077
5221
  pagesDiscovered: counters.pagesDiscovered,
@@ -5092,15 +5236,13 @@ Exploration complete: ${summary}`);
5092
5236
  return result;
5093
5237
  }
5094
5238
  async function dispatchMobileTool(args) {
5095
- const { toolName, toolArgs, iteration, driver, state: state2, enableRecordJourney, latest, buildResolved, ingestSnapshot, resolveRef, captureScreenshot, collectedTransitions, counters, journeys, log: log2 } = args;
5096
- const absorbActionResult = (xml, screenshot) => {
5097
- const resolved = buildResolved(xml, latest()?.screen.windowSize ?? null, void 0);
5239
+ const { toolName, toolArgs, iteration, driver, state: state2, enableRecordJourney, latest, buildResolved, absorbResolvedObservation, resolveRef, currentBoundary, collectedTransitions, counters, journeys, log: log2 } = args;
5240
+ const absorbActionResult = (actionResult, source) => {
5241
+ const resolved = buildResolved(actionResult.source, latest()?.screen.windowSize ?? null, actionResult.screenSignal);
5098
5242
  if (!resolved) {
5099
- return { observation: "(no UI tree returned)", screenId: null };
5243
+ return Promise.resolve({ observation: "(no UI tree returned)", screenId: null });
5100
5244
  }
5101
- const screenId = ingestSnapshot(resolved, iteration);
5102
- captureScreenshot(screenshot, screenId);
5103
- return { observation: resolved.observation, screenId };
5245
+ return absorbResolvedObservation(resolved, actionResult.screenshot, iteration, source);
5104
5246
  };
5105
5247
  switch (toolName) {
5106
5248
  // ----- Observation -----
@@ -5110,12 +5252,16 @@ Exploration complete: ${summary}`);
5110
5252
  if (!resolved) {
5111
5253
  return { ok: false, error: "snapshot returned no UI tree" };
5112
5254
  }
5113
- const screenId = ingestSnapshot(resolved, iteration);
5114
- captureScreenshot(snap.screenshot, screenId);
5115
- log2(` Snapshot: screen ${screenId} (${resolved.screen.elements.length} elements)`);
5255
+ const absorbed = await absorbResolvedObservation(resolved, snap.screenshot, iteration, "mobile_snapshot");
5256
+ if (absorbed.screenId) {
5257
+ const elementCount = state2.getScreen(absorbed.screenId)?.elements.length ?? resolved.screen.elements.length;
5258
+ log2(` Snapshot: screen ${absorbed.screenId} (${elementCount} elements)`);
5259
+ }
5116
5260
  return {
5117
- screenId,
5118
- observation: resolved.observation,
5261
+ screenId: absorbed.screenId,
5262
+ observation: absorbed.observation,
5263
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5264
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
5119
5265
  progress: `${state2.getScreenCount()} screens, ${collectedTransitions.length} transitions, ${journeys.length} journeys`
5120
5266
  };
5121
5267
  }
@@ -5126,14 +5272,21 @@ Exploration complete: ${summary}`);
5126
5272
  return { ok: false, error: `Unknown element ref "${String(toolArgs.ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.` };
5127
5273
  }
5128
5274
  const fromScreenId = state2.getCurrentScreenId();
5275
+ const boundaryBeforeAction = currentBoundary();
5276
+ const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
5129
5277
  const actionResult = await driver.tap(resolved.bounds);
5130
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5131
- autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, String(toolArgs.ref), "tap");
5278
+ const absorbed = await absorbActionResult(actionResult, "mobile_tap");
5279
+ const { observation, screenId } = absorbed;
5280
+ if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
5281
+ autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, String(toolArgs.ref), "tap");
5282
+ }
5132
5283
  return {
5133
5284
  ok: actionResult.ok,
5134
5285
  ...actionResult.error ? { error: actionResult.error } : {},
5135
5286
  screenId,
5136
- observation
5287
+ observation,
5288
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5289
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5137
5290
  };
5138
5291
  }
5139
5292
  // ----- Type -----
@@ -5144,12 +5297,15 @@ Exploration complete: ${summary}`);
5144
5297
  }
5145
5298
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
5146
5299
  const actionResult = await driver.type(value, resolved.bounds);
5147
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5300
+ const absorbed = await absorbActionResult(actionResult, "mobile_type");
5301
+ const { observation, screenId } = absorbed;
5148
5302
  return {
5149
5303
  ok: actionResult.ok,
5150
5304
  ...actionResult.error ? { error: actionResult.error } : {},
5151
5305
  screenId,
5152
- observation
5306
+ observation,
5307
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5308
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5153
5309
  };
5154
5310
  }
5155
5311
  // ----- Clear -----
@@ -5159,12 +5315,15 @@ Exploration complete: ${summary}`);
5159
5315
  return { ok: false, error: `Unknown element ref "${String(toolArgs.ref)}". Call mobile_snapshot first.` };
5160
5316
  }
5161
5317
  const actionResult = await driver.clear(resolved.bounds);
5162
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5318
+ const absorbed = await absorbActionResult(actionResult, "mobile_clear");
5319
+ const { observation, screenId } = absorbed;
5163
5320
  return {
5164
5321
  ok: actionResult.ok,
5165
5322
  ...actionResult.error ? { error: actionResult.error } : {},
5166
5323
  screenId,
5167
- observation
5324
+ observation,
5325
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5326
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5168
5327
  };
5169
5328
  }
5170
5329
  // ----- Swipe -----
@@ -5175,36 +5334,49 @@ Exploration complete: ${summary}`);
5175
5334
  }
5176
5335
  const distance = typeof toolArgs.distance === "number" ? toolArgs.distance : void 0;
5177
5336
  const actionResult = await driver.swipe(direction, distance);
5178
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5337
+ const absorbed = await absorbActionResult(actionResult, "mobile_swipe");
5338
+ const { observation, screenId } = absorbed;
5179
5339
  return {
5180
5340
  ok: actionResult.ok,
5181
5341
  ...actionResult.error ? { error: actionResult.error } : {},
5182
5342
  screenId,
5183
- observation
5343
+ observation,
5344
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5345
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5184
5346
  };
5185
5347
  }
5186
5348
  // ----- Back -----
5187
5349
  case "mobile_back": {
5188
5350
  const fromScreenId = state2.getCurrentScreenId();
5351
+ const boundaryBeforeAction = currentBoundary();
5352
+ const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
5189
5353
  const actionResult = await driver.back();
5190
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5191
- autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "back");
5354
+ const absorbed = await absorbActionResult(actionResult, "mobile_back");
5355
+ const { observation, screenId } = absorbed;
5356
+ if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
5357
+ autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "back");
5358
+ }
5192
5359
  return {
5193
5360
  ok: actionResult.ok,
5194
5361
  ...actionResult.error ? { error: actionResult.error } : {},
5195
5362
  screenId,
5196
- observation
5363
+ observation,
5364
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5365
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5197
5366
  };
5198
5367
  }
5199
5368
  // ----- Home (backgrounds only) -----
5200
5369
  case "mobile_home": {
5201
5370
  const actionResult = await driver.home();
5202
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5371
+ const absorbed = await absorbActionResult(actionResult, "mobile_home");
5372
+ const { observation, screenId } = absorbed;
5203
5373
  return {
5204
5374
  ok: actionResult.ok,
5205
5375
  ...actionResult.error ? { error: actionResult.error } : {},
5206
5376
  screenId,
5207
5377
  observation,
5378
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5379
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
5208
5380
  note: "Home only backgrounds the app. Use mobile_relaunch for a clean restart."
5209
5381
  };
5210
5382
  }
@@ -5217,9 +5389,15 @@ Exploration complete: ${summary}`);
5217
5389
  if (!resolved) {
5218
5390
  return { ok: true, note: "relaunched; no UI tree returned \u2014 call mobile_snapshot." };
5219
5391
  }
5220
- const screenId = ingestSnapshot(resolved, iteration);
5221
- captureScreenshot(snap.screenshot, screenId);
5222
- return { ok: true, screenId, observation: resolved.observation, note: "App relaunched (clean state)." };
5392
+ const absorbed = await absorbResolvedObservation(resolved, snap.screenshot, iteration, "mobile_relaunch");
5393
+ return {
5394
+ ok: true,
5395
+ screenId: absorbed.screenId,
5396
+ observation: absorbed.observation,
5397
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5398
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
5399
+ note: "App relaunched (clean state)."
5400
+ };
5223
5401
  }
5224
5402
  // ----- Deep-link (guarded) -----
5225
5403
  case "mobile_open_deeplink": {
@@ -5228,18 +5406,32 @@ Exploration complete: ${summary}`);
5228
5406
  return { ok: false, error: `Refused to open deep link "${deeplink}" (empty or dangerous scheme).` };
5229
5407
  }
5230
5408
  const fromScreenId = state2.getCurrentScreenId();
5409
+ const boundaryBeforeAction = currentBoundary();
5410
+ const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
5231
5411
  const actionResult = await driver.openDeepLink(deeplink);
5232
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5233
- autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "deeplink");
5412
+ const absorbed = await absorbActionResult(actionResult, "mobile_open_deeplink");
5413
+ const { observation, screenId } = absorbed;
5414
+ if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
5415
+ autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "deeplink");
5416
+ }
5234
5417
  return {
5235
5418
  ok: actionResult.ok,
5236
5419
  ...actionResult.error ? { error: actionResult.error } : {},
5237
5420
  screenId,
5238
- observation
5421
+ observation,
5422
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5423
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5239
5424
  };
5240
5425
  }
5241
5426
  // ----- capture_screen (mark + annotate current screen) -----
5242
5427
  case "capture_screen": {
5428
+ const boundary = currentBoundary();
5429
+ if (boundary.kind === "external" || boundary.kind === "transient_external") {
5430
+ return {
5431
+ ok: false,
5432
+ 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.`
5433
+ };
5434
+ }
5243
5435
  const screenId = state2.getCurrentScreenId();
5244
5436
  if (!screenId) {
5245
5437
  return { ok: false, error: "No current screen \u2014 call mobile_snapshot first." };
@@ -5290,6 +5482,13 @@ Exploration complete: ${summary}`);
5290
5482
  if (!enableRecordJourney) {
5291
5483
  return { ok: false, error: "record_journey is not available in survey mode." };
5292
5484
  }
5485
+ const boundary = currentBoundary();
5486
+ if (boundary.kind === "external" || boundary.kind === "transient_external") {
5487
+ return {
5488
+ ok: false,
5489
+ error: `Current foreground app is ${boundary.owner}, outside target app ${boundary.targetAppId}. External app screens cannot be recorded as a target-app journey.`
5490
+ };
5491
+ }
5293
5492
  const name = typeof toolArgs.name === "string" ? toolArgs.name : "";
5294
5493
  const screenId = typeof toolArgs.screenId === "string" && toolArgs.screenId ? toolArgs.screenId : state2.getCurrentScreenId() ?? "";
5295
5494
  const steps = Array.isArray(toolArgs.steps) ? toolArgs.steps.filter((s) => typeof s === "string") : [];
@@ -7080,7 +7279,7 @@ Each entry is exactly one of:
7080
7279
  const existingNamesLower = new Set(existingTestNames.map((n) => n.toLowerCase()));
7081
7280
  const usedNames = /* @__PURE__ */ new Set();
7082
7281
  const validTypes = /* @__PURE__ */ new Set(["HAPPY_PATH", "EDGE_CASE", "USER_JOURNEY", "REGRESSION", "BUG_REPORT"]);
7083
- const validOutcomeTypes = /* @__PURE__ */ new Set(["url", "element-visible", "text-visible", "value-match", "element-hidden"]);
7282
+ const validOutcomeTypes = /* @__PURE__ */ new Set(["url", "screen-visible", "element-visible", "text-visible", "value-match", "element-hidden"]);
7084
7283
  const validVariants = /* @__PURE__ */ new Set(["happy", "validation", "bug"]);
7085
7284
  const validCriticalities = /* @__PURE__ */ new Set(["critical", "high", "medium", "low"]);
7086
7285
  const inferOutcomeTypeFromDescription = (description) => /\b(?:heading|button|link|menu\s*item|menuitem|tab|card|modal|dialog|sidebar(?:\s+item)?|nav\s+item|navigation\s+link|form\s+label|field\s+label|avatar|banner)\b/i.test(description) ? "element-visible" : "text-visible";
@@ -7554,8 +7753,8 @@ Each entry is exactly one of:
7554
7753
  const agentModel = options.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
7555
7754
  const plannerMaxTokens = getAIPlannerMaxTokens(agentModel);
7556
7755
  logs2.push(`AI planner [${context.mode}]: ${siteMap.pages.length} pages, ${evidence.recordedJourneys.length} journeys, budget ${context.testBudget}`);
7557
- const systemPrompt = context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports.SCENARIO_SYSTEM_PROMPT;
7558
- const userPrompt = formatEvidenceForPlanner(compressed, evidence, context);
7756
+ const systemPrompt = options.systemPromptOverride ?? (context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports.SCENARIO_SYSTEM_PROMPT);
7757
+ const userPrompt = options.userPromptOverride ?? formatEvidenceForPlanner(compressed, evidence, context);
7559
7758
  logs2.push(`AI planner: ~${Math.ceil(userPrompt.length / 3.5)} input tokens`);
7560
7759
  const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)());
7561
7760
  const messages = [
@@ -7771,6 +7970,14 @@ var require_mobile_discovery_prompts = __commonJS({
7771
7970
  exports.runMobileAIPlanner = runMobileAIPlanner;
7772
7971
  var ai_provider_js_1 = require_ai_provider();
7773
7972
  var discover_ai_planner_js_1 = require_discover_ai_planner();
7973
+ function isTargetAppScreenId(screenId, appId) {
7974
+ if (!appId)
7975
+ return true;
7976
+ return screenId === appId || screenId.startsWith(`${appId}#`) || screenId.startsWith(`${appId}.`);
7977
+ }
7978
+ function targetAppScreens(graph, appId) {
7979
+ return [...graph.screens.values()].filter((state2) => isTargetAppScreenId(state2.screenId, appId));
7980
+ }
7774
7981
  exports.MOBILE_SURVEY_SYSTEM_PROMPT = `You are a feature analyst for a native mobile application (iOS or Android). You receive evidence from an AI explorer that mapped the app's screens by tapping, typing, and swiping through the live UI. Your job is to organize screens into meaningful feature groups.
7775
7982
 
7776
7983
  ## Output Discipline
@@ -8000,7 +8207,8 @@ ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
8000
8207
  if (context.featureName) {
8001
8208
  sections.push(`## Feature: ${context.featureName}`);
8002
8209
  }
8003
- const orderedScreens = [...graph.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8210
+ const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8211
+ const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
8004
8212
  sections.push(`## Screens Discovered (${orderedScreens.length})`);
8005
8213
  for (const state2 of orderedScreens) {
8006
8214
  const name = screenDisplayName(state2);
@@ -8022,7 +8230,7 @@ ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
8022
8230
  for (const state2 of orderedScreens)
8023
8231
  nameById.set(state2.screenId, screenDisplayName(state2));
8024
8232
  const lines = ["## Observed Screen Transitions"];
8025
- for (const t of graph.transitions.slice(0, MAX_TRANSITIONS)) {
8233
+ for (const t of graph.transitions.filter((transition) => includedScreenIds.has(transition.fromScreenId) && includedScreenIds.has(transition.toScreenId)).slice(0, MAX_TRANSITIONS)) {
8026
8234
  const from = nameById.get(t.fromScreenId) ?? t.fromScreenId;
8027
8235
  const to = nameById.get(t.toScreenId) ?? t.toScreenId;
8028
8236
  const via = t.viaRef ? ` via ${t.viaRef}` : "";
@@ -8108,7 +8316,7 @@ ${constraints.join("\n")}`);
8108
8316
  const now = /* @__PURE__ */ new Date();
8109
8317
  const idByScreen = /* @__PURE__ */ new Map();
8110
8318
  let pageSeq = 0;
8111
- const orderedScreens = [...graph.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8319
+ const orderedScreens = targetAppScreens(graph, meta.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8112
8320
  for (const state2 of orderedScreens) {
8113
8321
  idByScreen.set(state2.screenId, `mpage-${pageSeq++}`);
8114
8322
  }
@@ -8167,6 +8375,135 @@ ${constraints.join("\n")}`);
8167
8375
  seedFixtures: []
8168
8376
  };
8169
8377
  }
8378
+ function buildMobileFeatureGroupsFromGraph(graph, appId) {
8379
+ const groups = /* @__PURE__ */ new Map();
8380
+ const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8381
+ const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
8382
+ for (const state2 of orderedScreens) {
8383
+ const groupName = state2.screenType === "auth" ? "Authentication" : screenDisplayName(state2);
8384
+ const group = groups.get(groupName) ?? {
8385
+ name: groupName,
8386
+ description: `Native mobile screens related to ${groupName}.`,
8387
+ routes: [],
8388
+ criticality: state2.screenType === "auth" ? "critical" : "medium",
8389
+ pages: [],
8390
+ flows: []
8391
+ };
8392
+ group.routes.push(state2.screenId);
8393
+ group.pages?.push({
8394
+ route: state2.screenId,
8395
+ type: state2.screenType ?? "UNKNOWN",
8396
+ summary: `${state2.elements.filter((el) => el.actionable).length} actionable element(s)`
8397
+ });
8398
+ groups.set(groupName, group);
8399
+ }
8400
+ const nameById = /* @__PURE__ */ new Map();
8401
+ for (const state2 of graph.screens.values()) {
8402
+ nameById.set(state2.screenId, screenDisplayName(state2));
8403
+ }
8404
+ for (const transition of graph.transitions) {
8405
+ if (!includedScreenIds.has(transition.fromScreenId) || !includedScreenIds.has(transition.toScreenId))
8406
+ continue;
8407
+ const from = graph.screens.get(transition.fromScreenId);
8408
+ const to = graph.screens.get(transition.toScreenId);
8409
+ if (!from || !to)
8410
+ continue;
8411
+ const groupName = from.screenType === "auth" ? "Authentication" : screenDisplayName(from);
8412
+ const group = groups.get(groupName);
8413
+ if (!group)
8414
+ continue;
8415
+ const via = transition.viaRef ? ` via ${transition.viaRef}` : "";
8416
+ group.flows?.push(`${nameById.get(transition.fromScreenId) ?? transition.fromScreenId} \u2192 ${nameById.get(transition.toScreenId) ?? transition.toScreenId} (${transition.action}${via})`);
8417
+ }
8418
+ return [...groups.values()];
8419
+ }
8420
+ function buildMobileFallbackScenarios(input) {
8421
+ if (input.mode === "survey")
8422
+ return [];
8423
+ 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);
8424
+ if (orderedScreens.length === 0)
8425
+ return [];
8426
+ const entryScreenId = orderedScreens[0].screenId;
8427
+ const usedNames = new Set((input.existingTestNames ?? []).map((name) => name.toLowerCase()));
8428
+ const scenarios = [];
8429
+ const budget = Math.max(1, input.testBudget || orderedScreens.length);
8430
+ for (const state2 of orderedScreens) {
8431
+ if (scenarios.length >= budget)
8432
+ break;
8433
+ const screenName = screenDisplayName(state2);
8434
+ const baseName = `${screenName} - Screen Reachability`;
8435
+ const name = usedNames.has(baseName.toLowerCase()) ? `${baseName} ${state2.firstSeenIteration + 1}` : baseName;
8436
+ if (usedNames.has(name.toLowerCase()))
8437
+ continue;
8438
+ usedNames.add(name.toLowerCase());
8439
+ 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);
8440
+ const steps = [
8441
+ { action: "Relaunch the app from a clean state", target: entryScreenId },
8442
+ {
8443
+ action: state2.screenId === entryScreenId ? `Verify the launch screen is ${screenName}` : `Navigate through the app until the ${screenName} screen is visible`,
8444
+ target: state2.screenId
8445
+ }
8446
+ ];
8447
+ if (actionableLabels.length > 0) {
8448
+ steps.push({
8449
+ action: `Verify expected native controls are available: ${actionableLabels.join(", ")}`,
8450
+ target: state2.screenId
8451
+ });
8452
+ }
8453
+ scenarios.push({
8454
+ name,
8455
+ type: "HAPPY_PATH",
8456
+ priority: state2.screenType === "auth" ? 1 : 3,
8457
+ variant: "happy",
8458
+ featureGroup: state2.screenType === "auth" ? "Authentication" : screenName,
8459
+ goal: `User can reach and inspect the ${screenName} screen`,
8460
+ steps,
8461
+ expectedOutcomes: [{
8462
+ type: "screen-visible",
8463
+ description: `${screenName} screen is visible`,
8464
+ reliability: "medium"
8465
+ }],
8466
+ entryRoute: entryScreenId,
8467
+ startFromLanding: true,
8468
+ targetPages: [state2.screenId],
8469
+ evidence: { observedLocators: [], observedTransitions: [] },
8470
+ prerequisites: []
8471
+ });
8472
+ }
8473
+ return scenarios;
8474
+ }
8475
+ function filterRecordedJourneysToSiteMap(journeys, siteMap) {
8476
+ if (!journeys?.length)
8477
+ return [];
8478
+ const validRoutes = new Set(siteMap.pages.map((page) => page.route));
8479
+ const filtered = [];
8480
+ for (const journey of journeys) {
8481
+ const routes = (journey.routes?.length ? journey.routes : [journey.route]).filter((route2) => validRoutes.has(route2));
8482
+ if (routes.length === 0)
8483
+ continue;
8484
+ filtered.push({
8485
+ ...journey,
8486
+ route: validRoutes.has(journey.route) ? journey.route : routes[0],
8487
+ routes
8488
+ });
8489
+ }
8490
+ return filtered;
8491
+ }
8492
+ function filterFeatureGroupsToSiteMap(groups, siteMap) {
8493
+ if (groups.length === 0)
8494
+ return [];
8495
+ const validRoutes = new Set(siteMap.pages.map((page) => page.route));
8496
+ return groups.flatMap((group) => {
8497
+ const routes = group.routes.filter((route2) => validRoutes.has(route2));
8498
+ if (routes.length === 0)
8499
+ return [];
8500
+ return [{
8501
+ ...group,
8502
+ routes,
8503
+ pages: group.pages?.filter((page) => validRoutes.has(page.route))
8504
+ }];
8505
+ });
8506
+ }
8170
8507
  async function runMobileAIPlanner(input, options = {}) {
8171
8508
  const planner = options.planner ?? discover_ai_planner_js_1.runAIPlanner;
8172
8509
  const shouldBuildMobileAIClient = (process.env.SERVER_URL ?? "").trim().length > 0 || !options.planner;
@@ -8175,11 +8512,12 @@ ${constraints.join("\n")}`);
8175
8512
  projectId: input.projectId,
8176
8513
  appId: input.appId
8177
8514
  });
8515
+ const recordedJourneys = filterRecordedJourneysToSiteMap(input.recordedJourneys, siteMap);
8178
8516
  const collectedPages = input.collectedPages ?? [];
8179
8517
  const collectedTransitions = input.collectedTransitions ?? [];
8180
- const visitedRoutes = [...input.graph.screens.values()].filter((s) => s.visited).map((s) => s.screenId);
8181
- return planner(siteMap, {
8182
- recordedJourneys: input.recordedJourneys ?? [],
8518
+ const visitedRoutes = targetAppScreens(input.graph, input.appId).filter((s) => s.visited).map((s) => s.screenId);
8519
+ const result = await planner(siteMap, {
8520
+ recordedJourneys,
8183
8521
  collectedPages,
8184
8522
  collectedTransitions,
8185
8523
  visitedRoutes
@@ -8197,8 +8535,52 @@ ${constraints.join("\n")}`);
8197
8535
  }, {
8198
8536
  modelOverride: options.modelOverride,
8199
8537
  onAICall: options.onAICall,
8200
- ...aiClient ? { aiClient } : {}
8538
+ ...aiClient ? { aiClient } : {},
8539
+ systemPromptOverride: input.mode === "survey" ? exports.MOBILE_SURVEY_SYSTEM_PROMPT : exports.MOBILE_SCENARIO_SYSTEM_PROMPT,
8540
+ userPromptOverride: formatMobileEvidenceForPlanner(input.graph, {
8541
+ appBrief: input.appBrief,
8542
+ appId: input.appId,
8543
+ platform: input.platform,
8544
+ featureName: input.featureName,
8545
+ existingTestNames: input.existingTestNames,
8546
+ existingFeatureNames: input.existingFeatureNames,
8547
+ recordedJourneys,
8548
+ credentials: input.credentials,
8549
+ testBudget: input.testBudget,
8550
+ incremental: input.incremental
8551
+ })
8201
8552
  });
8553
+ const plannerFeatureGroups = filterFeatureGroupsToSiteMap(result.featureGroups, siteMap);
8554
+ const mobileFeatureGroups = plannerFeatureGroups.length > 0 ? plannerFeatureGroups : buildMobileFeatureGroupsFromGraph(input.graph, input.appId);
8555
+ if (input.mode !== "survey" && result.scenarios.length === 0 && input.graph.screens.size > 0) {
8556
+ const fallbackScenarios = buildMobileFallbackScenarios(input);
8557
+ if (fallbackScenarios.length > 0) {
8558
+ return {
8559
+ ...result,
8560
+ scenarios: fallbackScenarios,
8561
+ featureGroups: mobileFeatureGroups,
8562
+ logs: [
8563
+ ...result.logs,
8564
+ `Mobile fallback: synthesized ${fallbackScenarios.length} screen-reachability scenario(s) from the native screen graph`
8565
+ ],
8566
+ fallbackUsed: true,
8567
+ fallbackReason: result.fallbackReason ?? "Mobile planner produced no scenarios"
8568
+ };
8569
+ }
8570
+ }
8571
+ if (input.mode === "survey" && result.featureGroups.length === 0 && mobileFeatureGroups.length > 0) {
8572
+ return {
8573
+ ...result,
8574
+ featureGroups: mobileFeatureGroups,
8575
+ logs: [
8576
+ ...result.logs,
8577
+ `Mobile fallback: synthesized ${mobileFeatureGroups.length} feature group(s) from the native screen graph`
8578
+ ],
8579
+ fallbackUsed: true,
8580
+ fallbackReason: result.fallbackReason ?? "Mobile planner produced no feature groups"
8581
+ };
8582
+ }
8583
+ return { ...result, featureGroups: mobileFeatureGroups };
8202
8584
  }
8203
8585
  }
8204
8586
  });
@@ -8924,13 +9306,17 @@ ${statePacket}` },
8924
9306
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
8925
9307
  continue;
8926
9308
  }
8927
- tests.push(test);
8928
9309
  log2(` \u2713 generated "${test.name}" (verified: ${test.verified}, steps: ${test.steps?.length ?? 0})`);
8929
9310
  try {
8930
- onTestGenerated?.(test);
9311
+ await onTestGenerated?.(test);
8931
9312
  } catch (cbErr) {
9313
+ if (cbErr?.isPlanLimitReached) {
9314
+ log2(" Plan limit reached \u2014 stopping further generation");
9315
+ break;
9316
+ }
8932
9317
  log2(` onTestGenerated callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
8933
9318
  }
9319
+ tests.push(test);
8934
9320
  } catch (err) {
8935
9321
  log2(` scenario "${scenario.name}" failed: ${err instanceof Error ? err.message : String(err)} \u2014 skipping.`);
8936
9322
  continue;
@@ -12611,6 +12997,17 @@ var require_snapshot_observation = __commonJS({
12611
12997
  for (const container of group.containers) {
12612
12998
  lines.push(` - ${yamlScalar(container ?? "(none)")}`);
12613
12999
  }
13000
+ const hasRowContext = group.rowContexts.some((rc) => !!rc);
13001
+ if (hasRowContext) {
13002
+ lines.push(' distinguish_by_row: # same name in different rows \u2014 scope by this text, e.g. getByRole(role, { name }).filter({ has: getByText("<row>") })');
13003
+ for (let i = 0; i < group.rowContexts.length; i++) {
13004
+ const rowContext = group.rowContexts[i];
13005
+ if (!rowContext)
13006
+ continue;
13007
+ const ref = group.refs[i];
13008
+ lines.push(` - ${yamlScalar(`${ref ? `[${ref}] ` : ""}${rowContext}`)}`);
13009
+ }
13010
+ }
12614
13011
  }
12615
13012
  }
12616
13013
  lines.push("interactive_elements:");
@@ -12642,6 +13039,29 @@ var require_snapshot_observation = __commonJS({
12642
13039
  if (topLocator) {
12643
13040
  lines.push(` locator: ${yamlScalar((0, snapshot_parser_js_1.formatLocator)(topLocator))}`);
12644
13041
  }
13042
+ const enr = rankedElement.element.enrichment;
13043
+ if (enr?.rowContext) {
13044
+ lines.push(` row_context: ${yamlScalar(enr.rowContext)}`);
13045
+ }
13046
+ if (enr && (enr.occluded || enr.position && enr.position !== "in-view")) {
13047
+ const vis = [];
13048
+ if (enr.position && enr.position !== "in-view")
13049
+ vis.push(`off-screen-${enr.position}`);
13050
+ if (enr.occluded)
13051
+ vis.push("occluded");
13052
+ lines.push(` visibility: [${vis.map((token) => yamlScalar(token)).join(", ")}]`);
13053
+ }
13054
+ }
13055
+ }
13056
+ const recoveredClickables = options.recoveredClickables ?? [];
13057
+ if (recoveredClickables.length > 0) {
13058
+ lines.push("non_semantic_clickables:");
13059
+ 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.');
13060
+ for (const clickable of recoveredClickables.slice(0, 8)) {
13061
+ lines.push(` - text: ${yamlScalar(clickable.text)}`);
13062
+ if (clickable.rowContext) {
13063
+ lines.push(` row_context: ${yamlScalar(clickable.rowContext)}`);
13064
+ }
12645
13065
  }
12646
13066
  }
12647
13067
  lines.push("observation_cues:");
@@ -12815,6 +13235,9 @@ var require_snapshot_observation = __commonJS({
12815
13235
  score += 12;
12816
13236
  }
12817
13237
  }
13238
+ const enr = element.enrichment;
13239
+ if (enr?.rowContext)
13240
+ score += 4;
12818
13241
  return score;
12819
13242
  }
12820
13243
  function buildStateSummary(element) {
@@ -12990,10 +13413,12 @@ var require_snapshot_observation = __commonJS({
12990
13413
  const key = `${role}\0${name}`;
12991
13414
  let group = groups.get(key);
12992
13415
  if (!group) {
12993
- group = { role, name, containers: [] };
13416
+ group = { role, name, containers: [], rowContexts: [], refs: [] };
12994
13417
  groups.set(key, group);
12995
13418
  }
12996
13419
  group.containers.push(element.containerPath ?? null);
13420
+ group.rowContexts.push(element.enrichment?.rowContext ?? null);
13421
+ group.refs.push(element.ref ?? null);
12997
13422
  }
12998
13423
  return [...groups.values()].filter((group) => group.containers.length > 1);
12999
13424
  }
@@ -234647,6 +235072,154 @@ var require_preflight_syntax = __commonJS({
234647
235072
  }
234648
235073
  });
234649
235074
 
235075
+ // ../runner-core/dist/services/selector-registry-context.js
235076
+ var require_selector_registry_context = __commonJS({
235077
+ "../runner-core/dist/services/selector-registry-context.js"(exports) {
235078
+ "use strict";
235079
+ Object.defineProperty(exports, "__esModule", { value: true });
235080
+ exports.formatSelectorRegistryContextForPrompt = formatSelectorRegistryContextForPrompt;
235081
+ function isRecord(value) {
235082
+ return value !== null && typeof value === "object" && !Array.isArray(value);
235083
+ }
235084
+ function asString(value, fallback = "") {
235085
+ return typeof value === "string" ? value : fallback;
235086
+ }
235087
+ function asNumber(value, fallback = 0) {
235088
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
235089
+ }
235090
+ function asStringArray(value) {
235091
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
235092
+ }
235093
+ function truncateForSelectorPrompt(value, maxLength = 180) {
235094
+ const normalized = (value ?? "").replace(/\s+/g, " ").trim();
235095
+ if (normalized.length <= maxLength)
235096
+ return normalized;
235097
+ return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
235098
+ }
235099
+ function inlineCode(value, maxLength = 180) {
235100
+ return `\`${truncateForSelectorPrompt(value, maxLength).replace(/`/g, "'")}\``;
235101
+ }
235102
+ function normalizeRef(value) {
235103
+ if (!isRecord(value))
235104
+ return null;
235105
+ const expression = asString(value.expression).trim();
235106
+ if (!expression)
235107
+ return null;
235108
+ return {
235109
+ route: asString(value.route, "*"),
235110
+ usage: asString(value.usage, "locator"),
235111
+ source: asString(value.source, "generated"),
235112
+ expression,
235113
+ occurrenceIndex: asNumber(value.occurrenceIndex, 0)
235114
+ };
235115
+ }
235116
+ function normalizeSibling(value) {
235117
+ if (!isRecord(value))
235118
+ return null;
235119
+ const testCaseId = asString(value.testCaseId).trim();
235120
+ const expression = asString(value.expression).trim();
235121
+ if (!testCaseId || !expression)
235122
+ return null;
235123
+ return {
235124
+ testCaseId,
235125
+ name: asString(value.name, testCaseId),
235126
+ suite: typeof value.suite === "string" ? value.suite : null,
235127
+ lastPassedAt: typeof value.lastPassedAt === "string" ? value.lastPassedAt : null,
235128
+ expression,
235129
+ usage: asString(value.usage, "locator"),
235130
+ route: asString(value.route, "*"),
235131
+ source: asString(value.source, "generated")
235132
+ };
235133
+ }
235134
+ function normalizeEntry(value) {
235135
+ if (!isRecord(value))
235136
+ return null;
235137
+ const id = asString(value.id).trim();
235138
+ const expression = asString(value.expression).trim();
235139
+ if (!id || !expression)
235140
+ return null;
235141
+ return {
235142
+ id,
235143
+ key: asString(value.key, id),
235144
+ route: asString(value.route, "*"),
235145
+ scope: asString(value.scope, "page"),
235146
+ status: asString(value.status, "ACTIVE"),
235147
+ stability: asNumber(value.stability, 1),
235148
+ label: asString(value.label),
235149
+ locatorKind: asString(value.locatorKind),
235150
+ locatorRole: typeof value.locatorRole === "string" ? value.locatorRole : null,
235151
+ locatorName: typeof value.locatorName === "string" ? value.locatorName : null,
235152
+ locatorTestId: typeof value.locatorTestId === "string" ? value.locatorTestId : null,
235153
+ expression,
235154
+ fallbacks: asStringArray(value.fallbacks),
235155
+ currentTestRefs: Array.isArray(value.currentTestRefs) ? value.currentTestRefs.map(normalizeRef).filter((ref) => Boolean(ref)) : [],
235156
+ refCount: asNumber(value.refCount, 0),
235157
+ testCount: asNumber(value.testCount, 0),
235158
+ siblingFanoutCount: asNumber(value.siblingFanoutCount, 0),
235159
+ usageFamilies: asStringArray(value.usageFamilies),
235160
+ riskFlags: asStringArray(value.riskFlags),
235161
+ lastHealedAt: typeof value.lastHealedAt === "string" ? value.lastHealedAt : null,
235162
+ recentPassingSiblings: Array.isArray(value.recentPassingSiblings) ? value.recentPassingSiblings.map(normalizeSibling).filter((sibling) => Boolean(sibling)) : [],
235163
+ recentFailingSiblingCount: asNumber(value.recentFailingSiblingCount, 0)
235164
+ };
235165
+ }
235166
+ function formatSelectorRegistryContextForPrompt(context) {
235167
+ if (!isRecord(context))
235168
+ return "";
235169
+ const entries = Array.isArray(context.entries) ? context.entries.map(normalizeEntry).filter((entry) => Boolean(entry)) : [];
235170
+ if (entries.length === 0)
235171
+ return "";
235172
+ const summary = isRecord(context.summary) ? context.summary : null;
235173
+ const parts = [
235174
+ "## Selector Registry Memory (read-only evidence)",
235175
+ "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."
235176
+ ];
235177
+ if (summary) {
235178
+ const selectorCount = asNumber(summary.selectorCount, entries.length);
235179
+ const sharedSelectorCount = asNumber(summary.sharedSelectorCount, entries.filter((entry) => entry.testCount > 1).length);
235180
+ const maxSharedTestCount = asNumber(summary.maxSharedTestCount, Math.max(...entries.map((entry) => entry.testCount), 0));
235181
+ parts.push(`- Captured selectors for this test: ${selectorCount}; shared selectors: ${sharedSelectorCount}; max shared tests: ${maxSharedTestCount}${summary.truncated ? "; list truncated" : ""}.`);
235182
+ }
235183
+ for (const entry of entries.slice(0, 8)) {
235184
+ const risk = entry.riskFlags.length > 0 ? entry.riskFlags.join(", ") : "none";
235185
+ const families = entry.usageFamilies.length > 0 ? entry.usageFamilies.join(", ") : "locator";
235186
+ parts.push("");
235187
+ parts.push(`### ${truncateForSelectorPrompt(entry.label || entry.key, 90)}`);
235188
+ parts.push(`- Selector: ${inlineCode(entry.expression, 220)}`);
235189
+ parts.push(`- Route/scope/kind: ${entry.route} / ${entry.scope} / ${entry.locatorKind || "unknown"}; usage families: ${families}.`);
235190
+ parts.push(`- Shared by: ${entry.testCount} test(s), ${entry.siblingFanoutCount} sibling(s), ${entry.refCount} ref(s); risks: ${risk}; status: ${entry.status}; stability: ${entry.stability}.`);
235191
+ if (entry.lastHealedAt)
235192
+ parts.push(`- Last registry heal: ${entry.lastHealedAt}.`);
235193
+ const currentRefs = entry.currentTestRefs.slice(0, 3);
235194
+ if (currentRefs.length > 0) {
235195
+ parts.push("- Current test occurrences:");
235196
+ for (const ref of currentRefs) {
235197
+ parts.push(` - ${ref.usage} on ${ref.route} from ${ref.source}: ${inlineCode(ref.expression, 180)}`);
235198
+ }
235199
+ }
235200
+ const siblings = entry.recentPassingSiblings.slice(0, 3);
235201
+ if (siblings.length > 0) {
235202
+ parts.push("- Recent passing sibling tests using this registry entry:");
235203
+ for (const sibling of siblings) {
235204
+ const suite = sibling.suite ? ` (${truncateForSelectorPrompt(sibling.suite, 50)})` : "";
235205
+ const passed = sibling.lastPassedAt ? ` passed ${sibling.lastPassedAt}` : "";
235206
+ parts.push(` - ${truncateForSelectorPrompt(sibling.name, 80)}${suite}${passed}: ${inlineCode(sibling.expression, 180)}`);
235207
+ }
235208
+ }
235209
+ if (entry.recentFailingSiblingCount && entry.recentFailingSiblingCount > 0) {
235210
+ parts.push(`- Other recent failing siblings on this entry: ${entry.recentFailingSiblingCount}.`);
235211
+ }
235212
+ const fallbacks = (entry.fallbacks ?? []).slice(0, 3);
235213
+ if (fallbacks.length > 0) {
235214
+ parts.push(`- Registry fallbacks: ${fallbacks.map((fallback) => inlineCode(fallback, 120)).join(", ")}.`);
235215
+ }
235216
+ }
235217
+ return `${parts.join("\n")}
235218
+ `;
235219
+ }
235220
+ }
235221
+ });
235222
+
234650
235223
  // ../runner-core/dist/services/mcp-healer.js
234651
235224
  var require_mcp_healer = __commonJS({
234652
235225
  "../runner-core/dist/services/mcp-healer.js"(exports) {
@@ -234741,6 +235314,7 @@ var require_mcp_healer = __commonJS({
234741
235314
  var snapshot_context_compactor_js_1 = require_snapshot_context_compactor();
234742
235315
  var rate_limit_detector_js_1 = require_rate_limit_detector();
234743
235316
  var preflight_syntax_js_1 = require_preflight_syntax();
235317
+ var selector_registry_context_js_1 = require_selector_registry_context();
234744
235318
  function stripCodeFences(code) {
234745
235319
  let cleaned = code.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
234746
235320
  cleaned = cleaned.replace(/^\s*```(?:typescript|ts|javascript|js)?\s*\n?/, "").replace(/\n?\s*```\s*$/, "");
@@ -235302,7 +235876,7 @@ ${lines.join("\n")}
235302
235876
  }).join("\n");
235303
235877
  }
235304
235878
  async function executeScriptDrivenHeal(playwrightCode, baseUrl, options) {
235305
- const { testName, testVariables, surfaceIntelligence, healingContext, lastFailureError, failureScreenshot, lastHealAttempt, consecutiveFails, modelOverride, onAICall, stepResults, tags } = options;
235879
+ const { testName, testVariables, surfaceIntelligence, healingContext, selectorRegistryContext, lastFailureError, failureScreenshot, lastHealAttempt, consecutiveFails, modelOverride, onAICall, stepResults, tags } = options;
235306
235880
  const landingViolation = (tags ?? []).includes("@landing-violation");
235307
235881
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "strong") : (0, ai_provider_js_1.getModel)("strong");
235308
235882
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
@@ -235365,6 +235939,11 @@ ${lastHealAttempt.healReason.slice(0, 800)}`);
235365
235939
  if (healingContext) {
235366
235940
  parts.push(`
235367
235941
  ${formatHealingContextForPrompt(healingContext)}`);
235942
+ }
235943
+ const selectorRegistryPrompt = (0, selector_registry_context_js_1.formatSelectorRegistryContextForPrompt)(selectorRegistryContext);
235944
+ if (selectorRegistryPrompt) {
235945
+ parts.push(`
235946
+ ${selectorRegistryPrompt}`);
235368
235947
  }
235369
235948
  if (surfaceIntelligence) {
235370
235949
  parts.push(`
@@ -235972,6 +236551,7 @@ ${(verifyResult.logs ?? "").slice(0, 2e3)}`;
235972
236551
  testVariables: options?.testVariables,
235973
236552
  surfaceIntelligence: options?.surfaceIntelligence,
235974
236553
  healingContext: options?.healingContext,
236554
+ selectorRegistryContext: options?.selectorRegistryContext,
235975
236555
  lastFailureError: options?.lastFailureError,
235976
236556
  failureScreenshot: options?.failureScreenshot,
235977
236557
  lastHealAttempt: options?.lastHealAttempt,
@@ -236512,6 +237092,7 @@ ${publicLines}` : ""}${credBlock}
236512
237092
  `;
236513
237093
  })()}
236514
237094
  ${options?.healingContext ? `${formatHealingContextForPrompt(options.healingContext)}` : ""}
237095
+ ${(0, selector_registry_context_js_1.formatSelectorRegistryContextForPrompt)(options?.selectorRegistryContext)}
236515
237096
  ${surfaceIntelligence ? `## Original Recorded Actions (what the test was trying to do)
236516
237097
  ${surfaceIntelligence.slice(0, 1500)}
236517
237098
  ` : ""}${landingViolation ? `## STRUCTURAL REQUIREMENT
@@ -238558,6 +239139,7 @@ var require_grok_per_test_healer = __commonJS({
238558
239139
  "use strict";
238559
239140
  Object.defineProperty(exports, "__esModule", { value: true });
238560
239141
  exports.executeGrokPerTestHeal = executeGrokPerTestHeal2;
239142
+ exports.buildHealBrief = buildHealBrief;
238561
239143
  var node_child_process_1 = __require("child_process");
238562
239144
  var promises_1 = __require("fs/promises");
238563
239145
  var node_os_1 = __require("os");
@@ -238574,6 +239156,7 @@ var require_grok_per_test_healer = __commonJS({
238574
239156
  var playwright_mcp_config_js_1 = require_playwright_mcp_config();
238575
239157
  var heal_session_tailer_js_1 = require_heal_session_tailer();
238576
239158
  var proven_actions_adapter_js_1 = require_proven_actions_adapter();
239159
+ var selector_registry_context_js_1 = require_selector_registry_context();
238577
239160
  var HEAL_MCP_CAPABILITIES = ["core", "core-navigation", "core-input", "core-tabs", "testing"];
238578
239161
  var grok_cli_js_1 = require_grok_cli();
238579
239162
  var DEFAULT_MAX_ATTEMPTS = 3;
@@ -238679,6 +239262,7 @@ var require_grok_per_test_healer = __commonJS({
238679
239262
  lastDiagnosis,
238680
239263
  publicVars: publics,
238681
239264
  secretKeys: Object.keys(secrets),
239265
+ selectorRegistryContext: options.selectorRegistryContext ?? null,
238682
239266
  attempt,
238683
239267
  maxAttempts
238684
239268
  }), "utf8");
@@ -238906,6 +239490,7 @@ ${input.lastDiagnosis ? `
238906
239490
  ## Your prior diagnosis (do not repeat the same fix)
238907
239491
  ${input.lastDiagnosis}
238908
239492
  ` : ""}
239493
+ ${(0, selector_registry_context_js_1.formatSelectorRegistryContextForPrompt)(input.selectorRegistryContext)}
238909
239494
 
238910
239495
  ## Tools available
238911
239496
  - **bash** \u2014 \`curl\`, \`sed\`, \`grep\` for static inspection of the app under test.
@@ -299620,6 +300205,699 @@ var require_capture_page_normalizer = __commonJS({
299620
300205
  }
299621
300206
  });
299622
300207
 
300208
+ // ../runner-core/dist/services/perception-enricher.js
300209
+ var require_perception_enricher = __commonJS({
300210
+ "../runner-core/dist/services/perception-enricher.js"(exports) {
300211
+ "use strict";
300212
+ Object.defineProperty(exports, "__esModule", { value: true });
300213
+ exports.enrichCapturedPage = enrichCapturedPage;
300214
+ exports.mergePerceptionOntoElements = mergePerceptionOntoElements;
300215
+ exports.elementRoleClass = elementRoleClass;
300216
+ exports.normalizeName = normalizeName;
300217
+ exports.urlMatchesRoute = urlMatchesRoute;
300218
+ var DEFAULT_CONFIG = {
300219
+ maxCandidates: 220,
300220
+ maxScan: 4e3,
300221
+ recoverNonSemantic: false,
300222
+ budgetMs: 1500
300223
+ };
300224
+ var BLACKOUT_THRESHOLD = 8;
300225
+ var EVALUATE_TIMEOUT_MS = 1800;
300226
+ var MAX_RECOVERED = 12;
300227
+ async function enrichCapturedPage(page, input, opts = {}) {
300228
+ const empty = { recovered: [], enriched: 0, perceived: 0 };
300229
+ if (!page)
300230
+ return { ...empty, skipReason: "no-page" };
300231
+ try {
300232
+ const url = page.url();
300233
+ if (url && input.route && !urlMatchesRoute(url, input.route)) {
300234
+ opts.onLog?.(`[perception] skip: page ${shortUrl(url)} != captured route ${input.route}`);
300235
+ return { ...empty, skipReason: "route-mismatch" };
300236
+ }
300237
+ } catch {
300238
+ }
300239
+ const elements = input.elements ?? [];
300240
+ const parsedInteractive = input.parsedInteractiveCount ?? elements.length;
300241
+ const cfg = {
300242
+ ...DEFAULT_CONFIG,
300243
+ recoverNonSemantic: parsedInteractive < BLACKOUT_THRESHOLD
300244
+ };
300245
+ let perceived;
300246
+ try {
300247
+ const raw = await withTimeout(page.evaluate(collectPerception, cfg), opts.timeoutMs ?? EVALUATE_TIMEOUT_MS);
300248
+ if (!Array.isArray(raw))
300249
+ return { ...empty, skipReason: "bad-result" };
300250
+ perceived = raw;
300251
+ } catch (err) {
300252
+ opts.onLog?.(`[perception] evaluate failed \u2014 ${err instanceof Error ? err.message : String(err)}`);
300253
+ return { ...empty, skipReason: "evaluate-error" };
300254
+ }
300255
+ const { enriched } = mergePerceptionOntoElements(elements, perceived);
300256
+ const recovered = collectRecovered(perceived);
300257
+ opts.onLog?.(`[perception] enriched ${enriched}/${elements.length} elements, ${recovered.length} non-semantic clickables (${perceived.length} perceived)`);
300258
+ return { recovered, enriched, perceived: perceived.length };
300259
+ }
300260
+ function mergePerceptionOntoElements(elements, perceived) {
300261
+ const perceivedGroups = /* @__PURE__ */ new Map();
300262
+ for (const p of perceived) {
300263
+ if (p.nonSemantic || !p.roleClass || !p.name)
300264
+ continue;
300265
+ const key = `${p.roleClass}\0${p.name}`;
300266
+ let arr = perceivedGroups.get(key);
300267
+ if (!arr) {
300268
+ arr = [];
300269
+ perceivedGroups.set(key, arr);
300270
+ }
300271
+ arr.push(p);
300272
+ }
300273
+ for (const arr of perceivedGroups.values())
300274
+ arr.sort((a, b) => a.domOrder - b.domOrder);
300275
+ const elementGroups = /* @__PURE__ */ new Map();
300276
+ for (const el of elements) {
300277
+ const roleClass = elementRoleClass(el);
300278
+ const name = normalizeName(el.label);
300279
+ if (!roleClass || !name)
300280
+ continue;
300281
+ const key = `${roleClass}\0${name}`;
300282
+ let arr = elementGroups.get(key);
300283
+ if (!arr) {
300284
+ arr = [];
300285
+ elementGroups.set(key, arr);
300286
+ }
300287
+ arr.push(el);
300288
+ }
300289
+ let enriched = 0;
300290
+ for (const [key, els] of elementGroups) {
300291
+ const ps = perceivedGroups.get(key);
300292
+ if (!ps)
300293
+ continue;
300294
+ if (ps.length !== els.length)
300295
+ continue;
300296
+ for (let i = 0; i < els.length; i++) {
300297
+ const enrichment = toEnrichment(ps[i]);
300298
+ if (enrichment) {
300299
+ els[i].enrichment = enrichment;
300300
+ enriched++;
300301
+ }
300302
+ }
300303
+ }
300304
+ return { enriched };
300305
+ }
300306
+ function toEnrichment(p) {
300307
+ const e = {};
300308
+ if (p.rowContext)
300309
+ e.rowContext = p.rowContext;
300310
+ if (p.position && p.position !== "in-view")
300311
+ e.position = p.position;
300312
+ if (p.occluded)
300313
+ e.occluded = true;
300314
+ if (p.cursorPointer)
300315
+ e.cursorPointer = true;
300316
+ return Object.keys(e).length > 0 ? e : null;
300317
+ }
300318
+ function collectRecovered(perceived) {
300319
+ const out = [];
300320
+ const seen = /* @__PURE__ */ new Set();
300321
+ for (const p of perceived) {
300322
+ if (!p.nonSemantic || !p.text)
300323
+ continue;
300324
+ const key = `${p.text}\0${p.rowContext ?? ""}`;
300325
+ if (seen.has(key))
300326
+ continue;
300327
+ seen.add(key);
300328
+ const rc = { text: p.text };
300329
+ if (p.rowContext)
300330
+ rc.rowContext = p.rowContext;
300331
+ out.push(rc);
300332
+ if (out.length >= MAX_RECOVERED)
300333
+ break;
300334
+ }
300335
+ return out;
300336
+ }
300337
+ function elementRoleClass(el) {
300338
+ const r = (el.role ?? "").toLowerCase().trim();
300339
+ const mapped = roleBucket(r);
300340
+ if (mapped)
300341
+ return mapped;
300342
+ const t = (el.elementType ?? "").toLowerCase();
300343
+ if (t === "link")
300344
+ return "link";
300345
+ if (t === "button")
300346
+ return "button";
300347
+ if (t.startsWith("input")) {
300348
+ const it = (el.inputType ?? "").toLowerCase();
300349
+ if (it === "checkbox")
300350
+ return "checkbox";
300351
+ if (it === "radio")
300352
+ return "radio";
300353
+ if (it === "submit" || it === "button")
300354
+ return "button";
300355
+ return "textbox";
300356
+ }
300357
+ return t || "";
300358
+ }
300359
+ function roleBucket(r) {
300360
+ if (!r)
300361
+ return null;
300362
+ if (r === "link")
300363
+ return "link";
300364
+ if (r === "button")
300365
+ return "button";
300366
+ if (r === "textbox" || r === "searchbox" || r === "spinbutton")
300367
+ return "textbox";
300368
+ if (r === "combobox" || r === "listbox")
300369
+ return "combobox";
300370
+ if (r === "checkbox" || r === "switch")
300371
+ return "checkbox";
300372
+ if (r === "radio")
300373
+ return "radio";
300374
+ if (r === "tab")
300375
+ return "tab";
300376
+ if (r === "menuitem" || r === "menuitemcheckbox" || r === "menuitemradio")
300377
+ return "menuitem";
300378
+ if (r === "option")
300379
+ return "option";
300380
+ if (r === "slider")
300381
+ return "slider";
300382
+ return r;
300383
+ }
300384
+ function normalizeName(name) {
300385
+ return (name ?? "").replace(/\s+/g, " ").trim().toLowerCase().slice(0, 80);
300386
+ }
300387
+ function withTimeout(p, ms) {
300388
+ return new Promise((resolve, reject) => {
300389
+ const t = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms);
300390
+ p.then((v) => {
300391
+ clearTimeout(t);
300392
+ resolve(v);
300393
+ }, (e) => {
300394
+ clearTimeout(t);
300395
+ reject(e);
300396
+ });
300397
+ });
300398
+ }
300399
+ function parseLoc(input) {
300400
+ try {
300401
+ const u = new URL(input, "http://__rel__");
300402
+ return {
300403
+ origin: u.host === "__rel__" ? "" : u.host,
300404
+ // '' when input was path-relative
300405
+ path: (u.pathname || "/").replace(/\/+$/, "") || "/",
300406
+ search: u.search,
300407
+ hash: u.hash
300408
+ };
300409
+ } catch {
300410
+ const hashI = input.indexOf("#");
300411
+ const hash = hashI >= 0 ? input.slice(hashI) : "";
300412
+ const beforeHash = hashI >= 0 ? input.slice(0, hashI) : input;
300413
+ const qi = beforeHash.indexOf("?");
300414
+ const search = qi >= 0 ? beforeHash.slice(qi) : "";
300415
+ const path = ((qi >= 0 ? beforeHash.slice(0, qi) : beforeHash) || "/").replace(/\/+$/, "") || "/";
300416
+ return { origin: "", path, search, hash };
300417
+ }
300418
+ }
300419
+ function urlMatchesRoute(url, route2) {
300420
+ const u = parseLoc(url);
300421
+ const r = parseLoc(route2);
300422
+ if (u.path !== r.path)
300423
+ return false;
300424
+ if (r.origin && u.origin && r.origin !== u.origin)
300425
+ return false;
300426
+ if (r.search && u.search !== r.search)
300427
+ return false;
300428
+ if (r.hash && u.hash !== r.hash)
300429
+ return false;
300430
+ return true;
300431
+ }
300432
+ function shortUrl(url) {
300433
+ return url.length > 80 ? `${url.slice(0, 77)}...` : url;
300434
+ }
300435
+ function collectPerception(cfg) {
300436
+ const NATIVE = {
300437
+ A: true,
300438
+ BUTTON: true,
300439
+ INPUT: true,
300440
+ SELECT: true,
300441
+ TEXTAREA: true,
300442
+ SUMMARY: true,
300443
+ OPTION: true
300444
+ };
300445
+ const INTERACTIVE_ROLE = {
300446
+ button: true,
300447
+ link: true,
300448
+ textbox: true,
300449
+ searchbox: true,
300450
+ spinbutton: true,
300451
+ combobox: true,
300452
+ listbox: true,
300453
+ checkbox: true,
300454
+ radio: true,
300455
+ switch: true,
300456
+ menuitem: true,
300457
+ menuitemcheckbox: true,
300458
+ menuitemradio: true,
300459
+ tab: true,
300460
+ option: true,
300461
+ slider: true
300462
+ };
300463
+ const norm = (s) => (s || "").replace(/\s+/g, " ").trim();
300464
+ const lower = (s) => s.toLowerCase().slice(0, 80);
300465
+ const roleClassOf = (tag, roleAttr, inputType) => {
300466
+ const r = roleAttr.toLowerCase();
300467
+ if (r) {
300468
+ if (r === "link")
300469
+ return "link";
300470
+ if (r === "button")
300471
+ return "button";
300472
+ if (r === "textbox" || r === "searchbox" || r === "spinbutton")
300473
+ return "textbox";
300474
+ if (r === "combobox" || r === "listbox")
300475
+ return "combobox";
300476
+ if (r === "switch" || r === "checkbox")
300477
+ return "checkbox";
300478
+ if (r === "radio")
300479
+ return "radio";
300480
+ if (r === "tab")
300481
+ return "tab";
300482
+ if (r === "menuitem" || r === "menuitemcheckbox" || r === "menuitemradio")
300483
+ return "menuitem";
300484
+ if (r === "option")
300485
+ return "option";
300486
+ if (r === "slider")
300487
+ return "slider";
300488
+ return r;
300489
+ }
300490
+ if (tag === "A")
300491
+ return "link";
300492
+ if (tag === "BUTTON" || tag === "SUMMARY")
300493
+ return "button";
300494
+ if (tag === "SELECT")
300495
+ return "combobox";
300496
+ if (tag === "TEXTAREA")
300497
+ return "textbox";
300498
+ if (tag === "OPTION")
300499
+ return "option";
300500
+ if (tag === "INPUT") {
300501
+ const t = (inputType || "text").toLowerCase();
300502
+ if (t === "checkbox")
300503
+ return "checkbox";
300504
+ if (t === "radio")
300505
+ return "radio";
300506
+ if (t === "submit" || t === "button" || t === "image" || t === "reset")
300507
+ return "button";
300508
+ if (t === "range")
300509
+ return "slider";
300510
+ return "textbox";
300511
+ }
300512
+ return "other";
300513
+ };
300514
+ const accName = (el) => {
300515
+ const al = norm(el.getAttribute("aria-label"));
300516
+ if (al)
300517
+ return al;
300518
+ const lb = el.getAttribute("aria-labelledby");
300519
+ if (lb) {
300520
+ const ids = lb.split(/\s+/);
300521
+ let txt = "";
300522
+ for (let i = 0; i < ids.length; i++) {
300523
+ const t = el.ownerDocument.getElementById(ids[i]);
300524
+ if (t)
300525
+ txt += (txt ? " " : "") + norm(t.textContent);
300526
+ }
300527
+ if (txt)
300528
+ return txt;
300529
+ }
300530
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT") {
300531
+ const id = el.getAttribute("id");
300532
+ if (id) {
300533
+ try {
300534
+ const lab = el.ownerDocument.querySelector('label[for="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]');
300535
+ const t = lab ? norm(lab.textContent) : "";
300536
+ if (t)
300537
+ return t;
300538
+ } catch {
300539
+ }
300540
+ }
300541
+ const val = norm(el.value);
300542
+ if (val)
300543
+ return val;
300544
+ const ph = norm(el.getAttribute("placeholder"));
300545
+ if (ph)
300546
+ return ph;
300547
+ return norm(el.getAttribute("name"));
300548
+ }
300549
+ const text = norm(el.textContent);
300550
+ if (text)
300551
+ return text;
300552
+ return norm(el.getAttribute("title")) || norm(el.getAttribute("alt"));
300553
+ };
300554
+ const ROW_SEL = 'tr,li,[role="row"],[role="listitem"],[role="article"],article,[role="option"]';
300555
+ const isCardish = (el) => {
300556
+ const cls = (el.getAttribute("class") || "").toLowerCase();
300557
+ return /(^|[\s_-])(card|row|item|listitem|cell|tile)([\s_-]|$)/.test(cls);
300558
+ };
300559
+ const rowContextOf = (el, ownNameLower) => {
300560
+ let cur = el.parentElement;
300561
+ let depth = 0;
300562
+ while (cur && depth < 8) {
300563
+ let isRow = false;
300564
+ try {
300565
+ isRow = cur.matches(ROW_SEL);
300566
+ } catch {
300567
+ isRow = false;
300568
+ }
300569
+ if (isRow || isCardish(cur)) {
300570
+ const head = cur.querySelector('h1,h2,h3,h4,h5,h6,[role="heading"],strong,b,th,td');
300571
+ let txt = head ? norm(head.textContent) : "";
300572
+ if (!txt)
300573
+ txt = norm(cur.textContent);
300574
+ txt = txt.slice(0, 80);
300575
+ const low = txt.toLowerCase();
300576
+ if (txt && low !== ownNameLower && low.length > 1)
300577
+ return txt;
300578
+ return null;
300579
+ }
300580
+ cur = cur.parentElement;
300581
+ depth++;
300582
+ }
300583
+ return null;
300584
+ };
300585
+ const vh = window.innerHeight || 0;
300586
+ const vw = window.innerWidth || 0;
300587
+ const visibleRect = (el) => {
300588
+ let rect;
300589
+ let style;
300590
+ try {
300591
+ rect = el.getBoundingClientRect();
300592
+ style = window.getComputedStyle(el);
300593
+ } catch {
300594
+ return null;
300595
+ }
300596
+ if (style.display === "none" || style.visibility === "hidden")
300597
+ return null;
300598
+ if (parseFloat(style.opacity || "1") === 0)
300599
+ return null;
300600
+ if (rect.width <= 0 || rect.height <= 0)
300601
+ return null;
300602
+ try {
300603
+ if (el.closest('[aria-hidden="true"],[inert]'))
300604
+ return null;
300605
+ } catch {
300606
+ }
300607
+ return { rect, style };
300608
+ };
300609
+ const occlusionOf = (el, rect) => {
300610
+ const cx = rect.left + rect.width / 2;
300611
+ const cy = rect.top + rect.height / 2;
300612
+ if (cx < 0 || cy < 0 || cx > vw || cy > vh)
300613
+ return false;
300614
+ try {
300615
+ const hit = document.elementFromPoint(cx, cy);
300616
+ if (!hit || hit === el || el.contains(hit) || hit.contains(el))
300617
+ return false;
300618
+ let anc = hit;
300619
+ let guard = 0;
300620
+ while (anc && guard < 12) {
300621
+ const pos = window.getComputedStyle(anc).position;
300622
+ if (pos === "fixed" || pos === "sticky")
300623
+ return false;
300624
+ anc = anc.parentElement;
300625
+ guard++;
300626
+ }
300627
+ return true;
300628
+ } catch {
300629
+ return false;
300630
+ }
300631
+ };
300632
+ const positionOf = (rect) => rect.bottom < 0 ? "above" : rect.top > vh ? "below" : "in-view";
300633
+ const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : 0;
300634
+ const overBudget = () => t0 > 0 && performance.now() - t0 > cfg.budgetMs;
300635
+ const out = [];
300636
+ let order = 0;
300637
+ const SEL = 'a[href],button,input:not([type="hidden"]),textarea,select,summary,option,[role],[onclick],[tabindex]';
300638
+ const all = document.querySelectorAll(SEL);
300639
+ const limit = all.length < cfg.maxScan ? all.length : cfg.maxScan;
300640
+ for (let i = 0; i < limit; i++) {
300641
+ if (out.length >= cfg.maxCandidates)
300642
+ break;
300643
+ if ((i & 63) === 0 && overBudget())
300644
+ break;
300645
+ const el = all[i];
300646
+ const vis = visibleRect(el);
300647
+ if (!vis)
300648
+ continue;
300649
+ const tag = el.tagName;
300650
+ const roleAttr = el.getAttribute("role") || "";
300651
+ const inputType = tag === "INPUT" ? el.type || "" : "";
300652
+ const cursorPointer = vis.style.cursor === "pointer";
300653
+ const native = NATIVE[tag] === true;
300654
+ const roleLower = roleAttr.toLowerCase();
300655
+ const hasInteractiveRole = !!roleLower && INTERACTIVE_ROLE[roleLower] === true;
300656
+ const hasOnclick = el.hasAttribute("onclick");
300657
+ const tabAttr = el.getAttribute("tabindex");
300658
+ const tabbable = tabAttr != null && tabAttr !== "-1";
300659
+ const semantic = native || hasInteractiveRole;
300660
+ const nonSemantic = !semantic && (cursorPointer || hasOnclick || tabbable);
300661
+ if (!semantic && !nonSemantic)
300662
+ continue;
300663
+ const myOrder = order++;
300664
+ const name = accName(el);
300665
+ const lname = lower(name);
300666
+ const rect = vis.rect;
300667
+ out.push({
300668
+ domOrder: myOrder,
300669
+ roleClass: roleClassOf(tag, roleAttr, inputType),
300670
+ name: lname,
300671
+ rowContext: rowContextOf(el, lname),
300672
+ position: positionOf(rect),
300673
+ occluded: occlusionOf(el, rect),
300674
+ cursorPointer,
300675
+ nonSemantic,
300676
+ text: nonSemantic ? name.slice(0, 80) || null : null
300677
+ });
300678
+ }
300679
+ if (cfg.recoverNonSemantic) {
300680
+ const extra = document.querySelectorAll("div,span,li,td,p");
300681
+ const extraLimit = extra.length < cfg.maxScan ? extra.length : cfg.maxScan;
300682
+ for (let i = 0; i < extraLimit; i++) {
300683
+ if (out.length >= cfg.maxCandidates)
300684
+ break;
300685
+ if ((i & 63) === 0 && overBudget())
300686
+ break;
300687
+ const el = extra[i];
300688
+ const vis = visibleRect(el);
300689
+ if (!vis)
300690
+ continue;
300691
+ if (vis.style.cursor !== "pointer")
300692
+ continue;
300693
+ if (el.querySelector('a,button,input,select,textarea,[role="button"],[role="link"]'))
300694
+ continue;
300695
+ const rect = vis.rect;
300696
+ if (rect.width < 6 || rect.height < 6)
300697
+ continue;
300698
+ if (rect.width > vw * 0.98)
300699
+ continue;
300700
+ if (positionOf(rect) !== "in-view")
300701
+ continue;
300702
+ if (occlusionOf(el, rect))
300703
+ continue;
300704
+ const name = accName(el);
300705
+ if (!name)
300706
+ continue;
300707
+ const lname = lower(name);
300708
+ out.push({
300709
+ domOrder: order++,
300710
+ roleClass: "button",
300711
+ name: lname,
300712
+ rowContext: rowContextOf(el, lname),
300713
+ position: "in-view",
300714
+ occluded: false,
300715
+ cursorPointer: true,
300716
+ nonSemantic: true,
300717
+ text: name.slice(0, 80) || null
300718
+ });
300719
+ }
300720
+ }
300721
+ return out;
300722
+ }
300723
+ }
300724
+ });
300725
+
300726
+ // ../runner-core/dist/services/stuck-detector.js
300727
+ var require_stuck_detector = __commonJS({
300728
+ "../runner-core/dist/services/stuck-detector.js"(exports) {
300729
+ "use strict";
300730
+ Object.defineProperty(exports, "__esModule", { value: true });
300731
+ exports.StuckDetector = void 0;
300732
+ exports.isPageAffectingTool = isPageAffectingTool;
300733
+ exports.normalizeStuckSignature = normalizeStuckSignature;
300734
+ var PAGE_AFFECTING_TOOLS = /* @__PURE__ */ new Set([
300735
+ "browser_click",
300736
+ "browser_type",
300737
+ "browser_fill_form",
300738
+ "browser_select_option",
300739
+ "browser_navigate",
300740
+ "browser_press_key",
300741
+ "browser_hover",
300742
+ "browser_drag"
300743
+ ]);
300744
+ function isPageAffectingTool(toolName) {
300745
+ return PAGE_AFFECTING_TOOLS.has(toolName);
300746
+ }
300747
+ function normalizeStuckSignature(toolName, toolArgs) {
300748
+ const rawRef = typeof toolArgs.ref === "string" ? toolArgs.ref.trim() : "";
300749
+ const rawElement = typeof toolArgs.element === "string" ? toolArgs.element.trim() : "";
300750
+ const elementKey = rawRef ? rawRef : rawElement ? rawElement.toLowerCase().replace(/\S+@\S+/g, "[email]").slice(0, 60) : "";
300751
+ switch (toolName) {
300752
+ case "browser_click":
300753
+ case "browser_hover":
300754
+ case "browser_select_option":
300755
+ case "browser_type":
300756
+ case "browser_fill_form":
300757
+ case "browser_drag":
300758
+ return elementKey ? `${toolName}:${elementKey}` : null;
300759
+ case "browser_navigate": {
300760
+ const url = typeof toolArgs.url === "string" ? canonicalizeUrlPath(toolArgs.url) : "";
300761
+ return `browser_navigate:${url}`;
300762
+ }
300763
+ case "browser_press_key": {
300764
+ const key = typeof toolArgs.key === "string" ? toolArgs.key : "";
300765
+ return `browser_press_key:${key}`;
300766
+ }
300767
+ default:
300768
+ return null;
300769
+ }
300770
+ }
300771
+ function canonicalizeUrlPath(url) {
300772
+ try {
300773
+ const u = new URL(url, "http://x");
300774
+ return u.pathname.replace(/\/+$/, "") || "/";
300775
+ } catch {
300776
+ return url.trim().split(/[?#]/)[0] || url.trim();
300777
+ }
300778
+ }
300779
+ var NO_VERDICT = Object.freeze({
300780
+ tier: 0,
300781
+ kind: null,
300782
+ repeatedAction: null,
300783
+ recover: false,
300784
+ yieldRun: false
300785
+ });
300786
+ var DEFAULTS = {
300787
+ window: 8,
300788
+ repeatThreshold: 3,
300789
+ stagnationThreshold: 4,
300790
+ tier2After: 3,
300791
+ tier3After: 5,
300792
+ maxRecoveries: 2,
300793
+ repetitionReachesRecovery: true
300794
+ };
300795
+ var StuckDetector = class {
300796
+ opts;
300797
+ records = [];
300798
+ stallStreak = 0;
300799
+ recoveryCount = 0;
300800
+ lastEmittedTier = 0;
300801
+ constructor(options = {}) {
300802
+ this.opts = { ...DEFAULTS, ...options };
300803
+ }
300804
+ record(input) {
300805
+ this.records.push(input);
300806
+ if (this.records.length > this.opts.window) {
300807
+ this.records.splice(0, this.records.length - this.opts.window);
300808
+ }
300809
+ const stall = this.detectStall();
300810
+ if (!stall.stalled) {
300811
+ this.stallStreak = 0;
300812
+ this.lastEmittedTier = 0;
300813
+ return NO_VERDICT;
300814
+ }
300815
+ this.stallStreak += 1;
300816
+ let desired = 1;
300817
+ if (this.stallStreak >= this.opts.tier3After)
300818
+ desired = 3;
300819
+ else if (this.stallStreak >= this.opts.tier2After)
300820
+ desired = 2;
300821
+ if (stall.kind === "stagnation" && desired > 2)
300822
+ desired = 2;
300823
+ if (!this.opts.repetitionReachesRecovery && stall.kind === "repetition" && desired > 2)
300824
+ desired = 2;
300825
+ if (desired <= this.lastEmittedTier)
300826
+ return NO_VERDICT;
300827
+ if (desired === 3) {
300828
+ if (this.recoveryCount >= this.opts.maxRecoveries) {
300829
+ this.lastEmittedTier = 4;
300830
+ return {
300831
+ tier: 4,
300832
+ kind: stall.kind,
300833
+ repeatedAction: stall.repeatedAction,
300834
+ recover: false,
300835
+ yieldRun: true
300836
+ };
300837
+ }
300838
+ this.recoveryCount += 1;
300839
+ this.records = [];
300840
+ this.stallStreak = 0;
300841
+ this.lastEmittedTier = 0;
300842
+ return {
300843
+ tier: 3,
300844
+ kind: stall.kind,
300845
+ repeatedAction: stall.repeatedAction,
300846
+ recover: true,
300847
+ yieldRun: false
300848
+ };
300849
+ }
300850
+ this.lastEmittedTier = desired;
300851
+ return {
300852
+ tier: desired,
300853
+ kind: stall.kind,
300854
+ repeatedAction: stall.repeatedAction,
300855
+ recover: false,
300856
+ yieldRun: false
300857
+ };
300858
+ }
300859
+ detectStall() {
300860
+ const { stagnationThreshold, repeatThreshold } = this.opts;
300861
+ if (this.records.length < stagnationThreshold) {
300862
+ return { stalled: false, kind: null, repeatedAction: null };
300863
+ }
300864
+ const recent = this.records.slice(-stagnationThreshold);
300865
+ const progressStalled = recent.length >= stagnationThreshold && recent.every((r) => r.progressKey === recent[recent.length - 1].progressKey);
300866
+ if (progressStalled) {
300867
+ const counts = /* @__PURE__ */ new Map();
300868
+ for (const rec of this.records) {
300869
+ for (const sig of rec.actionSignatures) {
300870
+ counts.set(sig, (counts.get(sig) ?? 0) + 1);
300871
+ }
300872
+ }
300873
+ let repeatedAction = null;
300874
+ let max = 0;
300875
+ for (const [sig, count] of counts) {
300876
+ if (count > max) {
300877
+ max = count;
300878
+ repeatedAction = sig;
300879
+ }
300880
+ }
300881
+ if (repeatedAction && max >= repeatThreshold) {
300882
+ return { stalled: true, kind: "repetition", repeatedAction };
300883
+ }
300884
+ }
300885
+ if (progressStalled) {
300886
+ const sameRoute = recent.every((r) => r.route === recent[recent.length - 1].route);
300887
+ const pageAffectingCount = recent.filter((r) => r.pageAffecting).length;
300888
+ const distinctSignatures = new Set(recent.flatMap((r) => r.actionSignatures));
300889
+ const lowDiversity = distinctSignatures.size >= 1 && distinctSignatures.size <= Math.max(1, Math.floor(pageAffectingCount / 2));
300890
+ if (sameRoute && pageAffectingCount >= Math.ceil(stagnationThreshold / 2) && lowDiversity) {
300891
+ return { stalled: true, kind: "stagnation", repeatedAction: null };
300892
+ }
300893
+ }
300894
+ return { stalled: false, kind: null, repeatedAction: null };
300895
+ }
300896
+ };
300897
+ exports.StuckDetector = StuckDetector;
300898
+ }
300899
+ });
300900
+
299623
300901
  // ../runner-core/dist/services/evidence-harvester.js
299624
300902
  var require_evidence_harvester = __commonJS({
299625
300903
  "../runner-core/dist/services/evidence-harvester.js"(exports) {
@@ -299916,8 +301194,10 @@ var require_discover_explorer = __commonJS({
299916
301194
  var capture_page_normalizer_js_1 = require_capture_page_normalizer();
299917
301195
  var snapshot_observation_js_1 = require_snapshot_observation();
299918
301196
  var snapshot_context_compactor_js_1 = require_snapshot_context_compactor();
301197
+ var perception_enricher_js_1 = require_perception_enricher();
299919
301198
  var exploration_prompt_builder_js_2 = require_exploration_prompt_builder();
299920
301199
  var exploration_state_js_1 = require_exploration_state();
301200
+ var stuck_detector_js_1 = require_stuck_detector();
299921
301201
  var evidence_harvester_js_1 = require_evidence_harvester();
299922
301202
  var exploration_parser_js_1 = require_exploration_parser();
299923
301203
  var snapshot_context_compactor_js_2 = require_snapshot_context_compactor();
@@ -300700,6 +301980,7 @@ ${credentialBlock}${redactedBlock}${inboxBlock}`;
300700
301980
  const v3Env = process.env.DISCOVERY_PROMPT_V3;
300701
301981
  const useV3 = v3Env !== "false";
300702
301982
  const useV2 = true;
301983
+ const perceptionEnricherEnabled = (process.env.DISCOVERY_PERCEPTION_ENRICHER === "true" || process.env.DISCOVERY_PERCEPTION_ENRICHER === "1") && typeof options?.getPerceptionPage === "function";
300703
301984
  const state2 = (0, exploration_state_js_1.createExplorationState)();
300704
301985
  const recentExchanges = [];
300705
301986
  const transientDirectives = [];
@@ -300824,6 +302105,8 @@ ${inboxPromptBlock}`;
300824
302105
  let lastBriefRoute = "";
300825
302106
  let lastBriefIteration = 0;
300826
302107
  const BRIEF_INTERVAL = 5;
302108
+ const stuckDetectorEnabled = process.env.DISCOVERY_STUCK_DETECTOR === "1" || process.env.DISCOVERY_STUCK_DETECTOR === "true";
302109
+ const stuckDetector = stuckDetectorEnabled ? new stuck_detector_js_1.StuckDetector({ repetitionReachesRecovery: !isDeepMode }) : null;
300827
302110
  while (iteration < maxIterations && !explorationComplete) {
300828
302111
  iteration++;
300829
302112
  (0, exploration_state_js_1.reduceEvent)(state2, { type: "ITERATION_STARTED", iteration });
@@ -301295,6 +302578,19 @@ ${currentSummary}`;
301295
302578
  const elapsed = Date.now() - readinessStartedAt;
301296
302579
  logs2.push(` [discover-explorer] proceeded with not-ready snapshot after ${readinessRetries} retries / ${elapsed}ms`);
301297
302580
  }
302581
+ let recoveredClickables = [];
302582
+ if (perceptionEnricherEnabled) {
302583
+ try {
302584
+ const result = await (0, perception_enricher_js_1.enrichCapturedPage)(options?.getPerceptionPage?.(), {
302585
+ route: normalized.route,
302586
+ elements: normalized.elements,
302587
+ parsedInteractiveCount: normalized.snapshotMetrics?.parsedInteractiveCount
302588
+ }, { onLog: (msg) => logs2.push(` ${msg}`) });
302589
+ recoveredClickables = result.recovered;
302590
+ } catch (perceptionErr) {
302591
+ logs2.push(` [perception] enrich skipped \u2014 ${perceptionErr instanceof Error ? perceptionErr.message : String(perceptionErr)}`);
302592
+ }
302593
+ }
301298
302594
  (0, exploration_state_js_1.reduceEvent)(state2, {
301299
302595
  type: "PAGE_CAPTURED",
301300
302596
  route: normalized.route,
@@ -301363,7 +302659,7 @@ ${currentSummary}`;
301363
302659
  logs2.push(` Captured page: ${normalized.route}, ${normalized.provenCommands.length} proven commands${normalized.alreadyCaptured ? " (RE-VISIT)" : ""}`);
301364
302660
  const transitionHint = !normalized.alreadyCaptured && isSurveyMode ? { tip: `Also call record_transition with fromRoute, toRoute, triggerType, and triggerLabel (the exact link text you clicked to get here).` } : {};
301365
302661
  const lastAction = normalized.observations.length > 0 ? normalized.observations[normalized.observations.length - 1].action : void 0;
301366
- const snapshotBlock = (0, snapshot_observation_js_1.renderCompactSnapshotBlock)(normalized, { lastAction }, { elementLimit: 6, formLimit: 3, observationLimit: 3, mode: options?.mode ?? "full" });
302662
+ const snapshotBlock = (0, snapshot_observation_js_1.renderCompactSnapshotBlock)(normalized, { lastAction }, { elementLimit: 6, formLimit: 3, observationLimit: 3, mode: options?.mode ?? "full", recoveredClickables });
301367
302663
  toolResults.push({
301368
302664
  role: "tool",
301369
302665
  tool_call_id: toolCall.id,
@@ -302302,6 +303598,59 @@ Exploration complete: ${summary}`);
302302
303598
  }
302303
303599
  delete toolResults.__accountAlreadyExistsDirective;
302304
303600
  }
303601
+ if (stuckDetector && assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
303602
+ const actionSignatures = [];
303603
+ let pageAffecting = false;
303604
+ for (const tc of assistantMessage.tool_calls) {
303605
+ if ((0, stuck_detector_js_1.isPageAffectingTool)(tc.function.name))
303606
+ pageAffecting = true;
303607
+ let parsedArgs = {};
303608
+ try {
303609
+ parsedArgs = JSON.parse(tc.function.arguments || "{}");
303610
+ } catch {
303611
+ }
303612
+ const sig = (0, stuck_detector_js_1.normalizeStuckSignature)(tc.function.name, parsedArgs);
303613
+ if (sig)
303614
+ actionSignatures.push(sig);
303615
+ }
303616
+ const progressKey = `${state2.currentRoute}|${state2.visitedRoutes.size}|${state2.recordedJourneys.length}|${lastCaptureIteration}`;
303617
+ const verdict = stuckDetector.record({
303618
+ iteration,
303619
+ actionSignatures,
303620
+ route: state2.currentRoute,
303621
+ progressKey,
303622
+ pageAffecting
303623
+ });
303624
+ if (verdict.tier > 0) {
303625
+ const topFrontier = (0, exploration_state_js_1.getRankedFrontier)(state2).slice(0, 3);
303626
+ const frontierHint = topFrontier.length > 0 ? ` (e.g. ${topFrontier.join(", ")})` : "";
303627
+ const repeated = verdict.repeatedAction ? verdict.repeatedAction.slice(0, 80) : null;
303628
+ const act = repeated ? `"${repeated}"` : "the same actions";
303629
+ let directive = null;
303630
+ if (verdict.yieldRun) {
303631
+ logs2.push(` Stuck detector: yielding after repeated local loop (${verdict.kind}, ${act})`);
303632
+ explorationComplete = true;
303633
+ 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.`;
303634
+ break;
303635
+ } else if (verdict.recover) {
303636
+ 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.`;
303637
+ logs2.push(` Stuck detector: harness recovery (tier 3, ${verdict.kind}, ${act})`);
303638
+ } else if (verdict.tier === 2) {
303639
+ 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.`;
303640
+ logs2.push(` Stuck detector: firm nudge (tier 2, ${verdict.kind}, ${act})`);
303641
+ } else {
303642
+ 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.`;
303643
+ logs2.push(` Stuck detector: soft nudge (tier 1, ${verdict.kind}, ${act})`);
303644
+ }
303645
+ if (directive) {
303646
+ if (useV3) {
303647
+ transientDirectives.push(directive);
303648
+ } else {
303649
+ messages.push({ role: "user", content: directive });
303650
+ }
303651
+ }
303652
+ }
303653
+ }
302305
303654
  }
302306
303655
  if (!explorationComplete) {
302307
303656
  logs2.push(`
@@ -303010,7 +304359,8 @@ ${credentialLines}
303010
304359
  userNotes: featureOpts.userNotes,
303011
304360
  onAICall: featureOpts.onAICall,
303012
304361
  onProgress: featureOpts.onProgress,
303013
- onCredentialUpdate: featureOpts.onCredentialUpdate
304362
+ onCredentialUpdate: featureOpts.onCredentialUpdate,
304363
+ getPerceptionPage: featureOpts.getPerceptionPage
303014
304364
  }, config);
303015
304365
  }
303016
304366
  }
@@ -310054,54 +311404,13 @@ var require_mobile_exploration_state = __commonJS({
310054
311404
  var require_mobile_discovery_agent = __commonJS({
310055
311405
  "../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports) {
310056
311406
  "use strict";
310057
- var __createBinding = exports && exports.__createBinding || (Object.create ? (function(o, m, k, k2) {
310058
- if (k2 === void 0) k2 = k;
310059
- var desc = Object.getOwnPropertyDescriptor(m, k);
310060
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
310061
- desc = { enumerable: true, get: function() {
310062
- return m[k];
310063
- } };
310064
- }
310065
- Object.defineProperty(o, k2, desc);
310066
- }) : (function(o, m, k, k2) {
310067
- if (k2 === void 0) k2 = k;
310068
- o[k2] = m[k];
310069
- }));
310070
- var __setModuleDefault = exports && exports.__setModuleDefault || (Object.create ? (function(o, v) {
310071
- Object.defineProperty(o, "default", { enumerable: true, value: v });
310072
- }) : function(o, v) {
310073
- o["default"] = v;
310074
- });
310075
- var __importStar = exports && exports.__importStar || /* @__PURE__ */ (function() {
310076
- var ownKeys = function(o) {
310077
- ownKeys = Object.getOwnPropertyNames || function(o2) {
310078
- var ar = [];
310079
- for (var k in o2) if (Object.prototype.hasOwnProperty.call(o2, k)) ar[ar.length] = k;
310080
- return ar;
310081
- };
310082
- return ownKeys(o);
310083
- };
310084
- return function(mod) {
310085
- if (mod && mod.__esModule) return mod;
310086
- var result = {};
310087
- if (mod != null) {
310088
- for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
310089
- }
310090
- __setModuleDefault(result, mod);
310091
- return result;
310092
- };
310093
- })();
310094
311407
  Object.defineProperty(exports, "__esModule", { value: true });
310095
311408
  exports.runMobileDiscoveryOnRunner = runMobileDiscoveryOnRunner2;
310096
311409
  var mobile_explorer_js_1 = require_mobile_explorer();
310097
311410
  var mobile_discovery_prompts_js_1 = require_mobile_discovery_prompts();
311411
+ var mobile_generator_js_1 = require_mobile_generator();
310098
311412
  var mobile_exploration_state_js_1 = require_mobile_exploration_state();
310099
311413
  var discover_planner_js_1 = require_discover_planner();
310100
- var GENERATOR_MODULE = "./mobile-generator.js";
310101
- var defaultRunGenerate = async (args) => {
310102
- const mod = await Promise.resolve(`${GENERATOR_MODULE}`).then((s) => __importStar(__require(s)));
310103
- return mod.runMobileGeneratePhase(args);
310104
- };
310105
311414
  var EXPLORE_BUDGET_DEEP = 60;
310106
311415
  var EXPLORE_BUDGET_SURVEY = 40;
310107
311416
  function platformForMedium(medium) {
@@ -310136,7 +311445,7 @@ var require_mobile_discovery_agent = __commonJS({
310136
311445
  const onScreenshot = callbacks?.onScreenshot;
310137
311446
  const runExplore = callbacks?.runExplore ?? mobile_explorer_js_1.runMobileExplorePhase;
310138
311447
  const runPlan = callbacks?.runPlan ?? mobile_discovery_prompts_js_1.runMobileAIPlanner;
310139
- const runGenerate = callbacks?.runGenerate ?? defaultRunGenerate;
311448
+ const runGenerate = callbacks?.runGenerate ?? mobile_generator_js_1.runMobileGeneratePhase;
310140
311449
  const log2 = (line) => {
310141
311450
  logs2.push(line);
310142
311451
  try {
@@ -310209,6 +311518,7 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
310209
311518
  featureName: ctx.featureName,
310210
311519
  existingTestNames: ctx.existingTestNames,
310211
311520
  existingFeatureNames: ctx.existingFeatureNames,
311521
+ recordedJourneys: explore.recordedJourneys,
310212
311522
  collectedPages: screenMap,
310213
311523
  collectedTransitions,
310214
311524
  credentials: {
@@ -310276,9 +311586,7 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
310276
311586
  ctx,
310277
311587
  onLog: (line) => log2(line),
310278
311588
  onAICall,
310279
- onTestGenerated: (test) => {
310280
- callbacks?.onTestGenerated?.(test, { phase: "verified" });
310281
- }
311589
+ onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
310282
311590
  });
310283
311591
  const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
310284
311592
  log2(`
@@ -310802,7 +312110,8 @@ Respond with ONLY valid JSON:
310802
312110
  userNotes: ctx.userNotes,
310803
312111
  onAICall,
310804
312112
  onProgress,
310805
- onCredentialUpdate: ctx.onCredentialUpdate
312113
+ onCredentialUpdate: ctx.onCredentialUpdate,
312114
+ getPerceptionPage: () => handle?.page ?? null
310806
312115
  });
310807
312116
  logs2.push(...exploreResult.logs);
310808
312117
  screenshots.push(...exploreResult.screenshots);
@@ -311341,6 +312650,7 @@ Fatal error: ${msg}`);
311341
312650
  onAICall,
311342
312651
  onProgress,
311343
312652
  onCredentialUpdate: ctx.onCredentialUpdate,
312653
+ getPerceptionPage: () => handle?.page ?? null,
311344
312654
  onTestPhaseChange: (phase) => {
311345
312655
  healthObserver?.setTestPhase(phase);
311346
312656
  liveCollector?.setTestPhase(phase);
@@ -323980,6 +325290,8 @@ var require_live_browser_registry = __commonJS({
323980
325290
  exports.liveBrowserRegistry = void 0;
323981
325291
  var THUMBNAIL_INTERVAL_MS = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_INTERVAL_MS ?? "4000", 10) || 4e3;
323982
325292
  var THUMBNAIL_QUALITY = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_QUALITY ?? "60", 10) || 60;
325293
+ var BROWSER_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_MAX_BASE64_BYTES ?? "400000", 10) || 4e5;
325294
+ var MOBILE_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_MOBILE_THUMBNAIL_MAX_BASE64_BYTES ?? "2000000", 10) || 2e6;
323983
325295
  var STALE_SESSION_MS = parseInt(process.env.LIVE_BROWSER_STALE_MS ?? `${10 * 60 * 1e3}`, 10) || 10 * 60 * 1e3;
323984
325296
  var MAX_SESSIONS = parseInt(process.env.LIVE_BROWSER_MAX_SESSIONS ?? "32", 10) || 32;
323985
325297
  var LiveBrowserRegistry = class {
@@ -324074,7 +325386,8 @@ var require_live_browser_registry = __commonJS({
324074
325386
  entry.currentUrl = snapshot.currentUrl;
324075
325387
  }
324076
325388
  const thumbnail = snapshot.thumbnailJpegBase64;
324077
- if (thumbnail && thumbnail.length <= 16e4) {
325389
+ const thumbnailLimit = entry.meta.surface === "mobile" ? MOBILE_THUMBNAIL_MAX_BASE64_BYTES : BROWSER_THUMBNAIL_MAX_BASE64_BYTES;
325390
+ if (thumbnail && thumbnail.length <= thumbnailLimit) {
324078
325391
  entry.thumbnailJpegBase64 = thumbnail;
324079
325392
  entry.thumbnailCapturedAt = typeof snapshot.thumbnailCapturedAt === "number" && Number.isFinite(snapshot.thumbnailCapturedAt) ? snapshot.thumbnailCapturedAt : Date.now();
324080
325393
  entry.thumbnailWidth = typeof snapshot.thumbnailWidth === "number" && Number.isFinite(snapshot.thumbnailWidth) ? snapshot.thumbnailWidth : void 0;
@@ -324208,7 +325521,7 @@ var require_live_browser_registry = __commonJS({
324208
325521
  scale: "css"
324209
325522
  });
324210
325523
  const base64 = buf.toString("base64");
324211
- if (base64.length > 16e4) {
325524
+ if (base64.length > BROWSER_THUMBNAIL_MAX_BASE64_BYTES) {
324212
325525
  return;
324213
325526
  }
324214
325527
  entry.thumbnailJpegBase64 = base64;
@@ -324520,6 +325833,7 @@ var require_dist2 = __commonJS({
324520
325833
  return api_call_redaction_js_1.headerVal;
324521
325834
  } });
324522
325835
  __exportStar(require_mcp_healer(), exports);
325836
+ __exportStar(require_selector_registry_context(), exports);
324523
325837
  var grok_per_test_healer_js_1 = require_grok_per_test_healer();
324524
325838
  Object.defineProperty(exports, "executeGrokPerTestHeal", { enumerable: true, get: function() {
324525
325839
  return grok_per_test_healer_js_1.executeGrokPerTestHeal;
@@ -324805,7 +326119,7 @@ var require_package4 = __commonJS({
324805
326119
  "package.json"(exports, module) {
324806
326120
  module.exports = {
324807
326121
  name: "@validate.qa/runner",
324808
- version: "1.0.4",
326122
+ version: "1.0.5",
324809
326123
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
324810
326124
  bin: {
324811
326125
  "validate-runner": "dist/cli.js"
@@ -325477,6 +326791,647 @@ function getRunnerCapabilities() {
325477
326791
  };
325478
326792
  }
325479
326793
 
326794
+ // src/services/mobile/mobile-network-capture.ts
326795
+ var import_runner_core7 = __toESM(require_dist2());
326796
+ import { execFile, execFileSync as execFileSync3 } from "child_process";
326797
+ import { randomUUID } from "crypto";
326798
+ import { mkdtemp, rm, writeFile, readFile, readdir, mkdir, unlink } from "fs/promises";
326799
+ import { tmpdir, networkInterfaces } from "os";
326800
+ import { join as join2 } from "path";
326801
+ import { promisify } from "util";
326802
+ import {
326803
+ getLocal,
326804
+ generateCACertificate
326805
+ } from "mockttp";
326806
+ var execFileAsync = promisify(execFile);
326807
+ var MAX_CALLS = 1e3;
326808
+ var MAX_BODY_CAPTURE_BYTES = 64 * 1024;
326809
+ var DEVICE_CMD_TIMEOUT_MS = 15e3;
326810
+ var CA_KEY_BITS = 2048;
326811
+ var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
326812
+ var PENDING_CLEANUP_DIR = join2(tmpdir(), "validateqa-mobile-capture-pending");
326813
+ function envFlag(name) {
326814
+ const value = (process.env[name] ?? "").trim().toLowerCase();
326815
+ return value === "1" || value === "true" || value === "yes" || value === "on";
326816
+ }
326817
+ function rawHeadersToPairs(rawHeaders) {
326818
+ return rawHeaders.map(([name, value]) => ({ name, value }));
326819
+ }
326820
+ function headerValue(pairs, name) {
326821
+ const lower = name.toLowerCase();
326822
+ return pairs.find((h) => h.name.toLowerCase() === lower)?.value;
326823
+ }
326824
+ function resolveHostIp() {
326825
+ const ifaces = networkInterfaces();
326826
+ for (const addrs of Object.values(ifaces)) {
326827
+ if (!addrs) continue;
326828
+ for (const addr of addrs) {
326829
+ if (addr.family === "IPv4" && !addr.internal) return addr.address;
326830
+ }
326831
+ }
326832
+ return "127.0.0.1";
326833
+ }
326834
+ function resolveProxyHostForDevice(platform3, isPhysical) {
326835
+ if (platform3 === "ANDROID" && !isPhysical) return ANDROID_EMULATOR_HOST_LOOPBACK;
326836
+ if (platform3 === "IOS" && !isPhysical) return "127.0.0.1";
326837
+ return resolveHostIp();
326838
+ }
326839
+ function normalizeRemoteAddress(addr) {
326840
+ if (!addr) return "";
326841
+ return addr.startsWith("::ffff:") ? addr.slice("::ffff:".length) : addr;
326842
+ }
326843
+ function isLoopbackAddress(addr) {
326844
+ return addr === "127.0.0.1" || addr.startsWith("127.") || addr === "::1" || addr === "";
326845
+ }
326846
+ function isPrivateAddress(addr) {
326847
+ if (/^10\./.test(addr)) return true;
326848
+ if (/^192\.168\./.test(addr)) return true;
326849
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
326850
+ if (/^169\.254\./.test(addr)) return true;
326851
+ const lower = addr.toLowerCase();
326852
+ if (lower.startsWith("fe80:")) return true;
326853
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
326854
+ return false;
326855
+ }
326856
+ function isAllowedProxyPeer(remoteAddress, isPhysical) {
326857
+ const addr = normalizeRemoteAddress(remoteAddress);
326858
+ if (isLoopbackAddress(addr)) return true;
326859
+ return isPhysical && isPrivateAddress(addr);
326860
+ }
326861
+ function installSourceIpGuard(server3, isPhysical, onLog) {
326862
+ const underlying = server3.server;
326863
+ if (!underlying || typeof underlying.on !== "function") {
326864
+ onLog?.(
326865
+ "[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."
326866
+ );
326867
+ return;
326868
+ }
326869
+ underlying.on("connection", (socket) => {
326870
+ if (!isAllowedProxyPeer(socket.remoteAddress, isPhysical)) {
326871
+ onLog?.(`[mobile-net] rejected proxy connection from disallowed source ${socket.remoteAddress}`);
326872
+ socket.destroy();
326873
+ }
326874
+ });
326875
+ }
326876
+ function isTextish(contentType) {
326877
+ if (!contentType) return false;
326878
+ return /json|text|xml|graphql|html|csv|javascript|urlencoded/i.test(contentType);
326879
+ }
326880
+ async function readBodyText(body, contentType) {
326881
+ if (!isTextish(contentType)) return void 0;
326882
+ try {
326883
+ const decoded = await body.getDecodedBuffer();
326884
+ if (!decoded || decoded.length === 0) return void 0;
326885
+ if (decoded.length > MAX_BODY_CAPTURE_BYTES) {
326886
+ return decoded.subarray(0, MAX_BODY_CAPTURE_BYTES).toString("utf-8");
326887
+ }
326888
+ return decoded.toString("utf-8");
326889
+ } catch {
326890
+ return void 0;
326891
+ }
326892
+ }
326893
+ var MitmProxy = class {
326894
+ constructor(caKeyPem, caCertPem, caCertPath, isPhysical, tlsPassthroughAll, onLog) {
326895
+ this.caKeyPem = caKeyPem;
326896
+ this.caCertPem = caCertPem;
326897
+ this.caCertPath = caCertPath;
326898
+ this.isPhysical = isPhysical;
326899
+ this.tlsPassthroughAll = tlsPassthroughAll;
326900
+ this.onLog = onLog;
326901
+ }
326902
+ server = null;
326903
+ inFlight = /* @__PURE__ */ new Map();
326904
+ calls = [];
326905
+ dropped = 0;
326906
+ /** Flips true once any HTTPS response is successfully MITM-ed. */
326907
+ httpsIntercepted = false;
326908
+ /** Best-known reason interception is unavailable, refined by TLS failures. */
326909
+ tlsFailureReason = null;
326910
+ /** Count of opaque HTTPS tunnels when TLS passthrough is enabled. */
326911
+ tlsPassthroughCount = 0;
326912
+ /** Start listening; resolves with the actual bound port. */
326913
+ async listen(preferredPort) {
326914
+ const server3 = getLocal({
326915
+ // Our generated CA — mockttp mints per-host leaf certs signed by it.
326916
+ https: {
326917
+ key: this.caKeyPem,
326918
+ cert: this.caCertPem,
326919
+ ...this.tlsPassthroughAll ? { tlsPassthrough: [{ hostname: "*" }] } : {}
326920
+ },
326921
+ // HTTP/2 with HTTP/1.1 fallback; mobile clients negotiate either.
326922
+ http2: "fallback",
326923
+ // Keep the proxy quiet unless explicitly debugging.
326924
+ recordTraffic: false
326925
+ });
326926
+ await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
326927
+ await server3.on("request", (req) => this.onRequest(req));
326928
+ await server3.on("response", (res) => this.onResponse(res));
326929
+ await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
326930
+ await server3.on("tls-passthrough-opened", (event) => this.onTlsPassthroughOpened(event));
326931
+ await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
326932
+ installSourceIpGuard(server3, this.isPhysical, this.onLog);
326933
+ this.server = server3;
326934
+ return server3.port;
326935
+ }
326936
+ /** Captured calls, finalized through the shared redaction constructor. */
326937
+ finalize() {
326938
+ return this.calls.map(
326939
+ (c) => (0, import_runner_core7.buildApiCallSummary)({
326940
+ method: c.method,
326941
+ url: c.url,
326942
+ status: c.status,
326943
+ durationMs: c.durationMs,
326944
+ authHeader: c.authHeader,
326945
+ cookieHeader: c.cookieHeader,
326946
+ requestContentType: c.requestContentType,
326947
+ responseContentType: c.responseContentType,
326948
+ respHeaders: c.respHeaders,
326949
+ reqBodyText: c.reqBodyText,
326950
+ respBodyText: c.respBodyText,
326951
+ responseSize: c.responseSize,
326952
+ timestamp: c.startedAt,
326953
+ testPhase: "UNKNOWN"
326954
+ })
326955
+ );
326956
+ }
326957
+ /** True once at least one HTTPS exchange was decrypted (CA trusted). */
326958
+ get isIntercepting() {
326959
+ return this.httpsIntercepted;
326960
+ }
326961
+ /** A precise reason interception is (still) unavailable, or null if it works. */
326962
+ get interceptionReason() {
326963
+ if (this.httpsIntercepted) return null;
326964
+ if (this.tlsPassthroughAll) {
326965
+ 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}.` : ".");
326966
+ }
326967
+ return this.tlsFailureReason;
326968
+ }
326969
+ async close() {
326970
+ const server3 = this.server;
326971
+ this.server = null;
326972
+ this.inFlight.clear();
326973
+ if (!server3) return;
326974
+ try {
326975
+ await server3.stop();
326976
+ } catch (err) {
326977
+ this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg(err)}`);
326978
+ }
326979
+ this.onLog?.(
326980
+ `[mobile-net] proxy closed: ${this.calls.length} calls captured` + (this.dropped > 0 ? ` (${this.dropped} dropped over cap)` : "")
326981
+ );
326982
+ }
326983
+ // ── request → buffer by id ────────────────────────────────────────────────
326984
+ async onRequest(req) {
326985
+ try {
326986
+ const reqHeaders = rawHeadersToPairs(req.rawHeaders);
326987
+ const requestContentType = headerValue(reqHeaders, "content-type");
326988
+ const isHttps = isHttpsUrl(req.url);
326989
+ const reqBodyText = (0, import_runner_core7.isApiCall)(req.url, requestContentType) ? await readBodyText(req.body, requestContentType) : void 0;
326990
+ this.inFlight.set(req.id, {
326991
+ method: req.method,
326992
+ url: req.url,
326993
+ startedAt: req.timingEvents.startTime,
326994
+ reqHeaders,
326995
+ requestContentType,
326996
+ authHeader: headerValue(reqHeaders, "authorization"),
326997
+ cookieHeader: headerValue(reqHeaders, "cookie"),
326998
+ reqBodyText,
326999
+ isHttps
327000
+ });
327001
+ } catch (err) {
327002
+ this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg(err)}`);
327003
+ }
327004
+ }
327005
+ // ── response → finalize the matched request ───────────────────────────────
327006
+ async onResponse(res) {
327007
+ const pending = this.inFlight.get(res.id);
327008
+ if (!pending) return;
327009
+ this.inFlight.delete(res.id);
327010
+ try {
327011
+ if (pending.isHttps && !this.httpsIntercepted) {
327012
+ this.httpsIntercepted = true;
327013
+ this.tlsFailureReason = null;
327014
+ this.onLog?.("[mobile-net] HTTPS interception confirmed (CA trusted; bodies decrypted).");
327015
+ }
327016
+ const respHeaders = rawHeadersToPairs(res.rawHeaders);
327017
+ const responseContentType = headerValue(respHeaders, "content-type");
327018
+ const api = (0, import_runner_core7.isApiCall)(pending.url, responseContentType);
327019
+ if (!api && (0, import_runner_core7.isStaticAssetByExt)(pending.url)) return;
327020
+ if (this.calls.length >= MAX_CALLS) {
327021
+ this.dropped++;
327022
+ return;
327023
+ }
327024
+ const respBodyText = api ? await readBodyText(res.body, responseContentType) : void 0;
327025
+ const responseSize = await measureBody(res.body);
327026
+ const durationMs = computeDuration(pending.startedAt, res);
327027
+ this.calls.push({
327028
+ method: pending.method,
327029
+ url: pending.url,
327030
+ status: res.statusCode,
327031
+ startedAt: pending.startedAt,
327032
+ durationMs,
327033
+ reqHeaders: pending.reqHeaders,
327034
+ respHeaders,
327035
+ requestContentType: pending.requestContentType,
327036
+ responseContentType,
327037
+ authHeader: pending.authHeader,
327038
+ cookieHeader: pending.cookieHeader,
327039
+ reqBodyText: pending.reqBodyText,
327040
+ respBodyText,
327041
+ responseSize,
327042
+ intercepted: true
327043
+ });
327044
+ } catch (err) {
327045
+ this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg(err)}`);
327046
+ }
327047
+ }
327048
+ // ── tls-client-error → record WHY interception failed ─────────────────────
327049
+ onTlsClientError(failure) {
327050
+ if (this.httpsIntercepted) return;
327051
+ const host = failure.destination?.hostname ?? failure.tlsMetadata.sniHostname ?? "unknown host";
327052
+ 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.";
327053
+ let reason;
327054
+ switch (failure.failureCause) {
327055
+ case "cert-rejected":
327056
+ 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;
327057
+ break;
327058
+ case "closed":
327059
+ case "reset":
327060
+ 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).`;
327061
+ break;
327062
+ case "no-shared-cipher":
327063
+ reason = `TLS handshake to ${host} failed: no shared cipher with the client.`;
327064
+ break;
327065
+ case "handshake-timeout":
327066
+ reason = `TLS handshake to ${host} timed out before completing.`;
327067
+ break;
327068
+ default:
327069
+ reason = `TLS handshake to ${host} failed (${failure.failureCause}); HTTPS not intercepted for it.`;
327070
+ }
327071
+ if (!this.tlsFailureReason || failure.failureCause === "cert-rejected" || failure.failureCause === "closed" || failure.failureCause === "reset") {
327072
+ this.tlsFailureReason = reason;
327073
+ }
327074
+ this.onLog?.(`[mobile-net] ${reason}`);
327075
+ }
327076
+ onTlsPassthroughOpened(event) {
327077
+ this.tlsPassthroughCount++;
327078
+ if (this.tlsPassthroughCount === 1) {
327079
+ const host = event.destination?.hostname ?? event.hostname ?? "unknown host";
327080
+ this.onLog?.(
327081
+ `[mobile-net] HTTPS TLS passthrough active for ${host}; app traffic is preserved, but HTTPS bodies are not decrypted.`
327082
+ );
327083
+ }
327084
+ }
327085
+ };
327086
+ function isHttpsUrl(url) {
327087
+ try {
327088
+ return new URL(url).protocol === "https:";
327089
+ } catch {
327090
+ return false;
327091
+ }
327092
+ }
327093
+ async function measureBody(body) {
327094
+ try {
327095
+ const decoded = await body.getDecodedBuffer();
327096
+ return decoded ? decoded.length : 0;
327097
+ } catch {
327098
+ return 0;
327099
+ }
327100
+ }
327101
+ function computeDuration(startedAt, res) {
327102
+ const sent = res.timingEvents.responseSentTimestamp;
327103
+ const start = res.timingEvents.startTimestamp;
327104
+ if (typeof sent === "number" && typeof start === "number" && sent >= start) {
327105
+ return Math.round(sent - start);
327106
+ }
327107
+ const elapsed = Date.now() - startedAt;
327108
+ return elapsed > 0 ? elapsed : 0;
327109
+ }
327110
+ async function generateCaCert(dir, onLog) {
327111
+ try {
327112
+ const { key, cert } = await generateCACertificate({
327113
+ subject: {
327114
+ commonName: "validate.qa Mobile Capture CA",
327115
+ organizationName: "validate.qa"
327116
+ },
327117
+ bits: CA_KEY_BITS
327118
+ // mockttp's generated CA carries its own (multi-year) validity window; we
327119
+ // rely on stop() to remove the cert from the device promptly after the run
327120
+ // rather than on a short expiry.
327121
+ });
327122
+ const caCertPath = join2(dir, "validateqa-mobile-ca.pem");
327123
+ await writeFile(caCertPath, cert, "utf-8");
327124
+ return { caCertPath, caKeyPem: key, caCertPem: cert };
327125
+ } catch (err) {
327126
+ onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg(err)}`);
327127
+ return null;
327128
+ }
327129
+ }
327130
+ async function androidReadProxy(deviceId) {
327131
+ try {
327132
+ const { stdout } = await execFileAsync(
327133
+ "adb",
327134
+ ["-s", deviceId, "shell", "settings", "get", "global", "http_proxy"],
327135
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327136
+ );
327137
+ const value = stdout.trim();
327138
+ return value === "" || value === "null" ? null : value;
327139
+ } catch {
327140
+ return null;
327141
+ }
327142
+ }
327143
+ async function androidSetProxy(deviceId, hostPort, onLog) {
327144
+ try {
327145
+ await execFileAsync(
327146
+ "adb",
327147
+ ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", hostPort],
327148
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327149
+ );
327150
+ return true;
327151
+ } catch (err) {
327152
+ onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg(err)}`);
327153
+ return false;
327154
+ }
327155
+ }
327156
+ async function androidClearProxy(deviceId, originalProxy, onLog) {
327157
+ const restoreValue = originalProxy && originalProxy.length > 0 ? originalProxy : ":0";
327158
+ try {
327159
+ await execFileAsync(
327160
+ "adb",
327161
+ ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue],
327162
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327163
+ );
327164
+ } catch (err) {
327165
+ onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg(err)}`);
327166
+ }
327167
+ }
327168
+ async function androidInstallCa(deviceId, caCertPath) {
327169
+ const devicePath = "/sdcard/validateqa-mobile-ca.pem";
327170
+ try {
327171
+ await execFileAsync("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
327172
+ timeout: DEVICE_CMD_TIMEOUT_MS
327173
+ });
327174
+ return {
327175
+ installed: false,
327176
+ 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.`
327177
+ };
327178
+ } catch (err) {
327179
+ return { installed: false, note: `CA push failed: ${errMsg(err)}` };
327180
+ }
327181
+ }
327182
+ async function androidRemoveCa(deviceId, onLog) {
327183
+ try {
327184
+ await execFileAsync(
327185
+ "adb",
327186
+ ["-s", deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"],
327187
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327188
+ );
327189
+ } catch (err) {
327190
+ onLog?.(`[mobile-net] adb remove CA failed: ${errMsg(err)}`);
327191
+ }
327192
+ }
327193
+ async function iosSimTrustCa(deviceId, caCertPath) {
327194
+ try {
327195
+ await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
327196
+ timeout: DEVICE_CMD_TIMEOUT_MS
327197
+ });
327198
+ return {
327199
+ trusted: true,
327200
+ 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."
327201
+ };
327202
+ } catch (err) {
327203
+ return { trusted: false, note: `simctl add-root-cert failed: ${errMsg(err)}` };
327204
+ }
327205
+ }
327206
+ async function iosSimRemoveCa(deviceId, onLog) {
327207
+ if (process.env.VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET === "1") {
327208
+ try {
327209
+ await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "reset"], {
327210
+ timeout: DEVICE_CMD_TIMEOUT_MS
327211
+ });
327212
+ onLog?.(`[mobile-net] simctl keychain reset run for ${deviceId} (opt-in; cleared ALL sim certs).`);
327213
+ } catch (err) {
327214
+ onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg(err)}`);
327215
+ }
327216
+ return;
327217
+ }
327218
+ onLog?.(
327219
+ `[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.`
327220
+ );
327221
+ }
327222
+ function errMsg(err) {
327223
+ return err instanceof Error ? err.message : String(err);
327224
+ }
327225
+ var activeCleanups = /* @__PURE__ */ new Map();
327226
+ var exitHandlersInstalled = false;
327227
+ function markerPath(sessionKey) {
327228
+ return join2(PENDING_CLEANUP_DIR, `${sessionKey}.json`);
327229
+ }
327230
+ async function writePendingCleanup(sessionKey, marker) {
327231
+ try {
327232
+ await mkdir(PENDING_CLEANUP_DIR, { recursive: true });
327233
+ await writeFile(markerPath(sessionKey), JSON.stringify(marker), "utf-8");
327234
+ } catch {
327235
+ }
327236
+ }
327237
+ async function removePendingCleanup(sessionKey) {
327238
+ try {
327239
+ await unlink(markerPath(sessionKey));
327240
+ } catch {
327241
+ }
327242
+ }
327243
+ function restoreDeviceSync(marker, onLog) {
327244
+ const run2 = (args) => {
327245
+ try {
327246
+ execFileSync3("adb", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
327247
+ } catch {
327248
+ }
327249
+ };
327250
+ if (marker.platform === "ANDROID") {
327251
+ if (marker.proxySet) {
327252
+ const restoreValue = marker.originalProxy && marker.originalProxy.length > 0 ? marker.originalProxy : ":0";
327253
+ run2(["-s", marker.deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue]);
327254
+ }
327255
+ if (marker.caInstalledOnDevice) {
327256
+ run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
327257
+ }
327258
+ }
327259
+ onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
327260
+ }
327261
+ async function reconcilePendingCleanups(onLog) {
327262
+ let files;
327263
+ try {
327264
+ files = await readdir(PENDING_CLEANUP_DIR);
327265
+ } catch {
327266
+ return;
327267
+ }
327268
+ for (const file of files) {
327269
+ if (!file.endsWith(".json")) continue;
327270
+ const full = join2(PENDING_CLEANUP_DIR, file);
327271
+ try {
327272
+ const raw = await readFile(full, "utf-8");
327273
+ const marker = JSON.parse(raw);
327274
+ if (marker && typeof marker.deviceId === "string" && marker.platform) {
327275
+ onLog?.(`[mobile-net] reconciling orphaned capture cleanup for ${marker.deviceId}.`);
327276
+ restoreDeviceSync(marker, onLog);
327277
+ }
327278
+ } catch {
327279
+ }
327280
+ try {
327281
+ await unlink(full);
327282
+ } catch {
327283
+ }
327284
+ }
327285
+ }
327286
+ function ensureExitHandlers() {
327287
+ if (exitHandlersInstalled) return;
327288
+ exitHandlersInstalled = true;
327289
+ const drain = () => {
327290
+ for (const marker of activeCleanups.values()) {
327291
+ restoreDeviceSync(marker);
327292
+ }
327293
+ };
327294
+ process.on("beforeExit", drain);
327295
+ process.on("exit", drain);
327296
+ for (const signal of ["SIGTERM", "SIGINT"]) {
327297
+ process.on(signal, () => {
327298
+ drain();
327299
+ process.removeAllListeners(signal);
327300
+ process.kill(process.pid, signal);
327301
+ });
327302
+ }
327303
+ }
327304
+ async function startMobileNetworkCapture(options) {
327305
+ const { platform: platform3, deviceId, isPhysical = false, preferredPort = 0, onLog } = options;
327306
+ const forceTlsMitm = options.forceTlsMitm ?? envFlag("VALIDATEQA_MOBILE_FORCE_TLS_MITM");
327307
+ const tlsPassthroughAll = !forceTlsMitm && (platform3 === "ANDROID" || platform3 === "IOS" && isPhysical);
327308
+ await reconcilePendingCleanups(onLog);
327309
+ ensureExitHandlers();
327310
+ const sessionKey = randomUUID();
327311
+ const originalProxy = platform3 === "ANDROID" ? await androidReadProxy(deviceId) : null;
327312
+ let tmpDir = null;
327313
+ let caCertPath = null;
327314
+ let caKeyPem = null;
327315
+ let caCertPem = null;
327316
+ try {
327317
+ tmpDir = await mkdtemp(join2(tmpdir(), "validateqa-mobile-ca-"));
327318
+ const ca = await generateCaCert(tmpDir, onLog);
327319
+ if (ca) {
327320
+ caCertPath = ca.caCertPath;
327321
+ caKeyPem = ca.caKeyPem;
327322
+ caCertPem = ca.caCertPem;
327323
+ }
327324
+ } catch (err) {
327325
+ onLog?.(`[mobile-net] CA setup degraded: ${errMsg(err)}`);
327326
+ }
327327
+ if (!caKeyPem || !caCertPem) {
327328
+ try {
327329
+ const fallback = await generateCACertificate();
327330
+ caKeyPem = fallback.key;
327331
+ caCertPem = fallback.cert;
327332
+ } catch (err) {
327333
+ onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg(err)}`);
327334
+ if (tmpDir) {
327335
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => void 0);
327336
+ }
327337
+ throw err instanceof Error ? err : new Error(String(err));
327338
+ }
327339
+ }
327340
+ const proxy = new MitmProxy(caKeyPem, caCertPem, caCertPath, isPhysical, tlsPassthroughAll, onLog);
327341
+ const proxyPort = await proxy.listen(preferredPort);
327342
+ const proxyHost = resolveProxyHostForDevice(platform3, isPhysical);
327343
+ const hostPort = `${proxyHost}:${proxyPort}`;
327344
+ onLog?.(
327345
+ `[mobile-net] ${tlsPassthroughAll ? "capture proxy" : "MITM proxy"} listening; device target ${hostPort} (${platform3}${isPhysical ? " physical" : ""})`
327346
+ );
327347
+ let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
327348
+ let proxySet = false;
327349
+ let caInstalledOnDevice = false;
327350
+ try {
327351
+ if (platform3 === "ANDROID") {
327352
+ proxySet = await androidSetProxy(deviceId, hostPort, onLog);
327353
+ if (!proxySet) {
327354
+ wiringReason = "adb proxy configuration failed; no device traffic will be routed through the proxy.";
327355
+ } else if (tlsPassthroughAll) {
327356
+ 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}.` : ".");
327357
+ } else if (caCertPath) {
327358
+ const { note } = await androidInstallCa(deviceId, caCertPath);
327359
+ caInstalledOnDevice = true;
327360
+ wiringReason = note;
327361
+ }
327362
+ } else {
327363
+ if (!isPhysical && caCertPath) {
327364
+ const { trusted, note } = await iosSimTrustCa(deviceId, caCertPath);
327365
+ caInstalledOnDevice = trusted;
327366
+ wiringReason = note;
327367
+ } else if (isPhysical) {
327368
+ 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}).` : ".");
327369
+ }
327370
+ }
327371
+ } catch (err) {
327372
+ wiringReason = `device wiring degraded: ${errMsg(err)}`;
327373
+ onLog?.(`[mobile-net] ${wiringReason}`);
327374
+ }
327375
+ if (proxySet || caInstalledOnDevice) {
327376
+ const marker = {
327377
+ platform: platform3,
327378
+ deviceId,
327379
+ isPhysical,
327380
+ proxySet,
327381
+ originalProxy,
327382
+ caInstalledOnDevice
327383
+ };
327384
+ activeCleanups.set(sessionKey, marker);
327385
+ await writePendingCleanup(sessionKey, marker);
327386
+ }
327387
+ const capturedTmpDir = tmpDir;
327388
+ const capturedCaCertPath = caCertPath;
327389
+ let stopped = false;
327390
+ let finalCalls = [];
327391
+ const getCapabilities = () => {
327392
+ if (proxy.isIntercepting) return { intercepting: true };
327393
+ const reason = proxy.interceptionReason ?? wiringReason;
327394
+ return reason ? { intercepting: false, reason } : { intercepting: false };
327395
+ };
327396
+ const stop = async () => {
327397
+ if (stopped) return finalCalls;
327398
+ stopped = true;
327399
+ if (proxySet && platform3 === "ANDROID") {
327400
+ await androidClearProxy(deviceId, originalProxy, onLog);
327401
+ }
327402
+ if (caInstalledOnDevice && capturedCaCertPath) {
327403
+ if (platform3 === "ANDROID") {
327404
+ await androidRemoveCa(deviceId, onLog);
327405
+ } else if (!isPhysical) {
327406
+ await iosSimRemoveCa(deviceId, onLog);
327407
+ }
327408
+ }
327409
+ activeCleanups.delete(sessionKey);
327410
+ await removePendingCleanup(sessionKey);
327411
+ finalCalls = proxy.finalize();
327412
+ await proxy.close();
327413
+ if (capturedTmpDir) {
327414
+ try {
327415
+ await rm(capturedTmpDir, { recursive: true, force: true });
327416
+ } catch (err) {
327417
+ onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg(err)}`);
327418
+ }
327419
+ }
327420
+ const apiCount = finalCalls.filter((c) => c.isApi).length;
327421
+ onLog?.(
327422
+ `[mobile-net] stopped: ${finalCalls.length} calls captured (${apiCount} API; HTTPS interception ${proxy.isIntercepting ? "ACTIVE" : "inactive"}).`
327423
+ );
327424
+ return finalCalls;
327425
+ };
327426
+ return {
327427
+ proxyPort,
327428
+ proxyHost,
327429
+ caCertPath: capturedCaCertPath,
327430
+ getCapabilities,
327431
+ stop
327432
+ };
327433
+ }
327434
+
325480
327435
  // src/services/appium-executor.ts
325481
327436
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
325482
327437
  var WebDriverTransportError = class extends Error {
@@ -325862,7 +327817,24 @@ async function executeMobileTest(options) {
325862
327817
  const startTime = Date.now();
325863
327818
  const stepResults = [];
325864
327819
  const screenshots = [];
327820
+ let apiCalls = [];
325865
327821
  let sessionId = null;
327822
+ let capture = null;
327823
+ let captureStopped = false;
327824
+ const stopCapture = async () => {
327825
+ if (!capture || captureStopped) return apiCalls;
327826
+ captureStopped = true;
327827
+ try {
327828
+ const captured = await capture.stop();
327829
+ apiCalls = captured.map((call) => ({
327830
+ ...call,
327831
+ pageUrl: call.pageUrl ?? `mobile-test:${options.target.appId}`
327832
+ }));
327833
+ } catch (err) {
327834
+ log2(`[mobile-net] capture stop failed: ${err instanceof Error ? err.message : String(err)}`);
327835
+ }
327836
+ return apiCalls;
327837
+ };
325866
327838
  try {
325867
327839
  log2("Checking Appium server health...");
325868
327840
  const health = await checkAppiumHealth();
@@ -325890,6 +327862,21 @@ async function executeMobileTest(options) {
325890
327862
  selectedDevice = matchingDevices[0];
325891
327863
  }
325892
327864
  log2(`Using device: ${selectedDevice.name} (${selectedDevice.id})`);
327865
+ try {
327866
+ capture = await startMobileNetworkCapture({
327867
+ platform: options.target.platform,
327868
+ deviceId: selectedDevice.id,
327869
+ appId: options.target.appId,
327870
+ isPhysical: selectedDevice.isPhysical,
327871
+ onLog: (line) => log2(line)
327872
+ });
327873
+ const caps = capture.getCapabilities();
327874
+ if (!caps.intercepting) {
327875
+ log2(`[mobile-net] HTTPS interception unavailable: ${caps.reason ?? "unknown reason"} (continuing - plaintext/metadata only)`);
327876
+ }
327877
+ } catch (err) {
327878
+ log2(`[mobile-net] capture unavailable: ${err instanceof Error ? err.message : String(err)} (continuing without network log)`);
327879
+ }
325893
327880
  if (selectedDevice.installedApps && selectedDevice.installedApps.length > 0) {
325894
327881
  if (!selectedDevice.installedApps.includes(options.target.appId)) {
325895
327882
  throw new Error(
@@ -325999,10 +327986,12 @@ async function executeMobileTest(options) {
325999
327986
  }
326000
327987
  const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
326001
327988
  const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
327989
+ await stopCapture();
326002
327990
  return {
326003
327991
  passed: allPassed,
326004
327992
  stepResults,
326005
327993
  screenshots,
327994
+ apiCalls,
326006
327995
  logs: logLines.join("\n"),
326007
327996
  error: firstError,
326008
327997
  duration: Date.now() - startTime,
@@ -326011,10 +328000,12 @@ async function executeMobileTest(options) {
326011
328000
  } catch (err) {
326012
328001
  const errorMsg = err instanceof Error ? err.message : String(err);
326013
328002
  log2(`Fatal error: ${errorMsg}`);
328003
+ await stopCapture();
326014
328004
  return {
326015
328005
  passed: false,
326016
328006
  stepResults,
326017
328007
  screenshots,
328008
+ apiCalls,
326018
328009
  logs: logLines.join("\n"),
326019
328010
  error: errorMsg,
326020
328011
  duration: Date.now() - startTime,
@@ -326024,6 +328015,7 @@ async function executeMobileTest(options) {
326024
328015
  environmental: errorMsg
326025
328016
  };
326026
328017
  } finally {
328018
+ await stopCapture();
326027
328019
  if (sessionId) {
326028
328020
  log2("Tearing down Appium session...");
326029
328021
  await deleteSession(sessionId);
@@ -326031,7 +328023,7 @@ async function executeMobileTest(options) {
326031
328023
  }
326032
328024
  }
326033
328025
  }
326034
- async function createMobileDiscoverySession(target) {
328026
+ async function createMobileDiscoverySession(target, options) {
326035
328027
  const health = await checkAppiumHealth();
326036
328028
  if (!health.ok) {
326037
328029
  await startAppiumServer();
@@ -326058,6 +328050,12 @@ async function createMobileDiscoverySession(target) {
326058
328050
  `App ${target.appId} not installed on ${selectedDevice.name}. Install it first.`
326059
328051
  );
326060
328052
  }
328053
+ await options?.onDeviceSelected?.({
328054
+ deviceId: selectedDevice.id,
328055
+ name: selectedDevice.name,
328056
+ isPhysical: selectedDevice.isPhysical === true,
328057
+ platformVersion: selectedDevice.platformVersion
328058
+ });
326061
328059
  const sessionId = await createSession({
326062
328060
  appId: target.appId,
326063
328061
  platform: target.platform,
@@ -326088,10 +328086,10 @@ async function createMobileDiscoverySession(target) {
326088
328086
 
326089
328087
  // src/services/mobile-bridge.ts
326090
328088
  import { createServer } from "http";
326091
- import { execFileSync as execFileSync3 } from "child_process";
328089
+ import { execFileSync as execFileSync4 } from "child_process";
326092
328090
  import { randomBytes, timingSafeEqual } from "crypto";
326093
328091
  import { homedir as homedir2 } from "os";
326094
- import { join as join2, dirname } from "path";
328092
+ import { join as join3, dirname } from "path";
326095
328093
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, mkdirSync as mkdirSync2 } from "fs";
326096
328094
  init_dist();
326097
328095
 
@@ -326380,7 +328378,7 @@ async function getWindowSize3() {
326380
328378
  }
326381
328379
  var BRIDGE_PROCESS_MARKER = "validateqa-mobile-bridge";
326382
328380
  var BRIDGE_PORT = MOBILE_BRIDGE_PORT2;
326383
- var BRIDGE_PID_FILE = join2(homedir2(), ".validate.qa", "mobile-bridge.pid");
328381
+ var BRIDGE_PID_FILE = join3(homedir2(), ".validate.qa", "mobile-bridge.pid");
326384
328382
  function readBridgePidFile() {
326385
328383
  try {
326386
328384
  const raw = readFileSync2(BRIDGE_PID_FILE, "utf-8").trim();
@@ -326407,19 +328405,19 @@ function killStaleBridge() {
326407
328405
  const pid = readBridgePidFile();
326408
328406
  if (!pid) return;
326409
328407
  try {
326410
- execFileSync3("kill", ["-0", String(pid)], { stdio: "ignore" });
328408
+ execFileSync4("kill", ["-0", String(pid)], { stdio: "ignore" });
326411
328409
  } catch {
326412
328410
  clearBridgePidFile();
326413
328411
  return;
326414
328412
  }
326415
328413
  try {
326416
- const pidsOnPort = execFileSync3("lsof", ["-ti", `:${BRIDGE_PORT}`], { encoding: "utf-8" }).trim();
328414
+ const pidsOnPort = execFileSync4("lsof", ["-ti", `:${BRIDGE_PORT}`], { encoding: "utf-8" }).trim();
326417
328415
  const pidList = pidsOnPort.split("\n").filter(Boolean);
326418
328416
  if (!pidList.includes(String(pid))) {
326419
328417
  return;
326420
328418
  }
326421
- execFileSync3("kill", ["-9", String(pid)], { stdio: "ignore" });
326422
- execFileSync3("sleep", ["1"], { stdio: "ignore" });
328419
+ execFileSync4("kill", ["-9", String(pid)], { stdio: "ignore" });
328420
+ execFileSync4("sleep", ["1"], { stdio: "ignore" });
326423
328421
  } catch {
326424
328422
  } finally {
326425
328423
  clearBridgePidFile();
@@ -327250,7 +329248,7 @@ function detachMirrorSession(sessionId) {
327250
329248
  }
327251
329249
 
327252
329250
  // src/services/mobile/mobile-bridge-driver.ts
327253
- var import_runner_core7 = __toESM(require_dist2());
329251
+ var import_runner_core8 = __toESM(require_dist2());
327254
329252
  init_dist();
327255
329253
  var APPIUM_SESSION_ID_PATTERN2 = /^[a-f0-9-]{1,64}$/i;
327256
329254
  function readStringValue(result) {
@@ -327260,6 +329258,15 @@ function readStringValue(result) {
327260
329258
  }
327261
329259
  return void 0;
327262
329260
  }
329261
+ function normalizeAndroidActivity(activity, pkg2) {
329262
+ const trimmed = activity?.trim();
329263
+ if (!trimmed) return void 0;
329264
+ const packageName = pkg2?.trim();
329265
+ if (!packageName) return trimmed;
329266
+ if (trimmed.startsWith(".")) return `${packageName}${trimmed}`;
329267
+ if (!trimmed.includes(".")) return `${packageName}.${trimmed}`;
329268
+ return trimmed;
329269
+ }
327263
329270
  var MobileBridgeDriver = class {
327264
329271
  sessionId;
327265
329272
  platform;
@@ -327286,6 +329293,38 @@ var MobileBridgeDriver = class {
327286
329293
  appLifecycleArgs() {
327287
329294
  return this.platform === "ANDROID" ? { appId: this.appId } : { bundleId: this.appId };
327288
329295
  }
329296
+ baseScreenSignal(source) {
329297
+ const parsed = (0, import_runner_core8.parseMobileSource)(source, this.platform);
329298
+ return {
329299
+ platform: this.platform,
329300
+ screenHash: parsed.screenSignal.screenHash,
329301
+ ...parsed.screenSignal.activity ? { activity: parsed.screenSignal.activity } : {},
329302
+ ...parsed.screenSignal.bundleId ? { bundleId: parsed.screenSignal.bundleId } : {}
329303
+ };
329304
+ }
329305
+ async enrichScreenSignal(source) {
329306
+ const signal = this.baseScreenSignal(source);
329307
+ try {
329308
+ if (this.platform === "ANDROID") {
329309
+ const [activityRes, packageRes] = await Promise.all([
329310
+ this.executeScript("mobile: getCurrentActivity", []).catch(() => void 0),
329311
+ this.executeScript("mobile: getCurrentPackage", []).catch(() => void 0)
329312
+ ]);
329313
+ const activity = readStringValue(activityRes);
329314
+ const pkg2 = readStringValue(packageRes);
329315
+ if (pkg2) signal.bundleId = pkg2;
329316
+ const normalizedActivity = normalizeAndroidActivity(activity, pkg2);
329317
+ if (normalizedActivity) signal.activity = normalizedActivity;
329318
+ } else {
329319
+ const infoRes = await this.executeScript("mobile: activeAppInfo", []).catch(() => void 0);
329320
+ const bundleId = readActiveBundleId(infoRes);
329321
+ if (bundleId) signal.bundleId = bundleId;
329322
+ }
329323
+ } catch {
329324
+ }
329325
+ if (!signal.bundleId) signal.bundleId = this.appId;
329326
+ return signal;
329327
+ }
327289
329328
  /**
327290
329329
  * Run a `mobile:` script via execute/sync. Used by the NEW lifecycle
327291
329330
  * primitives (terminateApp/activateApp/deepLink/getCurrentActivity) that have
@@ -327300,9 +329339,7 @@ var MobileBridgeDriver = class {
327300
329339
  // ── Observation ────────────────────────────────────────
327301
329340
  /**
327302
329341
  * One round-trip observation: UI-tree XML + base64 screenshot + window size,
327303
- * plus a best-effort screen signal derived from the parsed XML (cheap — no
327304
- * extra device round-trip; the activity/bundleId enrichment is left to
327305
- * getScreenSignal()).
329342
+ * plus a best-effort screen signal enriched with the foreground app identity.
327306
329343
  */
327307
329344
  async snapshot() {
327308
329345
  const [xml, screenshot, windowSize] = await Promise.all([
@@ -327311,10 +329348,7 @@ var MobileBridgeDriver = class {
327311
329348
  getWindowSize2(this.sessionPath)
327312
329349
  ]);
327313
329350
  const source = xml ?? "";
327314
- const screenSignal = {
327315
- platform: this.platform,
327316
- screenHash: (0, import_runner_core7.parseMobileSource)(source, this.platform).screenSignal.screenHash
327317
- };
329351
+ const screenSignal = await this.enrichScreenSignal(source);
327318
329352
  return {
327319
329353
  xml: source,
327320
329354
  screenshot,
@@ -327343,7 +329377,8 @@ var MobileBridgeDriver = class {
327343
329377
  getScreenshot(this.sessionPath),
327344
329378
  getPageSource(this.sessionPath)
327345
329379
  ]);
327346
- return { ok: true, screenshot, source };
329380
+ const screenSignal = await this.enrichScreenSignal(source ?? "");
329381
+ return { ok: true, screenshot, source, screenSignal };
327347
329382
  }
327348
329383
  // ── Action primitives (1:1 with the bridge handleAction verbs) ──
327349
329384
  /** Center-tap the given bounds (coordinate-only; the loop always supplies bounds). */
@@ -327421,27 +329456,7 @@ var MobileBridgeDriver = class {
327421
329456
  */
327422
329457
  async getScreenSignal() {
327423
329458
  const source = await getPageSource(this.sessionPath) ?? "";
327424
- const screenHash = (0, import_runner_core7.parseMobileSource)(source, this.platform).screenSignal.screenHash;
327425
- const signal = { platform: this.platform, screenHash };
327426
- try {
327427
- if (this.platform === "ANDROID") {
327428
- const [activityRes, packageRes] = await Promise.all([
327429
- this.executeScript("mobile: getCurrentActivity", []).catch(() => void 0),
327430
- this.executeScript("mobile: getCurrentPackage", []).catch(() => void 0)
327431
- ]);
327432
- const activity = readStringValue(activityRes);
327433
- const pkg2 = readStringValue(packageRes);
327434
- if (activity) signal.activity = activity;
327435
- signal.bundleId = pkg2 ?? this.appId;
327436
- } else {
327437
- const infoRes = await this.executeScript("mobile: activeAppInfo", []).catch(() => void 0);
327438
- const bundleId = readActiveBundleId(infoRes);
327439
- signal.bundleId = bundleId ?? this.appId;
327440
- }
327441
- } catch {
327442
- if (!signal.bundleId) signal.bundleId = this.appId;
327443
- }
327444
- return signal;
329459
+ return this.enrichScreenSignal(source);
327445
329460
  }
327446
329461
  };
327447
329462
  function readActiveBundleId(result) {
@@ -327455,617 +329470,6 @@ function readActiveBundleId(result) {
327455
329470
  return void 0;
327456
329471
  }
327457
329472
 
327458
- // src/services/mobile/mobile-network-capture.ts
327459
- var import_runner_core8 = __toESM(require_dist2());
327460
- import { execFile, execFileSync as execFileSync4 } from "child_process";
327461
- import { randomUUID } from "crypto";
327462
- import { mkdtemp, rm, writeFile, readFile, readdir, mkdir, unlink } from "fs/promises";
327463
- import { tmpdir, networkInterfaces } from "os";
327464
- import { join as join3 } from "path";
327465
- import { promisify } from "util";
327466
- import {
327467
- getLocal,
327468
- generateCACertificate
327469
- } from "mockttp";
327470
- var execFileAsync = promisify(execFile);
327471
- var MAX_CALLS = 1e3;
327472
- var MAX_BODY_CAPTURE_BYTES = 64 * 1024;
327473
- var DEVICE_CMD_TIMEOUT_MS = 15e3;
327474
- var CA_KEY_BITS = 2048;
327475
- var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
327476
- var PENDING_CLEANUP_DIR = join3(tmpdir(), "validateqa-mobile-capture-pending");
327477
- function rawHeadersToPairs(rawHeaders) {
327478
- return rawHeaders.map(([name, value]) => ({ name, value }));
327479
- }
327480
- function headerValue(pairs, name) {
327481
- const lower = name.toLowerCase();
327482
- return pairs.find((h) => h.name.toLowerCase() === lower)?.value;
327483
- }
327484
- function resolveHostIp() {
327485
- const ifaces = networkInterfaces();
327486
- for (const addrs of Object.values(ifaces)) {
327487
- if (!addrs) continue;
327488
- for (const addr of addrs) {
327489
- if (addr.family === "IPv4" && !addr.internal) return addr.address;
327490
- }
327491
- }
327492
- return "127.0.0.1";
327493
- }
327494
- function resolveProxyHostForDevice(platform3, isPhysical) {
327495
- if (platform3 === "ANDROID" && !isPhysical) return ANDROID_EMULATOR_HOST_LOOPBACK;
327496
- if (platform3 === "IOS" && !isPhysical) return "127.0.0.1";
327497
- return resolveHostIp();
327498
- }
327499
- function normalizeRemoteAddress(addr) {
327500
- if (!addr) return "";
327501
- return addr.startsWith("::ffff:") ? addr.slice("::ffff:".length) : addr;
327502
- }
327503
- function isLoopbackAddress(addr) {
327504
- return addr === "127.0.0.1" || addr.startsWith("127.") || addr === "::1" || addr === "";
327505
- }
327506
- function isPrivateAddress(addr) {
327507
- if (/^10\./.test(addr)) return true;
327508
- if (/^192\.168\./.test(addr)) return true;
327509
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
327510
- if (/^169\.254\./.test(addr)) return true;
327511
- const lower = addr.toLowerCase();
327512
- if (lower.startsWith("fe80:")) return true;
327513
- if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
327514
- return false;
327515
- }
327516
- function isAllowedProxyPeer(remoteAddress, isPhysical) {
327517
- const addr = normalizeRemoteAddress(remoteAddress);
327518
- if (isLoopbackAddress(addr)) return true;
327519
- return isPhysical && isPrivateAddress(addr);
327520
- }
327521
- function installSourceIpGuard(server3, isPhysical, onLog) {
327522
- const underlying = server3.server;
327523
- if (!underlying || typeof underlying.on !== "function") {
327524
- onLog?.(
327525
- "[mobile-net] WARNING: could not attach source-IP guard to the proxy listener; the port is bound to all interfaces \u2014 firewall it to the device subnet."
327526
- );
327527
- return;
327528
- }
327529
- underlying.on("connection", (socket) => {
327530
- if (!isAllowedProxyPeer(socket.remoteAddress, isPhysical)) {
327531
- onLog?.(`[mobile-net] rejected proxy connection from disallowed source ${socket.remoteAddress}`);
327532
- socket.destroy();
327533
- }
327534
- });
327535
- }
327536
- function isTextish(contentType) {
327537
- if (!contentType) return false;
327538
- return /json|text|xml|graphql|html|csv|javascript|urlencoded/i.test(contentType);
327539
- }
327540
- async function readBodyText(body, contentType) {
327541
- if (!isTextish(contentType)) return void 0;
327542
- try {
327543
- const decoded = await body.getDecodedBuffer();
327544
- if (!decoded || decoded.length === 0) return void 0;
327545
- if (decoded.length > MAX_BODY_CAPTURE_BYTES) {
327546
- return decoded.subarray(0, MAX_BODY_CAPTURE_BYTES).toString("utf-8");
327547
- }
327548
- return decoded.toString("utf-8");
327549
- } catch {
327550
- return void 0;
327551
- }
327552
- }
327553
- var MitmProxy = class {
327554
- constructor(caKeyPem, caCertPem, isPhysical, onLog) {
327555
- this.caKeyPem = caKeyPem;
327556
- this.caCertPem = caCertPem;
327557
- this.isPhysical = isPhysical;
327558
- this.onLog = onLog;
327559
- }
327560
- server = null;
327561
- inFlight = /* @__PURE__ */ new Map();
327562
- calls = [];
327563
- dropped = 0;
327564
- /** Flips true once any HTTPS response is successfully MITM-ed. */
327565
- httpsIntercepted = false;
327566
- /** Best-known reason interception is unavailable, refined by TLS failures. */
327567
- tlsFailureReason = null;
327568
- /** Start listening; resolves with the actual bound port. */
327569
- async listen(preferredPort) {
327570
- const server3 = getLocal({
327571
- // Our generated CA — mockttp mints per-host leaf certs signed by it.
327572
- https: { key: this.caKeyPem, cert: this.caCertPem },
327573
- // HTTP/2 with HTTP/1.1 fallback; mobile clients negotiate either.
327574
- http2: "fallback",
327575
- // Keep the proxy quiet unless explicitly debugging.
327576
- recordTraffic: false
327577
- });
327578
- await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
327579
- await server3.on("request", (req) => this.onRequest(req));
327580
- await server3.on("response", (res) => this.onResponse(res));
327581
- await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
327582
- await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
327583
- installSourceIpGuard(server3, this.isPhysical, this.onLog);
327584
- this.server = server3;
327585
- return server3.port;
327586
- }
327587
- /** Captured calls, finalized through the shared redaction constructor. */
327588
- finalize() {
327589
- return this.calls.map(
327590
- (c) => (0, import_runner_core8.buildApiCallSummary)({
327591
- method: c.method,
327592
- url: c.url,
327593
- status: c.status,
327594
- durationMs: c.durationMs,
327595
- authHeader: c.authHeader,
327596
- cookieHeader: c.cookieHeader,
327597
- requestContentType: c.requestContentType,
327598
- responseContentType: c.responseContentType,
327599
- respHeaders: c.respHeaders,
327600
- reqBodyText: c.reqBodyText,
327601
- respBodyText: c.respBodyText,
327602
- responseSize: c.responseSize,
327603
- timestamp: c.startedAt,
327604
- testPhase: "UNKNOWN"
327605
- })
327606
- );
327607
- }
327608
- /** True once at least one HTTPS exchange was decrypted (CA trusted). */
327609
- get isIntercepting() {
327610
- return this.httpsIntercepted;
327611
- }
327612
- /** A precise reason interception is (still) unavailable, or null if it works. */
327613
- get interceptionReason() {
327614
- if (this.httpsIntercepted) return null;
327615
- return this.tlsFailureReason;
327616
- }
327617
- async close() {
327618
- const server3 = this.server;
327619
- this.server = null;
327620
- this.inFlight.clear();
327621
- if (!server3) return;
327622
- try {
327623
- await server3.stop();
327624
- } catch (err) {
327625
- this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg(err)}`);
327626
- }
327627
- this.onLog?.(
327628
- `[mobile-net] proxy closed: ${this.calls.length} calls captured` + (this.dropped > 0 ? ` (${this.dropped} dropped over cap)` : "")
327629
- );
327630
- }
327631
- // ── request → buffer by id ────────────────────────────────────────────────
327632
- async onRequest(req) {
327633
- try {
327634
- const reqHeaders = rawHeadersToPairs(req.rawHeaders);
327635
- const requestContentType = headerValue(reqHeaders, "content-type");
327636
- const isHttps = isHttpsUrl(req.url);
327637
- const reqBodyText = (0, import_runner_core8.isApiCall)(req.url, requestContentType) ? await readBodyText(req.body, requestContentType) : void 0;
327638
- this.inFlight.set(req.id, {
327639
- method: req.method,
327640
- url: req.url,
327641
- startedAt: req.timingEvents.startTime,
327642
- reqHeaders,
327643
- requestContentType,
327644
- authHeader: headerValue(reqHeaders, "authorization"),
327645
- cookieHeader: headerValue(reqHeaders, "cookie"),
327646
- reqBodyText,
327647
- isHttps
327648
- });
327649
- } catch (err) {
327650
- this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg(err)}`);
327651
- }
327652
- }
327653
- // ── response → finalize the matched request ───────────────────────────────
327654
- async onResponse(res) {
327655
- const pending = this.inFlight.get(res.id);
327656
- if (!pending) return;
327657
- this.inFlight.delete(res.id);
327658
- try {
327659
- if (pending.isHttps && !this.httpsIntercepted) {
327660
- this.httpsIntercepted = true;
327661
- this.tlsFailureReason = null;
327662
- this.onLog?.("[mobile-net] HTTPS interception confirmed (CA trusted; bodies decrypted).");
327663
- }
327664
- const respHeaders = rawHeadersToPairs(res.rawHeaders);
327665
- const responseContentType = headerValue(respHeaders, "content-type");
327666
- const api = (0, import_runner_core8.isApiCall)(pending.url, responseContentType);
327667
- if (!api && (0, import_runner_core8.isStaticAssetByExt)(pending.url)) return;
327668
- if (this.calls.length >= MAX_CALLS) {
327669
- this.dropped++;
327670
- return;
327671
- }
327672
- const respBodyText = api ? await readBodyText(res.body, responseContentType) : void 0;
327673
- const responseSize = await measureBody(res.body);
327674
- const durationMs = computeDuration(pending.startedAt, res);
327675
- this.calls.push({
327676
- method: pending.method,
327677
- url: pending.url,
327678
- status: res.statusCode,
327679
- startedAt: pending.startedAt,
327680
- durationMs,
327681
- reqHeaders: pending.reqHeaders,
327682
- respHeaders,
327683
- requestContentType: pending.requestContentType,
327684
- responseContentType,
327685
- authHeader: pending.authHeader,
327686
- cookieHeader: pending.cookieHeader,
327687
- reqBodyText: pending.reqBodyText,
327688
- respBodyText,
327689
- responseSize,
327690
- intercepted: true
327691
- });
327692
- } catch (err) {
327693
- this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg(err)}`);
327694
- }
327695
- }
327696
- // ── tls-client-error → record WHY interception failed ─────────────────────
327697
- onTlsClientError(failure) {
327698
- if (this.httpsIntercepted) return;
327699
- const host = failure.destination?.hostname ?? failure.tlsMetadata.sniHostname ?? "unknown host";
327700
- let reason;
327701
- switch (failure.failureCause) {
327702
- case "cert-rejected":
327703
- reason = `TLS handshake to ${host} rejected our certificate: the device does not trust the validate.qa CA, so HTTPS bodies cannot be decrypted (plaintext HTTP still captured). Install + trust the CA at caCertPath to enable interception.`;
327704
- break;
327705
- case "closed":
327706
- case "reset":
327707
- reason = `TLS handshake to ${host} was dropped after our certificate was offered \u2014 this is the signature of certificate pinning in the app. HTTPS bodies for pinned hosts cannot be decrypted (plaintext HTTP and connect-level host metadata still captured).`;
327708
- break;
327709
- case "no-shared-cipher":
327710
- reason = `TLS handshake to ${host} failed: no shared cipher with the client.`;
327711
- break;
327712
- case "handshake-timeout":
327713
- reason = `TLS handshake to ${host} timed out before completing.`;
327714
- break;
327715
- default:
327716
- reason = `TLS handshake to ${host} failed (${failure.failureCause}); HTTPS not intercepted for it.`;
327717
- }
327718
- if (!this.tlsFailureReason || failure.failureCause === "cert-rejected" || failure.failureCause === "closed" || failure.failureCause === "reset") {
327719
- this.tlsFailureReason = reason;
327720
- }
327721
- this.onLog?.(`[mobile-net] ${reason}`);
327722
- }
327723
- };
327724
- function isHttpsUrl(url) {
327725
- try {
327726
- return new URL(url).protocol === "https:";
327727
- } catch {
327728
- return false;
327729
- }
327730
- }
327731
- async function measureBody(body) {
327732
- try {
327733
- const decoded = await body.getDecodedBuffer();
327734
- return decoded ? decoded.length : 0;
327735
- } catch {
327736
- return 0;
327737
- }
327738
- }
327739
- function computeDuration(startedAt, res) {
327740
- const sent = res.timingEvents.responseSentTimestamp;
327741
- const start = res.timingEvents.startTimestamp;
327742
- if (typeof sent === "number" && typeof start === "number" && sent >= start) {
327743
- return Math.round(sent - start);
327744
- }
327745
- const elapsed = Date.now() - startedAt;
327746
- return elapsed > 0 ? elapsed : 0;
327747
- }
327748
- async function generateCaCert(dir, onLog) {
327749
- try {
327750
- const { key, cert } = await generateCACertificate({
327751
- subject: {
327752
- commonName: "validate.qa Mobile Capture CA",
327753
- organizationName: "validate.qa"
327754
- },
327755
- bits: CA_KEY_BITS
327756
- // mockttp's generated CA carries its own (multi-year) validity window; we
327757
- // rely on stop() to remove the cert from the device promptly after the run
327758
- // rather than on a short expiry.
327759
- });
327760
- const caCertPath = join3(dir, "validateqa-mobile-ca.pem");
327761
- await writeFile(caCertPath, cert, "utf-8");
327762
- return { caCertPath, caKeyPem: key, caCertPem: cert };
327763
- } catch (err) {
327764
- onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg(err)}`);
327765
- return null;
327766
- }
327767
- }
327768
- async function androidReadProxy(deviceId) {
327769
- try {
327770
- const { stdout } = await execFileAsync(
327771
- "adb",
327772
- ["-s", deviceId, "shell", "settings", "get", "global", "http_proxy"],
327773
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327774
- );
327775
- const value = stdout.trim();
327776
- return value === "" || value === "null" ? null : value;
327777
- } catch {
327778
- return null;
327779
- }
327780
- }
327781
- async function androidSetProxy(deviceId, hostPort, onLog) {
327782
- try {
327783
- await execFileAsync(
327784
- "adb",
327785
- ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", hostPort],
327786
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327787
- );
327788
- return true;
327789
- } catch (err) {
327790
- onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg(err)}`);
327791
- return false;
327792
- }
327793
- }
327794
- async function androidClearProxy(deviceId, originalProxy, onLog) {
327795
- const restoreValue = originalProxy && originalProxy.length > 0 ? originalProxy : ":0";
327796
- try {
327797
- await execFileAsync(
327798
- "adb",
327799
- ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue],
327800
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327801
- );
327802
- } catch (err) {
327803
- onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg(err)}`);
327804
- }
327805
- }
327806
- async function androidInstallCa(deviceId, caCertPath) {
327807
- const devicePath = "/sdcard/validateqa-mobile-ca.pem";
327808
- try {
327809
- await execFileAsync("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
327810
- timeout: DEVICE_CMD_TIMEOUT_MS
327811
- });
327812
- return {
327813
- installed: false,
327814
- note: "CA pushed to /sdcard but NOT auto-trusted: Android 7+ apps must opt into user CAs (network_security_config), and the system store requires a rooted/writable image. HTTPS bodies decrypt only for apps that trust user CAs; pinned apps never decrypt."
327815
- };
327816
- } catch (err) {
327817
- return { installed: false, note: `CA push failed: ${errMsg(err)}` };
327818
- }
327819
- }
327820
- async function androidRemoveCa(deviceId, onLog) {
327821
- try {
327822
- await execFileAsync(
327823
- "adb",
327824
- ["-s", deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"],
327825
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327826
- );
327827
- } catch (err) {
327828
- onLog?.(`[mobile-net] adb remove CA failed: ${errMsg(err)}`);
327829
- }
327830
- }
327831
- async function iosSimTrustCa(deviceId, caCertPath) {
327832
- try {
327833
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
327834
- timeout: DEVICE_CMD_TIMEOUT_MS
327835
- });
327836
- return {
327837
- trusted: true,
327838
- note: "CA added to simulator keychain (add-root-cert). NOTE: the simulator has no per-app HTTP proxy setting controllable via simctl \u2014 it shares the host network, so only traffic that actually routes through the proxy is intercepted; pinned apps still never decrypt."
327839
- };
327840
- } catch (err) {
327841
- return { trusted: false, note: `simctl add-root-cert failed: ${errMsg(err)}` };
327842
- }
327843
- }
327844
- async function iosSimRemoveCa(deviceId, onLog) {
327845
- if (process.env.VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET === "1") {
327846
- try {
327847
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "reset"], {
327848
- timeout: DEVICE_CMD_TIMEOUT_MS
327849
- });
327850
- onLog?.(`[mobile-net] simctl keychain reset run for ${deviceId} (opt-in; cleared ALL sim certs).`);
327851
- } catch (err) {
327852
- onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg(err)}`);
327853
- }
327854
- return;
327855
- }
327856
- onLog?.(
327857
- `[mobile-net] NOTE: the validate.qa capture CA remains TRUSTED in simulator ${deviceId}. simctl has no scoped remove (only a full keychain reset that wipes ALL certs), so we leave the single CA in place. Use a throwaway simulator, or set VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET=1 to reset the whole sim keychain on teardown.`
327858
- );
327859
- }
327860
- function errMsg(err) {
327861
- return err instanceof Error ? err.message : String(err);
327862
- }
327863
- var activeCleanups = /* @__PURE__ */ new Map();
327864
- var exitHandlersInstalled = false;
327865
- function markerPath(sessionKey) {
327866
- return join3(PENDING_CLEANUP_DIR, `${sessionKey}.json`);
327867
- }
327868
- async function writePendingCleanup(sessionKey, marker) {
327869
- try {
327870
- await mkdir(PENDING_CLEANUP_DIR, { recursive: true });
327871
- await writeFile(markerPath(sessionKey), JSON.stringify(marker), "utf-8");
327872
- } catch {
327873
- }
327874
- }
327875
- async function removePendingCleanup(sessionKey) {
327876
- try {
327877
- await unlink(markerPath(sessionKey));
327878
- } catch {
327879
- }
327880
- }
327881
- function restoreDeviceSync(marker, onLog) {
327882
- const run2 = (args) => {
327883
- try {
327884
- execFileSync4("adb", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
327885
- } catch {
327886
- }
327887
- };
327888
- if (marker.platform === "ANDROID") {
327889
- if (marker.proxySet) {
327890
- const restoreValue = marker.originalProxy && marker.originalProxy.length > 0 ? marker.originalProxy : ":0";
327891
- run2(["-s", marker.deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue]);
327892
- }
327893
- if (marker.caInstalledOnDevice) {
327894
- run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
327895
- }
327896
- }
327897
- onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
327898
- }
327899
- async function reconcilePendingCleanups(onLog) {
327900
- let files;
327901
- try {
327902
- files = await readdir(PENDING_CLEANUP_DIR);
327903
- } catch {
327904
- return;
327905
- }
327906
- for (const file of files) {
327907
- if (!file.endsWith(".json")) continue;
327908
- const full = join3(PENDING_CLEANUP_DIR, file);
327909
- try {
327910
- const raw = await readFile(full, "utf-8");
327911
- const marker = JSON.parse(raw);
327912
- if (marker && typeof marker.deviceId === "string" && marker.platform) {
327913
- onLog?.(`[mobile-net] reconciling orphaned capture cleanup for ${marker.deviceId}.`);
327914
- restoreDeviceSync(marker, onLog);
327915
- }
327916
- } catch {
327917
- }
327918
- try {
327919
- await unlink(full);
327920
- } catch {
327921
- }
327922
- }
327923
- }
327924
- function ensureExitHandlers() {
327925
- if (exitHandlersInstalled) return;
327926
- exitHandlersInstalled = true;
327927
- const drain = () => {
327928
- for (const marker of activeCleanups.values()) {
327929
- restoreDeviceSync(marker);
327930
- }
327931
- };
327932
- process.on("beforeExit", drain);
327933
- process.on("exit", drain);
327934
- for (const signal of ["SIGTERM", "SIGINT"]) {
327935
- process.on(signal, () => {
327936
- drain();
327937
- process.removeAllListeners(signal);
327938
- process.kill(process.pid, signal);
327939
- });
327940
- }
327941
- }
327942
- async function startMobileNetworkCapture(options) {
327943
- const { platform: platform3, deviceId, isPhysical = false, preferredPort = 0, onLog } = options;
327944
- await reconcilePendingCleanups(onLog);
327945
- ensureExitHandlers();
327946
- const sessionKey = randomUUID();
327947
- const originalProxy = platform3 === "ANDROID" ? await androidReadProxy(deviceId) : null;
327948
- let tmpDir = null;
327949
- let caCertPath = null;
327950
- let caKeyPem = null;
327951
- let caCertPem = null;
327952
- try {
327953
- tmpDir = await mkdtemp(join3(tmpdir(), "validateqa-mobile-ca-"));
327954
- const ca = await generateCaCert(tmpDir, onLog);
327955
- if (ca) {
327956
- caCertPath = ca.caCertPath;
327957
- caKeyPem = ca.caKeyPem;
327958
- caCertPem = ca.caCertPem;
327959
- }
327960
- } catch (err) {
327961
- onLog?.(`[mobile-net] CA setup degraded: ${errMsg(err)}`);
327962
- }
327963
- if (!caKeyPem || !caCertPem) {
327964
- try {
327965
- const fallback = await generateCACertificate();
327966
- caKeyPem = fallback.key;
327967
- caCertPem = fallback.cert;
327968
- } catch (err) {
327969
- onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg(err)}`);
327970
- if (tmpDir) {
327971
- await rm(tmpDir, { recursive: true, force: true }).catch(() => void 0);
327972
- }
327973
- throw err instanceof Error ? err : new Error(String(err));
327974
- }
327975
- }
327976
- const proxy = new MitmProxy(caKeyPem, caCertPem, isPhysical, onLog);
327977
- const proxyPort = await proxy.listen(preferredPort);
327978
- const proxyHost = resolveProxyHostForDevice(platform3, isPhysical);
327979
- const hostPort = `${proxyHost}:${proxyPort}`;
327980
- onLog?.(
327981
- `[mobile-net] MITM proxy listening; device target ${hostPort} (${platform3}${isPhysical ? " physical" : ""})`
327982
- );
327983
- let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
327984
- let proxySet = false;
327985
- let caInstalledOnDevice = false;
327986
- try {
327987
- if (platform3 === "ANDROID") {
327988
- proxySet = await androidSetProxy(deviceId, hostPort, onLog);
327989
- if (!proxySet) {
327990
- wiringReason = "adb proxy configuration failed; no device traffic will be routed through the proxy.";
327991
- } else if (caCertPath) {
327992
- const { note } = await androidInstallCa(deviceId, caCertPath);
327993
- caInstalledOnDevice = true;
327994
- wiringReason = note;
327995
- }
327996
- } else {
327997
- if (!isPhysical && caCertPath) {
327998
- const { trusted, note } = await iosSimTrustCa(deviceId, caCertPath);
327999
- caInstalledOnDevice = trusted;
328000
- wiringReason = note;
328001
- } else if (isPhysical) {
328002
- wiringReason = "Physical iOS device: set the Wi-Fi HTTP proxy + trust the CA in Settings \u2192 General \u2192 About \u2192 Certificate Trust Settings manually; capture is limited to whatever routes through.";
328003
- }
328004
- }
328005
- } catch (err) {
328006
- wiringReason = `device wiring degraded: ${errMsg(err)}`;
328007
- onLog?.(`[mobile-net] ${wiringReason}`);
328008
- }
328009
- if (proxySet || caInstalledOnDevice) {
328010
- const marker = {
328011
- platform: platform3,
328012
- deviceId,
328013
- isPhysical,
328014
- proxySet,
328015
- originalProxy,
328016
- caInstalledOnDevice
328017
- };
328018
- activeCleanups.set(sessionKey, marker);
328019
- await writePendingCleanup(sessionKey, marker);
328020
- }
328021
- const capturedTmpDir = tmpDir;
328022
- const capturedCaCertPath = caCertPath;
328023
- let stopped = false;
328024
- let finalCalls = [];
328025
- const getCapabilities = () => {
328026
- if (proxy.isIntercepting) return { intercepting: true };
328027
- const reason = proxy.interceptionReason ?? wiringReason;
328028
- return reason ? { intercepting: false, reason } : { intercepting: false };
328029
- };
328030
- const stop = async () => {
328031
- if (stopped) return finalCalls;
328032
- stopped = true;
328033
- if (proxySet && platform3 === "ANDROID") {
328034
- await androidClearProxy(deviceId, originalProxy, onLog);
328035
- }
328036
- if (caInstalledOnDevice && capturedCaCertPath) {
328037
- if (platform3 === "ANDROID") {
328038
- await androidRemoveCa(deviceId, onLog);
328039
- } else if (!isPhysical) {
328040
- await iosSimRemoveCa(deviceId, onLog);
328041
- }
328042
- }
328043
- activeCleanups.delete(sessionKey);
328044
- await removePendingCleanup(sessionKey);
328045
- finalCalls = proxy.finalize();
328046
- await proxy.close();
328047
- if (capturedTmpDir) {
328048
- try {
328049
- await rm(capturedTmpDir, { recursive: true, force: true });
328050
- } catch (err) {
328051
- onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg(err)}`);
328052
- }
328053
- }
328054
- const apiCount = finalCalls.filter((c) => c.isApi).length;
328055
- onLog?.(
328056
- `[mobile-net] stopped: ${finalCalls.length} calls captured (${apiCount} API; HTTPS interception ${proxy.isIntercepting ? "ACTIVE" : "inactive"}).`
328057
- );
328058
- return finalCalls;
328059
- };
328060
- return {
328061
- proxyPort,
328062
- proxyHost,
328063
- caCertPath: capturedCaCertPath,
328064
- getCapabilities,
328065
- stop
328066
- };
328067
- }
328068
-
328069
329473
  // src/services/doctor.ts
328070
329474
  import { execSync } from "child_process";
328071
329475
  import { existsSync as existsSync2 } from "fs";
@@ -328283,7 +329687,20 @@ function printDoctorReport(report) {
328283
329687
  init_dist();
328284
329688
  var import_runner_core11 = __toESM(require_dist2());
328285
329689
  function fingerprintTest(test) {
328286
- return createHash("sha256").update(test.name).update("\n--suite--\n").update(test.suiteName ?? "").update("\n--type--\n").update(test.testType ?? "").update("\n--code--\n").update(test.playwrightCode).digest("hex").slice(0, 24);
329690
+ const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
329691
+ return createHash("sha256").update(test.name).update("\n--suite--\n").update(test.suiteName ?? "").update("\n--type--\n").update(test.testType ?? "").update("\n--framework--\n").update(test.framework ?? "").update("\n--code--\n").update(code).digest("hex").slice(0, 24);
329692
+ }
329693
+ function mobileDiscoveryNetworkContext(result, appId) {
329694
+ const page = result?.collectedPages?.find((p) => p.screenId || p.route);
329695
+ const screenId = page?.screenId ?? page?.route;
329696
+ return screenId ? `mobile:${screenId}` : `mobile:${appId}`;
329697
+ }
329698
+ function attachMobileDiscoveryNetworkContext(calls, result, appId) {
329699
+ const pageUrl = mobileDiscoveryNetworkContext(result, appId);
329700
+ return calls.map((call) => ({
329701
+ ...call,
329702
+ pageUrl: call.pageUrl ?? pageUrl
329703
+ }));
328287
329704
  }
328288
329705
  var SECRET_VAR_PATTERN = /password|secret|token|api[_]?key|pass\b|credential|bearer|pat\b/i;
328289
329706
  var IS_CLOUD = process.env.RUNNER_CLOUD === "true";
@@ -329243,6 +330660,7 @@ ${finalVerifyResult.logs ?? ""}`;
329243
330660
  tags: run2.tags ?? [],
329244
330661
  surfaceIntelligence: run2.surfaceIntelligence,
329245
330662
  healingContext: run2.healingContext ?? null,
330663
+ selectorRegistryContext: run2.selectorRegistryContext ?? null,
329246
330664
  lastFailureError: run2.lastFailureError,
329247
330665
  testVariables: run2.testVariables,
329248
330666
  onAICall: healAudit,
@@ -329477,6 +330895,7 @@ ${finalVerifyResult.logs ?? ""}`;
329477
330895
  tags: failedTest.tags ?? [],
329478
330896
  surfaceIntelligence: failedTest.healingContext ? null : run2.surfaceIntelligence,
329479
330897
  healingContext: failedTest.healingContext ?? null,
330898
+ selectorRegistryContext: failedTest.selectorRegistryContext ?? null,
329480
330899
  lastFailureError: failedTest.error,
329481
330900
  testVariables: run2.testVariables,
329482
330901
  onAICall: healAudit,
@@ -329895,6 +331314,14 @@ ${finalVerifyResult.logs ?? ""}`;
329895
331314
  throw new Error("CONFIG_ISSUE: the shared mobile device is busy with an active recording session; mobile discovery will retry once it ends.");
329896
331315
  }
329897
331316
  const mobileOnLog = (line) => onLog("progress", line);
331317
+ liveBrowserHandle.patch({
331318
+ mode: ctx.mode === "survey" ? "survey" : "discovery",
331319
+ testName: ctx.featureName ?? "Mobile discovery",
331320
+ note: `${platform3} mobile discovery`,
331321
+ surface: "mobile",
331322
+ mobilePlatform: platform3
331323
+ });
331324
+ requestLiveBrowserHeartbeat(true);
329898
331325
  mobileOnLog(`Mobile discovery (${ctx.mode}) \u2014 ${platform3} / ${mobileTarget.appId}`);
329899
331326
  setExecutorBusy(true);
329900
331327
  let session = null;
@@ -329907,6 +331334,24 @@ ${finalVerifyResult.logs ?? ""}`;
329907
331334
  recordedDeviceId: mobileTarget.recordedDeviceId,
329908
331335
  launchMode: mobileTarget.launchMode,
329909
331336
  deeplink: mobileTarget.deeplink
331337
+ }, {
331338
+ onDeviceSelected: async (device) => {
331339
+ try {
331340
+ capture = await startMobileNetworkCapture({
331341
+ platform: platform3,
331342
+ deviceId: device.deviceId,
331343
+ appId: mobileTarget.appId,
331344
+ isPhysical: device.isPhysical,
331345
+ onLog: mobileOnLog
331346
+ });
331347
+ const caps = capture.getCapabilities();
331348
+ if (!caps.intercepting) {
331349
+ mobileOnLog(`[mobile-net] HTTPS interception unavailable: ${caps.reason ?? "unknown reason"} (continuing \u2014 plaintext/metadata only)`);
331350
+ }
331351
+ } catch (captureErr) {
331352
+ mobileOnLog(`[mobile-net] capture unavailable: ${captureErr instanceof Error ? captureErr.message : String(captureErr)} (continuing without network log)`);
331353
+ }
331354
+ }
329910
331355
  });
329911
331356
  mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
329912
331357
  attachMirrorSession(session.sessionId, platform3);
@@ -329915,23 +331360,21 @@ ${finalVerifyResult.logs ?? ""}`;
329915
331360
  platform: platform3,
329916
331361
  appId: session.appId
329917
331362
  });
329918
- capture = await startMobileNetworkCapture({
329919
- platform: platform3,
329920
- deviceId: session.deviceId,
329921
- appId: session.appId,
329922
- isPhysical: session.isPhysical,
329923
- onLog: mobileOnLog
329924
- });
329925
- const caps = capture.getCapabilities();
329926
- if (!caps.intercepting) {
329927
- mobileOnLog(`[mobile-net] HTTPS interception unavailable: ${caps.reason ?? "unknown reason"} (continuing \u2014 plaintext/metadata only)`);
329928
- }
329929
331363
  discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
329930
331364
  onLog: mobileOnLog,
329931
331365
  onAICall: discoveryAudit,
329932
331366
  // Stream the live device mirror through the existing page-screenshot
329933
- // relay (mobile has no route, so key it as 'mobile'). Fire-and-forget.
331367
+ // relay (mobile has no route, so key it as 'mobile') and the
331368
+ // heartbeat live-session feed. The heartbeat frame gives the web
331369
+ // UI a reliable mobile fallback when the direct bridge SSE is
331370
+ // still opening or blocked by the user's network/tunnel.
329934
331371
  onScreenshot: (base64) => {
331372
+ liveBrowserHandle.updateSnapshot({
331373
+ currentUrl: mobileTarget.appId,
331374
+ thumbnailJpegBase64: base64,
331375
+ thumbnailCapturedAt: Date.now()
331376
+ });
331377
+ requestLiveBrowserHeartbeat();
329935
331378
  void onScreenshot("mobile", base64);
329936
331379
  },
329937
331380
  onExploreComplete,
@@ -329943,7 +331386,7 @@ ${finalVerifyResult.logs ?? ""}`;
329943
331386
  try {
329944
331387
  const apiCalls = await capture.stop();
329945
331388
  if (discoveryResult) {
329946
- discoveryResult.apiCalls = apiCalls.length ? apiCalls : void 0;
331389
+ discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
329947
331390
  }
329948
331391
  } catch (capErr) {
329949
331392
  mobileOnLog(`[mobile-net] capture stop failed: ${capErr instanceof Error ? capErr.message : String(capErr)}`);
@@ -330260,6 +331703,7 @@ ${finalVerifyResult.logs ?? ""}`;
330260
331703
  status,
330261
331704
  stepResults: result2.stepResults,
330262
331705
  screenshots: result2.screenshots,
331706
+ apiCalls: result2.apiCalls,
330263
331707
  logs: result2.logs,
330264
331708
  error: reportedError,
330265
331709
  duration: duration2