@validate.qa/runner 1.0.29 → 1.0.31
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 +948 -128
- package/dist/cli.mjs +948 -128
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1584,7 +1584,11 @@ function buildStepTarget(metadata, selector) {
|
|
|
1584
1584
|
};
|
|
1585
1585
|
}
|
|
1586
1586
|
function labelForAction(action, metadata) {
|
|
1587
|
-
|
|
1587
|
+
const editable = isEditableMobileClassName(metadata.locatorBundle?.className);
|
|
1588
|
+
const locatorText = nonEmpty(metadata.locatorBundle?.text);
|
|
1589
|
+
const targetLabel = nonEmpty(metadata.targetLabel);
|
|
1590
|
+
const targetLabelIsEditableValue = editable && targetLabel !== void 0 && locatorText !== void 0 && targetLabel === locatorText;
|
|
1591
|
+
return (targetLabelIsEditableValue ? void 0 : targetLabel) ?? nonEmpty(metadata.locatorBundle?.accessibilityId) ?? nonEmpty(metadata.locatorBundle?.resourceId) ?? (editable ? void 0 : locatorText) ?? nonEmpty(action.selector) ?? nonEmpty(action.url) ?? "element";
|
|
1588
1592
|
}
|
|
1589
1593
|
function descriptionForStep(action, stepAction, metadata) {
|
|
1590
1594
|
const label = labelForAction(action, metadata);
|
|
@@ -2037,6 +2041,9 @@ function resolvePlatformLocatorBundle(target, platform3) {
|
|
|
2037
2041
|
function hasPlatformLocator(target, platform3) {
|
|
2038
2042
|
return Boolean(target?.platformLocators?.[platform3]);
|
|
2039
2043
|
}
|
|
2044
|
+
function isLocatorRequiringAction(action) {
|
|
2045
|
+
return LOCATOR_REQUIRING_ACTIONS.has(action);
|
|
2046
|
+
}
|
|
2040
2047
|
function hasCrossPlatformIdentity(bundle) {
|
|
2041
2048
|
if (!bundle)
|
|
2042
2049
|
return false;
|
|
@@ -2084,6 +2091,115 @@ function isTestReadyForPlatform(steps, authoringPlatform, runPlatform) {
|
|
|
2084
2091
|
return true;
|
|
2085
2092
|
return stepsMissingPlatformLocators(steps, runPlatform).length === 0 && stepsNeedingReviewForPlatform(steps, runPlatform).length === 0;
|
|
2086
2093
|
}
|
|
2094
|
+
function summarizeMobileReadiness(steps, authoringPlatform) {
|
|
2095
|
+
return MOBILE_PLATFORMS.map((platform3) => {
|
|
2096
|
+
const locatorSteps = steps.filter((step) => isLocatorRequiringAction(step.action) && stepAppliesToPlatform(step, platform3)).length;
|
|
2097
|
+
const executableSteps = steps.filter((step) => stepAppliesToPlatform(step, platform3)).length;
|
|
2098
|
+
if (authoringPlatform === platform3) {
|
|
2099
|
+
return {
|
|
2100
|
+
platform: platform3,
|
|
2101
|
+
authoringPlatform,
|
|
2102
|
+
ready: true,
|
|
2103
|
+
status: "ready",
|
|
2104
|
+
locatorSteps,
|
|
2105
|
+
executableSteps,
|
|
2106
|
+
missingLocators: 0,
|
|
2107
|
+
needsReview: 0
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
const missingLocators = stepsMissingPlatformLocators(steps, platform3).length;
|
|
2111
|
+
const needsReview = stepsNeedingReviewForPlatform(steps, platform3).length;
|
|
2112
|
+
const ready = missingLocators === 0 && needsReview === 0;
|
|
2113
|
+
return {
|
|
2114
|
+
platform: platform3,
|
|
2115
|
+
...authoringPlatform ? { authoringPlatform } : {},
|
|
2116
|
+
ready,
|
|
2117
|
+
status: ready ? "ready" : needsReview > 0 ? "needs_review" : "needs_locator_discovery",
|
|
2118
|
+
locatorSteps,
|
|
2119
|
+
executableSteps,
|
|
2120
|
+
missingLocators,
|
|
2121
|
+
needsReview
|
|
2122
|
+
};
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
function hasStableNativeIdentity(bundle) {
|
|
2126
|
+
if (!bundle)
|
|
2127
|
+
return false;
|
|
2128
|
+
return Boolean(bundle.accessibilityId?.trim() || bundle.resourceId?.trim() || bundle.text?.trim() || bundle.contentDesc?.trim() || bundle.xpath?.trim() && /@\w+\s*=/.test(bundle.xpath) && !/(?:contains|starts-with)\s*\(/i.test(bundle.xpath));
|
|
2129
|
+
}
|
|
2130
|
+
function isPositionalXpath(xpath) {
|
|
2131
|
+
const value = xpath?.trim();
|
|
2132
|
+
if (!value)
|
|
2133
|
+
return false;
|
|
2134
|
+
return value.startsWith("/") && !/@\w+\s*=/.test(value);
|
|
2135
|
+
}
|
|
2136
|
+
function addSelectorIssue(issues, step, severity, reason) {
|
|
2137
|
+
issues.push({
|
|
2138
|
+
order: step.order,
|
|
2139
|
+
action: step.action,
|
|
2140
|
+
severity,
|
|
2141
|
+
reason,
|
|
2142
|
+
...step.target?.primary ? { targetLabel: step.target.primary } : {},
|
|
2143
|
+
...step.target?.screenName ? { screenName: step.target.screenName } : {}
|
|
2144
|
+
});
|
|
2145
|
+
}
|
|
2146
|
+
function analyzeMobileSelectorQuality(steps) {
|
|
2147
|
+
const issues = [];
|
|
2148
|
+
let locatorSteps = 0;
|
|
2149
|
+
let stableLocatorSteps = 0;
|
|
2150
|
+
let crossPlatformIdentitySteps = 0;
|
|
2151
|
+
let coordinateFallbackSteps = 0;
|
|
2152
|
+
let missingLocatorSteps = 0;
|
|
2153
|
+
let positionalXpathSteps = 0;
|
|
2154
|
+
let classOnlySteps = 0;
|
|
2155
|
+
for (const step of steps) {
|
|
2156
|
+
if (!isLocatorRequiringAction(step.action))
|
|
2157
|
+
continue;
|
|
2158
|
+
locatorSteps += 1;
|
|
2159
|
+
const target = step.target;
|
|
2160
|
+
const bundle = target?.locatorBundle;
|
|
2161
|
+
if (!target || !bundle) {
|
|
2162
|
+
missingLocatorSteps += 1;
|
|
2163
|
+
addSelectorIssue(issues, step, "high", "No native locator bundle is stored for this element step.");
|
|
2164
|
+
continue;
|
|
2165
|
+
}
|
|
2166
|
+
if (hasStableNativeIdentity(bundle))
|
|
2167
|
+
stableLocatorSteps += 1;
|
|
2168
|
+
if (hasCrossPlatformIdentity(bundle))
|
|
2169
|
+
crossPlatformIdentitySteps += 1;
|
|
2170
|
+
if (target.bounds && !hasStableNativeIdentity(bundle)) {
|
|
2171
|
+
coordinateFallbackSteps += 1;
|
|
2172
|
+
addSelectorIssue(issues, step, "medium", "Step may replay by coordinates because the locator bundle has no stable native identity.");
|
|
2173
|
+
}
|
|
2174
|
+
if (isPositionalXpath(bundle.xpath)) {
|
|
2175
|
+
positionalXpathSteps += 1;
|
|
2176
|
+
addSelectorIssue(issues, step, "medium", "Locator relies on a positional XPath, which is brittle across app versions and platforms.");
|
|
2177
|
+
}
|
|
2178
|
+
if (bundle.className?.trim() && !hasStableNativeIdentity(bundle)) {
|
|
2179
|
+
classOnlySteps += 1;
|
|
2180
|
+
addSelectorIssue(issues, step, "medium", "Locator is class-only with no accessibility id, resource id, text, or content description.");
|
|
2181
|
+
}
|
|
2182
|
+
if (!hasCrossPlatformIdentity(bundle)) {
|
|
2183
|
+
addSelectorIssue(issues, step, "low", "No cross-platform identity signal is available; iOS/Android reuse may need locator discovery or manual review.");
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
const high = issues.filter((issue) => issue.severity === "high").length;
|
|
2187
|
+
const medium = issues.filter((issue) => issue.severity === "medium").length;
|
|
2188
|
+
const low = issues.filter((issue) => issue.severity === "low").length;
|
|
2189
|
+
const score = Math.max(0, Math.min(100, 100 - high * 25 - medium * 10 - low * 3));
|
|
2190
|
+
return {
|
|
2191
|
+
totalSteps: steps.length,
|
|
2192
|
+
locatorSteps,
|
|
2193
|
+
stableLocatorSteps,
|
|
2194
|
+
crossPlatformIdentitySteps,
|
|
2195
|
+
coordinateFallbackSteps,
|
|
2196
|
+
missingLocatorSteps,
|
|
2197
|
+
positionalXpathSteps,
|
|
2198
|
+
classOnlySteps,
|
|
2199
|
+
issues,
|
|
2200
|
+
summary: { high, medium, low, score }
|
|
2201
|
+
};
|
|
2202
|
+
}
|
|
2087
2203
|
var MOBILE_PLATFORMS, LOCATOR_REQUIRING_ACTIONS;
|
|
2088
2204
|
var init_mobile_cross_platform = __esm({
|
|
2089
2205
|
"../shared/dist/mobile-cross-platform.js"() {
|
|
@@ -2228,40 +2344,40 @@ var init_plans = __esm({
|
|
|
2228
2344
|
type: "extra_projects",
|
|
2229
2345
|
label: "Extra Projects",
|
|
2230
2346
|
description: "Add more projects to your workspace",
|
|
2231
|
-
priceLabel: "$
|
|
2232
|
-
monthlyPriceUsd:
|
|
2347
|
+
priceLabel: "$24.99 / month",
|
|
2348
|
+
monthlyPriceUsd: 24.99,
|
|
2233
2349
|
limitIncrement: 1
|
|
2234
2350
|
},
|
|
2235
2351
|
extra_tests: {
|
|
2236
2352
|
type: "extra_tests",
|
|
2237
2353
|
label: "Extra Tests",
|
|
2238
2354
|
description: "Increase tests per project limit",
|
|
2239
|
-
priceLabel: "$
|
|
2240
|
-
monthlyPriceUsd:
|
|
2241
|
-
limitIncrement:
|
|
2355
|
+
priceLabel: "$12.99 / month",
|
|
2356
|
+
monthlyPriceUsd: 12.99,
|
|
2357
|
+
limitIncrement: 50
|
|
2242
2358
|
},
|
|
2243
2359
|
extra_suite_runs: {
|
|
2244
2360
|
type: "extra_suite_runs",
|
|
2245
2361
|
label: "Extra Suite Runs",
|
|
2246
2362
|
description: "Increase daily suite run limit",
|
|
2247
|
-
priceLabel: "$
|
|
2248
|
-
monthlyPriceUsd:
|
|
2363
|
+
priceLabel: "$12.99 / month",
|
|
2364
|
+
monthlyPriceUsd: 12.99,
|
|
2249
2365
|
limitIncrement: 5
|
|
2250
2366
|
},
|
|
2251
2367
|
extra_discovery_runs: {
|
|
2252
2368
|
type: "extra_discovery_runs",
|
|
2253
2369
|
label: "Extra Discovery Runs",
|
|
2254
2370
|
description: "Increase discovery run limit",
|
|
2255
|
-
priceLabel: "$
|
|
2256
|
-
monthlyPriceUsd:
|
|
2371
|
+
priceLabel: "$19.99 / month",
|
|
2372
|
+
monthlyPriceUsd: 19.99,
|
|
2257
2373
|
limitIncrement: 5
|
|
2258
2374
|
},
|
|
2259
2375
|
extra_test_executions: {
|
|
2260
2376
|
type: "extra_test_executions",
|
|
2261
2377
|
label: "Extra Test Executions",
|
|
2262
2378
|
description: "+100 manual test runs per day, on top of your plan\u2019s daily dashboard run budget",
|
|
2263
|
-
priceLabel: "$
|
|
2264
|
-
monthlyPriceUsd:
|
|
2379
|
+
priceLabel: "$14.99 / month",
|
|
2380
|
+
monthlyPriceUsd: 14.99,
|
|
2265
2381
|
limitIncrement: 100
|
|
2266
2382
|
}
|
|
2267
2383
|
};
|
|
@@ -2286,18 +2402,18 @@ var init_plans = __esm({
|
|
|
2286
2402
|
PLAN_DEFINITIONS = {
|
|
2287
2403
|
standard: {
|
|
2288
2404
|
code: "standard",
|
|
2289
|
-
label: "Standard
|
|
2405
|
+
label: "Standard",
|
|
2290
2406
|
priceLabel: "Free",
|
|
2291
2407
|
monthlyPriceUsd: 0,
|
|
2292
2408
|
billingModel: "free",
|
|
2293
|
-
summary: "For curious developers. Get the full platform - MCP, Discovery, Voice, self-heal - with
|
|
2409
|
+
summary: "For curious developers. Get the full platform - MCP, Discovery, Voice, self-heal - with a starter budget for one real project.",
|
|
2294
2410
|
maxProjects: 1,
|
|
2295
|
-
maxTestsPerProject:
|
|
2411
|
+
maxTestsPerProject: 20,
|
|
2296
2412
|
maxSuiteRunsPerDay: 1,
|
|
2297
|
-
maxTestExecutionsPerDay:
|
|
2298
|
-
//
|
|
2299
|
-
maxDiscoveryRunsPerDay:
|
|
2300
|
-
//
|
|
2413
|
+
maxTestExecutionsPerDay: 10,
|
|
2414
|
+
// 5 regression / 5 manual at the default 50/50 split
|
|
2415
|
+
maxDiscoveryRunsPerDay: 1,
|
|
2416
|
+
// Per day
|
|
2301
2417
|
maxConcurrentDiscoveries: 1,
|
|
2302
2418
|
maxHealsPerDay: 5,
|
|
2303
2419
|
maxRegenerationsPerDay: 2,
|
|
@@ -2309,8 +2425,8 @@ var init_plans = __esm({
|
|
|
2309
2425
|
standard_plus: {
|
|
2310
2426
|
code: "standard_plus",
|
|
2311
2427
|
label: "Standard +",
|
|
2312
|
-
priceLabel: "$
|
|
2313
|
-
monthlyPriceUsd:
|
|
2428
|
+
priceLabel: "$99.99 / month",
|
|
2429
|
+
monthlyPriceUsd: 99.99,
|
|
2314
2430
|
billingModel: "subscription",
|
|
2315
2431
|
summary: "For solo devs and small teams shipping weekly. 200 tests, 100 test runs/day, full MCP. The plan most teams stay on.",
|
|
2316
2432
|
maxProjects: 1,
|
|
@@ -2375,16 +2491,16 @@ var init_plans = __esm({
|
|
|
2375
2491
|
requiresContact: true
|
|
2376
2492
|
}
|
|
2377
2493
|
};
|
|
2378
|
-
TRIAL_DURATION_DAYS =
|
|
2494
|
+
TRIAL_DURATION_DAYS = 30;
|
|
2379
2495
|
CREDIT_PRICES_CENTS = {
|
|
2380
|
-
discovery_run:
|
|
2496
|
+
discovery_run: 200,
|
|
2497
|
+
// $2.00
|
|
2498
|
+
suite_run: 50,
|
|
2381
2499
|
// $0.50
|
|
2382
|
-
|
|
2383
|
-
// $0.
|
|
2384
|
-
|
|
2385
|
-
// $0.
|
|
2386
|
-
heal: 10
|
|
2387
|
-
// $0.10
|
|
2500
|
+
test_run: 15,
|
|
2501
|
+
// $0.15
|
|
2502
|
+
heal: 75
|
|
2503
|
+
// $0.75
|
|
2388
2504
|
};
|
|
2389
2505
|
AI_CREDIT_MARGIN = 1.5;
|
|
2390
2506
|
CREDIT_PACKS_CENTS = [2e3, 5e3, 1e4];
|
|
@@ -3791,6 +3907,7 @@ __export(dist_exports, {
|
|
|
3791
3907
|
UI_VARIANTS: () => UI_VARIANTS,
|
|
3792
3908
|
accumulateStatements: () => accumulateStatements,
|
|
3793
3909
|
aiCostToCreditCents: () => aiCostToCreditCents,
|
|
3910
|
+
analyzeMobileSelectorQuality: () => analyzeMobileSelectorQuality,
|
|
3794
3911
|
bugFoundQuarantineWhere: () => bugFoundQuarantineWhere,
|
|
3795
3912
|
buildAppiumCode: () => buildAppiumCode,
|
|
3796
3913
|
buildLaunchStep: () => buildLaunchStep,
|
|
@@ -3877,6 +3994,7 @@ __export(dist_exports, {
|
|
|
3877
3994
|
stripEnvironmentalPrefix: () => stripEnvironmentalPrefix,
|
|
3878
3995
|
stripLoginScaffolding: () => stripLoginScaffolding,
|
|
3879
3996
|
summarizeHealth: () => summarizeHealth,
|
|
3997
|
+
summarizeMobileReadiness: () => summarizeMobileReadiness,
|
|
3880
3998
|
summarizeRunHealth: () => summarizeRunHealth,
|
|
3881
3999
|
toDbPricingPlan: () => toDbPricingPlan,
|
|
3882
4000
|
validateAssertionStrength: () => validateAssertionStrength
|
|
@@ -62154,7 +62272,7 @@ var require_mobile_explorer = __commonJS({
|
|
|
62154
62272
|
}
|
|
62155
62273
|
sections.push("");
|
|
62156
62274
|
if (mode === "survey") {
|
|
62157
|
-
sections.push(
|
|
62275
|
+
sections.push("**Mission:** Map every reachable screen. For each new screen: mobile_snapshot -> capture_screen -> record_transition. Get PAST the entry gate into the app's real main content \u2014 the login/landing screens are the ENTRANCE, not the app. If test credentials are provided (see the Test Credentials section), SIGN IN with them FIRST and map the authenticated app; that is the real product surface. Prefer a real login when credentials are provided; only if login genuinely cannot be completed after honest attempts (persistent error, the form will not accept the credential, no valid account) fall back to any guest/demo/skip entry the app offers and map the app fully from there \u2014 reaching the real feature surface that way is far better coverage than getting stuck on the login wall. Do NOT submit forms with junk data or mutate real data. Call finish_exploration ONLY when every reachable screen \u2014 including the authenticated content \u2014 has been captured and no unvisited frontier screens remain.");
|
|
62158
62276
|
} else {
|
|
62159
62277
|
sections.push("**Mission:** Use the focus features like a real user. Tap, type, submit, and observe outcomes. After a meaningful flow, call record_journey. Call finish_exploration when the flows are covered.");
|
|
62160
62278
|
}
|
|
@@ -62219,7 +62337,7 @@ LOG IN FIRST \u2014 before broad exploration. The authenticated app is the real
|
|
|
62219
62337
|
4. VERIFY you are actually signed in: re-snapshot and confirm an authenticated signal appeared (a home/feed/dashboard with real content, an account/profile tab, a "Sign out" option, or your user identity). If you are still on the login form, the login failed \u2014 check for an error, re-enter, and submit again (up to 2 attempts).
|
|
62220
62338
|
5. Only AFTER you have confirmed you are signed in, map the authenticated app.
|
|
62221
62339
|
|
|
62222
|
-
|
|
62340
|
+
TRY THE REAL LOGIN FIRST (above). It is the preferred entry. BUT your #1 job is to get INTO the app and map its real content \u2014 an empty survey that only maps the login/landing screens is the worst outcome. So: if the real login does not clearly succeed within 2 honest attempts (you fill + submit and are still on the login form / see an error / the form will not accept the credential), DO NOT get stuck \u2014 take any guest/demo/skip entry the app offers (e.g. "Continue as guest", "Skip", "Try it out", a demo/preview mode) instead and map the app fully from there. A guest/demo session that reveals the real feature surface (feed, tabs, settings, profile) is FAR better coverage than a handful of pre-login screens. Prefer authenticated content when you can reach it; never abandon the survey on the login wall when a demo/guest door is open.
|
|
62223
62341
|
|
|
62224
62342
|
SECURITY RULES:
|
|
62225
62343
|
- NEVER call \`mobile_type\` with an email, password, or token value \u2014 the runner will REJECT the call and you lose an iteration. Use \`mobile_fill_credential\` for every credential field.
|
|
@@ -62265,7 +62383,7 @@ Rules:
|
|
|
62265
62383
|
**Phase 2 \u2014 Logged-in breadth first (~35% of budget).** Once signed in, find the primary navigation (bottom tab bar, drawer, or menu) and visit EVERY top-level destination ONCE, capturing each screen, BEFORE drilling into any of them. A survey that maps one tab deeply but never opens the other tabs has failed \u2014 breadth across all tabs/drawer entries is mandatory.
|
|
62266
62384
|
**Phase 3 \u2014 Depth (remaining budget).** Return to each captured tab and explore its sub-screens: open ONE list row per list (rows share a template), open menus/sheets/dialogs, capture detail screens, and mobile_swipe up/down to reveal off-screen content. record_transition whenever you move between screens.
|
|
62267
62385
|
|
|
62268
|
-
Rules that apply throughout: capture_screen every new screen (name + type \u2014 use
|
|
62386
|
+
Rules that apply throughout: capture_screen every new screen (name + type \u2014 use screenType "LANDING" for welcome/onboarding/intro screens and "AUTH" for sign-in/sign-up); record_transition on every move; mobile_back to return; mobile_relaunch only for a clean restart between episodes. When no new screens remain, call finish_exploration.
|
|
62269
62387
|
|
|
62270
62388
|
## Boundaries (survey = map, do not mutate)
|
|
62271
62389
|
- Do NOT submit forms, save, delete, purchase, or send anything. You MAY open modals/menus to catalog them, then dismiss.
|
|
@@ -64424,6 +64542,78 @@ var require_discover_ai_planner = __commonJS({
|
|
|
64424
64542
|
function meaningfulTokens(name) {
|
|
64425
64543
|
return name.toLowerCase().split(/[\s\--_]+/).filter((t) => t.length > 2 && !JOURNEY_MATCH_STOPWORDS.has(t));
|
|
64426
64544
|
}
|
|
64545
|
+
function textIncludesCredentialField(text, field) {
|
|
64546
|
+
return field === "email" ? /\b(e-?mail|username|user\s*name|login\s*id)\b/i.test(text) : /\bpassword|passcode|pin\b/i.test(text);
|
|
64547
|
+
}
|
|
64548
|
+
function textIncludesCredentialSubmit(text) {
|
|
64549
|
+
return /\b(click|tap|press|submit|send)\b[^.]{0,80}\b(sign[ -]?in|signin|log[ -]?in|login|submit|continue)\b/i.test(text) || /\b(sign[ -]?in|signin|log[ -]?in|login|submit|continue)\b[^.]{0,80}\b(button|cta|form)\b/i.test(text) || /\b(submit|sign[ -]?in|signin|log[ -]?in|login)\b[^.]{0,80}\b(test\s+)?credentials\b/i.test(text) || /\b(test\s+)?credentials\b[^.]{0,80}\b(submit|sign[ -]?in|signin|log[ -]?in|login)\b/i.test(text);
|
|
64550
|
+
}
|
|
64551
|
+
function targetPagesHaveCredentialControls(siteMap, targetPages) {
|
|
64552
|
+
const targetSet = new Set(targetPages.map(normalizePlannerRoute));
|
|
64553
|
+
let hasEmail = false;
|
|
64554
|
+
let hasPassword = false;
|
|
64555
|
+
let hasSubmit = false;
|
|
64556
|
+
for (const page of siteMap.pages) {
|
|
64557
|
+
if (!targetSet.has(normalizePlannerRoute(page.route)))
|
|
64558
|
+
continue;
|
|
64559
|
+
for (const element of page.elements ?? []) {
|
|
64560
|
+
const label = [
|
|
64561
|
+
element.label,
|
|
64562
|
+
element.role,
|
|
64563
|
+
element.testId,
|
|
64564
|
+
element.name
|
|
64565
|
+
].filter(Boolean).join(" ");
|
|
64566
|
+
if (!label)
|
|
64567
|
+
continue;
|
|
64568
|
+
if (textIncludesCredentialField(label, "email"))
|
|
64569
|
+
hasEmail = true;
|
|
64570
|
+
if (textIncludesCredentialField(label, "password"))
|
|
64571
|
+
hasPassword = true;
|
|
64572
|
+
if (/\b(sign[ -]?in|signin|log[ -]?in|login|submit|continue)\b/i.test(label) && !/\b(google|apple|facebook|github|sso|oauth|magic\s*link)\b/i.test(label)) {
|
|
64573
|
+
hasSubmit = true;
|
|
64574
|
+
}
|
|
64575
|
+
}
|
|
64576
|
+
}
|
|
64577
|
+
return hasEmail && hasPassword && hasSubmit;
|
|
64578
|
+
}
|
|
64579
|
+
function journeyCoversCredentialSubmit(journey) {
|
|
64580
|
+
if (!journey)
|
|
64581
|
+
return false;
|
|
64582
|
+
const text = [
|
|
64583
|
+
journey.name,
|
|
64584
|
+
journey.expectedOutcome,
|
|
64585
|
+
...journey.steps ?? [],
|
|
64586
|
+
...journey.provenCommands ?? []
|
|
64587
|
+
].join(" ");
|
|
64588
|
+
const hasCredentialPair = /\bcredentials\b/i.test(text) || textIncludesCredentialField(text, "email") && textIncludesCredentialField(text, "password");
|
|
64589
|
+
return hasCredentialPair && textIncludesCredentialSubmit(text);
|
|
64590
|
+
}
|
|
64591
|
+
function scenarioAuthCredentialPolicy(scenario, steps) {
|
|
64592
|
+
const text = [
|
|
64593
|
+
scenario.name,
|
|
64594
|
+
scenario.goal,
|
|
64595
|
+
scenario.type,
|
|
64596
|
+
scenario.variant,
|
|
64597
|
+
...steps.map((step) => step.action)
|
|
64598
|
+
].filter(Boolean).join(" ");
|
|
64599
|
+
if (!/\b(auth|authentication|sign[ -]?in|signin|log[ -]?in|login|session)\b/i.test(text)) {
|
|
64600
|
+
return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
|
|
64601
|
+
}
|
|
64602
|
+
if (/\b(log[ -]?out|logout|sign[ -]?out|signout|forgot|reset|recover|magic\s*link|otp|mfa|2fa|sso|google|apple|facebook)\b/i.test(text)) {
|
|
64603
|
+
return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
|
|
64604
|
+
}
|
|
64605
|
+
if (!/\b(credential|e-?mail|username|user\s*name|password|valid|invalid|empty|required|wrong|case|special|leading|trailing)\b/i.test(text)) {
|
|
64606
|
+
return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
|
|
64607
|
+
}
|
|
64608
|
+
const uiOnly = /\b(mask|masked|visibility|show password|hide password|toggle|keyboard|keypad|max(?:imum)?\s*length|field focus|autofill)\b/i.test(text);
|
|
64609
|
+
const negative = /\b(invalid|empty|required|missing|wrong|error|validation|case|special|leading|trailing)\b/i.test(text);
|
|
64610
|
+
const happy = /\b(happy|valid\s+credentials|success|successful)\b/i.test(text) || scenario.type === "HAPPY_PATH" || scenario.variant === "happy";
|
|
64611
|
+
return {
|
|
64612
|
+
needsCredentialEvidence: true,
|
|
64613
|
+
needsSubmitStep: !uiOnly && (happy || negative),
|
|
64614
|
+
reason: uiOnly ? "credential UI scenario" : negative ? "credential validation scenario" : "credential sign-in scenario"
|
|
64615
|
+
};
|
|
64616
|
+
}
|
|
64427
64617
|
function truncateOutcome(text, limit) {
|
|
64428
64618
|
const normalized = text.replace(/\s+/g, " ").trim();
|
|
64429
64619
|
if (normalized.length <= limit)
|
|
@@ -65189,6 +65379,18 @@ Each entry is exactly one of:
|
|
|
65189
65379
|
if (matchSource === "route") {
|
|
65190
65380
|
warnings.push(`"${sc.name}" matched to journey "${matchingSuggestion.name}" by route overlap only - evidence may be weak`);
|
|
65191
65381
|
}
|
|
65382
|
+
const authCredentialPolicy = scenarioAuthCredentialPolicy(sc, steps);
|
|
65383
|
+
if (authCredentialPolicy.needsCredentialEvidence) {
|
|
65384
|
+
const hasCredentialEvidence = targetPagesHaveCredentialControls(siteMap, sc.targetPages) || journeyCoversCredentialSubmit(matchingSuggestion);
|
|
65385
|
+
if (!hasCredentialEvidence) {
|
|
65386
|
+
warnings.push(`Rejected "${sc.name}": ${authCredentialPolicy.reason} lacks observed email/password/sign-in controls or a recorded credential journey`);
|
|
65387
|
+
continue;
|
|
65388
|
+
}
|
|
65389
|
+
if (authCredentialPolicy.needsSubmitStep && !steps.some((step) => textIncludesCredentialSubmit(step.action)) && !journeyCoversCredentialSubmit(matchingSuggestion)) {
|
|
65390
|
+
warnings.push(`Rejected "${sc.name}": ${authCredentialPolicy.reason} does not include a concrete sign-in submit step`);
|
|
65391
|
+
continue;
|
|
65392
|
+
}
|
|
65393
|
+
}
|
|
65192
65394
|
const evidence = (0, discover_planner_js_1.extractScenarioEvidence)(
|
|
65193
65395
|
siteMap.pages,
|
|
65194
65396
|
sc.targetPages,
|
|
@@ -65778,7 +65980,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65778
65980
|
Return ONLY one valid JSON object matching the schema below. Do NOT include <think> blocks, markdown fences, analysis, prose, or any text outside the JSON object. Put any brief explanation only in the JSON "reasoning" field.
|
|
65779
65981
|
|
|
65780
65982
|
## Planning Principles
|
|
65781
|
-
1. **Evidence-backed only** - only plan tests for flows the explorer actually observed. EXCEPTION: for async/generative/long-running features (see rule
|
|
65983
|
+
1. **Evidence-backed only** - only plan tests for flows the explorer actually observed. EXCEPTION: for async/generative/long-running features (see rule 12), the explorer may have only observed the trigger state and not the successful completion state (because the flow was blocked by a prerequisite, rate limit, slow third party, or the iteration budget ran out). In that case you MAY script the durable-outcome verification step using STRUCTURAL signals only (a new row with a self-created identifier appears in the list, a status badge transitions to a known-observed value, the sheet/dialog dismisses, the input clears). Mark such scenarios with \`"evidenceLevel": "trigger-only"\`. **You MUST NOT assert specific text the explorer never observed** \u2014 phrases like "No results match your search", "Successfully saved", "Operation complete" are FORBIDDEN unless that exact text appears verbatim in evidence.observations or recordedJourneys[*].expectedOutcome. If the only viable verification is text you didn't see, downgrade the assertion to a structural one (the list is empty / the item count dropped to 0 / the input cleared) instead of inventing copy.
|
|
65782
65984
|
2. **One purpose per scenario** - each scenario tests ONE thing (happy path, validation, journey, or bug)
|
|
65783
65985
|
3. **Happy paths first** - then pair edge cases to the same form/flow
|
|
65784
65986
|
4. **Multi-screen journeys** - only when screen transitions were actually observed. When an observed target element sits below the fold, include an explicit swipe step to scroll it into view before the tap (mobile elements off-screen are not hittable until revealed)
|
|
@@ -65787,23 +65989,23 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65787
65989
|
7. **Expected outcomes are plain-English success criteria** - describe the user-visible product result, not the controls touched along the way. Steps already cover buttons, inputs, tabs, sheets, and menus. Expected outcomes should say what the user can now do or rely on (record saved, message sent, search results filtered, validation explained, home screen reached). Name the target screen or exact copy only when it is the real product outcome; do NOT list "heading visible", "button visible", or similar element inventory unless that exact control is the behavior under test.
|
|
65788
65990
|
8. **Auth-aware** - mark scenarios that need a signed-in session. Use process.env.TEST_USER_EMAIL / PASSWORD
|
|
65789
65991
|
9. **No duplicates** - check existing test names before adding
|
|
65790
|
-
10. **All tests start from
|
|
65791
|
-
|
|
65792
|
-
|
|
65992
|
+
10. **All tests start from the app launch entry** - every generated test starts by launching the target app. The runner clears app data when the device/reset policy safely supports it; otherwise it performs a terminate + activate relaunch, which can preserve app session data. Therefore scenarios MUST navigate through the app's own UI from whatever launch state is valid, and MUST NOT rely on mutable data from a prior scenario disappearing. Steps must reflect this: say "Launch the app and tap Sign In" not "deep-link to the login screen". Do NOT navigate by deep-link; the app's launch + tab/drawer navigation is the only entry path. \`targetPages\` lists the screen-ids involved in the flow.
|
|
65993
|
+
11. **targetPages must be copied CHARACTER-FOR-CHARACTER from the screen-ids in the evidence** (they look like \`com.example.app.MainActivity#ab12cd34\`). NEVER invent, prettify, or abbreviate them \u2014 no "Landing", "AUTH", "Home Screen", or any name that is not literally in the provided screens. A scenario whose targetPages contains even ONE id not present in the evidence is REJECTED WHOLESALE by the validator, so a single made-up route silently destroys the entire scenario. If you are unsure which screen-id a step touches, use the screen-id of the screen where the flow STARTS.
|
|
65994
|
+
12. **Happy paths must verify the DURABLE OUTCOME, not just the trigger.** A HAPPY_PATH is NOT complete until it verifies the feature's real output. "Button tapped" / "Sheet opened" / "Spinner appeared" is NOT a happy path - that is a UI interaction test. Apply this to any feature where an action produces a result the user then sees or uses: CRUD creates, photo/file uploads, searches that return results, imports, exports, payments, messages sent, AI/ML outputs, generated documents, sync runs, analysis runs, conversion, data processing. The scenario MUST include:
|
|
65793
65995
|
- The trigger step (Tap Submit / Generate / Send / Upload / Run / Analyze / Save / Pay / etc.)
|
|
65794
65996
|
- A wait step for the operation to complete. Phrase it by SIGNAL, not wall-clock ("Wait for the result to appear - up to 60 seconds"). Prefer signals the app actually uses: spinner disappears, status badge flips to a success state, a new row appears, an item count increments, a success toast/snackbar shows, the screen transitions to a result screen.
|
|
65795
65997
|
- A verification step on the DURABLE RESULT - the thing that outlives the transient UI: a new list row, a persisted record, a received message, a committed state change, visible output text. Do NOT assert on the spinner disappearing alone; assert on what replaced it.
|
|
65796
65998
|
- Where the feature exposes follow-up actions on the result (View / Open / Copy / Share / Delete), include at least one to prove the result is real and usable.
|
|
65797
65999
|
DO NOT verify subjective qualities of generated content ("relevant to the input", "high quality") - those are not deterministic. Verify structural/existence properties: non-empty, contains a known input value, matches an expected field, row count increased, status = completed.
|
|
65798
|
-
Example - BAD happy path: \`["
|
|
65799
|
-
Example - GOOD happy path (generic, async): \`["
|
|
65800
|
-
Example - GOOD happy path (sync CRUD): \`["
|
|
65801
|
-
|
|
65802
|
-
|
|
65803
|
-
|
|
65804
|
-
|
|
65805
|
-
|
|
65806
|
-
|
|
66000
|
+
Example - BAD happy path: \`["Launch app", "Tap Create", "Verify sheet opens"]\` \u2192 only tests the trigger.
|
|
66001
|
+
Example - GOOD happy path (generic, async): \`["Launch app and sign in if needed", "Navigate to the feature screen", "Note the current count of rows in the list", "Tap the primary action button", "Fill required inputs and submit", "Wait for completion signal (up to 60s)", "Verify the row count increased by 1", "Open the newly created row", "Verify its content is non-empty and contains the input I provided"]\`
|
|
66002
|
+
Example - GOOD happy path (sync CRUD): \`["Launch app and sign in if needed", "Navigate to the list screen", "Tap New", "Fill required fields", "Tap Save", "Verify the new record appears in the list", "Tap the record and verify its detail screen shows the values I entered"]\`
|
|
66003
|
+
13. **Auth happy paths require concrete session proof.** A sign-in HAPPY_PATH is incomplete if it only proves "user reaches the home screen" or "an authenticated screen is visible." It MUST verify at least one durable signed-in signal that was observed in the evidence: the account/profile tab opens, Sign out is available, the observed display name/email/avatar is present, an authenticated screen remains reachable after a relaunch with the session restored, or another app-specific session control is usable. Prefer concrete observed labels from the evidence (for example "Sign out", "Account", "Profile", or the user's own displayed name/email) over generic phrases like "personalized content." If the explorer only observed a screen transition and no signed-in signal, keep the screen outcome but mark the session-proof outcome reliability "low" and state what concrete signal was missing.
|
|
66004
|
+
14. **Independent execution contract.** Every scenario MUST pass when run alone, first, last, in random order, and against an app launch state that may be freshly cleared OR may preserve a previous session depending on the device/reset policy. A scenario that depends on a sibling scenario's side effects (e.g. "delete the conversation that another test created") is invalid. If two flows are inherently coupled, bundle them as a single USER_JOURNEY scenario.
|
|
66005
|
+
15. **Scenario-owned state.** If a scenario reads, searches, opens, updates, messages, cancels, or deletes an entity, that scenario MUST create or seed the entity in its own setup steps. "Use an existing record" / "the conversation already in the account" / "the existing user named X" are forbidden phrasings \u2014 they assume mutable pre-existing app data. Carve-out: baseline observations (current count, list length, "the home screen header is visible") are allowed as prerequisite reads ONLY when the durable assertion targets a self-created entity. Phrase setup as: "Tap New, fill required fields with run-scoped values, submit and verify the new record appears, THEN proceed to <feature under test>."
|
|
66006
|
+
16. **Run-scoped test data.** Generated entities MUST use unique, run-scoped identifiers \u2014 phrase as "test message <Date.now()>" or "QA-<unique id>" in steps. Subsequent assertions MUST target that unique identifier, not "the first row", "the latest message", "the last cell", or any visible display name observed during exploration. Hardcoding any specific display name, username, email, conversation title, or other identifier observed in the explorer's snapshot is forbidden \u2014 that data is incidental, not a stable contract. The same identifiers will not exist (or will look different) when the test runs against a fresh session or a different account.
|
|
66007
|
+
17. **Setup is plumbing, not purpose.** Sign-in + create-prereq + navigate steps DO NOT violate "one purpose per scenario" (rule 2). The scenario's purpose is its primary user goal and the durable assertion that proves it. Treat setup as scaffolding and write it inline; do not split a delete-confirm scenario into "sign in \u2192 create" and "delete \u2192 assert removed" \u2014 they belong in one scenario.
|
|
66008
|
+
18. **Prerequisites \u2014 declare every state assumption, AND prefer rewriting to declaring.** Every scenario MUST list its state prerequisites in the \`prerequisites\` array. The four kinds are \`self_owned\`, \`seed_fixture\`, \`external_relationship\`, and \`clean_initial_state\`.
|
|
65807
66009
|
|
|
65808
66010
|
**Before declaring a prereq as anything other than \`self_owned\`, attempt these IN ORDER:**
|
|
65809
66011
|
|
|
@@ -65826,7 +66028,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65826
66028
|
- \`clean_initial_state\`: never use.
|
|
65827
66029
|
|
|
65828
66030
|
If your scenario has NO state assumptions (pure navigation, public screen, validation scenarios where ANY state passes), use \`prerequisites: []\`. An empty array is the correct declaration for self-contained scenarios.
|
|
65829
|
-
|
|
66031
|
+
19. **Planner emits design, not implementation.** Live-generate handles execution against a real device snapshot.
|
|
65830
66032
|
|
|
65831
66033
|
**YOUR responsibility:**
|
|
65832
66034
|
- Scenario \`name\`, \`type\`, \`goal\`, \`prerequisites\`
|
|
@@ -65852,10 +66054,11 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65852
66054
|
Citing observed labels / accessibility-ids in \`expectedOutcomes\` (per Rule 12) is fine \u2014 that's asserting on stable product signals, not implementation specifics.
|
|
65853
66055
|
|
|
65854
66056
|
## Coverage Strategy
|
|
65855
|
-
- For every form: at least 1 HAPPY_PATH (submit valid data AND verify durable result per rule
|
|
65856
|
-
- For auth flows: sign-in happy path (verify the target screen AND a concrete signed-in/session signal per rule
|
|
66057
|
+
- For every form: at least 1 HAPPY_PATH (submit valid data AND verify durable result per rule 12) + 1 EDGE_CASE (empty/invalid submission)
|
|
66058
|
+
- For auth flows: sign-in happy path (verify the target screen AND a concrete signed-in/session signal per rule 13) + validation (wrong password, empty fields)
|
|
66059
|
+
- For credential sign-in flows: only plan credential scenarios when evidence shows the credential path itself \u2014 an email/username field, a password field, and a native Sign In/Login/Submit control on the target screen, or an Explorer-Recorded Journey that typed those fields and submitted them. Do NOT treat Google/Apple/Facebook/SSO buttons, magic-link entry, logout menus, profile menus, or already-signed-in screens as evidence for credential sign-in. If the explorer only observed signed-in menu/logout/session surfaces, plan session/logout scenarios only; surface missing credential coverage as a gap instead of inventing credential sign-in variants.
|
|
65857
66060
|
- For CRUD: each scenario MUST own the entity it operates on. Read/search/update/delete/cancel-on-existing/view-existing scenarios MUST include a create step earlier in the same scenario (or declare an immutable seed fixture by name). Never assume a record from prior runs or sibling tests exists. When create/delete are tightly coupled, bundle into a single USER_JOURNEY: create \u2192 verify \u2192 delete \u2192 verify removal.
|
|
65858
|
-
- For AI/async features (Generate, Analyze, Process, Import, Sync): happy path MUST complete the flow per rule
|
|
66061
|
+
- For AI/async features (Generate, Analyze, Process, Import, Sync): happy path MUST complete the flow per rule 12 - never stop at "button tapped" or "sheet opened"
|
|
65859
66062
|
- For navigation: multi-screen user journeys within the feature scope (do NOT drift into unrelated features)
|
|
65860
66063
|
|
|
65861
66064
|
## Output JSON Schema
|
|
@@ -65877,7 +66080,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65877
66080
|
"featureGroup": "Authentication",
|
|
65878
66081
|
"goal": "User can sign in with valid credentials and reach the home screen",
|
|
65879
66082
|
"steps": [
|
|
65880
|
-
{ "action": "
|
|
66083
|
+
{ "action": "Launch the app and tap Sign In to reach the sign-in screen" },
|
|
65881
66084
|
{ "action": "Type test credentials into the email field" },
|
|
65882
66085
|
{ "action": "Type test credentials into the password field" },
|
|
65883
66086
|
{ "action": "Tap the Sign In button" },
|
|
@@ -65950,6 +66153,59 @@ Each entry is exactly one of:
|
|
|
65950
66153
|
function screenDisplayName(state2) {
|
|
65951
66154
|
return state2.screenName?.trim() || state2.screenId;
|
|
65952
66155
|
}
|
|
66156
|
+
function isOpaqueMobileScreenName(value, appId) {
|
|
66157
|
+
const trimmed = value.trim();
|
|
66158
|
+
if (!trimmed)
|
|
66159
|
+
return false;
|
|
66160
|
+
if (appId && (trimmed === appId || trimmed.startsWith(`${appId}#`) || trimmed.startsWith(`${appId}.`))) {
|
|
66161
|
+
return true;
|
|
66162
|
+
}
|
|
66163
|
+
return /^[A-Za-z][\w$]*(?:\.[A-Za-z][\w$]*)+(?:#[A-Za-z0-9_-]+)?$/.test(trimmed);
|
|
66164
|
+
}
|
|
66165
|
+
function humanizeToken(token) {
|
|
66166
|
+
const spaced = token.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim();
|
|
66167
|
+
if (!spaced)
|
|
66168
|
+
return "";
|
|
66169
|
+
return spaced.split(/\s+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
66170
|
+
}
|
|
66171
|
+
function mobileAppDisplayName(appId) {
|
|
66172
|
+
const parts = (appId ?? "").split(".").map((part) => part.trim()).filter(Boolean).filter((part) => !/^(com|org|net|io|app|mobile|android|ios)$/i.test(part));
|
|
66173
|
+
const token = parts.length > 0 ? parts[parts.length - 1] : "";
|
|
66174
|
+
const human = humanizeToken(token);
|
|
66175
|
+
return human || "Mobile App";
|
|
66176
|
+
}
|
|
66177
|
+
function nonGenericScreenName(state2, appId) {
|
|
66178
|
+
const raw = state2.screenName?.trim();
|
|
66179
|
+
if (!raw || isOpaqueMobileScreenName(raw, appId))
|
|
66180
|
+
return null;
|
|
66181
|
+
const human = humanizeToken(raw.replace(/(?:Activity|ViewController|Fragment|Screen|Page)$/i, ""));
|
|
66182
|
+
if (!human || /^(main|root|launch|screen|page|activity|view controller)$/i.test(human))
|
|
66183
|
+
return null;
|
|
66184
|
+
return human;
|
|
66185
|
+
}
|
|
66186
|
+
function inferMobileFeatureGroupName(states, appId) {
|
|
66187
|
+
const usable = states.filter((state2) => state2.screenType?.toLowerCase() !== "error");
|
|
66188
|
+
const screenTypes = usable.map((state2) => state2.screenType?.toLowerCase()).filter(Boolean);
|
|
66189
|
+
if (screenTypes.length > 0 && screenTypes.every((type) => type === "auth"))
|
|
66190
|
+
return "Authentication";
|
|
66191
|
+
if (screenTypes.length > 0 && screenTypes.every((type) => type === "onboarding" || type === "landing" || type === "splash")) {
|
|
66192
|
+
return "Onboarding";
|
|
66193
|
+
}
|
|
66194
|
+
const named = usable.map((state2) => nonGenericScreenName(state2, appId)).find((name) => Boolean(name));
|
|
66195
|
+
return named ?? `${mobileAppDisplayName(appId)} App`;
|
|
66196
|
+
}
|
|
66197
|
+
function mobileFeatureCanonicalKey(name) {
|
|
66198
|
+
const normalized = name.trim().toLowerCase().replace(/['’]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
66199
|
+
if (!normalized)
|
|
66200
|
+
return "mobile-app";
|
|
66201
|
+
if (/(^|-)(auth|authentication|login|log-in|signin|sign-in|signup|sign-up)($|-)/.test(normalized)) {
|
|
66202
|
+
return "authentication";
|
|
66203
|
+
}
|
|
66204
|
+
if (/(^|-)(onboarding|welcome|landing|splash|intro|walkthrough)($|-)/.test(normalized)) {
|
|
66205
|
+
return "onboarding";
|
|
66206
|
+
}
|
|
66207
|
+
return normalized.replace(/-(app|screen|screens|flow|feature)$/g, "") || normalized;
|
|
66208
|
+
}
|
|
65953
66209
|
function formatMobileEvidenceForPlanner(graph, context = {}) {
|
|
65954
66210
|
const sections = [];
|
|
65955
66211
|
if (context.appBrief) {
|
|
@@ -66141,9 +66397,10 @@ ${constraints.join("\n")}`);
|
|
|
66141
66397
|
const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
|
|
66142
66398
|
for (const state2 of orderedScreens) {
|
|
66143
66399
|
const screenTypeLower = state2.screenType?.toLowerCase();
|
|
66144
|
-
const groupName = screenTypeLower === "auth" ? "Authentication" : screenTypeLower === "onboarding" || screenTypeLower === "landing" || screenTypeLower === "splash" ? "Onboarding" :
|
|
66400
|
+
const groupName = screenTypeLower === "auth" ? "Authentication" : screenTypeLower === "onboarding" || screenTypeLower === "landing" || screenTypeLower === "splash" ? "Onboarding" : inferMobileFeatureGroupName([state2], appId);
|
|
66145
66401
|
const group = groups.get(groupName) ?? {
|
|
66146
66402
|
name: groupName,
|
|
66403
|
+
canonicalKey: mobileFeatureCanonicalKey(groupName),
|
|
66147
66404
|
description: `Native mobile screens related to ${groupName}.`,
|
|
66148
66405
|
routes: [],
|
|
66149
66406
|
criticality: state2.screenType?.toLowerCase() === "auth" ? "critical" : "medium",
|
|
@@ -66169,7 +66426,7 @@ ${constraints.join("\n")}`);
|
|
|
66169
66426
|
const to = graph.screens.get(transition.toScreenId);
|
|
66170
66427
|
if (!from || !to)
|
|
66171
66428
|
continue;
|
|
66172
|
-
const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" :
|
|
66429
|
+
const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : inferMobileFeatureGroupName([from], appId);
|
|
66173
66430
|
const group = groups.get(groupName);
|
|
66174
66431
|
if (!group)
|
|
66175
66432
|
continue;
|
|
@@ -66193,7 +66450,7 @@ ${constraints.join("\n")}`);
|
|
|
66193
66450
|
for (const state2 of orderedScreens) {
|
|
66194
66451
|
if (scenarios.length >= budget)
|
|
66195
66452
|
break;
|
|
66196
|
-
const screenName =
|
|
66453
|
+
const screenName = nonGenericScreenName(state2, input.appId) ?? inferMobileFeatureGroupName([state2], input.appId);
|
|
66197
66454
|
const baseName = `${screenName} - Screen Reachability`;
|
|
66198
66455
|
const name = usedNames.has(baseName.toLowerCase()) ? `${baseName} ${state2.firstSeenIteration + 1}` : baseName;
|
|
66199
66456
|
if (usedNames.has(name.toLowerCase()))
|
|
@@ -66201,7 +66458,7 @@ ${constraints.join("\n")}`);
|
|
|
66201
66458
|
usedNames.add(name.toLowerCase());
|
|
66202
66459
|
const actionableLabels = state2.elements.filter((el) => el.actionable).map((el) => el.label?.trim() || el.text?.trim() || el.accessibilityId || shortResourceId(el.resourceId ?? "") || compactRole(el.role)).filter((label) => Boolean(label)).slice(0, 3);
|
|
66203
66460
|
const steps = [
|
|
66204
|
-
{ action: "
|
|
66461
|
+
{ action: "Launch the app and start from the current launch entry", target: entryScreenId },
|
|
66205
66462
|
{
|
|
66206
66463
|
action: state2.screenId === entryScreenId ? `Verify the launch screen is ${screenName}` : `Navigate through the app until the ${screenName} screen is visible`,
|
|
66207
66464
|
target: state2.screenId
|
|
@@ -66252,16 +66509,27 @@ ${constraints.join("\n")}`);
|
|
|
66252
66509
|
}
|
|
66253
66510
|
return filtered;
|
|
66254
66511
|
}
|
|
66255
|
-
function filterFeatureGroupsToSiteMap(groups, siteMap) {
|
|
66512
|
+
function filterFeatureGroupsToSiteMap(groups, siteMap, graph, appId) {
|
|
66256
66513
|
if (groups.length === 0)
|
|
66257
66514
|
return [];
|
|
66258
66515
|
const validRoutes = new Set(siteMap.pages.map((page) => page.route));
|
|
66516
|
+
const usedNames = /* @__PURE__ */ new Map();
|
|
66259
66517
|
return groups.flatMap((group) => {
|
|
66260
66518
|
const routes = group.routes.filter((route2) => validRoutes.has(route2));
|
|
66261
66519
|
if (routes.length === 0)
|
|
66262
66520
|
return [];
|
|
66521
|
+
const routeStates = routes.map((route2) => graph?.screens.get(route2)).filter((state2) => Boolean(state2));
|
|
66522
|
+
const rawName = group.name?.trim() || "";
|
|
66523
|
+
const inferredName = rawName && !isOpaqueMobileScreenName(rawName, appId) ? rawName : inferMobileFeatureGroupName(routeStates, appId);
|
|
66524
|
+
const count = usedNames.get(inferredName) ?? 0;
|
|
66525
|
+
usedNames.set(inferredName, count + 1);
|
|
66526
|
+
const name = count === 0 ? inferredName : `${inferredName} ${count + 1}`;
|
|
66527
|
+
const canonicalKey = group.canonicalKey?.trim() || mobileFeatureCanonicalKey(inferredName);
|
|
66263
66528
|
return [{
|
|
66264
66529
|
...group,
|
|
66530
|
+
name,
|
|
66531
|
+
canonicalKey,
|
|
66532
|
+
description: group.description?.trim() || `Native mobile screens related to ${name}.`,
|
|
66265
66533
|
routes,
|
|
66266
66534
|
pages: group.pages?.filter((page) => validRoutes.has(page.route))
|
|
66267
66535
|
}];
|
|
@@ -66313,7 +66581,7 @@ ${constraints.join("\n")}`);
|
|
|
66313
66581
|
incremental: input.incremental
|
|
66314
66582
|
})
|
|
66315
66583
|
});
|
|
66316
|
-
const plannerFeatureGroups = filterFeatureGroupsToSiteMap(result.featureGroups, siteMap);
|
|
66584
|
+
const plannerFeatureGroups = filterFeatureGroupsToSiteMap(result.featureGroups, siteMap, input.graph, input.appId);
|
|
66317
66585
|
const mobileFeatureGroups = plannerFeatureGroups.length > 0 ? plannerFeatureGroups : buildMobileFeatureGroupsFromGraph(input.graph, input.appId);
|
|
66318
66586
|
if (input.mode !== "survey" && result.scenarios.length === 0 && input.graph.screens.size > 0) {
|
|
66319
66587
|
const fallbackScenarios = buildMobileFallbackScenarios(input);
|
|
@@ -66560,7 +66828,15 @@ var require_mobile_generator = __commonJS({
|
|
|
66560
66828
|
return { screen, signal, refMap, observation };
|
|
66561
66829
|
}
|
|
66562
66830
|
function labelForElement(element) {
|
|
66563
|
-
|
|
66831
|
+
const editable = isEditableMobileElement(element);
|
|
66832
|
+
const label = nonEmpty2(element.label);
|
|
66833
|
+
const text = nonEmpty2(element.text);
|
|
66834
|
+
const labelIsEditableValue = editable && label !== void 0 && text !== void 0 && label === text;
|
|
66835
|
+
return (labelIsEditableValue ? void 0 : label) ?? nonEmpty2(element.accessibilityId) ?? nonEmpty2(element.resourceId) ?? (editable ? void 0 : text) ?? "element";
|
|
66836
|
+
}
|
|
66837
|
+
var EDITABLE_ELEMENT_ROLE_RE = /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/;
|
|
66838
|
+
function isEditableMobileElement(element) {
|
|
66839
|
+
return EDITABLE_ELEMENT_ROLE_RE.test(element.role.trim());
|
|
66564
66840
|
}
|
|
66565
66841
|
function nonEmpty2(value) {
|
|
66566
66842
|
if (typeof value !== "string")
|
|
@@ -66602,7 +66878,7 @@ var require_mobile_generator = __commonJS({
|
|
|
66602
66878
|
## Rules
|
|
66603
66879
|
- Re-snapshot before every assertion so the ref is fresh.
|
|
66604
66880
|
- Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
|
|
66605
|
-
- Assert only DURABLE elements \u2014 screen chrome that exists on every visit (tab labels, headers, nav bars, the entity you just created). NEVER assert one-time / data-dependent UI: empty-state CTAs
|
|
66881
|
+
- Assert only DURABLE elements \u2014 screen chrome that exists on every visit (tab labels, headers, nav bars, the entity you just created). NEVER assert one-time / data-dependent UI: empty-state CTAs, first-run or onboarding prompts, or counters \u2014 they vanish as soon as the account has data, and the test then fails forever on a healthy app.
|
|
66606
66882
|
- Do NOT trigger irreversible side effects (real payments, sending messages to others) \u2014 note them and assert what you safely can.
|
|
66607
66883
|
- Stay inside this app.${targetLine} Do not open or use app stores, browsers, email apps, settings, or any other app. If an action leaves the target app, the runner will relaunch the target app and reject that action.
|
|
66608
66884
|
- NEVER tap the phone's home screen / launcher (app icons, "Predicted app" chips, search bars, widgets). Those are NOT part of the app under test \u2014 tapping them is junk that poisons the test. If the app ever backgrounds or you find yourself on the launcher, use mobile_relaunch to cold-start the app; do NOT tap a launcher icon or app-prediction to get back in.
|
|
@@ -67174,6 +67450,13 @@ ${statePacket}` },
|
|
|
67174
67450
|
const { resolveRef, screenName, platform: platform3, recordAction } = ctx;
|
|
67175
67451
|
const element = resolveRef(toolArgs.ref);
|
|
67176
67452
|
if (element) {
|
|
67453
|
+
const weakReason = weakAssertionTargetReason(element);
|
|
67454
|
+
if (weakReason) {
|
|
67455
|
+
return {
|
|
67456
|
+
ok: false,
|
|
67457
|
+
error: `Ref ${element.ref} is not a durable assertion target (${weakReason}). Re-snapshot and assert a human-visible label, text, or stable control that proves the screen outcome.`
|
|
67458
|
+
};
|
|
67459
|
+
}
|
|
67177
67460
|
const metadata = metadataForElement(element, screenName(), platform3);
|
|
67178
67461
|
recordAction({ type: "ASSERT_VISIBLE", selector: (0, mobile_source_parser_js_2.preferredLocator)(element), metadata });
|
|
67179
67462
|
return { ok: true, label: metadata.targetLabel ?? "element" };
|
|
@@ -67183,6 +67466,13 @@ ${statePacket}` },
|
|
|
67183
67466
|
if (!accessibilityId && !text) {
|
|
67184
67467
|
return { ok: false, error: "mobile_assert_visible needs a ref (preferred), accessibilityId, or text." };
|
|
67185
67468
|
}
|
|
67469
|
+
const directTarget = accessibilityId ?? text;
|
|
67470
|
+
if (directTarget && isStructuralAssertionIdentity(directTarget)) {
|
|
67471
|
+
return {
|
|
67472
|
+
ok: false,
|
|
67473
|
+
error: `"${directTarget}" looks like a structural/internal container id, not a user-visible outcome. Assert a visible label, message, or stable product control instead.`
|
|
67474
|
+
};
|
|
67475
|
+
}
|
|
67186
67476
|
const screen = screenName();
|
|
67187
67477
|
if (accessibilityId) {
|
|
67188
67478
|
const bundle2 = { xpath: `//*[@content-desc=${xpathLiteral2(accessibilityId)} or @name=${xpathLiteral2(accessibilityId)}]`, className: "", accessibilityId };
|
|
@@ -67206,7 +67496,7 @@ ${statePacket}` },
|
|
|
67206
67496
|
if (!snapshot || snapshot.screen.elements.length === 0)
|
|
67207
67497
|
return null;
|
|
67208
67498
|
const elements = snapshot.screen.elements;
|
|
67209
|
-
const stableAnchors = elements.filter((el) => hasStableId(el) && !
|
|
67499
|
+
const stableAnchors = elements.filter((el) => hasStableId(el) && !weakAssertionTargetReason(el));
|
|
67210
67500
|
stableAnchors.sort((a, b) => {
|
|
67211
67501
|
const labelled2 = (el) => nonEmpty2(el.text) || nonEmpty2(el.label) ? 0 : 1;
|
|
67212
67502
|
return labelled2(a) - labelled2(b);
|
|
@@ -67219,8 +67509,8 @@ ${statePacket}` },
|
|
|
67219
67509
|
return nonButton;
|
|
67220
67510
|
if (stableAnchors.length > 0)
|
|
67221
67511
|
return stableAnchors[0];
|
|
67222
|
-
const labelled = elements.find((el) => !
|
|
67223
|
-
return labelled ?? elements.find((el) => !
|
|
67512
|
+
const labelled = elements.find((el) => !weakAssertionTargetReason(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
|
|
67513
|
+
return labelled ?? elements.find((el) => !weakAssertionTargetReason(el)) ?? null;
|
|
67224
67514
|
}
|
|
67225
67515
|
var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
|
|
67226
67516
|
var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
|
|
@@ -67232,9 +67522,9 @@ ${statePacket}` },
|
|
|
67232
67522
|
"navigationBarBackground",
|
|
67233
67523
|
// WebView hybrid apps (React/Vue/etc. rendered inside android.webkit.WebView)
|
|
67234
67524
|
// expose their framework ROOT container's DOM id as the element id — present on
|
|
67235
|
-
// EVERY in-app screen, so the floor must not anchor to it (observed live
|
|
67236
|
-
//
|
|
67237
|
-
// never matched via the text-contains fallback).
|
|
67525
|
+
// EVERY in-app screen, so the floor must not anchor to it (observed live: a
|
|
67526
|
+
// framework root id like `main-content` produced a screen-agnostic assertion
|
|
67527
|
+
// that then never matched via the text-contains fallback).
|
|
67238
67528
|
"main-content",
|
|
67239
67529
|
"root",
|
|
67240
67530
|
"app",
|
|
@@ -67243,18 +67533,53 @@ ${statePacket}` },
|
|
|
67243
67533
|
"react-root",
|
|
67244
67534
|
"application"
|
|
67245
67535
|
]);
|
|
67536
|
+
var STRUCTURAL_ASSERT_ID_RE = /^(?:skip\s*to\s*main\s*content|skip[-_]?to[-_]?main[-_]?content|step[-_]\d+|(?:auth|form|wizard)[-_]?step(?:[-_]\d+)?|(?:main[-_]?content|root|app[-_]?root|__next|react[-_]?root|application|content|container|wrapper|webview|layout|view|screen|page)(?:[-_]\d+)?)$/i;
|
|
67537
|
+
function locatorLocalName(value) {
|
|
67538
|
+
const trimmed = value.trim();
|
|
67539
|
+
const withoutStrategy = trimmed.startsWith("id=") ? trimmed.slice(3) : trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
|
|
67540
|
+
const slashIdx = withoutStrategy.indexOf("/");
|
|
67541
|
+
return slashIdx >= 0 ? withoutStrategy.slice(slashIdx + 1) : withoutStrategy;
|
|
67542
|
+
}
|
|
67543
|
+
function isStructuralAssertionIdentity(value) {
|
|
67544
|
+
const trimmed = nonEmpty2(value);
|
|
67545
|
+
if (!trimmed)
|
|
67546
|
+
return false;
|
|
67547
|
+
const localName = locatorLocalName(trimmed);
|
|
67548
|
+
return ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName) || STRUCTURAL_ASSERT_ID_RE.test(localName);
|
|
67549
|
+
}
|
|
67550
|
+
function hasHumanAssertionSignal(element) {
|
|
67551
|
+
const label = nonEmpty2(element.label);
|
|
67552
|
+
const text = nonEmpty2(element.text);
|
|
67553
|
+
const accessibilityId = nonEmpty2(element.accessibilityId);
|
|
67554
|
+
return Boolean(label && !isStructuralAssertionIdentity(label) || text && !isEditableMobileElement(element) && !isStructuralAssertionIdentity(text) || accessibilityId && !isStructuralAssertionIdentity(accessibilityId));
|
|
67555
|
+
}
|
|
67556
|
+
function weakAssertionTargetReason(element) {
|
|
67557
|
+
if (isAlwaysPresentContainer(element))
|
|
67558
|
+
return "always-present app/system container";
|
|
67559
|
+
const structuralIdentity = [
|
|
67560
|
+
element.accessibilityId,
|
|
67561
|
+
element.resourceId,
|
|
67562
|
+
element.label,
|
|
67563
|
+
element.text
|
|
67564
|
+
].some(isStructuralAssertionIdentity);
|
|
67565
|
+
const containerishClass = BARE_CONTAINER_CLASS_RE.test(element.role);
|
|
67566
|
+
const humanSignal = hasHumanAssertionSignal(element);
|
|
67567
|
+
if (structuralIdentity && (containerishClass || !humanSignal))
|
|
67568
|
+
return "structural/internal container id";
|
|
67569
|
+
if (containerishClass && !humanSignal)
|
|
67570
|
+
return "generic container with no visible product signal";
|
|
67571
|
+
return null;
|
|
67572
|
+
}
|
|
67246
67573
|
function isAlwaysPresentContainer(element) {
|
|
67247
67574
|
if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
|
|
67248
67575
|
return true;
|
|
67249
67576
|
const resourceId = element.resourceId?.trim();
|
|
67250
67577
|
if (resourceId) {
|
|
67251
|
-
|
|
67252
|
-
const localName = slashIdx >= 0 ? resourceId.slice(slashIdx + 1) : resourceId;
|
|
67253
|
-
if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName))
|
|
67578
|
+
if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(locatorLocalName(resourceId)))
|
|
67254
67579
|
return true;
|
|
67255
67580
|
}
|
|
67256
67581
|
const accessibilityId = element.accessibilityId?.trim();
|
|
67257
|
-
if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(accessibilityId))
|
|
67582
|
+
if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(locatorLocalName(accessibilityId)))
|
|
67258
67583
|
return true;
|
|
67259
67584
|
return false;
|
|
67260
67585
|
}
|
|
@@ -67269,7 +67594,7 @@ ${statePacket}` },
|
|
|
67269
67594
|
if (hasIdentity)
|
|
67270
67595
|
return false;
|
|
67271
67596
|
const resourceId = bundle.resourceId?.trim() ?? "";
|
|
67272
|
-
const localName =
|
|
67597
|
+
const localName = locatorLocalName(resourceId);
|
|
67273
67598
|
const containerishId = localName !== "" && (localName === "webview" || ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName));
|
|
67274
67599
|
const containerishClass = BARE_CONTAINER_CLASS_RE.test(bundle.className ?? "");
|
|
67275
67600
|
if (containerishId)
|
|
@@ -67288,6 +67613,88 @@ ${statePacket}` },
|
|
|
67288
67613
|
}));
|
|
67289
67614
|
return true;
|
|
67290
67615
|
}
|
|
67616
|
+
function scenarioText(scenario) {
|
|
67617
|
+
return [
|
|
67618
|
+
scenario.name,
|
|
67619
|
+
scenario.goal,
|
|
67620
|
+
...scenario.steps.flatMap((step) => [step.action, step.target, step.hint]),
|
|
67621
|
+
...scenario.expectedOutcomes.flatMap((outcome) => [outcome.description, outcome.hint])
|
|
67622
|
+
].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
|
|
67623
|
+
}
|
|
67624
|
+
function actionText(action) {
|
|
67625
|
+
const bundle = action.metadata.locatorBundle;
|
|
67626
|
+
return [
|
|
67627
|
+
action.type,
|
|
67628
|
+
action.selector,
|
|
67629
|
+
action.value,
|
|
67630
|
+
action.innerText,
|
|
67631
|
+
action.metadata.targetLabel,
|
|
67632
|
+
bundle?.text,
|
|
67633
|
+
bundle?.contentDesc,
|
|
67634
|
+
bundle?.accessibilityId,
|
|
67635
|
+
bundle?.resourceId,
|
|
67636
|
+
bundle?.className,
|
|
67637
|
+
bundle?.xpath
|
|
67638
|
+
].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
|
|
67639
|
+
}
|
|
67640
|
+
function scenarioIntentMismatchReason(scenario, actions) {
|
|
67641
|
+
const text = scenarioText(scenario);
|
|
67642
|
+
const isCredentialSignIn = /\b(sign\s*in|log\s*in|login|authenticate|auth)\b/.test(text) && /\b(email|e-mail|password|credential|valid credentials?)\b/.test(text) && !/\b(reset|forgot|recover|logout|log\s*out|sign\s*out)\b/.test(text);
|
|
67643
|
+
const typedEmailIndexes = [];
|
|
67644
|
+
const typedPasswordIndexes = [];
|
|
67645
|
+
actions.forEach((action, index) => {
|
|
67646
|
+
if (action.type !== "TYPE")
|
|
67647
|
+
return;
|
|
67648
|
+
const textForAction = actionText(action);
|
|
67649
|
+
if (/\b(email|e-mail|username|user name|login)\b|\{\{test_user_email\}\}/i.test(textForAction)) {
|
|
67650
|
+
typedEmailIndexes.push(index);
|
|
67651
|
+
}
|
|
67652
|
+
if (/\b(password|passcode|pin)\b|\{\{test_user_password\}\}/i.test(textForAction)) {
|
|
67653
|
+
typedPasswordIndexes.push(index);
|
|
67654
|
+
}
|
|
67655
|
+
});
|
|
67656
|
+
const lastCredentialIndex = Math.max(...typedEmailIndexes, ...typedPasswordIndexes);
|
|
67657
|
+
const hasAnyCredentialEntry = Number.isFinite(lastCredentialIndex);
|
|
67658
|
+
const submitAfterCredentials = actions.some((action, index) => {
|
|
67659
|
+
if (action.type !== "TAP" || index <= lastCredentialIndex)
|
|
67660
|
+
return false;
|
|
67661
|
+
const textForAction = actionText(action);
|
|
67662
|
+
if (!/\b(sign\s*in|log\s*in|login|continue|submit)\b/.test(textForAction))
|
|
67663
|
+
return false;
|
|
67664
|
+
return !/\b(with\s+(email|e-mail|google|facebook|apple)|email\s+form|showemailformbtn)\b/i.test(textForAction);
|
|
67665
|
+
});
|
|
67666
|
+
const isAuthLikeScenario = /\b(sign(?:ed)?\s*in|log(?:ged)?\s*in|login|authenticate|auth|credential|email|e-mail|password)\b/.test(text);
|
|
67667
|
+
if (isAuthLikeScenario && hasAnyCredentialEntry && !submitAfterCredentials) {
|
|
67668
|
+
return "credential fields were filled but no sign-in submit tap was recorded after them";
|
|
67669
|
+
}
|
|
67670
|
+
const uiOnlyCredentialScenario = /\b(mask|masked|visibility|show password|hide password|toggle|keyboard|keypad|max(?:imum)?\s*length|field focus|autofill)\b/.test(text);
|
|
67671
|
+
const emptyFieldValidationScenario = /\b(empty|required|missing|blank)\b/.test(text);
|
|
67672
|
+
const sessionLifecycleScenario = /\b(logout|log\s*out|sign\s*out|session persistence|remain(?:s)? signed in|remember me)\b/.test(text);
|
|
67673
|
+
const loginTapWithoutCredentialPath = actions.some((action) => {
|
|
67674
|
+
if (action.type !== "TAP")
|
|
67675
|
+
return false;
|
|
67676
|
+
const textForAction = actionText(action);
|
|
67677
|
+
if (/\b(logout|log\s*out|sign\s*out)\b/.test(textForAction))
|
|
67678
|
+
return false;
|
|
67679
|
+
return /\b(sign\s*in|log\s*in|login)\b/.test(textForAction);
|
|
67680
|
+
});
|
|
67681
|
+
if (loginTapWithoutCredentialPath && !hasAnyCredentialEntry && !uiOnlyCredentialScenario && !emptyFieldValidationScenario && (sessionLifecycleScenario || /\b(valid credentials?|happy path|successful sign\s*in|successful login)\b/.test(text))) {
|
|
67682
|
+
return "auth/session scenario tapped a login or sign-in control without recording a credential path";
|
|
67683
|
+
}
|
|
67684
|
+
if (!isCredentialSignIn)
|
|
67685
|
+
return null;
|
|
67686
|
+
const typedEmail = typedEmailIndexes.length > 0;
|
|
67687
|
+
const typedPassword = typedPasswordIndexes.length > 0;
|
|
67688
|
+
const tappedSubmit = submitAfterCredentials;
|
|
67689
|
+
if (typedEmail && typedPassword && tappedSubmit)
|
|
67690
|
+
return null;
|
|
67691
|
+
const missing = [
|
|
67692
|
+
typedEmail ? null : "email/username entry",
|
|
67693
|
+
typedPassword ? null : "password entry",
|
|
67694
|
+
tappedSubmit ? null : "sign-in submit tap"
|
|
67695
|
+
].filter((value) => value !== null);
|
|
67696
|
+
return `credential sign-in scenario did not record ${missing.join(", ")}`;
|
|
67697
|
+
}
|
|
67291
67698
|
function actionCycleKey(action) {
|
|
67292
67699
|
return `${action.type}|${action.selector}|${action.value ?? ""}`;
|
|
67293
67700
|
}
|
|
@@ -67311,6 +67718,22 @@ ${statePacket}` },
|
|
|
67311
67718
|
break;
|
|
67312
67719
|
}
|
|
67313
67720
|
}
|
|
67721
|
+
for (let window2 = Math.min(MAX_RETRY_CYCLE_LENGTH, Math.floor((out.length - 1) / 2)); window2 >= 2 && !changed; window2--) {
|
|
67722
|
+
for (let i = 0; i + 2 * window2 + 1 <= out.length; i++) {
|
|
67723
|
+
const first = out.slice(i, i + window2);
|
|
67724
|
+
if (first.some((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT"))
|
|
67725
|
+
continue;
|
|
67726
|
+
const back = out[i + window2];
|
|
67727
|
+
if (back.type !== "BACK")
|
|
67728
|
+
continue;
|
|
67729
|
+
const second = out.slice(i + window2 + 1, i + window2 + 1 + window2);
|
|
67730
|
+
if (!first.every((a, k) => actionCycleKey(a) === actionCycleKey(second[k])))
|
|
67731
|
+
continue;
|
|
67732
|
+
out.splice(i, window2 + 1);
|
|
67733
|
+
changed = true;
|
|
67734
|
+
break;
|
|
67735
|
+
}
|
|
67736
|
+
}
|
|
67314
67737
|
}
|
|
67315
67738
|
return out;
|
|
67316
67739
|
}
|
|
@@ -67325,6 +67748,11 @@ ${statePacket}` },
|
|
|
67325
67748
|
if (actions.length < beforeContainerDrop) {
|
|
67326
67749
|
log2(` dropped ${beforeContainerDrop - actions.length} bare-container tap step(s) (no identity \u2014 un-replayable ambient taps).`);
|
|
67327
67750
|
}
|
|
67751
|
+
const intentMismatch = scenarioIntentMismatchReason(scenario, actions);
|
|
67752
|
+
if (intentMismatch) {
|
|
67753
|
+
log2(` skipped scenario "${scenario.name}": ${intentMismatch}.`);
|
|
67754
|
+
return null;
|
|
67755
|
+
}
|
|
67328
67756
|
const floorAppended = appendHybridFloor(actions, recording, platform3);
|
|
67329
67757
|
if (floorAppended) {
|
|
67330
67758
|
log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
|
|
@@ -67347,6 +67775,10 @@ ${statePacket}` },
|
|
|
67347
67775
|
log2(` compile failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
67348
67776
|
return null;
|
|
67349
67777
|
}
|
|
67778
|
+
const selectorQuality = (0, shared_1.analyzeMobileSelectorQuality)(compiled.steps);
|
|
67779
|
+
if (selectorQuality.summary.high > 0 || selectorQuality.summary.medium > 0) {
|
|
67780
|
+
log2(` selector quality: score ${selectorQuality.summary.score}/100 (${selectorQuality.summary.high} high, ${selectorQuality.summary.medium} medium issue(s)).`);
|
|
67781
|
+
}
|
|
67350
67782
|
const claimedVerified = recording.finish?.verified ?? false;
|
|
67351
67783
|
const verified = claimedVerified && recording.assertCount > 0;
|
|
67352
67784
|
if (claimedVerified && !verified) {
|
|
@@ -67388,6 +67820,7 @@ ${statePacket}` },
|
|
|
67388
67820
|
targetPages: [...scenario.targetPages]
|
|
67389
67821
|
},
|
|
67390
67822
|
evidence: { observedLocators: [], observedTransitions: [] },
|
|
67823
|
+
mobileSelectorQuality: selectorQuality,
|
|
67391
67824
|
// Assertion-strength marker for the server promote-seam
|
|
67392
67825
|
// (resolveStabilityTransition): an UNVERIFIED recording proves none of the
|
|
67393
67826
|
// scenario's outcomes — only the hybrid floor's landing-screen visibility —
|
|
@@ -67412,6 +67845,25 @@ ${statePacket}` },
|
|
|
67412
67845
|
};
|
|
67413
67846
|
return test;
|
|
67414
67847
|
}
|
|
67848
|
+
async function prepareMobileScenarioAppState(driver, target, log2) {
|
|
67849
|
+
const resetPolicy = target?.resetPolicy ?? "AUTO";
|
|
67850
|
+
if (resetPolicy === "PRESERVE_APP_DATA") {
|
|
67851
|
+
await driver.relaunch();
|
|
67852
|
+
log2(" app data reset skipped by policy \u2014 scenario records after relaunch with preserved data.");
|
|
67853
|
+
return false;
|
|
67854
|
+
}
|
|
67855
|
+
const appDataCleared = await driver.resetAppState?.().catch((err) => {
|
|
67856
|
+
log2(` app-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
67857
|
+
return false;
|
|
67858
|
+
}) ?? false;
|
|
67859
|
+
if (appDataCleared)
|
|
67860
|
+
return true;
|
|
67861
|
+
if (resetPolicy === "CLEAR_APP_DATA") {
|
|
67862
|
+
log2(" CLEAR_APP_DATA requested but app-data reset was unavailable; falling back to relaunch.");
|
|
67863
|
+
}
|
|
67864
|
+
await driver.relaunch();
|
|
67865
|
+
return false;
|
|
67866
|
+
}
|
|
67415
67867
|
async function runMobileGeneratePhase(args) {
|
|
67416
67868
|
const { scenarios: allScenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot, skipScenarioNames, onInfraStop } = args;
|
|
67417
67869
|
const log2 = (line) => {
|
|
@@ -67444,11 +67896,9 @@ ${statePacket}` },
|
|
|
67444
67896
|
log2(`
|
|
67445
67897
|
-- Generating scenario ${i + 1}/${scenarios.length}: "${scenario.name}" --`);
|
|
67446
67898
|
try {
|
|
67447
|
-
const appDataCleared = await driver.
|
|
67899
|
+
const appDataCleared = await prepareMobileScenarioAppState(driver, ctx.target, log2);
|
|
67448
67900
|
if (appDataCleared) {
|
|
67449
67901
|
log2(" app data cleared \u2014 scenario records from a deterministic cold (logged-out) state.");
|
|
67450
|
-
} else {
|
|
67451
|
-
await driver.relaunch();
|
|
67452
67902
|
}
|
|
67453
67903
|
const recording = await runScenarioLoop({
|
|
67454
67904
|
scenario,
|
|
@@ -67999,8 +68449,9 @@ Reason over steps + error + tree + registry and return working healed steps. Do
|
|
|
67999
68449
|
1. ERROR: which step failed, on what locator/assertion? "element not found"/"no such element" almost always means a WRONG LOCATOR on a screen that DOES exist \u2014 pick a stronger IDENTITY locator that is actually present in the captured tree: a resource-id, an accessibility-id/content-desc, or an EXACT @text= predicate (never a loose contains()). Do NOT declare a feature absent from one failed locator.
|
|
68000
68450
|
2. STEP RESULTS: which steps passed before the failure.
|
|
68001
68451
|
3. TREE: does the current screen match what the failed step expects?
|
|
68002
|
-
3b. STALE EMPTY-STATE ASSERT: a failing assert on one-time / data-dependent UI (empty-state CTAs
|
|
68003
|
-
4.
|
|
68452
|
+
3b. STALE EMPTY-STATE ASSERT: a failing assert on one-time / data-dependent UI (empty-state CTAs, first-run prompts, onboarding banners) is NOT a locator problem \u2014 the element is gone because the account now has data. REPLACE the assertion with a durable landing-screen anchor (tab label, header, nav bar) that proves the same outcome (verdict fix).
|
|
68453
|
+
4. EXTERNAL AUTH HANDOFF: if the failure says the foreground app changed outside the target app after a Login / Sign In tap, and the steps never enter email/password credentials, do NOT heal by choosing Google/Apple/Facebook/SSO or another external app. If the test later expects Logout/session state, return test_invalid or prereq_unmet: the recorded test lacks a real credential setup.
|
|
68454
|
+
5. ENTRY-SCREEN DRIFT ("unexpected screen"/"screen drift"): the app started on a DIFFERENT screen than recorded; the fix is usually a MISSING NAVIGATION step (e.g. tap the onboarding/welcome CTA present in the tree before the recorded first step). Author it from the tree if you can (verdict fix); if a single static tree is insufficient, return needs_device.
|
|
68004
68455
|
## Response \u2014 STRICT JSON, exactly one verdict:
|
|
68005
68456
|
{"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps, your changes applied, order/metadata preserved>],"reason":"what changed and why","confidence":0.0-1.0}
|
|
68006
68457
|
{"verdict":"app_bug","reason":"app crashed/blank/5xx (NOT rate-limit)"}
|
|
@@ -68057,6 +68508,104 @@ For a fix, return the COMPLETE steps array (not a diff).`;
|
|
|
68057
68508
|
function isDrift(error2) {
|
|
68058
68509
|
return /unexpected screen|screen drift/i.test(error2 ?? "");
|
|
68059
68510
|
}
|
|
68511
|
+
function mobileStepSearchText(step) {
|
|
68512
|
+
const rawTarget = step.target;
|
|
68513
|
+
const structuredTarget = rawTarget && typeof rawTarget === "object" ? rawTarget : void 0;
|
|
68514
|
+
const bundle = structuredTarget?.locatorBundle;
|
|
68515
|
+
const stepValue = step.value;
|
|
68516
|
+
return [
|
|
68517
|
+
step.action,
|
|
68518
|
+
step.description,
|
|
68519
|
+
typeof stepValue === "string" ? stepValue : void 0,
|
|
68520
|
+
typeof rawTarget === "string" ? rawTarget : void 0,
|
|
68521
|
+
structuredTarget?.primary,
|
|
68522
|
+
bundle?.text,
|
|
68523
|
+
bundle?.contentDesc,
|
|
68524
|
+
bundle?.accessibilityId,
|
|
68525
|
+
bundle?.resourceId,
|
|
68526
|
+
bundle?.className,
|
|
68527
|
+
bundle?.xpath
|
|
68528
|
+
].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
|
|
68529
|
+
}
|
|
68530
|
+
function detectInvalidExternalAuthSetup(steps, error2) {
|
|
68531
|
+
if (!/foreground app changed|outside target app|com\.google\.android\.gms|external app/i.test(error2 ?? "")) {
|
|
68532
|
+
return null;
|
|
68533
|
+
}
|
|
68534
|
+
const hasBareLoginTap = steps.some((step) => {
|
|
68535
|
+
if (!/\btap\b/i.test(step.action))
|
|
68536
|
+
return false;
|
|
68537
|
+
const text = mobileStepSearchText(step);
|
|
68538
|
+
if (/\b(logout|log\s*out|sign\s*out)\b/.test(text))
|
|
68539
|
+
return false;
|
|
68540
|
+
return /\b(sign\s*in|signin|log\s*in|login)\b/.test(text);
|
|
68541
|
+
});
|
|
68542
|
+
if (!hasBareLoginTap)
|
|
68543
|
+
return null;
|
|
68544
|
+
const hasCredentialEntry = steps.some((step) => {
|
|
68545
|
+
if (!/\b(type|fill|input|enter)\b/i.test(step.action))
|
|
68546
|
+
return false;
|
|
68547
|
+
const text = mobileStepSearchText(step);
|
|
68548
|
+
return /\b(email|e-mail|username|user\s*name|password|passcode|pin|\{\{test_user_email\}\}|\{\{test_user_password\}\})\b/.test(text);
|
|
68549
|
+
});
|
|
68550
|
+
if (hasCredentialEntry)
|
|
68551
|
+
return null;
|
|
68552
|
+
return "bare Login/Sign In tap left the target app without any recorded credential setup; auth test is invalid until discovery records a real credential path";
|
|
68553
|
+
}
|
|
68554
|
+
function isCredentialFieldStep(step) {
|
|
68555
|
+
const text = mobileStepSearchText(step);
|
|
68556
|
+
return /\b(email|e-mail|username|user\s*name|password|passcode|pin|\{\{test_user_email\}\}|\{\{test_user_password\}\})\b/.test(text);
|
|
68557
|
+
}
|
|
68558
|
+
function isCredentialSubmitTap(step) {
|
|
68559
|
+
if (!/\b(tap|click|press)\b/i.test(step.action))
|
|
68560
|
+
return false;
|
|
68561
|
+
const text = mobileStepSearchText(step);
|
|
68562
|
+
if (/\b(logout|log\s*out|sign\s*out|reset|forgot|google|apple|facebook|github|sso|showemailform|with\s+email|email\s+form)\b/.test(text)) {
|
|
68563
|
+
return false;
|
|
68564
|
+
}
|
|
68565
|
+
return /\b(sign\s*in|signin|log\s*in|login|continue|submit)\b/.test(text);
|
|
68566
|
+
}
|
|
68567
|
+
function detectInvalidAuthValidationSetup(steps, error2) {
|
|
68568
|
+
if (!/\b(element not found|no such element|not visible|timeout|assert|could not be located|unexpected screen|screen drift)\b/i.test(error2 ?? "")) {
|
|
68569
|
+
return null;
|
|
68570
|
+
}
|
|
68571
|
+
const allText = steps.map(mobileStepSearchText).join(" ");
|
|
68572
|
+
if (!/\b(email|e-mail|username|user\s*name|password|passcode|pin)\b/.test(allText))
|
|
68573
|
+
return null;
|
|
68574
|
+
const uiOnlyCredentialScenario = /\b(mask|masked|unmask|visibility|visible password|show password|hide password|keyboard|ime|input type|max length|maxlength|character limit)\b/.test(allText);
|
|
68575
|
+
let lastCredentialEditOrder = -1;
|
|
68576
|
+
for (const step of steps) {
|
|
68577
|
+
if (!/\b(type|fill|input|enter|clear)\b/i.test(step.action))
|
|
68578
|
+
continue;
|
|
68579
|
+
if (!isCredentialFieldStep(step))
|
|
68580
|
+
continue;
|
|
68581
|
+
lastCredentialEditOrder = Math.max(lastCredentialEditOrder, step.order ?? -1);
|
|
68582
|
+
}
|
|
68583
|
+
if (lastCredentialEditOrder < 0)
|
|
68584
|
+
return null;
|
|
68585
|
+
if (uiOnlyCredentialScenario) {
|
|
68586
|
+
const hasCredentialFormEvidence = steps.some((step) => {
|
|
68587
|
+
if ((step.order ?? -1) >= lastCredentialEditOrder)
|
|
68588
|
+
return false;
|
|
68589
|
+
const text = mobileStepSearchText(step);
|
|
68590
|
+
return /\b(showemailformbtn|with\s+email|email\s+form|sign in with email|loginform|login form|email|e-mail|username|user\s*name)\b/.test(text);
|
|
68591
|
+
});
|
|
68592
|
+
if (hasCredentialFormEvidence)
|
|
68593
|
+
return null;
|
|
68594
|
+
return "credential-control test edits a credential field without recording a path to the email/password form; stale auth-control test is invalid until generation records a complete form path";
|
|
68595
|
+
}
|
|
68596
|
+
const hasSubmitAfterCredentialEdit = steps.some((step) => (step.order ?? -1) > lastCredentialEditOrder && isCredentialSubmitTap(step));
|
|
68597
|
+
if (hasSubmitAfterCredentialEdit)
|
|
68598
|
+
return null;
|
|
68599
|
+
const hasValidationAssertion = steps.some((step) => {
|
|
68600
|
+
if (!/\b(assert|wait)\b/i.test(step.action))
|
|
68601
|
+
return false;
|
|
68602
|
+
const text = mobileStepSearchText(step);
|
|
68603
|
+
return /\b(invalid|required|error|empty|missing|incorrect|wrong|try again|validation|must enter|enter (an )?(email|password)|email is required|password is required)\b/.test(text);
|
|
68604
|
+
});
|
|
68605
|
+
if (hasValidationAssertion)
|
|
68606
|
+
return null;
|
|
68607
|
+
return "credential field was edited but the test never submits the form or asserts a validation result; stale auth-validation test is invalid until generation records a complete flow";
|
|
68608
|
+
}
|
|
68060
68609
|
function stripCodeFences(text) {
|
|
68061
68610
|
const trimmed = text.trim();
|
|
68062
68611
|
const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/m.exec(trimmed);
|
|
@@ -68315,6 +68864,24 @@ ${args.directive}`);
|
|
|
68315
68864
|
const registryPresent = options.selectorRegistryContext != null;
|
|
68316
68865
|
const platform3 = target.platform;
|
|
68317
68866
|
const trimmedLogs = tailLimit(ctx.logs ?? "", MAX_LOG_CHARS);
|
|
68867
|
+
const invalidExternalAuthSetup = detectInvalidExternalAuthSetup(steps, ctx.error);
|
|
68868
|
+
if (invalidExternalAuthSetup) {
|
|
68869
|
+
return {
|
|
68870
|
+
passed: false,
|
|
68871
|
+
verdict: "test_invalid",
|
|
68872
|
+
healReason: invalidExternalAuthSetup,
|
|
68873
|
+
attemptsUsed: 0
|
|
68874
|
+
};
|
|
68875
|
+
}
|
|
68876
|
+
const invalidAuthValidationSetup = detectInvalidAuthValidationSetup(steps, ctx.error);
|
|
68877
|
+
if (invalidAuthValidationSetup) {
|
|
68878
|
+
return {
|
|
68879
|
+
passed: false,
|
|
68880
|
+
verdict: "test_invalid",
|
|
68881
|
+
healReason: invalidAuthValidationSetup,
|
|
68882
|
+
attemptsUsed: 0
|
|
68883
|
+
};
|
|
68884
|
+
}
|
|
68318
68885
|
let attemptCap = base2;
|
|
68319
68886
|
let recoverableUsed = 0;
|
|
68320
68887
|
let bonusUsed = 0;
|
|
@@ -68500,19 +69067,20 @@ ${args.directive}`);
|
|
|
68500
69067
|
removeTags: script.removeTags
|
|
68501
69068
|
};
|
|
68502
69069
|
}
|
|
68503
|
-
var DEFAULT_PHASE2_MAX_PASSES =
|
|
69070
|
+
var DEFAULT_PHASE2_MAX_PASSES = 4;
|
|
68504
69071
|
exports2.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Appium (mobile UI automation) test with LIVE device access (Phase 2). You receive: the failing test STEPS as JSON, the PRIOR failure error, and the LIVE current screen accessibility tree observed on-device RIGHT NOW. Unlike a frozen failure snapshot, this LIVE tree is the REAL screen the app is on this instant.
|
|
68505
69072
|
## Key insight \u2014 ENTRY-SCREEN DRIFT
|
|
68506
|
-
The recorded flow may have expected a DIFFERENT starting screen than the one shown. When the LIVE screen is an earlier/other screen (e.g. an onboarding
|
|
69073
|
+
The recorded flow may have expected a DIFFERENT starting screen than the one shown. When the LIVE screen is an earlier/other screen (e.g. an onboarding / welcome / splash screen), the fix is almost always a MISSING NAVIGATION step: AUTHOR the leading step(s) (e.g. a tap on the onboarding CTA present in the tree) that reach the recorded first screen, then keep the recorded steps. This is exactly what a static failing-source cannot reveal but a LIVE screen can.
|
|
68507
69074
|
## Analyze
|
|
68508
69075
|
1. Compare the recorded first step's expected screen to the LIVE tree.
|
|
68509
69076
|
2. If they differ, PREPEND the missing navigation step(s), authored from IDENTITY locators (resource-id, accessibility-id, or EXACT @text=) that are PRESENT in the LIVE tree \u2014 never a loose contains().
|
|
68510
69077
|
3. If a locator is simply wrong, swap it for a stronger identity locator present in the LIVE tree.
|
|
68511
|
-
3b. If the failing step ASSERTS one-time / data-dependent UI (an empty-state CTA
|
|
68512
|
-
4. If
|
|
69078
|
+
3b. If the failing step ASSERTS one-time / data-dependent UI (an empty-state CTA or first-run prompt) that is absent from the LIVE tree while the screen is otherwise correct, the assert is STALE \u2014 replace it with a durable element of the same screen (tab label, header, nav bar) and keep the rest.
|
|
69079
|
+
4. If the prior failure says the foreground app changed outside the target app after a Login / Sign In tap, and the steps never enter email/password credentials, do NOT observe/tap Google/Apple/Facebook/SSO. For logout/session tests, return test_invalid or prereq_unmet because the recorded test lacks a real credential setup.
|
|
69080
|
+
5. If you cannot yet SEE the screen you need, use "observe" to advance ONE step, then you will be re-shown the new live screen.
|
|
68513
69081
|
## Response \u2014 STRICT JSON, exactly one verdict:
|
|
68514
69082
|
{"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps incl. any prepended navigation, order/metadata coherent>],"reason":"what changed and why","confidence":0.0-1.0}
|
|
68515
|
-
{"verdict":"observe","action":{"type":"tap","target":"mobile.text('
|
|
69083
|
+
{"verdict":"observe","action":{"type":"tap","target":"mobile.text('<exact label visible in the live tree>')"}}
|
|
68516
69084
|
{"verdict":"observe","action":{"type":"back"}}
|
|
68517
69085
|
{"verdict":"observe","action":{"type":"relaunch"}}
|
|
68518
69086
|
{"verdict":"observe","action":{"type":"reset"}} \u2190 clears app data + cold-relaunches: use when the app is in the WRONG STATE for the recorded flow (e.g. signed in when the test expects the cleared first-launch screens, stale onboarding, poisoned local state)
|
|
@@ -73651,6 +74219,7 @@ var require_auth_recipe = __commonJS({
|
|
|
73651
74219
|
exports2.computeRecipeHash = computeRecipeHash;
|
|
73652
74220
|
exports2.buildAuthRecipe = buildAuthRecipe;
|
|
73653
74221
|
exports2.authProofLocator = authProofLocator;
|
|
74222
|
+
exports2.urlProofPredicateExpr = urlProofPredicateExpr;
|
|
73654
74223
|
exports2.renderAuthProofAssertion = renderAuthProofAssertion;
|
|
73655
74224
|
exports2.renderAuthFn = renderAuthFn;
|
|
73656
74225
|
exports2.absoluteRoute = absoluteRoute;
|
|
@@ -73705,6 +74274,9 @@ var require_auth_recipe = __commonJS({
|
|
|
73705
74274
|
};
|
|
73706
74275
|
}
|
|
73707
74276
|
function authProofLocator(proof) {
|
|
74277
|
+
if (proof.kind === "url") {
|
|
74278
|
+
throw new Error("authProofLocator: url proof has no element locator; callers must branch on proof.kind");
|
|
74279
|
+
}
|
|
73708
74280
|
if (proof.kind === "testid") {
|
|
73709
74281
|
return `page.getByTestId(${JSON.stringify(proof.testId)})`;
|
|
73710
74282
|
}
|
|
@@ -73713,7 +74285,14 @@ var require_auth_recipe = __commonJS({
|
|
|
73713
74285
|
}
|
|
73714
74286
|
return `page.getByRole(${JSON.stringify(proof.role)}).first()`;
|
|
73715
74287
|
}
|
|
74288
|
+
function urlProofPredicateExpr(route2) {
|
|
74289
|
+
const r = JSON.stringify(route2);
|
|
74290
|
+
return `(u) => u.pathname === ${r} || u.pathname.startsWith(${r} + '/')`;
|
|
74291
|
+
}
|
|
73716
74292
|
function renderAuthProofAssertion(proof) {
|
|
74293
|
+
if (proof.kind === "url") {
|
|
74294
|
+
return `await page.waitForURL(${urlProofPredicateExpr(proof.route)}, { timeout: 15000 });`;
|
|
74295
|
+
}
|
|
73717
74296
|
return `await expect(${authProofLocator(proof)}).toBeVisible({ timeout: 15000 });`;
|
|
73718
74297
|
}
|
|
73719
74298
|
function normalizeCmd(cmd) {
|
|
@@ -73727,9 +74306,16 @@ var require_auth_recipe = __commonJS({
|
|
|
73727
74306
|
lines.push(START_MARKER);
|
|
73728
74307
|
lines.push(`async function __vqaLogin(page) {`);
|
|
73729
74308
|
if (opts?.warmGuard) {
|
|
73730
|
-
const warmLanding = recipe.redirectRoute ?? recipe.preNavRoute;
|
|
74309
|
+
const warmLanding = recipe.redirectRoute ?? (recipe.authProof.kind === "url" ? recipe.authProof.route : recipe.preNavRoute);
|
|
74310
|
+
const loginRoute = recipe.route ?? recipe.preNavRoute;
|
|
73731
74311
|
lines.push(` await page.goto(${JSON.stringify(warmLanding)}, { waitUntil: 'domcontentloaded' }).catch(() => {});`);
|
|
73732
|
-
|
|
74312
|
+
if (recipe.authProof.kind === "url") {
|
|
74313
|
+
lines.push(` if ((${urlProofPredicateExpr(recipe.authProof.route)})(new URL(page.url()))) return;`);
|
|
74314
|
+
} else if (loginRoute) {
|
|
74315
|
+
lines.push(` if (await ${authProofLocator(recipe.authProof)}.isVisible().catch(() => false) && new URL(page.url()).pathname !== new URL(${JSON.stringify(loginRoute)}, page.url()).pathname) return;`);
|
|
74316
|
+
} else {
|
|
74317
|
+
lines.push(` if (await ${authProofLocator(recipe.authProof)}.isVisible().catch(() => false)) return;`);
|
|
74318
|
+
}
|
|
73733
74319
|
}
|
|
73734
74320
|
lines.push(` await page.goto(${JSON.stringify(recipe.preNavRoute)}, { waitUntil: 'domcontentloaded' });`);
|
|
73735
74321
|
for (const cmd of recipe.preNavCmds)
|
|
@@ -73778,6 +74364,11 @@ var require_auth_recipe = __commonJS({
|
|
|
73778
74364
|
}`;
|
|
73779
74365
|
}
|
|
73780
74366
|
function renderReplaySettleArrow(proof) {
|
|
74367
|
+
if (proof.kind === "url") {
|
|
74368
|
+
return `async (page) => {
|
|
74369
|
+
await page.waitForURL(${urlProofPredicateExpr(proof.route)}, { timeout: 15000 });
|
|
74370
|
+
}`;
|
|
74371
|
+
}
|
|
73781
74372
|
return `async (page) => {
|
|
73782
74373
|
await ${authProofLocator(proof)}.waitFor({ state: 'visible', timeout: 15000 });
|
|
73783
74374
|
}`;
|
|
@@ -307868,7 +308459,7 @@ ${keyLines || "- (none provided)"}
|
|
|
307868
308459
|
3. If the email/password fields are hidden behind a reveal button (e.g. "Sign in with Email", "Continue with Email"), click it first.
|
|
307869
308460
|
4. Fill the credentials with \`fill_credential\` \u2014 ONE call per field (email first, then password), referencing the credential KEYS above. Never type real values with browser_type. (PREFER per-field \`fill_credential\`; \`fill_credential_form\` is less reliable and a misfire wastes a scarce iteration.)
|
|
307870
308461
|
5. Submit the login form.
|
|
307871
|
-
6. Confirm you are authenticated WITHOUT opening any menu or dropdown: wait until you are signed in \u2014 this may be a REDIRECT to a new route, OR an in-place change on the SAME route (a login modal closing, the page swapping to authenticated content). Then take a \`browser_snapshot\`. The signed-in signal MUST be visible by DEFAULT on the resulting page \u2014 do NOT click an avatar / hamburger / account menu to hunt for a hidden "Logout" link, because the generated test asserts this proof WITHOUT opening any menu, so a menu-hidden control would fail.
|
|
308462
|
+
6. Confirm you are authenticated WITHOUT opening any menu or dropdown: wait until you are signed in \u2014 this may be a REDIRECT to a new route, OR an in-place change on the SAME route (a login modal closing, the page swapping to authenticated content). Then take a \`browser_snapshot\`. The signed-in signal MUST be visible by DEFAULT on the resulting page \u2014 do NOT click an avatar / hamburger / account menu to hunt for a hidden "Logout" link, because the generated test asserts this proof WITHOUT opening any menu, so a menu-hidden control would fail. PREFER a default-visible signal with a STABLE, identity-invariant label: a Logout / Sign out control that is already shown, a persistent authenticated nav or sidebar item (e.g. a Dashboard / Settings link), or a stable post-login heading. An avatar / account button whose ONLY label is the signed-in user's initials or name (e.g. "QT", "JS") is a WEAK signal \u2014 it changes per user/session, so do NOT rely on it; if that is the only signed-in signal on the page, still capture the page and finish (the system falls back to a durable route-based proof automatically).
|
|
307872
308463
|
7. Call \`capture_page\` for the login route ONCE (so its successful proven commands are saved), then call \`capture_page\` for the authenticated landing page IN ITS DEFAULT STATE (do not open any menu first) so the durable, default-visible auth-proof element is recorded.
|
|
307873
308464
|
8. The MOMENT an authenticated page is visible, your TOP PRIORITY is to \`capture_page\` that authenticated landing (step 7) and then call \`finish_exploration\` \u2014 do this IMMEDIATELY, before anything else, because your iteration budget is tight. Do NOT take extra browser_snapshots, do NOT explore features, do NOT click around the authenticated app, do NOT test validation or wrong-password flows. Capturing the authed landing + finishing is the whole job.
|
|
307874
308465
|
|
|
@@ -307928,7 +308519,11 @@ ${keyLines || "- (none provided)"}
|
|
|
307928
308519
|
if (el.testId)
|
|
307929
308520
|
testIdFreq.set(el.testId, (testIdFreq.get(el.testId) ?? 0) + 1);
|
|
307930
308521
|
}
|
|
307931
|
-
const
|
|
308522
|
+
const isInitialsLike = (name) => {
|
|
308523
|
+
const t = name.trim();
|
|
308524
|
+
return /^[A-Za-z]{1,3}$/.test(t) && t === t.toUpperCase();
|
|
308525
|
+
};
|
|
308526
|
+
const isPersonalized = (name) => !name || GREETING_RE.test(name) || name.includes("@") || findCredentialLiteralLeaks(name, credentials).length > 0 || isInitialsLike(name);
|
|
307932
308527
|
const absentOnLogin = (roleNameKey, testId) => {
|
|
307933
308528
|
if (testId && loginTestIds.has(testId))
|
|
307934
308529
|
return false;
|
|
@@ -307963,7 +308558,12 @@ ${keyLines || "- (none provided)"}
|
|
|
307963
308558
|
}
|
|
307964
308559
|
}
|
|
307965
308560
|
if (ranked.length === 0) {
|
|
307966
|
-
|
|
308561
|
+
const urlRoute = redirectRoute && redirectRoute !== "/" && !isLoginPage(redirectRoute) ? redirectRoute : landing.route !== "/" && !isLoginPage(landing.route) ? landing.route : null;
|
|
308562
|
+
if (urlRoute) {
|
|
308563
|
+
logs2(` \u2713 url auth-proof: authenticated landing route ${urlRoute} (no durable element proof on the default-state page)`);
|
|
308564
|
+
return { kind: "url", route: urlRoute };
|
|
308565
|
+
}
|
|
308566
|
+
logs2(" \u26A0 auth-proof: no durable element proof and no concrete authed landing route \u2014 falling back to model-authored login (no blind navigation default)");
|
|
307967
308567
|
return null;
|
|
307968
308568
|
}
|
|
307969
308569
|
ranked.sort((a, b) => a.tier - b.tier);
|
|
@@ -308114,7 +308714,7 @@ ${keyLines || "- (none provided)"}
|
|
|
308114
308714
|
logs2(" \u26A0 auth unverified (live-verify replay failed \u2014 fleet-config drift) \u2014 falling back to model-authored login");
|
|
308115
308715
|
return null;
|
|
308116
308716
|
}
|
|
308117
|
-
const proofDesc = authProof.kind === "role" ? `role:${authProof.role}${authProof.name ? `(${authProof.name})` : ""}` : `testid:${authProof.testId}`;
|
|
308717
|
+
const proofDesc = authProof.kind === "role" ? `role:${authProof.role}${authProof.name ? `(${authProof.name})` : ""}` : authProof.kind === "testid" ? `testid:${authProof.testId}` : `url:${authProof.route}`;
|
|
308118
308718
|
logs2(` \u2713 auth recipe verified (hash ${candidate.recipeHash}, proof ${proofDesc}, ${(candidate.replayPlan ?? []).length} replay steps) \u2014 stamping into @needs-auth tests`);
|
|
308119
308719
|
return candidate;
|
|
308120
308720
|
} catch (err) {
|
|
@@ -314807,13 +315407,13 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
314807
315407
|
exploreStats: { pagesDiscovered: 0, pagesVisited: 0, elementsFound: 0, transitionsFound: 0 },
|
|
314808
315408
|
duration: duration2,
|
|
314809
315409
|
mode,
|
|
314810
|
-
...paused ? { paused: true, pauseReason, remainingScenarios } : {}
|
|
315410
|
+
...paused ? { paused: true, pauseReason, pausePhase: "generate", remainingScenarios } : {}
|
|
314811
315411
|
// apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
|
|
314812
315412
|
};
|
|
314813
315413
|
}
|
|
314814
315414
|
log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
|
|
314815
|
-
const exploreAppDataCleared = await driver
|
|
314816
|
-
log2(exploreAppDataCleared ? "App data cleared \u2014 exploring from a deterministic cold (logged-out) state." : "App data reset unavailable (platform/device policy) \u2014 exploring the app in its current state.");
|
|
315415
|
+
const exploreAppDataCleared = await prepareMobileAppStateForDiscovery(driver, target, "explore", log2);
|
|
315416
|
+
log2(exploreAppDataCleared ? "App data cleared \u2014 exploring from a deterministic cold (logged-out) state." : target?.resetPolicy === "PRESERVE_APP_DATA" ? "App data reset skipped by policy \u2014 exploring with preserved app data." : "App data reset unavailable (platform/device policy) \u2014 exploring the app in its current state.");
|
|
314817
315417
|
const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
|
|
314818
315418
|
const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
|
|
314819
315419
|
const existingScreens = isSurvey && ctx.incremental ? compactExistingScreens(ctx.existingSiteMap) : void 0;
|
|
@@ -314848,6 +315448,32 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
|
|
|
314848
315448
|
log2(`[stream] explore-complete callback failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
314849
315449
|
});
|
|
314850
315450
|
}
|
|
315451
|
+
if (!isSurvey && shouldPauseAfterTruncatedExplore(explore)) {
|
|
315452
|
+
const duration2 = (Date.now() - startTime) / 1e3;
|
|
315453
|
+
const pauseReason = explore.truncatedReason === "timeout" ? "ai_timeout" : "ai_rate_limit";
|
|
315454
|
+
const summary2 = `Mobile discovery paused after exploration: mapped ${explore.exploreStats.pagesDiscovered} screen(s) before ${explore.truncatedReason ?? "AI"} interruption in ${duration2.toFixed(1)}s.`;
|
|
315455
|
+
log2(summary2);
|
|
315456
|
+
return {
|
|
315457
|
+
collectedPages: explore.collectedPages,
|
|
315458
|
+
collectedTransitions,
|
|
315459
|
+
tests: [],
|
|
315460
|
+
plans: [],
|
|
315461
|
+
logs: logs2.join("\n"),
|
|
315462
|
+
screenshots,
|
|
315463
|
+
pageScreenshots: explore.pageScreenshots,
|
|
315464
|
+
summary: summary2,
|
|
315465
|
+
warning: mergeWarning(void 0, explore),
|
|
315466
|
+
healthObservations: explore.healthObservations,
|
|
315467
|
+
exploreTruncated: true,
|
|
315468
|
+
exploreTruncatedReason: explore.truncatedReason,
|
|
315469
|
+
exploreStats: explore.exploreStats,
|
|
315470
|
+
duration: duration2,
|
|
315471
|
+
mode,
|
|
315472
|
+
paused: true,
|
|
315473
|
+
pauseReason,
|
|
315474
|
+
pausePhase: "explore"
|
|
315475
|
+
};
|
|
315476
|
+
}
|
|
314851
315477
|
log2("\nRelaunching app for a clean state before planning/generation...");
|
|
314852
315478
|
await driver.relaunch();
|
|
314853
315479
|
log2("\n\u2500\u2500 Phase 2: PLAN \u2500\u2500");
|
|
@@ -315000,7 +315626,7 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
|
|
|
315000
315626
|
// D-D4 — a circuit-breaker trip PAUSES the run (resumable) instead of
|
|
315001
315627
|
// finalizing COMPLETED/FAILED. The server's isDiscoveryPaused reads these
|
|
315002
315628
|
// (forwarded by runner.ts buildFinalBody, byte-identical to the web path).
|
|
315003
|
-
...generatePaused ? { paused: true, pauseReason: generatePauseReason, remainingScenarios: generateRemainingScenarios } : {}
|
|
315629
|
+
...generatePaused ? { paused: true, pauseReason: generatePauseReason, pausePhase: "generate", remainingScenarios: generateRemainingScenarios } : {}
|
|
315004
315630
|
// apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
|
|
315005
315631
|
};
|
|
315006
315632
|
} catch (fatalErr) {
|
|
@@ -315029,6 +315655,29 @@ Fatal error: ${msg}`);
|
|
|
315029
315655
|
};
|
|
315030
315656
|
}
|
|
315031
315657
|
}
|
|
315658
|
+
async function prepareMobileAppStateForDiscovery(driver, target, phase, log2) {
|
|
315659
|
+
void phase;
|
|
315660
|
+
const resetPolicy = target?.resetPolicy ?? "AUTO";
|
|
315661
|
+
if (resetPolicy === "PRESERVE_APP_DATA") {
|
|
315662
|
+
try {
|
|
315663
|
+
await driver.relaunch();
|
|
315664
|
+
} catch (err) {
|
|
315665
|
+
log2(`App relaunch failed while preserving app data: ${err instanceof Error ? err.message : String(err)}`);
|
|
315666
|
+
}
|
|
315667
|
+
return false;
|
|
315668
|
+
}
|
|
315669
|
+
const cleared = await driver.resetAppState?.().catch((err) => {
|
|
315670
|
+
log2(`App-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
315671
|
+
return false;
|
|
315672
|
+
}) ?? false;
|
|
315673
|
+
if (!cleared && resetPolicy === "CLEAR_APP_DATA") {
|
|
315674
|
+
log2("CLEAR_APP_DATA requested but app-data reset was unavailable; continuing without clearing app data.");
|
|
315675
|
+
}
|
|
315676
|
+
return cleared;
|
|
315677
|
+
}
|
|
315678
|
+
function shouldPauseAfterTruncatedExplore(explore) {
|
|
315679
|
+
return explore.truncated === true && (explore.truncatedReason === "rate_limit" || explore.truncatedReason === "timeout");
|
|
315680
|
+
}
|
|
315032
315681
|
function mergeWarning(plannerWarning, explore) {
|
|
315033
315682
|
if (!explore.truncated)
|
|
315034
315683
|
return plannerWarning;
|
|
@@ -329530,7 +330179,7 @@ var require_package4 = __commonJS({
|
|
|
329530
330179
|
"package.json"(exports2, module2) {
|
|
329531
330180
|
module2.exports = {
|
|
329532
330181
|
name: "@validate.qa/runner",
|
|
329533
|
-
version: "1.0.
|
|
330182
|
+
version: "1.0.31",
|
|
329534
330183
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
329535
330184
|
bin: {
|
|
329536
330185
|
"validate-runner": "dist/cli.js"
|
|
@@ -329679,6 +330328,8 @@ var MAX_RETRIES = 3;
|
|
|
329679
330328
|
var STARTUP_TIMEOUT_MS = 3e4;
|
|
329680
330329
|
var HEALTH_CHECK_INTERVAL_MS = 5e3;
|
|
329681
330330
|
var STABLE_AFTER_MS = 6e4;
|
|
330331
|
+
var DEFAULT_APPIUM_LOG_LEVEL = "warn";
|
|
330332
|
+
var MAX_APPIUM_LOG_LINE_CHARS = 4e3;
|
|
329682
330333
|
var appiumProcess = null;
|
|
329683
330334
|
var serverState = {
|
|
329684
330335
|
running: false,
|
|
@@ -329738,6 +330389,23 @@ function appendLog(line) {
|
|
|
329738
330389
|
logs = logs.slice(-MAX_LOG_LINES);
|
|
329739
330390
|
}
|
|
329740
330391
|
}
|
|
330392
|
+
function resolveAppiumLogLevel() {
|
|
330393
|
+
const raw = process.env.APPIUM_LOG_LEVEL?.trim();
|
|
330394
|
+
if (raw && /^[a-z]+(?::[a-z]+)?$/i.test(raw)) return raw;
|
|
330395
|
+
return DEFAULT_APPIUM_LOG_LEVEL;
|
|
330396
|
+
}
|
|
330397
|
+
function buildAppiumArgs(port, drivers) {
|
|
330398
|
+
const args = ["--port", String(port), "--log-level", resolveAppiumLogLevel()];
|
|
330399
|
+
if (drivers && drivers.length > 0) {
|
|
330400
|
+
args.push("--use-drivers", drivers.join(","));
|
|
330401
|
+
}
|
|
330402
|
+
return args;
|
|
330403
|
+
}
|
|
330404
|
+
function sanitizeAppiumLogLine(line) {
|
|
330405
|
+
const redacted = line.replace(/"text"\s*:\s*"[^"]*"/g, '"text":"[REDACTED]"').replace(/"password"\s*:\s*"[^"]*"/gi, '"password":"[REDACTED]"').replace(/"token"\s*:\s*"[^"]*"/gi, '"token":"[REDACTED]"').replace(/"value"\s*:\s*\[(?:"[^"]*",?){8,}\]/g, '"value":["[REDACTED]"]').replace(/"value"\s*:\s*"[A-Za-z0-9+/=]{512,}"/g, '"value":"[BASE64_REDACTED]"');
|
|
330406
|
+
if (redacted.length <= MAX_APPIUM_LOG_LINE_CHARS) return redacted;
|
|
330407
|
+
return `${redacted.slice(0, MAX_APPIUM_LOG_LINE_CHARS)}\u2026 [truncated ${redacted.length - MAX_APPIUM_LOG_LINE_CHARS} chars]`;
|
|
330408
|
+
}
|
|
329741
330409
|
async function checkAppiumHealth(port = serverState.port) {
|
|
329742
330410
|
try {
|
|
329743
330411
|
const controller = new AbortController();
|
|
@@ -329770,10 +330438,7 @@ async function startAppiumServer(options) {
|
|
|
329770
330438
|
return serverState;
|
|
329771
330439
|
}
|
|
329772
330440
|
const port = options?.port ?? APPIUM_PORT;
|
|
329773
|
-
const args =
|
|
329774
|
-
if (options?.drivers && options.drivers.length > 0) {
|
|
329775
|
-
args.push("--use-drivers", options.drivers.join(","));
|
|
329776
|
-
}
|
|
330441
|
+
const args = buildAppiumArgs(port, options?.drivers);
|
|
329777
330442
|
intentionalStop = false;
|
|
329778
330443
|
lastStartOptions = { ...lastStartOptions, ...options };
|
|
329779
330444
|
if (appiumGaveUp) {
|
|
@@ -329792,14 +330457,14 @@ async function startAppiumServer(options) {
|
|
|
329792
330457
|
serverState.pid = appiumProcess.pid ?? null;
|
|
329793
330458
|
serverState.port = port;
|
|
329794
330459
|
appiumProcess.stdout?.on("data", (data) => {
|
|
329795
|
-
const line = data.toString().trim();
|
|
330460
|
+
const line = sanitizeAppiumLogLine(data.toString().trim());
|
|
329796
330461
|
if (line) {
|
|
329797
330462
|
appendLog(`[appium:stdout] ${line}`);
|
|
329798
330463
|
options?.onLog?.(line);
|
|
329799
330464
|
}
|
|
329800
330465
|
});
|
|
329801
330466
|
appiumProcess.stderr?.on("data", (data) => {
|
|
329802
|
-
const line = data.toString().trim();
|
|
330467
|
+
const line = sanitizeAppiumLogLine(data.toString().trim());
|
|
329803
330468
|
if (line) {
|
|
329804
330469
|
appendLog(`[appium:stderr] ${line}`);
|
|
329805
330470
|
options?.onLog?.(line);
|
|
@@ -329958,6 +330623,17 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
|
|
|
329958
330623
|
// src/services/mobile/appium-actions.ts
|
|
329959
330624
|
var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
|
|
329960
330625
|
var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
330626
|
+
var APPIUM_DRIVER_DEAD_RE = /instrumentation process is not running|cannot be proxied to UiAutomator2 server|socket hang up|ECONNRESET|invalid session id|no such session/i;
|
|
330627
|
+
var AppiumDriverDeadError = class extends Error {
|
|
330628
|
+
isAppiumDriverDead = true;
|
|
330629
|
+
constructor(message) {
|
|
330630
|
+
super(message);
|
|
330631
|
+
this.name = "AppiumDriverDeadError";
|
|
330632
|
+
}
|
|
330633
|
+
};
|
|
330634
|
+
function isAppiumDriverDeadError(err) {
|
|
330635
|
+
return err instanceof AppiumDriverDeadError || typeof err === "object" && err !== null && err.isAppiumDriverDead === true;
|
|
330636
|
+
}
|
|
329961
330637
|
async function appiumRequest(path, options) {
|
|
329962
330638
|
const url = `http://localhost:${getAppiumState().port}${path}`;
|
|
329963
330639
|
const res = await fetch(url, {
|
|
@@ -329967,6 +330643,9 @@ async function appiumRequest(path, options) {
|
|
|
329967
330643
|
});
|
|
329968
330644
|
if (!res.ok) {
|
|
329969
330645
|
const body = await res.text().catch(() => "");
|
|
330646
|
+
if (APPIUM_DRIVER_DEAD_RE.test(body)) {
|
|
330647
|
+
throw new AppiumDriverDeadError(`Appium driver session is not usable (${res.status}): ${body.slice(0, 200)}`);
|
|
330648
|
+
}
|
|
329970
330649
|
throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
|
|
329971
330650
|
}
|
|
329972
330651
|
return res.json();
|
|
@@ -329981,7 +330660,8 @@ async function getScreenshot(sessionPath) {
|
|
|
329981
330660
|
try {
|
|
329982
330661
|
const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
|
|
329983
330662
|
return typeof value === "string" ? value : null;
|
|
329984
|
-
} catch {
|
|
330663
|
+
} catch (err) {
|
|
330664
|
+
if (isAppiumDriverDeadError(err)) throw err;
|
|
329985
330665
|
return null;
|
|
329986
330666
|
}
|
|
329987
330667
|
}
|
|
@@ -329989,7 +330669,8 @@ async function getPageSource(sessionPath) {
|
|
|
329989
330669
|
try {
|
|
329990
330670
|
const value = readValue(await appiumRequest(`${sessionPath}/source`));
|
|
329991
330671
|
return typeof value === "string" ? value : null;
|
|
329992
|
-
} catch {
|
|
330672
|
+
} catch (err) {
|
|
330673
|
+
if (isAppiumDriverDeadError(err)) throw err;
|
|
329993
330674
|
return null;
|
|
329994
330675
|
}
|
|
329995
330676
|
}
|
|
@@ -330005,7 +330686,8 @@ async function getWindowSize(sessionPath) {
|
|
|
330005
330686
|
return { width: w, height: h };
|
|
330006
330687
|
}
|
|
330007
330688
|
}
|
|
330008
|
-
} catch {
|
|
330689
|
+
} catch (err) {
|
|
330690
|
+
if (isAppiumDriverDeadError(err)) throw err;
|
|
330009
330691
|
}
|
|
330010
330692
|
if (attempt < retries - 1) {
|
|
330011
330693
|
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
|
|
@@ -330669,6 +331351,15 @@ var MitmProxy = class {
|
|
|
330669
331351
|
}
|
|
330670
331352
|
return this.tlsFailureReason;
|
|
330671
331353
|
}
|
|
331354
|
+
get passthroughCount() {
|
|
331355
|
+
return this.tlsPassthroughCount;
|
|
331356
|
+
}
|
|
331357
|
+
get capturedCallCounts() {
|
|
331358
|
+
return {
|
|
331359
|
+
callCount: this.calls.length,
|
|
331360
|
+
apiCallCount: this.calls.filter((call) => (0, import_runner_core7.isApiCall)(call.url, call.responseContentType)).length
|
|
331361
|
+
};
|
|
331362
|
+
}
|
|
330672
331363
|
async close() {
|
|
330673
331364
|
const server3 = this.server;
|
|
330674
331365
|
this.server = null;
|
|
@@ -331255,6 +331946,35 @@ async function startMobileNetworkCapture(options) {
|
|
|
331255
331946
|
const reason = proxy.interceptionReason ?? wiringReason;
|
|
331256
331947
|
return reason ? { intercepting: false, reason } : { intercepting: false };
|
|
331257
331948
|
};
|
|
331949
|
+
const health = () => {
|
|
331950
|
+
const capabilities = getCapabilities();
|
|
331951
|
+
const counts = stopped ? {
|
|
331952
|
+
callCount: finalCalls.length,
|
|
331953
|
+
apiCallCount: finalCalls.filter((call) => call.isApi).length
|
|
331954
|
+
} : proxy.capturedCallCounts;
|
|
331955
|
+
const warnings = [];
|
|
331956
|
+
if (!capabilities.intercepting && capabilities.reason) {
|
|
331957
|
+
warnings.push(capabilities.reason);
|
|
331958
|
+
}
|
|
331959
|
+
if (stopped && counts.callCount === 0) {
|
|
331960
|
+
warnings.push("No mobile network calls were captured. The app may not have generated traffic, the device proxy may be blocked, or the app may bypass system proxy settings.");
|
|
331961
|
+
}
|
|
331962
|
+
return {
|
|
331963
|
+
enabled: true,
|
|
331964
|
+
active: !stopped,
|
|
331965
|
+
platform: platform3,
|
|
331966
|
+
deviceId,
|
|
331967
|
+
appId: options.appId,
|
|
331968
|
+
proxyHost,
|
|
331969
|
+
proxyPort,
|
|
331970
|
+
intercepting: capabilities.intercepting,
|
|
331971
|
+
...capabilities.reason ? { reason: capabilities.reason } : {},
|
|
331972
|
+
callCount: counts.callCount,
|
|
331973
|
+
apiCallCount: counts.apiCallCount,
|
|
331974
|
+
tlsPassthroughCount: proxy.passthroughCount,
|
|
331975
|
+
warnings
|
|
331976
|
+
};
|
|
331977
|
+
};
|
|
331258
331978
|
const stop = async () => {
|
|
331259
331979
|
if (stopped) return finalCalls;
|
|
331260
331980
|
stopped = true;
|
|
@@ -331293,6 +332013,7 @@ async function startMobileNetworkCapture(options) {
|
|
|
331293
332013
|
proxyHost,
|
|
331294
332014
|
caCertPath: capturedCaCertPath,
|
|
331295
332015
|
getCapabilities,
|
|
332016
|
+
health,
|
|
331296
332017
|
stop
|
|
331297
332018
|
};
|
|
331298
332019
|
}
|
|
@@ -331307,6 +332028,7 @@ var COLD_BOOT_ENTRY_TIMEOUT_MS = 3e4;
|
|
|
331307
332028
|
var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
|
|
331308
332029
|
var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
|
|
331309
332030
|
var WD_ACTION_TIMEOUT_MS = 3e4;
|
|
332031
|
+
var APPIUM_DRIVER_DEAD_RE2 = /instrumentation process is not running|cannot be proxied to UiAutomator2 server|socket hang up|ECONNRESET/i;
|
|
331310
332032
|
var MAX_SESSION_CREATE_ATTEMPTS = 3;
|
|
331311
332033
|
var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
|
|
331312
332034
|
var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
|
|
@@ -331343,6 +332065,9 @@ async function wdRequest(path, options) {
|
|
|
331343
332065
|
if (res.status === 404 && /no such session|invalid session id|session is either terminated|deleted session/i.test(body)) {
|
|
331344
332066
|
throw new WebDriverTransportError(`WebDriver session is no longer available: ${body.slice(0, 200)}`);
|
|
331345
332067
|
}
|
|
332068
|
+
if (res.status === 500 && APPIUM_DRIVER_DEAD_RE2.test(body)) {
|
|
332069
|
+
throw new WebDriverTransportError(`WebDriver driver process is not usable: ${body.slice(0, 200)}`);
|
|
332070
|
+
}
|
|
331346
332071
|
if (res.status === 502 || res.status === 503 || res.status === 504) {
|
|
331347
332072
|
throw new WebDriverTransportError(`Appium server unavailable (${res.status}): ${body.slice(0, 200)}`);
|
|
331348
332073
|
}
|
|
@@ -331773,6 +332498,32 @@ function readStepTimeout(step) {
|
|
|
331773
332498
|
}
|
|
331774
332499
|
return void 0;
|
|
331775
332500
|
}
|
|
332501
|
+
async function revealLikelyOffscreenElement(sessionId) {
|
|
332502
|
+
const windowSize = await getWindowSize2(sessionId);
|
|
332503
|
+
const { startX, startY, endX, endY } = computeMobileSwipePointers(
|
|
332504
|
+
windowSize.width,
|
|
332505
|
+
windowSize.height,
|
|
332506
|
+
"up",
|
|
332507
|
+
clampMobileSwipeDistance(void 0)
|
|
332508
|
+
);
|
|
332509
|
+
await wdRequest(`/session/${sessionId}/actions`, {
|
|
332510
|
+
method: "POST",
|
|
332511
|
+
body: JSON.stringify({
|
|
332512
|
+
actions: [{
|
|
332513
|
+
type: "pointer",
|
|
332514
|
+
id: "finger-reveal",
|
|
332515
|
+
parameters: { pointerType: "touch" },
|
|
332516
|
+
actions: [
|
|
332517
|
+
{ type: "pointerMove", duration: 0, x: startX, y: startY },
|
|
332518
|
+
{ type: "pointerDown", button: 0 },
|
|
332519
|
+
{ type: "pointerMove", duration: 300, x: endX, y: endY },
|
|
332520
|
+
{ type: "pointerUp", button: 0 }
|
|
332521
|
+
]
|
|
332522
|
+
}]
|
|
332523
|
+
})
|
|
332524
|
+
});
|
|
332525
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
332526
|
+
}
|
|
331776
332527
|
async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
|
|
331777
332528
|
const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
|
|
331778
332529
|
if (strategies.length === 0) {
|
|
@@ -331782,6 +332533,7 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
|
|
|
331782
332533
|
}
|
|
331783
332534
|
const startTime = Date.now();
|
|
331784
332535
|
let lastError = "";
|
|
332536
|
+
let revealAttempted = false;
|
|
331785
332537
|
while (Date.now() - startTime < timeoutMs) {
|
|
331786
332538
|
for (const strategy of strategies) {
|
|
331787
332539
|
try {
|
|
@@ -331796,9 +332548,18 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
|
|
|
331796
332548
|
lastError = err instanceof Error ? err.message : String(err);
|
|
331797
332549
|
}
|
|
331798
332550
|
}
|
|
332551
|
+
if (!opts?.identityOnly && !revealAttempted && Date.now() - startTime > Math.min(1500, timeoutMs / 3)) {
|
|
332552
|
+
revealAttempted = true;
|
|
332553
|
+
try {
|
|
332554
|
+
await revealLikelyOffscreenElement(sessionId);
|
|
332555
|
+
} catch (err) {
|
|
332556
|
+
if (isTransportError(err)) throw err;
|
|
332557
|
+
lastError = `reveal scroll failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
332558
|
+
}
|
|
332559
|
+
}
|
|
331799
332560
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
331800
332561
|
}
|
|
331801
|
-
throw new Error(`Element not found after ${timeoutMs}ms. Tried ${strategies.length} strategies. Last error: ${lastError}`);
|
|
332562
|
+
throw new Error(`Element not found after ${timeoutMs}ms. Tried ${strategies.length} strategies${revealAttempted ? " plus one reveal scroll" : ""}. Last error: ${lastError}`);
|
|
331802
332563
|
}
|
|
331803
332564
|
async function tapCoordinates(sessionId, bounds) {
|
|
331804
332565
|
const tapX = Math.round(bounds.x + bounds.width / 2);
|
|
@@ -332222,21 +332983,29 @@ async function executeMobileTest(options) {
|
|
|
332222
332983
|
recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
|
|
332223
332984
|
const launchStepForReset = runSteps.find((s) => s.action === "launchApp");
|
|
332224
332985
|
const recordedFromClearedState = launchStepForReset?.metadata?.appReset === "cleared";
|
|
332225
|
-
|
|
332986
|
+
const resetPolicy = options.target.resetPolicy ?? "AUTO";
|
|
332987
|
+
const shouldClearAppData = resetPolicy === "CLEAR_APP_DATA" || resetPolicy === "AUTO" && recordedFromClearedState;
|
|
332988
|
+
let clearedAppDataBeforeLaunch = false;
|
|
332989
|
+
if (shouldClearAppData) {
|
|
332226
332990
|
if (shouldResetMobileAppData(options.target.platform, selectedDevice.id)) {
|
|
332227
332991
|
try {
|
|
332228
332992
|
await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
|
|
332229
|
-
|
|
332993
|
+
clearedAppDataBeforeLaunch = true;
|
|
332994
|
+
log2(resetPolicy === "CLEAR_APP_DATA" ? "App data cleared before launch (target resetPolicy=CLEAR_APP_DATA)." : "App data cleared before launch (test was recorded from a cold logged-out state).");
|
|
332230
332995
|
} catch (err) {
|
|
332231
332996
|
log2(`App-data clear failed (continuing with plain fresh launch): ${err instanceof Error ? err.message : String(err)}`);
|
|
332232
332997
|
}
|
|
332998
|
+
} else if (resetPolicy === "CLEAR_APP_DATA") {
|
|
332999
|
+
log2("target.resetPolicy=CLEAR_APP_DATA requested, but app-data reset is disabled by device policy (physical device / VALIDATEQA_MOBILE_APP_RESET=never) \u2014 replaying against preserved data.");
|
|
332233
333000
|
} else {
|
|
332234
333001
|
log2("Test expects a cleared app state, but app-data reset is disabled by policy (physical device / VALIDATEQA_MOBILE_APP_RESET=never) \u2014 replaying against the current app state.");
|
|
332235
333002
|
}
|
|
333003
|
+
} else if (recordedFromClearedState && resetPolicy === "PRESERVE_APP_DATA") {
|
|
333004
|
+
log2("Test was recorded from a cleared state, but target.resetPolicy=PRESERVE_APP_DATA \u2014 replaying against preserved app data.");
|
|
332236
333005
|
}
|
|
332237
333006
|
log2("Opening a fresh app instance for the test...");
|
|
332238
333007
|
await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
|
|
332239
|
-
if (
|
|
333008
|
+
if (clearedAppDataBeforeLaunch) {
|
|
332240
333009
|
await waitForColdBootContent(sessionId, log2);
|
|
332241
333010
|
}
|
|
332242
333011
|
if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
|
|
@@ -334482,8 +335251,9 @@ function envInt(name, fallback) {
|
|
|
334482
335251
|
var MOBILE_DEVICE_CEILING = Math.max(0, envInt("MOBILE_DEVICE_CEILING", 0));
|
|
334483
335252
|
var MOBILE_HEAL_HOLD_MS = Math.max(0, envInt("MOBILE_HEAL_HOLD_MS", 2e4));
|
|
334484
335253
|
function scrubAuditMessages(messages, testVariables) {
|
|
335254
|
+
if (messages === void 0 || messages === null) return messages;
|
|
334485
335255
|
const compacted = compactForAudit(messages);
|
|
334486
|
-
const secretValues = Object.values(testVariables).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
|
|
335256
|
+
const secretValues = Object.values(testVariables ?? {}).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
|
|
334487
335257
|
if (secretValues.length === 0) return compacted;
|
|
334488
335258
|
let json = JSON.stringify(compacted);
|
|
334489
335259
|
if (typeof json !== "string") return compacted;
|
|
@@ -334798,6 +335568,9 @@ var AUDIT_FLUSH_COUNT = 10;
|
|
|
334798
335568
|
var AUDIT_FLUSH_BYTES = 2 * 1024 * 1024;
|
|
334799
335569
|
var AUDIT_DRAIN_ATTEMPTS = 3;
|
|
334800
335570
|
var AUDIT_DRAIN_DELAY_MS = 1e3;
|
|
335571
|
+
var AUDIT_POST_MAX_BYTES = 4 * 1024 * 1024;
|
|
335572
|
+
var AUDIT_POST_MAX_ENTRIES = 50;
|
|
335573
|
+
var AUDIT_QUEUE_MAX_BYTES = 32 * 1024 * 1024;
|
|
334801
335574
|
var AUDIT_BODY_MAX_BYTES = 1024 * 1024;
|
|
334802
335575
|
var AUDIT_MAX_STRING_CHARS = 5e4;
|
|
334803
335576
|
var AUDIT_MAX_ARRAY_ITEMS = 50;
|
|
@@ -334848,7 +335621,7 @@ function compactForAudit(value, depth = 0, seen = /* @__PURE__ */ new WeakSet())
|
|
|
334848
335621
|
}
|
|
334849
335622
|
function scrubAuditText(text, testVariables) {
|
|
334850
335623
|
let out = text;
|
|
334851
|
-
for (const [key, value] of Object.entries(testVariables).sort((a, b) => b[1].length - a[1].length)) {
|
|
335624
|
+
for (const [key, value] of Object.entries(testVariables ?? {}).sort((a, b) => b[1].length - a[1].length)) {
|
|
334852
335625
|
if (!value || value.length < 3) continue;
|
|
334853
335626
|
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
334854
335627
|
const replacement = SECRET_VAR_PATTERN.test(key) ? "[REDACTED]" : `[TEST_VARIABLE:${key}]`;
|
|
@@ -334967,6 +335740,9 @@ function truncateAuditRequestBody(body) {
|
|
|
334967
335740
|
if (byteLen <= AUDIT_BODY_MAX_BYTES) return compacted;
|
|
334968
335741
|
return { _truncated: true, _originalLength: byteLen, preview: serialized.slice(0, 1e3) };
|
|
334969
335742
|
}
|
|
335743
|
+
function isTerminalAuditStatus(status) {
|
|
335744
|
+
return status === 400 || status === 404 || status === 409 || status === 413;
|
|
335745
|
+
}
|
|
334970
335746
|
function createAuditFlusher(url, headers) {
|
|
334971
335747
|
const entries = [];
|
|
334972
335748
|
let byteEstimate = 0;
|
|
@@ -334974,26 +335750,41 @@ function createAuditFlusher(url, headers) {
|
|
|
334974
335750
|
const doFlush = () => {
|
|
334975
335751
|
if (entries.length === 0) return Promise.resolve();
|
|
334976
335752
|
if (flushInFlight) return flushInFlight;
|
|
334977
|
-
const toSend = entries.splice(0);
|
|
334978
|
-
const savedBytes = byteEstimate;
|
|
334979
|
-
byteEstimate = 0;
|
|
334980
335753
|
const attempt = async () => {
|
|
334981
335754
|
try {
|
|
334982
|
-
|
|
334983
|
-
|
|
334984
|
-
|
|
334985
|
-
|
|
334986
|
-
|
|
334987
|
-
|
|
334988
|
-
|
|
334989
|
-
|
|
334990
|
-
|
|
334991
|
-
|
|
335755
|
+
while (entries.length > 0) {
|
|
335756
|
+
const toSend = [];
|
|
335757
|
+
let sendBytes = 0;
|
|
335758
|
+
while (entries.length > 0 && toSend.length < AUDIT_POST_MAX_ENTRIES) {
|
|
335759
|
+
const next = entries[0];
|
|
335760
|
+
if (toSend.length > 0 && sendBytes + next.bytes > AUDIT_POST_MAX_BYTES) break;
|
|
335761
|
+
toSend.push(entries.shift());
|
|
335762
|
+
sendBytes += next.bytes;
|
|
335763
|
+
}
|
|
335764
|
+
byteEstimate = Math.max(0, byteEstimate - sendBytes);
|
|
335765
|
+
try {
|
|
335766
|
+
const res = await fetch(url, {
|
|
335767
|
+
method: "POST",
|
|
335768
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
335769
|
+
body: JSON.stringify({ entries: toSend.map((e) => e.entry) }),
|
|
335770
|
+
signal: AbortSignal.timeout(3e4)
|
|
335771
|
+
});
|
|
335772
|
+
if (res.ok) continue;
|
|
335773
|
+
if (isTerminalAuditStatus(res.status)) {
|
|
335774
|
+
console.warn(`[audit-flush] HTTP ${res.status} (terminal) - dropping ${toSend.length} audit entries`);
|
|
335775
|
+
continue;
|
|
335776
|
+
}
|
|
335777
|
+
entries.unshift(...toSend);
|
|
335778
|
+
byteEstimate += sendBytes;
|
|
335779
|
+
console.warn(`[audit-flush] HTTP ${res.status} - ${toSend.length} entries requeued`);
|
|
335780
|
+
return;
|
|
335781
|
+
} catch (err) {
|
|
335782
|
+
entries.unshift(...toSend);
|
|
335783
|
+
byteEstimate += sendBytes;
|
|
335784
|
+
console.warn(`[audit-flush] Failed: ${err instanceof Error ? err.message : String(err)} - ${toSend.length} entries requeued`);
|
|
335785
|
+
return;
|
|
335786
|
+
}
|
|
334992
335787
|
}
|
|
334993
|
-
} catch (err) {
|
|
334994
|
-
entries.unshift(...toSend);
|
|
334995
|
-
byteEstimate += savedBytes;
|
|
334996
|
-
console.warn(`[audit-flush] Failed: ${err instanceof Error ? err.message : String(err)} - ${toSend.length} entries requeued`);
|
|
334997
335788
|
} finally {
|
|
334998
335789
|
flushInFlight = null;
|
|
334999
335790
|
}
|
|
@@ -335009,8 +335800,18 @@ function createAuditFlusher(url, headers) {
|
|
|
335009
335800
|
if ("responseBody" in safeEntry && safeEntry.responseBody != null) {
|
|
335010
335801
|
safeEntry = { ...safeEntry, responseBody: truncateAuditRequestBody(safeEntry.responseBody) };
|
|
335011
335802
|
}
|
|
335012
|
-
|
|
335013
|
-
|
|
335803
|
+
const bytes = safeJsonByteLength(safeEntry);
|
|
335804
|
+
entries.push({ entry: safeEntry, bytes });
|
|
335805
|
+
byteEstimate += bytes;
|
|
335806
|
+
if (byteEstimate > AUDIT_QUEUE_MAX_BYTES) {
|
|
335807
|
+
let droppedCount = 0;
|
|
335808
|
+
while (entries.length > 1 && byteEstimate > AUDIT_QUEUE_MAX_BYTES) {
|
|
335809
|
+
const dropped = entries.shift();
|
|
335810
|
+
byteEstimate -= dropped.bytes;
|
|
335811
|
+
droppedCount++;
|
|
335812
|
+
}
|
|
335813
|
+
console.warn(`[audit-flush] queue over ${AUDIT_QUEUE_MAX_BYTES} bytes - dropped ${droppedCount} oldest audit entries`);
|
|
335814
|
+
}
|
|
335014
335815
|
if (entries.length >= AUDIT_FLUSH_COUNT || byteEstimate >= AUDIT_FLUSH_BYTES) {
|
|
335015
335816
|
void doFlush();
|
|
335016
335817
|
}
|
|
@@ -335854,6 +336655,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
335854
336655
|
platformVersion: harvestTarget.platformVersion,
|
|
335855
336656
|
recordedDeviceId: harvestTarget.recordedDeviceId,
|
|
335856
336657
|
assignedDeviceId,
|
|
336658
|
+
resetPolicy: harvestTarget.resetPolicy,
|
|
335857
336659
|
launchMode: harvestTarget.launchMode,
|
|
335858
336660
|
deeplink: harvestTarget.deeplink
|
|
335859
336661
|
},
|
|
@@ -336130,6 +336932,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336130
336932
|
// local-runner projects. Mirrors the Fargate host forward in execute-run.ts.
|
|
336131
336933
|
paused: rest.paused,
|
|
336132
336934
|
pauseReason: rest.pauseReason,
|
|
336935
|
+
pausePhase: rest.pausePhase,
|
|
336133
336936
|
remainingScenarios: rest.remainingScenarios,
|
|
336134
336937
|
// Operator/UX signals the server persists to DiscoveryRun (Fargate parity).
|
|
336135
336938
|
coverageGaps: rest.coverageGaps,
|
|
@@ -336222,7 +337025,14 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336222
337025
|
if (getBridgeState().hasActiveSession) {
|
|
336223
337026
|
throw new Error("CONFIG_ISSUE: the shared mobile device is busy with an active recording session; mobile discovery will retry once it ends.");
|
|
336224
337027
|
}
|
|
336225
|
-
const
|
|
337028
|
+
const mobileProgressLines = [];
|
|
337029
|
+
const mobileOnLog = (line) => {
|
|
337030
|
+
mobileProgressLines.push(line);
|
|
337031
|
+
if (mobileProgressLines.length > 500) {
|
|
337032
|
+
mobileProgressLines.splice(0, mobileProgressLines.length - 500);
|
|
337033
|
+
}
|
|
337034
|
+
onLog("progress", mobileProgressLines.join("\n"));
|
|
337035
|
+
};
|
|
336226
337036
|
liveBrowserHandle.patch({
|
|
336227
337037
|
mode: ctx.mode === "survey" ? "survey" : "discovery",
|
|
336228
337038
|
testName: ctx.featureName ?? "Mobile discovery",
|
|
@@ -336243,6 +337053,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336243
337053
|
platformVersion: mobileTarget.platformVersion,
|
|
336244
337054
|
recordedDeviceId: mobileTarget.recordedDeviceId,
|
|
336245
337055
|
assignedDeviceId,
|
|
337056
|
+
resetPolicy: mobileTarget.resetPolicy,
|
|
336246
337057
|
launchMode: mobileTarget.launchMode,
|
|
336247
337058
|
deeplink: mobileTarget.deeplink
|
|
336248
337059
|
}, {
|
|
@@ -336274,7 +337085,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336274
337085
|
// Deterministic-state authorization: Android emulators only by
|
|
336275
337086
|
// default (VALIDATEQA_MOBILE_APP_RESET overrides) — a personal
|
|
336276
337087
|
// physical device must never have its app data wiped.
|
|
336277
|
-
allowAppReset: shouldResetMobileAppData(platform3, session.deviceId)
|
|
337088
|
+
allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId)
|
|
336278
337089
|
});
|
|
336279
337090
|
discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
|
|
336280
337091
|
onLog: mobileOnLog,
|
|
@@ -336306,8 +337117,13 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336306
337117
|
if (activeCapture) {
|
|
336307
337118
|
try {
|
|
336308
337119
|
const apiCalls = await activeCapture.stop();
|
|
337120
|
+
const captureHealth = activeCapture.health();
|
|
336309
337121
|
if (discoveryResult) {
|
|
336310
337122
|
discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
|
|
337123
|
+
const priorHealth = discoveryResult.healthObservations;
|
|
337124
|
+
const healthObject = priorHealth && typeof priorHealth === "object" && !Array.isArray(priorHealth) ? { ...priorHealth } : {};
|
|
337125
|
+
healthObject.mobileNetworkCapture = captureHealth;
|
|
337126
|
+
discoveryResult.healthObservations = healthObject;
|
|
336311
337127
|
}
|
|
336312
337128
|
} catch (capErr) {
|
|
336313
337129
|
mobileOnLog(`[mobile-net] capture stop failed: ${capErr instanceof Error ? capErr.message : String(capErr)}`);
|
|
@@ -336624,6 +337440,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336624
337440
|
platformVersion: target.platformVersion,
|
|
336625
337441
|
recordedDeviceId: target.recordedDeviceId,
|
|
336626
337442
|
assignedDeviceId,
|
|
337443
|
+
resetPolicy: target.resetPolicy,
|
|
336627
337444
|
launchMode: target.launchMode,
|
|
336628
337445
|
deeplink: target.deeplink,
|
|
336629
337446
|
authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
|
|
@@ -336638,6 +337455,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336638
337455
|
platformVersion: t.platformVersion,
|
|
336639
337456
|
recordedDeviceId: t.recordedDeviceId,
|
|
336640
337457
|
assignedDeviceId: t.assignedDeviceId,
|
|
337458
|
+
resetPolicy: t.resetPolicy,
|
|
336641
337459
|
launchMode: narrowMobileLaunchMode(t.launchMode),
|
|
336642
337460
|
deeplink: t.deeplink,
|
|
336643
337461
|
authoringPlatform: t.authoringPlatform
|
|
@@ -336700,6 +337518,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336700
337518
|
platformVersion: target.platformVersion,
|
|
336701
337519
|
recordedDeviceId: target.recordedDeviceId,
|
|
336702
337520
|
assignedDeviceId,
|
|
337521
|
+
resetPolicy: target.resetPolicy,
|
|
336703
337522
|
launchMode: target.launchMode,
|
|
336704
337523
|
deeplink: target.deeplink
|
|
336705
337524
|
});
|
|
@@ -336708,7 +337527,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336708
337527
|
sessionId: liveSession.sessionId,
|
|
336709
337528
|
platform: target.platform,
|
|
336710
337529
|
appId: liveSession.appId,
|
|
336711
|
-
allowAppReset: shouldResetMobileAppData(target.platform, liveSession.deviceId)
|
|
337530
|
+
allowAppReset: target.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(target.platform, liveSession.deviceId)
|
|
336712
337531
|
});
|
|
336713
337532
|
const liveDriver = {
|
|
336714
337533
|
snapshot: async () => {
|
|
@@ -336736,7 +337555,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336736
337555
|
relaunch: async () => {
|
|
336737
337556
|
await bridgeDriver.relaunch();
|
|
336738
337557
|
},
|
|
336739
|
-
resetAppState: async () => bridgeDriver.resetAppState()
|
|
337558
|
+
resetAppState: async () => target.resetPolicy === "PRESERVE_APP_DATA" ? false : bridgeDriver.resetAppState()
|
|
336740
337559
|
};
|
|
336741
337560
|
return await (0, import_runner_core13.executeMobileLiveHeal)({
|
|
336742
337561
|
steps: a.steps,
|
|
@@ -336837,6 +337656,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336837
337656
|
platformVersion: target.platformVersion,
|
|
336838
337657
|
recordedDeviceId: target.recordedDeviceId,
|
|
336839
337658
|
assignedDeviceId,
|
|
337659
|
+
resetPolicy: target.resetPolicy,
|
|
336840
337660
|
launchMode: target.launchMode,
|
|
336841
337661
|
deeplink: target.deeplink,
|
|
336842
337662
|
// Authoring platform (test's session medium). When it differs from
|