@validate.qa/runner 1.0.65 → 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 +336 -87
  2. package/dist/cli.mjs +336 -87
  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,
@@ -73624,6 +73681,7 @@ var require_inbox_tools = __commonJS({
73624
73681
  exports2.buildInboxPromptBlock = buildInboxPromptBlock;
73625
73682
  exports2.guardVerificationCodeFabrication = guardVerificationCodeFabrication;
73626
73683
  exports2.hasRegisterIntent = hasRegisterIntent;
73684
+ exports2.hasSignupTestIntent = hasSignupTestIntent;
73627
73685
  exports2.resolveInboxRegistration = resolveInboxRegistration;
73628
73686
  exports2.applyInboxRegistrationIdentity = applyInboxRegistrationIdentity;
73629
73687
  exports2.isThrowawayRegisterSeedEmail = isThrowawayRegisterSeedEmail;
@@ -74167,6 +74225,9 @@ This project asked to REGISTER a new account, but this runner CANNOT receive ema
74167
74225
  const v = variableContext.allTestVariables?.DISCOVERY_SHOULD_REGISTER ?? variableContext.publicTestVariables?.DISCOVERY_SHOULD_REGISTER ?? "";
74168
74226
  return String(v).trim().toLowerCase() === "true";
74169
74227
  }
74228
+ function hasSignupTestIntent(variableContext) {
74229
+ return variableContext?.allTestVariables?.DISCOVERY_ALLOW_SIGNUP_TESTS !== "false";
74230
+ }
74170
74231
  function resolveInboxRegistration(opts) {
74171
74232
  const config = (0, verification_inbox_js_1.loadVerificationInboxConfig)(opts.env ?? process.env);
74172
74233
  if (!config)
@@ -80879,6 +80940,45 @@ ${constraints.join("\n")}`);
80879
80940
  }
80880
80941
  });
80881
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
+
80882
80982
  // ../runner-core/dist/services/mobile/mobile-generator.js
80883
80983
  var require_mobile_generator = __commonJS({
80884
80984
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports2) {
@@ -80898,6 +80998,7 @@ var require_mobile_generator = __commonJS({
80898
80998
  var ai_queue_js_1 = require_ai_queue();
80899
80999
  var ai_retry_js_1 = require_ai_retry();
80900
81000
  var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
81001
+ var discovery_lint_flags_js_1 = require_discovery_lint_flags();
80901
81002
  var mobile_source_parser_js_1 = require_mobile_source_parser();
80902
81003
  var mobile_source_parser_js_2 = require_mobile_source_parser();
80903
81004
  var mobile_observation_js_1 = require_mobile_observation();
@@ -81067,7 +81168,15 @@ var require_mobile_generator = __commonJS({
81067
81168
  properties: {
81068
81169
  name: { type: "string", description: 'Descriptive test name (e.g. "Login - Happy Path").' },
81069
81170
  description: { type: "string", description: "One-line description of what the test proves." },
81070
- 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
+ }
81071
81180
  },
81072
81181
  required: ["name", "description", "verified"]
81073
81182
  }
@@ -81313,6 +81422,8 @@ var require_mobile_generator = __commonJS({
81313
81422
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
81314
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.
81315
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.
81316
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.
81317
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.
81318
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.
@@ -81418,6 +81529,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
81418
81529
  let assertCount = 0;
81419
81530
  let finish = null;
81420
81531
  let finishIntentRejections = 0;
81532
+ let finishStrengthRejections = 0;
81421
81533
  let emptyTurnsAfterFinishReject = 0;
81422
81534
  let latest = null;
81423
81535
  const getLatest = () => latest;
@@ -81910,31 +82022,49 @@ ${statePacket}`, redactionCredentials) },
81910
82022
  const claimedVerified = Boolean(toolArgs.verified);
81911
82023
  const intentReason = scenarioIntentMismatchReason(scenario, actions);
81912
82024
  const verifiedWithoutAssert = claimedVerified && assertCount === 0;
81913
- const canReject = finishIntentRejections < MAX_FINISH_INTENT_REJECTIONS && iteration < maxIterations && (intentReason !== null || verifiedWithoutAssert);
81914
- if (canReject) {
81915
- 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;
81916
82038
  const reasons = [];
81917
82039
  if (intentReason)
81918
82040
  reasons.push(intentReason);
81919
82041
  if (verifiedWithoutAssert) {
81920
82042
  reasons.push("claimed verified:true but never called mobile_assert_visible / mobile_assert_text");
81921
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
+ }
81922
82047
  const reasonText = reasons.join("; ");
81923
- 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}`);
81924
82049
  pushResult({
81925
82050
  ok: false,
81926
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.`,
81927
82052
  intentMismatch: intentReason,
