@validate.qa/runner 1.0.32 → 1.0.34

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.
Files changed (3) hide show
  1. package/dist/cli.js +693 -178
  2. package/dist/cli.mjs +693 -178
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -62290,7 +62290,7 @@ var require_mobile_explorer = __commonJS({
62290
62290
  return { screen, signal, refMap, observation };
62291
62291
  }
62292
62292
  function buildStatePacket(input) {
62293
- const { state: state2, mode, iteration, maxIterations, latestObservation, journeys, transitions } = input;
62293
+ const { state: state2, mode, iteration, maxIterations, latestObservation, journeys, transitions, featureName, featureContext, userNotes } = input;
62294
62294
  const sections = [];
62295
62295
  sections.push(`## Mobile Exploration Status (iteration ${iteration}/${maxIterations})`);
62296
62296
  sections.push(`Mode: ${mode} | Budget remaining: ${maxIterations - iteration} iterations`);
@@ -62303,10 +62303,46 @@ var require_mobile_explorer = __commonJS({
62303
62303
  sections.push("");
62304
62304
  if (mode === "survey") {
62305
62305
  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.");
62306
+ } else if (featureName?.trim()) {
62307
+ sections.push(`**Mission:** Feature-scoped deep exploration of **"${featureName.trim()}"**. Drive the app like a real user through THIS feature only: tap, type, submit, and observe outcomes. After a meaningful flow, call record_journey. Do NOT deep-dive unrelated features. Call finish_exploration when this feature's flows are covered.`);
62306
62308
  } else {
62307
62309
  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.");
62308
62310
  }
62309
62311
  sections.push("");
62312
+ if (mode === "deep" && featureName?.trim()) {
62313
+ sections.push(`## Feature: ${featureName.trim()}`);
62314
+ if (featureContext?.plannerDescription?.trim()) {
62315
+ sections.push(featureContext.plannerDescription.trim());
62316
+ }
62317
+ if (featureContext?.priorInsight?.trim()) {
62318
+ sections.push(`Prior insight: ${featureContext.priorInsight.trim()}`);
62319
+ }
62320
+ if (featureContext?.pageIntel && featureContext.pageIntel.length > 0) {
62321
+ sections.push("### Target screens / routes for this feature");
62322
+ for (const page of featureContext.pageIntel.slice(0, 12)) {
62323
+ sections.push(`- ${page.route} (${page.type}): ${page.summary}`);
62324
+ }
62325
+ }
62326
+ if (featureContext?.flowIntel && featureContext.flowIntel.length > 0) {
62327
+ sections.push("### Known flows");
62328
+ for (const flow of featureContext.flowIntel.slice(0, 8)) {
62329
+ sections.push(`- ${flow}`);
62330
+ }
62331
+ }
62332
+ if (featureContext?.otherFeatures && featureContext.otherFeatures.length > 0) {
62333
+ sections.push("### Other features \u2014 do NOT explore these (navigation context only)");
62334
+ for (const other of featureContext.otherFeatures.slice(0, 12)) {
62335
+ const pages = other.pages?.length ? ` [${other.pages.slice(0, 4).join(", ")}]` : "";
62336
+ sections.push(`- ${other.name}${pages}`);
62337
+ }
62338
+ }
62339
+ sections.push("");
62340
+ }
62341
+ if (userNotes?.trim()) {
62342
+ sections.push(`## Operator notes
62343
+ ${userNotes.trim()}`);
62344
+ sections.push("");
62345
+ }
62310
62346
  const screenCount = state2.getScreenCount();
62311
62347
  sections.push(`## Screens Mapped: ${screenCount} | Transitions: ${transitions.length} | Journeys: ${journeys.length}`);
62312
62348
  const currentScreenId = state2.getCurrentScreenId();
@@ -62463,7 +62499,7 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
62463
62499
  } catch {
62464
62500
  }
62465
62501
  };
62466
- const mode = ctx.mode === "deep" ? "deep" : "survey";
62502
+ const mode = ctx.mode === "survey" ? "survey" : "deep";
62467
62503
  const platform3 = platformForMedium(ctx.medium);
62468
62504
  const enableRecordJourney = config?.enableRecordJourney ?? mode !== "survey";
62469
62505
  const targetAppId = ctx.target?.appId?.trim() || void 0;
@@ -62751,7 +62787,10 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
62751
62787
  maxIterations,
62752
62788
  latestObservation: getLatest()?.observation ?? null,
62753
62789
  journeys,
62754
- transitions: collectedTransitions
62790
+ transitions: collectedTransitions,
62791
+ featureName: ctx.featureName,
62792
+ featureContext: ctx.featureContext,
62793
+ userNotes: ctx.userNotes
62755
62794
  });
62756
62795
  const messages = [
62757
62796
  { role: "system", content: systemPrompt },
@@ -65129,7 +65168,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65129
65168
  Example - BAD happy path: \`["Navigate to /feature", "Click Create", "Verify dialog opens"]\` \u2192 only tests the trigger.
65130
65169
  Example - GOOD happy path (generic, async): \`["Navigate to landing and log in", "Navigate to the feature page", "Note the current count of items on the list", "Click the primary action button", "Fill required inputs and submit", "Wait for completion signal (up to 60s)", "Verify the item count increased by 1", "Open the newly created item", "Verify its content is non-empty and contains the input I provided"]\`
65131
65170
  Example - GOOD happy path (sync CRUD): \`["Navigate to landing and log in", "Navigate to the list page", "Click New", "Fill required fields", "Click Save", "Verify the new record appears in the list", "Click the record and verify its detail view shows the values I entered"]\`
65132
- 12. **Auth happy paths require concrete session proof.** A login/auth HAPPY_PATH is incomplete if it only proves "user reaches dashboard" or "authenticated page is visible." It MUST verify at least one durable signed-in signal that was observed in the evidence: user/account menu opens, Logout/Sign out is available, the observed display name/email/avatar/account item is present, authenticated navigation remains available after a reload, or another app-specific session control is usable. Prefer concrete observed labels from the evidence (for example "Logout", "Demo User", "Account", "Profile") over generic phrases like "personalized content." If the explorer only observed a redirect and no signed-in signal, keep the route outcome but mark the session-proof outcome reliability "low" and state what concrete signal was missing.
65171
+ 12. **Auth happy paths require concrete session proof.** A login/auth HAPPY_PATH is incomplete if it only proves "user reaches dashboard" or "authenticated page is visible." It MUST verify at least one durable signed-in signal that was observed in the evidence: user/account menu opens, Logout/Sign out is available, the observed display name/email/avatar/account item is present, authenticated navigation remains available after a reload, or another app-specific session control is usable. Prefer concrete observed labels from the evidence (for example "Logout", "Sign out", "Account", "Profile", or the user's own displayed name/email from evidence) over generic phrases like "personalized content." If the explorer only observed a redirect and no signed-in signal, keep the route outcome but mark the session-proof outcome reliability "low" and state what concrete signal was missing.
65133
65172
  13. **Independent execution contract.** Every scenario MUST pass when run alone, first, last, in random order, and in parallel against an environment with no relevant feature data except explicitly guaranteed immutable fixtures. 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.
65134
65173
  14. **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 in the demo 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 dashboard heading is visible") are allowed as prerequisite reads ONLY when the durable assertion targets a self-created entity. Phrase setup as: "Click New, fill required fields with run-scoped values, submit and verify the new record appears, THEN proceed to <feature under test>."
65135
65174
  15. **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 item", "the latest message", "the last row", 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 in a fresh environment or against a different account.
@@ -66089,7 +66128,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
66089
66128
  9. **No duplicates** - check existing test names before adding
66090
66129
  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.
66091
66130
  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.
66092
- 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:
66131
+ 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" / "Settings screen opened" is NOT a happy path - that is a UI interaction test. Form-mutation happy paths (change password, create/edit/save a record, send a message, checkout, etc.) MUST include typing into the relevant fields AND committing the form (submit/save/send or keyboard Enter/Done) \u2014 navigating to the form screen alone is insufficient. 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:
66093
66132
  - The trigger step (Tap Submit / Generate / Send / Upload / Run / Analyze / Save / Pay / etc.)
66094
66133
  - 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.
66095
66134
  - 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.
@@ -66320,6 +66359,34 @@ ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
66320
66359
  if (context.featureName) {
66321
66360
  sections.push(`## Feature: ${context.featureName}
66322
66361
  SCOPE: this is a feature-focused deep run. Plan scenarios ONLY for the "${context.featureName}" feature \u2014 its screens, forms, and flows. Screens belonging to other features are navigation context / prerequisites only; do NOT plan scenarios whose primary goal exercises another feature.`);
66362
+ if (context.featureContext?.plannerDescription?.trim()) {
66363
+ sections.push(context.featureContext.plannerDescription.trim());
66364
+ }
66365
+ if (context.featureContext?.priorInsight?.trim()) {
66366
+ sections.push(`Prior insight: ${context.featureContext.priorInsight.trim()}`);
66367
+ }
66368
+ if (context.featureContext?.pageIntel && context.featureContext.pageIntel.length > 0) {
66369
+ sections.push("### Feature page intel");
66370
+ for (const page of context.featureContext.pageIntel.slice(0, 12)) {
66371
+ sections.push(`- ${page.route} (${page.type}): ${page.summary}`);
66372
+ }
66373
+ }
66374
+ if (context.featureContext?.flowIntel && context.featureContext.flowIntel.length > 0) {
66375
+ sections.push("### Known flows");
66376
+ for (const flow of context.featureContext.flowIntel.slice(0, 8)) {
66377
+ sections.push(`- ${flow}`);
66378
+ }
66379
+ }
66380
+ if (context.featureContext?.otherFeatures && context.featureContext.otherFeatures.length > 0) {
66381
+ sections.push("### Other features (do not plan primary scenarios for these)");
66382
+ for (const other of context.featureContext.otherFeatures.slice(0, 12)) {
66383
+ sections.push(`- ${other.name}`);
66384
+ }
66385
+ }
66386
+ }
66387
+ if (context.userNotes?.trim()) {
66388
+ sections.push(`## Operator notes
66389
+ ${context.userNotes.trim()}`);
66323
66390
  }
66324
66391
  const orderedScreens = plannableTargetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66325
66392
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
@@ -66446,7 +66513,7 @@ ${constraints.join("\n")}`);
66446
66513
  routePattern: null,
66447
66514
  title: state2.screenName ?? null,
66448
66515
  pageType: state2.screenType ?? "UNKNOWN",
66449
- requiresAuth: false,
66516
+ requiresAuth: state2.screenType?.toLowerCase() === "auth",
66450
66517
  visitCount: state2.visited ? 1 : 0,
66451
66518
  lastVisitedAt: state2.visited ? now : null,
66452
66519
  elementsCovered: 0,
@@ -66466,6 +66533,8 @@ ${constraints.join("\n")}`);
66466
66533
  const toPageId = idByScreen.get(t.toScreenId);
66467
66534
  if (!fromPageId || !toPageId)
66468
66535
  continue;
66536
+ const fromScreen = graph.screens.get(t.fromScreenId);
66537
+ const toScreen = graph.screens.get(t.toScreenId);
66469
66538
  transitions.push({
66470
66539
  id: `mtx-${txSeq++}`,
66471
66540
  fromPageId,
@@ -66473,7 +66542,7 @@ ${constraints.join("\n")}`);
66473
66542
  triggerType: t.action.toUpperCase(),
