@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.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,103 @@ 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;
|
|
1890
|
+
}
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1893
|
+
// ../shared/dist/mobile-cross-platform.js
|
|
1894
|
+
function isMobilePlatform(value) {
|
|
1895
|
+
return value === "IOS" || value === "ANDROID";
|
|
1896
|
+
}
|
|
1897
|
+
function mediumForMobilePlatform(platform3) {
|
|
1898
|
+
return platform3 === "IOS" ? "IOS_NATIVE" : "ANDROID_NATIVE";
|
|
1899
|
+
}
|
|
1900
|
+
function mobilePlatformFromMedium(medium) {
|
|
1901
|
+
if (medium === "IOS_NATIVE")
|
|
1902
|
+
return "IOS";
|
|
1903
|
+
if (medium === "ANDROID_NATIVE")
|
|
1904
|
+
return "ANDROID";
|
|
1905
|
+
return null;
|
|
1906
|
+
}
|
|
1907
|
+
function otherMobilePlatform(platform3) {
|
|
1908
|
+
return platform3 === "IOS" ? "ANDROID" : "IOS";
|
|
1909
|
+
}
|
|
1910
|
+
function platformTag(platform3) {
|
|
1911
|
+
return platform3 === "IOS" ? "@ios" : "@android";
|
|
1912
|
+
}
|
|
1913
|
+
function resolvePlatformLocatorBundle(target, platform3) {
|
|
1914
|
+
if (!target)
|
|
1915
|
+
return void 0;
|
|
1916
|
+
if (platform3) {
|
|
1917
|
+
const overlay = target.platformLocators?.[platform3];
|
|
1918
|
+
if (overlay)
|
|
1919
|
+
return overlay;
|
|
1920
|
+
}
|
|
1921
|
+
return target.locatorBundle;
|
|
1922
|
+
}
|
|
1923
|
+
function hasPlatformLocator(target, platform3) {
|
|
1924
|
+
return Boolean(target?.platformLocators?.[platform3]);
|
|
1925
|
+
}
|
|
1926
|
+
function hasCrossPlatformIdentity(bundle) {
|
|
1927
|
+
if (!bundle)
|
|
1928
|
+
return false;
|
|
1929
|
+
return Boolean(bundle.text?.trim() || bundle.contentDesc?.trim() || bundle.accessibilityId?.trim());
|
|
1930
|
+
}
|
|
1931
|
+
function stepIsDivergentForPlatform(step, platform3) {
|
|
1932
|
+
return Array.isArray(step.crossPlatformDivergent) && step.crossPlatformDivergent.includes(platform3);
|
|
1933
|
+
}
|
|
1934
|
+
function stepAppliesToPlatform(step, platform3) {
|
|
1935
|
+
const platforms = step.platforms;
|
|
1936
|
+
if (!platforms || platforms.length === 0)
|
|
1937
|
+
return true;
|
|
1938
|
+
if (!platform3)
|
|
1939
|
+
return true;
|
|
1940
|
+
return platforms.includes(platform3);
|
|
1941
|
+
}
|
|
1942
|
+
function stepsMissingPlatformLocators(steps, platform3) {
|
|
1943
|
+
return steps.filter((step) => {
|
|
1944
|
+
if (!LOCATOR_REQUIRING_ACTIONS.has(step.action))
|
|
1945
|
+
return false;
|
|
1946
|
+
if (!stepAppliesToPlatform(step, platform3))
|
|
1947
|
+
return false;
|
|
1948
|
+
if (hasPlatformLocator(step.target, platform3))
|
|
1949
|
+
return false;
|
|
1950
|
+
if (stepIsDivergentForPlatform(step, platform3))
|
|
1951
|
+
return false;
|
|
1952
|
+
return hasCrossPlatformIdentity(step.target?.locatorBundle);
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
function stepsNeedingReviewForPlatform(steps, platform3) {
|
|
1956
|
+
return steps.filter((step) => {
|
|
1957
|
+
if (!LOCATOR_REQUIRING_ACTIONS.has(step.action))
|
|
1958
|
+
return false;
|
|
1959
|
+
if (!stepAppliesToPlatform(step, platform3))
|
|
1960
|
+
return false;
|
|
1961
|
+
if (hasPlatformLocator(step.target, platform3))
|
|
1962
|
+
return false;
|
|
1963
|
+
if (stepIsDivergentForPlatform(step, platform3))
|
|
1964
|
+
return true;
|
|
1965
|
+
return !hasCrossPlatformIdentity(step.target?.locatorBundle);
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
function isTestReadyForPlatform(steps, authoringPlatform, runPlatform) {
|
|
1969
|
+
if (runPlatform === authoringPlatform)
|
|
1970
|
+
return true;
|
|
1971
|
+
return stepsMissingPlatformLocators(steps, runPlatform).length === 0 && stepsNeedingReviewForPlatform(steps, runPlatform).length === 0;
|
|
1972
|
+
}
|
|
1973
|
+
var MOBILE_PLATFORMS, LOCATOR_REQUIRING_ACTIONS;
|
|
1974
|
+
var init_mobile_cross_platform = __esm({
|
|
1975
|
+
"../shared/dist/mobile-cross-platform.js"() {
|
|
1976
|
+
"use strict";
|
|
1977
|
+
MOBILE_PLATFORMS = ["IOS", "ANDROID"];
|
|
1978
|
+
LOCATOR_REQUIRING_ACTIONS = /* @__PURE__ */ new Set([
|
|
1979
|
+
"tap",
|
|
1980
|
+
"type",
|
|
1981
|
+
"clear",
|
|
1982
|
+
"waitForElement",
|
|
1983
|
+
"assertVisible",
|
|
1984
|
+
"assertText"
|
|
1985
|
+
]);
|
|
1856
1986
|
}
|
|
1857
1987
|
});
|
|
1858
1988
|
|
|
@@ -3534,6 +3664,7 @@ __export(dist_exports, {
|
|
|
3534
3664
|
MOBILE_DEFAULT_SWIPE_DISTANCE: () => MOBILE_DEFAULT_SWIPE_DISTANCE,
|
|
3535
3665
|
MOBILE_FOCUS_SETTLE_MS: () => MOBILE_FOCUS_SETTLE_MS,
|
|
3536
3666
|
MOBILE_MAX_SWIPE_DISTANCE: () => MOBILE_MAX_SWIPE_DISTANCE,
|
|
3667
|
+
MOBILE_PLATFORMS: () => MOBILE_PLATFORMS,
|
|
3537
3668
|
MOBILE_SCREENSHOT_INTERVAL_MS: () => MOBILE_SCREENSHOT_INTERVAL_MS,
|
|
3538
3669
|
MOBILE_SNAPSHOT_MAX_SIZE_BYTES: () => MOBILE_SNAPSHOT_MAX_SIZE_BYTES,
|
|
3539
3670
|
MOBILE_STEP_TYPES: () => MOBILE_STEP_TYPES,
|
|
@@ -3587,6 +3718,8 @@ __export(dist_exports, {
|
|
|
3587
3718
|
getPlanDefinition: () => getPlanDefinition,
|
|
3588
3719
|
getPlanLabel: () => getPlanLabel,
|
|
3589
3720
|
getTrialDaysRemaining: () => getTrialDaysRemaining,
|
|
3721
|
+
hasCrossPlatformIdentity: () => hasCrossPlatformIdentity,
|
|
3722
|
+
hasPlatformLocator: () => hasPlatformLocator,
|
|
3590
3723
|
hostOfOrigin: () => hostOfOrigin,
|
|
3591
3724
|
isAddonType: () => isAddonType,
|
|
3592
3725
|
isApiTest: () => isApiTest,
|
|
@@ -3595,24 +3728,38 @@ __export(dist_exports, {
|
|
|
3595
3728
|
isCountedFailure: () => isCountedFailure,
|
|
3596
3729
|
isCreditPackAmountCents: () => isCreditPackAmountCents,
|
|
3597
3730
|
isDbPricingPlan: () => isDbPricingPlan,
|
|
3731
|
+
isEditableMobileClassName: () => isEditableMobileClassName,
|
|
3598
3732
|
isEnvironmentalError: () => isEnvironmentalError,
|
|
3733
|
+
isMobilePlatform: () => isMobilePlatform,
|
|
3599
3734
|
isPricingPlan: () => isPricingPlan,
|
|
3600
3735
|
isRunnableStep: () => isRunnableStep,
|
|
3601
3736
|
isSafeDeepLink: () => isSafeDeepLink,
|
|
3602
3737
|
isSameRegistrableDomainHost: () => isSameRegistrableDomainHost,
|
|
3738
|
+
isTestReadyForPlatform: () => isTestReadyForPlatform,
|
|
3603
3739
|
isTrialExpired: () => isTrialExpired,
|
|
3604
3740
|
isUIVariant: () => isUIVariant,
|
|
3605
3741
|
isValidMobileAppId: () => isValidMobileAppId,
|
|
3742
|
+
mediumForMobilePlatform: () => mediumForMobilePlatform,
|
|
3743
|
+
mobileCredentialPlaceholder: () => mobileCredentialPlaceholder,
|
|
3744
|
+
mobilePlatformFromMedium: () => mobilePlatformFromMedium,
|
|
3606
3745
|
normalizeControlName: () => normalizeControlName,
|
|
3746
|
+
otherMobilePlatform: () => otherMobilePlatform,
|
|
3607
3747
|
parenDepthDelta: () => parenDepthDelta,
|
|
3608
3748
|
planHasBillingPortal: () => planHasBillingPortal,
|
|
3609
3749
|
planRequiresCheckout: () => planRequiresCheckout,
|
|
3750
|
+
platformTag: () => platformTag,
|
|
3610
3751
|
registrableDomain: () => registrableDomain,
|
|
3611
3752
|
resolveCrossOriginConfigOverride: () => resolveCrossOriginConfigOverride,
|
|
3753
|
+
resolveMobileStepValuePlaceholders: () => resolveMobileStepValuePlaceholders,
|
|
3754
|
+
resolvePlatformLocatorBundle: () => resolvePlatformLocatorBundle,
|
|
3612
3755
|
safeOrigin: () => safeOrigin,
|
|
3613
3756
|
sanitizeTestVariables: () => sanitizeTestVariables,
|
|
3614
3757
|
selectorCandidatesForStep: () => selectorCandidatesForStep,
|
|
3615
3758
|
selectorForValue: () => selectorForValue,
|
|
3759
|
+
stepAppliesToPlatform: () => stepAppliesToPlatform,
|
|
3760
|
+
stepIsDivergentForPlatform: () => stepIsDivergentForPlatform,
|
|
3761
|
+
stepsMissingPlatformLocators: () => stepsMissingPlatformLocators,
|
|
3762
|
+
stepsNeedingReviewForPlatform: () => stepsNeedingReviewForPlatform,
|
|
3616
3763
|
stripEnvironmentalPrefix: () => stripEnvironmentalPrefix,
|
|
3617
3764
|
stripLoginScaffolding: () => stripLoginScaffolding,
|
|
3618
3765
|
summarizeHealth: () => summarizeHealth,
|
|
@@ -3626,6 +3773,7 @@ var init_dist = __esm({
|
|
|
3626
3773
|
init_types();
|
|
3627
3774
|
init_constants();
|
|
3628
3775
|
init_mobile_test_generation();
|
|
3776
|
+
init_mobile_cross_platform();
|
|
3629
3777
|
init_mobile_deeplink();
|
|
3630
3778
|
init_plans();
|
|
3631
3779
|
init_audit_pricing();
|
|
@@ -65462,6 +65610,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65462
65610
|
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).
|
|
65463
65611
|
7. Feature descriptions MUST reference actual element labels / accessibility-ids from the evidence
|
|
65464
65612
|
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.
|
|
65613
|
+
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.
|
|
65465
65614
|
|
|
65466
65615
|
## Output JSON Schema
|
|
65467
65616
|
{
|
|
@@ -65669,7 +65818,8 @@ ${context.appBrief.slice(0, 500)}`);
|
|
|
65669
65818
|
sections.push(`## App Under Test
|
|
65670
65819
|
${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
|
|
65671
65820
|
if (context.featureName) {
|
|
65672
|
-
sections.push(`## Feature: ${context.featureName}
|
|
65821
|
+
sections.push(`## Feature: ${context.featureName}
|
|
65822
|
+
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.`);
|
|
65673
65823
|
}
|
|
65674
65824
|
const orderedScreens = targetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
|
|
65675
65825
|
const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
|
|
@@ -66054,10 +66204,12 @@ var require_mobile_generator = __commonJS({
|
|
|
66054
66204
|
"../runner-core/dist/services/mobile/mobile-generator.js"(exports2) {
|
|
66055
66205
|
"use strict";
|
|
66056
66206
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
66207
|
+
exports2.collapseRetryCycles = collapseRetryCycles;
|
|
66057
66208
|
exports2.runMobileGeneratePhase = runMobileGeneratePhase;
|
|
66058
66209
|
var crypto_1 = require("crypto");
|
|
66059
66210
|
var shared_1 = (init_dist(), __toCommonJS(dist_exports));
|
|
66060
66211
|
var ai_provider_js_1 = require_ai_provider();
|
|
66212
|
+
var credential_tools_js_1 = require_credential_tools();
|
|
66061
66213
|
var ai_queue_js_1 = require_ai_queue();
|
|
66062
66214
|
var ai_retry_js_1 = require_ai_retry();
|
|
66063
66215
|
var mobile_source_parser_js_1 = require_mobile_source_parser();
|
|
@@ -66179,6 +66331,24 @@ var require_mobile_generator = __commonJS({
|
|
|
66179
66331
|
}
|
|
66180
66332
|
}
|
|
66181
66333
|
};
|
|
66334
|
+
var MOBILE_FILL_CREDENTIAL_TOOL = {
|
|
66335
|
+
type: "function",
|
|
66336
|
+
function: {
|
|
66337
|
+
name: "mobile_fill_credential",
|
|
66338
|
+
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.',
|
|
66339
|
+
parameters: {
|
|
66340
|
+
type: "object",
|
|
66341
|
+
properties: {
|
|
66342
|
+
ref: { type: "string", description: 'Text-field element ref from the latest mobile_snapshot, e.g. "e5".' },
|
|
66343
|
+
credentialKey: {
|
|
66344
|
+
type: "string",
|
|
66345
|
+
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.'
|
|
66346
|
+
}
|
|
66347
|
+
},
|
|
66348
|
+
required: ["ref", "credentialKey"]
|
|
66349
|
+
}
|
|
66350
|
+
}
|
|
66351
|
+
};
|
|
66182
66352
|
var FINISH_GENERATE_TOOL = {
|
|
66183
66353
|
type: "function",
|
|
66184
66354
|
function: {
|
|
@@ -66200,6 +66370,7 @@ var require_mobile_generator = __commonJS({
|
|
|
66200
66370
|
MOBILE_TAP_TOOL,
|
|
66201
66371
|
MOBILE_TYPE_TOOL,
|
|
66202
66372
|
MOBILE_CLEAR_TOOL,
|
|
66373
|
+
MOBILE_FILL_CREDENTIAL_TOOL,
|
|
66203
66374
|
MOBILE_SWIPE_TOOL,
|
|
66204
66375
|
MOBILE_BACK_TOOL,
|
|
66205
66376
|
MOBILE_RELAUNCH_TOOL,
|
|
@@ -66278,6 +66449,24 @@ var require_mobile_generator = __commonJS({
|
|
|
66278
66449
|
- 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.
|
|
66279
66450
|
|
|
66280
66451
|
Max iterations: ${MAX_SCENARIO_ITERATIONS}. You will be warned near the end.`;
|
|
66452
|
+
}
|
|
66453
|
+
function buildGenerateCredentialPromptBlock(secrets) {
|
|
66454
|
+
const keys = Object.keys(secrets).filter((k) => secrets[k] && secrets[k].length > 0);
|
|
66455
|
+
if (keys.length === 0)
|
|
66456
|
+
return "";
|
|
66457
|
+
const keyList = keys.map((k) => `- \`${k}\``).join("\n");
|
|
66458
|
+
return `
|
|
66459
|
+
|
|
66460
|
+
## Test Credentials (values are HIDDEN from you)
|
|
66461
|
+
This app has stored login credentials under these keys:
|
|
66462
|
+
${keyList}
|
|
66463
|
+
|
|
66464
|
+
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.
|
|
66465
|
+
|
|
66466
|
+
SECURITY RULES:
|
|
66467
|
+
- 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.
|
|
66468
|
+
- 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.
|
|
66469
|
+
- NEVER put credential values in test names, descriptions, or assert texts.`;
|
|
66281
66470
|
}
|
|
66282
66471
|
function buildScenarioKickoff(scenario) {
|
|
66283
66472
|
const stepLines = scenario.steps.map((step, i) => ` ${i + 1}. ${step.action}${step.target ? ` (target: ${step.target})` : ""}${step.hint ? ` [hint: ${step.hint}]` : ""}`).join("\n");
|
|
@@ -66348,7 +66537,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
|
|
|
66348
66537
|
}
|
|
66349
66538
|
}
|
|
66350
66539
|
async function runScenarioLoop(args) {
|
|
66351
|
-
const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, log: log2 } = args;
|
|
66540
|
+
const { scenario, driver, platform: platform3, maxIterations, agentModel, provider, aiClient, auditGroupId, onAICall, onScreenshot, targetAppId, credentials, log: log2 } = args;
|
|
66352
66541
|
const actions = [];
|
|
66353
66542
|
let assertCount = 0;
|
|
66354
66543
|
let finish = null;
|
|
@@ -66465,7 +66654,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
|
|
|
66465
66654
|
} catch (err) {
|
|
66466
66655
|
log2(` seed snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
66467
66656
|
}
|
|
66468
|
-
const systemPrompt = buildSystemPrompt(platform3, targetAppId);
|
|
66657
|
+
const systemPrompt = buildSystemPrompt(platform3, targetAppId) + buildGenerateCredentialPromptBlock(credentials);
|
|
66469
66658
|
const kickoff = buildScenarioKickoff(scenario);
|
|
66470
66659
|
const recentExchanges = [];
|
|
66471
66660
|
let iteration = 0;
|
|
@@ -66578,6 +66767,7 @@ ${statePacket}` },
|
|
|
66578
66767
|
latest: getLatest,
|
|
66579
66768
|
absorbObservation,
|
|
66580
66769
|
resolveRef,
|
|
66770
|
+
credentials,
|
|
66581
66771
|
screenName: () => lastScreenName || "UnknownScreen",
|
|
66582
66772
|
recordAction: (action) => {
|
|
66583
66773
|
actions.push(finalizeAction(action));
|
|
@@ -66599,7 +66789,7 @@ ${statePacket}` },
|
|
|
66599
66789
|
return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
|
|
66600
66790
|
}
|
|
66601
66791
|
async function dispatchGenerateTool(args) {
|
|
66602
|
-
const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, screenName, recordAction, log: log2 } = args;
|
|
66792
|
+
const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, credentials, screenName, recordAction, log: log2 } = args;
|
|
66603
66793
|
const unknownRef = (ref) => ({
|
|
66604
66794
|
ok: false,
|
|
66605
66795
|
error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
|
|
@@ -66665,6 +66855,14 @@ ${statePacket}` },
|
|
|
66665
66855
|
if (!element)
|
|
66666
66856
|
return unknownRef(toolArgs.ref);
|
|
66667
66857
|
const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
|
|
66858
|
+
for (const [key, secret] of Object.entries(credentials)) {
|
|
66859
|
+
if (secret && secret.length >= 4 && value.includes(secret)) {
|
|
66860
|
+
return {
|
|
66861
|
+
ok: false,
|
|
66862
|
+
error: `BLOCKED: mobile_type contained the stored credential value for "${key}". Use mobile_fill_credential({ ref, credentialKey: "${key}" }) instead of typing the credential directly.`
|
|
66863
|
+
};
|
|
66864
|
+
}
|
|
66865
|
+
}
|
|
66668
66866
|
const beforeScreenName = screenName();
|
|
66669
66867
|
const result = await driver.type(value, element.bounds);
|
|
66670
66868
|
const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_type");
|
|
@@ -66678,6 +66876,37 @@ ${statePacket}` },
|
|
|
66678
66876
|
}
|
|
66679
66877
|
return actionPayload(result, absorbed);
|
|
66680
66878
|
}
|
|
66879
|
+
// KEY-based credential fill: type the real value on-device, persist only a
|
|
66880
|
+
// {{KEY}} placeholder in the recorded step (replay resolves it against the
|
|
66881
|
+
// run's current test variables). The tool result echoes the KEY, never the
|
|
66882
|
+
// value, so the secret cannot enter the transcript.
|
|
66883
|
+
case "mobile_fill_credential": {
|
|
66884
|
+
const key = typeof toolArgs.credentialKey === "string" ? toolArgs.credentialKey.trim() : "";
|
|
66885
|
+
if (!key) {
|
|
66886
|
+
return { ok: false, error: 'mobile_fill_credential: missing required "credentialKey".' };
|
|
66887
|
+
}
|
|
66888
|
+
const secret = credentials[key];
|
|
66889
|
+
if (!secret) {
|
|
66890
|
+
const available = Object.keys(credentials).filter((k) => credentials[k]).join(", ") || "(none configured)";
|
|
66891
|
+
return { ok: false, error: `mobile_fill_credential: no credential configured for key "${key}". Available keys: ${available}` };
|
|
66892
|
+
}
|
|
66893
|
+
const element = resolveRef(toolArgs.ref);
|
|
66894
|
+
if (!element)
|
|
66895
|
+
return unknownRef(toolArgs.ref);
|
|
66896
|
+
const beforeScreenName = screenName();
|
|
66897
|
+
const result = await driver.type(secret, element.bounds);
|
|
66898
|
+
const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_fill_credential");
|
|
66899
|
+
if (shouldRecordAction(result, absorbed)) {
|
|
66900
|
+
recordAction({
|
|
66901
|
+
type: "TYPE",
|
|
66902
|
+
value: (0, shared_1.mobileCredentialPlaceholder)(key),
|
|
66903
|
+
selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
|
|
66904
|
+
metadata: metadataForElement(element, beforeScreenName, platform3)
|
|
66905
|
+
});
|
|
66906
|
+
log2(` fill_credential: recorded {{${key}}} into ${labelForElement(element)}`);
|
|
66907
|
+
}
|
|
66908
|
+
return { ...actionPayload(result, absorbed), filledCredentialKey: key };
|
|
66909
|
+
}
|
|
66681
66910
|
case "mobile_clear": {
|
|
66682
66911
|
const element = resolveRef(toolArgs.ref);
|
|
66683
66912
|
if (!element)
|
|
@@ -66826,18 +67055,40 @@ ${statePacket}` },
|
|
|
66826
67055
|
return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
|
|
66827
67056
|
}
|
|
66828
67057
|
var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
|
|
66829
|
-
var
|
|
66830
|
-
"
|
|
66831
|
-
"
|
|
66832
|
-
"
|
|
66833
|
-
"
|
|
66834
|
-
"
|
|
67058
|
+
var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
|
|
67059
|
+
"content",
|
|
67060
|
+
"decor",
|
|
67061
|
+
"decor_content_parent",
|
|
67062
|
+
"action_bar_root",
|
|
67063
|
+
"statusBarBackground",
|
|
67064
|
+
"navigationBarBackground",
|
|
67065
|
+
// WebView hybrid apps (React/Vue/etc. rendered inside android.webkit.WebView)
|
|
67066
|
+
// expose their framework ROOT container's DOM id as the element id — present on
|
|
67067
|
+
// EVERY in-app screen, so the floor must not anchor to it (observed live on
|
|
67068
|
+
// ThoughtStream: `main-content` produced a screen-agnostic assertion that then
|
|
67069
|
+
// never matched via the text-contains fallback).
|
|
67070
|
+
"main-content",
|
|
67071
|
+
"root",
|
|
67072
|
+
"app",
|
|
67073
|
+
"app-root",
|
|
67074
|
+
"__next",
|
|
67075
|
+
"react-root",
|
|
67076
|
+
"application"
|
|
66835
67077
|
]);
|
|
66836
67078
|
function isAlwaysPresentContainer(element) {
|
|
66837
67079
|
if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
|
|
66838
67080
|
return true;
|
|
66839
67081
|
const resourceId = element.resourceId?.trim();
|
|
66840
|
-
|
|
67082
|
+
if (resourceId) {
|
|
67083
|
+
const slashIdx = resourceId.indexOf("/");
|
|
67084
|
+
const localName = slashIdx >= 0 ? resourceId.slice(slashIdx + 1) : resourceId;
|
|
67085
|
+
if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName))
|
|
67086
|
+
return true;
|
|
67087
|
+
}
|
|
67088
|
+
const accessibilityId = element.accessibilityId?.trim();
|
|
67089
|
+
if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(accessibilityId))
|
|
67090
|
+
return true;
|
|
67091
|
+
return false;
|
|
66841
67092
|
}
|
|
66842
67093
|
function appendHybridFloor(actions, recording, platform3) {
|
|
66843
67094
|
const anchor = pickLandingAnchor(recording.lastSnapshot);
|
|
@@ -66851,9 +67102,38 @@ ${statePacket}` },
|
|
|
66851
67102
|
}));
|
|
66852
67103
|
return true;
|
|
66853
67104
|
}
|
|
67105
|
+
function actionCycleKey(action) {
|
|
67106
|
+
return `${action.type}|${action.selector}|${action.value ?? ""}`;
|
|
67107
|
+
}
|
|
67108
|
+
var MAX_RETRY_CYCLE_LENGTH = 8;
|
|
67109
|
+
function collapseRetryCycles(actions) {
|
|
67110
|
+
const out = [...actions];
|
|
67111
|
+
let changed = true;
|
|
67112
|
+
while (changed) {
|
|
67113
|
+
changed = false;
|
|
67114
|
+
const maxWindow = Math.min(MAX_RETRY_CYCLE_LENGTH, Math.floor(out.length / 2));
|
|
67115
|
+
for (let window2 = maxWindow; window2 >= 2 && !changed; window2--) {
|
|
67116
|
+
for (let i = 0; i + 2 * window2 <= out.length; i++) {
|
|
67117
|
+
const first = out.slice(i, i + window2);
|
|
67118
|
+
if (first.some((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT"))
|
|
67119
|
+
continue;
|
|
67120
|
+
const second = out.slice(i + window2, i + 2 * window2);
|
|
67121
|
+
if (!first.every((a, k) => actionCycleKey(a) === actionCycleKey(second[k])))
|
|
67122
|
+
continue;
|
|
67123
|
+
out.splice(i, window2);
|
|
67124
|
+
changed = true;
|
|
67125
|
+
break;
|
|
67126
|
+
}
|
|
67127
|
+
}
|
|
67128
|
+
}
|
|
67129
|
+
return out;
|
|
67130
|
+
}
|
|
66854
67131
|
function assembleTest(args) {
|
|
66855
67132
|
const { scenario, recording, ctx, platform: platform3, log: log2 } = args;
|
|
66856
|
-
const actions =
|
|
67133
|
+
const actions = collapseRetryCycles(recording.actions);
|
|
67134
|
+
if (actions.length < recording.actions.length) {
|
|
67135
|
+
log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
|
|
67136
|
+
}
|
|
66857
67137
|
const floorAppended = appendHybridFloor(actions, recording, platform3);
|
|
66858
67138
|
if (floorAppended) {
|
|
66859
67139
|
log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
|
|
@@ -66880,9 +67160,9 @@ ${statePacket}` },
|
|
|
66880
67160
|
if (claimedVerified && !verified) {
|
|
66881
67161
|
log2(" downgraded verified -> false: model claimed verified but called zero assert tools.");
|
|
66882
67162
|
}
|
|
66883
|
-
const
|
|
67163
|
+
const platformTag2 = platform3 === "IOS" ? "@ios" : "@android";
|
|
66884
67164
|
const reviewTag = verified ? "@verified" : "@needs-review";
|
|
66885
|
-
const tags = ["@discovered", "@mobile",
|
|
67165
|
+
const tags = ["@discovered", "@mobile", platformTag2, reviewTag];
|
|
66886
67166
|
const test = {
|
|
66887
67167
|
name: compiled.name,
|
|
66888
67168
|
description: recording.finish?.description ?? compiled.description,
|
|
@@ -66892,6 +67172,12 @@ ${statePacket}` },
|
|
|
66892
67172
|
playwrightCode: "",
|
|
66893
67173
|
verified,
|
|
66894
67174
|
tags,
|
|
67175
|
+
// Per-test feature label → TestCase.suite. One deep run plans scenarios for
|
|
67176
|
+
// MULTIPLE feature groups (the planner reuses survey feature names), so the
|
|
67177
|
+
// run-level ctx.featureName fallback in /discovery-test would wrongly file
|
|
67178
|
+
// every test under the deep-dived feature. The scenario's own group is the
|
|
67179
|
+
// correct home; the runner forwards it as `suiteName`.
|
|
67180
|
+
...scenario.featureGroup?.trim() ? { suiteName: scenario.featureGroup.trim() } : {},
|
|
66895
67181
|
// Carry the planner scenario identity so incremental/resume can match an
|
|
66896
67182
|
// already-generated scenario reliably (scenarioRef.name === the plan's
|
|
66897
67183
|
// scenario name). Without this the resume skip-set falls back to the
|
|
@@ -66909,7 +67195,27 @@ ${statePacket}` },
|
|
|
66909
67195
|
startFromLanding: scenario.startFromLanding,
|
|
66910
67196
|
targetPages: [...scenario.targetPages]
|
|
66911
67197
|
},
|
|
66912
|
-
evidence: { observedLocators: [], observedTransitions: [] }
|
|
67198
|
+
evidence: { observedLocators: [], observedTransitions: [] },
|
|
67199
|
+
// Assertion-strength marker for the server promote-seam
|
|
67200
|
+
// (resolveStabilityTransition): an UNVERIFIED recording proves none of the
|
|
67201
|
+
// scenario's outcomes — only the hybrid floor's landing-screen visibility —
|
|
67202
|
+
// so a passing replay is vacuous. Hold it as DRAFT (@needs-review) for a
|
|
67203
|
+
// human instead of letting the pass auto-promote it into the ACTIVE suite.
|
|
67204
|
+
// Mirrors the recorder mobile path (server test-pipeline.ts @unverified).
|
|
67205
|
+
...verified ? {} : {
|
|
67206
|
+
gateActivity: {
|
|
67207
|
+
outcomeGate: {
|
|
67208
|
+
requiredCount: scenario.expectedOutcomes.length,
|
|
67209
|
+
uncoveredCount: scenario.expectedOutcomes.length
|
|
67210
|
+
},
|
|
67211
|
+
strengthGate: {
|
|
67212
|
+
fired: false,
|
|
67213
|
+
severity: "block",
|
|
67214
|
+
blocked: true,
|
|
67215
|
+
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."
|
|
67216
|
+
}
|
|
67217
|
+
}
|
|
67218
|
+
}
|
|
66913
67219
|
}
|
|
66914
67220
|
};
|
|
66915
67221
|
return test;
|
|
@@ -66930,6 +67236,7 @@ ${statePacket}` },
|
|
|
66930
67236
|
const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
|
|
66931
67237
|
const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
|
|
66932
67238
|
const targetAppId = ctx.target?.appId?.trim() || void 0;
|
|
67239
|
+
const { secrets: credentialSecrets } = (0, credential_tools_js_1.partitionTestVariables)(ctx.variableContext?.allTestVariables ?? ctx.variableContext?.publicTestVariables);
|
|
66933
67240
|
const skipSet = new Set(skipScenarioNames ?? []);
|
|
66934
67241
|
const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
|
|
66935
67242
|
if (skipSet.size > 0) {
|
|
@@ -66958,6 +67265,7 @@ ${statePacket}` },
|
|
|
66958
67265
|
onAICall,
|
|
66959
67266
|
onScreenshot,
|
|
66960
67267
|
targetAppId,
|
|
67268
|
+
credentials: credentialSecrets,
|
|
66961
67269
|
log: log2
|
|
66962
67270
|
});
|
|
66963
67271
|
consecutiveInfraFailures = 0;
|
|
@@ -313580,9 +313888,12 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
|
|
|
313580
313888
|
});
|
|
313581
313889
|
let featureInsight;
|
|
313582
313890
|
if (mode === "deep" && ctx.featureName) {
|
|
313891
|
+
const targetName = ctx.featureName.trim().toLowerCase();
|
|
313892
|
+
const plannedGroup = (featureGroups ?? []).find((group) => group.name.trim().toLowerCase() === targetName);
|
|
313893
|
+
const plannedDescription = plannedGroup?.description?.trim();
|
|
313583
313894
|
featureInsight = {
|
|
313584
313895
|
featureName: ctx.featureName,
|
|
313585
|
-
description: `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
|
|
313896
|
+
description: plannedDescription && plannedDescription.length > 0 ? plannedDescription : `Mobile feature "${ctx.featureName}": ${explore.exploreStats.pagesDiscovered} screens explored, ${tests.length} tests generated.`
|
|
313586
313897
|
};
|
|
313587
313898
|
}
|
|
313588
313899
|
const duration = (Date.now() - startTime) / 1e3;
|
|
@@ -328118,7 +328429,7 @@ var require_package4 = __commonJS({
|
|
|
328118
328429
|
"package.json"(exports2, module2) {
|
|
328119
328430
|
module2.exports = {
|
|
328120
328431
|
name: "@validate.qa/runner",
|
|
328121
|
-
version: "1.0.
|
|
328432
|
+
version: "1.0.18",
|
|
328122
328433
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
328123
328434
|
bin: {
|
|
328124
328435
|
"validate-runner": "dist/cli.js"
|
|
@@ -329659,6 +329970,7 @@ async function startMobileNetworkCapture(options) {
|
|
|
329659
329970
|
|
|
329660
329971
|
// src/services/appium-executor.ts
|
|
329661
329972
|
var DEFAULT_STEP_TIMEOUT_MS = 1e4;
|
|
329973
|
+
var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
|
|
329662
329974
|
var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
|
|
329663
329975
|
var WD_ACTION_TIMEOUT_MS = 3e4;
|
|
329664
329976
|
var MAX_SESSION_CREATE_ATTEMPTS = 3;
|
|
@@ -329987,6 +330299,11 @@ function boundsFromStep(step) {
|
|
|
329987
330299
|
}
|
|
329988
330300
|
return null;
|
|
329989
330301
|
}
|
|
330302
|
+
function boundsForRun(step, platform3, isCrossPlatform) {
|
|
330303
|
+
if (isCrossPlatform) return null;
|
|
330304
|
+
if (platform3 && hasPlatformLocator(step.target, platform3)) return null;
|
|
330305
|
+
return boundsFromStep(step);
|
|
330306
|
+
}
|
|
329990
330307
|
function locatorStrategyForValue(value) {
|
|
329991
330308
|
const trimmed = value?.trim();
|
|
329992
330309
|
if (!trimmed) {
|
|
@@ -330006,19 +330323,21 @@ function locatorStrategyForValue(value) {
|
|
|
330006
330323
|
{ using: "id", value: trimmed }
|
|
330007
330324
|
];
|
|
330008
330325
|
}
|
|
330009
|
-
function buildLocatorStrategies(target) {
|
|
330326
|
+
function buildLocatorStrategies(target, platform3) {
|
|
330010
330327
|
if (!target) return [];
|
|
330011
330328
|
const strategies = [];
|
|
330012
|
-
const bundle = target
|
|
330329
|
+
const bundle = resolvePlatformLocatorBundle(target, platform3);
|
|
330013
330330
|
if (bundle) {
|
|
330014
330331
|
if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
|
|
330015
330332
|
if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
|
|
330016
330333
|
if (bundle.xpath) strategies.push({ using: "xpath", value: bundle.xpath });
|
|
330017
330334
|
if (bundle.className) strategies.push({ using: "class name", value: bundle.className });
|
|
330018
330335
|
}
|
|
330019
|
-
|
|
330020
|
-
|
|
330021
|
-
|
|
330336
|
+
if (!(platform3 && hasPlatformLocator(target, platform3))) {
|
|
330337
|
+
strategies.push(...locatorStrategyForValue(target.primary));
|
|
330338
|
+
for (const fallback of target.fallbacks ?? []) {
|
|
330339
|
+
strategies.push(...locatorStrategyForValue(fallback));
|
|
330340
|
+
}
|
|
330022
330341
|
}
|
|
330023
330342
|
return strategies.filter(
|
|
330024
330343
|
(strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
|
|
@@ -330044,10 +330363,10 @@ function isIdentityStrategy(strategy) {
|
|
|
330044
330363
|
if (strategy.using === "xpath") return strategy.value.includes("@");
|
|
330045
330364
|
return false;
|
|
330046
330365
|
}
|
|
330047
|
-
function buildIdentityLocatorStrategies(target) {
|
|
330366
|
+
function buildIdentityLocatorStrategies(target, platform3) {
|
|
330048
330367
|
if (!target) return [];
|
|
330049
330368
|
const strategies = [];
|
|
330050
|
-
const bundle = target
|
|
330369
|
+
const bundle = resolvePlatformLocatorBundle(target, platform3);
|
|
330051
330370
|
if (bundle) {
|
|
330052
330371
|
if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
|
|
330053
330372
|
if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
|
|
@@ -330057,9 +330376,11 @@ function buildIdentityLocatorStrategies(target) {
|
|
|
330057
330376
|
}
|
|
330058
330377
|
if (bundle.xpath && bundle.xpath.includes("@")) strategies.push({ using: "xpath", value: bundle.xpath });
|
|
330059
330378
|
}
|
|
330060
|
-
|
|
330061
|
-
for (const
|
|
330062
|
-
|
|
330379
|
+
if (!(platform3 && hasPlatformLocator(target, platform3))) {
|
|
330380
|
+
for (const value of [target.primary, ...target.fallbacks ?? []]) {
|
|
330381
|
+
for (const strategy of locatorStrategyForValue(value)) {
|
|
330382
|
+
if (isIdentityStrategy(strategy)) strategies.push(strategy);
|
|
330383
|
+
}
|
|
330063
330384
|
}
|
|
330064
330385
|
}
|
|
330065
330386
|
return dedupeStrategies(strategies);
|
|
@@ -330072,7 +330393,7 @@ function readStepTimeout(step) {
|
|
|
330072
330393
|
return void 0;
|
|
330073
330394
|
}
|
|
330074
330395
|
async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
|
|
330075
|
-
const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target) : buildLocatorStrategies(target);
|
|
330396
|
+
const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
|
|
330076
330397
|
if (strategies.length === 0) {
|
|
330077
330398
|
throw new Error(
|
|
330078
330399
|
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"
|
|
@@ -330174,18 +330495,18 @@ function shouldAbortAfterStepFailure(action) {
|
|
|
330174
330495
|
return false;
|
|
330175
330496
|
}
|
|
330176
330497
|
}
|
|
330177
|
-
async function executeTap(sessionId, step) {
|
|
330178
|
-
const bounds =
|
|
330498
|
+
async function executeTap(sessionId, step, platform3, isCrossPlatform = false) {
|
|
330499
|
+
const bounds = boundsForRun(step, platform3, isCrossPlatform);
|
|
330179
330500
|
if (step.target) {
|
|
330180
330501
|
try {
|
|
330181
|
-
const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
|
|
330502
|
+
const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
|
|
330182
330503
|
await wdRequest(`/session/${sessionId}/element/${elementId}/click`, {
|
|
330183
330504
|
method: "POST",
|
|
330184
330505
|
body: "{}"
|
|
330185
330506
|
});
|
|
330186
330507
|
return;
|
|
330187
330508
|
} catch (error2) {
|
|
330188
|
-
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
|
|
330509
|
+
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
|
|
330189
330510
|
}
|
|
330190
330511
|
}
|
|
330191
330512
|
if (!bounds) {
|
|
@@ -330196,7 +330517,7 @@ async function executeTap(sessionId, step) {
|
|
|
330196
330517
|
async function executeType(sessionId, step, context) {
|
|
330197
330518
|
if (step.value === void 0 || step.value === null || step.value === "") return;
|
|
330198
330519
|
const value = String(step.value);
|
|
330199
|
-
const bounds =
|
|
330520
|
+
const bounds = boundsForRun(step, context.platform, context.isCrossPlatform ?? false);
|
|
330200
330521
|
let elementId = null;
|
|
330201
330522
|
let lastInputError = null;
|
|
330202
330523
|
let tappedBounds = false;
|
|
@@ -330213,12 +330534,12 @@ async function executeType(sessionId, step, context) {
|
|
|
330213
330534
|
}
|
|
330214
330535
|
if (step.target) {
|
|
330215
330536
|
try {
|
|
330216
|
-
elementId = await findElement(sessionId, step.target, readStepTimeout(step));
|
|
330537
|
+
elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: context.platform });
|
|
330217
330538
|
lastInputError = await trySendElementValue(sessionId, elementId, value);
|
|
330218
330539
|
if (!lastInputError) return;
|
|
330219
330540
|
elementId = null;
|
|
330220
330541
|
} catch (error2) {
|
|
330221
|
-
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
|
|
330542
|
+
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, context.platform).length > 0) throw error2;
|
|
330222
330543
|
lastInputError = error2;
|
|
330223
330544
|
}
|
|
330224
330545
|
}
|
|
@@ -330266,14 +330587,14 @@ async function executeType(sessionId, step, context) {
|
|
|
330266
330587
|
}
|
|
330267
330588
|
throw new Error(`TYPE step could not send text to the focused target${lastInputError instanceof Error ? `: ${lastInputError.message}` : ""}`);
|
|
330268
330589
|
}
|
|
330269
|
-
async function executeClear(sessionId, step) {
|
|
330270
|
-
const bounds =
|
|
330590
|
+
async function executeClear(sessionId, step, platform3, isCrossPlatform = false) {
|
|
330591
|
+
const bounds = boundsForRun(step, platform3, isCrossPlatform);
|
|
330271
330592
|
let elementId = null;
|
|
330272
330593
|
if (step.target) {
|
|
330273
330594
|
try {
|
|
330274
|
-
elementId = await findElement(sessionId, step.target, readStepTimeout(step));
|
|
330595
|
+
elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
|
|
330275
330596
|
} catch (error2) {
|
|
330276
|
-
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
|
|
330597
|
+
if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
|
|
330277
330598
|
}
|
|
330278
330599
|
}
|
|
330279
330600
|
if (!elementId && bounds) {
|
|
@@ -330342,20 +330663,20 @@ async function executeHome(sessionId, platform3) {
|
|
|
330342
330663
|
body: JSON.stringify({ keycode: 3 })
|
|
330343
330664
|
});
|
|
330344
330665
|
}
|
|
330345
|
-
async function executeWaitForElement(sessionId, step) {
|
|
330666
|
+
async function executeWaitForElement(sessionId, step, platform3) {
|
|
330346
330667
|
const timeout = readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS;
|
|
330347
|
-
await findElement(sessionId, step.target, timeout, { identityOnly: true });
|
|
330668
|
+
await findElement(sessionId, step.target, timeout, { identityOnly: true, platform: platform3 });
|
|
330348
330669
|
}
|
|
330349
|
-
async function executeAssertVisible(sessionId, step) {
|
|
330350
|
-
const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
|
|
330670
|
+
async function executeAssertVisible(sessionId, step, platform3) {
|
|
330671
|
+
const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
|
|
330351
330672
|
const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
|
|
330352
330673
|
if (!result?.value) {
|
|
330353
330674
|
throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
|
|
330354
330675
|
}
|
|
330355
330676
|
}
|
|
330356
|
-
async function executeAssertText(sessionId, step) {
|
|
330677
|
+
async function executeAssertText(sessionId, step, platform3) {
|
|
330357
330678
|
if (!step.assertion) throw new Error("assertText step requires an assertion value");
|
|
330358
|
-
const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
|
|
330679
|
+
const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true, platform: platform3 });
|
|
330359
330680
|
const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
|
|
330360
330681
|
const actualText = typeof result?.value === "string" ? result.value : "";
|
|
330361
330682
|
const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
|
|
@@ -330386,6 +330707,10 @@ async function executeMobileTest(options) {
|
|
|
330386
330707
|
const logLines = [];
|
|
330387
330708
|
const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
|
|
330388
330709
|
const startTime = Date.now();
|
|
330710
|
+
const { steps: runSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
|
|
330711
|
+
if (missingVariableKeys.length > 0) {
|
|
330712
|
+
log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured for this project/run.`);
|
|
330713
|
+
}
|
|
330389
330714
|
const stepResults = [];
|
|
330390
330715
|
const screenshots = [];
|
|
330391
330716
|
let apiCalls = [];
|
|
@@ -330521,12 +330846,14 @@ async function executeMobileTest(options) {
|
|
|
330521
330846
|
}
|
|
330522
330847
|
await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
|
|
330523
330848
|
let entryDrift = false;
|
|
330524
|
-
const
|
|
330525
|
-
|
|
330849
|
+
const runPlatform = options.target.platform;
|
|
330850
|
+
const isCrossPlatform = options.target.authoringPlatform != null && options.target.authoringPlatform !== runPlatform;
|
|
330851
|
+
const entryStep = runSteps.find(
|
|
330852
|
+
(s) => s.action !== "launchApp" && stepAppliesToPlatform(s, runPlatform) && buildIdentityLocatorStrategies(s.target, runPlatform).length > 0
|
|
330526
330853
|
);
|
|
330527
330854
|
if (entryStep) {
|
|
330528
330855
|
try {
|
|
330529
|
-
await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
|
|
330856
|
+
await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true, platform: runPlatform });
|
|
330530
330857
|
} catch (err) {
|
|
330531
330858
|
if (isTransportError(err)) throw err;
|
|
330532
330859
|
entryDrift = true;
|
|
@@ -330538,8 +330865,14 @@ async function executeMobileTest(options) {
|
|
|
330538
330865
|
}
|
|
330539
330866
|
}
|
|
330540
330867
|
let environmental = null;
|
|
330541
|
-
for (const step of
|
|
330868
|
+
for (const step of runSteps) {
|
|
330542
330869
|
if (entryDrift) break;
|
|
330870
|
+
if (!stepAppliesToPlatform(step, runPlatform)) {
|
|
330871
|
+
log2(`Step ${step.order}: ${step.action} \u2014 skipped (not applicable to ${runPlatform})`);
|
|
330872
|
+
stepResults.push({ stepOrder: step.order, status: "skipped", duration: 0 });
|
|
330873
|
+
screenshots.push("");
|
|
330874
|
+
continue;
|
|
330875
|
+
}
|
|
330543
330876
|
const stepStart = Date.now();
|
|
330544
330877
|
log2(`Step ${step.order}: ${step.action} - ${step.description}`);
|
|
330545
330878
|
let status = "passed";
|
|
@@ -330551,17 +330884,18 @@ async function executeMobileTest(options) {
|
|
|
330551
330884
|
log2(" App launched (handled by session creation)");
|
|
330552
330885
|
break;
|
|
330553
330886
|
case "tap":
|
|
330554
|
-
await executeTap(sessionId, step);
|
|
330887
|
+
await executeTap(sessionId, step, runPlatform, isCrossPlatform);
|
|
330555
330888
|
break;
|
|
330556
330889
|
case "type":
|
|
330557
330890
|
await executeType(sessionId, step, {
|
|
330558
|
-
platform:
|
|
330891
|
+
platform: runPlatform,
|
|
330559
330892
|
deviceId: selectedDevice.id,
|
|
330560
|
-
log: log2
|
|
330893
|
+
log: log2,
|
|
330894
|
+
isCrossPlatform
|
|
330561
330895
|
});
|
|
330562
330896
|
break;
|
|
330563
330897
|
case "clear":
|
|
330564
|
-
await executeClear(sessionId, step);
|
|
330898
|
+
await executeClear(sessionId, step, runPlatform, isCrossPlatform);
|
|
330565
330899
|
break;
|
|
330566
330900
|
case "swipe":
|
|
330567
330901
|
case "scroll":
|
|
@@ -330571,16 +330905,16 @@ async function executeMobileTest(options) {
|
|
|
330571
330905
|
await executeBack(sessionId);
|
|
330572
330906
|
break;
|
|
330573
330907
|
case "home":
|
|
330574
|
-
await executeHome(sessionId,
|
|
330908
|
+
await executeHome(sessionId, runPlatform);
|
|
330575
330909
|
break;
|
|
330576
330910
|
case "waitForElement":
|
|
330577
|
-
await executeWaitForElement(sessionId, step);
|
|
330911
|
+
await executeWaitForElement(sessionId, step, runPlatform);
|
|
330578
330912
|
break;
|
|
330579
330913
|
case "assertVisible":
|
|
330580
|
-
await executeAssertVisible(sessionId, step);
|
|
330914
|
+
await executeAssertVisible(sessionId, step, runPlatform);
|
|
330581
330915
|
break;
|
|
330582
330916
|
case "assertText":
|
|
330583
|
-
await executeAssertText(sessionId, step);
|
|
330917
|
+
await executeAssertText(sessionId, step, runPlatform);
|
|
330584
330918
|
break;
|
|
330585
330919
|
default: {
|
|
330586
330920
|
const _exhaustive = step.action;
|
|
@@ -330617,8 +330951,14 @@ async function executeMobileTest(options) {
|
|
|
330617
330951
|
break;
|
|
330618
330952
|
}
|
|
330619
330953
|
}
|
|
330620
|
-
const
|
|
330621
|
-
const
|
|
330954
|
+
const executedResults = stepResults.filter((r) => r.status !== "skipped");
|
|
330955
|
+
const verifyOrders = new Set(
|
|
330956
|
+
runSteps.filter((s) => VERIFICATION_ACTIONS.has(s.action)).map((s) => s.order)
|
|
330957
|
+
);
|
|
330958
|
+
const anyVerificationPassed = stepResults.some((r) => r.status === "passed" && verifyOrders.has(r.stepOrder));
|
|
330959
|
+
const crossPlatformUnverified = isCrossPlatform && verifyOrders.size > 0 && !anyVerificationPassed;
|
|
330960
|
+
const allPassed = !environmental && executedResults.length > 0 && !crossPlatformUnverified && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
|
|
330961
|
+
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);
|
|
330622
330962
|
await stopCapture();
|
|
330623
330963
|
await stopRecording();
|
|
330624
330964
|
return {
|
|
@@ -330752,6 +331092,188 @@ async function createMobileDiscoverySession(target, options) {
|
|
|
330752
331092
|
}
|
|
330753
331093
|
};
|
|
330754
331094
|
}
|
|
331095
|
+
async function readElementAttribute(sessionId, elementId, name) {
|
|
331096
|
+
try {
|
|
331097
|
+
const result = await wdRequest(`/session/${sessionId}/element/${elementId}/attribute/${encodeURIComponent(name)}`);
|
|
331098
|
+
const value = result?.value;
|
|
331099
|
+
if (typeof value !== "string") return void 0;
|
|
331100
|
+
const trimmed = value.trim();
|
|
331101
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
331102
|
+
} catch (err) {
|
|
331103
|
+
if (isTransportError(err)) throw err;
|
|
331104
|
+
return void 0;
|
|
331105
|
+
}
|
|
331106
|
+
}
|
|
331107
|
+
function harvestFallbackXpath(platform3, attrs) {
|
|
331108
|
+
if (platform3 === "IOS") {
|
|
331109
|
+
if (attrs.name) return `//*[@name=${xpathLiteral(attrs.name)}]`;
|
|
331110
|
+
const text = attrs.label ?? attrs.value;
|
|
331111
|
+
if (text) return identityTextXpath(text);
|
|
331112
|
+
return "//*";
|
|
331113
|
+
}
|
|
331114
|
+
if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral(attrs.resourceId)}]`;
|
|
331115
|
+
if (attrs.text) return identityTextXpath(attrs.text);
|
|
331116
|
+
if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral(attrs.contentDesc)}]`;
|
|
331117
|
+
return "//*";
|
|
331118
|
+
}
|
|
331119
|
+
async function readNativeElementBundle(sessionId, elementId, platform3) {
|
|
331120
|
+
const className = await readElementAttribute(sessionId, elementId, platform3 === "IOS" ? "type" : "class") ?? "";
|
|
331121
|
+
if (platform3 === "IOS") {
|
|
331122
|
+
const name = await readElementAttribute(sessionId, elementId, "name");
|
|
331123
|
+
const label = await readElementAttribute(sessionId, elementId, "label");
|
|
331124
|
+
const value = await readElementAttribute(sessionId, elementId, "value");
|
|
331125
|
+
const text2 = label ?? value;
|
|
331126
|
+
if (!name && !text2) return null;
|
|
331127
|
+
const bundle2 = {
|
|
331128
|
+
xpath: harvestFallbackXpath("IOS", { name, label, value }),
|
|
331129
|
+
className
|
|
331130
|
+
};
|
|
331131
|
+
if (name) bundle2.accessibilityId = name;
|
|
331132
|
+
if (text2) bundle2.text = text2;
|
|
331133
|
+
return bundle2;
|
|
331134
|
+
}
|
|
331135
|
+
const resourceId = await readElementAttribute(sessionId, elementId, "resource-id");
|
|
331136
|
+
const contentDesc = await readElementAttribute(sessionId, elementId, "content-desc") ?? await readElementAttribute(sessionId, elementId, "contentDescription");
|
|
331137
|
+
const text = await readElementAttribute(sessionId, elementId, "text");
|
|
331138
|
+
if (!resourceId && !contentDesc && !text) return null;
|
|
331139
|
+
const bundle = {
|
|
331140
|
+
xpath: harvestFallbackXpath("ANDROID", { text, contentDesc, resourceId }),
|
|
331141
|
+
className
|
|
331142
|
+
};
|
|
331143
|
+
if (contentDesc) bundle.accessibilityId = contentDesc;
|
|
331144
|
+
if (resourceId) bundle.resourceId = resourceId;
|
|
331145
|
+
if (text) bundle.text = text;
|
|
331146
|
+
if (contentDesc) bundle.contentDesc = contentDesc;
|
|
331147
|
+
return bundle;
|
|
331148
|
+
}
|
|
331149
|
+
function isStateAdvancingAction(action) {
|
|
331150
|
+
return action === "tap" || action === "type" || action === "clear" || action === "swipe" || action === "scroll" || action === "back" || action === "home";
|
|
331151
|
+
}
|
|
331152
|
+
function stepNeedsLocator(action) {
|
|
331153
|
+
return action === "tap" || action === "type" || action === "clear" || action === "waitForElement" || action === "assertVisible" || action === "assertText";
|
|
331154
|
+
}
|
|
331155
|
+
async function driveHarvestStep(sessionId, step, elementId, platform3) {
|
|
331156
|
+
switch (step.action) {
|
|
331157
|
+
case "tap":
|
|
331158
|
+
if (!elementId) return false;
|
|
331159
|
+
await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
|
|
331160
|
+
return true;
|
|
331161
|
+
case "type": {
|
|
331162
|
+
if (step.value === void 0 || step.value === null || step.value === "") return true;
|
|
331163
|
+
if (!elementId) return false;
|
|
331164
|
+
await wdRequest(`/session/${sessionId}/element/${elementId}/click`, { method: "POST", body: "{}" });
|
|
331165
|
+
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
331166
|
+
const err = await trySendElementValue(sessionId, elementId, String(step.value));
|
|
331167
|
+
return !err;
|
|
331168
|
+
}
|
|
331169
|
+
case "clear":
|
|
331170
|
+
if (!elementId) return false;
|
|
331171
|
+
await wdRequest(`/session/${sessionId}/element/${elementId}/clear`, { method: "POST", body: "{}" });
|
|
331172
|
+
return true;
|
|
331173
|
+
case "swipe":
|
|
331174
|
+
case "scroll":
|
|
331175
|
+
await executeSwipe(sessionId, step);
|
|
331176
|
+
return true;
|
|
331177
|
+
case "back":
|
|
331178
|
+
await executeBack(sessionId);
|
|
331179
|
+
return true;
|
|
331180
|
+
case "home":
|
|
331181
|
+
await executeHome(sessionId, platform3);
|
|
331182
|
+
return true;
|
|
331183
|
+
case "launchApp":
|
|
331184
|
+
case "waitForElement":
|
|
331185
|
+
case "assertVisible":
|
|
331186
|
+
case "assertText":
|
|
331187
|
+
return true;
|
|
331188
|
+
}
|
|
331189
|
+
}
|
|
331190
|
+
async function harvestPlatformLocators(options) {
|
|
331191
|
+
const platform3 = options.target.platform;
|
|
331192
|
+
const logLines = [];
|
|
331193
|
+
const log2 = (msg) => {
|
|
331194
|
+
logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
|
|
331195
|
+
};
|
|
331196
|
+
const { steps: harvestSteps, missingKeys: missingVariableKeys } = resolveMobileStepValuePlaceholders(options.steps, options.testVariables);
|
|
331197
|
+
if (missingVariableKeys.length > 0) {
|
|
331198
|
+
log2(`\u26A0 Unresolved step-value placeholder(s): ${missingVariableKeys.map((k) => `{{${k}}}`).join(", ")} \u2014 no matching test variable configured.`);
|
|
331199
|
+
}
|
|
331200
|
+
const bundles = {};
|
|
331201
|
+
const divergentOrders = [];
|
|
331202
|
+
let reachedOrder = null;
|
|
331203
|
+
let environmentalError;
|
|
331204
|
+
log2(`Cross-platform locator discovery \u2014 resolving ${platform3} locators for ${harvestSteps.length} steps`);
|
|
331205
|
+
const session = await createMobileDiscoverySession({
|
|
331206
|
+
appId: options.target.appId,
|
|
331207
|
+
platform: platform3,
|
|
331208
|
+
platformVersion: options.target.platformVersion,
|
|
331209
|
+
recordedDeviceId: options.target.recordedDeviceId,
|
|
331210
|
+
assignedDeviceId: options.target.assignedDeviceId,
|
|
331211
|
+
launchMode: "LAUNCH_APP"
|
|
331212
|
+
});
|
|
331213
|
+
options.onSessionReady?.(session.sessionId, session.deviceId, platform3);
|
|
331214
|
+
try {
|
|
331215
|
+
await freshLaunchTargetApp(session.sessionId, platform3, options.target.appId, log2);
|
|
331216
|
+
if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
|
|
331217
|
+
try {
|
|
331218
|
+
await openDeepLink(session.sessionId, platform3, options.target.deeplink, options.target.appId);
|
|
331219
|
+
} catch (err) {
|
|
331220
|
+
log2(` Deep link failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
331221
|
+
}
|
|
331222
|
+
}
|
|
331223
|
+
await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, "harvest start", false);
|
|
331224
|
+
for (const step of harvestSteps) {
|
|
331225
|
+
if (step.action === "launchApp") continue;
|
|
331226
|
+
if (!stepAppliesToPlatform(step, platform3)) continue;
|
|
331227
|
+
let elementId = null;
|
|
331228
|
+
const identityStrategies = buildIdentityLocatorStrategies(step.target);
|
|
331229
|
+
if (identityStrategies.length > 0 && step.target) {
|
|
331230
|
+
try {
|
|
331231
|
+
elementId = await findElement(session.sessionId, step.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
|
|
331232
|
+
} catch (err) {
|
|
331233
|
+
if (isTransportError(err)) throw err;
|
|
331234
|
+
elementId = null;
|
|
331235
|
+
}
|
|
331236
|
+
}
|
|
331237
|
+
if (elementId) {
|
|
331238
|
+
const bundle = await readNativeElementBundle(session.sessionId, elementId, platform3);
|
|
331239
|
+
if (bundle) {
|
|
331240
|
+
bundles[step.order] = bundle;
|
|
331241
|
+
log2(` \u2713 Step ${step.order} (${step.action}) resolved on ${platform3}`);
|
|
331242
|
+
} else {
|
|
331243
|
+
divergentOrders.push(step.order);
|
|
331244
|
+
log2(` \u26A0 Step ${step.order} (${step.action}) resolved but has no durable identity \u2014 flagged divergent`);
|
|
331245
|
+
}
|
|
331246
|
+
} else if (stepNeedsLocator(step.action)) {
|
|
331247
|
+
divergentOrders.push(step.order);
|
|
331248
|
+
log2(` \u26A0 Step ${step.order} (${step.action}) could not be resolved on ${platform3} \u2014 needs manual authoring`);
|
|
331249
|
+
}
|
|
331250
|
+
const advanced = await driveHarvestStep(session.sessionId, step, elementId, platform3);
|
|
331251
|
+
if (!advanced && isStateAdvancingAction(step.action)) {
|
|
331252
|
+
log2(` \u2717 Could not advance step ${step.order} (${step.action}) on ${platform3}; stopping harvest early`);
|
|
331253
|
+
break;
|
|
331254
|
+
}
|
|
331255
|
+
reachedOrder = step.order;
|
|
331256
|
+
await restoreTargetAppIfExternal(session.sessionId, { appId: options.target.appId, platform: platform3 }, log2, `harvest step ${step.order}`, false);
|
|
331257
|
+
}
|
|
331258
|
+
} catch (err) {
|
|
331259
|
+
environmentalError = err instanceof Error ? err.message : String(err);
|
|
331260
|
+
log2(` \u2717 Harvest aborted: ${environmentalError}`);
|
|
331261
|
+
} finally {
|
|
331262
|
+
options.onSessionEnding?.(session.deviceId);
|
|
331263
|
+
await session.stop().catch(() => {
|
|
331264
|
+
});
|
|
331265
|
+
}
|
|
331266
|
+
return {
|
|
331267
|
+
platform: platform3,
|
|
331268
|
+
bundles,
|
|
331269
|
+
divergentOrders,
|
|
331270
|
+
reachedOrder,
|
|
331271
|
+
deviceId: session.deviceId,
|
|
331272
|
+
deviceName: session.deviceName,
|
|
331273
|
+
logs: logLines.join("\n"),
|
|
331274
|
+
...environmentalError ? { environmentalError } : {}
|
|
331275
|
+
};
|
|
331276
|
+
}
|
|
330755
331277
|
|
|
330756
331278
|
// src/mobile-device-queue.ts
|
|
330757
331279
|
function isNativeMobileDiscoveryContext(ctx) {
|
|
@@ -330943,12 +331465,46 @@ async function tapAt(sessionPath, bounds) {
|
|
|
330943
331465
|
throw new Error("TAP requires usable bounds (finite x/y)");
|
|
330944
331466
|
}
|
|
330945
331467
|
}
|
|
330946
|
-
async function
|
|
331468
|
+
async function getElementRect(sessionPath, elementId) {
|
|
331469
|
+
try {
|
|
331470
|
+
const value = readValue(await appiumRequest(`${sessionPath}/element/${elementId}/rect`));
|
|
331471
|
+
if (!value || typeof value !== "object") return null;
|
|
331472
|
+
const r = value;
|
|
331473
|
+
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)) {
|
|
331474
|
+
return null;
|
|
331475
|
+
}
|
|
331476
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
331477
|
+
} catch {
|
|
331478
|
+
return null;
|
|
331479
|
+
}
|
|
331480
|
+
}
|
|
331481
|
+
function rectMatchesIntendedBounds(rect, bounds) {
|
|
331482
|
+
if (bounds.width <= 0 || bounds.height <= 0) return false;
|
|
331483
|
+
const rectCenterX = rect.x + rect.width / 2;
|
|
331484
|
+
const rectCenterY = rect.y + rect.height / 2;
|
|
331485
|
+
const boundsCenterX = bounds.x + bounds.width / 2;
|
|
331486
|
+
const boundsCenterY = bounds.y + bounds.height / 2;
|
|
331487
|
+
const rectCenterInBounds = rectCenterX >= bounds.x && rectCenterX <= bounds.x + bounds.width && rectCenterY >= bounds.y && rectCenterY <= bounds.y + bounds.height;
|
|
331488
|
+
const boundsCenterInRect = rect.width > 0 && rect.height > 0 && boundsCenterX >= rect.x && boundsCenterX <= rect.x + rect.width && boundsCenterY >= rect.y && boundsCenterY <= rect.y + rect.height;
|
|
331489
|
+
return rectCenterInBounds || boundsCenterInRect;
|
|
331490
|
+
}
|
|
331491
|
+
async function resolveTypeTargetElementId(sessionPath, bounds) {
|
|
331492
|
+
const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
|
|
330947
331493
|
let elementId = await getActiveElementId2(sessionPath);
|
|
331494
|
+
if (elementId && box && box.width > 0 && box.height > 0) {
|
|
331495
|
+
const rect = await getElementRect(sessionPath, elementId);
|
|
331496
|
+
if (!rect || !rectMatchesIntendedBounds(rect, box)) {
|
|
331497
|
+
elementId = null;
|
|
331498
|
+
}
|
|
331499
|
+
}
|
|
330948
331500
|
if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
|
|
330949
331501
|
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
330950
331502
|
elementId = await getActiveElementId2(sessionPath);
|
|
330951
331503
|
}
|
|
331504
|
+
return elementId;
|
|
331505
|
+
}
|
|
331506
|
+
async function typeText(sessionPath, value, bounds) {
|
|
331507
|
+
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
330952
331508
|
if (!elementId) {
|
|
330953
331509
|
throw new Error("No focused element available for TYPE. Tap an input field first.");
|
|
330954
331510
|
}
|
|
@@ -330958,11 +331514,7 @@ async function typeText(sessionPath, value, bounds) {
|
|
|
330958
331514
|
});
|
|
330959
331515
|
}
|
|
330960
331516
|
async function clearField(sessionPath, bounds) {
|
|
330961
|
-
|
|
330962
|
-
if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
|
|
330963
|
-
await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
|
|
330964
|
-
elementId = await getActiveElementId2(sessionPath);
|
|
330965
|
-
}
|
|
331517
|
+
const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
|
|
330966
331518
|
if (!elementId) {
|
|
330967
331519
|
throw new Error("No focused element available for CLEAR. Tap an input field first.");
|
|
330968
331520
|
}
|
|
@@ -332712,6 +333264,7 @@ function scrubAuditMessages(messages, testVariables) {
|
|
|
332712
333264
|
const secretValues = Object.values(testVariables).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
|
|
332713
333265
|
if (secretValues.length === 0) return compacted;
|
|
332714
333266
|
let json = JSON.stringify(compacted);
|
|
333267
|
+
if (typeof json !== "string") return compacted;
|
|
332715
333268
|
for (const val of secretValues) {
|
|
332716
333269
|
const escaped = val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
332717
333270
|
json = json.replace(new RegExp(escaped, "g"), "[REDACTED]");
|
|
@@ -334009,6 +334562,104 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334009
334562
|
log.fail("ERROR - No discovery context");
|
|
334010
334563
|
return;
|
|
334011
334564
|
}
|
|
334565
|
+
const locatorCtx = ctx;
|
|
334566
|
+
if (locatorCtx.mode === "locator-discovery") {
|
|
334567
|
+
const harvestTarget = locatorCtx.target;
|
|
334568
|
+
const targetTestCaseId = locatorCtx.targetTestCaseId;
|
|
334569
|
+
const platform3 = locatorCtx.platform ?? harvestTarget?.platform;
|
|
334570
|
+
if (!platform3 || !harvestTarget?.appId || !targetTestCaseId) {
|
|
334571
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
334572
|
+
status: "ERROR",
|
|
334573
|
+
error: "CONFIG_ISSUE: locator-discovery run is missing platform/appId/targetTestCaseId."
|
|
334574
|
+
});
|
|
334575
|
+
log.fail("ERROR - malformed locator-discovery context");
|
|
334576
|
+
return;
|
|
334577
|
+
}
|
|
334578
|
+
if (getBridgeState().hasActiveSession) {
|
|
334579
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
334580
|
+
status: "ERROR",
|
|
334581
|
+
error: "CONFIG_ISSUE: the shared mobile device is busy with an active recording session; retry once it ends."
|
|
334582
|
+
});
|
|
334583
|
+
log.fail("ERROR - device busy (recording) for locator discovery");
|
|
334584
|
+
return;
|
|
334585
|
+
}
|
|
334586
|
+
const leasedName = assignedDeviceId ? getCachedDeviceSnapshot().find((d) => d.id === assignedDeviceId)?.name : void 0;
|
|
334587
|
+
liveBrowserHandle.patch({
|
|
334588
|
+
mode: "discovery",
|
|
334589
|
+
testName: "Cross-platform locator discovery",
|
|
334590
|
+
note: `${platform3} locator discovery`,
|
|
334591
|
+
surface: "mobile",
|
|
334592
|
+
mobilePlatform: platform3,
|
|
334593
|
+
...assignedDeviceId ? { deviceId: assignedDeviceId } : {},
|
|
334594
|
+
...leasedName ? { deviceName: leasedName } : {}
|
|
334595
|
+
});
|
|
334596
|
+
requestLiveBrowserHeartbeat(true);
|
|
334597
|
+
log.info(`Cross-platform locator discovery \u2014 ${platform3} / ${harvestTarget.appId} for test ${targetTestCaseId}`);
|
|
334598
|
+
setExecutorBusy(true);
|
|
334599
|
+
try {
|
|
334600
|
+
const harvest = await harvestPlatformLocators({
|
|
334601
|
+
steps: locatorCtx.sourceSteps ?? [],
|
|
334602
|
+
target: {
|
|
334603
|
+
appId: harvestTarget.appId,
|
|
334604
|
+
platform: platform3,
|
|
334605
|
+
platformVersion: harvestTarget.platformVersion,
|
|
334606
|
+
recordedDeviceId: harvestTarget.recordedDeviceId,
|
|
334607
|
+
assignedDeviceId,
|
|
334608
|
+
launchMode: harvestTarget.launchMode,
|
|
334609
|
+
deeplink: harvestTarget.deeplink
|
|
334610
|
+
},
|
|
334611
|
+
onSessionReady: (sessionId, deviceId, sessionPlatform) => {
|
|
334612
|
+
attachMirrorSession(sessionId, sessionPlatform, deviceId);
|
|
334613
|
+
const name = getCachedDeviceSnapshot().find((d) => d.id === deviceId)?.name;
|
|
334614
|
+
liveBrowserHandle.patch({ deviceId, ...name ? { deviceName: name } : {} });
|
|
334615
|
+
requestLiveBrowserHeartbeat(true);
|
|
334616
|
+
},
|
|
334617
|
+
onSessionEnding: (deviceId) => {
|
|
334618
|
+
detachMirrorSession(deviceId);
|
|
334619
|
+
},
|
|
334620
|
+
// Resolve {{KEY}} credential placeholders in guided-replay TYPE steps.
|
|
334621
|
+
testVariables: run2.testVariables
|
|
334622
|
+
});
|
|
334623
|
+
let queuedRunId = null;
|
|
334624
|
+
let resultPosted = false;
|
|
334625
|
+
try {
|
|
334626
|
+
const res = await fetch(`${opts.serverUrl}/api/runner/runs/${run2.runId}/locator-discovery-result`, {
|
|
334627
|
+
method: "POST",
|
|
334628
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
334629
|
+
body: JSON.stringify({
|
|
334630
|
+
bundles: harvest.bundles,
|
|
334631
|
+
divergentOrders: harvest.divergentOrders,
|
|
334632
|
+
reachedOrder: harvest.reachedOrder,
|
|
334633
|
+
logs: harvest.logs,
|
|
334634
|
+
...harvest.environmentalError ? { environmentalError: harvest.environmentalError } : {}
|
|
334635
|
+
}),
|
|
334636
|
+
signal: AbortSignal.timeout(3e4)
|
|
334637
|
+
});
|
|
334638
|
+
if (res.ok) {
|
|
334639
|
+
resultPosted = true;
|
|
334640
|
+
const data = await res.json().catch(() => null);
|
|
334641
|
+
queuedRunId = data?.queuedRunId ?? null;
|
|
334642
|
+
} else {
|
|
334643
|
+
log.warn(`[locator-discovery] result POST returned ${res.status}`);
|
|
334644
|
+
}
|
|
334645
|
+
} catch (postErr) {
|
|
334646
|
+
log.warn(`[locator-discovery] result POST failed: ${postErr instanceof Error ? postErr.message : String(postErr)}`);
|
|
334647
|
+
}
|
|
334648
|
+
const passed = resultPosted && !harvest.environmentalError && harvest.reachedOrder != null;
|
|
334649
|
+
await postResult(opts.serverUrl, headers, run2.runId, {
|
|
334650
|
+
status: passed ? "PASSED" : "ERROR",
|
|
334651
|
+
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,
|
|
334652
|
+
logs: harvest.logs,
|
|
334653
|
+
duration: Date.now() - startTime
|
|
334654
|
+
});
|
|
334655
|
+
log[passed ? "ok" : "fail"](
|
|
334656
|
+
`Locator discovery ${passed ? "complete" : "ended"} \u2014 ${Object.keys(harvest.bundles).length} overlays, ${harvest.divergentOrders.length} divergent${queuedRunId ? ` (queued run ${queuedRunId})` : ""}`
|
|
334657
|
+
);
|
|
334658
|
+
} finally {
|
|
334659
|
+
setExecutorBusy(false);
|
|
334660
|
+
}
|
|
334661
|
+
return;
|
|
334662
|
+
}
|
|
334012
334663
|
log.info(`Discovery (${ctx.mode}) on ${ctx.baseUrl}`);
|
|
334013
334664
|
const { push: pushAudit, drain: drainAudit } = createAuditFlusher(
|
|
334014
334665
|
`${opts.serverUrl}/api/runner/runs/${run2.runId}/ai-audit-batch`,
|
|
@@ -334722,7 +335373,11 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334722
335373
|
recordedDeviceId: target.recordedDeviceId,
|
|
334723
335374
|
assignedDeviceId,
|
|
334724
335375
|
launchMode: target.launchMode,
|
|
334725
|
-
deeplink: target.deeplink
|
|
335376
|
+
deeplink: target.deeplink,
|
|
335377
|
+
// Authoring platform (test's session medium). When it differs from
|
|
335378
|
+
// target.platform this is a cross-platform run: no blind coordinate
|
|
335379
|
+
// taps on foreign geometry, and the run must verify an outcome.
|
|
335380
|
+
authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
|
|
334726
335381
|
},
|
|
334727
335382
|
// Stream this run to its OWN per-device dashboard tile (keyed by the
|
|
334728
335383
|
// leased device id) so N parallel Appium runs each show a live mirror.
|
|
@@ -334732,7 +335387,11 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
334732
335387
|
},
|
|
334733
335388
|
onSessionEnding: (deviceId) => {
|
|
334734
335389
|
detachMirrorSession(deviceId);
|
|
334735
|
-
}
|
|
335390
|
+
},
|
|
335391
|
+
// Resolve {{KEY}} credential placeholders (recorded by the mobile
|
|
335392
|
+
// discovery generator instead of secret values) against this run's
|
|
335393
|
+
// CURRENT test variables.
|
|
335394
|
+
testVariables: run2.testVariables
|
|
334736
335395
|
});
|
|
334737
335396
|
attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
|
|
334738
335397
|
${result2.logs}`);
|