@validate.qa/runner 1.0.16 → 1.0.18
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 +730 -71
- package/dist/cli.mjs +730 -71
- 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,103 @@ 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;
|
|
1895
|
+
}
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
// ../shared/dist/mobile-cross-platform.js
|
|
1899
|
+
function isMobilePlatform(value) {
|
|
1900
|
+
return value === "IOS" || value === "ANDROID";
|
|
1901
|
+
}
|
|
1902
|
+
function mediumForMobilePlatform(platform3) {
|
|
1903
|
+
return platform3 === "IOS" ? "IOS_NATIVE" : "ANDROID_NATIVE";
|
|
1904
|
+
}
|
|
1905
|
+
function mobilePlatformFromMedium(medium) {
|
|
1906
|
+
if (medium === "IOS_NATIVE")
|
|
1907
|
+
return "IOS";
|
|
1908
|
+
if (medium === "ANDROID_NATIVE")
|
|
1909
|
+
return "ANDROID";
|
|
1910
|
+
return null;
|
|
1911
|
+
}
|
|
1912
|
+
function otherMobilePlatform(platform3) {
|
|
1913
|
+
return platform3 === "IOS" ? "ANDROID" : "IOS";
|
|
1914
|
+
}
|
|
1915
|
+
function platformTag(platform3) {
|
|
1916
|
+
return platform3 === "IOS" ? "@ios" : "@android";
|
|
1917
|
+
}
|
|
1918
|
+
function resolvePlatformLocatorBundle(target, platform3) {
|
|
1919
|
+
if (!target)
|
|
1920
|
+
return void 0;
|
|
1921
|
+
if (platform3) {
|
|
1922
|
+
const overlay = target.platformLocators?.[platform3];
|
|
1923
|
+
if (overlay)
|
|
1924
|
+
return overlay;
|
|
1925
|
+
}
|
|
1926
|
+
return target.locatorBundle;
|
|
1927
|
+
}
|
|
1928
|
+
function hasPlatformLocator(target, platform3) {
|
|
1929
|
+
return Boolean(target?.platformLocators?.[platform3]);
|
|
1930
|
+
}
|
|
1931
|
+
function hasCrossPlatformIdentity(bundle) {
|
|
1932
|
+
if (!bundle)
|
|
1933
|
+
return false;
|
|
1934
|
+
return Boolean(bundle.text?.trim() || bundle.contentDesc?.trim() || bundle.accessibilityId?.trim());
|
|
1935
|
+
}
|
|
1936
|
+
function stepIsDivergentForPlatform(step, platform3) {
|
|
1937
|
+
return Array.isArray(step.crossPlatformDivergent) && step.crossPlatformDivergent.includes(platform3);
|
|
1938
|
+
}
|
|
1939
|
+
function stepAppliesToPlatform(step, platform3) {
|
|
1940
|
+
const platforms = step.platforms;
|
|
1941
|
+
if (!platforms || platforms.length === 0)
|
|
1942
|
+
return true;
|
|
1943
|
+
if (!platform3)
|
|
1944
|
+
return true;
|
|
1945
|
+
return platforms.includes(platform3);
|
|
1946
|
+
}
|
|
1947
|
+
function stepsMissingPlatformLocators(steps, platform3) {
|
|
1948
|
+
return steps.filter((step) => {
|
|
1949
|
+
if (!LOCATOR_REQUIRING_ACTIONS.has(step.action))
|
|
1950
|
+
return false;
|
|
1951
|
+
if (!stepAppliesToPlatform(step, platform3))
|
|
1952
|
+
return false;
|
|
1953
|
+
if (hasPlatformLocator(step.target, platform3))
|
|
1954
|
+
return false;
|
|
1955
|
+
if (stepIsDivergentForPlatform(step, platform3))
|
|
1956
|
+
return false;
|
|
1957
|
+
return hasCrossPlatformIdentity(step.target?.locatorBundle);
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
function stepsNeedingReviewForPlatform(steps, platform3) {
|
|
1961
|
+
return steps.filter((step) => {
|
|
1962
|
+
if (!LOCATOR_REQUIRING_ACTIONS.has(step.action))
|
|
1963
|
+
return false;
|
|
1964
|
+
if (!stepAppliesToPlatform(step, platform3))
|
|
1965
|
+
return false;
|
|
1966
|
+
if (hasPlatformLocator(step.target, platform3))
|
|
1967
|
+
return false;
|
|
1968
|
+
if (stepIsDivergentForPlatform(step, platform3))
|
|
1969
|
+
return true;
|
|
1970
|
+
return !hasCrossPlatformIdentity(step.target?.locatorBundle);
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
function isTestReadyForPlatform(steps, authoringPlatform, runPlatform) {
|
|
1974
|
+
if (runPlatform === authoringPlatform)
|
|
1975
|
+
return true;
|
|
1976
|
+
return stepsMissingPlatformLocators(steps, runPlatform).length === 0 && stepsNeedingReviewForPlatform(steps, runPlatform).length === 0;
|
|
1977
|
+
}
|
|
1978
|
+
var MOBILE_PLATFORMS, LOCATOR_REQUIRING_ACTIONS;
|
|
1979
|
+
var init_mobile_cross_platform = __esm({
|
|
1980
|
+
"../shared/dist/mobile-cross-platform.js"() {
|
|
1981
|
+
"use strict";
|
|
1982
|
+
MOBILE_PLATFORMS = ["IOS", "ANDROID"];
|
|
1983
|
+
LOCATOR_REQUIRING_ACTIONS = /* @__PURE__ */ new Set([
|
|
1984
|
+
"tap",
|
|
1985
|
+
"type",
|
|
1986
|
+
"clear",
|
|
1987
|
+
"waitForElement",
|
|
1988
|
+
"assertVisible",
|
|
1989
|
+
"assertText"
|
|
1990
|
+
]);
|
|
1861
1991
|
}
|
|
1862
1992
|
});
|
|
1863
1993
|
|
|
@@ -3539,6 +3669,7 @@ __export(dist_exports, {
|
|
|
3539
3669
|
MOBILE_DEFAULT_SWIPE_DISTANCE: () => MOBILE_DEFAULT_SWIPE_DISTANCE,
|
|
3540
3670
|
MOBILE_FOCUS_SETTLE_MS: () => MOBILE_FOCUS_SETTLE_MS,
|
|
3541
3671
|
MOBILE_MAX_SWIPE_DISTANCE: () => MOBILE_MAX_SWIPE_DISTANCE,
|
|
3672
|
+
MOBILE_PLATFORMS: () => MOBILE_PLATFORMS,
|
|
3542
3673
|
MOBILE_SCREENSHOT_INTERVAL_MS: () => MOBILE_SCREENSHOT_INTERVAL_MS,
|
|
3543
3674
|
MOBILE_SNAPSHOT_MAX_SIZE_BYTES: () => MOBILE_SNAPSHOT_MAX_SIZE_BYTES,
|
|
3544
3675
|
MOBILE_STEP_TYPES: () => MOBILE_STEP_TYPES,
|
|
@@ -3592,6 +3723,8 @@ __export(dist_exports, {
|
|
|
3592
3723
|
getPlanDefinition: () => getPlanDefinition,
|
|
3593
3724
|
getPlanLabel: () => getPlanLabel,
|
|
3594
3725
|
getTrialDaysRemaining: () => getTrialDaysRemaining,
|
|
3726
|
+
hasCrossPlatformIdentity: () => hasCrossPlatformIdentity,
|
|
3727
|
+
hasPlatformLocator: () => hasPlatformLocator,
|
|
3595
3728
|
hostOfOrigin: () => hostOfOrigin,
|
|
3596
3729
|
isAddonType: () => isAddonType,
|
|
3597
3730
|
isApiTest: () => isApiTest,
|
|
@@ -3600,24 +3733,38 @@ __export(dist_exports, {
|
|
|
3600
3733
|
isCountedFailure: () => isCountedFailure,
|
|
3601
3734
|
isCreditPackAmountCents: () => isCreditPackAmountCents,
|
|
3602
3735
|
isDbPricingPlan: () => isDbPricingPlan,
|
|
3736
|
+
isEditableMobileClassName: () => isEditableMobileClassName,
|
|
3603
3737
|
isEnvironmentalError: () => isEnvironmentalError,
|
|
3738
|
+
isMobilePlatform: () => isMobilePlatform,
|
|
3604
3739
|
isPricingPlan: () => isPricingPlan,
|
|
3605
3740
|
isRunnableStep: () => isRunnableStep,
|
|
3606
3741
|
isSafeDeepLink: () => isSafeDeepLink,
|
|
3607
3742
|
isSameRegistrableDomainHost: () => isSameRegistrableDomainHost,
|
|
3743
|
+
isTestReadyForPlatform: () => isTestReadyForPlatform,
|
|
3608
3744
|
isTrialExpired: () => isTrialExpired,
|
|
3609
3745
|
isUIVariant: () => isUIVariant,
|
|
3610
3746
|
isValidMobileAppId: () => isValidMobileAppId,
|
|
3747
|
+
mediumForMobilePlatform: () => mediumForMobilePlatform,
|
|
3748
|
+
mobileCredentialPlaceholder: () => mobileCredentialPlaceholder,
|
|
3749
|
+
mobilePlatformFromMedium: () => mobilePlatformFromMedium,
|
|
3611
3750
|
normalizeControlName: () => normalizeControlName,
|
|
3751
|
+
otherMobilePlatform: () => otherMobilePlatform,
|
|
3612
3752
|
parenDepthDelta: () => parenDepthDelta,
|
|
3613
3753
|
planHasBillingPortal: () => planHasBillingPortal,
|
|
3614
3754
|
planRequiresCheckout: () => planRequiresCheckout,
|
|
3755
|
+
platformTag: () => platformTag,
|
|
3615
3756
|
registrableDomain: () => registrableDomain,
|
|
3616
3757
|
resolveCrossOriginConfigOverride: () => resolveCrossOriginConfigOverride,
|
|
3758
|
+
resolveMobileStepValuePlaceholders: () => resolveMobileStepValuePlaceholders,
|
|
3759
|
+
resolvePlatformLocatorBundle: () => resolvePlatformLocatorBundle,
|
|
3617
3760
|
safeOrigin: () => safeOrigin,
|
|
3618
3761
|
sanitizeTestVariables: () => sanitizeTestVariables,
|
|
3619
3762
|
selectorCandidatesForStep: () => selectorCandidatesForStep,
|
|
3620
3763
|
selectorForValue: () => selectorForValue,
|
|
3764
|
+
stepAppliesToPlatform: () => stepAppliesToPlatform,
|
|
3765
|
+
stepIsDivergentForPlatform: () => stepIsDivergentForPlatform,
|
|
3766
|
+
stepsMissingPlatformLocators: () => stepsMissingPlatformLocators,
|
|
3767
|
+
stepsNeedingReviewForPlatform: () => stepsNeedingReviewForPlatform,
|
|
3621
3768
|
stripEnvironmentalPrefix: () => stripEnvironmentalPrefix,
|
|
3622
3769
|
stripLoginScaffolding: () => stripLoginScaffolding,
|
|
3623
3770
|
summarizeHealth: () => summarizeHealth,
|
|
@@ -3631,6 +3778,7 @@ var init_dist = __esm({
|
|
|
3631
3778
|
init_types();
|
|
3632
3779
|
init_constants();
|
|
3633
3780
|
init_mobile_test_generation();
|
|
3781
|
+
init_mobile_cross_platform();
|
|
3634
3782
|
init_mobile_deeplink();
|
|
3635
3783
|
init_plans();
|
|
3636
3784
|
init_audit_pricing();
|
|
@@ -65467,6 +65615,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65467
65615
|
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).
|
|
65468
65616
|
7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
|
|
65469
65617
|
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.
|
|
65618
|
+
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.
|
|
65470
65619
|
|
|
65471
65620
|
## Output JSON Schema
|
|
65472
65621
|
{
|
|
@@ -65674,7 +65823,8 @@ ${context.appBrief.slice(0, 500)}`);
|
|
|
65674
65823
|
sections.push(`## App Under Test
|
|
65675
65824
|
${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
|
|
65676
65825
|
if (context.featureName) {
|
|
65677
|
-
sections.push(`## Feature: ${context.featureName}
|
|
65826
|
+
sections.push(`## Feature: ${context.featureName}
|
|
65827
|
+
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.`);
|
|
65678
65828
|
}
|
|
65679
65829
|
const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
|
|
65680
65830
|
const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
|
|
@@ -66059,10 +66209,12 @@ var require_mobile_generator = __commonJS({
|
|
|
66059
66209
|
"../runner-core/dist/services/mobile/mobile-generator.js"(exports) {
|
|
66060
66210
|
"use strict";
|
|
66061
66211
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
66212
|
+
exports.collapseRetryCycles = collapseRetryCycles;
|
|
66062
66213
|
exports.runMobileGeneratePhase = runMobileGeneratePhase;
|
|
66063
66214
|
var crypto_1 = __require("crypto");
|
|
66064
66215
|
var shared_1 = (init_dist(), __toCommonJS(dist_exports));
|
|
66065
66216
|
var ai_provider_js_1 = require_ai_provider();
|
|
66217
|
+
var credential_tools_js_1 = require_credential_tools();
|
|
66066
66218
|
var ai_queue_js_1 = require_ai_queue();
|
|
66067
66219
|
var ai_retry_js_1 = require_ai_retry();
|
|
66068
66220
|
var mobile_source_parser_js_1 = require_mobile_source_parser();
|
|
@@ -66184,6 +66336,24 @@ var require_mobile_generator = __commonJS({
|
|
|
66184
66336
|
}
|
|
66185
66337
|
}
|
|
66186
66338
|
};
|
|
66339
|
+
var MOBILE_FILL_CREDENTIAL_TOOL = {
|
|
66340
|
+
type: "function",
|
|
66341
|
+
function: {
|
|
66342
|
+
name: "mobile_fill_credential",
|
|
66343
|
+
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.',
|
|
66344
|
+
parameters: {
|
|
66345
|
+
type: "object",
|
|
66346
|
+
properties: {
|
|
66347
|
+
ref: { type: "string", description: 'Text-field element ref from the latest mobile_snapshot, e.g. "e5".' },
|
|
66348
|
+
credentialKey: {
|
|
66349
|
+
type: "string",
|
|
66350
|
+
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.'
|
|
66351
|
+
}
|
|
66352
|
+
},
|
|
66353
|
+
required: ["ref", "credentialKey"]
|
|
66354
|
+
}
|
|
66355
|
+
}
|
|
66356
|
+
};
|
|
66187
66357
|
var FINISH_GENERATE_TOOL = {
|
|
66188
66358
|
type: "function",
|
|
66189
66359
|
function: {
|
|
@@ -66205,6 +66375,7 @@ var require_mobile_generator = __commonJS({
|
|
|
66205
66375
|
MOBILE_TAP_TOOL,
|
|
66206
66376
|
MOBILE_TYPE_TOOL,
|
|
66207
66377
|
MOBILE_CLEAR_TOOL,
|
|
66378
|
+
MOBILE_FILL_CREDENTIAL_TOOL,
|
|
66208
66379
|
MOBILE_SWIPE_TOOL,
|
|
66209
66380
|
MOBILE_BACK_TOOL,
|
|
66210
66381
|
MOBILE_RELAUNCH_TOOL,
|
|
@@ -66283,6 +66454,24 @@ var require_mobile_generator = __commonJS({
|
|
|
66283
66454
|
- 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.
|
|
66284
66455
|
|
|
66285
66456
|
Max iterations: ${MAX_SCENARIO_ITERATIONS}. You will be warned near the end.`;
|
|
66457
|
+
}
|
|
66458
|
+
function buildGenerateCredentialPromptBlock(secrets) {
|
|
66459
|
+
const keys = Object.keys(secrets).filter((k) => secrets[k] && secrets[k].length > 0);
|
|
66460
|
+
if (keys.length === 0)
|
|
66461
|
+
return "";
|
|
66462
|
+
const keyList = keys.map((k) => `- \`${k}\``).join("\n");
|
|
66463
|
+
return `
|
|
66464
|
+
|
|
66465
|
+
## Test Credentials (values are HIDDEN from you)
|
|
66466
|
+
This app has stored login credentials under these keys:
|
|
66467
|
+
${keyList}
|
|
66468
|
+
|
|
66469
|
+
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.
|
|
66470
|
+
|
|
66471
|
+
SECURITY RULES:
|
|
66472
|
+
- 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.
|
|
66473
|
+
- 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.
|
|
66474
|
+
- NEVER put credential values in test names, descriptions, or assert texts.`;
|
|
66286
66475
|
}
|
|
66287
66476
|
function buildScenarioKickoff(scenario) {
|
|
66288
66477
|
const stepLines = scenario.steps.map((step, i) => ` ${i + 1}. ${step.action}${step.target ? ` (target: ${step.target})` : ""}${step.hint ? ` [hint: ${step.hint}]` : ""}`).join("\n");
|
|
@@ -66353,7 +66542,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
|
|
|
66353
66542
|
}
|
|
66354
66543
|
}
|
|
66355
66544
|
async function runScenarioLoop(args) {
|
|
66356
|
-
const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, log: log2 } = args;
|
|
66545
|
+
const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, log: log2 } = args;
|
|
66357
66546
|
const actions = [];
|
|
66358
66547
|
let assertCount = 0;
|
|
66359
66548
|
let finish = null;
|
|
@@ -66470,7 +66659,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
|
|
|
66470
66659
|
} catch (err) {
|
|
66471
66660
|
log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
66472
66661
|
}
|
|
66473
|
-
const systemPrompt = buildSystemPrompt(platform3, targetAppId);
|
|
66662
|
+
const systemPrompt = buildSystemPrompt(platform3, targetAppId) + buildGenerateCredentialPromptBlock(credentials);
|
|
66474
66663
|
const kickoff = buildScenarioKickoff(scenario);
|
|
66475
66664
|
const recentExchanges = [];
|
|
66476
66665
|
let iteration = 0;
|
|
@@ -66583,6 +66772,7 @@ ${statePacket}` },
|
|
|
66583
66772
|
latest: getLatest,
|
|
66584
66773
|
absorbObservation,
|
|
66585
66774
|
resolveRef,
|
|
66775
|
+
credentials,
|
|
66586
66776
|
screenName: () => lastScreenName || "UnknownScreen",
|
|
66587
66777
|
recordAction: (action) => {
|
|
66588
66778
|
actions.push(finalizeAction(action));
|
|
@@ -66604,7 +66794,7 @@ ${statePacket}` },
|
|
|
66604
66794
|
return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
|
|
66605
66795
|
}
|
|
66606
66796
|
async function dispatchGenerateTool(args) {
|
|
66607
|
-
const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, screenName, recordAction, log: log2 } = args;
|
|
66797
|
+
const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, credentials, screenName, recordAction, log: log2 } = args;
|
|
66608
66798
|
const unknownRef = (ref) => ({
|
|
66609
66799
|
ok: false,
|
|
66610
66800
|
error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
|
|
@@ -66670,6 +66860,14 @@ ${statePacket}` },
|
|
|
66670
66860
|
if (!element)
|
|
66671
66861
|
return unknownRef(toolArgs.ref);
|
|
66672
66862
|
const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
|
|
66863
|
+
for (const [key, secret] of Object.entries(credentials)) {
|
|
66864
|
+
if (secret && secret.length >= 4 && value.includes(secret)) {
|
|
66865
|
+
return {
|
|
66866
|
+
ok: false,
|
|
66867
|
+
error: `BLOCKED: mobile_type contained the stored credential value for "${key}". Use mobile_fill_credential({ ref, credentialKey: "${key}" }) instead of typing the credential directly.`
|
|
66868
|
+
};
|
|
66869
|
+
}
|
|
66870
|
+
}
|
|
66673
66871
|
const beforeScreenName = screenName();
|
|
66674
66872
|
const result = await driver.type(value, element.bounds);
|
|
66675
66873
|
const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_type");
|
|
@@ -66683,6 +66881,37 @@ ${statePacket}` },
|
|
|
66683
66881
|
}
|
|
66684
66882
|
return actionPayload(result, absorbed);
|
|
66685
66883
|
}
|
|
66884
|
+
// KEY-based credential fill: type the real value on-device, persist only a
|
|
66885
|
+
// {{KEY}} placeholder in the recorded step (replay resolves it against the
|
|
66886
|
+
// run's current test variables). The tool result echoes the KEY, never the
|
|
66887
|
+
// value, so the secret cannot enter the transcript.
|
|
66888
|
+
case "mobile_fill_credential": {
|
|
66889
|
+
const key = typeof toolArgs.credentialKey === "string" ? toolArgs.credentialKey.trim() : "";
|
|
66890
|
+
if (!key) {
|
|
66891
|
+
return { ok: false, error: 'mobile_fill_credential: missing required "credentialKey".' };
|
|
66892
|
+
}
|
|
66893
|
+
const secret = credentials[key];
|
|
66894
|
+
if (!secret) {
|
|
66895
|
+
const available = Object.keys(credentials).filter((k) => credentials[k]).join(", ") || "(none configured)";
|
|
66896
|
+
return { ok: false, error: `mobile_fill_credential: no credential configured for key "${key}". Available keys: ${available}` };
|
|
66897
|
+
}
|
|
66898
|
+
const element = resolveRef(toolArgs.ref);
|
|
66899
|
+
if (!element)
|
|
66900
|
+
return unknownRef(toolArgs.ref);
|
|
66901
|
+
const beforeScreenName = screenName();
|
|
66902
|
+
const result = await driver.type(secret, element.bounds);
|
|
66903
|
+
const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_fill_credential");
|
|
66904
|
+
if (shouldRecordAction(result, absorbed)) {
|
|
66905
|
+
recordAction({
|
|
66906
|
+
type: "TYPE",
|
|
66907
|
+
value: (0, shared_1.mobileCredentialPlaceholder)(key),
|
|
66908
|
+
selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
|
|
66909
|
+
metadata: metadataForElement(element, beforeScreenName, platform3)
|
|
66910
|
+
});
|
|
66911
|
+
log2(` fill_credential: recorded {{${key}}} into ${labelForElement(element)}`);
|
|
66912
|
+
}
|
|
66913
|
+
return { ...actionPayload(result, absorbed), filledCredentialKey: key };
|
|
66914
|
+
}
|
|
66686
66915
|
case "mobile_clear": {
|
|
66687
66916
|
const element = resolveRef(toolArgs.ref);
|
|
66688
66917
|
if (!element)
|
|
@@ -66831,18 +67060,40 @@ ${statePacket}` },
|
|
|
66831
67060
|
return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
|
|
66832
67061
|
}
|
|
66833
67062
|
var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
|
|
66834
|
-
var
|
|
66835
|
-
"
|
|
66836
|
-
"
|
|
66837
|
-
"
|
|
66838
|
-
"
|
|
66839
|
-
"
|
|
67063
|
+
var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
|
|
67064
|
+
"content",
|
|
67065
|
+
"decor",
|
|
67066
|
+
"decor_content_parent",
|
|
67067
|
+
"action_bar_root",
|
|
67068
|
+
"statusBarBackground",
|
|
67069
|
+
"navigationBarBackground",
|
|
67070
|
+
// WebView hybrid apps (React/Vue/etc. rendered inside android.webkit.WebView)
|
|
67071
|
+
// expose their framework ROOT container's DOM id as the element id — present on
|
|
67072
|
+
// EVERY in-app screen, so the floor must not anchor to it (observed live on
|
|
67073
|
+
// ThoughtStream: `main-content` produced a screen-agnostic assertion that then
|
|
67074
|
+
// never matched via the text-contains fallback).
|
|
67075
|
+
"main-content",
|
|
67076
|
+
"root",
|
|
67077
|
+
"app",
|
|
67078
|
+
"app-root",
|
|
67079
|
+
"__next",
|
|
67080
|
+
"react-root",
|
|
67081
|
+
"application"
|
|
66840
67082
|
]);
|
|
66841
67083
|
function isAlwaysPresentContainer(element) {
|
|
66842
67084
|
if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
|
|
66843
67085
|
return true;
|
|
66844
67086
|
const resourceId = element.resourceId?.trim();
|
|
66845
|
-
|
|
67087
|
+
if (resourceId) {
|
|
67088
|
+
const slashIdx = resourceId.indexOf("/");
|
|
67089
|
+
const localName = slashIdx >= 0 ? resourceId.slice(slashIdx + 1) : resourceId;
|
|
67090
|
+
if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName))
|
|
67091
|
+
return true;
|
|
67092
|
+
}
|
|
67093
|
+
const accessibilityId = element.accessibilityId?.trim();
|
|
67094
|
+
if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(accessibilityId))
|
|
67095
|
+
return true;
|
|
67096
|
+
return false;
|
|
66846
67097
|
}
|
|
66847
67098
|
function appendHybridFloor(actions, recording, platform3) {
|
|
66848
67099
|
const anchor = pickLandingAnchor(recording.lastSnapshot);
|
|
@@ -66856,9 +67107,38 @@ ${statePacket}` },
|
|
|
66856
67107
|
}));
|
|
66857
67108
|
return true;
|
|
66858
67109
|
}
|
|
67110
|
+
function actionCycleKey(action) {
|
|
67111
|
+
return `${action.type}|${action.selector}|${action.value ?? ""}`;
|
|
67112
|
+
}
|
|
67113
|
+
var MAX_RETRY_CYCLE_LENGTH = 8;
|
|
67114
|
+
function collapseRetryCycles(actions) {
|
|
67115
|
+
const out = [...actions];
|
|
67116
|
+
let changed = true;
|
|
67117
|
+
while (changed) {
|
|
67118
|
+
changed = false;
|
|
67119
|
+
const maxWindow = Math.min(MAX_RETRY_CYCLE_LENGTH, Math.floor(out.length / 2));
|
|
67120
|
+
for (let window2 = maxWindow; window2 >= 2 && !changed; window2--) {
|
|
67121
|
+
for (let i = 0; i + 2 * window2 <= out.length; i++) {
|
|
67122
|
+
const first = out.slice(i, i + window2);
|
|
67123
|
+
if (first.some((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT"))
|
|
67124
|
+
continue;
|
|
67125
|
+
const second = out.slice(i + window2, i + 2 * window2);
|
|
67126
|
+
if (!first.every((a, k) => actionCycleKey(a) === actionCycleKey(second[k])))
|
|
67127
|
+
continue;
|
|
67128
|
+
out.splice(i, window2);
|
|
67129
|
+
changed = true;
|
|
67130
|
+
break;
|
|
67131
|
+
}
|
|
67132
|
+
}
|
|
67133
|
+
}
|
|
67134
|
+
return out;
|
|
67135
|
+
}
|
|
66859
67136
|
function assembleTest(args) {
|
|
66860
67137
|
const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
|
|
66861
|
-
const actions =
|
|
67138
|
+
const actions = collapseRetryCycles(recording.actions);
|
|
67139
|
+
if (actions.length < recording.actions.length) {
|
|
67140
|
+
log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
|
|
67141
|
+
}
|
|
66862
67142
|
const floorAppended = appendHybridFloor(actions, recording, platform3);
|
|
66863
67143
|
if (floorAppended) {
|
|
66864
67144
|
log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
|
|
@@ -66885,9 +67165,9 @@ ${statePacket}` },
|
|
|
66885
67165
|
if (claimedVerified && !verified) {
|
|
66886
67166
|
log2(" downgraded verified -> false: model claimed verified but called zero assert tools.");
|
|
66887
67167
|
}
|
|
66888
|
-
const
|
|
67168
|
+
const platformTag2 = platform3 === "IOS" ? "@ios" : "@android";
|
|
66889
67169
|
const reviewTag = verified ? "@verified" : "@needs-review";
|
|
66890
|
-
const tags = ["@discovered", "@mobile",
|
|
67170
|
+
const tags = ["@discovered", "@mobile", platformTag2, reviewTag];
|
|
66891
67171
|
const test = {
|
|
66892
67172
|
name: compiled.name,
|
|
66893
67173
|
description: recording.finish?.description ?? compiled.description,
|
|
@@ -66897,6 +67177,12 @@ ${statePacket}` },
|
|
|
66897
67177
|
playwrightCode: "",
|
|
66898
67178
|
verified,
|
|
66899
67179
|
tags,
|
|
67180
|
+
// Per-test feature label → TestCase.suite. One deep run plans scenarios for
|
|
67181
|
+
// MULTIPLE feature groups (the planner reuses survey feature names), so the
|
|
67182
|
+
// run-level ctx.featureName fallback in /discovery-test would wrongly file
|
|
67183
|
+
// every test under the deep-dived feature. The scenario's own group is the
|
|
67184
|
+
// correct home; the runner forwards it as `suiteName`.
|
|
67185
|
+
...scenario.featureGroup?.trim() ? { suiteName: scenario.featureGroup.trim() } : {},
|
|
66900
67186
|
// Carry the planner scenario identity so incremental/resume can match an
|
|
66901
67187
|
// already-generated scenario reliably (scenarioRef.name === the plan's
|
|
66902
67188
|
// scenario name). Without this the resume skip-set falls back to the
|
|
@@ -66914,7 +67200,27 @@ ${statePacket}` },
|
|
|
66914
67200
|
startFromLanding: scenario.startFromLanding,
|
|
66915
67201
|
targetPages: [...scenario.targetPages]
|
|
66916
67202
|
},
|
|
66917
|
-
evidence: { observedLocators: [], observedTransitions: [] }
|
|
67203
|
+
evidence: { observedLocators: [], observedTransitions: [] },
|
|
67204
|
+
// Assertion-strength marker for the server promote-seam
|
|
67205
|
+
// (resolveStabilityTransition): an UNVERIFIED recording proves none of the
|
|
67206
|
+
// scenario's outcomes — only the hybrid floor's landing-screen visibility —
|
|
67207
|
+
// so a passing replay is vacuous. Hold it as DRAFT (@needs-review) for a
|
|
67208
|
+
// human instead of letting the pass auto-promote it into the ACTIVE suite.
|
|
67209
|
+
// Mirrors the recorder mobile path (server test-pipeline.ts @unverified).
|
|
67210
|
+
...verified ? {} : {
|
|
67211
|
+
gateActivity: {
|
|
67212
|
+
outcomeGate: {
|
|
67213
|
+
requiredCount: scenario.expectedOutcomes.length,
|
|
67214
|
+
uncoveredCount: scenario.expectedOutcomes.length
|
|
67215
|
+
},
|
|
67216
|
+
strengthGate: {
|
|
67217
|
+
fired: false,
|
|
67218
|
+
severity: "block",
|
|
67219
|
+
blocked: true,
|
|
67220
|
+
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."
|
|
67221
|
+
}
|
|
67222
|
+
}
|
|
67223
|
+
}
|
|
66918
67224
|
}
|
|
66919
67225
|
};
|
|
66920
67226
|
return test;
|
|
@@ -66935,6 +67241,7 @@ ${statePacket}` },
|
|
|
66935
67241
|
const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
|
|
66936
67242
|
const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
|
|
66937
67243
|
const targetAppId = ctx.target?.appId?.trim() || void 0;
|
|
67244
|
+
const { secrets: credentialSecrets } = (0, credential_tools_js_1.partitionTestVariables)(ctx.variableContext?.allTestVariables ?? ctx.variableContext?.publicTestVariables);
|
|
66938
67245
|
const skipSet = new Set(skipScenarioNames ?? []);
|
|
66939
67246
|
const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
|
|
66940
67247
|
if (skipSet.size > 0) {
|
|
@@ -66963,6 +67270,7 @@ ${statePacket}` },
|
|
|
66963
67270
|
onAICall,
|
|
66964
67271
|
onScreenshot,
|
|
66965
67272
|
targetAppId,
|
|
67273
|
+
credentials: credentialSecrets,
|
|
66966
67274
|
log: log2
|
|
66967
67275
|
});
|
|
66968
67276
|
consecutiveInfraFailures = 0;
|
|
@@ -313585,9 +313893,12 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
|
|
|
313585
313893
|
});
|
|
313586
313894
|
let featureInsight;
|
|
313587
313895
|
if (mode === "deep" && ctx.featureName) {
|
|
313896
|
+
const targetName = ctx.featureName.trim().toLowerCase();
|
|
313897
|
+
const plannedGroup = (featureGroups ?? []).find((group) => group.name.trim().toLowerCase() === targetName);
|
|
313898
|
+
const plannedDescription = plannedGroup?.description?.trim();
|
|
313588
313899
|
featureInsight = {
|
|
313589
313900
|
featureName: ctx.featureName,
|
|
313590
|
-
description: `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
|
|
313901
|
+
description: plannedDescription && plannedDescription.length > 0 ? plannedDescription : `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
|
|
313591
313902
|
};
|
|
313592
313903
|
}
|
|
313593
313904
|
const duration = (Date.now() - startTime) / 1e3;
|
|
@@ -328123,7 +328434,7 @@ var require_package4 = __commonJS({
|
|
|
328123
328434
|
"package.json"(exports, module) {
|
|
328124
328435
|
module.exports = {
|
|
328125
328436
|
name: "@validate.qa/runner",
|
|
328126
|
-
version: "1.0.
|
|
328437
|
+
version: "1.0.18",
|
|
328127
328438
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
328128
328439
|
bin: {
|
|
328129
328440
|
"validate-runner": "dist/cli.js"
|
|
@@ -329667,6 +329978,7 @@ async function startMobileNetworkCapture(options) {
|
|
|
329667
329978
|
|
|
329668
329979
|
// src/services/appium-executor.ts
|
|
329669
329980
|
var DEFAULT_STEP_TIMEOUT_MS = 1e4;
|
|
329981
|
+
var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
|
|
329670
329982
|
var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
|
|
329671
329983
|
var WD_ACTION_TIMEOUT_MS = 3e4;
|
|
329672
329984
|
var MAX_SESSION_CREATE_ATTEMPTS = 3;
|
|
@@ -329995,6 +330307,11 @@ function boundsFromStep(step) {
|
|
|
329995
330307
|
}
|
|
329996
330308
|
return null;
|
|
329997
330309
|
}
|
|
330310
|
+
function boundsForRun(step, platform3, isCrossPlatform) {
|
|
330311
|
+
if (isCrossPlatform) return null;
|
|
330312
|
+
if (platform3 && hasPlatformLocator(step.target, platform3)) return null;
|
|
330313
|
+
return boundsFromStep(step);
|
|
330314
|
+
}
|
|
329998
330315
|
function locatorStrategyForValue(value) {
|
|
329999
330316
|
const trimmed = value?.trim();
|
|
330000
330317
|
if (!trimmed) {
|
|
@@ -330014,19 +330331,21 @@ function locatorStrategyForValue(value) {
|
|
|
330014
330331
|
{ using: "id", value: trimmed }
|
|
330015
330332
|
];
|
|
330016
330333
|
}
|
|
330017
|
-
function buildLocatorStrategies(target) {
|
|
330334
|
+
function buildLocatorStrategies(target, platform3) {
|
|
330018
330335
|
if (!target) return [];
|
|
330019
330336
|
const strategies = [];
|
|
330020
|
-
const bundle = target
|
|
330337
|
+
const bundle = resolvePlatformLocatorBundle(target, platform3);
|
|
330021
330338
|
if (bundle) {
|
|
330022
330339
|
if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
|
|
330023
330340
|
if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
|
|
330024
330341
|
if (bundle.xpath) strategies.push({ using: "xpath", value: bundle.xpath });
|
|
330025
330342
|
if (bundle.className) strategies.push({ using: "class name", value: bundle.className });
|
|
330026
330343
|
}
|
|
330027
|
-
|
|
330028
|
-
|
|
330029
|
-
|
|
330344
|
+
if (!(platform3 && hasPlatformLocator(target, platform3))) {
|
|
330345
|
+
strategies.push(...locatorStrategyForValue(target.primary));
|
|
330346
|
+
for (const fallback of target.fallbacks ?? []) {
|
|
330347
|
+
strategies.push(...locatorStrategyForValue(fallback));
|
|
330348
|
+
}
|
|
330030
330349
|
}
|
|
330031
330350
|
return strategies.filter(
|
|
330032
330351
|
(strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
|
|
@@ -330052,10 +330371,10 @@ function isIdentityStrategy(strategy) {
|
|
|
330052
330371
|
if (strategy.using === "xpath") return strategy.value.includes("@");
|
|
330053
330372
|
return false;
|
|
330054
330373
|
}
|
|
330055
|
-
function buildIdentityLocatorStrategies(target) {
|
|
330374
|
+
function buildIdentityLocatorStrategies(target, platform3) {
|
|
330056
330375
|
if (!target) return [];
|
|
330057
330376
|
const strategies = [];
|
|
330058
|
-
const bundle = target
|
|
330377
|
+
const bundle = resolvePlatformLocatorBundle(target, platform3);
|
|
330059
330378
|
if (bundle) {
|
|
330060
330379
|
if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
|
|
330061
330380
|
if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
|
|
@@ -330065,9 +330384,11 @@ function buildIdentityLocatorStrategies(target) {
|
|
|
330065
330384
|
}
|
|
330066
330385
|
if (bundle.xpath && bundle.xpath.includes("@")) strategies.push({ using: "xpath", value: bundle.xpath });
|
|
330067
330386
|
}
|
|
330068
|
-
|
|
330069
|
-
for (const
|
|
330070
|
-
|
|
330387
|
+
if (!(platform3 && hasPlatformLocator(target, platform3))) {
|
|
330388
|
+
for (const value of [target.primary, ...target.fallbacks ?? []]) {
|
|
330389
|
+
for (const strategy of locatorStrategyForValue(value)) {
|
|
330390
|
+
if (isIdentityStrategy(strategy)) strategies.push(strategy);
|
|
330391
|
+
}
|
|
330071
330392
|
}
|
|
330072
330393
|
}
|
|
330073
330394
|
return dedupeStrategies(strategies);
|
|
@@ -330080,7 +330401,7 @@ function readStepTimeout(step) {
|
|
|
330080
330401
|
return void 0;
|
|
330081
330402
|
}
|
|
330082
330403
|
async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
|
|
330083
|
-
const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target) : buildLocatorStrategies(target);
|
|
330404
|
+
const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
|
|
330084
330405
|
if (strategies.length === 0) {
|
|
330085
330406
|
throw new Error(
|
|
330086
330407
|
opts?.identityOnly ? "No identity locator (accessibility id / resource id / text) available for this step \u2014 cannot verify the screen" : "No locator strategies available for this step"
|
|
@@ -330182,18 +330503,18 @@ function shouldAbortAfterStepFailure(action) {
|
|
|
330182
330503
|
return false;
|
|
330183
330504
|
}
|
|
330184
330505
|
}
|
|
330185
|
-
async function executeTap(sessionId, step) {
|
|
330186
|
-
const bounds =
|
|
330506
|
+
async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
|
|
330507
|
+
const bounds = boundsForRun(step, platform3, isCrossPlatform);
|
|
330187
330508
|
if (step.target) {
|
|
330188
330509
|
try {
|
|
330189
|
-
const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
|
|
330510
|
+
const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
|
|
330190
330511
|
await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
|
|
330191
330512
|
method: "POST",
|
|
330192
330513
|
body: "{}"
|
|
330193
330514
|
});
|
|
330194
330515
|
return;
|
|
330195
330516
|
} catch (error2) {
|
|
330196
|
-
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
|
|
330517
|
+
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
|
|
330197
330518
|
}
|
|
330198
330519
|
}
|
|
330199
330520
|
if (!bounds) {
|
|
@@ -330204,7 +330525,7 @@ async function executeTap(sessionId, step) {
|
|
|
330204
330525
|
async function executeType(sessionId, step, context) {
|
|
330205
330526
|
if (step.value === void 0 || step.value === null || step.value === "") return;
|
|
330206
330527
|
const value = String(step.value);
|
|
330207
|
-
const bounds =
|
|
330528
|
+
const bounds = boundsForRun(step, context.platform, context.isCrossPlatform ?? false);
|
|
330208
330529
|
let elementId = null;
|
|
330209
330530
|
let lastInputError = null;
|
|
330210
330531
|
let tappedBounds = false;
|
|
@@ -330221,12 +330542,12 @@ async function executeType(sessionId, step, context) {
|
|
|
330221
330542
|
}
|
|
330222
330543
|
if (step.target) {
|
|
330223
330544
|
try {
|
|
330224
|
-
elementId = await findElement(sessionId, step.target, readStepTimeout(step));
|
|
330545
|
+
elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: context.platform });
|
|
330225
330546
|
lastInputError = await trySendElementValue(sessionId, elementId, value);
|
|
330226
330547
|
if (!lastInputError) return;
|
|
330227
330548
|
elementId = null;
|
|
330228
330549
|
} catch (error2) {
|
|
330229
|
-
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
|
|
330550
|
+
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, context.platform).length > 0) throw error2;
|
|
330230
330551
|
lastInputError = error2;
|
|
330231
330552
|
}
|
|
330232
330553
|
}
|
|
@@ -330274,14 +330595,14 @@ async function executeType(sessionId, step, context) {
|
|
|
330274
330595
|
}
|
|
330275
330596
|
throw new Error(`TYPE step could not send text to the focused target${lastInputError instanceof Error ? `: ${lastInputError.message}` : ""}`);
|
|
330276
330597
|
}
|
|
330277
|
-
async function executeClear(sessionId, step) {
|
|
330278
|
-
const bounds =
|
|
330598
|
+
async function executeClear(sessionId, step, platform3, isCrossPlatform = false) {
|
|
330599
|
+
const bounds = boundsForRun(step, platform3, isCrossPlatform);
|
|
330279
330600
|
let elementId = null;
|
|
330280
330601
|
if (step.target) {
|
|
330281
330602
|
try {
|
|
330282
|
-
elementId = await findElement(sessionId, step.target, readStepTimeout(step));
|
|
330603
|
+
elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
|
|
330283
330604
|
} catch (error2) {
|
|
330284
|
-
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
|
|
330605
|
+
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
|
|
330285
330606
|
}
|
|
330286
330607
|
}
|
|
330287
330608
|
if (!elementId && bounds) {
|
|
@@ -330350,20 +330671,20 @@ async function executeHome(sessionId, platform3) {
|
|
|
330350
330671
|
body: JSON.stringify({ keycode: 3 })
|
|
330351
330672
|
});
|
|
330352
330673
|
}
|
|
330353
|
-
async function executeWaitForElement(sessionId, step) {
|
|
330674
|
+
async function executeWaitForElement(sessionId, step, platform3) {
|
|
330354
330675
|
const timeout = readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS;
|
|
330355
|
-
await findElement(sessionId, step.target, timeout, { identityOnly: true });
|
|
330676
|
+
await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3 });
|
|
330356
330677
|
}
|
|
330357
|
-
async function executeAssertVisible(sessionId, step) {
|
|
330358
|
-
const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
|
|
330678
|
+
async function executeAssertVisible(sessionId, step, platform3) {
|
|
330679
|
+
const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
|
|
330359
330680
|
const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
|
|
330360
330681
|
if (!result?.value) {
|
|
330361
330682
|
throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
|
|
330362
330683
|
}
|
|
330363
330684
|
}
|
|
330364
|
-
async function executeAssertText(sessionId, step) {
|
|
330685
|
+
async function executeAssertText(sessionId, step, platform3) {
|
|
330365
330686
|
if (!step.assertion) throw new Error("assertText step requires an assertion value");
|
|
330366
|
-
const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
|
|
330687
|
+
const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
|
|
330367
330688
|
const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
|
|
330368
330689
|
const actualText = typeof result?.value === "string" ? result.value : "";
|
|
330369
330690
|
const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
|
|
@@ -330394,6 +330715,10 @@ async function executeMobileTest(options) {
|
|
|
330394
330715
|
const logLines = [];
|
|
330395
330716
|
const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
|
|
330396
330717
|
const startTime = Date.now();
|
|
330718
|
+
const { steps: runSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
|
|
330719
|
+
if (missingVariableKeys.length > 0) {
|
|
330720
|
+
log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured for this project/run.`);
|
|
330721
|
+
}
|
|
330397
330722
|
const stepResults = [];
|
|
330398
330723
|
const screenshots = [];
|
|
330399
330724
|
let apiCalls = [];
|
|
@@ -330529,12 +330854,14 @@ async function executeMobileTest(options) {
|
|
|
330529
330854
|
}
|
|
330530
330855
|
await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
|
|
330531
330856
|
let entryDrift = false;
|
|
330532
|
-
const
|
|
330533
|
-
|
|
330857
|
+
const runPlatform = options.target.platform;
|
|
330858
|
+
const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
|
|
330859
|
+
const entryStep = runSteps.find(
|
|
330860
|
+
(s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
|
|
330534
330861
|
);
|
|
330535
330862
|
if (entryStep) {
|
|
330536
330863
|
try {
|
|
330537
|
-
await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
|
|
330864
|
+
await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true, platform: runPlatform });
|
|
330538
330865
|
} catch (err) {
|
|
330539
330866
|
if (isTransportError(err)) throw err;
|
|
330540
330867
|
entryDrift = true;
|
|
@@ -330546,8 +330873,14 @@ async function executeMobileTest(options) {
|
|
|
330546
330873
|
}
|
|
330547
330874
|
}
|
|
330548
330875
|
let environmental = null;
|
|
330549
|
-
for (const step of
|
|
330876
|
+
for (const step of runSteps) {
|
|
330550
330877
|
if (entryDrift) break;
|
|
330878
|
+
if (!stepAppliesToPlatform(step, runPlatform)) {
|
|
330879
|
+
log2(`Step ${step.order}: ${step.action} \u2014 skipped (not applicable to ${runPlatform})`);
|
|
330880
|
+
stepResults.push({ stepOrder: step.order, status: "skipped", duration: 0 });
|
|
330881
|
+
screenshots.push("");
|
|
330882
|
+
continue;
|
|
330883
|
+
}
|
|
330551
330884
|
const stepStart = Date.now();
|
|
330552
330885
|
log2(`Step ${step.order}: ${step.action} - ${step.description}`);
|
|
330553
330886
|
let status = "passed";
|
|
@@ -330559,17 +330892,18 @@ async function executeMobileTest(options) {
|
|
|
330559
330892
|
log2(" App launched (handled by session creation)");
|
|
330560
330893
|
break;
|
|
330561
330894
|
case "tap":
|
|
330562
|
-
await executeTap(sessionId, step);
|
|
330895
|
+
await executeTap(sessionId, step, runPlatform, isCrossPlatform);
|
|
330563
330896
|
break;
|
|
330564
330897
|
case "type":
|
|
330565
330898
|
await executeType(sessionId, step, {
|
|
330566
|
-
platform:
|
|
330899
|
+
platform: runPlatform,
|
|
330567
330900
|
deviceId: selectedDevice.id,
|
|
330568
|
-
log: log2
|
|
330901
|
+
log: log2,
|
|
330902
|
+
isCrossPlatform
|
|
330569
330903
|
});
|
|
330570
330904
|
break;
|
|
330571
330905
|
case "clear":
|
|
330572
|
-
await executeClear(sessionId, step);
|
|
330906
|
+
await executeClear(sessionId, step, runPlatform, isCrossPlatform);
|
|
330573
330907
|
break;
|
|
330574
330908
|
case "swipe":
|
|
330575
330909
|
case "scroll":
|
|
@@ -330579,16 +330913,16 @@ async function executeMobileTest(options) {
|
|
|
330579
330913
|
await executeBack(sessionId);
|
|
330580
330914
|
break;
|
|
330581
330915
|
case "home":
|
|
330582
|
-
await executeHome(sessionId,
|
|
330916
|
+
await executeHome(sessionId, runPlatform);
|
|
330583
330917
|
break;
|
|
330584
330918
|
case "waitForElement":
|
|
330585
|
-
await executeWaitForElement(sessionId, step);
|
|
330919
|
+
await executeWaitForElement(sessionId, step, runPlatform);
|
|
330586
330920
|
break;
|
|
330587
330921
|
case "assertVisible":
|
|
330588
|
-
await executeAssertVisible(sessionId, step);
|
|
330922
|
+
await executeAssertVisible(sessionId, step, runPlatform);
|
|
330589
330923
|
break;
|
|
330590
330924
|
case "assertText":
|
|
330591
|
-
await executeAssertText(sessionId, step);
|
|
330925
|
+
await executeAssertText(sessionId, step, runPlatform);
|
|
330592
330926
|
break;
|
|
330593
330927
|
default: {
|
|
330594
330928
|
const _exhaustive = step.action;
|
|
@@ -330625,8 +330959,14 @@ async function executeMobileTest(options) {
|
|
|
330625
330959
|
break;
|
|
330626
330960
|
}
|
|
330627
330961
|
}
|
|
330628
|
-
const
|
|
330629
|
-
const
|
|
330962
|
+
const executedResults = stepResults.filter((r) => r.status !== "skipped");
|
|
330963
|
+
const verifyOrders = new Set(
|
|
330964
|
+
runSteps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
|
|
330965
|
+
);
|
|
330966
|
+
const anyVerificationPassed = stepResults.some((r) => r.status === "passed" && verifyOrders.has(r.stepOrder));
|
|
330967
|
+
const crossPlatformUnverified = isCrossPlatform && verifyOrders.size > 0 && !anyVerificationPassed;
|
|
330968
|
+
const allPassed = !environmental && executedResults.length > 0 && !crossPlatformUnverified && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
|
|
330969
|
+
const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null) ?? (crossPlatformUnverified ? `Cross-platform run on ${runPlatform} verified no outcome \u2014 every assertion/wait step was skipped or unresolved. Author a ${runPlatform} assertion or resolve the divergence.` : null);
|
|
330630
330970
|
await stopCapture();
|
|
330631
330971
|
await stopRecording();
|
|
330632
330972
|
return {
|
|
@@ -330760,6 +331100,188 @@ async function createMobileDiscoverySession(target, options) {
|
|
|
330760
331100
|
}
|
|
330761
331101
|
};
|
|
330762
331102
|
}
|
|
331103
|
+
async function readElementAttribute(sessionId, elementId, name) {
|
|
331104
|
+
try {
|
|
331105
|
+
const result = await wdRequest(`/session/${sessionId}/element/${elementId}/attribute/${encodeURIComponent(name)}`);
|
|
331106
|
+
const value = result?.value;
|
|
331107
|
+
if (typeof value !== "string") return void 0;
|
|
331108
|
+
const trimmed = value.trim();
|
|
331109
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
331110
|
+
} catch (err) {
|
|
331111
|
+
if (isTransportError(err)) throw err;
|
|
331112
|
+
return void 0;
|
|
331113
|
+
}
|
|
331114
|
+
}
|
|
331115
|
+
function harvestFallbackXpath(platform3, attrs) {
|
|
331116
|
+
if (platform3 === "IOS") {
|
|
331117
|
+
if (attrs.name) return `//*[@name=${xpathLiteral(attrs.name)}]`;
|
|
331118
|
+
const text = attrs.label ?? attrs.value;
|
|
331119
|
+
if (text) return identityTextXpath(text);
|
|
331120
|
+
return "//*";
|
|
331121
|
+
}
|
|
331122
|
+
if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral(attrs.resourceId)}]`;
|
|
331123
|
+
if (attrs.text) return identityTextXpath(attrs.text);
|
|
331124
|
+
if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral(attrs.contentDesc)}]`;
|
|
331125
|
+
return "//*";
|
|
331126
|
+
}
|
|
331127
|
+
async function readNativeElementBundle(sessionId, elementId, platform3) {
|
|
331128
|
+
const className = await readElementAttribute(sessionId, elementId, platform3 === "IOS" ? "type" : "class") ?? "";
|
|
331129
|
+
if (platform3 === "IOS") {
|
|
331130
|
+
const name = await readElementAttribute(sessionId, elementId, "name");
|
|
331131
|
+
const label = await readElementAttribute(sessionId, elementId, "label");
|
|
331132
|
+
const value = await readElementAttribute(sessionId, elementId, "value");
|
|
331133
|
+
const text2 = label ?? value;
|
|
331134
|
+
if (!name && !text2) return null;
|
|
331135
|
+
const bundle2 = {
|
|
331136
|
+
xpath: harvestFallbackXpath("IOS", { name, label, value }),
|
|
331137
|
+
className
|
|
331138
|
+
};
|
|
331139
|
+
if (name) bundle2.accessibilityId = name;
|
|
331140
|
+
if (text2) bundle2.text = text2;
|
|
331141
|
+
return bundle2;
|
|
331142
|
+
}
|
|
331143
|
+
const resourceId = await readElementAttribute(sessionId, elementId, "resource-id");
|
|
331144
|
+
const contentDesc = await readElementAttribute(sessionId, elementId, "content-desc") ?? await readElementAttribute(sessionId, elementId, "contentDescription");
|
|
331145
|
+
const text = await readElementAttribute(sessionId, elementId, "text");
|
|
331146
|
+
if (!resourceId && !contentDesc && !text) return null;
|
|
331147
|
+
const bundle = {
|
|
331148
|
+
xpath: harvestFallbackXpath("ANDROID", { text, contentDesc, resourceId }),
|
|
331149
|
+
className
|
|
331150
|
+
};
|
|
331151
|
+
if (contentDesc) bundle.accessibilityId = contentDesc;
|
|
331152
|
+
if (resourceId) bundle.resourceId = resourceId;
|
|
331153
|
+
if (text) bundle.text = text;
|
|
331154
|
+
if (contentDesc) bundle.contentDesc = contentDesc;
|
|
331155
|
+
return bundle;
|
|
331156
|
+
}
|
|
331157
|
+
function isStateAdvancingAction(action) {
|
|
331158
|
+
return action === "tap" || action === "type" || action === "clear" || action === "swipe" || action === "scroll" || action === "back" || action === "home";
|
|
331159
|
+
}
|
|
331160
|
+
function stepNeedsLocator(action) {
|
|
331161
|
+
return action === "tap" || action === "type" || action === "clear" || action === "waitForElement" || action === "assertVisible" || action === "assertText";
|
|
331162
|
+
}
|
|
331163
|
+
async function driveHarvestStep(sessionId, step, elementId, platform3) {
|
|
331164
|
+
switch (step.action) {
|
|
331165
|
+
case "tap":
|
|
331166
|
+
if (!elementId) return false;
|
|
331167
|
+
await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
|
|
331168
|
+
return true;
|
|
331169
|
+
case "type": {
|
|
331170
|
+
if (step.value === void 0 || step.value === null || step.value === "") return true;
|
|
331171
|
+
if (!elementId) return false;
|
|
331172
|
+
await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
|
|
331173
|
+
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
331174
|
+
const err = await trySendElementValue(sessionId, elementId, String(step.value));
|
|
331175
|
+
return !err;
|
|
331176
|
+
}
|
|
331177
|
+
case "clear":
|
|
331178
|
+
if (!elementId) return false;
|
|
331179
|
+
await wdRequest(`/session/${sessionId}/element/${elementId}/clear`, { method: "POST", body: "{}" });
|
|
331180
|
+
return true;
|
|
331181
|
+
case "swipe":
|
|
331182
|
+
case "scroll":
|
|
331183
|
+
await executeSwipe(sessionId, step);
|
|
331184
|
+
return true;
|
|
331185
|
+
case "back":
|
|
331186
|
+
await executeBack(sessionId);
|
|
331187
|
+
return true;
|
|
331188
|
+
case "home":
|
|
331189
|
+
await executeHome(sessionId, platform3);
|
|
331190
|
+
return true;
|
|
331191
|
+
case "launchApp":
|
|
331192
|
+
case "waitForElement":
|
|
331193
|
+
case "assertVisible":
|
|
331194
|
+
case "assertText":
|
|
331195
|
+
return true;
|
|
331196
|
+
}
|
|
331197
|
+
}
|
|
331198
|
+
async function harvestPlatformLocators(options) {
|
|
331199
|
+
const platform3 = options.target.platform;
|
|
331200
|
+
const logLines = [];
|
|
331201
|
+
const log2 = (msg) => {
|
|
331202
|
+
logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
|
|
331203
|
+
};
|
|
331204
|
+
const { steps: harvestSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
|
|
331205
|
+
if (missingVariableKeys.length > 0) {
|
|
331206
|
+
log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured.`);
|
|
331207
|
+
}
|
|
331208
|
+
const bundles = {};
|
|
331209
|
+
const divergentOrders = [];
|
|
331210
|
+
let reachedOrder = null;
|
|
331211
|
+
let environmentalError;
|
|
331212
|
+
log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${harvestSteps.length} steps`);
|
|
331213
|
+
const session = await createMobileDiscoverySession({
|
|
331214
|
+
appId: options.target.appId,
|
|
331215
|
+
platform: platform3,
|
|
331216
|
+
platformVersion: options.target.platformVersion,
|
|
331217
|
+
recordedDeviceId: options.target.recordedDeviceId,
|
|
331218
|
+
assignedDeviceId: options.target.assignedDeviceId,
|
|
331219
|
+
launchMode: "LAUNCH_APP"
|
|
331220
|
+
});
|
|
331221
|
+
options.onSessionReady?.(session.sessionId, session.deviceId, platform3);
|
|
331222
|
+
try {
|
|
331223
|
+
await freshLaunchTargetApp(session.sessionId, platform3, options.target.appId, log2);
|
|
331224
|
+
if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
|
|
331225
|
+
try {
|
|
331226
|
+
await openDeepLink(session.sessionId, platform3, options.target.deeplink, options.target.appId);
|
|
331227
|
+
} catch (err) {
|
|
331228
|
+
log2(` Deep link failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
331229
|
+
}
|
|
331230
|
+
}
|
|
331231
|
+
await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
|
|
331232
|
+
for (const step of harvestSteps) {
|
|
331233
|
+
if (step.action === "launchApp") continue;
|
|
331234
|
+
if (!stepAppliesToPlatform(step, platform3)) continue;
|
|
331235
|
+
let elementId = null;
|
|
331236
|
+
const identityStrategies = buildIdentityLocatorStrategies(step.target);
|
|
331237
|
+
if (identityStrategies.length > 0 && step.target) {
|
|
331238
|
+
try {
|
|
331239
|
+
elementId = await findElement(session.sessionId, step.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
|
|
331240
|
+
} catch (err) {
|
|
331241
|
+
if (isTransportError(err)) throw err;
|
|
331242
|
+
elementId = null;
|
|
331243
|
+
}
|
|
331244
|
+
}
|
|
331245
|
+
if (elementId) {
|
|
331246
|
+
const bundle = await readNativeElementBundle(session.sessionId, elementId, platform3);
|
|
331247
|
+
if (bundle) {
|
|
331248
|
+
bundles[step.order] = bundle;
|
|
331249
|
+
log2(` \u2713 Step ${step.order} (${step.action}) resolved on ${platform3}`);
|
|
331250
|
+
} else {
|
|
331251
|
+
divergentOrders.push(step.order);
|
|
331252
|
+
log2(` \u26A0 Step ${step.order} (${step.action}) resolved but has no durable identity \u2014 flagged divergent`);
|
|
331253
|
+
}
|
|
331254
|
+
} else if (stepNeedsLocator(step.action)) {
|
|
331255
|
+
divergentOrders.push(step.order);
|
|
331256
|
+
log2(` \u26A0 Step ${step.order} (${step.action}) could not be resolved on ${platform3} \u2014 needs manual authoring`);
|
|
331257
|
+
}
|
|
331258
|
+
const advanced = await driveHarvestStep(session.sessionId, step, elementId, platform3);
|
|
331259
|
+
if (!advanced && isStateAdvancingAction(step.action)) {
|
|
331260
|
+
log2(` \u2717 Could not advance step ${step.order} (${step.action}) on ${platform3}; stopping harvest early`);
|
|
331261
|
+
break;
|
|
331262
|
+
}
|
|
331263
|
+
reachedOrder = step.order;
|
|
331264
|
+
await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, `harvest step ${step.order}`, false);
|
|
331265
|
+
}
|
|
331266
|
+
} catch (err) {
|
|
331267
|
+
environmentalError = err instanceof Error ? err.message : String(err);
|
|
331268
|
+
log2(` \u2717 Harvest aborted: ${environmentalError}`);
|
|
331269
|
+
} finally {
|
|
331270
|
+
options.onSessionEnding?.(session.deviceId);
|
|
331271
|
+
await session.stop().catch(() => {
|
|
331272
|
+
});
|
|
331273
|
+
}
|
|
331274
|
+
return {
|
|
331275
|
+
platform: platform3,
|
|
331276
|
+
bundles,
|
|
331277
|
+
divergentOrders,
|
|
331278
|
+
reachedOrder,
|
|
331279
|
+
deviceId: session.deviceId,
|
|
331280
|
+
deviceName: session.deviceName,
|
|
331281
|
+
logs: logLines.join("\n"),
|
|
331282
|
+
...environmentalError ? { environmentalError } : {}
|
|
331283
|
+
};
|
|
331284
|
+
}
|
|
330763
331285
|
|
|
330764
331286
|
// src/mobile-device-queue.ts
|
|
330765
331287
|
function isNativeMobileDiscoveryContext(ctx) {
|
|
@@ -330951,12 +331473,46 @@ async function tapAt(sessionPath, bounds) {
|
|
|
330951
331473
|
throw new Error("TAP requires usable bounds (finite x/y)");
|
|
330952
331474
|
}
|
|
330953
331475
|
}
|
|
330954
|
-
async function
|
|
331476
|
+
async function getElementRect(sessionPath, elementId) {
|
|
331477
|
+
try {
|
|
331478
|
+
const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
|
|
331479
|
+
if (!value || typeof value !== "object") return null;
|
|
331480
|
+
const r = value;
|
|
331481
|
+
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)) {
|
|
331482
|
+
return null;
|
|
331483
|
+
}
|
|
331484
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
331485
|
+
} catch {
|
|
331486
|
+
return null;
|
|
331487
|
+
}
|
|
331488
|
+
}
|
|
331489
|
+
function rectMatchesIntendedBounds(rect, bounds) {
|
|
331490
|
+
if (bounds.width <= 0 || bounds.height <= 0) return false;
|
|
331491
|
+
const rectCenterX = rect.x + rect.width / 2;
|
|
331492
|
+
const rectCenterY = rect.y + rect.height / 2;
|
|
331493
|
+
const boundsCenterX = bounds.x + bounds.width / 2;
|
|
331494
|
+
const boundsCenterY = bounds.y + bounds.height / 2;
|
|
331495
|
+
const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
|
|
331496
|
+
const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
|
|
331497
|
+
return rectCenterInBounds || boundsCenterInRect;
|
|
331498
|
+
}
|
|
331499
|
+
async function resolveTypeTargetElementId(sessionPath, bounds) {
|
|
331500
|
+
const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
|
|
330955
331501
|
let elementId = await getActiveElementId2(sessionPath);
|
|
331502
|
+
if (elementId && box && box.width > 0 && box.height > 0) {
|
|
331503
|
+
const rect = await getElementRect(sessionPath, elementId);
|
|
331504
|
+
if (!rect || !rectMatchesIntendedBounds(rect, box)) {
|
|
331505
|
+
elementId = null;
|
|
331506
|
+
}
|
|
331507
|
+
}
|
|
330956
331508
|
if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
|
|
330957
331509
|
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
330958
331510
|
elementId = await getActiveElementId2(sessionPath);
|
|
330959
331511
|
}
|
|
331512
|
+
return elementId;
|
|
331513
|
+
}
|
|
331514
|
+
async function typeText(sessionPath, value, bounds) {
|
|
331515
|
+
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
330960
331516
|
if (!elementId) {
|
|
330961
331517
|
throw new Error("No focused element available for TYPE. Tap an input field first.");
|
|
330962
331518
|
}
|
|
@@ -330966,11 +331522,7 @@ async function typeText(sessionPath, value, bounds) {
|
|
|
330966
331522
|
});
|
|
330967
331523
|
}
|
|
330968
331524
|
async function clearField(sessionPath, bounds) {
|
|
330969
|
-
|
|
330970
|
-
if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
|
|
330971
|
-
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
330972
|
-
elementId = await getActiveElementId2(sessionPath);
|
|
330973
|
-
}
|
|
331525
|
+
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
330974
331526
|
if (!elementId) {
|
|
330975
331527
|
throw new Error("No focused element available for CLEAR. Tap an input field first.");
|
|
330976
331528
|
}
|
|
@@ -332720,6 +333272,7 @@ function scrubAuditMessages(messages, testVariables) {
|
|
|
332720
333272
|
const secretValues = Object.values(testVariables).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
|
|
332721
333273
|
if (secretValues.length === 0) return compacted;
|
|
332722
333274
|
let json = JSON.stringify(compacted);
|
|
333275
|
+
if (typeof json !== "string") return compacted;
|
|
332723
333276
|
for (const val of secretValues) {
|
|
332724
333277
|
const escaped = val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
332725
333278
|
json = json.replace(new RegExp(escaped, "g"), "[REDACTED]");
|
|
@@ -334017,6 +334570,104 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334017
334570
|
log.fail("ERROR - No discovery context");
|
|
334018
334571
|
return;
|
|
334019
334572
|
}
|
|
334573
|
+
const locatorCtx = ctx;
|
|
334574
|
+
if (locatorCtx.mode === "locator-discovery") {
|
|
334575
|
+
const harvestTarget = locatorCtx.target;
|
|
334576
|
+
const targetTestCaseId = locatorCtx.targetTestCaseId;
|
|
334577
|
+
const platform3 = locatorCtx.platform ?? harvestTarget?.platform;
|
|
334578
|
+
if (!platform3 || !harvestTarget?.appId || !targetTestCaseId) {
|
|
334579
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
334580
|
+
status: "ERROR",
|
|
334581
|
+
error: "CONFIG_ISSUE: locator-discovery run is missing platform/appId/targetTestCaseId."
|
|
334582
|
+
});
|
|
334583
|
+
log.fail("ERROR - malformed locator-discovery context");
|
|
334584
|
+
return;
|
|
334585
|
+
}
|
|
334586
|
+
if (getBridgeState().hasActiveSession) {
|
|
334587
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
334588
|
+
status: "ERROR",
|
|
334589
|
+
error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; retry once it ends."
|
|
334590
|
+
});
|
|
334591
|
+
log.fail("ERROR - device busy (recording) for locator discovery");
|
|
334592
|
+
return;
|
|
334593
|
+
}
|
|
334594
|
+
const leasedName = assignedDeviceId ? getCachedDeviceSnapshot().find((d) => d.id === assignedDeviceId)?.name : void 0;
|
|
334595
|
+
liveBrowserHandle.patch({
|
|
334596
|
+
mode: "discovery",
|
|
334597
|
+
testName: "Cross-platform locator discovery",
|
|
334598
|
+
note: `${platform3} locator discovery`,
|
|
334599
|
+
surface: "mobile",
|
|
334600
|
+
mobilePlatform: platform3,
|
|
334601
|
+
...assignedDeviceId ? { deviceId: assignedDeviceId } : {},
|
|
334602
|
+
...leasedName ? { deviceName: leasedName } : {}
|
|
334603
|
+
});
|
|
334604
|
+
requestLiveBrowserHeartbeat(true);
|
|
334605
|
+
log.info(`Cross-platform locator discovery \u2014 ${platform3} / ${harvestTarget.appId} for test ${targetTestCaseId}`);
|
|
334606
|
+
setExecutorBusy(true);
|
|
334607
|
+
try {
|
|
334608
|
+
const harvest = await harvestPlatformLocators({
|
|
334609
|
+
steps: locatorCtx.sourceSteps ?? [],
|
|
334610
|
+
target: {
|
|
334611
|
+
appId: harvestTarget.appId,
|
|
334612
|
+
platform: platform3,
|
|
334613
|
+
platformVersion: harvestTarget.platformVersion,
|
|
334614
|
+
recordedDeviceId: harvestTarget.recordedDeviceId,
|
|
334615
|
+
assignedDeviceId,
|
|
334616
|
+
launchMode: harvestTarget.launchMode,
|
|
334617
|
+
deeplink: harvestTarget.deeplink
|
|
334618
|
+
},
|
|
334619
|
+
onSessionReady: (sessionId, deviceId, sessionPlatform) => {
|
|
334620
|
+
attachMirrorSession(sessionId, sessionPlatform, deviceId);
|
|
334621
|
+
const name = getCachedDeviceSnapshot().find((d) => d.id === deviceId)?.name;
|
|
334622
|
+
liveBrowserHandle.patch({ deviceId, ...name ? { deviceName: name } : {} });
|
|
334623
|
+
requestLiveBrowserHeartbeat(true);
|
|
334624
|
+
},
|
|
334625
|
+
onSessionEnding: (deviceId) => {
|
|
334626
|
+
detachMirrorSession(deviceId);
|
|
334627
|
+
},
|
|
334628
|
+
// Resolve {{KEY}} credential placeholders in guided-replay TYPE steps.
|
|
334629
|
+
testVariables: run2.testVariables
|
|
334630
|
+
});
|
|
334631
|
+
let queuedRunId = null;
|
|
334632
|
+
let resultPosted = false;
|
|
334633
|
+
try {
|
|
334634
|
+
const res = await fetch(`${opts.serverUrl}/api/runner/runs/${run2.runId}/locator-discovery-result`, {
|
|
334635
|
+
method: "POST",
|
|
334636
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
334637
|
+
body: JSON.stringify({
|
|
334638
|
+
bundles: harvest.bundles,
|
|
334639
|
+
divergentOrders: harvest.divergentOrders,
|
|
334640
|
+
reachedOrder: harvest.reachedOrder,
|
|
334641
|
+
logs: harvest.logs,
|
|
334642
|
+
...harvest.environmentalError ? { environmentalError: harvest.environmentalError } : {}
|
|
334643
|
+
}),
|
|
334644
|
+
signal: AbortSignal.timeout(3e4)
|
|
334645
|
+
});
|
|
334646
|
+
if (res.ok) {
|
|
334647
|
+
resultPosted = true;
|
|
334648
|
+
const data = await res.json().catch(() => null);
|
|
334649
|
+
queuedRunId = data?.queuedRunId ?? null;
|
|
334650
|
+
} else {
|
|
334651
|
+
log.warn(`[locator-discovery] result POST returned ${res.status}`);
|
|
334652
|
+
}
|
|
334653
|
+
} catch (postErr) {
|
|
334654
|
+
log.warn(`[locator-discovery] result POST failed: ${postErr instanceof Error ? postErr.message : String(postErr)}`);
|
|
334655
|
+
}
|
|
334656
|
+
const passed = resultPosted && !harvest.environmentalError && harvest.reachedOrder != null;
|
|
334657
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
334658
|
+
status: passed ? "PASSED" : "ERROR",
|
|
334659
|
+
error: !resultPosted ? "CONFIG_ISSUE: failed to persist harvested cross-platform locators (result POST did not succeed); retriable." : harvest.environmentalError ? `CONFIG_ISSUE: ${harvest.environmentalError}` : harvest.reachedOrder == null ? "CONFIG_ISSUE: locator discovery could not reach any step on the target platform." : void 0,
|
|
334660
|
+
logs: harvest.logs,
|
|
334661
|
+
duration: Date.now() - startTime
|
|
334662
|
+
});
|
|
334663
|
+
log[passed ? "ok" : "fail"](
|
|
334664
|
+
`Locator discovery ${passed ? "complete" : "ended"} \u2014 ${Object.keys(harvest.bundles).length} overlays, ${harvest.divergentOrders.length} divergent${queuedRunId ? ` (queued run ${queuedRunId})` : ""}`
|
|
334665
|
+
);
|
|
334666
|
+
} finally {
|
|
334667
|
+
setExecutorBusy(false);
|
|
334668
|
+
}
|
|
334669
|
+
return;
|
|
334670
|
+
}
|
|
334020
334671
|
log.info(`Discovery (${ctx.mode}) on ${ctx.baseUrl}`);
|
|
334021
334672
|
const { push: pushAudit, drain: drainAudit } = createAuditFlusher(
|
|
334022
334673
|
`${opts.serverUrl}/api/runner/runs/${run2.runId}/ai-audit-batch`,
|
|
@@ -334730,7 +335381,11 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334730
335381
|
recordedDeviceId: target.recordedDeviceId,
|
|
334731
335382
|
assignedDeviceId,
|
|
334732
335383
|
launchMode: target.launchMode,
|
|
334733
|
-
deeplink: target.deeplink
|
|
335384
|
+
deeplink: target.deeplink,
|
|
335385
|
+
// Authoring platform (test's session medium). When it differs from
|
|
335386
|
+
// target.platform this is a cross-platform run: no blind coordinate
|
|
335387
|
+
// taps on foreign geometry, and the run must verify an outcome.
|
|
335388
|
+
authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
|
|
334734
335389
|
},
|
|
334735
335390
|
// Stream this run to its OWN per-device dashboard tile (keyed by the
|
|
334736
335391
|
// leased device id) so N parallel Appium runs each show a live mirror.
|
|
@@ -334740,7 +335395,11 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334740
335395
|
},
|
|
334741
335396
|
onSessionEnding: (deviceId) => {
|
|
334742
335397
|
detachMirrorSession(deviceId);
|
|
334743
|
-
}
|
|
335398
|
+
},
|
|
335399
|
+
// Resolve {{KEY}} credential placeholders (recorded by the mobile
|
|
335400
|
+
// discovery generator instead of secret values) against this run's
|
|
335401
|
+
// CURRENT test variables.
|
|
335402
|
+
testVariables: run2.testVariables
|
|
334744
335403
|
});
|
|
334745
335404
|
attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
|
|
334746
335405
|
${result2.logs}`);
|