66474
66543
  triggerLabel: t.viaRef ?? null,
66475
66544
  observedCount: 1,
66476
- requiresAuth: false
66545
+ requiresAuth: fromScreen?.screenType?.toLowerCase() === "auth" || toScreen?.screenType?.toLowerCase() === "auth"
66477
66546
  });
66478
66547
  }
66479
66548
  return {
@@ -66676,7 +66745,9 @@ ${constraints.join("\n")}`);
66676
66745
  recordedJourneys,
66677
66746
  credentials: input.credentials,
66678
66747
  testBudget: input.testBudget,
66679
- incremental: input.incremental
66748
+ incremental: input.incremental,
66749
+ userNotes: input.userNotes,
66750
+ featureContext: input.featureContext
66680
66751
  })
66681
66752
  });
66682
66753
  const plannerFeatureGroups = filterFeatureGroupsToSiteMap(result.featureGroups, siteMap, input.graph, input.appId);
@@ -66728,6 +66799,9 @@ var require_mobile_generator = __commonJS({
66728
66799
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports) {
66729
66800
  "use strict";
66730
66801
  Object.defineProperty(exports, "__esModule", { value: true });
66802
+ exports.collapseConsecutiveBacks = collapseConsecutiveBacks;
66803
+ exports.scenarioIntentMismatchReason = scenarioIntentMismatchReason;
66804
+ exports.dedupeConsecutiveIdenticalActions = dedupeConsecutiveIdenticalActions;
66731
66805
  exports.collapseRetryCycles = collapseRetryCycles;
66732
66806
  exports.runMobileGeneratePhase = runMobileGeneratePhase;
66733
66807
  var crypto_1 = __require("crypto");
@@ -66935,8 +67009,38 @@ var require_mobile_generator = __commonJS({
66935
67009
  return (labelIsEditableValue ? void 0 : label) ?? nonEmpty2(element.accessibilityId) ?? nonEmpty2(element.resourceId) ?? (editable ? void 0 : text) ?? "element";
66936
67010
  }
66937
67011
  var EDITABLE_ELEMENT_ROLE_RE = /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/;
67012
+ var EDITABLE_IDENTITY_RE = /\b(email|e-?mail|password|passwd|passcode|username|user[_-]?name|login|search|query|otp|pin|phone|mobile|input|textfield|text[_-]?field|edit|typehere)\b/i;
66938
67013
  function isEditableMobileElement(element) {
66939
- return EDITABLE_ELEMENT_ROLE_RE.test(element.role.trim());
67014
+ if (EDITABLE_ELEMENT_ROLE_RE.test(element.role.trim()))
67015
+ return true;
67016
+ const identity = [element.accessibilityId, element.resourceId, element.label].filter((v) => typeof v === "string" && v.trim().length > 0).join(" ");
67017
+ if (identity && EDITABLE_IDENTITY_RE.test(identity))
67018
+ return true;
67019
+ return false;
67020
+ }
67021
+ function isIdentityBearingTapTarget(element) {
67022
+ if (weakAssertionTargetReason(element))
67023
+ return false;
67024
+ if (isBareContainerLikeElement(element))
67025
+ return false;
67026
+ if (isEditableMobileElement(element))
67027
+ return false;
67028
+ if (/button|link|tab|menuitem|checkbox|switch|radio/i.test(element.role))
67029
+ return true;
67030
+ const label = [
67031
+ element.label,
67032
+ element.text,
67033
+ element.accessibilityId,
67034
+ element.resourceId
67035
+ ].filter((v) => typeof v === "string" && v.trim().length > 0).join(" ");
67036
+ return /\b(sign\s*in|log\s*in|login|submit|save|continue|create|send|confirm|done|next|apply|checkout|pay|finish|update|register|sign\s*up)\b/i.test(label);
67037
+ }
67038
+ function isBareContainerLikeElement(element) {
67039
+ if (BARE_CONTAINER_CLASS_RE.test(element.role)) {
67040
+ const hasIdentity = Boolean(nonEmpty2(element.text) || nonEmpty2(element.label) || nonEmpty2(element.accessibilityId));
67041
+ return !hasIdentity;
67042
+ }
67043
+ return false;
66940
67044
  }
66941
67045
  function nonEmpty2(value) {
66942
67046
  if (typeof value !== "string")
@@ -66968,7 +67072,7 @@ var require_mobile_generator = __commonJS({
66968
67072
  ## Your job
66969
67073
  1. mobile_snapshot the current screen.
66970
67074
  2. Execute the scenario's steps using the real UI: tap buttons, fill fields (mobile_type), submit, navigate.
66971
- 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.
67075
+ 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, change password, edit profile), you MUST commit it \u2014 tap the submit/primary button OR press the keyboard's Enter/Done action on the last field (mobile_type with a trailing newline on the focused field when no button is visible). Filling fields alone is NOT the behavior under test. Navigating to a form screen without changing its fields is also NOT enough for mutation scenarios (change/create/update/edit). If it navigates, you MUST land on the destination. A "happy path" that fills a form but never commits \u2014 or only opens the form without typing \u2014 is WRONG.
66972
67076
  4. When you reach the target screen / complete the flow, PROVE the outcome with an assert tool:
66973
67077
  - mobile_assert_visible({ref}) \u2014 the target screen's stable element is on screen.
66974
67078
  - mobile_assert_text({ref, expected}) \u2014 an element contains expected text (only durable when the element has a stable accessibility-id / resource-id).
@@ -66983,6 +67087,8 @@ var require_mobile_generator = __commonJS({
66983
67087
  - 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.
66984
67088
  - 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.
66985
67089
  - 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.
67090
+ - Do NOT thrash the system Back button. One intentional back/navigation is fine; a chain of backs while lost is not a test \u2014 re-snapshot, re-orient, and take the productive path once.
67091
+ - Prefer product identity locators (accessibility-id, resource-id, visible label). Never assert on bare hierarchy roots or unlabeled full-screen containers \u2014 those prove nothing.
66986
67092
  - 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.
66987
67093
  - 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.
66988
67094
  - 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.
@@ -67001,7 +67107,7 @@ This app has stored login credentials under these keys:
67001
67107
  ${keyList}
67002
67108
 
67003
67109
  When a scenario needs a signed-in session, complete the FULL login sequence \u2014 filling the fields alone does NOT log you in:
67004
- 1. Reach the email/password form. 2. Fill each credential field with \`mobile_fill_credential\` (pass the field ref and the KEY, e.g. \`{ ref: "e5", credentialKey: "TEST_USER_EMAIL" }\`) \u2014 the runner types the real value on the device and the generated test stores a \`{{KEY}}\` placeholder that replay resolves; you never see the secret. 3. TAP THE SUBMIT button (Sign In / Log In / Continue). 4. Re-snapshot and VERIFY an authenticated screen appeared. If login fails twice, do NOT keep retrying or wander: call finish_generate with verified:false and say the login could not be completed (the credentials may be invalid for this app).
67110
+ 1. Reach the email/password form. 2. Fill each credential field with \`mobile_fill_credential\` (pass the field ref and the KEY, e.g. \`{ ref: "e5", credentialKey: "TEST_USER_EMAIL" }\`) \u2014 the runner types the real value on the device and the generated test stores a \`{{KEY}}\` placeholder that replay resolves; you never see the secret. 3. COMMIT the form \u2014 tap the submit/primary button (Sign In / Log In / Continue) OR, if no button is visible, focus the last field and \`mobile_type\` a trailing newline (\`\\n\`) to trigger the keyboard's Enter/Done action. 4. Re-snapshot and VERIFY an authenticated screen appeared. If login fails twice, do NOT keep retrying or wander: call finish_generate with verified:false and say the login could not be completed (the credentials may be invalid for this app).
67005
67111
 
67006
67112
  SECURITY RULES:
67007
67113
  - NEVER call \`mobile_type\` with a real email, password, or token value \u2014 the runner will REJECT the call and you lose an iteration. Use \`mobile_fill_credential\` for every credential field.
@@ -67351,6 +67457,7 @@ ${statePacket}` },
67351
67457
  if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT")
67352
67458
  assertCount++;
67353
67459
  },
67460
+ recordedActions: actions,
67354
67461
  log: log2
67355
67462
  });
67356
67463
  pushResult(dispatchResult);
@@ -67366,7 +67473,7 @@ ${statePacket}` },
67366
67473
  return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
67367
67474
  }
67368
67475
  async function dispatchGenerateTool(args) {
67369
- const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, credentials, screenName, recordAction, log: log2 } = args;
67476
+ const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, credentials, screenName, recordAction, recordedActions, log: log2 } = args;
67370
67477
  const unknownRef = (ref) => ({
67371
67478
  ok: false,
67372
67479
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
@@ -67428,12 +67535,26 @@ ${statePacket}` },
67428
67535
  };
67429
67536
  }
67430
67537
  const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_tap");
67431
- if (shouldRecordAction(result, absorbed) && changedScreen(before, absorbed)) {
67432
- recordAction({
67538
+ const screenChanged = changedScreen(before, absorbed);
67539
+ const identityTap = isIdentityBearingTapTarget(element);
67540
+ if (shouldRecordAction(result, absorbed) && (screenChanged || identityTap)) {
67541
+ const tapAction = {
67433
67542
  type: "TAP",
67434
67543
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
67435
- metadata: metadataForElement(element, beforeScreenName, platform3)
67436
- });
67544
+ metadata: metadataForElement(element, beforeScreenName, platform3),
67545
+ innerText: null,
67546
+ url: "",
67547
+ value: null
67548
+ };
67549
+ const lastRecorded = recordedActions[recordedActions.length - 1];
67550
+ if (lastRecorded && actionCycleKey(lastRecorded) === actionCycleKey(tapAction)) {
67551
+ log2(` tap left content signature unchanged but duplicates the prior step \u2014 not recording again.`);
67552
+ } else if (!screenChanged && identityTap) {
67553
+ log2(` tap left content signature unchanged but target has identity \u2014 recording commit control "${labelForElement(element)}".`);
67554
+ recordAction(tapAction);
67555
+ } else {
67556
+ recordAction(tapAction);
67557
+ }
67437
67558
  } else if (shouldRecordAction(result, absorbed)) {
67438
67559
  log2(" tap changed nothing on screen \u2014 not recording it as a step (dead action).");
67439
67560
  }
@@ -67572,6 +67693,13 @@ ${statePacket}` },
67572
67693
  const element = resolveRef(toolArgs.ref);
67573
67694
  if (!element)
67574
67695
  return unknownRef(toolArgs.ref);
67696
+ const weakReason = weakAssertionTargetReason(element);
67697
+ if (weakReason) {
67698
+ return {
67699
+ ok: false,
67700
+ 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.`
67701
+ };
67702
+ }
67575
67703
  const expected = nonEmpty2(typeof toolArgs.expected === "string" ? toolArgs.expected : String(toolArgs.expected ?? ""));
67576
67704
  if (!expected)
