@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.js CHANGED
@@ -62285,7 +62285,7 @@ var require_mobile_explorer = __commonJS({
62285
62285
  return { screen, signal, refMap, observation };
62286
62286
  }
62287
62287
  function buildStatePacket(input) {
62288
- const { state: state2, mode, iteration, maxIterations, latestObservation, journeys, transitions } = input;
62288
+ const { state: state2, mode, iteration, maxIterations, latestObservation, journeys, transitions, featureName, featureContext, userNotes } = input;
62289
62289
  const sections = [];
62290
62290
  sections.push(`## Mobile Exploration Status (iteration ${iteration}/${maxIterations})`);
62291
62291
  sections.push(`Mode: ${mode} | Budget remaining: ${maxIterations - iteration} iterations`);
@@ -62298,10 +62298,46 @@ var require_mobile_explorer = __commonJS({
62298
62298
  sections.push("");
62299
62299
  if (mode === "survey") {
62300
62300
  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.");
62301
+ } else if (featureName?.trim()) {
62302
+ 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.`);
62301
62303
  } else {
62302
62304
  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.");
62303
62305
  }
62304
62306
  sections.push("");
62307
+ if (mode === "deep" && featureName?.trim()) {
62308
+ sections.push(`## Feature: ${featureName.trim()}`);
62309
+ if (featureContext?.plannerDescription?.trim()) {
62310
+ sections.push(featureContext.plannerDescription.trim());
62311
+ }
62312
+ if (featureContext?.priorInsight?.trim()) {
62313
+ sections.push(`Prior insight: ${featureContext.priorInsight.trim()}`);
62314
+ }
62315
+ if (featureContext?.pageIntel && featureContext.pageIntel.length > 0) {
62316
+ sections.push("### Target screens / routes for this feature");
62317
+ for (const page of featureContext.pageIntel.slice(0, 12)) {
62318
+ sections.push(`- ${page.route} (${page.type}): ${page.summary}`);
62319
+ }
62320
+ }
62321
+ if (featureContext?.flowIntel && featureContext.flowIntel.length > 0) {
62322
+ sections.push("### Known flows");
62323
+ for (const flow of featureContext.flowIntel.slice(0, 8)) {
62324
+ sections.push(`- ${flow}`);
62325
+ }
62326
+ }
62327
+ if (featureContext?.otherFeatures && featureContext.otherFeatures.length > 0) {
62328
+ sections.push("### Other features \u2014 do NOT explore these (navigation context only)");
62329
+ for (const other of featureContext.otherFeatures.slice(0, 12)) {
62330
+ const pages = other.pages?.length ? ` [${other.pages.slice(0, 4).join(", ")}]` : "";
62331
+ sections.push(`- ${other.name}${pages}`);
62332
+ }
62333
+ }
62334
+ sections.push("");
62335
+ }
62336
+ if (userNotes?.trim()) {
62337
+ sections.push(`## Operator notes
62338
+ ${userNotes.trim()}`);
62339
+ sections.push("");
62340
+ }
62305
62341
  const screenCount = state2.getScreenCount();
62306
62342
  sections.push(`## Screens Mapped: ${screenCount} | Transitions: ${transitions.length} | Journeys: ${journeys.length}`);
62307
62343
  const currentScreenId = state2.getCurrentScreenId();
@@ -62458,7 +62494,7 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
62458
62494
  } catch {
62459
62495
  }
62460
62496
  };
62461
- const mode = ctx.mode === "deep" ? "deep" : "survey";
62497
+ const mode = ctx.mode === "survey" ? "survey" : "deep";
62462
62498
  const platform3 = platformForMedium(ctx.medium);
62463
62499
  const enableRecordJourney = config?.enableRecordJourney ?? mode !== "survey";
62464
62500
  const targetAppId = ctx.target?.appId?.trim() || void 0;
@@ -62746,7 +62782,10 @@ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.nam
62746
62782
  maxIterations,
62747
62783
  latestObservation: getLatest()?.observation ?? null,
62748
62784
  journeys,
62749
- transitions: collectedTransitions
62785
+ transitions: collectedTransitions,
62786
+ featureName: ctx.featureName,
62787
+ featureContext: ctx.featureContext,
62788
+ userNotes: ctx.userNotes
62750
62789
  });
62751
62790
  const messages = [
62752
62791
  { role: "system", content: systemPrompt },
@@ -65124,7 +65163,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65124
65163
  Example - BAD happy path: \`["Navigate to /feature", "Click Create", "Verify dialog opens"]\` \u2192 only tests the trigger.
65125
65164
  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"]\`
65126
65165
  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"]\`
65127
- 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.
65166
+ 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.
65128
65167
  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.
65129
65168
  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>."
65130
65169
  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.
@@ -66084,7 +66123,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
66084
66123
  9. **No duplicates** - check existing test names before adding
66085
66124
  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.
66086
66125
  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.
66087
- 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:
66126
+ 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:
66088
66127
  - The trigger step (Tap Submit / Generate / Send / Upload / Run / Analyze / Save / Pay / etc.)
66089
66128
  - 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.
66090
66129
  - 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.
@@ -66315,6 +66354,34 @@ ${targetBits.length > 0 ? targetBits.join(" ") : "native mobile app"}`);
66315
66354
  if (context.featureName) {
66316
66355
  sections.push(`## Feature: ${context.featureName}
66317
66356
  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.`);
66357
+ if (context.featureContext?.plannerDescription?.trim()) {
66358
+ sections.push(context.featureContext.plannerDescription.trim());
66359
+ }
66360
+ if (context.featureContext?.priorInsight?.trim()) {
66361
+ sections.push(`Prior insight: ${context.featureContext.priorInsight.trim()}`);
66362
+ }
66363
+ if (context.featureContext?.pageIntel && context.featureContext.pageIntel.length > 0) {
66364
+ sections.push("### Feature page intel");
66365
+ for (const page of context.featureContext.pageIntel.slice(0, 12)) {
66366
+ sections.push(`- ${page.route} (${page.type}): ${page.summary}`);
66367
+ }
66368
+ }
66369
+ if (context.featureContext?.flowIntel && context.featureContext.flowIntel.length > 0) {
66370
+ sections.push("### Known flows");
66371
+ for (const flow of context.featureContext.flowIntel.slice(0, 8)) {
66372
+ sections.push(`- ${flow}`);
66373
+ }
66374
+ }
66375
+ if (context.featureContext?.otherFeatures && context.featureContext.otherFeatures.length > 0) {
66376
+ sections.push("### Other features (do not plan primary scenarios for these)");
66377
+ for (const other of context.featureContext.otherFeatures.slice(0, 12)) {
66378
+ sections.push(`- ${other.name}`);
66379
+ }
66380
+ }
66381
+ }
66382
+ if (context.userNotes?.trim()) {
66383
+ sections.push(`## Operator notes
66384
+ ${context.userNotes.trim()}`);
66318
66385
  }
66319
66386
  const orderedScreens = plannableTargetAppScreens(graph, context.appId).sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
66320
66387
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
@@ -66441,7 +66508,7 @@ ${constraints.join("\n")}`);
66441
66508
  routePattern: null,
66442
66509
  title: state2.screenName ?? null,
66443
66510
  pageType: state2.screenType ?? "UNKNOWN",
66444
- requiresAuth: false,
66511
+ requiresAuth: state2.screenType?.toLowerCase() === "auth",
66445
66512
  visitCount: state2.visited ? 1 : 0,
66446
66513
  lastVisitedAt: state2.visited ? now : null,
66447
66514
  elementsCovered: 0,
@@ -66461,6 +66528,8 @@ ${constraints.join("\n")}`);
66461
66528
  const toPageId = idByScreen.get(t.toScreenId);
66462
66529
  if (!fromPageId || !toPageId)
66463
66530
  continue;
66531
+ const fromScreen = graph.screens.get(t.fromScreenId);
66532
+ const toScreen = graph.screens.get(t.toScreenId);
66464
66533
  transitions.push({
66465
66534
  id: `mtx-${txSeq++}`,
66466
66535
  fromPageId,
@@ -66468,7 +66537,7 @@ ${constraints.join("\n")}`);
66468
66537
  triggerType: t.action.toUpperCase(),
66469
66538
  triggerLabel: t.viaRef ?? null,
66470
66539
  observedCount: 1,
66471
- requiresAuth: false
66540
+ requiresAuth: fromScreen?.screenType?.toLowerCase() === "auth" || toScreen?.screenType?.toLowerCase() === "auth"
66472
66541
  });
66473
66542
  }
66474
66543
  return {
@@ -66671,7 +66740,9 @@ ${constraints.join("\n")}`);
66671
66740
  recordedJourneys,
66672
66741
  credentials: input.credentials,
66673
66742
  testBudget: input.testBudget,
66674
- incremental: input.incremental
66743
+ incremental: input.incremental,
66744
+ userNotes: input.userNotes,
66745
+ featureContext: input.featureContext
66675
66746
  })
66676
66747
  });
66677
66748
  const plannerFeatureGroups = filterFeatureGroupsToSiteMap(result.featureGroups, siteMap, input.graph, input.appId);
