@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.js CHANGED
@@ -615,12 +615,19 @@ var require_ai_provider = __commonJS({
615
615
  function getMobileAIClient() {
616
616
  if ((process.env.SERVER_URL ?? "").trim().length > 0)
617
617
  return getServerProxiedAIClient();
618
- return getAIClient();
618
+ if (isMobileAiUnitTestMode())
619
+ return getAIClient();
620
+ throw new Error("Mobile AI requires the platform server URL (SERVER_URL). Customer runners do not provision provider API keys \u2014 the platform proxies every mobile AI completion through /api/runner/ai/execute, which lives on the shared server with the keys. If you reached this from `validate-runner start`, the credentials file is missing `serverUrl` or the install is broken; re-run `npx @validate.qa/runner login`. If you are calling the runner-core library directly, pass `options.aiClient` in tests or set VALIDATEQA_TEST_MODE=1 to opt into the local-provider fallback.");
619
621
  }
620
622
  function getMobileClientForModel(modelId) {
621
623
  if ((process.env.SERVER_URL ?? "").trim().length > 0)
622
624
  return getServerProxiedClientForModel(modelId);
623
- return getClientForModel(modelId);
625
+ if (isMobileAiUnitTestMode())
626
+ return getClientForModel(modelId);
627
+ throw new Error(`Mobile AI for model "${modelId}" requires the platform server URL (SERVER_URL). Customer runners do not provision provider API keys \u2014 the platform proxies every mobile AI completion through /api/runner/ai/execute. See getMobileAIClient() for remediation steps.`);
628
+ }
629
+ function isMobileAiUnitTestMode() {
630
+ return (process.env.VALIDATEQA_TEST_MODE ?? "").trim() === "1" || (process.env.NODE_ENV ?? "").trim() === "test";
624
631
  }
625
632
  }
626
633
  });
@@ -4432,6 +4439,15 @@ var require_mobile_explorer = __commonJS({
4432
4439
  var MAX_RECENT_EXCHANGES = 24;
4433
4440
  var OBSERVATION_MAX_ELEMENTS = 50;
4434
4441
  var NO_SNAPSHOT_NUDGE_THRESHOLD = 4;
4442
+ var ANDROID_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
4443
+ "com.android.permissioncontroller",
4444
+ "com.google.android.permissioncontroller",
4445
+ "com.android.packageinstaller",
4446
+ "com.google.android.packageinstaller"
4447
+ ]);
4448
+ var IOS_TRANSIENT_SYSTEM_APP_IDS = /* @__PURE__ */ new Set([
4449
+ "com.apple.springboard"
4450
+ ]);
4435
4451
  var MOBILE_SNAPSHOT_TOOL = {
4436
4452
  type: "function",
4437
4453
  function: {
@@ -4654,6 +4670,40 @@ var require_mobile_explorer = __commonJS({
4654
4670
  return "timeout";
4655
4671
  return "ai_error";
4656
4672
  }
4673
+ function ownerFromScreenSignal(signal, targetAppId) {
4674
+ const bundleId = signal.bundleId?.trim();
4675
+ if (bundleId)
4676
+ return bundleId;
4677
+ const activity = signal.activity?.trim();
4678
+ if (activity && targetAppId && (activity === targetAppId || activity.startsWith(`${targetAppId}.`))) {
4679
+ return targetAppId;
4680
+ }
4681
+ return null;
4682
+ }
4683
+ function isTransientSystemOwner(owner, platform3) {
4684
+ return platform3 === "ANDROID" ? ANDROID_TRANSIENT_SYSTEM_APP_IDS.has(owner) : IOS_TRANSIENT_SYSTEM_APP_IDS.has(owner);
4685
+ }
4686
+ function classifyMobileScreenBoundary(signal, targetAppId, platform3) {
4687
+ if (!targetAppId)
4688
+ return { kind: "target" };
4689
+ const owner = ownerFromScreenSignal(signal, targetAppId);
4690
+ if (!owner)
4691
+ return { kind: "unknown" };
4692
+ if (owner === targetAppId)
4693
+ return { kind: "target" };
4694
+ if (isTransientSystemOwner(owner, platform3)) {
4695
+ return { kind: "transient_external", owner, targetAppId };
4696
+ }
4697
+ return { kind: "external", owner, targetAppId };
4698
+ }
4699
+ function boundaryNote(boundary) {
4700
+ return [
4701
+ "",
4702
+ "## App Boundary",
4703
+ `Foreground app is ${boundary.owner}, outside target app ${boundary.targetAppId}.`,
4704
+ boundary.kind === "transient_external" ? "This looks like a system overlay. Handle or dismiss it if needed, but do not capture it as an app screen." : "The runner excluded this external app screen from the survey evidence."
4705
+ ].join("\n");
4706
+ }
4657
4707
  function resolveSnapshot(xml, windowSize, driverSignal, platform3) {
4658
4708
  const parsed = (0, mobile_source_parser_js_1.parseMobileSource)(xml, platform3);
4659
4709
  const signal = {
@@ -4734,8 +4784,9 @@ var require_mobile_explorer = __commonJS({
4734
4784
  }
4735
4785
  return sections.join("\n");
4736
4786
  }
4737
- function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief) {
4787
+ function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId) {
4738
4788
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
4789
+ const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
4739
4790
  const surveyBody = `You are a mobile QA exploration agent driving a real ${platformLabel} app on a connected device. Your goal: MAP every reachable screen so downstream agents know what to test. You are NOT testing \u2014 you are building a complete screen map.
4740
4791
 
4741
4792
  ## How the loop works
@@ -4753,7 +4804,7 @@ var require_mobile_explorer = __commonJS({
4753
4804
 
4754
4805
  ## Boundaries (survey = map, do not mutate)
4755
4806
  - Do NOT submit forms, save, delete, purchase, or send anything. You MAY open modals/menus to catalog them, then dismiss.
4756
- - Stay inside this app. Do not leave to the system home screen or other apps (mobile_home backgrounds \u2014 avoid it).
4807
+ - Stay inside this app.${targetLine} Do not leave to the system home screen or other apps (mobile_home backgrounds \u2014 avoid it). If a tap opens an app store, settings, browser, or another app, do not explore it; return with mobile_back or mobile_relaunch.
4757
4808
  - If you hit a login wall and have no credentials, capture the auth screen and explore what is reachable.
4758
4809
 
4759
4810
  Max iterations: ${maxIterations}. You will be warned near the end.`;
@@ -4771,6 +4822,7 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
4771
4822
  4. Use mobile_relaunch for a clean, hermetic restart before testing a distinct flow that needs fresh state.
4772
4823
  5. mobile_swipe to scroll; mobile_back to return.
4773
4824
  6. Do NOT trigger irreversible side effects (real payments, sending messages/invites to others). Note them instead.
4825
+ 7. Stay inside this app.${targetLine} If a tap opens an app store, settings, browser, or another app, do not explore it; return with mobile_back or mobile_relaunch.
4774
4826
 
4775
4827
  Max iterations: ${maxIterations}. You will be warned near the end.`;
4776
4828
  const briefBlock = appBrief ? `
@@ -4796,6 +4848,7 @@ Use this to prioritize which screens and flows to cover.` : "";
4796
4848
  const mode = ctx.mode === "deep" ? "deep" : "survey";
4797
4849
  const platform3 = platformForMedium(ctx.medium);
4798
4850
  const enableRecordJourney = config?.enableRecordJourney ?? mode !== "survey";
4851
+ const targetAppId = ctx.target?.appId?.trim() || void 0;
4799
4852
  const defaultBudget = mode === "deep" ? MAX_MOBILE_ITERATIONS_DEEP : MAX_MOBILE_ITERATIONS_SURVEY;
4800
4853
  const maxIterations = config?.maxIterations && config.maxIterations > 0 ? Math.floor(config.maxIterations) : defaultBudget;
4801
4854
  const modelOverride = ctx.exploreModel ?? null;
@@ -4846,6 +4899,81 @@ Use this to prioritize which screens and flows to cover.` : "";
4846
4899
  return null;
4847
4900
  return resolveSnapshot(xml, windowSize, driverSignal, platform3);
4848
4901
  };
4902
+ const currentBoundary = () => {
4903
+ const current = getLatest();
4904
+ return current ? classifyMobileScreenBoundary(current.signal, targetAppId, platform3) : { kind: "unknown" };
4905
+ };
4906
+ const absorbResolvedObservation = async (resolved, screenshot, iterationForObservation, source) => {
4907
+ const boundary = classifyMobileScreenBoundary(resolved.signal, targetAppId, platform3);
4908
+ if (boundary.kind === "target" || boundary.kind === "unknown") {
4909
+ const screenId = ingestSnapshot(resolved, iterationForObservation);
4910
+ captureScreenshot(screenshot, screenId);
4911
+ return { observation: resolved.observation, screenId };
4912
+ }
4913
+ latest = resolved;
4914
+ lastSnapshotIteration = iterationForObservation;
4915
+ if (boundary.kind === "transient_external") {
4916
+ log2(` System overlay detected from ${source}: ${boundary.owner}; not adding it to target-app survey evidence.`);
4917
+ return {
4918
+ observation: `${resolved.observation}${boundaryNote(boundary)}`,
4919
+ screenId: null,
4920
+ transientExternal: true,
4921
+ externalOwner: boundary.owner
4922
+ };
4923
+ }
4924
+ log2(` External app detected from ${source}: ${boundary.owner}; excluding it from survey evidence and relaunching ${boundary.targetAppId}.`);
4925
+ try {
4926
+ await driver.relaunch();
4927
+ const snap = await driver.snapshot();
4928
+ const recovered = buildResolved(snap.xml, snap.windowSize, snap.screenSignal);
4929
+ if (!recovered) {
4930
+ return {
4931
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunched target app, but no UI tree was returned)`,
4932
+ screenId: null,
4933
+ externalBlocked: true,
4934
+ externalOwner: boundary.owner
4935
+ };
4936
+ }
4937
+ const recoveredBoundary = classifyMobileScreenBoundary(recovered.signal, targetAppId, platform3);
4938
+ latest = recovered;
4939
+ lastSnapshotIteration = iterationForObservation;
4940
+ if (recoveredBoundary.kind === "target" || recoveredBoundary.kind === "unknown") {
4941
+ const screenId = ingestSnapshot(recovered, iterationForObservation);
4942
+ captureScreenshot(snap.screenshot, screenId);
4943
+ return {
4944
+ observation: recovered.observation,
4945
+ screenId,
4946
+ externalBlocked: true,
4947
+ externalOwner: boundary.owner
4948
+ };
4949
+ }
4950
+ if (recoveredBoundary.kind === "transient_external") {
4951
+ log2(` Relaunch surfaced system overlay ${recoveredBoundary.owner}; not adding it to target-app survey evidence.`);
4952
+ return {
4953
+ observation: `${recovered.observation}${boundaryNote(recoveredBoundary)}`,
4954
+ screenId: null,
4955
+ externalBlocked: true,
4956
+ transientExternal: true,
4957
+ externalOwner: boundary.owner
4958
+ };
4959
+ }
4960
+ log2(` Relaunch still outside target app (${recoveredBoundary.owner}); no screen recorded.`);
4961
+ return {
4962
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; relaunch still showed ${recoveredBoundary.owner}, so no screen was recorded)`,
4963
+ screenId: null,
4964
+ externalBlocked: true,
4965
+ externalOwner: boundary.owner
4966
+ };
4967
+ } catch (err) {
4968
+ const message = err instanceof Error ? err.message : String(err);
4969
+ return {
4970
+ observation: `(left target app ${boundary.targetAppId} for ${boundary.owner}; failed to relaunch target app: ${message})`,
4971
+ screenId: null,
4972
+ externalBlocked: true,
4973
+ externalOwner: boundary.owner
4974
+ };
4975
+ }
4976
+ };
4849
4977
  const resolveRef = (ref) => {
4850
4978
  if (typeof ref !== "string" || !latest)
4851
4979
  return null;
@@ -4854,7 +4982,7 @@ Use this to prioritize which screens and flows to cover.` : "";
4854
4982
  return null;
4855
4983
  return { element, bounds: element.bounds };
4856
4984
  };
4857
- const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief);
4985
+ const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId);
4858
4986
  const kickoff = buildKickoffMessage(mode);
4859
4987
  const recentExchanges = [];
4860
4988
  const transientDirectives = [];
@@ -4884,9 +5012,13 @@ Use this to prioritize which screens and flows to cover.` : "";
4884
5012
  const seed = await driver.snapshot();
4885
5013
  const resolved = buildResolved(seed.xml, seed.windowSize, seed.screenSignal);
4886
5014
  if (resolved) {
4887
- const screenId = ingestSnapshot(resolved, 0);
4888
- captureScreenshot(seed.screenshot, screenId);
4889
- log2(`Seed snapshot: screen ${screenId} (${resolved.screen.elements.length} elements)`);
5015
+ const absorbed = await absorbResolvedObservation(resolved, seed.screenshot, 0, "seed snapshot");
5016
+ if (absorbed.screenId) {
5017
+ const elementCount = state2.getScreen(absorbed.screenId)?.elements.length ?? resolved.screen.elements.length;
5018
+ log2(`Seed snapshot: screen ${absorbed.screenId} (${elementCount} elements)`);
5019
+ } else {
5020
+ log2("Seed snapshot was outside the target app and was not recorded.");
5021
+ }
4890
5022
  } else {
4891
5023
  log2("Seed snapshot returned no XML \u2014 the agent must call mobile_snapshot.");
4892
5024
  }
@@ -5027,9 +5159,9 @@ Exploration complete: ${summary}`);
5027
5159
  mode,
5028
5160
  latest: getLatest,
5029
5161
  buildResolved,
5030
- ingestSnapshot,
5162
+ absorbResolvedObservation,
5031
5163
  resolveRef,
5032
- captureScreenshot,
5164
+ currentBoundary,
5033
5165
  collectedTransitions,
5034
5166
  counters,
5035
5167
  journeys,
@@ -5064,9 +5196,21 @@ Exploration complete: ${summary}`);
5064
5196
  counters.pagesDiscovered = collectedPages.length;
5065
5197
  }
5066
5198
  log2(`Mobile explore phase done \u2014 ${collectedPages.length} screens, ${collectedTransitions.length} transitions, ${journeys.length} journeys, ${counters.elementsFound} elements observed.`);
5199
+ const recordedJourneys = journeys.map((journey) => {
5200
+ const routes = journey.screenIds && journey.screenIds.length > 0 ? journey.screenIds : [journey.screenId];
5201
+ return {
5202
+ name: journey.name,
5203
+ route: journey.screenId,
5204
+ routes,
5205
+ steps: journey.steps,
5206
+ expectedOutcome: journey.expectedOutcome,
5207
+ iteration: journey.iteration
5208
+ };
5209
+ });
5067
5210
  const result = {
5068
5211
  collectedPages,
5069
5212
  collectedTransitions,
5213
+ recordedJourneys,
5070
5214
  screenshots,
5071
5215
  exploreStats: {
5072
5216
  pagesDiscovered: counters.pagesDiscovered,
@@ -5087,15 +5231,13 @@ Exploration complete: ${summary}`);
5087
5231
  return result;
5088
5232
  }
5089
5233
  async function dispatchMobileTool(args) {
5090
- const { toolName, toolArgs, iteration, driver, state: state2, enableRecordJourney, latest, buildResolved, ingestSnapshot, resolveRef, captureScreenshot, collectedTransitions, counters, journeys, log: log2 } = args;
5091
- const absorbActionResult = (xml, screenshot) => {
5092
- const resolved = buildResolved(xml, latest()?.screen.windowSize ?? null, void 0);
5234
+ const { toolName, toolArgs, iteration, driver, state: state2, enableRecordJourney, latest, buildResolved, absorbResolvedObservation, resolveRef, currentBoundary, collectedTransitions, counters, journeys, log: log2 } = args;
5235
+ const absorbActionResult = (actionResult, source) => {
5236
+ const resolved = buildResolved(actionResult.source, latest()?.screen.windowSize ?? null, actionResult.screenSignal);
5093
5237
  if (!resolved) {
5094
- return { observation: "(no UI tree returned)", screenId: null };
5238
+ return Promise.resolve({ observation: "(no UI tree returned)", screenId: null });
5095
5239
  }
5096
- const screenId = ingestSnapshot(resolved, iteration);
5097
- captureScreenshot(screenshot, screenId);
5098
- return { observation: resolved.observation, screenId };
5240
+ return absorbResolvedObservation(resolved, actionResult.screenshot, iteration, source);
5099
5241
  };
5100
5242
  switch (toolName) {
5101
5243
  // ----- Observation -----
@@ -5105,12 +5247,16 @@ Exploration complete: ${summary}`);
5105
5247
  if (!resolved) {
5106
5248
  return { ok: false, error: "snapshot returned no UI tree" };
5107
5249
  }
5108
- const screenId = ingestSnapshot(resolved, iteration);
5109
- captureScreenshot(snap.screenshot, screenId);
5110
- log2(` Snapshot: screen ${screenId} (${resolved.screen.elements.length} elements)`);
5250
+ const absorbed = await absorbResolvedObservation(resolved, snap.screenshot, iteration, "mobile_snapshot");
5251
+ if (absorbed.screenId) {
5252
+ const elementCount = state2.getScreen(absorbed.screenId)?.elements.length ?? resolved.screen.elements.length;
5253
+ log2(` Snapshot: screen ${absorbed.screenId} (${elementCount} elements)`);
5254
+ }
5111
5255
  return {
5112
- screenId,
5113
- observation: resolved.observation,
5256
+ screenId: absorbed.screenId,
5257
+ observation: absorbed.observation,
5258
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5259
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
5114
5260
  progress: `${state2.getScreenCount()} screens, ${collectedTransitions.length} transitions, ${journeys.length} journeys`
5115
5261
  };
5116
5262
  }
@@ -5121,14 +5267,21 @@ Exploration complete: ${summary}`);
5121
5267
  return { ok: false, error: `Unknown element ref "${String(toolArgs.ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.` };
5122
5268
  }
5123
5269
  const fromScreenId = state2.getCurrentScreenId();
5270
+ const boundaryBeforeAction = currentBoundary();
5271
+ const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
5124
5272
  const actionResult = await driver.tap(resolved.bounds);
5125
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5126
- autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, String(toolArgs.ref), "tap");
5273
+ const absorbed = await absorbActionResult(actionResult, "mobile_tap");
5274
+ const { observation, screenId } = absorbed;
5275
+ if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
5276
+ autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, String(toolArgs.ref), "tap");
5277
+ }
5127
5278
  return {
5128
5279
  ok: actionResult.ok,
5129
5280
  ...actionResult.error ? { error: actionResult.error } : {},
5130
5281
  screenId,
5131
- observation
5282
+ observation,
5283
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5284
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5132
5285
  };
5133
5286
  }
5134
5287
  // ----- Type -----
@@ -5139,12 +5292,15 @@ Exploration complete: ${summary}`);
5139
5292
  }
5140
5293
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
5141
5294
  const actionResult = await driver.type(value, resolved.bounds);
5142
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5295
+ const absorbed = await absorbActionResult(actionResult, "mobile_type");
5296
+ const { observation, screenId } = absorbed;
5143
5297
  return {
5144
5298
  ok: actionResult.ok,
5145
5299
  ...actionResult.error ? { error: actionResult.error } : {},
5146
5300
  screenId,
5147
- observation
5301
+ observation,
5302
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5303
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5148
5304
  };
5149
5305
  }
5150
5306
  // ----- Clear -----
@@ -5154,12 +5310,15 @@ Exploration complete: ${summary}`);
5154
5310
  return { ok: false, error: `Unknown element ref "${String(toolArgs.ref)}". Call mobile_snapshot first.` };
5155
5311
  }
5156
5312
  const actionResult = await driver.clear(resolved.bounds);
5157
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5313
+ const absorbed = await absorbActionResult(actionResult, "mobile_clear");
5314
+ const { observation, screenId } = absorbed;
5158
5315
  return {
5159
5316
  ok: actionResult.ok,
5160
5317
  ...actionResult.error ? { error: actionResult.error } : {},
5161
5318
  screenId,
5162
- observation
5319
+ observation,
5320
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5321
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5163
5322
  };
5164
5323
  }
5165
5324
  // ----- Swipe -----
@@ -5170,36 +5329,49 @@ Exploration complete: ${summary}`);
5170
5329
  }
5171
5330
  const distance = typeof toolArgs.distance === "number" ? toolArgs.distance : void 0;
5172
5331
  const actionResult = await driver.swipe(direction, distance);
5173
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5332
+ const absorbed = await absorbActionResult(actionResult, "mobile_swipe");
5333
+ const { observation, screenId } = absorbed;
5174
5334
  return {
5175
5335
  ok: actionResult.ok,
5176
5336
  ...actionResult.error ? { error: actionResult.error } : {},
5177
5337
  screenId,
5178
- observation
5338
+ observation,
5339
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5340
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5179
5341
  };
5180
5342
  }
5181
5343
  // ----- Back -----
5182
5344
  case "mobile_back": {
5183
5345
  const fromScreenId = state2.getCurrentScreenId();
5346
+ const boundaryBeforeAction = currentBoundary();
5347
+ const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
5184
5348
  const actionResult = await driver.back();
5185
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5186
- autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "back");
5349
+ const absorbed = await absorbActionResult(actionResult, "mobile_back");
5350
+ const { observation, screenId } = absorbed;
5351
+ if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
5352
+ autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "back");
5353
+ }
5187
5354
  return {
5188
5355
  ok: actionResult.ok,
5189
5356
  ...actionResult.error ? { error: actionResult.error } : {},
5190
5357
  screenId,
5191
- observation
5358
+ observation,
5359
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5360
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5192
5361
  };
5193
5362
  }
5194
5363
  // ----- Home (backgrounds only) -----
5195
5364
  case "mobile_home": {
5196
5365
  const actionResult = await driver.home();
5197
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5366
+ const absorbed = await absorbActionResult(actionResult, "mobile_home");
5367
+ const { observation, screenId } = absorbed;
5198
5368
  return {
5199
5369
  ok: actionResult.ok,
5200
5370
  ...actionResult.error ? { error: actionResult.error } : {},
5201
5371
  screenId,
5202
5372
  observation,
5373
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5374
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
5203
5375
  note: "Home only backgrounds the app. Use mobile_relaunch for a clean restart."
5204
5376
  };
5205
5377
  }
@@ -5212,9 +5384,15 @@ Exploration complete: ${summary}`);
5212
5384
  if (!resolved) {
5213
5385
  return { ok: true, note: "relaunched; no UI tree returned \u2014 call mobile_snapshot." };
5214
5386
  }
5215
- const screenId = ingestSnapshot(resolved, iteration);
5216
- captureScreenshot(snap.screenshot, screenId);
5217
- return { ok: true, screenId, observation: resolved.observation, note: "App relaunched (clean state)." };
5387
+ const absorbed = await absorbResolvedObservation(resolved, snap.screenshot, iteration, "mobile_relaunch");
5388
+ return {
5389
+ ok: true,
5390
+ screenId: absorbed.screenId,
5391
+ observation: absorbed.observation,
5392
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5393
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {},
5394
+ note: "App relaunched (clean state)."
5395
+ };
5218
5396
  }