67577
67705
  return { ok: false, error: "mobile_assert_text needs a non-empty `expected` value." };
@@ -67770,6 +67898,105 @@ ${statePacket}` },
67770
67898
  ...scenario.expectedOutcomes.flatMap((outcome) => [outcome.description, outcome.hint])
67771
67899
  ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
67772
67900
  }
67901
+ var INSPECTION_OUTCOME_TYPES = /* @__PURE__ */ new Set([
67902
+ "element-visible",
67903
+ "text-visible",
67904
+ "element-hidden",
67905
+ "screen-visible"
67906
+ ]);
67907
+ function isInspectionOnlyScenario(scenario, actions) {
67908
+ if (actions.length === 0 || scenario.expectedOutcomes.length === 0)
67909
+ return false;
67910
+ if (!scenario.expectedOutcomes.every((o) => INSPECTION_OUTCOME_TYPES.has(o.type))) {
67911
+ return false;
67912
+ }
67913
+ if (actions.some((a) => a.type === "TYPE" || a.type === "CLEAR")) {
67914
+ return false;
67915
+ }
67916
+ const text = scenarioText(scenario);
67917
+ const visibilityOnlyGoal = (/\b(entry\s+points?|providers?|options?|provider\s+buttons?)\b/.test(text) || /\b(show|display)\s+(all|the)?\s*(provider|option|button|link|entry)/.test(text) || /\b(all\s+(three\s+)?providers?)\b/.test(text)) && !/\b(change|update|create|save|send|register|edit|with\s+valid|successful\s+(sign|log)|complete\s+(the\s+)?(login|sign[- ]?in)|submit\s+(the\s+)?(form|credentials)|enter\s+(an?\s+)?(email|password))\b/.test(text);
67918
+ if (visibilityOnlyGoal) {
67919
+ const assertCount2 = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT").length;
67920
+ return assertCount2 > 0;
67921
+ }
67922
+ const completionIntent = scenario.type === "HAPPY_PATH" || /\b(happy\s*path|successful|with\s+valid|complete\s+(the\s+)?(login|sign[- ]?in|registration|checkout|purchase))\b/.test(text);
67923
+ const formIntent = /\b(sign\s*in|log\s*in|login|authenticate|register|sign\s*up|checkout|create|save|submit|send|change|update|edit)\b/.test(text);
67924
+ if (completionIntent && formIntent)
67925
+ return false;
67926
+ const assertCount = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT").length;
67927
+ return assertCount > 0;
67928
+ }
67929
+ function isNavigationFocusedScenario(scenario) {
67930
+ const text = scenarioText(scenario);
67931
+ const navigates = /\b(back|return|go\s+home|navigate\s+back|leave|exit|cancel\s+out|dismiss|previous\s+screen)\b/.test(text);
67932
+ const completesFlow = /\b(happy\s*path|successful|complete\s+(the\s+)?(login|sign[- ]?in|registration|checkout)|with\s+valid)\b/.test(text);
67933
+ return navigates && !completesFlow;
67934
+ }
67935
+ function isFormValidationScenario(scenario) {
67936
+ if (scenario.variant === "validation")
67937
+ return true;
67938
+ if (scenario.type !== "EDGE_CASE")
67939
+ return false;
67940
+ const text = scenarioText(scenario);
67941
+ return /\b(empty|required|missing|blank|invalid|reject|error|validation|wrong|incorrect|mismatch)\b/.test(text);
67942
+ }
67943
+ function isFormMutationHappyPath(scenario) {
67944
+ if (isNavigationFocusedScenario(scenario))
67945
+ return false;
67946
+ if (isFormValidationScenario(scenario))
67947
+ return false;
67948
+ const text = scenarioText(scenario);
67949
+ const isHappy = scenario.type === "HAPPY_PATH" || /\b(happy\s*path|successful|success)\b/.test(text);
67950
+ if (!isHappy)
67951
+ return false;
67952
+ if (/\b(sign\s*in|log\s*in|login|authenticate|sign\s*out|log\s*out)\b/.test(text) && !/\b(change|update|reset)\s+(the\s+)?password\b/.test(text)) {
67953
+ return false;
67954
+ }
67955
+ if (/\b(show|display|visible|options?|providers?|links?)\b/.test(text) && !/\b(change|update|create|save|send|register|edit|post|write|compose|delete|add|set)\b/.test(text)) {
67956
+ return false;
67957
+ }
67958
+ const mutates = /\b(change|update|create|save|send|register|sign\s*up|checkout|edit|post|write|compose|set|add|delete|remove)\b/.test(text);
67959
+ const hasObject = /\b(password|email|profile|message|post|item|record|form|settings|account|name|title|description|comment|bio|phone|address|payment|card|user|note|task|entry)\b/.test(text);
67960
+ return mutates && hasObject;
67961
+ }
67962
+ function isNavigationChromeTap(action) {
67963
+ if (action.type !== "TAP")
67964
+ return false;
67965
+ const textForAction = actionText(action);
67966
+ return /\b(go\s+back|back\s+to|←|return\s+to|cancel|close|dismiss|not\s+now|skip|maybe\s+later)\b/.test(textForAction);
67967
+ }
67968
+ function isAuthMethodChooserTap(action) {
67969
+ if (action.type !== "TAP")
67970
+ return false;
67971
+ const textForAction = actionText(action);
67972
+ if (/\b(submit|confirm|sign\s*in|log\s*in|login)\b/.test(textForAction) && !/\b(with|using|via)\b/.test(textForAction)) {
67973
+ return false;
67974
+ }
67975
+ return /\b(continue\s+with|sign\s+in\s+with|log\s+in\s+with|use\s+(email|phone)|choose|pick|select)\b/.test(textForAction);
67976
+ }
67977
+ function isCredentialSubmitTap(action) {
67978
+ if (action.type !== "TAP")
67979
+ return false;
67980
+ if (isNavigationChromeTap(action) || isAuthMethodChooserTap(action))
67981
+ return false;
67982
+ const textForAction = actionText(action);
67983
+ return /\b(sign\s*in|log\s*in|login|continue|submit|done|next|confirm)\b/.test(textForAction);
67984
+ }
67985
+ function isKeyboardFormCommit(action) {
67986
+ return action.type === "TYPE" && (action.value ?? "").endsWith("\n");
67987
+ }
67988
+ function isFormCommitAction(action) {
67989
+ if (isKeyboardFormCommit(action))
67990
+ return true;
67991
+ if (isCredentialSubmitTap(action))
67992
+ return true;
67993
+ if (action.type !== "TAP")
67994
+ return false;
67995
+ if (isNavigationChromeTap(action) || isAuthMethodChooserTap(action))
67996
+ return false;
67997
+ const textForAction = actionText(action);
67998
+ return /\b(save|create|send|confirm|next|apply|post|checkout|pay|finish|update|register|sign\s*up|done|continue|submit)\b/i.test(textForAction);
67999
+ }
67773
68000
  function actionText(action) {
67774
68001
  const bundle = action.metadata.locatorBundle;
67775
68002
  return [
@@ -67786,9 +68013,80 @@ ${statePacket}` },
67786
68013
  bundle?.xpath
67787
68014
  ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
67788
68015
  }
68016
+ function isStructuralOnlyAssert(action) {
68017
+ if (action.type !== "ASSERT_VISIBLE" && action.type !== "ASSERT_TEXT")
68018
+ return false;
68019
+ const sel = (action.selector ?? "").trim();
68020
+ if (/^\/hierarchy(\[\d+\])?(\/android\.widget\.FrameLayout\[\d+\])*$/i.test(sel))
68021
+ return true;
68022
+ if (isStructuralAssertionIdentity(sel) || isStructuralAssertionIdentity(action.metadata.targetLabel)) {
68023
+ const bundle = action.metadata.locatorBundle;
68024
+ const human = Boolean(nonEmpty2(action.innerText) || nonEmpty2(action.metadata.targetLabel) || nonEmpty2(bundle?.text) || nonEmpty2(bundle?.contentDesc));
68025
+ if (!human)
68026
+ return true;
68027
+ }
68028
+ if (/^\/hierarchy/i.test(sel)) {
68029
+ const bundle = action.metadata.locatorBundle;
68030
+ const hasIdentity = Boolean(nonEmpty2(action.metadata.targetLabel) || nonEmpty2(action.innerText) || nonEmpty2(bundle?.text) || nonEmpty2(bundle?.contentDesc) || nonEmpty2(bundle?.accessibilityId) || nonEmpty2(bundle?.resourceId) && !isStructuralAssertionIdentity(bundle?.resourceId));
68031
+ if (!hasIdentity)
68032
+ return true;
68033
+ }
68034
+ return false;
68035
+ }
68036
+ function collapseConsecutiveBacks(actions) {
68037
+ if (actions.length <= 1)
68038
+ return actions;
68039
+ const out = [actions[0]];
68040
+ for (let i = 1; i < actions.length; i++) {
68041
+ if (actions[i].type === "BACK" && out[out.length - 1].type === "BACK")
68042
+ continue;
68043
+ out.push(actions[i]);
68044
+ }
68045
+ return out;
68046
+ }
67789
68047
  function scenarioIntentMismatchReason(scenario, actions) {
67790
68048
  const text = scenarioText(scenario);
67791
- const isCredentialSignIn = /\b(sign\s*in|log\s*in|login|authenticate|auth)\b/.test(text) && /\b(email|e-mail|password|credential|valid credentials?)\b/.test(text) && !/\b(reset|forgot|recover|logout|log\s*out|sign\s*out)\b/.test(text);
68049
+ if (isInspectionOnlyScenario(scenario, actions)) {
68050
+ return null;
68051
+ }
68052
+ if (isNavigationFocusedScenario(scenario)) {
68053
+ return null;
68054
+ }
68055
+ if (isFormValidationScenario(scenario)) {
68056
+ const typedFields = actions.some((a) => a.type === "TYPE");
68057
+ const hasCommit = actions.some((a) => isFormCommitAction(a));
68058
+ const hasValidationAssert = actions.some((a) => {
68059
+ if (a.type !== "ASSERT_VISIBLE" && a.type !== "ASSERT_TEXT")
68060
+ return false;
68061
+ return /\b(error|required|invalid|empty|missing|incorrect|wrong|try again|mismatch|does not match|must match)\b/.test(actionText(a));
68062
+ });
68063
+ const emptyFieldIntent = /\b(empty|required|missing|blank)\b/.test(text);
68064
+ const invalidValueIntent = /\b(wrong|incorrect|invalid|mismatch|reject)\b/.test(text);
68065
+ if (emptyFieldIntent && !invalidValueIntent) {
68066
+ if (!hasCommit && !hasValidationAssert) {
68067
+ return "validation scenario did not record a form commit or validation outcome";
68068
+ }
68069
+ return null;
68070
+ }
68071
+ if (typedFields && !hasCommit) {
68072
+ return "validation scenario filled fields but never committed the form";
68073
+ }
68074
+ if (!hasCommit && !hasValidationAssert) {
68075
+ return "validation scenario did not record a form commit or validation outcome";
68076
+ }
68077
+ return null;
68078
+ }
68079
+ if (isFormMutationHappyPath(scenario)) {
68080
+ const hasType = actions.some((a) => a.type === "TYPE");
68081
+ const hasCommit = actions.some((a) => isFormCommitAction(a));
68082
+ if (!hasType) {
68083
+ return "form-mutation happy path did not record any field entry";
68084
+ }
68085
+ if (!hasCommit) {
68086
+ return "form-mutation happy path did not record a form commit";
68087
+ }
68088
+ }
68089
+ const isCredentialSignIn = /\b(sign\s*in|log\s*in|login|authenticate|auth)\b/.test(text) && /\b(email|e-mail|password|credential|valid credentials?)\b/.test(text) && !/\b(reset|forgot|recover|logout|log\s*out|sign\s*out|change\s+password|update\s+password)\b/.test(text);
67792
68090
  const typedEmailIndexes = [];
67793
68091
  const typedPasswordIndexes = [];
67794
68092
  actions.forEach((action, index) => {
@@ -67805,19 +68103,19 @@ ${statePacket}` },
67805
68103
  const lastCredentialIndex = Math.max(...typedEmailIndexes, ...typedPasswordIndexes);
67806
68104
  const hasAnyCredentialEntry = Number.isFinite(lastCredentialIndex);
67807
68105
  const submitAfterCredentials = actions.some((action, index) => {
67808
- if (action.type !== "TAP" || index <= lastCredentialIndex)
68106
+ if (index < lastCredentialIndex)
67809
68107
  return false;
67810
- const textForAction = actionText(action);
67811
- if (!/\b(sign\s*in|log\s*in|login|continue|submit)\b/.test(textForAction))
68108
+ if (!isFormCommitAction(action))
67812
68109
  return false;
67813
- return !/\b(with\s+(email|e-mail|google|facebook|apple)|email\s+form|showemailformbtn)\b/i.test(textForAction);
68110
+ if (action.type === "TYPE" && index === lastCredentialIndex)
68111
+ return true;
68112
+ return index > lastCredentialIndex;
67814
68113
  });
67815
68114
  const isAuthLikeScenario = /\b(sign(?:ed)?\s*in|log(?:ged)?\s*in|login|authenticate|auth|credential|email|e-mail|password)\b/.test(text);
67816
68115
  if (isAuthLikeScenario && hasAnyCredentialEntry && !submitAfterCredentials) {
67817
- return "credential fields were filled but no sign-in submit tap was recorded after them";
68116
+ return "credential fields were filled but no form commit was recorded after them";
67818
68117
  }
67819
68118
  const uiOnlyCredentialScenario = /\b(mask|masked|visibility|show password|hide password|toggle|keyboard|keypad|max(?:imum)?\s*length|field focus|autofill)\b/.test(text);
67820
- const emptyFieldValidationScenario = /\b(empty|required|missing|blank)\b/.test(text);
67821
68119
  const sessionLifecycleScenario = /\b(logout|log\s*out|sign\s*out|session persistence|remain(?:s)? signed in|remember me)\b/.test(text);
67822
68120
  const loginTapWithoutCredentialPath = actions.some((action) => {
67823
68121
  if (action.type !== "TAP")
@@ -67825,29 +68123,70 @@ ${statePacket}` },
67825
68123
  const textForAction = actionText(action);
67826
68124
  if (/\b(logout|log\s*out|sign\s*out)\b/.test(textForAction))
67827
68125
  return false;
68126
+ if (isAuthMethodChooserTap(action) || isNavigationChromeTap(action))
68127
+ return false;
67828
68128
  return /\b(sign\s*in|log\s*in|login)\b/.test(textForAction);
67829
68129
  });