@@ -66723,6 +66794,9 @@ var require_mobile_generator = __commonJS({
66723
66794
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports2) {
66724
66795
  "use strict";
66725
66796
  Object.defineProperty(exports2, "__esModule", { value: true });
66797
+ exports2.collapseConsecutiveBacks = collapseConsecutiveBacks;
66798
+ exports2.scenarioIntentMismatchReason = scenarioIntentMismatchReason;
66799
+ exports2.dedupeConsecutiveIdenticalActions = dedupeConsecutiveIdenticalActions;
66726
66800
  exports2.collapseRetryCycles = collapseRetryCycles;
66727
66801
  exports2.runMobileGeneratePhase = runMobileGeneratePhase;
66728
66802
  var crypto_1 = require("crypto");
@@ -66930,8 +67004,38 @@ var require_mobile_generator = __commonJS({
66930
67004
  return (labelIsEditableValue ? void 0 : label) ?? nonEmpty2(element.accessibilityId) ?? nonEmpty2(element.resourceId) ?? (editable ? void 0 : text) ?? "element";
66931
67005
  }
66932
67006
  var EDITABLE_ELEMENT_ROLE_RE = /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/;
67007
+ 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;
66933
67008
  function isEditableMobileElement(element) {
66934
- return EDITABLE_ELEMENT_ROLE_RE.test(element.role.trim());
67009
+ if (EDITABLE_ELEMENT_ROLE_RE.test(element.role.trim()))
67010
+ return true;
67011
+ const identity = [element.accessibilityId, element.resourceId, element.label].filter((v) => typeof v === "string" && v.trim().length > 0).join(" ");
67012
+ if (identity && EDITABLE_IDENTITY_RE.test(identity))
67013
+ return true;
67014
+ return false;
67015
+ }
67016
+ function isIdentityBearingTapTarget(element) {
67017
+ if (weakAssertionTargetReason(element))
67018
+ return false;
67019
+ if (isBareContainerLikeElement(element))
67020
+ return false;
67021
+ if (isEditableMobileElement(element))
67022
+ return false;
67023
+ if (/button|link|tab|menuitem|checkbox|switch|radio/i.test(element.role))
67024
+ return true;
67025
+ const label = [
67026
+ element.label,
67027
+ element.text,
67028
+ element.accessibilityId,
67029
+ element.resourceId
67030
+ ].filter((v) => typeof v === "string" && v.trim().length > 0).join(" ");
67031
+ 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);
67032
+ }
67033
+ function isBareContainerLikeElement(element) {
67034
+ if (BARE_CONTAINER_CLASS_RE.test(element.role)) {
67035
+ const hasIdentity = Boolean(nonEmpty2(element.text) || nonEmpty2(element.label) || nonEmpty2(element.accessibilityId));
67036
+ return !hasIdentity;
67037
+ }
67038
+ return false;
66935
67039
  }
66936
67040
  function nonEmpty2(value) {
66937
67041
  if (typeof value !== "string")
@@ -66963,7 +67067,7 @@ var require_mobile_generator = __commonJS({
66963
67067
  ## Your job
66964
67068
  1. mobile_snapshot the current screen.
66965
67069
  2. Execute the scenario's steps using the real UI: tap buttons, fill fields (mobile_type), submit, navigate.
66966
- 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.
67070
+ 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.
66967
67071
  4. When you reach the target screen / complete the flow, PROVE the outcome with an assert tool:
66968
67072
  - mobile_assert_visible({ref}) \u2014 the target screen's stable element is on screen.
66969
67073
  - mobile_assert_text({ref, expected}) \u2014 an element contains expected text (only durable when the element has a stable accessibility-id / resource-id).
@@ -66978,6 +67082,8 @@ var require_mobile_generator = __commonJS({
66978
67082
  - 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.
66979
67083
  - 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.
66980
67084
  - 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.
67085
+ - 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.
67086
+ - 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.
66981
67087
  - 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.
66982
67088
  - 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.
66983
67089
  - 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.
@@ -66996,7 +67102,7 @@ This app has stored login credentials under these keys:
66996
67102
  ${keyList}
66997
67103
 
66998
67104
  When a scenario needs a signed-in session, complete the FULL login sequence \u2014 filling the fields alone does NOT log you in:
66999
- 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).
67105
+ 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).
67000
67106
 
67001
67107
  SECURITY RULES:
67002
67108
  - 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.
@@ -67346,6 +67452,7 @@ ${statePacket}` },
67346
67452
  if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT")
67347
67453
  assertCount++;
67348
67454
  },
67455
+ recordedActions: actions,
67349
67456
  log: log2
67350
67457
  });
67351
67458
  pushResult(dispatchResult);
@@ -67361,7 +67468,7 @@ ${statePacket}` },
67361
67468
  return { actions, assertCount, finish, lastSnapshot: getLatest(), lastScreenName };
67362
67469
  }
67363
67470
  async function dispatchGenerateTool(args) {
67364
- const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, credentials, screenName, recordAction, log: log2 } = args;
67471
+ const { toolName, toolArgs, driver, platform: platform3, latest, absorbObservation, resolveRef, credentials, screenName, recordAction, recordedActions, log: log2 } = args;
67365
67472
  const unknownRef = (ref) => ({
67366
67473
  ok: false,
67367
67474
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
@@ -67423,12 +67530,26 @@ ${statePacket}` },
67423
67530
  };
67424
67531
  }
67425
67532
  const absorbed = await absorbObservation(result.source, result.screenSignal, latest()?.screen.windowSize ?? null, result.screenshot, "mobile_tap");
67426
- if (shouldRecordAction(result, absorbed) && changedScreen(before, absorbed)) {
67427
- recordAction({
67533
+ const screenChanged = changedScreen(before, absorbed);
67534
+ const identityTap = isIdentityBearingTapTarget(element);
67535
+ if (shouldRecordAction(result, absorbed) && (screenChanged || identityTap)) {
67536
+ const tapAction = {
67428
67537
  type: "TAP",
67429
67538
  selector: (0, mobile_source_parser_js_2.preferredLocator)(element),
67430
- metadata: metadataForElement(element, beforeScreenName, platform3)
67431
- });
67539
+ metadata: metadataForElement(element, beforeScreenName, platform3),
67540
+ innerText: null,
67541
+ url: "",
67542
+ value: null
67543
+ };
67544
+ const lastRecorded = recordedActions[recordedActions.length - 1];
67545
+ if (lastRecorded && actionCycleKey(lastRecorded) === actionCycleKey(tapAction)) {
67546
+ log2(` tap left content signature unchanged but duplicates the prior step \u2014 not recording again.`);
67547
+ } else if (!screenChanged && identityTap) {
67548
+ log2(` tap left content signature unchanged but target has identity \u2014 recording commit control "${labelForElement(element)}".`);
67549
+ recordAction(tapAction);
67550
+ } else {
67551
+ recordAction(tapAction);
67552
+ }
67432
67553
  } else if (shouldRecordAction(result, absorbed)) {
67433
67554
  log2(" tap changed nothing on screen \u2014 not recording it as a step (dead action).");
67434
67555
  }
@@ -67567,6 +67688,13 @@ ${statePacket}` },
67567
67688
  const element = resolveRef(toolArgs.ref);
67568
67689
  if (!element)
67569
67690
  return unknownRef(toolArgs.ref);
67691
+ const weakReason = weakAssertionTargetReason(element);
67692
+ if (weakReason) {
67693
+ return {
67694
+ ok: false,
67695
+ 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.`
67696
+ };
67697
+ }
67570
67698
  const expected = nonEmpty2(typeof toolArgs.expected === "string" ? toolArgs.expected : String(toolArgs.expected ?? ""));
67571
67699
  if (!expected)
67572
67700
  return { ok: false, error: "mobile_assert_text needs a non-empty `expected` value." };
@@ -67765,6 +67893,105 @@ ${statePacket}` },
67765
67893
  ...scenario.expectedOutcomes.flatMap((outcome) => [outcome.description, outcome.hint])
67766
67894
  ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
67767
67895
  }
67896
+ var INSPECTION_OUTCOME_TYPES = /* @__PURE__ */ new Set([
67897
+ "element-visible",
67898
+ "text-visible",
67899
+ "element-hidden",
67900
+ "screen-visible"
67901
+ ]);
67902
+ function isInspectionOnlyScenario(scenario, actions) {
67903
+ if (actions.length === 0 || scenario.expectedOutcomes.length === 0)
67904
+ return false;
67905
+ if (!scenario.expectedOutcomes.every((o) => INSPECTION_OUTCOME_TYPES.has(o.type))) {
67906
+ return false;
67907
+ }
67908
+ if (actions.some((a) => a.type === "TYPE" || a.type === "CLEAR")) {
67909
+ return false;
67910
+ }
67911
+ const text = scenarioText(scenario);
67912
+ 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);
67913
+ if (visibilityOnlyGoal) {
67914
+ const assertCount2 = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT").length;
67915
+ return assertCount2 > 0;
67916
+ }
67917
+ 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);
67918
+ 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);
67919
+ if (completionIntent && formIntent)
67920
+ return false;
67921
+ const assertCount = actions.filter((a) => a.type === "ASSERT_VISIBLE" || a.type === "ASSERT_TEXT").length;
67922
+ return assertCount > 0;
67923
+ }
67924
+ function isNavigationFocusedScenario(scenario) {
67925
+ const text = scenarioText(scenario);
67926
+ const navigates = /\b(back|return|go\s+home|navigate\s+back|leave|exit|cancel\s+out|dismiss|previous\s+screen)\b/.test(text);
67927
+ const completesFlow = /\b(happy\s*path|successful|complete\s+(the\s+)?(login|sign[- ]?in|registration|checkout)|with\s+valid)\b/.test(text);
67928
+ return navigates && !completesFlow;
67929
+ }
67930
+ function isFormValidationScenario(scenario) {
67931
+ if (scenario.variant === "validation")
67932
+ return true;
67933
+ if (scenario.type !== "EDGE_CASE")
67934
+ return false;
67935
+ const text = scenarioText(scenario);
67936
+ return /\b(empty|required|missing|blank|invalid|reject|error|validation|wrong|incorrect|mismatch)\b/.test(text);
67937
+ }
67938
+ function isFormMutationHappyPath(scenario) {
67939
+ if (isNavigationFocusedScenario(scenario))
67940
+ return false;
67941
+ if (isFormValidationScenario(scenario))
67942
+ return false;
67943
+ const text = scenarioText(scenario);
67944
+ const isHappy = scenario.type === "HAPPY_PATH" || /\b(happy\s*path|successful|success)\b/.test(text);
67945
+ if (!isHappy)
67946
+ return false;
67947
+ 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)) {
67948
+ return false;
67949
+ }
67950
+ 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)) {
67951
+ return false;
67952
+ }
67953
+ const mutates = /\b(change|update|create|save|send|register|sign\s*up|checkout|edit|post|write|compose|set|add|delete|remove)\b/.test(text);
67954
+ 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);
67955
+ return mutates && hasObject;
67956
+ }
67957
+ function isNavigationChromeTap(action) {
67958
+ if (action.type !== "TAP")
67959
+ return false;
67960
+ const textForAction = actionText(action);
67961
+ return /\b(go\s+back|back\s+to|←|return\s+to|cancel|close|dismiss|not\s+now|skip|maybe\s+later)\b/.test(textForAction);
67962
+ }
67963
+ function isAuthMethodChooserTap(action) {
67964
+ if (action.type !== "TAP")
67965
+ return false;
67966
+ const textForAction = actionText(action);
67967
+ if (/\b(submit|confirm|sign\s*in|log\s*in|login)\b/.test(textForAction) && !/\b(with|using|via)\b/.test(textForAction)) {
67968
+ return false;
67969
+ }
67970
+ 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);
67971
+ }
67972
+ function isCredentialSubmitTap(action) {
67973
+ if (action.type !== "TAP")
67974
+ return false;
67975
+ if (isNavigationChromeTap(action) || isAuthMethodChooserTap(action))
67976
+ return false;
67977
+ const textForAction = actionText(action);
67978
+ return /\b(sign\s*in|log\s*in|login|continue|submit|done|next|confirm)\b/.test(textForAction);
67979
+ }
67980
+ function isKeyboardFormCommit(action) {
67981
+ return action.type === "TYPE" && (action.value ?? "").endsWith("\n");
67982
+ }
67983
+ function isFormCommitAction(action) {
67984
+ if (isKeyboardFormCommit(action))
67985
+ return true;
67986
+ if (isCredentialSubmitTap(action))
67987
+ return true;
67988
+ if (action.type !== "TAP")
67989
+ return false;
67990
+ if (isNavigationChromeTap(action) || isAuthMethodChooserTap(action))
67991
+ return false;
67992
+ const textForAction = actionText(action);
67993
+ return /\b(save|create|send|confirm|next|apply|post|checkout|pay|finish|update|register|sign\s*up|done|continue|submit)\b/i.test(textForAction);
67994
+ }
67768
67995
  function actionText(action) {
67769
67996
  const bundle = action.metadata.locatorBundle;
67770
67997
  return [
@@ -67781,9 +68008,80 @@ ${statePacket}` },
67781
68008
  bundle?.xpath
67782
68009
  ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
67783
68010
  }
68011
+ function isStructuralOnlyAssert(action) {
68012
+ if (action.type !== "ASSERT_VISIBLE" && action.type !== "ASSERT_TEXT")
68013
+ return false;
68014
+ const sel = (action.selector ?? "").trim();
68015
+ if (/^\/hierarchy(\[\d+\])?(\/android\.widget\.FrameLayout\[\d+\])*$/i.test(sel))
68016
+ return true;
68017
+ if (isStructuralAssertionIdentity(sel) || isStructuralAssertionIdentity(action.metadata.targetLabel)) {
68018
+ const bundle = action.metadata.locatorBundle;
68019
+ const human = Boolean(nonEmpty2(action.innerText) || nonEmpty2(action.metadata.targetLabel) || nonEmpty2(bundle?.text) || nonEmpty2(bundle?.contentDesc));
68020
+ if (!human)
68021
+ return true;
68022
+ }
68023
+ if (/^\/hierarchy/i.test(sel)) {
68024
+ const bundle = action.metadata.locatorBundle;
68025
+ 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));
68026
+ if (!hasIdentity)
68027
+ return true;
68028
+ }
68029
+ return false;
68030
+ }
68031
+ function collapseConsecutiveBacks(actions) {
68032
+ if (actions.length <= 1)
68033
+ return actions;
68034
+ const out = [actions[0]];
68035
+ for (let i = 1; i < actions.length; i++) {
68036
+ if (actions[i].type === "BACK" && out[out.length - 1].type === "BACK")
68037
+ continue;
68038
+ out.push(actions[i]);
68039
+ }
68040
+ return out;
68041
+ }
67784
68042
  function scenarioIntentMismatchReason(scenario, actions) {
67785
68043
  const text = scenarioText(scenario);
67786
- 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);
68044
+ if (isInspectionOnlyScenario(scenario, actions)) {
68045
+ return null;
68046
+ }
68047
+ if (isNavigationFocusedScenario(scenario)) {
68048
+ return null;
68049
+ }
68050
+ if (isFormValidationScenario(scenario)) {
68051
+ const typedFields = actions.some((a) => a.type === "TYPE");
68052
+ const hasCommit = actions.some((a) => isFormCommitAction(a));
68053
+ const hasValidationAssert = actions.some((a) => {
68054
+ if (a.type !== "ASSERT_VISIBLE" && a.type !== "ASSERT_TEXT")
68055
+ return false;
68056
+ return /\b(error|required|invalid|empty|missing|incorrect|wrong|try again|mismatch|does not match|must match)\b/.test(actionText(a));
68057
+ });
68058
+ const emptyFieldIntent = /\b(empty|required|missing|blank)\b/.test(text);
68059
+ const invalidValueIntent = /\b(wrong|incorrect|invalid|mismatch|reject)\b/.test(text);
68060
+ if (emptyFieldIntent && !invalidValueIntent) {
68061
+ if (!hasCommit && !hasValidationAssert) {
68062
+ return "validation scenario did not record a form commit or validation outcome";
68063
+ }
68064
+ return null;
68065
+ }
68066
+ if (typedFields && !hasCommit) {
68067
+ return "validation scenario filled fields but never committed the form";
68068
+ }
68069
+ if (!hasCommit && !hasValidationAssert) {
68070
+ return "validation scenario did not record a form commit or validation outcome";
68071
+ }
68072
+ return null;
68073
+ }
68074
+ if (isFormMutationHappyPath(scenario)) {
68075
+ const hasType = actions.some((a) => a.type === "TYPE");
68076
+ const hasCommit = actions.some((a) => isFormCommitAction(a));
68077
+ if (!hasType) {
68078
+ return "form-mutation happy path did not record any field entry";
68079
+ }
68080
+ if (!hasCommit) {
68081
+ return "form-mutation happy path did not record a form commit";
68082
+ }
68083
+ }
68084
+ 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);
67787
68085
  const typedEmailIndexes = [];
67788
68086
  const typedPasswordIndexes = [];
67789
68087
  actions.forEach((action, index) => {
@@ -67800,19 +68098,19 @@ ${statePacket}` },
67800
68098
  const lastCredentialIndex = Math.max(...typedEmailIndexes, ...typedPasswordIndexes);
67801
68099
  const hasAnyCredentialEntry = Number.isFinite(lastCredentialIndex);
67802
68100
  const submitAfterCredentials = actions.some((action, index) => {
67803
- if (action.type !== "TAP" || index <= lastCredentialIndex)
68101
+ if (index < lastCredentialIndex)
67804
68102
  return false;
67805
- const textForAction = actionText(action);
67806
- if (!/\b(sign\s*in|log\s*in|login|continue|submit)\b/.test(textForAction))
68103
+ if (!isFormCommitAction(action))
67807
68104
  return false;
67808
- return !/\b(with\s+(email|e-mail|google|facebook|apple)|email\s+form|showemailformbtn)\b/i.test(textForAction);
68105
+ if (action.type === "TYPE" && index === lastCredentialIndex)
68106
+ return true;
68107
+ return index > lastCredentialIndex;
67809
68108
  });
67810
68109
  const isAuthLikeScenario = /\b(sign(?:ed)?\s*in|log(?:ged)?\s*in|login|authenticate|auth|credential|email|e-mail|password)\b/.test(text);
67811
68110
  if (isAuthLikeScenario && hasAnyCredentialEntry && !submitAfterCredentials) {
67812
- return "credential fields were filled but no sign-in submit tap was recorded after them";
68111
+ return "credential fields were filled but no form commit was recorded after them";
67813
68112
  }
67814
68113
  const uiOnlyCredentialScenario = /\b(mask|masked|visibility|show password|hide password|toggle|keyboard|keypad|max(?:imum)?\s*length|field focus|autofill)\b/.test(text);
67815
- const emptyFieldValidationScenario = /\b(empty|required|missing|blank)\b/.test(text);
67816
68114
  const sessionLifecycleScenario = /\b(logout|log\s*out|sign\s*out|session persistence|remain(?:s)? signed in|remember me)\b/.test(text);
67817
68115
  const loginTapWithoutCredentialPath = actions.some((action) => {
67818
68116
  if (action.type !== "TAP")
@@ -67820,29 +68118,70 @@ ${statePacket}` },
67820
68118
  const textForAction = actionText(action);
67821
68119
  if (/\b(logout|log\s*out|sign\s*out)\b/.test(textForAction))
67822
68120
  return false;
68121
+ if (isAuthMethodChooserTap(action) || isNavigationChromeTap(action))
68122
+ return false;
67823
68123
  return /\b(sign\s*in|log\s*in|login)\b/.test(textForAction);
67824
68124
  });
67825
- if (loginTapWithoutCredentialPath && !hasAnyCredentialEntry && !uiOnlyCredentialScenario && !emptyFieldValidationScenario && (sessionLifecycleScenario || /\b(valid credentials?|happy path|successful sign\s*in|successful login)\b/.test(text))) {
68125
+ if (loginTapWithoutCredentialPath && !hasAnyCredentialEntry && !uiOnlyCredentialScenario && (sessionLifecycleScenario || /\b(valid credentials?|happy path|successful sign\s*in|successful login)\b/.test(text))) {
67826
68126
  return "auth/session scenario tapped a login or sign-in control without recording a credential path";
67827
68127
  }
67828
- if (!isCredentialSignIn)
68128
+ if (isCredentialSignIn) {
68129
+ const typedEmail = typedEmailIndexes.length > 0;
68130
+ const typedPassword = typedPasswordIndexes.length > 0;
68131
+ const tappedSubmit = submitAfterCredentials;
68132
+ if (typedEmail && typedPassword && tappedSubmit) {
68133
+ } else {
68134
+ const missing = [
68135
+ typedEmail ? null : "email/username entry",
68136
+ typedPassword ? null : "password entry",
68137
+ tappedSubmit ? null : "form commit (submit tap or keyboard Enter)"
68138
+ ].filter((value) => value !== null);
68139
+ return `credential sign-in scenario did not record ${missing.join(", ")}`;
68140
+ }
68141
+ }
68142
+ const isHappyPath = /\b(happy\s*path|successful|success|create|save|submit|complete|checkout|send|post)\b/.test(text) || scenario.type === "HAPPY_PATH";
68143
+ if (!isHappyPath)
67829
68144
  return null;
67830
- const typedEmail = typedEmailIndexes.length > 0;
67831
- const typedPassword = typedPasswordIndexes.length > 0;
67832
- const tappedSubmit = submitAfterCredentials;
67833
- if (typedEmail && typedPassword && tappedSubmit)
68145
+ let lastTypeIndex = -1;
68146
+ let lastAssertIndex = -1;
68147
+ actions.forEach((action, index) => {
68148
+ if (action.type === "TYPE")
68149
+ lastTypeIndex = index;
68150
+ if (action.type === "ASSERT_VISIBLE" || action.type === "ASSERT_TEXT")
68151
+ lastAssertIndex = index;
68152
+ });
68153
+ if (lastTypeIndex < 0 || lastAssertIndex <= lastTypeIndex)
67834
68154
  return null;
67835
- const missing = [
67836
- typedEmail ? null : "email/username entry",
67837
- typedPassword ? null : "password entry",
67838
- tappedSubmit ? null : "sign-in submit tap"
67839
- ].filter((value) => value !== null);
67840
- return `credential sign-in scenario did not record ${missing.join(", ")}`;
68155
+ const typeScreen = actions[lastTypeIndex]?.metadata?.screenName?.trim() ?? "";
68156
+ const assertScreen = actions[lastAssertIndex]?.metadata?.screenName?.trim() ?? "";
68157
+ const screensDiffer = typeScreen !== "" && assertScreen !== "" && typeScreen !== assertScreen;
68158
+ const lastTypeAction = actions[lastTypeIndex];
68159
+ const keyboardCommitOnLastType = lastTypeAction != null && isKeyboardFormCommit(lastTypeAction);
68160
+ const commitAfterType = keyboardCommitOnLastType || actions.some((action, index) => {
68161
+ if (index <= lastTypeIndex || index >= lastAssertIndex)
68162
+ return false;
68163
+ return isFormCommitAction(action);
68164
+ });
68165
+ if (screensDiffer && !commitAfterType) {
68166
+ return "form fields were filled then a success assert was recorded without a commit/submit tap between them";
68167
+ }
68168
+ return null;
67841
68169
  }
67842
68170
  function actionCycleKey(action) {
67843
68171
  return `${action.type}|${action.selector}|${action.value ?? ""}`;
67844
68172
  }
67845
68173
  var MAX_RETRY_CYCLE_LENGTH = 8;
68174
+ function dedupeConsecutiveIdenticalActions(actions) {
68175
+ if (actions.length <= 1)
68176
+ return actions;
68177
+ const out = [actions[0]];
68178
+ for (let i = 1; i < actions.length; i++) {
68179
+ if (actionCycleKey(actions[i]) === actionCycleKey(out[out.length - 1]))
68180
+ continue;
68181
+ out.push(actions[i]);
68182
+ }
68183
+ return out;
68184
+ }
67846
68185
  function collapseRetryCycles(actions) {
67847
68186
  const out = [...actions];
67848
68187
  let changed = true;
@@ -67883,15 +68222,30 @@ ${statePacket}` },
67883
68222
  }
67884
68223
  function assembleTest(args) {
67885
68224
  const { scenario, recording, ctx, platform: platform3, appDataCleared, log: log2 } = args;
67886
- let actions = collapseRetryCycles(recording.actions);
68225
+ let actions = dedupeConsecutiveIdenticalActions(recording.actions);
67887
68226
  if (actions.length < recording.actions.length) {
67888
- log2(` collapsed ${recording.actions.length - actions.length} retry-cycle step(s) out of the recorded trace (${recording.actions.length} \u2192 ${actions.length}).`);
68227
+ log2(` deduped ${recording.actions.length - actions.length} consecutive identical step(s) (${recording.actions.length} \u2192 ${actions.length}).`);
68228
+ }
68229
+ const beforeBackCollapse = actions.length;
68230
+ actions = collapseConsecutiveBacks(actions);
68231
+ if (actions.length < beforeBackCollapse) {
68232
+ log2(` collapsed ${beforeBackCollapse - actions.length} consecutive BACK step(s) (${beforeBackCollapse} \u2192 ${actions.length}).`);
68233
+ }
68234
+ const beforeCycleCollapse = actions.length;
68235
+ actions = collapseRetryCycles(actions);
68236
+ if (actions.length < beforeCycleCollapse) {
68237
+ log2(` collapsed ${beforeCycleCollapse - actions.length} retry-cycle step(s) out of the recorded trace (${beforeCycleCollapse} \u2192 ${actions.length}).`);
67889
68238
  }
67890
68239
  const beforeContainerDrop = actions.length;
67891
68240
  actions = actions.filter((a) => !isBareContainerTap(a));
67892
68241
  if (actions.length < beforeContainerDrop) {
67893
68242
  log2(` dropped ${beforeContainerDrop - actions.length} bare-container tap step(s) (no identity \u2014 un-replayable ambient taps).`);
67894
68243
  }
68244
+ const beforeWeakAssertDrop = actions.length;
68245
+ actions = actions.filter((a) => !isStructuralOnlyAssert(a));
68246
+ if (actions.length < beforeWeakAssertDrop) {
68247
+ log2(` dropped ${beforeWeakAssertDrop - actions.length} structural-only assert step(s) (no product identity).`);
68248
+ }
67895
68249
  const intentMismatch = scenarioIntentMismatchReason(scenario, actions);
67896
68250
  if (intentMismatch) {
67897
68251
  log2(` skipped scenario "${scenario.name}": ${intentMismatch}.`);
@@ -68703,10 +69057,22 @@ For a fix, return the COMPLETE steps array (not a diff).`;
68703
69057
  if (!/\b(tap|click|press)\b/i.test(step.action))
68704
69058
  return false;
68705
69059
  const text = mobileStepSearchText(step);
68706
- 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)) {
69060
+ if (/\b(logout|log\s*out|sign\s*out|reset|forgot|go\s+back|back\s+to|cancel|close|dismiss)\b/.test(text)) {
68707
69061
  return false;
68708
69062
  }
68709
- return /\b(sign\s*in|signin|log\s*in|login|continue|submit)\b/.test(text);
69063
+ 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)) {
69064
+ return false;
69065
+ }
69066
+ return /\b(sign\s*in|signin|log\s*in|login|continue|submit|done|next|confirm)\b/.test(text);
69067
+ }
69068
+ function isKeyboardFormCommitStep(step) {
69069
+ if (!/\b(type|fill|input|enter)\b/i.test(step.action))
69070
+ return false;
69071
+ const value = typeof step.value === "string" ? step.value : "";
69072
+ return value.endsWith("\n");
69073
+ }
69074
+ function isFormCommitStep(step) {
69075
+ return isCredentialSubmitTap(step) || isKeyboardFormCommitStep(step);
68710
69076
  }
68711
69077
  function detectInvalidAuthValidationSetup(steps, error2) {
68712
69078
  if (!/\b(element not found|no such element|not visible|timeout|assert|could not be located|unexpected screen|screen drift)\b/i.test(error2 ?? "")) {
@@ -68731,20 +69097,23 @@ For a fix, return the COMPLETE steps array (not a diff).`;
68731
69097
  if ((step.order ?? -1) >= lastCredentialEditOrder)
68732
69098
  return false;
68733
69099
  const text = mobileStepSearchText(step);
68734
- 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);
69100
+ 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);
68735
69101
  });
