@validate.qa/runner 1.0.30 → 1.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +372 -38
  2. package/dist/cli.mjs +372 -38
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2344,40 +2344,40 @@ var init_plans = __esm({
2344
2344
  type: "extra_projects",
2345
2345
  label: "Extra Projects",
2346
2346
  description: "Add more projects to your workspace",
2347
- priceLabel: "$19.99 / month",
2348
- monthlyPriceUsd: 19.99,
2347
+ priceLabel: "$24.99 / month",
2348
+ monthlyPriceUsd: 24.99,
2349
2349
  limitIncrement: 1
2350
2350
  },
2351
2351
  extra_tests: {
2352
2352
  type: "extra_tests",
2353
2353
  label: "Extra Tests",
2354
2354
  description: "Increase tests per project limit",
2355
- priceLabel: "$4.99 / month",
2356
- monthlyPriceUsd: 4.99,
2357
- limitIncrement: 100
2355
+ priceLabel: "$12.99 / month",
2356
+ monthlyPriceUsd: 12.99,
2357
+ limitIncrement: 50
2358
2358
  },
2359
2359
  extra_suite_runs: {
2360
2360
  type: "extra_suite_runs",
2361
2361
  label: "Extra Suite Runs",
2362
2362
  description: "Increase daily suite run limit",
2363
- priceLabel: "$7.99 / month",
2364
- monthlyPriceUsd: 7.99,
2363
+ priceLabel: "$12.99 / month",
2364
+ monthlyPriceUsd: 12.99,
2365
2365
  limitIncrement: 5
2366
2366
  },
2367
2367
  extra_discovery_runs: {
2368
2368
  type: "extra_discovery_runs",
2369
2369
  label: "Extra Discovery Runs",
2370
2370
  description: "Increase discovery run limit",
2371
- priceLabel: "$6.99 / month",
2372
- monthlyPriceUsd: 6.99,
2371
+ priceLabel: "$19.99 / month",
2372
+ monthlyPriceUsd: 19.99,
2373
2373
  limitIncrement: 5
2374
2374
  },
2375
2375
  extra_test_executions: {
2376
2376
  type: "extra_test_executions",
2377
2377
  label: "Extra Test Executions",
2378
2378
  description: "+100 manual test runs per day, on top of your plan\u2019s daily dashboard run budget",
2379
- priceLabel: "$9.99 / month",
2380
- monthlyPriceUsd: 9.99,
2379
+ priceLabel: "$14.99 / month",
2380
+ monthlyPriceUsd: 14.99,
2381
2381
  limitIncrement: 100
2382
2382
  }
2383
2383
  };
