@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.mjs CHANGED
@@ -2241,6 +2241,18 @@ function selectorForValue(value) {
2241
2241
  function isIdentityMobileXpath(value) {
2242
2242
  return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
2243
2243
  }
2244
+ function xpathLiteral(value) {
2245
+ if (!value.includes("'"))
2246
+ return `'${value}'`;
2247
+ if (!value.includes('"'))
2248
+ return `"${value}"`;
2249
+ const parts = value.split("'").map((p) => `'${p}'`);
2250
+ return `concat(${parts.join(`, "'", `)})`;
2251
+ }
2252
+ function identityTextXpath(value) {
2253
+ const lit = xpathLiteral(value);
2254
+ return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
2255
+ }
2244
2256
  function mobileLocatorBundleHasIdentity(bundle) {
2245
2257
  if (!bundle)
2246
2258
  return false;
@@ -2267,6 +2279,13 @@ function selectorCandidatesForStep(step) {
2267
2279
  candidates.push(`~${bundle.accessibilityId}`);
2268
2280
  if (nonEmpty(bundle.resourceId))
2269
2281
  candidates.push(`id=${bundle.resourceId}`);
2282
+ const isGateStep = step.action === "waitForElement" || step.action === "assertVisible" || step.action === "assertText";
2283
+ const textIdentity = isGateStep || !isEditableMobileClassName(bundle.className) ? bundle.text : void 0;
2284
+ for (const value of [textIdentity, bundle.contentDesc]) {
2285
+ const trimmed = nonEmpty(value);
2286
+ if (trimmed)
2287
+ candidates.push(identityTextXpath(trimmed));
2288
+ }
2270
2289
  if (nonEmpty(bundle.xpath))
2271
2290
  candidates.push(bundle.xpath);
2272
2291
  }
@@ -2715,7 +2734,7 @@ function uniq(values) {
2715
2734
  }
2716
2735
  return result;
2717
2736
  }
2718
- function xpathLiteral(value) {
2737
+ function xpathLiteral2(value) {
2719
2738
  if (!value.includes("'"))
2720
2739
  return `'${value}'`;
2721
2740
  if (!value.includes('"'))
@@ -2723,7 +2742,7 @@ function xpathLiteral(value) {
2723
2742
  return `concat(${value.split("'").map((part) => `'${part}'`).join(`, "'", `)})`;
2724
2743
  }
2725
2744
  function mobileTextXPath(value, platform3) {
2726
- const literal = xpathLiteral(value);
2745
+ const literal = xpathLiteral2(value);
2727
2746
  if (platform3 === "IOS") {
2728
2747
  return `//*[@name=${literal} or @label=${literal} or @value=${literal}]`;
2729
2748
  }
@@ -3871,6 +3890,43 @@ function accumulateStatements(lines) {
3871
3890
  }
3872
3891
  return out;
3873
3892
  }
3893
+ function classifyMobileAssertValue(value, context = {}) {
3894
+ const text = value.trim();
3895
+ if (!text)
3896
+ return null;
3897
+ if (/^\{\{[A-Z0-9_]+\}\}$/.test(text))
3898
+ return null;
3899
+ const t1 = matchFirst(TIER1_PATTERNS, text);
3900
+ if (t1)
3901
+ return { tier: 1, reason: t1[1] };
3902
+ const authIdentityValues = [];
3903
+ if (context.authenticatedUserEmail)
3904
+ authIdentityValues.push(context.authenticatedUserEmail);
3905
+ if (context.authenticatedUserName && context.authenticatedUserName.length >= 3) {
3906
+ authIdentityValues.push(context.authenticatedUserName);
3907
+ }
3908
+ for (const auth of authIdentityValues) {
3909
+ if (auth.length >= 3 && text.toLowerCase().includes(auth.toLowerCase())) {
3910
+ return { tier: 2, reason: "asserts the authenticated user identity \u2014 fails under any other test account" };
3911
+ }
3912
+ }
3913
+ const knownValues = [
3914
+ ...context.knownUserInputs ?? [],
3915
+ ...context.scenarioCreatedValues ?? []
3916
+ ].filter((v) => {
3917
+ for (const auth of authIdentityValues) {
3918
+ if (auth.length >= 3 && v.toLowerCase().includes(auth.toLowerCase()))
3919
+ return false;
3920
+ }
3921
+ return true;
3922
+ });
3923
+ if (containsKnownValue(text, knownValues))
3924
+ return null;
3925
+ const t2 = matchFirst(TIER2_PATTERNS, text);
3926
+ if (t2)
3927
+ return { tier: 2, reason: t2[1] };
3928
+ return null;
3929
+ }
3874
3930
  function parenDepthDelta(text) {
3875
3931
  let depth = 0;
3876
3932
  let quote = null;
@@ -6044,6 +6100,7 @@ __export(dist_exports, {
6044
6100
  classifyAssertionBlock: () => classifyAssertionBlock,
6045
6101
  classifyAssertionLine: () => classifyAssertionLine,
6046
6102
  classifyErrorMessage: () => classifyErrorMessage,
6103
+ classifyMobileAssertValue: () => classifyMobileAssertValue,
6047
6104
  classifyTestHealth: () => classifyTestHealth,
6048
6105
  coldSessionConfigMessage: () => coldSessionConfigMessage,
6049
6106
  computeMobileSwipePointers: () => computeMobileSwipePointers,
@@ -80888,6 +80945,45 @@ ${constraints.join("\n")}`);
80888
80945
  }
80889
80946
  });
80890
80947
 
80948
+ // ../runner-core/dist/services/discovery-lint-flags.js
80949
+ var require_discovery_lint_flags = __commonJS({
80950
+ "../runner-core/dist/services/discovery-lint-flags.js"(exports) {
80951
+ "use strict";
80952
+ Object.defineProperty(exports, "__esModule", { value: true });
80953
+ exports.structuralLocatorLintMode = structuralLocatorLintMode;
80954
+ exports.isPwMethodPreflightEnabled = isPwMethodPreflightEnabled;
80955
+ exports.isVqaLoginDedupeEnabled = isVqaLoginDedupeEnabled;
80956
+ exports.mobileSelectorQualityLintMode = mobileSelectorQualityLintMode;
80957
+ exports.isTestgenEmailVerifyEnabled = isTestgenEmailVerifyEnabled;
80958
+ function strictOn(value) {
80959
+ return value === "true" || value === "1";
80960
+ }
80961
+ function structuralLocatorLintMode() {
80962
+ const v = (process.env.DISCOVERY_STRUCTURAL_LOCATOR_LINT || "").trim().toLowerCase();
80963
+ if (v === "warn")
80964
+ return "warn";
80965
+ if (v === "true" || v === "1" || v === "enforce")
80966
+ return "enforce";
80967
+ return "off";
80968
+ }
80969
+ function isPwMethodPreflightEnabled() {
80970
+ return strictOn(process.env.DISCOVERY_PW_METHOD_PREFLIGHT);
80971
+ }
80972
+ function isVqaLoginDedupeEnabled() {
80973
+ return strictOn(process.env.DISCOVERY_VQALOGIN_DEDUPE);
80974
+ }
80975
+ function mobileSelectorQualityLintMode() {
80976
+ const v = (process.env.MOBILE_SELECTOR_QUALITY_LINT || "").trim().toLowerCase();
80977
+ if (v === "true" || v === "1" || v === "enforce")
80978
+ return "enforce";
80979
+ return "off";
80980
+ }
80981
+ function isTestgenEmailVerifyEnabled() {
80982
+ return strictOn(process.env.DISCOVERY_TESTGEN_EMAIL_VERIFY);
80983
+ }
80984
+ }
80985
+ });
80986
+
80891
80987
  // ../runner-core/dist/services/mobile/mobile-generator.js
80892
80988
  var require_mobile_generator = __commonJS({
80893
80989
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports) {
@@ -80907,6 +81003,7 @@ var require_mobile_generator = __commonJS({
80907
81003
  var ai_queue_js_1 = require_ai_queue();
80908
81004
  var ai_retry_js_1 = require_ai_retry();
80909
81005
  var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
81006
+ var discovery_lint_flags_js_1 = require_discovery_lint_flags();
80910
81007
  var mobile_source_parser_js_1 = require_mobile_source_parser();
80911
81008
  var mobile_source_parser_js_2 = require_mobile_source_parser();
80912
81009
  var mobile_observation_js_1 = require_mobile_observation();
@@ -81076,7 +81173,15 @@ var require_mobile_generator = __commonJS({
81076
81173
  properties: {
81077
81174
  name: { type: "string", description: 'Descriptive test name (e.g. "Login - Happy Path").' },
81078
81175
  description: { type: "string", description: "One-line description of what the test proves." },
81079
- verified: { type: "boolean", description: "True only if you asserted the outcome LIVE with an assert tool." }
81176
+ verified: { type: "boolean", description: "True only if you asserted the outcome LIVE with an assert tool." },
81177
+ suspectedBug: {
81178
+ type: "boolean",
81179
+ 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."
81180
+ },
81181
+ bugDescription: {
81182
+ type: "string",
81183
+ description: "What is broken in the APP (not the test) \u2014 concrete screen + observed vs expected."
81184
+ }
81080
81185
  },
81081
81186
  required: ["name", "description", "verified"]
81082
81187
  }
@@ -81322,6 +81427,8 @@ var require_mobile_generator = __commonJS({
81322
81427
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
81323
81428
  - 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.
81324
81429
  - 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.
81430
+ - 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.
81431
+ - 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.
81325
81432
  - 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.
81326
81433
  - 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.
81327
81434
  - 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.
@@ -81427,6 +81534,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
81427
81534
  let assertCount = 0;
81428
81535
  let finish = null;
81429
81536
  let finishIntentRejections = 0;
81537
+ let finishStrengthRejections = 0;
81430
81538
  let emptyTurnsAfterFinishReject = 0;
81431
81539
  let latest = null;
81432
81540
  const getLatest = () => latest;
@@ -81919,31 +82027,49 @@ ${statePacket}`, redactionCredentials) },
81919
82027
  const claimedVerified = Boolean(toolArgs.verified);
81920
82028
  const intentReason = scenarioIntentMismatchReason(scenario, actions);
81921
82029
  const verifiedWithoutAssert = claimedVerified && assertCount === 0;
81922
- const canReject = finishIntentRejections < MAX_FINISH_INTENT_REJECTIONS && iteration < maxIterations && (intentReason !== null || verifiedWithoutAssert);
81923
- if (canReject) {
81924
- finishIntentRejections += 1;
82030
+ const intentStyleReject = finishIntentRejections < MAX_FINISH_INTENT_REJECTIONS && iteration < maxIterations && (intentReason !== null || verifiedWithoutAssert);
82031
+ let strengthRejectReason = null;
82032
+ if (!intentStyleReject && claimedVerified && assertCount > 0 && intentReason === null && finishStrengthRejections < 1 && iteration < maxIterations) {
82033
+ const verdict = evaluateMobileAssertionStrength(actions);
82034
+ if (verdict.severity === "block") {
82035
+ strengthRejectReason = verdict.reason;
82036
+ }
82037
+ }
82038
+ if (intentStyleReject || strengthRejectReason !== null) {
82039
+ if (intentStyleReject)
82040
+ finishIntentRejections += 1;
82041
+ if (strengthRejectReason !== null)
82042
+ finishStrengthRejections += 1;
81925
82043
  const reasons = [];
81926
82044
  if (intentReason)
81927
82045
  reasons.push(intentReason);
81928
82046
  if (verifiedWithoutAssert) {
81929
82047
  reasons.push("claimed verified:true but never called mobile_assert_visible / mobile_assert_text");
81930
82048
  }
82049
+ if (strengthRejectReason !== null) {
82050
+ 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.`);
82051
+ }
81931
82052
  const reasonText = reasons.join("; ");
81932
- log2(` finish_generate REJECTED (${finishIntentRejections}/${MAX_FINISH_INTENT_REJECTIONS}): ${reasonText}`);
82053
+ log2(` finish_generate REJECTED (intent ${finishIntentRejections}/${MAX_FINISH_INTENT_REJECTIONS}, strength ${finishStrengthRejections}/1): ${reasonText}`);
81933
82054
  pushResult({
81934
82055
  ok: false,
81935
82056
  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.`,
81936
82057
  intentMismatch: intentReason,
81937
82058
  verifiedWithoutAssert,
81938
- rejection: finishIntentRejections,
81939
- maxRejections: MAX_FINISH_INTENT_REJECTIONS
82059
+ ...strengthRejectReason !== null ? { strengthBlock: strengthRejectReason } : {},
82060
+ rejection: finishIntentRejections + finishStrengthRejections,
82061
+ maxRejections: MAX_FINISH_INTENT_REJECTIONS + 1
81940
82062
  });
81941
82063
  continue;
81942
82064
  }
81943
82065
  finish = {
81944
82066
  name: redactCredentialValues(nonEmpty3(String(toolArgs.name ?? "")) ?? scenario.name, redactionCredentials),
81945
82067
  description: redactCredentialValues(nonEmpty3(String(toolArgs.description ?? "")) ?? scenario.goal, redactionCredentials),
81946
- verified: claimedVerified
82068
+ verified: claimedVerified,
82069
+ ...toolArgs.suspectedBug === true ? {
82070
+ suspectedBug: true,
82071
+ bugDescription: redactCredentialValues(nonEmpty3(String(toolArgs.bugDescription ?? "")) ?? "", redactionCredentials)
82072
+ } : {}
81947
82073
  };
81948
82074
  log2(` finish_generate: "${finish.name}" (claimed verified: ${finish.verified}, asserts: ${assertCount})`);
81949
82075
  pushResult({ acknowledged: true });
@@ -82007,6 +82133,7 @@ ${statePacket}`, redactionCredentials) },
82007
82133
  ok: false,
82008
82134
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
82009
82135
  });
82136
+ const recordedTypeValues = () => recordedActions.filter((action) => action.type === "TYPE" && typeof action.value === "string" && action.value.length > 0).map((action) => action.value);
82010
82137
  const actionPayload = (result, absorbed) => {
82011
82138
  const blocked = Boolean(absorbed.externalBlocked || absorbed.transientExternal);
82012
82139
  return {
@@ -82407,7 +82534,8 @@ ${statePacket}`, redactionCredentials) },
82407
82534
  latest,
82408
82535
  screenName,
82409
82536
  platform: platform3,
82410
- recordAction
82537
+ recordAction,
82538
+ typedValues: recordedTypeValues
82411
82539
  });
