@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.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,
@@ -73629,6 +73686,7 @@ var require_inbox_tools = __commonJS({
73629
73686
  exports.buildInboxPromptBlock = buildInboxPromptBlock;
73630
73687
  exports.guardVerificationCodeFabrication = guardVerificationCodeFabrication;
73631
73688
  exports.hasRegisterIntent = hasRegisterIntent;
73689
+ exports.hasSignupTestIntent = hasSignupTestIntent;
73632
73690
  exports.resolveInboxRegistration = resolveInboxRegistration;
73633
73691
  exports.applyInboxRegistrationIdentity = applyInboxRegistrationIdentity;
73634
73692
  exports.isThrowawayRegisterSeedEmail = isThrowawayRegisterSeedEmail;
@@ -74172,6 +74230,9 @@ This project asked to REGISTER a new account, but this runner CANNOT receive ema
74172
74230
  const v = variableContext.allTestVariables?.DISCOVERY_SHOULD_REGISTER ?? variableContext.publicTestVariables?.DISCOVERY_SHOULD_REGISTER ?? "";
74173
74231
  return String(v).trim().toLowerCase() === "true";
74174
74232
  }
74233
+ function hasSignupTestIntent(variableContext) {
74234
+ return variableContext?.allTestVariables?.DISCOVERY_ALLOW_SIGNUP_TESTS !== "false";
74235
+ }
74175
74236
  function resolveInboxRegistration(opts) {
74176
74237
  const config = (0, verification_inbox_js_1.loadVerificationInboxConfig)(opts.env ?? process.env);
74177
74238
  if (!config)
@@ -80884,6 +80945,45 @@ ${constraints.join("\n")}`);
80884
80945
  }
80885
80946
  });
80886
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
+
80887
80987
  // ../runner-core/dist/services/mobile/mobile-generator.js
80888
80988
  var require_mobile_generator = __commonJS({
80889
80989
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports) {
@@ -80903,6 +81003,7 @@ var require_mobile_generator = __commonJS({
80903
81003
  var ai_queue_js_1 = require_ai_queue();
80904
81004
  var ai_retry_js_1 = require_ai_retry();
80905
81005
  var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
81006
+ var discovery_lint_flags_js_1 = require_discovery_lint_flags();
80906
81007
  var mobile_source_parser_js_1 = require_mobile_source_parser();
80907
81008
  var mobile_source_parser_js_2 = require_mobile_source_parser();
80908
81009
  var mobile_observation_js_1 = require_mobile_observation();
@@ -81072,7 +81173,15 @@ var require_mobile_generator = __commonJS({
81072
81173
  properties: {
81073
81174
  name: { type: "string", description: 'Descriptive test name (e.g. "Login - Happy Path").' },
81074
81175
  description: { type: "string", description: "One-line description of what the test proves." },
81075
- 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
+ }
81076
81185
  },
81077
81186
  required: ["name", "description", "verified"]
81078
81187
  }
@@ -81318,6 +81427,8 @@ var require_mobile_generator = __commonJS({
81318
81427
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
81319
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.
81320
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.
81321
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.
81322
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.
81323
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.
@@ -81423,6 +81534,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
81423
81534
  let assertCount = 0;
81424
81535
  let finish = null;
81425
81536
  let finishIntentRejections = 0;
81537
+ let finishStrengthRejections = 0;
81426
81538
  let emptyTurnsAfterFinishReject = 0;
81427
81539
  let latest = null;
81428
81540
  const getLatest = () => latest;
@@ -81915,31 +82027,49 @@ ${statePacket}`, redactionCredentials) },
81915
82027
  const claimedVerified = Boolean(toolArgs.verified);
81916
82028
  const intentReason = scenarioIntentMismatchReason(scenario, actions);
81917
82029
  const verifiedWithoutAssert = claimedVerified && assertCount === 0;
81918
- const canReject = finishIntentRejections < MAX_FINISH_INTENT_REJECTIONS && iteration < maxIterations && (intentReason !== null || verifiedWithoutAssert);
81919
- if (canReject) {
81920
- 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;
81921
82043
  const reasons = [];
81922
82044
  if (intentReason)
81923
82045
  reasons.push(intentReason);
81924
82046
  if (verifiedWithoutAssert) {
81925
82047
  reasons.push("claimed verified:true but never called mobile_assert_visible / mobile_assert_text");
81926
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
+ }
81927
82052
  const reasonText = reasons.join("; ");
81928
- 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}`);
81929
82054
  pushResult({
81930
82055
  ok: false,
81931
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.`,
81932
82057
  intentMismatch: intentReason,
