@validate.qa/runner 1.0.66 → 1.0.67

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 +286 -77
  2. package/dist/cli.mjs +286 -77
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2236,6 +2236,18 @@ function selectorForValue(value) {
2236
2236
  function isIdentityMobileXpath(value) {
2237
2237
  return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
2238
2238
  }
2239
+ function xpathLiteral(value) {
2240
+ if (!value.includes("'"))
2241
+ return `'${value}'`;
2242
+ if (!value.includes('"'))
2243
+ return `"${value}"`;
2244
+ const parts = value.split("'").map((p) => `'${p}'`);
2245
+ return `concat(${parts.join(`, "'", `)})`;
2246
+ }
2247
+ function identityTextXpath(value) {
2248
+ const lit = xpathLiteral(value);
2249
+ return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
2250
+ }
2239
2251
  function mobileLocatorBundleHasIdentity(bundle) {
2240
2252
  if (!bundle)
2241
2253
  return false;
@@ -2262,6 +2274,13 @@ function selectorCandidatesForStep(step) {
2262
2274
  candidates.push(`~${bundle.accessibilityId}`);
2263
2275
  if (nonEmpty(bundle.resourceId))
2264
2276
  candidates.push(`id=${bundle.resourceId}`);
2277
+ const isGateStep = step.action === "waitForElement" || step.action === "assertVisible" || step.action === "assertText";
2278
+ const textIdentity = isGateStep || !isEditableMobileClassName(bundle.className) ? bundle.text : void 0;
2279
+ for (const value of [textIdentity, bundle.contentDesc]) {
2280
+ const trimmed = nonEmpty(value);
2281
+ if (trimmed)
2282
+ candidates.push(identityTextXpath(trimmed));
2283
+ }
2265
2284
  if (nonEmpty(bundle.xpath))
2266
2285
  candidates.push(bundle.xpath);
2267
2286
  }
@@ -2710,7 +2729,7 @@ function uniq(values) {
2710
2729
  }
2711
2730
  return result;
2712
2731
  }
2713
- function xpathLiteral(value) {
2732
+ function xpathLiteral2(value) {
2714
2733
  if (!value.includes("'"))
2715
2734
  return `'${value}'`;
2716
2735
  if (!value.includes('"'))
@@ -2718,7 +2737,7 @@ function xpathLiteral(value) {
2718
2737
  return `concat(${value.split("'").map((part) => `'${part}'`).join(`, "'", `)})`;
2719
2738
  }
2720
2739
  function mobileTextXPath(value, platform3) {
2721
- const literal = xpathLiteral(value);
2740
+ const literal = xpathLiteral2(value);
2722
2741
  if (platform3 === "IOS") {
2723
2742
  return `//*[@name=${literal} or @label=${literal} or @value=${literal}]`;
2724
2743
  }
@@ -3866,6 +3885,43 @@ function accumulateStatements(lines) {
3866
3885
  }
3867
3886
  return out;
3868
3887
  }
3888
+ function classifyMobileAssertValue(value, context = {}) {
3889
+ const text = value.trim();
3890
+ if (!text)
3891
+ return null;
3892
+ if (/^\{\{[A-Z0-9_]+\}\}$/.test(text))
3893
+ return null;
3894
+ const t1 = matchFirst(TIER1_PATTERNS, text);
3895
+ if (t1)
3896
+ return { tier: 1, reason: t1[1] };
3897
+ const authIdentityValues = [];
3898
+ if (context.authenticatedUserEmail)
3899
+ authIdentityValues.push(context.authenticatedUserEmail);
3900
+ if (context.authenticatedUserName && context.authenticatedUserName.length >= 3) {
3901
+ authIdentityValues.push(context.authenticatedUserName);
3902
+ }
3903
+ for (const auth of authIdentityValues) {
3904
+ if (auth.length >= 3 && text.toLowerCase().includes(auth.toLowerCase())) {
3905
+ return { tier: 2, reason: "asserts the authenticated user identity \u2014 fails under any other test account" };
3906
+ }
3907
+ }
3908
+ const knownValues = [
3909
+ ...context.knownUserInputs ?? [],
3910
+ ...context.scenarioCreatedValues ?? []
3911
+ ].filter((v) => {
3912
+ for (const auth of authIdentityValues) {
3913
+ if (auth.length >= 3 && v.toLowerCase().includes(auth.toLowerCase()))
3914
+ return false;
3915
+ }
3916
+ return true;
3917
+ });
3918
+ if (containsKnownValue(text, knownValues))
3919
+ return null;
3920
+ const t2 = matchFirst(TIER2_PATTERNS, text);
3921
+ if (t2)
3922
+ return { tier: 2, reason: t2[1] };
3923
+ return null;
3924
+ }
3869
3925
  function parenDepthDelta(text) {
3870
3926
  let depth = 0;
3871
3927
  let quote = null;
@@ -6039,6 +6095,7 @@ __export(dist_exports, {
6039
6095
  classifyAssertionBlock: () => classifyAssertionBlock,
6040
6096
  classifyAssertionLine: () => classifyAssertionLine,
6041
6097
  classifyErrorMessage: () => classifyErrorMessage,
6098
+ classifyMobileAssertValue: () => classifyMobileAssertValue,
6042
6099
  classifyTestHealth: () => classifyTestHealth,
6043
6100
  coldSessionConfigMessage: () => coldSessionConfigMessage,
6044
6101
  computeMobileSwipePointers: () => computeMobileSwipePointers,
@@ -80883,6 +80940,45 @@ ${constraints.join("\n")}`);
80883
80940
  }
80884
80941
  });
80885
80942
 
80943
+ // ../runner-core/dist/services/discovery-lint-flags.js
80944
+ var require_discovery_lint_flags = __commonJS({
80945
+ "../runner-core/dist/services/discovery-lint-flags.js"(exports2) {
80946
+ "use strict";
80947
+ Object.defineProperty(exports2, "__esModule", { value: true });
80948
+ exports2.structuralLocatorLintMode = structuralLocatorLintMode;
80949
+ exports2.isPwMethodPreflightEnabled = isPwMethodPreflightEnabled;
80950
+ exports2.isVqaLoginDedupeEnabled = isVqaLoginDedupeEnabled;
80951
+ exports2.mobileSelectorQualityLintMode = mobileSelectorQualityLintMode;
80952
+ exports2.isTestgenEmailVerifyEnabled = isTestgenEmailVerifyEnabled;
80953
+ function strictOn(value) {
80954
+ return value === "true" || value === "1";
80955
+ }
80956
+ function structuralLocatorLintMode() {
80957
+ const v = (process.env.DISCOVERY_STRUCTURAL_LOCATOR_LINT || "").trim().toLowerCase();
80958
+ if (v === "warn")
80959
+ return "warn";
80960
+ if (v === "true" || v === "1" || v === "enforce")
80961
+ return "enforce";
80962
+ return "off";
80963
+ }
80964
+ function isPwMethodPreflightEnabled() {
80965
+ return strictOn(process.env.DISCOVERY_PW_METHOD_PREFLIGHT);
80966
+ }
80967
+ function isVqaLoginDedupeEnabled() {
80968
+ return strictOn(process.env.DISCOVERY_VQALOGIN_DEDUPE);
80969
+ }
80970
+ function mobileSelectorQualityLintMode() {
80971
+ const v = (process.env.MOBILE_SELECTOR_QUALITY_LINT || "").trim().toLowerCase();
80972
+ if (v === "true" || v === "1" || v === "enforce")
80973
+ return "enforce";
80974
+ return "off";
80975
+ }
80976
+ function isTestgenEmailVerifyEnabled() {
80977
+ return strictOn(process.env.DISCOVERY_TESTGEN_EMAIL_VERIFY);
80978
+ }
80979
+ }
80980
+ });
80981
+
80886
80982
  // ../runner-core/dist/services/mobile/mobile-generator.js
80887
80983
  var require_mobile_generator = __commonJS({
80888
80984
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports2) {
@@ -80902,6 +80998,7 @@ var require_mobile_generator = __commonJS({
80902
80998
  var ai_queue_js_1 = require_ai_queue();
80903
80999
  var ai_retry_js_1 = require_ai_retry();
80904
81000
  var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
81001
+ var discovery_lint_flags_js_1 = require_discovery_lint_flags();
80905
81002
  var mobile_source_parser_js_1 = require_mobile_source_parser();
80906
81003
  var mobile_source_parser_js_2 = require_mobile_source_parser();
80907
81004
  var mobile_observation_js_1 = require_mobile_observation();
@@ -81071,7 +81168,15 @@ var require_mobile_generator = __commonJS({
81071
81168
  properties: {
81072
81169
  name: { type: "string", description: 'Descriptive test name (e.g. "Login - Happy Path").' },
81073
81170
  description: { type: "string", description: "One-line description of what the test proves." },
81074
- verified: { type: "boolean", description: "True only if you asserted the outcome LIVE with an assert tool." }
81171
+ verified: { type: "boolean", description: "True only if you asserted the outcome LIVE with an assert tool." },
81172
+ suspectedBug: {
81173
+ type: "boolean",
81174
+ description: "True when the app itself misbehaved (crash, error screen, broken flow) and the scenario could not behave as specified. Requires bugDescription and at least one honored assert proving what you observed."
81175
+ },
81176
+ bugDescription: {
81177
+ type: "string",
81178
+ description: "What is broken in the APP (not the test) \u2014 concrete screen + observed vs expected."
81179
+ }
81075
81180
  },
81076
81181
  required: ["name", "description", "verified"]
81077
81182
  }
@@ -81317,6 +81422,8 @@ var require_mobile_generator = __commonJS({
81317
81422
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
81318
81423
  - Assert only DURABLE elements \u2014 screen chrome that exists on every visit (tab labels, headers, nav bars, the entity you just created). NEVER assert one-time / data-dependent UI: empty-state CTAs, first-run or onboarding prompts, or counters \u2014 they vanish as soon as the account has data, and the test then fails forever on a healthy app.
81319
81424
  - Do NOT trigger irreversible or destructive side effects \u2014 real payments; sending messages to others; and ESPECIALLY account/data deletion, deactivation, or any "type DELETE to confirm" gate (the runner hard-blocks destructive taps and confirmation keystrokes). Note them and assert what you safely can.
81425
+ - STATE OWNERSHIP for mutation scenarios (edit / rename / archive / remove): CREATE the record first in this same recording, then act on the row YOU created \u2014 never mutate pre-existing data (it may not exist on a clean install, so the replay fails forever, and another test may depend on it). If the app offers no way to create one, do NOT act on someone else's data: assert what you can safely prove and finish with verified:false, explaining why in the description.
81426
+ - If the APP itself misbehaves (crash, error screen, a flow that cannot proceed as specified), do not force a green path: assert the failure you observed (e.g. the error text), then call finish_generate with suspectedBug:true and a concrete bugDescription of what is broken in the app.
81320
81427
  - 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.
81321
81428
  - 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.
81322
81429
  - 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.
@@ -81422,6 +81529,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
81422
81529
  let assertCount = 0;
81423
81530
  let finish = null;
81424
81531
  let finishIntentRejections = 0;
81532
+ let finishStrengthRejections = 0;
81425
81533
  let emptyTurnsAfterFinishReject = 0;
81426
81534
  let latest = null;
81427
81535
  const getLatest = () => latest;
@@ -81914,31 +82022,49 @@ ${statePacket}`, redactionCredentials) },
81914
82022
  const claimedVerified = Boolean(toolArgs.verified);
81915
82023
  const intentReason = scenarioIntentMismatchReason(scenario, actions);
81916
82024
  const verifiedWithoutAssert = claimedVerified && assertCount === 0;
81917
- const canReject = finishIntentRejections < MAX_FINISH_INTENT_REJECTIONS && iteration < maxIterations && (intentReason !== null || verifiedWithoutAssert);
81918
- if (canReject) {
81919
- finishIntentRejections += 1;
82025
+ const intentStyleReject = finishIntentRejections < MAX_FINISH_INTENT_REJECTIONS && iteration < maxIterations && (intentReason !== null || verifiedWithoutAssert);
82026
+ let strengthRejectReason = null;
82027
+ if (!intentStyleReject && claimedVerified && assertCount > 0 && intentReason === null && finishStrengthRejections < 1 && iteration < maxIterations) {
82028
+ const verdict = evaluateMobileAssertionStrength(actions);
82029
+ if (verdict.severity === "block") {
82030
+ strengthRejectReason = verdict.reason;
82031
+ }
82032
+ }
82033
+ if (intentStyleReject || strengthRejectReason !== null) {
82034
+ if (intentStyleReject)
82035
+ finishIntentRejections += 1;
82036
+ if (strengthRejectReason !== null)
82037
+ finishStrengthRejections += 1;
81920
82038
  const reasons = [];
81921
82039
  if (intentReason)
81922
82040
  reasons.push(intentReason);
81923
82041
  if (verifiedWithoutAssert) {
81924
82042
  reasons.push("claimed verified:true but never called mobile_assert_visible / mobile_assert_text");
81925
82043
  }
82044
+ if (strengthRejectReason !== null) {
82045
+ reasons.push(`assertion strength: ${strengthRejectReason} \u2014 your only live proof re-checks a control you already used. Assert the scenario OUTCOME instead: an element on the resulting screen, the created/updated row, or the success/error message.`);
82046
+ }
81926
82047
  const reasonText = reasons.join("; ");
81927
- log2(` finish_generate REJECTED (${finishIntentRejections}/${MAX_FINISH_INTENT_REJECTIONS}): ${reasonText}`);
82048
+ log2(` finish_generate REJECTED (intent ${finishIntentRejections}/${MAX_FINISH_INTENT_REJECTIONS}, strength ${finishStrengthRejections}/1): ${reasonText}`);
81928
82049
  pushResult({
81929
82050
  ok: false,
81930
82051
  error: `Cannot finish yet \u2014 the recorded steps do not complete this scenario: ${reasonText}. Continue driving the app (fill fields, commit forms, navigate, assert the REAL outcome), then call finish_generate again. Do NOT re-open menus or thrash Back.`,
81931
82052
  intentMismatch: intentReason,
81932
82053
  verifiedWithoutAssert,
81933
- rejection: finishIntentRejections,
81934
- maxRejections: MAX_FINISH_INTENT_REJECTIONS
82054
+ ...strengthRejectReason !== null ? { strengthBlock: strengthRejectReason } : {},
82055
+ rejection: finishIntentRejections + finishStrengthRejections,
82056
+ maxRejections: MAX_FINISH_INTENT_REJECTIONS + 1
81935
82057
  });
81936
82058
  continue;
81937
82059
  }
81938
82060
  finish = {
81939
82061
  name: redactCredentialValues(nonEmpty3(String(toolArgs.name ?? "")) ?? scenario.name, redactionCredentials),
81940
82062
  description: redactCredentialValues(nonEmpty3(String(toolArgs.description ?? "")) ?? scenario.goal, redactionCredentials),
81941
- verified: claimedVerified
82063
+ verified: claimedVerified,
82064
+ ...toolArgs.suspectedBug === true ? {
82065
+ suspectedBug: true,
82066
+ bugDescription: redactCredentialValues(nonEmpty3(String(toolArgs.bugDescription ?? "")) ?? "", redactionCredentials)
82067
+ } : {}
81942
82068
  };
81943
82069
  log2(` finish_generate: "${finish.name}" (claimed verified: ${finish.verified}, asserts: ${assertCount})`);
81944
82070
  pushResult({ acknowledged: true });
@@ -82002,6 +82128,7 @@ ${statePacket}`, redactionCredentials) },
82002
82128
  ok: false,
82003
82129
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
82004
82130
  });
82131
+ const recordedTypeValues = () => recordedActions.filter((action) => action.type === "TYPE" && typeof action.value === "string" && action.value.length > 0).map((action) => action.value);
82005
82132
  const actionPayload = (result, absorbed) => {
82006
82133
  const blocked = Boolean(absorbed.externalBlocked || absorbed.transientExternal);
82007
82134
  return {
@@ -82402,7 +82529,8 @@ ${statePacket}`, redactionCredentials) },
82402
82529
  latest,
82403
82530
  screenName,
82404
82531
  platform: platform3,
82405
- recordAction
82532
+ recordAction,
82533
+ typedValues: recordedTypeValues
82406
82534
  });
82407
82535
  if (!recorded.ok)
82408
82536
  return recorded;
@@ -82471,6 +82599,15 @@ ${statePacket}`, redactionCredentials) },
82471
82599
  error: `Live text assertion failed: expected ${JSON.stringify(expected)} was not present on ref ${element.ref}. Re-snapshot after the outcome appears and assert the text that is actually visible.`
82472
82600
  };
82473
82601
  }
82602
+ const volatileExpected = (0, shared_1.classifyMobileAssertValue)(expected, {
82603
+ knownUserInputs: recordedTypeValues()
82604
+ });
82605
+ if (volatileExpected) {
82606
+ return {
82607
+ ok: false,
82608
+ error: `Expected text ${JSON.stringify(expected)} is ${volatileExpected.tier === 1 ? "transient one-shot UI" : "session-dynamic content"} (${volatileExpected.reason}) \u2014 it changes between runs, so the test would fail forever on a healthy app. Assert durable post-state instead: an element unique to the destination screen, a stable label, or text your flow itself entered.`
82609
+ };
82610
+ }
82474
82611
  const metadata = metadataForElement(element, screenName(), platform3);
82475
82612
  const expectedNeedle = expected.trim().toLowerCase();
82476
82613
  const identityValues = [element.accessibilityId, element.resourceId].map((value) => value?.trim().toLowerCase()).filter((value) => Boolean(value));
@@ -82526,6 +82663,17 @@ ${statePacket}`, redactionCredentials) },
82526
82663
  if (!accessibilityId && !text) {
82527
82664
  return { ok: false, error: "mobile_assert_visible needs a ref (preferred), accessibilityId, or text." };
82528
82665
  }