67830
- if (loginTapWithoutCredentialPath && !hasAnyCredentialEntry && !uiOnlyCredentialScenario && !emptyFieldValidationScenario && (sessionLifecycleScenario || /\b(valid credentials?|happy path|successful sign\s*in|successful login)\b/.test(text))) {
68130
+ if (loginTapWithoutCredentialPath && !hasAnyCredentialEntry && !uiOnlyCredentialScenario && (sessionLifecycleScenario || /\b(valid credentials?|happy path|successful sign\s*in|successful login)\b/.test(text))) {
67831
68131
  return "auth/session scenario tapped a login or sign-in control without recording a credential path";
67832
68132
  }
67833
- if (!isCredentialSignIn)
68133
+ if (isCredentialSignIn) {
68134
+ const typedEmail = typedEmailIndexes.length > 0;
68135
+ const typedPassword = typedPasswordIndexes.length > 0;
68136
+ const tappedSubmit = submitAfterCredentials;
68137
+ if (typedEmail && typedPassword && tappedSubmit) {
68138
+ } else {
68139
+ const missing = [
68140
+ typedEmail ? null : "email/username entry",
68141
+ typedPassword ? null : "password entry",
68142
+ tappedSubmit ? null : "form commit (submit tap or keyboard Enter)"
68143
+ ].filter((value) => value !== null);
68144
+ return `credential sign-in scenario did not record ${missing.join(", ")}`;
68145
+ }
68146
+ }
68147
+ const isHappyPath = /\b(happy\s*path|successful|success|create|save|submit|complete|checkout|send|post)\b/.test(text) || scenario.type === "HAPPY_PATH";
68148
+ if (!isHappyPath)
67834
68149
  return null;
67835
- const typedEmail = typedEmailIndexes.length > 0;
67836
- const typedPassword = typedPasswordIndexes.length > 0;
67837
- const tappedSubmit = submitAfterCredentials;
67838
- if (typedEmail && typedPassword && tappedSubmit)
68150
+ let lastTypeIndex = -1;
68151
+ let lastAssertIndex = -1;
68152
+ actions.forEach((action, index) => {
68153
+ if (action.type === "TYPE")
68154
+ lastTypeIndex = index;
68155
+ if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT")
68156
+ lastAssertIndex = index;
68157
+ });
68158
+ if (lastTypeIndex < 0 || lastAssertIndex <= lastTypeIndex)
67839
68159
  return null;
67840
- const missing = [
67841
- typedEmail ? null : "email/username entry",
67842
- typedPassword ? null : "password entry",
67843
- tappedSubmit ? null : "sign-in submit tap"
67844
- ].filter((value) => value !== null);
67845
- return `credential sign-in scenario did not record ${missing.join(", ")}`;
68160
+ const typeScreen = actions[lastTypeIndex]?.metadata?.screenName?.trim() ?? "";
68161
+ const assertScreen = actions[lastAssertIndex]?.metadata?.screenName?.trim() ?? "";
68162
+ const screensDiffer = typeScreen !== "" && assertScreen !== "" && typeScreen !== assertScreen;
68163
+ const lastTypeAction = actions[lastTypeIndex];
68164
+ const keyboardCommitOnLastType = lastTypeAction != null && isKeyboardFormCommit(lastTypeAction);
68165
+ const commitAfterType = keyboardCommitOnLastType || actions.some((action, index) => {
68166
+ if (index <= lastTypeIndex || index >= lastAssertIndex)
68167
+ return false;
68168
+ return isFormCommitAction(action);
68169
+ });
68170
+ if (screensDiffer && !commitAfterType) {
68171
+ return "form fields were filled then a success assert was recorded without a commit/submit tap between them";
68172
+ }
68173
+ return null;
67846
68174
  }
67847
68175
  function actionCycleKey(action) {
67848
68176
  return `${action.type}|${action.selector}|${action.value ?? ""}`;
67849
68177
  }
67850
68178
  var MAX_RETRY_CYCLE_LENGTH = 8;