68736
69102
  if (hasCredentialFormEvidence)
68737
69103
  return null;
68738
69104
  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";
68739
69105
  }
68740
- const hasSubmitAfterCredentialEdit = steps.some((step) => (step.order ?? -1) > lastCredentialEditOrder && isCredentialSubmitTap(step));
69106
+ const hasSubmitAfterCredentialEdit = steps.some((step) => (step.order ?? -1) > lastCredentialEditOrder && isFormCommitStep(step));
68741
69107
  if (hasSubmitAfterCredentialEdit)
68742
69108
  return null;
69109
+ const hasKeyboardCommitOnCredential = steps.some((step) => (step.order ?? -1) === lastCredentialEditOrder && isKeyboardFormCommitStep(step));
69110
+ if (hasKeyboardCommitOnCredential)
69111
+ return null;
68743
69112
  const hasValidationAssertion = steps.some((step) => {
68744
69113
  if (!/\b(assert|wait)\b/i.test(step.action))
68745
69114
  return false;
68746
69115
  const text = mobileStepSearchText(step);
68747
- 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);
69116
+ 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);
68748
69117
  });
68749
69118
  if (hasValidationAssertion)
68750
69119
  return null;
@@ -68772,8 +69141,21 @@ For a fix, return the COMPLETE steps array (not a diff).`;
68772
69141
  function coerceSteps(value) {
68773
69142
  if (!Array.isArray(value) || value.length === 0)
68774
69143
  return void 0;
68775
- const allValid = value.every((step) => isRecord(step) && typeof step.action === "string" && typeof step.order === "number");
68776
- return allValid ? value : void 0;
69144
+ const steps = [];
69145
+ for (let i = 0; i < value.length; i++) {
69146
+ const step = value[i];
69147
+ if (!isRecord(step) || typeof step.action !== "string" || !step.action.trim()) {
69148
+ return void 0;
69149
+ }
69150
+ const orderRaw = step.order;
69151
+ const order = typeof orderRaw === "number" && Number.isFinite(orderRaw) ? orderRaw : typeof orderRaw === "string" && orderRaw.trim() !== "" && Number.isFinite(Number(orderRaw)) ? Number(orderRaw) : i;
69152
+ steps.push({
69153
+ ...step,
69154
+ order,
69155
+ action: step.action
69156
+ });
69157
+ }
69158
+ return steps.map((step, order) => ({ ...step, order }));
68777
69159
  }
68778
69160
  function parseHealVerdict(content) {
68779
69161
  const cleaned = stripCodeFences(content);
@@ -72994,6 +73376,48 @@ var require_snapshot_observation = __commonJS({
72994
73376
  var PROMOTED_FORM_FIELD_CAP = 12;
72995
73377
  var FORM_MEMBER_CONTAINER_SEGMENT_RE = /(?:^|>\s*)(?:form|group)\b/i;
72996
73378
  var DATA_GRID_CONTAINER_SEGMENT_RE = /(?:^|>\s*)(?:table|grid|rowgroup|row|gridcell|cell)\b/i;
73379
+ var DEEP_NONNAV_CAP_CENSUS = 16;
73380
+ var CENSUS_NAV_LINK_CAP = 60;
73381
+ var CENSUS_MIN_ACTION_GROUP = 3;
73382
+ var DESTRUCTIVE_ACTION_VERB_RE = /^(?:delete|remove|destroy|suspend|deactivate|disable|revoke|ban|block|archive|terminate|reject|cancel|reset|purge|wipe|unpublish|unsubscribe)$/i;
73383
+ function leadingActionVerb(label) {
73384
+ if (!label)
73385
+ return null;
73386
+ const m = label.trim().match(/[A-Za-z]+/);
73387
+ return m ? m[0].toLowerCase() : null;
73388
+ }
73389
+ function isNavLinkEntry(entry) {
73390
+ return entry.element.elementType === "LINK" && !!entry.element.triggersNavigation;
73391
+ }
73392
+ function isRowRepeatedControl(element) {
73393
+ return !!element.enrichment?.rowContext || !!element.containerPath && DATA_GRID_CONTAINER_SEGMENT_RE.test(element.containerPath);
73394
+ }
73395
+ function rowContainerScope(containerPath) {
73396
+ if (!containerPath)
73397
+ return null;
73398
+ const m = containerPath.match(/^(.*?)\s*>\s*row\b/i);
73399
+ return m ? m[1].trim() : containerPath.trim();
73400
+ }
73401
+ function containerScopeLabel(scope) {
73402
+ if (!scope)
73403
+ return null;
73404
+ const landmarks = [...scope.matchAll(/(?:table|grid|treegrid|list|listbox|tree|rowgroup):([^>]+)/gi)];
73405
+ if (landmarks.length > 0)
73406
+ return landmarks[landmarks.length - 1][1].trim();
73407
+ const segs = scope.split(">").map((s) => s.trim()).filter(Boolean);
73408
+ return segs.length ? segs[segs.length - 1] : null;
73409
+ }
73410
+ function rowIdentityKey(element) {
73411
+ const rc = element.enrichment?.rowContext;
73412
+ if (rc)
73413
+ return `rc:${rc}`;
73414
+ const cp = element.containerPath;
73415
+ if (cp) {
73416
+ const m = cp.match(/>\s*row:(.+)$/i);
73417
+ return m ? `row:${m[1].trim()}` : `cp:${cp}`;
73418
+ }
73419
+ return element.ref ? `ref:${element.ref}` : "unknown";
73420
+ }
72997
73421
  function isGroupedFormField(element) {
72998
73422
  if (!FORM_CONTROL_ROLE_RE.test(element.role ?? ""))
72999
73423
  return false;
@@ -73009,6 +73433,7 @@ var require_snapshot_observation = __commonJS({
73009
73433
  const sourceKind = page.snapshotMeta?.sourceKind ?? page.snapshotArtifact?.sourceKind ?? "missing";
73010
73434
  const artifact = page.snapshotArtifact;
73011
73435
  const richRender = options.richRender ?? process.env.DISCOVERY_SNAPSHOT_RICH_RENDER === "true";
73436
+ const rowActionCensus = richRender && options.mode !== "survey" && (options.rowActionCensus ?? process.env.DISCOVERY_SNAPSHOT_ROW_CENSUS === "true");
73012
73437
  const activeScope = inferActiveSnapshotScope(page);
73013
73438
  const ranked = rankSnapshotElements(getScopedSnapshotElements(page, activeScope), withScopeContext({ ...context, richRank: richRender }, activeScope));
73014
73439
  const allInteractive = ranked.filter(({ element }) => isInteractiveCandidate(element)).filter(({ element }) => {
@@ -73022,10 +73447,17 @@ var require_snapshot_observation = __commonJS({
73022
73447
  });
73023
73448
  let interactive;
73024
73449
  if (options.mode === "survey") {
73025
- const navLinks = allInteractive.filter(({ element }) => element.elementType === "LINK" && element.triggersNavigation);
73026
- const others = allInteractive.filter(({ element }) => !(element.elementType === "LINK" && element.triggersNavigation));
73450
+ const navLinks = allInteractive.filter(isNavLinkEntry);
73451
+ const others = allInteractive.filter((entry) => !isNavLinkEntry(entry));
73027
73452
  const othersCap = richRender ? 6 : 4;
73028
73453
  interactive = [...navLinks, ...others.slice(0, othersCap)];
73454
+ } else if (rowActionCensus) {
73455
+ const navLinks = allInteractive.filter(isNavLinkEntry);
73456
+ const others = allInteractive.filter((entry) => !isNavLinkEntry(entry));
73457
+ interactive = [
73458
+ ...navLinks.slice(0, CENSUS_NAV_LINK_CAP),
73459
+ ...others.slice(0, options.elementLimit ?? DEEP_NONNAV_CAP_CENSUS)
73460
+ ];
73029
73461
  } else {
73030
73462
  interactive = allInteractive.slice(0, options.elementLimit ?? (richRender ? 8 : 6));
73031
73463
  }
@@ -73038,6 +73470,36 @@ var require_snapshot_observation = __commonJS({
73038
73470
  }
73039
73471
  }
73040
73472
  }
73473
+ if (rowActionCensus && interactive.length > 0) {
73474
+ const shownVerbs = new Set(interactive.filter((entry) => !isNavLinkEntry(entry) && isRowRepeatedControl(entry.element)).map(({ element }) => leadingActionVerb(qualifyElementLabel(element))).filter(Boolean));
73475
+ const bestByVerb = /* @__PURE__ */ new Map();
73476
+ for (const entry of allInteractive) {
73477
+ if (isNavLinkEntry(entry) || !isRowRepeatedControl(entry.element))
73478
+ continue;
73479
+ const verb = leadingActionVerb(qualifyElementLabel(entry.element));
73480
+ if (!verb || !DESTRUCTIVE_ACTION_VERB_RE.test(verb))
73481
+ continue;
73482
+ if (shownVerbs.has(verb))
73483
+ continue;
73484
+ if (interactive.includes(entry))
73485
+ continue;
73486
+ const existing = bestByVerb.get(verb);
73487
+ if (!existing) {
73488
+ bestByVerb.set(verb, entry);
73489
+ continue;
73490
+ }
73491
+ if (existing.element.isDisabled && !entry.element.isDisabled)
73492
+ bestByVerb.set(verb, entry);
73493
+ }
73494
+ const swapIns = [...bestByVerb.values()];
73495
+ if (swapIns.length > 0) {
73496
+ const navShown = interactive.filter(isNavLinkEntry);
73497
+ const nonNavShown = interactive.filter((entry) => !isNavLinkEntry(entry));
73498
+ const evictable = Math.min(swapIns.length, nonNavShown.length);
73499
+ const trimmed = nonNavShown.slice(0, nonNavShown.length - evictable);
73500
+ interactive = [...navShown, ...trimmed, ...swapIns.slice(0, evictable)];
73501
+ }
73502
+ }
73041
73503
  const duplicates = detectDuplicateRoleNameGroups(duplicateScanCandidates);
73042
73504
  const allForms = artifact?.formGroups ?? page.formGroups ?? [];
73043
73505
  const forms = allForms.slice(0, options.formLimit ?? 3);
@@ -73337,6 +73799,54 @@ var require_snapshot_observation = __commonJS({
73337
73799
  const hidden = allInteractive.length - interactive.length;
73338
73800
  if (hidden > 0) {
73339
73801
  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.`);