82666
+ if (!accessibilityId && text) {
82667
+ const volatileIdentity = (0, shared_1.classifyMobileAssertValue)(text, {
82668
+ knownUserInputs: ctx.typedValues?.() ?? []
82669
+ });
82670
+ if (volatileIdentity) {
82671
+ return {
82672
+ ok: false,
82673
+ error: `${JSON.stringify(text)} is ${volatileIdentity.tier === 1 ? "transient one-shot UI" : "session-dynamic content"} (${volatileIdentity.reason}) \u2014 a text identity must be durable to resolve at replay. Assert a stable element of the destination screen instead (prefer a ref from mobile_snapshot).`
82674
+ };
82675
+ }
82676
+ }
82529
82677
  const directTarget = accessibilityId ?? text;
82530
82678
  if (directTarget && isStructuralAssertionIdentity(directTarget)) {
82531
82679
  return {
@@ -83161,7 +83309,21 @@ ${statePacket}`, redactionCredentials) },
83161
83309
  if (claimedVerified && !verified) {
83162
83310
  log2(" downgraded verified -> false: model claimed verified but called zero assert tools.");
83163
83311
  }
83164
- const strengthVerdict = evaluateMobileAssertionStrength(actions);
83312
+ const baseStrengthVerdict = evaluateMobileAssertionStrength(actions);
83313
+ const bugDescription = recording.finish?.bugDescription?.trim() ?? "";
83314
+ const bugWitnessHonored = process.env.DISCOVERY_BUG_WITNESS_ENABLED === "true" && recording.finish?.suspectedBug === true && bugDescription.length >= 12 && recording.assertCount > 0;
83315
+ if (recording.finish?.suspectedBug === true && !bugWitnessHonored) {
83316
+ log2(" suspectedBug declared but NOT honored: " + (process.env.DISCOVERY_BUG_WITNESS_ENABLED !== "true" ? "bug-witness channel disabled (DISCOVERY_BUG_WITNESS_ENABLED)." : bugDescription.length < 12 ? "bugDescription too short." : "no honored assert backs the observation."));
83317
+ }
83318
+ const strengthVerdict = bugWitnessHonored ? {
83319
+ ...baseStrengthVerdict,
83320
+ ok: false,
83321
+ severity: "route-bug",
83322
+ reason: `Model declared an app bug during generation: ${bugDescription}`
83323
+ } : baseStrengthVerdict;
83324
+ if (bugWitnessHonored) {
83325
+ log2(` bug-witness honored \u2014 routing as app bug: ${bugDescription}`);
83326
+ }
83165
83327
  if (strengthVerdict.severity === "block" || strengthVerdict.severity === "route-bug") {
83166
83328
  if (verified) {
83167
83329
  log2(` downgraded verified -> false: assertion strength ${strengthVerdict.severity} \u2014 ${strengthVerdict.reason}`);
@@ -83177,7 +83339,11 @@ ${statePacket}`, redactionCredentials) },
83177
83339
  verified = false;