68179
+ function dedupeConsecutiveIdenticalActions(actions) {
68180
+ if (actions.length <= 1)
68181
+ return actions;
68182
+ const out = [actions[0]];
68183
+ for (let i = 1; i < actions.length; i++) {
68184
+ if (actionCycleKey(actions[i]) === actionCycleKey(out[out.length - 1]))
68185
+ continue;
68186
+ out.push(actions[i]);
68187
+ }
68188
+ return out;
68189
+ }
67851
68190
  function collapseRetryCycles(actions) {
67852
68191
  const out = [...actions];
67853
68192
  let changed = true;
@@ -67888,15 +68227,30 @@ ${statePacket}` },
67888
68227
  }
67889
68228
  function assembleTest(args) {
67890
68229
  const { scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 } = args;
67891
- let actions = collapseRetryCycles(recording.actions);
68230
+ let actions = dedupeConsecutiveIdenticalActions(recording.actions);
67892
68231
  if (actions.length < recording.actions.length) {
67893
- log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
68232
+ log2(` deduped ${recording.actions.length - actions.length} consecutive identical step(s) (${recording.actions.length} \u2192 ${actions.length}).`);
68233
+ }
68234
+ const beforeBackCollapse = actions.length;
68235
+ actions = collapseConsecutiveBacks(actions);
68236
+ if (actions.length < beforeBackCollapse) {
68237
+ log2(` collapsed ${beforeBackCollapse - actions.length} consecutive BACK step(s) (${beforeBackCollapse} \u2192 ${actions.length}).`);
68238
+ }
68239
+ const beforeCycleCollapse = actions.length;
68240
+ actions = collapseRetryCycles(actions);
68241
+ if (actions.length < beforeCycleCollapse) {
68242
+ log2(` collapsed ${beforeCycleCollapse - actions.length} retry-cycle step(s) out of the recorded trace (${beforeCycleCollapse} \u2192 ${actions.length}).`);
67894
68243
  }
67895
68244
  const beforeContainerDrop = actions.length;
67896
68245
  actions = actions.filter((a) => !isBareContainerTap(a));
67897
68246
  if (actions.length < beforeContainerDrop) {
67898
68247
  log2(` dropped ${beforeContainerDrop - actions.length} bare-container tap step(s) (no identity \u2014 un-replayable ambient taps).`);
67899
68248
  }
68249
+ const beforeWeakAssertDrop = actions.length;
68250
+ actions = actions.filter((a) => !isStructuralOnlyAssert(a));
68251
+ if (actions.length < beforeWeakAssertDrop) {
68252
+ log2(` dropped ${beforeWeakAssertDrop - actions.length} structural-only assert step(s) (no product identity).`);
68253
+ }
67900
68254
  const intentMismatch = scenarioIntentMismatchReason(scenario, actions);
67901
68255
  if (intentMismatch) {
67902
68256
  log2(` skipped scenario "${scenario.name}": ${intentMismatch}.`);
@@ -68708,10 +69062,22 @@ For a fix, return the COMPLETE steps array (not a diff).`;
68708
69062
  if (!/\b(tap|click|press)\b/i.test(step.action))
68709
69063
  return false;
68710
69064
  const text = mobileStepSearchText(step);
68711
- if (/\b(logout|log\s*out|sign\s*out|reset|forgot|google|apple|facebook|github|sso|showemailform|with\s+email|email\s+form)\b/.test(text)) {
69065
+ if (/\b(logout|log\s*out|sign\s*out|reset|forgot|go\s+back|back\s+to|cancel|close|dismiss)\b/.test(text)) {
68712
69066
  return false;
68713
69067
  }
68714
- return /\b(sign\s*in|signin|log\s*in|login|continue|submit)\b/.test(text);
69068
+ if (/\b(continue\s+with|sign\s+in\s+with|log\s+in\s+with|use\s+(email|phone)|choose|pick|select)\b/.test(text) && !/\b(submit|confirm)\b/.test(text)) {
69069
+ return false;
69070
+ }
69071
+ return /\b(sign\s*in|signin|log\s*in|login|continue|submit|done|next|confirm)\b/.test(text);
69072
+ }
69073
+ function isKeyboardFormCommitStep(step) {
69074
+ if (!/\b(type|fill|input|enter)\b/i.test(step.action))
69075
+ return false;
69076
+ const value = typeof step.value === "string" ? step.value : "";
69077
+ return value.endsWith("\n");
69078
+ }
69079
+ function isFormCommitStep(step) {
69080
+ return isCredentialSubmitTap(step) || isKeyboardFormCommitStep(step);
68715
69081
  }
68716
69082
  function detectInvalidAuthValidationSetup(steps, error2) {
68717
69083
  if (!/\b(element not found|no such element|not visible|timeout|assert|could not be located|unexpected screen|screen drift)\b/i.test(error2 ?? "")) {
@@ -68736,20 +69102,23 @@ For a fix, return the COMPLETE steps array (not a diff).`;
68736
69102
  if ((step.order ?? -1) >= lastCredentialEditOrder)
68737
69103
  return false;
68738
69104
  const text = mobileStepSearchText(step);
68739
- return /\b(showemailformbtn|with\s+email|email\s+form|sign in with email|loginform|login form|email|e-mail|username|user\s*name)\b/.test(text);
69105
+ return /\b(sign\s+in\s+with\s+email|log\s+in\s+with\s+email|email\s+and\s+password|login\s+form|credential\s+form|email|e-mail|username|user\s*name)\b/.test(text);
68740
69106
  });
68741
69107
  if (hasCredentialFormEvidence)
68742
69108
  return null;
68743
69109
  return "credential-control test edits a credential field without recording a path to the email/password form; stale auth-control test is invalid until generation records a complete form path";
68744
69110
  }
68745
- const hasSubmitAfterCredentialEdit = steps.some((step) => (step.order ?? -1) > lastCredentialEditOrder && isCredentialSubmitTap(step));
69111
+ const hasSubmitAfterCredentialEdit = steps.some((step) => (step.order ?? -1) > lastCredentialEditOrder && isFormCommitStep(step));
68746
69112
  if (hasSubmitAfterCredentialEdit)
68747
69113
  return null;
69114
+ const hasKeyboardCommitOnCredential = steps.some((step) => (step.order ?? -1) === lastCredentialEditOrder && isKeyboardFormCommitStep(step));
69115
+ if (hasKeyboardCommitOnCredential)
69116
+ return null;
68748
69117
  const hasValidationAssertion = steps.some((step) => {
68749
69118
  if (!/\b(assert|wait)\b/i.test(step.action))
68750
69119
  return false;
68751
69120
  const text = mobileStepSearchText(step);
68752
- return /\b(invalid|required|error|empty|missing|incorrect|wrong|try again|validation|must enter|enter (an )?(email|password)|email is required|password is required)\b/.test(text);
69121
+ return /\b(invalid|required|error|empty|missing|incorrect|wrong|try again|validation|must enter|enter (an )?(email|password)|email is required|password is required|mismatch|does not match)\b/.test(text);
68753
69122
  });
68754
69123
  if (hasValidationAssertion)
68755
69124
  return null;
@@ -68777,8 +69146,21 @@ For a fix, return the COMPLETE steps array (not a diff).`;
68777
69146
  function coerceSteps(value) {
68778
69147
  if (!Array.isArray(value) || value.length === 0)
68779
69148
  return void 0;
68780
- const allValid = value.every((step) => isRecord(step) && typeof step.action === "string" && typeof step.order === "number");
68781
- return allValid ? value : void 0;
69149
+ const steps = [];
69150
+ for (let i = 0; i < value.length; i++) {
69151
+ const step = value[i];
69152
+ if (!isRecord(step) || typeof step.action !== "string" || !step.action.trim()) {
69153
+ return void 0;
69154
+ }
69155
+ const orderRaw = step.order;
69156
+ const order = typeof orderRaw === "number" && Number.isFinite(orderRaw) ? orderRaw : typeof orderRaw === "string" && orderRaw.trim() !== "" && Number.isFinite(Number(orderRaw)) ? Number(orderRaw) : i;
69157
+ steps.push({
69158
+ ...step,
69159
+ order,
69160
+ action: step.action
69161
+ });
69162
+ }
69163
+ return steps.map((step, order) => ({ ...step, order }));
68782
69164
  }
68783
69165
  function parseHealVerdict(content) {
68784
69166
  const cleaned = stripCodeFences(content);
@@ -72999,6 +73381,48 @@ var require_snapshot_observation = __commonJS({
72999
73381
  var PROMOTED_FORM_FIELD_CAP = 12;
73000
73382
  var FORM_MEMBER_CONTAINER_SEGMENT_RE = /(?:^|>\s*)(?:form|group)\b/i;
73001
73383
  var DATA_GRID_CONTAINER_SEGMENT_RE = /(?:^|>\s*)(?:table|grid|rowgroup|row|gridcell|cell)\b/i;
73384
+ var DEEP_NONNAV_CAP_CENSUS = 16;
73385
+ var CENSUS_NAV_LINK_CAP = 60;
73386
+ var CENSUS_MIN_ACTION_GROUP = 3;
73387
+ var DESTRUCTIVE_ACTION_VERB_RE = /^(?:delete|remove|destroy|suspend|deactivate|disable|revoke|ban|block|archive|terminate|reject|cancel|reset|purge|wipe|unpublish|unsubscribe)$/i;
73388
+ function leadingActionVerb(label) {
73389
+ if (!label)
73390
+ return null;
73391
+ const m = label.trim().match(/[A-Za-z]+/);
73392
+ return m ? m[0].toLowerCase() : null;
73393
+ }
73394
+ function isNavLinkEntry(entry) {
73395
+ return entry.element.elementType === "LINK" && !!entry.element.triggersNavigation;
73396
+ }
73397
+ function isRowRepeatedControl(element) {
73398
+ return !!element.enrichment?.rowContext || !!element.containerPath && DATA_GRID_CONTAINER_SEGMENT_RE.test(element.containerPath);
73399
+ }
73400
+ function rowContainerScope(containerPath) {
73401
+ if (!containerPath)
73402
+ return null;
73403
+ const m = containerPath.match(/^(.*?)\s*>\s*row\b/i);
73404
+ return m ? m[1].trim() : containerPath.trim();
73405
+ }
73406
+ function containerScopeLabel(scope) {
73407
+ if (!scope)
73408
+ return null;
73409
+ const landmarks = [...scope.matchAll(/(?:table|grid|treegrid|list|listbox|tree|rowgroup):([^>]+)/gi)];
73410
+ if (landmarks.length > 0)
73411
+ return landmarks[landmarks.length - 1][1].trim();
73412
+ const segs = scope.split(">").map((s) => s.trim()).filter(Boolean);
73413
+ return segs.length ? segs[segs.length - 1] : null;
73414
+ }
73415
+ function rowIdentityKey(element) {
73416
+ const rc = element.enrichment?.rowContext;
73417
+ if (rc)
73418
+ return `rc:${rc}`;
73419
+ const cp = element.containerPath;
73420
+ if (cp) {
73421
+ const m = cp.match(/>\s*row:(.+)$/i);
73422
+ return m ? `row:${m[1].trim()}` : `cp:${cp}`;
73423
+ }
73424
+ return element.ref ? `ref:${element.ref}` : "unknown";
73425
+ }
73002
73426
  function isGroupedFormField(element) {
73003
73427
  if (!FORM_CONTROL_ROLE_RE.test(element.role ?? ""))
73004
73428
  return false;
@@ -73014,6 +73438,7 @@ var require_snapshot_observation = __commonJS({
73014
73438
  const sourceKind = page.snapshotMeta?.sourceKind ?? page.snapshotArtifact?.sourceKind ?? "missing";
73015
73439
  const artifact = page.snapshotArtifact;
73016
73440
  const richRender = options.richRender ?? process.env.DISCOVERY_SNAPSHOT_RICH_RENDER === "true";
73441
+ const rowActionCensus = richRender && options.mode !== "survey" && (options.rowActionCensus ?? process.env.DISCOVERY_SNAPSHOT_ROW_CENSUS === "true");
73017
73442
  const activeScope = inferActiveSnapshotScope(page);
73018
73443
  const ranked = rankSnapshotElements(getScopedSnapshotElements(page, activeScope), withScopeContext({ ...context, richRank: richRender }, activeScope));
73019
73444
  const allInteractive = ranked.filter(({ element }) => isInteractiveCandidate(element)).filter(({ element }) => {
@@ -73027,10 +73452,17 @@ var require_snapshot_observation = __commonJS({
73027
73452
  });
73028
73453
  let interactive;
73029
73454
  if (options.mode === "survey") {
73030
- const navLinks = allInteractive.filter(({ element }) => element.elementType === "LINK" && element.triggersNavigation);
73031
- const others = allInteractive.filter(({ element }) => !(element.elementType === "LINK" && element.triggersNavigation));
73455
+ const navLinks = allInteractive.filter(isNavLinkEntry);
73456
+ const others = allInteractive.filter((entry) => !isNavLinkEntry(entry));
73032
73457
  const othersCap = richRender ? 6 : 4;
73033
73458
  interactive = [...navLinks, ...others.slice(0, othersCap)];
73459
+ } else if (rowActionCensus) {
73460
+ const navLinks = allInteractive.filter(isNavLinkEntry);
73461
+ const others = allInteractive.filter((entry) => !isNavLinkEntry(entry));
73462
+ interactive = [
73463
+ ...navLinks.slice(0, CENSUS_NAV_LINK_CAP),
73464
+ ...others.slice(0, options.elementLimit ?? DEEP_NONNAV_CAP_CENSUS)
73465
+ ];
73034
73466
  } else {
73035
73467
  interactive = allInteractive.slice(0, options.elementLimit ?? (richRender ? 8 : 6));
73036
73468
  }
@@ -73043,6 +73475,36 @@ var require_snapshot_observation = __commonJS({
73043
73475
  }
73044
73476
  }
73045
73477
  }
73478
+ if (rowActionCensus && interactive.length > 0) {
73479
+ const shownVerbs = new Set(interactive.filter((entry) => !isNavLinkEntry(entry) && isRowRepeatedControl(entry.element)).map(({ element }) => leadingActionVerb(qualifyElementLabel(element))).filter(Boolean));
73480
+ const bestByVerb = /* @__PURE__ */ new Map();
73481
+ for (const entry of allInteractive) {
73482
+ if (isNavLinkEntry(entry) || !isRowRepeatedControl(entry.element))
73483
+ continue;
73484
+ const verb = leadingActionVerb(qualifyElementLabel(entry.element));
73485
+ if (!verb || !DESTRUCTIVE_ACTION_VERB_RE.test(verb))
73486
+ continue;
73487
+ if (shownVerbs.has(verb))
73488
+ continue;
73489
+ if (interactive.includes(entry))
73490
+ continue;
73491
+ const existing = bestByVerb.get(verb);
73492
+ if (!existing) {
73493
+ bestByVerb.set(verb, entry);
73494
+ continue;
73495
+ }
73496
+ if (existing.element.isDisabled && !entry.element.isDisabled)
73497
+ bestByVerb.set(verb, entry);
73498
+ }
73499
+ const swapIns = [...bestByVerb.values()];
73500
+ if (swapIns.length > 0) {
73501
+ const navShown = interactive.filter(isNavLinkEntry);
73502
+ const nonNavShown = interactive.filter((entry) => !isNavLinkEntry(entry));
73503
+ const evictable = Math.min(swapIns.length, nonNavShown.length);
73504
+ const trimmed = nonNavShown.slice(0, nonNavShown.length - evictable);
73505
+ interactive = [...navShown, ...trimmed, ...swapIns.slice(0, evictable)];
73506
+ }
73507
+ }
73046
73508
  const duplicates = detectDuplicateRoleNameGroups(duplicateScanCandidates);
73047
73509
  const allForms = artifact?.formGroups ?? page.formGroups ?? [];
73048
73510
  const forms = allForms.slice(0, options.formLimit ?? 3);
@@ -73342,6 +73804,54 @@ var require_snapshot_observation = __commonJS({
73342
73804
  const hidden = allInteractive.length - interactive.length;
73343
73805
  if (hidden > 0) {
73344
73806
  lines.push(` # +${hidden} more interactive element(s) not individually listed (capped) \u2014 some may appear in the duplicates block above; if your target isn't here, narrow the scope or act on it by ref.`);
73807
+ if (rowActionCensus) {
73808
+ const shownSet = new Set(interactive.map((entry) => entry.element));
73809
+ const groups = /* @__PURE__ */ new Map();
73810
+ for (const entry of allInteractive) {
73811
+ const el = entry.element;
73812
+ if (!isRowRepeatedControl(el))
73813
+ continue;
73814
+ const verb = leadingActionVerb(qualifyElementLabel(el));
73815
+ if (!verb)
73816
+ continue;
73817
+ const role = (el.role ?? el.elementType ?? "control").toLowerCase();
73818
+ const scope = rowContainerScope(el.containerPath);
73819
+ const key = JSON.stringify([scope ?? "", role, verb]);
73820
+ let group = groups.get(key);
73821
+ if (!group) {
73822
+ group = { verb, role, scopeLabel: containerScopeLabel(scope), rows: /* @__PURE__ */ new Set(), shownRows: /* @__PURE__ */ new Set(), disabledRows: /* @__PURE__ */ new Set(), controls: 0 };
73823
+ groups.set(key, group);
73824
+ }
73825
+ const rowKey = rowIdentityKey(el);
73826
+ group.rows.add(rowKey);
73827
+ group.controls += 1;
73828
+ if (shownSet.has(el))
73829
+ group.shownRows.add(rowKey);
73830
+ if (el.isDisabled)
73831
+ group.disabledRows.add(rowKey);
73832
+ }
73833
+ const collapsed = [...groups.values()].map((g) => ({
73834
+ verb: g.verb,
73835
+ role: g.role,
73836
+ scopeLabel: g.scopeLabel,
73837
+ total: g.rows.size,
73838
+ shown: g.shownRows.size,
73839
+ disabled: g.disabledRows.size,
73840
+ controls: g.controls
73841
+ })).filter((g) => g.total >= CENSUS_MIN_ACTION_GROUP && g.total > g.shown).sort((a, b) => {
73842
+ const aDestructive = DESTRUCTIVE_ACTION_VERB_RE.test(a.verb) ? 0 : 1;
73843
+ const bDestructive = DESTRUCTIVE_ACTION_VERB_RE.test(b.verb) ? 0 : 1;
73844
+ return aDestructive - bDestructive || b.total - b.shown - (a.total - a.shown);
73845
+ });
73846
+ for (const g of collapsed) {
73847
+ const verbLabel = g.verb.charAt(0).toUpperCase() + g.verb.slice(1);
73848
+ const destructiveTag = DESTRUCTIVE_ACTION_VERB_RE.test(g.verb) ? " [destructive]" : "";
73849
+ const where = g.scopeLabel ? ` in ${yamlScalar(g.scopeLabel)}` : "";
73850
+ const controlsNote = g.controls > g.total ? ` (${g.controls} controls across ${g.total} rows)` : "";
73851
+ const disabledNote = g.disabled > 0 ? `; ${g.disabled} disabled at capture` : "";
73852
+ lines.push(` # \u21B3 "${verbLabel}" ${g.role}${destructiveTag}${where}: present on ${g.total} rows${controlsNote} \u2014 ${g.shown} listed above, ${g.total - g.shown} collapsed${disabledNote}; the same action repeats down these rows \u2014 target a specific row by its row identity (the row's Edit link / row_context above), not a bare "${verbLabel}" locator.`);
73853
+ }
73854
+ }
73345
73855
  }
73346
73856
  }
73347
73857
  }
@@ -74364,13 +74874,14 @@ var require_auth_recipe = __commonJS({
74364
74874
  "../runner-core/dist/services/auth-recipe.js"(exports) {
74365
74875
  "use strict";
74366
74876
  Object.defineProperty(exports, "__esModule", { value: true });
74367
- exports.AUTH_MARKER_VERSION = void 0;
74877
+ exports.RESERVED_RUNNER_ENV_VARS = exports.AUTH_MARKER_VERSION = void 0;
74368
74878
  exports.computeRecipeHash = computeRecipeHash;
74369
74879
  exports.buildAuthRecipe = buildAuthRecipe;
74370
74880
  exports.authProofLocator = authProofLocator;
74371
74881
  exports.urlProofPredicateExpr = urlProofPredicateExpr;
74372
74882
  exports.renderAuthProofAssertion = renderAuthProofAssertion;
74373
74883
  exports.renderAuthFn = renderAuthFn;
74884
+ exports.isReservedRunnerEnvVar = isReservedRunnerEnvVar;
74374
74885
  exports.absoluteRoute = absoluteRoute;
74375
74886
  exports.renderReplayClickArrow = renderReplayClickArrow;
74376
74887
  exports.renderReplaySettleArrow = renderReplaySettleArrow;
@@ -74489,6 +75000,17 @@ var require_auth_recipe = __commonJS({
74489
75000
  lines.push(END_MARKER);
74490
75001
  return lines.join("\n");
74491
75002
  }
75003
+ exports.RESERVED_RUNNER_ENV_VARS = /* @__PURE__ */ new Set([
75004
+ "AUTH_FILE",
75005
+ "SHARED_AUTH_INJECT",
75006
+ "VALIDATE_RUN_ID",
75007
+ "VALIDATE_TASK_ARN",
75008
+ "VALIDATE_RUNNER_AUTH",
75009
+ "VALIDATE_SERVER_URL"
75010
+ ]);
75011
+ function isReservedRunnerEnvVar(key) {
75012
+ return key.startsWith("VALIDATE_") || exports.RESERVED_RUNNER_ENV_VARS.has(key);
75013
+ }
74492
75014
  function absoluteRoute(route2, baseUrl) {
74493
75015
  try {
74494
75016
  return new URL(route2, baseUrl).href;
@@ -315593,9 +316115,15 @@ var require_mobile_discovery_agent = __commonJS({
315593
316115
  log2(`
315594
316116
  Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exploreStats.elementsFound} elements, ${explore.exploreStats.transitionsFound} transitions (${exploreElapsed}s)`);
315595
316117
  if (callbacks?.onExploreComplete) {
315596
- await callbacks.onExploreComplete(toExploreCompleteData(explore, collectedTransitions)).catch((err) => {
315597
- log2(`[stream] explore-complete callback failed: ${err instanceof Error ? err.message : String(err)}`);
315598
- });
316118
+ try {
316119
+ await callbacks.onExploreComplete(toExploreCompleteData(explore, collectedTransitions));
316120
+ } catch (err) {
316121
+ const message = checkpointErrorMessage(err);
316122
+ log2(`[stream] explore-complete callback failed: ${message}`);
316123
+ if (isTerminalDiscoveryCheckpointError(err)) {
316124
+ throw err;
316125
+ }
316126
+ }
315599
316127
  }
315600
316128
  if (!isSurvey && shouldPauseAfterTruncatedExplore(explore)) {
315601
316129
  const duration2 = (Date.now() - startTime) / 1e3;
@@ -315623,8 +316151,10 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
315623
316151
  pausePhase: "explore"
315624
316152
  };
315625
316153
  }
315626
- log2("\nRelaunching app for a clean state before planning/generation...");
315627
- await driver.relaunch();
316154
+ if (!isSurvey) {
316155
+ log2("\nRelaunching app for a clean state before planning/generation...");
316156
+ await driver.relaunch();
316157
+ }
315628
316158
  log2("\n\u2500\u2500 Phase 2: PLAN \u2500\u2500");
315629
316159
  const graph = state2.toScreenGraph();
315630
316160
  const screenMap = state2.buildInMemoryScreenMap();
@@ -315647,7 +316177,9 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
315647
316177
  hasPassword: !!ctx.variableContext?.allTestVariables?.TEST_USER_PASSWORD || !!ctx.variableContext?.publicTestVariables?.TEST_USER_PASSWORD
315648
316178
  },
315649
316179
  incremental: ctx.incremental,
315650
- testBudget
316180
+ testBudget,
316181
+ userNotes: ctx.userNotes,
316182
+ featureContext: ctx.featureContext
315651
316183
  }, {
315652
316184
  modelOverride: ctx.planModel ?? ctx.generateModel,
315653
316185
  onAICall
@@ -315820,10 +316352,20 @@ Fatal error: ${msg}`);
315820
316352
  log2(`App-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
315821
316353
  return false;
315822
316354
  }) ?? false;
315823
- if (!cleared && resetPolicy === "CLEAR_APP_DATA") {
315824
- log2("CLEAR_APP_DATA requested but app-data reset was unavailable; continuing without clearing app data.");
316355
+ if (cleared)
316356
+ return true;
316357
+ if (resetPolicy === "CLEAR_APP_DATA") {
316358
+ log2("CLEAR_APP_DATA requested but app-data reset was unavailable; a post-explore relaunch will reset state before planning.");
316359
+ }
316360
+ if (mode !== "survey") {
316361
+ return false;
315825
316362
  }
315826
- return cleared;
316363
+ try {
316364
+ await driver.relaunch();
316365
+ } catch (err) {
316366
+ log2(`App relaunch failed after unsuccessful app-data reset: ${err instanceof Error ? err.message : String(err)}`);
316367
+ }
316368
+ return false;
315827
316369
  }
315828
316370
  function shouldPauseAfterTruncatedExplore(explore) {
315829
316371
  return explore.truncated === true && (explore.truncatedReason === "rate_limit" || explore.truncatedReason === "timeout");
@@ -315870,6 +316412,7 @@ var require_discover_agent = __commonJS({
315870
316412
  var claude_discovery_runner_js_1 = require_claude_discovery_runner();
315871
316413
  var agentic_cli_constants_js_1 = require_agentic_cli_constants();
315872
316414
  var canvas_detector_js_1 = require_canvas_detector();
316415
+ var auth_recipe_js_1 = require_auth_recipe();
315873
316416
  async function runCanvasPreflight(ctx, sharedBrowser, log2) {
315874
316417
  const cached = ctx.appRenderType ?? null;
315875
316418
  const engine = (0, agentic_cli_constants_js_1.getAgenticEngine)(ctx.exploreModel);
@@ -316565,7 +317108,7 @@ Health: ${healthSummary.totalIssues} issue(s) - ${healthSummary.consoleErrors.le
316565
317108
  }
316566
317109
  if (envRefs.size > 0) {
316567
317110
  const existingKeys = new Set(ctx.variableContext.availableVariableKeys);
316568
- const missing = [...envRefs].filter((k) => !existingKeys.has(k));
317111
+ const missing = [...envRefs].filter((k) => !existingKeys.has(k) && !(0, auth_recipe_js_1.isReservedRunnerEnvVar)(k));
316569
317112
  if (missing.length > 0) {
316570
317113
  autoVariableDefaults = {};
316571
317114
  for (const key of missing) {
@@ -317081,7 +317624,7 @@ Health: ${healthSummary.totalIssues} issue(s) - ${healthSummary.consoleErrors.le
317081
317624
  envRefs.add(match[1]);
317082
317625
  if (envRefs.size > 0) {
317083
317626
  const existingKeys = new Set(ctx.variableContext.availableVariableKeys);
317084
- const missing = [...envRefs].filter((k) => !existingKeys.has(k));
317627
+ const missing = [...envRefs].filter((k) => !existingKeys.has(k) && !(0, auth_recipe_js_1.isReservedRunnerEnvVar)(k));
317085
317628
  if (missing.length > 0) {
317086
317629
  autoVariableDefaults = {};
317087
317630
  for (const key of missing) {
@@ -329963,9 +330506,9 @@ var require_dist2 = __commonJS({
329963
330506
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports2, p)) __createBinding(exports2, m, p);
329964
330507
  };
329965
330508
  Object.defineProperty(exports, "__esModule", { value: true });
329966
- exports.buildImportSystemRules = exports.buildImportKickoff = exports.buildImportContractMd = exports.MAX_ARCHIVE_BYTES = exports.isUnsafeEntryName = exports.extractZipSafely = exports.downloadArchiveCapped = exports.detectArchiveKind = exports.acquireRepoForImport = exports.runFrameworkImportOnRunner = exports.getAgenticEngine = exports.resolveRefFromSnapshot = exports.replayAuthRecipeIntoBrowser = exports.runRegisterCaptureRun = exports.runLoginCaptureRun = exports.AUTH_MARKER_VERSION = exports.buildHealAuthStamp = exports.extractAuthRegion = exports.upsertAuthBlock = exports.buildAuthRecipe = exports.commandToReplayStep = exports.buildReplayPlan = exports.absoluteRoute = exports.renderReplaySettleArrow = exports.renderReplayClickArrow = exports.renderAuthFn = exports.canonicalizeRoute = exports.executeGrokPerBatchHeal = exports.executeGrokPerTestHeal = exports.headerVal = exports.classifyAuthType = exports.isTextExcerptable = exports.isStaticAssetByExt = exports.isApiCall = exports.buildApiCallSummary = exports.computeMobileHealOutcome = exports.detectMobileStepSecretLeak = exports.redactMobileSecretsFromText = exports.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = exports.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = exports.executeMobileLiveHeal = exports.executeMobileTestWithHealing = exports.executeMobileScriptDrivenHeal = exports.runMobileGeneratePhase = exports.runMobileAIPlanner = exports.buildMobileSiteMap = exports.formatMobileEvidenceForPlanner = exports.MOBILE_SCENARIO_SYSTEM_PROMPT = exports.MOBILE_SURVEY_SYSTEM_PROMPT = exports.runMobileExplorePhase = void 0;
329967
- exports.UnsupportedRendererError = exports.buildCanvasModeAuthoringAddendum = exports.buildUnsupportedRendererMessage = exports.modelSupportsVision = exports.decideCanvasStrategy = exports.decideAppRenderType = exports.detectCanvasApp = exports.detectRateLimitBlock = exports.isSafeCanonicalRedirect = exports.resolveBaseUrlPreflight = exports.checkBaseUrlRedirect = exports.stripJsonFences = exports.scrubCredentials = exports.withAIRetry = exports.snapshotMatchingChildren = exports.findNewChromiumChildPid = exports.killProcessTree = exports.gracefulCloseWithTreeKill = exports.liveBrowserRegistry = exports.REPO_SWITCH_REMOVED_FRACTION = exports.MAX_MANIFEST_FILES = exports.toDiffSummary = exports.isLikelyTestFile = exports.sanitizeRepoRelPath = exports.sha256Hex = exports.hashFiles = exports.enumerateTestFiles = exports.buildNextManifest = exports.detectRepoSwitch = exports.classifyImportDiff = exports.parseImportManifest = exports.REPO_ANALYSIS_SCHEMA_VERSION = exports.validateSourceEndpoints = exports.validateAuthoredTest = exports.validateRepoMapFile = exports.validateRepoMemoryFile = exports.buildTestingStandardsSection = exports.buildRepoAuthorSystemRules = exports.buildRepoAuthorKickoff = exports.buildRepoAuthorContractMd = exports.buildRepoMapSystemRules = exports.buildRepoMapKickoff = exports.buildRepoMapContractMd = exports.buildRepoMemorySystemRules = exports.buildRepoMemoryKickoff = exports.buildRepoMemoryContractMd = exports.runRepoAnalysisOnRunner = exports.IMPORT_SCHEMA_VERSION = exports.validateImportResultFile = exports.validateImportedTest = void 0;
329968
- exports.UNSUPPORTED_RENDERER_PREFIX = void 0;
330509
+ exports.buildImportContractMd = exports.MAX_ARCHIVE_BYTES = exports.isUnsafeEntryName = exports.extractZipSafely = exports.downloadArchiveCapped = exports.detectArchiveKind = exports.acquireRepoForImport = exports.runFrameworkImportOnRunner = exports.getAgenticEngine = exports.resolveRefFromSnapshot = exports.replayAuthRecipeIntoBrowser = exports.runRegisterCaptureRun = exports.runLoginCaptureRun = exports.isReservedRunnerEnvVar = exports.RESERVED_RUNNER_ENV_VARS = exports.AUTH_MARKER_VERSION = exports.buildHealAuthStamp = exports.extractAuthRegion = exports.upsertAuthBlock = exports.buildAuthRecipe = exports.commandToReplayStep = exports.buildReplayPlan = exports.absoluteRoute = exports.renderReplaySettleArrow = exports.renderReplayClickArrow = exports.renderAuthFn = exports.canonicalizeRoute = exports.executeGrokPerBatchHeal = exports.executeGrokPerTestHeal = exports.headerVal = exports.classifyAuthType = exports.isTextExcerptable = exports.isStaticAssetByExt = exports.isApiCall = exports.buildApiCallSummary = exports.computeMobileHealOutcome = exports.detectMobileStepSecretLeak = exports.redactMobileSecretsFromText = exports.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = exports.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = exports.executeMobileLiveHeal = exports.executeMobileTestWithHealing = exports.executeMobileScriptDrivenHeal = exports.runMobileGeneratePhase = exports.runMobileAIPlanner = exports.buildMobileSiteMap = exports.formatMobileEvidenceForPlanner = exports.MOBILE_SCENARIO_SYSTEM_PROMPT = exports.MOBILE_SURVEY_SYSTEM_PROMPT = exports.runMobileExplorePhase = void 0;
330510
+ exports.buildUnsupportedRendererMessage = exports.modelSupportsVision = exports.decideCanvasStrategy = exports.decideAppRenderType = exports.detectCanvasApp = exports.detectRateLimitBlock = exports.isSafeCanonicalRedirect = exports.resolveBaseUrlPreflight = exports.checkBaseUrlRedirect = exports.stripJsonFences = exports.scrubCredentials = exports.withAIRetry = exports.snapshotMatchingChildren = exports.findNewChromiumChildPid = exports.killProcessTree = exports.gracefulCloseWithTreeKill = exports.liveBrowserRegistry = exports.REPO_SWITCH_REMOVED_FRACTION = exports.MAX_MANIFEST_FILES = exports.toDiffSummary = exports.isLikelyTestFile = exports.sanitizeRepoRelPath = exports.sha256Hex = exports.hashFiles = exports.enumerateTestFiles = exports.buildNextManifest = exports.detectRepoSwitch = exports.classifyImportDiff = exports.parseImportManifest = exports.REPO_ANALYSIS_SCHEMA_VERSION = exports.validateSourceEndpoints = exports.validateAuthoredTest = exports.validateRepoMapFile = exports.validateRepoMemoryFile = exports.buildTestingStandardsSection = exports.buildRepoAuthorSystemRules = exports.buildRepoAuthorKickoff = exports.buildRepoAuthorContractMd = exports.buildRepoMapSystemRules = exports.buildRepoMapKickoff = exports.buildRepoMapContractMd = exports.buildRepoMemorySystemRules = exports.buildRepoMemoryKickoff = exports.buildRepoMemoryContractMd = exports.runRepoAnalysisOnRunner = exports.IMPORT_SCHEMA_VERSION = exports.validateImportResultFile = exports.validateImportedTest = exports.buildImportSystemRules = exports.buildImportKickoff = void 0;
330511
+ exports.UNSUPPORTED_RENDERER_PREFIX = exports.UnsupportedRendererError = exports.buildCanvasModeAuthoringAddendum = void 0;
329969
330512
  __exportStar(require_ai_provider(), exports);
329970
330513
  __exportStar(require_mobile_types(), exports);
329971
330514
  __exportStar(require_mobile_source_parser(), exports);
@@ -330102,6 +330645,12 @@ var require_dist2 = __commonJS({
330102
330645
  Object.defineProperty(exports, "AUTH_MARKER_VERSION", { enumerable: true, get: function() {
330103
330646
  return auth_recipe_js_1.AUTH_MARKER_VERSION;
330104
330647
  } });
330648
+ Object.defineProperty(exports, "RESERVED_RUNNER_ENV_VARS", { enumerable: true, get: function() {
330649
+ return auth_recipe_js_1.RESERVED_RUNNER_ENV_VARS;
330650
+ } });
330651
+ Object.defineProperty(exports, "isReservedRunnerEnvVar", { enumerable: true, get: function() {
330652
+ return auth_recipe_js_1.isReservedRunnerEnvVar;
330653
+ } });
330105
330654
  var auth_capture_run_js_1 = require_auth_capture_run();
330106
330655
  Object.defineProperty(exports, "runLoginCaptureRun", { enumerable: true, get: function() {
330107
330656
  return auth_capture_run_js_1.runLoginCaptureRun;
@@ -330329,7 +330878,7 @@ var require_package4 = __commonJS({
330329
330878
  "package.json"(exports, module) {
330330
330879
  module.exports = {
330331
330880
  name: "@validate.qa/runner",
330332
- version: "1.0.32",
330881
+ version: "1.0.34",
330333
330882
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
330334
330883
  bin: {
330335
330884
  "validate-runner": "dist/cli.js"
@@ -330945,25 +331494,53 @@ async function resolveTypeTargetElementId(sessionPath, bounds) {
330945
331494
  }
330946
331495
  return elementId;
330947
331496
  }
331497
+ async function sendKeysToFocusedInput(sessionPath, value) {
331498
+ await appiumRequest(`${sessionPath}/keys`, {
331499
+ method: "POST",
331500
+ body: JSON.stringify({ text: value, value: Array.from(value) })
331501
+ });
331502
+ }
330948
331503
  async function typeText(sessionPath, value, bounds) {
330949
331504
  const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
330950
- if (!elementId) {
330951
- throw new Error("No focused element available for TYPE. Tap an input field first.");
331505
+ if (elementId) {
331506
+ try {
331507
+ await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
331508
+ method: "POST",
331509
+ body: JSON.stringify({ text: value })
331510
+ });
331511
+ return;
331512
+ } catch (err) {
331513
+ if (isAppiumDriverDeadError(err)) throw err;
331514
+ }
330952
331515
  }
330953
- await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
330954
- method: "POST",
330955
- body: JSON.stringify({ text: value })
330956
- });
331516
+ await sendKeysToFocusedInput(sessionPath, value);
330957
331517
  }
330958
331518
  async function clearField(sessionPath, bounds) {
330959
331519
  const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
330960
- if (!elementId) {
330961
- throw new Error("No focused element available for CLEAR. Tap an input field first.");
331520
+ if (elementId) {
331521
+ try {
331522
+ await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
331523
+ method: "POST",
331524
+ body: JSON.stringify({})
331525
+ });
331526
+ return;
331527
+ } catch (err) {
331528
+ if (isAppiumDriverDeadError(err)) throw err;
331529
+ }
331530
+ }
331531
+ try {
331532
+ await appiumRequest(`${sessionPath}/keys`, {
331533
+ method: "POST",
331534
+ // Common select-all + delete sequence is platform-dependent; empty keys
331535
+ // after focus is the portable baseline Appium accepts for many hybrids.
331536
+ body: JSON.stringify({ text: "", value: [] })
331537
+ });
331538
+ } catch (err) {
331539
+ if (isAppiumDriverDeadError(err)) throw err;
331540
+ throw new Error(
331541
+ `No focused element available for CLEAR. Tap an input field first.${err instanceof Error ? ` (${err.message})` : ""}`
331542
+ );
330962
331543
  }
330963
- await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
330964
- method: "POST",
330965
- body: JSON.stringify({})
330966
- });
330967
331544
  }
330968
331545
  async function swipe(sessionPath, direction, distance, windowSize) {
330969
331546
  const dist = clampMobileSwipeDistance(distance);
@@ -332204,6 +332781,10 @@ async function startMobileNetworkCapture(options) {
332204
332781
 
332205
332782
  // src/services/appium-executor.ts
332206
332783
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
332784
+ var ELEMENT_ID_PATTERN2 = /^[A-Za-z0-9._:-]{1,128}$/;
332785
+ function validateElementId(elementId) {
332786
+ return typeof elementId === "string" && ELEMENT_ID_PATTERN2.test(elementId) ? elementId : null;
332787
+ }
332207
332788
  var MOBILE_NEW_COMMAND_TIMEOUT_SECONDS = (() => {
332208
332789
  const raw = Number(process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT);
332209
332790
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 600;
@@ -332338,6 +332919,9 @@ async function createSession(target) {
332338
332919
  });
332339
332920
  const sessionId = result?.value?.sessionId ?? result?.sessionId;
332340
332921
  if (!sessionId) throw new Error("Appium session creation returned no session ID");
332922
+ if (target.platform === "ANDROID") {
332923
+ await prepareAndroidInputEnvironment(target.deviceId);
332924
+ }
332341
332925
  return sessionId;
332342
332926
  }
332343
332927
  var TERMINAL_SESSION_START = /not installed|no such (app|file)|could not confirm|bundle ?id .*(not|missing)|xcodeorgid|signing|provisioning|not authorized|unauthorized/i;
@@ -332725,7 +333309,9 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
332725
333309
  method: "POST",
332726
333310
  body: JSON.stringify({ using: strategy.using, value: strategy.value })
332727
333311
  });
332728
- const elementId = result?.value?.ELEMENT || result?.value?.["element-6066-11e4-a52e-4f735466cecf"];
333312
+ const elementId = validateElementId(
333313
+ result?.value?.ELEMENT || result?.value?.["element-6066-11e4-a52e-4f735466cecf"]
333314
+ );
332729
333315
  if (elementId) return elementId;
332730
333316
  } catch (err) {
332731
333317
  if (isTransportError(err)) throw err;
@@ -332765,24 +333351,25 @@ async function tapCoordinates(sessionId, bounds) {
332765
333351
  })
332766
333352
  });
332767
333353
  }
332768
- async function getActiveElementId2(sessionId) {
332769
- try {
332770
- const activeEl = await wdRequest(`/session/${sessionId}/element/active`);
332771
- return activeEl?.value?.ELEMENT || activeEl?.value?.["element-6066-11e4-a52e-4f735466cecf"] || null;
332772
- } catch {
332773
- return null;
332774
- }
332775
- }
332776
- async function sendKeysToFocusedInput(sessionId, value) {
332777
- await wdRequest(`/session/${sessionId}/keys`, {
332778
- method: "POST",
332779
- body: JSON.stringify({ text: value, value: Array.from(value) })
332780
- });
332781
- }
332782
333354
  function encodeAndroidInputText(value) {
332783
333355
  const inputEncoded = value.replace(/%/g, "%25").replace(/\s/g, "%s");
332784
333356
  return `'${inputEncoded.replace(/'/g, `'\\''`)}'`;
332785
333357
  }
333358
+ async function prepareAndroidInputEnvironment(deviceId) {
333359
+ if (!deviceId) return;
333360
+ const settingsToDisable = [
333361
+ ["secure", "stylus_handwriting_enabled", "0"]
333362
+ ];
333363
+ for (const [namespace, key, value] of settingsToDisable) {
333364
+ try {
333365
+ await execFileAsync2("adb", ["-s", deviceId, "shell", "settings", "put", namespace, key, value], {
333366
+ timeout: 8e3,
333367
+ windowsHide: true
333368
+ });
333369
+ } catch {
333370
+ }
333371
+ }
333372
+ }
332786
333373
  async function sendAndroidTextToFocusedInput(deviceId, value) {
332787
333374
  if (!deviceId) {
332788
333375
  throw new Error("Android keyboard fallback requires a device id");
@@ -332844,100 +333431,28 @@ async function executeType(sessionId, step, context) {
332844
333431
  if (step.value === void 0 || step.value === null || step.value === "") return;
332845
333432
  const value = String(step.value);
332846
333433
  const bounds = boundsForRun(step, context.platform, context.isCrossPlatform ?? false);
332847
- let elementId = null;
332848
- let lastInputError = null;
332849
- let tappedBounds = false;
332850
- if (bounds) {
332851
- await tapCoordinates(sessionId, bounds);
332852
- tappedBounds = true;
332853
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
332854
- elementId = await getActiveElementId2(sessionId);
332855
- if (elementId) {
332856
- lastInputError = await trySendElementValue(sessionId, elementId, value);
332857
- if (!lastInputError) return;
332858
- elementId = null;
332859
- }
332860
- }
332861
- if (step.target) {
332862
- try {
332863
- elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: context.platform });
332864
- lastInputError = await trySendElementValue(sessionId, elementId, value);
332865
- if (!lastInputError) return;
332866
- elementId = null;
332867
- } catch (error2) {
332868
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, context.platform).length > 0) throw error2;
332869
- lastInputError = error2;
332870
- }
332871
- }
332872
- if (!elementId && bounds) {
332873
- await tapCoordinates(sessionId, bounds);
332874
- tappedBounds = true;
332875
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
332876
- elementId = await getActiveElementId2(sessionId);
332877
- }
332878
- if (!elementId) {
332879
- elementId = await getActiveElementId2(sessionId);
332880
- }
332881
- if (elementId) {
332882
- lastInputError = await trySendElementValue(sessionId, elementId, value);
332883
- if (!lastInputError) return;
332884
- elementId = null;
332885
- if (!bounds) {
332886
- throw lastInputError instanceof Error ? lastInputError : new Error(String(lastInputError));
332887
- }
332888
- }
332889
- if (bounds) {
332890
- if (!tappedBounds) {
332891
- await tapCoordinates(sessionId, bounds);
332892
- tappedBounds = true;
332893
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
332894
- }
332895
- try {
332896
- await sendKeysToFocusedInput(sessionId, value);
332897
- return;
332898
- } catch (error2) {
332899
- if (isTransportError(error2)) throw error2;
332900
- lastInputError = error2;
332901
- }
332902
- }
332903
- if (context.platform === "ANDROID" && tappedBounds) {
332904
- try {
332905
- context.log?.(" Appium active-element send failed; using Android focused-keyboard input fallback");
332906
- await sendAndroidTextToFocusedInput(context.deviceId, value);
332907
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
332908
- return;
332909
- } catch (error2) {
332910
- if (isTransportError(error2)) throw error2;
332911
- lastInputError = error2;
333434
+ const sessionPath = `/session/${sessionId}`;
333435
+ try {
333436
+ await typeText(sessionPath, value, bounds);
333437
+ return;
333438
+ } catch (error2) {
333439
+ if (isTransportError(error2)) throw error2;
333440
+ if (context.platform === "ANDROID" && bounds) {
333441
+ try {
333442
+ context.log?.(" Appium active-element send failed; using Android focused-keyboard input fallback");
333443
+ await sendAndroidTextToFocusedInput(context.deviceId, value);
333444
+ await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
333445
+ return;
333446
+ } catch (androidError) {
333447
+ if (isTransportError(androidError)) throw androidError;
333448
+ }
332912
333449
  }
333450
+ throw error2 instanceof Error ? error2 : new Error(String(error2));
332913
333451
  }
332914
- throw new Error(`TYPE step could not send text to the focused target${lastInputError instanceof Error ? `: ${lastInputError.message}` : ""}`);
332915
333452
  }
332916
333453
  async function executeClear(sessionId, step, platform3, isCrossPlatform = false) {
332917
333454
  const bounds = boundsForRun(step, platform3, isCrossPlatform);
332918
- let elementId = null;
332919
- if (step.target) {
332920
- try {
332921
- elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
332922
- } catch (error2) {
332923
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
332924
- }
332925
- }
332926
- if (!elementId && bounds) {
332927
- await tapCoordinates(sessionId, bounds);
332928
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
332929
- elementId = await getActiveElementId2(sessionId);
332930
- }
332931
- if (!elementId) {
332932
- elementId = await getActiveElementId2(sessionId);
332933
- }
332934
- if (!elementId) {
332935
- throw new Error("CLEAR step could not resolve a target or focused element");
332936
- }
332937
- await wdRequest(`/session/${sessionId}/element/${elementId}/clear`, {
332938
- method: "POST",
332939
- body: "{}"
332940
- });
333455
+ await clearField(`/session/${sessionId}`, bounds);
332941
333456
  }
332942
333457
  async function executeSwipe(sessionId, step) {
332943
333458
  const direction = step.metadata?.direction || "up";