5219
5397
  // ----- Deep-link (guarded) -----
5220
5398
  case "mobile_open_deeplink": {
@@ -5223,18 +5401,32 @@ Exploration complete: ${summary}`);
5223
5401
  return { ok: false, error: `Refused to open deep link "${deeplink}" (empty or dangerous scheme).` };
5224
5402
  }
5225
5403
  const fromScreenId = state2.getCurrentScreenId();
5404
+ const boundaryBeforeAction = currentBoundary();
5405
+ const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
5226
5406
  const actionResult = await driver.openDeepLink(deeplink);
5227
- const { observation, screenId } = absorbActionResult(actionResult.source, actionResult.screenshot);
5228
- autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "deeplink");
5407
+ const absorbed = await absorbActionResult(actionResult, "mobile_open_deeplink");
5408
+ const { observation, screenId } = absorbed;
5409
+ if (!startedOutsideTarget && !absorbed.externalBlocked && !absorbed.transientExternal) {
5410
+ autoRecordTransition(state2, collectedTransitions, counters, fromScreenId, screenId, void 0, "deeplink");
5411
+ }
5229
5412
  return {
5230
5413
  ok: actionResult.ok,
5231
5414
  ...actionResult.error ? { error: actionResult.error } : {},
5232
5415
  screenId,
5233
- observation
5416
+ observation,
5417
+ ...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
5418
+ ...absorbed.transientExternal ? { transientExternalApp: true, externalApp: absorbed.externalOwner } : {}
5234
5419
  };
5235
5420
  }
5236
5421
  // ----- capture_screen (mark + annotate current screen) -----
5237
5422
  case "capture_screen": {
5423
+ const boundary = currentBoundary();
5424
+ if (boundary.kind === "external" || boundary.kind === "transient_external") {
5425
+ return {
5426
+ ok: false,
5427
+ 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.`
5428
+ };
5429
+ }
5238
5430
  const screenId = state2.getCurrentScreenId();
5239
5431
  if (!screenId) {
5240
5432
  return { ok: false, error: "No current screen \u2014 call mobile_snapshot first." };
@@ -5285,6 +5477,13 @@ Exploration complete: ${summary}`);
5285
5477
  if (!enableRecordJourney) {
5286
5478
  return { ok: false, error: "record_journey is not available in survey mode." };
5287
5479
  }
5480
+ const boundary = currentBoundary();
5481
+ if (boundary.kind === "external" || boundary.kind === "transient_external") {
5482
+ return {
5483
+ ok: false,
5484
+ error: `Current foreground app is ${boundary.owner}, outside target app ${boundary.targetAppId}. External app screens cannot be recorded as a target-app journey.`
5485
+ };
5486
+ }
5288
5487
  const name = typeof toolArgs.name === "string" ? toolArgs.name : "";
5289
5488
  const screenId = typeof toolArgs.screenId === "string" && toolArgs.screenId ? toolArgs.screenId : state2.getCurrentScreenId() ?? "";
5290
5489
  const steps = Array.isArray(toolArgs.steps) ? toolArgs.steps.filter((s) => typeof s === "string") : [];
@@ -7075,7 +7274,7 @@ Each entry is exactly one of:
7075
7274
  const existingNamesLower = new Set(existingTestNames.map((n) => n.toLowerCase()));
7076
7275
  const usedNames = /* @__PURE__ */ new Set();
7077
7276
  const validTypes = /* @__PURE__ */ new Set(["HAPPY_PATH", "EDGE_CASE", "USER_JOURNEY", "REGRESSION", "BUG_REPORT"]);
7078
- const validOutcomeTypes = /* @__PURE__ */ new Set(["url", "element-visible", "text-visible", "value-match", "element-hidden"]);
7277
+ const validOutcomeTypes = /* @__PURE__ */ new Set(["url", "screen-visible", "element-visible", "text-visible", "value-match", "element-hidden"]);
7079
7278
  const validVariants = /* @__PURE__ */ new Set(["happy", "validation", "bug"]);
7080
7279
  const validCriticalities = /* @__PURE__ */ new Set(["critical", "high", "medium", "low"]);
7081
7280
  const inferOutcomeTypeFromDescription = (description) => /\b(?:heading|button|link|menu\s*item|menuitem|tab|card|modal|dialog|sidebar(?:\s+item)?|nav\s+item|navigation\s+link|form\s+label|field\s+label|avatar|banner)\b/i.test(description) ? "element-visible" : "text-visible";
@@ -7549,8 +7748,8 @@ Each entry is exactly one of:
7549
7748
  const agentModel = options.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
7550
7749
  const plannerMaxTokens = getAIPlannerMaxTokens(agentModel);
7551
7750
  logs2.push(`AI planner [${context.mode}]: ${siteMap.pages.length} pages, ${evidence.recordedJourneys.length} journeys, budget ${context.testBudget}`);
7552
- const systemPrompt = context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports2.SCENARIO_SYSTEM_PROMPT;
7553
- const userPrompt = formatEvidenceForPlanner(compressed, evidence, context);
7751
+ const systemPrompt = options.systemPromptOverride ?? (context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports2.SCENARIO_SYSTEM_PROMPT);
7752
+ const userPrompt = options.userPromptOverride ?? formatEvidenceForPlanner(compressed, evidence, context);
7554
7753
  logs2.push(`AI planner: ~${Math.ceil(userPrompt.length / 3.5)} input tokens`);
7555
7754
  const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)());
7556
7755
  const messages = [
@@ -7766,6 +7965,14 @@ var require_mobile_discovery_prompts = __commonJS({
7766
7965
  exports2.runMobileAIPlanner = runMobileAIPlanner;
7767
7966
  var ai_provider_js_1 = require_ai_provider();
7768
7967
  var discover_ai_planner_js_1 = require_discover_ai_planner();
7968
+ function isTargetAppScreenId(screenId, appId) {
7969
+ if (!appId)
7970
+ return true;
7971
+ return screenId === appId || screenId.startsWith(`${appId}#`) || screenId.startsWith(`${appId}.`);
7972
+ }
7973
+ function targetAppScreens(graph, appId) {
7974
+ return [...graph.screens.values()].filter((state2) => isTargetAppScreenId(state2.screenId, appId));
7975
+ }
7769
7976
  exports2.MOBILE_SURVEY_SYSTEM_PROMPT = `You are a feature analyst for a native mobile application (iOS or Android). You receive evidence from an AI explorer that mapped the app's screens by tapping, typing, and swiping through the live UI. Your job is to organize screens into meaningful feature groups.
7770
7977
 
7771
7978
  ## Output Discipline
@@ -7995,7 +8202,8 @@ ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
7995
8202
  if (context.featureName) {
7996
8203
  sections.push(`## Feature: ${context.featureName}`);
7997
8204
  }
7998
- const orderedScreens = [...graph.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8205
+ const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8206
+ const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
7999
8207
  sections.push(`## Screens Discovered (${orderedScreens.length})`);
8000
8208
  for (const state2 of orderedScreens) {
8001
8209
  const name = screenDisplayName(state2);
@@ -8017,7 +8225,7 @@ ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
8017
8225
  for (const state2 of orderedScreens)
8018
8226
  nameById.set(state2.screenId, screenDisplayName(state2));
8019
8227
  const lines = ["## Observed Screen Transitions"];
8020
- for (const t of graph.transitions.slice(0, MAX_TRANSITIONS)) {
8228
+ for (const t of graph.transitions.filter((transition) => includedScreenIds.has(transition.fromScreenId) && includedScreenIds.has(transition.toScreenId)).slice(0, MAX_TRANSITIONS)) {
8021
8229
  const from = nameById.get(t.fromScreenId) ?? t.fromScreenId;
8022
8230
  const to = nameById.get(t.toScreenId) ?? t.toScreenId;
8023
8231
  const via = t.viaRef ? ` via ${t.viaRef}` : "";
@@ -8103,7 +8311,7 @@ ${constraints.join("\n")}`);
8103
8311
  const now = /* @__PURE__ */ new Date();
8104
8312
  const idByScreen = /* @__PURE__ */ new Map();
8105
8313
  let pageSeq = 0;
8106
- const orderedScreens = [...graph.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8314
+ const orderedScreens = targetAppScreens(graph, meta.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8107
8315
  for (const state2 of orderedScreens) {
8108
8316
  idByScreen.set(state2.screenId, `mpage-${pageSeq++}`);
8109
8317
  }
@@ -8162,6 +8370,135 @@ ${constraints.join("\n")}`);
8162
8370
  seedFixtures: []
8163
8371
  };
8164
8372
  }
8373
+ function buildMobileFeatureGroupsFromGraph(graph, appId) {
8374
+ const groups = /* @__PURE__ */ new Map();
8375
+ const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8376
+ const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
8377
+ for (const state2 of orderedScreens) {
8378
+ const groupName = state2.screenType === "auth" ? "Authentication" : screenDisplayName(state2);
8379
+ const group = groups.get(groupName) ?? {
8380
+ name: groupName,
8381
+ description: `Native mobile screens related to ${groupName}.`,
8382
+ routes: [],
8383
+ criticality: state2.screenType === "auth" ? "critical" : "medium",
8384
+ pages: [],
8385
+ flows: []
8386
+ };
8387
+ group.routes.push(state2.screenId);
8388
+ group.pages?.push({
8389
+ route: state2.screenId,
8390
+ type: state2.screenType ?? "UNKNOWN",
8391
+ summary: `${state2.elements.filter((el) => el.actionable).length} actionable element(s)`
8392
+ });
8393
+ groups.set(groupName, group);
8394
+ }
8395
+ const nameById = /* @__PURE__ */ new Map();
8396
+ for (const state2 of graph.screens.values()) {
8397
+ nameById.set(state2.screenId, screenDisplayName(state2));
8398
+ }
8399
+ for (const transition of graph.transitions) {
8400
+ if (!includedScreenIds.has(transition.fromScreenId) || !includedScreenIds.has(transition.toScreenId))
8401
+ continue;
8402
+ const from = graph.screens.get(transition.fromScreenId);
8403
+ const to = graph.screens.get(transition.toScreenId);
8404
+ if (!from || !to)
8405
+ continue;
8406
+ const groupName = from.screenType === "auth" ? "Authentication" : screenDisplayName(from);
8407
+ const group = groups.get(groupName);
8408
+ if (!group)
8409
+ continue;
8410
+ const via = transition.viaRef ? ` via ${transition.viaRef}` : "";
8411
+ group.flows?.push(`${nameById.get(transition.fromScreenId) ?? transition.fromScreenId} \u2192 ${nameById.get(transition.toScreenId) ?? transition.toScreenId} (${transition.action}${via})`);
8412
+ }
8413
+ return [...groups.values()];
8414
+ }
8415
+ function buildMobileFallbackScenarios(input) {
8416
+ if (input.mode === "survey")
8417
+ return [];
8418
+ 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);
8419
+ if (orderedScreens.length === 0)
8420
+ return [];
8421
+ const entryScreenId = orderedScreens[0].screenId;
8422
+ const usedNames = new Set((input.existingTestNames ?? []).map((name) => name.toLowerCase()));
8423
+ const scenarios = [];
8424
+ const budget = Math.max(1, input.testBudget || orderedScreens.length);
8425
+ for (const state2 of orderedScreens) {
8426
+ if (scenarios.length >= budget)
8427
+ break;
8428
+ const screenName = screenDisplayName(state2);
8429
+ const baseName = `${screenName} - Screen Reachability`;
8430
+ const name = usedNames.has(baseName.toLowerCase()) ? `${baseName} ${state2.firstSeenIteration + 1}` : baseName;
8431
+ if (usedNames.has(name.toLowerCase()))
8432
+ continue;
8433
+ usedNames.add(name.toLowerCase());
8434
+ 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);
8435
+ const steps = [
8436
+ { action: "Relaunch the app from a clean state", target: entryScreenId },
8437
+ {
8438
+ action: state2.screenId === entryScreenId ? `Verify the launch screen is ${screenName}` : `Navigate through the app until the ${screenName} screen is visible`,
8439
+ target: state2.screenId
8440
+ }
8441
+ ];
8442
+ if (actionableLabels.length > 0) {
8443
+ steps.push({
8444
+ action: `Verify expected native controls are available: ${actionableLabels.join(", ")}`,
8445
+ target: state2.screenId
8446
+ });
8447
+ }
8448
+ scenarios.push({
8449
+ name,
8450
+ type: "HAPPY_PATH",
8451
+ priority: state2.screenType === "auth" ? 1 : 3,
8452
+ variant: "happy",
8453
+ featureGroup: state2.screenType === "auth" ? "Authentication" : screenName,
8454
+ goal: `User can reach and inspect the ${screenName} screen`,
8455
+ steps,
8456
+ expectedOutcomes: [{
8457
+ type: "screen-visible",
8458
+ description: `${screenName} screen is visible`,
8459
+ reliability: "medium"
8460
+ }],
8461
+ entryRoute: entryScreenId,
8462
+ startFromLanding: true,
8463
+ targetPages: [state2.screenId],
8464
+ evidence: { observedLocators: [], observedTransitions: [] },
8465
+ prerequisites: []
8466
+ });
8467
+ }
8468
+ return scenarios;
8469
+ }
8470
+ function filterRecordedJourneysToSiteMap(journeys, siteMap) {
8471
+ if (!journeys?.length)
8472
+ return [];
8473
+ const validRoutes = new Set(siteMap.pages.map((page) => page.route));
8474
+ const filtered = [];
8475
+ for (const journey of journeys) {
8476
+ const routes = (journey.routes?.length ? journey.routes : [journey.route]).filter((route2) => validRoutes.has(route2));
8477
+ if (routes.length === 0)
8478
+ continue;
8479
+ filtered.push({
8480
+ ...journey,
8481
+ route: validRoutes.has(journey.route) ? journey.route : routes[0],
8482
+ routes
8483
+ });
8484
+ }
8485
+ return filtered;
8486
+ }
8487
+ function filterFeatureGroupsToSiteMap(groups, siteMap) {
8488
+ if (groups.length === 0)
8489
+ return [];
8490
+ const validRoutes = new Set(siteMap.pages.map((page) => page.route));
8491
+ return groups.flatMap((group) => {
8492
+ const routes = group.routes.filter((route2) => validRoutes.has(route2));
8493
+ if (routes.length === 0)
8494
+ return [];
8495
+ return [{
8496
+ ...group,
8497
+ routes,
8498
+ pages: group.pages?.filter((page) => validRoutes.has(page.route))
8499
+ }];
8500
+ });
8501
+ }
8165
8502
  async function runMobileAIPlanner(input, options = {}) {
8166
8503
  const planner = options.planner ?? discover_ai_planner_js_1.runAIPlanner;
8167
8504
  const shouldBuildMobileAIClient = (process.env.SERVER_URL ?? "").trim().length > 0 || !options.planner;
@@ -8170,11 +8507,12 @@ ${constraints.join("\n")}`);
8170
8507
  projectId: input.projectId,
8171
8508
  appId: input.appId
8172
8509
  });
8510
+ const recordedJourneys = filterRecordedJourneysToSiteMap(input.recordedJourneys, siteMap);
8173
8511
  const collectedPages = input.collectedPages ?? [];
8174
8512
  const collectedTransitions = input.collectedTransitions ?? [];
8175
- const visitedRoutes = [...input.graph.screens.values()].filter((s) => s.visited).map((s) => s.screenId);
8176
- return planner(siteMap, {
8177
- recordedJourneys: input.recordedJourneys ?? [],
8513
+ const visitedRoutes = targetAppScreens(input.graph, input.appId).filter((s) => s.visited).map((s) => s.screenId);
8514
+ const result = await planner(siteMap, {
8515
+ recordedJourneys,
8178
8516
  collectedPages,
8179
8517
  collectedTransitions,
8180
8518
  visitedRoutes
@@ -8192,8 +8530,52 @@ ${constraints.join("\n")}`);
8192
8530
  }, {
8193
8531
  modelOverride: options.modelOverride,
8194
8532
  onAICall: options.onAICall,
8195
- ...aiClient ? { aiClient } : {}
8533
+ ...aiClient ? { aiClient } : {},
8534
+ systemPromptOverride: input.mode === "survey" ? exports2.MOBILE_SURVEY_SYSTEM_PROMPT : exports2.MOBILE_SCENARIO_SYSTEM_PROMPT,
8535
+ userPromptOverride: formatMobileEvidenceForPlanner(input.graph, {
8536
+ appBrief: input.appBrief,
8537
+ appId: input.appId,
8538
+ platform: input.platform,
8539
+ featureName: input.featureName,
8540
+ existingTestNames: input.existingTestNames,
8541
+ existingFeatureNames: input.existingFeatureNames,
8542
+ recordedJourneys,
8543
+ credentials: input.credentials,
8544
+ testBudget: input.testBudget,
8545
+ incremental: input.incremental
8546
+ })
8196
8547
  });
8548
+ const plannerFeatureGroups = filterFeatureGroupsToSiteMap(result.featureGroups, siteMap);
8549
+ const mobileFeatureGroups = plannerFeatureGroups.length > 0 ? plannerFeatureGroups : buildMobileFeatureGroupsFromGraph(input.graph, input.appId);
8550
+ if (input.mode !== "survey" && result.scenarios.length === 0 && input.graph.screens.size > 0) {
8551
+ const fallbackScenarios = buildMobileFallbackScenarios(input);
8552
+ if (fallbackScenarios.length > 0) {
8553
+ return {
8554
+ ...result,
8555
+ scenarios: fallbackScenarios,
8556
+ featureGroups: mobileFeatureGroups,
8557
+ logs: [
8558
+ ...result.logs,
8559
+ `Mobile fallback: synthesized ${fallbackScenarios.length} screen-reachability scenario(s) from the native screen graph`
8560
+ ],
8561
+ fallbackUsed: true,
8562
+ fallbackReason: result.fallbackReason ?? "Mobile planner produced no scenarios"
8563
+ };
8564
+ }
8565
+ }
8566
+ if (input.mode === "survey" && result.featureGroups.length === 0 && mobileFeatureGroups.length > 0) {
8567
+ return {
8568
+ ...result,
8569
+ featureGroups: mobileFeatureGroups,
8570
+ logs: [
8571
+ ...result.logs,
8572
+ `Mobile fallback: synthesized ${mobileFeatureGroups.length} feature group(s) from the native screen graph`
8573
+ ],
8574
+ fallbackUsed: true,
8575
+ fallbackReason: result.fallbackReason ?? "Mobile planner produced no feature groups"
8576
+ };
8577
+ }
8578
+ return { ...result, featureGroups: mobileFeatureGroups };
8197
8579
  }
8198
8580
  }
8199
8581
  });
@@ -8919,13 +9301,17 @@ ${statePacket}` },
8919
9301
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
8920
9302
  continue;
8921
9303
  }
8922
- tests.push(test);
8923
9304
  log2(` \u2713 generated "${test.name}" (verified: ${test.verified}, steps: ${test.steps?.length ?? 0})`);
8924
9305
  try {
8925
- onTestGenerated?.(test);
9306
+ await onTestGenerated?.(test);
8926
9307
  } catch (cbErr) {
9308
+ if (cbErr?.isPlanLimitReached) {
9309
+ log2(" Plan limit reached \u2014 stopping further generation");
9310
+ break;
9311
+ }
8927
9312
  log2(` onTestGenerated callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
8928
9313
  }
9314
+ tests.push(test);
8929
9315
  } catch (err) {
8930
9316
  log2(` scenario "${scenario.name}" failed: ${err instanceof Error ? err.message : String(err)} \u2014 skipping.`);
8931
9317
  continue;
@@ -12606,6 +12992,17 @@ var require_snapshot_observation = __commonJS({
12606
12992
  for (const container of group.containers) {
12607
12993
  lines.push(` - ${yamlScalar(container ?? "(none)")}`);
12608
12994
  }
12995
+ const hasRowContext = group.rowContexts.some((rc) => !!rc);
12996
+ if (hasRowContext) {
12997
+ lines.push(' distinguish_by_row: # same name in different rows \u2014 scope by this text, e.g. getByRole(role, { name }).filter({ has: getByText("<row>") })');
12998
+ for (let i = 0; i < group.rowContexts.length; i++) {
12999
+ const rowContext = group.rowContexts[i];
13000
+ if (!rowContext)
13001
+ continue;
13002
+ const ref = group.refs[i];
13003
+ lines.push(` - ${yamlScalar(`${ref ? `[${ref}] ` : ""}${rowContext}`)}`);
13004
+ }
13005
+ }
12609
13006
  }
12610
13007
  }
12611
13008
  lines.push("interactive_elements:");
@@ -12637,6 +13034,29 @@ var require_snapshot_observation = __commonJS({
12637
13034
  if (topLocator) {
12638
13035
  lines.push(` locator: ${yamlScalar((0, snapshot_parser_js_1.formatLocator)(topLocator))}`);
12639
13036
  }
13037
+ const enr = rankedElement.element.enrichment;
13038
+ if (enr?.rowContext) {
13039
+ lines.push(` row_context: ${yamlScalar(enr.rowContext)}`);
13040
+ }
13041
+ if (enr && (enr.occluded || enr.position && enr.position !== "in-view")) {
13042
+ const vis = [];
13043
+ if (enr.position && enr.position !== "in-view")
13044
+ vis.push(`off-screen-${enr.position}`);
13045
+ if (enr.occluded)
13046
+ vis.push("occluded");
13047
+ lines.push(` visibility: [${vis.map((token) => yamlScalar(token)).join(", ")}]`);
13048
+ }
13049
+ }
13050
+ }
13051
+ const recoveredClickables = options.recoveredClickables ?? [];
13052
+ if (recoveredClickables.length > 0) {
13053
+ lines.push("non_semantic_clickables:");
13054
+ 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.');
13055
+ for (const clickable of recoveredClickables.slice(0, 8)) {
13056
+ lines.push(` - text: ${yamlScalar(clickable.text)}`);
13057
+ if (clickable.rowContext) {
13058
+ lines.push(` row_context: ${yamlScalar(clickable.rowContext)}`);
13059
+ }
12640
13060
  }
12641
13061
  }
12642
13062
  lines.push("observation_cues:");
@@ -12810,6 +13230,9 @@ var require_snapshot_observation = __commonJS({
12810
13230
  score += 12;
12811
13231
  }
12812
13232
  }
13233
+ const enr = element.enrichment;
13234
+ if (enr?.rowContext)
13235
+ score += 4;
12813
13236
  return score;
12814
13237
  }