83178
83340
  }
83179
83341
  const platformTag2 = platform3 === "IOS" ? "@ios" : "@android";
83180
- const reviewTag = verified ? "@verified" : "@needs-review";
83342
+ const selectorLintEnforced = (0, discovery_lint_flags_js_1.mobileSelectorQualityLintMode)() === "enforce" && selectorQuality.summary.high > 0;
83343
+ if (selectorLintEnforced && verified) {
83344
+ log2(` selector-quality lint (enforce): ${selectorQuality.summary.high} high-severity locator issue(s) \u2014 tagging @needs-review.`);
83345
+ }
83346
+ const reviewTag = verified && !selectorLintEnforced ? "@verified" : "@needs-review";
83181
83347
  const tags = ["@discovered", "@mobile", platformTag2, reviewTag];
83182
83348
  if (strengthVerdict.severity === "downgrade")
83183
83349
  tags.push("@smoke");
@@ -86257,38 +86423,6 @@ var require_run_callback_proxy = __commonJS({
86257
86423
  }
86258
86424
  });
86259
86425
 
86260
- // ../runner-core/dist/services/discovery-lint-flags.js
86261
- var require_discovery_lint_flags = __commonJS({
86262
- "../runner-core/dist/services/discovery-lint-flags.js"(exports2) {
86263
- "use strict";
86264
- Object.defineProperty(exports2, "__esModule", { value: true });
86265
- exports2.structuralLocatorLintMode = structuralLocatorLintMode;
86266
- exports2.isPwMethodPreflightEnabled = isPwMethodPreflightEnabled;
86267
- exports2.isVqaLoginDedupeEnabled = isVqaLoginDedupeEnabled;
86268
- exports2.isTestgenEmailVerifyEnabled = isTestgenEmailVerifyEnabled;
86269
- function strictOn(value) {
86270
- return value === "true" || value === "1";
86271
- }
86272
- function structuralLocatorLintMode() {
86273
- const v = (process.env.DISCOVERY_STRUCTURAL_LOCATOR_LINT || "").trim().toLowerCase();
86274
- if (v === "warn")
86275
- return "warn";
86276
- if (v === "true" || v === "1" || v === "enforce")
86277
- return "enforce";
86278
- return "off";
86279
- }
86280
- function isPwMethodPreflightEnabled() {
86281
- return strictOn(process.env.DISCOVERY_PW_METHOD_PREFLIGHT);
86282
- }
86283
- function isVqaLoginDedupeEnabled() {
86284
- return strictOn(process.env.DISCOVERY_VQALOGIN_DEDUPE);
86285
- }
86286
- function isTestgenEmailVerifyEnabled() {
86287
- return strictOn(process.env.DISCOVERY_TESTGEN_EMAIL_VERIFY);
86288
- }
86289
- }
86290
- });
86291
-
86292
86426
  // ../runner-core/dist/services/auth-recipe.js