73802
+ if (rowActionCensus) {
73803
+ const shownSet = new Set(interactive.map((entry) => entry.element));
73804
+ const groups = /* @__PURE__ */ new Map();
73805
+ for (const entry of allInteractive) {
73806
+ const el = entry.element;
73807
+ if (!isRowRepeatedControl(el))
73808
+ continue;
73809
+ const verb = leadingActionVerb(qualifyElementLabel(el));
73810
+ if (!verb)
73811
+ continue;
73812
+ const role = (el.role ?? el.elementType ?? "control").toLowerCase();
73813
+ const scope = rowContainerScope(el.containerPath);
73814
+ const key = JSON.stringify([scope ?? "", role, verb]);
73815
+ let group = groups.get(key);
73816
+ if (!group) {
73817
+ group = { verb, role, scopeLabel: containerScopeLabel(scope), rows: /* @__PURE__ */ new Set(), shownRows: /* @__PURE__ */ new Set(), disabledRows: /* @__PURE__ */ new Set(), controls: 0 };
73818
+ groups.set(key, group);
73819
+ }
73820
+ const rowKey = rowIdentityKey(el);
73821
+ group.rows.add(rowKey);
73822
+ group.controls += 1;
73823
+ if (shownSet.has(el))
73824
+ group.shownRows.add(rowKey);
73825
+ if (el.isDisabled)
73826
+ group.disabledRows.add(rowKey);
73827
+ }
73828
+ const collapsed = [...groups.values()].map((g) => ({
73829
+ verb: g.verb,
73830
+ role: g.role,
73831
+ scopeLabel: g.scopeLabel,
73832
+ total: g.rows.size,
73833
+ shown: g.shownRows.size,
73834
+ disabled: g.disabledRows.size,
73835
+ controls: g.controls
73836
+ })).filter((g) => g.total >= CENSUS_MIN_ACTION_GROUP && g.total > g.shown).sort((a, b) => {
73837
+ const aDestructive = DESTRUCTIVE_ACTION_VERB_RE.test(a.verb) ? 0 : 1;
73838
+ const bDestructive = DESTRUCTIVE_ACTION_VERB_RE.test(b.verb) ? 0 : 1;
73839
+ return aDestructive - bDestructive || b.total - b.shown - (a.total - a.shown);
73840
+ });
73841
+ for (const g of collapsed) {
73842
+ const verbLabel = g.verb.charAt(0).toUpperCase() + g.verb.slice(1);
73843
+ const destructiveTag = DESTRUCTIVE_ACTION_VERB_RE.test(g.verb) ? " [destructive]" : "";
73844
+ const where = g.scopeLabel ? ` in ${yamlScalar(g.scopeLabel)}` : "";
73845
+ const controlsNote = g.controls > g.total ? ` (${g.controls} controls across ${g.total} rows)` : "";
73846
+ const disabledNote = g.disabled > 0 ? `; ${g.disabled} disabled at capture` : "";
73847
+ 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.`);
73848
+ }
73849
+ }
73340
73850
  }
73341
73851
  }
73342
73852
  }
@@ -74359,13 +74869,14 @@ var require_auth_recipe = __commonJS({
74359
74869
  "../runner-core/dist/services/auth-recipe.js"(exports2) {
74360
74870
  "use strict";
74361
74871
  Object.defineProperty(exports2, "__esModule", { value: true });
74362
- exports2.AUTH_MARKER_VERSION = void 0;
74872
+ exports2.RESERVED_RUNNER_ENV_VARS = exports2.AUTH_MARKER_VERSION = void 0;
74363
74873
  exports2.computeRecipeHash = computeRecipeHash;
74364
74874
  exports2.buildAuthRecipe = buildAuthRecipe;
74365
74875
  exports2.authProofLocator = authProofLocator;
74366
74876
  exports2.urlProofPredicateExpr = urlProofPredicateExpr;
74367
74877
  exports2.renderAuthProofAssertion = renderAuthProofAssertion;
74368
74878
  exports2.renderAuthFn = renderAuthFn;
74879
+ exports2.isReservedRunnerEnvVar = isReservedRunnerEnvVar;
74369
74880
  exports2.absoluteRoute = absoluteRoute;
74370
74881
  exports2.renderReplayClickArrow = renderReplayClickArrow;
74371
74882
  exports2.renderReplaySettleArrow = renderReplaySettleArrow;
@@ -74484,6 +74995,17 @@ var require_auth_recipe = __commonJS({
74484
74995
  lines.push(END_MARKER);
74485
74996
  return lines.join("\n");
74486
74997
  }
74998
+ exports2.RESERVED_RUNNER_ENV_VARS = /* @__PURE__ */ new Set([
74999
+ "AUTH_FILE",
75000
+ "SHARED_AUTH_INJECT",
75001
+ "VALIDATE_RUN_ID",
75002
+ "VALIDATE_TASK_ARN",
75003
+ "VALIDATE_RUNNER_AUTH",
75004
+ "VALIDATE_SERVER_URL"
75005
+ ]);
75006
+ function isReservedRunnerEnvVar(key) {
75007
+ return key.startsWith("VALIDATE_") || exports2.RESERVED_RUNNER_ENV_VARS.has(key);
75008
+ }
74487
75009
  function absoluteRoute(route2, baseUrl) {
74488
75010
  try {
74489
75011
  return new URL(route2, baseUrl).href;
@@ -315588,9 +316110,15 @@ var require_mobile_discovery_agent = __commonJS({
315588
316110
  log2(`
315589
316111
  Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exploreStats.elementsFound} elements, ${explore.exploreStats.transitionsFound} transitions (${exploreElapsed}s)`);
315590
316112
  if (callbacks?.onExploreComplete) {
315591
- await callbacks.onExploreComplete(toExploreCompleteData(explore, collectedTransitions)).catch((err) => {
315592
- log2(`[stream] explore-complete callback failed: ${err instanceof Error ? err.message : String(err)}`);
315593
- });
316113
+ try {
316114
+ await callbacks.onExploreComplete(toExploreCompleteData(explore, collectedTransitions));
316115
+ } catch (err) {
316116
+ const message = checkpointErrorMessage(err);
316117
+ log2(`[stream] explore-complete callback failed: ${message}`);
316118
+ if (isTerminalDiscoveryCheckpointError(err)) {
316119
+ throw err;
316120
+ }
316121
+ }
315594
316122
  }
315595
316123
  if (!isSurvey && shouldPauseAfterTruncatedExplore(explore)) {
315596
316124
  const duration2 = (Date.now() - startTime) / 1e3;
@@ -315618,8 +316146,10 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
315618
316146
  pausePhase: "explore"
315619
316147
  };
315620
316148
  }
315621
- log2("\nRelaunching app for a clean state before planning/generation...");
315622
- await driver.relaunch();
316149
+ if (!isSurvey) {
316150
+ log2("\nRelaunching app for a clean state before planning/generation...");
316151
+ await driver.relaunch();
316152
+ }
315623
316153
  log2("\n\u2500\u2500 Phase 2: PLAN \u2500\u2500");
315624
316154
  const graph = state2.toScreenGraph();
315625
316155
  const screenMap = state2.buildInMemoryScreenMap();
@@ -315642,7 +316172,9 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
315642
316172
  hasPassword: !!ctx.variableContext?.allTestVariables?.TEST_USER_PASSWORD || !!ctx.variableContext?.publicTestVariables?.TEST_USER_PASSWORD
315643
316173
  },
315644
316174
  incremental: ctx.incremental,
315645
- testBudget
316175
+ testBudget,
316176
+ userNotes: ctx.userNotes,
316177
+ featureContext: ctx.featureContext
315646
316178
  }, {
315647
316179
  modelOverride: ctx.planModel ?? ctx.generateModel,
315648
316180
  onAICall
@@ -315815,10 +316347,20 @@ Fatal error: ${msg}`);
315815
316347
  log2(`App-data reset failed: ${err instanceof Error ? err.message : String(err)}`);