81928
82053
  verifiedWithoutAssert,
81929
- rejection: finishIntentRejections,
81930
- maxRejections: MAX_FINISH_INTENT_REJECTIONS
82054
+ ...strengthRejectReason !== null ? { strengthBlock: strengthRejectReason } : {},
82055
+ rejection: finishIntentRejections + finishStrengthRejections,
82056
+ maxRejections: MAX_FINISH_INTENT_REJECTIONS + 1
81931
82057
  });
81932
82058
  continue;
81933
82059
  }
81934
82060
  finish = {
81935
82061
  name: redactCredentialValues(nonEmpty3(String(toolArgs.name ?? "")) ?? scenario.name, redactionCredentials),
81936
82062
  description: redactCredentialValues(nonEmpty3(String(toolArgs.description ?? "")) ?? scenario.goal, redactionCredentials),
81937
- verified: claimedVerified
82063
+ verified: claimedVerified,
82064
+ ...toolArgs.suspectedBug === true ? {
82065
+ suspectedBug: true,
82066
+ bugDescription: redactCredentialValues(nonEmpty3(String(toolArgs.bugDescription ?? "")) ?? "", redactionCredentials)
82067
+ } : {}
81938
82068
  };
81939
82069
  log2(` finish_generate: "${finish.name}" (claimed verified: ${finish.verified}, asserts: ${assertCount})`);
81940
82070
  pushResult({ acknowledged: true });
@@ -81998,6 +82128,7 @@ ${statePacket}`, redactionCredentials) },
81998
82128
  ok: false,
81999
82129
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
82000
82130
  });
82131
+ const recordedTypeValues = () => recordedActions.filter((action) => action.type === "TYPE" && typeof action.value === "string" && action.value.length > 0).map((action) => action.value);
82001
82132
  const actionPayload = (result, absorbed) => {
82002
82133
  const blocked = Boolean(absorbed.externalBlocked || absorbed.transientExternal);
82003
82134
  return {
@@ -82398,7 +82529,8 @@ ${statePacket}`, redactionCredentials) },
82398
82529
  latest,
82399
82530
  screenName,
82400
82531
  platform: platform3,
82401
- recordAction
82532
+ recordAction,
82533
+ typedValues: recordedTypeValues
82402
82534
  });
82403
82535
  if (!recorded.ok)
82404
82536
  return recorded;
@@ -82467,6 +82599,15 @@ ${statePacket}`, redactionCredentials) },
82467
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.`
82468
82600
  };
82469
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
+ }
82470
82611
  const metadata = metadataForElement(element, screenName(), platform3);
82471
82612
  const expectedNeedle = expected.trim().toLowerCase();
82472
82613
  const identityValues = [element.accessibilityId, element.resourceId].map((value) => value?.trim().toLowerCase()).filter((value) => Boolean(value));
@@ -82522,6 +82663,17 @@ ${statePacket}`, redactionCredentials) },
82522
82663
  if (!accessibilityId && !text) {
82523
82664
  return { ok: false, error: "mobile_assert_visible needs a ref (preferred), accessibilityId, or text." };
82524
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
+ }
82525
82677
  const directTarget = accessibilityId ?? text;
82526
82678
  if (directTarget && isStructuralAssertionIdentity(directTarget)) {
82527
82679
  return {
@@ -83157,7 +83309,21 @@ ${statePacket}`, redactionCredentials) },
83157
83309
  if (claimedVerified && !verified) {
83158
83310
  log2(" downgraded verified -> false: model claimed verified but called zero assert tools.");
83159
83311
  }