@@ -2402,16 +2402,16 @@ var init_plans = __esm({
2402
2402
  PLAN_DEFINITIONS = {
2403
2403
  standard: {
2404
2404
  code: "standard",
2405
- label: "Standard Trial",
2405
+ label: "Standard",
2406
2406
  priceLabel: "Free",
2407
2407
  monthlyPriceUsd: 0,
2408
2408
  billingModel: "free",
2409
- summary: "For curious developers. Get the full platform - MCP, Discovery, Voice, self-heal - with enough headroom for one real project.",
2409
+ summary: "For curious developers. Get the full platform - MCP, Discovery, Voice, self-heal - with a starter budget for one real project.",
2410
2410
  maxProjects: 1,
2411
- maxTestsPerProject: 100,
2411
+ maxTestsPerProject: 20,
2412
2412
  maxSuiteRunsPerDay: 1,
2413
- maxTestExecutionsPerDay: 50,
2414
- // 25 regression / 25 manual at the default 50/50 split
2413
+ maxTestExecutionsPerDay: 10,
2414
+ // 5 regression / 5 manual at the default 50/50 split
2415
2415
  maxDiscoveryRunsPerDay: 1,
2416
2416
  // Per day
2417
2417
  maxConcurrentDiscoveries: 1,
@@ -2493,14 +2493,14 @@ var init_plans = __esm({
2493
2493
  };
2494
2494
  TRIAL_DURATION_DAYS = 30;
2495
2495
  CREDIT_PRICES_CENTS = {
2496
- discovery_run: 50,
2496
+ discovery_run: 200,
2497
+ // $2.00
2498
+ suite_run: 50,
2497
2499
  // $0.50
2498
- suite_run: 10,
2499
- // $0.10
2500
- test_run: 10,
2501
- // $0.10
2502
- heal: 10
2503
- // $0.10
2500
+ test_run: 15,
2501
+ // $0.15
2502
+ heal: 75
2503
+ // $0.75
2504
2504
  };
2505
2505
  AI_CREDIT_MARGIN = 1.5;
2506
2506
  CREDIT_PACKS_CENTS = [2e3, 5e3, 1e4];
@@ -64542,6 +64542,78 @@ var require_discover_ai_planner = __commonJS({
64542
64542
  function meaningfulTokens(name) {
64543
64543
  return name.toLowerCase().split(/[\s\--_]+/).filter((t) => t.length > 2 && !JOURNEY_MATCH_STOPWORDS.has(t));
64544
64544
  }
64545
+ function textIncludesCredentialField(text, field) {
64546
+ return field === "email" ? /\b(e-?mail|username|user\s*name|login\s*id)\b/i.test(text) : /\bpassword|passcode|pin\b/i.test(text);
64547
+ }
64548
+ function textIncludesCredentialSubmit(text) {
64549
+ return /\b(click|tap|press|submit|send)\b[^.]{0,80}\b(sign[ -]?in|signin|log[ -]?in|login|submit|continue)\b/i.test(text) || /\b(sign[ -]?in|signin|log[ -]?in|login|submit|continue)\b[^.]{0,80}\b(button|cta|form)\b/i.test(text) || /\b(submit|sign[ -]?in|signin|log[ -]?in|login)\b[^.]{0,80}\b(test\s+)?credentials\b/i.test(text) || /\b(test\s+)?credentials\b[^.]{0,80}\b(submit|sign[ -]?in|signin|log[ -]?in|login)\b/i.test(text);
64550
+ }
64551
+ function targetPagesHaveCredentialControls(siteMap, targetPages) {
64552
+ const targetSet = new Set(targetPages.map(normalizePlannerRoute));
64553
+ let hasEmail = false;
64554
+ let hasPassword = false;
64555
+ let hasSubmit = false;
64556
+ for (const page of siteMap.pages) {
64557
+ if (!targetSet.has(normalizePlannerRoute(page.route)))
64558
+ continue;
64559
+ for (const element of page.elements ?? []) {
64560
+ const label = [
64561
+ element.label,
64562
+ element.role,
64563
+ element.testId,
64564
+ element.name
64565
+ ].filter(Boolean).join(" ");
64566
+ if (!label)
64567
+ continue;
64568
+ if (textIncludesCredentialField(label, "email"))
64569
+ hasEmail = true;
64570
+ if (textIncludesCredentialField(label, "password"))
64571
+ hasPassword = true;
64572
+ if (/\b(sign[ -]?in|signin|log[ -]?in|login|submit|continue)\b/i.test(label) && !/\b(google|apple|facebook|github|sso|oauth|magic\s*link)\b/i.test(label)) {
64573
+ hasSubmit = true;
64574
+ }
64575
+ }
64576
+ }
64577
+ return hasEmail && hasPassword && hasSubmit;
64578
+ }
64579
+ function journeyCoversCredentialSubmit(journey) {
64580
+ if (!journey)
64581
+ return false;
64582
+ const text = [
64583
+ journey.name,
64584
+ journey.expectedOutcome,
64585
+ ...journey.steps ?? [],
64586
+ ...journey.provenCommands ?? []
64587
+ ].join(" ");
64588
+ const hasCredentialPair = /\bcredentials\b/i.test(text) || textIncludesCredentialField(text, "email") && textIncludesCredentialField(text, "password");
64589
+ return hasCredentialPair && textIncludesCredentialSubmit(text);
64590
+ }
64591
+ function scenarioAuthCredentialPolicy(scenario, steps) {
64592
+ const text = [
64593
+ scenario.name,
64594
+ scenario.goal,
64595
+ scenario.type,
64596
+ scenario.variant,
64597
+ ...steps.map((step) => step.action)
64598
+ ].filter(Boolean).join(" ");
64599
+ if (!/\b(auth|authentication|sign[ -]?in|signin|log[ -]?in|login|session)\b/i.test(text)) {
64600
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64601
+ }
64602
+ if (/\b(log[ -]?out|logout|sign[ -]?out|signout|forgot|reset|recover|magic\s*link|otp|mfa|2fa|sso|google|apple|facebook)\b/i.test(text)) {
64603
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64604
+ }
64605
+ if (!/\b(credential|e-?mail|username|user\s*name|password|valid|invalid|empty|required|wrong|case|special|leading|trailing)\b/i.test(text)) {
64606
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64607
+ }
64608
+ const uiOnly = /\b(mask|masked|visibility|show password|hide password|toggle|keyboard|keypad|max(?:imum)?\s*length|field focus|autofill)\b/i.test(text);
64609
+ const negative = /\b(invalid|empty|required|missing|wrong|error|validation|case|special|leading|trailing)\b/i.test(text);
64610
+ const happy = /\b(happy|valid\s+credentials|success|successful)\b/i.test(text) || scenario.type === "HAPPY_PATH" || scenario.variant === "happy";
64611
+ return {
64612
+ needsCredentialEvidence: true,
64613
+ needsSubmitStep: !uiOnly && (happy || negative),
64614
+ reason: uiOnly ? "credential UI scenario" : negative ? "credential validation scenario" : "credential sign-in scenario"
64615
+ };
64616
+ }
64545
64617
  function truncateOutcome(text, limit) {
64546
64618
  const normalized = text.replace(/\s+/g, " ").trim();
64547
64619
  if (normalized.length <= limit)
@@ -65307,6 +65379,18 @@ Each entry is exactly one of:
65307
65379
  if (matchSource === "route") {
65308
65380
  warnings.push(`"${sc.name}" matched to journey "${matchingSuggestion.name}" by route overlap only - evidence may be weak`);
65309
65381
  }
65382
+ const authCredentialPolicy = scenarioAuthCredentialPolicy(sc, steps);
65383
+ if (authCredentialPolicy.needsCredentialEvidence) {
65384
+ const hasCredentialEvidence = targetPagesHaveCredentialControls(siteMap, sc.targetPages) || journeyCoversCredentialSubmit(matchingSuggestion);
65385
+ if (!hasCredentialEvidence) {
65386
+ warnings.push(`Rejected "${sc.name}": ${authCredentialPolicy.reason} lacks observed email/password/sign-in controls or a recorded credential journey`);
65387
+ continue;
65388
+ }
65389
+ if (authCredentialPolicy.needsSubmitStep && !steps.some((step) => textIncludesCredentialSubmit(step.action)) && !journeyCoversCredentialSubmit(matchingSuggestion)) {
65390
+ warnings.push(`Rejected "${sc.name}": ${authCredentialPolicy.reason} does not include a concrete sign-in submit step`);
65391
+ continue;
65392
+ }
65393
+ }
65310
65394
  const evidence = (0, discover_planner_js_1.extractScenarioEvidence)(
65311
65395
  siteMap.pages,
65312
65396
  sc.targetPages,
@@ -65972,6 +66056,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65972
66056
  ## Coverage Strategy
65973
66057
  - For every form: at least 1 HAPPY_PATH (submit valid data AND verify durable result per rule 12) + 1 EDGE_CASE (empty/invalid submission)
65974
66058
  - For auth flows: sign-in happy path (verify the target screen AND a concrete signed-in/session signal per rule 13) + validation (wrong password, empty fields)
66059
+ - For credential sign-in flows: only plan credential scenarios when evidence shows the credential path itself \u2014 an email/username field, a password field, and a native Sign In/Login/Submit control on the target screen, or an Explorer-Recorded Journey that typed those fields and submitted them. Do NOT treat Google/Apple/Facebook/SSO buttons, magic-link entry, logout menus, profile menus, or already-signed-in screens as evidence for credential sign-in. If the explorer only observed signed-in menu/logout/session surfaces, plan session/logout scenarios only; surface missing credential coverage as a gap instead of inventing credential sign-in variants.
65975
66060
  - For CRUD: each scenario MUST own the entity it operates on. Read/search/update/delete/cancel-on-existing/view-existing scenarios MUST include a create step earlier in the same scenario (or declare an immutable seed fixture by name). Never assume a record from prior runs or sibling tests exists. When create/delete are tightly coupled, bundle into a single USER_JOURNEY: create \u2192 verify \u2192 delete \u2192 verify removal.
65976
66061
  - For AI/async features (Generate, Analyze, Process, Import, Sync): happy path MUST complete the flow per rule 12 - never stop at "button tapped" or "sheet opened"
65977
66062
  - For navigation: multi-screen user journeys within the feature scope (do NOT drift into unrelated features)
@@ -67448,7 +67533,7 @@ ${statePacket}` },
67448
67533
  "react-root",
67449
67534
  "application"
67450
67535
  ]);
67451
- var STRUCTURAL_ASSERT_ID_RE = /^(?:step[-_]\d+|(?:auth|form|wizard)[-_]?step(?:[-_]\d+)?|(?:main[-_]?content|root|app[-_]?root|__next|react[-_]?root|application|content|container|wrapper|webview|layout|view|screen|page)(?:[-_]\d+)?)$/i;
67536
+ var STRUCTURAL_ASSERT_ID_RE = /^(?:skip\s*to\s*main\s*content|skip[-_]?to[-_]?main[-_]?content|step[-_]\d+|(?:auth|form|wizard)[-_]?step(?:[-_]\d+)?|(?:main[-_]?content|root|app[-_]?root|__next|react[-_]?root|application|content|container|wrapper|webview|layout|view|screen|page)(?:[-_]\d+)?)$/i;
67452
67537
  function locatorLocalName(value) {
67453
67538
  const trimmed = value.trim();
67454
67539
  const withoutStrategy = trimmed.startsWith("id=") ? trimmed.slice(3) : trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
@@ -67528,6 +67613,88 @@ ${statePacket}` },
67528
67613
  }));
67529
67614
  return true;
67530
67615
  }
67616
+ function scenarioText(scenario) {
67617
+ return [
67618
+ scenario.name,
67619
+ scenario.goal,
67620
+ ...scenario.steps.flatMap((step) => [step.action, step.target, step.hint]),
67621
+ ...scenario.expectedOutcomes.flatMap((outcome) => [outcome.description, outcome.hint])
67622
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
67623
+ }
67624
+ function actionText(action) {
67625
+ const bundle = action.metadata.locatorBundle;
67626
+ return [
67627
+ action.type,
67628
+ action.selector,
67629
+ action.value,
67630
+ action.innerText,
67631
+ action.metadata.targetLabel,
67632
+ bundle?.text,
67633
+ bundle?.contentDesc,
67634
+ bundle?.accessibilityId,
67635
+ bundle?.resourceId,
67636
+ bundle?.className,
67637
+ bundle?.xpath
67638
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
67639
+ }
67640
+ function scenarioIntentMismatchReason(scenario, actions) {
67641
+ const text = scenarioText(scenario);
67642
+ const isCredentialSignIn = /\b(sign\s*in|log\s*in|login|authenticate|auth)\b/.test(text) && /\b(email|e-mail|password|credential|valid credentials?)\b/.test(text) && !/\b(reset|forgot|recover|logout|log\s*out|sign\s*out)\b/.test(text);
67643
+ const typedEmailIndexes = [];
67644
+ const typedPasswordIndexes = [];
67645
+ actions.forEach((action, index) => {
67646
+ if (action.type !== "TYPE")
67647
+ return;
67648
+ const textForAction = actionText(action);
67649
+ if (/\b(email|e-mail|username|user name|login)\b|\{\{test_user_email\}\}/i.test(textForAction)) {
67650
+ typedEmailIndexes.push(index);
67651
+ }
67652
+ if (/\b(password|passcode|pin)\b|\{\{test_user_password\}\}/i.test(textForAction)) {
67653
+ typedPasswordIndexes.push(index);
67654
+ }
67655
+ });
67656
+ const lastCredentialIndex = Math.max(...typedEmailIndexes, ...typedPasswordIndexes);
67657
+ const hasAnyCredentialEntry = Number.isFinite(lastCredentialIndex);
67658
+ const submitAfterCredentials = actions.some((action, index) => {
67659
+ if (action.type !== "TAP" || index <= lastCredentialIndex)
67660
+ return false;
67661
+ const textForAction = actionText(action);
67662
+ if (!/\b(sign\s*in|log\s*in|login|continue|submit)\b/.test(textForAction))
67663
+ return false;
67664
+ return !/\b(with\s+(email|e-mail|google|facebook|apple)|email\s+form|showemailformbtn)\b/i.test(textForAction);
67665
+ });
67666
+ const isAuthLikeScenario = /\b(sign(?:ed)?\s*in|log(?:ged)?\s*in|login|authenticate|auth|credential|email|e-mail|password)\b/.test(text);
67667
+ if (isAuthLikeScenario && hasAnyCredentialEntry && !submitAfterCredentials) {
67668
+ return "credential fields were filled but no sign-in submit tap was recorded after them";
67669
+ }
67670
+ const uiOnlyCredentialScenario = /\b(mask|masked|visibility|show password|hide password|toggle|keyboard|keypad|max(?:imum)?\s*length|field focus|autofill)\b/.test(text);
67671
+ const emptyFieldValidationScenario = /\b(empty|required|missing|blank)\b/.test(text);
67672
+ const sessionLifecycleScenario = /\b(logout|log\s*out|sign\s*out|session persistence|remain(?:s)? signed in|remember me)\b/.test(text);
67673
+ const loginTapWithoutCredentialPath = actions.some((action) => {
67674
+ if (action.type !== "TAP")
67675
+ return false;
67676
+ const textForAction = actionText(action);
67677
+ if (/\b(logout|log\s*out|sign\s*out)\b/.test(textForAction))
67678
+ return false;
67679
+ return /\b(sign\s*in|log\s*in|login)\b/.test(textForAction);
67680
+ });
67681
+ if (loginTapWithoutCredentialPath && !hasAnyCredentialEntry && !uiOnlyCredentialScenario && !emptyFieldValidationScenario && (sessionLifecycleScenario || /\b(valid credentials?|happy path|successful sign\s*in|successful login)\b/.test(text))) {
67682
+ return "auth/session scenario tapped a login or sign-in control without recording a credential path";
67683
+ }
67684
+ if (!isCredentialSignIn)
67685
+ return null;
67686
+ const typedEmail = typedEmailIndexes.length > 0;
67687
+ const typedPassword = typedPasswordIndexes.length > 0;
67688
+ const tappedSubmit = submitAfterCredentials;
67689
+ if (typedEmail && typedPassword && tappedSubmit)
67690
+ return null;
67691
+ const missing = [
67692
+ typedEmail ? null : "email/username entry",
67693
+ typedPassword ? null : "password entry",
67694
+ tappedSubmit ? null : "sign-in submit tap"
67695
+ ].filter((value) => value !== null);
67696
+ return `credential sign-in scenario did not record ${missing.join(", ")}`;
67697
+ }
67531
67698
  function actionCycleKey(action) {
67532
67699
  return `${action.type}|${action.selector}|${action.value ?? ""}`;
67533
67700
  }
@@ -67581,6 +67748,11 @@ ${statePacket}` },
67581
67748
  if (actions.length < beforeContainerDrop) {
67582
67749
  log2(` dropped ${beforeContainerDrop - actions.length} bare-container tap step(s) (no identity \u2014 un-replayable ambient taps).`);
67583
67750
  }
67751
+ const intentMismatch = scenarioIntentMismatchReason(scenario, actions);
67752
+ if (intentMismatch) {
67753
+ log2(` skipped scenario "${scenario.name}": ${intentMismatch}.`);
67754
+ return null;
67755
+ }
67584
67756
  const floorAppended = appendHybridFloor(actions, recording, platform3);
67585
67757
  if (floorAppended) {
67586
67758
  log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
@@ -68278,7 +68450,8 @@ Reason over steps + error + tree + registry and return working healed steps. Do
68278
68450
  2. STEP RESULTS: which steps passed before the failure.
68279
68451
  3. TREE: does the current screen match what the failed step expects?
68280
68452
  3b. STALE EMPTY-STATE ASSERT: a failing assert on one-time / data-dependent UI (empty-state CTAs, first-run prompts, onboarding banners) is NOT a locator problem \u2014 the element is gone because the account now has data. REPLACE the assertion with a durable landing-screen anchor (tab label, header, nav bar) that proves the same outcome (verdict fix).
68281
- 4. ENTRY-SCREEN DRIFT ("unexpected screen"/"screen drift"): the app started on a DIFFERENT screen than recorded; the fix is usually a MISSING NAVIGATION step (e.g. tap the onboarding/welcome CTA present in the tree before the recorded first step). Author it from the tree if you can (verdict fix); if a single static tree is insufficient, return needs_device.
68453
+ 4. EXTERNAL AUTH HANDOFF: if the failure says the foreground app changed outside the target app after a Login / Sign In tap, and the steps never enter email/password credentials, do NOT heal by choosing Google/Apple/Facebook/SSO or another external app. If the test later expects Logout/session state, return test_invalid or prereq_unmet: the recorded test lacks a real credential setup.
68454
+ 5. ENTRY-SCREEN DRIFT ("unexpected screen"/"screen drift"): the app started on a DIFFERENT screen than recorded; the fix is usually a MISSING NAVIGATION step (e.g. tap the onboarding/welcome CTA present in the tree before the recorded first step). Author it from the tree if you can (verdict fix); if a single static tree is insufficient, return needs_device.
68282
68455
  ## Response \u2014 STRICT JSON, exactly one verdict:
68283
68456
  {"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps, your changes applied, order/metadata preserved>],"reason":"what changed and why","confidence":0.0-1.0}
68284
68457
  {"verdict":"app_bug","reason":"app crashed/blank/5xx (NOT rate-limit)"}
@@ -68335,6 +68508,104 @@ For a fix, return the COMPLETE steps array (not a diff).`;
68335
68508
  function isDrift(error2) {
68336
68509
  return /unexpected screen|screen drift/i.test(error2 ?? "");
68337
68510
  }
68511
+ function mobileStepSearchText(step) {
68512
+ const rawTarget = step.target;
68513
+ const structuredTarget = rawTarget && typeof rawTarget === "object" ? rawTarget : void 0;
68514
+ const bundle = structuredTarget?.locatorBundle;
68515
+ const stepValue = step.value;
68516
+ return [
68517
+ step.action,
68518
+ step.description,
68519
+ typeof stepValue === "string" ? stepValue : void 0,
68520
+ typeof rawTarget === "string" ? rawTarget : void 0,
68521
+ structuredTarget?.primary,
68522
+ bundle?.text,
68523
+ bundle?.contentDesc,
68524
+ bundle?.accessibilityId,
68525
+ bundle?.resourceId,
68526
+ bundle?.className,
68527
+ bundle?.xpath
68528
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
68529
+ }
68530
+ function detectInvalidExternalAuthSetup(steps, error2) {
68531
+ if (!/foreground app changed|outside target app|com\.google\.android\.gms|external app/i.test(error2 ?? "")) {
68532
+ return null;
68533
+ }
68534
+ const hasBareLoginTap = steps.some((step) => {
68535
+ if (!/\btap\b/i.test(step.action))
68536
+ return false;
68537
+ const text = mobileStepSearchText(step);
68538
+ if (/\b(logout|log\s*out|sign\s*out)\b/.test(text))
68539
+ return false;
68540
+ return /\b(sign\s*in|signin|log\s*in|login)\b/.test(text);
68541
+ });
68542
+ if (!hasBareLoginTap)
68543
+ return null;
68544
+ const hasCredentialEntry = steps.some((step) => {
68545
+ if (!/\b(type|fill|input|enter)\b/i.test(step.action))
68546
+ return false;
68547
+ const text = mobileStepSearchText(step);
68548
+ return /\b(email|e-mail|username|user\s*name|password|passcode|pin|\{\{test_user_email\}\}|\{\{test_user_password\}\})\b/.test(text);
68549
+ });
68550
+ if (hasCredentialEntry)
68551
+ return null;
68552
+ return "bare Login/Sign In tap left the target app without any recorded credential setup; auth test is invalid until discovery records a real credential path";
68553
+ }
68554
+ function isCredentialFieldStep(step) {
68555
+ const text = mobileStepSearchText(step);
68556
+ return /\b(email|e-mail|username|user\s*name|password|passcode|pin|\{\{test_user_email\}\}|\{\{test_user_password\}\})\b/.test(text);
68557
+ }
68558
+ function isCredentialSubmitTap(step) {
68559
+ if (!/\b(tap|click|press)\b/i.test(step.action))
68560
+ return false;
68561
+ const text = mobileStepSearchText(step);
68562
+ if (/\b(logout|log\s*out|sign\s*out|reset|forgot|google|apple|facebook|github|sso|showemailform|with\s+email|email\s+form)\b/.test(text)) {
68563
+ return false;
68564
+ }
68565
+ return /\b(sign\s*in|signin|log\s*in|login|continue|submit)\b/.test(text);
68566
+ }
68567
+ function detectInvalidAuthValidationSetup(steps, error2) {
68568
+ if (!/\b(element not found|no such element|not visible|timeout|assert|could not be located|unexpected screen|screen drift)\b/i.test(error2 ?? "")) {
68569
+ return null;
68570
+ }
68571
+ const allText = steps.map(mobileStepSearchText).join(" ");
68572
+ if (!/\b(email|e-mail|username|user\s*name|password|passcode|pin)\b/.test(allText))
68573
+ return null;
68574
+ const uiOnlyCredentialScenario = /\b(mask|masked|unmask|visibility|visible password|show password|hide password|keyboard|ime|input type|max length|maxlength|character limit)\b/.test(allText);
68575
+ let lastCredentialEditOrder = -1;
68576
+ for (const step of steps) {
68577
+ if (!/\b(type|fill|input|enter|clear)\b/i.test(step.action))
68578
+ continue;
68579
+ if (!isCredentialFieldStep(step))
68580
+ continue;
68581
+ lastCredentialEditOrder = Math.max(lastCredentialEditOrder, step.order ?? -1);
68582
+ }
68583
+ if (lastCredentialEditOrder < 0)
68584
+ return null;
68585
+ if (uiOnlyCredentialScenario) {
68586
+ const hasCredentialFormEvidence = steps.some((step) => {
68587
+ if ((step.order ?? -1) >= lastCredentialEditOrder)
68588
+ return false;
68589
+ const text = mobileStepSearchText(step);
68590
+ return /\b(showemailformbtn|with\s+email|email\s+form|sign in with email|loginform|login form|email|e-mail|username|user\s*name)\b/.test(text);
68591
+ });
68592
+ if (hasCredentialFormEvidence)
68593
+ return null;
68594
+ return "credential-control test edits a credential field without recording a path to the email/password form; stale auth-control test is invalid until generation records a complete form path";
68595
+ }
68596
+ const hasSubmitAfterCredentialEdit = steps.some((step) => (step.order ?? -1) > lastCredentialEditOrder && isCredentialSubmitTap(step));
68597
+ if (hasSubmitAfterCredentialEdit)
68598
+ return null;
68599
+ const hasValidationAssertion = steps.some((step) => {
68600
+ if (!/\b(assert|wait)\b/i.test(step.action))
68601
+ return false;
68602
+ const text = mobileStepSearchText(step);
68603
+ return /\b(invalid|required|error|empty|missing|incorrect|wrong|try again|validation|must enter|enter (an )?(email|password)|email is required|password is required)\b/.test(text);
68604
+ });
68605
+ if (hasValidationAssertion)
68606
+ return null;
68607
+ return "credential field was edited but the test never submits the form or asserts a validation result; stale auth-validation test is invalid until generation records a complete flow";
68608
+ }
68338
68609
  function stripCodeFences(text) {
68339
68610
  const trimmed = text.trim();
68340
68611
  const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/m.exec(trimmed);
@@ -68593,6 +68864,24 @@ ${args.directive}`);
68593
68864
  const registryPresent = options.selectorRegistryContext != null;
68594
68865
  const platform3 = target.platform;
68595
68866
  const trimmedLogs = tailLimit(ctx.logs ?? "", MAX_LOG_CHARS);
68867
+ const invalidExternalAuthSetup = detectInvalidExternalAuthSetup(steps, ctx.error);
68868
+ if (invalidExternalAuthSetup) {
68869
+ return {
68870
+ passed: false,
68871
+ verdict: "test_invalid",
68872
+ healReason: invalidExternalAuthSetup,
68873
+ attemptsUsed: 0
68874
+ };
68875
+ }
68876
+ const invalidAuthValidationSetup = detectInvalidAuthValidationSetup(steps, ctx.error);
68877
+ if (invalidAuthValidationSetup) {
68878
+ return {
68879
+ passed: false,
68880
+ verdict: "test_invalid",
68881
+ healReason: invalidAuthValidationSetup,
68882
+ attemptsUsed: 0
68883
+ };
68884
+ }
68596
68885
  let attemptCap = base2;
68597
68886
  let recoverableUsed = 0;
68598
68887
  let bonusUsed = 0;
@@ -68778,7 +69067,7 @@ ${args.directive}`);
68778
69067
  removeTags: script.removeTags
68779
69068
  };
68780
69069
  }
68781
- var DEFAULT_PHASE2_MAX_PASSES = 6;
69070
+ var DEFAULT_PHASE2_MAX_PASSES = 4;
68782
69071
  exports2.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Appium (mobile UI automation) test with LIVE device access (Phase 2). You receive: the failing test STEPS as JSON, the PRIOR failure error, and the LIVE current screen accessibility tree observed on-device RIGHT NOW. Unlike a frozen failure snapshot, this LIVE tree is the REAL screen the app is on this instant.
68783
69072
  ## Key insight \u2014 ENTRY-SCREEN DRIFT
68784
69073
  The recorded flow may have expected a DIFFERENT starting screen than the one shown. When the LIVE screen is an earlier/other screen (e.g. an onboarding / welcome / splash screen), the fix is almost always a MISSING NAVIGATION step: AUTHOR the leading step(s) (e.g. a tap on the onboarding CTA present in the tree) that reach the recorded first screen, then keep the recorded steps. This is exactly what a static failing-source cannot reveal but a LIVE screen can.
@@ -68787,7 +69076,8 @@ The recorded flow may have expected a DIFFERENT starting screen than the one sho
68787
69076
  2. If they differ, PREPEND the missing navigation step(s), authored from IDENTITY locators (resource-id, accessibility-id, or EXACT @text=) that are PRESENT in the LIVE tree \u2014 never a loose contains().
68788
69077
  3. If a locator is simply wrong, swap it for a stronger identity locator present in the LIVE tree.
68789
69078
  3b. If the failing step ASSERTS one-time / data-dependent UI (an empty-state CTA or first-run prompt) that is absent from the LIVE tree while the screen is otherwise correct, the assert is STALE \u2014 replace it with a durable element of the same screen (tab label, header, nav bar) and keep the rest.
68790
- 4. If you cannot yet SEE the screen you need, use "observe" to advance ONE step, then you will be re-shown the new live screen.
69079
+ 4. If the prior failure says the foreground app changed outside the target app after a Login / Sign In tap, and the steps never enter email/password credentials, do NOT observe/tap Google/Apple/Facebook/SSO. For logout/session tests, return test_invalid or prereq_unmet because the recorded test lacks a real credential setup.
69080
+ 5. If you cannot yet SEE the screen you need, use "observe" to advance ONE step, then you will be re-shown the new live screen.
68791
69081
  ## Response \u2014 STRICT JSON, exactly one verdict:
68792
69082
  {"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps incl. any prepended navigation, order/metadata coherent>],"reason":"what changed and why","confidence":0.0-1.0}
68793
69083
  {"verdict":"observe","action":{"type":"tap","target":"mobile.text('<exact label visible in the live tree>')"}}
@@ -329889,7 +330179,7 @@ var require_package4 = __commonJS({
329889
330179
  "package.json"(exports2, module2) {
329890
330180
  module2.exports = {
329891
330181
  name: "@validate.qa/runner",
329892
- version: "1.0.30",
330182
+ version: "1.0.31",
329893
330183
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
329894
330184
  bin: {
329895
330185
  "validate-runner": "dist/cli.js"
@@ -330038,6 +330328,8 @@ var MAX_RETRIES = 3;
330038
330328
  var STARTUP_TIMEOUT_MS = 3e4;
330039
330329
  var HEALTH_CHECK_INTERVAL_MS = 5e3;
330040
330330
  var STABLE_AFTER_MS = 6e4;
330331
+ var DEFAULT_APPIUM_LOG_LEVEL = "warn";
330332
+ var MAX_APPIUM_LOG_LINE_CHARS = 4e3;
330041
330333
  var appiumProcess = null;
330042
330334
  var serverState = {
330043
330335
  running: false,
@@ -330097,6 +330389,23 @@ function appendLog(line) {
330097
330389
  logs = logs.slice(-MAX_LOG_LINES);
330098
330390
  }
330099
330391
  }
330392
+ function resolveAppiumLogLevel() {
330393
+ const raw = process.env.APPIUM_LOG_LEVEL?.trim();
330394
+ if (raw && /^[a-z]+(?::[a-z]+)?$/i.test(raw)) return raw;
330395
+ return DEFAULT_APPIUM_LOG_LEVEL;
330396
+ }
330397
+ function buildAppiumArgs(port, drivers) {
330398
+ const args = ["--port", String(port), "--log-level", resolveAppiumLogLevel()];
330399
+ if (drivers && drivers.length > 0) {
330400
+ args.push("--use-drivers", drivers.join(","));
330401
+ }
330402
+ return args;
330403
+ }
330404
+ function sanitizeAppiumLogLine(line) {
330405
+ const redacted = line.replace(/"text"\s*:\s*"[^"]*"/g, '"text":"[REDACTED]"').replace(/"password"\s*:\s*"[^"]*"/gi, '"password":"[REDACTED]"').replace(/"token"\s*:\s*"[^"]*"/gi, '"token":"[REDACTED]"').replace(/"value"\s*:\s*\[(?:"[^"]*",?){8,}\]/g, '"value":["[REDACTED]"]').replace(/"value"\s*:\s*"[A-Za-z0-9+/=]{512,}"/g, '"value":"[BASE64_REDACTED]"');
330406
+ if (redacted.length <= MAX_APPIUM_LOG_LINE_CHARS) return redacted;
330407
+ return `${redacted.slice(0, MAX_APPIUM_LOG_LINE_CHARS)}\u2026 [truncated ${redacted.length - MAX_APPIUM_LOG_LINE_CHARS} chars]`;
330408
+ }
330100
330409
  async function checkAppiumHealth(port = serverState.port) {
330101
330410
  try {
330102
330411
  const controller = new AbortController();
@@ -330129,10 +330438,7 @@ async function startAppiumServer(options) {
330129
330438
  return serverState;
330130
330439
  }
330131
330440
  const port = options?.port ?? APPIUM_PORT;
330132
- const args = ["--port", String(port)];
330133
- if (options?.drivers && options.drivers.length > 0) {
330134
- args.push("--use-drivers", options.drivers.join(","));
330135
- }
330441
+ const args = buildAppiumArgs(port, options?.drivers);
330136
330442
  intentionalStop = false;
330137
330443
  lastStartOptions = { ...lastStartOptions, ...options };
330138
330444
  if (appiumGaveUp) {
@@ -330151,14 +330457,14 @@ async function startAppiumServer(options) {
330151
330457
  serverState.pid = appiumProcess.pid ?? null;
330152
330458
  serverState.port = port;
330153
330459
  appiumProcess.stdout?.on("data", (data) => {
330154
- const line = data.toString().trim();
330460
+ const line = sanitizeAppiumLogLine(data.toString().trim());
330155
330461
  if (line) {
330156
330462
  appendLog(`[appium:stdout] ${line}`);
330157
330463
  options?.onLog?.(line);
330158
330464
  }
330159
330465
  });
330160
330466
  appiumProcess.stderr?.on("data", (data) => {
330161
- const line = data.toString().trim();
330467
+ const line = sanitizeAppiumLogLine(data.toString().trim());
330162
330468
  if (line) {
330163
330469
  appendLog(`[appium:stderr] ${line}`);
330164
330470
  options?.onLog?.(line);
@@ -330317,6 +330623,17 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
330317
330623
  // src/services/mobile/appium-actions.ts
330318
330624
  var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
330319
330625
  var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
330626
+ var APPIUM_DRIVER_DEAD_RE = /instrumentation process is not running|cannot be proxied to UiAutomator2 server|socket hang up|ECONNRESET|invalid session id|no such session/i;
330627
+ var AppiumDriverDeadError = class extends Error {
330628
+ isAppiumDriverDead = true;
330629
+ constructor(message) {
330630
+ super(message);
330631
+ this.name = "AppiumDriverDeadError";
330632
+ }
330633
+ };
330634
+ function isAppiumDriverDeadError(err) {
330635
+ return err instanceof AppiumDriverDeadError || typeof err === "object" && err !== null && err.isAppiumDriverDead === true;
330636
+ }
330320
330637
  async function appiumRequest(path, options) {
330321
330638
  const url = `http://localhost:${getAppiumState().port}${path}`;
330322
330639
  const res = await fetch(url, {
@@ -330326,6 +330643,9 @@ async function appiumRequest(path, options) {
330326
330643
  });
330327
330644
  if (!res.ok) {
330328
330645
  const body = await res.text().catch(() => "");
330646
+ if (APPIUM_DRIVER_DEAD_RE.test(body)) {
330647
+ throw new AppiumDriverDeadError(`Appium driver session is not usable (${res.status}): ${body.slice(0, 200)}`);
330648
+ }
330329
330649
  throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
330330
330650
  }
330331
330651
  return res.json();
@@ -330340,7 +330660,8 @@ async function getScreenshot(sessionPath) {
330340
330660
  try {
330341
330661
  const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
330342
330662
  return typeof value === "string" ? value : null;
330343
- } catch {
330663
+ } catch (err) {
330664
+ if (isAppiumDriverDeadError(err)) throw err;
330344
330665
  return null;
330345
330666
  }
330346
330667
  }
@@ -330348,7 +330669,8 @@ async function getPageSource(sessionPath) {
330348
330669
  try {
330349
330670
  const value = readValue(await appiumRequest(`${sessionPath}/source`));
330350
330671
  return typeof value === "string" ? value : null;
330351
- } catch {
330672
+ } catch (err) {
330673
+ if (isAppiumDriverDeadError(err)) throw err;
330352
330674
  return null;
330353
330675
  }
330354
330676
  }
@@ -330364,7 +330686,8 @@ async function getWindowSize(sessionPath) {
330364
330686
  return { width: w, height: h };
330365
330687
  }
330366
330688
  }
330367
- } catch {
330689
+ } catch (err) {
330690
+ if (isAppiumDriverDeadError(err)) throw err;
330368
330691
  }
330369
330692
  if (attempt < retries - 1) {
330370
330693
  await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
@@ -331705,6 +332028,7 @@ var COLD_BOOT_ENTRY_TIMEOUT_MS = 3e4;
331705
332028
  var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
331706
332029
  var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
331707
332030
  var WD_ACTION_TIMEOUT_MS = 3e4;
332031
+ var APPIUM_DRIVER_DEAD_RE2 = /instrumentation process is not running|cannot be proxied to UiAutomator2 server|socket hang up|ECONNRESET/i;
331708
332032
  var MAX_SESSION_CREATE_ATTEMPTS = 3;
331709
332033
  var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
331710
332034
  var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
@@ -331741,6 +332065,9 @@ async function wdRequest(path, options) {
331741
332065
  if (res.status === 404 && /no such session|invalid session id|session is either terminated|deleted session/i.test(body)) {
331742
332066
  throw new WebDriverTransportError(`WebDriver session is no longer available: ${body.slice(0, 200)}`);
331743
332067
  }
332068
+ if (res.status === 500 && APPIUM_DRIVER_DEAD_RE2.test(body)) {
332069
+ throw new WebDriverTransportError(`WebDriver driver process is not usable: ${body.slice(0, 200)}`);
332070
+ }
331744
332071
  if (res.status === 502 || res.status === 503 || res.status === 504) {
331745
332072
  throw new WebDriverTransportError(`Appium server unavailable (${res.status}): ${body.slice(0, 200)}`);
331746
332073
  }
@@ -336698,7 +337025,14 @@ ${finalVerifyResult.logs ?? ""}`;
336698
337025
  if (getBridgeState().hasActiveSession) {
336699
337026
  throw new Error("CONFIG_ISSUE: the shared mobile device is busy with an active recording session; mobile discovery will retry once it ends.");
336700
337027
  }
336701
- const mobileOnLog = (line) => onLog("progress", line);
337028
+ const mobileProgressLines = [];
337029
+ const mobileOnLog = (line) => {
337030
+ mobileProgressLines.push(line);
337031
+ if (mobileProgressLines.length > 500) {
337032
+ mobileProgressLines.splice(0, mobileProgressLines.length - 500);
337033
+ }
337034
+ onLog("progress", mobileProgressLines.join("\n"));
337035
+ };
336702
337036
  liveBrowserHandle.patch({
336703
337037
  mode: ctx.mode === "survey" ? "survey" : "discovery",
336704
337038
  testName: ctx.featureName ?? "Mobile discovery",
package/dist/cli.mjs CHANGED
@@ -2349,40 +2349,40 @@ var init_plans = __esm({
2349
2349
  type: "extra_projects",
2350
2350
  label: "Extra Projects",
2351
2351
  description: "Add more projects to your workspace",
2352
- priceLabel: "$19.99 / month",
2353
- monthlyPriceUsd: 19.99,
2352
+ priceLabel: "$24.99 / month",
2353
+ monthlyPriceUsd: 24.99,
2354
2354
  limitIncrement: 1
2355
2355
  },
2356
2356
  extra_tests: {
2357
2357
  type: "extra_tests",
2358
2358
  label: "Extra Tests",
2359
2359
  description: "Increase tests per project limit",
2360
- priceLabel: "$4.99 / month",
2361
- monthlyPriceUsd: 4.99,
2362
- limitIncrement: 100
2360
+ priceLabel: "$12.99 / month",
2361
+ monthlyPriceUsd: 12.99,
2362
+ limitIncrement: 50
2363
2363
  },
2364
2364
  extra_suite_runs: {
2365
2365
  type: "extra_suite_runs",
2366
2366
  label: "Extra Suite Runs",
2367
2367
  description: "Increase daily suite run limit",
2368
- priceLabel: "$7.99 / month",
2369
- monthlyPriceUsd: 7.99,
2368
+ priceLabel: "$12.99 / month",
2369
+ monthlyPriceUsd: 12.99,
2370
2370
  limitIncrement: 5
2371
2371
  },
2372
2372
  extra_discovery_runs: {
2373
2373
  type: "extra_discovery_runs",
2374
2374
  label: "Extra Discovery Runs",
2375
2375
  description: "Increase discovery run limit",
2376
- priceLabel: "$6.99 / month",
2377
- monthlyPriceUsd: 6.99,
2376
+ priceLabel: "$19.99 / month",
2377
+ monthlyPriceUsd: 19.99,
2378
2378
  limitIncrement: 5
2379
2379
  },
2380
2380
  extra_test_executions: {
2381
2381
  type: "extra_test_executions",
2382
2382
  label: "Extra Test Executions",
2383
2383
  description: "+100 manual test runs per day, on top of your plan\u2019s daily dashboard run budget",
2384
- priceLabel: "$9.99 / month",
2385
- monthlyPriceUsd: 9.99,
2384
+ priceLabel: "$14.99 / month",
2385
+ monthlyPriceUsd: 14.99,
2386
2386
  limitIncrement: 100
2387
2387
  }
2388
2388
  };
@@ -2407,16 +2407,16 @@ var init_plans = __esm({
2407
2407
  PLAN_DEFINITIONS = {
2408
2408
  standard: {
2409
2409
  code: "standard",
2410
- label: "Standard Trial",
2410
+ label: "Standard",
2411
2411
  priceLabel: "Free",
2412
2412
  monthlyPriceUsd: 0,
2413
2413
  billingModel: "free",
2414
- summary: "For curious developers. Get the full platform - MCP, Discovery, Voice, self-heal - with enough headroom for one real project.",
2414
+ summary: "For curious developers. Get the full platform - MCP, Discovery, Voice, self-heal - with a starter budget for one real project.",
2415
2415
  maxProjects: 1,
2416
- maxTestsPerProject: 100,
2416
+ maxTestsPerProject: 20,
2417
2417
  maxSuiteRunsPerDay: 1,
2418
- maxTestExecutionsPerDay: 50,
2419
- // 25 regression / 25 manual at the default 50/50 split
2418
+ maxTestExecutionsPerDay: 10,
2419
+ // 5 regression / 5 manual at the default 50/50 split
2420
2420
  maxDiscoveryRunsPerDay: 1,
2421
2421
  // Per day
2422
2422
  maxConcurrentDiscoveries: 1,
@@ -2498,14 +2498,14 @@ var init_plans = __esm({
2498
2498
  };
2499
2499
  TRIAL_DURATION_DAYS = 30;
2500
2500
  CREDIT_PRICES_CENTS = {
2501
- discovery_run: 50,
2501
+ discovery_run: 200,
2502
+ // $2.00
2503
+ suite_run: 50,
2502
2504
  // $0.50
2503
- suite_run: 10,
2504
- // $0.10
2505
- test_run: 10,
2506
- // $0.10
2507
- heal: 10
2508
- // $0.10
2505
+ test_run: 15,
2506
+ // $0.15
2507
+ heal: 75
2508
+ // $0.75
2509
2509
  };
2510
2510
  AI_CREDIT_MARGIN = 1.5;
2511
2511
  CREDIT_PACKS_CENTS = [2e3, 5e3, 1e4];
@@ -64547,6 +64547,78 @@ var require_discover_ai_planner = __commonJS({
64547
64547
  function meaningfulTokens(name) {
64548
64548
  return name.toLowerCase().split(/[\s\--_]+/).filter((t) => t.length > 2 && !JOURNEY_MATCH_STOPWORDS.has(t));
64549
64549
  }
64550
+ function textIncludesCredentialField(text, field) {
64551
+ return field === "email" ? /\b(e-?mail|username|user\s*name|login\s*id)\b/i.test(text) : /\bpassword|passcode|pin\b/i.test(text);
64552
+ }
64553
+ function textIncludesCredentialSubmit(text) {
64554
+ return /\b(click|tap|press|submit|send)\b[^.]{0,80}\b(sign[ -]?in|signin|log[ -]?in|login|submit|continue)\b/i.test(text) || /\b(sign[ -]?in|signin|log[ -]?in|login|submit|continue)\b[^.]{0,80}\b(button|cta|form)\b/i.test(text) || /\b(submit|sign[ -]?in|signin|log[ -]?in|login)\b[^.]{0,80}\b(test\s+)?credentials\b/i.test(text) || /\b(test\s+)?credentials\b[^.]{0,80}\b(submit|sign[ -]?in|signin|log[ -]?in|login)\b/i.test(text);
64555
+ }
64556
+ function targetPagesHaveCredentialControls(siteMap, targetPages) {
64557
+ const targetSet = new Set(targetPages.map(normalizePlannerRoute));
64558
+ let hasEmail = false;
64559
+ let hasPassword = false;
64560
+ let hasSubmit = false;
64561
+ for (const page of siteMap.pages) {
64562
+ if (!targetSet.has(normalizePlannerRoute(page.route)))
64563
+ continue;
64564
+ for (const element of page.elements ?? []) {
64565
+ const label = [
64566
+ element.label,
64567
+ element.role,
64568
+ element.testId,
64569
+ element.name
64570
+ ].filter(Boolean).join(" ");
64571
+ if (!label)
64572
+ continue;
64573
+ if (textIncludesCredentialField(label, "email"))
64574
+ hasEmail = true;
64575
+ if (textIncludesCredentialField(label, "password"))
64576
+ hasPassword = true;
64577
+ if (/\b(sign[ -]?in|signin|log[ -]?in|login|submit|continue)\b/i.test(label) && !/\b(google|apple|facebook|github|sso|oauth|magic\s*link)\b/i.test(label)) {
64578
+ hasSubmit = true;
64579
+ }
64580
+ }
64581
+ }
64582
+ return hasEmail && hasPassword && hasSubmit;
64583
+ }
64584
+ function journeyCoversCredentialSubmit(journey) {
64585
+ if (!journey)
64586
+ return false;
64587
+ const text = [
64588
+ journey.name,
64589
+ journey.expectedOutcome,
64590
+ ...journey.steps ?? [],
64591
+ ...journey.provenCommands ?? []
64592
+ ].join(" ");
64593
+ const hasCredentialPair = /\bcredentials\b/i.test(text) || textIncludesCredentialField(text, "email") && textIncludesCredentialField(text, "password");
64594
+ return hasCredentialPair && textIncludesCredentialSubmit(text);
64595
+ }
64596
+ function scenarioAuthCredentialPolicy(scenario, steps) {
64597
+ const text = [
64598
+ scenario.name,
64599
+ scenario.goal,
64600
+ scenario.type,
64601
+ scenario.variant,
64602
+ ...steps.map((step) => step.action)
64603
+ ].filter(Boolean).join(" ");
64604
+ if (!/\b(auth|authentication|sign[ -]?in|signin|log[ -]?in|login|session)\b/i.test(text)) {
64605
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64606
+ }
64607
+ if (/\b(log[ -]?out|logout|sign[ -]?out|signout|forgot|reset|recover|magic\s*link|otp|mfa|2fa|sso|google|apple|facebook)\b/i.test(text)) {
64608
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64609
+ }
64610
+ if (!/\b(credential|e-?mail|username|user\s*name|password|valid|invalid|empty|required|wrong|case|special|leading|trailing)\b/i.test(text)) {
64611
+ return { needsCredentialEvidence: false, needsSubmitStep: false, reason: "" };
64612
+ }
64613
+ const uiOnly = /\b(mask|masked|visibility|show password|hide password|toggle|keyboard|keypad|max(?:imum)?\s*length|field focus|autofill)\b/i.test(text);
64614
+ const negative = /\b(invalid|empty|required|missing|wrong|error|validation|case|special|leading|trailing)\b/i.test(text);
64615
+ const happy = /\b(happy|valid\s+credentials|success|successful)\b/i.test(text) || scenario.type === "HAPPY_PATH" || scenario.variant === "happy";
64616
+ return {
64617
+ needsCredentialEvidence: true,
64618
+ needsSubmitStep: !uiOnly && (happy || negative),
64619
+ reason: uiOnly ? "credential UI scenario" : negative ? "credential validation scenario" : "credential sign-in scenario"
64620
+ };
64621
+ }
64550
64622
  function truncateOutcome(text, limit) {
64551
64623
  const normalized = text.replace(/\s+/g, " ").trim();
64552
64624
  if (normalized.length <= limit)
@@ -65312,6 +65384,18 @@ Each entry is exactly one of:
65312
65384
  if (matchSource === "route") {
65313
65385
  warnings.push(`"${sc.name}" matched to journey "${matchingSuggestion.name}" by route overlap only - evidence may be weak`);
65314
65386
  }
65387
+ const authCredentialPolicy = scenarioAuthCredentialPolicy(sc, steps);
65388
+ if (authCredentialPolicy.needsCredentialEvidence) {
65389
+ const hasCredentialEvidence = targetPagesHaveCredentialControls(siteMap, sc.targetPages) || journeyCoversCredentialSubmit(matchingSuggestion);
65390
+ if (!hasCredentialEvidence) {
65391
+ warnings.push(`Rejected "${sc.name}": ${authCredentialPolicy.reason} lacks observed email/password/sign-in controls or a recorded credential journey`);
65392
+ continue;
65393
+ }
65394
+ if (authCredentialPolicy.needsSubmitStep && !steps.some((step) => textIncludesCredentialSubmit(step.action)) && !journeyCoversCredentialSubmit(matchingSuggestion)) {
65395
+ warnings.push(`Rejected "${sc.name}": ${authCredentialPolicy.reason} does not include a concrete sign-in submit step`);
65396
+ continue;
65397
+ }
65398
+ }
65315
65399
  const evidence = (0, discover_planner_js_1.extractScenarioEvidence)(
65316
65400
  siteMap.pages,
65317
65401
  sc.targetPages,
@@ -65977,6 +66061,7 @@ Return ONLY one valid JSON object matching the schema below. Do NOT include <thi
65977
66061
  ## Coverage Strategy
65978
66062
  - For every form: at least 1 HAPPY_PATH (submit valid data AND verify durable result per rule 12) + 1 EDGE_CASE (empty/invalid submission)
65979
66063
  - For auth flows: sign-in happy path (verify the target screen AND a concrete signed-in/session signal per rule 13) + validation (wrong password, empty fields)
66064
+ - For credential sign-in flows: only plan credential scenarios when evidence shows the credential path itself \u2014 an email/username field, a password field, and a native Sign In/Login/Submit control on the target screen, or an Explorer-Recorded Journey that typed those fields and submitted them. Do NOT treat Google/Apple/Facebook/SSO buttons, magic-link entry, logout menus, profile menus, or already-signed-in screens as evidence for credential sign-in. If the explorer only observed signed-in menu/logout/session surfaces, plan session/logout scenarios only; surface missing credential coverage as a gap instead of inventing credential sign-in variants.
65980
66065
  - For CRUD: each scenario MUST own the entity it operates on. Read/search/update/delete/cancel-on-existing/view-existing scenarios MUST include a create step earlier in the same scenario (or declare an immutable seed fixture by name). Never assume a record from prior runs or sibling tests exists. When create/delete are tightly coupled, bundle into a single USER_JOURNEY: create \u2192 verify \u2192 delete \u2192 verify removal.
65981
66066
  - For AI/async features (Generate, Analyze, Process, Import, Sync): happy path MUST complete the flow per rule 12 - never stop at "button tapped" or "sheet opened"
65982
66067
  - For navigation: multi-screen user journeys within the feature scope (do NOT drift into unrelated features)
@@ -67453,7 +67538,7 @@ ${statePacket}` },
67453
67538
  "react-root",
67454
67539
  "application"
67455
67540
  ]);
67456
- var STRUCTURAL_ASSERT_ID_RE = /^(?:step[-_]\d+|(?:auth|form|wizard)[-_]?step(?:[-_]\d+)?|(?:main[-_]?content|root|app[-_]?root|__next|react[-_]?root|application|content|container|wrapper|webview|layout|view|screen|page)(?:[-_]\d+)?)$/i;
67541
+ var STRUCTURAL_ASSERT_ID_RE = /^(?:skip\s*to\s*main\s*content|skip[-_]?to[-_]?main[-_]?content|step[-_]\d+|(?:auth|form|wizard)[-_]?step(?:[-_]\d+)?|(?:main[-_]?content|root|app[-_]?root|__next|react[-_]?root|application|content|container|wrapper|webview|layout|view|screen|page)(?:[-_]\d+)?)$/i;
67457
67542
  function locatorLocalName(value) {
67458
67543
  const trimmed = value.trim();
67459
67544
  const withoutStrategy = trimmed.startsWith("id=") ? trimmed.slice(3) : trimmed.startsWith("~") ? trimmed.slice(1) : trimmed;
@@ -67533,6 +67618,88 @@ ${statePacket}` },
67533
67618
  }));
67534
67619
  return true;
67535
67620
  }
67621
+ function scenarioText(scenario) {
67622
+ return [
67623
+ scenario.name,
67624
+ scenario.goal,
67625
+ ...scenario.steps.flatMap((step) => [step.action, step.target, step.hint]),
67626
+ ...scenario.expectedOutcomes.flatMap((outcome) => [outcome.description, outcome.hint])
67627
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
67628
+ }
67629
+ function actionText(action) {
67630
+ const bundle = action.metadata.locatorBundle;
67631
+ return [
67632
+ action.type,
67633
+ action.selector,
67634
+ action.value,
67635
+ action.innerText,
67636
+ action.metadata.targetLabel,
67637
+ bundle?.text,
67638
+ bundle?.contentDesc,
67639
+ bundle?.accessibilityId,
67640
+ bundle?.resourceId,
67641
+ bundle?.className,
67642
+ bundle?.xpath
67643
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
67644
+ }
67645
+ function scenarioIntentMismatchReason(scenario, actions) {
67646
+ const text = scenarioText(scenario);
67647
+ 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);
67648
+ const typedEmailIndexes = [];
67649
+ const typedPasswordIndexes = [];
67650
+ actions.forEach((action, index) => {
67651
+ if (action.type !== "TYPE")
67652
+ return;
67653
+ const textForAction = actionText(action);
67654
+ if (/\b(email|e-mail|username|user name|login)\b|\{\{test_user_email\}\}/i.test(textForAction)) {
67655
+ typedEmailIndexes.push(index);
67656
+ }
67657
+ if (/\b(password|passcode|pin)\b|\{\{test_user_password\}\}/i.test(textForAction)) {
67658
+ typedPasswordIndexes.push(index);
67659
+ }
67660
+ });
67661
+ const lastCredentialIndex = Math.max(...typedEmailIndexes, ...typedPasswordIndexes);
67662
+ const hasAnyCredentialEntry = Number.isFinite(lastCredentialIndex);
67663
+ const submitAfterCredentials = actions.some((action, index) => {
67664
+ if (action.type !== "TAP" || index <= lastCredentialIndex)
67665
+ return false;
67666
+ const textForAction = actionText(action);
67667
+ if (!/\b(sign\s*in|log\s*in|login|continue|submit)\b/.test(textForAction))
67668
+ return false;
67669
+ return !/\b(with\s+(email|e-mail|google|facebook|apple)|email\s+form|showemailformbtn)\b/i.test(textForAction);
67670
+ });
67671
+ const isAuthLikeScenario = /\b(sign(?:ed)?\s*in|log(?:ged)?\s*in|login|authenticate|auth|credential|email|e-mail|password)\b/.test(text);
67672
+ if (isAuthLikeScenario && hasAnyCredentialEntry && !submitAfterCredentials) {
67673
+ return "credential fields were filled but no sign-in submit tap was recorded after them";
67674
+ }
67675
+ const uiOnlyCredentialScenario = /\b(mask|masked|visibility|show password|hide password|toggle|keyboard|keypad|max(?:imum)?\s*length|field focus|autofill)\b/.test(text);
67676
+ const emptyFieldValidationScenario = /\b(empty|required|missing|blank)\b/.test(text);
67677
+ const sessionLifecycleScenario = /\b(logout|log\s*out|sign\s*out|session persistence|remain(?:s)? signed in|remember me)\b/.test(text);
67678
+ const loginTapWithoutCredentialPath = actions.some((action) => {
67679
+ if (action.type !== "TAP")
67680
+ return false;
67681
+ const textForAction = actionText(action);
67682
+ if (/\b(logout|log\s*out|sign\s*out)\b/.test(textForAction))
67683
+ return false;
67684
+ return /\b(sign\s*in|log\s*in|login)\b/.test(textForAction);
67685
+ });
67686
+ if (loginTapWithoutCredentialPath && !hasAnyCredentialEntry && !uiOnlyCredentialScenario && !emptyFieldValidationScenario && (sessionLifecycleScenario || /\b(valid credentials?|happy path|successful sign\s*in|successful login)\b/.test(text))) {
67687
+ return "auth/session scenario tapped a login or sign-in control without recording a credential path";
67688
+ }
67689
+ if (!isCredentialSignIn)
67690
+ return null;
67691
+ const typedEmail = typedEmailIndexes.length > 0;
67692
+ const typedPassword = typedPasswordIndexes.length > 0;
67693
+ const tappedSubmit = submitAfterCredentials;
67694
+ if (typedEmail && typedPassword && tappedSubmit)
67695
+ return null;
67696
+ const missing = [
67697
+ typedEmail ? null : "email/username entry",
67698
+ typedPassword ? null : "password entry",
67699
+ tappedSubmit ? null : "sign-in submit tap"
67700
+ ].filter((value) => value !== null);
67701
+ return `credential sign-in scenario did not record ${missing.join(", ")}`;
67702
+ }
67536
67703
  function actionCycleKey(action) {
67537
67704
  return `${action.type}|${action.selector}|${action.value ?? ""}`;
67538
67705
  }
@@ -67586,6 +67753,11 @@ ${statePacket}` },
67586
67753
  if (actions.length < beforeContainerDrop) {
67587
67754
  log2(` dropped ${beforeContainerDrop - actions.length} bare-container tap step(s) (no identity \u2014 un-replayable ambient taps).`);
67588
67755
  }
67756
+ const intentMismatch = scenarioIntentMismatchReason(scenario, actions);
67757
+ if (intentMismatch) {
67758
+ log2(` skipped scenario "${scenario.name}": ${intentMismatch}.`);
67759
+ return null;
67760
+ }
67589
67761
  const floorAppended = appendHybridFloor(actions, recording, platform3);
67590
67762
  if (floorAppended) {
67591
67763
  log2(" appended hybrid durable-outcome floor (landing-screen ASSERT_VISIBLE).");
@@ -68283,7 +68455,8 @@ Reason over steps + error + tree + registry and return working healed steps. Do
68283
68455
  2. STEP RESULTS: which steps passed before the failure.
68284
68456
  3. TREE: does the current screen match what the failed step expects?
68285
68457
  3b. STALE EMPTY-STATE ASSERT: a failing assert on one-time / data-dependent UI (empty-state CTAs, first-run prompts, onboarding banners) is NOT a locator problem \u2014 the element is gone because the account now has data. REPLACE the assertion with a durable landing-screen anchor (tab label, header, nav bar) that proves the same outcome (verdict fix).
68286
- 4. ENTRY-SCREEN DRIFT ("unexpected screen"/"screen drift"): the app started on a DIFFERENT screen than recorded; the fix is usually a MISSING NAVIGATION step (e.g. tap the onboarding/welcome CTA present in the tree before the recorded first step). Author it from the tree if you can (verdict fix); if a single static tree is insufficient, return needs_device.
68458
+ 4. EXTERNAL AUTH HANDOFF: if the failure says the foreground app changed outside the target app after a Login / Sign In tap, and the steps never enter email/password credentials, do NOT heal by choosing Google/Apple/Facebook/SSO or another external app. If the test later expects Logout/session state, return test_invalid or prereq_unmet: the recorded test lacks a real credential setup.
68459
+ 5. ENTRY-SCREEN DRIFT ("unexpected screen"/"screen drift"): the app started on a DIFFERENT screen than recorded; the fix is usually a MISSING NAVIGATION step (e.g. tap the onboarding/welcome CTA present in the tree before the recorded first step). Author it from the tree if you can (verdict fix); if a single static tree is insufficient, return needs_device.
68287
68460
  ## Response \u2014 STRICT JSON, exactly one verdict:
68288
68461
  {"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps, your changes applied, order/metadata preserved>],"reason":"what changed and why","confidence":0.0-1.0}
68289
68462
  {"verdict":"app_bug","reason":"app crashed/blank/5xx (NOT rate-limit)"}
@@ -68340,6 +68513,104 @@ For a fix, return the COMPLETE steps array (not a diff).`;
68340
68513
  function isDrift(error2) {
68341
68514
  return /unexpected screen|screen drift/i.test(error2 ?? "");
68342
68515
  }
68516
+ function mobileStepSearchText(step) {
68517
+ const rawTarget = step.target;
68518
+ const structuredTarget = rawTarget && typeof rawTarget === "object" ? rawTarget : void 0;
68519
+ const bundle = structuredTarget?.locatorBundle;
68520
+ const stepValue = step.value;
68521
+ return [
68522
+ step.action,
68523
+ step.description,
68524
+ typeof stepValue === "string" ? stepValue : void 0,
68525
+ typeof rawTarget === "string" ? rawTarget : void 0,
68526
+ structuredTarget?.primary,
68527
+ bundle?.text,
68528
+ bundle?.contentDesc,
68529
+ bundle?.accessibilityId,
68530
+ bundle?.resourceId,
68531
+ bundle?.className,
68532
+ bundle?.xpath
68533
+ ].filter((value) => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
68534
+ }
68535
+ function detectInvalidExternalAuthSetup(steps, error2) {
68536
+ if (!/foreground app changed|outside target app|com\.google\.android\.gms|external app/i.test(error2 ?? "")) {
68537
+ return null;
68538
+ }
68539
+ const hasBareLoginTap = steps.some((step) => {
68540
+ if (!/\btap\b/i.test(step.action))
68541
+ return false;
68542
+ const text = mobileStepSearchText(step);
68543
+ if (/\b(logout|log\s*out|sign\s*out)\b/.test(text))
68544
+ return false;
68545
+ return /\b(sign\s*in|signin|log\s*in|login)\b/.test(text);
68546
+ });
68547
+ if (!hasBareLoginTap)
68548
+ return null;
68549
+ const hasCredentialEntry = steps.some((step) => {
68550
+ if (!/\b(type|fill|input|enter)\b/i.test(step.action))
68551
+ return false;
68552
+ const text = mobileStepSearchText(step);
68553
+ return /\b(email|e-mail|username|user\s*name|password|passcode|pin|\{\{test_user_email\}\}|\{\{test_user_password\}\})\b/.test(text);
68554
+ });
68555
+ if (hasCredentialEntry)
68556
+ return null;
68557
+ return "bare Login/Sign In tap left the target app without any recorded credential setup; auth test is invalid until discovery records a real credential path";
68558
+ }
68559
+ function isCredentialFieldStep(step) {
68560
+ const text = mobileStepSearchText(step);
68561
+ return /\b(email|e-mail|username|user\s*name|password|passcode|pin|\{\{test_user_email\}\}|\{\{test_user_password\}\})\b/.test(text);
68562
+ }
68563
+ function isCredentialSubmitTap(step) {
68564
+ if (!/\b(tap|click|press)\b/i.test(step.action))
68565
+ return false;
68566
+ const text = mobileStepSearchText(step);
68567
+ 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)) {
68568
+ return false;
68569
+ }
68570
+ return /\b(sign\s*in|signin|log\s*in|login|continue|submit)\b/.test(text);
68571
+ }
68572
+ function detectInvalidAuthValidationSetup(steps, error2) {
68573
+ if (!/\b(element not found|no such element|not visible|timeout|assert|could not be located|unexpected screen|screen drift)\b/i.test(error2 ?? "")) {
68574
+ return null;
68575
+ }
68576
+ const allText = steps.map(mobileStepSearchText).join(" ");
68577
+ if (!/\b(email|e-mail|username|user\s*name|password|passcode|pin)\b/.test(allText))
68578
+ return null;
68579
+ const uiOnlyCredentialScenario = /\b(mask|masked|unmask|visibility|visible password|show password|hide password|keyboard|ime|input type|max length|maxlength|character limit)\b/.test(allText);
68580
+ let lastCredentialEditOrder = -1;
68581
+ for (const step of steps) {
68582
+ if (!/\b(type|fill|input|enter|clear)\b/i.test(step.action))
68583
+ continue;
68584
+ if (!isCredentialFieldStep(step))
68585
+ continue;
68586
+ lastCredentialEditOrder = Math.max(lastCredentialEditOrder, step.order ?? -1);
68587
+ }
68588
+ if (lastCredentialEditOrder < 0)
68589
+ return null;
68590
+ if (uiOnlyCredentialScenario) {
68591
+ const hasCredentialFormEvidence = steps.some((step) => {
68592
+ if ((step.order ?? -1) >= lastCredentialEditOrder)
68593
+ return false;
68594
+ const text = mobileStepSearchText(step);
68595
+ 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);
68596
+ });
68597
+ if (hasCredentialFormEvidence)
68598
+ return null;
68599
+ 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";
68600
+ }
68601
+ const hasSubmitAfterCredentialEdit = steps.some((step) => (step.order ?? -1) > lastCredentialEditOrder && isCredentialSubmitTap(step));
68602
+ if (hasSubmitAfterCredentialEdit)
68603
+ return null;
68604
+ const hasValidationAssertion = steps.some((step) => {
68605
+ if (!/\b(assert|wait)\b/i.test(step.action))
68606
+ return false;
68607
+ const text = mobileStepSearchText(step);
68608
+ 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);
68609
+ });
68610
+ if (hasValidationAssertion)
68611
+ return null;
68612
+ return "credential field was edited but the test never submits the form or asserts a validation result; stale auth-validation test is invalid until generation records a complete flow";
68613
+ }
68343
68614
  function stripCodeFences(text) {
68344
68615
  const trimmed = text.trim();
68345
68616
  const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/m.exec(trimmed);
@@ -68598,6 +68869,24 @@ ${args.directive}`);
68598
68869
  const registryPresent = options.selectorRegistryContext != null;
68599
68870
  const platform3 = target.platform;
68600
68871
  const trimmedLogs = tailLimit(ctx.logs ?? "", MAX_LOG_CHARS);
68872
+ const invalidExternalAuthSetup = detectInvalidExternalAuthSetup(steps, ctx.error);
68873
+ if (invalidExternalAuthSetup) {
68874
+ return {
68875
+ passed: false,
68876
+ verdict: "test_invalid",
68877
+ healReason: invalidExternalAuthSetup,
68878
+ attemptsUsed: 0
68879
+ };
68880
+ }
68881
+ const invalidAuthValidationSetup = detectInvalidAuthValidationSetup(steps, ctx.error);
68882
+ if (invalidAuthValidationSetup) {
68883
+ return {
68884
+ passed: false,
68885
+ verdict: "test_invalid",
68886
+ healReason: invalidAuthValidationSetup,
68887
+ attemptsUsed: 0
68888
+ };
68889
+ }
68601
68890
  let attemptCap = base2;
68602
68891
  let recoverableUsed = 0;
68603
68892
  let bonusUsed = 0;
@@ -68783,7 +69072,7 @@ ${args.directive}`);
68783
69072
  removeTags: script.removeTags
68784
69073
  };
68785
69074
  }
68786
- var DEFAULT_PHASE2_MAX_PASSES = 6;
69075
+ var DEFAULT_PHASE2_MAX_PASSES = 4;
68787
69076
  exports.MOBILE_LIVE_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Appium (mobile UI automation) test with LIVE device access (Phase 2). You receive: the failing test STEPS as JSON, the PRIOR failure error, and the LIVE current screen accessibility tree observed on-device RIGHT NOW. Unlike a frozen failure snapshot, this LIVE tree is the REAL screen the app is on this instant.
68788
69077
  ## Key insight \u2014 ENTRY-SCREEN DRIFT
68789
69078
  The recorded flow may have expected a DIFFERENT starting screen than the one shown. When the LIVE screen is an earlier/other screen (e.g. an onboarding / welcome / splash screen), the fix is almost always a MISSING NAVIGATION step: AUTHOR the leading step(s) (e.g. a tap on the onboarding CTA present in the tree) that reach the recorded first screen, then keep the recorded steps. This is exactly what a static failing-source cannot reveal but a LIVE screen can.
@@ -68792,7 +69081,8 @@ The recorded flow may have expected a DIFFERENT starting screen than the one sho
68792
69081
  2. If they differ, PREPEND the missing navigation step(s), authored from IDENTITY locators (resource-id, accessibility-id, or EXACT @text=) that are PRESENT in the LIVE tree \u2014 never a loose contains().
68793
69082
  3. If a locator is simply wrong, swap it for a stronger identity locator present in the LIVE tree.
68794
69083
  3b. If the failing step ASSERTS one-time / data-dependent UI (an empty-state CTA or first-run prompt) that is absent from the LIVE tree while the screen is otherwise correct, the assert is STALE \u2014 replace it with a durable element of the same screen (tab label, header, nav bar) and keep the rest.
68795
- 4. If you cannot yet SEE the screen you need, use "observe" to advance ONE step, then you will be re-shown the new live screen.
69084
+ 4. If the prior failure says the foreground app changed outside the target app after a Login / Sign In tap, and the steps never enter email/password credentials, do NOT observe/tap Google/Apple/Facebook/SSO. For logout/session tests, return test_invalid or prereq_unmet because the recorded test lacks a real credential setup.
69085
+ 5. If you cannot yet SEE the screen you need, use "observe" to advance ONE step, then you will be re-shown the new live screen.
68796
69086
  ## Response \u2014 STRICT JSON, exactly one verdict:
68797
69087
  {"verdict":"fix","steps":[<FULL healed MobileTestStep array, all steps incl. any prepended navigation, order/metadata coherent>],"reason":"what changed and why","confidence":0.0-1.0}
68798
69088
  {"verdict":"observe","action":{"type":"tap","target":"mobile.text('<exact label visible in the live tree>')"}}
@@ -329894,7 +330184,7 @@ var require_package4 = __commonJS({
329894
330184
  "package.json"(exports, module) {
329895
330185
  module.exports = {
329896
330186
  name: "@validate.qa/runner",
329897
- version: "1.0.30",
330187
+ version: "1.0.31",
329898
330188
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
329899
330189
  bin: {
329900
330190
  "validate-runner": "dist/cli.js"
@@ -330043,6 +330333,8 @@ var MAX_RETRIES = 3;
330043
330333
  var STARTUP_TIMEOUT_MS = 3e4;
330044
330334
  var HEALTH_CHECK_INTERVAL_MS = 5e3;
330045
330335
  var STABLE_AFTER_MS = 6e4;
330336
+ var DEFAULT_APPIUM_LOG_LEVEL = "warn";
330337
+ var MAX_APPIUM_LOG_LINE_CHARS = 4e3;
330046
330338
  var appiumProcess = null;
330047
330339
  var serverState = {
330048
330340
  running: false,
@@ -330102,6 +330394,23 @@ function appendLog(line) {
330102
330394
  logs = logs.slice(-MAX_LOG_LINES);
330103
330395
  }
330104
330396
  }
330397
+ function resolveAppiumLogLevel() {
330398
+ const raw = process.env.APPIUM_LOG_LEVEL?.trim();
330399
+ if (raw && /^[a-z]+(?::[a-z]+)?$/i.test(raw)) return raw;
330400
+ return DEFAULT_APPIUM_LOG_LEVEL;
330401
+ }
330402
+ function buildAppiumArgs(port, drivers) {
330403
+ const args = ["--port", String(port), "--log-level", resolveAppiumLogLevel()];
330404
+ if (drivers && drivers.length > 0) {
330405
+ args.push("--use-drivers", drivers.join(","));
330406
+ }
330407
+ return args;
330408
+ }
330409
+ function sanitizeAppiumLogLine(line) {
330410
+ const redacted = line.replace(/"text"\s*:\s*"[^"]*"/g, '"text":"[REDACTED]"').replace(/"password"\s*:\s*"[^"]*"/gi, '"password":"[REDACTED]"').replace(/"token"\s*:\s*"[^"]*"/gi, '"token":"[REDACTED]"').replace(/"value"\s*:\s*\[(?:"[^"]*",?){8,}\]/g, '"value":["[REDACTED]"]').replace(/"value"\s*:\s*"[A-Za-z0-9+/=]{512,}"/g, '"value":"[BASE64_REDACTED]"');
330411
+ if (redacted.length <= MAX_APPIUM_LOG_LINE_CHARS) return redacted;
330412
+ return `${redacted.slice(0, MAX_APPIUM_LOG_LINE_CHARS)}\u2026 [truncated ${redacted.length - MAX_APPIUM_LOG_LINE_CHARS} chars]`;
330413
+ }
330105
330414
  async function checkAppiumHealth(port = serverState.port) {
330106
330415
  try {
330107
330416
  const controller = new AbortController();
@@ -330134,10 +330443,7 @@ async function startAppiumServer(options) {
330134
330443
  return serverState;
330135
330444
  }
330136
330445
  const port = options?.port ?? APPIUM_PORT;
330137
- const args = ["--port", String(port)];
330138
- if (options?.drivers && options.drivers.length > 0) {
330139
- args.push("--use-drivers", options.drivers.join(","));
330140
- }
330446
+ const args = buildAppiumArgs(port, options?.drivers);
330141
330447
  intentionalStop = false;
330142
330448
  lastStartOptions = { ...lastStartOptions, ...options };
330143
330449
  if (appiumGaveUp) {
@@ -330156,14 +330462,14 @@ async function startAppiumServer(options) {
330156
330462
  serverState.pid = appiumProcess.pid ?? null;
330157
330463
  serverState.port = port;
330158
330464
  appiumProcess.stdout?.on("data", (data) => {
330159
- const line = data.toString().trim();
330465
+ const line = sanitizeAppiumLogLine(data.toString().trim());
330160
330466
  if (line) {
330161
330467
  appendLog(`[appium:stdout] ${line}`);
330162
330468
  options?.onLog?.(line);
330163
330469
  }
330164
330470
  });
330165
330471
  appiumProcess.stderr?.on("data", (data) => {
330166
- const line = data.toString().trim();
330472
+ const line = sanitizeAppiumLogLine(data.toString().trim());
330167
330473
  if (line) {
330168
330474
  appendLog(`[appium:stderr] ${line}`);
330169
330475
  options?.onLog?.(line);
@@ -330322,6 +330628,17 @@ function resetServerState(port = APPIUM_PORT, restartCount = 0) {
330322
330628
  // src/services/mobile/appium-actions.ts
330323
330629
  var APPIUM_REQUEST_TIMEOUT_MS = 1e4;
330324
330630
  var ELEMENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
330631
+ var APPIUM_DRIVER_DEAD_RE = /instrumentation process is not running|cannot be proxied to UiAutomator2 server|socket hang up|ECONNRESET|invalid session id|no such session/i;
330632
+ var AppiumDriverDeadError = class extends Error {
330633
+ isAppiumDriverDead = true;
330634
+ constructor(message) {
330635
+ super(message);
330636
+ this.name = "AppiumDriverDeadError";
330637
+ }
330638
+ };
330639
+ function isAppiumDriverDeadError(err) {
330640
+ return err instanceof AppiumDriverDeadError || typeof err === "object" && err !== null && err.isAppiumDriverDead === true;
330641
+ }
330325
330642
  async function appiumRequest(path, options) {
330326
330643
  const url = `http://localhost:${getAppiumState().port}${path}`;
330327
330644
  const res = await fetch(url, {
@@ -330331,6 +330648,9 @@ async function appiumRequest(path, options) {
330331
330648
  });
330332
330649
  if (!res.ok) {
330333
330650
  const body = await res.text().catch(() => "");
330651
+ if (APPIUM_DRIVER_DEAD_RE.test(body)) {
330652
+ throw new AppiumDriverDeadError(`Appium driver session is not usable (${res.status}): ${body.slice(0, 200)}`);
330653
+ }
330334
330654
  throw new Error(`Appium error ${res.status}: ${body.slice(0, 200)}`);
330335
330655
  }
330336
330656
  return res.json();
@@ -330345,7 +330665,8 @@ async function getScreenshot(sessionPath) {
330345
330665
  try {
330346
330666
  const value = readValue(await appiumRequest(`${sessionPath}/screenshot`));
330347
330667
  return typeof value === "string" ? value : null;
330348
- } catch {
330668
+ } catch (err) {
330669
+ if (isAppiumDriverDeadError(err)) throw err;
330349
330670
  return null;
330350
330671
  }
330351
330672
  }
@@ -330353,7 +330674,8 @@ async function getPageSource(sessionPath) {
330353
330674
  try {
330354
330675
  const value = readValue(await appiumRequest(`${sessionPath}/source`));
330355
330676
  return typeof value === "string" ? value : null;
330356
- } catch {
330677
+ } catch (err) {
330678
+ if (isAppiumDriverDeadError(err)) throw err;
330357
330679
  return null;
330358
330680
  }
330359
330681
  }
@@ -330369,7 +330691,8 @@ async function getWindowSize(sessionPath) {
330369
330691
  return { width: w, height: h };
330370
330692
  }
330371
330693
  }
330372
- } catch {
330694
+ } catch (err) {
330695
+ if (isAppiumDriverDeadError(err)) throw err;
330373
330696
  }
330374
330697
  if (attempt < retries - 1) {
330375
330698
  await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
@@ -331713,6 +332036,7 @@ var COLD_BOOT_ENTRY_TIMEOUT_MS = 3e4;
331713
332036
  var VERIFICATION_ACTIONS = /* @__PURE__ */ new Set(["assertVisible", "assertText", "waitForElement"]);
331714
332037
  var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
331715
332038
  var WD_ACTION_TIMEOUT_MS = 3e4;
332039
+ var APPIUM_DRIVER_DEAD_RE2 = /instrumentation process is not running|cannot be proxied to UiAutomator2 server|socket hang up|ECONNRESET/i;
331716
332040
  var MAX_SESSION_CREATE_ATTEMPTS = 3;
331717
332041
  var execFileAsync2 = promisify2(execFile2);
331718
332042
  var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
@@ -331749,6 +332073,9 @@ async function wdRequest(path, options) {
331749
332073
  if (res.status === 404 && /no such session|invalid session id|session is either terminated|deleted session/i.test(body)) {
331750
332074
  throw new WebDriverTransportError(`WebDriver session is no longer available: ${body.slice(0, 200)}`);
331751
332075
  }
332076
+ if (res.status === 500 && APPIUM_DRIVER_DEAD_RE2.test(body)) {
332077
+ throw new WebDriverTransportError(`WebDriver driver process is not usable: ${body.slice(0, 200)}`);
332078
+ }
331752
332079
  if (res.status === 502 || res.status === 503 || res.status === 504) {
331753
332080
  throw new WebDriverTransportError(`Appium server unavailable (${res.status}): ${body.slice(0, 200)}`);
331754
332081
  }
@@ -336706,7 +337033,14 @@ ${finalVerifyResult.logs ?? ""}`;
336706
337033
  if (getBridgeState().hasActiveSession) {
336707
337034
  throw new Error("CONFIG_ISSUE: the shared mobile device is busy with an active recording session; mobile discovery will retry once it ends.");
336708
337035
  }
336709
- const mobileOnLog = (line) => onLog("progress", line);
337036
+ const mobileProgressLines = [];
337037
+ const mobileOnLog = (line) => {
337038
+ mobileProgressLines.push(line);
337039
+ if (mobileProgressLines.length > 500) {
337040
+ mobileProgressLines.splice(0, mobileProgressLines.length - 500);
337041
+ }
337042
+ onLog("progress", mobileProgressLines.join("\n"));
337043
+ };
336710
337044
  liveBrowserHandle.patch({
336711
337045
  mode: ctx.mode === "survey" ? "survey" : "discovery",
336712
337046
  testName: ctx.featureName ?? "Mobile discovery",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@validate.qa/runner",
3
- "version": "1.0.30",
3
+ "version": "1.0.31",
4
4
  "description": "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
5
5
  "bin": {
6
6
  "validate-runner": "dist/cli.js"