81933
82058
  verifiedWithoutAssert,
81934
- rejection: finishIntentRejections,
81935
- maxRejections: MAX_FINISH_INTENT_REJECTIONS
82059
+ ...strengthRejectReason !== null ? { strengthBlock: strengthRejectReason } : {},
82060
+ rejection: finishIntentRejections + finishStrengthRejections,
82061
+ maxRejections: MAX_FINISH_INTENT_REJECTIONS + 1
81936
82062
  });
81937
82063
  continue;
81938
82064
  }
81939
82065
  finish = {
81940
82066
  name: redactCredentialValues(nonEmpty3(String(toolArgs.name ?? "")) ?? scenario.name, redactionCredentials),
81941
82067
  description: redactCredentialValues(nonEmpty3(String(toolArgs.description ?? "")) ?? scenario.goal, redactionCredentials),
81942
- verified: claimedVerified
82068
+ verified: claimedVerified,
82069
+ ...toolArgs.suspectedBug === true ? {
82070
+ suspectedBug: true,
82071
+ bugDescription: redactCredentialValues(nonEmpty3(String(toolArgs.bugDescription ?? "")) ?? "", redactionCredentials)
82072
+ } : {}
81943
82073
  };
81944
82074
  log2(` finish_generate: "${finish.name}" (claimed verified: ${finish.verified}, asserts: ${assertCount})`);
81945
82075
  pushResult({ acknowledged: true });
@@ -82003,6 +82133,7 @@ ${statePacket}`, redactionCredentials) },
82003
82133
  ok: false,
82004
82134
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
82005
82135
  });
82136
+ const recordedTypeValues = () => recordedActions.filter((action) => action.type === "TYPE" && typeof action.value === "string" && action.value.length > 0).map((action) => action.value);
82006
82137
  const actionPayload = (result, absorbed) => {
82007
82138
  const blocked = Boolean(absorbed.externalBlocked || absorbed.transientExternal);
82008
82139
  return {
@@ -82403,7 +82534,8 @@ ${statePacket}`, redactionCredentials) },
82403
82534
  latest,
82404
82535
  screenName,
82405
82536
  platform: platform3,
82406
- recordAction
82537
+ recordAction,
82538
+ typedValues: recordedTypeValues
82407
82539
  });
82408
82540
  if (!recorded.ok)
82409
82541
  return recorded;
@@ -82472,6 +82604,15 @@ ${statePacket}`, redactionCredentials) },
82472
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.`
82473
82605
  };
82474
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
+ }
82475
82616
  const metadata = metadataForElement(element, screenName(), platform3);
82476
82617
  const expectedNeedle = expected.trim().toLowerCase();
82477
82618
  const identityValues = [element.accessibilityId, element.resourceId].map((value) => value?.trim().toLowerCase()).filter((value) => Boolean(value));
@@ -82527,6 +82668,17 @@ ${statePacket}`, redactionCredentials) },
82527
82668
  if (!accessibilityId && !text) {
82528
82669
  return { ok: false, error: "mobile_assert_visible needs a ref (preferred), accessibilityId, or text." };
82529
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
+ }
82530
82682
  const directTarget = accessibilityId ?? text;
82531
82683
  if (directTarget && isStructuralAssertionIdentity(directTarget)) {
82532
82684
  return {
@@ -83162,7 +83314,21 @@ ${statePacket}`, redactionCredentials) },
83162
83314
  if (claimedVerified && !verified) {
83163
83315
  log2(" downgraded verified -> false: model claimed verified but called zero assert tools.");
83164
83316
  }