82412
82540
  if (!recorded.ok)
82413
82541
  return recorded;
@@ -82476,6 +82604,15 @@ ${statePacket}`, redactionCredentials) },
82476
82604
  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.`
82477
82605
  };
82478
82606
  }
82607
+ const volatileExpected = (0, shared_1.classifyMobileAssertValue)(expected, {
82608
+ knownUserInputs: recordedTypeValues()
82609
+ });
82610
+ if (volatileExpected) {
82611
+ return {
82612
+ ok: false,
82613
+ 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.`
82614
+ };
82615
+ }
82479
82616
  const metadata = metadataForElement(element, screenName(), platform3);
82480
82617
  const expectedNeedle = expected.trim().toLowerCase();
82481
82618
  const identityValues = [element.accessibilityId, element.resourceId].map((value) => value?.trim().toLowerCase()).filter((value) => Boolean(value));
@@ -82531,6 +82668,17 @@ ${statePacket}`, redactionCredentials) },
82531
82668
  if (!accessibilityId && !text) {
82532
82669
  return { ok: false, error: "mobile_assert_visible needs a ref (preferred), accessibilityId, or text." };
82533
82670
  }
82671
+ if (!accessibilityId && text) {
82672
+ const volatileIdentity = (0, shared_1.classifyMobileAssertValue)(text, {
82673
+ knownUserInputs: ctx.typedValues?.() ?? []
82674
+ });
82675
+ if (volatileIdentity) {
82676
+ return {
82677
+ ok: false,
82678
+ 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).`
82679
+ };
82680
+ }
82681
+ }
82534
82682
  const directTarget = accessibilityId ?? text;
82535
82683
  if (directTarget && isStructuralAssertionIdentity(directTarget)) {
82536
82684
  return {
@@ -83166,7 +83314,21 @@ ${statePacket}`, redactionCredentials) },
83166
83314
  if (claimedVerified && !verified) {
83167
83315
  log2(" downgraded verified -> false: model claimed verified but called zero assert tools.");
83168
83316
  }
