@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.js
CHANGED
|
@@ -1023,6 +1023,10 @@ var require_mobile_source_parser = __commonJS({
|
|
|
1023
1023
|
}
|
|
1024
1024
|
return fnv1a(parts.join("\n"));
|
|
1025
1025
|
}
|
|
1026
|
+
var EDITABLE_CLASS_RE = /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/;
|
|
1027
|
+
function isEditableElement(el) {
|
|
1028
|
+
return EDITABLE_CLASS_RE.test(el.role.trim());
|
|
1029
|
+
}
|
|
1026
1030
|
function buildLocatorBundle(el, platform3) {
|
|
1027
1031
|
const bundle = {
|
|
1028
1032
|
xpath: el.xpath,
|
|
@@ -1032,7 +1036,7 @@ var require_mobile_source_parser = __commonJS({
|
|
|
1032
1036
|
bundle.accessibilityId = el.accessibilityId;
|
|
1033
1037
|
if (platform3 === "ANDROID" && el.resourceId)
|
|
1034
1038
|
bundle.resourceId = el.resourceId;
|
|
1035
|
-
if (el.text)
|
|
1039
|
+
if (el.text && !isEditableElement(el))
|
|
1036
1040
|
bundle.text = el.text;
|
|
1037
1041
|
if (platform3 === "ANDROID" && el.accessibilityId)
|
|
1038
1042
|
bundle.contentDesc = el.accessibilityId;
|
|
@@ -1043,7 +1047,7 @@ var require_mobile_source_parser = __commonJS({
|
|
|
1043
1047
|
return el.accessibilityId;
|
|
1044
1048
|
if (el.resourceId)
|
|
1045
1049
|
return el.resourceId;
|
|
1046
|
-
if (el.text)
|
|
1050
|
+
if (el.text && !isEditableElement(el))
|
|
1047
1051
|
return el.text;
|
|
1048
1052
|
return el.xpath;
|
|
1049
1053
|
}
|
|
@@ -1363,6 +1367,31 @@ function toInlineCommentText(value) {
|
|
|
1363
1367
|
function isPlainObject(value) {
|
|
1364
1368
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1365
1369
|
}
|
|
1370
|
+
function isEditableMobileClassName(className) {
|
|
1371
|
+
if (!className)
|
|
1372
|
+
return false;
|
|
1373
|
+
return /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/.test(className.trim());
|
|
1374
|
+
}
|
|
1375
|
+
function mobileCredentialPlaceholder(key) {
|
|
1376
|
+
return `{{${key}}}`;
|
|
1377
|
+
}
|
|
1378
|
+
function resolveMobileStepValuePlaceholders(steps, variables) {
|
|
1379
|
+
const missing = /* @__PURE__ */ new Set();
|
|
1380
|
+
const vars = variables ?? {};
|
|
1381
|
+
const resolved = steps.map((step) => {
|
|
1382
|
+
if (typeof step.value !== "string" || !step.value.includes("{{"))
|
|
1383
|
+
return step;
|
|
1384
|
+
const value = step.value.replace(MOBILE_VALUE_PLACEHOLDER_RE, (token, key) => {
|
|
1385
|
+
const substitute = vars[key];
|
|
1386
|
+
if (typeof substitute === "string" && substitute.length > 0)
|
|
1387
|
+
return substitute;
|
|
1388
|
+
missing.add(key);
|
|
1389
|
+
return token;
|
|
1390
|
+
});
|
|
1391
|
+
return value === step.value ? step : { ...step, value };
|
|
1392
|
+
});
|
|
1393
|
+
return { steps: resolved, missingKeys: [...missing] };
|
|
1394
|
+
}
|
|
1366
1395
|
function readMobileMetadata(value) {
|
|
1367
1396
|
if (!isPlainObject(value)) {
|
|
1368
1397
|
return { screenName: "UnknownScreen" };
|
|
@@ -1404,7 +1433,11 @@ function buildStepTarget(metadata, selector) {
|
|
|
1404
1433
|
const primary = nonEmpty(locatorBundle.accessibilityId) ?? nonEmpty(locatorBundle.resourceId) ?? trimmedSelector ?? nonEmpty(locatorBundle.xpath) ?? locatorBundle.xpath;
|
|
1405
1434
|
const fallbackCandidates = [
|
|
1406
1435
|
locatorBundle.resourceId,
|
|
1407
|
-
|
|
1436
|
+
// For editable fields the text attribute IS the currently-typed value — a
|
|
1437
|
+
// self-invalidating locator that only matches while the recorded text is
|
|
1438
|
+
// still in the field (and it can leak typed input into locators). Static
|
|
1439
|
+
// elements keep their text fallback.
|
|
1440
|
+
isEditableMobileClassName(locatorBundle.className) ? void 0 : locatorBundle.text,
|
|
1408
1441
|
locatorBundle.contentDesc,
|
|
1409
1442
|
locatorBundle.xpath,
|
|
1410
1443
|
selector
|
|
@@ -1835,7 +1868,7 @@ function isRunnableStep(step) {
|
|
|
1835
1868
|
return false;
|
|
1836
1869
|
}
|
|
1837
1870
|
}
|
|
1838
|
-
var STEP_ACTION_BY_ACTION_TYPE;
|
|
1871
|
+
var STEP_ACTION_BY_ACTION_TYPE, MOBILE_VALUE_PLACEHOLDER_RE;
|
|
1839
1872
|
var init_mobile_test_generation = __esm({
|
|
1840
1873
|
"../shared/dist/mobile-test-generation.js"() {
|
|
1841
1874
|
"use strict";
|
|
@@ -1853,6 +1886,7 @@ var init_mobile_test_generation = __esm({
|
|
|
1853
1886
|
ASSERT_VISIBLE: "assertVisible",
|
|
1854
1887
|
ASSERT_TEXT: "assertText"
|
|
1855
1888
|
};
|
|
1889
|
+
MOBILE_VALUE_PLACEHOLDER_RE = /\{\{([A-Za-z0-9_]+)\}\}/g;
|
|
1856
1890
|
}
|
|
1857
1891
|
});
|
|
1858
1892
|
|
|
@@ -3694,6 +3728,7 @@ __export(dist_exports, {
|
|
|
3694
3728
|
isCountedFailure: () => isCountedFailure,
|
|
3695
3729
|
isCreditPackAmountCents: () => isCreditPackAmountCents,
|
|
3696
3730
|
isDbPricingPlan: () => isDbPricingPlan,
|
|
3731
|
+
isEditableMobileClassName: () => isEditableMobileClassName,
|
|
3697
3732
|
isEnvironmentalError: () => isEnvironmentalError,
|
|
3698
3733
|
isMobilePlatform: () => isMobilePlatform,
|
|
3699
3734
|
isPricingPlan: () => isPricingPlan,
|
|
@@ -3705,6 +3740,7 @@ __export(dist_exports, {
|
|
|
3705
3740
|
isUIVariant: () => isUIVariant,
|
|
3706
3741
|
isValidMobileAppId: () => isValidMobileAppId,
|
|
3707
3742
|
mediumForMobilePlatform: () => mediumForMobilePlatform,
|
|
3743
|
+
mobileCredentialPlaceholder: () => mobileCredentialPlaceholder,
|
|
3708
3744
|
mobilePlatformFromMedium: () => mobilePlatformFromMedium,
|
|
3709
3745
|
normalizeControlName: () => normalizeControlName,
|
|
3710
3746
|
otherMobilePlatform: () => otherMobilePlatform,
|
|
@@ -3714,6 +3750,7 @@ __export(dist_exports, {
|
|
|
3714
3750
|
platformTag: () => platformTag,
|
|
3715
3751
|
registrableDomain: () => registrableDomain,
|
|
3716
3752
|
resolveCrossOriginConfigOverride: () => resolveCrossOriginConfigOverride,
|
|
3753
|
+
resolveMobileStepValuePlaceholders: () => resolveMobileStepValuePlaceholders,
|
|
3717
3754
|
resolvePlatformLocatorBundle: () => resolvePlatformLocatorBundle,
|
|
3718
3755
|
safeOrigin: () => safeOrigin,
|
|
3719
3756
|
sanitizeTestVariables: () => sanitizeTestVariables,
|
|
@@ -62003,7 +62040,7 @@ var require_mobile_explorer = __commonJS({
|
|
|
62003
62040
|
}
|
|
62004
62041
|
sections.push("");
|
|
62005
62042
|
if (mode === "survey") {
|
|
62006
|
-
sections.push(
|
|
62043
|
+
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.`);
|
|
62007
62044
|
} else {
|
|
62008
62045
|
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.");
|
|
62009
62046
|
}
|
|
@@ -62061,7 +62098,14 @@ var require_mobile_explorer = __commonJS({
|
|
|
62061
62098
|
This app has stored login credentials under these keys:
|
|
62062
62099
|
${keyList}
|
|
62063
62100
|
|
|
62064
|
-
|
|
62101
|
+
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:
|
|
62102
|
+
1. Reach the email/password sign-in form (tap "Sign in with Email" / "Login" from the landing screen if needed).
|
|
62103
|
+
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.
|
|
62104
|
+
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.
|
|
62105
|
+
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).
|
|
62106
|
+
5. Only AFTER you have confirmed you are signed in, map the authenticated app.
|
|
62107
|
+
|
|
62108
|
+
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.
|
|
62065
62109
|
|
|
62066
62110
|
SECURITY RULES:
|
|
62067
62111
|
- 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.
|
|
@@ -62368,6 +62412,8 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
|
|
|
62368
62412
|
let summary = "";
|
|
62369
62413
|
let truncated = false;
|
|
62370
62414
|
let truncatedReason;
|
|
62415
|
+
let finishRedirects = 0;
|
|
62416
|
+
const MAX_FINISH_REDIRECTS = 2;
|
|
62371
62417
|
try {
|
|
62372
62418
|
try {
|
|
62373
62419
|
const seed = await driver.snapshot();
|
|
@@ -62497,6 +62543,20 @@ ${statePacket}` }
|
|
|
62497
62543
|
continue;
|
|
62498
62544
|
}
|
|
62499
62545
|
if (toolName === "finish_exploration") {
|
|
62546
|
+
const frontier = mode === "survey" ? state2.getFrontier() : [];
|
|
62547
|
+
const budgetRemainingPct = (maxIterations - iteration) / maxIterations;
|
|
62548
|
+
if (frontier.length > 0 && budgetRemainingPct >= 0.2 && finishRedirects < MAX_FINISH_REDIRECTS) {
|
|
62549
|
+
finishRedirects++;
|
|
62550
|
+
const targets = frontier.slice(0, 6).join(", ");
|
|
62551
|
+
log2(`
|
|
62552
|
+
finish_exploration bounced (${finishRedirects}/${MAX_FINISH_REDIRECTS}): ${frontier.length} frontier screen(s) still unvisited, ${Math.round(budgetRemainingPct * 100)}% budget left.`);
|
|
62553
|
+
pushResult({
|
|
62554
|
+
acknowledged: false,
|
|
62555
|
+
keepExploring: true,
|
|
62556
|
+
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.`
|
|
62557
|
+
});
|
|
62558
|
+
continue;
|
|
62559
|
+
}
|
|
62500
62560
|
explorationComplete = true;
|
|
62501
62561
|
summary = String(toolArgs.summary ?? "");
|
|
62502
62562
|
log2(`
|
|
@@ -62715,6 +62775,14 @@ Exploration complete: ${summary}`);
|
|
|
62715
62775
|
...actionResult.error ? { error: actionResult.error } : {},
|
|
62716
62776
|
// Report only the KEY that was filled — never the value.
|
|
62717
62777
|
filledCredentialKey: key,
|
|
62778
|
+
// Code-level reinforcement of the login sequence: filling a field does
|
|
62779
|
+
// NOT submit the form. The observed failure was fill-without-submit —
|
|
62780
|
+
// the model filled email+password then wandered off (into demo content)
|
|
62781
|
+
// without ever tapping Sign In, so it never reached the authenticated
|
|
62782
|
+
// app. This nudge fires on every credential fill so the model is
|
|
62783
|
+
// reminded to complete + verify the login rather than treating the fill
|
|
62784
|
+
// as "logged in".
|
|
62785
|
+
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.",
|
|
62718
62786
|
screenId,
|
|
62719
62787
|
observation,
|
|
62720
62788
|
...absorbed.externalBlocked ? { blockedExternalApp: true, externalApp: absorbed.externalOwner } : {},
|
|
@@ -65573,6 +65641,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65573
65641
|
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).
|
|
65574
65642
|
7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
|
|
65575
65643
|
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.
|
|
65644
|
+
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.
|
|
65576
65645
|
|
|
65577
65646
|
## Output JSON Schema
|
|
65578
65647
|
{
|
|
@@ -65780,7 +65849,8 @@ ${context.appBrief.slice(0, 500)}`);
|
|
|
65780
65849
|
sections.push(`## App Under Test
|
|
65781
65850
|
${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
|
|
65782
65851
|
if (context.featureName) {
|
|
65783
|
-
sections.push(`## Feature: ${context.featureName}
|
|
65852
|
+
sections.push(`## Feature: ${context.featureName}
|
|
65853
|
+
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.`);
|
|
65784
65854
|
}
|
|
65785
65855
|
const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
|
|
65786
65856
|
const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
|
|
@@ -66165,10 +66235,12 @@ var require_mobile_generator = __commonJS({
|
|
|
66165
66235
|
"../runner-core/dist/services/mobile/mobile-generator.js"(exports2) {
|
|
66166
66236
|
"use strict";
|
|
66167
66237
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
66238
|
+
exports2.collapseRetryCycles = collapseRetryCycles;
|
|
66168
66239
|
exports2.runMobileGeneratePhase = runMobileGeneratePhase;
|
|
66169
66240
|
var crypto_1 = require("crypto");
|
|
66170
66241
|
var shared_1 = (init_dist(), __toCommonJS(dist_exports));
|
|
66171
66242
|
var ai_provider_js_1 = require_ai_provider();
|
|
66243
|
+
var credential_tools_js_1 = require_credential_tools();
|
|
66172
66244
|
var ai_queue_js_1 = require_ai_queue();
|
|
66173
66245
|
var ai_retry_js_1 = require_ai_retry();
|
|
66174
66246
|
var mobile_source_parser_js_1 = require_mobile_source_parser();
|
|
@@ -66290,6 +66362,24 @@ var require_mobile_generator = __commonJS({
|
|
|
66290
66362
|
}
|
|
66291
66363
|
}
|
|
66292
66364
|
};
|
|
66365
|
+
var MOBILE_FILL_CREDENTIAL_TOOL = {
|
|
66366
|
+
type: "function",
|
|
66367
|
+
function: {
|
|
66368
|
+
name: "mobile_fill_credential",
|
|
66369
|
+
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.',
|
|
66370
|
+
parameters: {
|
|
66371
|
+
type: "object",
|
|
66372
|
+
properties: {
|
|
66373
|
+
ref: { type: "string", description: 'Text-field element ref from the latest mobile_snapshot, e.g. "e5".' },
|
|
66374
|
+
credentialKey: {
|
|
66375
|
+
type: "string",
|
|
66376
|
+
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.'
|
|
66377
|
+
}
|
|
66378
|
+
},
|
|
66379
|
+
required: ["ref", "credentialKey"]
|
|
66380
|
+
}
|
|
66381
|
+
}
|
|
66382
|
+
};
|
|
66293
66383
|
var FINISH_GENERATE_TOOL = {
|
|
66294
66384
|
type: "function",
|
|
66295
66385
|
function: {
|
|
@@ -66311,6 +66401,7 @@ var require_mobile_generator = __commonJS({
|
|
|
66311
66401
|
MOBILE_TAP_TOOL,
|
|
66312
66402
|
MOBILE_TYPE_TOOL,
|
|
66313
66403
|
MOBILE_CLEAR_TOOL,
|
|
66404
|
+
MOBILE_FILL_CREDENTIAL_TOOL,
|
|
66314
66405
|
MOBILE_SWIPE_TOOL,
|
|
66315
66406
|
MOBILE_BACK_TOOL,
|
|
66316
66407
|
MOBILE_RELAUNCH_TOOL,
|
|
@@ -66389,6 +66480,24 @@ var require_mobile_generator = __commonJS({
|
|
|
66389
66480
|
- 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.
|
|
66390
66481
|
|
|
66391
66482
|
Max iterations: ${MAX_SCENARIO_ITERATIONS}. You will be warned near the end.`;
|
|
66483
|
+
}
|
|
66484
|
+
function buildGenerateCredentialPromptBlock(secrets) {
|
|
66485
|
+
const keys = Object.keys(secrets).filter((k) => secrets[k] && secrets[k].length > 0);
|
|
66486
|
+
if (keys.length === 0)
|
|
66487
|
+
return "";
|
|
66488
|
+
const keyList = keys.map((k) => `- \`${k}\``).join("\n");
|
|
66489
|
+
return `
|
|
66490
|
+
|
|
66491
|
+
## Test Credentials (values are HIDDEN from you)
|
|
66492
|
+
This app has stored login credentials under these keys:
|
|
66493
|
+
${keyList}
|
|
66494
|
+
|
|
66495
|
+
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.
|
|
66496
|
+
|
|
66497
|
+
SECURITY RULES:
|
|
66498
|
+
- 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.
|
|
66499
|
+
- 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.
|
|
66500
|
+
- NEVER put credential values in test names, descriptions, or assert texts.`;
|
|
66392
66501
|
}
|
|
66393
66502
|
function buildScenarioKickoff(scenario) {
|
|
66394
66503
|
const stepLines = scenario.steps.map((step, i) => ` ${i + 1}. ${step.action}${step.target ? ` (target: ${step.target})` : ""}${step.hint ? ` [hint: ${step.hint}]` : ""}`).join("\n");
|
|
@@ -66459,7 +66568,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
|
|
|
66459
66568
|
}
|
|
66460
66569
|
}
|
|
66461
66570
|
async function runScenarioLoop(args) {
|
|
66462
|
-
const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, log: log2 } = args;
|
|
66571
|
+
const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, log: log2 } = args;
|
|
66463
66572
|
const actions = [];
|
|
66464
66573
|
let assertCount = 0;
|
|
66465
66574
|
let finish = null;
|
|
@@ -66576,7 +66685,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
|
|
|
66576
66685
|
} catch (err) {
|
|
66577
66686
|
log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
66578
66687
|
}
|
|
66579
|
-
const systemPrompt = buildSystemPrompt(platform3, targetAppId);
|
|
66688
|
+
const systemPrompt = buildSystemPrompt(platform3, targetAppId) + buildGenerateCredentialPromptBlock(credentials);
|
|
66580
66689
|
const kickoff = buildScenarioKickoff(scenario);
|
|
66581
66690
|
const recentExchanges = [];
|
|
66582
66691
|
let iteration = 0;
|
|
@@ -66689,6 +66798,7 @@ ${statePacket}` },
|
|
|
66689
66798
|
latest: getLatest,
|
|
66690
66799
|
absorbObservation,
|
|
66691
66800
|
resolveRef,
|
|
66801
|
+
credentials,
|
|
66692
66802
|
screenName: () => lastScreenName || "UnknownScreen",
|
|
66693
66803
|
recordAction: (action) => {
|
|
66694
66804
|
actions.push(finalizeAction(action));
|
|
@@ -66710,7 +66820,7 @@ ${statePacket}` },
|
|
|
66710
66820
|
return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
|
|
66711
66821
|
}
|
|
66712
66822
|
async function dispatchGenerateTool(args) {
|
|
66713
|
-
const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, screenName, recordAction, log: log2 } = args;
|
|
66823
|
+
const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, credentials, screenName, recordAction, log: log2 } = args;
|
|
66714
66824
|
const unknownRef = (ref) => ({
|
|
66715
66825
|
ok: false,
|
|
66716
66826
|
error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
|
|
@@ -66776,6 +66886,14 @@ ${statePacket}` },
|
|
|
66776
66886
|
if (!element)
|
|
66777
66887
|
return unknownRef(toolArgs.ref);
|
|
66778
66888
|
const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
|
|
66889
|
+
for (const [key, secret] of Object.entries(credentials)) {
|
|
66890
|
+
if (secret && secret.length >= 4 && value.includes(secret)) {
|
|
66891
|
+
return {
|
|
66892
|
+
ok: false,
|
|
66893
|
+
error: `BLOCKED: mobile_type contained the stored credential value for "${key}". Use mobile_fill_credential({ ref, credentialKey: "${key}" }) instead of typing the credential directly.`
|
|
66894
|
+
};
|
|
66895
|
+
}
|
|
66896
|
+
}
|
|
66779
66897
|
const beforeScreenName = screenName();
|
|
66780
66898
|
const result = await driver.type(value, element.bounds);
|
|
66781
66899
|
const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_type");
|
|
@@ -66789,6 +66907,37 @@ ${statePacket}` },
|
|
|
66789
66907
|
}
|
|
66790
66908
|
return actionPayload(result, absorbed);
|
|
66791
66909
|
}
|
|
66910
|
+
// KEY-based credential fill: type the real value on-device, persist only a
|
|
66911
|
+
// {{KEY}} placeholder in the recorded step (replay resolves it against the
|
|
66912
|
+
// run's current test variables). The tool result echoes the KEY, never the
|
|
66913
|
+
// value, so the secret cannot enter the transcript.
|
|
66914
|
+
case "mobile_fill_credential": {
|
|
66915
|
+
const key = typeof toolArgs.credentialKey === "string" ? toolArgs.credentialKey.trim() : "";
|
|
66916
|
+
if (!key) {
|
|
66917
|
+
return { ok: false, error: 'mobile_fill_credential: missing required "credentialKey".' };
|
|
66918
|
+
}
|
|
66919
|
+
const secret = credentials[key];
|
|
66920
|
+
if (!secret) {
|
|
66921
|
+
const available = Object.keys(credentials).filter((k) => credentials[k]).join(", ") || "(none configured)";
|
|
66922
|
+
return { ok: false, error: `mobile_fill_credential: no credential configured for key "${key}". Available keys: ${available}` };
|
|
66923
|
+
}
|
|
66924
|
+
const element = resolveRef(toolArgs.ref);
|
|
66925
|
+
if (!element)
|
|
66926
|
+
return unknownRef(toolArgs.ref);
|
|
66927
|
+
const beforeScreenName = screenName();
|
|
66928
|
+
const result = await driver.type(secret, element.bounds);
|
|
66929
|
+
const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_fill_credential");
|
|
66930
|
+
if (shouldRecordAction(result, absorbed)) {
|
|
66931
|
+
recordAction({
|
|
66932
|
+
type: "TYPE",
|
|
66933
|
+
value: (0, shared_1.mobileCredentialPlaceholder)(key),
|
|
66934
|
+
selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
|
|
66935
|
+
metadata: metadataForElement(element, beforeScreenName, platform3)
|
|
66936
|
+
});
|
|
66937
|
+
log2(` fill_credential: recorded {{${key}}} into ${labelForElement(element)}`);
|
|
66938
|
+
}
|
|
66939
|
+
return { ...actionPayload(result, absorbed), filledCredentialKey: key };
|
|
66940
|
+
}
|
|
66792
66941
|
case "mobile_clear": {
|
|
66793
66942
|
const element = resolveRef(toolArgs.ref);
|
|
66794
66943
|
if (!element)
|
|
@@ -66937,18 +67086,40 @@ ${statePacket}` },
|
|
|
66937
67086
|
return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
|
|
66938
67087
|
}
|
|
66939
67088
|
var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
|
|
66940
|
-
var
|
|
66941
|
-
"
|
|
66942
|
-
"
|
|
66943
|
-
"
|
|
66944
|
-
"
|
|
66945
|
-
"
|
|
67089
|
+
var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
|
|
67090
|
+
"content",
|
|
67091
|
+
"decor",
|
|
67092
|
+
"decor_content_parent",
|
|
67093
|
+
"action_bar_root",
|
|
67094
|
+
"statusBarBackground",
|
|
67095
|
+
"navigationBarBackground",
|
|
67096
|
+
// WebView hybrid apps (React/Vue/etc. rendered inside android.webkit.WebView)
|
|
67097
|
+
// expose their framework ROOT container's DOM id as the element id — present on
|
|
67098
|
+
// EVERY in-app screen, so the floor must not anchor to it (observed live on
|
|
67099
|
+
// ThoughtStream: `main-content` produced a screen-agnostic assertion that then
|
|
67100
|
+
// never matched via the text-contains fallback).
|
|
67101
|
+
"main-content",
|
|
67102
|
+
"root",
|
|
67103
|
+
"app",
|
|
67104
|
+
"app-root",
|
|
67105
|
+
"__next",
|
|
67106
|
+
"react-root",
|
|
67107
|
+
"application"
|
|
66946
67108
|
]);
|
|
66947
67109
|
function isAlwaysPresentContainer(element) {
|
|
66948
67110
|
if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
|
|
66949
67111
|
return true;
|
|
66950
67112
|
const resourceId = element.resourceId?.trim();
|
|
66951
|
-
|
|
67113
|
+
if (resourceId) {
|
|
67114
|
+
const slashIdx = resourceId.indexOf("/");
|
|
67115
|
+
const localName = slashIdx >= 0 ? resourceId.slice(slashIdx + 1) : resourceId;
|
|
67116
|
+
if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName))
|
|
67117
|
+
return true;
|
|
67118
|
+
}
|
|
67119
|
+
const accessibilityId = element.accessibilityId?.trim();
|
|
67120
|
+
if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(accessibilityId))
|
|
67121
|
+
return true;
|
|
67122
|
+
return false;
|
|
66952
67123
|
}
|
|
66953
67124
|
function appendHybridFloor(actions, recording, platform3) {
|
|
66954
67125
|
const anchor = pickLandingAnchor(recording.lastSnapshot);
|
|
@@ -66962,9 +67133,38 @@ ${statePacket}` },
|
|
|
66962
67133
|
}));
|
|
66963
67134
|
return true;
|
|
66964
67135
|
}
|
|
67136
|
+
function actionCycleKey(action) {
|
|
67137
|
+
return `${action.type}|${action.selector}|${action.value ?? ""}`;
|
|
67138
|
+
}
|
|
67139
|
+
var MAX_RETRY_CYCLE_LENGTH = 8;
|
|
67140
|
+
function collapseRetryCycles(actions) {
|
|
67141
|
+
const out = [...actions];
|
|
67142
|
+
let changed = true;
|
|
67143
|
+
while (changed) {
|
|
67144
|
+
changed = false;
|
|
67145
|
+
const maxWindow = Math.min(MAX_RETRY_CYCLE_LENGTH, Math.floor(out.length / 2));
|
|
67146
|
+
for (let window2 = maxWindow; window2 >= 2 && !changed; window2--) {
|
|
67147
|
+
for (let i = 0; i + 2 * window2 <= out.length; i++) {
|
|
67148
|
+
const first = out.slice(i, i + window2);
|
|
67149
|
+
if (first.some((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT"))
|
|
67150
|
+
continue;
|
|
67151
|
+
const second = out.slice(i + window2, i + 2 * window2);
|
|
67152
|
+
if (!first.every((a, k) => actionCycleKey(a) === actionCycleKey(second[k])))
|
|
67153
|
+
continue;
|
|
67154
|
+
out.splice(i, window2);
|
|
67155
|
+
changed = true;
|
|
67156
|
+
break;
|
|
67157
|
+
}
|
|
67158
|
+
}
|
|
67159
|
+
}
|
|
67160
|
+
return out;
|
|
67161
|
+
}
|
|
66965
67162
|
function assembleTest(args) {
|
|
66966
67163
|
const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
|
|
66967
|
-
const actions =
|
|
67164
|
+
const actions = collapseRetryCycles(recording.actions);
|
|
67165
|
+
if (actions.length < recording.actions.length) {
|
|
67166
|
+
log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
|
|
67167
|
+
}
|
|
66968
67168
|
const floorAppended = appendHybridFloor(actions, recording, platform3);
|
|
66969
67169
|
if (floorAppended) {
|
|
66970
67170
|
log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
|
|
@@ -67003,6 +67203,12 @@ ${statePacket}` },
|
|
|
67003
67203
|
playwrightCode: "",
|
|
67004
67204
|
verified,
|
|
67005
67205
|
tags,
|
|
67206
|
+
// Per-test feature label → TestCase.suite. One deep run plans scenarios for
|
|
67207
|
+
// MULTIPLE feature groups (the planner reuses survey feature names), so the
|
|
67208
|
+
// run-level ctx.featureName fallback in /discovery-test would wrongly file
|
|
67209
|
+
// every test under the deep-dived feature. The scenario's own group is the
|
|
67210
|
+
// correct home; the runner forwards it as `suiteName`.
|
|
67211
|
+
...scenario.featureGroup?.trim() ? { suiteName: scenario.featureGroup.trim() } : {},
|
|
67006
67212
|
// Carry the planner scenario identity so incremental/resume can match an
|
|
67007
67213
|
// already-generated scenario reliably (scenarioRef.name === the plan's
|
|
67008
67214
|
// scenario name). Without this the resume skip-set falls back to the
|
|
@@ -67020,7 +67226,27 @@ ${statePacket}` },
|
|
|
67020
67226
|
startFromLanding: scenario.startFromLanding,
|
|
67021
67227
|
targetPages: [...scenario.targetPages]
|
|
67022
67228
|
},
|
|
67023
|
-
evidence: { observedLocators: [], observedTransitions: [] }
|
|
67229
|
+
evidence: { observedLocators: [], observedTransitions: [] },
|
|
67230
|
+
// Assertion-strength marker for the server promote-seam
|
|
67231
|
+
// (resolveStabilityTransition): an UNVERIFIED recording proves none of the
|
|
67232
|
+
// scenario's outcomes — only the hybrid floor's landing-screen visibility —
|
|
67233
|
+
// so a passing replay is vacuous. Hold it as DRAFT (@needs-review) for a
|
|
67234
|
+
// human instead of letting the pass auto-promote it into the ACTIVE suite.
|
|
67235
|
+
// Mirrors the recorder mobile path (server test-pipeline.ts @unverified).
|
|
67236
|
+
...verified ? {} : {
|
|
67237
|
+
gateActivity: {
|
|
67238
|
+
outcomeGate: {
|
|
67239
|
+
requiredCount: scenario.expectedOutcomes.length,
|
|
67240
|
+
uncoveredCount: scenario.expectedOutcomes.length
|
|
67241
|
+
},
|
|
67242
|
+
strengthGate: {
|
|
67243
|
+
fired: false,
|
|
67244
|
+
severity: "block",
|
|
67245
|
+
blocked: true,
|
|
67246
|
+
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."
|
|
67247
|
+
}
|
|
67248
|
+
}
|
|
67249
|
+
}
|
|
67024
67250
|
}
|
|
67025
67251
|
};
|
|
67026
67252
|
return test;
|
|
@@ -67041,6 +67267,7 @@ ${statePacket}` },
|
|
|
67041
67267
|
const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
|
|
67042
67268
|
const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
|
|
67043
67269
|
const targetAppId = ctx.target?.appId?.trim() || void 0;
|
|
67270
|
+
const { secrets: credentialSecrets } = (0, credential_tools_js_1.partitionTestVariables)(ctx.variableContext?.allTestVariables ?? ctx.variableContext?.publicTestVariables);
|
|
67044
67271
|
const skipSet = new Set(skipScenarioNames ?? []);
|
|
67045
67272
|
const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
|
|
67046
67273
|
if (skipSet.size > 0) {
|
|
@@ -67069,6 +67296,7 @@ ${statePacket}` },
|
|
|
67069
67296
|
onAICall,
|
|
67070
67297
|
onScreenshot,
|
|
67071
67298
|
targetAppId,
|
|
67299
|
+
credentials: credentialSecrets,
|
|
67072
67300
|
log: log2
|
|
67073
67301
|
});
|
|
67074
67302
|
consecutiveInfraFailures = 0;
|
|
@@ -313691,9 +313919,12 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
|
|
|
313691
313919
|
});
|
|
313692
313920
|
let featureInsight;
|
|
313693
313921
|
if (mode === "deep" && ctx.featureName) {
|
|
313922
|
+
const targetName = ctx.featureName.trim().toLowerCase();
|
|
313923
|
+
const plannedGroup = (featureGroups ?? []).find((group) => group.name.trim().toLowerCase() === targetName);
|
|
313924
|
+
const plannedDescription = plannedGroup?.description?.trim();
|
|
313694
313925
|
featureInsight = {
|
|
313695
313926
|
featureName: ctx.featureName,
|
|
313696
|
-
description: `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
|
|
313927
|
+
description: plannedDescription && plannedDescription.length > 0 ? plannedDescription : `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
|
|
313697
313928
|
};
|
|
313698
313929
|
}
|
|
313699
313930
|
const duration = (Date.now() - startTime) / 1e3;
|
|
@@ -328229,7 +328460,7 @@ var require_package4 = __commonJS({
|
|
|
328229
328460
|
"package.json"(exports2, module2) {
|
|
328230
328461
|
module2.exports = {
|
|
328231
328462
|
name: "@validate.qa/runner",
|
|
328232
|
-
version: "1.0.
|
|
328463
|
+
version: "1.0.19",
|
|
328233
328464
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
328234
328465
|
bin: {
|
|
328235
328466
|
"validate-runner": "dist/cli.js"
|
|
@@ -330507,6 +330738,10 @@ async function executeMobileTest(options) {
|
|
|
330507
330738
|
const logLines = [];
|
|
330508
330739
|
const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
|
|
330509
330740
|
const startTime = Date.now();
|
|
330741
|
+
const { steps: runSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
|
|
330742
|
+
if (missingVariableKeys.length > 0) {
|
|
330743
|
+
log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured for this project/run.`);
|
|
330744
|
+
}
|
|
330510
330745
|
const stepResults = [];
|
|
330511
330746
|
const screenshots = [];
|
|
330512
330747
|
let apiCalls = [];
|
|
@@ -330644,7 +330879,7 @@ async function executeMobileTest(options) {
|
|
|
330644
330879
|
let entryDrift = false;
|
|
330645
330880
|
const runPlatform = options.target.platform;
|
|
330646
330881
|
const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
|
|
330647
|
-
const entryStep =
|
|
330882
|
+
const entryStep = runSteps.find(
|
|
330648
330883
|
(s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
|
|
330649
330884
|
);
|
|
330650
330885
|
if (entryStep) {
|
|
@@ -330661,7 +330896,7 @@ async function executeMobileTest(options) {
|
|
|
330661
330896
|
}
|
|
330662
330897
|
}
|
|
330663
330898
|
let environmental = null;
|
|
330664
|
-
for (const step of
|
|
330899
|
+
for (const step of runSteps) {
|
|
330665
330900
|
if (entryDrift) break;
|
|
330666
330901
|
if (!stepAppliesToPlatform(step, runPlatform)) {
|
|
330667
330902
|
log2(`Step ${step.order}: ${step.action} \u2014 skipped (not applicable to ${runPlatform})`);
|
|
@@ -330749,7 +330984,7 @@ async function executeMobileTest(options) {
|
|
|
330749
330984
|
}
|
|
330750
330985
|
const executedResults = stepResults.filter((r) => r.status !== "skipped");
|
|
330751
330986
|
const verifyOrders = new Set(
|
|
330752
|
-
|
|
330987
|
+
runSteps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
|
|
330753
330988
|
);
|
|
330754
330989
|
const anyVerificationPassed = stepResults.some((r) => r.status === "passed" && verifyOrders.has(r.stepOrder));
|
|
330755
330990
|
const crossPlatformUnverified = isCrossPlatform && verifyOrders.size > 0 && !anyVerificationPassed;
|
|
@@ -330989,11 +331224,15 @@ async function harvestPlatformLocators(options) {
|
|
|
330989
331224
|
const log2 = (msg) => {
|
|
330990
331225
|
logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
|
|
330991
331226
|
};
|
|
331227
|
+
const { steps: harvestSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
|
|
331228
|
+
if (missingVariableKeys.length > 0) {
|
|
331229
|
+
log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured.`);
|
|
331230
|
+
}
|
|
330992
331231
|
const bundles = {};
|
|
330993
331232
|
const divergentOrders = [];
|
|
330994
331233
|
let reachedOrder = null;
|
|
330995
331234
|
let environmentalError;
|
|
330996
|
-
log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${
|
|
331235
|
+
log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${harvestSteps.length} steps`);
|
|
330997
331236
|
const session = await createMobileDiscoverySession({
|
|
330998
331237
|
appId: options.target.appId,
|
|
330999
331238
|
platform: platform3,
|
|
@@ -331013,7 +331252,7 @@ async function harvestPlatformLocators(options) {
|
|
|
331013
331252
|
}
|
|
331014
331253
|
}
|
|
331015
331254
|
await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
|
|
331016
|
-
for (const step of
|
|
331255
|
+
for (const step of harvestSteps) {
|
|
331017
331256
|
if (step.action === "launchApp") continue;
|
|
331018
331257
|
if (!stepAppliesToPlatform(step, platform3)) continue;
|
|
331019
331258
|
let elementId = null;
|
|
@@ -331257,12 +331496,46 @@ async function tapAt(sessionPath, bounds) {
|
|
|
331257
331496
|
throw new Error("TAP requires usable bounds (finite x/y)");
|
|
331258
331497
|
}
|
|
331259
331498
|
}
|
|
331260
|
-
async function
|
|
331499
|
+
async function getElementRect(sessionPath, elementId) {
|
|
331500
|
+
try {
|
|
331501
|
+
const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
|
|
331502
|
+
if (!value || typeof value !== "object") return null;
|
|
331503
|
+
const r = value;
|
|
331504
|
+
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)) {
|
|
331505
|
+
return null;
|
|
331506
|
+
}
|
|
331507
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
331508
|
+
} catch {
|
|
331509
|
+
return null;
|
|
331510
|
+
}
|
|
331511
|
+
}
|
|
331512
|
+
function rectMatchesIntendedBounds(rect, bounds) {
|
|
331513
|
+
if (bounds.width <= 0 || bounds.height <= 0) return false;
|
|
331514
|
+
const rectCenterX = rect.x + rect.width / 2;
|
|
331515
|
+
const rectCenterY = rect.y + rect.height / 2;
|
|
331516
|
+
const boundsCenterX = bounds.x + bounds.width / 2;
|
|
331517
|
+
const boundsCenterY = bounds.y + bounds.height / 2;
|
|
331518
|
+
const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
|
|
331519
|
+
const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
|
|
331520
|
+
return rectCenterInBounds || boundsCenterInRect;
|
|
331521
|
+
}
|
|
331522
|
+
async function resolveTypeTargetElementId(sessionPath, bounds) {
|
|
331523
|
+
const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
|
|
331261
331524
|
let elementId = await getActiveElementId2(sessionPath);
|
|
331525
|
+
if (elementId && box && box.width > 0 && box.height > 0) {
|
|
331526
|
+
const rect = await getElementRect(sessionPath, elementId);
|
|
331527
|
+
if (!rect || !rectMatchesIntendedBounds(rect, box)) {
|
|
331528
|
+
elementId = null;
|
|
331529
|
+
}
|
|
331530
|
+
}
|
|
331262
331531
|
if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
|
|
331263
331532
|
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
331264
331533
|
elementId = await getActiveElementId2(sessionPath);
|
|
331265
331534
|
}
|
|
331535
|
+
return elementId;
|
|
331536
|
+
}
|
|
331537
|
+
async function typeText(sessionPath, value, bounds) {
|
|
331538
|
+
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
331266
331539
|
if (!elementId) {
|
|
331267
331540
|
throw new Error("No focused element available for TYPE. Tap an input field first.");
|
|
331268
331541
|
}
|
|
@@ -331272,11 +331545,7 @@ async function typeText(sessionPath, value, bounds) {
|
|
|
331272
331545
|
});
|
|
331273
331546
|
}
|
|
331274
331547
|
async function clearField(sessionPath, bounds) {
|
|
331275
|
-
|
|
331276
|
-
if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
|
|
331277
|
-
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
331278
|
-
elementId = await getActiveElementId2(sessionPath);
|
|
331279
|
-
}
|
|
331548
|
+
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
331280
331549
|
if (!elementId) {
|
|
331281
331550
|
throw new Error("No focused element available for CLEAR. Tap an input field first.");
|
|
331282
331551
|
}
|
|
@@ -333026,6 +333295,7 @@ function scrubAuditMessages(messages, testVariables) {
|
|
|
333026
333295
|
const secretValues = Object.values(testVariables).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
|
|
333027
333296
|
if (secretValues.length === 0) return compacted;
|
|
333028
333297
|
let json = JSON.stringify(compacted);
|
|
333298
|
+
if (typeof json !== "string") return compacted;
|
|
333029
333299
|
for (const val of secretValues) {
|
|
333030
333300
|
const escaped = val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
333031
333301
|
json = json.replace(new RegExp(escaped, "g"), "[REDACTED]");
|
|
@@ -334377,7 +334647,9 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334377
334647
|
},
|
|
334378
334648
|
onSessionEnding: (deviceId) => {
|
|
334379
334649
|
detachMirrorSession(deviceId);
|
|
334380
|
-
}
|
|
334650
|
+
},
|
|
334651
|
+
// Resolve {{KEY}} credential placeholders in guided-replay TYPE steps.
|
|
334652
|
+
testVariables: run2.testVariables
|
|
334381
334653
|
});
|
|
334382
334654
|
let queuedRunId = null;
|
|
334383
334655
|
let resultPosted = false;
|
|
@@ -335146,7 +335418,11 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
335146
335418
|
},
|
|
335147
335419
|
onSessionEnding: (deviceId) => {
|
|
335148
335420
|
detachMirrorSession(deviceId);
|
|
335149
|
-
}
|
|
335421
|
+
},
|
|
335422
|
+
// Resolve {{KEY}} credential placeholders (recorded by the mobile
|
|
335423
|
+
// discovery generator instead of secret values) against this run's
|
|
335424
|
+
// CURRENT test variables.
|
|
335425
|
+
testVariables: run2.testVariables
|
|
335150
335426
|
});
|
|
335151
335427
|
attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
|
|
335152
335428
|
${result2.logs}`);
|