@validate.qa/runner 1.0.66 → 1.0.68

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 +499 -122
  2. package/dist/cli.mjs +499 -122
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -978,7 +978,7 @@ var require_mobile_source_parser = __commonJS({
978
978
  const value = nonEmpty3(attrs.value);
979
979
  const label = name ?? labelAttr ?? value;
980
980
  const accessibilityId = name;
981
- const text = value && value !== label ? value : labelAttr && labelAttr !== label ? labelAttr : void 0;
981
+ const text = labelAttr ?? (isStateValueRole(role) ? void 0 : value);
982
982
  const bounds = parseIOSBounds(attrs);
983
983
  const enabled = attrBoolDefaultTrue(attrs.enabled);
984
984
  const visible = attrBoolDefaultTrue(attrs.visible);
@@ -1125,6 +1125,42 @@ var require_mobile_source_parser = __commonJS({
1125
1125
  }
1126
1126
  }
1127
1127
  }
1128
+ function annotateIdentityUniqueness(elements) {
1129
+ const a11yCounts = /* @__PURE__ */ new Map();
1130
+ const ridCounts = /* @__PURE__ */ new Map();
1131
+ const textCounts = /* @__PURE__ */ new Map();
1132
+ const bump = (map2, value) => {
1133
+ const key = value?.trim();
1134
+ if (!key)
1135
+ return;
1136
+ map2.set(key, (map2.get(key) ?? 0) + 1);
1137
+ };
1138
+ for (const element of elements) {
1139
+ bump(a11yCounts, element.accessibilityId);
1140
+ bump(ridCounts, element.resourceId);
1141
+ const identityValues = new Set([element.text, element.accessibilityId].map((value) => value?.trim()).filter((value) => Boolean(value)));
1142
+ for (const value of identityValues)
1143
+ bump(textCounts, value);
1144
+ }
1145
+ const isUnique = (map2, value) => {
1146
+ const key = value?.trim();
1147
+ if (!key)
1148
+ return void 0;
1149
+ return (map2.get(key) ?? 0) <= 1;
1150
+ };
1151
+ for (const element of elements) {
1152
+ const accessibilityId = isUnique(a11yCounts, element.accessibilityId);
1153
+ const resourceId = isUnique(ridCounts, element.resourceId);
1154
+ const text = isUnique(textCounts, element.text);
1155
+ if (accessibilityId === void 0 && resourceId === void 0 && text === void 0)
1156
+ continue;
1157
+ element.identityUnique = {
1158
+ ...accessibilityId !== void 0 ? { accessibilityId } : {},
1159
+ ...resourceId !== void 0 ? { resourceId } : {},
1160
+ ...text !== void 0 ? { text } : {}
1161
+ };
1162
+ }
1163
+ }
1128
1164
  function flatten(root, platform3) {
1129
1165
  const out = [];
1130
1166
  const stack = [];
@@ -1170,6 +1206,7 @@ var require_mobile_source_parser = __commonJS({
1170
1206
  }
1171
1207
  }
1172
1208
  markRepeatedBottomBarNavigation(out);
1209
+ annotateIdentityUniqueness(out);
1173
1210
  return out;
1174
1211
  }
1175
1212
  function fnv1a(input) {
@@ -1251,6 +1288,13 @@ var require_mobile_source_parser = __commonJS({
1251
1288
  function isEditableElement(el) {
1252
1289
  return EDITABLE_CLASS_RE.test(el.role.trim());
1253
1290
  }
1291
+ var STATE_VALUE_CLASS_RE = /(XCUIElementTypeSwitch|XCUIElementTypeToggle|XCUIElementTypeSlider|XCUIElementTypeStepper|XCUIElementTypePageIndicator|XCUIElementTypeProgressIndicator|XCUIElementTypeSegmentedControl|XCUIElementTypePicker|XCUIElementTypePickerWheel|ToggleButton|SeekBar|ProgressBar|RatingBar|NumberPicker)$/;
1292
+ function isStateValueRole(role) {
1293
+ return STATE_VALUE_CLASS_RE.test(role.trim());
1294
+ }
1295
+ function isStateValueElement(el) {
1296
+ return isStateValueRole(el.role);
1297
+ }
1254
1298
  function xpathAttrLiteral(value) {
1255
1299
  if (!value.includes("'"))
1256
1300
  return `'${value}'`;
@@ -1261,17 +1305,18 @@ var require_mobile_source_parser = __commonJS({
1261
1305
  function semanticXPath(el, platform3) {
1262
1306
  const cls = nonBlank(el.role) ?? "*";
1263
1307
  const a11y = nonBlank(el.accessibilityId);
1264
- if (a11y) {
1265
- return platform3 === "ANDROID" ? `//${cls}[@content-desc=${xpathAttrLiteral(a11y)}]` : `//${cls}[@name=${xpathAttrLiteral(a11y)}]`;
1266
- }
1267
- const rid = nonBlank(el.resourceId);
1268
- if (platform3 === "ANDROID" && rid)
1269
- return `//${cls}[@resource-id=${xpathAttrLiteral(rid)}]`;
1270
- const text = nonBlank(el.text);
1271
- if (text && !isEditableElement(el)) {
1272
- return platform3 === "ANDROID" ? `//${cls}[@text=${xpathAttrLiteral(text)}]` : `//${cls}[@label=${xpathAttrLiteral(text)}]`;
1273
- }
1274
- return void 0;
1308
+ const rid = platform3 === "ANDROID" ? nonBlank(el.resourceId) : void 0;
1309
+ const text = isEditableElement(el) || isStateValueElement(el) ? void 0 : nonBlank(el.text);
1310
+ const a11yXpath = a11y ? platform3 === "ANDROID" ? `//${cls}[@content-desc=${xpathAttrLiteral(a11y)}]` : `//${cls}[@name=${xpathAttrLiteral(a11y)}]` : void 0;
1311
+ const ridXpath = rid ? `//${cls}[@resource-id=${xpathAttrLiteral(rid)}]` : void 0;
1312
+ const textXpath = text ? platform3 === "ANDROID" ? `//${cls}[@text=${xpathAttrLiteral(text)}]` : `//${cls}[@label=${xpathAttrLiteral(text)}]` : void 0;
1313
+ const candidates = [
1314
+ { xpath: a11yXpath, unique: el.identityUnique?.accessibilityId },
1315
+ { xpath: ridXpath, unique: el.identityUnique?.resourceId },
1316
+ { xpath: textXpath, unique: el.identityUnique?.text }
1317
+ ];
1318
+ const uniqueFirst = candidates.find((c) => c.xpath && c.unique !== false);
1319
+ return (uniqueFirst ?? candidates.find((c) => c.xpath))?.xpath;
1275
1320
  }
1276
1321
  function nonBlank(value) {
1277
1322
  if (typeof value !== "string")
@@ -1280,30 +1325,40 @@ var require_mobile_source_parser = __commonJS({
1280
1325
  return trimmed.length > 0 ? trimmed : void 0;
1281
1326
  }
1282
1327
  function buildLocatorBundle(el, platform3) {
1328
+ const durableText = el.text && !isEditableElement(el) && !isStateValueElement(el) ? el.text : void 0;
1283
1329
  const bundle = {
1284
1330
  // Attribute-anchored xpath when the element has identity; the absolute
1285
1331
  // indexed path only for identity-less elements (nothing else can find them).
1286
1332
  xpath: semanticXPath(el, platform3) ?? el.xpath,
1287
1333
  className: el.role
1288
1334
  };
1289
- if (el.accessibilityId)
1335
+ const a11yUnique = el.identityUnique?.accessibilityId !== false;
1336
+ const ridUnique = el.identityUnique?.resourceId !== false;
1337
+ const textUnique = el.identityUnique?.text !== false;
1338
+ const hasUniqueAlternativeToA11y = Boolean(el.resourceId && platform3 === "ANDROID" && ridUnique || durableText && textUnique);
1339
+ const hasUniqueAlternativeToRid = Boolean(el.accessibilityId && a11yUnique || durableText && textUnique);
1340
+ const includeA11y = Boolean(el.accessibilityId && (a11yUnique || !hasUniqueAlternativeToA11y));
1341
+ const includeRid = Boolean(platform3 === "ANDROID" && el.resourceId && (ridUnique || !hasUniqueAlternativeToRid));
1342
+ if (includeA11y && el.accessibilityId)
1290
1343
  bundle.accessibilityId = el.accessibilityId;
1291
- if (platform3 === "ANDROID" && el.resourceId)
1344
+ if (includeRid && el.resourceId)
1292
1345
  bundle.resourceId = el.resourceId;
1293
- if (el.text && !isEditableElement(el))
1294
- bundle.text = el.text;
1295
- if (platform3 === "ANDROID" && el.accessibilityId)
1346
+ if (durableText)
1347
+ bundle.text = durableText;
1348
+ if (includeA11y && platform3 === "ANDROID" && el.accessibilityId)
1296
1349
  bundle.contentDesc = el.accessibilityId;
1297
1350
  return bundle;
1298
1351
  }
1299
1352
  function preferredLocator(el) {
1300
- if (el.accessibilityId)
1301
- return el.accessibilityId;
1302
- if (el.resourceId)
1303
- return el.resourceId;
1304
- if (el.text && !isEditableElement(el))
1305
- return el.text;
1306
- return el.xpath;
1353
+ const durableText = el.text && !isEditableElement(el) && !isStateValueElement(el) ? el.text : void 0;
1354
+ const candidates = [
1355
+ { value: el.accessibilityId, unique: el.identityUnique?.accessibilityId },
1356
+ { value: el.resourceId, unique: el.identityUnique?.resourceId },
1357
+ { value: durableText, unique: el.identityUnique?.text }
1358
+ ];
1359
+ const uniqueFirst = candidates.find((c) => c.value && c.unique !== false);
1360
+ const chosen = (uniqueFirst ?? candidates.find((c) => c.value))?.value;
1361
+ return chosen ?? el.xpath;
1307
1362
  }
1308
1363
  function buildScreenSignal(elements, root, platform3) {
1309
1364
  const signal = {
@@ -1966,6 +2021,11 @@ function isEditableMobileClassName(className) {
1966
2021
  return false;
1967
2022
  return /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/.test(className.trim());
1968
2023
  }
2024
+ function isStateValueMobileClassName(className) {
2025
+ if (!className)
2026
+ return false;
2027
+ return /(XCUIElementTypeSwitch|XCUIElementTypeToggle|XCUIElementTypeSlider|XCUIElementTypeStepper|XCUIElementTypePageIndicator|XCUIElementTypeProgressIndicator|XCUIElementTypeSegmentedControl|XCUIElementTypePicker|XCUIElementTypePickerWheel|ToggleButton|SeekBar|ProgressBar|RatingBar|NumberPicker)$/.test(className.trim());
2028
+ }
1969
2029
  function mobileCredentialPlaceholder(key) {
1970
2030
  return `{{${key}}}`;
1971
2031
  }
@@ -2095,9 +2155,10 @@ function buildStepTarget(metadata, selector) {
2095
2155
  locatorBundle.resourceId,
2096
2156
  // For editable fields the text attribute IS the currently-typed value — a
2097
2157
  // self-invalidating locator that only matches while the recorded text is
2098
- // still in the field (and it can leak typed input into locators). Static
2099
- // elements keep their text fallback.
2100
- isEditableMobileClassName(locatorBundle.className) ? void 0 : locatorBundle.text,
2158
+ // still in the field (and it can leak typed input into locators). State
2159
+ // controls' text is their current state ("1"/"ON") and collides with every
2160
+ // same-state sibling. Static elements keep their text fallback.
2161
+ isEditableMobileClassName(locatorBundle.className) || isStateValueMobileClassName(locatorBundle.className) ? void 0 : locatorBundle.text,
2101
2162
  locatorBundle.contentDesc,
2102
2163
  locatorBundle.xpath,
2103
2164
  selector
@@ -2236,12 +2297,26 @@ function selectorForValue(value) {
2236
2297
  function isIdentityMobileXpath(value) {
2237
2298
  return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
2238
2299
  }
2300
+ function xpathLiteral(value) {
2301
+ if (!value.includes("'"))
2302
+ return `'${value}'`;
2303
+ if (!value.includes('"'))
2304
+ return `"${value}"`;
2305
+ const parts = value.split("'").map((p) => `'${p}'`);
2306
+ return `concat(${parts.join(`, "'", `)})`;
2307
+ }
2308
+ function identityTextXpath(value) {
2309
+ const lit = xpathLiteral(value);
2310
+ return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
2311
+ }
2239
2312
  function mobileLocatorBundleHasIdentity(bundle) {
2240
2313
  if (!bundle)
2241
2314
  return false;
2242
2315
  if (nonEmpty(bundle.accessibilityId) || nonEmpty(bundle.resourceId))
2243
2316
  return true;
2244
- if (nonEmpty(bundle.text) || nonEmpty(bundle.contentDesc))
2317
+ if (nonEmpty(bundle.text) && !isStateValueMobileClassName(bundle.className))
2318
+ return true;
2319
+ if (nonEmpty(bundle.contentDesc))
2245
2320
  return true;
2246
2321
  const xpath = nonEmpty(bundle.xpath);
2247
2322
  return !!xpath && isIdentityMobileXpath(xpath);
@@ -2264,14 +2339,25 @@ function selectorCandidatesForStep(step) {
2264
2339
  candidates.push(`id=${bundle.resourceId}`);
2265
2340
  if (nonEmpty(bundle.xpath))
2266
2341
  candidates.push(bundle.xpath);
2342
+ const isGateStep = step.action === "waitForElement" || step.action === "assertVisible" || step.action === "assertText";
2343
+ const textIdentity = (isGateStep || !isEditableMobileClassName(bundle.className)) && !isStateValueMobileClassName(bundle.className) ? bundle.text : void 0;
2344
+ for (const value of [textIdentity, bundle.contentDesc]) {
2345
+ const trimmed = nonEmpty(value);
2346
+ if (trimmed)
2347
+ candidates.push(identityTextXpath(trimmed));
2348
+ }
2267
2349
  }
2268
2350
  const hasExplicitResourceId = !!nonEmpty(bundle?.resourceId);
2351
+ const bundleContributedIdentity = candidates.some((value) => !(value.startsWith("/") || value.startsWith("(//")) || isIdentityMobileXpath(value));
2269
2352
  const pushValueCandidates = (value) => {
2270
2353
  candidates.push(selectorForValue(value));
2271
2354
  const looksLikeIdToken = /^[A-Za-z_][\w.:-]*$/.test(value);
2272
2355
  if (!hasExplicitResourceId && looksLikeIdToken && !/^(~|id=|css=|\/|\()/.test(value) && !value.includes(":id/")) {
2273
2356
  candidates.push(`id=${value}`);
2274
2357
  }
2358
+ if (!bundleContributedIdentity && !/^(~|id=|css=|\/|\()/.test(value)) {
2359
+ candidates.push(identityTextXpath(value));
2360
+ }
2275
2361
  };
2276
2362
  if (nonEmpty(target.primary))
2277
2363
  pushValueCandidates(target.primary);
@@ -2710,7 +2796,7 @@ function uniq(values) {
2710
2796
  }
2711
2797
  return result;
2712
2798
  }
2713
- function xpathLiteral(value) {
2799
+ function xpathLiteral2(value) {
2714
2800
  if (!value.includes("'"))
2715
2801
  return `'${value}'`;
2716
2802
  if (!value.includes('"'))
@@ -2718,7 +2804,7 @@ function xpathLiteral(value) {
2718
2804
  return `concat(${value.split("'").map((part) => `'${part}'`).join(`, "'", `)})`;
2719
2805
  }
2720
2806
  function mobileTextXPath(value, platform3) {
2721
- const literal = xpathLiteral(value);
2807
+ const literal = xpathLiteral2(value);
2722
2808
  if (platform3 === "IOS") {
2723
2809
  return `//*[@name=${literal} or @label=${literal} or @value=${literal}]`;
2724
2810
  }
@@ -2966,7 +3052,8 @@ function isWebViewStep(step) {
2966
3052
  function hasCrossPlatformIdentity(bundle) {
2967
3053
  if (!bundle)
2968
3054
  return false;
2969
- return Boolean(bundle.text?.trim() || bundle.contentDesc?.trim() || bundle.accessibilityId?.trim());
3055
+ const durableText = isStateValueMobileClassName(bundle.className) ? void 0 : bundle.text;
3056
+ return Boolean(durableText?.trim() || bundle.contentDesc?.trim() || bundle.accessibilityId?.trim());
2970
3057
  }
2971
3058
  function stepIsDivergentForPlatform(step, platform3) {
2972
3059
  return Array.isArray(step.crossPlatformDivergent) && step.crossPlatformDivergent.includes(platform3);
@@ -3048,7 +3135,8 @@ function summarizeMobileReadiness(steps, authoringPlatform) {
3048
3135
  function hasStableNativeIdentity(bundle) {
3049
3136
  if (!bundle)
3050
3137
  return false;
3051
- return Boolean(bundle.accessibilityId?.trim() || bundle.resourceId?.trim() || bundle.text?.trim() || bundle.contentDesc?.trim() || bundle.xpath?.trim() && XPATH_ATTRIBUTE_EQUALITY_RE.test(bundle.xpath) && !/(?:contains|starts-with)\s*\(/i.test(bundle.xpath));
3138
+ const durableText = isStateValueMobileClassName(bundle.className) ? void 0 : bundle.text;
3139
+ return Boolean(bundle.accessibilityId?.trim() || bundle.resourceId?.trim() || durableText?.trim() || bundle.contentDesc?.trim() || bundle.xpath?.trim() && XPATH_ATTRIBUTE_EQUALITY_RE.test(bundle.xpath) && !/(?:contains|starts-with)\s*\(/i.test(bundle.xpath));
3052
3140
  }
3053
3141
  function isPositionalXpath(xpath) {
3054
3142
  const value = xpath?.trim();
@@ -3128,6 +3216,7 @@ var MOBILE_PLATFORMS, LOCATOR_REQUIRING_ACTIONS, XPATH_ATTRIBUTE_EQUALITY_RE;
3128
3216
  var init_mobile_cross_platform = __esm({
3129
3217
  "../shared/dist/mobile-cross-platform.js"() {
3130
3218
  "use strict";
3219
+ init_mobile_test_generation();
3131
3220
  MOBILE_PLATFORMS = ["IOS", "ANDROID"];
3132
3221
  LOCATOR_REQUIRING_ACTIONS = /* @__PURE__ */ new Set([
3133
3222
  "tap",
@@ -3866,6 +3955,43 @@ function accumulateStatements(lines) {
3866
3955
  }
3867
3956
  return out;
3868
3957
  }
3958
+ function classifyMobileAssertValue(value, context = {}) {
3959
+ const text = value.trim();
3960
+ if (!text)
3961
+ return null;
3962
+ if (/^\{\{[A-Z0-9_]+\}\}$/.test(text))
3963
+ return null;
3964
+ const t1 = matchFirst(TIER1_PATTERNS, text);
3965
+ if (t1)
3966
+ return { tier: 1, reason: t1[1] };
3967
+ const authIdentityValues = [];
3968
+ if (context.authenticatedUserEmail)
3969
+ authIdentityValues.push(context.authenticatedUserEmail);
3970
+ if (context.authenticatedUserName && context.authenticatedUserName.length >= 3) {
3971
+ authIdentityValues.push(context.authenticatedUserName);
3972
+ }
3973
+ for (const auth of authIdentityValues) {
3974
+ if (auth.length >= 3 && text.toLowerCase().includes(auth.toLowerCase())) {
3975
+ return { tier: 2, reason: "asserts the authenticated user identity \u2014 fails under any other test account" };
3976
+ }
3977
+ }
3978
+ const knownValues = [
3979
+ ...context.knownUserInputs ?? [],
3980
+ ...context.scenarioCreatedValues ?? []
3981
+ ].filter((v) => {
3982
+ for (const auth of authIdentityValues) {
3983
+ if (auth.length >= 3 && v.toLowerCase().includes(auth.toLowerCase()))
3984
+ return false;
3985
+ }
3986
+ return true;
3987
+ });
3988
+ if (containsKnownValue(text, knownValues))
3989
+ return null;
3990
+ const t2 = matchFirst(TIER2_PATTERNS, text);
3991
+ if (t2)
3992
+ return { tier: 2, reason: t2[1] };
3993
+ return null;
3994
+ }
3869
3995
  function parenDepthDelta(text) {
3870
3996
  let depth = 0;
3871
3997
  let quote = null;
@@ -6039,6 +6165,7 @@ __export(dist_exports, {
6039
6165
  classifyAssertionBlock: () => classifyAssertionBlock,
6040
6166
  classifyAssertionLine: () => classifyAssertionLine,
6041
6167
  classifyErrorMessage: () => classifyErrorMessage,
6168
+ classifyMobileAssertValue: () => classifyMobileAssertValue,
6042
6169
  classifyTestHealth: () => classifyTestHealth,
6043
6170
  coldSessionConfigMessage: () => coldSessionConfigMessage,
6044
6171
  computeMobileSwipePointers: () => computeMobileSwipePointers,
@@ -6092,6 +6219,7 @@ __export(dist_exports, {
6092
6219
  isEditableMobileClassName: () => isEditableMobileClassName,
6093
6220
  isEmailFromIdentity: () => isEmailFromIdentity,
6094
6221
  isEnvironmentalError: () => isEnvironmentalError,
6222
+ isIdentityMobileXpath: () => isIdentityMobileXpath,
6095
6223
  isMobilePlatform: () => isMobilePlatform,
6096
6224
  isPricingPlan: () => isPricingPlan,
6097
6225
  isRunnableStep: () => isRunnableStep,
@@ -6099,6 +6227,7 @@ __export(dist_exports, {
6099
6227
  isSameRegistrableDomainHost: () => isSameRegistrableDomainHost,
6100
6228
  isSensitiveQueryKey: () => isSensitiveQueryKey,
6101
6229
  isSharedAuthInjectEnabled: () => isSharedAuthInjectEnabled,
6230
+ isStateValueMobileClassName: () => isStateValueMobileClassName,
6102
6231
  isSttModelForProvider: () => isSttModelForProvider,
6103
6232
  isTestReadyForPlatform: () => isTestReadyForPlatform,
6104
6233
  isTrialExpired: () => isTrialExpired,
@@ -7006,6 +7135,7 @@ var require_mobile_observation = __commonJS({
7006
7135
  exports2.buildCollectedElementsForScreen = buildCollectedElementsForScreen;
7007
7136
  exports2.extractNativeMobileLandmarks = extractNativeMobileLandmarks;
7008
7137
  exports2.safeMobileElementLabel = safeMobileElementLabel;
7138
+ var mobile_source_parser_js_1 = require_mobile_source_parser();
7009
7139
  var DEFAULT_MAX_ELEMENTS = 60;
7010
7140
  var MAX_LABEL_LENGTH = 80;
7011
7141
  var EVIDENCE_LABEL_LIMIT = 40;
@@ -7096,7 +7226,8 @@ var require_mobile_observation = __commonJS({
7096
7226
  const locators = [];
7097
7227
  if (stableId)
7098
7228
  locators.push({ strategy: "getByTestId", value: stableId, confidence: 0.95 });
7099
- const xpath = element.xpath?.trim();
7229
+ const platform3 = element.role.startsWith("XCUIElementType") ? "IOS" : "ANDROID";
7230
+ const xpath = (0, mobile_source_parser_js_1.buildLocatorBundle)(element, platform3).xpath?.trim();
7100
7231
  if (xpath)
7101
7232
  locators.push({ strategy: "css", value: xpath, confidence: 0.4 });
7102
7233
  const collected = {
@@ -78345,6 +78476,10 @@ var require_discover_ai_planner = __commonJS({
78345
78476
  if (context.appBrief) {
78346
78477
  sections.push(`## Application Brief
78347
78478
  ${context.appBrief.slice(0, 500)}`);
78479
+ }
78480
+ if (context.knowledgeContext && context.knowledgeContext.trim().length > 0) {
78481
+ sections.push(`## Known Facts
78482
+ ${context.knowledgeContext.slice(0, 4e3)}`);
78348
78483
  }
78349
78484
  sections.push(`## Base URL
78350
78485
  ${compressed.baseUrl}`);
@@ -80883,6 +81018,45 @@ ${constraints.join("\n")}`);
80883
81018
  }
80884
81019
  });
80885
81020
 
81021
+ // ../runner-core/dist/services/discovery-lint-flags.js
81022
+ var require_discovery_lint_flags = __commonJS({
81023
+ "../runner-core/dist/services/discovery-lint-flags.js"(exports2) {
81024
+ "use strict";
81025
+ Object.defineProperty(exports2, "__esModule", { value: true });
81026
+ exports2.structuralLocatorLintMode = structuralLocatorLintMode;
81027
+ exports2.isPwMethodPreflightEnabled = isPwMethodPreflightEnabled;
81028
+ exports2.isVqaLoginDedupeEnabled = isVqaLoginDedupeEnabled;
81029
+ exports2.mobileSelectorQualityLintMode = mobileSelectorQualityLintMode;
81030
+ exports2.isTestgenEmailVerifyEnabled = isTestgenEmailVerifyEnabled;
81031
+ function strictOn(value) {
81032
+ return value === "true" || value === "1";
81033
+ }
81034
+ function structuralLocatorLintMode() {
81035
+ const v = (process.env.DISCOVERY_STRUCTURAL_LOCATOR_LINT || "").trim().toLowerCase();
81036
+ if (v === "warn")
81037
+ return "warn";
81038
+ if (v === "true" || v === "1" || v === "enforce")
81039
+ return "enforce";
81040
+ return "off";
81041
+ }
81042
+ function isPwMethodPreflightEnabled() {
81043
+ return strictOn(process.env.DISCOVERY_PW_METHOD_PREFLIGHT);
81044
+ }
81045
+ function isVqaLoginDedupeEnabled() {
81046
+ return strictOn(process.env.DISCOVERY_VQALOGIN_DEDUPE);
81047
+ }
81048
+ function mobileSelectorQualityLintMode() {
81049
+ const v = (process.env.MOBILE_SELECTOR_QUALITY_LINT || "").trim().toLowerCase();
81050
+ if (v === "true" || v === "1" || v === "enforce")
81051
+ return "enforce";
81052
+ return "off";
81053
+ }
81054
+ function isTestgenEmailVerifyEnabled() {
81055
+ return strictOn(process.env.DISCOVERY_TESTGEN_EMAIL_VERIFY);
81056
+ }
81057
+ }
81058
+ });
81059
+
80886
81060
  // ../runner-core/dist/services/mobile/mobile-generator.js
80887
81061
  var require_mobile_generator = __commonJS({
80888
81062
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports2) {
@@ -80902,6 +81076,7 @@ var require_mobile_generator = __commonJS({
80902
81076
  var ai_queue_js_1 = require_ai_queue();
80903
81077
  var ai_retry_js_1 = require_ai_retry();
80904
81078
  var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
81079
+ var discovery_lint_flags_js_1 = require_discovery_lint_flags();
80905
81080
  var mobile_source_parser_js_1 = require_mobile_source_parser();
80906
81081
  var mobile_source_parser_js_2 = require_mobile_source_parser();
80907
81082
  var mobile_observation_js_1 = require_mobile_observation();
@@ -81071,7 +81246,15 @@ var require_mobile_generator = __commonJS({
81071
81246
  properties: {
81072
81247
  name: { type: "string", description: 'Descriptive test name (e.g. "Login - Happy Path").' },
81073
81248
  description: { type: "string", description: "One-line description of what the test proves." },
81074
- verified: { type: "boolean", description: "True only if you asserted the outcome LIVE with an assert tool." }
81249
+ verified: { type: "boolean", description: "True only if you asserted the outcome LIVE with an assert tool." },
81250
+ suspectedBug: {
81251
+ type: "boolean",
81252
+ 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."
81253
+ },
81254
+ bugDescription: {
81255
+ type: "string",
81256
+ description: "What is broken in the APP (not the test) \u2014 concrete screen + observed vs expected."
81257
+ }
81075
81258
  },
81076
81259
  required: ["name", "description", "verified"]
81077
81260
  }
@@ -81317,6 +81500,8 @@ var require_mobile_generator = __commonJS({
81317
81500
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
81318
81501
  - Assert only DURABLE elements \u2014 screen chrome that exists on every visit (tab labels, headers, nav bars, the entity you just created). NEVER assert one-time / data-dependent UI: empty-state CTAs, first-run or onboarding prompts, or counters \u2014 they vanish as soon as the account has data, and the test then fails forever on a healthy app.
81319
81502
  - 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.
81503
+ - 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.
81504
+ - If the APP itself misbehaves (crash, error screen, a flow that cannot proceed as specified), do not force a green path: assert the failure you observed (e.g. the error text), then call finish_generate with suspectedBug:true and a concrete bugDescription of what is broken in the app.
81320
81505
  - Stay inside this app.${targetLine} Do not open or use app stores, browsers, email apps, settings, or any other app. If an action leaves the target app, the runner will relaunch the target app and reject that action.
81321
81506
  - NEVER tap the phone's home screen / launcher (app icons, "Predicted app" chips, search bars, widgets). Those are NOT part of the app under test \u2014 tapping them is junk that poisons the test. If the app ever backgrounds or you find yourself on the launcher, use mobile_relaunch to cold-start the app; do NOT tap a launcher icon or app-prediction to get back in.
81322
81507
  - Do NOT re-do a sequence you already completed. If you filled a form then navigated away, do NOT come back and fill it again \u2014 the scenario needs ONE clean pass. Filling the login form, going back, and re-filling it is exactly the kind of wandering that produces a bloated, broken test.
@@ -81422,6 +81607,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
81422
81607
  let assertCount = 0;
81423
81608
  let finish = null;
81424
81609
  let finishIntentRejections = 0;
81610
+ let finishStrengthRejections = 0;
81425
81611
  let emptyTurnsAfterFinishReject = 0;
81426
81612
  let latest = null;
81427
81613
  const getLatest = () => latest;
@@ -81914,31 +82100,49 @@ ${statePacket}`, redactionCredentials) },
81914
82100
  const claimedVerified = Boolean(toolArgs.verified);
81915
82101
  const intentReason = scenarioIntentMismatchReason(scenario, actions);
81916
82102
  const verifiedWithoutAssert = claimedVerified && assertCount === 0;
81917
- const canReject = finishIntentRejections < MAX_FINISH_INTENT_REJECTIONS && iteration < maxIterations && (intentReason !== null || verifiedWithoutAssert);
81918
- if (canReject) {
81919
- finishIntentRejections += 1;
82103
+ const intentStyleReject = finishIntentRejections < MAX_FINISH_INTENT_REJECTIONS && iteration < maxIterations && (intentReason !== null || verifiedWithoutAssert);
82104
+ let strengthRejectReason = null;
82105
+ if (!intentStyleReject && claimedVerified && assertCount > 0 && intentReason === null && finishStrengthRejections < 1 && iteration < maxIterations) {
82106
+ const verdict = evaluateMobileAssertionStrength(actions);
82107
+ if (verdict.severity === "block") {
82108
+ strengthRejectReason = verdict.reason;
82109
+ }
82110
+ }
82111
+ if (intentStyleReject || strengthRejectReason !== null) {
82112
+ if (intentStyleReject)
82113
+ finishIntentRejections += 1;
82114
+ if (strengthRejectReason !== null)
82115
+ finishStrengthRejections += 1;
81920
82116
  const reasons = [];
81921
82117
  if (intentReason)
81922
82118
  reasons.push(intentReason);
81923
82119
  if (verifiedWithoutAssert) {
81924
82120
  reasons.push("claimed verified:true but never called mobile_assert_visible / mobile_assert_text");
81925
82121
  }
82122
+ if (strengthRejectReason !== null) {
82123
+ 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.`);
82124
+ }
81926
82125
  const reasonText = reasons.join("; ");
81927
- log2(` finish_generate REJECTED (${finishIntentRejections}/${MAX_FINISH_INTENT_REJECTIONS}): ${reasonText}`);
82126
+ log2(` finish_generate REJECTED (intent ${finishIntentRejections}/${MAX_FINISH_INTENT_REJECTIONS}, strength ${finishStrengthRejections}/1): ${reasonText}`);
81928
82127
  pushResult({
81929
82128
  ok: false,
81930
82129
  error: `Cannot finish yet \u2014 the recorded steps do not complete this scenario: ${reasonText}. Continue driving the app (fill fields, commit forms, navigate, assert the REAL outcome), then call finish_generate again. Do NOT re-open menus or thrash Back.`,
81931
82130
  intentMismatch: intentReason,
81932
82131
  verifiedWithoutAssert,
81933
- rejection: finishIntentRejections,
81934
- maxRejections: MAX_FINISH_INTENT_REJECTIONS
82132
+ ...strengthRejectReason !== null ? { strengthBlock: strengthRejectReason } : {},
82133
+ rejection: finishIntentRejections + finishStrengthRejections,
82134
+ maxRejections: MAX_FINISH_INTENT_REJECTIONS + 1
81935
82135
  });
81936
82136
  continue;
81937
82137
  }
81938
82138
  finish = {
81939
82139
  name: redactCredentialValues(nonEmpty3(String(toolArgs.name ?? "")) ?? scenario.name, redactionCredentials),
81940
82140
  description: redactCredentialValues(nonEmpty3(String(toolArgs.description ?? "")) ?? scenario.goal, redactionCredentials),
81941
- verified: claimedVerified
82141
+ verified: claimedVerified,
82142
+ ...toolArgs.suspectedBug === true ? {
82143
+ suspectedBug: true,
82144
+ bugDescription: redactCredentialValues(nonEmpty3(String(toolArgs.bugDescription ?? "")) ?? "", redactionCredentials)
82145
+ } : {}
81942
82146
  };
81943
82147
  log2(` finish_generate: "${finish.name}" (claimed verified: ${finish.verified}, asserts: ${assertCount})`);
81944
82148
  pushResult({ acknowledged: true });
@@ -82002,6 +82206,7 @@ ${statePacket}`, redactionCredentials) },
82002
82206
  ok: false,
82003
82207
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
82004
82208
  });
82209
+ const recordedTypeValues = () => recordedActions.filter((action) => action.type === "TYPE" && typeof action.value === "string" && action.value.length > 0).map((action) => action.value);
82005
82210
  const actionPayload = (result, absorbed) => {
82006
82211
  const blocked = Boolean(absorbed.externalBlocked || absorbed.transientExternal);
82007
82212
  return {
@@ -82402,7 +82607,8 @@ ${statePacket}`, redactionCredentials) },
82402
82607
  latest,
82403
82608
  screenName,
82404
82609
  platform: platform3,
82405
- recordAction
82610
+ recordAction,
82611
+ typedValues: recordedTypeValues
82406
82612
  });
82407
82613
  if (!recorded.ok)
82408
82614
  return recorded;
@@ -82471,6 +82677,15 @@ ${statePacket}`, redactionCredentials) },
82471
82677
  error: `Live text assertion failed: expected ${JSON.stringify(expected)} was not present on ref ${element.ref}. Re-snapshot after the outcome appears and assert the text that is actually visible.`
82472
82678
  };
82473
82679
  }
82680
+ const volatileExpected = (0, shared_1.classifyMobileAssertValue)(expected, {
82681
+ knownUserInputs: recordedTypeValues()
82682
+ });
82683
+ if (volatileExpected) {
82684
+ return {
82685
+ ok: false,
82686
+ 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.`
82687
+ };
82688
+ }
82474
82689
  const metadata = metadataForElement(element, screenName(), platform3);
82475
82690
  const expectedNeedle = expected.trim().toLowerCase();
82476
82691
  const identityValues = [element.accessibilityId, element.resourceId].map((value) => value?.trim().toLowerCase()).filter((value) => Boolean(value));
@@ -82526,6 +82741,17 @@ ${statePacket}`, redactionCredentials) },
82526
82741
  if (!accessibilityId && !text) {
82527
82742
  return { ok: false, error: "mobile_assert_visible needs a ref (preferred), accessibilityId, or text." };
82528
82743
  }
82744
+ if (!accessibilityId && text) {
82745
+ const volatileIdentity = (0, shared_1.classifyMobileAssertValue)(text, {
82746
+ knownUserInputs: ctx.typedValues?.() ?? []
82747
+ });
82748
+ if (volatileIdentity) {
82749
+ return {
82750
+ ok: false,
82751
+ 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).`
82752
+ };
82753
+ }
82754
+ }
82529
82755
  const directTarget = accessibilityId ?? text;
82530
82756
  if (directTarget && isStructuralAssertionIdentity(directTarget)) {
82531
82757
  return {
@@ -83161,7 +83387,21 @@ ${statePacket}`, redactionCredentials) },
83161
83387
  if (claimedVerified && !verified) {
83162
83388
  log2(" downgraded verified -> false: model claimed verified but called zero assert tools.");
83163
83389
  }
83164
- const strengthVerdict = evaluateMobileAssertionStrength(actions);
83390
+ const baseStrengthVerdict = evaluateMobileAssertionStrength(actions);
83391
+ const bugDescription = recording.finish?.bugDescription?.trim() ?? "";
83392
+ const bugWitnessHonored = process.env.DISCOVERY_BUG_WITNESS_ENABLED === "true" && recording.finish?.suspectedBug === true && bugDescription.length >= 12 && recording.assertCount > 0;
83393
+ if (recording.finish?.suspectedBug === true && !bugWitnessHonored) {
83394
+ 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."));
83395
+ }
83396
+ const strengthVerdict = bugWitnessHonored ? {
83397
+ ...baseStrengthVerdict,
83398
+ ok: false,
83399
+ severity: "route-bug",
83400
+ reason: `Model declared an app bug during generation: ${bugDescription}`
83401
+ } : baseStrengthVerdict;
83402
+ if (bugWitnessHonored) {
83403
+ log2(` bug-witness honored \u2014 routing as app bug: ${bugDescription}`);
83404
+ }
83165
83405
  if (strengthVerdict.severity === "block" || strengthVerdict.severity === "route-bug") {
83166
83406
  if (verified) {
83167
83407
  log2(` downgraded verified -> false: assertion strength ${strengthVerdict.severity} \u2014 ${strengthVerdict.reason}`);
@@ -83177,7 +83417,11 @@ ${statePacket}`, redactionCredentials) },
83177
83417
  verified = false;
83178
83418
  }
83179
83419
  const platformTag2 = platform3 === "IOS" ? "@ios" : "@android";
83180
- const reviewTag = verified ? "@verified" : "@needs-review";
83420
+ const selectorLintEnforced = (0, discovery_lint_flags_js_1.mobileSelectorQualityLintMode)() === "enforce" && selectorQuality.summary.high > 0;
83421
+ if (selectorLintEnforced && verified) {
83422
+ log2(` selector-quality lint (enforce): ${selectorQuality.summary.high} high-severity locator issue(s) \u2014 tagging @needs-review.`);
83423
+ }
83424
+ const reviewTag = verified && !selectorLintEnforced ? "@verified" : "@needs-review";
83181
83425
  const tags = ["@discovered", "@mobile", platformTag2, reviewTag];
83182
83426
  if (strengthVerdict.severity === "downgrade")
83183
83427
  tags.push("@smoke");
@@ -86257,38 +86501,6 @@ var require_run_callback_proxy = __commonJS({
86257
86501
  }
86258
86502
  });
86259
86503
 
86260
- // ../runner-core/dist/services/discovery-lint-flags.js
86261
- var require_discovery_lint_flags = __commonJS({
86262
- "../runner-core/dist/services/discovery-lint-flags.js"(exports2) {
86263
- "use strict";
86264
- Object.defineProperty(exports2, "__esModule", { value: true });
86265
- exports2.structuralLocatorLintMode = structuralLocatorLintMode;
86266
- exports2.isPwMethodPreflightEnabled = isPwMethodPreflightEnabled;
86267
- exports2.isVqaLoginDedupeEnabled = isVqaLoginDedupeEnabled;
86268
- exports2.isTestgenEmailVerifyEnabled = isTestgenEmailVerifyEnabled;
86269
- function strictOn(value) {
86270
- return value === "true" || value === "1";
86271
- }
86272
- function structuralLocatorLintMode() {
86273
- const v = (process.env.DISCOVERY_STRUCTURAL_LOCATOR_LINT || "").trim().toLowerCase();
86274
- if (v === "warn")
86275
- return "warn";
86276
- if (v === "true" || v === "1" || v === "enforce")
86277
- return "enforce";
86278
- return "off";
86279
- }
86280
- function isPwMethodPreflightEnabled() {
86281
- return strictOn(process.env.DISCOVERY_PW_METHOD_PREFLIGHT);
86282
- }
86283
- function isVqaLoginDedupeEnabled() {
86284
- return strictOn(process.env.DISCOVERY_VQALOGIN_DEDUPE);
86285
- }
86286
- function isTestgenEmailVerifyEnabled() {
86287
- return strictOn(process.env.DISCOVERY_TESTGEN_EMAIL_VERIFY);
86288
- }
86289
- }
86290
- });
86291
-
86292
86504
  // ../runner-core/dist/services/auth-recipe.js
86293
86505
  var require_auth_recipe = __commonJS({
86294
86506
  "../runner-core/dist/services/auth-recipe.js"(exports2) {
@@ -92149,6 +92361,15 @@ var require_agentic_discovery_prompts = __commonJS({
92149
92361
  lines.push(input.appBrief.trim());
92150
92362
  lines.push("");
92151
92363
  }
92364
+ if (input.knownFactsBlock && input.knownFactsBlock.trim()) {
92365
+ lines.push("## Known facts about this feature (from project documentation)");
92366
+ lines.push("");
92367
+ lines.push("Advisory hints about routes/data/constraints \u2014 verify against the live");
92368
+ lines.push("app; the browser is the source of truth for locators.");
92369
+ lines.push("");
92370
+ lines.push(input.knownFactsBlock.trim());
92371
+ lines.push("");
92372
+ }
92152
92373
  if (input.credentials && input.credentials.length > 0) {
92153
92374
  lines.push("## Test credentials (throwaway accounts)");
92154
92375
  lines.push("");
@@ -328816,6 +329037,15 @@ var require_agentic_survey_prompts = __commonJS({
328816
329037
  lines.push(input.appBrief.trim());
328817
329038
  lines.push("");
328818
329039
  }
329040
+ if (input.knownFactsBlock && input.knownFactsBlock.trim().length > 0) {
329041
+ lines.push("## Known facts about this app (from project documentation)");
329042
+ lines.push("");
329043
+ lines.push("Use these as hints for what features/routes/constraints exist. They are");
329044
+ lines.push("advisory \u2014 verify against the live app; the browser is the source of truth.");
329045
+ lines.push("");
329046
+ lines.push(input.knownFactsBlock.trim());
329047
+ lines.push("");
329048
+ }
328819
329049
  if (input.credentials && input.credentials.length > 0) {
328820
329050
  lines.push("## Test credentials (throwaway accounts)");
328821
329051
  lines.push("");
@@ -329443,6 +329673,7 @@ var require_grok_survey_runner = __commonJS({
329443
329673
  const briefInput = {
329444
329674
  baseUrl: ctx.baseUrl,
329445
329675
  appBrief: ctx.appBrief,
329676
+ knownFactsBlock: ctx.knowledgeContext,
329446
329677
  credentials: credentials.length > 0 ? credentials : void 0,
329447
329678
  existingFeatureNames: ctx.existingFeatureNames ?? [],
329448
329679
  focusAreas: ctx.focusAreas,
@@ -331138,6 +331369,7 @@ var require_grok_discovery_runner = __commonJS({
331138
331369
  featureObservations: ctx.featureContext?.flowIntel,
331139
331370
  notesForTestAuthor: ctx.featureContext?.priorInsight,
331140
331371
  appBrief: ctx.appBrief,
331372
+ knownFactsBlock: ctx.knowledgeContext,
331141
331373
  credentials: credentials.length > 0 ? credentials : void 0,
331142
331374
  testVariableKeys: testVariableKeys.length > 0 ? testVariableKeys : void 0,
331143
331375
  existingTestNames: ctx.existingTestNames ?? [],
@@ -332075,6 +332307,7 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
332075
332307
  featureObservations: ctx.featureContext?.flowIntel,
332076
332308
  notesForTestAuthor: ctx.featureContext?.priorInsight,
332077
332309
  appBrief: ctx.appBrief,
332310
+ knownFactsBlock: ctx.knowledgeContext,
332078
332311
  credentials: credentials.length > 0 ? credentials : void 0,
332079
332312
  testVariableKeys: testVariableKeys.length > 0 ? testVariableKeys : void 0,
332080
332313
  existingTestNames: ctx.existingTestNames ?? [],
@@ -333166,6 +333399,7 @@ ${preview}`);
333166
333399
  const briefInput = {
333167
333400
  baseUrl: ctx.baseUrl,
333168
333401
  appBrief: ctx.appBrief,
333402
+ knownFactsBlock: ctx.knowledgeContext,
333169
333403
  credentials: credentials.length > 0 ? credentials : void 0,
333170
333404
  existingFeatureNames: ctx.existingFeatureNames ?? [],
333171
333405
  focusAreas: ctx.focusAreas,
@@ -334025,6 +334259,7 @@ var require_claude_discovery_runner = __commonJS({
334025
334259
  featureObservations: ctx.featureContext?.flowIntel,
334026
334260
  notesForTestAuthor: ctx.featureContext?.priorInsight,
334027
334261
  appBrief: ctx.appBrief,
334262
+ knownFactsBlock: ctx.knowledgeContext,
334028
334263
  credentials: credentials.length > 0 ? credentials : void 0,
334029
334264
  testVariableKeys: testVariableKeys.length > 0 ? testVariableKeys : void 0,
334030
334265
  existingTestNames: ctx.existingTestNames ?? [],
@@ -335879,7 +336114,8 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
335879
336114
  // scenarioTouchesSignupAuthSurface) still isolate non-auth scenarios. The
335880
336115
  // auth-feature scope IS applied at the DEEP call site where ctx.featureName is known.
335881
336116
  emailVerifyArmed: mode !== "survey" && plannerEmailVerifyArmed(ctx),
335882
- testBudget
336117
+ testBudget,
336118
+ knowledgeContext: ctx.knowledgeContext
335883
336119
  }, {
335884
336120
  modelOverride: ctx.planModel ?? ctx.generateModel,
335885
336121
  gateModelOverride: ctx.planGateModel,
@@ -335991,6 +336227,7 @@ Health: ${surveyHealth.totalIssues} issue(s) - ${surveyHealth.consoleErrors.leng
335991
336227
  interactionLog: exploreResult.interactionLog,
335992
336228
  modelOverride: ctx.generateModel,
335993
336229
  appBrief: ctx.appBrief,
336230
+ knowledgeContext: ctx.knowledgeContext,
335994
336231
  onAICall,
335995
336232
  onProgress,
335996
336233
  mcpClient: mcpClient ?? void 0,
@@ -336470,7 +336707,8 @@ Plan (reused): ${resumeSavedScenarios.length} scenarios from the paused run.`);
336470
336707
  // configured inbox), so the planner never promises what generation can't do.
336471
336708
  // Scoped to AUTH features: a non-auth feature has no signup surface to test.
336472
336709
  emailVerifyArmed: plannerEmailVerifyArmed(ctx) && featureLooksLikeAuth(ctx.featureName, augmentedFeatureFocusAreas),
336473
- testBudget: featureTestBudget
336710
+ testBudget: featureTestBudget,
336711
+ knowledgeContext: ctx.knowledgeContext
336474
336712
  }, {
336475
336713
  modelOverride: ctx.planModel ?? ctx.generateModel,
336476
336714
  gateModelOverride: ctx.planGateModel,
@@ -336516,6 +336754,7 @@ Plan: ${planResult.scenarios.length} scenarios (budget: ${featureTestBudget})`);
336516
336754
  interactionLog: exploreResult.interactionLog,
336517
336755
  modelOverride: ctx.generateModel,
336518
336756
  appBrief: ctx.appBrief,
336757
+ knowledgeContext: ctx.knowledgeContext,
336519
336758
  onAICall,
336520
336759
  mcpClient: mcpClient ?? void 0,
336521
336760
  // Pooled browser for parallel scenario generation (Fargate opt-in via
@@ -347729,6 +347968,12 @@ Write valid JSON only (no markdown fences inside files). Be exhaustive.`;
347729
347968
  "Use {{TEST_USER_EMAIL}}/{{TEST_USER_PASSWORD}} for credentials. Include",
347730
347969
  "assertVisible or assertText unless tagging @needs-review. When an incremental",
347731
347970
  "allow-list is present, extract ONLY from those files.",
347971
+ "",
347972
+ "Every tap/type/clear/waitForElement/assertVisible/assertText target MUST",
347973
+ "carry an IDENTITY locator: ~accessibility-id, id=resource-id, visible text,",
347974
+ "or an attribute-EQUALITY xpath (//android.widget.Button[@text='Submit']).",
347975
+ "Positional/index-only xpaths (/hierarchy/\u2026/View[3]) are rejected \u2014 imported",
347976
+ "steps have no recorded coordinates, so such a step can never replay.",
347732
347977
  "Write strictly valid JSON."
347733
347978
  ].join("\n");
347734
347979
  }
@@ -347746,6 +347991,32 @@ Write valid JSON only (no markdown fences inside files). Be exhaustive.`;
347746
347991
  function isPlainObject2(value) {
347747
347992
  return !!value && typeof value === "object" && !Array.isArray(value);
347748
347993
  }
347994
+ var LOCATOR_REQUIRING_IMPORT_ACTIONS = /* @__PURE__ */ new Set([
347995
+ "tap",
347996
+ "type",
347997
+ "clear",
347998
+ "waitForElement",
347999
+ "assertVisible",
348000
+ "assertText"
348001
+ ]);
348002
+ function selectorValueHasIdentity(value) {
348003
+ const trimmed = value.trim();
348004
+ if (!trimmed)
348005
+ return false;
348006
+ if (trimmed.startsWith("/") || trimmed.startsWith("(//"))
348007
+ return (0, shared_1.isIdentityMobileXpath)(trimmed);
348008
+ return true;
348009
+ }
348010
+ function stepLocatorLacksIdentity(step) {
348011
+ if (!LOCATOR_REQUIRING_IMPORT_ACTIONS.has(step.action))
348012
+ return false;
348013
+ const target = step.target;
348014
+ if (!target)
348015
+ return true;
348016
+ if ((0, shared_1.mobileLocatorBundleHasIdentity)(target.locatorBundle))
348017
+ return false;
348018
+ return ![target.primary, ...target.fallbacks ?? []].some((value) => typeof value === "string" && selectorValueHasIdentity(value));
348019
+ }
347749
348020
  function isValidStep(value) {
347750
348021
  if (!value || typeof value !== "object" || Array.isArray(value))
347751
348022
  return false;
@@ -347787,12 +348058,7 @@ Write valid JSON only (no markdown fences inside files). Be exhaustive.`;
347787
348058
  const o = value;
347788
348059
  if (!isNonEmptyString(o.name))
347789
348060
  return { ok: false, reason: "name: missing" };
347790
- if (!isNonEmptyString(o.appiumCode))
347791
- return { ok: false, reason: "appiumCode: missing" };
347792
- const code = o.appiumCode;
347793
- if (!/webdriverio|remote\s*\(|driver\s*\./i.test(code)) {
347794
- return { ok: false, reason: "appiumCode: not a WebdriverIO/Appium script" };
347795
- }
348061
+ const code = isNonEmptyString(o.appiumCode) ? o.appiumCode : "";
347796
348062
  if (!Array.isArray(o.steps) || o.steps.length === 0) {
347797
348063
  return { ok: false, reason: "steps: missing or empty" };
347798
348064
  }
@@ -347802,6 +348068,13 @@ Write valid JSON only (no markdown fences inside files). Be exhaustive.`;
347802
348068
  if (!o.steps.every(isValidStep)) {
347803
348069
  return { ok: false, reason: "steps: invalid step shape" };
347804
348070
  }
348071
+ const identityless = o.steps.find(stepLocatorLacksIdentity);
348072
+ if (identityless) {
348073
+ return {
348074
+ ok: false,
348075
+ reason: `steps: step ${identityless.order} (${identityless.action}) has no identity locator \u2014 a positional/index xpath cannot replay (imported steps have no recorded coordinates). Use ~accessibility-id, id=resource-id, visible text, or an attribute-equality xpath like //android.widget.Button[@text='Submit'].`
348076
+ };
348077
+ }
347805
348078
  const steps = o.steps.map((step, order) => ({
347806
348079
  ...step,
347807
348080
  order,
@@ -351095,7 +351368,7 @@ var require_package4 = __commonJS({
351095
351368
  "package.json"(exports2, module2) {
351096
351369
  module2.exports = {
351097
351370
  name: "@validate.qa/runner",
351098
- version: "1.0.66",
351371
+ version: "1.0.68",
351099
351372
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
351100
351373
  bin: {
351101
351374
  "validate-runner": "dist/cli.js"
@@ -351491,6 +351764,9 @@ function sanitizeAppiumLogLine(line) {
351491
351764
  }
351492
351765
  function setAppiumWorkloadActive(active) {
351493
351766
  activeWorkloadCount = active ? activeWorkloadCount + 1 : Math.max(0, activeWorkloadCount - 1);
351767
+ if (activeWorkloadCount === 0) {
351768
+ consecutiveHealthFailures = 0;
351769
+ }
351494
351770
  }
351495
351771
  function shouldRestartAfterHealthFailure(workloadCount, failureCount) {
351496
351772
  return workloadCount <= 0 && failureCount >= HEALTH_FAILURES_BEFORE_RESTART;
@@ -351986,17 +352262,31 @@ function rectMatchesIntendedBounds(rect, bounds) {
351986
352262
  return rectCenterInBounds || boundsCenterInRect;
351987
352263
  }
351988
352264
  async function resolveTypeTargetElementId(sessionPath, bounds) {
351989
- const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
352265
+ const rawBox = bounds !== void 0 ? normaliseBounds(bounds) : null;
352266
+ const box = rawBox && rawBox.width > 0 && rawBox.height > 0 ? rawBox : null;
351990
352267
  let elementId = await getActiveElementId(sessionPath);
351991
- if (elementId && box && box.width > 0 && box.height > 0) {
352268
+ let rejectedElementId = null;
352269
+ let rejectedForMismatch = false;
352270
+ if (elementId && box) {
351992
352271
  const rect = await getElementRect(sessionPath, elementId);
351993
352272
  if (!rect || !rectMatchesIntendedBounds(rect, box)) {
352273
+ rejectedElementId = elementId;
352274
+ rejectedForMismatch = !!rect;
351994
352275
  elementId = null;
351995
352276
  }
351996
352277
  }
351997
352278
  if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
351998
352279
  await new Promise((resolve2) => setTimeout(resolve2, MOBILE_FOCUS_SETTLE_MS));
351999
352280
  elementId = await getActiveElementId(sessionPath);
352281
+ if (elementId && box) {
352282
+ const rect = await getElementRect(sessionPath, elementId);
352283
+ const provablyWrong = rect ? !rectMatchesIntendedBounds(rect, box) : rejectedForMismatch && elementId === rejectedElementId;
352284
+ if (provablyWrong) {
352285
+ throw new Error(
352286
+ "Could not focus the intended field: a different element kept keyboard focus after tapping the target bounds. Tap the field directly, then retry."
352287
+ );
352288
+ }
352289
+ }
352000
352290
  }
352001
352291
  return elementId;
352002
352292
  }
@@ -354546,7 +354836,7 @@ function boundsForRun(step, platform3, isCrossPlatform) {
354546
354836
  if (platform3 && hasPlatformLocator(step.target, platform3)) return null;
354547
354837
  return boundsFromStep(step);
354548
354838
  }
354549
- function locatorStrategyForValue(value) {
354839
+ function locatorStrategyForValue(value, platform3) {
354550
354840
  const trimmed = value?.trim();
354551
354841
  if (!trimmed) {
354552
354842
  return [];
@@ -354562,7 +354852,8 @@ function locatorStrategyForValue(value) {
354562
354852
  }
354563
354853
  return [
354564
354854
  { using: "accessibility id", value: trimmed },
354565
- { using: "id", value: trimmed }
354855
+ { using: "id", value: trimmed },
354856
+ ...textIdentityStrategies(trimmed, platform3)
354566
354857
  ];
354567
354858
  }
354568
354859
  function buildLocatorStrategies(target, platform3) {
@@ -354572,12 +354863,19 @@ function buildLocatorStrategies(target, platform3) {
354572
354863
  if (bundle) {
354573
354864
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
354574
354865
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
354575
- if (bundle.xpath) strategies.push({ using: "xpath", value: bundle.xpath });
354866
+ if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
354867
+ for (const value of [
354868
+ isEditableMobileClassName(bundle.className) || isStateValueMobileClassName(bundle.className) ? void 0 : bundle.text,
354869
+ bundle.contentDesc
354870
+ ]) {
354871
+ const trimmed = value?.trim();
354872
+ if (trimmed) strategies.push(...textIdentityStrategies(trimmed, platform3));
354873
+ }
354576
354874
  }
354577
354875
  if (!(platform3 && hasPlatformLocator(target, platform3))) {
354578
- strategies.push(...locatorStrategyForValue(target.primary));
354876
+ strategies.push(...locatorStrategyForValue(target.primary, platform3));
354579
354877
  for (const fallback of target.fallbacks ?? []) {
354580
- strategies.push(...locatorStrategyForValue(fallback));
354878
+ strategies.push(...locatorStrategyForValue(fallback, platform3));
354581
354879
  }
354582
354880
  }
354583
354881
  return strategies.filter((strategy) => strategy.using !== "xpath" || isIdentityXpath(strategy.value)).filter(
@@ -354589,22 +354887,41 @@ function dedupeStrategies(strategies) {
354589
354887
  (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
354590
354888
  );
354591
354889
  }
354592
- function xpathLiteral2(value) {
354890
+ function xpathLiteral3(value) {
354593
354891
  if (!value.includes("'")) return `'${value}'`;
354594
354892
  if (!value.includes('"')) return `"${value}"`;
354595
354893
  const parts = value.split("'").map((p) => `'${p}'`);
354596
354894
  return `concat(${parts.join(`, "'", `)})`;
354597
354895
  }
354598
- function identityTextXpath(value) {
354599
- const lit = xpathLiteral2(value);
354896
+ function identityTextXpath2(value) {
354897
+ const lit = xpathLiteral3(value);
354600
354898
  return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
354601
354899
  }
354900
+ function iosPredicateLiteral(value) {
354901
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
354902
+ }
354903
+ function iosTextIdentityPredicate(value) {
354904
+ const lit = iosPredicateLiteral(value);
354905
+ return `label == ${lit} OR name == ${lit} OR value == ${lit}`;
354906
+ }
354907
+ function textIdentityStrategies(value, platform3) {
354908
+ const strategies = [];
354909
+ if (platform3 === "IOS") {
354910
+ strategies.push({ using: "-ios predicate string", value: iosTextIdentityPredicate(value) });
354911
+ }
354912
+ strategies.push({ using: "xpath", value: identityTextXpath2(value) });
354913
+ return strategies;
354914
+ }
354915
+ function isIdentityIosPredicate(value) {
354916
+ return /(?:^|[\s(])(?:name|label|value)\s*==\s*"/.test(value) && !/\b(?:CONTAINS|BEGINSWITH|ENDSWITH|LIKE|MATCHES|IN)\b/i.test(value);
354917
+ }
354602
354918
  function isIdentityXpath(value) {
354603
354919
  return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
354604
354920
  }
354605
354921
  function isIdentityStrategy(strategy) {
354606
354922
  if (strategy.using === "accessibility id" || strategy.using === "id") return true;
354607
354923
  if (strategy.using === "xpath") return isIdentityXpath(strategy.value);
354924
+ if (strategy.using === "-ios predicate string") return isIdentityIosPredicate(strategy.value);
354608
354925
  return false;
354609
354926
  }
354610
354927
  function buildIdentityLocatorStrategies(target, platform3) {
@@ -354614,15 +354931,18 @@ function buildIdentityLocatorStrategies(target, platform3) {
354614
354931
  if (bundle) {
354615
354932
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
354616
354933
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
354617
- for (const value of [bundle.text, bundle.contentDesc]) {
354934
+ if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
354935
+ for (const value of [
354936
+ isStateValueMobileClassName(bundle.className) ? void 0 : bundle.text,
354937
+ bundle.contentDesc
354938
+ ]) {
354618
354939
  const trimmed = value?.trim();
354619
- if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
354940
+ if (trimmed) strategies.push(...textIdentityStrategies(trimmed, platform3));
354620
354941
  }
354621
- if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
354622
354942
  }
354623
354943
  if (!(platform3 && hasPlatformLocator(target, platform3))) {
354624
354944
  for (const value of [target.primary, ...target.fallbacks ?? []]) {
354625
- for (const strategy of locatorStrategyForValue(value)) {
354945
+ for (const strategy of locatorStrategyForValue(value, platform3)) {
354626
354946
  if (isIdentityStrategy(strategy)) strategies.push(strategy);
354627
354947
  }
354628
354948
  }
@@ -355096,6 +355416,19 @@ async function readElementTextCandidates(sessionId, elementId, actualText) {
355096
355416
  }
355097
355417
  return values;
355098
355418
  }
355419
+ var controlPortSlotsByDevice = /* @__PURE__ */ new Map();
355420
+ function acquireControlPortSlot(deviceId) {
355421
+ const existing = controlPortSlotsByDevice.get(deviceId);
355422
+ if (existing !== void 0) return existing;
355423
+ const taken = new Set(controlPortSlotsByDevice.values());
355424
+ let slot = 0;
355425
+ while (taken.has(slot)) slot += 1;
355426
+ controlPortSlotsByDevice.set(deviceId, slot);
355427
+ return slot;
355428
+ }
355429
+ function releaseControlPortSlot(deviceId) {
355430
+ controlPortSlotsByDevice.delete(deviceId);
355431
+ }
355099
355432
  async function executeMobileTest(options) {
355100
355433
  const logLines = [];
355101
355434
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
@@ -355115,6 +355448,7 @@ async function executeMobileTest(options) {
355115
355448
  let captureStopped = false;
355116
355449
  let recordingStarted = false;
355117
355450
  let recordingStopped = false;
355451
+ let acquiredSlotDeviceId = null;
355118
355452
  let video;
355119
355453
  let failingSource;
355120
355454
  const stopCapture = async () => {
@@ -355217,7 +355551,8 @@ async function executeMobileTest(options) {
355217
355551
  log2(`Warning: Could not retrieve installed apps list. Proceeding with test execution.`);
355218
355552
  }
355219
355553
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
355220
- const leasedPortOffset = Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id));
355554
+ const leasedPortOffset = acquireControlPortSlot(selectedDevice.id);
355555
+ acquiredSlotDeviceId = selectedDevice.id;
355221
355556
  try {
355222
355557
  sessionId = await createSessionWithRetry({
355223
355558
  appId: options.target.appId,
@@ -355494,6 +355829,7 @@ async function executeMobileTest(options) {
355494
355829
  await deleteSession(sessionId);
355495
355830
  log2("Session destroyed");
355496
355831
  }
355832
+ if (acquiredSlotDeviceId) releaseControlPortSlot(acquiredSlotDeviceId);
355497
355833
  }
355498
355834
  }
355499
355835
  async function createMobileDiscoverySession(target, options) {
@@ -355539,21 +355875,28 @@ async function createMobileDiscoverySession(target, options) {
355539
355875
  isPhysical: selectedDevice.isPhysical === true,
355540
355876
  platformVersion: selectedDevice.platformVersion
355541
355877
  });
355542
- const sessionId = await createSessionWithRetry({
355543
- appId: target.appId,
355544
- platform: target.platform,
355545
- deviceId: selectedDevice.id,
355546
- platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
355547
- isPhysical: selectedDevice.isPhysical,
355548
- // Distinct control resources per concurrent device, including simulators.
355549
- portOffset: Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id))
355550
- });
355878
+ const discoveryPortSlotDeviceId = selectedDevice.id;
355879
+ let sessionId;
355880
+ try {
355881
+ sessionId = await createSessionWithRetry({
355882
+ appId: target.appId,
355883
+ platform: target.platform,
355884
+ deviceId: selectedDevice.id,
355885
+ platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
355886
+ isPhysical: selectedDevice.isPhysical,
355887
+ portOffset: acquireControlPortSlot(discoveryPortSlotDeviceId)
355888
+ });
355889
+ } catch (err) {
355890
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355891
+ throw err;
355892
+ }
355551
355893
  if (target.launchMode === "DEEP_LINK" && target.deeplink) {
355552
355894
  try {
355553
355895
  await openDeepLink(sessionId, target.platform, target.deeplink, target.appId);
355554
355896
  } catch (err) {
355555
355897
  await deleteSession(sessionId).catch(() => {
355556
355898
  });
355899
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355557
355900
  throw new Error(
355558
355901
  `Deep-link launch failed for mobile discovery: ${err instanceof Error ? err.message : String(err)}`
355559
355902
  );
@@ -355575,6 +355918,7 @@ async function createMobileDiscoverySession(target, options) {
355575
355918
  } catch {
355576
355919
  }
355577
355920
  await deleteSession(sessionId);
355921
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355578
355922
  }
355579
355923
  };
355580
355924
  }
@@ -355592,26 +355936,26 @@ async function readElementAttribute(sessionId, elementId, name) {
355592
355936
  }
355593
355937
  function harvestFallbackXpath(platform3, attrs) {
355594
355938
  if (platform3 === "IOS") {
355595
- if (attrs.name) return `//*[@name=${xpathLiteral2(attrs.name)}]`;
355596
- const text = attrs.label ?? attrs.value;
355597
- if (text) return identityTextXpath(text);
355939
+ if (attrs.name) return `//*[@name=${xpathLiteral3(attrs.name)}]`;
355940
+ if (attrs.text) return identityTextXpath2(attrs.text);
355598
355941
  return "//*";
355599
355942
  }
355600
- if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral2(attrs.resourceId)}]`;
355601
- if (attrs.text) return identityTextXpath(attrs.text);
355602
- if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral2(attrs.contentDesc)}]`;
355943
+ if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral3(attrs.resourceId)}]`;
355944
+ if (attrs.text) return identityTextXpath2(attrs.text);
355945
+ if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral3(attrs.contentDesc)}]`;
355603
355946
  return "//*";
355604
355947
  }
355605
355948
  async function readNativeElementBundle(sessionId, elementId, platform3) {
355606
355949
  const className = await readElementAttribute(sessionId, elementId, platform3 === "IOS" ? "type" : "class") ?? "";
355950
+ const volatileText = isEditableMobileClassName(className) || isStateValueMobileClassName(className);
355607
355951
  if (platform3 === "IOS") {
355608
355952
  const name = await readElementAttribute(sessionId, elementId, "name");
355609
355953
  const label = await readElementAttribute(sessionId, elementId, "label");
355610
355954
  const value = await readElementAttribute(sessionId, elementId, "value");
355611
- const text2 = label ?? value;
355955
+ const text2 = label ?? (volatileText ? void 0 : value);
355612
355956
  if (!name && !text2) return null;
355613
355957
  const bundle2 = {
355614
- xpath: harvestFallbackXpath("IOS", { name, label, value }),
355958
+ xpath: harvestFallbackXpath("IOS", { name, text: text2 }),
355615
355959
  className
355616
355960
  };
355617
355961
  if (name) bundle2.accessibilityId = name;
@@ -355620,7 +355964,8 @@ async function readNativeElementBundle(sessionId, elementId, platform3) {
355620
355964
  }
355621
355965
  const resourceId = await readElementAttribute(sessionId, elementId, "resource-id");
355622
355966
  const contentDesc = await readElementAttribute(sessionId, elementId, "content-desc") ?? await readElementAttribute(sessionId, elementId, "contentDescription");
355623
- const text = await readElementAttribute(sessionId, elementId, "text");
355967
+ const rawText = await readElementAttribute(sessionId, elementId, "text");
355968
+ const text = volatileText ? void 0 : rawText;
355624
355969
  if (!resourceId && !contentDesc && !text) return null;
355625
355970
  const bundle = {
355626
355971
  xpath: harvestFallbackXpath("ANDROID", { text, contentDesc, resourceId }),
@@ -355952,6 +356297,7 @@ var sessionStartInFlight = null;
355952
356297
  var sessionReaperTimer = null;
355953
356298
  var mirrorClientsEmptyAt = null;
355954
356299
  var mirrorSessionOwner = null;
356300
+ var actionInFlight = false;
355955
356301
  var recordingWorkloadActive = false;
355956
356302
  function setRecordingWorkloadActive(active) {
355957
356303
  if (recordingWorkloadActive === active) return;
@@ -356181,7 +356527,11 @@ async function handleHealth(_req, res) {
356181
356527
  // 409 — it must never be inferred from server-side global run counts, which
356182
356528
  // include unrelated projects and runners.
356183
356529
  executorBusy: executorBusyCount > 0,
356184
- recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording"
356530
+ // TRUE also during the multi-second session-START window: the claim loop
356531
+ // reads this to keep test runs off the recording device, and the Appium
356532
+ // session id only lands AFTER `POST /session` returns — without the
356533
+ // in-flight latch a run admitted in that window fights the recording.
356534
+ recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording" || sessionStartInFlight !== null
356185
356535
  });
356186
356536
  }
356187
356537
  function realDeviceCapabilities2(platform3) {
@@ -356259,7 +356609,11 @@ async function handleDevices(_req, res) {
356259
356609
  sendJSON(res, 200, {
356260
356610
  devices,
356261
356611
  executorBusy: executorBusyCount > 0,
356262
- recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording"
356612
+ // TRUE also during the multi-second session-START window: the claim loop
356613
+ // reads this to keep test runs off the recording device, and the Appium
356614
+ // session id only lands AFTER `POST /session` returns — without the
356615
+ // in-flight latch a run admitted in that window fights the recording.
356616
+ recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording" || sessionStartInFlight !== null
356263
356617
  });
356264
356618
  }
356265
356619
  async function handleSessionStart(req, res) {
@@ -356348,6 +356702,8 @@ async function startSession(validated, res) {
356348
356702
  state.platform = targetPlatform === "IOS" ? "IOS" : "ANDROID";
356349
356703
  mirrorSessionOwner = "recording";
356350
356704
  setRecordingWorkloadActive(true);
356705
+ mirrorClientsEmptyAt = Date.now();
356706
+ ensureSessionReaper();
356351
356707
  if (launchMode === "DEEP_LINK" && deeplink) {
356352
356708
  try {
356353
356709
  const args = targetPlatform === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
@@ -356701,6 +357057,14 @@ async function handleAction(req, res) {
356701
357057
  sendJSON(res, 400, { error: "No active Appium session" });
356702
357058
  return;
356703
357059
  }
357060
+ if (mirrorSessionOwner !== "recording") {
357061
+ sendJSON(res, 409, { error: "No recording session owns the device (a runner-driven session is active)." });
357062
+ return;
357063
+ }
357064
+ if (actionInFlight) {
357065
+ sendJSON(res, 409, { error: "Another action is still executing. Wait for it to finish." });
357066
+ return;
357067
+ }
356704
357068
  validateSessionId(state.appiumSessionId);
356705
357069
  const body = await parseBody(req);
356706
357070
  const type = typeof body.type === "string" ? body.type : null;
@@ -356712,6 +357076,7 @@ async function handleAction(req, res) {
356712
357076
  sendJSON(res, 400, { error: "Unknown or unsupported action type" });
356713
357077
  return;
356714
357078
  }
357079
+ actionInFlight = true;
356715
357080
  try {
356716
357081
  const sessionPath = `/session/${state.appiumSessionId}`;
356717
357082
  switch (type) {
@@ -356784,15 +357149,23 @@ async function handleAction(req, res) {
356784
357149
  sendJSON(res, 400, { error: `Unknown action type: ${type}` });
356785
357150
  return;
356786
357151
  }
356787
- const [screenshot, source] = await Promise.all([getScreenshot2(), getPageSource2()]);
357152
+ await waitForUiSettle(sessionPath);
357153
+ const screenshot = await getScreenshot2();
357154
+ let source = await getPageSource2();
357155
+ if (source === null) {
357156
+ source = await getPageSource2();
357157
+ }
356788
357158
  sendJSON(res, 200, {
356789
357159
  ok: true,
356790
357160
  screenshot: screenshot ?? null,
356791
357161
  source: source ?? null,
357162
+ ...source === null || source === void 0 ? { sourceUnavailable: true } : {},
356792
357163
  timestamp: Date.now()
356793
357164
  });
356794
357165
  } catch (err) {
356795
357166
  sendJSON(res, 500, { error: `Action failed: ${err instanceof Error ? err.message : String(err)}` });
357167
+ } finally {
357168
+ actionInFlight = false;
356796
357169
  }
356797
357170
  }
356798
357171
  async function handleScreenshot(_req, res) {
@@ -357030,7 +357403,11 @@ function getBridgeState() {
357030
357403
  tunnelUrl: state.tunnelUrl,
357031
357404
  tunnelToken: state.tunnelToken,
357032
357405
  hasActiveSession: !!state.appiumSessionId,
357033
- recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording",
357406
+ // TRUE also during the multi-second session-START window: the claim loop
357407
+ // reads this to keep test runs off the recording device, and the Appium
357408
+ // session id only lands AFTER `POST /session` returns — without the
357409
+ // in-flight latch a run admitted in that window fights the recording.
357410
+ recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording" || sessionStartInFlight !== null,
357034
357411
  platform: state.platform
357035
357412
  };
357036
357413
  }