83169
- const strengthVerdict = evaluateMobileAssertionStrength(actions);
83317
+ const baseStrengthVerdict = evaluateMobileAssertionStrength(actions);
83318
+ const bugDescription = recording.finish?.bugDescription?.trim() ?? "";
83319
+ const bugWitnessHonored = process.env.DISCOVERY_BUG_WITNESS_ENABLED === "true" && recording.finish?.suspectedBug === true && bugDescription.length >= 12 && recording.assertCount > 0;
83320
+ if (recording.finish?.suspectedBug === true && !bugWitnessHonored) {
83321
+ 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."));
83322
+ }
83323
+ const strengthVerdict = bugWitnessHonored ? {
83324
+ ...baseStrengthVerdict,
83325
+ ok: false,
83326
+ severity: "route-bug",
83327
+ reason: `Model declared an app bug during generation: ${bugDescription}`
83328
+ } : baseStrengthVerdict;
83329
+ if (bugWitnessHonored) {
83330
+ log2(` bug-witness honored \u2014 routing as app bug: ${bugDescription}`);
83331
+ }
83170
83332
  if (strengthVerdict.severity === "block" || strengthVerdict.severity === "route-bug") {
83171
83333
  if (verified) {
83172
83334
  log2(` downgraded verified -> false: assertion strength ${strengthVerdict.severity} \u2014 ${strengthVerdict.reason}`);
@@ -83182,7 +83344,11 @@ ${statePacket}`, redactionCredentials) },
83182
83344
  verified = false;
83183
83345
  }
83184
83346
  const platformTag2 = platform3 === "IOS" ? "@ios" : "@android";
83185
- const reviewTag = verified ? "@verified" : "@needs-review";
83347
+ const selectorLintEnforced = (0, discovery_lint_flags_js_1.mobileSelectorQualityLintMode)() === "enforce" && selectorQuality.summary.high > 0;
83348
+ if (selectorLintEnforced && verified) {
83349
+ log2(` selector-quality lint (enforce): ${selectorQuality.summary.high} high-severity locator issue(s) \u2014 tagging @needs-review.`);
83350
+ }
83351
+ const reviewTag = verified && !selectorLintEnforced ? "@verified" : "@needs-review";
83186
83352
  const tags = ["@discovered", "@mobile", platformTag2, reviewTag];
83187
83353
  if (strengthVerdict.severity === "downgrade")
83188
83354
  tags.push("@smoke");
@@ -86262,38 +86428,6 @@ var require_run_callback_proxy = __commonJS({
86262
86428
  }
86263
86429
  });
86264
86430
 
86265
- // ../runner-core/dist/services/discovery-lint-flags.js
86266
- var require_discovery_lint_flags = __commonJS({
86267
- "../runner-core/dist/services/discovery-lint-flags.js"(exports) {
86268
- "use strict";
86269
- Object.defineProperty(exports, "__esModule", { value: true });
86270
- exports.structuralLocatorLintMode = structuralLocatorLintMode;
86271
- exports.isPwMethodPreflightEnabled = isPwMethodPreflightEnabled;
86272
- exports.isVqaLoginDedupeEnabled = isVqaLoginDedupeEnabled;
86273
- exports.isTestgenEmailVerifyEnabled = isTestgenEmailVerifyEnabled;
86274
- function strictOn(value) {
86275
- return value === "true" || value === "1";
86276
- }
86277
- function structuralLocatorLintMode() {
86278
- const v = (process.env.DISCOVERY_STRUCTURAL_LOCATOR_LINT || "").trim().toLowerCase();
86279
- if (v === "warn")
86280
- return "warn";
86281
- if (v === "true" || v === "1" || v === "enforce")
86282
- return "enforce";
86283
- return "off";
86284
- }
86285
- function isPwMethodPreflightEnabled() {
86286
- return strictOn(process.env.DISCOVERY_PW_METHOD_PREFLIGHT);
86287
- }
86288
- function isVqaLoginDedupeEnabled() {
86289
- return strictOn(process.env.DISCOVERY_VQALOGIN_DEDUPE);
86290
- }
86291
- function isTestgenEmailVerifyEnabled() {
86292
- return strictOn(process.env.DISCOVERY_TESTGEN_EMAIL_VERIFY);
86293
- }
86294
- }
86295
- });
86296
-
86297
86431
  // ../runner-core/dist/services/auth-recipe.js
86298
86432
  var require_auth_recipe = __commonJS({
86299
86433
  "../runner-core/dist/services/auth-recipe.js"(exports) {
@@ -347792,12 +347926,7 @@ Write valid JSON only (no markdown fences inside files). Be exhaustive.`;
347792
347926
  const o = value;
347793
347927
  if (!isNonEmptyString(o.name))
347794
347928
  return { ok: false, reason: "name: missing" };
347795
- if (!isNonEmptyString(o.appiumCode))
347796
- return { ok: false, reason: "appiumCode: missing" };
347797
- const code = o.appiumCode;
347798
- if (!/webdriverio|remote\s*\(|driver\s*\./i.test(code)) {
347799
- return { ok: false, reason: "appiumCode: not a WebdriverIO/Appium script" };
347800
- }
347929
+ const code = isNonEmptyString(o.appiumCode) ? o.appiumCode : "";
347801
347930
  if (!Array.isArray(o.steps) || o.steps.length === 0) {
347802
347931
  return { ok: false, reason: "steps: missing or empty" };
347803
347932
  }
@@ -351100,7 +351229,7 @@ var require_package4 = __commonJS({
351100
351229
  "package.json"(exports, module) {
351101
351230
  module.exports = {
351102
351231
  name: "@validate.qa/runner",
351103
- version: "1.0.66",
351232
+ version: "1.0.67",
351104
351233
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
351105
351234
  bin: {
351106
351235
  "validate-runner": "dist/cli.js"
@@ -351496,6 +351625,9 @@ function sanitizeAppiumLogLine(line) {
351496
351625
  }
351497
351626
  function setAppiumWorkloadActive(active) {
351498
351627
  activeWorkloadCount = active ? activeWorkloadCount + 1 : Math.max(0, activeWorkloadCount - 1);
351628
+ if (activeWorkloadCount === 0) {
351629
+ consecutiveHealthFailures = 0;
351630
+ }
351499
351631
  }
351500
351632
  function shouldRestartAfterHealthFailure(workloadCount, failureCount) {
351501
351633
  return workloadCount <= 0 && failureCount >= HEALTH_FAILURES_BEFORE_RESTART;
@@ -351991,17 +352123,31 @@ function rectMatchesIntendedBounds(rect, bounds) {
351991
352123
  return rectCenterInBounds || boundsCenterInRect;
351992
352124
  }
351993
352125
  async function resolveTypeTargetElementId(sessionPath, bounds) {
351994
- const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
352126
+ const rawBox = bounds !== void 0 ? normaliseBounds(bounds) : null;
352127
+ const box = rawBox && rawBox.width > 0 && rawBox.height > 0 ? rawBox : null;
351995
352128
  let elementId = await getActiveElementId(sessionPath);
351996
- if (elementId && box && box.width > 0 && box.height > 0) {
352129
+ let rejectedElementId = null;
352130
+ let rejectedForMismatch = false;
352131
+ if (elementId && box) {
351997
352132
  const rect = await getElementRect(sessionPath, elementId);
351998
352133
  if (!rect || !rectMatchesIntendedBounds(rect, box)) {
352134
+ rejectedElementId = elementId;
352135
+ rejectedForMismatch = !!rect;
351999
352136
  elementId = null;
352000
352137
  }
352001
352138
  }
352002
352139
  if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
352003
352140
  await new Promise((resolve2) => setTimeout(resolve2, MOBILE_FOCUS_SETTLE_MS));
352004
352141
  elementId = await getActiveElementId(sessionPath);
352142
+ if (elementId && box) {
352143
+ const rect = await getElementRect(sessionPath, elementId);
352144
+ const provablyWrong = rect ? !rectMatchesIntendedBounds(rect, box) : rejectedForMismatch && elementId === rejectedElementId;
352145
+ if (provablyWrong) {
352146
+ throw new Error(
352147
+ "Could not focus the intended field: a different element kept keyboard focus after tapping the target bounds. Tap the field directly, then retry."
352148
+ );
352149
+ }
352150
+ }
352005
352151
  }
352006
352152
  return elementId;
352007
352153
  }
@@ -354580,6 +354726,13 @@ function buildLocatorStrategies(target, platform3) {
354580
354726
  if (bundle) {
354581
354727
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
354582
354728
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
354729
+ for (const value of [
354730
+ isEditableMobileClassName(bundle.className) ? void 0 : bundle.text,
354731
+ bundle.contentDesc
354732
+ ]) {
354733
+ const trimmed = value?.trim();
354734
+ if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath2(trimmed) });
354735
+ }
354583
354736
  if (bundle.xpath) strategies.push({ using: "xpath", value: bundle.xpath });
354584
354737
  }
354585
354738
  if (!(platform3 && hasPlatformLocator(target, platform3))) {
@@ -354597,14 +354750,14 @@ function dedupeStrategies(strategies) {
354597
354750
  (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
354598
354751
  );
354599
354752
  }
354600
- function xpathLiteral2(value) {
354753
+ function xpathLiteral3(value) {
354601
354754
  if (!value.includes("'")) return `'${value}'`;
354602
354755
  if (!value.includes('"')) return `"${value}"`;
354603
354756
  const parts = value.split("'").map((p) => `'${p}'`);
354604
354757
  return `concat(${parts.join(`, "'", `)})`;
354605
354758
  }
354606
- function identityTextXpath(value) {
354607
- const lit = xpathLiteral2(value);
354759
+ function identityTextXpath2(value) {
354760
+ const lit = xpathLiteral3(value);
354608
354761
  return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
354609
354762
  }
354610
354763
  function isIdentityXpath(value) {
@@ -354624,7 +354777,7 @@ function buildIdentityLocatorStrategies(target, platform3) {
354624
354777
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
354625
354778
  for (const value of [bundle.text, bundle.contentDesc]) {
354626
354779
  const trimmed = value?.trim();
354627
- if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
354780
+ if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath2(trimmed) });
354628
354781
  }
354629
354782
  if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
354630
354783
  }
@@ -355104,6 +355257,19 @@ async function readElementTextCandidates(sessionId, elementId, actualText) {
355104
355257
  }
355105
355258
  return values;
355106
355259
  }
355260
+ var controlPortSlotsByDevice = /* @__PURE__ */ new Map();
355261
+ function acquireControlPortSlot(deviceId) {
355262
+ const existing = controlPortSlotsByDevice.get(deviceId);
355263
+ if (existing !== void 0) return existing;
355264
+ const taken = new Set(controlPortSlotsByDevice.values());
355265
+ let slot = 0;
355266
+ while (taken.has(slot)) slot += 1;
355267
+ controlPortSlotsByDevice.set(deviceId, slot);
355268
+ return slot;
355269
+ }
355270
+ function releaseControlPortSlot(deviceId) {
355271
+ controlPortSlotsByDevice.delete(deviceId);
355272
+ }
355107
355273
  async function executeMobileTest(options) {
355108
355274
  const logLines = [];
355109
355275
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
@@ -355123,6 +355289,7 @@ async function executeMobileTest(options) {
355123
355289
  let captureStopped = false;
355124
355290
  let recordingStarted = false;
355125
355291
  let recordingStopped = false;
355292
+ let acquiredSlotDeviceId = null;
355126
355293
  let video;
355127
355294
  let failingSource;
355128
355295
  const stopCapture = async () => {
@@ -355225,7 +355392,8 @@ async function executeMobileTest(options) {
355225
355392
  log2(`Warning: Could not retrieve installed apps list. Proceeding with test execution.`);
355226
355393
  }
355227
355394
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
355228
- const leasedPortOffset = Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id));
355395
+ const leasedPortOffset = acquireControlPortSlot(selectedDevice.id);
355396
+ acquiredSlotDeviceId = selectedDevice.id;
355229
355397
  try {
355230
355398
  sessionId = await createSessionWithRetry({
355231
355399
  appId: options.target.appId,
@@ -355502,6 +355670,7 @@ async function executeMobileTest(options) {
355502
355670
  await deleteSession(sessionId);
355503
355671
  log2("Session destroyed");
355504
355672
  }
355673
+ if (acquiredSlotDeviceId) releaseControlPortSlot(acquiredSlotDeviceId);
355505
355674
  }
355506
355675
  }
355507
355676
  async function createMobileDiscoverySession(target, options) {
@@ -355547,21 +355716,28 @@ async function createMobileDiscoverySession(target, options) {
355547
355716
  isPhysical: selectedDevice.isPhysical === true,
355548
355717
  platformVersion: selectedDevice.platformVersion
355549
355718
  });
355550
- const sessionId = await createSessionWithRetry({
355551
- appId: target.appId,
355552
- platform: target.platform,
355553
- deviceId: selectedDevice.id,
355554
- platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
355555
- isPhysical: selectedDevice.isPhysical,
355556
- // Distinct control resources per concurrent device, including simulators.
355557
- portOffset: Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id))
355558
- });
355719
+ const discoveryPortSlotDeviceId = selectedDevice.id;
355720
+ let sessionId;
355721
+ try {
355722
+ sessionId = await createSessionWithRetry({
355723
+ appId: target.appId,
355724
+ platform: target.platform,
355725
+ deviceId: selectedDevice.id,
355726
+ platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
355727
+ isPhysical: selectedDevice.isPhysical,
355728
+ portOffset: acquireControlPortSlot(discoveryPortSlotDeviceId)
355729
+ });
355730
+ } catch (err) {
355731
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355732
+ throw err;
355733
+ }
355559
355734
  if (target.launchMode === "DEEP_LINK" && target.deeplink) {
355560
355735
  try {
355561
355736
  await openDeepLink(sessionId, target.platform, target.deeplink, target.appId);
355562
355737
  } catch (err) {
355563
355738
  await deleteSession(sessionId).catch(() => {
355564
355739
  });
355740
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355565
355741
  throw new Error(
355566
355742
  `Deep-link launch failed for mobile discovery: ${err instanceof Error ? err.message : String(err)}`
355567
355743
  );
@@ -355583,6 +355759,7 @@ async function createMobileDiscoverySession(target, options) {
355583
355759
  } catch {
355584
355760
  }
355585
355761
  await deleteSession(sessionId);
355762
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355586
355763
  }
355587
355764
  };
355588
355765
  }
@@ -355600,14 +355777,14 @@ async function readElementAttribute(sessionId, elementId, name) {
355600
355777
  }
355601
355778
  function harvestFallbackXpath(platform3, attrs) {
355602
355779
  if (platform3 === "IOS") {
355603
- if (attrs.name) return `//*[@name=${xpathLiteral2(attrs.name)}]`;
355780
+ if (attrs.name) return `//*[@name=${xpathLiteral3(attrs.name)}]`;
355604
355781
  const text = attrs.label ?? attrs.value;
355605
- if (text) return identityTextXpath(text);
355782
+ if (text) return identityTextXpath2(text);
355606
355783
  return "//*";
355607
355784
  }
355608
- if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral2(attrs.resourceId)}]`;
355609
- if (attrs.text) return identityTextXpath(attrs.text);
355610
- if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral2(attrs.contentDesc)}]`;
355785
+ if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral3(attrs.resourceId)}]`;
355786
+ if (attrs.text) return identityTextXpath2(attrs.text);
355787
+ if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral3(attrs.contentDesc)}]`;
355611
355788
  return "//*";
355612
355789
  }
355613
355790
  async function readNativeElementBundle(sessionId, elementId, platform3) {
@@ -355960,6 +356137,7 @@ var sessionStartInFlight = null;
355960
356137
  var sessionReaperTimer = null;
355961
356138
  var mirrorClientsEmptyAt = null;
355962
356139
  var mirrorSessionOwner = null;
356140
+ var actionInFlight = false;
355963
356141
  var recordingWorkloadActive = false;
355964
356142
  function setRecordingWorkloadActive(active) {
355965
356143
  if (recordingWorkloadActive === active) return;
@@ -356189,7 +356367,11 @@ async function handleHealth(_req, res) {
356189
356367
  // 409 — it must never be inferred from server-side global run counts, which
356190
356368
  // include unrelated projects and runners.
356191
356369
  executorBusy: executorBusyCount > 0,
356192
- recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording"
356370
+ // TRUE also during the multi-second session-START window: the claim loop
356371
+ // reads this to keep test runs off the recording device, and the Appium
356372
+ // session id only lands AFTER `POST /session` returns — without the
356373
+ // in-flight latch a run admitted in that window fights the recording.
356374
+ recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording" || sessionStartInFlight !== null
356193
356375
  });
356194
356376
  }
356195
356377
  function realDeviceCapabilities2(platform3) {
@@ -356267,7 +356449,11 @@ async function handleDevices(_req, res) {
356267
356449
  sendJSON(res, 200, {
356268
356450
  devices,
356269
356451
  executorBusy: executorBusyCount > 0,
356270
- recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording"
356452
+ // TRUE also during the multi-second session-START window: the claim loop
356453
+ // reads this to keep test runs off the recording device, and the Appium
356454
+ // session id only lands AFTER `POST /session` returns — without the
356455
+ // in-flight latch a run admitted in that window fights the recording.
356456
+ recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording" || sessionStartInFlight !== null
356271
356457
  });
356272
356458
  }
356273
356459
  async function handleSessionStart(req, res) {
@@ -356356,6 +356542,8 @@ async function startSession(validated, res) {
356356
356542
  state.platform = targetPlatform === "IOS" ? "IOS" : "ANDROID";
356357
356543
  mirrorSessionOwner = "recording";
356358
356544
  setRecordingWorkloadActive(true);
356545
+ mirrorClientsEmptyAt = Date.now();
356546
+ ensureSessionReaper();
356359
356547
  if (launchMode === "DEEP_LINK" && deeplink) {
356360
356548
  try {
356361
356549
  const args = targetPlatform === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
@@ -356709,6 +356897,14 @@ async function handleAction(req, res) {
356709
356897
  sendJSON(res, 400, { error: "No active Appium session" });
356710
356898
  return;
356711
356899
  }
356900
+ if (mirrorSessionOwner !== "recording") {
356901
+ sendJSON(res, 409, { error: "No recording session owns the device (a runner-driven session is active)." });
356902
+ return;
356903
+ }
356904
+ if (actionInFlight) {
356905
+ sendJSON(res, 409, { error: "Another action is still executing. Wait for it to finish." });
356906
+ return;
356907
+ }
356712
356908
  validateSessionId(state.appiumSessionId);
356713
356909
  const body = await parseBody(req);
356714
356910
  const type = typeof body.type === "string" ? body.type : null;
@@ -356720,6 +356916,7 @@ async function handleAction(req, res) {
356720
356916
  sendJSON(res, 400, { error: "Unknown or unsupported action type" });
356721
356917
  return;
356722
356918
  }
356919
+ actionInFlight = true;
356723
356920
  try {
356724
356921
  const sessionPath = `/session/${state.appiumSessionId}`;
356725
356922
  switch (type) {
@@ -356792,15 +356989,23 @@ async function handleAction(req, res) {
356792
356989
  sendJSON(res, 400, { error: `Unknown action type: ${type}` });
356793
356990
  return;
356794
356991
  }
356795
- const [screenshot, source] = await Promise.all([getScreenshot2(), getPageSource2()]);
356992
+ await waitForUiSettle(sessionPath);
356993
+ const screenshot = await getScreenshot2();
356994
+ let source = await getPageSource2();
356995
+ if (source === null) {
356996
+ source = await getPageSource2();
356997
+ }
356796
356998
  sendJSON(res, 200, {
356797
356999
  ok: true,
356798
357000
  screenshot: screenshot ?? null,
356799
357001
  source: source ?? null,
357002
+ ...source === null || source === void 0 ? { sourceUnavailable: true } : {},
356800
357003
  timestamp: Date.now()
356801
357004
  });
356802
357005
  } catch (err) {
356803
357006
  sendJSON(res, 500, { error: `Action failed: ${err instanceof Error ? err.message : String(err)}` });
357007
+ } finally {
357008
+ actionInFlight = false;
356804
357009
  }
356805
357010
  }
356806
357011
  async function handleScreenshot(_req, res) {
@@ -357038,7 +357243,11 @@ function getBridgeState() {
357038
357243
  tunnelUrl: state.tunnelUrl,
357039
357244
  tunnelToken: state.tunnelToken,
357040
357245
  hasActiveSession: !!state.appiumSessionId,
357041
- recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording",
357246
+ // TRUE also during the multi-second session-START window: the claim loop
357247
+ // reads this to keep test runs off the recording device, and the Appium
357248
+ // session id only lands AFTER `POST /session` returns — without the
357249
+ // in-flight latch a run admitted in that window fights the recording.
357250
+ recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording" || sessionStartInFlight !== null,
357042
357251
  platform: state.platform
357043
357252
  };
357044
357253
  }