12815
13238
  function buildStateSummary(element) {
@@ -12985,10 +13408,12 @@ var require_snapshot_observation = __commonJS({
12985
13408
  const key = `${role}\0${name}`;
12986
13409
  let group = groups.get(key);
12987
13410
  if (!group) {
12988
- group = { role, name, containers: [] };
13411
+ group = { role, name, containers: [], rowContexts: [], refs: [] };
12989
13412
  groups.set(key, group);
12990
13413
  }
12991
13414
  group.containers.push(element.containerPath ?? null);
13415
+ group.rowContexts.push(element.enrichment?.rowContext ?? null);
13416
+ group.refs.push(element.ref ?? null);
12992
13417
  }
12993
13418
  return [...groups.values()].filter((group) => group.containers.length > 1);
12994
13419
  }
@@ -234642,6 +235067,154 @@ var require_preflight_syntax = __commonJS({
234642
235067
  }
234643
235068
  });
234644
235069
 
235070
+ // ../runner-core/dist/services/selector-registry-context.js
235071
+ var require_selector_registry_context = __commonJS({
235072
+ "../runner-core/dist/services/selector-registry-context.js"(exports2) {
235073
+ "use strict";
235074
+ Object.defineProperty(exports2, "__esModule", { value: true });
235075
+ exports2.formatSelectorRegistryContextForPrompt = formatSelectorRegistryContextForPrompt;
235076
+ function isRecord(value) {
235077
+ return value !== null && typeof value === "object" && !Array.isArray(value);
235078
+ }
235079
+ function asString(value, fallback = "") {
235080
+ return typeof value === "string" ? value : fallback;
235081
+ }
235082
+ function asNumber(value, fallback = 0) {
235083
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
235084
+ }
235085
+ function asStringArray(value) {
235086
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
235087
+ }
235088
+ function truncateForSelectorPrompt(value, maxLength = 180) {
235089
+ const normalized = (value ?? "").replace(/\s+/g, " ").trim();
235090
+ if (normalized.length <= maxLength)
235091
+ return normalized;
235092
+ return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
235093
+ }
235094
+ function inlineCode(value, maxLength = 180) {
235095
+ return `\`${truncateForSelectorPrompt(value, maxLength).replace(/`/g, "'")}\``;
235096
+ }
235097
+ function normalizeRef(value) {
235098
+ if (!isRecord(value))
235099
+ return null;
235100
+ const expression = asString(value.expression).trim();
235101
+ if (!expression)
235102
+ return null;
235103
+ return {
235104
+ route: asString(value.route, "*"),
235105
+ usage: asString(value.usage, "locator"),
235106
+ source: asString(value.source, "generated"),
235107
+ expression,
235108
+ occurrenceIndex: asNumber(value.occurrenceIndex, 0)
235109
+ };
235110
+ }
235111
+ function normalizeSibling(value) {
235112
+ if (!isRecord(value))
235113
+ return null;
235114
+ const testCaseId = asString(value.testCaseId).trim();
235115
+ const expression = asString(value.expression).trim();
235116
+ if (!testCaseId || !expression)
235117
+ return null;
235118
+ return {
235119
+ testCaseId,
235120
+ name: asString(value.name, testCaseId),
235121
+ suite: typeof value.suite === "string" ? value.suite : null,
235122
+ lastPassedAt: typeof value.lastPassedAt === "string" ? value.lastPassedAt : null,
235123
+ expression,
235124
+ usage: asString(value.usage, "locator"),
235125
+ route: asString(value.route, "*"),
235126
+ source: asString(value.source, "generated")
235127
+ };
235128
+ }
235129
+ function normalizeEntry(value) {
235130
+ if (!isRecord(value))
235131
+ return null;
235132
+ const id = asString(value.id).trim();
235133
+ const expression = asString(value.expression).trim();
235134
+ if (!id || !expression)
235135
+ return null;
235136
+ return {
235137
+ id,
235138
+ key: asString(value.key, id),
235139
+ route: asString(value.route, "*"),
235140
+ scope: asString(value.scope, "page"),
235141
+ status: asString(value.status, "ACTIVE"),
235142
+ stability: asNumber(value.stability, 1),
235143
+ label: asString(value.label),
235144
+ locatorKind: asString(value.locatorKind),
235145
+ locatorRole: typeof value.locatorRole === "string" ? value.locatorRole : null,
235146
+ locatorName: typeof value.locatorName === "string" ? value.locatorName : null,
235147
+ locatorTestId: typeof value.locatorTestId === "string" ? value.locatorTestId : null,
235148
+ expression,
235149
+ fallbacks: asStringArray(value.fallbacks),
235150
+ currentTestRefs: Array.isArray(value.currentTestRefs) ? value.currentTestRefs.map(normalizeRef).filter((ref) => Boolean(ref)) : [],
235151
+ refCount: asNumber(value.refCount, 0),
235152
+ testCount: asNumber(value.testCount, 0),
235153
+ siblingFanoutCount: asNumber(value.siblingFanoutCount, 0),
235154
+ usageFamilies: asStringArray(value.usageFamilies),
235155
+ riskFlags: asStringArray(value.riskFlags),
235156
+ lastHealedAt: typeof value.lastHealedAt === "string" ? value.lastHealedAt : null,
235157
+ recentPassingSiblings: Array.isArray(value.recentPassingSiblings) ? value.recentPassingSiblings.map(normalizeSibling).filter((sibling) => Boolean(sibling)) : [],
235158
+ recentFailingSiblingCount: asNumber(value.recentFailingSiblingCount, 0)
235159
+ };
235160
+ }
235161
+ function formatSelectorRegistryContextForPrompt(context) {
235162
+ if (!isRecord(context))
235163
+ return "";
235164
+ const entries = Array.isArray(context.entries) ? context.entries.map(normalizeEntry).filter((entry) => Boolean(entry)) : [];
235165
+ if (entries.length === 0)
235166
+ return "";
235167
+ const summary = isRecord(context.summary) ? context.summary : null;
235168
+ const parts = [
235169
+ "## Selector Registry Memory (read-only evidence)",
235170
+ "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."
235171
+ ];
235172
+ if (summary) {
235173
+ const selectorCount = asNumber(summary.selectorCount, entries.length);
235174
+ const sharedSelectorCount = asNumber(summary.sharedSelectorCount, entries.filter((entry) => entry.testCount > 1).length);
235175
+ const maxSharedTestCount = asNumber(summary.maxSharedTestCount, Math.max(...entries.map((entry) => entry.testCount), 0));
235176
+ parts.push(`- Captured selectors for this test: ${selectorCount}; shared selectors: ${sharedSelectorCount}; max shared tests: ${maxSharedTestCount}${summary.truncated ? "; list truncated" : ""}.`);
235177
+ }
235178
+ for (const entry of entries.slice(0, 8)) {
235179
+ const risk = entry.riskFlags.length > 0 ? entry.riskFlags.join(", ") : "none";
235180
+ const families = entry.usageFamilies.length > 0 ? entry.usageFamilies.join(", ") : "locator";
235181
+ parts.push("");
235182
+ parts.push(`### ${truncateForSelectorPrompt(entry.label || entry.key, 90)}`);
235183
+ parts.push(`- Selector: ${inlineCode(entry.expression, 220)}`);
235184
+ parts.push(`- Route/scope/kind: ${entry.route} / ${entry.scope} / ${entry.locatorKind || "unknown"}; usage families: ${families}.`);
235185
+ parts.push(`- Shared by: ${entry.testCount} test(s), ${entry.siblingFanoutCount} sibling(s), ${entry.refCount} ref(s); risks: ${risk}; status: ${entry.status}; stability: ${entry.stability}.`);
235186
+ if (entry.lastHealedAt)
235187
+ parts.push(`- Last registry heal: ${entry.lastHealedAt}.`);
235188
+ const currentRefs = entry.currentTestRefs.slice(0, 3);
235189
+ if (currentRefs.length > 0) {
235190
+ parts.push("- Current test occurrences:");
235191
+ for (const ref of currentRefs) {
235192
+ parts.push(` - ${ref.usage} on ${ref.route} from ${ref.source}: ${inlineCode(ref.expression, 180)}`);
235193
+ }
235194
+ }
235195
+ const siblings = entry.recentPassingSiblings.slice(0, 3);
235196
+ if (siblings.length > 0) {
235197
+ parts.push("- Recent passing sibling tests using this registry entry:");
235198
+ for (const sibling of siblings) {
235199
+ const suite = sibling.suite ? ` (${truncateForSelectorPrompt(sibling.suite, 50)})` : "";
235200
+ const passed = sibling.lastPassedAt ? ` passed ${sibling.lastPassedAt}` : "";
235201
+ parts.push(` - ${truncateForSelectorPrompt(sibling.name, 80)}${suite}${passed}: ${inlineCode(sibling.expression, 180)}`);
235202
+ }
235203
+ }
235204
+ if (entry.recentFailingSiblingCount && entry.recentFailingSiblingCount > 0) {
235205
+ parts.push(`- Other recent failing siblings on this entry: ${entry.recentFailingSiblingCount}.`);
235206
+ }
235207
+ const fallbacks = (entry.fallbacks ?? []).slice(0, 3);
235208
+ if (fallbacks.length > 0) {
235209
+ parts.push(`- Registry fallbacks: ${fallbacks.map((fallback) => inlineCode(fallback, 120)).join(", ")}.`);
235210
+ }
235211
+ }
235212
+ return `${parts.join("\n")}
235213
+ `;
235214
+ }
235215
+ }
235216
+ });
235217
+
234645
235218
  // ../runner-core/dist/services/mcp-healer.js
