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