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