83165
- 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
+ }
83166
83332
  if (strengthVerdict.severity === "block" || strengthVerdict.severity === "route-bug") {
83167
83333
  if (verified) {
83168
83334
  log2(` downgraded verified -> false: assertion strength ${strengthVerdict.severity} \u2014 ${strengthVerdict.reason}`);
@@ -83178,7 +83344,11 @@ ${statePacket}`, redactionCredentials) },
83178
83344
  verified = false;
83179
83345
  }
83180
83346
  const platformTag2 = platform3 === "IOS" ? "@ios" : "@android";
83181
- 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";
83182
83352
  const tags = ["@discovered", "@mobile", platformTag2, reviewTag];
83183
83353
  if (strengthVerdict.severity === "downgrade")
83184
83354
  tags.push("@smoke");
@@ -86258,38 +86428,6 @@ var require_run_callback_proxy = __commonJS({
86258
86428
  }
86259
86429
  });
86260
86430
 
86261
- // ../runner-core/dist/services/discovery-lint-flags.js
86262
- var require_discovery_lint_flags = __commonJS({
86263
- "../runner-core/dist/services/discovery-lint-flags.js"(exports) {
86264
- "use strict";
86265
- Object.defineProperty(exports, "__esModule", { value: true });
86266
- exports.structuralLocatorLintMode = structuralLocatorLintMode;
86267
- exports.isPwMethodPreflightEnabled = isPwMethodPreflightEnabled;
86268
- exports.isVqaLoginDedupeEnabled = isVqaLoginDedupeEnabled;
86269
- exports.isTestgenEmailVerifyEnabled = isTestgenEmailVerifyEnabled;
86270
- function strictOn(value) {
86271
- return value === "true" || value === "1";
86272
- }
86273
- function structuralLocatorLintMode() {
86274
- const v = (process.env.DISCOVERY_STRUCTURAL_LOCATOR_LINT || "").trim().toLowerCase();
86275
- if (v === "warn")
86276
- return "warn";
86277
- if (v === "true" || v === "1" || v === "enforce")
86278
- return "enforce";
86279
- return "off";
86280
- }
86281
- function isPwMethodPreflightEnabled() {
86282
- return strictOn(process.env.DISCOVERY_PW_METHOD_PREFLIGHT);
86283
- }
86284
- function isVqaLoginDedupeEnabled() {
86285
- return strictOn(process.env.DISCOVERY_VQALOGIN_DEDUPE);
86286
- }
86287
- function isTestgenEmailVerifyEnabled() {
86288
- return strictOn(process.env.DISCOVERY_TESTGEN_EMAIL_VERIFY);
86289
- }
86290
- }
86291
- });
86292
-
86293
86431
  // ../runner-core/dist/services/auth-recipe.js
86294
86432
  var require_auth_recipe = __commonJS({
86295
86433
  "../runner-core/dist/services/auth-recipe.js"(exports) {
@@ -95120,6 +95258,8 @@ var require_discover_generator = __commonJS({
95120
95258
  Object.defineProperty(exports, "__esModule", { value: true });
95121
95259
  exports.MIN_BUG_DESCRIPTION_LENGTH = exports.VALID_PLAYWRIGHT_GETBY_METHODS = exports.BLOCKED_MCP_TOOLS_SCENARIO_GEN = void 0;
95122
95260
  exports.scenarioLooksLikeRegistration = scenarioLooksLikeRegistration;
95261
+ exports.signupFeatureTestingAllowed = signupFeatureTestingAllowed;
95262
+ exports.scenarioTouchesSignupAuthSurface = scenarioTouchesSignupAuthSurface;
95123
95263
  exports.filterMcpToolsForScenarioGen = filterMcpToolsForScenarioGen;
95124
95264
  exports.detectScenarioSignals = detectScenarioSignals;
95125
95265
  exports.scenarioStepsCoverAuth = scenarioStepsCoverAuth;
@@ -95207,6 +95347,19 @@ EMIT (the code you put in finish_generate) \u2014 do NOT hardcode the throwaway
95207
95347
  - Tag the test \`@email-verify\`. Emit EXACTLY ONE test() in this file (one registration per run).
95208
95348
  - 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.`;
95209
95349
  }
95350
+ function signupFeatureTestingAllowed(credentials) {
95351
+ if (!(0, discovery_lint_flags_js_1.isTestgenEmailVerifyEnabled)() || !(0, verification_inbox_js_1.loadVerificationInboxConfig)())
95352
+ return false;
95353
+ return credentials?.DISCOVERY_ALLOW_SIGNUP_TESTS !== "false";
95354
+ }
95355
+ var SIGNUP_AUTH_ROUTE_RE = /\/(sign-?up|verify-?email|email-?verification|create-?account|confirm-?email)\b/i;
95356
+ function scenarioTouchesSignupAuthSurface(scenario, pages) {
95357
+ const routes = [scenario.entryRoute, ...scenario.targetPages ?? []].filter((r) => !!r);
95358
+ if (routes.some((r) => SIGNUP_AUTH_ROUTE_RE.test(r)))
95359
+ return true;
95360
+ const authRoutes = new Set(pages.filter((p) => (p.pageType ?? "").toUpperCase() === "AUTH").map((p) => p.route));
95361
+ return routes.some((r) => authRoutes.has(r));
95362
+ }
95210
95363
  exports.BLOCKED_MCP_TOOLS_SCENARIO_GEN = /* @__PURE__ */ new Set([
95211
95364
  "browser_install",
95212
95365
  "browser_close",
@@ -98318,9 +98471,16 @@ ${tailBlock}`;
98318
98471
  extraAuthEvidence: !!opts?.recipe || hasConfiguredLoginCredentials(credentials)
98319
98472
  });
98320
98473
  const baseSkeletonHadRegression = tags.includes("@regression");
98474
+ const selfProvisioningSignupScenario = signupFeatureTestingAllowed(credentials) && scenarioLooksLikeRegistration(scenario) && scenarioTouchesSignupAuthSurface(scenario, siteMap.pages);
98475
+ if (selfProvisioningSignupScenario) {
98476
+ for (const prereqTag of ["@needs-auth", "@inline-login", "@auth-guarded"]) {
98477
+ const idx = tags.indexOf(prereqTag);
98478
+ if (idx !== -1)
98479
+ tags.splice(idx, 1);
98480
+ }
98481
+ }
98321
98482
  const hasVerifiedRecipe = !!opts?.recipe && tags.includes("@needs-auth");
98322
- const projectHasRegisterIntent = credentials?.DISCOVERY_SHOULD_REGISTER === "true";
98323
- const inboxGenConfig = (0, discovery_lint_flags_js_1.isTestgenEmailVerifyEnabled)() && projectHasRegisterIntent && !hasVerifiedRecipe && scenarioLooksLikeRegistration(scenario) ? (0, verification_inbox_js_1.loadVerificationInboxConfig)() : null;
98483
+ const inboxGenConfig = selfProvisioningSignupScenario ? (0, verification_inbox_js_1.loadVerificationInboxConfig)() : null;
98324
98484
  let genInboxState = null;
98325
98485
  let genInboxCredentials = credentials;
98326
98486
  let genInboxSecret = null;
@@ -99267,7 +99427,8 @@ Scenario: "${scenario.name}" (${scenario.type})`);
99267
99427
  } catch {
99268
99428
  }
99269
99429
  const recipe = authRecipe;
99270
- const replayLogin = shouldReplayAuthRecipe(scenario.targetPages, siteMap.pages, recipe);
99430
+ const selfProvisioningSignupScenario = signupFeatureTestingAllowed(credentials) && scenarioLooksLikeRegistration(scenario) && scenarioTouchesSignupAuthSurface(scenario, siteMap.pages);
99431
+ const replayLogin = !selfProvisioningSignupScenario && shouldReplayAuthRecipe(scenario.targetPages, siteMap.pages, recipe);
99271
99432
  let effectiveRecipe = null;
99272
99433
  if (replayLogin && recipe) {
99273
99434
  const planLeaks = findSensitiveLiteralLeaks(JSON.stringify(recipe.replayPlan ?? []), credentials);
@@ -99374,7 +99535,8 @@ Scenario: "${scenario.name}" (${scenario.type})`);
99374
99535
  return;
99375
99536
  }
99376
99537
  let effectiveRecipe = null;
99377
- if (shouldReplayAuthRecipe(scenario.targetPages, siteMap.pages, authRecipe) && authRecipe) {
99538
+ const selfProvisioningSignupScenario = signupFeatureTestingAllowed(credentials) && scenarioLooksLikeRegistration(scenario) && scenarioTouchesSignupAuthSurface(scenario, siteMap.pages);
99539
+ if (!selfProvisioningSignupScenario && shouldReplayAuthRecipe(scenario.targetPages, siteMap.pages, authRecipe) && authRecipe) {
99378
99540
  const planLeaks = findSensitiveLiteralLeaks(JSON.stringify(authRecipe.replayPlan ?? []), credentials);
99379
99541
  if (planLeaks.length > 0) {
99380
99542
  scenarioLogs.push(` Warning: login replay plan contains literal credential value(s) for ${planLeaks.join(", ")} \u2014 falling back to model-authored login for "${scenario.name}"`);
@@ -335175,6 +335337,7 @@ var require_discover_agent = __commonJS({
335175
335337
  var discover_ai_planner_js_1 = require_discover_ai_planner();
335176
335338
  var discovery_lint_flags_js_1 = require_discovery_lint_flags();
335177
335339
  var verification_inbox_js_1 = require_verification_inbox();
335340
+ var inbox_tools_js_1 = require_inbox_tools();
335178
335341
  var sitemap_builder_js_1 = require_sitemap_builder();
335179
335342
  Object.defineProperty(exports, "buildInMemorySiteMap", { enumerable: true, get: function() {
335180
335343
  return sitemap_builder_js_1.buildInMemorySiteMap;
@@ -335188,11 +335351,16 @@ var require_discover_agent = __commonJS({
335188
335351
  var canvas_detector_js_1 = require_canvas_detector();
335189
335352
  var auth_recipe_js_1 = require_auth_recipe();
335190
335353
  function plannerEmailVerifyArmed(ctx) {
335191
- if (!(0, discovery_lint_flags_js_1.isTestgenEmailVerifyEnabled)())
335192
- return false;
335193
- if (ctx.variableContext?.allTestVariables?.DISCOVERY_SHOULD_REGISTER !== "true")
335354
+ if (!(0, discovery_lint_flags_js_1.isTestgenEmailVerifyEnabled)() || !(0, verification_inbox_js_1.loadVerificationInboxConfig)())
335194
335355
  return false;
335195
- return (0, verification_inbox_js_1.isVerificationInboxConfigured)();
335356
+ return (0, inbox_tools_js_1.hasSignupTestIntent)(ctx.variableContext);
335357
+ }
335358
+ 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;
335359
+ var PLANNER_AUTH_ROUTE_RE = /\/(auth|login|sign-?in|sign-?up|signup|verify-?email|create-?account)\b/i;
335360
+ function featureLooksLikeAuth(featureName, focusRoutes) {
335361
+ if (featureName && PLANNER_AUTH_NAME_RE.test(featureName))
335362
+ return true;
335363
+ return (focusRoutes ?? []).some((r) => PLANNER_AUTH_ROUTE_RE.test(r));
335196
335364
  }
335197
335365
  async function runCanvasPreflight(ctx, sharedBrowser, log2) {
335198
335366
  const cached = ctx.appRenderType ?? null;
@@ -335844,6 +336012,11 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
335844
336012
  authRedirectRoute: (0, shared_1.parseBoolFlag)(process.env.AUTH_RECIPE_STAMP) ? ctx.authRecipe?.redirectRoute ?? void 0 : void 0,
335845
336013
  // Email-verify capability — only for scenario-producing modes; a survey emits
335846
336014
  // featureGroups (no scenarios), so keep its prompt free of the round-trip block.
336015
+ // This site serves survey + FULL mode. Full mode is app-wide (no single feature
336016
+ // identity here), so we deliberately do NOT apply the auth-feature scope: the
336017
+ // per-scenario generator gates (scenarioLooksLikeRegistration +
336018
+ // scenarioTouchesSignupAuthSurface) still isolate non-auth scenarios. The
336019
+ // auth-feature scope IS applied at the DEEP call site where ctx.featureName is known.
335847
336020
  emailVerifyArmed: mode !== "survey" && plannerEmailVerifyArmed(ctx),
335848
336021
  testBudget
335849
336022
  }, {
@@ -336432,9 +336605,10 @@ Plan (reused): ${resumeSavedScenarios.length} scenarios from the paused run.`);
336432
336605
  // Email-verify capability: when armed, let the planner plan the full
336433
336606
  // register→verify and forgot-password→reset round trips to completion
336434
336607
  // (see the planner's "## Verification Inbox Available" block). Armed on the
336435
- // SAME project-level predicate the generator uses (flag + register intent +
336608
+ // SAME project-level predicate the generator uses (flag + consent +
336436
336609
  // configured inbox), so the planner never promises what generation can't do.
336437
- emailVerifyArmed: plannerEmailVerifyArmed(ctx),
336610
+ // Scoped to AUTH features: a non-auth feature has no signup surface to test.
336611
+ emailVerifyArmed: plannerEmailVerifyArmed(ctx) && featureLooksLikeAuth(ctx.featureName, augmentedFeatureFocusAreas),
336438
336612
  testBudget: featureTestBudget
336439
336613
  }, {
336440
336614
  modelOverride: ctx.planModel ?? ctx.generateModel,
@@ -347752,12 +347926,7 @@ Write valid JSON only (no markdown fences inside files). Be exhaustive.`;
347752
347926
  const o = value;
347753
347927
  if (!isNonEmptyString(o.name))
347754
347928
  return { ok: false, reason: "name: missing" };
347755
- if (!isNonEmptyString(o.appiumCode))
347756
- return { ok: false, reason: "appiumCode: missing" };
347757
- const code = o.appiumCode;
347758
- if (!/webdriverio|remote\s*\(|driver\s*\./i.test(code)) {
347759
- return { ok: false, reason: "appiumCode: not a WebdriverIO/Appium script" };
347760
- }
347929
+ const code = isNonEmptyString(o.appiumCode) ? o.appiumCode : "";
347761
347930
  if (!Array.isArray(o.steps) || o.steps.length === 0) {
347762
347931
  return { ok: false, reason: "steps: missing or empty" };
347763
347932
  }
@@ -351060,7 +351229,7 @@ var require_package4 = __commonJS({
351060
351229
  "package.json"(exports, module) {
351061
351230
  module.exports = {
351062
351231
  name: "@validate.qa/runner",
351063
- version: "1.0.65",
351232
+ version: "1.0.67",
351064
351233
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
351065
351234
  bin: {
351066
351235
  "validate-runner": "dist/cli.js"
@@ -351456,6 +351625,9 @@ function sanitizeAppiumLogLine(line) {
351456
351625
  }
351457
351626
  function setAppiumWorkloadActive(active) {
351458
351627
  activeWorkloadCount = active ? activeWorkloadCount + 1 : Math.max(0, activeWorkloadCount - 1);
351628
+ if (activeWorkloadCount === 0) {
351629
+ consecutiveHealthFailures = 0;
351630
+ }
351459
351631
  }
351460
351632
  function shouldRestartAfterHealthFailure(workloadCount, failureCount) {
351461
351633
  return workloadCount <= 0 && failureCount >= HEALTH_FAILURES_BEFORE_RESTART;
@@ -351951,17 +352123,31 @@ function rectMatchesIntendedBounds(rect, bounds) {
351951
352123
  return rectCenterInBounds || boundsCenterInRect;
351952
352124
  }
351953
352125
  async function resolveTypeTargetElementId(sessionPath, bounds) {
351954
- 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;
351955
352128
  let elementId = await getActiveElementId(sessionPath);
351956
- if (elementId && box && box.width > 0 && box.height > 0) {
352129
+ let rejectedElementId = null;
352130
+ let rejectedForMismatch = false;
352131
+ if (elementId && box) {
351957
352132
  const rect = await getElementRect(sessionPath, elementId);
351958
352133
  if (!rect || !rectMatchesIntendedBounds(rect, box)) {
352134
+ rejectedElementId = elementId;
352135
+ rejectedForMismatch = !!rect;
351959
352136
  elementId = null;
351960
352137
  }
351961
352138
  }
351962
352139
  if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
351963
352140
  await new Promise((resolve2) => setTimeout(resolve2, MOBILE_FOCUS_SETTLE_MS));
351964
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
+ }
351965
352151
  }
351966
352152
  return elementId;
351967
352153
  }
@@ -354540,6 +354726,13 @@ function buildLocatorStrategies(target, platform3) {
354540
354726
  if (bundle) {
354541
354727
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
354542
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
+ }
354543
354736
  if (bundle.xpath) strategies.push({ using: "xpath", value: bundle.xpath });
354544
354737
  }
354545
354738
  if (!(platform3 && hasPlatformLocator(target, platform3))) {
@@ -354557,14 +354750,14 @@ function dedupeStrategies(strategies) {
354557
354750
  (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
354558
354751
  );
354559
354752
  }
354560
- function xpathLiteral2(value) {
354753
+ function xpathLiteral3(value) {
354561
354754
  if (!value.includes("'")) return `'${value}'`;
354562
354755
  if (!value.includes('"')) return `"${value}"`;
354563
354756
  const parts = value.split("'").map((p) => `'${p}'`);
354564
354757
  return `concat(${parts.join(`, "'", `)})`;
354565
354758
  }
354566
- function identityTextXpath(value) {
354567
- const lit = xpathLiteral2(value);
354759
+ function identityTextXpath2(value) {
354760
+ const lit = xpathLiteral3(value);
354568
354761
  return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
354569
354762
  }
354570
354763
  function isIdentityXpath(value) {
@@ -354584,7 +354777,7 @@ function buildIdentityLocatorStrategies(target, platform3) {
354584
354777
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
354585
354778
  for (const value of [bundle.text, bundle.contentDesc]) {
354586
354779
  const trimmed = value?.trim();
354587
- if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
354780
+ if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath2(trimmed) });
354588
354781
  }
354589
354782
  if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
354590
354783
  }
@@ -355064,6 +355257,19 @@ async function readElementTextCandidates(sessionId, elementId, actualText) {
355064
355257
  }
355065
355258
  return values;
355066
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
+ }
355067
355273
  async function executeMobileTest(options) {
355068
355274
  const logLines = [];
355069
355275
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
@@ -355083,6 +355289,7 @@ async function executeMobileTest(options) {
355083
355289
  let captureStopped = false;
355084
355290
  let recordingStarted = false;
355085
355291
  let recordingStopped = false;
355292
+ let acquiredSlotDeviceId = null;
355086
355293
  let video;
355087
355294
  let failingSource;
355088
355295
  const stopCapture = async () => {
@@ -355185,7 +355392,8 @@ async function executeMobileTest(options) {
355185
355392
  log2(`Warning: Could not retrieve installed apps list. Proceeding with test execution.`);
355186
355393
  }
355187
355394
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
355188
- const leasedPortOffset = Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id));
355395
+ const leasedPortOffset = acquireControlPortSlot(selectedDevice.id);
355396
+ acquiredSlotDeviceId = selectedDevice.id;
355189
355397
  try {
355190
355398
  sessionId = await createSessionWithRetry({
355191
355399
  appId: options.target.appId,
@@ -355462,6 +355670,7 @@ async function executeMobileTest(options) {
355462
355670
  await deleteSession(sessionId);
355463
355671
  log2("Session destroyed");
355464
355672
  }
355673
+ if (acquiredSlotDeviceId) releaseControlPortSlot(acquiredSlotDeviceId);
355465
355674
  }
355466
355675
  }
355467
355676
  async function createMobileDiscoverySession(target, options) {
@@ -355507,21 +355716,28 @@ async function createMobileDiscoverySession(target, options) {
355507
355716
  isPhysical: selectedDevice.isPhysical === true,
355508
355717
  platformVersion: selectedDevice.platformVersion
355509
355718
  });
355510
- const sessionId = await createSessionWithRetry({
355511
- appId: target.appId,
355512
- platform: target.platform,
355513
- deviceId: selectedDevice.id,
355514
- platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
355515
- isPhysical: selectedDevice.isPhysical,
355516
- // Distinct control resources per concurrent device, including simulators.
355517
- portOffset: Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id))
355518
- });
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
+ }
355519
355734
  if (target.launchMode === "DEEP_LINK" && target.deeplink) {
355520
355735
  try {
355521
355736
  await openDeepLink(sessionId, target.platform, target.deeplink, target.appId);
355522
355737
  } catch (err) {
355523
355738
  await deleteSession(sessionId).catch(() => {
355524
355739
  });
355740
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355525
355741
  throw new Error(
355526
355742
  `Deep-link launch failed for mobile discovery: ${err instanceof Error ? err.message : String(err)}`
355527
355743
  );
@@ -355543,6 +355759,7 @@ async function createMobileDiscoverySession(target, options) {
355543
355759
  } catch {
355544
355760
  }
355545
355761
  await deleteSession(sessionId);
355762
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355546
355763
  }
355547
355764
  };
355548
355765
  }
@@ -355560,14 +355777,14 @@ async function readElementAttribute(sessionId, elementId, name) {
355560
355777
  }
355561
355778
  function harvestFallbackXpath(platform3, attrs) {
355562
355779
  if (platform3 === "IOS") {
355563
- if (attrs.name) return `//*[@name=${xpathLiteral2(attrs.name)}]`;
355780
+ if (attrs.name) return `//*[@name=${xpathLiteral3(attrs.name)}]`;
355564
355781
  const text = attrs.label ?? attrs.value;
355565
- if (text) return identityTextXpath(text);
355782
+ if (text) return identityTextXpath2(text);
355566
355783
  return "//*";
355567
355784
  }
