@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.mjs CHANGED
@@ -983,7 +983,7 @@ var require_mobile_source_parser = __commonJS({
983
983
  const value = nonEmpty3(attrs.value);
984
984
  const label = name ?? labelAttr ?? value;
985
985
  const accessibilityId = name;
986
- const text = value && value !== label ? value : labelAttr && labelAttr !== label ? labelAttr : void 0;
986
+ const text = labelAttr ?? (isStateValueRole(role) ? void 0 : value);
987
987
  const bounds = parseIOSBounds(attrs);
988
988
  const enabled = attrBoolDefaultTrue(attrs.enabled);
989
989
  const visible = attrBoolDefaultTrue(attrs.visible);
@@ -1130,6 +1130,42 @@ var require_mobile_source_parser = __commonJS({
1130
1130
  }
1131
1131
  }
1132
1132
  }
1133
+ function annotateIdentityUniqueness(elements) {
1134
+ const a11yCounts = /* @__PURE__ */ new Map();
1135
+ const ridCounts = /* @__PURE__ */ new Map();
1136
+ const textCounts = /* @__PURE__ */ new Map();
1137
+ const bump = (map2, value) => {
1138
+ const key = value?.trim();
1139
+ if (!key)
1140
+ return;
1141
+ map2.set(key, (map2.get(key) ?? 0) + 1);
1142
+ };
1143
+ for (const element of elements) {
1144
+ bump(a11yCounts, element.accessibilityId);
1145
+ bump(ridCounts, element.resourceId);
1146
+ const identityValues = new Set([element.text, element.accessibilityId].map((value) => value?.trim()).filter((value) => Boolean(value)));
1147
+ for (const value of identityValues)
1148
+ bump(textCounts, value);
1149
+ }
1150
+ const isUnique = (map2, value) => {
1151
+ const key = value?.trim();
1152
+ if (!key)
1153
+ return void 0;
1154
+ return (map2.get(key) ?? 0) <= 1;
1155
+ };
1156
+ for (const element of elements) {
1157
+ const accessibilityId = isUnique(a11yCounts, element.accessibilityId);
1158
+ const resourceId = isUnique(ridCounts, element.resourceId);
1159
+ const text = isUnique(textCounts, element.text);
1160
+ if (accessibilityId === void 0 && resourceId === void 0 && text === void 0)
1161
+ continue;
1162
+ element.identityUnique = {
1163
+ ...accessibilityId !== void 0 ? { accessibilityId } : {},
1164
+ ...resourceId !== void 0 ? { resourceId } : {},
1165
+ ...text !== void 0 ? { text } : {}
1166
+ };
1167
+ }
1168
+ }
1133
1169
  function flatten(root, platform3) {
1134
1170
  const out = [];
1135
1171
  const stack = [];
@@ -1175,6 +1211,7 @@ var require_mobile_source_parser = __commonJS({
1175
1211
  }
1176
1212
  }
1177
1213
  markRepeatedBottomBarNavigation(out);
1214
+ annotateIdentityUniqueness(out);
1178
1215
  return out;
1179
1216
  }
1180
1217
  function fnv1a(input) {
@@ -1256,6 +1293,13 @@ var require_mobile_source_parser = __commonJS({
1256
1293
  function isEditableElement(el) {
1257
1294
  return EDITABLE_CLASS_RE.test(el.role.trim());
1258
1295
  }
1296
+ var STATE_VALUE_CLASS_RE = /(XCUIElementTypeSwitch|XCUIElementTypeToggle|XCUIElementTypeSlider|XCUIElementTypeStepper|XCUIElementTypePageIndicator|XCUIElementTypeProgressIndicator|XCUIElementTypeSegmentedControl|XCUIElementTypePicker|XCUIElementTypePickerWheel|ToggleButton|SeekBar|ProgressBar|RatingBar|NumberPicker)$/;
1297
+ function isStateValueRole(role) {
1298
+ return STATE_VALUE_CLASS_RE.test(role.trim());
1299
+ }
1300
+ function isStateValueElement(el) {
1301
+ return isStateValueRole(el.role);
1302
+ }
1259
1303
  function xpathAttrLiteral(value) {
1260
1304
  if (!value.includes("'"))
1261
1305
  return `'${value}'`;
@@ -1266,17 +1310,18 @@ var require_mobile_source_parser = __commonJS({
1266
1310
  function semanticXPath(el, platform3) {
1267
1311
  const cls = nonBlank(el.role) ?? "*";
1268
1312
  const a11y = nonBlank(el.accessibilityId);
1269
- if (a11y) {
1270
- return platform3 === "ANDROID" ? `//${cls}[@content-desc=${xpathAttrLiteral(a11y)}]` : `//${cls}[@name=${xpathAttrLiteral(a11y)}]`;
1271
- }
1272
- const rid = nonBlank(el.resourceId);
1273
- if (platform3 === "ANDROID" && rid)
1274
- return `//${cls}[@resource-id=${xpathAttrLiteral(rid)}]`;
1275
- const text = nonBlank(el.text);
1276
- if (text && !isEditableElement(el)) {
1277
- return platform3 === "ANDROID" ? `//${cls}[@text=${xpathAttrLiteral(text)}]` : `//${cls}[@label=${xpathAttrLiteral(text)}]`;
1278
- }
1279
- return void 0;
1313
+ const rid = platform3 === "ANDROID" ? nonBlank(el.resourceId) : void 0;
1314
+ const text = isEditableElement(el) || isStateValueElement(el) ? void 0 : nonBlank(el.text);
1315
+ const a11yXpath = a11y ? platform3 === "ANDROID" ? `//${cls}[@content-desc=${xpathAttrLiteral(a11y)}]` : `//${cls}[@name=${xpathAttrLiteral(a11y)}]` : void 0;
1316
+ const ridXpath = rid ? `//${cls}[@resource-id=${xpathAttrLiteral(rid)}]` : void 0;
1317
+ const textXpath = text ? platform3 === "ANDROID" ? `//${cls}[@text=${xpathAttrLiteral(text)}]` : `//${cls}[@label=${xpathAttrLiteral(text)}]` : void 0;
1318
+ const candidates = [
1319
+ { xpath: a11yXpath, unique: el.identityUnique?.accessibilityId },
1320
+ { xpath: ridXpath, unique: el.identityUnique?.resourceId },
1321
+ { xpath: textXpath, unique: el.identityUnique?.text }
1322
+ ];
1323
+ const uniqueFirst = candidates.find((c) => c.xpath && c.unique !== false);
1324
+ return (uniqueFirst ?? candidates.find((c) => c.xpath))?.xpath;
1280
1325
  }
1281
1326
  function nonBlank(value) {
1282
1327
  if (typeof value !== "string")
@@ -1285,30 +1330,40 @@ var require_mobile_source_parser = __commonJS({
1285
1330
  return trimmed.length > 0 ? trimmed : void 0;
1286
1331
  }
1287
1332
  function buildLocatorBundle(el, platform3) {
1333
+ const durableText = el.text && !isEditableElement(el) && !isStateValueElement(el) ? el.text : void 0;
1288
1334
  const bundle = {
1289
1335
  // Attribute-anchored xpath when the element has identity; the absolute
1290
1336
  // indexed path only for identity-less elements (nothing else can find them).
1291
1337
  xpath: semanticXPath(el, platform3) ?? el.xpath,
1292
1338
  className: el.role
1293
1339
  };
1294
- if (el.accessibilityId)
1340
+ const a11yUnique = el.identityUnique?.accessibilityId !== false;
1341
+ const ridUnique = el.identityUnique?.resourceId !== false;
1342
+ const textUnique = el.identityUnique?.text !== false;
1343
+ const hasUniqueAlternativeToA11y = Boolean(el.resourceId && platform3 === "ANDROID" && ridUnique || durableText && textUnique);
1344
+ const hasUniqueAlternativeToRid = Boolean(el.accessibilityId && a11yUnique || durableText && textUnique);
1345
+ const includeA11y = Boolean(el.accessibilityId && (a11yUnique || !hasUniqueAlternativeToA11y));
1346
+ const includeRid = Boolean(platform3 === "ANDROID" && el.resourceId && (ridUnique || !hasUniqueAlternativeToRid));
1347
+ if (includeA11y && el.accessibilityId)
1295
1348
  bundle.accessibilityId = el.accessibilityId;
1296
- if (platform3 === "ANDROID" && el.resourceId)
1349
+ if (includeRid && el.resourceId)
1297
1350
  bundle.resourceId = el.resourceId;
1298
- if (el.text && !isEditableElement(el))
1299
- bundle.text = el.text;
1300
- if (platform3 === "ANDROID" && el.accessibilityId)
1351
+ if (durableText)
1352
+ bundle.text = durableText;
1353
+ if (includeA11y && platform3 === "ANDROID" && el.accessibilityId)
1301
1354
  bundle.contentDesc = el.accessibilityId;
1302
1355
  return bundle;
1303
1356
  }
1304
1357
  function preferredLocator(el) {
1305
- if (el.accessibilityId)
1306
- return el.accessibilityId;
1307
- if (el.resourceId)
1308
- return el.resourceId;
1309
- if (el.text && !isEditableElement(el))
1310
- return el.text;
1311
- return el.xpath;
1358
+ const durableText = el.text && !isEditableElement(el) && !isStateValueElement(el) ? el.text : void 0;
1359
+ const candidates = [
1360
+ { value: el.accessibilityId, unique: el.identityUnique?.accessibilityId },
1361
+ { value: el.resourceId, unique: el.identityUnique?.resourceId },
1362
+ { value: durableText, unique: el.identityUnique?.text }
1363
+ ];
1364
+ const uniqueFirst = candidates.find((c) => c.value && c.unique !== false);
1365
+ const chosen = (uniqueFirst ?? candidates.find((c) => c.value))?.value;
1366
+ return chosen ?? el.xpath;
1312
1367
  }
1313
1368
  function buildScreenSignal(elements, root, platform3) {
1314
1369
  const signal = {
@@ -1971,6 +2026,11 @@ function isEditableMobileClassName(className) {
1971
2026
  return false;
1972
2027
  return /(EditText|AutoCompleteTextView|XCUIElementTypeTextField|XCUIElementTypeSecureTextField|XCUIElementTypeSearchField|XCUIElementTypeTextView)$/.test(className.trim());
1973
2028
  }
2029
+ function isStateValueMobileClassName(className) {
2030
+ if (!className)
2031
+ return false;
2032
+ return /(XCUIElementTypeSwitch|XCUIElementTypeToggle|XCUIElementTypeSlider|XCUIElementTypeStepper|XCUIElementTypePageIndicator|XCUIElementTypeProgressIndicator|XCUIElementTypeSegmentedControl|XCUIElementTypePicker|XCUIElementTypePickerWheel|ToggleButton|SeekBar|ProgressBar|RatingBar|NumberPicker)$/.test(className.trim());
2033
+ }
1974
2034
  function mobileCredentialPlaceholder(key) {
1975
2035
  return `{{${key}}}`;
1976
2036
  }
@@ -2100,9 +2160,10 @@ function buildStepTarget(metadata, selector) {
2100
2160
  locatorBundle.resourceId,
2101
2161
  // For editable fields the text attribute IS the currently-typed value — a
2102
2162
  // self-invalidating locator that only matches while the recorded text is
2103
- // still in the field (and it can leak typed input into locators). Static
2104
- // elements keep their text fallback.
2105
- isEditableMobileClassName(locatorBundle.className) ? void 0 : locatorBundle.text,
2163
+ // still in the field (and it can leak typed input into locators). State
2164
+ // controls' text is their current state ("1"/"ON") and collides with every
2165
+ // same-state sibling. Static elements keep their text fallback.
2166
+ isEditableMobileClassName(locatorBundle.className) || isStateValueMobileClassName(locatorBundle.className) ? void 0 : locatorBundle.text,
2106
2167
  locatorBundle.contentDesc,
2107
2168
  locatorBundle.xpath,
2108
2169
  selector
@@ -2241,12 +2302,26 @@ function selectorForValue(value) {
2241
2302
  function isIdentityMobileXpath(value) {
2242
2303
  return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
2243
2304
  }
2305
+ function xpathLiteral(value) {
2306
+ if (!value.includes("'"))
2307
+ return `'${value}'`;
2308
+ if (!value.includes('"'))
2309
+ return `"${value}"`;
2310
+ const parts = value.split("'").map((p) => `'${p}'`);
2311
+ return `concat(${parts.join(`, "'", `)})`;
2312
+ }
2313
+ function identityTextXpath(value) {
2314
+ const lit = xpathLiteral(value);
2315
+ return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
2316
+ }
2244
2317
  function mobileLocatorBundleHasIdentity(bundle) {
2245
2318
  if (!bundle)
2246
2319
  return false;
2247
2320
  if (nonEmpty(bundle.accessibilityId) || nonEmpty(bundle.resourceId))
2248
2321
  return true;
2249
- if (nonEmpty(bundle.text) || nonEmpty(bundle.contentDesc))
2322
+ if (nonEmpty(bundle.text) && !isStateValueMobileClassName(bundle.className))
2323
+ return true;
2324
+ if (nonEmpty(bundle.contentDesc))
2250
2325
  return true;
2251
2326
  const xpath = nonEmpty(bundle.xpath);
2252
2327
  return !!xpath && isIdentityMobileXpath(xpath);
@@ -2269,14 +2344,25 @@ function selectorCandidatesForStep(step) {
2269
2344
  candidates.push(`id=${bundle.resourceId}`);
2270
2345
  if (nonEmpty(bundle.xpath))
2271
2346
  candidates.push(bundle.xpath);
2347
+ const isGateStep = step.action === "waitForElement" || step.action === "assertVisible" || step.action === "assertText";
2348
+ const textIdentity = (isGateStep || !isEditableMobileClassName(bundle.className)) && !isStateValueMobileClassName(bundle.className) ? bundle.text : void 0;
2349
+ for (const value of [textIdentity, bundle.contentDesc]) {
2350
+ const trimmed = nonEmpty(value);
2351
+ if (trimmed)
2352
+ candidates.push(identityTextXpath(trimmed));
2353
+ }
2272
2354
  }
2273
2355
  const hasExplicitResourceId = !!nonEmpty(bundle?.resourceId);
2356
+ const bundleContributedIdentity = candidates.some((value) => !(value.startsWith("/") || value.startsWith("(//")) || isIdentityMobileXpath(value));
2274
2357
  const pushValueCandidates = (value) => {
2275
2358
  candidates.push(selectorForValue(value));
2276
2359
  const looksLikeIdToken = /^[A-Za-z_][\w.:-]*$/.test(value);
2277
2360
  if (!hasExplicitResourceId && looksLikeIdToken && !/^(~|id=|css=|\/|\()/.test(value) && !value.includes(":id/")) {
2278
2361
  candidates.push(`id=${value}`);
2279
2362
  }
2363
+ if (!bundleContributedIdentity && !/^(~|id=|css=|\/|\()/.test(value)) {
2364
+ candidates.push(identityTextXpath(value));
2365
+ }
2280
2366
  };
2281
2367
  if (nonEmpty(target.primary))
2282
2368
  pushValueCandidates(target.primary);
@@ -2715,7 +2801,7 @@ function uniq(values) {
2715
2801
  }
2716
2802
  return result;
2717
2803
  }
2718
- function xpathLiteral(value) {
2804
+ function xpathLiteral2(value) {
2719
2805
  if (!value.includes("'"))
2720
2806
  return `'${value}'`;
2721
2807
  if (!value.includes('"'))
@@ -2723,7 +2809,7 @@ function xpathLiteral(value) {
2723
2809
  return `concat(${value.split("'").map((part) => `'${part}'`).join(`, "'", `)})`;
2724
2810
  }
2725
2811
  function mobileTextXPath(value, platform3) {
2726
- const literal = xpathLiteral(value);
2812
+ const literal = xpathLiteral2(value);
2727
2813
  if (platform3 === "IOS") {
2728
2814
  return `//*[@name=${literal} or @label=${literal} or @value=${literal}]`;
2729
2815
  }
@@ -2971,7 +3057,8 @@ function isWebViewStep(step) {
2971
3057
  function hasCrossPlatformIdentity(bundle) {
2972
3058
  if (!bundle)
2973
3059
  return false;
2974
- return Boolean(bundle.text?.trim() || bundle.contentDesc?.trim() || bundle.accessibilityId?.trim());
3060
+ const durableText = isStateValueMobileClassName(bundle.className) ? void 0 : bundle.text;
3061
+ return Boolean(durableText?.trim() || bundle.contentDesc?.trim() || bundle.accessibilityId?.trim());
2975
3062
  }
2976
3063
  function stepIsDivergentForPlatform(step, platform3) {
2977
3064
  return Array.isArray(step.crossPlatformDivergent) && step.crossPlatformDivergent.includes(platform3);
@@ -3053,7 +3140,8 @@ function summarizeMobileReadiness(steps, authoringPlatform) {
3053
3140
  function hasStableNativeIdentity(bundle) {
3054
3141
  if (!bundle)
3055
3142
  return false;
3056
- 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));
3143
+ const durableText = isStateValueMobileClassName(bundle.className) ? void 0 : bundle.text;
3144
+ 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));
3057
3145
  }
3058
3146
  function isPositionalXpath(xpath) {
3059
3147
  const value = xpath?.trim();
@@ -3133,6 +3221,7 @@ var MOBILE_PLATFORMS, LOCATOR_REQUIRING_ACTIONS, XPATH_ATTRIBUTE_EQUALITY_RE;
3133
3221
  var init_mobile_cross_platform = __esm({
3134
3222
  "../shared/dist/mobile-cross-platform.js"() {
3135
3223
  "use strict";
3224
+ init_mobile_test_generation();
3136
3225
  MOBILE_PLATFORMS = ["IOS", "ANDROID"];
3137
3226
  LOCATOR_REQUIRING_ACTIONS = /* @__PURE__ */ new Set([
3138
3227
  "tap",
@@ -3871,6 +3960,43 @@ function accumulateStatements(lines) {
3871
3960
  }
3872
3961
  return out;
3873
3962
  }
3963
+ function classifyMobileAssertValue(value, context = {}) {
3964
+ const text = value.trim();
3965
+ if (!text)
3966
+ return null;
3967
+ if (/^\{\{[A-Z0-9_]+\}\}$/.test(text))
3968
+ return null;
3969
+ const t1 = matchFirst(TIER1_PATTERNS, text);
3970
+ if (t1)
3971
+ return { tier: 1, reason: t1[1] };
3972
+ const authIdentityValues = [];
3973
+ if (context.authenticatedUserEmail)
3974
+ authIdentityValues.push(context.authenticatedUserEmail);
3975
+ if (context.authenticatedUserName && context.authenticatedUserName.length >= 3) {
3976
+ authIdentityValues.push(context.authenticatedUserName);
3977
+ }
3978
+ for (const auth of authIdentityValues) {
3979
+ if (auth.length >= 3 && text.toLowerCase().includes(auth.toLowerCase())) {
3980
+ return { tier: 2, reason: "asserts the authenticated user identity \u2014 fails under any other test account" };
3981
+ }
3982
+ }
3983
+ const knownValues = [
3984
+ ...context.knownUserInputs ?? [],
3985
+ ...context.scenarioCreatedValues ?? []
3986
+ ].filter((v) => {
3987
+ for (const auth of authIdentityValues) {
3988
+ if (auth.length >= 3 && v.toLowerCase().includes(auth.toLowerCase()))
3989
+ return false;
3990
+ }
3991
+ return true;
3992
+ });
3993
+ if (containsKnownValue(text, knownValues))
3994
+ return null;
3995
+ const t2 = matchFirst(TIER2_PATTERNS, text);
3996
+ if (t2)
3997
+ return { tier: 2, reason: t2[1] };
3998
+ return null;
3999
+ }
3874
4000
  function parenDepthDelta(text) {
3875
4001
  let depth = 0;
3876
4002
  let quote = null;
@@ -6044,6 +6170,7 @@ __export(dist_exports, {
6044
6170
  classifyAssertionBlock: () => classifyAssertionBlock,
6045
6171
  classifyAssertionLine: () => classifyAssertionLine,
6046
6172
  classifyErrorMessage: () => classifyErrorMessage,
6173
+ classifyMobileAssertValue: () => classifyMobileAssertValue,
6047
6174
  classifyTestHealth: () => classifyTestHealth,
6048
6175
  coldSessionConfigMessage: () => coldSessionConfigMessage,
6049
6176
  computeMobileSwipePointers: () => computeMobileSwipePointers,
@@ -6097,6 +6224,7 @@ __export(dist_exports, {
6097
6224
  isEditableMobileClassName: () => isEditableMobileClassName,
6098
6225
  isEmailFromIdentity: () => isEmailFromIdentity,
6099
6226
  isEnvironmentalError: () => isEnvironmentalError,
6227
+ isIdentityMobileXpath: () => isIdentityMobileXpath,
6100
6228
  isMobilePlatform: () => isMobilePlatform,
6101
6229
  isPricingPlan: () => isPricingPlan,
6102
6230
  isRunnableStep: () => isRunnableStep,
@@ -6104,6 +6232,7 @@ __export(dist_exports, {
6104
6232
  isSameRegistrableDomainHost: () => isSameRegistrableDomainHost,
6105
6233
  isSensitiveQueryKey: () => isSensitiveQueryKey,
6106
6234
  isSharedAuthInjectEnabled: () => isSharedAuthInjectEnabled,
6235
+ isStateValueMobileClassName: () => isStateValueMobileClassName,
6107
6236
  isSttModelForProvider: () => isSttModelForProvider,
6108
6237
  isTestReadyForPlatform: () => isTestReadyForPlatform,
6109
6238
  isTrialExpired: () => isTrialExpired,
@@ -7011,6 +7140,7 @@ var require_mobile_observation = __commonJS({
7011
7140
  exports.buildCollectedElementsForScreen = buildCollectedElementsForScreen;
7012
7141
  exports.extractNativeMobileLandmarks = extractNativeMobileLandmarks;
7013
7142
  exports.safeMobileElementLabel = safeMobileElementLabel;
7143
+ var mobile_source_parser_js_1 = require_mobile_source_parser();
7014
7144
  var DEFAULT_MAX_ELEMENTS = 60;
7015
7145
  var MAX_LABEL_LENGTH = 80;
7016
7146
  var EVIDENCE_LABEL_LIMIT = 40;
@@ -7101,7 +7231,8 @@ var require_mobile_observation = __commonJS({
7101
7231
  const locators = [];
7102
7232
  if (stableId)
7103
7233
  locators.push({ strategy: "getByTestId", value: stableId, confidence: 0.95 });
7104
- const xpath = element.xpath?.trim();
7234
+ const platform3 = element.role.startsWith("XCUIElementType") ? "IOS" : "ANDROID";
7235
+ const xpath = (0, mobile_source_parser_js_1.buildLocatorBundle)(element, platform3).xpath?.trim();
7105
7236
  if (xpath)
7106
7237
  locators.push({ strategy: "css", value: xpath, confidence: 0.4 });
7107
7238
  const collected = {
@@ -78350,6 +78481,10 @@ var require_discover_ai_planner = __commonJS({
78350
78481
  if (context.appBrief) {
78351
78482
  sections.push(`## Application Brief
78352
78483
  ${context.appBrief.slice(0, 500)}`);
78484
+ }
78485
+ if (context.knowledgeContext && context.knowledgeContext.trim().length > 0) {
78486
+ sections.push(`## Known Facts
78487
+ ${context.knowledgeContext.slice(0, 4e3)}`);
78353
78488
  }
78354
78489
  sections.push(`## Base URL
78355
78490
  ${compressed.baseUrl}`);
@@ -80888,6 +81023,45 @@ ${constraints.join("\n")}`);
80888
81023
  }
80889
81024
  });
80890
81025
 
81026
+ // ../runner-core/dist/services/discovery-lint-flags.js
81027
+ var require_discovery_lint_flags = __commonJS({
81028
+ "../runner-core/dist/services/discovery-lint-flags.js"(exports) {
81029
+ "use strict";
81030
+ Object.defineProperty(exports, "__esModule", { value: true });
81031
+ exports.structuralLocatorLintMode = structuralLocatorLintMode;
81032
+ exports.isPwMethodPreflightEnabled = isPwMethodPreflightEnabled;
81033
+ exports.isVqaLoginDedupeEnabled = isVqaLoginDedupeEnabled;
81034
+ exports.mobileSelectorQualityLintMode = mobileSelectorQualityLintMode;
81035
+ exports.isTestgenEmailVerifyEnabled = isTestgenEmailVerifyEnabled;
81036
+ function strictOn(value) {
81037
+ return value === "true" || value === "1";
81038
+ }
81039
+ function structuralLocatorLintMode() {
81040
+ const v = (process.env.DISCOVERY_STRUCTURAL_LOCATOR_LINT || "").trim().toLowerCase();
81041
+ if (v === "warn")
81042
+ return "warn";
81043
+ if (v === "true" || v === "1" || v === "enforce")
81044
+ return "enforce";
81045
+ return "off";
81046
+ }
81047
+ function isPwMethodPreflightEnabled() {
81048
+ return strictOn(process.env.DISCOVERY_PW_METHOD_PREFLIGHT);
81049
+ }
81050
+ function isVqaLoginDedupeEnabled() {
81051
+ return strictOn(process.env.DISCOVERY_VQALOGIN_DEDUPE);
81052
+ }
81053
+ function mobileSelectorQualityLintMode() {
81054
+ const v = (process.env.MOBILE_SELECTOR_QUALITY_LINT || "").trim().toLowerCase();
81055
+ if (v === "true" || v === "1" || v === "enforce")
81056
+ return "enforce";
81057
+ return "off";
81058
+ }
81059
+ function isTestgenEmailVerifyEnabled() {
81060
+ return strictOn(process.env.DISCOVERY_TESTGEN_EMAIL_VERIFY);
81061
+ }
81062
+ }
81063
+ });
81064
+
80891
81065
  // ../runner-core/dist/services/mobile/mobile-generator.js
80892
81066
  var require_mobile_generator = __commonJS({
80893
81067
  "../runner-core/dist/services/mobile/mobile-generator.js"(exports) {
@@ -80907,6 +81081,7 @@ var require_mobile_generator = __commonJS({
80907
81081
  var ai_queue_js_1 = require_ai_queue();
80908
81082
  var ai_retry_js_1 = require_ai_retry();
80909
81083
  var mobile_session_keepalive_js_1 = require_mobile_session_keepalive();
81084
+ var discovery_lint_flags_js_1 = require_discovery_lint_flags();
80910
81085
  var mobile_source_parser_js_1 = require_mobile_source_parser();
80911
81086
  var mobile_source_parser_js_2 = require_mobile_source_parser();
80912
81087
  var mobile_observation_js_1 = require_mobile_observation();
@@ -81076,7 +81251,15 @@ var require_mobile_generator = __commonJS({
81076
81251
  properties: {
81077
81252
  name: { type: "string", description: 'Descriptive test name (e.g. "Login - Happy Path").' },
81078
81253
  description: { type: "string", description: "One-line description of what the test proves." },
81079
- verified: { type: "boolean", description: "True only if you asserted the outcome LIVE with an assert tool." }
81254
+ verified: { type: "boolean", description: "True only if you asserted the outcome LIVE with an assert tool." },
81255
+ suspectedBug: {
81256
+ type: "boolean",
81257
+ 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."
81258
+ },
81259
+ bugDescription: {
81260
+ type: "string",
81261
+ description: "What is broken in the APP (not the test) \u2014 concrete screen + observed vs expected."
81262
+ }
81080
81263
  },
81081
81264
  required: ["name", "description", "verified"]
81082
81265
  }
@@ -81322,6 +81505,8 @@ var require_mobile_generator = __commonJS({
81322
81505
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
81323
81506
  - Assert only DURABLE elements \u2014 screen chrome that exists on every visit (tab labels, headers, nav bars, the entity you just created). NEVER assert one-time / data-dependent UI: empty-state CTAs, first-run or onboarding prompts, or counters \u2014 they vanish as soon as the account has data, and the test then fails forever on a healthy app.
81324
81507
  - 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.
81508
+ - 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.
81509
+ - If the APP itself misbehaves (crash, error screen, a flow that cannot proceed as specified), do not force a green path: assert the failure you observed (e.g. the error text), then call finish_generate with suspectedBug:true and a concrete bugDescription of what is broken in the app.
81325
81510
  - Stay inside this app.${targetLine} Do not open or use app stores, browsers, email apps, settings, or any other app. If an action leaves the target app, the runner will relaunch the target app and reject that action.
81326
81511
  - NEVER tap the phone's home screen / launcher (app icons, "Predicted app" chips, search bars, widgets). Those are NOT part of the app under test \u2014 tapping them is junk that poisons the test. If the app ever backgrounds or you find yourself on the launcher, use mobile_relaunch to cold-start the app; do NOT tap a launcher icon or app-prediction to get back in.
81327
81512
  - Do NOT re-do a sequence you already completed. If you filled a form then navigated away, do NOT come back and fill it again \u2014 the scenario needs ONE clean pass. Filling the login form, going back, and re-filling it is exactly the kind of wandering that produces a bloated, broken test.
@@ -81427,6 +81612,7 @@ Start by calling mobile_snapshot, then execute the steps above \u2014 the minimu
81427
81612
  let assertCount = 0;
81428
81613
  let finish = null;
81429
81614
  let finishIntentRejections = 0;
81615
+ let finishStrengthRejections = 0;
81430
81616
  let emptyTurnsAfterFinishReject = 0;
81431
81617
  let latest = null;
81432
81618
  const getLatest = () => latest;
@@ -81919,31 +82105,49 @@ ${statePacket}`, redactionCredentials) },
81919
82105
  const claimedVerified = Boolean(toolArgs.verified);
81920
82106
  const intentReason = scenarioIntentMismatchReason(scenario, actions);
81921
82107
  const verifiedWithoutAssert = claimedVerified && assertCount === 0;
81922
- const canReject = finishIntentRejections < MAX_FINISH_INTENT_REJECTIONS && iteration < maxIterations && (intentReason !== null || verifiedWithoutAssert);
81923
- if (canReject) {
81924
- finishIntentRejections += 1;
82108
+ const intentStyleReject = finishIntentRejections < MAX_FINISH_INTENT_REJECTIONS && iteration < maxIterations && (intentReason !== null || verifiedWithoutAssert);
82109
+ let strengthRejectReason = null;
82110
+ if (!intentStyleReject && claimedVerified && assertCount > 0 && intentReason === null && finishStrengthRejections < 1 && iteration < maxIterations) {
82111
+ const verdict = evaluateMobileAssertionStrength(actions);
82112
+ if (verdict.severity === "block") {
82113
+ strengthRejectReason = verdict.reason;
82114
+ }
82115
+ }
82116
+ if (intentStyleReject || strengthRejectReason !== null) {
82117
+ if (intentStyleReject)
82118
+ finishIntentRejections += 1;
82119
+ if (strengthRejectReason !== null)
82120
+ finishStrengthRejections += 1;
81925
82121
  const reasons = [];
81926
82122
  if (intentReason)
81927
82123
  reasons.push(intentReason);
81928
82124
  if (verifiedWithoutAssert) {
81929
82125
  reasons.push("claimed verified:true but never called mobile_assert_visible / mobile_assert_text");
81930
82126
  }
82127
+ if (strengthRejectReason !== null) {
82128
+ 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.`);
82129
+ }
81931
82130
  const reasonText = reasons.join("; ");
81932
- log2(` finish_generate REJECTED (${finishIntentRejections}/${MAX_FINISH_INTENT_REJECTIONS}): ${reasonText}`);
82131
+ log2(` finish_generate REJECTED (intent ${finishIntentRejections}/${MAX_FINISH_INTENT_REJECTIONS}, strength ${finishStrengthRejections}/1): ${reasonText}`);
81933
82132
  pushResult({
81934
82133
  ok: false,
81935
82134
  error: `Cannot finish yet \u2014 the recorded steps do not complete this scenario: ${reasonText}. Continue driving the app (fill fields, commit forms, navigate, assert the REAL outcome), then call finish_generate again. Do NOT re-open menus or thrash Back.`,
81936
82135
  intentMismatch: intentReason,
81937
82136
  verifiedWithoutAssert,
81938
- rejection: finishIntentRejections,
81939
- maxRejections: MAX_FINISH_INTENT_REJECTIONS
82137
+ ...strengthRejectReason !== null ? { strengthBlock: strengthRejectReason } : {},
82138
+ rejection: finishIntentRejections + finishStrengthRejections,
82139
+ maxRejections: MAX_FINISH_INTENT_REJECTIONS + 1
81940
82140
  });
81941
82141
  continue;
81942
82142
  }
81943
82143
  finish = {
81944
82144
  name: redactCredentialValues(nonEmpty3(String(toolArgs.name ?? "")) ?? scenario.name, redactionCredentials),
81945
82145
  description: redactCredentialValues(nonEmpty3(String(toolArgs.description ?? "")) ?? scenario.goal, redactionCredentials),
81946
- verified: claimedVerified
82146
+ verified: claimedVerified,
82147
+ ...toolArgs.suspectedBug === true ? {
82148
+ suspectedBug: true,
82149
+ bugDescription: redactCredentialValues(nonEmpty3(String(toolArgs.bugDescription ?? "")) ?? "", redactionCredentials)
82150
+ } : {}
81947
82151
  };
81948
82152
  log2(` finish_generate: "${finish.name}" (claimed verified: ${finish.verified}, asserts: ${assertCount})`);
81949
82153
  pushResult({ acknowledged: true });
@@ -82007,6 +82211,7 @@ ${statePacket}`, redactionCredentials) },
82007
82211
  ok: false,
82008
82212
  error: `Unknown element ref "${String(ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.`
82009
82213
  });
82214
+ const recordedTypeValues = () => recordedActions.filter((action) => action.type === "TYPE" && typeof action.value === "string" && action.value.length > 0).map((action) => action.value);
82010
82215
  const actionPayload = (result, absorbed) => {
82011
82216
  const blocked = Boolean(absorbed.externalBlocked || absorbed.transientExternal);
82012
82217
  return {
@@ -82407,7 +82612,8 @@ ${statePacket}`, redactionCredentials) },
82407
82612
  latest,
82408
82613
  screenName,
82409
82614
  platform: platform3,
82410
- recordAction
82615
+ recordAction,
82616
+ typedValues: recordedTypeValues
82411
82617
  });
82412
82618
  if (!recorded.ok)
82413
82619
  return recorded;
@@ -82476,6 +82682,15 @@ ${statePacket}`, redactionCredentials) },
82476
82682
  error: `Live text assertion failed: expected ${JSON.stringify(expected)} was not present on ref ${element.ref}. Re-snapshot after the outcome appears and assert the text that is actually visible.`
82477
82683
  };
82478
82684
  }
82685
+ const volatileExpected = (0, shared_1.classifyMobileAssertValue)(expected, {
82686
+ knownUserInputs: recordedTypeValues()
82687
+ });
82688
+ if (volatileExpected) {
82689
+ return {
82690
+ ok: false,
82691
+ 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.`
82692
+ };
82693
+ }
82479
82694
  const metadata = metadataForElement(element, screenName(), platform3);
82480
82695
  const expectedNeedle = expected.trim().toLowerCase();
82481
82696
  const identityValues = [element.accessibilityId, element.resourceId].map((value) => value?.trim().toLowerCase()).filter((value) => Boolean(value));
@@ -82531,6 +82746,17 @@ ${statePacket}`, redactionCredentials) },
82531
82746
  if (!accessibilityId && !text) {
82532
82747
  return { ok: false, error: "mobile_assert_visible needs a ref (preferred), accessibilityId, or text." };
82533
82748
  }
82749
+ if (!accessibilityId && text) {
82750
+ const volatileIdentity = (0, shared_1.classifyMobileAssertValue)(text, {
82751
+ knownUserInputs: ctx.typedValues?.() ?? []
82752
+ });
82753
+ if (volatileIdentity) {
82754
+ return {
82755
+ ok: false,
82756
+ 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).`
82757
+ };
82758
+ }
82759
+ }
82534
82760
  const directTarget = accessibilityId ?? text;
82535
82761
  if (directTarget && isStructuralAssertionIdentity(directTarget)) {
82536
82762
  return {
@@ -83166,7 +83392,21 @@ ${statePacket}`, redactionCredentials) },
83166
83392
  if (claimedVerified && !verified) {
83167
83393
  log2(" downgraded verified -> false: model claimed verified but called zero assert tools.");
83168
83394
  }
83169
- const strengthVerdict = evaluateMobileAssertionStrength(actions);
83395
+ const baseStrengthVerdict = evaluateMobileAssertionStrength(actions);
83396
+ const bugDescription = recording.finish?.bugDescription?.trim() ?? "";
83397
+ const bugWitnessHonored = process.env.DISCOVERY_BUG_WITNESS_ENABLED === "true" && recording.finish?.suspectedBug === true && bugDescription.length >= 12 && recording.assertCount > 0;
83398
+ if (recording.finish?.suspectedBug === true && !bugWitnessHonored) {
83399
+ 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."));
83400
+ }
83401
+ const strengthVerdict = bugWitnessHonored ? {
83402
+ ...baseStrengthVerdict,
83403
+ ok: false,
83404
+ severity: "route-bug",
83405
+ reason: `Model declared an app bug during generation: ${bugDescription}`
83406
+ } : baseStrengthVerdict;
83407
+ if (bugWitnessHonored) {
83408
+ log2(` bug-witness honored \u2014 routing as app bug: ${bugDescription}`);
83409
+ }
83170
83410
  if (strengthVerdict.severity === "block" || strengthVerdict.severity === "route-bug") {
83171
83411
  if (verified) {
83172
83412
  log2(` downgraded verified -> false: assertion strength ${strengthVerdict.severity} \u2014 ${strengthVerdict.reason}`);
@@ -83182,7 +83422,11 @@ ${statePacket}`, redactionCredentials) },
83182
83422
  verified = false;
83183
83423
  }
83184
83424
  const platformTag2 = platform3 === "IOS" ? "@ios" : "@android";
83185
- const reviewTag = verified ? "@verified" : "@needs-review";
83425
+ const selectorLintEnforced = (0, discovery_lint_flags_js_1.mobileSelectorQualityLintMode)() === "enforce" && selectorQuality.summary.high > 0;
83426
+ if (selectorLintEnforced && verified) {
83427
+ log2(` selector-quality lint (enforce): ${selectorQuality.summary.high} high-severity locator issue(s) \u2014 tagging @needs-review.`);
83428
+ }
83429
+ const reviewTag = verified && !selectorLintEnforced ? "@verified" : "@needs-review";
83186
83430
  const tags = ["@discovered", "@mobile", platformTag2, reviewTag];
83187
83431
  if (strengthVerdict.severity === "downgrade")
83188
83432
  tags.push("@smoke");
@@ -86262,38 +86506,6 @@ var require_run_callback_proxy = __commonJS({
86262
86506
  }
86263
86507
  });
86264
86508
 
86265
- // ../runner-core/dist/services/discovery-lint-flags.js
86266
- var require_discovery_lint_flags = __commonJS({
86267
- "../runner-core/dist/services/discovery-lint-flags.js"(exports) {
86268
- "use strict";
86269
- Object.defineProperty(exports, "__esModule", { value: true });
86270
- exports.structuralLocatorLintMode = structuralLocatorLintMode;
86271
- exports.isPwMethodPreflightEnabled = isPwMethodPreflightEnabled;
86272
- exports.isVqaLoginDedupeEnabled = isVqaLoginDedupeEnabled;
86273
- exports.isTestgenEmailVerifyEnabled = isTestgenEmailVerifyEnabled;
86274
- function strictOn(value) {
86275
- return value === "true" || value === "1";
86276
- }
86277
- function structuralLocatorLintMode() {
86278
- const v = (process.env.DISCOVERY_STRUCTURAL_LOCATOR_LINT || "").trim().toLowerCase();
86279
- if (v === "warn")
86280
- return "warn";
86281
- if (v === "true" || v === "1" || v === "enforce")
86282
- return "enforce";
86283
- return "off";
86284
- }
86285
- function isPwMethodPreflightEnabled() {
86286
- return strictOn(process.env.DISCOVERY_PW_METHOD_PREFLIGHT);
86287
- }
86288
- function isVqaLoginDedupeEnabled() {
86289
- return strictOn(process.env.DISCOVERY_VQALOGIN_DEDUPE);
86290
- }
86291
- function isTestgenEmailVerifyEnabled() {
86292
- return strictOn(process.env.DISCOVERY_TESTGEN_EMAIL_VERIFY);
86293
- }
86294
- }
86295
- });
86296
-
86297
86509
  // ../runner-core/dist/services/auth-recipe.js
86298
86510
  var require_auth_recipe = __commonJS({
86299
86511
  "../runner-core/dist/services/auth-recipe.js"(exports) {
@@ -92154,6 +92366,15 @@ var require_agentic_discovery_prompts = __commonJS({
92154
92366
  lines.push(input.appBrief.trim());
92155
92367
  lines.push("");
92156
92368
  }
92369
+ if (input.knownFactsBlock && input.knownFactsBlock.trim()) {
92370
+ lines.push("## Known facts about this feature (from project documentation)");
92371
+ lines.push("");
92372
+ lines.push("Advisory hints about routes/data/constraints \u2014 verify against the live");
92373
+ lines.push("app; the browser is the source of truth for locators.");
92374
+ lines.push("");
92375
+ lines.push(input.knownFactsBlock.trim());
92376
+ lines.push("");
92377
+ }
92157
92378
  if (input.credentials && input.credentials.length > 0) {
92158
92379
  lines.push("## Test credentials (throwaway accounts)");
92159
92380
  lines.push("");
@@ -328821,6 +329042,15 @@ var require_agentic_survey_prompts = __commonJS({
328821
329042
  lines.push(input.appBrief.trim());
328822
329043
  lines.push("");
328823
329044
  }
329045
+ if (input.knownFactsBlock && input.knownFactsBlock.trim().length > 0) {
329046
+ lines.push("## Known facts about this app (from project documentation)");
329047
+ lines.push("");
329048
+ lines.push("Use these as hints for what features/routes/constraints exist. They are");
329049
+ lines.push("advisory \u2014 verify against the live app; the browser is the source of truth.");
329050
+ lines.push("");
329051
+ lines.push(input.knownFactsBlock.trim());
329052
+ lines.push("");
329053
+ }
328824
329054
  if (input.credentials && input.credentials.length > 0) {
328825
329055
  lines.push("## Test credentials (throwaway accounts)");
328826
329056
  lines.push("");
@@ -329448,6 +329678,7 @@ var require_grok_survey_runner = __commonJS({
329448
329678
  const briefInput = {
329449
329679
  baseUrl: ctx.baseUrl,
329450
329680
  appBrief: ctx.appBrief,
329681
+ knownFactsBlock: ctx.knowledgeContext,
329451
329682
  credentials: credentials.length > 0 ? credentials : void 0,
329452
329683
  existingFeatureNames: ctx.existingFeatureNames ?? [],
329453
329684
  focusAreas: ctx.focusAreas,
@@ -331143,6 +331374,7 @@ var require_grok_discovery_runner = __commonJS({
331143
331374
  featureObservations: ctx.featureContext?.flowIntel,
331144
331375
  notesForTestAuthor: ctx.featureContext?.priorInsight,
331145
331376
  appBrief: ctx.appBrief,
331377
+ knownFactsBlock: ctx.knowledgeContext,
331146
331378
  credentials: credentials.length > 0 ? credentials : void 0,
331147
331379
  testVariableKeys: testVariableKeys.length > 0 ? testVariableKeys : void 0,
331148
331380
  existingTestNames: ctx.existingTestNames ?? [],
@@ -332080,6 +332312,7 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
332080
332312
  featureObservations: ctx.featureContext?.flowIntel,
332081
332313
  notesForTestAuthor: ctx.featureContext?.priorInsight,
332082
332314
  appBrief: ctx.appBrief,
332315
+ knownFactsBlock: ctx.knowledgeContext,
332083
332316
  credentials: credentials.length > 0 ? credentials : void 0,
332084
332317
  testVariableKeys: testVariableKeys.length > 0 ? testVariableKeys : void 0,
332085
332318
  existingTestNames: ctx.existingTestNames ?? [],
@@ -333171,6 +333404,7 @@ ${preview}`);
333171
333404
  const briefInput = {
333172
333405
  baseUrl: ctx.baseUrl,
333173
333406
  appBrief: ctx.appBrief,
333407
+ knownFactsBlock: ctx.knowledgeContext,
333174
333408
  credentials: credentials.length > 0 ? credentials : void 0,
333175
333409
  existingFeatureNames: ctx.existingFeatureNames ?? [],
333176
333410
  focusAreas: ctx.focusAreas,
@@ -334030,6 +334264,7 @@ var require_claude_discovery_runner = __commonJS({
334030
334264
  featureObservations: ctx.featureContext?.flowIntel,
334031
334265
  notesForTestAuthor: ctx.featureContext?.priorInsight,
334032
334266
  appBrief: ctx.appBrief,
334267
+ knownFactsBlock: ctx.knowledgeContext,
334033
334268
  credentials: credentials.length > 0 ? credentials : void 0,
334034
334269
  testVariableKeys: testVariableKeys.length > 0 ? testVariableKeys : void 0,
334035
334270
  existingTestNames: ctx.existingTestNames ?? [],
@@ -335884,7 +336119,8 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
335884
336119
  // scenarioTouchesSignupAuthSurface) still isolate non-auth scenarios. The
335885
336120
  // auth-feature scope IS applied at the DEEP call site where ctx.featureName is known.
335886
336121
  emailVerifyArmed: mode !== "survey" && plannerEmailVerifyArmed(ctx),
335887
- testBudget
336122
+ testBudget,
336123
+ knowledgeContext: ctx.knowledgeContext
335888
336124
  }, {
335889
336125
  modelOverride: ctx.planModel ?? ctx.generateModel,
335890
336126
  gateModelOverride: ctx.planGateModel,
@@ -335996,6 +336232,7 @@ Health: ${surveyHealth.totalIssues} issue(s) - ${surveyHealth.consoleErrors.leng
335996
336232
  interactionLog: exploreResult.interactionLog,
335997
336233
  modelOverride: ctx.generateModel,
335998
336234
  appBrief: ctx.appBrief,
336235
+ knowledgeContext: ctx.knowledgeContext,
335999
336236
  onAICall,
336000
336237
  onProgress,
336001
336238
  mcpClient: mcpClient ?? void 0,
@@ -336475,7 +336712,8 @@ Plan (reused): ${resumeSavedScenarios.length} scenarios from the paused run.`);
336475
336712
  // configured inbox), so the planner never promises what generation can't do.
336476
336713
  // Scoped to AUTH features: a non-auth feature has no signup surface to test.
336477
336714
  emailVerifyArmed: plannerEmailVerifyArmed(ctx) && featureLooksLikeAuth(ctx.featureName, augmentedFeatureFocusAreas),
336478
- testBudget: featureTestBudget
336715
+ testBudget: featureTestBudget,
336716
+ knowledgeContext: ctx.knowledgeContext
336479
336717
  }, {
336480
336718
  modelOverride: ctx.planModel ?? ctx.generateModel,
336481
336719
  gateModelOverride: ctx.planGateModel,
@@ -336521,6 +336759,7 @@ Plan: ${planResult.scenarios.length} scenarios (budget: ${featureTestBudget})`);
336521
336759
  interactionLog: exploreResult.interactionLog,
336522
336760
  modelOverride: ctx.generateModel,
336523
336761
  appBrief: ctx.appBrief,
336762
+ knowledgeContext: ctx.knowledgeContext,
336524
336763
  onAICall,
336525
336764
  mcpClient: mcpClient ?? void 0,
336526
336765
  // Pooled browser for parallel scenario generation (Fargate opt-in via
@@ -347734,6 +347973,12 @@ Write valid JSON only (no markdown fences inside files). Be exhaustive.`;
347734
347973
  "Use {{TEST_USER_EMAIL}}/{{TEST_USER_PASSWORD}} for credentials. Include",
347735
347974
  "assertVisible or assertText unless tagging @needs-review. When an incremental",
347736
347975
  "allow-list is present, extract ONLY from those files.",
347976
+ "",
347977
+ "Every tap/type/clear/waitForElement/assertVisible/assertText target MUST",
347978
+ "carry an IDENTITY locator: ~accessibility-id, id=resource-id, visible text,",
347979
+ "or an attribute-EQUALITY xpath (//android.widget.Button[@text='Submit']).",
347980
+ "Positional/index-only xpaths (/hierarchy/\u2026/View[3]) are rejected \u2014 imported",
347981
+ "steps have no recorded coordinates, so such a step can never replay.",
347737
347982
  "Write strictly valid JSON."
347738
347983
  ].join("\n");
347739
347984
  }
@@ -347751,6 +347996,32 @@ Write valid JSON only (no markdown fences inside files). Be exhaustive.`;
347751
347996
  function isPlainObject2(value) {
347752
347997
  return !!value && typeof value === "object" && !Array.isArray(value);
347753
347998
  }
347999
+ var LOCATOR_REQUIRING_IMPORT_ACTIONS = /* @__PURE__ */ new Set([
348000
+ "tap",
348001
+ "type",
348002
+ "clear",
348003
+ "waitForElement",
348004
+ "assertVisible",
348005
+ "assertText"
348006
+ ]);
348007
+ function selectorValueHasIdentity(value) {
348008
+ const trimmed = value.trim();
348009
+ if (!trimmed)
348010
+ return false;
348011
+ if (trimmed.startsWith("/") || trimmed.startsWith("(//"))
348012
+ return (0, shared_1.isIdentityMobileXpath)(trimmed);
348013
+ return true;
348014
+ }
348015
+ function stepLocatorLacksIdentity(step) {
348016
+ if (!LOCATOR_REQUIRING_IMPORT_ACTIONS.has(step.action))
348017
+ return false;
348018
+ const target = step.target;
348019
+ if (!target)
348020
+ return true;
348021
+ if ((0, shared_1.mobileLocatorBundleHasIdentity)(target.locatorBundle))
348022
+ return false;
348023
+ return ![target.primary, ...target.fallbacks ?? []].some((value) => typeof value === "string" && selectorValueHasIdentity(value));
348024
+ }
347754
348025
  function isValidStep(value) {
347755
348026
  if (!value || typeof value !== "object" || Array.isArray(value))
347756
348027
  return false;
@@ -347792,12 +348063,7 @@ Write valid JSON only (no markdown fences inside files). Be exhaustive.`;
347792
348063
  const o = value;
347793
348064
  if (!isNonEmptyString(o.name))
347794
348065
  return { ok: false, reason: "name: missing" };
347795
- if (!isNonEmptyString(o.appiumCode))
347796
- return { ok: false, reason: "appiumCode: missing" };
347797
- const code = o.appiumCode;
347798
- if (!/webdriverio|remote\s*\(|driver\s*\./i.test(code)) {
347799
- return { ok: false, reason: "appiumCode: not a WebdriverIO/Appium script" };
347800
- }
348066
+ const code = isNonEmptyString(o.appiumCode) ? o.appiumCode : "";
347801
348067
  if (!Array.isArray(o.steps) || o.steps.length === 0) {
347802
348068
  return { ok: false, reason: "steps: missing or empty" };
347803
348069
  }
@@ -347807,6 +348073,13 @@ Write valid JSON only (no markdown fences inside files). Be exhaustive.`;
347807
348073
  if (!o.steps.every(isValidStep)) {
347808
348074
  return { ok: false, reason: "steps: invalid step shape" };
347809
348075
  }
348076
+ const identityless = o.steps.find(stepLocatorLacksIdentity);
348077
+ if (identityless) {
348078
+ return {
348079
+ ok: false,
348080
+ 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'].`
348081
+ };
348082
+ }
347810
348083
  const steps = o.steps.map((step, order) => ({
347811
348084
  ...step,
347812
348085
  order,
@@ -351100,7 +351373,7 @@ var require_package4 = __commonJS({
351100
351373
  "package.json"(exports, module) {
351101
351374
  module.exports = {
351102
351375
  name: "@validate.qa/runner",
351103
- version: "1.0.66",
351376
+ version: "1.0.68",
351104
351377
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
351105
351378
  bin: {
351106
351379
  "validate-runner": "dist/cli.js"
@@ -351496,6 +351769,9 @@ function sanitizeAppiumLogLine(line) {
351496
351769
  }
351497
351770
  function setAppiumWorkloadActive(active) {
351498
351771
  activeWorkloadCount = active ? activeWorkloadCount + 1 : Math.max(0, activeWorkloadCount - 1);
351772
+ if (activeWorkloadCount === 0) {
351773
+ consecutiveHealthFailures = 0;
351774
+ }
351499
351775
  }
351500
351776
  function shouldRestartAfterHealthFailure(workloadCount, failureCount) {
351501
351777
  return workloadCount <= 0 && failureCount >= HEALTH_FAILURES_BEFORE_RESTART;
@@ -351991,17 +352267,31 @@ function rectMatchesIntendedBounds(rect, bounds) {
351991
352267
  return rectCenterInBounds || boundsCenterInRect;
351992
352268
  }
351993
352269
  async function resolveTypeTargetElementId(sessionPath, bounds) {
351994
- const box = bounds !== void 0 ? normaliseBounds(bounds) : null;
352270
+ const rawBox = bounds !== void 0 ? normaliseBounds(bounds) : null;
352271
+ const box = rawBox && rawBox.width > 0 && rawBox.height > 0 ? rawBox : null;
351995
352272
  let elementId = await getActiveElementId(sessionPath);
351996
- if (elementId && box && box.width > 0 && box.height > 0) {
352273
+ let rejectedElementId = null;
352274
+ let rejectedForMismatch = false;
352275
+ if (elementId && box) {
351997
352276
  const rect = await getElementRect(sessionPath, elementId);
351998
352277
  if (!rect || !rectMatchesIntendedBounds(rect, box)) {
352278
+ rejectedElementId = elementId;
352279
+ rejectedForMismatch = !!rect;
351999
352280
  elementId = null;
352000
352281
  }
352001
352282
  }
352002
352283
  if (!elementId && bounds !== void 0 && await tapBoundsCenter(sessionPath, bounds)) {
352003
352284
  await new Promise((resolve2) => setTimeout(resolve2, MOBILE_FOCUS_SETTLE_MS));
352004
352285
  elementId = await getActiveElementId(sessionPath);
352286
+ if (elementId && box) {
352287
+ const rect = await getElementRect(sessionPath, elementId);
352288
+ const provablyWrong = rect ? !rectMatchesIntendedBounds(rect, box) : rejectedForMismatch && elementId === rejectedElementId;
352289
+ if (provablyWrong) {
352290
+ throw new Error(
352291
+ "Could not focus the intended field: a different element kept keyboard focus after tapping the target bounds. Tap the field directly, then retry."
352292
+ );
352293
+ }
352294
+ }
352005
352295
  }
352006
352296
  return elementId;
352007
352297
  }
@@ -354554,7 +354844,7 @@ function boundsForRun(step, platform3, isCrossPlatform) {
354554
354844
  if (platform3 && hasPlatformLocator(step.target, platform3)) return null;
354555
354845
  return boundsFromStep(step);
354556
354846
  }
354557
- function locatorStrategyForValue(value) {
354847
+ function locatorStrategyForValue(value, platform3) {
354558
354848
  const trimmed = value?.trim();
354559
354849
  if (!trimmed) {
354560
354850
  return [];
@@ -354570,7 +354860,8 @@ function locatorStrategyForValue(value) {
354570
354860
  }
354571
354861
  return [
354572
354862
  { using: "accessibility id", value: trimmed },
354573
- { using: "id", value: trimmed }
354863
+ { using: "id", value: trimmed },
354864
+ ...textIdentityStrategies(trimmed, platform3)
354574
354865
  ];
354575
354866
  }
354576
354867
  function buildLocatorStrategies(target, platform3) {
@@ -354580,12 +354871,19 @@ function buildLocatorStrategies(target, platform3) {
354580
354871
  if (bundle) {
354581
354872
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
354582
354873
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
354583
- if (bundle.xpath) strategies.push({ using: "xpath", value: bundle.xpath });
354874
+ if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
354875
+ for (const value of [
354876
+ isEditableMobileClassName(bundle.className) || isStateValueMobileClassName(bundle.className) ? void 0 : bundle.text,
354877
+ bundle.contentDesc
354878
+ ]) {
354879
+ const trimmed = value?.trim();
354880
+ if (trimmed) strategies.push(...textIdentityStrategies(trimmed, platform3));
354881
+ }
354584
354882
  }
354585
354883
  if (!(platform3 && hasPlatformLocator(target, platform3))) {
354586
- strategies.push(...locatorStrategyForValue(target.primary));
354884
+ strategies.push(...locatorStrategyForValue(target.primary, platform3));
354587
354885
  for (const fallback of target.fallbacks ?? []) {
354588
- strategies.push(...locatorStrategyForValue(fallback));
354886
+ strategies.push(...locatorStrategyForValue(fallback, platform3));
354589
354887
  }
354590
354888
  }
354591
354889
  return strategies.filter((strategy) => strategy.using !== "xpath" || isIdentityXpath(strategy.value)).filter(
@@ -354597,22 +354895,41 @@ function dedupeStrategies(strategies) {
354597
354895
  (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
354598
354896
  );
354599
354897
  }
354600
- function xpathLiteral2(value) {
354898
+ function xpathLiteral3(value) {
354601
354899
  if (!value.includes("'")) return `'${value}'`;
354602
354900
  if (!value.includes('"')) return `"${value}"`;
354603
354901
  const parts = value.split("'").map((p) => `'${p}'`);
354604
354902
  return `concat(${parts.join(`, "'", `)})`;
354605
354903
  }
354606
- function identityTextXpath(value) {
354607
- const lit = xpathLiteral2(value);
354904
+ function identityTextXpath2(value) {
354905
+ const lit = xpathLiteral3(value);
354608
354906
  return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
354609
354907
  }
354908
+ function iosPredicateLiteral(value) {
354909
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
354910
+ }
354911
+ function iosTextIdentityPredicate(value) {
354912
+ const lit = iosPredicateLiteral(value);
354913
+ return `label == ${lit} OR name == ${lit} OR value == ${lit}`;
354914
+ }
354915
+ function textIdentityStrategies(value, platform3) {
354916
+ const strategies = [];
354917
+ if (platform3 === "IOS") {
354918
+ strategies.push({ using: "-ios predicate string", value: iosTextIdentityPredicate(value) });
354919
+ }
354920
+ strategies.push({ using: "xpath", value: identityTextXpath2(value) });
354921
+ return strategies;
354922
+ }
354923
+ function isIdentityIosPredicate(value) {
354924
+ return /(?:^|[\s(])(?:name|label|value)\s*==\s*"/.test(value) && !/\b(?:CONTAINS|BEGINSWITH|ENDSWITH|LIKE|MATCHES|IN)\b/i.test(value);
354925
+ }
354610
354926
  function isIdentityXpath(value) {
354611
354927
  return /@[A-Za-z_:][-A-Za-z0-9_:.]*\s*=/.test(value) && !/(?:contains|starts-with)\s*\(/i.test(value);
354612
354928
  }
354613
354929
  function isIdentityStrategy(strategy) {
354614
354930
  if (strategy.using === "accessibility id" || strategy.using === "id") return true;
354615
354931
  if (strategy.using === "xpath") return isIdentityXpath(strategy.value);
354932
+ if (strategy.using === "-ios predicate string") return isIdentityIosPredicate(strategy.value);
354616
354933
  return false;
354617
354934
  }
354618
354935
  function buildIdentityLocatorStrategies(target, platform3) {
@@ -354622,15 +354939,18 @@ function buildIdentityLocatorStrategies(target, platform3) {
354622
354939
  if (bundle) {
354623
354940
  if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
354624
354941
  if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
354625
- for (const value of [bundle.text, bundle.contentDesc]) {
354942
+ if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
354943
+ for (const value of [
354944
+ isStateValueMobileClassName(bundle.className) ? void 0 : bundle.text,
354945
+ bundle.contentDesc
354946
+ ]) {
354626
354947
  const trimmed = value?.trim();
354627
- if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
354948
+ if (trimmed) strategies.push(...textIdentityStrategies(trimmed, platform3));
354628
354949
  }
354629
- if (bundle.xpath && isIdentityXpath(bundle.xpath)) strategies.push({ using: "xpath", value: bundle.xpath });
354630
354950
  }
354631
354951
  if (!(platform3 && hasPlatformLocator(target, platform3))) {
354632
354952
  for (const value of [target.primary, ...target.fallbacks ?? []]) {
354633
- for (const strategy of locatorStrategyForValue(value)) {
354953
+ for (const strategy of locatorStrategyForValue(value, platform3)) {
354634
354954
  if (isIdentityStrategy(strategy)) strategies.push(strategy);
354635
354955
  }
354636
354956
  }
@@ -355104,6 +355424,19 @@ async function readElementTextCandidates(sessionId, elementId, actualText) {
355104
355424
  }
355105
355425
  return values;
355106
355426
  }
355427
+ var controlPortSlotsByDevice = /* @__PURE__ */ new Map();
355428
+ function acquireControlPortSlot(deviceId) {
355429
+ const existing = controlPortSlotsByDevice.get(deviceId);
355430
+ if (existing !== void 0) return existing;
355431
+ const taken = new Set(controlPortSlotsByDevice.values());
355432
+ let slot = 0;
355433
+ while (taken.has(slot)) slot += 1;
355434
+ controlPortSlotsByDevice.set(deviceId, slot);
355435
+ return slot;
355436
+ }
355437
+ function releaseControlPortSlot(deviceId) {
355438
+ controlPortSlotsByDevice.delete(deviceId);
355439
+ }
355107
355440
  async function executeMobileTest(options) {
355108
355441
  const logLines = [];
355109
355442
  const log2 = (msg) => logLines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`);
@@ -355123,6 +355456,7 @@ async function executeMobileTest(options) {
355123
355456
  let captureStopped = false;
355124
355457
  let recordingStarted = false;
355125
355458
  let recordingStopped = false;
355459
+ let acquiredSlotDeviceId = null;
355126
355460
  let video;
355127
355461
  let failingSource;
355128
355462
  const stopCapture = async () => {
@@ -355225,7 +355559,8 @@ async function executeMobileTest(options) {
355225
355559
  log2(`Warning: Could not retrieve installed apps list. Proceeding with test execution.`);
355226
355560
  }
355227
355561
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
355228
- const leasedPortOffset = Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id));
355562
+ const leasedPortOffset = acquireControlPortSlot(selectedDevice.id);
355563
+ acquiredSlotDeviceId = selectedDevice.id;
355229
355564
  try {
355230
355565
  sessionId = await createSessionWithRetry({
355231
355566
  appId: options.target.appId,
@@ -355502,6 +355837,7 @@ async function executeMobileTest(options) {
355502
355837
  await deleteSession(sessionId);
355503
355838
  log2("Session destroyed");
355504
355839
  }
355840
+ if (acquiredSlotDeviceId) releaseControlPortSlot(acquiredSlotDeviceId);
355505
355841
  }
355506
355842
  }
355507
355843
  async function createMobileDiscoverySession(target, options) {
@@ -355547,21 +355883,28 @@ async function createMobileDiscoverySession(target, options) {
355547
355883
  isPhysical: selectedDevice.isPhysical === true,
355548
355884
  platformVersion: selectedDevice.platformVersion
355549
355885
  });
355550
- const sessionId = await createSessionWithRetry({
355551
- appId: target.appId,
355552
- platform: target.platform,
355553
- deviceId: selectedDevice.id,
355554
- platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
355555
- isPhysical: selectedDevice.isPhysical,
355556
- // Distinct control resources per concurrent device, including simulators.
355557
- portOffset: Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id))
355558
- });
355886
+ const discoveryPortSlotDeviceId = selectedDevice.id;
355887
+ let sessionId;
355888
+ try {
355889
+ sessionId = await createSessionWithRetry({
355890
+ appId: target.appId,
355891
+ platform: target.platform,
355892
+ deviceId: selectedDevice.id,
355893
+ platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
355894
+ isPhysical: selectedDevice.isPhysical,
355895
+ portOffset: acquireControlPortSlot(discoveryPortSlotDeviceId)
355896
+ });
355897
+ } catch (err) {
355898
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355899
+ throw err;
355900
+ }
355559
355901
  if (target.launchMode === "DEEP_LINK" && target.deeplink) {
355560
355902
  try {
355561
355903
  await openDeepLink(sessionId, target.platform, target.deeplink, target.appId);
355562
355904
  } catch (err) {
355563
355905
  await deleteSession(sessionId).catch(() => {
355564
355906
  });
355907
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355565
355908
  throw new Error(
355566
355909
  `Deep-link launch failed for mobile discovery: ${err instanceof Error ? err.message : String(err)}`
355567
355910
  );
@@ -355583,6 +355926,7 @@ async function createMobileDiscoverySession(target, options) {
355583
355926
  } catch {
355584
355927
  }
355585
355928
  await deleteSession(sessionId);
355929
+ releaseControlPortSlot(discoveryPortSlotDeviceId);
355586
355930
  }
355587
355931
  };
355588
355932
  }
@@ -355600,26 +355944,26 @@ async function readElementAttribute(sessionId, elementId, name) {
355600
355944
  }
355601
355945
  function harvestFallbackXpath(platform3, attrs) {
355602
355946
  if (platform3 === "IOS") {
355603
- if (attrs.name) return `//*[@name=${xpathLiteral2(attrs.name)}]`;
355604
- const text = attrs.label ?? attrs.value;
355605
- if (text) return identityTextXpath(text);
355947
+ if (attrs.name) return `//*[@name=${xpathLiteral3(attrs.name)}]`;
355948
+ if (attrs.text) return identityTextXpath2(attrs.text);
355606
355949
  return "//*";
355607
355950
  }
355608
- if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral2(attrs.resourceId)}]`;
355609
- if (attrs.text) return identityTextXpath(attrs.text);
355610
- if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral2(attrs.contentDesc)}]`;
355951
+ if (attrs.resourceId) return `//*[@resource-id=${xpathLiteral3(attrs.resourceId)}]`;
355952
+ if (attrs.text) return identityTextXpath2(attrs.text);
355953
+ if (attrs.contentDesc) return `//*[@content-desc=${xpathLiteral3(attrs.contentDesc)}]`;
355611
355954
  return "//*";
355612
355955
  }
355613
355956
  async function readNativeElementBundle(sessionId, elementId, platform3) {
355614
355957
  const className = await readElementAttribute(sessionId, elementId, platform3 === "IOS" ? "type" : "class") ?? "";
355958
+ const volatileText = isEditableMobileClassName(className) || isStateValueMobileClassName(className);
355615
355959
  if (platform3 === "IOS") {
355616
355960
  const name = await readElementAttribute(sessionId, elementId, "name");
355617
355961
  const label = await readElementAttribute(sessionId, elementId, "label");
355618
355962
  const value = await readElementAttribute(sessionId, elementId, "value");
355619
- const text2 = label ?? value;
355963
+ const text2 = label ?? (volatileText ? void 0 : value);
355620
355964
  if (!name && !text2) return null;
355621
355965
  const bundle2 = {
355622
- xpath: harvestFallbackXpath("IOS", { name, label, value }),
355966
+ xpath: harvestFallbackXpath("IOS", { name, text: text2 }),
355623
355967
  className
355624
355968
  };
355625
355969
  if (name) bundle2.accessibilityId = name;
@@ -355628,7 +355972,8 @@ async function readNativeElementBundle(sessionId, elementId, platform3) {
355628
355972
  }
355629
355973
  const resourceId = await readElementAttribute(sessionId, elementId, "resource-id");
355630
355974
  const contentDesc = await readElementAttribute(sessionId, elementId, "content-desc") ?? await readElementAttribute(sessionId, elementId, "contentDescription");
355631
- const text = await readElementAttribute(sessionId, elementId, "text");
355975
+ const rawText = await readElementAttribute(sessionId, elementId, "text");
355976
+ const text = volatileText ? void 0 : rawText;
355632
355977
  if (!resourceId && !contentDesc && !text) return null;
355633
355978
  const bundle = {
355634
355979
  xpath: harvestFallbackXpath("ANDROID", { text, contentDesc, resourceId }),
@@ -355960,6 +356305,7 @@ var sessionStartInFlight = null;
355960
356305
  var sessionReaperTimer = null;
355961
356306
  var mirrorClientsEmptyAt = null;
355962
356307
  var mirrorSessionOwner = null;
356308
+ var actionInFlight = false;
355963
356309
  var recordingWorkloadActive = false;
355964
356310
  function setRecordingWorkloadActive(active) {
355965
356311
  if (recordingWorkloadActive === active) return;
@@ -356189,7 +356535,11 @@ async function handleHealth(_req, res) {
356189
356535
  // 409 — it must never be inferred from server-side global run counts, which
356190
356536
  // include unrelated projects and runners.
356191
356537
  executorBusy: executorBusyCount > 0,
356192
- recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording"
356538
+ // TRUE also during the multi-second session-START window: the claim loop
356539
+ // reads this to keep test runs off the recording device, and the Appium
356540
+ // session id only lands AFTER `POST /session` returns — without the
356541
+ // in-flight latch a run admitted in that window fights the recording.
356542
+ recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording" || sessionStartInFlight !== null
356193
356543
  });
356194
356544
  }
356195
356545
  function realDeviceCapabilities2(platform3) {
@@ -356267,7 +356617,11 @@ async function handleDevices(_req, res) {
356267
356617
  sendJSON(res, 200, {
356268
356618
  devices,
356269
356619
  executorBusy: executorBusyCount > 0,
356270
- recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording"
356620
+ // TRUE also during the multi-second session-START window: the claim loop
356621
+ // reads this to keep test runs off the recording device, and the Appium
356622
+ // session id only lands AFTER `POST /session` returns — without the
356623
+ // in-flight latch a run admitted in that window fights the recording.
356624
+ recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording" || sessionStartInFlight !== null
356271
356625
  });
356272
356626
  }
356273
356627
  async function handleSessionStart(req, res) {
@@ -356356,6 +356710,8 @@ async function startSession(validated, res) {
356356
356710
  state.platform = targetPlatform === "IOS" ? "IOS" : "ANDROID";
356357
356711
  mirrorSessionOwner = "recording";
356358
356712
  setRecordingWorkloadActive(true);
356713
+ mirrorClientsEmptyAt = Date.now();
356714
+ ensureSessionReaper();
356359
356715
  if (launchMode === "DEEP_LINK" && deeplink) {
356360
356716
  try {
356361
356717
  const args = targetPlatform === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
@@ -356709,6 +357065,14 @@ async function handleAction(req, res) {
356709
357065
  sendJSON(res, 400, { error: "No active Appium session" });
356710
357066
  return;
356711
357067
  }
357068
+ if (mirrorSessionOwner !== "recording") {
357069
+ sendJSON(res, 409, { error: "No recording session owns the device (a runner-driven session is active)." });
357070
+ return;
357071
+ }
357072
+ if (actionInFlight) {
357073
+ sendJSON(res, 409, { error: "Another action is still executing. Wait for it to finish." });
357074
+ return;
357075
+ }
356712
357076
  validateSessionId(state.appiumSessionId);
356713
357077
  const body = await parseBody(req);
356714
357078
  const type = typeof body.type === "string" ? body.type : null;
@@ -356720,6 +357084,7 @@ async function handleAction(req, res) {
356720
357084
  sendJSON(res, 400, { error: "Unknown or unsupported action type" });
356721
357085
  return;
356722
357086
  }
357087
+ actionInFlight = true;
356723
357088
  try {
356724
357089
  const sessionPath = `/session/${state.appiumSessionId}`;
356725
357090
  switch (type) {
@@ -356792,15 +357157,23 @@ async function handleAction(req, res) {
356792
357157
  sendJSON(res, 400, { error: `Unknown action type: ${type}` });
356793
357158
  return;
356794
357159
  }
356795
- const [screenshot, source] = await Promise.all([getScreenshot2(), getPageSource2()]);
357160
+ await waitForUiSettle(sessionPath);
357161
+ const screenshot = await getScreenshot2();
357162
+ let source = await getPageSource2();
357163
+ if (source === null) {
357164
+ source = await getPageSource2();
357165
+ }
356796
357166
  sendJSON(res, 200, {
356797
357167
  ok: true,
356798
357168
  screenshot: screenshot ?? null,
356799
357169
  source: source ?? null,
357170
+ ...source === null || source === void 0 ? { sourceUnavailable: true } : {},
356800
357171
  timestamp: Date.now()
356801
357172
  });
356802
357173
  } catch (err) {
356803
357174
  sendJSON(res, 500, { error: `Action failed: ${err instanceof Error ? err.message : String(err)}` });
357175
+ } finally {
357176
+ actionInFlight = false;
356804
357177
  }
356805
357178
  }
356806
357179
  async function handleScreenshot(_req, res) {
@@ -357038,7 +357411,11 @@ function getBridgeState() {
357038
357411
  tunnelUrl: state.tunnelUrl,
357039
357412
  tunnelToken: state.tunnelToken,
357040
357413
  hasActiveSession: !!state.appiumSessionId,
357041
- recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording",
357414
+ // TRUE also during the multi-second session-START window: the claim loop
357415
+ // reads this to keep test runs off the recording device, and the Appium
357416
+ // session id only lands AFTER `POST /session` returns — without the
357417
+ // in-flight latch a run admitted in that window fights the recording.
357418
+ recordingActive: !!state.appiumSessionId && mirrorSessionOwner === "recording" || sessionStartInFlight !== null,
357042
357419
  platform: state.platform
357043
357420
  };
357044
357421
  }