234646
235219
  var require_mcp_healer = __commonJS({
234647
235220
  "../runner-core/dist/services/mcp-healer.js"(exports2) {
@@ -234736,6 +235309,7 @@ var require_mcp_healer = __commonJS({
234736
235309
  var snapshot_context_compactor_js_1 = require_snapshot_context_compactor();
234737
235310
  var rate_limit_detector_js_1 = require_rate_limit_detector();
234738
235311
  var preflight_syntax_js_1 = require_preflight_syntax();
235312
+ var selector_registry_context_js_1 = require_selector_registry_context();
234739
235313
  function stripCodeFences(code) {
234740
235314
  let cleaned = code.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
234741
235315
  cleaned = cleaned.replace(/^\s*```(?:typescript|ts|javascript|js)?\s*\n?/, "").replace(/\n?\s*```\s*$/, "");
@@ -235297,7 +235871,7 @@ ${lines.join("\n")}
235297
235871
  }).join("\n");
235298
235872
  }
235299
235873
  async function executeScriptDrivenHeal(playwrightCode, baseUrl, options) {
235300
- const { testName, testVariables, surfaceIntelligence, healingContext, lastFailureError, failureScreenshot, lastHealAttempt, consecutiveFails, modelOverride, onAICall, stepResults, tags } = options;
235874
+ const { testName, testVariables, surfaceIntelligence, healingContext, selectorRegistryContext, lastFailureError, failureScreenshot, lastHealAttempt, consecutiveFails, modelOverride, onAICall, stepResults, tags } = options;
235301
235875
  const landingViolation = (tags ?? []).includes("@landing-violation");
235302
235876
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "strong") : (0, ai_provider_js_1.getModel)("strong");
235303
235877
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
@@ -235360,6 +235934,11 @@ ${lastHealAttempt.healReason.slice(0, 800)}`);
235360
235934
  if (healingContext) {
235361
235935
  parts.push(`
235362
235936
  ${formatHealingContextForPrompt(healingContext)}`);
235937
+ }
235938
+ const selectorRegistryPrompt = (0, selector_registry_context_js_1.formatSelectorRegistryContextForPrompt)(selectorRegistryContext);
235939
+ if (selectorRegistryPrompt) {
235940
+ parts.push(`
235941
+ ${selectorRegistryPrompt}`);
235363
235942
  }
235364
235943
  if (surfaceIntelligence) {
235365
235944
  parts.push(`
@@ -235967,6 +236546,7 @@ ${(verifyResult.logs ?? "").slice(0, 2e3)}`;
235967
236546
  testVariables: options?.testVariables,
235968
236547
  surfaceIntelligence: options?.surfaceIntelligence,
235969
236548
  healingContext: options?.healingContext,
236549
+ selectorRegistryContext: options?.selectorRegistryContext,
235970
236550
  lastFailureError: options?.lastFailureError,
235971
236551
  failureScreenshot: options?.failureScreenshot,
235972
236552
  lastHealAttempt: options?.lastHealAttempt,
@@ -236507,6 +237087,7 @@ ${publicLines}` : ""}${credBlock}
236507
237087
  `;
236508
237088
  })()}
236509
237089
  ${options?.healingContext ? `${formatHealingContextForPrompt(options.healingContext)}` : ""}
237090
+ ${(0, selector_registry_context_js_1.formatSelectorRegistryContextForPrompt)(options?.selectorRegistryContext)}
236510
237091
  ${surfaceIntelligence ? `## Original Recorded Actions (what the test was trying to do)
236511
237092
  ${surfaceIntelligence.slice(0, 1500)}
236512
237093
  ` : ""}${landingViolation ? `## STRUCTURAL REQUIREMENT
@@ -238553,6 +239134,7 @@ var require_grok_per_test_healer = __commonJS({
238553
239134
  "use strict";
238554
239135
  Object.defineProperty(exports2, "__esModule", { value: true });
238555
239136
  exports2.executeGrokPerTestHeal = executeGrokPerTestHeal2;
239137
+ exports2.buildHealBrief = buildHealBrief;
238556
239138
  var node_child_process_1 = require("child_process");
238557
239139
  var promises_1 = require("fs/promises");
238558
239140
  var node_os_1 = require("os");
@@ -238569,6 +239151,7 @@ var require_grok_per_test_healer = __commonJS({
238569
239151
  var playwright_mcp_config_js_1 = require_playwright_mcp_config();
238570
239152
  var heal_session_tailer_js_1 = require_heal_session_tailer();
238571
239153
  var proven_actions_adapter_js_1 = require_proven_actions_adapter();
239154
+ var selector_registry_context_js_1 = require_selector_registry_context();
238572
239155
  var HEAL_MCP_CAPABILITIES = ["core", "core-navigation", "core-input", "core-tabs", "testing"];
238573
239156
  var grok_cli_js_1 = require_grok_cli();
238574
239157
  var DEFAULT_MAX_ATTEMPTS = 3;
@@ -238674,6 +239257,7 @@ var require_grok_per_test_healer = __commonJS({
238674
239257
  lastDiagnosis,
238675
239258
  publicVars: publics,
238676
239259
  secretKeys: Object.keys(secrets),
239260
+ selectorRegistryContext: options.selectorRegistryContext ?? null,
238677
239261
  attempt,
238678
239262
  maxAttempts
238679
239263
  }), "utf8");
@@ -238901,6 +239485,7 @@ ${input.lastDiagnosis ? `
238901
239485
  ## Your prior diagnosis (do not repeat the same fix)
238902
239486
  ${input.lastDiagnosis}
238903
239487
  ` : ""}
239488
+ ${(0, selector_registry_context_js_1.formatSelectorRegistryContextForPrompt)(input.selectorRegistryContext)}
238904
239489
 
238905
239490
  ## Tools available
238906
239491
  - **bash** \u2014 \`curl\`, \`sed\`, \`grep\` for static inspection of the app under test.
@@ -299615,6 +300200,699 @@ var require_capture_page_normalizer = __commonJS({
299615
300200
  }
299616
300201
  });
299617
300202
 
300203
+ // ../runner-core/dist/services/perception-enricher.js
300204
+ var require_perception_enricher = __commonJS({
300205
+ "../runner-core/dist/services/perception-enricher.js"(exports2) {
300206
+ "use strict";
300207
+ Object.defineProperty(exports2, "__esModule", { value: true });
300208
+ exports2.enrichCapturedPage = enrichCapturedPage;
300209
+ exports2.mergePerceptionOntoElements = mergePerceptionOntoElements;
300210
+ exports2.elementRoleClass = elementRoleClass;
300211
+ exports2.normalizeName = normalizeName;
300212
+ exports2.urlMatchesRoute = urlMatchesRoute;
300213
+ var DEFAULT_CONFIG = {
300214
+ maxCandidates: 220,
300215
+ maxScan: 4e3,
300216
+ recoverNonSemantic: false,
300217
+ budgetMs: 1500
300218
+ };
300219
+ var BLACKOUT_THRESHOLD = 8;
300220
+ var EVALUATE_TIMEOUT_MS = 1800;
300221
+ var MAX_RECOVERED = 12;
300222
+ async function enrichCapturedPage(page, input, opts = {}) {
300223
+ const empty = { recovered: [], enriched: 0, perceived: 0 };
300224
+ if (!page)
300225
+ return { ...empty, skipReason: "no-page" };
300226
+ try {
300227
+ const url = page.url();
300228
+ if (url && input.route && !urlMatchesRoute(url, input.route)) {
300229
+ opts.onLog?.(`[perception] skip: page ${shortUrl(url)} != captured route ${input.route}`);
300230
+ return { ...empty, skipReason: "route-mismatch" };
300231
+ }
300232
+ } catch {
300233
+ }
300234
+ const elements = input.elements ?? [];
300235
+ const parsedInteractive = input.parsedInteractiveCount ?? elements.length;
300236
+ const cfg = {
300237
+ ...DEFAULT_CONFIG,
300238
+ recoverNonSemantic: parsedInteractive < BLACKOUT_THRESHOLD
300239
+ };
300240
+ let perceived;
300241
+ try {
300242
+ const raw = await withTimeout(page.evaluate(collectPerception, cfg), opts.timeoutMs ?? EVALUATE_TIMEOUT_MS);
300243
+ if (!Array.isArray(raw))
300244
+ return { ...empty, skipReason: "bad-result" };
300245
+ perceived = raw;
300246
+ } catch (err) {
300247
+ opts.onLog?.(`[perception] evaluate failed \u2014 ${err instanceof Error ? err.message : String(err)}`);
300248
+ return { ...empty, skipReason: "evaluate-error" };
300249
+ }
300250
+ const { enriched } = mergePerceptionOntoElements(elements, perceived);
300251
+ const recovered = collectRecovered(perceived);
300252
+ opts.onLog?.(`[perception] enriched ${enriched}/${elements.length} elements, ${recovered.length} non-semantic clickables (${perceived.length} perceived)`);
300253
+ return { recovered, enriched, perceived: perceived.length };
300254
+ }
300255
+ function mergePerceptionOntoElements(elements, perceived) {
300256
+ const perceivedGroups = /* @__PURE__ */ new Map();
300257
+ for (const p of perceived) {
300258
+ if (p.nonSemantic || !p.roleClass || !p.name)
300259
+ continue;
300260
+ const key = `${p.roleClass}\0${p.name}`;
300261
+ let arr = perceivedGroups.get(key);
300262
+ if (!arr) {
300263
+ arr = [];
300264
+ perceivedGroups.set(key, arr);
300265
+ }
300266
+ arr.push(p);
300267
+ }
300268
+ for (const arr of perceivedGroups.values())
300269
+ arr.sort((a, b) => a.domOrder - b.domOrder);
300270
+ const elementGroups = /* @__PURE__ */ new Map();
300271
+ for (const el of elements) {
300272
+ const roleClass = elementRoleClass(el);
300273
+ const name = normalizeName(el.label);
300274
+ if (!roleClass || !name)
300275
+ continue;
300276
+ const key = `${roleClass}\0${name}`;
300277
+ let arr = elementGroups.get(key);
300278
+ if (!arr) {
300279
+ arr = [];
300280
+ elementGroups.set(key, arr);
300281
+ }
300282
+ arr.push(el);
300283
+ }
300284
+ let enriched = 0;
300285
+ for (const [key, els] of elementGroups) {
300286
+ const ps = perceivedGroups.get(key);
300287
+ if (!ps)
300288
+ continue;
300289
+ if (ps.length !== els.length)
300290
+ continue;
300291
+ for (let i = 0; i < els.length; i++) {
300292
+ const enrichment = toEnrichment(ps[i]);
300293
+ if (enrichment) {
300294
+ els[i].enrichment = enrichment;
300295
+ enriched++;
300296
+ }
300297
+ }
300298
+ }
300299
+ return { enriched };
300300
+ }
300301
+ function toEnrichment(p) {
300302
+ const e = {};
300303
+ if (p.rowContext)
300304
+ e.rowContext = p.rowContext;
300305
+ if (p.position && p.position !== "in-view")
300306
+ e.position = p.position;
300307
+ if (p.occluded)
300308
+ e.occluded = true;
300309
+ if (p.cursorPointer)
300310
+ e.cursorPointer = true;
300311
+ return Object.keys(e).length > 0 ? e : null;
300312
+ }
300313
+ function collectRecovered(perceived) {
300314
+ const out = [];
300315
+ const seen = /* @__PURE__ */ new Set();
300316
+ for (const p of perceived) {
300317
+ if (!p.nonSemantic || !p.text)
300318
+ continue;
300319
+ const key = `${p.text}\0${p.rowContext ?? ""}`;
300320
+ if (seen.has(key))
300321
+ continue;
300322
+ seen.add(key);
300323
+ const rc = { text: p.text };
300324
+ if (p.rowContext)
300325
+ rc.rowContext = p.rowContext;
300326
+ out.push(rc);
300327
+ if (out.length >= MAX_RECOVERED)
300328
+ break;
300329
+ }
300330
+ return out;
300331
+ }
300332
+ function elementRoleClass(el) {
300333
+ const r = (el.role ?? "").toLowerCase().trim();
300334
+ const mapped = roleBucket(r);
300335
+ if (mapped)
300336
+ return mapped;
300337
+ const t = (el.elementType ?? "").toLowerCase();
300338
+ if (t === "link")
300339
+ return "link";
300340
+ if (t === "button")
300341
+ return "button";
300342
+ if (t.startsWith("input")) {
300343
+ const it = (el.inputType ?? "").toLowerCase();
300344
+ if (it === "checkbox")
300345
+ return "checkbox";
300346
+ if (it === "radio")
300347
+ return "radio";
300348
+ if (it === "submit" || it === "button")
300349
+ return "button";
300350
+ return "textbox";
300351
+ }
300352
+ return t || "";
300353
+ }
300354
+ function roleBucket(r) {
300355
+ if (!r)
300356
+ return null;
300357
+ if (r === "link")
300358
+ return "link";
300359
+ if (r === "button")
300360
+ return "button";
300361
+ if (r === "textbox" || r === "searchbox" || r === "spinbutton")
300362
+ return "textbox";
300363
+ if (r === "combobox" || r === "listbox")
300364
+ return "combobox";
300365
+ if (r === "checkbox" || r === "switch")
300366
+ return "checkbox";
300367
+ if (r === "radio")
300368
+ return "radio";
300369
+ if (r === "tab")
300370
+ return "tab";
300371
+ if (r === "menuitem" || r === "menuitemcheckbox" || r === "menuitemradio")
300372
+ return "menuitem";
300373
+ if (r === "option")
300374
+ return "option";
300375
+ if (r === "slider")
300376
+ return "slider";
300377
+ return r;
300378
+ }
300379
+ function normalizeName(name) {
300380
+ return (name ?? "").replace(/\s+/g, " ").trim().toLowerCase().slice(0, 80);
300381
+ }
300382
+ function withTimeout(p, ms) {
300383
+ return new Promise((resolve, reject) => {
300384
+ const t = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms);
300385
+ p.then((v) => {
300386
+ clearTimeout(t);
300387
+ resolve(v);
300388
+ }, (e) => {
300389
+ clearTimeout(t);
300390
+ reject(e);
300391
+ });
300392
+ });
300393
+ }
300394
+ function parseLoc(input) {
300395
+ try {
300396
+ const u = new URL(input, "http://__rel__");
300397
+ return {
300398
+ origin: u.host === "__rel__" ? "" : u.host,
300399
+ // '' when input was path-relative
300400
+ path: (u.pathname || "/").replace(/\/+$/, "") || "/",
300401
+ search: u.search,
300402
+ hash: u.hash
300403
+ };
300404
+ } catch {
300405
+ const hashI = input.indexOf("#");
300406
+ const hash = hashI >= 0 ? input.slice(hashI) : "";
300407
+ const beforeHash = hashI >= 0 ? input.slice(0, hashI) : input;
300408
+ const qi = beforeHash.indexOf("?");
300409
+ const search = qi >= 0 ? beforeHash.slice(qi) : "";
300410
+ const path = ((qi >= 0 ? beforeHash.slice(0, qi) : beforeHash) || "/").replace(/\/+$/, "") || "/";
300411
+ return { origin: "", path, search, hash };
300412
+ }
300413
+ }
300414
+ function urlMatchesRoute(url, route2) {
300415
+ const u = parseLoc(url);
300416
+ const r = parseLoc(route2);
300417
+ if (u.path !== r.path)
300418
+ return false;
300419
+ if (r.origin && u.origin && r.origin !== u.origin)
300420
+ return false;
300421
+ if (r.search && u.search !== r.search)
300422
+ return false;
300423
+ if (r.hash && u.hash !== r.hash)
300424
+ return false;
300425
+ return true;
300426
+ }
300427
+ function shortUrl(url) {
300428
+ return url.length > 80 ? `${url.slice(0, 77)}...` : url;
300429
+ }
300430
+ function collectPerception(cfg) {
300431
+ const NATIVE = {
300432
+ A: true,
300433
+ BUTTON: true,
300434
+ INPUT: true,
300435
+ SELECT: true,
300436
+ TEXTAREA: true,
300437
+ SUMMARY: true,
300438
+ OPTION: true
300439
+ };
300440
+ const INTERACTIVE_ROLE = {
300441
+ button: true,
300442
+ link: true,
300443
+ textbox: true,
300444
+ searchbox: true,
300445
+ spinbutton: true,
300446
+ combobox: true,
300447
+ listbox: true,
300448
+ checkbox: true,
300449
+ radio: true,
300450
+ switch: true,
300451
+ menuitem: true,
300452
+ menuitemcheckbox: true,
300453
+ menuitemradio: true,
300454
+ tab: true,
300455
+ option: true,
300456
+ slider: true
300457
+ };
300458
+ const norm = (s) => (s || "").replace(/\s+/g, " ").trim();
300459
+ const lower = (s) => s.toLowerCase().slice(0, 80);
300460
+ const roleClassOf = (tag, roleAttr, inputType) => {
300461
+ const r = roleAttr.toLowerCase();
300462
+ if (r) {
300463
+ if (r === "link")
300464
+ return "link";
300465
+ if (r === "button")
300466
+ return "button";
300467
+ if (r === "textbox" || r === "searchbox" || r === "spinbutton")
300468
+ return "textbox";
300469
+ if (r === "combobox" || r === "listbox")
300470
+ return "combobox";
300471
+ if (r === "switch" || r === "checkbox")
300472
+ return "checkbox";
300473
+ if (r === "radio")
300474
+ return "radio";
300475
+ if (r === "tab")
300476
+ return "tab";
300477
+ if (r === "menuitem" || r === "menuitemcheckbox" || r === "menuitemradio")
300478
+ return "menuitem";
300479
+ if (r === "option")
300480
+ return "option";
300481
+ if (r === "slider")
300482
+ return "slider";
300483
+ return r;
300484
+ }
300485
+ if (tag === "A")
300486
+ return "link";
300487
+ if (tag === "BUTTON" || tag === "SUMMARY")
300488
+ return "button";
300489
+ if (tag === "SELECT")
300490
+ return "combobox";
300491
+ if (tag === "TEXTAREA")
300492
+ return "textbox";
300493
+ if (tag === "OPTION")
300494
+ return "option";
300495
+ if (tag === "INPUT") {
300496
+ const t = (inputType || "text").toLowerCase();
300497
+ if (t === "checkbox")
300498
+ return "checkbox";
300499
+ if (t === "radio")
300500
+ return "radio";
300501
+ if (t === "submit" || t === "button" || t === "image" || t === "reset")
300502
+ return "button";
300503
+ if (t === "range")
300504
+ return "slider";
300505
+ return "textbox";
300506
+ }
300507
+ return "other";
300508
+ };
300509
+ const accName = (el) => {
300510
+ const al = norm(el.getAttribute("aria-label"));
300511
+ if (al)
300512
+ return al;
300513
+ const lb = el.getAttribute("aria-labelledby");
300514
+ if (lb) {
300515
+ const ids = lb.split(/\s+/);
300516
+ let txt = "";
300517
+ for (let i = 0; i < ids.length; i++) {
300518
+ const t = el.ownerDocument.getElementById(ids[i]);
300519
+ if (t)
300520
+ txt += (txt ? " " : "") + norm(t.textContent);
300521
+ }
300522
+ if (txt)
300523
+ return txt;
300524
+ }
300525
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT") {
300526
+ const id = el.getAttribute("id");
300527
+ if (id) {
300528
+ try {
300529
+ const lab = el.ownerDocument.querySelector('label[for="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]');
300530
+ const t = lab ? norm(lab.textContent) : "";
300531
+ if (t)
300532
+ return t;
300533
+ } catch {
300534
+ }
300535
+ }
300536
+ const val = norm(el.value);
300537
+ if (val)
300538
+ return val;
300539
+ const ph = norm(el.getAttribute("placeholder"));
300540
+ if (ph)
300541
+ return ph;
300542
+ return norm(el.getAttribute("name"));
300543
+ }
300544
+ const text = norm(el.textContent);
300545
+ if (text)
300546
+ return text;
300547
+ return norm(el.getAttribute("title")) || norm(el.getAttribute("alt"));
300548
+ };
300549
+ const ROW_SEL = 'tr,li,[role="row"],[role="listitem"],[role="article"],article,[role="option"]';
300550
+ const isCardish = (el) => {
300551
+ const cls = (el.getAttribute("class") || "").toLowerCase();
300552
+ return /(^|[\s_-])(card|row|item|listitem|cell|tile)([\s_-]|$)/.test(cls);
300553
+ };
300554
+ const rowContextOf = (el, ownNameLower) => {
300555
+ let cur = el.parentElement;
300556
+ let depth = 0;
300557
+ while (cur && depth < 8) {
300558
+ let isRow = false;
300559
+ try {
300560
+ isRow = cur.matches(ROW_SEL);
300561
+ } catch {
300562
+ isRow = false;
300563
+ }
300564
+ if (isRow || isCardish(cur)) {
300565
+ const head = cur.querySelector('h1,h2,h3,h4,h5,h6,[role="heading"],strong,b,th,td');
300566
+ let txt = head ? norm(head.textContent) : "";
300567
+ if (!txt)
300568
+ txt = norm(cur.textContent);
300569
+ txt = txt.slice(0, 80);
300570
+ const low = txt.toLowerCase();
300571
+ if (txt && low !== ownNameLower && low.length > 1)
300572
+ return txt;
300573
+ return null;
300574
+ }
300575
+ cur = cur.parentElement;
300576
+ depth++;
300577
+ }
300578
+ return null;
300579
+ };
300580
+ const vh = window.innerHeight || 0;
300581
+ const vw = window.innerWidth || 0;
300582
+ const visibleRect = (el) => {
300583
+ let rect;
300584
+ let style;
300585
+ try {
300586
+ rect = el.getBoundingClientRect();
300587
+ style = window.getComputedStyle(el);
300588
+ } catch {
300589
+ return null;
300590
+ }
300591
+ if (style.display === "none" || style.visibility === "hidden")
300592
+ return null;
300593
+ if (parseFloat(style.opacity || "1") === 0)
300594
+ return null;
300595
+ if (rect.width <= 0 || rect.height <= 0)
300596
+ return null;
300597
+ try {
300598
+ if (el.closest('[aria-hidden="true"],[inert]'))
300599
+ return null;
300600
+ } catch {
300601
+ }
300602
+ return { rect, style };
300603
+ };
300604
+ const occlusionOf = (el, rect) => {
300605
+ const cx = rect.left + rect.width / 2;
300606
+ const cy = rect.top + rect.height / 2;
300607
+ if (cx < 0 || cy < 0 || cx > vw || cy > vh)
300608
+ return false;
300609
+ try {
300610
+ const hit = document.elementFromPoint(cx, cy);
300611
+ if (!hit || hit === el || el.contains(hit) || hit.contains(el))
300612
+ return false;
300613
+ let anc = hit;
300614
+ let guard = 0;
300615
+ while (anc && guard < 12) {
300616
+ const pos = window.getComputedStyle(anc).position;
300617
+ if (pos === "fixed" || pos === "sticky")
300618
+ return false;
300619
+ anc = anc.parentElement;
300620
+ guard++;
300621
+ }
300622
+ return true;
300623
+ } catch {
300624
+ return false;
300625
+ }
300626
+ };
300627
+ const positionOf = (rect) => rect.bottom < 0 ? "above" : rect.top > vh ? "below" : "in-view";
300628
+ const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : 0;
300629
+ const overBudget = () => t0 > 0 && performance.now() - t0 > cfg.budgetMs;
300630
+ const out = [];
300631
+ let order = 0;
300632
+ const SEL = 'a[href],button,input:not([type="hidden"]),textarea,select,summary,option,[role],[onclick],[tabindex]';
300633
+ const all = document.querySelectorAll(SEL);
300634
+ const limit = all.length < cfg.maxScan ? all.length : cfg.maxScan;
300635
+ for (let i = 0; i < limit; i++) {
300636
+ if (out.length >= cfg.maxCandidates)
300637
+ break;
300638
+ if ((i & 63) === 0 && overBudget())
300639
+ break;
300640
+ const el = all[i];
300641
+ const vis = visibleRect(el);
300642
+ if (!vis)
300643
+ continue;
300644
+ const tag = el.tagName;
300645
+ const roleAttr = el.getAttribute("role") || "";
300646
+ const inputType = tag === "INPUT" ? el.type || "" : "";
300647
+ const cursorPointer = vis.style.cursor === "pointer";
300648
+ const native = NATIVE[tag] === true;
300649
+ const roleLower = roleAttr.toLowerCase();
300650
+ const hasInteractiveRole = !!roleLower && INTERACTIVE_ROLE[roleLower] === true;
300651
+ const hasOnclick = el.hasAttribute("onclick");
300652
+ const tabAttr = el.getAttribute("tabindex");
300653
+ const tabbable = tabAttr != null && tabAttr !== "-1";
300654
+ const semantic = native || hasInteractiveRole;
300655
+ const nonSemantic = !semantic && (cursorPointer || hasOnclick || tabbable);
300656
+ if (!semantic && !nonSemantic)
300657
+ continue;
300658
+ const myOrder = order++;
300659
+ const name = accName(el);
300660
+ const lname = lower(name);
300661
+ const rect = vis.rect;
300662
+ out.push({
300663
+ domOrder: myOrder,
300664
+ roleClass: roleClassOf(tag, roleAttr, inputType),
300665
+ name: lname,
300666
+ rowContext: rowContextOf(el, lname),
300667
+ position: positionOf(rect),
300668
+ occluded: occlusionOf(el, rect),
300669
+ cursorPointer,
300670
+ nonSemantic,
300671
+ text: nonSemantic ? name.slice(0, 80) || null : null
300672
+ });
300673
+ }
300674
+ if (cfg.recoverNonSemantic) {
300675
+ const extra = document.querySelectorAll("div,span,li,td,p");
300676
+ const extraLimit = extra.length < cfg.maxScan ? extra.length : cfg.maxScan;
300677
+ for (let i = 0; i < extraLimit; i++) {
300678
+ if (out.length >= cfg.maxCandidates)
300679
+ break;
300680
+ if ((i & 63) === 0 && overBudget())
300681
+ break;
300682
+ const el = extra[i];
300683
+ const vis = visibleRect(el);
300684
+ if (!vis)
300685
+ continue;
300686
+ if (vis.style.cursor !== "pointer")
300687
+ continue;
300688
+ if (el.querySelector('a,button,input,select,textarea,[role="button"],[role="link"]'))
300689
+ continue;
300690
+ const rect = vis.rect;
300691
+ if (rect.width < 6 || rect.height < 6)
300692
+ continue;
300693
+ if (rect.width > vw * 0.98)
300694
+ continue;
300695
+ if (positionOf(rect) !== "in-view")
300696
+ continue;
300697
+ if (occlusionOf(el, rect))
300698
+ continue;
300699
+ const name = accName(el);
300700
+ if (!name)
300701
+ continue;
300702
+ const lname = lower(name);
300703
+ out.push({
300704
+ domOrder: order++,
300705
+ roleClass: "button",
300706
+ name: lname,
300707
+ rowContext: rowContextOf(el, lname),
300708
+ position: "in-view",
300709
+ occluded: false,
300710
+ cursorPointer: true,
300711
+ nonSemantic: true,
300712
+ text: name.slice(0, 80) || null
300713
+ });
300714
+ }
300715
+ }
300716
+ return out;
300717
+ }
300718
+ }
300719
+ });
300720
+
300721
+ // ../runner-core/dist/services/stuck-detector.js
300722
+ var require_stuck_detector = __commonJS({
300723
+ "../runner-core/dist/services/stuck-detector.js"(exports2) {
300724
+ "use strict";
300725
+ Object.defineProperty(exports2, "__esModule", { value: true });
300726
+ exports2.StuckDetector = void 0;
300727
+ exports2.isPageAffectingTool = isPageAffectingTool;
300728
+ exports2.normalizeStuckSignature = normalizeStuckSignature;
300729
+ var PAGE_AFFECTING_TOOLS = /* @__PURE__ */ new Set([
300730
+ "browser_click",
300731
+ "browser_type",
300732
+ "browser_fill_form",
300733
+ "browser_select_option",
300734
+ "browser_navigate",
300735
+ "browser_press_key",
300736
+ "browser_hover",
300737
+ "browser_drag"
300738
+ ]);
300739
+ function isPageAffectingTool(toolName) {
300740
+ return PAGE_AFFECTING_TOOLS.has(toolName);
300741
+ }
300742
+ function normalizeStuckSignature(toolName, toolArgs) {
300743
+ const rawRef = typeof toolArgs.ref === "string" ? toolArgs.ref.trim() : "";
300744
+ const rawElement = typeof toolArgs.element === "string" ? toolArgs.element.trim() : "";
300745
+ const elementKey = rawRef ? rawRef : rawElement ? rawElement.toLowerCase().replace(/\S+@\S+/g, "[email]").slice(0, 60) : "";
300746
+ switch (toolName) {
300747
+ case "browser_click":
300748
+ case "browser_hover":
300749
+ case "browser_select_option":
300750
+ case "browser_type":
300751
+ case "browser_fill_form":
300752
+ case "browser_drag":
300753
+ return elementKey ? `${toolName}:${elementKey}` : null;
300754
+ case "browser_navigate": {
300755
+ const url = typeof toolArgs.url === "string" ? canonicalizeUrlPath(toolArgs.url) : "";
300756
+ return `browser_navigate:${url}`;
300757
+ }
300758
+ case "browser_press_key": {
300759
+ const key = typeof toolArgs.key === "string" ? toolArgs.key : "";
300760
+ return `browser_press_key:${key}`;
300761
+ }
300762
+ default:
300763
+ return null;
300764
+ }
300765
+ }
300766
+ function canonicalizeUrlPath(url) {
300767
+ try {
300768
+ const u = new URL(url, "http://x");
300769
+ return u.pathname.replace(/\/+$/, "") || "/";
300770
+ } catch {
300771
+ return url.trim().split(/[?#]/)[0] || url.trim();
300772
+ }
300773
+ }
300774
+ var NO_VERDICT = Object.freeze({
300775
+ tier: 0,
300776
+ kind: null,
300777
+ repeatedAction: null,
300778
+ recover: false,
300779
+ yieldRun: false
300780
+ });
300781
+ var DEFAULTS = {
300782
+ window: 8,
300783
+ repeatThreshold: 3,
300784
+ stagnationThreshold: 4,
300785
+ tier2After: 3,
300786
+ tier3After: 5,
300787
+ maxRecoveries: 2,
300788
+ repetitionReachesRecovery: true
300789
+ };
300790
+ var StuckDetector = class {
300791
+ opts;
300792
+ records = [];
300793
+ stallStreak = 0;
300794
+ recoveryCount = 0;
300795
+ lastEmittedTier = 0;
300796
+ constructor(options = {}) {
300797
+ this.opts = { ...DEFAULTS, ...options };
300798
+ }
300799
+ record(input) {
300800
+ this.records.push(input);
300801
+ if (this.records.length > this.opts.window) {
300802
+ this.records.splice(0, this.records.length - this.opts.window);
300803
+ }
300804
+ const stall = this.detectStall();
300805
+ if (!stall.stalled) {
300806
+ this.stallStreak = 0;
300807
+ this.lastEmittedTier = 0;
300808
+ return NO_VERDICT;
300809
+ }
300810
+ this.stallStreak += 1;
300811
+ let desired = 1;
300812
+ if (this.stallStreak >= this.opts.tier3After)
300813
+ desired = 3;
300814
+ else if (this.stallStreak >= this.opts.tier2After)
300815
+ desired = 2;
300816
+ if (stall.kind === "stagnation" && desired > 2)
300817
+ desired = 2;
300818
+ if (!this.opts.repetitionReachesRecovery && stall.kind === "repetition" && desired > 2)
300819
+ desired = 2;
300820
+ if (desired <= this.lastEmittedTier)
300821
+ return NO_VERDICT;
300822
+ if (desired === 3) {
300823
+ if (this.recoveryCount >= this.opts.maxRecoveries) {
300824
+ this.lastEmittedTier = 4;
300825
+ return {
300826
+ tier: 4,
300827
+ kind: stall.kind,
300828
+ repeatedAction: stall.repeatedAction,
300829
+ recover: false,
300830
+ yieldRun: true
300831
+ };
300832
+ }
300833
+ this.recoveryCount += 1;
300834
+ this.records = [];
300835
+ this.stallStreak = 0;
300836
+ this.lastEmittedTier = 0;
300837
+ return {
300838
+ tier: 3,
300839
+ kind: stall.kind,
300840
+ repeatedAction: stall.repeatedAction,
300841
+ recover: true,
300842
+ yieldRun: false
300843
+ };
300844
+ }
300845
+ this.lastEmittedTier = desired;
300846
+ return {
300847
+ tier: desired,
300848
+ kind: stall.kind,
300849
+ repeatedAction: stall.repeatedAction,
300850
+ recover: false,
300851
+ yieldRun: false
300852
+ };
300853
+ }
300854
+ detectStall() {
300855
+ const { stagnationThreshold, repeatThreshold } = this.opts;
300856
+ if (this.records.length < stagnationThreshold) {
300857
+ return { stalled: false, kind: null, repeatedAction: null };
300858
+ }
300859
+ const recent = this.records.slice(-stagnationThreshold);
300860
+ const progressStalled = recent.length >= stagnationThreshold && recent.every((r) => r.progressKey === recent[recent.length - 1].progressKey);
300861
+ if (progressStalled) {
300862
+ const counts = /* @__PURE__ */ new Map();
300863
+ for (const rec of this.records) {
300864
+ for (const sig of rec.actionSignatures) {
300865
+ counts.set(sig, (counts.get(sig) ?? 0) + 1);
300866
+ }
300867
+ }
300868
+ let repeatedAction = null;
300869
+ let max = 0;
300870
+ for (const [sig, count] of counts) {
300871
+ if (count > max) {
300872
+ max = count;
300873
+ repeatedAction = sig;
300874
+ }
300875
+ }
300876
+ if (repeatedAction && max >= repeatThreshold) {
300877
+ return { stalled: true, kind: "repetition", repeatedAction };
300878
+ }
300879
+ }
300880
+ if (progressStalled) {
300881
+ const sameRoute = recent.every((r) => r.route === recent[recent.length - 1].route);
300882
+ const pageAffectingCount = recent.filter((r) => r.pageAffecting).length;
300883
+ const distinctSignatures = new Set(recent.flatMap((r) => r.actionSignatures));
300884
+ const lowDiversity = distinctSignatures.size >= 1 && distinctSignatures.size <= Math.max(1, Math.floor(pageAffectingCount / 2));
300885
+ if (sameRoute && pageAffectingCount >= Math.ceil(stagnationThreshold / 2) && lowDiversity) {
300886
+ return { stalled: true, kind: "stagnation", repeatedAction: null };
300887
+ }
300888
+ }
300889
+ return { stalled: false, kind: null, repeatedAction: null };
300890
+ }
300891
+ };
300892
+ exports2.StuckDetector = StuckDetector;
300893
+ }
300894
+ });
300895
+
299618
300896
  // ../runner-core/dist/services/evidence-harvester.js
299619
300897
  var require_evidence_harvester = __commonJS({
299620
300898
  "../runner-core/dist/services/evidence-harvester.js"(exports2) {
@@ -299911,8 +301189,10 @@ var require_discover_explorer = __commonJS({
299911
301189
  var capture_page_normalizer_js_1 = require_capture_page_normalizer();
299912
301190
  var snapshot_observation_js_1 = require_snapshot_observation();
299913
301191
  var snapshot_context_compactor_js_1 = require_snapshot_context_compactor();
301192
+ var perception_enricher_js_1 = require_perception_enricher();
299914
301193
  var exploration_prompt_builder_js_2 = require_exploration_prompt_builder();
299915
301194
  var exploration_state_js_1 = require_exploration_state();
301195
+ var stuck_detector_js_1 = require_stuck_detector();
299916
301196
  var evidence_harvester_js_1 = require_evidence_harvester();
299917
301197
  var exploration_parser_js_1 = require_exploration_parser();
299918
301198
  var snapshot_context_compactor_js_2 = require_snapshot_context_compactor();
@@ -300695,6 +301975,7 @@ ${credentialBlock}${redactedBlock}${inboxBlock}`;
300695
301975
  const v3Env = process.env.DISCOVERY_PROMPT_V3;
300696
301976
  const useV3 = v3Env !== "false";
300697
301977
  const useV2 = true;
301978
+ const perceptionEnricherEnabled = (process.env.DISCOVERY_PERCEPTION_ENRICHER === "true" || process.env.DISCOVERY_PERCEPTION_ENRICHER === "1") && typeof options?.getPerceptionPage === "function";
300698
301979
  const state2 = (0, exploration_state_js_1.createExplorationState)();
300699
301980
  const recentExchanges = [];
300700
301981
  const transientDirectives = [];
@@ -300819,6 +302100,8 @@ ${inboxPromptBlock}`;
300819
302100
  let lastBriefRoute = "";
300820
302101
  let lastBriefIteration = 0;
300821
302102
  const BRIEF_INTERVAL = 5;
302103
+ const stuckDetectorEnabled = process.env.DISCOVERY_STUCK_DETECTOR === "1" || process.env.DISCOVERY_STUCK_DETECTOR === "true";
302104
+ const stuckDetector = stuckDetectorEnabled ? new stuck_detector_js_1.StuckDetector({ repetitionReachesRecovery: !isDeepMode }) : null;
300822
302105
  while (iteration < maxIterations && !explorationComplete) {
300823
302106
  iteration++;
300824
302107
  (0, exploration_state_js_1.reduceEvent)(state2, { type: "ITERATION_STARTED", iteration });
@@ -301290,6 +302573,19 @@ ${currentSummary}`;
301290
302573
  const elapsed = Date.now() - readinessStartedAt;
301291
302574
  logs2.push(` [discover-explorer] proceeded with not-ready snapshot after ${readinessRetries} retries / ${elapsed}ms`);
301292
302575
  }
302576
+ let recoveredClickables = [];
302577
+ if (perceptionEnricherEnabled) {
302578
+ try {
302579
+ const result = await (0, perception_enricher_js_1.enrichCapturedPage)(options?.getPerceptionPage?.(), {
302580
+ route: normalized.route,
302581
+ elements: normalized.elements,
302582
+ parsedInteractiveCount: normalized.snapshotMetrics?.parsedInteractiveCount
302583
+ }, { onLog: (msg) => logs2.push(` ${msg}`) });
302584
+ recoveredClickables = result.recovered;
302585
+ } catch (perceptionErr) {
302586
+ logs2.push(` [perception] enrich skipped \u2014 ${perceptionErr instanceof Error ? perceptionErr.message : String(perceptionErr)}`);
302587
+ }
302588
+ }
301293
302589
  (0, exploration_state_js_1.reduceEvent)(state2, {
301294
302590
  type: "PAGE_CAPTURED",
301295
302591
  route: normalized.route,
@@ -301358,7 +302654,7 @@ ${currentSummary}`;
301358
302654
  logs2.push(` Captured page: ${normalized.route}, ${normalized.provenCommands.length} proven commands${normalized.alreadyCaptured ? " (RE-VISIT)" : ""}`);
301359
302655
  const transitionHint = !normalized.alreadyCaptured && isSurveyMode ? { tip: `Also call record_transition with fromRoute, toRoute, triggerType, and triggerLabel (the exact link text you clicked to get here).` } : {};
301360
302656
  const lastAction = normalized.observations.length > 0 ? normalized.observations[normalized.observations.length - 1].action : void 0;
301361
- const snapshotBlock = (0, snapshot_observation_js_1.renderCompactSnapshotBlock)(normalized, { lastAction }, { elementLimit: 6, formLimit: 3, observationLimit: 3, mode: options?.mode ?? "full" });
302657
+ const snapshotBlock = (0, snapshot_observation_js_1.renderCompactSnapshotBlock)(normalized, { lastAction }, { elementLimit: 6, formLimit: 3, observationLimit: 3, mode: options?.mode ?? "full", recoveredClickables });
301362
302658
  toolResults.push({
301363
302659
  role: "tool",
301364
302660
  tool_call_id: toolCall.id,
@@ -302297,6 +303593,59 @@ Exploration complete: ${summary}`);
302297
303593
  }
302298
303594
  delete toolResults.__accountAlreadyExistsDirective;
302299
303595
  }
303596
+ if (stuckDetector && assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
303597
+ const actionSignatures = [];
303598
+ let pageAffecting = false;
303599
+ for (const tc of assistantMessage.tool_calls) {
303600
+ if ((0, stuck_detector_js_1.isPageAffectingTool)(tc.function.name))
303601
+ pageAffecting = true;
303602
+ let parsedArgs = {};
303603
+ try {
303604
+ parsedArgs = JSON.parse(tc.function.arguments || "{}");
303605
+ } catch {
303606
+ }
303607
+ const sig = (0, stuck_detector_js_1.normalizeStuckSignature)(tc.function.name, parsedArgs);
303608
+ if (sig)
303609
+ actionSignatures.push(sig);
303610
+ }
303611
+ const progressKey = `${state2.currentRoute}|${state2.visitedRoutes.size}|${state2.recordedJourneys.length}|${lastCaptureIteration}`;
303612
+ const verdict = stuckDetector.record({
303613
+ iteration,
303614
+ actionSignatures,
303615
+ route: state2.currentRoute,
303616
+ progressKey,
303617
+ pageAffecting
303618
+ });
303619
+ if (verdict.tier > 0) {
303620
+ const topFrontier = (0, exploration_state_js_1.getRankedFrontier)(state2).slice(0, 3);
303621
+ const frontierHint = topFrontier.length > 0 ? ` (e.g. ${topFrontier.join(", ")})` : "";
303622
+ const repeated = verdict.repeatedAction ? verdict.repeatedAction.slice(0, 80) : null;
303623
+ const act = repeated ? `"${repeated}"` : "the same actions";
303624
+ let directive = null;
303625
+ if (verdict.yieldRun) {
303626
+ logs2.push(` Stuck detector: yielding after repeated local loop (${verdict.kind}, ${act})`);
303627
+ explorationComplete = true;
303628
+ 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.`;
303629
+ break;
303630
+ } else if (verdict.recover) {
303631
+ 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.`;
303632
+ logs2.push(` Stuck detector: harness recovery (tier 3, ${verdict.kind}, ${act})`);
303633
+ } else if (verdict.tier === 2) {
303634
+ 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.`;
303635
+ logs2.push(` Stuck detector: firm nudge (tier 2, ${verdict.kind}, ${act})`);
303636
+ } else {
303637
+ 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.`;
303638
+ logs2.push(` Stuck detector: soft nudge (tier 1, ${verdict.kind}, ${act})`);
303639
+ }
303640
+ if (directive) {
303641
+ if (useV3) {
303642
+ transientDirectives.push(directive);
303643
+ } else {
303644
+ messages.push({ role: "user", content: directive });
303645
+ }
303646
+ }
303647
+ }
303648
+ }
302300
303649
  }
302301
303650
  if (!explorationComplete) {
302302
303651
  logs2.push(`
@@ -303005,7 +304354,8 @@ ${credentialLines}
303005
304354
  userNotes: featureOpts.userNotes,
303006
304355
  onAICall: featureOpts.onAICall,
303007
304356
  onProgress: featureOpts.onProgress,
303008
- onCredentialUpdate: featureOpts.onCredentialUpdate
304357
+ onCredentialUpdate: featureOpts.onCredentialUpdate,
304358
+ getPerceptionPage: featureOpts.getPerceptionPage
303009
304359
  }, config);
303010
304360
  }
303011
304361
  }
@@ -310049,54 +311399,13 @@ var require_mobile_exploration_state = __commonJS({
310049
311399
  var require_mobile_discovery_agent = __commonJS({
310050
311400
  "../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports2) {
310051
311401
  "use strict";
310052
- var __createBinding = exports2 && exports2.__createBinding || (Object.create ? (function(o, m, k, k2) {
310053
- if (k2 === void 0) k2 = k;
310054
- var desc = Object.getOwnPropertyDescriptor(m, k);
310055
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
310056
- desc = { enumerable: true, get: function() {
310057
- return m[k];
310058
- } };
310059
- }
310060
- Object.defineProperty(o, k2, desc);
310061
- }) : (function(o, m, k, k2) {
310062
- if (k2 === void 0) k2 = k;
310063
- o[k2] = m[k];
310064
- }));
310065
- var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? (function(o, v) {
310066
- Object.defineProperty(o, "default", { enumerable: true, value: v });
310067
- }) : function(o, v) {
310068
- o["default"] = v;
310069
- });
310070
- var __importStar = exports2 && exports2.__importStar || /* @__PURE__ */ (function() {
310071
- var ownKeys = function(o) {
310072
- ownKeys = Object.getOwnPropertyNames || function(o2) {
310073
- var ar = [];
310074
- for (var k in o2) if (Object.prototype.hasOwnProperty.call(o2, k)) ar[ar.length] = k;
310075
- return ar;
310076
- };
310077
- return ownKeys(o);
310078
- };
310079
- return function(mod) {
310080
- if (mod && mod.__esModule) return mod;
310081
- var result = {};
310082
- if (mod != null) {
310083
- for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
310084
- }
310085
- __setModuleDefault(result, mod);
310086
- return result;
310087
- };
310088
- })();
310089
311402
  Object.defineProperty(exports2, "__esModule", { value: true });
310090
311403
  exports2.runMobileDiscoveryOnRunner = runMobileDiscoveryOnRunner2;
310091
311404
  var mobile_explorer_js_1 = require_mobile_explorer();
310092
311405
  var mobile_discovery_prompts_js_1 = require_mobile_discovery_prompts();
311406
+ var mobile_generator_js_1 = require_mobile_generator();
310093
311407
  var mobile_exploration_state_js_1 = require_mobile_exploration_state();
310094
311408
  var discover_planner_js_1 = require_discover_planner();
310095
- var GENERATOR_MODULE = "./mobile-generator.js";
310096
- var defaultRunGenerate = async (args) => {
310097
- const mod = await Promise.resolve(`${GENERATOR_MODULE}`).then((s) => __importStar(require(s)));
310098
- return mod.runMobileGeneratePhase(args);
310099
- };
310100
311409
  var EXPLORE_BUDGET_DEEP = 60;
310101
311410
  var EXPLORE_BUDGET_SURVEY = 40;
310102
311411
  function platformForMedium(medium) {
@@ -310131,7 +311440,7 @@ var require_mobile_discovery_agent = __commonJS({
310131
311440
  const onScreenshot = callbacks?.onScreenshot;
310132
311441
  const runExplore = callbacks?.runExplore ?? mobile_explorer_js_1.runMobileExplorePhase;
310133
311442
  const runPlan = callbacks?.runPlan ?? mobile_discovery_prompts_js_1.runMobileAIPlanner;
310134
- const runGenerate = callbacks?.runGenerate ?? defaultRunGenerate;
311443
+ const runGenerate = callbacks?.runGenerate ?? mobile_generator_js_1.runMobileGeneratePhase;
310135
311444
  const log2 = (line) => {
310136
311445
  logs2.push(line);
310137
311446
  try {
@@ -310204,6 +311513,7 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
310204
311513
  featureName: ctx.featureName,
310205
311514
  existingTestNames: ctx.existingTestNames,
310206
311515
  existingFeatureNames: ctx.existingFeatureNames,
311516
+ recordedJourneys: explore.recordedJourneys,
310207
311517
  collectedPages: screenMap,
310208
311518
  collectedTransitions,
310209
311519
  credentials: {
@@ -310271,9 +311581,7 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
310271
311581
  ctx,
310272
311582
  onLog: (line) => log2(line),
310273
311583
  onAICall,
310274
- onTestGenerated: (test) => {
310275
- callbacks?.onTestGenerated?.(test, { phase: "verified" });
310276
- }
311584
+ onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
310277
311585
  });
310278
311586
  const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
310279
311587
  log2(`
@@ -310797,7 +312105,8 @@ Respond with ONLY valid JSON:
310797
312105
  userNotes: ctx.userNotes,
310798
312106
  onAICall,
310799
312107
  onProgress,
310800
- onCredentialUpdate: ctx.onCredentialUpdate
312108
+ onCredentialUpdate: ctx.onCredentialUpdate,
312109
+ getPerceptionPage: () => handle?.page ?? null
310801
312110
  });
310802
312111
  logs2.push(...exploreResult.logs);
310803
312112
  screenshots.push(...exploreResult.screenshots);
@@ -311336,6 +312645,7 @@ Fatal error: ${msg}`);
311336
312645
  onAICall,
311337
312646
  onProgress,
311338
312647
  onCredentialUpdate: ctx.onCredentialUpdate,
312648
+ getPerceptionPage: () => handle?.page ?? null,
311339
312649
  onTestPhaseChange: (phase) => {
311340
312650
  healthObserver?.setTestPhase(phase);
311341
312651
  liveCollector?.setTestPhase(phase);
@@ -323975,6 +325285,8 @@ var require_live_browser_registry = __commonJS({
323975
325285
  exports2.liveBrowserRegistry = void 0;
323976
325286
  var THUMBNAIL_INTERVAL_MS = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_INTERVAL_MS ?? "4000", 10) || 4e3;
323977
325287
  var THUMBNAIL_QUALITY = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_QUALITY ?? "60", 10) || 60;
325288
+ var BROWSER_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_MAX_BASE64_BYTES ?? "400000", 10) || 4e5;
325289
+ var MOBILE_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_MOBILE_THUMBNAIL_MAX_BASE64_BYTES ?? "2000000", 10) || 2e6;
323978
325290
  var STALE_SESSION_MS = parseInt(process.env.LIVE_BROWSER_STALE_MS ?? `${10 * 60 * 1e3}`, 10) || 10 * 60 * 1e3;
323979
325291
  var MAX_SESSIONS = parseInt(process.env.LIVE_BROWSER_MAX_SESSIONS ?? "32", 10) || 32;
323980
325292
  var LiveBrowserRegistry = class {
@@ -324069,7 +325381,8 @@ var require_live_browser_registry = __commonJS({
324069
325381
  entry.currentUrl = snapshot.currentUrl;
324070
325382
  }
324071
325383
  const thumbnail = snapshot.thumbnailJpegBase64;
324072
- if (thumbnail && thumbnail.length <= 16e4) {
325384
+ const thumbnailLimit = entry.meta.surface === "mobile" ? MOBILE_THUMBNAIL_MAX_BASE64_BYTES : BROWSER_THUMBNAIL_MAX_BASE64_BYTES;
325385
+ if (thumbnail && thumbnail.length <= thumbnailLimit) {
324073
325386
  entry.thumbnailJpegBase64 = thumbnail;
324074
325387
  entry.thumbnailCapturedAt = typeof snapshot.thumbnailCapturedAt === "number" && Number.isFinite(snapshot.thumbnailCapturedAt) ? snapshot.thumbnailCapturedAt : Date.now();
324075
325388
  entry.thumbnailWidth = typeof snapshot.thumbnailWidth === "number" && Number.isFinite(snapshot.thumbnailWidth) ? snapshot.thumbnailWidth : void 0;
@@ -324203,7 +325516,7 @@ var require_live_browser_registry = __commonJS({
324203
325516
  scale: "css"
324204
325517
  });
324205
325518
  const base64 = buf.toString("base64");
324206
- if (base64.length > 16e4) {
325519
+ if (base64.length > BROWSER_THUMBNAIL_MAX_BASE64_BYTES) {
324207
325520
  return;
324208
325521
  }
324209
325522
  entry.thumbnailJpegBase64 = base64;
@@ -324515,6 +325828,7 @@ var require_dist2 = __commonJS({
324515
325828
  return api_call_redaction_js_1.headerVal;
324516
325829
  } });
324517
325830
  __exportStar(require_mcp_healer(), exports2);
325831
+ __exportStar(require_selector_registry_context(), exports2);
324518
325832
  var grok_per_test_healer_js_1 = require_grok_per_test_healer();
324519
325833
  Object.defineProperty(exports2, "executeGrokPerTestHeal", { enumerable: true, get: function() {
324520
325834
  return grok_per_test_healer_js_1.executeGrokPerTestHeal;
@@ -324800,7 +326114,7 @@ var require_package4 = __commonJS({
324800
326114
  "package.json"(exports2, module2) {
324801
326115
  module2.exports = {
324802
326116
  name: "@validate.qa/runner",
324803
- version: "1.0.4",
326117
+ version: "1.0.5",
324804
326118
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
324805
326119
  bin: {
324806
326120
  "validate-runner": "dist/cli.js"
@@ -325472,6 +326786,644 @@ function getRunnerCapabilities() {
325472
326786
  };
325473
326787
  }
325474
326788
 
326789
+ // src/services/mobile/mobile-network-capture.ts
326790
+ var import_node_child_process = require("child_process");
326791
+ var import_node_crypto = require("crypto");
326792
+ var import_promises = require("fs/promises");
326793
+ var import_node_os = require("os");
326794
+ var import_node_path = require("path");
326795
+ var import_node_util = require("util");
326796
+ var import_mockttp = require("mockttp");
326797
+ var import_runner_core7 = __toESM(require_dist2());
326798
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
326799
+ var MAX_CALLS = 1e3;
326800
+ var MAX_BODY_CAPTURE_BYTES = 64 * 1024;
326801
+ var DEVICE_CMD_TIMEOUT_MS = 15e3;
326802
+ var CA_KEY_BITS = 2048;
326803
+ var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
326804
+ var PENDING_CLEANUP_DIR = (0, import_node_path.join)((0, import_node_os.tmpdir)(), "validateqa-mobile-capture-pending");
326805
+ function envFlag(name) {
326806
+ const value = (process.env[name] ?? "").trim().toLowerCase();
326807
+ return value === "1" || value === "true" || value === "yes" || value === "on";
326808
+ }
326809
+ function rawHeadersToPairs(rawHeaders) {
326810
+ return rawHeaders.map(([name, value]) => ({ name, value }));
326811
+ }
326812
+ function headerValue(pairs, name) {
326813
+ const lower = name.toLowerCase();
326814
+ return pairs.find((h) => h.name.toLowerCase() === lower)?.value;
326815
+ }
326816
+ function resolveHostIp() {
326817
+ const ifaces = (0, import_node_os.networkInterfaces)();
326818
+ for (const addrs of Object.values(ifaces)) {
326819
+ if (!addrs) continue;
326820
+ for (const addr of addrs) {
326821
+ if (addr.family === "IPv4" && !addr.internal) return addr.address;
326822
+ }
326823
+ }
326824
+ return "127.0.0.1";
326825
+ }
326826
+ function resolveProxyHostForDevice(platform3, isPhysical) {
326827
+ if (platform3 === "ANDROID" && !isPhysical) return ANDROID_EMULATOR_HOST_LOOPBACK;
326828
+ if (platform3 === "IOS" && !isPhysical) return "127.0.0.1";
326829
+ return resolveHostIp();
326830
+ }
326831
+ function normalizeRemoteAddress(addr) {
326832
+ if (!addr) return "";
326833
+ return addr.startsWith("::ffff:") ? addr.slice("::ffff:".length) : addr;
326834
+ }
326835
+ function isLoopbackAddress(addr) {
326836
+ return addr === "127.0.0.1" || addr.startsWith("127.") || addr === "::1" || addr === "";
326837
+ }
326838
+ function isPrivateAddress(addr) {
326839
+ if (/^10\./.test(addr)) return true;
326840
+ if (/^192\.168\./.test(addr)) return true;
326841
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
326842
+ if (/^169\.254\./.test(addr)) return true;
326843
+ const lower = addr.toLowerCase();
326844
+ if (lower.startsWith("fe80:")) return true;
326845
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
326846
+ return false;
326847
+ }
326848
+ function isAllowedProxyPeer(remoteAddress, isPhysical) {
326849
+ const addr = normalizeRemoteAddress(remoteAddress);
326850
+ if (isLoopbackAddress(addr)) return true;
326851
+ return isPhysical && isPrivateAddress(addr);
326852
+ }
326853
+ function installSourceIpGuard(server3, isPhysical, onLog) {
326854
+ const underlying = server3.server;
326855
+ if (!underlying || typeof underlying.on !== "function") {
326856
+ onLog?.(
326857
+ "[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."
326858
+ );
326859
+ return;
326860
+ }
326861
+ underlying.on("connection", (socket) => {
326862
+ if (!isAllowedProxyPeer(socket.remoteAddress, isPhysical)) {
326863
+ onLog?.(`[mobile-net] rejected proxy connection from disallowed source ${socket.remoteAddress}`);
326864
+ socket.destroy();
326865
+ }
326866
+ });
326867
+ }
326868
+ function isTextish(contentType) {
326869
+ if (!contentType) return false;
326870
+ return /json|text|xml|graphql|html|csv|javascript|urlencoded/i.test(contentType);
326871
+ }
326872
+ async function readBodyText(body, contentType) {
326873
+ if (!isTextish(contentType)) return void 0;
326874
+ try {
326875
+ const decoded = await body.getDecodedBuffer();
326876
+ if (!decoded || decoded.length === 0) return void 0;
326877
+ if (decoded.length > MAX_BODY_CAPTURE_BYTES) {
326878
+ return decoded.subarray(0, MAX_BODY_CAPTURE_BYTES).toString("utf-8");
326879
+ }
326880
+ return decoded.toString("utf-8");
326881
+ } catch {
326882
+ return void 0;
326883
+ }
326884
+ }
326885
+ var MitmProxy = class {
326886
+ constructor(caKeyPem, caCertPem, caCertPath, isPhysical, tlsPassthroughAll, onLog) {
326887
+ this.caKeyPem = caKeyPem;
326888
+ this.caCertPem = caCertPem;
326889
+ this.caCertPath = caCertPath;
326890
+ this.isPhysical = isPhysical;
326891
+ this.tlsPassthroughAll = tlsPassthroughAll;
326892
+ this.onLog = onLog;
326893
+ }
326894
+ server = null;
326895
+ inFlight = /* @__PURE__ */ new Map();
326896
+ calls = [];
326897
+ dropped = 0;
326898
+ /** Flips true once any HTTPS response is successfully MITM-ed. */
326899
+ httpsIntercepted = false;
326900
+ /** Best-known reason interception is unavailable, refined by TLS failures. */
326901
+ tlsFailureReason = null;
326902
+ /** Count of opaque HTTPS tunnels when TLS passthrough is enabled. */
326903
+ tlsPassthroughCount = 0;
326904
+ /** Start listening; resolves with the actual bound port. */
326905
+ async listen(preferredPort) {
326906
+ const server3 = (0, import_mockttp.getLocal)({
326907
+ // Our generated CA — mockttp mints per-host leaf certs signed by it.
326908
+ https: {
326909
+ key: this.caKeyPem,
326910
+ cert: this.caCertPem,
326911
+ ...this.tlsPassthroughAll ? { tlsPassthrough: [{ hostname: "*" }] } : {}
326912
+ },
326913
+ // HTTP/2 with HTTP/1.1 fallback; mobile clients negotiate either.
326914
+ http2: "fallback",
326915
+ // Keep the proxy quiet unless explicitly debugging.
326916
+ recordTraffic: false
326917
+ });
326918
+ await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
326919
+ await server3.on("request", (req) => this.onRequest(req));
326920
+ await server3.on("response", (res) => this.onResponse(res));
326921
+ await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
326922
+ await server3.on("tls-passthrough-opened", (event) => this.onTlsPassthroughOpened(event));
326923
+ await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
326924
+ installSourceIpGuard(server3, this.isPhysical, this.onLog);
326925
+ this.server = server3;
326926
+ return server3.port;
326927
+ }
326928
+ /** Captured calls, finalized through the shared redaction constructor. */
326929
+ finalize() {
326930
+ return this.calls.map(
326931
+ (c) => (0, import_runner_core7.buildApiCallSummary)({
326932
+ method: c.method,
326933
+ url: c.url,
326934
+ status: c.status,
326935
+ durationMs: c.durationMs,
326936
+ authHeader: c.authHeader,
326937
+ cookieHeader: c.cookieHeader,
326938
+ requestContentType: c.requestContentType,
326939
+ responseContentType: c.responseContentType,
326940
+ respHeaders: c.respHeaders,
326941
+ reqBodyText: c.reqBodyText,
326942
+ respBodyText: c.respBodyText,
326943
+ responseSize: c.responseSize,
326944
+ timestamp: c.startedAt,
326945
+ testPhase: "UNKNOWN"
326946
+ })
326947
+ );
326948
+ }
326949
+ /** True once at least one HTTPS exchange was decrypted (CA trusted). */
326950
+ get isIntercepting() {
326951
+ return this.httpsIntercepted;
326952
+ }
326953
+ /** A precise reason interception is (still) unavailable, or null if it works. */
326954
+ get interceptionReason() {
326955
+ if (this.httpsIntercepted) return null;
326956
+ if (this.tlsPassthroughAll) {
326957
+ 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}.` : ".");
326958
+ }
326959
+ return this.tlsFailureReason;
326960
+ }
326961
+ async close() {
326962
+ const server3 = this.server;
326963
+ this.server = null;
326964
+ this.inFlight.clear();
326965
+ if (!server3) return;
326966
+ try {
326967
+ await server3.stop();
326968
+ } catch (err) {
326969
+ this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg(err)}`);
326970
+ }
326971
+ this.onLog?.(
326972
+ `[mobile-net] proxy closed: ${this.calls.length} calls captured` + (this.dropped > 0 ? ` (${this.dropped} dropped over cap)` : "")
326973
+ );
326974
+ }
326975
+ // ── request → buffer by id ────────────────────────────────────────────────
326976
+ async onRequest(req) {
326977
+ try {
326978
+ const reqHeaders = rawHeadersToPairs(req.rawHeaders);
326979
+ const requestContentType = headerValue(reqHeaders, "content-type");
326980
+ const isHttps = isHttpsUrl(req.url);
326981
+ const reqBodyText = (0, import_runner_core7.isApiCall)(req.url, requestContentType) ? await readBodyText(req.body, requestContentType) : void 0;
326982
+ this.inFlight.set(req.id, {
326983
+ method: req.method,
326984
+ url: req.url,
326985
+ startedAt: req.timingEvents.startTime,
326986
+ reqHeaders,
326987
+ requestContentType,
326988
+ authHeader: headerValue(reqHeaders, "authorization"),
326989
+ cookieHeader: headerValue(reqHeaders, "cookie"),
326990
+ reqBodyText,
326991
+ isHttps
326992
+ });
326993
+ } catch (err) {
326994
+ this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg(err)}`);
326995
+ }
326996
+ }
326997
+ // ── response → finalize the matched request ───────────────────────────────
326998
+ async onResponse(res) {
326999
+ const pending = this.inFlight.get(res.id);
327000
+ if (!pending) return;
327001
+ this.inFlight.delete(res.id);
327002
+ try {
327003
+ if (pending.isHttps && !this.httpsIntercepted) {
327004
+ this.httpsIntercepted = true;
327005
+ this.tlsFailureReason = null;
327006
+ this.onLog?.("[mobile-net] HTTPS interception confirmed (CA trusted; bodies decrypted).");
327007
+ }
327008
+ const respHeaders = rawHeadersToPairs(res.rawHeaders);
327009
+ const responseContentType = headerValue(respHeaders, "content-type");
327010
+ const api = (0, import_runner_core7.isApiCall)(pending.url, responseContentType);
327011
+ if (!api && (0, import_runner_core7.isStaticAssetByExt)(pending.url)) return;
327012
+ if (this.calls.length >= MAX_CALLS) {
327013
+ this.dropped++;
327014
+ return;
327015
+ }
327016
+ const respBodyText = api ? await readBodyText(res.body, responseContentType) : void 0;
327017
+ const responseSize = await measureBody(res.body);
327018
+ const durationMs = computeDuration(pending.startedAt, res);
327019
+ this.calls.push({
327020
+ method: pending.method,
327021
+ url: pending.url,
327022
+ status: res.statusCode,
327023
+ startedAt: pending.startedAt,
327024
+ durationMs,
327025
+ reqHeaders: pending.reqHeaders,
327026
+ respHeaders,
327027
+ requestContentType: pending.requestContentType,
327028
+ responseContentType,
327029
+ authHeader: pending.authHeader,
327030
+ cookieHeader: pending.cookieHeader,
327031
+ reqBodyText: pending.reqBodyText,
327032
+ respBodyText,
327033
+ responseSize,
327034
+ intercepted: true
327035
+ });
327036
+ } catch (err) {
327037
+ this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg(err)}`);
327038
+ }
327039
+ }
327040
+ // ── tls-client-error → record WHY interception failed ─────────────────────
327041
+ onTlsClientError(failure) {
327042
+ if (this.httpsIntercepted) return;
327043
+ const host = failure.destination?.hostname ?? failure.tlsMetadata.sniHostname ?? "unknown host";
327044
+ 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.";
327045
+ let reason;
327046
+ switch (failure.failureCause) {
327047
+ case "cert-rejected":
327048
+ 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;
327049
+ break;
327050
+ case "closed":
327051
+ case "reset":
327052
+ 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).`;
327053
+ break;
327054
+ case "no-shared-cipher":
327055
+ reason = `TLS handshake to ${host} failed: no shared cipher with the client.`;
327056
+ break;
327057
+ case "handshake-timeout":
327058
+ reason = `TLS handshake to ${host} timed out before completing.`;
327059
+ break;
327060
+ default:
327061
+ reason = `TLS handshake to ${host} failed (${failure.failureCause}); HTTPS not intercepted for it.`;
327062
+ }
327063
+ if (!this.tlsFailureReason || failure.failureCause === "cert-rejected" || failure.failureCause === "closed" || failure.failureCause === "reset") {
327064
+ this.tlsFailureReason = reason;
327065
+ }
327066
+ this.onLog?.(`[mobile-net] ${reason}`);
327067
+ }
327068
+ onTlsPassthroughOpened(event) {
327069
+ this.tlsPassthroughCount++;
327070
+ if (this.tlsPassthroughCount === 1) {
327071
+ const host = event.destination?.hostname ?? event.hostname ?? "unknown host";
327072
+ this.onLog?.(
327073
+ `[mobile-net] HTTPS TLS passthrough active for ${host}; app traffic is preserved, but HTTPS bodies are not decrypted.`
327074
+ );
327075
+ }
327076
+ }
327077
+ };
327078
+ function isHttpsUrl(url) {
327079
+ try {
327080
+ return new URL(url).protocol === "https:";
327081
+ } catch {
327082
+ return false;
327083
+ }
327084
+ }
327085
+ async function measureBody(body) {
327086
+ try {
327087
+ const decoded = await body.getDecodedBuffer();
327088
+ return decoded ? decoded.length : 0;
327089
+ } catch {
327090
+ return 0;
327091
+ }
327092
+ }
327093
+ function computeDuration(startedAt, res) {
327094
+ const sent = res.timingEvents.responseSentTimestamp;
327095
+ const start = res.timingEvents.startTimestamp;
327096
+ if (typeof sent === "number" && typeof start === "number" && sent >= start) {
327097
+ return Math.round(sent - start);
327098
+ }
327099
+ const elapsed = Date.now() - startedAt;
327100
+ return elapsed > 0 ? elapsed : 0;
327101
+ }
327102
+ async function generateCaCert(dir, onLog) {
327103
+ try {
327104
+ const { key, cert } = await (0, import_mockttp.generateCACertificate)({
327105
+ subject: {
327106
+ commonName: "validate.qa Mobile Capture CA",
327107
+ organizationName: "validate.qa"
327108
+ },
327109
+ bits: CA_KEY_BITS
327110
+ // mockttp's generated CA carries its own (multi-year) validity window; we
327111
+ // rely on stop() to remove the cert from the device promptly after the run
327112
+ // rather than on a short expiry.
327113
+ });
327114
+ const caCertPath = (0, import_node_path.join)(dir, "validateqa-mobile-ca.pem");
327115
+ await (0, import_promises.writeFile)(caCertPath, cert, "utf-8");
327116
+ return { caCertPath, caKeyPem: key, caCertPem: cert };
327117
+ } catch (err) {
327118
+ onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg(err)}`);
327119
+ return null;
327120
+ }
327121
+ }
327122
+ async function androidReadProxy(deviceId) {
327123
+ try {
327124
+ const { stdout } = await execFileAsync(
327125
+ "adb",
327126
+ ["-s", deviceId, "shell", "settings", "get", "global", "http_proxy"],
327127
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327128
+ );
327129
+ const value = stdout.trim();
327130
+ return value === "" || value === "null" ? null : value;
327131
+ } catch {
327132
+ return null;
327133
+ }
327134
+ }
327135
+ async function androidSetProxy(deviceId, hostPort, onLog) {
327136
+ try {
327137
+ await execFileAsync(
327138
+ "adb",
327139
+ ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", hostPort],
327140
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327141
+ );
327142
+ return true;
327143
+ } catch (err) {
327144
+ onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg(err)}`);
327145
+ return false;
327146
+ }
327147
+ }
327148
+ async function androidClearProxy(deviceId, originalProxy, onLog) {
327149
+ const restoreValue = originalProxy && originalProxy.length > 0 ? originalProxy : ":0";
327150
+ try {
327151
+ await execFileAsync(
327152
+ "adb",
327153
+ ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue],
327154
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327155
+ );
327156
+ } catch (err) {
327157
+ onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg(err)}`);
327158
+ }
327159
+ }
327160
+ async function androidInstallCa(deviceId, caCertPath) {
327161
+ const devicePath = "/sdcard/validateqa-mobile-ca.pem";
327162
+ try {
327163
+ await execFileAsync("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
327164
+ timeout: DEVICE_CMD_TIMEOUT_MS
327165
+ });
327166
+ return {
327167
+ installed: false,
327168
+ 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.`
327169
+ };
327170
+ } catch (err) {
327171
+ return { installed: false, note: `CA push failed: ${errMsg(err)}` };
327172
+ }
327173
+ }
327174
+ async function androidRemoveCa(deviceId, onLog) {
327175
+ try {
327176
+ await execFileAsync(
327177
+ "adb",
327178
+ ["-s", deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"],
327179
+ { timeout: DEVICE_CMD_TIMEOUT_MS }
327180
+ );
327181
+ } catch (err) {
327182
+ onLog?.(`[mobile-net] adb remove CA failed: ${errMsg(err)}`);
327183
+ }
327184
+ }
327185
+ async function iosSimTrustCa(deviceId, caCertPath) {
327186
+ try {
327187
+ await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
327188
+ timeout: DEVICE_CMD_TIMEOUT_MS
327189
+ });
327190
+ return {
327191
+ trusted: true,
327192
+ 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."
327193
+ };
327194
+ } catch (err) {
327195
+ return { trusted: false, note: `simctl add-root-cert failed: ${errMsg(err)}` };
327196
+ }
327197
+ }
327198
+ async function iosSimRemoveCa(deviceId, onLog) {
327199
+ if (process.env.VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET === "1") {
327200
+ try {
327201
+ await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "reset"], {
327202
+ timeout: DEVICE_CMD_TIMEOUT_MS
327203
+ });
327204
+ onLog?.(`[mobile-net] simctl keychain reset run for ${deviceId} (opt-in; cleared ALL sim certs).`);
327205
+ } catch (err) {
327206
+ onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg(err)}`);
327207
+ }
327208
+ return;
327209
+ }
327210
+ onLog?.(
327211
+ `[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.`
327212
+ );
327213
+ }
327214
+ function errMsg(err) {
327215
+ return err instanceof Error ? err.message : String(err);
327216
+ }
327217
+ var activeCleanups = /* @__PURE__ */ new Map();
327218
+ var exitHandlersInstalled = false;
327219
+ function markerPath(sessionKey) {
327220
+ return (0, import_node_path.join)(PENDING_CLEANUP_DIR, `${sessionKey}.json`);
327221
+ }
327222
+ async function writePendingCleanup(sessionKey, marker) {
327223
+ try {
327224
+ await (0, import_promises.mkdir)(PENDING_CLEANUP_DIR, { recursive: true });
327225
+ await (0, import_promises.writeFile)(markerPath(sessionKey), JSON.stringify(marker), "utf-8");
327226
+ } catch {
327227
+ }
327228
+ }
327229
+ async function removePendingCleanup(sessionKey) {
327230
+ try {
327231
+ await (0, import_promises.unlink)(markerPath(sessionKey));
327232
+ } catch {
327233
+ }
327234
+ }
327235
+ function restoreDeviceSync(marker, onLog) {
327236
+ const run2 = (args) => {
327237
+ try {
327238
+ (0, import_node_child_process.execFileSync)("adb", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
327239
+ } catch {
327240
+ }
327241
+ };
327242
+ if (marker.platform === "ANDROID") {
327243
+ if (marker.proxySet) {
327244
+ const restoreValue = marker.originalProxy && marker.originalProxy.length > 0 ? marker.originalProxy : ":0";
327245
+ run2(["-s", marker.deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue]);
327246
+ }
327247
+ if (marker.caInstalledOnDevice) {
327248
+ run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
327249
+ }
327250
+ }
327251
+ onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
327252
+ }
327253
+ async function reconcilePendingCleanups(onLog) {
327254
+ let files;
327255
+ try {
327256
+ files = await (0, import_promises.readdir)(PENDING_CLEANUP_DIR);
327257
+ } catch {
327258
+ return;
327259
+ }
327260
+ for (const file of files) {
327261
+ if (!file.endsWith(".json")) continue;
327262
+ const full = (0, import_node_path.join)(PENDING_CLEANUP_DIR, file);
327263
+ try {
327264
+ const raw = await (0, import_promises.readFile)(full, "utf-8");
327265
+ const marker = JSON.parse(raw);
327266
+ if (marker && typeof marker.deviceId === "string" && marker.platform) {
327267
+ onLog?.(`[mobile-net] reconciling orphaned capture cleanup for ${marker.deviceId}.`);
327268
+ restoreDeviceSync(marker, onLog);
327269
+ }
327270
+ } catch {
327271
+ }
327272
+ try {
327273
+ await (0, import_promises.unlink)(full);
327274
+ } catch {
327275
+ }
327276
+ }
327277
+ }
327278
+ function ensureExitHandlers() {
327279
+ if (exitHandlersInstalled) return;
327280
+ exitHandlersInstalled = true;
327281
+ const drain = () => {
327282
+ for (const marker of activeCleanups.values()) {
327283
+ restoreDeviceSync(marker);
327284
+ }
327285
+ };
327286
+ process.on("beforeExit", drain);
327287
+ process.on("exit", drain);
327288
+ for (const signal of ["SIGTERM", "SIGINT"]) {
327289
+ process.on(signal, () => {
327290
+ drain();
327291
+ process.removeAllListeners(signal);
327292
+ process.kill(process.pid, signal);
327293
+ });
327294
+ }
327295
+ }
327296
+ async function startMobileNetworkCapture(options) {
327297
+ const { platform: platform3, deviceId, isPhysical = false, preferredPort = 0, onLog } = options;
327298
+ const forceTlsMitm = options.forceTlsMitm ?? envFlag("VALIDATEQA_MOBILE_FORCE_TLS_MITM");
327299
+ const tlsPassthroughAll = !forceTlsMitm && (platform3 === "ANDROID" || platform3 === "IOS" && isPhysical);
327300
+ await reconcilePendingCleanups(onLog);
327301
+ ensureExitHandlers();
327302
+ const sessionKey = (0, import_node_crypto.randomUUID)();
327303
+ const originalProxy = platform3 === "ANDROID" ? await androidReadProxy(deviceId) : null;
327304
+ let tmpDir = null;
327305
+ let caCertPath = null;
327306
+ let caKeyPem = null;
327307
+ let caCertPem = null;
327308
+ try {
327309
+ tmpDir = await (0, import_promises.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "validateqa-mobile-ca-"));
327310
+ const ca = await generateCaCert(tmpDir, onLog);
327311
+ if (ca) {
327312
+ caCertPath = ca.caCertPath;
327313
+ caKeyPem = ca.caKeyPem;
327314
+ caCertPem = ca.caCertPem;
327315
+ }
327316
+ } catch (err) {
327317
+ onLog?.(`[mobile-net] CA setup degraded: ${errMsg(err)}`);
327318
+ }
327319
+ if (!caKeyPem || !caCertPem) {
327320
+ try {
327321
+ const fallback = await (0, import_mockttp.generateCACertificate)();
327322
+ caKeyPem = fallback.key;
327323
+ caCertPem = fallback.cert;
327324
+ } catch (err) {
327325
+ onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg(err)}`);
327326
+ if (tmpDir) {
327327
+ await (0, import_promises.rm)(tmpDir, { recursive: true, force: true }).catch(() => void 0);
327328
+ }
327329
+ throw err instanceof Error ? err : new Error(String(err));
327330
+ }
327331
+ }
327332
+ const proxy = new MitmProxy(caKeyPem, caCertPem, caCertPath, isPhysical, tlsPassthroughAll, onLog);
327333
+ const proxyPort = await proxy.listen(preferredPort);
327334
+ const proxyHost = resolveProxyHostForDevice(platform3, isPhysical);
327335
+ const hostPort = `${proxyHost}:${proxyPort}`;
327336
+ onLog?.(
327337
+ `[mobile-net] ${tlsPassthroughAll ? "capture proxy" : "MITM proxy"} listening; device target ${hostPort} (${platform3}${isPhysical ? " physical" : ""})`
327338
+ );
327339
+ let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
327340
+ let proxySet = false;
327341
+ let caInstalledOnDevice = false;
327342
+ try {
327343
+ if (platform3 === "ANDROID") {
327344
+ proxySet = await androidSetProxy(deviceId, hostPort, onLog);
327345
+ if (!proxySet) {
327346
+ wiringReason = "adb proxy configuration failed; no device traffic will be routed through the proxy.";
327347
+ } else if (tlsPassthroughAll) {
327348
+ 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}.` : ".");
327349
+ } else if (caCertPath) {
327350
+ const { note } = await androidInstallCa(deviceId, caCertPath);
327351
+ caInstalledOnDevice = true;
327352
+ wiringReason = note;
327353
+ }
327354
+ } else {
327355
+ if (!isPhysical && caCertPath) {
327356
+ const { trusted, note } = await iosSimTrustCa(deviceId, caCertPath);
327357
+ caInstalledOnDevice = trusted;
327358
+ wiringReason = note;
327359
+ } else if (isPhysical) {
327360
+ 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}).` : ".");
327361
+ }
327362
+ }
327363
+ } catch (err) {
327364
+ wiringReason = `device wiring degraded: ${errMsg(err)}`;
327365
+ onLog?.(`[mobile-net] ${wiringReason}`);
327366
+ }
327367
+ if (proxySet || caInstalledOnDevice) {
327368
+ const marker = {
327369
+ platform: platform3,
327370
+ deviceId,
327371
+ isPhysical,
327372
+ proxySet,
327373
+ originalProxy,
327374
+ caInstalledOnDevice
327375
+ };
327376
+ activeCleanups.set(sessionKey, marker);
327377
+ await writePendingCleanup(sessionKey, marker);
327378
+ }
327379
+ const capturedTmpDir = tmpDir;
327380
+ const capturedCaCertPath = caCertPath;
327381
+ let stopped = false;
327382
+ let finalCalls = [];
327383
+ const getCapabilities = () => {
327384
+ if (proxy.isIntercepting) return { intercepting: true };
327385
+ const reason = proxy.interceptionReason ?? wiringReason;
327386
+ return reason ? { intercepting: false, reason } : { intercepting: false };
327387
+ };
327388
+ const stop = async () => {
327389
+ if (stopped) return finalCalls;
327390
+ stopped = true;
327391
+ if (proxySet && platform3 === "ANDROID") {
327392
+ await androidClearProxy(deviceId, originalProxy, onLog);
327393
+ }
327394
+ if (caInstalledOnDevice && capturedCaCertPath) {
327395
+ if (platform3 === "ANDROID") {
327396
+ await androidRemoveCa(deviceId, onLog);
327397
+ } else if (!isPhysical) {
327398
+ await iosSimRemoveCa(deviceId, onLog);
327399
+ }
327400
+ }
327401
+ activeCleanups.delete(sessionKey);
327402
+ await removePendingCleanup(sessionKey);
327403
+ finalCalls = proxy.finalize();
327404
+ await proxy.close();
327405
+ if (capturedTmpDir) {
327406
+ try {
327407
+ await (0, import_promises.rm)(capturedTmpDir, { recursive: true, force: true });
327408
+ } catch (err) {
327409
+ onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg(err)}`);
327410
+ }
327411
+ }
327412
+ const apiCount = finalCalls.filter((c) => c.isApi).length;
327413
+ onLog?.(
327414
+ `[mobile-net] stopped: ${finalCalls.length} calls captured (${apiCount} API; HTTPS interception ${proxy.isIntercepting ? "ACTIVE" : "inactive"}).`
327415
+ );
327416
+ return finalCalls;
327417
+ };
327418
+ return {
327419
+ proxyPort,
327420
+ proxyHost,
327421
+ caCertPath: capturedCaCertPath,
327422
+ getCapabilities,
327423
+ stop
327424
+ };
327425
+ }
327426
+
325475
327427
  // src/services/appium-executor.ts
325476
327428
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
325477
327429
  var WebDriverTransportError = class extends Error {
@@ -325857,7 +327809,24 @@ async function executeMobileTest(options) {
325857
327809
  const startTime = Date.now();
325858
327810
  const stepResults = [];
325859
327811
  const screenshots = [];
327812
+ let apiCalls = [];
325860
327813
  let sessionId = null;
327814
+ let capture = null;
327815
+ let captureStopped = false;
327816
+ const stopCapture = async () => {
327817
+ if (!capture || captureStopped) return apiCalls;
327818
+ captureStopped = true;
327819
+ try {
327820
+ const captured = await capture.stop();
327821
+ apiCalls = captured.map((call) => ({
327822
+ ...call,
327823
+ pageUrl: call.pageUrl ?? `mobile-test:${options.target.appId}`
327824
+ }));
327825
+ } catch (err) {
327826
+ log2(`[mobile-net] capture stop failed: ${err instanceof Error ? err.message : String(err)}`);
327827
+ }
327828
+ return apiCalls;
327829
+ };
325861
327830
  try {
325862
327831
  log2("Checking Appium server health...");
325863
327832
  const health = await checkAppiumHealth();
@@ -325885,6 +327854,21 @@ async function executeMobileTest(options) {
325885
327854
  selectedDevice = matchingDevices[0];
325886
327855
  }
325887
327856
  log2(`Using device: ${selectedDevice.name} (${selectedDevice.id})`);
327857
+ try {
327858
+ capture = await startMobileNetworkCapture({
327859
+ platform: options.target.platform,
327860
+ deviceId: selectedDevice.id,
327861
+ appId: options.target.appId,
327862
+ isPhysical: selectedDevice.isPhysical,
327863
+ onLog: (line) => log2(line)
327864
+ });
327865
+ const caps = capture.getCapabilities();
327866
+ if (!caps.intercepting) {
327867
+ log2(`[mobile-net] HTTPS interception unavailable: ${caps.reason ?? "unknown reason"} (continuing - plaintext/metadata only)`);
327868
+ }
327869
+ } catch (err) {
327870
+ log2(`[mobile-net] capture unavailable: ${err instanceof Error ? err.message : String(err)} (continuing without network log)`);
327871
+ }
325888
327872
  if (selectedDevice.installedApps && selectedDevice.installedApps.length > 0) {
325889
327873
  if (!selectedDevice.installedApps.includes(options.target.appId)) {
325890
327874
  throw new Error(
@@ -325994,10 +327978,12 @@ async function executeMobileTest(options) {
325994
327978
  }
325995
327979
  const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
325996
327980
  const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
327981
+ await stopCapture();
325997
327982
  return {
325998
327983
  passed: allPassed,
325999
327984
  stepResults,
326000
327985
  screenshots,
327986
+ apiCalls,
326001
327987
  logs: logLines.join("\n"),
326002
327988
  error: firstError,
326003
327989
  duration: Date.now() - startTime,
@@ -326006,10 +327992,12 @@ async function executeMobileTest(options) {
326006
327992
  } catch (err) {
326007
327993
  const errorMsg = err instanceof Error ? err.message : String(err);
326008
327994
  log2(`Fatal error: ${errorMsg}`);
327995
+ await stopCapture();
326009
327996
  return {
326010
327997
  passed: false,
326011
327998
  stepResults,
326012
327999
  screenshots,
328000
+ apiCalls,
326013
328001
  logs: logLines.join("\n"),
326014
328002
  error: errorMsg,
326015
328003
  duration: Date.now() - startTime,
@@ -326019,6 +328007,7 @@ async function executeMobileTest(options) {
326019
328007
  environmental: errorMsg
326020
328008
  };
326021
328009
  } finally {
328010
+ await stopCapture();
326022
328011
  if (sessionId) {
326023
328012
  log2("Tearing down Appium session...");
326024
328013
  await deleteSession(sessionId);
@@ -326026,7 +328015,7 @@ async function executeMobileTest(options) {
326026
328015
  }
326027
328016
  }
326028
328017
  }
326029
- async function createMobileDiscoverySession(target) {
328018
+ async function createMobileDiscoverySession(target, options) {
326030
328019
  const health = await checkAppiumHealth();
326031
328020
  if (!health.ok) {
326032
328021
  await startAppiumServer();
@@ -326053,6 +328042,12 @@ async function createMobileDiscoverySession(target) {
326053
328042
  `App ${target.appId} not installed on ${selectedDevice.name}. Install it first.`
326054
328043
  );
326055
328044
  }
328045
+ await options?.onDeviceSelected?.({
328046
+ deviceId: selectedDevice.id,
328047
+ name: selectedDevice.name,
328048
+ isPhysical: selectedDevice.isPhysical === true,
328049
+ platformVersion: selectedDevice.platformVersion
328050
+ });
326056
328051
  const sessionId = await createSession({
326057
328052
  appId: target.appId,
326058
328053
  platform: target.platform,
@@ -327245,7 +329240,7 @@ function detachMirrorSession(sessionId) {
327245
329240
  }
327246
329241
 
327247
329242
  // src/services/mobile/mobile-bridge-driver.ts
327248
- var import_runner_core7 = __toESM(require_dist2());
329243
+ var import_runner_core8 = __toESM(require_dist2());
327249
329244
  init_dist();
327250
329245
  var APPIUM_SESSION_ID_PATTERN2 = /^[a-f0-9-]{1,64}$/i;
327251
329246
  function readStringValue(result) {
@@ -327255,6 +329250,15 @@ function readStringValue(result) {
327255
329250
  }
327256
329251
  return void 0;
327257
329252
  }
329253
+ function normalizeAndroidActivity(activity, pkg2) {
329254
+ const trimmed = activity?.trim();
329255
+ if (!trimmed) return void 0;
329256
+ const packageName = pkg2?.trim();
329257
+ if (!packageName) return trimmed;
329258
+ if (trimmed.startsWith(".")) return `${packageName}${trimmed}`;
329259
+ if (!trimmed.includes(".")) return `${packageName}.${trimmed}`;
329260
+ return trimmed;
329261
+ }
327258
329262
  var MobileBridgeDriver = class {
327259
329263
  sessionId;
327260
329264
  platform;
@@ -327281,6 +329285,38 @@ var MobileBridgeDriver = class {
327281
329285
  appLifecycleArgs() {
327282
329286
  return this.platform === "ANDROID" ? { appId: this.appId } : { bundleId: this.appId };
327283
329287
  }
329288
+ baseScreenSignal(source) {
329289
+ const parsed = (0, import_runner_core8.parseMobileSource)(source, this.platform);
329290
+ return {
329291
+ platform: this.platform,
329292
+ screenHash: parsed.screenSignal.screenHash,
329293
+ ...parsed.screenSignal.activity ? { activity: parsed.screenSignal.activity } : {},
329294
+ ...parsed.screenSignal.bundleId ? { bundleId: parsed.screenSignal.bundleId } : {}
329295
+ };
329296
+ }
329297
+ async enrichScreenSignal(source) {
329298
+ const signal = this.baseScreenSignal(source);
329299
+ try {
329300
+ if (this.platform === "ANDROID") {
329301
+ const [activityRes, packageRes] = await Promise.all([
329302
+ this.executeScript("mobile: getCurrentActivity", []).catch(() => void 0),
329303
+ this.executeScript("mobile: getCurrentPackage", []).catch(() => void 0)
329304
+ ]);
329305
+ const activity = readStringValue(activityRes);
329306
+ const pkg2 = readStringValue(packageRes);
329307
+ if (pkg2) signal.bundleId = pkg2;
329308
+ const normalizedActivity = normalizeAndroidActivity(activity, pkg2);
329309
+ if (normalizedActivity) signal.activity = normalizedActivity;
329310
+ } else {
329311
+ const infoRes = await this.executeScript("mobile: activeAppInfo", []).catch(() => void 0);
329312
+ const bundleId = readActiveBundleId(infoRes);
329313
+ if (bundleId) signal.bundleId = bundleId;
329314
+ }
329315
+ } catch {
329316
+ }
329317
+ if (!signal.bundleId) signal.bundleId = this.appId;
329318
+ return signal;
329319
+ }
327284
329320
  /**
327285
329321
  * Run a `mobile:` script via execute/sync. Used by the NEW lifecycle
327286
329322
  * primitives (terminateApp/activateApp/deepLink/getCurrentActivity) that have
@@ -327295,9 +329331,7 @@ var MobileBridgeDriver = class {
327295
329331
  // ── Observation ────────────────────────────────────────
327296
329332
  /**
327297
329333
  * One round-trip observation: UI-tree XML + base64 screenshot + window size,
327298
- * plus a best-effort screen signal derived from the parsed XML (cheap — no
327299
- * extra device round-trip; the activity/bundleId enrichment is left to
327300
- * getScreenSignal()).
329334
+ * plus a best-effort screen signal enriched with the foreground app identity.
327301
329335
  */
327302
329336
  async snapshot() {
327303
329337
  const [xml, screenshot, windowSize] = await Promise.all([
@@ -327306,10 +329340,7 @@ var MobileBridgeDriver = class {
327306
329340
  getWindowSize2(this.sessionPath)
327307
329341
  ]);
327308
329342
  const source = xml ?? "";
327309
- const screenSignal = {
327310
- platform: this.platform,
327311
- screenHash: (0, import_runner_core7.parseMobileSource)(source, this.platform).screenSignal.screenHash
327312
- };
329343
+ const screenSignal = await this.enrichScreenSignal(source);
327313
329344
  return {
327314
329345
  xml: source,
327315
329346
  screenshot,
@@ -327338,7 +329369,8 @@ var MobileBridgeDriver = class {
327338
329369
  getScreenshot(this.sessionPath),
327339
329370
  getPageSource(this.sessionPath)
327340
329371
  ]);
327341
- return { ok: true, screenshot, source };
329372
+ const screenSignal = await this.enrichScreenSignal(source ?? "");
329373
+ return { ok: true, screenshot, source, screenSignal };
327342
329374
  }
327343
329375
  // ── Action primitives (1:1 with the bridge handleAction verbs) ──
327344
329376
  /** Center-tap the given bounds (coordinate-only; the loop always supplies bounds). */
@@ -327416,27 +329448,7 @@ var MobileBridgeDriver = class {
327416
329448
  */
327417
329449
  async getScreenSignal() {
327418
329450
  const source = await getPageSource(this.sessionPath) ?? "";
327419
- const screenHash = (0, import_runner_core7.parseMobileSource)(source, this.platform).screenSignal.screenHash;
327420
- const signal = { platform: this.platform, screenHash };
327421
- try {
327422
- if (this.platform === "ANDROID") {
327423
- const [activityRes, packageRes] = await Promise.all([
327424
- this.executeScript("mobile: getCurrentActivity", []).catch(() => void 0),
327425
- this.executeScript("mobile: getCurrentPackage", []).catch(() => void 0)
327426
- ]);
327427
- const activity = readStringValue(activityRes);
327428
- const pkg2 = readStringValue(packageRes);
327429
- if (activity) signal.activity = activity;
327430
- signal.bundleId = pkg2 ?? this.appId;
327431
- } else {
327432
- const infoRes = await this.executeScript("mobile: activeAppInfo", []).catch(() => void 0);
327433
- const bundleId = readActiveBundleId(infoRes);
327434
- signal.bundleId = bundleId ?? this.appId;
327435
- }
327436
- } catch {
327437
- if (!signal.bundleId) signal.bundleId = this.appId;
327438
- }
327439
- return signal;
329451
+ return this.enrichScreenSignal(source);
327440
329452
  }
327441
329453
  };
327442
329454
  function readActiveBundleId(result) {
@@ -327450,614 +329462,6 @@ function readActiveBundleId(result) {
327450
329462
  return void 0;
327451
329463
  }
327452
329464
 
327453
- // src/services/mobile/mobile-network-capture.ts
327454
- var import_node_child_process = require("child_process");
327455
- var import_node_crypto = require("crypto");
327456
- var import_promises = require("fs/promises");
327457
- var import_node_os = require("os");
327458
- var import_node_path = require("path");
327459
- var import_node_util = require("util");
327460
- var import_mockttp = require("mockttp");
327461
- var import_runner_core8 = __toESM(require_dist2());
327462
- var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
327463
- var MAX_CALLS = 1e3;
327464
- var MAX_BODY_CAPTURE_BYTES = 64 * 1024;
327465
- var DEVICE_CMD_TIMEOUT_MS = 15e3;
327466
- var CA_KEY_BITS = 2048;
327467
- var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
327468
- var PENDING_CLEANUP_DIR = (0, import_node_path.join)((0, import_node_os.tmpdir)(), "validateqa-mobile-capture-pending");
327469
- function rawHeadersToPairs(rawHeaders) {
327470
- return rawHeaders.map(([name, value]) => ({ name, value }));
327471
- }
327472
- function headerValue(pairs, name) {
327473
- const lower = name.toLowerCase();
327474
- return pairs.find((h) => h.name.toLowerCase() === lower)?.value;
327475
- }
327476
- function resolveHostIp() {
327477
- const ifaces = (0, import_node_os.networkInterfaces)();
327478
- for (const addrs of Object.values(ifaces)) {
327479
- if (!addrs) continue;
327480
- for (const addr of addrs) {
327481
- if (addr.family === "IPv4" && !addr.internal) return addr.address;
327482
- }
327483
- }
327484
- return "127.0.0.1";
327485
- }
327486
- function resolveProxyHostForDevice(platform3, isPhysical) {
327487
- if (platform3 === "ANDROID" && !isPhysical) return ANDROID_EMULATOR_HOST_LOOPBACK;
327488
- if (platform3 === "IOS" && !isPhysical) return "127.0.0.1";
327489
- return resolveHostIp();
327490
- }
327491
- function normalizeRemoteAddress(addr) {
327492
- if (!addr) return "";
327493
- return addr.startsWith("::ffff:") ? addr.slice("::ffff:".length) : addr;
327494
- }
327495
- function isLoopbackAddress(addr) {
327496
- return addr === "127.0.0.1" || addr.startsWith("127.") || addr === "::1" || addr === "";
327497
- }
327498
- function isPrivateAddress(addr) {
327499
- if (/^10\./.test(addr)) return true;
327500
- if (/^192\.168\./.test(addr)) return true;
327501
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
327502
- if (/^169\.254\./.test(addr)) return true;
327503
- const lower = addr.toLowerCase();
327504
- if (lower.startsWith("fe80:")) return true;
327505
- if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
327506
- return false;
327507
- }
327508
- function isAllowedProxyPeer(remoteAddress, isPhysical) {
327509
- const addr = normalizeRemoteAddress(remoteAddress);
327510
- if (isLoopbackAddress(addr)) return true;
327511
- return isPhysical && isPrivateAddress(addr);
327512
- }
327513
- function installSourceIpGuard(server3, isPhysical, onLog) {
327514
- const underlying = server3.server;
327515
- if (!underlying || typeof underlying.on !== "function") {
327516
- onLog?.(
327517
- "[mobile-net] WARNING: could not attach source-IP guard to the proxy listener; the port is bound to all interfaces \u2014 firewall it to the device subnet."
327518
- );
327519
- return;
327520
- }
327521
- underlying.on("connection", (socket) => {
327522
- if (!isAllowedProxyPeer(socket.remoteAddress, isPhysical)) {
327523
- onLog?.(`[mobile-net] rejected proxy connection from disallowed source ${socket.remoteAddress}`);
327524
- socket.destroy();
327525
- }
327526
- });
327527
- }
327528
- function isTextish(contentType) {
327529
- if (!contentType) return false;
327530
- return /json|text|xml|graphql|html|csv|javascript|urlencoded/i.test(contentType);
327531
- }
327532
- async function readBodyText(body, contentType) {
327533
- if (!isTextish(contentType)) return void 0;
327534
- try {
327535
- const decoded = await body.getDecodedBuffer();
327536
- if (!decoded || decoded.length === 0) return void 0;
327537
- if (decoded.length > MAX_BODY_CAPTURE_BYTES) {
327538
- return decoded.subarray(0, MAX_BODY_CAPTURE_BYTES).toString("utf-8");
327539
- }
327540
- return decoded.toString("utf-8");
327541
- } catch {
327542
- return void 0;
327543
- }
327544
- }
327545
- var MitmProxy = class {
327546
- constructor(caKeyPem, caCertPem, isPhysical, onLog) {
327547
- this.caKeyPem = caKeyPem;
327548
- this.caCertPem = caCertPem;
327549
- this.isPhysical = isPhysical;
327550
- this.onLog = onLog;
327551
- }
327552
- server = null;
327553
- inFlight = /* @__PURE__ */ new Map();
327554
- calls = [];
327555
- dropped = 0;
327556
- /** Flips true once any HTTPS response is successfully MITM-ed. */
327557
- httpsIntercepted = false;
327558
- /** Best-known reason interception is unavailable, refined by TLS failures. */
327559
- tlsFailureReason = null;
327560
- /** Start listening; resolves with the actual bound port. */
327561
- async listen(preferredPort) {
327562
- const server3 = (0, import_mockttp.getLocal)({
327563
- // Our generated CA — mockttp mints per-host leaf certs signed by it.
327564
- https: { key: this.caKeyPem, cert: this.caCertPem },
327565
- // HTTP/2 with HTTP/1.1 fallback; mobile clients negotiate either.
327566
- http2: "fallback",
327567
- // Keep the proxy quiet unless explicitly debugging.
327568
- recordTraffic: false
327569
- });
327570
- await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
327571
- await server3.on("request", (req) => this.onRequest(req));
327572
- await server3.on("response", (res) => this.onResponse(res));
327573
- await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
327574
- await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
327575
- installSourceIpGuard(server3, this.isPhysical, this.onLog);
327576
- this.server = server3;
327577
- return server3.port;
327578
- }
327579
- /** Captured calls, finalized through the shared redaction constructor. */
327580
- finalize() {
327581
- return this.calls.map(
327582
- (c) => (0, import_runner_core8.buildApiCallSummary)({
327583
- method: c.method,
327584
- url: c.url,
327585
- status: c.status,
327586
- durationMs: c.durationMs,
327587
- authHeader: c.authHeader,
327588
- cookieHeader: c.cookieHeader,
327589
- requestContentType: c.requestContentType,
327590
- responseContentType: c.responseContentType,
327591
- respHeaders: c.respHeaders,
327592
- reqBodyText: c.reqBodyText,
327593
- respBodyText: c.respBodyText,
327594
- responseSize: c.responseSize,
327595
- timestamp: c.startedAt,
327596
- testPhase: "UNKNOWN"
327597
- })
327598
- );
327599
- }
327600
- /** True once at least one HTTPS exchange was decrypted (CA trusted). */
327601
- get isIntercepting() {
327602
- return this.httpsIntercepted;
327603
- }
327604
- /** A precise reason interception is (still) unavailable, or null if it works. */
327605
- get interceptionReason() {
327606
- if (this.httpsIntercepted) return null;
327607
- return this.tlsFailureReason;
327608
- }
327609
- async close() {
327610
- const server3 = this.server;
327611
- this.server = null;
327612
- this.inFlight.clear();
327613
- if (!server3) return;
327614
- try {
327615
- await server3.stop();
327616
- } catch (err) {
327617
- this.onLog?.(`[mobile-net] proxy stop error (ignored): ${errMsg(err)}`);
327618
- }
327619
- this.onLog?.(
327620
- `[mobile-net] proxy closed: ${this.calls.length} calls captured` + (this.dropped > 0 ? ` (${this.dropped} dropped over cap)` : "")
327621
- );
327622
- }
327623
- // ── request → buffer by id ────────────────────────────────────────────────
327624
- async onRequest(req) {
327625
- try {
327626
- const reqHeaders = rawHeadersToPairs(req.rawHeaders);
327627
- const requestContentType = headerValue(reqHeaders, "content-type");
327628
- const isHttps = isHttpsUrl(req.url);
327629
- const reqBodyText = (0, import_runner_core8.isApiCall)(req.url, requestContentType) ? await readBodyText(req.body, requestContentType) : void 0;
327630
- this.inFlight.set(req.id, {
327631
- method: req.method,
327632
- url: req.url,
327633
- startedAt: req.timingEvents.startTime,
327634
- reqHeaders,
327635
- requestContentType,
327636
- authHeader: headerValue(reqHeaders, "authorization"),
327637
- cookieHeader: headerValue(reqHeaders, "cookie"),
327638
- reqBodyText,
327639
- isHttps
327640
- });
327641
- } catch (err) {
327642
- this.onLog?.(`[mobile-net] request capture error (ignored): ${errMsg(err)}`);
327643
- }
327644
- }
327645
- // ── response → finalize the matched request ───────────────────────────────
327646
- async onResponse(res) {
327647
- const pending = this.inFlight.get(res.id);
327648
- if (!pending) return;
327649
- this.inFlight.delete(res.id);
327650
- try {
327651
- if (pending.isHttps && !this.httpsIntercepted) {
327652
- this.httpsIntercepted = true;
327653
- this.tlsFailureReason = null;
327654
- this.onLog?.("[mobile-net] HTTPS interception confirmed (CA trusted; bodies decrypted).");
327655
- }
327656
- const respHeaders = rawHeadersToPairs(res.rawHeaders);
327657
- const responseContentType = headerValue(respHeaders, "content-type");
327658
- const api = (0, import_runner_core8.isApiCall)(pending.url, responseContentType);
327659
- if (!api && (0, import_runner_core8.isStaticAssetByExt)(pending.url)) return;
327660
- if (this.calls.length >= MAX_CALLS) {
327661
- this.dropped++;
327662
- return;
327663
- }
327664
- const respBodyText = api ? await readBodyText(res.body, responseContentType) : void 0;
327665
- const responseSize = await measureBody(res.body);
327666
- const durationMs = computeDuration(pending.startedAt, res);
327667
- this.calls.push({
327668
- method: pending.method,
327669
- url: pending.url,
327670
- status: res.statusCode,
327671
- startedAt: pending.startedAt,
327672
- durationMs,
327673
- reqHeaders: pending.reqHeaders,
327674
- respHeaders,
327675
- requestContentType: pending.requestContentType,
327676
- responseContentType,
327677
- authHeader: pending.authHeader,
327678
- cookieHeader: pending.cookieHeader,
327679
- reqBodyText: pending.reqBodyText,
327680
- respBodyText,
327681
- responseSize,
327682
- intercepted: true
327683
- });
327684
- } catch (err) {
327685
- this.onLog?.(`[mobile-net] response capture error (ignored): ${errMsg(err)}`);
327686
- }
327687
- }
327688
- // ── tls-client-error → record WHY interception failed ─────────────────────
327689
- onTlsClientError(failure) {
327690
- if (this.httpsIntercepted) return;
327691
- const host = failure.destination?.hostname ?? failure.tlsMetadata.sniHostname ?? "unknown host";
327692
- let reason;
327693
- switch (failure.failureCause) {
327694
- case "cert-rejected":
327695
- reason = `TLS handshake to ${host} rejected our certificate: the device does not trust the validate.qa CA, so HTTPS bodies cannot be decrypted (plaintext HTTP still captured). Install + trust the CA at caCertPath to enable interception.`;
327696
- break;
327697
- case "closed":
327698
- case "reset":
327699
- reason = `TLS handshake to ${host} was dropped after our certificate was offered \u2014 this is the signature of certificate pinning in the app. HTTPS bodies for pinned hosts cannot be decrypted (plaintext HTTP and connect-level host metadata still captured).`;
327700
- break;
327701
- case "no-shared-cipher":
327702
- reason = `TLS handshake to ${host} failed: no shared cipher with the client.`;
327703
- break;
327704
- case "handshake-timeout":
327705
- reason = `TLS handshake to ${host} timed out before completing.`;
327706
- break;
327707
- default:
327708
- reason = `TLS handshake to ${host} failed (${failure.failureCause}); HTTPS not intercepted for it.`;
327709
- }
327710
- if (!this.tlsFailureReason || failure.failureCause === "cert-rejected" || failure.failureCause === "closed" || failure.failureCause === "reset") {
327711
- this.tlsFailureReason = reason;
327712
- }
327713
- this.onLog?.(`[mobile-net] ${reason}`);
327714
- }
327715
- };
327716
- function isHttpsUrl(url) {
327717
- try {
327718
- return new URL(url).protocol === "https:";
327719
- } catch {
327720
- return false;
327721
- }
327722
- }
327723
- async function measureBody(body) {
327724
- try {
327725
- const decoded = await body.getDecodedBuffer();
327726
- return decoded ? decoded.length : 0;
327727
- } catch {
327728
- return 0;
327729
- }
327730
- }
327731
- function computeDuration(startedAt, res) {
327732
- const sent = res.timingEvents.responseSentTimestamp;
327733
- const start = res.timingEvents.startTimestamp;
327734
- if (typeof sent === "number" && typeof start === "number" && sent >= start) {
327735
- return Math.round(sent - start);
327736
- }
327737
- const elapsed = Date.now() - startedAt;
327738
- return elapsed > 0 ? elapsed : 0;
327739
- }
327740
- async function generateCaCert(dir, onLog) {
327741
- try {
327742
- const { key, cert } = await (0, import_mockttp.generateCACertificate)({
327743
- subject: {
327744
- commonName: "validate.qa Mobile Capture CA",
327745
- organizationName: "validate.qa"
327746
- },
327747
- bits: CA_KEY_BITS
327748
- // mockttp's generated CA carries its own (multi-year) validity window; we
327749
- // rely on stop() to remove the cert from the device promptly after the run
327750
- // rather than on a short expiry.
327751
- });
327752
- const caCertPath = (0, import_node_path.join)(dir, "validateqa-mobile-ca.pem");
327753
- await (0, import_promises.writeFile)(caCertPath, cert, "utf-8");
327754
- return { caCertPath, caKeyPem: key, caCertPem: cert };
327755
- } catch (err) {
327756
- onLog?.(`[mobile-net] CA generation failed; running plaintext-only: ${errMsg(err)}`);
327757
- return null;
327758
- }
327759
- }
327760
- async function androidReadProxy(deviceId) {
327761
- try {
327762
- const { stdout } = await execFileAsync(
327763
- "adb",
327764
- ["-s", deviceId, "shell", "settings", "get", "global", "http_proxy"],
327765
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327766
- );
327767
- const value = stdout.trim();
327768
- return value === "" || value === "null" ? null : value;
327769
- } catch {
327770
- return null;
327771
- }
327772
- }
327773
- async function androidSetProxy(deviceId, hostPort, onLog) {
327774
- try {
327775
- await execFileAsync(
327776
- "adb",
327777
- ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", hostPort],
327778
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327779
- );
327780
- return true;
327781
- } catch (err) {
327782
- onLog?.(`[mobile-net] adb set http_proxy failed: ${errMsg(err)}`);
327783
- return false;
327784
- }
327785
- }
327786
- async function androidClearProxy(deviceId, originalProxy, onLog) {
327787
- const restoreValue = originalProxy && originalProxy.length > 0 ? originalProxy : ":0";
327788
- try {
327789
- await execFileAsync(
327790
- "adb",
327791
- ["-s", deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue],
327792
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327793
- );
327794
- } catch (err) {
327795
- onLog?.(`[mobile-net] adb restore http_proxy failed: ${errMsg(err)}`);
327796
- }
327797
- }
327798
- async function androidInstallCa(deviceId, caCertPath) {
327799
- const devicePath = "/sdcard/validateqa-mobile-ca.pem";
327800
- try {
327801
- await execFileAsync("adb", ["-s", deviceId, "push", caCertPath, devicePath], {
327802
- timeout: DEVICE_CMD_TIMEOUT_MS
327803
- });
327804
- return {
327805
- installed: false,
327806
- note: "CA pushed to /sdcard but NOT auto-trusted: Android 7+ apps must opt into user CAs (network_security_config), and the system store requires a rooted/writable image. HTTPS bodies decrypt only for apps that trust user CAs; pinned apps never decrypt."
327807
- };
327808
- } catch (err) {
327809
- return { installed: false, note: `CA push failed: ${errMsg(err)}` };
327810
- }
327811
- }
327812
- async function androidRemoveCa(deviceId, onLog) {
327813
- try {
327814
- await execFileAsync(
327815
- "adb",
327816
- ["-s", deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"],
327817
- { timeout: DEVICE_CMD_TIMEOUT_MS }
327818
- );
327819
- } catch (err) {
327820
- onLog?.(`[mobile-net] adb remove CA failed: ${errMsg(err)}`);
327821
- }
327822
- }
327823
- async function iosSimTrustCa(deviceId, caCertPath) {
327824
- try {
327825
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "add-root-cert", caCertPath], {
327826
- timeout: DEVICE_CMD_TIMEOUT_MS
327827
- });
327828
- return {
327829
- trusted: true,
327830
- note: "CA added to simulator keychain (add-root-cert). NOTE: the simulator has no per-app HTTP proxy setting controllable via simctl \u2014 it shares the host network, so only traffic that actually routes through the proxy is intercepted; pinned apps still never decrypt."
327831
- };
327832
- } catch (err) {
327833
- return { trusted: false, note: `simctl add-root-cert failed: ${errMsg(err)}` };
327834
- }
327835
- }
327836
- async function iosSimRemoveCa(deviceId, onLog) {
327837
- if (process.env.VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET === "1") {
327838
- try {
327839
- await execFileAsync("xcrun", ["simctl", "keychain", deviceId, "reset"], {
327840
- timeout: DEVICE_CMD_TIMEOUT_MS
327841
- });
327842
- onLog?.(`[mobile-net] simctl keychain reset run for ${deviceId} (opt-in; cleared ALL sim certs).`);
327843
- } catch (err) {
327844
- onLog?.(`[mobile-net] simctl keychain reset failed: ${errMsg(err)}`);
327845
- }
327846
- return;
327847
- }
327848
- onLog?.(
327849
- `[mobile-net] NOTE: the validate.qa capture CA remains TRUSTED in simulator ${deviceId}. simctl has no scoped remove (only a full keychain reset that wipes ALL certs), so we leave the single CA in place. Use a throwaway simulator, or set VALIDATEQA_MOBILE_SIM_KEYCHAIN_RESET=1 to reset the whole sim keychain on teardown.`
327850
- );
327851
- }
327852
- function errMsg(err) {
327853
- return err instanceof Error ? err.message : String(err);
327854
- }
327855
- var activeCleanups = /* @__PURE__ */ new Map();
327856
- var exitHandlersInstalled = false;
327857
- function markerPath(sessionKey) {
327858
- return (0, import_node_path.join)(PENDING_CLEANUP_DIR, `${sessionKey}.json`);
327859
- }
327860
- async function writePendingCleanup(sessionKey, marker) {
327861
- try {
327862
- await (0, import_promises.mkdir)(PENDING_CLEANUP_DIR, { recursive: true });
327863
- await (0, import_promises.writeFile)(markerPath(sessionKey), JSON.stringify(marker), "utf-8");
327864
- } catch {
327865
- }
327866
- }
327867
- async function removePendingCleanup(sessionKey) {
327868
- try {
327869
- await (0, import_promises.unlink)(markerPath(sessionKey));
327870
- } catch {
327871
- }
327872
- }
327873
- function restoreDeviceSync(marker, onLog) {
327874
- const run2 = (args) => {
327875
- try {
327876
- (0, import_node_child_process.execFileSync)("adb", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
327877
- } catch {
327878
- }
327879
- };
327880
- if (marker.platform === "ANDROID") {
327881
- if (marker.proxySet) {
327882
- const restoreValue = marker.originalProxy && marker.originalProxy.length > 0 ? marker.originalProxy : ":0";
327883
- run2(["-s", marker.deviceId, "shell", "settings", "put", "global", "http_proxy", restoreValue]);
327884
- }
327885
- if (marker.caInstalledOnDevice) {
327886
- run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
327887
- }
327888
- }
327889
- onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
327890
- }
327891
- async function reconcilePendingCleanups(onLog) {
327892
- let files;
327893
- try {
327894
- files = await (0, import_promises.readdir)(PENDING_CLEANUP_DIR);
327895
- } catch {
327896
- return;
327897
- }
327898
- for (const file of files) {
327899
- if (!file.endsWith(".json")) continue;
327900
- const full = (0, import_node_path.join)(PENDING_CLEANUP_DIR, file);
327901
- try {
327902
- const raw = await (0, import_promises.readFile)(full, "utf-8");
327903
- const marker = JSON.parse(raw);
327904
- if (marker && typeof marker.deviceId === "string" && marker.platform) {
327905
- onLog?.(`[mobile-net] reconciling orphaned capture cleanup for ${marker.deviceId}.`);
327906
- restoreDeviceSync(marker, onLog);
327907
- }
327908
- } catch {
327909
- }
327910
- try {
327911
- await (0, import_promises.unlink)(full);
327912
- } catch {
327913
- }
327914
- }
327915
- }
327916
- function ensureExitHandlers() {
327917
- if (exitHandlersInstalled) return;
327918
- exitHandlersInstalled = true;
327919
- const drain = () => {
327920
- for (const marker of activeCleanups.values()) {
327921
- restoreDeviceSync(marker);
327922
- }
327923
- };
327924
- process.on("beforeExit", drain);
327925
- process.on("exit", drain);
327926
- for (const signal of ["SIGTERM", "SIGINT"]) {
327927
- process.on(signal, () => {
327928
- drain();
327929
- process.removeAllListeners(signal);
327930
- process.kill(process.pid, signal);
327931
- });
327932
- }
327933
- }
327934
- async function startMobileNetworkCapture(options) {
327935
- const { platform: platform3, deviceId, isPhysical = false, preferredPort = 0, onLog } = options;
327936
- await reconcilePendingCleanups(onLog);
327937
- ensureExitHandlers();
327938
- const sessionKey = (0, import_node_crypto.randomUUID)();
327939
- const originalProxy = platform3 === "ANDROID" ? await androidReadProxy(deviceId) : null;
327940
- let tmpDir = null;
327941
- let caCertPath = null;
327942
- let caKeyPem = null;
327943
- let caCertPem = null;
327944
- try {
327945
- tmpDir = await (0, import_promises.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "validateqa-mobile-ca-"));
327946
- const ca = await generateCaCert(tmpDir, onLog);
327947
- if (ca) {
327948
- caCertPath = ca.caCertPath;
327949
- caKeyPem = ca.caKeyPem;
327950
- caCertPem = ca.caCertPem;
327951
- }
327952
- } catch (err) {
327953
- onLog?.(`[mobile-net] CA setup degraded: ${errMsg(err)}`);
327954
- }
327955
- if (!caKeyPem || !caCertPem) {
327956
- try {
327957
- const fallback = await (0, import_mockttp.generateCACertificate)();
327958
- caKeyPem = fallback.key;
327959
- caCertPem = fallback.cert;
327960
- } catch (err) {
327961
- onLog?.(`[mobile-net] fatal CA failure, cannot start proxy: ${errMsg(err)}`);
327962
- if (tmpDir) {
327963
- await (0, import_promises.rm)(tmpDir, { recursive: true, force: true }).catch(() => void 0);
327964
- }
327965
- throw err instanceof Error ? err : new Error(String(err));
327966
- }
327967
- }
327968
- const proxy = new MitmProxy(caKeyPem, caCertPem, isPhysical, onLog);
327969
- const proxyPort = await proxy.listen(preferredPort);
327970
- const proxyHost = resolveProxyHostForDevice(platform3, isPhysical);
327971
- const hostPort = `${proxyHost}:${proxyPort}`;
327972
- onLog?.(
327973
- `[mobile-net] MITM proxy listening; device target ${hostPort} (${platform3}${isPhysical ? " physical" : ""})`
327974
- );
327975
- let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
327976
- let proxySet = false;
327977
- let caInstalledOnDevice = false;
327978
- try {
327979
- if (platform3 === "ANDROID") {
327980
- proxySet = await androidSetProxy(deviceId, hostPort, onLog);
327981
- if (!proxySet) {
327982
- wiringReason = "adb proxy configuration failed; no device traffic will be routed through the proxy.";
327983
- } else if (caCertPath) {
327984
- const { note } = await androidInstallCa(deviceId, caCertPath);
327985
- caInstalledOnDevice = true;
327986
- wiringReason = note;
327987
- }
327988
- } else {
327989
- if (!isPhysical && caCertPath) {
327990
- const { trusted, note } = await iosSimTrustCa(deviceId, caCertPath);
327991
- caInstalledOnDevice = trusted;
327992
- wiringReason = note;
327993
- } else if (isPhysical) {
327994
- wiringReason = "Physical iOS device: set the Wi-Fi HTTP proxy + trust the CA in Settings \u2192 General \u2192 About \u2192 Certificate Trust Settings manually; capture is limited to whatever routes through.";
327995
- }
327996
- }
327997
- } catch (err) {
327998
- wiringReason = `device wiring degraded: ${errMsg(err)}`;
327999
- onLog?.(`[mobile-net] ${wiringReason}`);
328000
- }
328001
- if (proxySet || caInstalledOnDevice) {
328002
- const marker = {
328003
- platform: platform3,
328004
- deviceId,
328005
- isPhysical,
328006
- proxySet,
328007
- originalProxy,
328008
- caInstalledOnDevice
328009
- };
328010
- activeCleanups.set(sessionKey, marker);
328011
- await writePendingCleanup(sessionKey, marker);
328012
- }
328013
- const capturedTmpDir = tmpDir;
328014
- const capturedCaCertPath = caCertPath;
328015
- let stopped = false;
328016
- let finalCalls = [];
328017
- const getCapabilities = () => {
328018
- if (proxy.isIntercepting) return { intercepting: true };
328019
- const reason = proxy.interceptionReason ?? wiringReason;
328020
- return reason ? { intercepting: false, reason } : { intercepting: false };
328021
- };
328022
- const stop = async () => {
328023
- if (stopped) return finalCalls;
328024
- stopped = true;
328025
- if (proxySet && platform3 === "ANDROID") {
328026
- await androidClearProxy(deviceId, originalProxy, onLog);
328027
- }
328028
- if (caInstalledOnDevice && capturedCaCertPath) {
328029
- if (platform3 === "ANDROID") {
328030
- await androidRemoveCa(deviceId, onLog);
328031
- } else if (!isPhysical) {
328032
- await iosSimRemoveCa(deviceId, onLog);
328033
- }
328034
- }
328035
- activeCleanups.delete(sessionKey);
328036
- await removePendingCleanup(sessionKey);
328037
- finalCalls = proxy.finalize();
328038
- await proxy.close();
328039
- if (capturedTmpDir) {
328040
- try {
328041
- await (0, import_promises.rm)(capturedTmpDir, { recursive: true, force: true });
328042
- } catch (err) {
328043
- onLog?.(`[mobile-net] temp CA cleanup failed: ${errMsg(err)}`);
328044
- }
328045
- }
328046
- const apiCount = finalCalls.filter((c) => c.isApi).length;
328047
- onLog?.(
328048
- `[mobile-net] stopped: ${finalCalls.length} calls captured (${apiCount} API; HTTPS interception ${proxy.isIntercepting ? "ACTIVE" : "inactive"}).`
328049
- );
328050
- return finalCalls;
328051
- };
328052
- return {
328053
- proxyPort,
328054
- proxyHost,
328055
- caCertPath: capturedCaCertPath,
328056
- getCapabilities,
328057
- stop
328058
- };
328059
- }
328060
-
328061
329465
  // src/services/doctor.ts
328062
329466
  var import_child_process4 = require("child_process");
328063
329467
  var import_fs3 = require("fs");
@@ -328275,7 +329679,20 @@ function printDoctorReport(report) {
328275
329679
  init_dist();
328276
329680
  var import_runner_core11 = __toESM(require_dist2());
328277
329681
  function fingerprintTest(test) {
328278
- return (0, import_node_crypto2.createHash)("sha256").update(test.name).update("\n--suite--\n").update(test.suiteName ?? "").update("\n--type--\n").update(test.testType ?? "").update("\n--code--\n").update(test.playwrightCode).digest("hex").slice(0, 24);
329682
+ const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
329683
+ return (0, import_node_crypto2.createHash)("sha256").update(test.name).update("\n--suite--\n").update(test.suiteName ?? "").update("\n--type--\n").update(test.testType ?? "").update("\n--framework--\n").update(test.framework ?? "").update("\n--code--\n").update(code).digest("hex").slice(0, 24);
329684
+ }
329685
+ function mobileDiscoveryNetworkContext(result, appId) {
329686
+ const page = result?.collectedPages?.find((p) => p.screenId || p.route);
329687
+ const screenId = page?.screenId ?? page?.route;
329688
+ return screenId ? `mobile:${screenId}` : `mobile:${appId}`;
329689
+ }
329690
+ function attachMobileDiscoveryNetworkContext(calls, result, appId) {
329691
+ const pageUrl = mobileDiscoveryNetworkContext(result, appId);
329692
+ return calls.map((call) => ({
329693
+ ...call,
329694
+ pageUrl: call.pageUrl ?? pageUrl
329695
+ }));
328279
329696
  }
328280
329697
  var SECRET_VAR_PATTERN = /password|secret|token|api[_]?key|pass\b|credential|bearer|pat\b/i;
328281
329698
  var IS_CLOUD = process.env.RUNNER_CLOUD === "true";
@@ -329235,6 +330652,7 @@ ${finalVerifyResult.logs ?? ""}`;
329235
330652
  tags: run2.tags ?? [],
329236
330653
  surfaceIntelligence: run2.surfaceIntelligence,
329237
330654
  healingContext: run2.healingContext ?? null,
330655
+ selectorRegistryContext: run2.selectorRegistryContext ?? null,
329238
330656
  lastFailureError: run2.lastFailureError,
329239
330657
  testVariables: run2.testVariables,
329240
330658
  onAICall: healAudit,
@@ -329469,6 +330887,7 @@ ${finalVerifyResult.logs ?? ""}`;
329469
330887
  tags: failedTest.tags ?? [],
329470
330888
  surfaceIntelligence: failedTest.healingContext ? null : run2.surfaceIntelligence,
329471
330889
  healingContext: failedTest.healingContext ?? null,
330890
+ selectorRegistryContext: failedTest.selectorRegistryContext ?? null,
329472
330891
  lastFailureError: failedTest.error,
329473
330892
  testVariables: run2.testVariables,
329474
330893
  onAICall: healAudit,
@@ -329887,6 +331306,14 @@ ${finalVerifyResult.logs ?? ""}`;
329887
331306
  throw new Error("CONFIG_ISSUE: the shared mobile device is busy with an active recording session; mobile discovery will retry once it ends.");
329888
331307
  }
329889
331308
  const mobileOnLog = (line) => onLog("progress", line);
331309
+ liveBrowserHandle.patch({
331310
+ mode: ctx.mode === "survey" ? "survey" : "discovery",
331311
+ testName: ctx.featureName ?? "Mobile discovery",
331312
+ note: `${platform3} mobile discovery`,
331313
+ surface: "mobile",
331314
+ mobilePlatform: platform3
331315
+ });
331316
+ requestLiveBrowserHeartbeat(true);
329890
331317
  mobileOnLog(`Mobile discovery (${ctx.mode}) \u2014 ${platform3} / ${mobileTarget.appId}`);
329891
331318
  setExecutorBusy(true);
329892
331319
  let session = null;
@@ -329899,6 +331326,24 @@ ${finalVerifyResult.logs ?? ""}`;
329899
331326
  recordedDeviceId: mobileTarget.recordedDeviceId,
329900
331327
  launchMode: mobileTarget.launchMode,
329901
331328
  deeplink: mobileTarget.deeplink
331329
+ }, {
331330
+ onDeviceSelected: async (device) => {
331331
+ try {
331332
+ capture = await startMobileNetworkCapture({
331333
+ platform: platform3,
331334
+ deviceId: device.deviceId,
331335
+ appId: mobileTarget.appId,
331336
+ isPhysical: device.isPhysical,
331337
+ onLog: mobileOnLog
331338
+ });
331339
+ const caps = capture.getCapabilities();
331340
+ if (!caps.intercepting) {
331341
+ mobileOnLog(`[mobile-net] HTTPS interception unavailable: ${caps.reason ?? "unknown reason"} (continuing \u2014 plaintext/metadata only)`);
331342
+ }
331343
+ } catch (captureErr) {
331344
+ mobileOnLog(`[mobile-net] capture unavailable: ${captureErr instanceof Error ? captureErr.message : String(captureErr)} (continuing without network log)`);
331345
+ }
331346
+ }
329902
331347
  });
329903
331348
  mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
329904
331349
  attachMirrorSession(session.sessionId, platform3);
@@ -329907,23 +331352,21 @@ ${finalVerifyResult.logs ?? ""}`;
329907
331352
  platform: platform3,
329908
331353
  appId: session.appId
329909
331354
  });
