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