86293
86427
  var require_auth_recipe = __commonJS({
86294
86428
  "../runner-core/dist/services/auth-recipe.js"(exports2) {
@@ -347787,12 +347921,7 @@ Write valid JSON only (no markdown fences inside files). Be exhaustive.`;
347787
347921
  const o = value;
347788
347922
  if (!isNonEmptyString(o.name))
347789
347923
  return { ok: false, reason: "name: missing" };
347790
- if (!isNonEmptyString(o.appiumCode))
347791
- return { ok: false, reason: "appiumCode: missing" };
347792
- const code = o.appiumCode;
347793
- if (!/webdriverio|remote\s*\(|driver\s*\./i.test(code)) {
347794
- return { ok: false, reason: "appiumCode: not a WebdriverIO/Appium script" };
347795
- }
347924
+ const code = isNonEmptyString(o.appiumCode) ? o.appiumCode : "";
347796
347925
  if (!Array.isArray(o.steps) || o.steps.length === 0) {
347797
347926
  return { ok: false, reason: "steps: missing or empty" };
347798
347927
  }
@@ -351095,7 +351224,7 @@ var require_package4 = __commonJS({
351095
351224
  "package.json"(exports2, module2) {
351096
351225
  module2.exports = {
351097
351226
  name: "@validate.qa/runner",
351098
- version: "1.0.66",
351227
+ version: "1.0.67",
351099
351228
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
351100
351229
  bin: {
351101
351230
  "validate-runner": "dist/cli.js"
@@ -351491,6 +351620,9 @@ function sanitizeAppiumLogLine(line) {
351491
351620
  }
351492
351621
  function setAppiumWorkloadActive(active) {
351493
351622
  activeWorkloadCount = active ? activeWorkloadCount + 1 : Math.max(0, activeWorkloadCount - 1);
351623
+ if (activeWorkloadCount === 0) {
351624
+ consecutiveHealthFailures = 0;
351625
+ }
351494
351626
  }
351495
351627
  function shouldRestartAfterHealthFailure(workloadCount, failureCount) {
351496
351628
  return workloadCount <= 0 && failureCount >= HEALTH_FAILURES_BEFORE_RESTART;
@@ -351986,17 +352118,31 @@ function rectMatchesIntendedBounds(rect, bounds) {
351986
352118
  return rectCenterInBounds || boundsCenterInRect;
351987
352119
  }
351988
352120
  async function resolveTypeTargetElementId(sessionPath, bounds) {
351989
- const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
352121
+ const rawBox = bounds !== void 0 ? normaliseBounds(bounds) : null;
352122
+ const box = rawBox && rawBox.width > 0 && rawBox.height > 0 ? rawBox : null;
351990
352123
  let elementId = await getActiveElementId(sessionPath);
351991
- if (elementId && box && box.width > 0 && box.height > 0) {
352124
+ let rejectedElementId = null;
352125
+ let rejectedForMismatch = false;
352126
+ if (elementId && box) {
351992
352127
  const rect = await getElementRect(sessionPath, elementId);
351993
352128
  if (!rect || !rectMatchesIntendedBounds(rect, box)) {
352129
+ rejectedElementId = elementId;
352130
+ rejectedForMismatch = !!rect;
351994
352131
  elementId = null;
351995
352132
  }
351996
352133
  }
351997
352134
  if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
351998
352135
  await new Promise((resolve2) => setTimeout(resolve2, MOBILE_FOCUS_SETTLE_MS));
351999
352136
  elementId = await getActiveElementId(sessionPath);
352137
+ if (elementId && box) {
352138
+ const rect = await getElementRect(sessionPath, elementId);
352139
+ const provablyWrong = rect ? !rectMatchesIntendedBounds(rect, box) : rejectedForMismatch && elementId === rejectedElementId;
352140
+ if (provablyWrong) {
352141
+ throw new Error(
352142
+ "Could not focus the intended field: a different element kept keyboard focus after tapping the target bounds. Tap the field directly, then retry."
352143
+ );
352144
+ }
352145
+ }
352000
352146
  }
352001
352147
  return elementId;
352002
352148
  }
@@ -354572,6 +354718,13 @@ function buildLocatorStrategies(target, platform3) {
354572
354718
  if (bundle) {
354573
354719
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
354574
354720
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
354721
+ for (const value of [
354722
+ isEditableMobileClassName(bundle.className) ? void 0 : bundle.text,
354723
+ bundle.contentDesc
354724
+ ]) {
354725
+ const trimmed = value?.trim();
354726
+ if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath2(trimmed) });
354727
+ }
354575
354728
  if (bundle.xpath) strategies.push({ using: "xpath", value: bundle.xpath });
354576
354729
  }
354577
354730
  if (!(platform3 && hasPlatformLocator(target, platform3))) {
@@ -354589,14 +354742,14 @@ function dedupeStrategies(strategies) {
354589
354742
  (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
354590
354743
  );
354591
354744
  }
354592
- function xpathLiteral2(value) {
354745
+ function xpathLiteral3(value) {
354593
354746
  if (!value.includes("'")) return `'${value}'`;
354594
354747
  if (!value.includes('"')) return `"${value}"`;
354595
354748
  const parts = value.split("'").map((p) => `'${p}'`);
354596
354749
  return `concat(${parts.join(`, "'", `)})`;
354597
354750
  }
354598
- function identityTextXpath(value) {
354599
- const lit = xpathLiteral2(value);
354751
+ function identityTextXpath2(value) {
354752
+ const lit = xpathLiteral3(value);
354600
354753
  return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
354601
354754
  }
354602
354755
  function isIdentityXpath(value) {
@@ -354616,7 +354769,7 @@ function buildIdentityLocatorStrategies(target, platform3) {
354616
354769
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
354617
354770
  for (const value of [bundle.text, bundle.contentDesc]) {
354618
354771
  const trimmed = value?.trim();
354619
- if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
354772
+ if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath2(trimmed) });
354620
354773
  }
354621
354774
  if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
354622
354775
  }
@@ -355096,6 +355249,19 @@ async function readElementTextCandidates(sessionId, elementId, actualText) {
355096
355249
  }
355097
355250
  return values;
355098
355251
  }
355252
+ var controlPortSlotsByDevice = /* @__PURE__ */ new Map();
355253
+ function acquireControlPortSlot(deviceId) {
355254
+ const existing = controlPortSlotsByDevice.get(deviceId);
355255
+ if (existing !== void 0) return existing;
355256
+ const taken = new Set(controlPortSlotsByDevice.values());
355257
+ let slot = 0;
355258
+ while (taken.has(slot)) slot += 1;
355259
+ controlPortSlotsByDevice.set(deviceId, slot);
355260
+ return slot;
355261
+ }
355262
+ function releaseControlPortSlot(deviceId) {
355263
+ controlPortSlotsByDevice.delete(deviceId);
355264
+ }
355099
355265
  async function executeMobileTest(options) {
355100
355266
  const logLines = [];
355101
355267
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
@@ -355115,6 +355281,7 @@ async function executeMobileTest(options) {
355115
355281
  let captureStopped = false;
355116
355282
  let recordingStarted = false;
355117
355283
  let recordingStopped = false;
355284
+ let acquiredSlotDeviceId = null;
355118
355285
  let video;
355119
355286
  let failingSource;
355120
355287
  const stopCapture = async () => {
@@ -355217,7 +355384,8 @@ async function executeMobileTest(options) {
355217
355384
  log2(`Warning: Could not retrieve installed apps list. Proceeding with test execution.`);
355218
355385
  }
355219
355386
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
355220
- const leasedPortOffset = Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id));
355387
+ const leasedPortOffset = acquireControlPortSlot(selectedDevice.id);
355388
+ acquiredSlotDeviceId = selectedDevice.id;
355221
355389
  try {
355222
355390
  sessionId = await createSessionWithRetry({
355223
355391
  appId: options.target.appId,
@@ -355494,6 +355662,7 @@ async function executeMobileTest(options) {
355494
355662
  await deleteSession(sessionId);
355495
355663
  log2("Session destroyed");
355496
355664
  }
355665
+ if (acquiredSlotDeviceId) releaseControlPortSlot(acquiredSlotDeviceId);
355497
355666
  }
355498
355667
  }
355499
355668
  async function createMobileDiscoverySession(target, options) {
@@ -355539,21 +355708,28 @@ async function createMobileDiscoverySession(target, options) {
355539
355708
  isPhysical: selectedDevice.isPhysical === true,
355540
355709
  platformVersion: selectedDevice.platformVersion
355541
355710
  });
355542
- const sessionId = await createSessionWithRetry({
355543
- appId: target.appId,
355544
- platform: target.platform,
355545
- deviceId: selectedDevice.id,
355546
- platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
355547
- isPhysical: selectedDevice.isPhysical,
355548
- // Distinct control resources per concurrent device, including simulators.
355549
- portOffset: Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id))
355550
- });
355711
+ const discoveryPortSlotDeviceId = selectedDevice.id;
355712
+ let sessionId;
355713
+ try {
355714
+ sessionId = await createSessionWithRetry({
355715
+ appId: target.appId,
355716
+ platform: target.platform,
355717
+ deviceId: selectedDevice.id,
355718
+ platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
355719
+ isPhysical: selectedDevice.isPhysical,
355720
+ portOffset: acquireControlPortSlot(discoveryPortSlotDeviceId)
355721
+ });
355722
+ } catch (err) {
355723
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355724
+ throw err;
355725
+ }
355551
355726
  if (target.launchMode === "DEEP_LINK" && target.deeplink) {
355552
355727
  try {
355553
355728
  await openDeepLink(sessionId, target.platform, target.deeplink, target.appId);
355554
355729
  } catch (err) {
355555
355730
  await deleteSession(sessionId).catch(() => {
355556
355731
  });
355732
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355557
355733
  throw new Error(
355558
355734
  `Deep-link launch failed for mobile discovery: ${err instanceof Error ? err.message : String(err)}`
355559
355735
  );
@@ -355575,6 +355751,7 @@ async function createMobileDiscoverySession(target, options) {
355575
355751
  } catch {
355576
355752
  }
355577
355753
  await deleteSession(sessionId);
355754
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355578
355755
  }
355579
355756
  };
355580
355757
  }
@@ -355592,14 +355769,14 @@ async function readElementAttribute(sessionId, elementId, name) {
355592
355769
  }
355593
355770
  function harvestFallbackXpath(platform3, attrs) {
355594
355771
  if (platform3 === "IOS") {
355595
- if (attrs.name) return `//*[@name=${xpathLiteral2(attrs.name)}]`;
355772
+ if (attrs.name) return `//*[@name=${xpathLiteral3(attrs.name)}]`;
355596
355773
  const text = attrs.label ?? attrs.value;
355597
- if (text) return identityTextXpath(text);
355774
+ if (text) return identityTextXpath2(text);
355598
355775
  return "//*";
355599
355776
  }
355600
- if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral2(attrs.resourceId)}]`;
355601
- if (attrs.text) return identityTextXpath(attrs.text);
355602
- if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral2(attrs.contentDesc)}]`;
355777
+ if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral3(attrs.resourceId)}]`;
355778
+ if (attrs.text) return identityTextXpath2(attrs.text);
355779
+ if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral3(attrs.contentDesc)}]`;
355603
355780
  return "//*";