329910
- capture = await startMobileNetworkCapture({
329911
- platform: platform3,
329912
- deviceId: session.deviceId,
329913
- appId: session.appId,
329914
- isPhysical: session.isPhysical,
329915
- onLog: mobileOnLog
329916
- });
329917
- const caps = capture.getCapabilities();
329918
- if (!caps.intercepting) {
329919
- mobileOnLog(`[mobile-net] HTTPS interception unavailable: ${caps.reason ?? "unknown reason"} (continuing \u2014 plaintext/metadata only)`);
329920
- }
329921
331355
  discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
329922
331356
  onLog: mobileOnLog,
329923
331357
  onAICall: discoveryAudit,
329924
331358
  // Stream the live device mirror through the existing page-screenshot
329925
- // relay (mobile has no route, so key it as 'mobile'). Fire-and-forget.
331359
+ // relay (mobile has no route, so key it as 'mobile') and the
331360
+ // heartbeat live-session feed. The heartbeat frame gives the web
331361
+ // UI a reliable mobile fallback when the direct bridge SSE is
331362
+ // still opening or blocked by the user's network/tunnel.
329926
331363
  onScreenshot: (base64) => {
331364
+ liveBrowserHandle.updateSnapshot({
331365
+ currentUrl: mobileTarget.appId,
331366
+ thumbnailJpegBase64: base64,
331367
+ thumbnailCapturedAt: Date.now()
331368
+ });
331369
+ requestLiveBrowserHeartbeat();
329927
331370
  void onScreenshot("mobile", base64);
329928
331371
  },
329929
331372
  onExploreComplete,
@@ -329935,7 +331378,7 @@ ${finalVerifyResult.logs ?? ""}`;
329935
331378
  try {
329936
331379
  const apiCalls = await capture.stop();
329937
331380
  if (discoveryResult) {
329938
- discoveryResult.apiCalls = apiCalls.length ? apiCalls : void 0;
331381
+ discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
329939
331382
  }
329940
331383
  } catch (capErr) {
329941
331384
  mobileOnLog(`[mobile-net] capture stop failed: ${capErr instanceof Error ? capErr.message : String(capErr)}`);
@@ -330252,6 +331695,7 @@ ${finalVerifyResult.logs ?? ""}`;
330252
331695
  status,
330253
331696
  stepResults: result2.stepResults,
330254
331697
  screenshots: result2.screenshots,
331698
+ apiCalls: result2.apiCalls,
330255
331699
  logs: result2.logs,
330256
331700
  error: reportedError,
330257
331701
  duration: duration2