@validate.qa/runner 1.0.28 → 1.0.30
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 +588 -98
- package/dist/cli.mjs +588 -98
- 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"() {
|
|
@@ -2292,12 +2408,12 @@ var init_plans = __esm({
|
|
|
2292
2408
|
billingModel: "free",
|
|
2293
2409
|
summary: "For curious developers. Get the full platform - MCP, Discovery, Voice, self-heal - with enough headroom for one real project.",
|
|
2294
2410
|
maxProjects: 1,
|
|
2295
|
-
maxTestsPerProject:
|
|
2411
|
+
maxTestsPerProject: 100,
|
|
2296
2412
|
maxSuiteRunsPerDay: 1,
|
|
2297
|
-
maxTestExecutionsPerDay:
|
|
2298
|
-
//
|
|
2299
|
-
maxDiscoveryRunsPerDay:
|
|
2300
|
-
//
|
|
2413
|
+
maxTestExecutionsPerDay: 50,
|
|
2414
|
+
// 25 regression / 25 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,7 +2491,7 @@ 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
2496
|
discovery_run: 50,
|
|
2381
2497
|
// $0.50
|
|
@@ -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.
|
|
@@ -65778,7 +65896,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65778
65896
|
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
65897
|
|
|
65780
65898
|
## 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
|
|
65899
|
+
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
65900
|
2. **One purpose per scenario** - each scenario tests ONE thing (happy path, validation, journey, or bug)
|
|
65783
65901
|
3. **Happy paths first** - then pair edge cases to the same form/flow
|
|
65784
65902
|
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 +65905,23 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65787
65905
|
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
65906
|
8. **Auth-aware** - mark scenarios that need a signed-in session. Use process.env.TEST_USER_EMAIL / PASSWORD
|
|
65789
65907
|
9. **No duplicates** - check existing test names before adding
|
|
65790
|
-
10. **All tests start from
|
|
65791
|
-
|
|
65792
|
-
|
|
65908
|
+
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.
|
|
65909
|
+
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.
|
|
65910
|
+
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
65911
|
- The trigger step (Tap Submit / Generate / Send / Upload / Run / Analyze / Save / Pay / etc.)
|
|
65794
65912
|
- 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
65913
|
- 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
65914
|
- 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
65915
|
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
|
-
|
|
65916
|
+
Example - BAD happy path: \`["Launch app", "Tap Create", "Verify sheet opens"]\` \u2192 only tests the trigger.
|
|
65917
|
+
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"]\`
|
|
65918
|
+
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"]\`
|
|
65919
|
+
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.
|
|
65920
|
+
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.
|
|
65921
|
+
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>."
|
|
65922
|
+
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.
|
|
65923
|
+
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.
|
|
65924
|
+
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
65925
|
|
|
65808
65926
|
**Before declaring a prereq as anything other than \`self_owned\`, attempt these IN ORDER:**
|
|
65809
65927
|
|
|
@@ -65826,7 +65944,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65826
65944
|
- \`clean_initial_state\`: never use.
|
|
65827
65945
|
|
|
65828
65946
|
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
|
-
|
|
65947
|
+
19. **Planner emits design, not implementation.** Live-generate handles execution against a real device snapshot.
|
|
65830
65948
|
|
|
65831
65949
|
**YOUR responsibility:**
|
|
65832
65950
|
- Scenario \`name\`, \`type\`, \`goal\`, \`prerequisites\`
|
|
@@ -65852,10 +65970,10 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65852
65970
|
Citing observed labels / accessibility-ids in \`expectedOutcomes\` (per Rule 12) is fine \u2014 that's asserting on stable product signals, not implementation specifics.
|
|
65853
65971
|
|
|
65854
65972
|
## 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
|
|
65973
|
+
- For every form: at least 1 HAPPY_PATH (submit valid data AND verify durable result per rule 12) + 1 EDGE_CASE (empty/invalid submission)
|
|
65974
|
+
- 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)
|
|
65857
65975
|
- 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
|
|
65976
|
+
- 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
65977
|
- For navigation: multi-screen user journeys within the feature scope (do NOT drift into unrelated features)
|
|
65860
65978
|
|
|
65861
65979
|
## Output JSON Schema
|
|
@@ -65877,7 +65995,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
|
|
|
65877
65995
|
"featureGroup": "Authentication",
|
|
65878
65996
|
"goal": "User can sign in with valid credentials and reach the home screen",
|
|
65879
65997
|
"steps": [
|
|
65880
|
-
{ "action": "
|
|
65998
|
+
{ "action": "Launch the app and tap Sign In to reach the sign-in screen" },
|
|
65881
65999
|
{ "action": "Type test credentials into the email field" },
|
|
65882
66000
|
{ "action": "Type test credentials into the password field" },
|
|
65883
66001
|
{ "action": "Tap the Sign In button" },
|
|
@@ -65950,6 +66068,59 @@ Each entry is exactly one of:
|
|
|
65950
66068
|
function screenDisplayName(state2) {
|
|
65951
66069
|
return state2.screenName?.trim() || state2.screenId;
|
|
65952
66070
|
}
|
|
66071
|
+
function isOpaqueMobileScreenName(value, appId) {
|
|
66072
|
+
const trimmed = value.trim();
|
|
66073
|
+
if (!trimmed)
|
|
66074
|
+
return false;
|
|
66075
|
+
if (appId && (trimmed === appId || trimmed.startsWith(`${appId}#`) || trimmed.startsWith(`${appId}.`))) {
|
|
66076
|
+
return true;
|
|
66077
|
+
}
|
|
66078
|
+
return /^[A-Za-z][\w$]*(?:\.[A-Za-z][\w$]*)+(?:#[A-Za-z0-9_-]+)?$/.test(trimmed);
|
|
66079
|
+
}
|
|
66080
|
+
function humanizeToken(token) {
|
|
66081
|
+
const spaced = token.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim();
|
|
66082
|
+
if (!spaced)
|
|
66083
|
+
return "";
|
|
66084
|
+
return spaced.split(/\s+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
66085
|
+
}
|
|
66086
|
+
function mobileAppDisplayName(appId) {
|
|
66087
|
+
const parts = (appId ?? "").split(".").map((part) => part.trim()).filter(Boolean).filter((part) => !/^(com|org|net|io|app|mobile|android|ios)$/i.test(part));
|
|
66088
|
+
const token = parts.length > 0 ? parts[parts.length - 1] : "";
|
|
66089
|
+
const human = humanizeToken(token);
|
|
66090
|
+
return human || "Mobile App";
|
|
66091
|
+
}
|
|
66092
|
+
function nonGenericScreenName(state2, appId) {
|
|
66093
|
+
const raw = state2.screenName?.trim();
|
|
66094
|
+
if (!raw || isOpaqueMobileScreenName(raw, appId))
|
|
66095
|
+
return null;
|
|
66096
|
+
const human = humanizeToken(raw.replace(/(?:Activity|ViewController|Fragment|Screen|Page)$/i, ""));
|
|
66097
|
+
if (!human || /^(main|root|launch|screen|page|activity|view controller)$/i.test(human))
|
|
66098
|
+
return null;
|
|
66099
|
+
return human;
|
|
66100
|
+
}
|
|
66101
|
+
function inferMobileFeatureGroupName(states, appId) {
|
|
66102
|
+
const usable = states.filter((state2) => state2.screenType?.toLowerCase() !== "error");
|
|
66103
|
+
const screenTypes = usable.map((state2) => state2.screenType?.toLowerCase()).filter(Boolean);
|
|
66104
|
+
if (screenTypes.length > 0 && screenTypes.every((type) => type === "auth"))
|
|
66105
|
+
return "Authentication";
|
|
66106
|
+
if (screenTypes.length > 0 && screenTypes.every((type) => type === "onboarding" || type === "landing" || type === "splash")) {
|
|
66107
|
+
return "Onboarding";
|
|
66108
|
+
}
|
|
66109
|
+
const named = usable.map((state2) => nonGenericScreenName(state2, appId)).find((name) => Boolean(name));
|
|
66110
|
+
return named ?? `${mobileAppDisplayName(appId)} App`;
|
|
66111
|
+
}
|
|
66112
|
+
function mobileFeatureCanonicalKey(name) {
|
|
66113
|
+
const normalized = name.trim().toLowerCase().replace(/['’]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
66114
|
+
if (!normalized)
|
|
66115
|
+
return "mobile-app";
|
|
66116
|
+
if (/(^|-)(auth|authentication|login|log-in|signin|sign-in|signup|sign-up)($|-)/.test(normalized)) {
|
|
66117
|
+
return "authentication";
|
|
66118
|
+
}
|
|
66119
|
+
if (/(^|-)(onboarding|welcome|landing|splash|intro|walkthrough)($|-)/.test(normalized)) {
|
|
66120
|
+
return "onboarding";
|
|
66121
|
+
}
|
|
66122
|
+
return normalized.replace(/-(app|screen|screens|flow|feature)$/g, "") || normalized;
|
|
66123
|
+
}
|
|
65953
66124
|
function formatMobileEvidenceForPlanner(graph, context = {}) {
|
|
65954
66125
|
const sections = [];
|
|
65955
66126
|
if (context.appBrief) {
|
|
@@ -66141,9 +66312,10 @@ ${constraints.join("\n")}`);
|
|
|
66141
66312
|
const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
|
|
66142
66313
|
for (const state2 of orderedScreens) {
|
|
66143
66314
|
const screenTypeLower = state2.screenType?.toLowerCase();
|
|
66144
|
-
const groupName = screenTypeLower === "auth" ? "Authentication" : screenTypeLower === "onboarding" || screenTypeLower === "landing" || screenTypeLower === "splash" ? "Onboarding" :
|
|
66315
|
+
const groupName = screenTypeLower === "auth" ? "Authentication" : screenTypeLower === "onboarding" || screenTypeLower === "landing" || screenTypeLower === "splash" ? "Onboarding" : inferMobileFeatureGroupName([state2], appId);
|
|
66145
66316
|
const group = groups.get(groupName) ?? {
|
|
66146
66317
|
name: groupName,
|
|
66318
|
+
canonicalKey: mobileFeatureCanonicalKey(groupName),
|
|
66147
66319
|
description: `Native mobile screens related to ${groupName}.`,
|
|
66148
66320
|
routes: [],
|
|
66149
66321
|
criticality: state2.screenType?.toLowerCase() === "auth" ? "critical" : "medium",
|
|
@@ -66169,7 +66341,7 @@ ${constraints.join("\n")}`);
|
|
|
66169
66341
|
const to = graph.screens.get(transition.toScreenId);
|
|
66170
66342
|
if (!from || !to)
|
|
66171
66343
|
continue;
|
|
66172
|
-
const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" :
|
|
66344
|
+
const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : inferMobileFeatureGroupName([from], appId);
|
|
66173
66345
|
const group = groups.get(groupName);
|
|
66174
66346
|
if (!group)
|
|
66175
66347
|
continue;
|
|
@@ -66193,7 +66365,7 @@ ${constraints.join("\n")}`);
|
|
|
66193
66365
|
for (const state2 of orderedScreens) {
|
|
66194
66366
|
if (scenarios.length >= budget)
|
|
66195
66367
|
break;
|
|
66196
|
-
const screenName =
|
|
66368
|
+
const screenName = nonGenericScreenName(state2, input.appId) ?? inferMobileFeatureGroupName([state2], input.appId);
|
|
66197
66369
|
const baseName = `${screenName} - Screen Reachability`;
|
|
66198
66370
|
const name = usedNames.has(baseName.toLowerCase()) ? `${baseName} ${state2.firstSeenIteration + 1}` : baseName;
|
|
66199
66371
|
if (usedNames.has(name.toLowerCase()))
|
|
@@ -66201,7 +66373,7 @@ ${constraints.join("\n")}`);
|
|
|
66201
66373
|
usedNames.add(name.toLowerCase());
|
|
66202
66374
|
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
66375
|
const steps = [
|
|
66204
|
-
{ action: "
|
|
66376
|
+
{ action: "Launch the app and start from the current launch entry", target: entryScreenId },
|
|
66205
66377
|
{
|
|
66206
66378
|
action: state2.screenId === entryScreenId ? `Verify the launch screen is ${screenName}` : `Navigate through the app until the ${screenName} screen is visible`,
|
|
66207
66379
|
target: state2.screenId
|
|
@@ -66252,16 +66424,27 @@ ${constraints.join("\n")}`);
|
|
|
66252
66424
|
}
|
|
66253
66425
|
return filtered;
|
|
66254
66426
|
}
|
|
66255
|
-
function filterFeatureGroupsToSiteMap(groups, siteMap) {
|
|
66427
|
+
function filterFeatureGroupsToSiteMap(groups, siteMap, graph, appId) {
|
|
66256
66428
|
if (groups.length === 0)
|
|
66257
66429
|
return [];
|
|
66258
66430
|
const validRoutes = new Set(siteMap.pages.map((page) => page.route));
|
|
66431
|
+
const usedNames = /* @__PURE__ */ new Map();
|
|
66259
66432
|
return groups.flatMap((group) => {
|
|
66260
66433
|
const routes = group.routes.filter((route2) => validRoutes.has(route2));
|
|
66261
66434
|
if (routes.length === 0)
|
|
66262
66435
|
return [];
|
|
66436
|
+
const routeStates = routes.map((route2) => graph?.screens.get(route2)).filter((state2) => Boolean(state2));
|
|
66437
|
+
const rawName = group.name?.trim() || "";
|
|
66438
|
+
const inferredName = rawName && !isOpaqueMobileScreenName(rawName, appId) ? rawName : inferMobileFeatureGroupName(routeStates, appId);
|
|
66439
|
+
const count = usedNames.get(inferredName) ?? 0;
|
|
66440
|
+
usedNames.set(inferredName, count + 1);
|
|
66441
|
+
const name = count === 0 ? inferredName : `${inferredName} ${count + 1}`;
|
|
66442
|
+
const canonicalKey = group.canonicalKey?.trim() || mobileFeatureCanonicalKey(inferredName);
|
|
66263
66443
|
return [{
|
|
66264
66444
|
...group,
|
|
66445
|
+
name,
|
|
66446
|
+
canonicalKey,
|
|
66447
|
+
description: group.description?.trim() || `Native mobile screens related to ${name}.`,
|
|
66265
66448
|
routes,
|
|
66266
66449
|
pages: group.pages?.filter((page) => validRoutes.has(page.route))
|
|
66267
66450
|
}];
|
|
@@ -66313,7 +66496,7 @@ ${constraints.join("\n")}`);
|
|
|
66313
66496
|
incremental: input.incremental
|
|
66314
66497
|
})
|
|
66315
66498
|
});
|
|
66316
|
-
const plannerFeatureGroups = filterFeatureGroupsToSiteMap(result.featureGroups, siteMap);
|
|
66499
|
+
const plannerFeatureGroups = filterFeatureGroupsToSiteMap(result.featureGroups, siteMap, input.graph, input.appId);
|
|
66317
66500
|
const mobileFeatureGroups = plannerFeatureGroups.length > 0 ? plannerFeatureGroups : buildMobileFeatureGroupsFromGraph(input.graph, input.appId);
|
|
66318
66501
|
if (input.mode !== "survey" && result.scenarios.length === 0 && input.graph.screens.size > 0) {
|
|
66319
66502
|
const fallbackScenarios = buildMobileFallbackScenarios(input);
|
|
@@ -66560,7 +66743,15 @@ var require_mobile_generator = __commonJS({
|
|
|
66560
66743
|
return { screen, signal, refMap, observation };
|
|
66561
66744
|
}
|
|
66562
66745
|
function labelForElement(element) {
|
|
66563
|
-
|
|
66746
|
+
const editable = isEditableMobileElement(element);
|
|
66747
|
+
const label = nonEmpty2(element.label);
|
|
66748
|
+
const text = nonEmpty2(element.text);
|
|
66749
|
+
const labelIsEditableValue = editable && label !== void 0 && text !== void 0 && label === text;
|
|
66750
|
+
return (labelIsEditableValue ? void 0 : label) ?? nonEmpty2(element.accessibilityId) ?? nonEmpty2(element.resourceId) ?? (editable ? void 0 : text) ?? "element";
|
|
66751
|
+
}
|
|
66752
|
+
var EDITABLE_ELEMENT_ROLE_RE = /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/;
|
|
66753
|
+
function isEditableMobileElement(element) {
|
|
66754
|
+
return EDITABLE_ELEMENT_ROLE_RE.test(element.role.trim());
|
|
66564
66755
|
}
|
|
66565
66756
|
function nonEmpty2(value) {
|
|
66566
66757
|
if (typeof value !== "string")
|
|
@@ -66592,17 +66783,21 @@ var require_mobile_generator = __commonJS({
|
|
|
66592
66783
|
## Your job
|
|
66593
66784
|
1. mobile_snapshot the current screen.
|
|
66594
66785
|
2. Execute the scenario's steps using the real UI: tap buttons, fill fields (mobile_type), submit, navigate.
|
|
66595
|
-
3.
|
|
66786
|
+
3. COMPLETE THE ACTION the scenario is about \u2014 do not stop half-way. If the scenario submits a form (sign in, sign up, search, create), you MUST tap the submit/primary button; filling fields is NOT the behavior under test. If it navigates, you MUST land on the destination. A "happy path" that fills a login form but never taps "Sign In" is WRONG.
|
|
66787
|
+
4. When you reach the target screen / complete the flow, PROVE the outcome with an assert tool:
|
|
66596
66788
|
- mobile_assert_visible({ref}) \u2014 the target screen's stable element is on screen.
|
|
66597
66789
|
- mobile_assert_text({ref, expected}) \u2014 an element contains expected text (only durable when the element has a stable accessibility-id / resource-id).
|
|
66598
|
-
|
|
66790
|
+
- Assert the REAL post-action outcome: after a sign-in, assert a signed-in signal (dashboard/feed content, account tab, "Sign out"); after an invalid submit, assert the error message; after navigation, assert an element unique to the destination screen. Do NOT assert an opaque internal id ("step-1", "step-2") as the outcome \u2014 pick a human-meaningful element.
|
|
66791
|
+
5. Call finish_generate with verified:true ONLY if you asserted the outcome with an assert tool.
|
|
66599
66792
|
|
|
66600
66793
|
## Rules
|
|
66601
66794
|
- Re-snapshot before every assertion so the ref is fresh.
|
|
66602
66795
|
- Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
|
|
66603
|
-
- 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
|
|
66796
|
+
- 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.
|
|
66604
66797
|
- Do NOT trigger irreversible side effects (real payments, sending messages to others) \u2014 note them and assert what you safely can.
|
|
66605
66798
|
- 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.
|
|
66799
|
+
- 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.
|
|
66800
|
+
- Do NOT re-do a sequence you already completed. If you filled a form then navigated away, do NOT come back and fill it again \u2014 the scenario needs ONE clean pass. Filling the login form, going back, and re-filling it is exactly the kind of wandering that produces a bloated, broken test.
|
|
66606
66801
|
- Do ONLY the actions this scenario needs. Do NOT re-open a menu you already opened, re-select an option that is already selected, toggle a setting back and forth, or repeat a step that already succeeded \u2014 EVERY action you take is baked into the generated test verbatim, so each extra action becomes a permanent junk step.
|
|
66607
66802
|
- Each action must make NEW progress toward the goal. If an action leaves the screen unchanged, do NOT repeat it \u2014 pick a different element or move on.
|
|
66608
66803
|
- Finish promptly once the outcome is proven: the instant the expected outcome is visible, assert it and call finish_generate. Do not keep exploring after the goal is proven.
|
|
@@ -67170,6 +67365,13 @@ ${statePacket}` },
|
|
|
67170
67365
|
const { resolveRef, screenName, platform: platform3, recordAction } = ctx;
|
|
67171
67366
|
const element = resolveRef(toolArgs.ref);
|
|
67172
67367
|
if (element) {
|
|
67368
|
+
const weakReason = weakAssertionTargetReason(element);
|
|
67369
|
+
if (weakReason) {
|
|
67370
|
+
return {
|
|
67371
|
+
ok: false,
|
|
67372
|
+
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.`
|
|
67373
|
+
};
|
|
67374
|
+
}
|
|
67173
67375
|
const metadata = metadataForElement(element, screenName(), platform3);
|
|
67174
67376
|
recordAction({ type: "ASSERT_VISIBLE", selector: (0, mobile_source_parser_js_2.preferredLocator)(element), metadata });
|
|
67175
67377
|
return { ok: true, label: metadata.targetLabel ?? "element" };
|
|
@@ -67179,6 +67381,13 @@ ${statePacket}` },
|
|
|
67179
67381
|
if (!accessibilityId && !text) {
|
|
67180
67382
|
return { ok: false, error: "mobile_assert_visible needs a ref (preferred), accessibilityId, or text." };
|
|
67181
67383
|
}
|
|
67384
|
+
const directTarget = accessibilityId ?? text;
|
|
67385
|
+
if (directTarget && isStructuralAssertionIdentity(directTarget)) {
|
|
67386
|
+
return {
|
|
67387
|
+
ok: false,
|
|
67388
|
+
error: `"${directTarget}" looks like a structural/internal container id, not a user-visible outcome. Assert a visible label, message, or stable product control instead.`
|
|
67389
|
+
};
|
|
67390
|
+
}
|
|
67182
67391
|
const screen = screenName();
|
|
67183
67392
|
if (accessibilityId) {
|
|
67184
67393
|
const bundle2 = { xpath: `//*[@content-desc=${xpathLiteral2(accessibilityId)} or @name=${xpathLiteral2(accessibilityId)}]`, className: "", accessibilityId };
|
|
@@ -67202,7 +67411,7 @@ ${statePacket}` },
|
|
|
67202
67411
|
if (!snapshot || snapshot.screen.elements.length === 0)
|
|
67203
67412
|
return null;
|
|
67204
67413
|
const elements = snapshot.screen.elements;
|
|
67205
|
-
const stableAnchors = elements.filter((el) => hasStableId(el) && !
|
|
67414
|
+
const stableAnchors = elements.filter((el) => hasStableId(el) && !weakAssertionTargetReason(el));
|
|
67206
67415
|
stableAnchors.sort((a, b) => {
|
|
67207
67416
|
const labelled2 = (el) => nonEmpty2(el.text) || nonEmpty2(el.label) ? 0 : 1;
|
|
67208
67417
|
return labelled2(a) - labelled2(b);
|
|
@@ -67215,8 +67424,8 @@ ${statePacket}` },
|
|
|
67215
67424
|
return nonButton;
|
|
67216
67425
|
if (stableAnchors.length > 0)
|
|
67217
67426
|
return stableAnchors[0];
|
|
67218
|
-
const labelled = elements.find((el) => !
|
|
67219
|
-
return labelled ?? elements.find((el) => !
|
|
67427
|
+
const labelled = elements.find((el) => !weakAssertionTargetReason(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
|
|
67428
|
+
return labelled ?? elements.find((el) => !weakAssertionTargetReason(el)) ?? null;
|
|
67220
67429
|
}
|
|
67221
67430
|
var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
|
|
67222
67431
|
var ALWAYS_PRESENT_CONTAINER_ID_NAMES = /* @__PURE__ */ new Set([
|
|
@@ -67228,9 +67437,9 @@ ${statePacket}` },
|
|
|
67228
67437
|
"navigationBarBackground",
|
|
67229
67438
|
// WebView hybrid apps (React/Vue/etc. rendered inside android.webkit.WebView)
|
|
67230
67439
|
// expose their framework ROOT container's DOM id as the element id — present on
|
|
67231
|
-
// EVERY in-app screen, so the floor must not anchor to it (observed live
|
|
67232
|
-
//
|
|
67233
|
-
// never matched via the text-contains fallback).
|
|
67440
|
+
// EVERY in-app screen, so the floor must not anchor to it (observed live: a
|
|
67441
|
+
// framework root id like `main-content` produced a screen-agnostic assertion
|
|
67442
|
+
// that then never matched via the text-contains fallback).
|
|
67234
67443
|
"main-content",
|
|
67235
67444
|
"root",
|
|
67236
67445
|
"app",
|
|
@@ -67239,18 +67448,53 @@ ${statePacket}` },
|
|
|
67239
67448
|
"react-root",
|
|
67240
67449
|
"application"
|
|
67241
67450
|
]);
|
|
67451
|
+
var STRUCTURAL_ASSERT_ID_RE = /^(?: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;
|
|
67452
|
+
function locatorLocalName(value) {
|
|
67453
|
+
const trimmed = value.trim();
|
|
67454
|
+
const withoutStrategy = trimmed.startsWith("id=") ? trimmed.slice(3) : trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
|
|
67455
|
+
const slashIdx = withoutStrategy.indexOf("/");
|
|
67456
|
+
return slashIdx >= 0 ? withoutStrategy.slice(slashIdx + 1) : withoutStrategy;
|
|
67457
|
+
}
|
|
67458
|
+
function isStructuralAssertionIdentity(value) {
|
|
67459
|
+
const trimmed = nonEmpty2(value);
|
|
67460
|
+
if (!trimmed)
|
|
67461
|
+
return false;
|
|
67462
|
+
const localName = locatorLocalName(trimmed);
|
|
67463
|
+
return ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName) || STRUCTURAL_ASSERT_ID_RE.test(localName);
|
|
67464
|
+
}
|
|
67465
|
+
function hasHumanAssertionSignal(element) {
|
|
67466
|
+
const label = nonEmpty2(element.label);
|
|
67467
|
+
const text = nonEmpty2(element.text);
|
|
67468
|
+
const accessibilityId = nonEmpty2(element.accessibilityId);
|
|
67469
|
+
return Boolean(label && !isStructuralAssertionIdentity(label) || text && !isEditableMobileElement(element) && !isStructuralAssertionIdentity(text) || accessibilityId && !isStructuralAssertionIdentity(accessibilityId));
|
|
67470
|
+
}
|
|
67471
|
+
function weakAssertionTargetReason(element) {
|
|
67472
|
+
if (isAlwaysPresentContainer(element))
|
|
67473
|
+
return "always-present app/system container";
|
|
67474
|
+
const structuralIdentity = [
|
|
67475
|
+
element.accessibilityId,
|
|
67476
|
+
element.resourceId,
|
|
67477
|
+
element.label,
|
|
67478
|
+
element.text
|
|
67479
|
+
].some(isStructuralAssertionIdentity);
|
|
67480
|
+
const containerishClass = BARE_CONTAINER_CLASS_RE.test(element.role);
|
|
67481
|
+
const humanSignal = hasHumanAssertionSignal(element);
|
|
67482
|
+
if (structuralIdentity && (containerishClass || !humanSignal))
|
|
67483
|
+
return "structural/internal container id";
|
|
67484
|
+
if (containerishClass && !humanSignal)
|
|
67485
|
+
return "generic container with no visible product signal";
|
|
67486
|
+
return null;
|
|
67487
|
+
}
|
|
67242
67488
|
function isAlwaysPresentContainer(element) {
|
|
67243
67489
|
if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
|
|
67244
67490
|
return true;
|
|
67245
67491
|
const resourceId = element.resourceId?.trim();
|
|
67246
67492
|
if (resourceId) {
|
|
67247
|
-
|
|
67248
|
-
const localName = slashIdx >= 0 ? resourceId.slice(slashIdx + 1) : resourceId;
|
|
67249
|
-
if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName))
|
|
67493
|
+
if (ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(locatorLocalName(resourceId)))
|
|
67250
67494
|
return true;
|
|
67251
67495
|
}
|
|
67252
67496
|
const accessibilityId = element.accessibilityId?.trim();
|
|
67253
|
-
if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(accessibilityId))
|
|
67497
|
+
if (accessibilityId && ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(locatorLocalName(accessibilityId)))
|
|
67254
67498
|
return true;
|
|
67255
67499
|
return false;
|
|
67256
67500
|
}
|
|
@@ -67265,7 +67509,7 @@ ${statePacket}` },
|
|
|
67265
67509
|
if (hasIdentity)
|
|
67266
67510
|
return false;
|
|
67267
67511
|
const resourceId = bundle.resourceId?.trim() ?? "";
|
|
67268
|
-
const localName =
|
|
67512
|
+
const localName = locatorLocalName(resourceId);
|
|
67269
67513
|
const containerishId = localName !== "" && (localName === "webview" || ALWAYS_PRESENT_CONTAINER_ID_NAMES.has(localName));
|
|
67270
67514
|
const containerishClass = BARE_CONTAINER_CLASS_RE.test(bundle.className ?? "");
|
|
67271
67515
|
if (containerishId)
|
|
@@ -67307,6 +67551,22 @@ ${statePacket}` },
|
|
|
67307
67551
|
break;
|
|
67308
67552
|
}
|
|
67309
67553
|
}
|
|
67554
|
+
for (let window2 = Math.min(MAX_RETRY_CYCLE_LENGTH, Math.floor((out.length - 1) / 2)); window2 >= 2 && !changed; window2--) {
|
|
67555
|
+
for (let i = 0; i + 2 * window2 + 1 <= out.length; i++) {
|
|
67556
|
+
const first = out.slice(i, i + window2);
|
|
67557
|
+
if (first.some((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT"))
|
|
67558
|
+
continue;
|
|
67559
|
+
const back = out[i + window2];
|
|
67560
|
+
if (back.type !== "BACK")
|
|
67561
|
+
continue;
|
|
67562
|
+
const second = out.slice(i + window2 + 1, i + window2 + 1 + window2);
|
|
67563
|
+
if (!first.every((a, k) => actionCycleKey(a) === actionCycleKey(second[k])))
|
|
67564
|
+
continue;
|
|
67565
|
+
out.splice(i, window2 + 1);
|
|
67566
|
+
changed = true;
|
|
67567
|
+
break;
|
|
67568
|
+
}
|
|
67569
|
+
}
|
|
67310
67570
|
}
|
|
67311
67571
|
return out;
|
|
67312
67572
|
}
|
|
@@ -67343,6 +67603,10 @@ ${statePacket}` },
|
|
|
67343
67603
|
log2(` compile failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
67344
67604
|
return null;
|
|
67345
67605
|
}
|
|
67606
|
+
const selectorQuality = (0, shared_1.analyzeMobileSelectorQuality)(compiled.steps);
|
|
67607
|
+
if (selectorQuality.summary.high > 0 || selectorQuality.summary.medium > 0) {
|
|
67608
|
+
log2(` selector quality: score ${selectorQuality.summary.score}/100 (${selectorQuality.summary.high} high, ${selectorQuality.summary.medium} medium issue(s)).`);
|
|
67609
|
+
}
|
|
67346
67610
|
const claimedVerified = recording.finish?.verified ?? false;
|
|
67347
67611
|
const verified = claimedVerified && recording.assertCount > 0;
|
|
67348
67612
|
if (claimedVerified && !verified) {
|
|
@@ -67384,6 +67648,7 @@ ${statePacket}` },
|
|
|
67384
67648
|
targetPages: [...scenario.targetPages]
|
|
67385
67649
|
},
|
|
67386
67650
|
evidence: { observedLocators: [], observedTransitions: [] },
|
|
67651
|
+
mobileSelectorQuality: selectorQuality,
|
|
67387
67652
|
// Assertion-strength marker for the server promote-seam
|
|
67388
67653
|
// (resolveStabilityTransition): an UNVERIFIED recording proves none of the
|
|
67389
67654
|
// scenario's outcomes — only the hybrid floor's landing-screen visibility —
|
|
@@ -67408,6 +67673,25 @@ ${statePacket}` },
|
|
|
67408
67673
|
};
|
|
67409
67674
|
return test;
|
|
67410
67675
|
}
|
|
67676
|
+
async function prepareMobileScenarioAppState(driver, target, log2) {
|
|
67677
|
+
const resetPolicy = target?.resetPolicy ?? "AUTO";
|
|
67678
|
+
if (resetPolicy === "PRESERVE_APP_DATA") {
|
|
67679
|
+
await driver.relaunch();
|
|
67680
|
+
log2(" app data reset skipped by policy \u2014 scenario records after relaunch with preserved data.");
|
|
67681
|
+
return false;
|
|
67682
|
+
}
|
|
67683
|
+
const appDataCleared = await driver.resetAppState?.().catch((err) => {
|
|
67684
|
+
log2(` app-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
67685
|
+
return false;
|
|
67686
|
+
}) ?? false;
|
|
67687
|
+
if (appDataCleared)
|
|
67688
|
+
return true;
|
|
67689
|
+
if (resetPolicy === "CLEAR_APP_DATA") {
|
|
67690
|
+
log2(" CLEAR_APP_DATA requested but app-data reset was unavailable; falling back to relaunch.");
|
|
67691
|
+
}
|
|
67692
|
+
await driver.relaunch();
|
|
67693
|
+
return false;
|
|
67694
|
+
}
|
|
67411
67695
|
async function runMobileGeneratePhase(args) {
|
|
67412
67696
|
const { scenarios: allScenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot, skipScenarioNames, onInfraStop } = args;
|
|
67413
67697
|
const log2 = (line) => {
|
|
@@ -67440,11 +67724,9 @@ ${statePacket}` },
|
|
|
67440
67724
|
log2(`
|
|
67441
67725
|
-- Generating scenario ${i + 1}/${scenarios.length}: "${scenario.name}" --`);
|
|
67442
67726
|
try {
|
|
67443
|
-
const appDataCleared = await driver.
|
|
67727
|
+
const appDataCleared = await prepareMobileScenarioAppState(driver, ctx.target, log2);
|
|
67444
67728
|
if (appDataCleared) {
|
|
67445
67729
|
log2(" app data cleared \u2014 scenario records from a deterministic cold (logged-out) state.");
|
|
67446
|
-
} else {
|
|
67447
|
-
await driver.relaunch();
|
|
67448
67730
|
}
|
|
67449
67731
|
const recording = await runScenarioLoop({
|
|
67450
67732
|
scenario,
|
|
@@ -67995,8 +68277,8 @@ Reason over steps + error + tree + registry and return working healed steps. Do
|
|
|
67995
68277
|
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.
|
|
67996
68278
|
2. STEP RESULTS: which steps passed before the failure.
|
|
67997
68279
|
3. TREE: does the current screen match what the failed step expects?
|
|
67998
|
-
3b. STALE EMPTY-STATE ASSERT: a failing assert on one-time / data-dependent UI (empty-state CTAs
|
|
67999
|
-
4. 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
|
|
68280
|
+
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).
|
|
68281
|
+
4. 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.
|
|
68000
68282
|
## Response \u2014 STRICT JSON, exactly one verdict:
|
|
68001
68283
|
{"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}
|
|
68002
68284
|
{"verdict":"app_bug","reason":"app crashed/blank/5xx (NOT rate-limit)"}
|
|
@@ -68499,16 +68781,16 @@ ${args.directive}`);
|
|
|
68499
68781
|
var DEFAULT_PHASE2_MAX_PASSES = 6;
|
|
68500
68782
|
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.
|
|
68501
68783
|
## Key insight \u2014 ENTRY-SCREEN DRIFT
|
|
68502
|
-
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
|
|
68784
|
+
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.
|
|
68503
68785
|
## Analyze
|
|
68504
68786
|
1. Compare the recorded first step's expected screen to the LIVE tree.
|
|
68505
68787
|
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().
|
|
68506
68788
|
3. If a locator is simply wrong, swap it for a stronger identity locator present in the LIVE tree.
|
|
68507
|
-
3b. If the failing step ASSERTS one-time / data-dependent UI (an empty-state CTA
|
|
68789
|
+
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.
|
|
68508
68790
|
4. 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.
|
|
68509
68791
|
## Response \u2014 STRICT JSON, exactly one verdict:
|
|
68510
68792
|
{"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}
|
|
68511
|
-
{"verdict":"observe","action":{"type":"tap","target":"mobile.text('
|
|
68793
|
+
{"verdict":"observe","action":{"type":"tap","target":"mobile.text('<exact label visible in the live tree>')"}}
|
|
68512
68794
|
{"verdict":"observe","action":{"type":"back"}}
|
|
68513
68795
|
{"verdict":"observe","action":{"type":"relaunch"}}
|
|
68514
68796
|
{"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)
|
|
@@ -73647,6 +73929,7 @@ var require_auth_recipe = __commonJS({
|
|
|
73647
73929
|
exports2.computeRecipeHash = computeRecipeHash;
|
|
73648
73930
|
exports2.buildAuthRecipe = buildAuthRecipe;
|
|
73649
73931
|
exports2.authProofLocator = authProofLocator;
|
|
73932
|
+
exports2.urlProofPredicateExpr = urlProofPredicateExpr;
|
|
73650
73933
|
exports2.renderAuthProofAssertion = renderAuthProofAssertion;
|
|
73651
73934
|
exports2.renderAuthFn = renderAuthFn;
|
|
73652
73935
|
exports2.absoluteRoute = absoluteRoute;
|
|
@@ -73701,6 +73984,9 @@ var require_auth_recipe = __commonJS({
|
|
|
73701
73984
|
};
|
|
73702
73985
|
}
|
|
73703
73986
|
function authProofLocator(proof) {
|
|
73987
|
+
if (proof.kind === "url") {
|
|
73988
|
+
throw new Error("authProofLocator: url proof has no element locator; callers must branch on proof.kind");
|
|
73989
|
+
}
|
|
73704
73990
|
if (proof.kind === "testid") {
|
|
73705
73991
|
return `page.getByTestId(${JSON.stringify(proof.testId)})`;
|
|
73706
73992
|
}
|
|
@@ -73709,7 +73995,14 @@ var require_auth_recipe = __commonJS({
|
|
|
73709
73995
|
}
|
|
73710
73996
|
return `page.getByRole(${JSON.stringify(proof.role)}).first()`;
|
|
73711
73997
|
}
|
|
73998
|
+
function urlProofPredicateExpr(route2) {
|
|
73999
|
+
const r = JSON.stringify(route2);
|
|
74000
|
+
return `(u) => u.pathname === ${r} || u.pathname.startsWith(${r} + '/')`;
|
|
74001
|
+
}
|
|
73712
74002
|
function renderAuthProofAssertion(proof) {
|
|
74003
|
+
if (proof.kind === "url") {
|
|
74004
|
+
return `await page.waitForURL(${urlProofPredicateExpr(proof.route)}, { timeout: 15000 });`;
|
|
74005
|
+
}
|
|
73713
74006
|
return `await expect(${authProofLocator(proof)}).toBeVisible({ timeout: 15000 });`;
|
|
73714
74007
|
}
|
|
73715
74008
|
function normalizeCmd(cmd) {
|
|
@@ -73723,9 +74016,16 @@ var require_auth_recipe = __commonJS({
|
|
|
73723
74016
|
lines.push(START_MARKER);
|
|
73724
74017
|
lines.push(`async function __vqaLogin(page) {`);
|
|
73725
74018
|
if (opts?.warmGuard) {
|
|
73726
|
-
const warmLanding = recipe.redirectRoute ?? recipe.preNavRoute;
|
|
74019
|
+
const warmLanding = recipe.redirectRoute ?? (recipe.authProof.kind === "url" ? recipe.authProof.route : recipe.preNavRoute);
|
|
74020
|
+
const loginRoute = recipe.route ?? recipe.preNavRoute;
|
|
73727
74021
|
lines.push(` await page.goto(${JSON.stringify(warmLanding)}, { waitUntil: 'domcontentloaded' }).catch(() => {});`);
|
|
73728
|
-
|
|
74022
|
+
if (recipe.authProof.kind === "url") {
|
|
74023
|
+
lines.push(` if ((${urlProofPredicateExpr(recipe.authProof.route)})(new URL(page.url()))) return;`);
|
|
74024
|
+
} else if (loginRoute) {
|
|
74025
|
+
lines.push(` if (await ${authProofLocator(recipe.authProof)}.isVisible().catch(() => false) && new URL(page.url()).pathname !== new URL(${JSON.stringify(loginRoute)}, page.url()).pathname) return;`);
|
|
74026
|
+
} else {
|
|
74027
|
+
lines.push(` if (await ${authProofLocator(recipe.authProof)}.isVisible().catch(() => false)) return;`);
|
|
74028
|
+
}
|
|
73729
74029
|
}
|
|
73730
74030
|
lines.push(` await page.goto(${JSON.stringify(recipe.preNavRoute)}, { waitUntil: 'domcontentloaded' });`);
|
|
73731
74031
|
for (const cmd of recipe.preNavCmds)
|
|
@@ -73774,6 +74074,11 @@ var require_auth_recipe = __commonJS({
|
|
|
73774
74074
|
}`;
|
|
73775
74075
|
}
|
|
73776
74076
|
function renderReplaySettleArrow(proof) {
|
|
74077
|
+
if (proof.kind === "url") {
|
|
74078
|
+
return `async (page) => {
|
|
74079
|
+
await page.waitForURL(${urlProofPredicateExpr(proof.route)}, { timeout: 15000 });
|
|
74080
|
+
}`;
|
|
74081
|
+
}
|
|
73777
74082
|
return `async (page) => {
|
|
73778
74083
|
await ${authProofLocator(proof)}.waitFor({ state: 'visible', timeout: 15000 });
|
|
73779
74084
|
}`;
|
|
@@ -307864,7 +308169,7 @@ ${keyLines || "- (none provided)"}
|
|
|
307864
308169
|
3. If the email/password fields are hidden behind a reveal button (e.g. "Sign in with Email", "Continue with Email"), click it first.
|
|
307865
308170
|
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.)
|
|
307866
308171
|
5. Submit the login form.
|
|
307867
|
-
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.
|
|
308172
|
+
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).
|
|
307868
308173
|
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.
|
|
307869
308174
|
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.
|
|
307870
308175
|
|
|
@@ -307924,7 +308229,11 @@ ${keyLines || "- (none provided)"}
|
|
|
307924
308229
|
if (el.testId)
|
|
307925
308230
|
testIdFreq.set(el.testId, (testIdFreq.get(el.testId) ?? 0) + 1);
|
|
307926
308231
|
}
|
|
307927
|
-
const
|
|
308232
|
+
const isInitialsLike = (name) => {
|
|
308233
|
+
const t = name.trim();
|
|
308234
|
+
return /^[A-Za-z]{1,3}$/.test(t) && t === t.toUpperCase();
|
|
308235
|
+
};
|
|
308236
|
+
const isPersonalized = (name) => !name || GREETING_RE.test(name) || name.includes("@") || findCredentialLiteralLeaks(name, credentials).length > 0 || isInitialsLike(name);
|
|
307928
308237
|
const absentOnLogin = (roleNameKey, testId) => {
|
|
307929
308238
|
if (testId && loginTestIds.has(testId))
|
|
307930
308239
|
return false;
|
|
@@ -307959,7 +308268,12 @@ ${keyLines || "- (none provided)"}
|
|
|
307959
308268
|
}
|
|
307960
308269
|
}
|
|
307961
308270
|
if (ranked.length === 0) {
|
|
307962
|
-
|
|
308271
|
+
const urlRoute = redirectRoute && redirectRoute !== "/" && !isLoginPage(redirectRoute) ? redirectRoute : landing.route !== "/" && !isLoginPage(landing.route) ? landing.route : null;
|
|
308272
|
+
if (urlRoute) {
|
|
308273
|
+
logs2(` \u2713 url auth-proof: authenticated landing route ${urlRoute} (no durable element proof on the default-state page)`);
|
|
308274
|
+
return { kind: "url", route: urlRoute };
|
|
308275
|
+
}
|
|
308276
|
+
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)");
|
|
307963
308277
|
return null;
|
|
307964
308278
|
}
|
|
307965
308279
|
ranked.sort((a, b) => a.tier - b.tier);
|
|
@@ -308110,7 +308424,7 @@ ${keyLines || "- (none provided)"}
|
|
|
308110
308424
|
logs2(" \u26A0 auth unverified (live-verify replay failed \u2014 fleet-config drift) \u2014 falling back to model-authored login");
|
|
308111
308425
|
return null;
|
|
308112
308426
|
}
|
|
308113
|
-
const proofDesc = authProof.kind === "role" ? `role:${authProof.role}${authProof.name ? `(${authProof.name})` : ""}` : `testid:${authProof.testId}`;
|
|
308427
|
+
const proofDesc = authProof.kind === "role" ? `role:${authProof.role}${authProof.name ? `(${authProof.name})` : ""}` : authProof.kind === "testid" ? `testid:${authProof.testId}` : `url:${authProof.route}`;
|
|
308114
308428
|
logs2(` \u2713 auth recipe verified (hash ${candidate.recipeHash}, proof ${proofDesc}, ${(candidate.replayPlan ?? []).length} replay steps) \u2014 stamping into @needs-auth tests`);
|
|
308115
308429
|
return candidate;
|
|
308116
308430
|
} catch (err) {
|
|
@@ -314803,13 +315117,13 @@ var require_mobile_discovery_agent = __commonJS({
|
|
|
314803
315117
|
exploreStats: { pagesDiscovered: 0, pagesVisited: 0, elementsFound: 0, transitionsFound: 0 },
|
|
314804
315118
|
duration: duration2,
|
|
314805
315119
|
mode,
|
|
314806
|
-
...paused ? { paused: true, pauseReason, remainingScenarios } : {}
|
|
315120
|
+
...paused ? { paused: true, pauseReason, pausePhase: "generate", remainingScenarios } : {}
|
|
314807
315121
|
// apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
|
|
314808
315122
|
};
|
|
314809
315123
|
}
|
|
314810
315124
|
log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
|
|
314811
|
-
const exploreAppDataCleared = await driver
|
|
314812
|
-
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.");
|
|
315125
|
+
const exploreAppDataCleared = await prepareMobileAppStateForDiscovery(driver, target, "explore", log2);
|
|
315126
|
+
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.");
|
|
314813
315127
|
const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
|
|
314814
315128
|
const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
|
|
314815
315129
|
const existingScreens = isSurvey && ctx.incremental ? compactExistingScreens(ctx.existingSiteMap) : void 0;
|
|
@@ -314844,6 +315158,32 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
|
|
|
314844
315158
|
log2(`[stream] explore-complete callback failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
314845
315159
|
});
|
|
314846
315160
|
}
|
|
315161
|
+
if (!isSurvey && shouldPauseAfterTruncatedExplore(explore)) {
|
|
315162
|
+
const duration2 = (Date.now() - startTime) / 1e3;
|
|
315163
|
+
const pauseReason = explore.truncatedReason === "timeout" ? "ai_timeout" : "ai_rate_limit";
|
|
315164
|
+
const summary2 = `Mobile discovery paused after exploration: mapped ${explore.exploreStats.pagesDiscovered} screen(s) before ${explore.truncatedReason ?? "AI"} interruption in ${duration2.toFixed(1)}s.`;
|
|
315165
|
+
log2(summary2);
|
|
315166
|
+
return {
|
|
315167
|
+
collectedPages: explore.collectedPages,
|
|
315168
|
+
collectedTransitions,
|
|
315169
|
+
tests: [],
|
|
315170
|
+
plans: [],
|
|
315171
|
+
logs: logs2.join("\n"),
|
|
315172
|
+
screenshots,
|
|
315173
|
+
pageScreenshots: explore.pageScreenshots,
|
|
315174
|
+
summary: summary2,
|
|
315175
|
+
warning: mergeWarning(void 0, explore),
|
|
315176
|
+
healthObservations: explore.healthObservations,
|
|
315177
|
+
exploreTruncated: true,
|
|
315178
|
+
exploreTruncatedReason: explore.truncatedReason,
|
|
315179
|
+
exploreStats: explore.exploreStats,
|
|
315180
|
+
duration: duration2,
|
|
315181
|
+
mode,
|
|
315182
|
+
paused: true,
|
|
315183
|
+
pauseReason,
|
|
315184
|
+
pausePhase: "explore"
|
|
315185
|
+
};
|
|
315186
|
+
}
|
|
314847
315187
|
log2("\nRelaunching app for a clean state before planning/generation...");
|
|
314848
315188
|
await driver.relaunch();
|
|
314849
315189
|
log2("\n\u2500\u2500 Phase 2: PLAN \u2500\u2500");
|
|
@@ -314996,7 +315336,7 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
|
|
|
314996
315336
|
// D-D4 — a circuit-breaker trip PAUSES the run (resumable) instead of
|
|
314997
315337
|
// finalizing COMPLETED/FAILED. The server's isDiscoveryPaused reads these
|
|
314998
315338
|
// (forwarded by runner.ts buildFinalBody, byte-identical to the web path).
|
|
314999
|
-
...generatePaused ? { paused: true, pauseReason: generatePauseReason, remainingScenarios: generateRemainingScenarios } : {}
|
|
315339
|
+
...generatePaused ? { paused: true, pauseReason: generatePauseReason, pausePhase: "generate", remainingScenarios: generateRemainingScenarios } : {}
|
|
315000
315340
|
// apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
|
|
315001
315341
|
};
|
|
315002
315342
|
} catch (fatalErr) {
|
|
@@ -315025,6 +315365,29 @@ Fatal error: ${msg}`);
|
|
|
315025
315365
|
};
|
|
315026
315366
|
}
|
|
315027
315367
|
}
|
|
315368
|
+
async function prepareMobileAppStateForDiscovery(driver, target, phase, log2) {
|
|
315369
|
+
void phase;
|
|
315370
|
+
const resetPolicy = target?.resetPolicy ?? "AUTO";
|
|
315371
|
+
if (resetPolicy === "PRESERVE_APP_DATA") {
|
|
315372
|
+
try {
|
|
315373
|
+
await driver.relaunch();
|
|
315374
|
+
} catch (err) {
|
|
315375
|
+
log2(`App relaunch failed while preserving app data: ${err instanceof Error ? err.message : String(err)}`);
|
|
315376
|
+
}
|
|
315377
|
+
return false;
|
|
315378
|
+
}
|
|
315379
|
+
const cleared = await driver.resetAppState?.().catch((err) => {
|
|
315380
|
+
log2(`App-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
315381
|
+
return false;
|
|
315382
|
+
}) ?? false;
|
|
315383
|
+
if (!cleared && resetPolicy === "CLEAR_APP_DATA") {
|
|
315384
|
+
log2("CLEAR_APP_DATA requested but app-data reset was unavailable; continuing without clearing app data.");
|
|
315385
|
+
}
|
|
315386
|
+
return cleared;
|
|
315387
|
+
}
|
|
315388
|
+
function shouldPauseAfterTruncatedExplore(explore) {
|
|
315389
|
+
return explore.truncated === true && (explore.truncatedReason === "rate_limit" || explore.truncatedReason === "timeout");
|
|
315390
|
+
}
|
|
315028
315391
|
function mergeWarning(plannerWarning, explore) {
|
|
315029
315392
|
if (!explore.truncated)
|
|
315030
315393
|
return plannerWarning;
|
|
@@ -329526,7 +329889,7 @@ var require_package4 = __commonJS({
|
|
|
329526
329889
|
"package.json"(exports2, module2) {
|
|
329527
329890
|
module2.exports = {
|
|
329528
329891
|
name: "@validate.qa/runner",
|
|
329529
|
-
version: "1.0.
|
|
329892
|
+
version: "1.0.30",
|
|
329530
329893
|
description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
|
|
329531
329894
|
bin: {
|
|
329532
329895
|
"validate-runner": "dist/cli.js"
|
|
@@ -330665,6 +331028,15 @@ var MitmProxy = class {
|
|
|
330665
331028
|
}
|
|
330666
331029
|
return this.tlsFailureReason;
|
|
330667
331030
|
}
|
|
331031
|
+
get passthroughCount() {
|
|
331032
|
+
return this.tlsPassthroughCount;
|
|
331033
|
+
}
|
|
331034
|
+
get capturedCallCounts() {
|
|
331035
|
+
return {
|
|
331036
|
+
callCount: this.calls.length,
|
|
331037
|
+
apiCallCount: this.calls.filter((call) => (0, import_runner_core7.isApiCall)(call.url, call.responseContentType)).length
|
|
331038
|
+
};
|
|
331039
|
+
}
|
|
330668
331040
|
async close() {
|
|
330669
331041
|
const server3 = this.server;
|
|
330670
331042
|
this.server = null;
|
|
@@ -331251,6 +331623,35 @@ async function startMobileNetworkCapture(options) {
|
|
|
331251
331623
|
const reason = proxy.interceptionReason ?? wiringReason;
|
|
331252
331624
|
return reason ? { intercepting: false, reason } : { intercepting: false };
|
|
331253
331625
|
};
|
|
331626
|
+
const health = () => {
|
|
331627
|
+
const capabilities = getCapabilities();
|
|
331628
|
+
const counts = stopped ? {
|
|
331629
|
+
callCount: finalCalls.length,
|
|
331630
|
+
apiCallCount: finalCalls.filter((call) => call.isApi).length
|
|
331631
|
+
} : proxy.capturedCallCounts;
|
|
331632
|
+
const warnings = [];
|
|
331633
|
+
if (!capabilities.intercepting && capabilities.reason) {
|
|
331634
|
+
warnings.push(capabilities.reason);
|
|
331635
|
+
}
|
|
331636
|
+
if (stopped && counts.callCount === 0) {
|
|
331637
|
+
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.");
|
|
331638
|
+
}
|
|
331639
|
+
return {
|
|
331640
|
+
enabled: true,
|
|
331641
|
+
active: !stopped,
|
|
331642
|
+
platform: platform3,
|
|
331643
|
+
deviceId,
|
|
331644
|
+
appId: options.appId,
|
|
331645
|
+
proxyHost,
|
|
331646
|
+
proxyPort,
|
|
331647
|
+
intercepting: capabilities.intercepting,
|
|
331648
|
+
...capabilities.reason ? { reason: capabilities.reason } : {},
|
|
331649
|
+
callCount: counts.callCount,
|
|
331650
|
+
apiCallCount: counts.apiCallCount,
|
|
331651
|
+
tlsPassthroughCount: proxy.passthroughCount,
|
|
331652
|
+
warnings
|
|
331653
|
+
};
|
|
331654
|
+
};
|
|
331254
331655
|
const stop = async () => {
|
|
331255
331656
|
if (stopped) return finalCalls;
|
|
331256
331657
|
stopped = true;
|
|
@@ -331289,6 +331690,7 @@ async function startMobileNetworkCapture(options) {
|
|
|
331289
331690
|
proxyHost,
|
|
331290
331691
|
caCertPath: capturedCaCertPath,
|
|
331291
331692
|
getCapabilities,
|
|
331693
|
+
health,
|
|
331292
331694
|
stop
|
|
331293
331695
|
};
|
|
331294
331696
|
}
|
|
@@ -331769,6 +332171,32 @@ function readStepTimeout(step) {
|
|
|
331769
332171
|
}
|
|
331770
332172
|
return void 0;
|
|
331771
332173
|
}
|
|
332174
|
+
async function revealLikelyOffscreenElement(sessionId) {
|
|
332175
|
+
const windowSize = await getWindowSize2(sessionId);
|
|
332176
|
+
const { startX, startY, endX, endY } = computeMobileSwipePointers(
|
|
332177
|
+
windowSize.width,
|
|
332178
|
+
windowSize.height,
|
|
332179
|
+
"up",
|
|
332180
|
+
clampMobileSwipeDistance(void 0)
|
|
332181
|
+
);
|
|
332182
|
+
await wdRequest(`/session/${sessionId}/actions`, {
|
|
332183
|
+
method: "POST",
|
|
332184
|
+
body: JSON.stringify({
|
|
332185
|
+
actions: [{
|
|
332186
|
+
type: "pointer",
|
|
332187
|
+
id: "finger-reveal",
|
|
332188
|
+
parameters: { pointerType: "touch" },
|
|
332189
|
+
actions: [
|
|
332190
|
+
{ type: "pointerMove", duration: 0, x: startX, y: startY },
|
|
332191
|
+
{ type: "pointerDown", button: 0 },
|
|
332192
|
+
{ type: "pointerMove", duration: 300, x: endX, y: endY },
|
|
332193
|
+
{ type: "pointerUp", button: 0 }
|
|
332194
|
+
]
|
|
332195
|
+
}]
|
|
332196
|
+
})
|
|
332197
|
+
});
|
|
332198
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
332199
|
+
}
|
|
331772
332200
|
async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
|
|
331773
332201
|
const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target, opts?.platform) : buildLocatorStrategies(target, opts?.platform);
|
|
331774
332202
|
if (strategies.length === 0) {
|
|
@@ -331778,6 +332206,7 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
|
|
|
331778
332206
|
}
|
|
331779
332207
|
const startTime = Date.now();
|
|
331780
332208
|
let lastError = "";
|
|
332209
|
+
let revealAttempted = false;
|
|
331781
332210
|
while (Date.now() - startTime < timeoutMs) {
|
|
331782
332211
|
for (const strategy of strategies) {
|
|
331783
332212
|
try {
|
|
@@ -331792,9 +332221,18 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
|
|
|
331792
332221
|
lastError = err instanceof Error ? err.message : String(err);
|
|
331793
332222
|
}
|
|
331794
332223
|
}
|
|
332224
|
+
if (!opts?.identityOnly && !revealAttempted && Date.now() - startTime > Math.min(1500, timeoutMs / 3)) {
|
|
332225
|
+
revealAttempted = true;
|
|
332226
|
+
try {
|
|
332227
|
+
await revealLikelyOffscreenElement(sessionId);
|
|
332228
|
+
} catch (err) {
|
|
332229
|
+
if (isTransportError(err)) throw err;
|
|
332230
|
+
lastError = `reveal scroll failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
332231
|
+
}
|
|
332232
|
+
}
|
|
331795
332233
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
331796
332234
|
}
|
|
331797
|
-
throw new Error(`Element not found after ${timeoutMs}ms. Tried ${strategies.length} strategies. Last error: ${lastError}`);
|
|
332235
|
+
throw new Error(`Element not found after ${timeoutMs}ms. Tried ${strategies.length} strategies${revealAttempted ? " plus one reveal scroll" : ""}. Last error: ${lastError}`);
|
|
331798
332236
|
}
|
|
331799
332237
|
async function tapCoordinates(sessionId, bounds) {
|
|
331800
332238
|
const tapX = Math.round(bounds.x + bounds.width / 2);
|
|
@@ -332218,21 +332656,29 @@ async function executeMobileTest(options) {
|
|
|
332218
332656
|
recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
|
|
332219
332657
|
const launchStepForReset = runSteps.find((s) => s.action === "launchApp");
|
|
332220
332658
|
const recordedFromClearedState = launchStepForReset?.metadata?.appReset === "cleared";
|
|
332221
|
-
|
|
332659
|
+
const resetPolicy = options.target.resetPolicy ?? "AUTO";
|
|
332660
|
+
const shouldClearAppData = resetPolicy === "CLEAR_APP_DATA" || resetPolicy === "AUTO" && recordedFromClearedState;
|
|
332661
|
+
let clearedAppDataBeforeLaunch = false;
|
|
332662
|
+
if (shouldClearAppData) {
|
|
332222
332663
|
if (shouldResetMobileAppData(options.target.platform, selectedDevice.id)) {
|
|
332223
332664
|
try {
|
|
332224
332665
|
await executeMobileScript(sessionId, "mobile: clearApp", [{ appId: options.target.appId }]);
|
|
332225
|
-
|
|
332666
|
+
clearedAppDataBeforeLaunch = true;
|
|
332667
|
+
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).");
|
|
332226
332668
|
} catch (err) {
|
|
332227
332669
|
log2(`App-data clear failed (continuing with plain fresh launch): ${err instanceof Error ? err.message : String(err)}`);
|
|
332228
332670
|
}
|
|
332671
|
+
} else if (resetPolicy === "CLEAR_APP_DATA") {
|
|
332672
|
+
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.");
|
|
332229
332673
|
} else {
|
|
332230
332674
|
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.");
|
|
332231
332675
|
}
|
|
332676
|
+
} else if (recordedFromClearedState && resetPolicy === "PRESERVE_APP_DATA") {
|
|
332677
|
+
log2("Test was recorded from a cleared state, but target.resetPolicy=PRESERVE_APP_DATA \u2014 replaying against preserved app data.");
|
|
332232
332678
|
}
|
|
332233
332679
|
log2("Opening a fresh app instance for the test...");
|
|
332234
332680
|
await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
|
|
332235
|
-
if (
|
|
332681
|
+
if (clearedAppDataBeforeLaunch) {
|
|
332236
332682
|
await waitForColdBootContent(sessionId, log2);
|
|
332237
332683
|
}
|
|
332238
332684
|
if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
|
|
@@ -334478,8 +334924,9 @@ function envInt(name, fallback) {
|
|
|
334478
334924
|
var MOBILE_DEVICE_CEILING = Math.max(0, envInt("MOBILE_DEVICE_CEILING", 0));
|
|
334479
334925
|
var MOBILE_HEAL_HOLD_MS = Math.max(0, envInt("MOBILE_HEAL_HOLD_MS", 2e4));
|
|
334480
334926
|
function scrubAuditMessages(messages, testVariables) {
|
|
334927
|
+
if (messages === void 0 || messages === null) return messages;
|
|
334481
334928
|
const compacted = compactForAudit(messages);
|
|
334482
|
-
const secretValues = Object.values(testVariables).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
|
|
334929
|
+
const secretValues = Object.values(testVariables ?? {}).filter((v) => typeof v === "string" && v.length >= 4).sort((a, b) => b.length - a.length);
|
|
334483
334930
|
if (secretValues.length === 0) return compacted;
|
|
334484
334931
|
let json = JSON.stringify(compacted);
|
|
334485
334932
|
if (typeof json !== "string") return compacted;
|
|
@@ -334794,6 +335241,9 @@ var AUDIT_FLUSH_COUNT = 10;
|
|
|
334794
335241
|
var AUDIT_FLUSH_BYTES = 2 * 1024 * 1024;
|
|
334795
335242
|
var AUDIT_DRAIN_ATTEMPTS = 3;
|
|
334796
335243
|
var AUDIT_DRAIN_DELAY_MS = 1e3;
|
|
335244
|
+
var AUDIT_POST_MAX_BYTES = 4 * 1024 * 1024;
|
|
335245
|
+
var AUDIT_POST_MAX_ENTRIES = 50;
|
|
335246
|
+
var AUDIT_QUEUE_MAX_BYTES = 32 * 1024 * 1024;
|
|
334797
335247
|
var AUDIT_BODY_MAX_BYTES = 1024 * 1024;
|
|
334798
335248
|
var AUDIT_MAX_STRING_CHARS = 5e4;
|
|
334799
335249
|
var AUDIT_MAX_ARRAY_ITEMS = 50;
|
|
@@ -334844,7 +335294,7 @@ function compactForAudit(value, depth = 0, seen = /* @__PURE__ */ new WeakSet())
|
|
|
334844
335294
|
}
|
|
334845
335295
|
function scrubAuditText(text, testVariables) {
|
|
334846
335296
|
let out = text;
|
|
334847
|
-
for (const [key, value] of Object.entries(testVariables).sort((a, b) => b[1].length - a[1].length)) {
|
|
335297
|
+
for (const [key, value] of Object.entries(testVariables ?? {}).sort((a, b) => b[1].length - a[1].length)) {
|
|
334848
335298
|
if (!value || value.length < 3) continue;
|
|
334849
335299
|
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
334850
335300
|
const replacement = SECRET_VAR_PATTERN.test(key) ? "[REDACTED]" : `[TEST_VARIABLE:${key}]`;
|
|
@@ -334963,6 +335413,9 @@ function truncateAuditRequestBody(body) {
|
|
|
334963
335413
|
if (byteLen <= AUDIT_BODY_MAX_BYTES) return compacted;
|
|
334964
335414
|
return { _truncated: true, _originalLength: byteLen, preview: serialized.slice(0, 1e3) };
|
|
334965
335415
|
}
|
|
335416
|
+
function isTerminalAuditStatus(status) {
|
|
335417
|
+
return status === 400 || status === 404 || status === 409 || status === 413;
|
|
335418
|
+
}
|
|
334966
335419
|
function createAuditFlusher(url, headers) {
|
|
334967
335420
|
const entries = [];
|
|
334968
335421
|
let byteEstimate = 0;
|
|
@@ -334970,26 +335423,41 @@ function createAuditFlusher(url, headers) {
|
|
|
334970
335423
|
const doFlush = () => {
|
|
334971
335424
|
if (entries.length === 0) return Promise.resolve();
|
|
334972
335425
|
if (flushInFlight) return flushInFlight;
|
|
334973
|
-
const toSend = entries.splice(0);
|
|
334974
|
-
const savedBytes = byteEstimate;
|
|
334975
|
-
byteEstimate = 0;
|
|
334976
335426
|
const attempt = async () => {
|
|
334977
335427
|
try {
|
|
334978
|
-
|
|
334979
|
-
|
|
334980
|
-
|
|
334981
|
-
|
|
334982
|
-
|
|
334983
|
-
|
|
334984
|
-
|
|
334985
|
-
|
|
334986
|
-
|
|
334987
|
-
|
|
335428
|
+
while (entries.length > 0) {
|
|
335429
|
+
const toSend = [];
|
|
335430
|
+
let sendBytes = 0;
|
|
335431
|
+
while (entries.length > 0 && toSend.length < AUDIT_POST_MAX_ENTRIES) {
|
|
335432
|
+
const next = entries[0];
|
|
335433
|
+
if (toSend.length > 0 && sendBytes + next.bytes > AUDIT_POST_MAX_BYTES) break;
|
|
335434
|
+
toSend.push(entries.shift());
|
|
335435
|
+
sendBytes += next.bytes;
|
|
335436
|
+
}
|
|
335437
|
+
byteEstimate = Math.max(0, byteEstimate - sendBytes);
|
|
335438
|
+
try {
|
|
335439
|
+
const res = await fetch(url, {
|
|
335440
|
+
method: "POST",
|
|
335441
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
335442
|
+
body: JSON.stringify({ entries: toSend.map((e) => e.entry) }),
|
|
335443
|
+
signal: AbortSignal.timeout(3e4)
|
|
335444
|
+
});
|
|
335445
|
+
if (res.ok) continue;
|
|
335446
|
+
if (isTerminalAuditStatus(res.status)) {
|
|
335447
|
+
console.warn(`[audit-flush] HTTP ${res.status} (terminal) - dropping ${toSend.length} audit entries`);
|
|
335448
|
+
continue;
|
|
335449
|
+
}
|
|
335450
|
+
entries.unshift(...toSend);
|
|
335451
|
+
byteEstimate += sendBytes;
|
|
335452
|
+
console.warn(`[audit-flush] HTTP ${res.status} - ${toSend.length} entries requeued`);
|
|
335453
|
+
return;
|
|
335454
|
+
} catch (err) {
|
|
335455
|
+
entries.unshift(...toSend);
|
|
335456
|
+
byteEstimate += sendBytes;
|
|
335457
|
+
console.warn(`[audit-flush] Failed: ${err instanceof Error ? err.message : String(err)} - ${toSend.length} entries requeued`);
|
|
335458
|
+
return;
|
|
335459
|
+
}
|
|
334988
335460
|
}
|
|
334989
|
-
} catch (err) {
|
|
334990
|
-
entries.unshift(...toSend);
|
|
334991
|
-
byteEstimate += savedBytes;
|
|
334992
|
-
console.warn(`[audit-flush] Failed: ${err instanceof Error ? err.message : String(err)} - ${toSend.length} entries requeued`);
|
|
334993
335461
|
} finally {
|
|
334994
335462
|
flushInFlight = null;
|
|
334995
335463
|
}
|
|
@@ -335005,8 +335473,18 @@ function createAuditFlusher(url, headers) {
|
|
|
335005
335473
|
if ("responseBody" in safeEntry && safeEntry.responseBody != null) {
|
|
335006
335474
|
safeEntry = { ...safeEntry, responseBody: truncateAuditRequestBody(safeEntry.responseBody) };
|
|
335007
335475
|
}
|
|
335008
|
-
|
|
335009
|
-
|
|
335476
|
+
const bytes = safeJsonByteLength(safeEntry);
|
|
335477
|
+
entries.push({ entry: safeEntry, bytes });
|
|
335478
|
+
byteEstimate += bytes;
|
|
335479
|
+
if (byteEstimate > AUDIT_QUEUE_MAX_BYTES) {
|
|
335480
|
+
let droppedCount = 0;
|
|
335481
|
+
while (entries.length > 1 && byteEstimate > AUDIT_QUEUE_MAX_BYTES) {
|
|
335482
|
+
const dropped = entries.shift();
|
|
335483
|
+
byteEstimate -= dropped.bytes;
|
|
335484
|
+
droppedCount++;
|
|
335485
|
+
}
|
|
335486
|
+
console.warn(`[audit-flush] queue over ${AUDIT_QUEUE_MAX_BYTES} bytes - dropped ${droppedCount} oldest audit entries`);
|
|
335487
|
+
}
|
|
335010
335488
|
if (entries.length >= AUDIT_FLUSH_COUNT || byteEstimate >= AUDIT_FLUSH_BYTES) {
|
|
335011
335489
|
void doFlush();
|
|
335012
335490
|
}
|
|
@@ -335850,6 +336328,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
335850
336328
|
platformVersion: harvestTarget.platformVersion,
|
|
335851
336329
|
recordedDeviceId: harvestTarget.recordedDeviceId,
|
|
335852
336330
|
assignedDeviceId,
|
|
336331
|
+
resetPolicy: harvestTarget.resetPolicy,
|
|
335853
336332
|
launchMode: harvestTarget.launchMode,
|
|
335854
336333
|
deeplink: harvestTarget.deeplink
|
|
335855
336334
|
},
|
|
@@ -336126,6 +336605,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336126
336605
|
// local-runner projects. Mirrors the Fargate host forward in execute-run.ts.
|
|
336127
336606
|
paused: rest.paused,
|
|
336128
336607
|
pauseReason: rest.pauseReason,
|
|
336608
|
+
pausePhase: rest.pausePhase,
|
|
336129
336609
|
remainingScenarios: rest.remainingScenarios,
|
|
336130
336610
|
// Operator/UX signals the server persists to DiscoveryRun (Fargate parity).
|
|
336131
336611
|
coverageGaps: rest.coverageGaps,
|
|
@@ -336239,6 +336719,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336239
336719
|
platformVersion: mobileTarget.platformVersion,
|
|
336240
336720
|
recordedDeviceId: mobileTarget.recordedDeviceId,
|
|
336241
336721
|
assignedDeviceId,
|
|
336722
|
+
resetPolicy: mobileTarget.resetPolicy,
|
|
336242
336723
|
launchMode: mobileTarget.launchMode,
|
|
336243
336724
|
deeplink: mobileTarget.deeplink
|
|
336244
336725
|
}, {
|
|
@@ -336270,7 +336751,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336270
336751
|
// Deterministic-state authorization: Android emulators only by
|
|
336271
336752
|
// default (VALIDATEQA_MOBILE_APP_RESET overrides) — a personal
|
|
336272
336753
|
// physical device must never have its app data wiped.
|
|
336273
|
-
allowAppReset: shouldResetMobileAppData(platform3, session.deviceId)
|
|
336754
|
+
allowAppReset: mobileTarget.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(platform3, session.deviceId)
|
|
336274
336755
|
});
|
|
336275
336756
|
discoveryResult = await (0, import_runner_core4.runMobileDiscoveryOnRunner)(ctx, driver, {
|
|
336276
336757
|
onLog: mobileOnLog,
|
|
@@ -336302,8 +336783,13 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336302
336783
|
if (activeCapture) {
|
|
336303
336784
|
try {
|
|
336304
336785
|
const apiCalls = await activeCapture.stop();
|
|
336786
|
+
const captureHealth = activeCapture.health();
|
|
336305
336787
|
if (discoveryResult) {
|
|
336306
336788
|
discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
|
|
336789
|
+
const priorHealth = discoveryResult.healthObservations;
|
|
336790
|
+
const healthObject = priorHealth && typeof priorHealth === "object" && !Array.isArray(priorHealth) ? { ...priorHealth } : {};
|
|
336791
|
+
healthObject.mobileNetworkCapture = captureHealth;
|
|
336792
|
+
discoveryResult.healthObservations = healthObject;
|
|
336307
336793
|
}
|
|
336308
336794
|
} catch (capErr) {
|
|
336309
336795
|
mobileOnLog(`[mobile-net] capture stop failed: ${capErr instanceof Error ? capErr.message : String(capErr)}`);
|
|
@@ -336620,6 +337106,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336620
337106
|
platformVersion: target.platformVersion,
|
|
336621
337107
|
recordedDeviceId: target.recordedDeviceId,
|
|
336622
337108
|
assignedDeviceId,
|
|
337109
|
+
resetPolicy: target.resetPolicy,
|
|
336623
337110
|
launchMode: target.launchMode,
|
|
336624
337111
|
deeplink: target.deeplink,
|
|
336625
337112
|
authoringPlatform: mobilePlatformFromMedium(run2.authoringMedium) ?? void 0
|
|
@@ -336634,6 +337121,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336634
337121
|
platformVersion: t.platformVersion,
|
|
336635
337122
|
recordedDeviceId: t.recordedDeviceId,
|
|
336636
337123
|
assignedDeviceId: t.assignedDeviceId,
|
|
337124
|
+
resetPolicy: t.resetPolicy,
|
|
336637
337125
|
launchMode: narrowMobileLaunchMode(t.launchMode),
|
|
336638
337126
|
deeplink: t.deeplink,
|
|
336639
337127
|
authoringPlatform: t.authoringPlatform
|
|
@@ -336696,6 +337184,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336696
337184
|
platformVersion: target.platformVersion,
|
|
336697
337185
|
recordedDeviceId: target.recordedDeviceId,
|
|
336698
337186
|
assignedDeviceId,
|
|
337187
|
+
resetPolicy: target.resetPolicy,
|
|
336699
337188
|
launchMode: target.launchMode,
|
|
336700
337189
|
deeplink: target.deeplink
|
|
336701
337190
|
});
|
|
@@ -336704,7 +337193,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336704
337193
|
sessionId: liveSession.sessionId,
|
|
336705
337194
|
platform: target.platform,
|
|
336706
337195
|
appId: liveSession.appId,
|
|
336707
|
-
allowAppReset: shouldResetMobileAppData(target.platform, liveSession.deviceId)
|
|
337196
|
+
allowAppReset: target.resetPolicy !== "PRESERVE_APP_DATA" && shouldResetMobileAppData(target.platform, liveSession.deviceId)
|
|
336708
337197
|
});
|
|
336709
337198
|
const liveDriver = {
|
|
336710
337199
|
snapshot: async () => {
|
|
@@ -336732,7 +337221,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336732
337221
|
relaunch: async () => {
|
|
336733
337222
|
await bridgeDriver.relaunch();
|
|
336734
337223
|
},
|
|
336735
|
-
resetAppState: async () => bridgeDriver.resetAppState()
|
|
337224
|
+
resetAppState: async () => target.resetPolicy === "PRESERVE_APP_DATA" ? false : bridgeDriver.resetAppState()
|
|
336736
337225
|
};
|
|
336737
337226
|
return await (0, import_runner_core13.executeMobileLiveHeal)({
|
|
336738
337227
|
steps: a.steps,
|
|
@@ -336833,6 +337322,7 @@ ${finalVerifyResult.logs ?? ""}`;
|
|
|
336833
337322
|
platformVersion: target.platformVersion,
|
|
336834
337323
|
recordedDeviceId: target.recordedDeviceId,
|
|
336835
337324
|
assignedDeviceId,
|
|
337325
|
+
resetPolicy: target.resetPolicy,
|
|
336836
337326
|
launchMode: target.launchMode,
|
|
336837
337327
|
deeplink: target.deeplink,
|
|
336838
337328
|
// Authoring platform (test's session medium). When it differs from
|