@validate.qa/runner 1.0.23 → 1.0.25
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 +1605 -302
- package/dist/cli.mjs +1605 -302
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -245,8 +245,8 @@ var require_ai_provider = __commonJS({
|
|
|
245
245
|
exports2.resetAIClient = resetAIClient;
|
|
246
246
|
exports2.getServerProxiedAIClient = getServerProxiedAIClient;
|
|
247
247
|
exports2.getServerProxiedClientForModel = getServerProxiedClientForModel;
|
|
248
|
-
exports2.getMobileAIClient =
|
|
249
|
-
exports2.getMobileClientForModel =
|
|
248
|
+
exports2.getMobileAIClient = getMobileAIClient2;
|
|
249
|
+
exports2.getMobileClientForModel = getMobileClientForModel2;
|
|
250
250
|
var openai_1 = __importDefault(require("openai"));
|
|
251
251
|
var async_hooks_1 = require("async_hooks");
|
|
252
252
|
var ai_retry_js_1 = require_ai_retry();
|
|
@@ -643,14 +643,14 @@ var require_ai_provider = __commonJS({
|
|
|
643
643
|
function getServerProxiedClientForModel(_modelId) {
|
|
644
644
|
return getServerProxiedAIClient();
|
|
645
645
|
}
|
|
646
|
-
function
|
|
646
|
+
function getMobileAIClient2() {
|
|
647
647
|
if ((process.env.SERVER_URL ?? "").trim().length > 0)
|
|
648
648
|
return getServerProxiedAIClient();
|
|
649
649
|
if (isMobileAiUnitTestMode())
|
|
650
650
|
return getAIClient();
|
|
651
651
|
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.");
|
|
652
652
|
}
|
|
653
|
-
function
|
|
653
|
+
function getMobileClientForModel2(modelId) {
|
|
654
654
|
if ((process.env.SERVER_URL ?? "").trim().length > 0)
|
|
655
655
|
return getServerProxiedClientForModel(modelId);
|
|
656
656
|
if (isMobileAiUnitTestMode())
|
|
@@ -681,6 +681,8 @@ var require_mobile_source_parser = __commonJS({
|
|
|
681
681
|
exports2.buildLocatorBundle = buildLocatorBundle;
|
|
682
682
|
exports2.preferredLocator = preferredLocator;
|
|
683
683
|
exports2.parseMobileSource = parseMobileSource2;
|
|
684
|
+
exports2.resolveLocatorAgainstScreenDetailed = resolveLocatorAgainstScreenDetailed;
|
|
685
|
+
exports2.resolveLocatorAgainstScreen = resolveLocatorAgainstScreen;
|
|
684
686
|
var IOS_ACTIONABLE_ROLE_SUFFIXES = [
|
|
685
687
|
"Button",
|
|
686
688
|
"TextField",
|
|
@@ -1084,6 +1086,106 @@ var require_mobile_source_parser = __commonJS({
|
|
|
1084
1086
|
windowSize: null
|
|
1085
1087
|
};
|
|
1086
1088
|
}
|
|
1089
|
+
var XPATH_IDENTITY_TEXT_ATTRS = /* @__PURE__ */ new Set([
|
|
1090
|
+
"text",
|
|
1091
|
+
"content-desc",
|
|
1092
|
+
"label",
|
|
1093
|
+
"name",
|
|
1094
|
+
"value"
|
|
1095
|
+
]);
|
|
1096
|
+
function elementValuesForXpathAttr(el, attr) {
|
|
1097
|
+
if (attr === "resource-id")
|
|
1098
|
+
return [el.resourceId];
|
|
1099
|
+
if (attr === "class")
|
|
1100
|
+
return [el.role];
|
|
1101
|
+
if (XPATH_IDENTITY_TEXT_ATTRS.has(attr))
|
|
1102
|
+
return [el.text, el.accessibilityId, el.label];
|
|
1103
|
+
return [];
|
|
1104
|
+
}
|
|
1105
|
+
function elementCarriesIdentityText(el, value) {
|
|
1106
|
+
return el.text === value || el.accessibilityId === value || el.label === value;
|
|
1107
|
+
}
|
|
1108
|
+
function parseXpathClauses(xpath) {
|
|
1109
|
+
const clauses = [];
|
|
1110
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1111
|
+
const push = (attr, value, matchKind) => {
|
|
1112
|
+
const key = `${attr}\0${value}\0${matchKind}`;
|
|
1113
|
+
if (seen.has(key))
|
|
1114
|
+
return;
|
|
1115
|
+
seen.add(key);
|
|
1116
|
+
clauses.push({ attr, value, matchKind });
|
|
1117
|
+
};
|
|
1118
|
+
const fnRe = /\b(contains|starts-with)\s*\(\s*@([A-Za-z_][\w.:-]*)\s*,\s*("([^"]*)"|'([^']*)')\s*\)/gi;
|
|
1119
|
+
let m;
|
|
1120
|
+
while ((m = fnRe.exec(xpath)) !== null) {
|
|
1121
|
+
const kind = m[1].toLowerCase() === "contains" ? "contains" : "starts-with";
|
|
1122
|
+
const value = m[4] ?? m[5] ?? "";
|
|
1123
|
+
push(m[2], value, kind);
|
|
1124
|
+
}
|
|
1125
|
+
const eqRe = /@([A-Za-z_][\w.:-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
1126
|
+
while ((m = eqRe.exec(xpath)) !== null) {
|
|
1127
|
+
const value = m[3] ?? m[4] ?? "";
|
|
1128
|
+
push(m[1], value, "exact");
|
|
1129
|
+
}
|
|
1130
|
+
return clauses;
|
|
1131
|
+
}
|
|
1132
|
+
function elementMatchesClause(el, clause) {
|
|
1133
|
+
const values = elementValuesForXpathAttr(el, clause.attr);
|
|
1134
|
+
return values.some((v) => {
|
|
1135
|
+
if (v === void 0)
|
|
1136
|
+
return false;
|
|
1137
|
+
if (clause.matchKind === "contains")
|
|
1138
|
+
return v.includes(clause.value);
|
|
1139
|
+
if (clause.matchKind === "starts-with")
|
|
1140
|
+
return v.startsWith(clause.value);
|
|
1141
|
+
return v === clause.value;
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
function matchXpath(xpath, elements) {
|
|
1145
|
+
const clauses = parseXpathClauses(xpath);
|
|
1146
|
+
if (clauses.length === 0)
|
|
1147
|
+
return { elements: [], exact: false };
|
|
1148
|
+
const matched = elements.filter((el) => clauses.some((c) => elementMatchesClause(el, c)));
|
|
1149
|
+
const exact = clauses.every((c) => c.matchKind === "exact");
|
|
1150
|
+
return { elements: matched, exact };
|
|
1151
|
+
}
|
|
1152
|
+
function resolveLocatorAgainstScreenDetailed(candidate, screen) {
|
|
1153
|
+
const elements = screen.elements;
|
|
1154
|
+
const accId = candidate.accessibilityId?.trim();
|
|
1155
|
+
if (accId) {
|
|
1156
|
+
const matches = elements.filter((el) => el.accessibilityId === accId);
|
|
1157
|
+
if (matches.length > 0)
|
|
1158
|
+
return { elements: matches, strategy: "accessibilityId", exact: true };
|
|
1159
|
+
}
|
|
1160
|
+
const resId = candidate.resourceId?.trim();
|
|
1161
|
+
if (resId) {
|
|
1162
|
+
const matches = elements.filter((el) => el.resourceId === resId);
|
|
1163
|
+
if (matches.length > 0)
|
|
1164
|
+
return { elements: matches, strategy: "resourceId", exact: true };
|
|
1165
|
+
}
|
|
1166
|
+
const text = candidate.text?.trim();
|
|
1167
|
+
if (text) {
|
|
1168
|
+
const matches = elements.filter((el) => elementCarriesIdentityText(el, text));
|
|
1169
|
+
if (matches.length > 0)
|
|
1170
|
+
return { elements: matches, strategy: "text", exact: true };
|
|
1171
|
+
}
|
|
1172
|
+
const xpath = candidate.xpath?.trim();
|
|
1173
|
+
if (xpath) {
|
|
1174
|
+
const res = matchXpath(xpath, elements);
|
|
1175
|
+
if (res.elements.length > 0)
|
|
1176
|
+
return { elements: res.elements, strategy: "xpath", exact: res.exact };
|
|
1177
|
+
}
|
|
1178
|
+
const className = candidate.className?.trim();
|
|
1179
|
+
if (className) {
|
|
1180
|
+
const matches = elements.filter((el) => el.role === className);
|
|
1181
|
+
if (matches.length > 0)
|
|
1182
|
+
return { elements: matches, strategy: "className", exact: true };
|
|
1183
|
+
}
|
|
1184
|
+
return { elements: [], strategy: "none", exact: false };
|
|
1185
|
+
}
|
|
1186
|
+
function resolveLocatorAgainstScreen(candidate, screen) {
|
|
1187
|
+
return resolveLocatorAgainstScreenDetailed(candidate, screen).elements;
|
|
1188
|
+
}
|
|
1087
1189
|
}
|
|
1088
1190
|
});
|
|
1089
1191
|
|
|
@@ -62139,7 +62241,7 @@ Rules:
|
|
|
62139
62241
|
- Register ONCE per run. If signup says the account ALREADY exists, log in with that email + the \`${opts.passwordKey}\` password instead of registering again.
|
|
62140
62242
|
- Only call the verification tools AFTER submitting the signup form; pass the app as \`senderMatch\` (the inbox is shared).`;
|
|
62141
62243
|
}
|
|
62142
|
-
function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId, existingScreens) {
|
|
62244
|
+
function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId, existingScreens, incremental) {
|
|
62143
62245
|
const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
|
|
62144
62246
|
const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
|
|
62145
62247
|
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.
|
|
@@ -62149,13 +62251,12 @@ Rules:
|
|
|
62149
62251
|
- Every element is addressed by a ref like e1, e2. **Refs are ONLY valid for the most recent mobile_snapshot.** Re-snapshot after every action that changes the screen, then use the NEW refs.
|
|
62150
62252
|
- mobile_tap/mobile_type/mobile_clear take a ref. The loop resolves the ref to coordinates for you \u2014 you never pass coordinates.
|
|
62151
62253
|
|
|
62152
|
-
## Strategy
|
|
62153
|
-
1
|
|
62154
|
-
2
|
|
62155
|
-
3
|
|
62156
|
-
|
|
62157
|
-
|
|
62158
|
-
6. When no new screens remain, call finish_exploration.
|
|
62254
|
+
## Strategy \u2014 three phases, in order
|
|
62255
|
+
**Phase 1 \u2014 Logged-out entry (be quick, ~15% of budget).** mobile_snapshot the launch screen, capture_screen it, and capture each distinct pre-login screen ONCE (landing, theme/language picker, sign-up, sign-in, forgot-password). Do NOT exhaustively try onboarding variants (every theme, every language) \u2014 one capture per screen type is enough. Then LOG IN using the provided credentials and verify you are actually signed in.
|
|
62256
|
+
**Phase 2 \u2014 Logged-in breadth first (~35% of budget).** Once signed in, find the primary navigation (bottom tab bar, drawer, or menu) and visit EVERY top-level destination ONCE, capturing each screen, BEFORE drilling into any of them. A survey that maps one tab deeply but never opens the other tabs has failed \u2014 breadth across all tabs/drawer entries is mandatory.
|
|
62257
|
+
**Phase 3 \u2014 Depth (remaining budget).** Return to each captured tab and explore its sub-screens: open ONE list row per list (rows share a template), open menus/sheets/dialogs, capture detail screens, and mobile_swipe up/down to reveal off-screen content. record_transition whenever you move between screens.
|
|
62258
|
+
|
|
62259
|
+
Rules that apply throughout: capture_screen every new screen (name + type \u2014 use type "onboarding"/"landing" for pre-login screens, "auth" for sign-in/sign-up); record_transition on every move; mobile_back to return; mobile_relaunch only for a clean restart between episodes. When no new screens remain, call finish_exploration.
|
|
62159
62260
|
|
|
62160
62261
|
## Boundaries (survey = map, do not mutate)
|
|
62161
62262
|
- Do NOT submit forms, save, delete, purchase, or send anything. You MAY open modals/menus to catalog them, then dismiss.
|
|
@@ -62185,7 +62286,7 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
|
|
|
62185
62286
|
## Application Brief
|
|
62186
62287
|
${appBrief}
|
|
62187
62288
|
Use this to prioritize which screens and flows to cover.` : "";
|
|
62188
|
-
const existingScreensBlock = mode === "survey" && existingScreens && existingScreens.length > 0 ? `
|
|
62289
|
+
const existingScreensBlock = mode === "survey" && incremental && existingScreens && existingScreens.length > 0 ? `
|
|
62189
62290
|
|
|
62190
62291
|
## ALREADY DISCOVERED SCREENS (skip these; find new)
|
|
62191
62292
|
These screens were mapped in a previous survey. Do NOT re-map them \u2014 use your budget to find NEW screens and flows not listed here. If you land on one of these, move on to something new.
|
|
@@ -62380,7 +62481,7 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
|
|
|
62380
62481
|
const browserCreds = ctx.variableContext.allTestVariables ?? ctx.variableContext.publicTestVariables;
|
|
62381
62482
|
const { secrets: credentialSecrets } = (0, credential_tools_js_1.partitionTestVariables)(browserCreds);
|
|
62382
62483
|
const hasCredentials = Object.keys(credentialSecrets).length > 0;
|
|
62383
|
-
const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId, existingScreens) + buildMobileCredentialPromptBlock(credentialSecrets) + (registerMode && inboxState ? buildMobileInboxPromptBlock({
|
|
62484
|
+
const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId, existingScreens, ctx.incremental) + buildMobileCredentialPromptBlock(credentialSecrets) + (registerMode && inboxState ? buildMobileInboxPromptBlock({
|
|
62384
62485
|
email: inboxState.email,
|
|
62385
62486
|
passwordKey: inboxState.passwordKey ?? "TEST_USER_PASSWORD",
|
|
62386
62487
|
context: mode
|
|
@@ -65645,6 +65746,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65645
65746
|
7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
|
|
65646
65747
|
8. Do NOT create standalone features for generic error / empty screens (network-error, permission-denied). If a feature screen is broken or crashes, keep it in its intended feature and note the failure in the description.
|
|
65647
65748
|
9. COVER EVERY SCREEN: every explored screen except generic error / permission screens MUST appear in exactly one feature group's routes. Do not leave screens ungrouped \u2014 a screen the explorer reached is part of SOME feature. Attach an ambiguous screen to its most closely related feature and note the ambiguity in that feature's description. Sign-in / sign-up / forgot-password screens form an "Authentication" group even when they were only reached via links from other screens.
|
|
65749
|
+
10. LOGGED-OUT vs LOGGED-IN SEPARATION: screens shown BEFORE the user is signed in (splash, landing/welcome, theme or language pickers, walkthroughs, sign-up, sign-in, forgot-password, demo/preview screens) must NEVER share a feature group with the signed-in app. Put sign-in / sign-up / forgot-password in "Authentication" (rule 4) and the remaining pre-login screens in an "Onboarding" group. Features named after signed-in areas (feeds, dashboards, tabs, settings) must contain ONLY screens reached after authentication. A pre-login flow is a linear sequence, not a navigation destination \u2014 do not let it swallow the app's main features.
|
|
65648
65750
|
|
|
65649
65751
|
## Output JSON Schema
|
|
65650
65752
|
{
|
|
@@ -66028,7 +66130,8 @@ ${constraints.join("\n")}`);
|
|
|
66028
66130
|
const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
|
|
66029
66131
|
const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
|
|
66030
66132
|
for (const state2 of orderedScreens) {
|
|
66031
|
-
const
|
|
66133
|
+
const screenTypeLower = state2.screenType?.toLowerCase();
|
|
66134
|
+
const groupName = screenTypeLower === "auth" ? "Authentication" : screenTypeLower === "onboarding" || screenTypeLower === "landing" || screenTypeLower === "splash" ? "Onboarding" : screenDisplayName(state2);
|
|
66032
66135
|
const group = groups.get(groupName) ?? {
|
|
66033
66136
|
name: groupName,
|
|
66034
66137
|
description: `Native mobile screens related to ${groupName}.`,
|
|
@@ -67136,6 +67239,24 @@ ${statePacket}` },
|
|
|
67136
67239
|
return true;
|
|
67137
67240
|
return false;
|
|
67138
67241
|
}
|
|
67242
|
+
var BARE_CONTAINER_CLASS_RE = /(webview|framelayout|linearlayout|relativelayout|viewgroup|scrollview|recyclerview|listview|XCUIElementTypeWebView|XCUIElementTypeApplication|XCUIElementTypeWindow|XCUIElementTypeOther|XCUIElementTypeScrollView)$/i;
|
|
67243
|
+
function isBareContainerTap(action) {
|
|
67244
|
+
if (action.type !== "TAP")
|
|
67245
|
+
return false;
|
|
67246
|
+
const bundle = action.metadata.locatorBundle;
|
|
67247
|
+
if (!bundle)
|
|
67248
|
+
return false;
|
|
67249
|
+
const hasIdentity = Boolean(bundle.text?.trim() || bundle.contentDesc?.trim() || bundle.accessibilityId?.trim() || action.metadata.targetLabel?.trim());
|
|
67250
|
+
if (hasIdentity)
|
|
67251
|
+
return false;
|
|
67252
|
+
const resourceId = bundle.resourceId?.trim() ?? "";
|
|
67253
|
+
const localName = resourceId.includes("/") ? resourceId.slice(resourceId.indexOf("/") + 1) : resourceId;
|
|
67254
|
+
const containerishId = localName !== "" && (localName === "webview" || ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName));
|
|
67255
|
+
const containerishClass = BARE_CONTAINER_CLASS_RE.test(bundle.className ?? "");
|
|
67256
|
+
if (containerishId)
|
|
67257
|
+
return true;
|
|
67258
|
+
return containerishClass && (resourceId === "" || containerishId);
|
|
67259
|
+
}
|
|
67139
67260
|
function appendHybridFloor(actions, recording, platform3) {
|
|
67140
67261
|
const anchor = pickLandingAnchor(recording.lastSnapshot);
|
|
67141
67262
|
if (!anchor)
|
|
@@ -67176,10 +67297,15 @@ ${statePacket}` },
|
|
|
67176
67297
|
}
|
|
67177
67298
|
function assembleTest(args) {
|
|
67178
67299
|
const { scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 } = args;
|
|
67179
|
-
|
|
67300
|
+
let actions = collapseRetryCycles(recording.actions);
|
|
67180
67301
|
if (actions.length < recording.actions.length) {
|
|
67181
67302
|
log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
|
|
67182
67303
|
}
|
|
67304
|
+
const beforeContainerDrop = actions.length;
|
|
67305
|
+
actions = actions.filter((a) => !isBareContainerTap(a));
|
|
67306
|
+
if (actions.length < beforeContainerDrop) {
|
|
67307
|
+
log2(` dropped ${beforeContainerDrop - actions.length} bare-container tap step(s) (no identity \u2014 un-replayable ambient taps).`);
|
|
67308
|
+
}
|
|
67183
67309
|
const floorAppended = appendHybridFloor(actions, recording, platform3);
|
|
67184
67310
|
if (floorAppended) {
|
|
67185
67311
|
log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
|
|
@@ -67367,6 +67493,1155 @@ Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) gene
|
|
|
67367
67493
|
}
|
|
67368
67494
|
});
|
|
67369
67495
|
|
|
67496
|
+
// ../runner-core/dist/services/mobile/mobile-heal-postprocess.js
|
|
67497
|
+
var require_mobile_heal_postprocess = __commonJS({
|
|
67498
|
+
"../runner-core/dist/services/mobile/mobile-heal-postprocess.js"(exports2) {
|
|
67499
|
+
"use strict";
|
|
67500
|
+
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
67501
|
+
exports2.redactMobileSecretsFromText = redactMobileSecretsFromText;
|
|
67502
|
+
exports2.detectMobileStepSecretLeak = detectMobileStepSecretLeak;
|
|
67503
|
+
exports2.computeMobileHealOutcome = computeMobileHealOutcome2;
|
|
67504
|
+
var REDACT_KEY_RE = /email|username|user|phone|password|secret|token|api[_-]?key|access[_-]?key|\bpass\b|credential|bearer|\bpat\b|session|cookie|jwt|otp|pin/i;
|
|
67505
|
+
var DETECT_KEY_RE = /password|secret|token|api[_]?key|\bpass\b|credential|bearer|\bpat\b/i;
|
|
67506
|
+
var MIN_REDACT_LEN = 4;
|
|
67507
|
+
var MIN_DETECT_LEN = 6;
|
|
67508
|
+
var TOKEN_SOURCES = [
|
|
67509
|
+
"eyJ[A-Za-z0-9_-]{6,}\\.[A-Za-z0-9_-]{6,}\\.[A-Za-z0-9_-]{4,}",
|
|
67510
|
+
// JWT (three base64url segments)
|
|
67511
|
+
"Bearer\\s+[A-Za-z0-9._~+/=-]{12,}",
|
|
67512
|
+
// Authorization: Bearer <token>
|
|
67513
|
+
"sk-[A-Za-z0-9]{16,}"
|
|
67514
|
+
// OpenAI-style secret key
|
|
67515
|
+
];
|
|
67516
|
+
var EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
|
|
67517
|
+
function escapeRegExp(value) {
|
|
67518
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
67519
|
+
}
|
|
67520
|
+
function collectSecretVars(testVariables, minLen, keyRe) {
|
|
67521
|
+
if (!testVariables)
|
|
67522
|
+
return [];
|
|
67523
|
+
const out = [];
|
|
67524
|
+
for (const [key, value] of Object.entries(testVariables)) {
|
|
67525
|
+
if (typeof value === "string" && value.length >= minLen && keyRe.test(key)) {
|
|
67526
|
+
out.push({ key, value });
|
|
67527
|
+
}
|
|
67528
|
+
}
|
|
67529
|
+
return out.sort((a, b) => b.value.length - a.value.length);
|
|
67530
|
+
}
|
|
67531
|
+
function redactMobileSecretsFromText(text, testVariables) {
|
|
67532
|
+
if (!text)
|
|
67533
|
+
return text;
|
|
67534
|
+
let out = text;
|
|
67535
|
+
for (const { key, value } of collectSecretVars(testVariables, MIN_REDACT_LEN, REDACT_KEY_RE)) {
|
|
67536
|
+
out = out.replace(new RegExp(escapeRegExp(value), "g"), `{{${key}}}`);
|
|
67537
|
+
}
|
|
67538
|
+
for (const source of TOKEN_SOURCES) {
|
|
67539
|
+
out = out.replace(new RegExp(source, "g"), "[REDACTED]");
|
|
67540
|
+
}
|
|
67541
|
+
out = out.replace(EMAIL_RE, "[EMAIL]");
|
|
67542
|
+
return out;
|
|
67543
|
+
}
|
|
67544
|
+
function detectMobileStepSecretLeak(steps, testVariables) {
|
|
67545
|
+
if (!Array.isArray(steps) || steps.length === 0)
|
|
67546
|
+
return null;
|
|
67547
|
+
const serialized = JSON.stringify(steps);
|
|
67548
|
+
if (typeof serialized !== "string")
|
|
67549
|
+
return null;
|
|
67550
|
+
for (const { key, value } of collectSecretVars(testVariables, MIN_DETECT_LEN, DETECT_KEY_RE)) {
|
|
67551
|
+
if (serialized.includes(value))
|
|
67552
|
+
return `literal value of ${key}`;
|
|
67553
|
+
}
|
|
67554
|
+
for (const source of TOKEN_SOURCES) {
|
|
67555
|
+
if (new RegExp(source).test(serialized))
|
|
67556
|
+
return "embedded token/bearer/JWT";
|
|
67557
|
+
}
|
|
67558
|
+
return null;
|
|
67559
|
+
}
|
|
67560
|
+
var NEEDS_REVIEW_VERDICTS = /* @__PURE__ */ new Set([
|
|
67561
|
+
"prereq_unmet",
|
|
67562
|
+
"state_stale",
|
|
67563
|
+
"test_invalid",
|
|
67564
|
+
"unhealable_dynamic"
|
|
67565
|
+
]);
|
|
67566
|
+
function computeMobileHealOutcome2(args) {
|
|
67567
|
+
const { heal } = args;
|
|
67568
|
+
const hasFix = Array.isArray(heal.healedSteps) && heal.healedSteps.length > 0;
|
|
67569
|
+
const secretLeak = heal.passed && hasFix ? detectMobileStepSecretLeak(heal.healedSteps, args.testVariables) : null;
|
|
67570
|
+
const isConfigIssue = heal.verdict === "config_issue";
|
|
67571
|
+
const isAppBug = heal.verdict === "app_bug";
|
|
67572
|
+
const extraTags = new Set((heal.extraTags ?? []).filter((t) => typeof t === "string" && t.length > 0));
|
|
67573
|
+
if (secretLeak)
|
|
67574
|
+
extraTags.add("@needs-review");
|
|
67575
|
+
if (!heal.passed && NEEDS_REVIEW_VERDICTS.has(heal.verdict))
|
|
67576
|
+
extraTags.add("@needs-review");
|
|
67577
|
+
let status;
|
|
67578
|
+
if (secretLeak)
|
|
67579
|
+
status = "FAILED";
|
|
67580
|
+
else if (heal.passed)
|
|
67581
|
+
status = "PASSED";
|
|
67582
|
+
else if (isConfigIssue)
|
|
67583
|
+
status = "ERROR";
|
|
67584
|
+
else
|
|
67585
|
+
status = "FAILED";
|
|
67586
|
+
let error2;
|
|
67587
|
+
if (status === "PASSED") {
|
|
67588
|
+
error2 = void 0;
|
|
67589
|
+
} else if (secretLeak) {
|
|
67590
|
+
error2 = `HEAL_SECRET_LEAK: healed steps contained a literal secret (${secretLeak}); patch dropped, tagged @needs-review`;
|
|
67591
|
+
} else if (isConfigIssue) {
|
|
67592
|
+
error2 = `CONFIG_ISSUE: ${heal.healReason ?? args.finalError ?? args.reproduceError ?? "environment/rate-limit blocker"}`;
|
|
67593
|
+
} else if (isAppBug) {
|
|
67594
|
+
error2 = `APP_BUG: ${heal.healReason ?? args.finalError ?? args.reproduceError ?? "application defect found during heal"}`;
|
|
67595
|
+
} else {
|
|
67596
|
+
error2 = args.finalError ?? heal.healReason ?? args.reproduceError ?? void 0;
|
|
67597
|
+
}
|
|
67598
|
+
return {
|
|
67599
|
+
status,
|
|
67600
|
+
error: error2,
|
|
67601
|
+
extraTags: extraTags.size > 0 ? [...extraTags] : void 0,
|
|
67602
|
+
removeTags: heal.removeTags,
|
|
67603
|
+
shouldPatch: heal.passed === true && hasFix && !secretLeak,
|
|
67604
|
+
secretLeak
|
|
67605
|
+
};
|
|
67606
|
+
}
|
|
67607
|
+
}
|
|
67608
|
+
});
|
|
67609
|
+
|
|
67610
|
+
// ../runner-core/dist/services/selector-registry-context.js
|
|
67611
|
+
var require_selector_registry_context = __commonJS({
|
|
67612
|
+
"../runner-core/dist/services/selector-registry-context.js"(exports2) {
|
|
67613
|
+
"use strict";
|
|
67614
|
+
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
67615
|
+
exports2.normalizeSelectorRegistryEntries = normalizeSelectorRegistryEntries;
|
|
67616
|
+
exports2.formatSelectorRegistryContextForPrompt = formatSelectorRegistryContextForPrompt;
|
|
67617
|
+
exports2.collectRegistryCandidates = collectRegistryCandidates;
|
|
67618
|
+
exports2.buildRegistryCandidateBlock = buildRegistryCandidateBlock;
|
|
67619
|
+
exports2.planRegistryFallbackFix = planRegistryFallbackFix;
|
|
67620
|
+
exports2.substituteSelectorInCode = substituteSelectorInCode;
|
|
67621
|
+
exports2.parseMobileLocatorExpression = parseMobileLocatorExpression;
|
|
67622
|
+
exports2.collectMobileRegistryCandidates = collectMobileRegistryCandidates;
|
|
67623
|
+
exports2.mobileCandidateToTarget = mobileCandidateToTarget;
|
|
67624
|
+
function isRecord(value) {
|
|
67625
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
67626
|
+
}
|
|
67627
|
+
function asString(value, fallback = "") {
|
|
67628
|
+
return typeof value === "string" ? value : fallback;
|
|
67629
|
+
}
|
|
67630
|
+
function asNumber(value, fallback = 0) {
|
|
67631
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
67632
|
+
}
|
|
67633
|
+
function asStringArray(value) {
|
|
67634
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
67635
|
+
}
|
|
67636
|
+
function truncateForSelectorPrompt(value, maxLength = 180) {
|
|
67637
|
+
const normalized = (value ?? "").replace(/\s+/g, " ").trim();
|
|
67638
|
+
if (normalized.length <= maxLength)
|
|
67639
|
+
return normalized;
|
|
67640
|
+
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
67641
|
+
}
|
|
67642
|
+
function inlineCode(value, maxLength = 180) {
|
|
67643
|
+
return `\`${truncateForSelectorPrompt(value, maxLength).replace(/`/g, "'")}\``;
|
|
67644
|
+
}
|
|
67645
|
+
function normalizeRef(value) {
|
|
67646
|
+
if (!isRecord(value))
|
|
67647
|
+
return null;
|
|
67648
|
+
const expression = asString(value.expression).trim();
|
|
67649
|
+
if (!expression)
|
|
67650
|
+
return null;
|
|
67651
|
+
return {
|
|
67652
|
+
route: asString(value.route, "*"),
|
|
67653
|
+
usage: asString(value.usage, "locator"),
|
|
67654
|
+
source: asString(value.source, "generated"),
|
|
67655
|
+
expression,
|
|
67656
|
+
occurrenceIndex: asNumber(value.occurrenceIndex, 0)
|
|
67657
|
+
};
|
|
67658
|
+
}
|
|
67659
|
+
function normalizeSibling(value) {
|
|
67660
|
+
if (!isRecord(value))
|
|
67661
|
+
return null;
|
|
67662
|
+
const testCaseId = asString(value.testCaseId).trim();
|
|
67663
|
+
const expression = asString(value.expression).trim();
|
|
67664
|
+
if (!testCaseId || !expression)
|
|
67665
|
+
return null;
|
|
67666
|
+
return {
|
|
67667
|
+
testCaseId,
|
|
67668
|
+
name: asString(value.name, testCaseId),
|
|
67669
|
+
suite: typeof value.suite === "string" ? value.suite : null,
|
|
67670
|
+
lastPassedAt: typeof value.lastPassedAt === "string" ? value.lastPassedAt : null,
|
|
67671
|
+
expression,
|
|
67672
|
+
usage: asString(value.usage, "locator"),
|
|
67673
|
+
route: asString(value.route, "*"),
|
|
67674
|
+
source: asString(value.source, "generated")
|
|
67675
|
+
};
|
|
67676
|
+
}
|
|
67677
|
+
function normalizeEntry(value) {
|
|
67678
|
+
if (!isRecord(value))
|
|
67679
|
+
return null;
|
|
67680
|
+
const id = asString(value.id).trim();
|
|
67681
|
+
const expression = asString(value.expression).trim();
|
|
67682
|
+
if (!id || !expression)
|
|
67683
|
+
return null;
|
|
67684
|
+
return {
|
|
67685
|
+
id,
|
|
67686
|
+
key: asString(value.key, id),
|
|
67687
|
+
route: asString(value.route, "*"),
|
|
67688
|
+
scope: asString(value.scope, "page"),
|
|
67689
|
+
status: asString(value.status, "ACTIVE"),
|
|
67690
|
+
stability: asNumber(value.stability, 1),
|
|
67691
|
+
label: asString(value.label),
|
|
67692
|
+
locatorKind: asString(value.locatorKind),
|
|
67693
|
+
locatorRole: typeof value.locatorRole === "string" ? value.locatorRole : null,
|
|
67694
|
+
locatorName: typeof value.locatorName === "string" ? value.locatorName : null,
|
|
67695
|
+
locatorTestId: typeof value.locatorTestId === "string" ? value.locatorTestId : null,
|
|
67696
|
+
expression,
|
|
67697
|
+
fallbacks: asStringArray(value.fallbacks),
|
|
67698
|
+
currentTestRefs: Array.isArray(value.currentTestRefs) ? value.currentTestRefs.map(normalizeRef).filter((ref) => Boolean(ref)) : [],
|
|
67699
|
+
refCount: asNumber(value.refCount, 0),
|
|
67700
|
+
testCount: asNumber(value.testCount, 0),
|
|
67701
|
+
siblingFanoutCount: asNumber(value.siblingFanoutCount, 0),
|
|
67702
|
+
usageFamilies: asStringArray(value.usageFamilies),
|
|
67703
|
+
riskFlags: asStringArray(value.riskFlags),
|
|
67704
|
+
lastHealedAt: typeof value.lastHealedAt === "string" ? value.lastHealedAt : null,
|
|
67705
|
+
recentPassingSiblings: Array.isArray(value.recentPassingSiblings) ? value.recentPassingSiblings.map(normalizeSibling).filter((sibling) => Boolean(sibling)) : [],
|
|
67706
|
+
recentFailingSiblingCount: asNumber(value.recentFailingSiblingCount, 0)
|
|
67707
|
+
};
|
|
67708
|
+
}
|
|
67709
|
+
function normalizeSelectorRegistryEntries(context) {
|
|
67710
|
+
if (!isRecord(context))
|
|
67711
|
+
return [];
|
|
67712
|
+
return Array.isArray(context.entries) ? context.entries.map(normalizeEntry).filter((entry) => Boolean(entry)) : [];
|
|
67713
|
+
}
|
|
67714
|
+
function formatSelectorRegistryContextForPrompt(context) {
|
|
67715
|
+
if (!isRecord(context))
|
|
67716
|
+
return "";
|
|
67717
|
+
const entries = normalizeSelectorRegistryEntries(context);
|
|
67718
|
+
if (entries.length === 0)
|
|
67719
|
+
return "";
|
|
67720
|
+
const summary = isRecord(context.summary) ? context.summary : null;
|
|
67721
|
+
const parts = [
|
|
67722
|
+
"## Selector Registry Memory (read-only evidence)",
|
|
67723
|
+
"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."
|
|
67724
|
+
];
|
|
67725
|
+
if (summary) {
|
|
67726
|
+
const selectorCount = asNumber(summary.selectorCount, entries.length);
|
|
67727
|
+
const sharedSelectorCount = asNumber(summary.sharedSelectorCount, entries.filter((entry) => entry.testCount > 1).length);
|
|
67728
|
+
const maxSharedTestCount = asNumber(summary.maxSharedTestCount, Math.max(...entries.map((entry) => entry.testCount), 0));
|
|
67729
|
+
parts.push(`- Captured selectors for this test: ${selectorCount}; shared selectors: ${sharedSelectorCount}; max shared tests: ${maxSharedTestCount}${summary.truncated ? "; list truncated" : ""}.`);
|
|
67730
|
+
}
|
|
67731
|
+
for (const entry of entries.slice(0, 8)) {
|
|
67732
|
+
const risk = entry.riskFlags.length > 0 ? entry.riskFlags.join(", ") : "none";
|
|
67733
|
+
const families = entry.usageFamilies.length > 0 ? entry.usageFamilies.join(", ") : "locator";
|
|
67734
|
+
parts.push("");
|
|
67735
|
+
parts.push(`### ${truncateForSelectorPrompt(entry.label || entry.key, 90)}`);
|
|
67736
|
+
parts.push(`- Selector: ${inlineCode(entry.expression, 220)}`);
|
|
67737
|
+
parts.push(`- Route/scope/kind: ${entry.route} / ${entry.scope} / ${entry.locatorKind || "unknown"}; usage families: ${families}.`);
|
|
67738
|
+
parts.push(`- Shared by: ${entry.testCount} test(s), ${entry.siblingFanoutCount} sibling(s), ${entry.refCount} ref(s); risks: ${risk}; status: ${entry.status}; stability: ${entry.stability}.`);
|
|
67739
|
+
if (entry.lastHealedAt)
|
|
67740
|
+
parts.push(`- Last registry heal: ${entry.lastHealedAt}.`);
|
|
67741
|
+
const currentRefs = entry.currentTestRefs.slice(0, 3);
|
|
67742
|
+
if (currentRefs.length > 0) {
|
|
67743
|
+
parts.push("- Current test occurrences:");
|
|
67744
|
+
for (const ref of currentRefs) {
|
|
67745
|
+
parts.push(` - ${ref.usage} on ${ref.route} from ${ref.source}: ${inlineCode(ref.expression, 180)}`);
|
|
67746
|
+
}
|
|
67747
|
+
}
|
|
67748
|
+
const siblings = entry.recentPassingSiblings.slice(0, 3);
|
|
67749
|
+
if (siblings.length > 0) {
|
|
67750
|
+
parts.push("- Recent passing sibling tests using this registry entry:");
|
|
67751
|
+
for (const sibling of siblings) {
|
|
67752
|
+
const suite = sibling.suite ? ` (${truncateForSelectorPrompt(sibling.suite, 50)})` : "";
|
|
67753
|
+
const passed = sibling.lastPassedAt ? ` passed ${sibling.lastPassedAt}` : "";
|
|
67754
|
+
parts.push(` - ${truncateForSelectorPrompt(sibling.name, 80)}${suite}${passed}: ${inlineCode(sibling.expression, 180)}`);
|
|
67755
|
+
}
|
|
67756
|
+
}
|
|
67757
|
+
if (entry.recentFailingSiblingCount && entry.recentFailingSiblingCount > 0) {
|
|
67758
|
+
parts.push(`- Other recent failing siblings on this entry: ${entry.recentFailingSiblingCount}.`);
|
|
67759
|
+
}
|
|
67760
|
+
const fallbacks = (entry.fallbacks ?? []).slice(0, 3);
|
|
67761
|
+
if (fallbacks.length > 0) {
|
|
67762
|
+
parts.push(`- Registry fallbacks: ${fallbacks.map((fallback) => inlineCode(fallback, 120)).join(", ")}.`);
|
|
67763
|
+
}
|
|
67764
|
+
}
|
|
67765
|
+
return `${parts.join("\n")}
|
|
67766
|
+
`;
|
|
67767
|
+
}
|
|
67768
|
+
function selectorKey(expression) {
|
|
67769
|
+
return expression.trim().replace(/^page\s*\.\s*/, "").replace(/\s+/g, " ").replace(/"/g, "'").trim();
|
|
67770
|
+
}
|
|
67771
|
+
function collectRegistryCandidates(context) {
|
|
67772
|
+
const entries = normalizeSelectorRegistryEntries(context).slice(0, 8);
|
|
67773
|
+
const seen = /* @__PURE__ */ new Set();
|
|
67774
|
+
const ranked = [[], [], []];
|
|
67775
|
+
for (const entry of entries) {
|
|
67776
|
+
const entryLabel = entry.label || entry.key;
|
|
67777
|
+
for (const sibling of entry.recentPassingSiblings) {
|
|
67778
|
+
const key = selectorKey(sibling.expression);
|
|
67779
|
+
if (seen.has(key))
|
|
67780
|
+
continue;
|
|
67781
|
+
seen.add(key);
|
|
67782
|
+
ranked[0].push({ expression: sibling.expression, source: "passing-sibling", entryLabel, siblingName: sibling.name });
|
|
67783
|
+
}
|
|
67784
|
+
const entryKey = selectorKey(entry.expression);
|
|
67785
|
+
if (!seen.has(entryKey)) {
|
|
67786
|
+
seen.add(entryKey);
|
|
67787
|
+
ranked[1].push({ expression: entry.expression, source: "registry-entry", entryLabel });
|
|
67788
|
+
}
|
|
67789
|
+
for (const fallback of entry.fallbacks ?? []) {
|
|
67790
|
+
const key = selectorKey(fallback);
|
|
67791
|
+
if (seen.has(key))
|
|
67792
|
+
continue;
|
|
67793
|
+
seen.add(key);
|
|
67794
|
+
ranked[2].push({ expression: fallback, source: "fallback", entryLabel });
|
|
67795
|
+
}
|
|
67796
|
+
}
|
|
67797
|
+
return ranked.flat();
|
|
67798
|
+
}
|
|
67799
|
+
function buildRegistryCandidateBlock(context, excludeExpressions) {
|
|
67800
|
+
const exclude = new Set(excludeExpressions.map(selectorKey));
|
|
67801
|
+
const candidates = collectRegistryCandidates(context).filter((candidate) => !exclude.has(selectorKey(candidate.expression))).slice(0, 6);
|
|
67802
|
+
if (candidates.length === 0)
|
|
67803
|
+
return "";
|
|
67804
|
+
const lines = [
|
|
67805
|
+
"## Untried Selector Candidates (from registry memory)",
|
|
67806
|
+
"Ranked alternatives for elements this test uses, from the platform selector registry. Verify each against the screenshot and error before adopting \u2014 do not adopt blindly."
|
|
67807
|
+
];
|
|
67808
|
+
for (const candidate of candidates) {
|
|
67809
|
+
const provenance = candidate.source === "passing-sibling" ? `proven in passing sibling "${truncateForSelectorPrompt(candidate.siblingName, 80)}"` : candidate.source === "registry-entry" ? `registry entry for "${truncateForSelectorPrompt(candidate.entryLabel, 80)}"` : `registry fallback for "${truncateForSelectorPrompt(candidate.entryLabel, 80)}"`;
|
|
67810
|
+
lines.push(`- ${inlineCode(candidate.expression, 180)} \u2014 ${provenance}`);
|
|
67811
|
+
}
|
|
67812
|
+
return lines.join("\n");
|
|
67813
|
+
}
|
|
67814
|
+
function findExpressionInCode(code, expression) {
|
|
67815
|
+
const trimmed = expression.trim();
|
|
67816
|
+
if (trimmed && code.includes(trimmed))
|
|
67817
|
+
return trimmed;
|
|
67818
|
+
const withoutPage = trimmed.replace(/^page\s*\.\s*/, "");
|
|
67819
|
+
if (withoutPage !== trimmed && withoutPage && code.includes(withoutPage))
|
|
67820
|
+
return withoutPage;
|
|
67821
|
+
const withPage = `page.${withoutPage}`;
|
|
67822
|
+
if (withPage !== trimmed && code.includes(withPage))
|
|
67823
|
+
return withPage;
|
|
67824
|
+
return null;
|
|
67825
|
+
}
|
|
67826
|
+
function extractFailureFocus(error2) {
|
|
67827
|
+
const focus = error2.split(/\r?\n/).filter((line) => /^\s*>/.test(line) || /waiting for|locator resolved to/i.test(line) || /^\s*locator:/i.test(line));
|
|
67828
|
+
return focus.length > 0 ? focus.join("\n") : error2;
|
|
67829
|
+
}
|
|
67830
|
+
function planRegistryFallbackFix(args) {
|
|
67831
|
+
const { code, error: error2, context } = args;
|
|
67832
|
+
if (!error2 || !code)
|
|
67833
|
+
return null;
|
|
67834
|
+
const entries = normalizeSelectorRegistryEntries(context).slice(0, 8);
|
|
67835
|
+
if (entries.length === 0)
|
|
67836
|
+
return null;
|
|
67837
|
+
const errorKey = selectorKey(extractFailureFocus(error2));
|
|
67838
|
+
for (const entry of entries) {
|
|
67839
|
+
const failingExpressions = [entry.expression, ...entry.currentTestRefs.map((ref) => ref.expression)];
|
|
67840
|
+
const failingSeen = /* @__PURE__ */ new Set();
|
|
67841
|
+
for (const failing of failingExpressions) {
|
|
67842
|
+
const failingKey = selectorKey(failing);
|
|
67843
|
+
if (!failingKey || failingSeen.has(failingKey))
|
|
67844
|
+
continue;
|
|
67845
|
+
failingSeen.add(failingKey);
|
|
67846
|
+
if (!errorKey.includes(failingKey))
|
|
67847
|
+
continue;
|
|
67848
|
+
const matchedInCode = findExpressionInCode(code, failing);
|
|
67849
|
+
if (!matchedInCode)
|
|
67850
|
+
continue;
|
|
67851
|
+
const entryLabel = entry.label || entry.key;
|
|
67852
|
+
const candidateSeen = /* @__PURE__ */ new Set([failingKey]);
|
|
67853
|
+
const candidates = [];
|
|
67854
|
+
for (const fallback of entry.fallbacks ?? []) {
|
|
67855
|
+
const key = selectorKey(fallback);
|
|
67856
|
+
if (!key || candidateSeen.has(key) || code.includes(fallback.trim()))
|
|
67857
|
+
continue;
|
|
67858
|
+
candidateSeen.add(key);
|
|
67859
|
+
candidates.push({ expression: fallback, source: "fallback", entryLabel });
|
|
67860
|
+
}
|
|
67861
|
+
for (const sibling of entry.recentPassingSiblings) {
|
|
67862
|
+
const key = selectorKey(sibling.expression);
|
|
67863
|
+
if (!key || candidateSeen.has(key) || code.includes(sibling.expression.trim()))
|
|
67864
|
+
continue;
|
|
67865
|
+
candidateSeen.add(key);
|
|
67866
|
+
candidates.push({ expression: sibling.expression, source: "passing-sibling", entryLabel, siblingName: sibling.name });
|
|
67867
|
+
}
|
|
67868
|
+
if (candidates.length === 0)
|
|
67869
|
+
continue;
|
|
67870
|
+
return { matchedInCode, entryLabel, candidates };
|
|
67871
|
+
}
|
|
67872
|
+
}
|
|
67873
|
+
return null;
|
|
67874
|
+
}
|
|
67875
|
+
function substituteSelectorInCode(code, matchedInCode, replacement) {
|
|
67876
|
+
const target = matchedInCode.trim();
|
|
67877
|
+
let normalized = replacement.trim();
|
|
67878
|
+
const targetHasPage = /^page\s*\.\s*/.test(target);
|
|
67879
|
+
const replacementHasPage = /^page\s*\.\s*/.test(normalized);
|
|
67880
|
+
if (targetHasPage && !replacementHasPage)
|
|
67881
|
+
normalized = `page.${normalized}`;
|
|
67882
|
+
if (!targetHasPage && replacementHasPage)
|
|
67883
|
+
normalized = normalized.replace(/^page\s*\.\s*/, "");
|
|
67884
|
+
return code.split(target).join(normalized);
|
|
67885
|
+
}
|
|
67886
|
+
var MOBILE_LOCATOR_RE = /^mobile\.(accessibilityId|resourceId|text|contentDesc|xpath|className|locator)\(\s*'((?:\\.|[^'])*)'\s*\)$/;
|
|
67887
|
+
function unescapeMobileLocatorValue(value) {
|
|
67888
|
+
return value.replace(/\\'/g, "'").replace(/\\\\/g, "\\");
|
|
67889
|
+
}
|
|
67890
|
+
function parseMobileLocatorExpression(expression) {
|
|
67891
|
+
const match = MOBILE_LOCATOR_RE.exec(expression.trim());
|
|
67892
|
+
if (!match)
|
|
67893
|
+
return null;
|
|
67894
|
+
return { kind: match[1], value: unescapeMobileLocatorValue(match[2]).trim() };
|
|
67895
|
+
}
|
|
67896
|
+
function mobileIdentityKey(parts) {
|
|
67897
|
+
return `${parts.kind}\0${parts.value}`;
|
|
67898
|
+
}
|
|
67899
|
+
function collectMobileFallbackExpressions(entry, primary) {
|
|
67900
|
+
const seen = /* @__PURE__ */ new Set([mobileIdentityKey(primary)]);
|
|
67901
|
+
const fallbacks = [];
|
|
67902
|
+
const consider = (expression) => {
|
|
67903
|
+
const parsed = parseMobileLocatorExpression(expression);
|
|
67904
|
+
if (!parsed)
|
|
67905
|
+
return;
|
|
67906
|
+
const key = mobileIdentityKey(parsed);
|
|
67907
|
+
if (seen.has(key))
|
|
67908
|
+
return;
|
|
67909
|
+
seen.add(key);
|
|
67910
|
+
fallbacks.push(expression.trim());
|
|
67911
|
+
};
|
|
67912
|
+
for (const fallback of entry.fallbacks ?? [])
|
|
67913
|
+
consider(fallback);
|
|
67914
|
+
for (const sibling of entry.recentPassingSiblings)
|
|
67915
|
+
consider(sibling.expression);
|
|
67916
|
+
return fallbacks;
|
|
67917
|
+
}
|
|
67918
|
+
function collectMobileRegistryCandidates(context) {
|
|
67919
|
+
const entries = normalizeSelectorRegistryEntries(context).slice(0, 8);
|
|
67920
|
+
const seen = /* @__PURE__ */ new Set();
|
|
67921
|
+
const candidates = [];
|
|
67922
|
+
for (const entry of entries) {
|
|
67923
|
+
const primary = parseMobileLocatorExpression(entry.expression);
|
|
67924
|
+
if (!primary)
|
|
67925
|
+
continue;
|
|
67926
|
+
const identityKey = mobileIdentityKey(primary);
|
|
67927
|
+
if (seen.has(identityKey))
|
|
67928
|
+
continue;
|
|
67929
|
+
seen.add(identityKey);
|
|
67930
|
+
candidates.push({
|
|
67931
|
+
kind: primary.kind,
|
|
67932
|
+
value: primary.value,
|
|
67933
|
+
fallbacks: collectMobileFallbackExpressions(entry, primary)
|
|
67934
|
+
});
|
|
67935
|
+
}
|
|
67936
|
+
return candidates;
|
|
67937
|
+
}
|
|
67938
|
+
function mobileCandidateToTarget(candidate) {
|
|
67939
|
+
const { kind, value } = candidate;
|
|
67940
|
+
switch (kind) {
|
|
67941
|
+
case "resourceId":
|
|
67942
|
+
return { locatorBundle: { resourceId: value } };
|
|
67943
|
+
case "accessibilityId":
|
|
67944
|
+
return { locatorBundle: { accessibilityId: value } };
|
|
67945
|
+
case "text":
|
|
67946
|
+
return { locatorBundle: { text: value } };
|
|
67947
|
+
case "contentDesc":
|
|
67948
|
+
return { locatorBundle: { contentDesc: value } };
|
|
67949
|
+
case "xpath":
|
|
67950
|
+
return { locatorBundle: { xpath: value } };
|
|
67951
|
+
case "className":
|
|
67952
|
+
return { locatorBundle: { className: value } };
|
|
67953
|
+
default:
|
|
67954
|
+
return {};
|
|
67955
|
+
}
|
|
67956
|
+
}
|
|
67957
|
+
}
|
|
67958
|
+
});
|
|
67959
|
+
|
|
67960
|
+
// ../runner-core/dist/services/mobile/mobile-heal-loop.js
|
|
67961
|
+
var require_mobile_heal_loop = __commonJS({
|
|
67962
|
+
"../runner-core/dist/services/mobile/mobile-heal-loop.js"(exports2) {
|
|
67963
|
+
"use strict";
|
|
67964
|
+
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
67965
|
+
exports2.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = exports2.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = void 0;
|
|
67966
|
+
exports2.executeMobileScriptDrivenHeal = executeMobileScriptDrivenHeal;
|
|
67967
|
+
exports2.executeMobileTestWithHealing = executeMobileTestWithHealing2;
|
|
67968
|
+
exports2.executeMobileLiveHeal = executeMobileLiveHeal2;
|
|
67969
|
+
var ai_provider_js_1 = require_ai_provider();
|
|
67970
|
+
var ai_queue_js_1 = require_ai_queue();
|
|
67971
|
+
var ai_retry_js_1 = require_ai_retry();
|
|
67972
|
+
var mobile_source_parser_js_1 = require_mobile_source_parser();
|
|
67973
|
+
var mobile_heal_postprocess_js_1 = require_mobile_heal_postprocess();
|
|
67974
|
+
var mobile_observation_js_1 = require_mobile_observation();
|
|
67975
|
+
var selector_registry_context_js_1 = require_selector_registry_context();
|
|
67976
|
+
exports2.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Appium (mobile UI automation) test. You receive: the test STEPS as JSON, the failure ERROR + step-level results, and the PAGE-SOURCE accessibility tree captured at the moment of failure (this tree is your 'screenshot'), plus optional known-good selector candidates from a registry.
|
|
67977
|
+
## You MUST try to fix the steps
|
|
67978
|
+
Reason over steps + error + tree + registry and return working healed steps. Do NOT return needs_device unless you have genuinely exhausted that reasoning (and NEVER before your final attempt).
|
|
67979
|
+
## Analyze
|
|
67980
|
+
1. ERROR: which step failed, on what locator/assertion? "element not found"/"no such element" almost always means a WRONG LOCATOR on a screen that DOES exist \u2014 pick a stronger IDENTITY locator that is actually present in the captured tree: a resource-id, an accessibility-id/content-desc, or an EXACT @text= predicate (never a loose contains()). Do NOT declare a feature absent from one failed locator.
|
|
67981
|
+
2. STEP RESULTS: which steps passed before the failure.
|
|
67982
|
+
3. TREE: does the current screen match what the failed step expects?
|
|
67983
|
+
4. ENTRY-SCREEN DRIFT ("unexpected screen"/"screen drift"): the app started on a DIFFERENT screen than recorded; the fix is usually a MISSING NAVIGATION step (e.g. tap "Get Started" before the recorded first step). Author it from the tree if you can (verdict fix); if a single static tree is insufficient, return needs_device.
|
|
67984
|
+
## Response \u2014 STRICT JSON, exactly one verdict:
|
|
67985
|
+
{"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps, your changes applied, order/metadata preserved>],"reason":"what changed and why","confidence":0.0-1.0}
|
|
67986
|
+
{"verdict":"app_bug","reason":"app crashed/blank/5xx (NOT rate-limit)"}
|
|
67987
|
+
{"verdict":"config_issue","reason":"429/rate limit/lockout/quota"}
|
|
67988
|
+
{"verdict":"needs_device","reason":"cannot fix from a static tree; need live re-observation"}
|
|
67989
|
+
{"verdict":"prereq_unmet","reason":"required entity/state absent for the account"}
|
|
67990
|
+
{"verdict":"state_stale","reason":"leftover data blocks a baseline assertion"}
|
|
67991
|
+
{"verdict":"test_invalid","reason":"scenario cannot be made to pass"}
|
|
67992
|
+
For a fix, return the COMPLETE steps array (not a diff).`;
|
|
67993
|
+
var DEFAULT_SCRIPT_MAX_ATTEMPTS = 6;
|
|
67994
|
+
var RECOVERABLE_MAX = 3;
|
|
67995
|
+
var BONUS_ATTEMPTS_CEIL = 4;
|
|
67996
|
+
var DEFAULT_BONUS_ATTEMPTS = 2;
|
|
67997
|
+
var MAX_LOG_CHARS = 4096;
|
|
67998
|
+
var OBSERVATION_MAX_ELEMENTS = 120;
|
|
67999
|
+
var REGISTRY_PREFLIGHT_MAX_CANDIDATES = 2;
|
|
68000
|
+
var MOBILE_HEAL_VERDICTS = [
|
|
68001
|
+
"fix",
|
|
68002
|
+
"app_bug",
|
|
68003
|
+
"config_issue",
|
|
68004
|
+
"needs_device",
|
|
68005
|
+
"prereq_unmet",
|
|
68006
|
+
"state_stale",
|
|
68007
|
+
"test_invalid",
|
|
68008
|
+
"unhealable_dynamic"
|
|
68009
|
+
];
|
|
68010
|
+
var TERMINAL_VERDICTS = /* @__PURE__ */ new Set([
|
|
68011
|
+
"app_bug",
|
|
68012
|
+
"config_issue",
|
|
68013
|
+
"prereq_unmet",
|
|
68014
|
+
"state_stale",
|
|
68015
|
+
"test_invalid",
|
|
68016
|
+
"unhealable_dynamic"
|
|
68017
|
+
]);
|
|
68018
|
+
function isRecord(value) {
|
|
68019
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
68020
|
+
}
|
|
68021
|
+
function envInt2(key) {
|
|
68022
|
+
const raw = process.env[key];
|
|
68023
|
+
if (raw === void 0)
|
|
68024
|
+
return void 0;
|
|
68025
|
+
const parsed = Number.parseInt(raw, 10);
|
|
68026
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
68027
|
+
}
|
|
68028
|
+
function tailLimit(text, max) {
|
|
68029
|
+
return text.length <= max ? text : text.slice(text.length - max);
|
|
68030
|
+
}
|
|
68031
|
+
function isMobileHealVerdict(value) {
|
|
68032
|
+
return MOBILE_HEAL_VERDICTS.includes(value);
|
|
68033
|
+
}
|
|
68034
|
+
function isLocatorNotFound(error2) {
|
|
68035
|
+
return /not found|no such element/i.test(error2 ?? "");
|
|
68036
|
+
}
|
|
68037
|
+
function isDrift(error2) {
|
|
68038
|
+
return /unexpected screen|screen drift/i.test(error2 ?? "");
|
|
68039
|
+
}
|
|
68040
|
+
function stripCodeFences(text) {
|
|
68041
|
+
const trimmed = text.trim();
|
|
68042
|
+
const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/m.exec(trimmed);
|
|
68043
|
+
return fenced ? fenced[1].trim() : trimmed;
|
|
68044
|
+
}
|
|
68045
|
+
function extractBracesSubstring(text) {
|
|
68046
|
+
const first = text.indexOf("{");
|
|
68047
|
+
const last = text.lastIndexOf("}");
|
|
68048
|
+
if (first === -1 || last === -1 || last <= first)
|
|
68049
|
+
return null;
|
|
68050
|
+
return text.slice(first, last + 1);
|
|
68051
|
+
}
|
|
68052
|
+
function safeJsonParse(text) {
|
|
68053
|
+
try {
|
|
68054
|
+
return JSON.parse(text);
|
|
68055
|
+
} catch {
|
|
68056
|
+
return void 0;
|
|
68057
|
+
}
|
|
68058
|
+
}
|
|
68059
|
+
function coerceSteps(value) {
|
|
68060
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
68061
|
+
return void 0;
|
|
68062
|
+
const allValid = value.every((step) => isRecord(step) && typeof step.action === "string" && typeof step.order === "number");
|
|
68063
|
+
return allValid ? value : void 0;
|
|
68064
|
+
}
|
|
68065
|
+
function parseHealVerdict(content) {
|
|
68066
|
+
const cleaned = stripCodeFences(content);
|
|
68067
|
+
let obj = safeJsonParse(cleaned);
|
|
68068
|
+
if (!isRecord(obj)) {
|
|
68069
|
+
const sub = extractBracesSubstring(cleaned);
|
|
68070
|
+
obj = sub ? safeJsonParse(sub) : void 0;
|
|
68071
|
+
}
|
|
68072
|
+
if (!isRecord(obj))
|
|
68073
|
+
return null;
|
|
68074
|
+
const rawVerdict = obj.verdict;
|
|
68075
|
+
if (typeof rawVerdict !== "string" || !isMobileHealVerdict(rawVerdict))
|
|
68076
|
+
return null;
|
|
68077
|
+
return {
|
|
68078
|
+
verdict: rawVerdict,
|
|
68079
|
+
steps: coerceSteps(obj.steps),
|
|
68080
|
+
reason: typeof obj.reason === "string" ? obj.reason : void 0,
|
|
68081
|
+
confidence: typeof obj.confidence === "number" ? obj.confidence : void 0
|
|
68082
|
+
};
|
|
68083
|
+
}
|
|
68084
|
+
function partsToCandidate(parts) {
|
|
68085
|
+
switch (parts.kind) {
|
|
68086
|
+
case "accessibilityId":
|
|
68087
|
+
return { accessibilityId: parts.value };
|
|
68088
|
+
case "resourceId":
|
|
68089
|
+
return { resourceId: parts.value };
|
|
68090
|
+
case "text":
|
|
68091
|
+
case "contentDesc":
|
|
68092
|
+
return { text: parts.value };
|
|
68093
|
+
case "xpath":
|
|
68094
|
+
return { xpath: parts.value };
|
|
68095
|
+
case "className":
|
|
68096
|
+
return { className: parts.value };
|
|
68097
|
+
default:
|
|
68098
|
+
return {};
|
|
68099
|
+
}
|
|
68100
|
+
}
|
|
68101
|
+
function hasIdentitySignal(candidate) {
|
|
68102
|
+
return Boolean(candidate.accessibilityId || candidate.resourceId || candidate.text || candidate.xpath);
|
|
68103
|
+
}
|
|
68104
|
+
function stepToLocatorCandidate(step) {
|
|
68105
|
+
const primary = step.target?.primary;
|
|
68106
|
+
if (primary) {
|
|
68107
|
+
const parsed = (0, selector_registry_context_js_1.parseMobileLocatorExpression)(primary);
|
|
68108
|
+
if (parsed) {
|
|
68109
|
+
const candidate = partsToCandidate(parsed);
|
|
68110
|
+
if (hasIdentitySignal(candidate))
|
|
68111
|
+
return candidate;
|
|
68112
|
+
}
|
|
68113
|
+
}
|
|
68114
|
+
const bundle = step.target?.locatorBundle;
|
|
68115
|
+
if (bundle) {
|
|
68116
|
+
const candidate = {};
|
|
68117
|
+
if (bundle.accessibilityId)
|
|
68118
|
+
candidate.accessibilityId = bundle.accessibilityId;
|
|
68119
|
+
if (bundle.resourceId)
|
|
68120
|
+
candidate.resourceId = bundle.resourceId;
|
|
68121
|
+
if (bundle.text)
|
|
68122
|
+
candidate.text = bundle.text;
|
|
68123
|
+
else if (bundle.contentDesc)
|
|
68124
|
+
candidate.text = bundle.contentDesc;
|
|
68125
|
+
if (bundle.xpath)
|
|
68126
|
+
candidate.xpath = bundle.xpath;
|
|
68127
|
+
if (hasIdentitySignal(candidate))
|
|
68128
|
+
return candidate;
|
|
68129
|
+
}
|
|
68130
|
+
return null;
|
|
68131
|
+
}
|
|
68132
|
+
function stepIdentityExpressions(step) {
|
|
68133
|
+
const out = [];
|
|
68134
|
+
const primary = step.target?.primary;
|
|
68135
|
+
if (primary)
|
|
68136
|
+
out.push(primary);
|
|
68137
|
+
const bundle = step.target?.locatorBundle;
|
|
68138
|
+
if (bundle) {
|
|
68139
|
+
if (bundle.resourceId)
|
|
68140
|
+
out.push(`mobile.resourceId('${bundle.resourceId}')`);
|
|
68141
|
+
if (bundle.accessibilityId)
|
|
68142
|
+
out.push(`mobile.accessibilityId('${bundle.accessibilityId}')`);
|
|
68143
|
+
if (bundle.text)
|
|
68144
|
+
out.push(`mobile.text('${bundle.text}')`);
|
|
68145
|
+
if (bundle.contentDesc)
|
|
68146
|
+
out.push(`mobile.contentDesc('${bundle.contentDesc}')`);
|
|
68147
|
+
if (bundle.xpath)
|
|
68148
|
+
out.push(`mobile.xpath('${bundle.xpath}')`);
|
|
68149
|
+
}
|
|
68150
|
+
return out;
|
|
68151
|
+
}
|
|
68152
|
+
function stepTargetChanged(base2, healed) {
|
|
68153
|
+
return JSON.stringify(base2?.target) !== JSON.stringify(healed.target);
|
|
68154
|
+
}
|
|
68155
|
+
function findMissingChangedLocators(baseSteps, healedSteps, screen) {
|
|
68156
|
+
const missing = [];
|
|
68157
|
+
healedSteps.forEach((healed, index) => {
|
|
68158
|
+
if (!stepTargetChanged(baseSteps[index], healed))
|
|
68159
|
+
return;
|
|
68160
|
+
const candidate = stepToLocatorCandidate(healed);
|
|
68161
|
+
if (!candidate)
|
|
68162
|
+
return;
|
|
68163
|
+
if ((0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen).length === 0) {
|
|
68164
|
+
missing.push({
|
|
68165
|
+
label: healed.target?.primary ?? JSON.stringify(candidate),
|
|
68166
|
+
expressions: stepIdentityExpressions(healed)
|
|
68167
|
+
});
|
|
68168
|
+
}
|
|
68169
|
+
});
|
|
68170
|
+
return missing;
|
|
68171
|
+
}
|
|
68172
|
+
function changedStepExpressions(baseSteps, healedSteps) {
|
|
68173
|
+
const out = [];
|
|
68174
|
+
healedSteps.forEach((healed, index) => {
|
|
68175
|
+
if (stepTargetChanged(baseSteps[index], healed))
|
|
68176
|
+
out.push(...stepIdentityExpressions(healed));
|
|
68177
|
+
});
|
|
68178
|
+
return out;
|
|
68179
|
+
}
|
|
68180
|
+
function pushUnique(target, additions) {
|
|
68181
|
+
for (const value of additions) {
|
|
68182
|
+
if (value && !target.includes(value))
|
|
68183
|
+
target.push(value);
|
|
68184
|
+
}
|
|
68185
|
+
}
|
|
68186
|
+
function buildUserContent(args) {
|
|
68187
|
+
const parts = [];
|
|
68188
|
+
const safeError = (0, mobile_heal_postprocess_js_1.redactMobileSecretsFromText)(args.activeError ?? void 0, args.testVariables);
|
|
68189
|
+
const safeLogs = (0, mobile_heal_postprocess_js_1.redactMobileSecretsFromText)(args.trimmedLogs, args.testVariables);
|
|
68190
|
+
parts.push("## Failing test steps (JSON)");
|
|
68191
|
+
parts.push("```json\n" + JSON.stringify(args.baseSteps, null, 2) + "\n```");
|
|
68192
|
+
parts.push("## Steps (numbered)");
|
|
68193
|
+
parts.push(args.baseSteps.map((step, index) => {
|
|
68194
|
+
const locator = step.target?.primary ? ` -> ${step.target.primary}` : "";
|
|
68195
|
+
return `${index + 1}. [${step.action}] ${step.description ?? ""}${locator}`;
|
|
68196
|
+
}).join("\n"));
|
|
68197
|
+
parts.push("## Failure");
|
|
68198
|
+
parts.push(`Error: ${safeError ?? "(none reported)"}`);
|
|
68199
|
+
if (safeLogs)
|
|
68200
|
+
parts.push("Logs (tail):\n" + safeLogs);
|
|
68201
|
+
if (args.activeFailingSource) {
|
|
68202
|
+
const safeSource = (0, mobile_heal_postprocess_js_1.redactMobileSecretsFromText)(args.activeFailingSource, args.testVariables) ?? args.activeFailingSource;
|
|
68203
|
+
const screen = (0, mobile_source_parser_js_1.parseMobileSource)(safeSource, args.platform);
|
|
68204
|
+
parts.push("## Captured screen (accessibility tree at moment of failure)");
|
|
68205
|
+
parts.push((0, mobile_observation_js_1.renderMobileObservation)(screen, { maxElements: OBSERVATION_MAX_ELEMENTS }));
|
|
68206
|
+
}
|
|
68207
|
+
if (args.attempt === 1) {
|
|
68208
|
+
const registryBlock = (0, selector_registry_context_js_1.formatSelectorRegistryContextForPrompt)(args.registryContext);
|
|
68209
|
+
if (registryBlock)
|
|
68210
|
+
parts.push(registryBlock);
|
|
68211
|
+
} else {
|
|
68212
|
+
const candidateBlock = (0, selector_registry_context_js_1.buildRegistryCandidateBlock)(args.registryContext, [...args.triedLocators]);
|
|
68213
|
+
if (candidateBlock)
|
|
68214
|
+
parts.push(candidateBlock);
|
|
68215
|
+
if (args.triedLocators.length > 0) {
|
|
68216
|
+
parts.push("## Locators already tried (do NOT repeat):\n" + args.triedLocators.map((locator) => `- ${locator}`).join("\n"));
|
|
68217
|
+
}
|
|
68218
|
+
const safeLastError = (0, mobile_heal_postprocess_js_1.redactMobileSecretsFromText)(args.lastError, args.testVariables);
|
|
68219
|
+
if (safeLastError)
|
|
68220
|
+
parts.push(`## Previous fix failed with:
|
|
68221
|
+
${safeLastError}`);
|
|
68222
|
+
}
|
|
68223
|
+
if (args.directive)
|
|
68224
|
+
parts.push(`## Directive
|
|
68225
|
+
${args.directive}`);
|
|
68226
|
+
return parts.join("\n\n");
|
|
68227
|
+
}
|
|
68228
|
+
function findFailingStepIndex(steps, error2) {
|
|
68229
|
+
const withTarget = steps.map((step, index) => ({ step, index })).filter((entry) => Boolean(entry.step.target?.primary));
|
|
68230
|
+
if (withTarget.length === 0)
|
|
68231
|
+
return -1;
|
|
68232
|
+
if (error2) {
|
|
68233
|
+
for (let k = withTarget.length - 1; k >= 0; k -= 1) {
|
|
68234
|
+
const primary = withTarget[k].step.target?.primary;
|
|
68235
|
+
if (primary && error2.includes(primary))
|
|
68236
|
+
return withTarget[k].index;
|
|
68237
|
+
}
|
|
68238
|
+
}
|
|
68239
|
+
return withTarget[withTarget.length - 1].index;
|
|
68240
|
+
}
|
|
68241
|
+
function substituteStepTarget(steps, index, candidate, bundlePartial) {
|
|
68242
|
+
const existing = steps[index].target;
|
|
68243
|
+
const newBundle = {
|
|
68244
|
+
xpath: existing?.locatorBundle?.xpath ?? "",
|
|
68245
|
+
className: existing?.locatorBundle?.className ?? "",
|
|
68246
|
+
...existing?.locatorBundle,
|
|
68247
|
+
...bundlePartial
|
|
68248
|
+
};
|
|
68249
|
+
const escaped = candidate.value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
68250
|
+
const newTarget = {
|
|
68251
|
+
primary: `mobile.${candidate.kind}('${escaped}')`,
|
|
68252
|
+
fallbacks: existing?.fallbacks ?? [],
|
|
68253
|
+
screenName: existing?.screenName ?? "",
|
|
68254
|
+
locatorBundle: newBundle,
|
|
68255
|
+
platformLocators: existing?.platformLocators
|
|
68256
|
+
};
|
|
68257
|
+
return steps.map((step, i) => i === index ? { ...step, target: newTarget } : step);
|
|
68258
|
+
}
|
|
68259
|
+
async function tryRegistryPreflight(steps, target, ctx, options) {
|
|
68260
|
+
const runNativeTest = options.runNativeTest;
|
|
68261
|
+
if (!runNativeTest || !options.selectorRegistryContext || !ctx.error)
|
|
68262
|
+
return null;
|
|
68263
|
+
const candidates = (0, selector_registry_context_js_1.collectMobileRegistryCandidates)(options.selectorRegistryContext);
|
|
68264
|
+
const failingIndex = findFailingStepIndex(steps, ctx.error);
|
|
68265
|
+
if (failingIndex < 0)
|
|
68266
|
+
return null;
|
|
68267
|
+
let tried = 0;
|
|
68268
|
+
for (const candidate of candidates) {
|
|
68269
|
+
if (tried >= REGISTRY_PREFLIGHT_MAX_CANDIDATES)
|
|
68270
|
+
break;
|
|
68271
|
+
const mapped = (0, selector_registry_context_js_1.mobileCandidateToTarget)(candidate);
|
|
68272
|
+
if (!mapped.locatorBundle || Object.keys(mapped.locatorBundle).length === 0)
|
|
68273
|
+
continue;
|
|
68274
|
+
tried += 1;
|
|
68275
|
+
const healedSteps = substituteStepTarget(steps, failingIndex, candidate, mapped.locatorBundle);
|
|
68276
|
+
const verify = await runNativeTest({ steps: healedSteps, target, testVariables: ctx.testVariables });
|
|
68277
|
+
if (verify.passed) {
|
|
68278
|
+
return {
|
|
68279
|
+
passed: true,
|
|
68280
|
+
verdict: "fix",
|
|
68281
|
+
healedSteps,
|
|
68282
|
+
healReason: "registry-fallback",
|
|
68283
|
+
stepResults: verify.stepResults,
|
|
68284
|
+
logs: verify.logs
|
|
68285
|
+
};
|
|
68286
|
+
}
|
|
68287
|
+
}
|
|
68288
|
+
return null;
|
|
68289
|
+
}
|
|
68290
|
+
async function executeMobileScriptDrivenHeal(steps, target, ctx, options) {
|
|
68291
|
+
const aiClient = options.aiClient ?? (options.model ? (0, ai_provider_js_1.getMobileClientForModel)(options.model) : (0, ai_provider_js_1.getMobileAIClient)());
|
|
68292
|
+
const model = options.model ? (0, ai_provider_js_1.resolveModelForTask)(options.model, "strong") : (0, ai_provider_js_1.getModel)("strong");
|
|
68293
|
+
const base2 = options.maxScriptAttempts ?? envInt2("MOBILE_HEAL_SCRIPT_MAX_ATTEMPTS") ?? DEFAULT_SCRIPT_MAX_ATTEMPTS;
|
|
68294
|
+
const bonusMax = Math.min(options.bonusAttempts ?? DEFAULT_BONUS_ATTEMPTS, BONUS_ATTEMPTS_CEIL);
|
|
68295
|
+
const registryPresent = options.selectorRegistryContext != null;
|
|
68296
|
+
const platform3 = target.platform;
|
|
68297
|
+
const trimmedLogs = tailLimit(ctx.logs ?? "", MAX_LOG_CHARS);
|
|
68298
|
+
let attemptCap = base2;
|
|
68299
|
+
let recoverableUsed = 0;
|
|
68300
|
+
let bonusUsed = 0;
|
|
68301
|
+
let attempt = 0;
|
|
68302
|
+
let baseSteps = steps;
|
|
68303
|
+
let activeError = ctx.error;
|
|
68304
|
+
let activeFailingSource = ctx.failingSource;
|
|
68305
|
+
let lastError;
|
|
68306
|
+
let lastHealedSteps;
|
|
68307
|
+
let lastHealReason;
|
|
68308
|
+
let lastConfidence;
|
|
68309
|
+
let lastVerdict;
|
|
68310
|
+
let pendingDirective = null;
|
|
68311
|
+
const triedLocators = [];
|
|
68312
|
+
let sameErrorCount = 0;
|
|
68313
|
+
let lastErrorSignature = null;
|
|
68314
|
+
const markRecoverableTurn = () => {
|
|
68315
|
+
if (recoverableUsed < RECOVERABLE_MAX) {
|
|
68316
|
+
attemptCap += 1;
|
|
68317
|
+
recoverableUsed += 1;
|
|
68318
|
+
}
|
|
68319
|
+
};
|
|
68320
|
+
const noteError = (error2) => {
|
|
68321
|
+
const signature = (error2 ?? "").slice(0, 150);
|
|
68322
|
+
if (signature && signature === lastErrorSignature) {
|
|
68323
|
+
sameErrorCount += 1;
|
|
68324
|
+
} else {
|
|
68325
|
+
sameErrorCount = error2 ? 1 : 0;
|
|
68326
|
+
lastErrorSignature = signature;
|
|
68327
|
+
}
|
|
68328
|
+
};
|
|
68329
|
+
for (; ; ) {
|
|
68330
|
+
if (attempt >= attemptCap) {
|
|
68331
|
+
const untriedRegistry = registryPresent && (0, selector_registry_context_js_1.buildRegistryCandidateBlock)(options.selectorRegistryContext, triedLocators) !== "";
|
|
68332
|
+
const canBonus = bonusUsed < bonusMax && lastHealedSteps !== void 0 && sameErrorCount < 2 && untriedRegistry;
|
|
68333
|
+
if (canBonus) {
|
|
68334
|
+
attemptCap += 1;
|
|
68335
|
+
bonusUsed += 1;
|
|
68336
|
+
} else {
|
|
68337
|
+
break;
|
|
68338
|
+
}
|
|
68339
|
+
}
|
|
68340
|
+
attempt += 1;
|
|
68341
|
+
const userContent = buildUserContent({
|
|
68342
|
+
attempt,
|
|
68343
|
+
baseSteps,
|
|
68344
|
+
activeError,
|
|
68345
|
+
trimmedLogs,
|
|
68346
|
+
activeFailingSource,
|
|
68347
|
+
platform: platform3,
|
|
68348
|
+
registryContext: options.selectorRegistryContext ?? null,
|
|
68349
|
+
triedLocators,
|
|
68350
|
+
lastError,
|
|
68351
|
+
directive: pendingDirective,
|
|
68352
|
+
testVariables: ctx.testVariables
|
|
68353
|
+
});
|
|
68354
|
+
pendingDirective = null;
|
|
68355
|
+
const messages = [
|
|
68356
|
+
{ role: "system", content: exports2.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT },
|
|
68357
|
+
{ role: "user", content: userContent }
|
|
68358
|
+
];
|
|
68359
|
+
let content;
|
|
68360
|
+
try {
|
|
68361
|
+
const response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
|
|
68362
|
+
arm();
|
|
68363
|
+
return aiClient.chat.completions.create({ model, messages, max_tokens: 8e3, temperature: 0.1 }, { signal });
|
|
68364
|
+
}), { label: `mobile-heal-attempt-${attempt}`, deferTimeoutUntilArmed: true });
|
|
68365
|
+
content = response?.choices?.[0]?.message?.content ?? "";
|
|
68366
|
+
} catch {
|
|
68367
|
+
markRecoverableTurn();
|
|
68368
|
+
pendingDirective = "The previous attempt did not reach the model. Re-analyze and return one valid JSON verdict.";
|
|
68369
|
+
continue;
|
|
68370
|
+
}
|
|
68371
|
+
const parsed = parseHealVerdict(content);
|
|
68372
|
+
if (!parsed) {
|
|
68373
|
+
markRecoverableTurn();
|
|
68374
|
+
pendingDirective = "Your last response was not valid JSON matching the verdict schema. Return exactly one JSON verdict object.";
|
|
68375
|
+
continue;
|
|
68376
|
+
}
|
|
68377
|
+
if (TERMINAL_VERDICTS.has(parsed.verdict)) {
|
|
68378
|
+
return { passed: false, verdict: parsed.verdict, healReason: parsed.reason, attemptsUsed: attempt };
|
|
68379
|
+
}
|
|
68380
|
+
if (parsed.verdict === "needs_device") {
|
|
68381
|
+
const isFinalAttempt = attempt >= attemptCap && recoverableUsed >= RECOVERABLE_MAX;
|
|
68382
|
+
if (!isFinalAttempt) {
|
|
68383
|
+
markRecoverableTurn();
|
|
68384
|
+
pendingDirective = "You returned needs_device but MUST try a step/locator fix first \u2014 this is not your final attempt. Re-analyze the captured tree and return a concrete fix.";
|
|
68385
|
+
continue;
|
|
68386
|
+
}
|
|
68387
|
+
return {
|
|
68388
|
+
passed: false,
|
|
68389
|
+
verdict: "needs_device",
|
|
68390
|
+
healedSteps: lastHealedSteps,
|
|
68391
|
+
healReason: parsed.reason,
|
|
68392
|
+
attemptsUsed: attempt,
|
|
68393
|
+
verifiedFallbackAvailable: lastHealedSteps !== void 0
|
|
68394
|
+
};
|
|
68395
|
+
}
|
|
68396
|
+
const healedSteps = parsed.steps;
|
|
68397
|
+
if (!healedSteps) {
|
|
68398
|
+
markRecoverableTurn();
|
|
68399
|
+
pendingDirective = 'You returned verdict "fix" but no usable steps array. Return the COMPLETE healed MobileTestStep array.';
|
|
68400
|
+
continue;
|
|
68401
|
+
}
|
|
68402
|
+
if (activeFailingSource && isLocatorNotFound(activeError) && !isDrift(activeError)) {
|
|
68403
|
+
const screen = (0, mobile_source_parser_js_1.parseMobileSource)(activeFailingSource, platform3);
|
|
68404
|
+
const missing = findMissingChangedLocators(baseSteps, healedSteps, screen);
|
|
68405
|
+
if (missing.length > 0) {
|
|
68406
|
+
for (const entry of missing)
|
|
68407
|
+
pushUnique(triedLocators, entry.expressions);
|
|
68408
|
+
markRecoverableTurn();
|
|
68409
|
+
lastVerdict = "fix";
|
|
68410
|
+
pendingDirective = `Your proposed locator ${missing[0].label} does NOT exist in the captured screen tree. Pick an identity locator (resource-id, accessibility-id, or exact @text=) that is PRESENT in the tree.`;
|
|
68411
|
+
continue;
|
|
68412
|
+
}
|
|
68413
|
+
}
|
|
68414
|
+
if (!options.runNativeTest) {
|
|
68415
|
+
lastHealedSteps = healedSteps;
|
|
68416
|
+
lastHealReason = parsed.reason;
|
|
68417
|
+
lastConfidence = parsed.confidence;
|
|
68418
|
+
lastVerdict = "fix";
|
|
68419
|
+
pushUnique(triedLocators, changedStepExpressions(baseSteps, healedSteps));
|
|
68420
|
+
baseSteps = healedSteps;
|
|
68421
|
+
pendingDirective = "The proposed fix could not be device-verified here. If it is not clearly correct, revise it.";
|
|
68422
|
+
continue;
|
|
68423
|
+
}
|
|
68424
|
+
const verify = await options.runNativeTest({ steps: healedSteps, target, testVariables: ctx.testVariables });
|
|
68425
|
+
if (verify.passed) {
|
|
68426
|
+
return {
|
|
68427
|
+
passed: true,
|
|
68428
|
+
verdict: "fix",
|
|
68429
|
+
healedSteps,
|
|
68430
|
+
healReason: parsed.reason,
|
|
68431
|
+
confidence: parsed.confidence,
|
|
68432
|
+
attemptsUsed: attempt
|
|
68433
|
+
};
|
|
68434
|
+
}
|
|
68435
|
+
lastError = verify.error ?? lastError;
|
|
68436
|
+
lastHealedSteps = healedSteps;
|
|
68437
|
+
lastHealReason = parsed.reason;
|
|
68438
|
+
lastConfidence = parsed.confidence;
|
|
68439
|
+
lastVerdict = "fix";
|
|
68440
|
+
noteError(verify.error);
|
|
68441
|
+
pushUnique(triedLocators, changedStepExpressions(baseSteps, healedSteps));
|
|
68442
|
+
if (verify.failingSource)
|
|
68443
|
+
activeFailingSource = verify.failingSource;
|
|
68444
|
+
activeError = verify.error ?? activeError;
|
|
68445
|
+
baseSteps = healedSteps;
|
|
68446
|
+
}
|
|
68447
|
+
return {
|
|
68448
|
+
passed: false,
|
|
68449
|
+
verdict: lastVerdict ?? "fix",
|
|
68450
|
+
healedSteps: lastHealedSteps,
|
|
68451
|
+
healReason: lastHealReason,
|
|
68452
|
+
confidence: lastConfidence,
|
|
68453
|
+
attemptsUsed: attempt,
|
|
68454
|
+
lastError,
|
|
68455
|
+
verifiedFallbackAvailable: lastHealedSteps !== void 0 ? false : void 0
|
|
68456
|
+
};
|
|
68457
|
+
}
|
|
68458
|
+
async function executeMobileTestWithHealing2(steps, target, ctx, options) {
|
|
68459
|
+
const preflight = await tryRegistryPreflight(steps, target, ctx, options);
|
|
68460
|
+
if (preflight)
|
|
68461
|
+
return preflight;
|
|
68462
|
+
const script = await executeMobileScriptDrivenHeal(steps, target, ctx, options);
|
|
68463
|
+
const shouldEscalate = script.verdict === "needs_device" || !script.passed;
|
|
68464
|
+
if (shouldEscalate && options.runLiveHeal) {
|
|
68465
|
+
return options.runLiveHeal({
|
|
68466
|
+
steps: script.healedSteps ?? steps,
|
|
68467
|
+
target,
|
|
68468
|
+
testVariables: ctx.testVariables,
|
|
68469
|
+
options
|
|
68470
|
+
});
|
|
68471
|
+
}
|
|
68472
|
+
return {
|
|
68473
|
+
passed: script.passed,
|
|
68474
|
+
healedSteps: script.healedSteps,
|
|
68475
|
+
healedAppiumCode: script.healedAppiumCode,
|
|
68476
|
+
healReason: script.healReason,
|
|
68477
|
+
verdict: script.verdict,
|
|
68478
|
+
confidence: script.confidence,
|
|
68479
|
+
extraTags: script.extraTags,
|
|
68480
|
+
removeTags: script.removeTags
|
|
68481
|
+
};
|
|
68482
|
+
}
|
|
68483
|
+
var DEFAULT_PHASE2_MAX_PASSES = 6;
|
|
68484
|
+
exports2.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Appium (mobile UI automation) test with LIVE device access (Phase 2). You receive: the failing test STEPS as JSON, the PRIOR failure error, and the LIVE current screen accessibility tree observed on-device RIGHT NOW. Unlike a frozen failure snapshot, this LIVE tree is the REAL screen the app is on this instant.
|
|
68485
|
+
## Key insight \u2014 ENTRY-SCREEN DRIFT
|
|
68486
|
+
The recorded flow may have expected a DIFFERENT starting screen than the one shown. When the LIVE screen is an earlier/other screen (e.g. an onboarding or "Get Started" screen), the fix is almost always a MISSING NAVIGATION step: AUTHOR the leading step(s) (e.g. a tap on "Get Started") that reach the recorded first screen, then keep the recorded steps. This is exactly what a static failing-source cannot reveal but a LIVE screen can.
|
|
68487
|
+
## Analyze
|
|
68488
|
+
1. Compare the recorded first step's expected screen to the LIVE tree.
|
|
68489
|
+
2. If they differ, PREPEND the missing navigation step(s), authored from IDENTITY locators (resource-id, accessibility-id, or EXACT @text=) that are PRESENT in the LIVE tree \u2014 never a loose contains().
|
|
68490
|
+
3. If a locator is simply wrong, swap it for a stronger identity locator present in the LIVE tree.
|
|
68491
|
+
4. If you cannot yet SEE the screen you need, use "observe" to advance ONE step, then you will be re-shown the new live screen.
|
|
68492
|
+
## Response \u2014 STRICT JSON, exactly one verdict:
|
|
68493
|
+
{"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps incl. any prepended navigation, order/metadata coherent>],"reason":"what changed and why","confidence":0.0-1.0}
|
|
68494
|
+
{"verdict":"observe","action":{"type":"tap","target":"mobile.text('Get Started')"}}
|
|
68495
|
+
{"verdict":"observe","action":{"type":"back"}}
|
|
68496
|
+
{"verdict":"observe","action":{"type":"relaunch"}}
|
|
68497
|
+
{"verdict":"observe","action":{"type":"reset"}} \u2190 clears app data + cold-relaunches: use when the app is in the WRONG STATE for the recorded flow (e.g. signed in when the test expects the cleared first-launch screens, stale onboarding, poisoned local state)
|
|
68498
|
+
{"verdict":"app_bug","reason":"app crashed/blank/5xx (NOT rate-limit)"}
|
|
68499
|
+
{"verdict":"config_issue","reason":"429/rate limit/lockout/quota"}
|
|
68500
|
+
{"verdict":"needs_device","reason":"cannot resolve even with live access"}
|
|
68501
|
+
{"verdict":"prereq_unmet","reason":"required entity/state absent for the account"}
|
|
68502
|
+
{"verdict":"state_stale","reason":"leftover data blocks a baseline assertion"}
|
|
68503
|
+
{"verdict":"test_invalid","reason":"scenario cannot be made to pass"}
|
|
68504
|
+
{"verdict":"unhealable_dynamic","reason":"assertion targets non-durable dynamic content"}
|
|
68505
|
+
For a fix, return the COMPLETE steps array (not a diff). Use "observe" only to reveal a screen you cannot yet see; prefer "fix" as soon as the LIVE tree is enough. "observe" with type "tap" REQUIRES a target locator expression; "back"/"relaunch" take no target.`;
|
|
68506
|
+
function parseLiveObserveAction(content) {
|
|
68507
|
+
const cleaned = stripCodeFences(content);
|
|
68508
|
+
let obj = safeJsonParse(cleaned);
|
|
68509
|
+
if (!isRecord(obj)) {
|
|
68510
|
+
const sub = extractBracesSubstring(cleaned);
|
|
68511
|
+
obj = sub ? safeJsonParse(sub) : void 0;
|
|
68512
|
+
}
|
|
68513
|
+
if (!isRecord(obj) || obj.verdict !== "observe")
|
|
68514
|
+
return null;
|
|
68515
|
+
const action = obj.action;
|
|
68516
|
+
if (!isRecord(action))
|
|
68517
|
+
return null;
|
|
68518
|
+
const type = action.type;
|
|
68519
|
+
if (type !== "tap" && type !== "back" && type !== "relaunch" && type !== "reset")
|
|
68520
|
+
return null;
|
|
68521
|
+
const target = typeof action.target === "string" ? action.target : void 0;
|
|
68522
|
+
return { type, target };
|
|
68523
|
+
}
|
|
68524
|
+
async function applyLiveObserveAction(driver, action, screen) {
|
|
68525
|
+
if (action.type === "back") {
|
|
68526
|
+
await driver.back();
|
|
68527
|
+
return;
|
|
68528
|
+
}
|
|
68529
|
+
if (action.type === "relaunch") {
|
|
68530
|
+
await driver.relaunch();
|
|
68531
|
+
return;
|
|
68532
|
+
}
|
|
68533
|
+
if (action.type === "reset") {
|
|
68534
|
+
const cleared = await driver.resetAppState();
|
|
68535
|
+
if (!cleared)
|
|
68536
|
+
await driver.relaunch();
|
|
68537
|
+
return;
|
|
68538
|
+
}
|
|
68539
|
+
if (!action.target)
|
|
68540
|
+
return;
|
|
68541
|
+
const parts = (0, selector_registry_context_js_1.parseMobileLocatorExpression)(action.target);
|
|
68542
|
+
if (!parts)
|
|
68543
|
+
return;
|
|
68544
|
+
const candidate = partsToCandidate(parts);
|
|
68545
|
+
if (!hasIdentitySignal(candidate))
|
|
68546
|
+
return;
|
|
68547
|
+
const first = (0, mobile_source_parser_js_1.resolveLocatorAgainstScreen)(candidate, screen)[0];
|
|
68548
|
+
if (first)
|
|
68549
|
+
await driver.tap(first.bounds);
|
|
68550
|
+
}
|
|
68551
|
+
function buildLiveUserContent(args) {
|
|
68552
|
+
const parts = [];
|
|
68553
|
+
parts.push("## Failing test steps (JSON)");
|
|
68554
|
+
parts.push("```json\n" + JSON.stringify(args.steps, null, 2) + "\n```");
|
|
68555
|
+
parts.push("## Prior failure");
|
|
68556
|
+
parts.push(`Error: ${args.priorError ?? "(none reported)"}`);
|
|
68557
|
+
parts.push("## LIVE current screen (accessibility tree observed on-device NOW)");
|
|
68558
|
+
parts.push(args.observation);
|
|
68559
|
+
return parts.join("\n\n");
|
|
68560
|
+
}
|
|
68561
|
+
async function executeMobileLiveHeal2(args) {
|
|
68562
|
+
const { target, testVariables, options } = args;
|
|
68563
|
+
const liveDriver = options.liveDriver;
|
|
68564
|
+
if (!liveDriver) {
|
|
68565
|
+
return { passed: false, verdict: "needs_device", healReason: "no live driver available" };
|
|
68566
|
+
}
|
|
68567
|
+
const runNativeTest = options.runNativeTest;
|
|
68568
|
+
if (!runNativeTest) {
|
|
68569
|
+
return { passed: false, verdict: "needs_device", healReason: "no native verify seam available" };
|
|
68570
|
+
}
|
|
68571
|
+
const aiClient = options.aiClient ?? (options.model ? (0, ai_provider_js_1.getMobileClientForModel)(options.model) : (0, ai_provider_js_1.getMobileAIClient)());
|
|
68572
|
+
const model = options.model ? (0, ai_provider_js_1.resolveModelForTask)(options.model, "agent") : (0, ai_provider_js_1.getModel)("agent");
|
|
68573
|
+
const maxPasses = envInt2("MOBILE_HEAL_PHASE2_MAX_PASSES") ?? DEFAULT_PHASE2_MAX_PASSES;
|
|
68574
|
+
let baseSteps = args.steps;
|
|
68575
|
+
let priorError = args.priorError ?? null;
|
|
68576
|
+
let bestSteps;
|
|
68577
|
+
let bestStepResults;
|
|
68578
|
+
for (let pass = 1; pass <= maxPasses; pass += 1) {
|
|
68579
|
+
const snap = await liveDriver.snapshot();
|
|
68580
|
+
const screen = (0, mobile_source_parser_js_1.parseMobileSource)(snap.xml, target.platform);
|
|
68581
|
+
const safeScreen = (0, mobile_source_parser_js_1.parseMobileSource)((0, mobile_heal_postprocess_js_1.redactMobileSecretsFromText)(snap.xml, testVariables) ?? snap.xml, target.platform);
|
|
68582
|
+
const observation = (0, mobile_observation_js_1.renderMobileObservation)(safeScreen, { maxElements: OBSERVATION_MAX_ELEMENTS });
|
|
68583
|
+
const messages = [
|
|
68584
|
+
{ role: "system", content: exports2.MOBILE_LIVE_HEAL_SYSTEM_PROMPT },
|
|
68585
|
+
{
|
|
68586
|
+
role: "user",
|
|
68587
|
+
content: buildLiveUserContent({
|
|
68588
|
+
steps: baseSteps,
|
|
68589
|
+
priorError: (0, mobile_heal_postprocess_js_1.redactMobileSecretsFromText)(priorError ?? void 0, testVariables) ?? null,
|
|
68590
|
+
observation
|
|
68591
|
+
})
|
|
68592
|
+
}
|
|
68593
|
+
];
|
|
68594
|
+
let content;
|
|
68595
|
+
try {
|
|
68596
|
+
const response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
|
|
68597
|
+
arm();
|
|
68598
|
+
return aiClient.chat.completions.create({ model, messages, max_tokens: 8e3, temperature: 0.1 }, { signal });
|
|
68599
|
+
}), { label: `mobile-live-heal-pass-${pass}`, deferTimeoutUntilArmed: true });
|
|
68600
|
+
content = response?.choices?.[0]?.message?.content ?? "";
|
|
68601
|
+
} catch {
|
|
68602
|
+
continue;
|
|
68603
|
+
}
|
|
68604
|
+
const observe = parseLiveObserveAction(content);
|
|
68605
|
+
if (observe) {
|
|
68606
|
+
await applyLiveObserveAction(liveDriver, observe, screen);
|
|
68607
|
+
continue;
|
|
68608
|
+
}
|
|
68609
|
+
const parsed = parseHealVerdict(content);
|
|
68610
|
+
if (!parsed)
|
|
68611
|
+
continue;
|
|
68612
|
+
if (TERMINAL_VERDICTS.has(parsed.verdict)) {
|
|
68613
|
+
return { passed: false, verdict: parsed.verdict, healReason: parsed.reason, healedSteps: bestSteps };
|
|
68614
|
+
}
|
|
68615
|
+
if (parsed.verdict !== "fix" || !parsed.steps)
|
|
68616
|
+
continue;
|
|
68617
|
+
const healedSteps = parsed.steps;
|
|
68618
|
+
const verify = await runNativeTest({ steps: healedSteps, target, testVariables });
|
|
68619
|
+
if (verify.passed) {
|
|
68620
|
+
return {
|
|
68621
|
+
passed: true,
|
|
68622
|
+
verdict: "fix",
|
|
68623
|
+
healedSteps,
|
|
68624
|
+
healReason: parsed.reason,
|
|
68625
|
+
confidence: parsed.confidence,
|
|
68626
|
+
stepResults: verify.stepResults
|
|
68627
|
+
};
|
|
68628
|
+
}
|
|
68629
|
+
baseSteps = healedSteps;
|
|
68630
|
+
bestSteps = healedSteps;
|
|
68631
|
+
bestStepResults = verify.stepResults;
|
|
68632
|
+
priorError = verify.error ?? priorError;
|
|
68633
|
+
}
|
|
68634
|
+
return {
|
|
68635
|
+
passed: false,
|
|
68636
|
+
verdict: "needs_device",
|
|
68637
|
+
healedSteps: bestSteps,
|
|
68638
|
+
healReason: "live heal exhausted",
|
|
68639
|
+
stepResults: bestStepResults
|
|
68640
|
+
};
|
|
68641
|
+
}
|
|
68642
|
+
}
|
|
68643
|
+
});
|
|
68644
|
+
|
|
67370
68645
|
// ../runner-core/dist/services/browser-config.js
|
|
67371
68646
|
var require_browser_config = __commonJS({
|
|
67372
68647
|
"../runner-core/dist/services/browser-config.js"(exports2) {
|
|
@@ -293115,282 +294390,6 @@ var require_preflight_syntax = __commonJS({
|
|
|
293115
294390
|
}
|
|
293116
294391
|
});
|
|
293117
294392
|
|
|
293118
|
-
// ../runner-core/dist/services/selector-registry-context.js
|
|
293119
|
-
var require_selector_registry_context = __commonJS({
|
|
293120
|
-
"../runner-core/dist/services/selector-registry-context.js"(exports2) {
|
|
293121
|
-
"use strict";
|
|
293122
|
-
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
293123
|
-
exports2.normalizeSelectorRegistryEntries = normalizeSelectorRegistryEntries;
|
|
293124
|
-
exports2.formatSelectorRegistryContextForPrompt = formatSelectorRegistryContextForPrompt;
|
|
293125
|
-
exports2.collectRegistryCandidates = collectRegistryCandidates;
|
|
293126
|
-
exports2.buildRegistryCandidateBlock = buildRegistryCandidateBlock;
|
|
293127
|
-
exports2.planRegistryFallbackFix = planRegistryFallbackFix;
|
|
293128
|
-
exports2.substituteSelectorInCode = substituteSelectorInCode;
|
|
293129
|
-
function isRecord(value) {
|
|
293130
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
293131
|
-
}
|
|
293132
|
-
function asString(value, fallback = "") {
|
|
293133
|
-
return typeof value === "string" ? value : fallback;
|
|
293134
|
-
}
|
|
293135
|
-
function asNumber(value, fallback = 0) {
|
|
293136
|
-
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
293137
|
-
}
|
|
293138
|
-
function asStringArray(value) {
|
|
293139
|
-
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
293140
|
-
}
|
|
293141
|
-
function truncateForSelectorPrompt(value, maxLength = 180) {
|
|
293142
|
-
const normalized = (value ?? "").replace(/\s+/g, " ").trim();
|
|
293143
|
-
if (normalized.length <= maxLength)
|
|
293144
|
-
return normalized;
|
|
293145
|
-
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
293146
|
-
}
|
|
293147
|
-
function inlineCode(value, maxLength = 180) {
|
|
293148
|
-
return `\`${truncateForSelectorPrompt(value, maxLength).replace(/`/g, "'")}\``;
|
|
293149
|
-
}
|
|
293150
|
-
function normalizeRef(value) {
|
|
293151
|
-
if (!isRecord(value))
|
|
293152
|
-
return null;
|
|
293153
|
-
const expression = asString(value.expression).trim();
|
|
293154
|
-
if (!expression)
|
|
293155
|
-
return null;
|
|
293156
|
-
return {
|
|
293157
|
-
route: asString(value.route, "*"),
|
|
293158
|
-
usage: asString(value.usage, "locator"),
|
|
293159
|
-
source: asString(value.source, "generated"),
|
|
293160
|
-
expression,
|
|
293161
|
-
occurrenceIndex: asNumber(value.occurrenceIndex, 0)
|
|
293162
|
-
};
|
|
293163
|
-
}
|
|
293164
|
-
function normalizeSibling(value) {
|
|
293165
|
-
if (!isRecord(value))
|
|
293166
|
-
return null;
|
|
293167
|
-
const testCaseId = asString(value.testCaseId).trim();
|
|
293168
|
-
const expression = asString(value.expression).trim();
|
|
293169
|
-
if (!testCaseId || !expression)
|
|
293170
|
-
return null;
|
|
293171
|
-
return {
|
|
293172
|
-
testCaseId,
|
|
293173
|
-
name: asString(value.name, testCaseId),
|
|
293174
|
-
suite: typeof value.suite === "string" ? value.suite : null,
|
|
293175
|
-
lastPassedAt: typeof value.lastPassedAt === "string" ? value.lastPassedAt : null,
|
|
293176
|
-
expression,
|
|
293177
|
-
usage: asString(value.usage, "locator"),
|
|
293178
|
-
route: asString(value.route, "*"),
|
|
293179
|
-
source: asString(value.source, "generated")
|
|
293180
|
-
};
|
|
293181
|
-
}
|
|
293182
|
-
function normalizeEntry(value) {
|
|
293183
|
-
if (!isRecord(value))
|
|
293184
|
-
return null;
|
|
293185
|
-
const id = asString(value.id).trim();
|
|
293186
|
-
const expression = asString(value.expression).trim();
|
|
293187
|
-
if (!id || !expression)
|
|
293188
|
-
return null;
|
|
293189
|
-
return {
|
|
293190
|
-
id,
|
|
293191
|
-
key: asString(value.key, id),
|
|
293192
|
-
route: asString(value.route, "*"),
|
|
293193
|
-
scope: asString(value.scope, "page"),
|
|
293194
|
-
status: asString(value.status, "ACTIVE"),
|
|
293195
|
-
stability: asNumber(value.stability, 1),
|
|
293196
|
-
label: asString(value.label),
|
|
293197
|
-
locatorKind: asString(value.locatorKind),
|
|
293198
|
-
locatorRole: typeof value.locatorRole === "string" ? value.locatorRole : null,
|
|
293199
|
-
locatorName: typeof value.locatorName === "string" ? value.locatorName : null,
|
|
293200
|
-
locatorTestId: typeof value.locatorTestId === "string" ? value.locatorTestId : null,
|
|
293201
|
-
expression,
|
|
293202
|
-
fallbacks: asStringArray(value.fallbacks),
|
|
293203
|
-
currentTestRefs: Array.isArray(value.currentTestRefs) ? value.currentTestRefs.map(normalizeRef).filter((ref) => Boolean(ref)) : [],
|
|
293204
|
-
refCount: asNumber(value.refCount, 0),
|
|
293205
|
-
testCount: asNumber(value.testCount, 0),
|
|
293206
|
-
siblingFanoutCount: asNumber(value.siblingFanoutCount, 0),
|
|
293207
|
-
usageFamilies: asStringArray(value.usageFamilies),
|
|
293208
|
-
riskFlags: asStringArray(value.riskFlags),
|
|
293209
|
-
lastHealedAt: typeof value.lastHealedAt === "string" ? value.lastHealedAt : null,
|
|
293210
|
-
recentPassingSiblings: Array.isArray(value.recentPassingSiblings) ? value.recentPassingSiblings.map(normalizeSibling).filter((sibling) => Boolean(sibling)) : [],
|
|
293211
|
-
recentFailingSiblingCount: asNumber(value.recentFailingSiblingCount, 0)
|
|
293212
|
-
};
|
|
293213
|
-
}
|
|
293214
|
-
function normalizeSelectorRegistryEntries(context) {
|
|
293215
|
-
if (!isRecord(context))
|
|
293216
|
-
return [];
|
|
293217
|
-
return Array.isArray(context.entries) ? context.entries.map(normalizeEntry).filter((entry) => Boolean(entry)) : [];
|
|
293218
|
-
}
|
|
293219
|
-
function formatSelectorRegistryContextForPrompt(context) {
|
|
293220
|
-
if (!isRecord(context))
|
|
293221
|
-
return "";
|
|
293222
|
-
const entries = normalizeSelectorRegistryEntries(context);
|
|
293223
|
-
if (entries.length === 0)
|
|
293224
|
-
return "";
|
|
293225
|
-
const summary = isRecord(context.summary) ? context.summary : null;
|
|
293226
|
-
const parts = [
|
|
293227
|
-
"## Selector Registry Memory (read-only evidence)",
|
|
293228
|
-
"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."
|
|
293229
|
-
];
|
|
293230
|
-
if (summary) {
|
|
293231
|
-
const selectorCount = asNumber(summary.selectorCount, entries.length);
|
|
293232
|
-
const sharedSelectorCount = asNumber(summary.sharedSelectorCount, entries.filter((entry) => entry.testCount > 1).length);
|
|
293233
|
-
const maxSharedTestCount = asNumber(summary.maxSharedTestCount, Math.max(...entries.map((entry) => entry.testCount), 0));
|
|
293234
|
-
parts.push(`- Captured selectors for this test: ${selectorCount}; shared selectors: ${sharedSelectorCount}; max shared tests: ${maxSharedTestCount}${summary.truncated ? "; list truncated" : ""}.`);
|
|
293235
|
-
}
|
|
293236
|
-
for (const entry of entries.slice(0, 8)) {
|
|
293237
|
-
const risk = entry.riskFlags.length > 0 ? entry.riskFlags.join(", ") : "none";
|
|
293238
|
-
const families = entry.usageFamilies.length > 0 ? entry.usageFamilies.join(", ") : "locator";
|
|
293239
|
-
parts.push("");
|
|
293240
|
-
parts.push(`### ${truncateForSelectorPrompt(entry.label || entry.key, 90)}`);
|
|
293241
|
-
parts.push(`- Selector: ${inlineCode(entry.expression, 220)}`);
|
|
293242
|
-
parts.push(`- Route/scope/kind: ${entry.route} / ${entry.scope} / ${entry.locatorKind || "unknown"}; usage families: ${families}.`);
|
|
293243
|
-
parts.push(`- Shared by: ${entry.testCount} test(s), ${entry.siblingFanoutCount} sibling(s), ${entry.refCount} ref(s); risks: ${risk}; status: ${entry.status}; stability: ${entry.stability}.`);
|
|
293244
|
-
if (entry.lastHealedAt)
|
|
293245
|
-
parts.push(`- Last registry heal: ${entry.lastHealedAt}.`);
|
|
293246
|
-
const currentRefs = entry.currentTestRefs.slice(0, 3);
|
|
293247
|
-
if (currentRefs.length > 0) {
|
|
293248
|
-
parts.push("- Current test occurrences:");
|
|
293249
|
-
for (const ref of currentRefs) {
|
|
293250
|
-
parts.push(` - ${ref.usage} on ${ref.route} from ${ref.source}: ${inlineCode(ref.expression, 180)}`);
|
|
293251
|
-
}
|
|
293252
|
-
}
|
|
293253
|
-
const siblings = entry.recentPassingSiblings.slice(0, 3);
|
|
293254
|
-
if (siblings.length > 0) {
|
|
293255
|
-
parts.push("- Recent passing sibling tests using this registry entry:");
|
|
293256
|
-
for (const sibling of siblings) {
|
|
293257
|
-
const suite = sibling.suite ? ` (${truncateForSelectorPrompt(sibling.suite, 50)})` : "";
|
|
293258
|
-
const passed = sibling.lastPassedAt ? ` passed ${sibling.lastPassedAt}` : "";
|
|
293259
|
-
parts.push(` - ${truncateForSelectorPrompt(sibling.name, 80)}${suite}${passed}: ${inlineCode(sibling.expression, 180)}`);
|
|
293260
|
-
}
|
|
293261
|
-
}
|
|
293262
|
-
if (entry.recentFailingSiblingCount && entry.recentFailingSiblingCount > 0) {
|
|
293263
|
-
parts.push(`- Other recent failing siblings on this entry: ${entry.recentFailingSiblingCount}.`);
|
|
293264
|
-
}
|
|
293265
|
-
const fallbacks = (entry.fallbacks ?? []).slice(0, 3);
|
|
293266
|
-
if (fallbacks.length > 0) {
|
|
293267
|
-
parts.push(`- Registry fallbacks: ${fallbacks.map((fallback) => inlineCode(fallback, 120)).join(", ")}.`);
|
|
293268
|
-
}
|
|
293269
|
-
}
|
|
293270
|
-
return `${parts.join("\n")}
|
|
293271
|
-
`;
|
|
293272
|
-
}
|
|
293273
|
-
function selectorKey(expression) {
|
|
293274
|
-
return expression.trim().replace(/^page\s*\.\s*/, "").replace(/\s+/g, " ").replace(/"/g, "'").trim();
|
|
293275
|
-
}
|
|
293276
|
-
function collectRegistryCandidates(context) {
|
|
293277
|
-
const entries = normalizeSelectorRegistryEntries(context).slice(0, 8);
|
|
293278
|
-
const seen = /* @__PURE__ */ new Set();
|
|
293279
|
-
const ranked = [[], [], []];
|
|
293280
|
-
for (const entry of entries) {
|
|
293281
|
-
const entryLabel = entry.label || entry.key;
|
|
293282
|
-
for (const sibling of entry.recentPassingSiblings) {
|
|
293283
|
-
const key = selectorKey(sibling.expression);
|
|
293284
|
-
if (seen.has(key))
|
|
293285
|
-
continue;
|
|
293286
|
-
seen.add(key);
|
|
293287
|
-
ranked[0].push({ expression: sibling.expression, source: "passing-sibling", entryLabel, siblingName: sibling.name });
|
|
293288
|
-
}
|
|
293289
|
-
const entryKey = selectorKey(entry.expression);
|
|
293290
|
-
if (!seen.has(entryKey)) {
|
|
293291
|
-
seen.add(entryKey);
|
|
293292
|
-
ranked[1].push({ expression: entry.expression, source: "registry-entry", entryLabel });
|
|
293293
|
-
}
|
|
293294
|
-
for (const fallback of entry.fallbacks ?? []) {
|
|
293295
|
-
const key = selectorKey(fallback);
|
|
293296
|
-
if (seen.has(key))
|
|
293297
|
-
continue;
|
|
293298
|
-
seen.add(key);
|
|
293299
|
-
ranked[2].push({ expression: fallback, source: "fallback", entryLabel });
|
|
293300
|
-
}
|
|
293301
|
-
}
|
|
293302
|
-
return ranked.flat();
|
|
293303
|
-
}
|
|
293304
|
-
function buildRegistryCandidateBlock(context, excludeExpressions) {
|
|
293305
|
-
const exclude = new Set(excludeExpressions.map(selectorKey));
|
|
293306
|
-
const candidates = collectRegistryCandidates(context).filter((candidate) => !exclude.has(selectorKey(candidate.expression))).slice(0, 6);
|
|
293307
|
-
if (candidates.length === 0)
|
|
293308
|
-
return "";
|
|
293309
|
-
const lines = [
|
|
293310
|
-
"## Untried Selector Candidates (from registry memory)",
|
|
293311
|
-
"Ranked alternatives for elements this test uses, from the platform selector registry. Verify each against the screenshot and error before adopting \u2014 do not adopt blindly."
|
|
293312
|
-
];
|
|
293313
|
-
for (const candidate of candidates) {
|
|
293314
|
-
const provenance = candidate.source === "passing-sibling" ? `proven in passing sibling "${truncateForSelectorPrompt(candidate.siblingName, 80)}"` : candidate.source === "registry-entry" ? `registry entry for "${truncateForSelectorPrompt(candidate.entryLabel, 80)}"` : `registry fallback for "${truncateForSelectorPrompt(candidate.entryLabel, 80)}"`;
|
|
293315
|
-
lines.push(`- ${inlineCode(candidate.expression, 180)} \u2014 ${provenance}`);
|
|
293316
|
-
}
|
|
293317
|
-
return lines.join("\n");
|
|
293318
|
-
}
|
|
293319
|
-
function findExpressionInCode(code, expression) {
|
|
293320
|
-
const trimmed = expression.trim();
|
|
293321
|
-
if (trimmed && code.includes(trimmed))
|
|
293322
|
-
return trimmed;
|
|
293323
|
-
const withoutPage = trimmed.replace(/^page\s*\.\s*/, "");
|
|
293324
|
-
if (withoutPage !== trimmed && withoutPage && code.includes(withoutPage))
|
|
293325
|
-
return withoutPage;
|
|
293326
|
-
const withPage = `page.${withoutPage}`;
|
|
293327
|
-
if (withPage !== trimmed && code.includes(withPage))
|
|
293328
|
-
return withPage;
|
|
293329
|
-
return null;
|
|
293330
|
-
}
|
|
293331
|
-
function extractFailureFocus(error2) {
|
|
293332
|
-
const focus = error2.split(/\r?\n/).filter((line) => /^\s*>/.test(line) || /waiting for|locator resolved to/i.test(line) || /^\s*locator:/i.test(line));
|
|
293333
|
-
return focus.length > 0 ? focus.join("\n") : error2;
|
|
293334
|
-
}
|
|
293335
|
-
function planRegistryFallbackFix(args) {
|
|
293336
|
-
const { code, error: error2, context } = args;
|
|
293337
|
-
if (!error2 || !code)
|
|
293338
|
-
return null;
|
|
293339
|
-
const entries = normalizeSelectorRegistryEntries(context).slice(0, 8);
|
|
293340
|
-
if (entries.length === 0)
|
|
293341
|
-
return null;
|
|
293342
|
-
const errorKey = selectorKey(extractFailureFocus(error2));
|
|
293343
|
-
for (const entry of entries) {
|
|
293344
|
-
const failingExpressions = [entry.expression, ...entry.currentTestRefs.map((ref) => ref.expression)];
|
|
293345
|
-
const failingSeen = /* @__PURE__ */ new Set();
|
|
293346
|
-
for (const failing of failingExpressions) {
|
|
293347
|
-
const failingKey = selectorKey(failing);
|
|
293348
|
-
if (!failingKey || failingSeen.has(failingKey))
|
|
293349
|
-
continue;
|
|
293350
|
-
failingSeen.add(failingKey);
|
|
293351
|
-
if (!errorKey.includes(failingKey))
|
|
293352
|
-
continue;
|
|
293353
|
-
const matchedInCode = findExpressionInCode(code, failing);
|
|
293354
|
-
if (!matchedInCode)
|
|
293355
|
-
continue;
|
|
293356
|
-
const entryLabel = entry.label || entry.key;
|
|
293357
|
-
const candidateSeen = /* @__PURE__ */ new Set([failingKey]);
|
|
293358
|
-
const candidates = [];
|
|
293359
|
-
for (const fallback of entry.fallbacks ?? []) {
|
|
293360
|
-
const key = selectorKey(fallback);
|
|
293361
|
-
if (!key || candidateSeen.has(key) || code.includes(fallback.trim()))
|
|
293362
|
-
continue;
|
|
293363
|
-
candidateSeen.add(key);
|
|
293364
|
-
candidates.push({ expression: fallback, source: "fallback", entryLabel });
|
|
293365
|
-
}
|
|
293366
|
-
for (const sibling of entry.recentPassingSiblings) {
|
|
293367
|
-
const key = selectorKey(sibling.expression);
|
|
293368
|
-
if (!key || candidateSeen.has(key) || code.includes(sibling.expression.trim()))
|
|
293369
|
-
continue;
|
|
293370
|
-
candidateSeen.add(key);
|
|
293371
|
-
candidates.push({ expression: sibling.expression, source: "passing-sibling", entryLabel, siblingName: sibling.name });
|
|
293372
|
-
}
|
|
293373
|
-
if (candidates.length === 0)
|
|
293374
|
-
continue;
|
|
293375
|
-
return { matchedInCode, entryLabel, candidates };
|
|
293376
|
-
}
|
|
293377
|
-
}
|
|
293378
|
-
return null;
|
|
293379
|
-
}
|
|
293380
|
-
function substituteSelectorInCode(code, matchedInCode, replacement) {
|
|
293381
|
-
const target = matchedInCode.trim();
|
|
293382
|
-
let normalized = replacement.trim();
|
|
293383
|
-
const targetHasPage = /^page\s*\.\s*/.test(target);
|
|
293384
|
-
const replacementHasPage = /^page\s*\.\s*/.test(normalized);
|
|
293385
|
-
if (targetHasPage && !replacementHasPage)
|
|
293386
|
-
normalized = `page.${normalized}`;
|
|
293387
|
-
if (!targetHasPage && replacementHasPage)
|
|
293388
|
-
normalized = normalized.replace(/^page\s*\.\s*/, "");
|
|
293389
|
-
return code.split(target).join(normalized);
|
|
293390
|
-
}
|
|
293391
|
-
}
|
|
293392
|
-
});
|
|
293393
|
-
|
|
293394
294393
|
// ../runner-core/dist/services/heal-selector-ledger.js
|
|
293395
294394
|
var require_heal_selector_ledger = __commonJS({
|
|
293396
294395
|
"../runner-core/dist/services/heal-selector-ledger.js"(exports2) {
|
|
@@ -313796,7 +314795,7 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
313796
314795
|
log2(exploreAppDataCleared ? "App data cleared \u2014 exploring from a deterministic cold (logged-out) state." : "App data reset unavailable (platform/device policy) \u2014 exploring the app in its current state.");
|
|
313797
314796
|
const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
|
|
313798
314797
|
const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
|
|
313799
|
-
const existingScreens = compactExistingScreens(ctx.existingSiteMap);
|
|
314798
|
+
const existingScreens = isSurvey && ctx.incremental ? compactExistingScreens(ctx.existingSiteMap) : void 0;
|
|
313800
314799
|
const explore = await runExplore({
|
|
313801
314800
|
ctx,
|
|
313802
314801
|
driver,
|
|
@@ -328144,8 +329143,9 @@ var require_dist2 = __commonJS({
|
|
|
328144
329143
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
|
|
328145
329144
|
};
|
|
328146
329145
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
328147
|
-
exports2.
|
|
328148
|
-
exports2.
|
|
329146
|
+
exports2.buildImportSystemRules = exports2.buildImportKickoff = exports2.buildImportContractMd = exports2.MAX_ARCHIVE_BYTES = exports2.isUnsafeEntryName = exports2.extractZipSafely = exports2.downloadArchiveCapped = exports2.detectArchiveKind = exports2.acquireRepoForImport = exports2.runFrameworkImportOnRunner = exports2.getAgenticEngine = exports2.resolveRefFromSnapshot = exports2.replayAuthRecipeIntoBrowser = exports2.runRegisterCaptureRun = exports2.runLoginCaptureRun = exports2.AUTH_MARKER_VERSION = exports2.buildHealAuthStamp = exports2.extractAuthRegion = exports2.upsertAuthBlock = exports2.buildAuthRecipe = exports2.commandToReplayStep = exports2.buildReplayPlan = exports2.absoluteRoute = exports2.renderReplaySettleArrow = exports2.renderReplayClickArrow = exports2.renderAuthFn = exports2.canonicalizeRoute = exports2.executeGrokPerBatchHeal = exports2.executeGrokPerTestHeal = exports2.headerVal = exports2.classifyAuthType = exports2.isTextExcerptable = exports2.isStaticAssetByExt = exports2.isApiCall = exports2.buildApiCallSummary = exports2.computeMobileHealOutcome = exports2.detectMobileStepSecretLeak = exports2.redactMobileSecretsFromText = exports2.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = exports2.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = exports2.executeMobileLiveHeal = exports2.executeMobileTestWithHealing = exports2.executeMobileScriptDrivenHeal = exports2.runMobileGeneratePhase = exports2.runMobileAIPlanner = exports2.buildMobileSiteMap = exports2.formatMobileEvidenceForPlanner = exports2.MOBILE_SCENARIO_SYSTEM_PROMPT = exports2.MOBILE_SURVEY_SYSTEM_PROMPT = exports2.runMobileExplorePhase = void 0;
|
|
329147
|
+
exports2.UnsupportedRendererError = exports2.buildCanvasModeAuthoringAddendum = exports2.buildUnsupportedRendererMessage = exports2.modelSupportsVision = exports2.decideCanvasStrategy = exports2.decideAppRenderType = exports2.detectCanvasApp = exports2.detectRateLimitBlock = exports2.isSafeCanonicalRedirect = exports2.resolveBaseUrlPreflight = exports2.checkBaseUrlRedirect = exports2.stripJsonFences = exports2.scrubCredentials = exports2.withAIRetry = exports2.snapshotMatchingChildren = exports2.findNewChromiumChildPid = exports2.killProcessTree = exports2.gracefulCloseWithTreeKill = exports2.liveBrowserRegistry = exports2.REPO_SWITCH_REMOVED_FRACTION = exports2.MAX_MANIFEST_FILES = exports2.toDiffSummary = exports2.isLikelyTestFile = exports2.sanitizeRepoRelPath = exports2.sha256Hex = exports2.hashFiles = exports2.enumerateTestFiles = exports2.buildNextManifest = exports2.detectRepoSwitch = exports2.classifyImportDiff = exports2.parseImportManifest = exports2.REPO_ANALYSIS_SCHEMA_VERSION = exports2.validateSourceEndpoints = exports2.validateAuthoredTest = exports2.validateRepoMapFile = exports2.validateRepoMemoryFile = exports2.buildTestingStandardsSection = exports2.buildRepoAuthorSystemRules = exports2.buildRepoAuthorKickoff = exports2.buildRepoAuthorContractMd = exports2.buildRepoMapSystemRules = exports2.buildRepoMapKickoff = exports2.buildRepoMapContractMd = exports2.buildRepoMemorySystemRules = exports2.buildRepoMemoryKickoff = exports2.buildRepoMemoryContractMd = exports2.runRepoAnalysisOnRunner = exports2.IMPORT_SCHEMA_VERSION = exports2.validateImportResultFile = exports2.validateImportedTest = void 0;
|
|
329148
|
+
exports2.UNSUPPORTED_RENDERER_PREFIX = void 0;
|
|
328149
329149
|
__exportStar(require_ai_provider(), exports2);
|
|
328150
329150
|
__exportStar(require_mobile_types(), exports2);
|
|
328151
329151
|
__exportStar(require_mobile_source_parser(), exports2);
|
|
@@ -328174,6 +329174,32 @@ var require_dist2 = __commonJS({
|
|
|
328174
329174
|
Object.defineProperty(exports2, "runMobileGeneratePhase", { enumerable: true, get: function() {
|
|
328175
329175
|
return mobile_generator_js_1.runMobileGeneratePhase;
|
|
328176
329176
|
} });
|
|
329177
|
+
var mobile_heal_loop_js_1 = require_mobile_heal_loop();
|
|
329178
|
+
Object.defineProperty(exports2, "executeMobileScriptDrivenHeal", { enumerable: true, get: function() {
|
|
329179
|
+
return mobile_heal_loop_js_1.executeMobileScriptDrivenHeal;
|
|
329180
|
+
} });
|
|
329181
|
+
Object.defineProperty(exports2, "executeMobileTestWithHealing", { enumerable: true, get: function() {
|
|
329182
|
+
return mobile_heal_loop_js_1.executeMobileTestWithHealing;
|
|
329183
|
+
} });
|
|
329184
|
+
Object.defineProperty(exports2, "executeMobileLiveHeal", { enumerable: true, get: function() {
|
|
329185
|
+
return mobile_heal_loop_js_1.executeMobileLiveHeal;
|
|
329186
|
+
} });
|
|
329187
|
+
Object.defineProperty(exports2, "MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT", { enumerable: true, get: function() {
|
|
329188
|
+
return mobile_heal_loop_js_1.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT;
|
|
329189
|
+
} });
|
|
329190
|
+
Object.defineProperty(exports2, "MOBILE_LIVE_HEAL_SYSTEM_PROMPT", { enumerable: true, get: function() {
|
|
329191
|
+
return mobile_heal_loop_js_1.MOBILE_LIVE_HEAL_SYSTEM_PROMPT;
|
|
329192
|
+
} });
|
|
329193
|
+
var mobile_heal_postprocess_js_1 = require_mobile_heal_postprocess();
|
|
329194
|
+
Object.defineProperty(exports2, "redactMobileSecretsFromText", { enumerable: true, get: function() {
|
|
329195
|
+
return mobile_heal_postprocess_js_1.redactMobileSecretsFromText;
|
|
329196
|
+
} });
|
|
329197
|
+
Object.defineProperty(exports2, "detectMobileStepSecretLeak", { enumerable: true, get: function() {
|
|
329198
|
+
return mobile_heal_postprocess_js_1.detectMobileStepSecretLeak;
|
|
329199
|
+
} });
|
|
329200
|
+
Object.defineProperty(exports2, "computeMobileHealOutcome", { enumerable: true, get: function() {
|
|
329201
|
+
return mobile_heal_postprocess_js_1.computeMobileHealOutcome;
|
|
329202
|
+
} });
|
|
328177
329203
|
__exportStar(require_browser_config(), exports2);
|
|
328178
329204
|
__exportStar(require_mcp(), exports2);
|
|
328179
329205
|
var api_call_redaction_js_1 = require_api_call_redaction();
|
|
@@ -328483,7 +329509,7 @@ var require_package4 = __commonJS({
|
|
|
328483
329509
|
"package.json"(exports2, module2) {
|
|
328484
329510
|
module2.exports = {
|
|
328485
329511
|
name: "@validate.qa/runner",
|
|
328486
|
-
version: "1.0.
|
|
329512
|
+
version: "1.0.25",
|
|
328487
329513
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
328488
329514
|
bin: {
|
|
328489
329515
|
"validate-runner": "dist/cli.js"
|
|
@@ -330552,6 +331578,14 @@ async function takeScreenshot(sessionId) {
|
|
|
330552
331578
|
return null;
|
|
330553
331579
|
}
|
|
330554
331580
|
}
|
|
331581
|
+
async function captureFailingSource(sessionId) {
|
|
331582
|
+
try {
|
|
331583
|
+
const result = await wdRequest(`/session/${sessionId}/source`);
|
|
331584
|
+
return typeof result?.value === "string" ? result.value : void 0;
|
|
331585
|
+
} catch {
|
|
331586
|
+
return void 0;
|
|
331587
|
+
}
|
|
331588
|
+
}
|
|
330555
331589
|
function mobileRecordingEnabled() {
|
|
330556
331590
|
const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
|
|
330557
331591
|
return value !== "0" && value !== "false";
|
|
@@ -330681,9 +331715,12 @@ function identityTextXpath(value) {
|
|
|
330681
331715
|
const lit = xpathLiteral(value);
|
|
330682
331716
|
return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
|
|
330683
331717
|
}
|
|
331718
|
+
function isIdentityXpath(value) {
|
|
331719
|
+
return value.includes("@") && !/(?:contains|starts-with)\s*\(/i.test(value);
|
|
331720
|
+
}
|
|
330684
331721
|
function isIdentityStrategy(strategy) {
|
|
330685
331722
|
if (strategy.using === "accessibility id" || strategy.using === "id") return true;
|
|
330686
|
-
if (strategy.using === "xpath") return strategy.value
|
|
331723
|
+
if (strategy.using === "xpath") return isIdentityXpath(strategy.value);
|
|
330687
331724
|
return false;
|
|
330688
331725
|
}
|
|
330689
331726
|
function buildIdentityLocatorStrategies(target, platform3) {
|
|
@@ -330697,7 +331734,7 @@ function buildIdentityLocatorStrategies(target, platform3) {
|
|
|
330697
331734
|
const trimmed = value?.trim();
|
|
330698
331735
|
if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
|
|
330699
331736
|
}
|
|
330700
|
-
if (bundle.xpath && bundle.xpath
|
|
331737
|
+
if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
|
|
330701
331738
|
}
|
|
330702
331739
|
if (!(platform3 && hasPlatformLocator(target, platform3))) {
|
|
330703
331740
|
for (const value of [target.primary, ...target.fallbacks ?? []]) {
|
|
@@ -331044,6 +332081,7 @@ async function executeMobileTest(options) {
|
|
|
331044
332081
|
let recordingStarted = false;
|
|
331045
332082
|
let recordingStopped = false;
|
|
331046
332083
|
let video;
|
|
332084
|
+
let failingSource;
|
|
331047
332085
|
const stopCapture = async () => {
|
|
331048
332086
|
if (!capture || captureStopped) return apiCalls;
|
|
331049
332087
|
captureStopped = true;
|
|
@@ -331067,6 +332105,10 @@ async function executeMobileTest(options) {
|
|
|
331067
332105
|
}
|
|
331068
332106
|
return video;
|
|
331069
332107
|
};
|
|
332108
|
+
const recordFirstFailingSource = async () => {
|
|
332109
|
+
if (failingSource != null || !sessionId) return;
|
|
332110
|
+
failingSource = await captureFailingSource(sessionId);
|
|
332111
|
+
};
|
|
331070
332112
|
try {
|
|
331071
332113
|
log2("Checking Appium server health...");
|
|
331072
332114
|
const health = await checkAppiumHealth();
|
|
@@ -331188,9 +332230,18 @@ async function executeMobileTest(options) {
|
|
|
331188
332230
|
let entryDrift = false;
|
|
331189
332231
|
const runPlatform = options.target.platform;
|
|
331190
332232
|
const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
|
|
331191
|
-
const
|
|
332233
|
+
const CONTAINER_ONLY_ID_RE = /(?:^|[:/])id\/(webview|content|decor|decor_content_parent|action_bar_root|statusBarBackground|navigationBarBackground)$|^(?:main-content|root|app|app-root|__next|react-root|application)$/i;
|
|
332234
|
+
const isContainerOnlyEntryTarget = (target) => {
|
|
332235
|
+
const primary = target?.primary?.trim() ?? "";
|
|
332236
|
+
if (primary === "") return false;
|
|
332237
|
+
const m = primary.match(/^mobile\.(?:resourceId|accessibilityId)\('([^']+)'\)$/);
|
|
332238
|
+
const value = m ? m[1] : primary;
|
|
332239
|
+
return CONTAINER_ONLY_ID_RE.test(value);
|
|
332240
|
+
};
|
|
332241
|
+
const identityBearingSteps = runSteps.filter(
|
|
331192
332242
|
(s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
|
|
331193
332243
|
);
|
|
332244
|
+
const entryStep = identityBearingSteps.find((s) => !isContainerOnlyEntryTarget(s.target)) ?? identityBearingSteps[0];
|
|
331194
332245
|
if (entryStep) {
|
|
331195
332246
|
try {
|
|
331196
332247
|
const entryTimeout = recordedFromClearedState ? Math.max(DEFAULT_STEP_TIMEOUT_MS, COLD_BOOT_ENTRY_TIMEOUT_MS) : DEFAULT_STEP_TIMEOUT_MS;
|
|
@@ -331200,6 +332251,7 @@ async function executeMobileTest(options) {
|
|
|
331200
332251
|
entryDrift = true;
|
|
331201
332252
|
const driftMsg = `App started on an unexpected screen \u2014 the recorded flow's first element ("${entryStep.target?.primary ?? entryStep.description}") is not present, so the replay was aborted before it could blind-tap stale coordinates (screen drift; the app may be on onboarding or a different screen than when the test was recorded).`;
|
|
331202
332253
|
log2(` \u2717 Entry-screen drift: ${driftMsg}`);
|
|
332254
|
+
await recordFirstFailingSource();
|
|
331203
332255
|
stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
|
|
331204
332256
|
const driftShot = await takeScreenshot(sessionId).catch(() => null);
|
|
331205
332257
|
screenshots.push(driftShot ?? "");
|
|
@@ -331276,6 +332328,9 @@ async function executeMobileTest(options) {
|
|
|
331276
332328
|
}
|
|
331277
332329
|
const screenshot = await takeScreenshot(sessionId).catch(() => null);
|
|
331278
332330
|
screenshots.push(screenshot ?? "");
|
|
332331
|
+
if (status === "failed") {
|
|
332332
|
+
await recordFirstFailingSource();
|
|
332333
|
+
}
|
|
331279
332334
|
stepResults.push({
|
|
331280
332335
|
stepOrder: step.order,
|
|
331281
332336
|
status,
|
|
@@ -331313,7 +332368,8 @@ async function executeMobileTest(options) {
|
|
|
331313
332368
|
duration: Date.now() - startTime,
|
|
331314
332369
|
deviceId: selectedDevice?.id,
|
|
331315
332370
|
deviceName: selectedDevice?.name,
|
|
331316
|
-
environmental: environmental ?? void 0
|
|
332371
|
+
environmental: environmental ?? void 0,
|
|
332372
|
+
failingSource
|
|
331317
332373
|
};
|
|
331318
332374
|
} catch (err) {
|
|
331319
332375
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -331334,7 +332390,8 @@ async function executeMobileTest(options) {
|
|
|
331334
332390
|
// A throw before/around the step loop is a setup/environment problem
|
|
331335
332391
|
// (no device booted, app not installed, session could not be created,
|
|
331336
332392
|
// Appium unreachable) — never a genuine test assertion failure.
|
|
331337
|
-
environmental: errorMsg
|
|
332393
|
+
environmental: errorMsg,
|
|
332394
|
+
failingSource
|
|
331338
332395
|
};
|
|
331339
332396
|
} finally {
|
|
331340
332397
|
await stopCapture();
|
|
@@ -333303,6 +334360,9 @@ function budgetResultPayload(result, maxBytes = RESULT_BODY_BUDGET_BYTES, videoS
|
|
|
333303
334360
|
if ((bytes > maxBytes || oversizedVideo) && payload.video != null) {
|
|
333304
334361
|
drop(["video", "videoMimeType"], "video");
|
|
333305
334362
|
}
|
|
334363
|
+
if (bytes > maxBytes && payload.failingSource != null) {
|
|
334364
|
+
drop(["failingSource"], "failingSource");
|
|
334365
|
+
}
|
|
333306
334366
|
if (bytes > maxBytes && Array.isArray(payload.screenshots) && payload.screenshots.length > 0) {
|
|
333307
334367
|
const shots = [...payload.screenshots];
|
|
333308
334368
|
const bySizeDesc = shots.map((shot, i) => ({ i, len: typeof shot === "string" ? shot.length : 0 })).sort((a, b) => b.len - a.len);
|
|
@@ -333354,6 +334414,7 @@ ${tail}` };
|
|
|
333354
334414
|
// src/runner.ts
|
|
333355
334415
|
init_dist();
|
|
333356
334416
|
var import_runner_core12 = __toESM(require_dist2());
|
|
334417
|
+
var import_runner_core13 = __toESM(require_dist2());
|
|
333357
334418
|
function fingerprintTest(test) {
|
|
333358
334419
|
const code = test.framework === "APPIUM" ? [test.appiumCode ?? "", JSON.stringify(test.steps ?? [])].join("\n--steps--\n") : test.playwrightCode ?? "";
|
|
333359
334420
|
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);
|
|
@@ -333591,6 +334652,33 @@ async function postHealResult(serverUrl, headers, runId, healedCode, testCaseId,
|
|
|
333591
334652
|
}
|
|
333592
334653
|
throw new Error(`Failed to post heal result after ${MAX_ATTEMPTS} attempts: ${lastError?.message}`);
|
|
333593
334654
|
}
|
|
334655
|
+
async function postMobileHealResult(serverUrl, headers, runId, body) {
|
|
334656
|
+
const MAX_ATTEMPTS = 3;
|
|
334657
|
+
let lastError;
|
|
334658
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
334659
|
+
try {
|
|
334660
|
+
const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/mobile-heal-result`, {
|
|
334661
|
+
method: "POST",
|
|
334662
|
+
headers,
|
|
334663
|
+
body: JSON.stringify(body),
|
|
334664
|
+
signal: AbortSignal.timeout(6e4)
|
|
334665
|
+
});
|
|
334666
|
+
if (res.ok) return;
|
|
334667
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
334668
|
+
} catch (err) {
|
|
334669
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
334670
|
+
}
|
|
334671
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
334672
|
+
const delay = Math.pow(2, attempt) * 1e3;
|
|
334673
|
+
console.warn(`[runner] postMobileHealResult attempt ${attempt} failed (${lastError.message}), retrying in ${delay}ms...`);
|
|
334674
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
334675
|
+
}
|
|
334676
|
+
}
|
|
334677
|
+
throw new Error(`Failed to post mobile heal result after ${MAX_ATTEMPTS} attempts: ${lastError?.message}`);
|
|
334678
|
+
}
|
|
334679
|
+
function narrowMobileLaunchMode(mode) {
|
|
334680
|
+
return mode === "LAUNCH_APP" || mode === "DEEP_LINK" || mode === "RESUME" ? mode : void 0;
|
|
334681
|
+
}
|
|
333594
334682
|
async function postBatchHealResult(serverUrl, headers, runId, body) {
|
|
333595
334683
|
const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/batch-heal-result`, {
|
|
333596
334684
|
method: "POST",
|
|
@@ -335499,6 +336587,218 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
335499
336587
|
log.fail("ERROR - No mobile steps");
|
|
335500
336588
|
return;
|
|
335501
336589
|
}
|
|
336590
|
+
if (runMode === "heal" && run2.mobileAiHeal === true) {
|
|
336591
|
+
if (getBridgeState().hasActiveSession) {
|
|
336592
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
336593
|
+
status: "ERROR",
|
|
336594
|
+
error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; the AI heal will retry once it ends.",
|
|
336595
|
+
duration: Date.now() - startTime
|
|
336596
|
+
});
|
|
336597
|
+
log.warn("Skipped mobile AI heal (device busy with a recording)");
|
|
336598
|
+
return;
|
|
336599
|
+
}
|
|
336600
|
+
const healTarget = {
|
|
336601
|
+
appId: target.appId,
|
|
336602
|
+
platform: target.platform,
|
|
336603
|
+
platformVersion: target.platformVersion,
|
|
336604
|
+
recordedDeviceId: target.recordedDeviceId,
|
|
336605
|
+
assignedDeviceId,
|
|
336606
|
+
launchMode: target.launchMode,
|
|
336607
|
+
deeplink: target.deeplink,
|
|
336608
|
+
authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
|
|
336609
|
+
};
|
|
336610
|
+
const nativeVerify = { last: null };
|
|
336611
|
+
const runNativeTest = async ({ steps, target: t, testVariables }) => {
|
|
336612
|
+
const res = await executeMobileTest({
|
|
336613
|
+
steps,
|
|
336614
|
+
target: {
|
|
336615
|
+
appId: t.appId,
|
|
336616
|
+
platform: t.platform,
|
|
336617
|
+
platformVersion: t.platformVersion,
|
|
336618
|
+
recordedDeviceId: t.recordedDeviceId,
|
|
336619
|
+
assignedDeviceId: t.assignedDeviceId,
|
|
336620
|
+
launchMode: narrowMobileLaunchMode(t.launchMode),
|
|
336621
|
+
deeplink: t.deeplink,
|
|
336622
|
+
authoringPlatform: t.authoringPlatform
|
|
336623
|
+
},
|
|
336624
|
+
testVariables
|
|
336625
|
+
});
|
|
336626
|
+
nativeVerify.last = res;
|
|
336627
|
+
return {
|
|
336628
|
+
passed: res.passed,
|
|
336629
|
+
error: res.error,
|
|
336630
|
+
stepResults: res.stepResults.map((s) => ({ stepOrder: s.stepOrder, status: s.status, error: s.error })),
|
|
336631
|
+
failingSource: res.failingSource,
|
|
336632
|
+
logs: res.logs
|
|
336633
|
+
};
|
|
336634
|
+
};
|
|
336635
|
+
setExecutorBusy(true);
|
|
336636
|
+
try {
|
|
336637
|
+
log.info("\u{1FA7A} Mobile AI heal \u2014 reproducing the failure on device...");
|
|
336638
|
+
const reproduce = await runNativeTest({ steps: mobileSteps, target: healTarget, testVariables: run2.testVariables });
|
|
336639
|
+
if (reproduce.passed) {
|
|
336640
|
+
const recovered = nativeVerify.last;
|
|
336641
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
336642
|
+
status: "PASSED",
|
|
336643
|
+
stepResults: recovered?.stepResults,
|
|
336644
|
+
screenshots: recovered?.screenshots,
|
|
336645
|
+
video: recovered?.video,
|
|
336646
|
+
videoMimeType: recovered?.videoMimeType,
|
|
336647
|
+
apiCalls: recovered?.apiCalls,
|
|
336648
|
+
logs: recovered?.logs,
|
|
336649
|
+
duration: Date.now() - startTime
|
|
336650
|
+
});
|
|
336651
|
+
try {
|
|
336652
|
+
await postMobileHealResult(opts.serverUrl, headers, run2.runId, {
|
|
336653
|
+
healedSteps: mobileSteps,
|
|
336654
|
+
healedAppiumCode: "",
|
|
336655
|
+
healReason: "recovered on reproduce",
|
|
336656
|
+
verdict: "fix",
|
|
336657
|
+
recovered: true,
|
|
336658
|
+
priorStepsHash: run2.priorStepsHash
|
|
336659
|
+
});
|
|
336660
|
+
log.ok("\u{1FA7A} Reproduce PASSED \u2014 test recovered (flaky); promotion posted");
|
|
336661
|
+
} catch (patchErr) {
|
|
336662
|
+
log.warn(`Failed to post recovered heal result: ${patchErr instanceof Error ? patchErr.message : String(patchErr)}`);
|
|
336663
|
+
}
|
|
336664
|
+
return;
|
|
336665
|
+
}
|
|
336666
|
+
const reproduceError = reproduce.error;
|
|
336667
|
+
const modelOverride = run2.healerModel ?? void 0;
|
|
336668
|
+
const healerOptions = {
|
|
336669
|
+
aiClient: modelOverride ? (0, import_runner_core13.getMobileClientForModel)(modelOverride) : (0, import_runner_core13.getMobileAIClient)(),
|
|
336670
|
+
...modelOverride ? { model: modelOverride } : {},
|
|
336671
|
+
runNativeTest,
|
|
336672
|
+
selectorRegistryContext: run2.selectorRegistryContext ?? null,
|
|
336673
|
+
runLiveHeal: async (a) => {
|
|
336674
|
+
let liveSession = null;
|
|
336675
|
+
try {
|
|
336676
|
+
liveSession = await createMobileDiscoverySession({
|
|
336677
|
+
appId: target.appId,
|
|
336678
|
+
platform: target.platform,
|
|
336679
|
+
platformVersion: target.platformVersion,
|
|
336680
|
+
recordedDeviceId: target.recordedDeviceId,
|
|
336681
|
+
assignedDeviceId,
|
|
336682
|
+
launchMode: target.launchMode,
|
|
336683
|
+
deeplink: target.deeplink
|
|
336684
|
+
});
|
|
336685
|
+
attachMirrorSession(liveSession.sessionId, target.platform, liveSession.deviceId);
|
|
336686
|
+
const bridgeDriver = new MobileBridgeDriver({
|
|
336687
|
+
sessionId: liveSession.sessionId,
|
|
336688
|
+
platform: target.platform,
|
|
336689
|
+
appId: liveSession.appId,
|
|
336690
|
+
allowAppReset: shouldResetMobileAppData(target.platform, liveSession.deviceId)
|
|
336691
|
+
});
|
|
336692
|
+
const liveDriver = {
|
|
336693
|
+
snapshot: async () => {
|
|
336694
|
+
const snap = await bridgeDriver.snapshot();
|
|
336695
|
+
return { xml: snap.xml, screenshot: snap.screenshot, windowSize: snap.windowSize };
|
|
336696
|
+
},
|
|
336697
|
+
tap: async (bounds) => {
|
|
336698
|
+
await bridgeDriver.tap(bounds);
|
|
336699
|
+
},
|
|
336700
|
+
type: async (value, bounds) => {
|
|
336701
|
+
await bridgeDriver.type(value, bounds);
|
|
336702
|
+
},
|
|
336703
|
+
clear: async (bounds) => {
|
|
336704
|
+
await bridgeDriver.clear(bounds);
|
|
336705
|
+
},
|
|
336706
|
+
swipe: async (direction, distance) => {
|
|
336707
|
+
await bridgeDriver.swipe(direction, distance);
|
|
336708
|
+
},
|
|
336709
|
+
back: async () => {
|
|
336710
|
+
await bridgeDriver.back();
|
|
336711
|
+
},
|
|
336712
|
+
home: async () => {
|
|
336713
|
+
await bridgeDriver.home();
|
|
336714
|
+
},
|
|
336715
|
+
relaunch: async () => {
|
|
336716
|
+
await bridgeDriver.relaunch();
|
|
336717
|
+
},
|
|
336718
|
+
resetAppState: async () => bridgeDriver.resetAppState()
|
|
336719
|
+
};
|
|
336720
|
+
return await (0, import_runner_core13.executeMobileLiveHeal)({
|
|
336721
|
+
steps: a.steps,
|
|
336722
|
+
target: a.target,
|
|
336723
|
+
testVariables: a.testVariables,
|
|
336724
|
+
priorError: reproduceError,
|
|
336725
|
+
options: { ...a.options ?? healerOptions, liveDriver }
|
|
336726
|
+
});
|
|
336727
|
+
} finally {
|
|
336728
|
+
if (liveSession) {
|
|
336729
|
+
detachMirrorSession(liveSession.deviceId);
|
|
336730
|
+
try {
|
|
336731
|
+
await liveSession.stop();
|
|
336732
|
+
} catch {
|
|
336733
|
+
}
|
|
336734
|
+
}
|
|
336735
|
+
}
|
|
336736
|
+
}
|
|
336737
|
+
};
|
|
336738
|
+
log.info("\u{1FA7A} Mobile AI heal \u2014 Phase 1 (script) \u2192 Phase 2 (live device, lazy)...");
|
|
336739
|
+
const heal = await (0, import_runner_core13.executeMobileTestWithHealing)(
|
|
336740
|
+
mobileSteps,
|
|
336741
|
+
healTarget,
|
|
336742
|
+
{ error: reproduceError, logs: reproduce.logs, failingSource: reproduce.failingSource, testVariables: run2.testVariables },
|
|
336743
|
+
healerOptions
|
|
336744
|
+
);
|
|
336745
|
+
const finalRes = nativeVerify.last;
|
|
336746
|
+
const outcome = (0, import_runner_core13.computeMobileHealOutcome)({
|
|
336747
|
+
heal,
|
|
336748
|
+
finalError: finalRes?.error ?? null,
|
|
336749
|
+
reproduceError,
|
|
336750
|
+
testVariables: run2.testVariables
|
|
336751
|
+
});
|
|
336752
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
336753
|
+
status: outcome.status,
|
|
336754
|
+
stepResults: finalRes?.stepResults,
|
|
336755
|
+
screenshots: finalRes?.screenshots,
|
|
336756
|
+
video: finalRes?.video,
|
|
336757
|
+
videoMimeType: finalRes?.videoMimeType,
|
|
336758
|
+
apiCalls: finalRes?.apiCalls,
|
|
336759
|
+
logs: finalRes?.logs,
|
|
336760
|
+
error: outcome.error,
|
|
336761
|
+
healReason: heal.healReason,
|
|
336762
|
+
// NB: the loop's numeric `confidence` (0-1) is intentionally NOT posted
|
|
336763
|
+
// here — RunnerResultPayload.confidence is the web healer's categorical
|
|
336764
|
+
// 'high'|'medium'|'low'. The numeric value rides /mobile-heal-result via
|
|
336765
|
+
// the healReason instead. Mapping the two would be lossy and misleading.
|
|
336766
|
+
// extraTags carries @needs-review for the non-fix verdicts (applied by
|
|
336767
|
+
// the server's mobileAiHeal /result tag hook); PASSED heals apply tags
|
|
336768
|
+
// via /mobile-heal-result instead.
|
|
336769
|
+
extraTags: outcome.extraTags,
|
|
336770
|
+
removeTags: outcome.removeTags,
|
|
336771
|
+
duration: Date.now() - startTime,
|
|
336772
|
+
failingSource: finalRes?.failingSource
|
|
336773
|
+
});
|
|
336774
|
+
if (outcome.shouldPatch && heal.healedSteps && heal.healedSteps.length > 0) {
|
|
336775
|
+
const healedAppiumCode = heal.healedAppiumCode ?? buildAppiumCode(run2.testName ?? run2.testCaseId ?? "Healed mobile test", run2.target ?? null, heal.healedSteps);
|
|
336776
|
+
try {
|
|
336777
|
+
await postMobileHealResult(opts.serverUrl, headers, run2.runId, {
|
|
336778
|
+
healedSteps: heal.healedSteps,
|
|
336779
|
+
healedAppiumCode,
|
|
336780
|
+
healReason: heal.healReason ?? "",
|
|
336781
|
+
verdict: heal.verdict,
|
|
336782
|
+
extraTags: heal.extraTags,
|
|
336783
|
+
removeTags: heal.removeTags,
|
|
336784
|
+
priorStepsHash: run2.priorStepsHash
|
|
336785
|
+
});
|
|
336786
|
+
log.ok(`\u{1FA7A} Mobile heal patch posted for test ${run2.testCaseId ?? "(unknown)"} (verdict: ${heal.verdict})`);
|
|
336787
|
+
} catch (patchErr) {
|
|
336788
|
+
log.warn(`Failed to post mobile heal patch: ${patchErr instanceof Error ? patchErr.message : String(patchErr)}`);
|
|
336789
|
+
}
|
|
336790
|
+
} else if (outcome.secretLeak) {
|
|
336791
|
+
log.warn(`\u{1FA7A} Mobile heal patch DROPPED \u2014 healed steps contained a secret (${outcome.secretLeak}); tagged @needs-review`);
|
|
336792
|
+
}
|
|
336793
|
+
const duration3 = Date.now() - startTime;
|
|
336794
|
+
log[outcome.status === "PASSED" ? "ok" : "fail"](
|
|
336795
|
+
`\u{1FA7A} Mobile AI heal ${outcome.status} (verdict: ${heal.verdict}, ${duration3}ms)${outcome.error ? ` - ${outcome.error}` : ""}`
|
|
336796
|
+
);
|
|
336797
|
+
} finally {
|
|
336798
|
+
setExecutorBusy(false);
|
|
336799
|
+
}
|
|
336800
|
+
return;
|
|
336801
|
+
}
|
|
335502
336802
|
const maxRetries = typeof run2.retryCount === "number" && Number.isFinite(run2.retryCount) ? Math.max(0, Math.min(2, Math.floor(run2.retryCount))) : 0;
|
|
335503
336803
|
setExecutorBusy(true);
|
|
335504
336804
|
let result2 = null;
|
|
@@ -335568,7 +336868,10 @@ ${result2.logs}`);
|
|
|
335568
336868
|
apiCalls: result2.apiCalls,
|
|
335569
336869
|
logs: result2.logs,
|
|
335570
336870
|
error: reportedError,
|
|
335571
|
-
duration: duration2
|
|
336871
|
+
duration: duration2,
|
|
336872
|
+
// Point-of-failure page-source XML (mobile) for the AI healer. Budget-
|
|
336873
|
+
// droppable (see result-payload-budget.ts); the verdict is never dropped.
|
|
336874
|
+
failingSource: result2.failingSource
|
|
335572
336875
|
});
|
|
335573
336876
|
log[result2.passed ? "ok" : "fail"](
|
|
335574
336877
|
`\u{1F4F1} ${status} (${duration2}ms)${reportedError ? ` - ${reportedError}` : ""}`
|