@validate.qa/runner 1.0.17 → 1.0.19
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 +310 -34
- package/dist/cli.mjs +310 -34
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -1028,6 +1028,10 @@ var require_mobile_source_parser = __commonJS({
|
|
|
1028
1028
|
}
|
|
1029
1029
|
return fnv1a(parts.join("\n"));
|
|
1030
1030
|
}
|
|
1031
|
+
var EDITABLE_CLASS_RE = /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/;
|
|
1032
|
+
function isEditableElement(el) {
|
|
1033
|
+
return EDITABLE_CLASS_RE.test(el.role.trim());
|
|
1034
|
+
}
|
|
1031
1035
|
function buildLocatorBundle(el, platform3) {
|
|
1032
1036
|
const bundle = {
|
|
1033
1037
|
xpath: el.xpath,
|
|
@@ -1037,7 +1041,7 @@ var require_mobile_source_parser = __commonJS({
|
|
|
1037
1041
|
bundle.accessibilityId = el.accessibilityId;
|
|
1038
1042
|
if (platform3 === "ANDROID" && el.resourceId)
|
|
1039
1043
|
bundle.resourceId = el.resourceId;
|
|
1040
|
-
if (el.text)
|
|
1044
|
+
if (el.text && !isEditableElement(el))
|
|
1041
1045
|
bundle.text = el.text;
|
|
1042
1046
|
if (platform3 === "ANDROID" && el.accessibilityId)
|
|
1043
1047
|
bundle.contentDesc = el.accessibilityId;
|
|
@@ -1048,7 +1052,7 @@ var require_mobile_source_parser = __commonJS({
|
|
|
1048
1052
|
return el.accessibilityId;
|
|
1049
1053
|
if (el.resourceId)
|
|
1050
1054
|
return el.resourceId;
|
|
1051
|
-
if (el.text)
|
|
1055
|
+
if (el.text && !isEditableElement(el))
|
|
1052
1056
|
return el.text;
|
|
1053
1057
|
return el.xpath;
|
|
1054
1058
|
}
|
|
@@ -1368,6 +1372,31 @@ function toInlineCommentText(value) {
|
|
|
1368
1372
|
function isPlainObject(value) {
|
|
1369
1373
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1370
1374
|
}
|
|
1375
|
+
function isEditableMobileClassName(className) {
|
|
1376
|
+
if (!className)
|
|
1377
|
+
return false;
|
|
1378
|
+
return /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/.test(className.trim());
|
|
1379
|
+
}
|
|
1380
|
+
function mobileCredentialPlaceholder(key) {
|
|
1381
|
+
return `{{${key}}}`;
|
|
1382
|
+
}
|
|
1383
|
+
function resolveMobileStepValuePlaceholders(steps, variables) {
|
|
1384
|
+
const missing = /* @__PURE__ */ new Set();
|
|
1385
|
+
const vars = variables ?? {};
|
|
1386
|
+
const resolved = steps.map((step) => {
|
|
1387
|
+
if (typeof step.value !== "string" || !step.value.includes("{{"))
|
|
1388
|
+
return step;
|
|
1389
|
+
const value = step.value.replace(MOBILE_VALUE_PLACEHOLDER_RE, (token, key) => {
|
|
1390
|
+
const substitute = vars[key];
|
|
1391
|
+
if (typeof substitute === "string" && substitute.length > 0)
|
|
1392
|
+
return substitute;
|
|
1393
|
+
missing.add(key);
|
|
1394
|
+
return token;
|
|
1395
|
+
});
|
|
1396
|
+
return value === step.value ? step : { ...step, value };
|
|
1397
|
+
});
|
|
1398
|
+
return { steps: resolved, missingKeys: [...missing] };
|
|
1399
|
+
}
|
|
1371
1400
|
function readMobileMetadata(value) {
|
|
1372
1401
|
if (!isPlainObject(value)) {
|
|
1373
1402
|
return { screenName: "UnknownScreen" };
|
|
@@ -1409,7 +1438,11 @@ function buildStepTarget(metadata, selector) {
|
|
|
1409
1438
|
const primary = nonEmpty(locatorBundle.accessibilityId) ?? nonEmpty(locatorBundle.resourceId) ?? trimmedSelector ?? nonEmpty(locatorBundle.xpath) ?? locatorBundle.xpath;
|
|
1410
1439
|
const fallbackCandidates = [
|
|
1411
1440
|
locatorBundle.resourceId,
|
|
1412
|
-
|
|
1441
|
+
// For editable fields the text attribute IS the currently-typed value — a
|
|
1442
|
+
// self-invalidating locator that only matches while the recorded text is
|
|
1443
|
+
// still in the field (and it can leak typed input into locators). Static
|
|
1444
|
+
// elements keep their text fallback.
|
|
1445
|
+
isEditableMobileClassName(locatorBundle.className) ? void 0 : locatorBundle.text,
|
|
1413
1446
|
locatorBundle.contentDesc,
|
|
1414
1447
|
locatorBundle.xpath,
|
|
1415
1448
|
selector
|
|
@@ -1840,7 +1873,7 @@ function isRunnableStep(step) {
|
|
|
1840
1873
|
return false;
|
|
1841
1874
|
}
|
|
1842
1875
|
}
|
|
1843
|
-
var STEP_ACTION_BY_ACTION_TYPE;
|
|
1876
|
+
var STEP_ACTION_BY_ACTION_TYPE, MOBILE_VALUE_PLACEHOLDER_RE;
|
|
1844
1877
|
var init_mobile_test_generation = __esm({
|
|
1845
1878
|
"../shared/dist/mobile-test-generation.js"() {
|
|
1846
1879
|
"use strict";
|
|
@@ -1858,6 +1891,7 @@ var init_mobile_test_generation = __esm({
|
|
|
1858
1891
|
ASSERT_VISIBLE: "assertVisible",
|
|
1859
1892
|
ASSERT_TEXT: "assertText"
|
|
1860
1893
|
};
|
|
1894
|
+
MOBILE_VALUE_PLACEHOLDER_RE = /\{\{([A-Za-z0-9_]+)\}\}/g;
|
|
1861
1895
|
}
|
|
1862
1896
|
});
|
|
1863
1897
|
|
|
@@ -3699,6 +3733,7 @@ __export(dist_exports, {
|
|
|
3699
3733
|
isCountedFailure: () => isCountedFailure,
|
|
3700
3734
|
isCreditPackAmountCents: () => isCreditPackAmountCents,
|
|
3701
3735
|
isDbPricingPlan: () => isDbPricingPlan,
|
|
3736
|
+
isEditableMobileClassName: () => isEditableMobileClassName,
|
|
3702
3737
|
isEnvironmentalError: () => isEnvironmentalError,
|
|
3703
3738
|
isMobilePlatform: () => isMobilePlatform,
|
|
3704
3739
|
isPricingPlan: () => isPricingPlan,
|
|
@@ -3710,6 +3745,7 @@ __export(dist_exports, {
|
|
|
3710
3745
|
isUIVariant: () => isUIVariant,
|
|
3711
3746
|
isValidMobileAppId: () => isValidMobileAppId,
|
|
3712
3747
|
mediumForMobilePlatform: () => mediumForMobilePlatform,
|
|
3748
|
+
mobileCredentialPlaceholder: () => mobileCredentialPlaceholder,
|
|
3713
3749
|
mobilePlatformFromMedium: () => mobilePlatformFromMedium,
|
|
3714
3750
|
normalizeControlName: () => normalizeControlName,
|
|
3715
3751
|
otherMobilePlatform: () => otherMobilePlatform,
|
|
@@ -3719,6 +3755,7 @@ __export(dist_exports, {
|
|
|
3719
3755
|
platformTag: () => platformTag,
|
|
3720
3756
|
registrableDomain: () => registrableDomain,
|
|
3721
3757
|
resolveCrossOriginConfigOverride: () => resolveCrossOriginConfigOverride,
|
|
3758
|
+
resolveMobileStepValuePlaceholders: () => resolveMobileStepValuePlaceholders,
|
|
3722
3759
|
resolvePlatformLocatorBundle: () => resolvePlatformLocatorBundle,
|
|
3723
3760
|
safeOrigin: () => safeOrigin,
|
|
3724
3761
|
sanitizeTestVariables: () => sanitizeTestVariables,
|
|
@@ -62008,7 +62045,7 @@ var require_mobile_explorer = __commonJS({
|
|
|
62008
62045
|
}
|
|
62009
62046
|
sections.push("");
|
|
62010
62047
|
if (mode === "survey") {
|
|
62011
|
-
sections.push(
|
|
62048
|
+
sections.push(`**Mission:** Map every reachable screen. For each new screen: mobile_snapshot -> capture_screen -> record_transition. Get PAST the entry gate into the app's real main content \u2014 the login/landing screens are the ENTRANCE, not the app. If test credentials are provided (see the Test Credentials section), SIGN IN with them FIRST and map the authenticated app; that is the real product surface. Use a guest/demo/skip entry (e.g. "See Demo Profile", "Continue as guest") ONLY as a fallback when no credentials are available or login cannot be completed \u2014 demo mode is a limited sandbox, not the authenticated app. Do NOT submit forms with junk data or mutate real data. Call finish_exploration ONLY when every reachable screen \u2014 including the authenticated content \u2014 has been captured and no unvisited frontier screens remain.`);
|
|
62012
62049
|
} else {
|
|
62013
62050
|
sections.push("**Mission:** Use the focus features like a real user. Tap, type, submit, and observe outcomes. After a meaningful flow, call record_journey. Call finish_exploration when the flows are covered.");
|
|
62014
62051
|
}
|
|
@@ -62066,7 +62103,14 @@ var require_mobile_explorer = __commonJS({
|
|
|
62066
62103
|
This app has stored login credentials under these keys:
|
|
62067
62104
|
${keyList}
|
|
62068
62105
|
|
|
62069
|
-
|
|
62106
|
+
LOG IN FIRST \u2014 before broad exploration. The authenticated app is the real product surface; a survey that only maps the logged-out/demo screens has missed most of the app. This OVERRIDES the "do not submit forms" rule for the login form ONLY. The login sequence is:
|
|
62107
|
+
1. Reach the email/password sign-in form (tap "Sign in with Email" / "Login" from the landing screen if needed).
|
|
62108
|
+
2. Fill the email field: \`mobile_fill_credential({ ref, credentialKey: "TEST_USER_EMAIL" })\`. Fill the password field: \`mobile_fill_credential({ ref, credentialKey: "TEST_USER_PASSWORD" })\`. The runner substitutes the real value on the device; you never see, log, or echo it.
|
|
62109
|
+
3. TAP THE SUBMIT BUTTON (e.g. "Sign In", "Log In", "Continue"). Filling the fields does NOT log you in \u2014 you MUST tap submit.
|
|
62110
|
+
4. VERIFY you are actually signed in: re-snapshot and confirm an authenticated signal appeared (a home/feed/dashboard with real content, an account/profile tab, a "Sign out" option, or your user identity). If you are still on the login form, the login failed \u2014 check for an error, re-enter, and submit again (up to 2 attempts).
|
|
62111
|
+
5. Only AFTER you have confirmed you are signed in, map the authenticated app.
|
|
62112
|
+
|
|
62113
|
+
PREFER real login over any "demo"/"guest"/"skip" entry: those give a limited sandbox, NOT the authenticated app. Use them only if login is impossible.
|
|
62070
62114
|
|
|
62071
62115
|
SECURITY RULES:
|
|
62072
62116
|
- NEVER call \`mobile_type\` with an email, password, or token value \u2014 the runner will REJECT the call and you lose an iteration. Use \`mobile_fill_credential\` for every credential field.
|
|
@@ -62373,6 +62417,8 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
|
|
|
62373
62417
|
let summary = "";
|
|
62374
62418
|
let truncated = false;
|
|
62375
62419
|
let truncatedReason;
|
|
62420
|
+
let finishRedirects = 0;
|
|
62421
|
+
const MAX_FINISH_REDIRECTS = 2;
|
|
62376
62422
|
try {
|
|
62377
62423
|
try {
|
|
62378
62424
|
const seed = await driver.snapshot();
|
|
@@ -62502,6 +62548,20 @@ ${statePacket}` }
|
|
|
62502
62548
|
continue;
|
|
62503
62549
|
}
|
|
62504
62550
|
if (toolName === "finish_exploration") {
|
|
62551
|
+
const frontier = mode === "survey" ? state2.getFrontier() : [];
|
|
62552
|
+
const budgetRemainingPct = (maxIterations - iteration) / maxIterations;
|
|
62553
|
+
if (frontier.length > 0 && budgetRemainingPct >= 0.2 && finishRedirects < MAX_FINISH_REDIRECTS) {
|
|
62554
|
+
finishRedirects++;
|
|
62555
|
+
const targets = frontier.slice(0, 6).join(", ");
|
|
62556
|
+
log2(`
|
|
62557
|
+
finish_exploration bounced (${finishRedirects}/${MAX_FINISH_REDIRECTS}): ${frontier.length} frontier screen(s) still unvisited, ${Math.round(budgetRemainingPct * 100)}% budget left.`);
|
|
62558
|
+
pushResult({
|
|
62559
|
+
acknowledged: false,
|
|
62560
|
+
keepExploring: true,
|
|
62561
|
+
reason: `Not done yet \u2014 ${frontier.length} screen(s) were seen via transitions but never visited, and ${Math.round(budgetRemainingPct * 100)}% of the iteration budget remains. A survey must map EVERY reachable screen. Navigate into the unvisited screens (e.g. ${targets}), snapshot + capture_screen each, then call finish_exploration only once no unvisited screens remain. If a screen is genuinely unreachable (needs credentials you don't have, or leaves the app), note that and move to the next one.`
|
|
62562
|
+
});
|
|
62563
|
+
continue;
|
|
62564
|
+
}
|
|
62505
62565
|
explorationComplete = true;
|
|
62506
62566
|
summary = String(toolArgs.summary ?? "");
|
|
62507
62567
|
log2(`
|
|
@@ -62720,6 +62780,14 @@ Exploration complete: ${summary}`);
|
|
|
62720
62780
|
...actionResult.error ? { error: actionResult.error } : {},
|
|
62721
62781
|
// Report only the KEY that was filled — never the value.
|
|
62722
62782
|
filledCredentialKey: key,
|
|
62783
|
+
// Code-level reinforcement of the login sequence: filling a field does
|
|
62784
|
+
// NOT submit the form. The observed failure was fill-without-submit —
|
|
62785
|
+
// the model filled email+password then wandered off (into demo content)
|
|
62786
|
+
// without ever tapping Sign In, so it never reached the authenticated
|
|
62787
|
+
// app. This nudge fires on every credential fill so the model is
|
|
62788
|
+
// reminded to complete + verify the login rather than treating the fill
|
|
62789
|
+
// as "logged in".
|
|
62790
|
+
nextStep: "Credential filled \u2014 this does NOT log you in. Once all credential fields are filled, tap the submit button (Sign In / Log In / Continue), then re-snapshot and confirm an authenticated screen (home/feed/account/Sign out) appeared before exploring further.",
|
|
62723
62791
|
screenId,
|
|
62724
62792
|
observation,
|
|
62725
62793
|
...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
|
|
@@ -65578,6 +65646,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65578
65646
|
6. Do NOT merge screens into one feature just because they serve a similar broader goal. Treat areas as separate features when the evidence shows separate primary navigation destinations (e.g. separate tab-bar items or drawer links), separate screen families for distinct entities, or different CRUD entities/forms. Keep screens together when they share one navigational area and operate on the same entity (e.g. a list screen, its filter/sort sheet, and its detail screen all belong together).
|
|
65579
65647
|
7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
|
|
65580
65648
|
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.
|
|
65649
|
+
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.
|
|
65581
65650
|
|
|
65582
65651
|
## Output JSON Schema
|
|
65583
65652
|
{
|
|
@@ -65785,7 +65854,8 @@ ${context.appBrief.slice(0, 500)}`);
|
|
|
65785
65854
|
sections.push(`## App Under Test
|
|
65786
65855
|
${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
|
|
65787
65856
|
if (context.featureName) {
|
|
65788
|
-
sections.push(`## Feature: ${context.featureName}
|
|
65857
|
+
sections.push(`## Feature: ${context.featureName}
|
|
65858
|
+
SCOPE: this is a feature-focused deep run. Plan scenarios ONLY for the "${context.featureName}" feature \u2014 its screens, forms, and flows. Screens belonging to other features are navigation context / prerequisites only; do NOT plan scenarios whose primary goal exercises another feature.`);
|
|
65789
65859
|
}
|
|
65790
65860
|
const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
|
|
65791
65861
|
const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
|
|
@@ -66170,10 +66240,12 @@ var require_mobile_generator = __commonJS({
|
|
|
66170
66240
|
"../runner-core/dist/services/mobile/mobile-generator.js"(exports) {
|
|
66171
66241
|
"use strict";
|
|
66172
66242
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
66243
|
+
exports.collapseRetryCycles = collapseRetryCycles;
|
|
66173
66244
|
exports.runMobileGeneratePhase = runMobileGeneratePhase;
|
|
66174
66245
|
var crypto_1 = __require("crypto");
|
|
66175
66246
|
var shared_1 = (init_dist(), __toCommonJS(dist_exports));
|
|
66176
66247
|
var ai_provider_js_1 = require_ai_provider();
|
|
66248
|
+
var credential_tools_js_1 = require_credential_tools();
|
|
66177
66249
|
var ai_queue_js_1 = require_ai_queue();
|
|
66178
66250
|
var ai_retry_js_1 = require_ai_retry();
|
|
66179
66251
|
var mobile_source_parser_js_1 = require_mobile_source_parser();
|
|
@@ -66295,6 +66367,24 @@ var require_mobile_generator = __commonJS({
|
|
|
66295
66367
|
}
|
|
66296
66368
|
}
|
|
66297
66369
|
};
|
|
66370
|
+
var MOBILE_FILL_CREDENTIAL_TOOL = {
|
|
66371
|
+
type: "function",
|
|
66372
|
+
function: {
|
|
66373
|
+
name: "mobile_fill_credential",
|
|
66374
|
+
description: 'Fill a stored credential VALUE into a text field. ALWAYS use this instead of mobile_type for ANY email/password/token/auth field. Pass the field ref (eN) and the variable KEY (e.g. "TEST_USER_EMAIL"); the runner types the real value on the device and the generated test stores a {{KEY}} placeholder \u2014 you never see the secret. Available keys are listed in the Test Credentials section of the system prompt.',
|
|
66375
|
+
parameters: {
|
|
66376
|
+
type: "object",
|
|
66377
|
+
properties: {
|
|
66378
|
+
ref: { type: "string", description: 'Text-field element ref from the latest mobile_snapshot, e.g. "e5".' },
|
|
66379
|
+
credentialKey: {
|
|
66380
|
+
type: "string",
|
|
66381
|
+
description: 'The credential variable name (e.g. "TEST_USER_EMAIL", "TEST_USER_PASSWORD"). Must be one of the keys listed in the Test Credentials section of the prompt.'
|
|
66382
|
+
}
|
|
66383
|
+
},
|
|
66384
|
+
required: ["ref", "credentialKey"]
|
|
66385
|
+
}
|
|
66386
|
+
}
|
|
66387
|
+
};
|
|
66298
66388
|
var FINISH_GENERATE_TOOL = {
|
|
66299
66389
|
type: "function",
|
|
66300
66390
|
function: {
|
|
@@ -66316,6 +66406,7 @@ var require_mobile_generator = __commonJS({
|
|
|
66316
66406
|
MOBILE_TAP_TOOL,
|
|
66317
66407
|
MOBILE_TYPE_TOOL,
|
|
66318
66408
|
MOBILE_CLEAR_TOOL,
|
|
66409
|
+
MOBILE_FILL_CREDENTIAL_TOOL,
|
|
66319
66410
|
MOBILE_SWIPE_TOOL,
|
|
66320
66411
|
MOBILE_BACK_TOOL,
|
|
66321
66412
|
MOBILE_RELAUNCH_TOOL,
|
|
@@ -66394,6 +66485,24 @@ var require_mobile_generator = __commonJS({
|
|
|
66394
66485
|
- Finish promptly once the outcome is proven: the instant the expected outcome is visible, assert it and call finish_generate. Do not keep exploring after the goal is proven.
|
|
66395
66486
|
|
|
66396
66487
|
Max iterations: ${MAX_SCENARIO_ITERATIONS}. You will be warned near the end.`;
|
|
66488
|
+
}
|
|
66489
|
+
function buildGenerateCredentialPromptBlock(secrets) {
|
|
66490
|
+
const keys = Object.keys(secrets).filter((k) => secrets[k] && secrets[k].length > 0);
|
|
66491
|
+
if (keys.length === 0)
|
|
66492
|
+
return "";
|
|
66493
|
+
const keyList = keys.map((k) => `- \`${k}\``).join("\n");
|
|
66494
|
+
return `
|
|
66495
|
+
|
|
66496
|
+
## Test Credentials (values are HIDDEN from you)
|
|
66497
|
+
This app has stored login credentials under these keys:
|
|
66498
|
+
${keyList}
|
|
66499
|
+
|
|
66500
|
+
When a scenario needs a signed-in session or fills a login/sign-up form, fill each credential field with \`mobile_fill_credential\`: pass the field ref and the KEY (e.g. \`{ ref: "e5", credentialKey: "TEST_USER_EMAIL" }\`). The runner types the real value on the device and the generated test stores a \`{{KEY}}\` placeholder that replay resolves \u2014 you never see, log, or echo the secret.
|
|
66501
|
+
|
|
66502
|
+
SECURITY RULES:
|
|
66503
|
+
- NEVER call \`mobile_type\` with a real email, password, or token value \u2014 the runner will REJECT the call and you lose an iteration. Use \`mobile_fill_credential\` for every credential field.
|
|
66504
|
+
- For validation scenarios (wrong password, empty submit) you MAY \`mobile_type\` synthetic invalid values like "wrong@example.com" / "badpassword" \u2014 those are not real credentials and are allowed.
|
|
66505
|
+
- NEVER put credential values in test names, descriptions, or assert texts.`;
|
|
66397
66506
|
}
|
|
66398
66507
|
function buildScenarioKickoff(scenario) {
|
|
66399
66508
|
const stepLines = scenario.steps.map((step, i) => ` ${i + 1}. ${step.action}${step.target ? ` (target: ${step.target})` : ""}${step.hint ? ` [hint: ${step.hint}]` : ""}`).join("\n");
|
|
@@ -66464,7 +66573,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
|
|
|
66464
66573
|
}
|
|
66465
66574
|
}
|
|
66466
66575
|
async function runScenarioLoop(args) {
|
|
66467
|
-
const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, log: log2 } = args;
|
|
66576
|
+
const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, log: log2 } = args;
|
|
66468
66577
|
const actions = [];
|
|
66469
66578
|
let assertCount = 0;
|
|
66470
66579
|
let finish = null;
|
|
@@ -66581,7 +66690,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
|
|
|
66581
66690
|
} catch (err) {
|
|
66582
66691
|
log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
66583
66692
|
}
|
|
66584
|
-
const systemPrompt = buildSystemPrompt(platform3, targetAppId);
|
|
66693
|
+
const systemPrompt = buildSystemPrompt(platform3, targetAppId) + buildGenerateCredentialPromptBlock(credentials);
|
|
66585
66694
|
const kickoff = buildScenarioKickoff(scenario);
|
|
66586
66695
|
const recentExchanges = [];
|
|
66587
66696
|
let iteration = 0;
|
|
@@ -66694,6 +66803,7 @@ ${statePacket}` },
|
|
|
66694
66803
|
latest: getLatest,
|
|
66695
66804
|
absorbObservation,
|
|
66696
66805
|
resolveRef,
|
|
66806
|
+
credentials,
|
|
66697
66807
|
screenName: () => lastScreenName || "UnknownScreen",
|
|
66698
66808
|
recordAction: (action) => {
|
|
66699
66809
|
actions.push(finalizeAction(action));
|
|
@@ -66715,7 +66825,7 @@ ${statePacket}` },
|
|
|
66715
66825
|
return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
|
|
66716
66826
|
}
|
|
66717
66827
|
async function dispatchGenerateTool(args) {
|
|
66718
|
-
const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, screenName, recordAction, log: log2 } = args;
|
|
66828
|
+
const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, credentials, screenName, recordAction, log: log2 } = args;
|
|
66719
66829
|
const unknownRef = (ref) => ({
|
|
66720
66830
|
ok: false,
|
|
66721
66831
|
error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
|
|
@@ -66781,6 +66891,14 @@ ${statePacket}` },
|
|
|
66781
66891
|
if (!element)
|
|
66782
66892
|
return unknownRef(toolArgs.ref);
|
|
66783
66893
|
const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
|
|
66894
|
+
for (const [key, secret] of Object.entries(credentials)) {
|
|
66895
|
+
if (secret && secret.length >= 4 && value.includes(secret)) {
|
|
66896
|
+
return {
|
|
66897
|
+
ok: false,
|
|
66898
|
+
error: `BLOCKED: mobile_type contained the stored credential value for "${key}". Use mobile_fill_credential({ ref, credentialKey: "${key}" }) instead of typing the credential directly.`
|
|
66899
|
+
};
|
|
66900
|
+
}
|
|
66901
|
+
}
|
|
66784
66902
|
const beforeScreenName = screenName();
|
|
66785
66903
|
const result = await driver.type(value, element.bounds);
|
|
66786
66904
|
const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_type");
|
|
@@ -66794,6 +66912,37 @@ ${statePacket}` },
|
|
|
66794
66912
|
}
|
|
66795
66913
|
return actionPayload(result, absorbed);
|
|
66796
66914
|
}
|
|
66915
|
+
// KEY-based credential fill: type the real value on-device, persist only a
|
|
66916
|
+
// {{KEY}} placeholder in the recorded step (replay resolves it against the
|
|
66917
|
+
// run's current test variables). The tool result echoes the KEY, never the
|
|
66918
|
+
// value, so the secret cannot enter the transcript.
|
|
66919
|
+
case "mobile_fill_credential": {
|
|
66920
|
+
const key = typeof toolArgs.credentialKey === "string" ? toolArgs.credentialKey.trim() : "";
|
|
66921
|
+
if (!key) {
|
|
66922
|
+
return { ok: false, error: 'mobile_fill_credential: missing required "credentialKey".' };
|
|
66923
|
+
}
|
|
66924
|
+
const secret = credentials[key];
|
|
66925
|
+
if (!secret) {
|
|
66926
|
+
const available = Object.keys(credentials).filter((k) => credentials[k]).join(", ") || "(none configured)";
|
|
66927
|
+
return { ok: false, error: `mobile_fill_credential: no credential configured for key "${key}". Available keys: ${available}` };
|
|
66928
|
+
}
|
|
66929
|
+
const element = resolveRef(toolArgs.ref);
|
|
66930
|
+
if (!element)
|
|
66931
|
+
return unknownRef(toolArgs.ref);
|
|
66932
|
+
const beforeScreenName = screenName();
|
|
66933
|
+
const result = await driver.type(secret, element.bounds);
|
|
66934
|
+
const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_fill_credential");
|
|
66935
|
+
if (shouldRecordAction(result, absorbed)) {
|
|
66936
|
+
recordAction({
|
|
66937
|
+
type: "TYPE",
|
|
66938
|
+
value: (0, shared_1.mobileCredentialPlaceholder)(key),
|
|
66939
|
+
selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
|
|
66940
|
+
metadata: metadataForElement(element, beforeScreenName, platform3)
|
|
66941
|
+
});
|
|
66942
|
+
log2(` fill_credential: recorded {{${key}}} into ${labelForElement(element)}`);
|
|
66943
|
+
}
|
|
66944
|
+
return { ...actionPayload(result, absorbed), filledCredentialKey: key };
|
|
66945
|
+
}
|
|
66797
66946
|
case "mobile_clear": {
|
|
66798
66947
|
const element = resolveRef(toolArgs.ref);
|
|
66799
66948
|
if (!element)
|
|
@@ -66942,18 +67091,40 @@ ${statePacket}` },
|
|
|
66942
67091
|
return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
|
|
66943
67092
|
}
|
|
66944
67093
|
var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
|
|
66945
|
-
var
|
|
66946
|
-
"
|
|
66947
|
-
"
|
|
66948
|
-
"
|
|
66949
|
-
"
|
|
66950
|
-
"
|
|
67094
|
+
var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
|
|
67095
|
+
"content",
|
|
67096
|
+
"decor",
|
|
67097
|
+
"decor_content_parent",
|
|
67098
|
+
"action_bar_root",
|
|
67099
|
+
"statusBarBackground",
|
|
67100
|
+
"navigationBarBackground",
|
|
67101
|
+
// WebView hybrid apps (React/Vue/etc. rendered inside android.webkit.WebView)
|
|
67102
|
+
// expose their framework ROOT container's DOM id as the element id — present on
|
|
67103
|
+
// EVERY in-app screen, so the floor must not anchor to it (observed live on
|
|
67104
|
+
// ThoughtStream: `main-content` produced a screen-agnostic assertion that then
|
|
67105
|
+
// never matched via the text-contains fallback).
|
|
67106
|
+
"main-content",
|
|
67107
|
+
"root",
|
|
67108
|
+
"app",
|
|
67109
|
+
"app-root",
|
|
67110
|
+
"__next",
|
|
67111
|
+
"react-root",
|
|
67112
|
+
"application"
|
|
66951
67113
|
]);
|
|
66952
67114
|
function isAlwaysPresentContainer(element) {
|
|
66953
67115
|
if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
|
|
66954
67116
|
return true;
|
|
66955
67117
|
const resourceId = element.resourceId?.trim();
|
|
66956
|
-
|
|
67118
|
+
if (resourceId) {
|
|
67119
|
+
const slashIdx = resourceId.indexOf("/");
|
|
67120
|
+
const localName = slashIdx >= 0 ? resourceId.slice(slashIdx + 1) : resourceId;
|
|
67121
|
+
if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName))
|
|
67122
|
+
return true;
|
|
67123
|
+
}
|
|
67124
|
+
const accessibilityId = element.accessibilityId?.trim();
|
|
67125
|
+
if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(accessibilityId))
|
|
67126
|
+
return true;
|
|
67127
|
+
return false;
|
|
66957
67128
|
}
|
|
66958
67129
|
function appendHybridFloor(actions, recording, platform3) {
|
|
66959
67130
|
const anchor = pickLandingAnchor(recording.lastSnapshot);
|
|
@@ -66967,9 +67138,38 @@ ${statePacket}` },
|
|
|
66967
67138
|
}));
|
|
66968
67139
|
return true;
|
|
66969
67140
|
}
|
|
67141
|
+
function actionCycleKey(action) {
|
|
67142
|
+
return `${action.type}|${action.selector}|${action.value ?? ""}`;
|
|
67143
|
+
}
|
|
67144
|
+
var MAX_RETRY_CYCLE_LENGTH = 8;
|
|
67145
|
+
function collapseRetryCycles(actions) {
|
|
67146
|
+
const out = [...actions];
|
|
67147
|
+
let changed = true;
|
|
67148
|
+
while (changed) {
|
|
67149
|
+
changed = false;
|
|
67150
|
+
const maxWindow = Math.min(MAX_RETRY_CYCLE_LENGTH, Math.floor(out.length / 2));
|
|
67151
|
+
for (let window2 = maxWindow; window2 >= 2 && !changed; window2--) {
|
|
67152
|
+
for (let i = 0; i + 2 * window2 <= out.length; i++) {
|
|
67153
|
+
const first = out.slice(i, i + window2);
|
|
67154
|
+
if (first.some((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT"))
|
|
67155
|
+
continue;
|
|
67156
|
+
const second = out.slice(i + window2, i + 2 * window2);
|
|
67157
|
+
if (!first.every((a, k) => actionCycleKey(a) === actionCycleKey(second[k])))
|
|
67158
|
+
continue;
|
|
67159
|
+
out.splice(i, window2);
|
|
67160
|
+
changed = true;
|
|
67161
|
+
break;
|
|
67162
|
+
}
|
|
67163
|
+
}
|
|
67164
|
+
}
|
|
67165
|
+
return out;
|
|
67166
|
+
}
|
|
66970
67167
|
function assembleTest(args) {
|
|
66971
67168
|
const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
|
|
66972
|
-
const actions =
|
|
67169
|
+
const actions = collapseRetryCycles(recording.actions);
|
|
67170
|
+
if (actions.length < recording.actions.length) {
|
|
67171
|
+
log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
|
|
67172
|
+
}
|
|
66973
67173
|
const floorAppended = appendHybridFloor(actions, recording, platform3);
|
|
66974
67174
|
if (floorAppended) {
|
|
66975
67175
|
log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
|
|
@@ -67008,6 +67208,12 @@ ${statePacket}` },
|
|
|
67008
67208
|
playwrightCode: "",
|
|
67009
67209
|
verified,
|
|
67010
67210
|
tags,
|
|
67211
|
+
// Per-test feature label → TestCase.suite. One deep run plans scenarios for
|
|
67212
|
+
// MULTIPLE feature groups (the planner reuses survey feature names), so the
|
|
67213
|
+
// run-level ctx.featureName fallback in /discovery-test would wrongly file
|
|
67214
|
+
// every test under the deep-dived feature. The scenario's own group is the
|
|
67215
|
+
// correct home; the runner forwards it as `suiteName`.
|
|
67216
|
+
...scenario.featureGroup?.trim() ? { suiteName: scenario.featureGroup.trim() } : {},
|
|
67011
67217
|
// Carry the planner scenario identity so incremental/resume can match an
|
|
67012
67218
|
// already-generated scenario reliably (scenarioRef.name === the plan's
|
|
67013
67219
|
// scenario name). Without this the resume skip-set falls back to the
|
|
@@ -67025,7 +67231,27 @@ ${statePacket}` },
|
|
|
67025
67231
|
startFromLanding: scenario.startFromLanding,
|
|
67026
67232
|
targetPages: [...scenario.targetPages]
|
|
67027
67233
|
},
|
|
67028
|
-
evidence: { observedLocators: [], observedTransitions: [] }
|
|
67234
|
+
evidence: { observedLocators: [], observedTransitions: [] },
|
|
67235
|
+
// Assertion-strength marker for the server promote-seam
|
|
67236
|
+
// (resolveStabilityTransition): an UNVERIFIED recording proves none of the
|
|
67237
|
+
// scenario's outcomes — only the hybrid floor's landing-screen visibility —
|
|
67238
|
+
// so a passing replay is vacuous. Hold it as DRAFT (@needs-review) for a
|
|
67239
|
+
// human instead of letting the pass auto-promote it into the ACTIVE suite.
|
|
67240
|
+
// Mirrors the recorder mobile path (server test-pipeline.ts @unverified).
|
|
67241
|
+
...verified ? {} : {
|
|
67242
|
+
gateActivity: {
|
|
67243
|
+
outcomeGate: {
|
|
67244
|
+
requiredCount: scenario.expectedOutcomes.length,
|
|
67245
|
+
uncoveredCount: scenario.expectedOutcomes.length
|
|
67246
|
+
},
|
|
67247
|
+
strengthGate: {
|
|
67248
|
+
fired: false,
|
|
67249
|
+
severity: "block",
|
|
67250
|
+
blocked: true,
|
|
67251
|
+
reason: "Mobile scenario finished unverified \u2014 the agent never proved the scenario outcome live, so the recording may not demonstrate the goal (e.g. a happy path that never completed). Review before promotion."
|
|
67252
|
+
}
|
|
67253
|
+
}
|
|
67254
|
+
}
|
|
67029
67255
|
}
|
|
67030
67256
|
};
|
|
67031
67257
|
return test;
|
|
@@ -67046,6 +67272,7 @@ ${statePacket}` },
|
|
|
67046
67272
|
const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
|
|
67047
67273
|
const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
|
|
67048
67274
|
const targetAppId = ctx.target?.appId?.trim() || void 0;
|
|
67275
|
+
const { secrets: credentialSecrets } = (0, credential_tools_js_1.partitionTestVariables)(ctx.variableContext?.allTestVariables ?? ctx.variableContext?.publicTestVariables);
|
|
67049
67276
|
const skipSet = new Set(skipScenarioNames ?? []);
|
|
67050
67277
|
const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
|
|
67051
67278
|
if (skipSet.size > 0) {
|
|
@@ -67074,6 +67301,7 @@ ${statePacket}` },
|
|
|
67074
67301
|
onAICall,
|
|
67075
67302
|
onScreenshot,
|
|
67076
67303
|
targetAppId,
|
|
67304
|
+
credentials: credentialSecrets,
|
|
67077
67305
|
log: log2
|
|
67078
67306
|
});
|
|
67079
67307
|
consecutiveInfraFailures = 0;
|
|
@@ -313696,9 +313924,12 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
|
|
|
313696
313924
|
});
|
|
313697
313925
|
let featureInsight;
|
|
313698
313926
|
if (mode === "deep" && ctx.featureName) {
|
|
313927
|
+
const targetName = ctx.featureName.trim().toLowerCase();
|
|
313928
|
+
const plannedGroup = (featureGroups ?? []).find((group) => group.name.trim().toLowerCase() === targetName);
|
|
313929
|
+
const plannedDescription = plannedGroup?.description?.trim();
|
|
313699
313930
|
featureInsight = {
|
|
313700
313931
|
featureName: ctx.featureName,
|
|
313701
|
-
description: `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
|
|
313932
|
+
description: plannedDescription && plannedDescription.length > 0 ? plannedDescription : `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
|
|
313702
313933
|
};
|
|
313703
313934
|
}
|
|
313704
313935
|
const duration = (Date.now() - startTime) / 1e3;
|
|
@@ -328234,7 +328465,7 @@ var require_package4 = __commonJS({
|
|
|
328234
328465
|
"package.json"(exports, module) {
|
|
328235
328466
|
module.exports = {
|
|
328236
328467
|
name: "@validate.qa/runner",
|
|
328237
|
-
version: "1.0.
|
|
328468
|
+
version: "1.0.19",
|
|
328238
328469
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
328239
328470
|
bin: {
|
|
328240
328471
|
"validate-runner": "dist/cli.js"
|
|
@@ -330515,6 +330746,10 @@ async function executeMobileTest(options) {
|
|
|
330515
330746
|
const logLines = [];
|
|
330516
330747
|
const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
|
|
330517
330748
|
const startTime = Date.now();
|
|
330749
|
+
const { steps: runSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
|
|
330750
|
+
if (missingVariableKeys.length > 0) {
|
|
330751
|
+
log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured for this project/run.`);
|
|
330752
|
+
}
|
|
330518
330753
|
const stepResults = [];
|
|
330519
330754
|
const screenshots = [];
|
|
330520
330755
|
let apiCalls = [];
|
|
@@ -330652,7 +330887,7 @@ async function executeMobileTest(options) {
|
|
|
330652
330887
|
let entryDrift = false;
|
|
330653
330888
|
const runPlatform = options.target.platform;
|
|
330654
330889
|
const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
|
|
330655
|
-
const entryStep =
|
|
330890
|
+
const entryStep = runSteps.find(
|
|
330656
330891
|
(s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
|
|
330657
330892
|
);
|
|
330658
330893
|
if (entryStep) {
|
|
@@ -330669,7 +330904,7 @@ async function executeMobileTest(options) {
|
|
|
330669
330904
|
}
|
|
330670
330905
|
}
|
|
330671
330906
|
let environmental = null;
|
|
330672
|
-
for (const step of
|
|
330907
|
+
for (const step of runSteps) {
|
|
330673
330908
|
if (entryDrift) break;
|
|
330674
330909
|
if (!stepAppliesToPlatform(step, runPlatform)) {
|
|
330675
330910
|
log2(`Step ${step.order}: ${step.action} \u2014 skipped (not applicable to ${runPlatform})`);
|
|
@@ -330757,7 +330992,7 @@ async function executeMobileTest(options) {
|
|
|
330757
330992
|
}
|
|
330758
330993
|
const executedResults = stepResults.filter((r) => r.status !== "skipped");
|
|
330759
330994
|
const verifyOrders = new Set(
|
|
330760
|
-
|
|
330995
|
+
runSteps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
|
|
330761
330996
|
);
|
|
330762
330997
|
const anyVerificationPassed = stepResults.some((r) => r.status === "passed" && verifyOrders.has(r.stepOrder));
|
|
330763
330998
|
const crossPlatformUnverified = isCrossPlatform && verifyOrders.size > 0 && !anyVerificationPassed;
|
|
@@ -330997,11 +331232,15 @@ async function harvestPlatformLocators(options) {
|
|
|
330997
331232
|
const log2 = (msg) => {
|
|
330998
331233
|
logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
|
|
330999
331234
|
};
|
|
331235
|
+
const { steps: harvestSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
|
|
331236
|
+
if (missingVariableKeys.length > 0) {
|
|
331237
|
+
log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured.`);
|
|
331238
|
+
}
|
|
331000
331239
|
const bundles = {};
|
|
331001
331240
|
const divergentOrders = [];
|
|
331002
331241
|
let reachedOrder = null;
|
|
331003
331242
|
let environmentalError;
|
|
331004
|
-
log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${
|
|
331243
|
+
log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${harvestSteps.length} steps`);
|
|
331005
331244
|
const session = await createMobileDiscoverySession({
|
|
331006
331245
|
appId: options.target.appId,
|
|
331007
331246
|
platform: platform3,
|
|
@@ -331021,7 +331260,7 @@ async function harvestPlatformLocators(options) {
|
|
|
331021
331260
|
}
|
|
331022
331261
|
}
|
|
331023
331262
|
await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
|
|
331024
|
-
for (const step of
|
|
331263
|
+
for (const step of harvestSteps) {
|
|
331025
331264
|
if (step.action === "launchApp") continue;
|
|
331026
331265
|
if (!stepAppliesToPlatform(step, platform3)) continue;
|
|
331027
331266
|
let elementId = null;
|
|
@@ -331265,12 +331504,46 @@ async function tapAt(sessionPath, bounds) {
|
|
|
331265
331504
|
throw new Error("TAP requires usable bounds (finite x/y)");
|
|
331266
331505
|
}
|
|
331267
331506
|
}
|
|
331268
|
-
async function
|
|
331507
|
+
async function getElementRect(sessionPath, elementId) {
|
|
331508
|
+
try {
|
|
331509
|
+
const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
|
|
331510
|
+
if (!value || typeof value !== "object") return null;
|
|
331511
|
+
const r = value;
|
|
331512
|
+
if (typeof r.x !== "number" || typeof r.y !== "number" || typeof r.width !== "number" || typeof r.height !== "number" || !Number.isFinite(r.x) || !Number.isFinite(r.y)) {
|
|
331513
|
+
return null;
|
|
331514
|
+
}
|
|
331515
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
331516
|
+
} catch {
|
|
331517
|
+
return null;
|
|
331518
|
+
}
|
|
331519
|
+
}
|
|
331520
|
+
function rectMatchesIntendedBounds(rect, bounds) {
|
|
331521
|
+
if (bounds.width <= 0 || bounds.height <= 0) return false;
|
|
331522
|
+
const rectCenterX = rect.x + rect.width / 2;
|
|
331523
|
+
const rectCenterY = rect.y + rect.height / 2;
|
|
331524
|
+
const boundsCenterX = bounds.x + bounds.width / 2;
|
|
331525
|
+
const boundsCenterY = bounds.y + bounds.height / 2;
|
|
331526
|
+
const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
|
|
331527
|
+
const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
|
|
331528
|
+
return rectCenterInBounds || boundsCenterInRect;
|
|
331529
|
+
}
|
|
331530
|
+
async function resolveTypeTargetElementId(sessionPath, bounds) {
|
|
331531
|
+
const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
|
|
331269
331532
|
let elementId = await getActiveElementId2(sessionPath);
|
|
331533
|
+
if (elementId && box && box.width > 0 && box.height > 0) {
|
|
331534
|
+
const rect = await getElementRect(sessionPath, elementId);
|
|
331535
|
+
if (!rect || !rectMatchesIntendedBounds(rect, box)) {
|
|
331536
|
+
elementId = null;
|
|
331537
|
+
}
|
|
331538
|
+
}
|
|
331270
331539
|
if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
|
|
331271
331540
|
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
331272
331541
|
elementId = await getActiveElementId2(sessionPath);
|
|
331273
331542
|
}
|
|
331543
|
+
return elementId;
|
|
331544
|
+
}
|
|
331545
|
+
async function typeText(sessionPath, value, bounds) {
|
|
331546
|
+
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
331274
331547
|
if (!elementId) {
|
|
331275
331548
|
throw new Error("No focused element available for TYPE. Tap an input field first.");
|
|
331276
331549
|
}
|
|
@@ -331280,11 +331553,7 @@ async function typeText(sessionPath, value, bounds) {
|
|
|
331280
331553
|
});
|
|
331281
331554
|
}
|
|
331282
331555
|
async function clearField(sessionPath, bounds) {
|
|
331283
|
-
|
|
331284
|
-
if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
|
|
331285
|
-
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
331286
|
-
elementId = await getActiveElementId2(sessionPath);
|
|
331287
|
-
}
|
|
331556
|
+
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
331288
331557
|
if (!elementId) {
|
|
331289
331558
|
throw new Error("No focused element available for CLEAR. Tap an input field first.");
|
|
331290
331559
|
}
|
|
@@ -333034,6 +333303,7 @@ function scrubAuditMessages(messages, testVariables) {
|
|
|
333034
333303
|
const secretValues = Object.values(testVariables).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
|
|
333035
333304
|
if (secretValues.length === 0) return compacted;
|
|
333036
333305
|
let json = JSON.stringify(compacted);
|
|
333306
|
+
if (typeof json !== "string") return compacted;
|
|
333037
333307
|
for (const val of secretValues) {
|
|
333038
333308
|
const escaped = val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
333039
333309
|
json = json.replace(new RegExp(escaped, "g"), "[REDACTED]");
|
|
@@ -334385,7 +334655,9 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334385
334655
|
},
|
|
334386
334656
|
onSessionEnding: (deviceId) => {
|
|
334387
334657
|
detachMirrorSession(deviceId);
|
|
334388
|
-
}
|
|
334658
|
+
},
|
|
334659
|
+
// Resolve {{KEY}} credential placeholders in guided-replay TYPE steps.
|
|
334660
|
+
testVariables: run2.testVariables
|
|
334389
334661
|
});
|
|
334390
334662
|
let queuedRunId = null;
|
|
334391
334663
|
let resultPosted = false;
|
|
@@ -335154,7 +335426,11 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
335154
335426
|
},
|
|
335155
335427
|
onSessionEnding: (deviceId) => {
|
|
335156
335428
|
detachMirrorSession(deviceId);
|
|
335157
|
-
}
|
|
335429
|
+
},
|
|
335430
|
+
// Resolve {{KEY}} credential placeholders (recorded by the mobile
|
|
335431
|
+
// discovery generator instead of secret values) against this run's
|
|
335432
|
+
// CURRENT test variables.
|
|
335433
|
+
testVariables: run2.testVariables
|
|
335158
335434
|
});
|
|
335159
335435
|
attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
|
|
335160
335436
|
${result2.logs}`);
|