355604
355781
  }
355605
355782
  async function readNativeElementBundle(sessionId, elementId, platform3) {
@@ -355952,6 +356129,7 @@ var sessionStartInFlight = null;
355952
356129
  var sessionReaperTimer = null;
355953
356130
  var mirrorClientsEmptyAt = null;
355954
356131
  var mirrorSessionOwner = null;
356132
+ var actionInFlight = false;
355955
356133
  var recordingWorkloadActive = false;
355956
356134
  function setRecordingWorkloadActive(active) {
355957
356135
  if (recordingWorkloadActive === active) return;
@@ -356181,7 +356359,11 @@ async function handleHealth(_req, res) {
356181
356359
  // 409 — it must never be inferred from server-side global run counts, which
356182
356360
  // include unrelated projects and runners.
356183
356361
  executorBusy: executorBusyCount > 0,
356184
- recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording"
356362
+ // TRUE also during the multi-second session-START window: the claim loop
356363
+ // reads this to keep test runs off the recording device, and the Appium
356364
+ // session id only lands AFTER `POST /session` returns — without the
356365
+ // in-flight latch a run admitted in that window fights the recording.
356366
+ recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording" || sessionStartInFlight !== null
356185
356367
  });
356186
356368
  }
356187
356369
  function realDeviceCapabilities2(platform3) {
@@ -356259,7 +356441,11 @@ async function handleDevices(_req, res) {
356259
356441
  sendJSON(res, 200, {
356260
356442
  devices,
356261
356443
  executorBusy: executorBusyCount > 0,
356262
- recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording"
356444
+ // TRUE also during the multi-second session-START window: the claim loop
356445
+ // reads this to keep test runs off the recording device, and the Appium
356446
+ // session id only lands AFTER `POST /session` returns — without the
356447
+ // in-flight latch a run admitted in that window fights the recording.
356448
+ recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording" || sessionStartInFlight !== null
356263
356449
  });
356264
356450
  }
356265
356451
  async function handleSessionStart(req, res) {
@@ -356348,6 +356534,8 @@ async function startSession(validated, res) {
356348
356534
  state.platform = targetPlatform === "IOS" ? "IOS" : "ANDROID";
356349
356535
  mirrorSessionOwner = "recording";
356350
356536
  setRecordingWorkloadActive(true);
356537
+ mirrorClientsEmptyAt = Date.now();
356538
+ ensureSessionReaper();
356351
356539
  if (launchMode === "DEEP_LINK" && deeplink) {
356352
356540
  try {
356353
356541
  const args = targetPlatform === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
@@ -356701,6 +356889,14 @@ async function handleAction(req, res) {
356701
356889
  sendJSON(res, 400, { error: "No active Appium session" });
356702
356890
  return;
356703
356891
  }
356892
+ if (mirrorSessionOwner !== "recording") {
356893
+ sendJSON(res, 409, { error: "No recording session owns the device (a runner-driven session is active)." });
356894
+ return;
356895
+ }
356896
+ if (actionInFlight) {
356897
+ sendJSON(res, 409, { error: "Another action is still executing. Wait for it to finish." });
356898
+ return;
356899
+ }
356704
356900
  validateSessionId(state.appiumSessionId);
356705
356901
  const body = await parseBody(req);
356706
356902
  const type = typeof body.type === "string" ? body.type : null;
@@ -356712,6 +356908,7 @@ async function handleAction(req, res) {
356712
356908
  sendJSON(res, 400, { error: "Unknown or unsupported action type" });
356713
356909
  return;
356714
356910
  }
356911
+ actionInFlight = true;
356715
356912
  try {
356716
356913
  const sessionPath = `/session/${state.appiumSessionId}`;
356717
356914
  switch (type) {
@@ -356784,15 +356981,23 @@ async function handleAction(req, res) {
356784
356981
  sendJSON(res, 400, { error: `Unknown action type: ${type}` });
356785
356982
  return;
356786
356983
  }
356787
- const [screenshot, source] = await Promise.all([getScreenshot2(), getPageSource2()]);
356984
+ await waitForUiSettle(sessionPath);
356985
+ const screenshot = await getScreenshot2();
356986
+ let source = await getPageSource2();
356987
+ if (source === null) {
356988
+ source = await getPageSource2();
356989
+ }
356788
356990
  sendJSON(res, 200, {
356789
356991
  ok: true,
356790
356992
  screenshot: screenshot ?? null,
356791
356993
  source: source ?? null,
356994
+ ...source === null || source === void 0 ? { sourceUnavailable: true } : {},
356792
356995
  timestamp: Date.now()
356793
356996
  });
356794
356997
  } catch (err) {
356795
356998
  sendJSON(res, 500, { error: `Action failed: ${err instanceof Error ? err.message : String(err)}` });
356999
+ } finally {
357000
+ actionInFlight = false;
356796
357001
  }
356797
357002
  }
356798
357003
  async function handleScreenshot(_req, res) {
@@ -357030,7 +357235,11 @@ function getBridgeState() {
357030
357235
  tunnelUrl: state.tunnelUrl,
357031
357236
  tunnelToken: state.tunnelToken,
357032
357237
  hasActiveSession: !!state.appiumSessionId,
357033
- recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording",
357238
+ // TRUE also during the multi-second session-START window: the claim loop
357239
+ // reads this to keep test runs off the recording device, and the Appium
357240
+ // session id only lands AFTER `POST /session` returns — without the
357241
+ // in-flight latch a run admitted in that window fights the recording.
357242
+ recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording" || sessionStartInFlight !== null,
357034
357243
  platform: state.platform
357035
357244
  };
357036
357245
  }