315816
316348
  return false;
315817
316349
  }) ?? false;
315818
- if (!cleared && resetPolicy === "CLEAR_APP_DATA") {
315819
- log2("CLEAR_APP_DATA requested but app-data reset was unavailable; continuing without clearing app data.");
316350
+ if (cleared)
316351
+ return true;
316352
+ if (resetPolicy === "CLEAR_APP_DATA") {
316353
+ log2("CLEAR_APP_DATA requested but app-data reset was unavailable; a post-explore relaunch will reset state before planning.");
316354
+ }
316355
+ if (mode !== "survey") {
316356
+ return false;
315820
316357
  }
315821
- return cleared;
316358
+ try {
316359
+ await driver.relaunch();
316360
+ } catch (err) {
316361
+ log2(`App relaunch failed after unsuccessful app-data reset: ${err instanceof Error ? err.message : String(err)}`);
316362
+ }
316363
+ return false;
315822
316364
  }
315823
316365
  function shouldPauseAfterTruncatedExplore(explore) {
315824
316366
  return explore.truncated === true && (explore.truncatedReason === "rate_limit" || explore.truncatedReason === "timeout");
@@ -315865,6 +316407,7 @@ var require_discover_agent = __commonJS({
315865
316407
  var claude_discovery_runner_js_1 = require_claude_discovery_runner();
315866
316408
  var agentic_cli_constants_js_1 = require_agentic_cli_constants();
315867
316409
  var canvas_detector_js_1 = require_canvas_detector();
316410
+ var auth_recipe_js_1 = require_auth_recipe();
315868
316411
  async function runCanvasPreflight(ctx, sharedBrowser, log2) {
315869
316412
  const cached = ctx.appRenderType ?? null;
315870
316413
  const engine = (0, agentic_cli_constants_js_1.getAgenticEngine)(ctx.exploreModel);
@@ -316560,7 +317103,7 @@ Health: ${healthSummary.totalIssues} issue(s) - ${healthSummary.consoleErrors.le
316560
317103
  }
316561
317104
  if (envRefs.size > 0) {
316562
317105
  const existingKeys = new Set(ctx.variableContext.availableVariableKeys);
316563
- const missing = [...envRefs].filter((k) => !existingKeys.has(k));
317106
+ const missing = [...envRefs].filter((k) => !existingKeys.has(k) && !(0, auth_recipe_js_1.isReservedRunnerEnvVar)(k));
316564
317107
  if (missing.length > 0) {
316565
317108
  autoVariableDefaults = {};
316566
317109
  for (const key of missing) {
@@ -317076,7 +317619,7 @@ Health: ${healthSummary.totalIssues} issue(s) - ${healthSummary.consoleErrors.le
317076
317619
  envRefs.add(match[1]);
317077
317620
  if (envRefs.size > 0) {
317078
317621
  const existingKeys = new Set(ctx.variableContext.availableVariableKeys);
317079
- const missing = [...envRefs].filter((k) => !existingKeys.has(k));
317622
+ const missing = [...envRefs].filter((k) => !existingKeys.has(k) && !(0, auth_recipe_js_1.isReservedRunnerEnvVar)(k));
317080
317623
  if (missing.length > 0) {
317081
317624
  autoVariableDefaults = {};
317082
317625
  for (const key of missing) {
@@ -329958,9 +330501,9 @@ var require_dist2 = __commonJS({
329958
330501
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
329959
330502
  };
329960
330503
  Object.defineProperty(exports2, "__esModule", { value: true });
329961
- exports2.buildImportSystemRules = exports2.buildImportKickoff = exports2.buildImportContractMd = exports2.MAX_ARCHIVE_BYTES = exports2.isUnsafeEntryName = exports2.extractZipSafely = exports2.downloadArchiveCapped = exports2.detectArchiveKind = exports2.acquireRepoForImport = exports2.runFrameworkImportOnRunner = exports2.getAgenticEngine = exports2.resolveRefFromSnapshot = exports2.replayAuthRecipeIntoBrowser = exports2.runRegisterCaptureRun = exports2.runLoginCaptureRun = exports2.AUTH_MARKER_VERSION = exports2.buildHealAuthStamp = exports2.extractAuthRegion = exports2.upsertAuthBlock = exports2.buildAuthRecipe = exports2.commandToReplayStep = exports2.buildReplayPlan = exports2.absoluteRoute = exports2.renderReplaySettleArrow = exports2.renderReplayClickArrow = exports2.renderAuthFn = exports2.canonicalizeRoute = exports2.executeGrokPerBatchHeal = exports2.executeGrokPerTestHeal = exports2.headerVal = exports2.classifyAuthType = exports2.isTextExcerptable = exports2.isStaticAssetByExt = exports2.isApiCall = exports2.buildApiCallSummary = exports2.computeMobileHealOutcome = exports2.detectMobileStepSecretLeak = exports2.redactMobileSecretsFromText = exports2.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = exports2.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = exports2.executeMobileLiveHeal = exports2.executeMobileTestWithHealing = exports2.executeMobileScriptDrivenHeal = exports2.runMobileGeneratePhase = exports2.runMobileAIPlanner = exports2.buildMobileSiteMap = exports2.formatMobileEvidenceForPlanner = exports2.MOBILE_SCENARIO_SYSTEM_PROMPT = exports2.MOBILE_SURVEY_SYSTEM_PROMPT = exports2.runMobileExplorePhase = void 0;
329962
- exports2.UnsupportedRendererError = exports2.buildCanvasModeAuthoringAddendum = exports2.buildUnsupportedRendererMessage = exports2.modelSupportsVision = exports2.decideCanvasStrategy = exports2.decideAppRenderType = exports2.detectCanvasApp = exports2.detectRateLimitBlock = exports2.isSafeCanonicalRedirect = exports2.resolveBaseUrlPreflight = exports2.checkBaseUrlRedirect = exports2.stripJsonFences = exports2.scrubCredentials = exports2.withAIRetry = exports2.snapshotMatchingChildren = exports2.findNewChromiumChildPid = exports2.killProcessTree = exports2.gracefulCloseWithTreeKill = exports2.liveBrowserRegistry = exports2.REPO_SWITCH_REMOVED_FRACTION = exports2.MAX_MANIFEST_FILES = exports2.toDiffSummary = exports2.isLikelyTestFile = exports2.sanitizeRepoRelPath = exports2.sha256Hex = exports2.hashFiles = exports2.enumerateTestFiles = exports2.buildNextManifest = exports2.detectRepoSwitch = exports2.classifyImportDiff = exports2.parseImportManifest = exports2.REPO_ANALYSIS_SCHEMA_VERSION = exports2.validateSourceEndpoints = exports2.validateAuthoredTest = exports2.validateRepoMapFile = exports2.validateRepoMemoryFile = exports2.buildTestingStandardsSection = exports2.buildRepoAuthorSystemRules = exports2.buildRepoAuthorKickoff = exports2.buildRepoAuthorContractMd = exports2.buildRepoMapSystemRules = exports2.buildRepoMapKickoff = exports2.buildRepoMapContractMd = exports2.buildRepoMemorySystemRules = exports2.buildRepoMemoryKickoff = exports2.buildRepoMemoryContractMd = exports2.runRepoAnalysisOnRunner = exports2.IMPORT_SCHEMA_VERSION = exports2.validateImportResultFile = exports2.validateImportedTest = void 0;
329963
- exports2.UNSUPPORTED_RENDERER_PREFIX = void 0;
330504
+ exports2.buildImportContractMd = exports2.MAX_ARCHIVE_BYTES = exports2.isUnsafeEntryName = exports2.extractZipSafely = exports2.downloadArchiveCapped = exports2.detectArchiveKind = exports2.acquireRepoForImport = exports2.runFrameworkImportOnRunner = exports2.getAgenticEngine = exports2.resolveRefFromSnapshot = exports2.replayAuthRecipeIntoBrowser = exports2.runRegisterCaptureRun = exports2.runLoginCaptureRun = exports2.isReservedRunnerEnvVar = exports2.RESERVED_RUNNER_ENV_VARS = exports2.AUTH_MARKER_VERSION = exports2.buildHealAuthStamp = exports2.extractAuthRegion = exports2.upsertAuthBlock = exports2.buildAuthRecipe = exports2.commandToReplayStep = exports2.buildReplayPlan = exports2.absoluteRoute = exports2.renderReplaySettleArrow = exports2.renderReplayClickArrow = exports2.renderAuthFn = exports2.canonicalizeRoute = exports2.executeGrokPerBatchHeal = exports2.executeGrokPerTestHeal = exports2.headerVal = exports2.classifyAuthType = exports2.isTextExcerptable = exports2.isStaticAssetByExt = exports2.isApiCall = exports2.buildApiCallSummary = exports2.computeMobileHealOutcome = exports2.detectMobileStepSecretLeak = exports2.redactMobileSecretsFromText = exports2.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = exports2.MOBILE_SCRIPT_HEAL_SYSTEM_PROMPT = exports2.executeMobileLiveHeal = exports2.executeMobileTestWithHealing = exports2.executeMobileScriptDrivenHeal = exports2.runMobileGeneratePhase = exports2.runMobileAIPlanner = exports2.buildMobileSiteMap = exports2.formatMobileEvidenceForPlanner = exports2.MOBILE_SCENARIO_SYSTEM_PROMPT = exports2.MOBILE_SURVEY_SYSTEM_PROMPT = exports2.runMobileExplorePhase = void 0;
330505
+ exports2.buildUnsupportedRendererMessage = exports2.modelSupportsVision = exports2.decideCanvasStrategy = exports2.decideAppRenderType = exports2.detectCanvasApp = exports2.detectRateLimitBlock = exports2.isSafeCanonicalRedirect = exports2.resolveBaseUrlPreflight = exports2.checkBaseUrlRedirect = exports2.stripJsonFences = exports2.scrubCredentials = exports2.withAIRetry = exports2.snapshotMatchingChildren = exports2.findNewChromiumChildPid = exports2.killProcessTree = exports2.gracefulCloseWithTreeKill = exports2.liveBrowserRegistry = exports2.REPO_SWITCH_REMOVED_FRACTION = exports2.MAX_MANIFEST_FILES = exports2.toDiffSummary = exports2.isLikelyTestFile = exports2.sanitizeRepoRelPath = exports2.sha256Hex = exports2.hashFiles = exports2.enumerateTestFiles = exports2.buildNextManifest = exports2.detectRepoSwitch = exports2.classifyImportDiff = exports2.parseImportManifest = exports2.REPO_ANALYSIS_SCHEMA_VERSION = exports2.validateSourceEndpoints = exports2.validateAuthoredTest = exports2.validateRepoMapFile = exports2.validateRepoMemoryFile = exports2.buildTestingStandardsSection = exports2.buildRepoAuthorSystemRules = exports2.buildRepoAuthorKickoff = exports2.buildRepoAuthorContractMd = exports2.buildRepoMapSystemRules = exports2.buildRepoMapKickoff = exports2.buildRepoMapContractMd = exports2.buildRepoMemorySystemRules = exports2.buildRepoMemoryKickoff = exports2.buildRepoMemoryContractMd = exports2.runRepoAnalysisOnRunner = exports2.IMPORT_SCHEMA_VERSION = exports2.validateImportResultFile = exports2.validateImportedTest = exports2.buildImportSystemRules = exports2.buildImportKickoff = void 0;
330506
+ exports2.UNSUPPORTED_RENDERER_PREFIX = exports2.UnsupportedRendererError = exports2.buildCanvasModeAuthoringAddendum = void 0;
329964
330507
  __exportStar(require_ai_provider(), exports2);
329965
330508
  __exportStar(require_mobile_types(), exports2);
329966
330509
  __exportStar(require_mobile_source_parser(), exports2);
@@ -330097,6 +330640,12 @@ var require_dist2 = __commonJS({
330097
330640
  Object.defineProperty(exports2, "AUTH_MARKER_VERSION", { enumerable: true, get: function() {
330098
330641
  return auth_recipe_js_1.AUTH_MARKER_VERSION;
330099
330642
  } });
330643
+ Object.defineProperty(exports2, "RESERVED_RUNNER_ENV_VARS", { enumerable: true, get: function() {
330644
+ return auth_recipe_js_1.RESERVED_RUNNER_ENV_VARS;
330645
+ } });
330646
+ Object.defineProperty(exports2, "isReservedRunnerEnvVar", { enumerable: true, get: function() {
330647
+ return auth_recipe_js_1.isReservedRunnerEnvVar;
330648
+ } });
330100
330649
  var auth_capture_run_js_1 = require_auth_capture_run();
330101
330650
  Object.defineProperty(exports2, "runLoginCaptureRun", { enumerable: true, get: function() {
330102
330651
  return auth_capture_run_js_1.runLoginCaptureRun;
@@ -330324,7 +330873,7 @@ var require_package4 = __commonJS({
330324
330873
  "package.json"(exports2, module2) {
330325
330874
  module2.exports = {
330326
330875
  name: "@validate.qa/runner",
330327
- version: "1.0.32",
330876
+ version: "1.0.34",
330328
330877
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
330329
330878
  bin: {
330330
330879
  "validate-runner": "dist/cli.js"
@@ -330940,25 +331489,53 @@ async function resolveTypeTargetElementId(sessionPath, bounds) {
330940
331489
  }
330941
331490
  return elementId;
330942
331491
  }
331492
+ async function sendKeysToFocusedInput(sessionPath, value) {
331493
+ await appiumRequest(`${sessionPath}/keys`, {
331494
+ method: "POST",
331495
+ body: JSON.stringify({ text: value, value: Array.from(value) })
331496
+ });
331497
+ }
330943
331498
  async function typeText(sessionPath, value, bounds) {
330944
331499
  const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
330945
- if (!elementId) {
330946
- throw new Error("No focused element available for TYPE. Tap an input field first.");
331500
+ if (elementId) {
331501
+ try {
331502
+ await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
331503
+ method: "POST",
331504
+ body: JSON.stringify({ text: value })
331505
+ });
331506
+ return;
331507
+ } catch (err) {
331508
+ if (isAppiumDriverDeadError(err)) throw err;
331509
+ }
330947
331510
  }
330948
- await appiumRequest(`${sessionPath}/element/${elementId}/value`, {
330949
- method: "POST",
330950
- body: JSON.stringify({ text: value })
330951
- });
331511
+ await sendKeysToFocusedInput(sessionPath, value);
330952
331512
  }
330953
331513
  async function clearField(sessionPath, bounds) {
330954
331514
  const elementId = await resolveTypeTargetElementId(sessionPath, bounds);
330955
- if (!elementId) {
330956
- throw new Error("No focused element available for CLEAR. Tap an input field first.");
331515
+ if (elementId) {
331516
+ try {
331517
+ await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
331518
+ method: "POST",
331519
+ body: JSON.stringify({})
331520
+ });
331521
+ return;
331522
+ } catch (err) {
331523
+ if (isAppiumDriverDeadError(err)) throw err;
331524
+ }
331525
+ }
331526
+ try {
331527
+ await appiumRequest(`${sessionPath}/keys`, {
331528
+ method: "POST",
331529
+ // Common select-all + delete sequence is platform-dependent; empty keys
331530
+ // after focus is the portable baseline Appium accepts for many hybrids.
331531
+ body: JSON.stringify({ text: "", value: [] })
331532
+ });
331533
+ } catch (err) {
331534
+ if (isAppiumDriverDeadError(err)) throw err;
331535
+ throw new Error(
331536
+ `No focused element available for CLEAR. Tap an input field first.${err instanceof Error ? ` (${err.message})` : ""}`
331537
+ );
330957
331538
  }
330958
- await appiumRequest(`${sessionPath}/element/${elementId}/clear`, {
330959
- method: "POST",
330960
- body: JSON.stringify({})
330961
- });
330962
331539
  }
330963
331540
  async function swipe(sessionPath, direction, distance, windowSize) {
330964
331541
  const dist = clampMobileSwipeDistance(distance);
@@ -332196,6 +332773,10 @@ async function startMobileNetworkCapture(options) {
332196
332773
 
332197
332774
  // src/services/appium-executor.ts
332198
332775
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
332776
+ var ELEMENT_ID_PATTERN2 = /^[A-Za-z0-9._:-]{1,128}$/;
332777
+ function validateElementId(elementId) {
332778
+ return typeof elementId === "string" && ELEMENT_ID_PATTERN2.test(elementId) ? elementId : null;
332779
+ }
332199
332780
  var MOBILE_NEW_COMMAND_TIMEOUT_SECONDS = (() => {
332200
332781
  const raw = Number(process.env.VALIDATEQA_MOBILE_NEW_COMMAND_TIMEOUT);
332201
332782
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 600;
@@ -332330,6 +332911,9 @@ async function createSession(target) {
332330
332911
  });
332331
332912
  const sessionId = result?.value?.sessionId ?? result?.sessionId;
332332
332913
  if (!sessionId) throw new Error("Appium session creation returned no session ID");
332914
+ if (target.platform === "ANDROID") {
332915
+ await prepareAndroidInputEnvironment(target.deviceId);
332916
+ }
332333
332917
  return sessionId;
332334
332918
  }
332335
332919
  var TERMINAL_SESSION_START = /not installed|no such (app|file)|could not confirm|bundle ?id .*(not|missing)|xcodeorgid|signing|provisioning|not authorized|unauthorized/i;
@@ -332717,7 +333301,9 @@ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_M
332717
333301
  method: "POST",
332718
333302
  body: JSON.stringify({ using: strategy.using, value: strategy.value })
332719
333303
  });
332720
- const elementId = result?.value?.ELEMENT || result?.value?.["element-6066-11e4-a52e-4f735466cecf"];
333304
+ const elementId = validateElementId(
333305
+ result?.value?.ELEMENT || result?.value?.["element-6066-11e4-a52e-4f735466cecf"]
333306
+ );
332721
333307
  if (elementId) return elementId;
332722
333308
  } catch (err) {
332723
333309
  if (isTransportError(err)) throw err;
@@ -332757,24 +333343,25 @@ async function tapCoordinates(sessionId, bounds) {
332757
333343
  })
332758
333344
  });
332759
333345
  }
332760
- async function getActiveElementId2(sessionId) {
332761
- try {
332762
- const activeEl = await wdRequest(`/session/${sessionId}/element/active`);
332763
- return activeEl?.value?.ELEMENT || activeEl?.value?.["element-6066-11e4-a52e-4f735466cecf"] || null;
332764
- } catch {
332765
- return null;
332766
- }
332767
- }
332768
- async function sendKeysToFocusedInput(sessionId, value) {
332769
- await wdRequest(`/session/${sessionId}/keys`, {
332770
- method: "POST",
332771
- body: JSON.stringify({ text: value, value: Array.from(value) })
332772
- });
332773
- }
332774
333346
  function encodeAndroidInputText(value) {
332775
333347
  const inputEncoded = value.replace(/%/g, "%25").replace(/\s/g, "%s");
332776
333348
  return `'${inputEncoded.replace(/'/g, `'\\''`)}'`;
332777
333349
  }
333350
+ async function prepareAndroidInputEnvironment(deviceId) {
333351
+ if (!deviceId) return;
333352
+ const settingsToDisable = [
333353
+ ["secure", "stylus_handwriting_enabled", "0"]
333354
+ ];
333355
+ for (const [namespace, key, value] of settingsToDisable) {
333356
+ try {
333357
+ await execFileAsync2("adb", ["-s", deviceId, "shell", "settings", "put", namespace, key, value], {
333358
+ timeout: 8e3,
333359
+ windowsHide: true
333360
+ });
333361
+ } catch {
333362
+ }
333363
+ }
333364
+ }
332778
333365
  async function sendAndroidTextToFocusedInput(deviceId, value) {
332779
333366
  if (!deviceId) {
332780
333367
  throw new Error("Android keyboard fallback requires a device id");
@@ -332836,100 +333423,28 @@ async function executeType(sessionId, step, context) {
332836
333423
  if (step.value === void 0 || step.value === null || step.value === "") return;
332837
333424
  const value = String(step.value);
332838
333425
  const bounds = boundsForRun(step, context.platform, context.isCrossPlatform ?? false);
332839
- let elementId = null;
332840
- let lastInputError = null;
332841
- let tappedBounds = false;
332842
- if (bounds) {
332843
- await tapCoordinates(sessionId, bounds);
332844
- tappedBounds = true;
332845
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
332846
- elementId = await getActiveElementId2(sessionId);
332847
- if (elementId) {
332848
- lastInputError = await trySendElementValue(sessionId, elementId, value);
332849
- if (!lastInputError) return;
332850
- elementId = null;
332851
- }
332852
- }
332853
- if (step.target) {
332854
- try {
332855
- elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: context.platform });
332856
- lastInputError = await trySendElementValue(sessionId, elementId, value);
332857
- if (!lastInputError) return;
332858
- elementId = null;
332859
- } catch (error2) {
332860
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, context.platform).length > 0) throw error2;
332861
- lastInputError = error2;
332862
- }
332863
- }
332864
- if (!elementId && bounds) {
332865
- await tapCoordinates(sessionId, bounds);
332866
- tappedBounds = true;
332867
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
332868
- elementId = await getActiveElementId2(sessionId);
332869
- }
332870
- if (!elementId) {
332871
- elementId = await getActiveElementId2(sessionId);
332872
- }
332873
- if (elementId) {
332874
- lastInputError = await trySendElementValue(sessionId, elementId, value);
332875
- if (!lastInputError) return;
332876
- elementId = null;
332877
- if (!bounds) {
332878
- throw lastInputError instanceof Error ? lastInputError : new Error(String(lastInputError));
332879
- }
332880
- }
332881
- if (bounds) {
332882
- if (!tappedBounds) {
332883
- await tapCoordinates(sessionId, bounds);
332884
- tappedBounds = true;
332885
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
332886
- }
332887
- try {
332888
- await sendKeysToFocusedInput(sessionId, value);
332889
- return;
332890
- } catch (error2) {
332891
- if (isTransportError(error2)) throw error2;
332892
- lastInputError = error2;
332893
- }
332894
- }
332895
- if (context.platform === "ANDROID" && tappedBounds) {
332896
- try {
332897
- context.log?.(" Appium active-element send failed; using Android focused-keyboard input fallback");
332898
- await sendAndroidTextToFocusedInput(context.deviceId, value);
332899
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
332900
- return;
332901
- } catch (error2) {
332902
- if (isTransportError(error2)) throw error2;
332903
- lastInputError = error2;
333426
+ const sessionPath = `/session/${sessionId}`;
333427
+ try {
333428
+ await typeText(sessionPath, value, bounds);
333429
+ return;
333430
+ } catch (error2) {
333431
+ if (isTransportError(error2)) throw error2;
333432
+ if (context.platform === "ANDROID" && bounds) {
333433
+ try {
333434
+ context.log?.(" Appium active-element send failed; using Android focused-keyboard input fallback");
333435
+ await sendAndroidTextToFocusedInput(context.deviceId, value);
333436
+ await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
333437
+ return;
333438
+ } catch (androidError) {
333439
+ if (isTransportError(androidError)) throw androidError;
333440
+ }
332904
333441
  }
333442
+ throw error2 instanceof Error ? error2 : new Error(String(error2));
332905
333443
  }
332906
- throw new Error(`TYPE step could not send text to the focused target${lastInputError instanceof Error ? `: ${lastInputError.message}` : ""}`);
332907
333444
  }
332908
333445
  async function executeClear(sessionId, step, platform3, isCrossPlatform = false) {
332909
333446
  const bounds = boundsForRun(step, platform3, isCrossPlatform);
332910
- let elementId = null;
332911
- if (step.target) {
332912
- try {
332913
- elementId = await findElement(sessionId, step.target, readStepTimeout(step), { platform: platform3 });
332914
- } catch (error2) {
332915
- if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target, platform3).length > 0) throw error2;
332916
- }
332917
- }
332918
- if (!elementId && bounds) {
332919
- await tapCoordinates(sessionId, bounds);
332920
- await new Promise((resolve) => setTimeout(resolve, MOBILE_FOCUS_SETTLE_MS));
332921
- elementId = await getActiveElementId2(sessionId);
332922
- }
332923
- if (!elementId) {
332924
- elementId = await getActiveElementId2(sessionId);
332925
- }
332926
- if (!elementId) {
332927
- throw new Error("CLEAR step could not resolve a target or focused element");
332928
- }
332929
- await wdRequest(`/session/${sessionId}/element/${elementId}/clear`, {
332930
- method: "POST",
332931
- body: "{}"
332932
- });
333447
+ await clearField(`/session/${sessionId}`, bounds);
332933
333448
  }
332934
333449
  async function executeSwipe(sessionId, step) {
332935
333450
  const direction = step.metadata?.direction || "up";