83160
- 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
+ }
83161
83327
  if (strengthVerdict.severity === "block" || strengthVerdict.severity === "route-bug") {
83162
83328
  if (verified) {
83163
83329
  log2(` downgraded verified -> false: assertion strength ${strengthVerdict.severity} \u2014 ${strengthVerdict.reason}`);
@@ -83173,7 +83339,11 @@ ${statePacket}`, redactionCredentials) },
83173
83339
  verified = false;
83174
83340
  }
83175
83341
  const platformTag2 = platform3 === "IOS" ? "@ios" : "@android";
83176
- 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";
83177
83347
  const tags = ["@discovered", "@mobile", platformTag2, reviewTag];
83178
83348
  if (strengthVerdict.severity === "downgrade")
83179
83349
  tags.push("@smoke");
@@ -86253,38 +86423,6 @@ var require_run_callback_proxy = __commonJS({
86253
86423
  }
86254
86424
  });
86255
86425
 
86256
- // ../runner-core/dist/services/discovery-lint-flags.js
86257
- var require_discovery_lint_flags = __commonJS({
86258
- "../runner-core/dist/services/discovery-lint-flags.js"(exports2) {
86259
- "use strict";
86260
- Object.defineProperty(exports2, "__esModule", { value: true });
86261
- exports2.structuralLocatorLintMode = structuralLocatorLintMode;
86262
- exports2.isPwMethodPreflightEnabled = isPwMethodPreflightEnabled;
86263
- exports2.isVqaLoginDedupeEnabled = isVqaLoginDedupeEnabled;
86264
- exports2.isTestgenEmailVerifyEnabled = isTestgenEmailVerifyEnabled;
86265
- function strictOn(value) {
86266
- return value === "true" || value === "1";
86267
- }
86268
- function structuralLocatorLintMode() {
86269
- const v = (process.env.DISCOVERY_STRUCTURAL_LOCATOR_LINT || "").trim().toLowerCase();
86270
- if (v === "warn")
86271
- return "warn";
86272
- if (v === "true" || v === "1" || v === "enforce")
86273
- return "enforce";
86274
- return "off";
86275
- }
86276
- function isPwMethodPreflightEnabled() {
86277
- return strictOn(process.env.DISCOVERY_PW_METHOD_PREFLIGHT);
86278
- }
86279
- function isVqaLoginDedupeEnabled() {
86280
- return strictOn(process.env.DISCOVERY_VQALOGIN_DEDUPE);
86281
- }
86282
- function isTestgenEmailVerifyEnabled() {
86283
- return strictOn(process.env.DISCOVERY_TESTGEN_EMAIL_VERIFY);
86284
- }
86285
- }
86286
- });
86287
-
86288
86426
  // ../runner-core/dist/services/auth-recipe.js
86289
86427
  var require_auth_recipe = __commonJS({
86290
86428
  "../runner-core/dist/services/auth-recipe.js"(exports2) {
@@ -95115,6 +95253,8 @@ var require_discover_generator = __commonJS({
95115
95253
  Object.defineProperty(exports2, "__esModule", { value: true });
95116
95254
  exports2.MIN_BUG_DESCRIPTION_LENGTH = exports2.VALID_PLAYWRIGHT_GETBY_METHODS = exports2.BLOCKED_MCP_TOOLS_SCENARIO_GEN = void 0;
95117
95255
  exports2.scenarioLooksLikeRegistration = scenarioLooksLikeRegistration;
95256
+ exports2.signupFeatureTestingAllowed = signupFeatureTestingAllowed;
95257
+ exports2.scenarioTouchesSignupAuthSurface = scenarioTouchesSignupAuthSurface;
95118
95258
  exports2.filterMcpToolsForScenarioGen = filterMcpToolsForScenarioGen;
95119
95259
  exports2.detectScenarioSignals = detectScenarioSignals;
95120
95260
  exports2.scenarioStepsCoverAuth = scenarioStepsCoverAuth;
@@ -95202,6 +95342,19 @@ EMIT (the code you put in finish_generate) \u2014 do NOT hardcode the throwaway
95202
95342
  - Tag the test \`@email-verify\`. Emit EXACTLY ONE test() in this file (one registration per run).
95203
95343
  - End with a DURABLE assertion of the post-verification signed-in state (e.g. a Logout control, an account/dashboard landmark) in your OWN test body \u2014 the helper call is not itself a proof.`;
95204
95344
  }
95345
+ function signupFeatureTestingAllowed(credentials) {
95346
+ if (!(0, discovery_lint_flags_js_1.isTestgenEmailVerifyEnabled)() || !(0, verification_inbox_js_1.loadVerificationInboxConfig)())
95347
+ return false;
95348
+ return credentials?.DISCOVERY_ALLOW_SIGNUP_TESTS !== "false";
95349
+ }
95350
+ var SIGNUP_AUTH_ROUTE_RE = /\/(sign-?up|verify-?email|email-?verification|create-?account|confirm-?email)\b/i;
95351
+ function scenarioTouchesSignupAuthSurface(scenario, pages) {
95352
+ const routes = [scenario.entryRoute, ...scenario.targetPages ?? []].filter((r) => !!r);
95353
+ if (routes.some((r) => SIGNUP_AUTH_ROUTE_RE.test(r)))
95354
+ return true;
95355
+ const authRoutes = new Set(pages.filter((p) => (p.pageType ?? "").toUpperCase() === "AUTH").map((p) => p.route));
95356
+ return routes.some((r) => authRoutes.has(r));
95357
+ }
95205
95358
  exports2.BLOCKED_MCP_TOOLS_SCENARIO_GEN = /* @__PURE__ */ new Set([
95206
95359
  "browser_install",
95207
95360
  "browser_close",
@@ -98313,9 +98466,16 @@ ${tailBlock}`;
98313
98466
  extraAuthEvidence: !!opts?.recipe || hasConfiguredLoginCredentials(credentials)
98314
98467
  });
98315
98468
  const baseSkeletonHadRegression = tags.includes("@regression");
98469
+ const selfProvisioningSignupScenario = signupFeatureTestingAllowed(credentials) && scenarioLooksLikeRegistration(scenario) && scenarioTouchesSignupAuthSurface(scenario, siteMap.pages);
98470
+ if (selfProvisioningSignupScenario) {
98471
+ for (const prereqTag of ["@needs-auth", "@inline-login", "@auth-guarded"]) {
98472
+ const idx = tags.indexOf(prereqTag);
98473
+ if (idx !== -1)
98474
+ tags.splice(idx, 1);
98475
+ }
98476
+ }
98316
98477
  const hasVerifiedRecipe = !!opts?.recipe && tags.includes("@needs-auth");
98317
- const projectHasRegisterIntent = credentials?.DISCOVERY_SHOULD_REGISTER === "true";
98318
- const inboxGenConfig = (0, discovery_lint_flags_js_1.isTestgenEmailVerifyEnabled)() && projectHasRegisterIntent && !hasVerifiedRecipe && scenarioLooksLikeRegistration(scenario) ? (0, verification_inbox_js_1.loadVerificationInboxConfig)() : null;
98478
+ const inboxGenConfig = selfProvisioningSignupScenario ? (0, verification_inbox_js_1.loadVerificationInboxConfig)() : null;
98319
98479
  let genInboxState = null;
98320
98480
  let genInboxCredentials = credentials;
98321
98481
  let genInboxSecret = null;
@@ -99262,7 +99422,8 @@ Scenario: "${scenario.name}" (${scenario.type})`);
99262
99422
  } catch {
99263
99423
  }
99264
99424
  const recipe = authRecipe;
99265
- const replayLogin = shouldReplayAuthRecipe(scenario.targetPages, siteMap.pages, recipe);
99425
+ const selfProvisioningSignupScenario = signupFeatureTestingAllowed(credentials) && scenarioLooksLikeRegistration(scenario) && scenarioTouchesSignupAuthSurface(scenario, siteMap.pages);
99426
+ const replayLogin = !selfProvisioningSignupScenario && shouldReplayAuthRecipe(scenario.targetPages, siteMap.pages, recipe);
99266
99427
  let effectiveRecipe = null;
99267
99428
  if (replayLogin && recipe) {
99268
99429
  const planLeaks = findSensitiveLiteralLeaks(JSON.stringify(recipe.replayPlan ?? []), credentials);
@@ -99369,7 +99530,8 @@ Scenario: "${scenario.name}" (${scenario.type})`);
99369
99530
  return;
99370
99531
  }
99371
99532
  let effectiveRecipe = null;
99372
- if (shouldReplayAuthRecipe(scenario.targetPages, siteMap.pages, authRecipe) && authRecipe) {
99533
+ const selfProvisioningSignupScenario = signupFeatureTestingAllowed(credentials) && scenarioLooksLikeRegistration(scenario) && scenarioTouchesSignupAuthSurface(scenario, siteMap.pages);
99534
+ if (!selfProvisioningSignupScenario && shouldReplayAuthRecipe(scenario.targetPages, siteMap.pages, authRecipe) && authRecipe) {
99373
99535
  const planLeaks = findSensitiveLiteralLeaks(JSON.stringify(authRecipe.replayPlan ?? []), credentials);
99374
99536
  if (planLeaks.length > 0) {
99375
99537
  scenarioLogs.push(` Warning: login replay plan contains literal credential value(s) for ${planLeaks.join(", ")} \u2014 falling back to model-authored login for "${scenario.name}"`);
@@ -335170,6 +335332,7 @@ var require_discover_agent = __commonJS({
335170
335332
  var discover_ai_planner_js_1 = require_discover_ai_planner();
335171
335333
  var discovery_lint_flags_js_1 = require_discovery_lint_flags();
335172
335334
  var verification_inbox_js_1 = require_verification_inbox();
335335
+ var inbox_tools_js_1 = require_inbox_tools();
335173
335336
  var sitemap_builder_js_1 = require_sitemap_builder();
335174
335337
  Object.defineProperty(exports2, "buildInMemorySiteMap", { enumerable: true, get: function() {
335175
335338
  return sitemap_builder_js_1.buildInMemorySiteMap;
@@ -335183,11 +335346,16 @@ var require_discover_agent = __commonJS({
335183
335346
  var canvas_detector_js_1 = require_canvas_detector();
335184
335347
  var auth_recipe_js_1 = require_auth_recipe();
335185
335348
  function plannerEmailVerifyArmed(ctx) {
335186
- if (!(0, discovery_lint_flags_js_1.isTestgenEmailVerifyEnabled)())
335187
- return false;
335188
- if (ctx.variableContext?.allTestVariables?.DISCOVERY_SHOULD_REGISTER !== "true")
335349
+ if (!(0, discovery_lint_flags_js_1.isTestgenEmailVerifyEnabled)() || !(0, verification_inbox_js_1.loadVerificationInboxConfig)())
335189
335350
  return false;
335190
- return (0, verification_inbox_js_1.isVerificationInboxConfigured)();
335351
+ return (0, inbox_tools_js_1.hasSignupTestIntent)(ctx.variableContext);
335352
+ }
335353
+ var PLANNER_AUTH_NAME_RE = /\b(auth|authentication|log[\s-]?in|sign[\s-]?in|sign[\s-]?up|signup|account|password|verify[\s-]?email)\b/i;
335354
+ var PLANNER_AUTH_ROUTE_RE = /\/(auth|login|sign-?in|sign-?up|signup|verify-?email|create-?account)\b/i;
335355
+ function featureLooksLikeAuth(featureName, focusRoutes) {
335356
+ if (featureName && PLANNER_AUTH_NAME_RE.test(featureName))
335357
+ return true;
335358
+ return (focusRoutes ?? []).some((r) => PLANNER_AUTH_ROUTE_RE.test(r));
335191
335359
  }
335192
335360
  async function runCanvasPreflight(ctx, sharedBrowser, log2) {
335193
335361
  const cached = ctx.appRenderType ?? null;
@@ -335839,6 +336007,11 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
335839
336007
  authRedirectRoute: (0, shared_1.parseBoolFlag)(process.env.AUTH_RECIPE_STAMP) ? ctx.authRecipe?.redirectRoute ?? void 0 : void 0,
335840
336008
  // Email-verify capability — only for scenario-producing modes; a survey emits
335841
336009
  // featureGroups (no scenarios), so keep its prompt free of the round-trip block.
336010
+ // This site serves survey + FULL mode. Full mode is app-wide (no single feature
336011
+ // identity here), so we deliberately do NOT apply the auth-feature scope: the
336012
+ // per-scenario generator gates (scenarioLooksLikeRegistration +
336013
+ // scenarioTouchesSignupAuthSurface) still isolate non-auth scenarios. The
336014
+ // auth-feature scope IS applied at the DEEP call site where ctx.featureName is known.
335842
336015
  emailVerifyArmed: mode !== "survey" && plannerEmailVerifyArmed(ctx),
335843
336016
  testBudget
335844
336017
  }, {
@@ -336427,9 +336600,10 @@ Plan (reused): ${resumeSavedScenarios.length} scenarios from the paused run.`);
336427
336600
  // Email-verify capability: when armed, let the planner plan the full
336428
336601
  // register→verify and forgot-password→reset round trips to completion
336429
336602
  // (see the planner's "## Verification Inbox Available" block). Armed on the
336430
- // SAME project-level predicate the generator uses (flag + register intent +
336603
+ // SAME project-level predicate the generator uses (flag + consent +
336431
336604
  // configured inbox), so the planner never promises what generation can't do.
336432
- emailVerifyArmed: plannerEmailVerifyArmed(ctx),
336605
+ // Scoped to AUTH features: a non-auth feature has no signup surface to test.
336606
+ emailVerifyArmed: plannerEmailVerifyArmed(ctx) && featureLooksLikeAuth(ctx.featureName, augmentedFeatureFocusAreas),
336433
336607
  testBudget: featureTestBudget
336434
336608
  }, {
336435
336609
  modelOverride: ctx.planModel ?? ctx.generateModel,
@@ -347747,12 +347921,7 @@ Write valid JSON only (no markdown fences inside files). Be exhaustive.`;
347747
347921
  const o = value;
347748
347922
  if (!isNonEmptyString(o.name))
347749
347923
  return { ok: false, reason: "name: missing" };
347750
- if (!isNonEmptyString(o.appiumCode))
347751
- return { ok: false, reason: "appiumCode: missing" };
347752
- const code = o.appiumCode;
347753
- if (!/webdriverio|remote\s*\(|driver\s*\./i.test(code)) {
347754
- return { ok: false, reason: "appiumCode: not a WebdriverIO/Appium script" };
347755
- }
347924
+ const code = isNonEmptyString(o.appiumCode) ? o.appiumCode : "";
347756
347925
  if (!Array.isArray(o.steps) || o.steps.length === 0) {
347757
347926
  return { ok: false, reason: "steps: missing or empty" };
347758
347927
  }
@@ -351055,7 +351224,7 @@ var require_package4 = __commonJS({
351055
351224
  "package.json"(exports2, module2) {
351056
351225
  module2.exports = {
351057
351226
  name: "@validate.qa/runner",
351058
- version: "1.0.65",
351227
+ version: "1.0.67",
351059
351228
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
351060
351229
  bin: {
351061
351230
  "validate-runner": "dist/cli.js"
@@ -351451,6 +351620,9 @@ function sanitizeAppiumLogLine(line) {
351451
351620
  }
351452
351621
  function setAppiumWorkloadActive(active) {
351453
351622
  activeWorkloadCount = active ? activeWorkloadCount + 1 : Math.max(0, activeWorkloadCount - 1);
351623
+ if (activeWorkloadCount === 0) {
351624
+ consecutiveHealthFailures = 0;
351625
+ }
351454
351626
  }
351455
351627
  function shouldRestartAfterHealthFailure(workloadCount, failureCount) {
351456
351628
  return workloadCount <= 0 && failureCount >= HEALTH_FAILURES_BEFORE_RESTART;
@@ -351946,17 +352118,31 @@ function rectMatchesIntendedBounds(rect, bounds) {
351946
352118
  return rectCenterInBounds || boundsCenterInRect;
351947
352119
  }
351948
352120
  async function resolveTypeTargetElementId(sessionPath, bounds) {
351949
- 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;
351950
352123
  let elementId = await getActiveElementId(sessionPath);
351951
- if (elementId && box && box.width > 0 && box.height > 0) {
352124
+ let rejectedElementId = null;
352125
+ let rejectedForMismatch = false;
352126
+ if (elementId && box) {
351952
352127
  const rect = await getElementRect(sessionPath, elementId);
351953
352128
  if (!rect || !rectMatchesIntendedBounds(rect, box)) {
352129
+ rejectedElementId = elementId;
352130
+ rejectedForMismatch = !!rect;
351954
352131
  elementId = null;
351955
352132
  }
351956
352133
  }
351957
352134
  if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
351958
352135
  await new Promise((resolve2) => setTimeout(resolve2, MOBILE_FOCUS_SETTLE_MS));
351959
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
+ }
351960
352146
  }
351961
352147
  return elementId;
351962
352148
  }
@@ -354532,6 +354718,13 @@ function buildLocatorStrategies(target, platform3) {
354532
354718
  if (bundle) {
354533
354719
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
354534
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
+ }
354535
354728
  if (bundle.xpath) strategies.push({ using: "xpath", value: bundle.xpath });
354536
354729
  }
354537
354730
  if (!(platform3 && hasPlatformLocator(target, platform3))) {
@@ -354549,14 +354742,14 @@ function dedupeStrategies(strategies) {
354549
354742
  (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
354550
354743
  );
354551
354744
  }
354552
- function xpathLiteral2(value) {
354745
+ function xpathLiteral3(value) {
354553
354746
  if (!value.includes("'")) return `'${value}'`;
354554
354747
  if (!value.includes('"')) return `"${value}"`;
354555
354748
  const parts = value.split("'").map((p) => `'${p}'`);
354556
354749
  return `concat(${parts.join(`, "'", `)})`;
354557
354750
  }
354558
- function identityTextXpath(value) {
354559
- const lit = xpathLiteral2(value);
354751
+ function identityTextXpath2(value) {
354752
+ const lit = xpathLiteral3(value);
354560
354753
  return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
354561
354754
  }
354562
354755
  function isIdentityXpath(value) {
@@ -354576,7 +354769,7 @@ function buildIdentityLocatorStrategies(target, platform3) {
354576
354769
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
354577
354770
  for (const value of [bundle.text, bundle.contentDesc]) {
354578
354771
  const trimmed = value?.trim();
354579
- if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
354772
+ if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath2(trimmed) });
354580
354773
  }
354581
354774
  if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
354582
354775
  }
@@ -355056,6 +355249,19 @@ async function readElementTextCandidates(sessionId, elementId, actualText) {
355056
355249
  }
355057
355250
  return values;
355058
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
+ }
355059
355265
  async function executeMobileTest(options) {
355060
355266
  const logLines = [];
355061
355267
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
@@ -355075,6 +355281,7 @@ async function executeMobileTest(options) {
355075
355281
  let captureStopped = false;
355076
355282
  let recordingStarted = false;
355077
355283
  let recordingStopped = false;
355284
+ let acquiredSlotDeviceId = null;
355078
355285
  let video;
355079
355286
  let failingSource;
355080
355287
  const stopCapture = async () => {
@@ -355177,7 +355384,8 @@ async function executeMobileTest(options) {
355177
355384
  log2(`Warning: Could not retrieve installed apps list. Proceeding with test execution.`);
355178
355385
  }
355179
355386
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
355180
- const leasedPortOffset = Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id));
355387
+ const leasedPortOffset = acquireControlPortSlot(selectedDevice.id);
355388
+ acquiredSlotDeviceId = selectedDevice.id;
355181
355389
  try {
355182
355390
  sessionId = await createSessionWithRetry({
355183
355391
  appId: options.target.appId,
@@ -355454,6 +355662,7 @@ async function executeMobileTest(options) {
355454
355662
  await deleteSession(sessionId);
355455
355663
  log2("Session destroyed");
355456
355664
  }
355665
+ if (acquiredSlotDeviceId) releaseControlPortSlot(acquiredSlotDeviceId);
355457
355666
  }
355458
355667
  }
355459
355668
  async function createMobileDiscoverySession(target, options) {
@@ -355499,21 +355708,28 @@ async function createMobileDiscoverySession(target, options) {
355499
355708
  isPhysical: selectedDevice.isPhysical === true,
355500
355709
  platformVersion: selectedDevice.platformVersion
355501
355710
  });
355502
- const sessionId = await createSessionWithRetry({
355503
- appId: target.appId,
355504
- platform: target.platform,
355505
- deviceId: selectedDevice.id,
355506
- platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
355507
- isPhysical: selectedDevice.isPhysical,
355508
- // Distinct control resources per concurrent device, including simulators.
355509
- portOffset: Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id))
355510
- });
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
+ }
355511
355726
  if (target.launchMode === "DEEP_LINK" && target.deeplink) {
355512
355727
  try {
355513
355728
  await openDeepLink(sessionId, target.platform, target.deeplink, target.appId);
355514
355729
  } catch (err) {
355515
355730
  await deleteSession(sessionId).catch(() => {
355516
355731
  });
355732
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355517
355733
  throw new Error(
355518
355734
  `Deep-link launch failed for mobile discovery: ${err instanceof Error ? err.message : String(err)}`
355519
355735
  );
@@ -355535,6 +355751,7 @@ async function createMobileDiscoverySession(target, options) {
355535
355751
  } catch {
355536
355752
  }
355537
355753
  await deleteSession(sessionId);
355754
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355538
355755
  }
355539
355756
  };
355540
355757
  }
@@ -355552,14 +355769,14 @@ async function readElementAttribute(sessionId, elementId, name) {
355552
355769
  }
355553
355770
  function harvestFallbackXpath(platform3, attrs) {
355554
355771
  if (platform3 === "IOS") {
355555
- if (attrs.name) return `//*[@name=${xpathLiteral2(attrs.name)}]`;
355772
+ if (attrs.name) return `//*[@name=${xpathLiteral3(attrs.name)}]`;
355556
355773
  const text = attrs.label ?? attrs.value;
355557
- if (text) return identityTextXpath(text);
355774
+ if (text) return identityTextXpath2(text);
355558
355775
  return "//*";
355559
355776
  }