355568
- if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral2(attrs.resourceId)}]`;
355569
- if (attrs.text) return identityTextXpath(attrs.text);
355570
- 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)}]`;
355571
355788
  return "//*";
355572
355789
  }
355573
355790
  async function readNativeElementBundle(sessionId, elementId, platform3) {
@@ -355920,6 +356137,7 @@ var sessionStartInFlight = null;
355920
356137
  var sessionReaperTimer = null;
355921
356138
  var mirrorClientsEmptyAt = null;
355922
356139
  var mirrorSessionOwner = null;
356140
+ var actionInFlight = false;
355923
356141
  var recordingWorkloadActive = false;
355924
356142
  function setRecordingWorkloadActive(active) {
355925
356143
  if (recordingWorkloadActive === active) return;
@@ -356149,7 +356367,11 @@ async function handleHealth(_req, res) {
356149
356367
  // 409 — it must never be inferred from server-side global run counts, which
356150
356368
  // include unrelated projects and runners.
356151
356369
  executorBusy: executorBusyCount > 0,
356152
- 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
356153
356375
  });
356154
356376
  }
356155
356377
  function realDeviceCapabilities2(platform3) {
@@ -356227,7 +356449,11 @@ async function handleDevices(_req, res) {
356227
356449
  sendJSON(res, 200, {
356228
356450
  devices,
356229
356451
  executorBusy: executorBusyCount > 0,
356230
- 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
356231
356457
  });
356232
356458
  }
356233
356459
  async function handleSessionStart(req, res) {
@@ -356316,6 +356542,8 @@ async function startSession(validated, res) {
356316
356542
  state.platform = targetPlatform === "IOS" ? "IOS" : "ANDROID";
356317
356543
  mirrorSessionOwner = "recording";
356318
356544
  setRecordingWorkloadActive(true);
356545
+ mirrorClientsEmptyAt = Date.now();
356546
+ ensureSessionReaper();
356319
356547
  if (launchMode === "DEEP_LINK" && deeplink) {
356320
356548
  try {
356321
356549
  const args = targetPlatform === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
@@ -356669,6 +356897,14 @@ async function handleAction(req, res) {
356669
356897
  sendJSON(res, 400, { error: "No active Appium session" });
356670
356898
  return;
356671
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
+ }
356672
356908
  validateSessionId(state.appiumSessionId);
356673
356909
  const body = await parseBody(req);
356674
356910
  const type = typeof body.type === "string" ? body.type : null;
@@ -356680,6 +356916,7 @@ async function handleAction(req, res) {
356680
356916
  sendJSON(res, 400, { error: "Unknown or unsupported action type" });
356681
356917
  return;
356682
356918
  }
356919
+ actionInFlight = true;
356683
356920
  try {
356684
356921
  const sessionPath = `/session/${state.appiumSessionId}`;
356685
356922
  switch (type) {
@@ -356752,15 +356989,23 @@ async function handleAction(req, res) {
356752
356989
  sendJSON(res, 400, { error: `Unknown action type: ${type}` });
356753
356990
  return;
356754
356991
  }
356755
- 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
+ }
356756
356998
  sendJSON(res, 200, {
356757
356999
  ok: true,
356758
357000
  screenshot: screenshot ?? null,
356759
357001
  source: source ?? null,
357002
+ ...source === null || source === void 0 ? { sourceUnavailable: true } : {},
356760
357003
  timestamp: Date.now()
356761
357004
  });
356762
357005
  } catch (err) {
356763
357006
  sendJSON(res, 500, { error: `Action failed: ${err instanceof Error ? err.message : String(err)}` });
357007
+ } finally {
357008
+ actionInFlight = false;
356764
357009
  }
356765
357010
  }
356766
357011
  async function handleScreenshot(_req, res) {
@@ -356998,7 +357243,11 @@ function getBridgeState() {
356998
357243
  tunnelUrl: state.tunnelUrl,
356999
357244
  tunnelToken: state.tunnelToken,
357000
357245
  hasActiveSession: !!state.appiumSessionId,
357001
- 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,
357002
357251
  platform: state.platform
357003
357252
  };
357004
357253
  }