355560
- if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral2(attrs.resourceId)}]`;
355561
- if (attrs.text) return identityTextXpath(attrs.text);
355562
- 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)}]`;
355563
355780
  return "//*";
355564
355781
  }
355565
355782
  async function readNativeElementBundle(sessionId, elementId, platform3) {
@@ -355912,6 +356129,7 @@ var sessionStartInFlight = null;
355912
356129
  var sessionReaperTimer = null;
355913
356130
  var mirrorClientsEmptyAt = null;
355914
356131
  var mirrorSessionOwner = null;
356132
+ var actionInFlight = false;
355915
356133
  var recordingWorkloadActive = false;
355916
356134
  function setRecordingWorkloadActive(active) {
355917
356135
  if (recordingWorkloadActive === active) return;
@@ -356141,7 +356359,11 @@ async function handleHealth(_req, res) {
356141
356359
  // 409 — it must never be inferred from server-side global run counts, which
356142
356360
  // include unrelated projects and runners.
356143
356361
  executorBusy: executorBusyCount > 0,
356144
- 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
356145
356367
  });
356146
356368
  }
356147
356369
  function realDeviceCapabilities2(platform3) {
@@ -356219,7 +356441,11 @@ async function handleDevices(_req, res) {
356219
356441
  sendJSON(res, 200, {
356220
356442
  devices,
356221
356443
  executorBusy: executorBusyCount > 0,
356222
- 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
356223
356449
  });
356224
356450
  }
356225
356451
  async function handleSessionStart(req, res) {
@@ -356308,6 +356534,8 @@ async function startSession(validated, res) {
356308
356534
  state.platform = targetPlatform === "IOS" ? "IOS" : "ANDROID";
356309
356535
  mirrorSessionOwner = "recording";
356310
356536
  setRecordingWorkloadActive(true);
356537
+ mirrorClientsEmptyAt = Date.now();
356538
+ ensureSessionReaper();
356311
356539
  if (launchMode === "DEEP_LINK" && deeplink) {
356312
356540
  try {
356313
356541
  const args = targetPlatform === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
@@ -356661,6 +356889,14 @@ async function handleAction(req, res) {
356661
356889
  sendJSON(res, 400, { error: "No active Appium session" });
356662
356890
  return;
356663
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
+ }
356664
356900
  validateSessionId(state.appiumSessionId);
356665
356901
  const body = await parseBody(req);
356666
356902
  const type = typeof body.type === "string" ? body.type : null;
@@ -356672,6 +356908,7 @@ async function handleAction(req, res) {
356672
356908
  sendJSON(res, 400, { error: "Unknown or unsupported action type" });
356673
356909
  return;
356674
356910
  }
356911
+ actionInFlight = true;
356675
356912
  try {
356676
356913
  const sessionPath = `/session/${state.appiumSessionId}`;
356677
356914
  switch (type) {
@@ -356744,15 +356981,23 @@ async function handleAction(req, res) {
356744
356981
  sendJSON(res, 400, { error: `Unknown action type: ${type}` });
356745
356982
  return;
356746
356983
  }
356747
- 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
+ }
356748
356990
  sendJSON(res, 200, {
356749
356991
  ok: true,
356750
356992
  screenshot: screenshot ?? null,
356751
356993
  source: source ?? null,
356994
+ ...source === null || source === void 0 ? { sourceUnavailable: true } : {},
356752
356995
  timestamp: Date.now()
356753
356996
  });
356754
356997
  } catch (err) {
356755
356998
  sendJSON(res, 500, { error: `Action failed: ${err instanceof Error ? err.message : String(err)}` });
356999
+ } finally {
357000
+ actionInFlight = false;
356756
357001
  }
356757
357002
  }
356758
357003
  async function handleScreenshot(_req, res) {
@@ -356990,7 +357235,11 @@ function getBridgeState() {
356990
357235
  tunnelUrl: state.tunnelUrl,
356991
357236
  tunnelToken: state.tunnelToken,
356992
357237
  hasActiveSession: !!state.appiumSessionId,
356993
- 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,
356994
357243
  platform: state.platform
356995
357244
  };
356996
357245
  }