@validate.qa/runner 1.0.12 → 1.0.13

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 +928 -127
  2. package/dist/cli.mjs +928 -127
  3. package/package.json +2 -2
package/dist/cli.mjs CHANGED
@@ -238,6 +238,9 @@ var require_ai_provider = __commonJS({
238
238
  exports.getNoThinkingRequestOptions = getNoThinkingRequestOptions;
239
239
  exports.getAIClient = getAIClient;
240
240
  exports.getClientForModel = getClientForModel;
241
+ exports.shouldProxyAIThroughServerForLocalRunner = shouldProxyAIThroughServerForLocalRunner;
242
+ exports.getRunnerAIClient = getRunnerAIClient;
243
+ exports.getRunnerClientForModel = getRunnerClientForModel;
241
244
  exports.getModel = getModel;
242
245
  exports.resolveModelForTask = resolveModelForTask;
243
246
  exports.getAvailableModels = getAvailableModels;
@@ -515,6 +518,21 @@ var require_ai_provider = __commonJS({
515
518
  function getClientForModel(modelId) {
516
519
  return buildClientForProvider(getProviderForModel(modelId));
517
520
  }
521
+ function shouldProxyAIThroughServerForLocalRunner() {
522
+ const serverUrl = (process.env.SERVER_URL ?? "").trim();
523
+ const token = (process.env.AUTH_TOKEN || process.env.RUNNER_TOKEN || "").trim();
524
+ return serverUrl.length > 0 && token.startsWith("urt_");
525
+ }
526
+ function getRunnerAIClient() {
527
+ if (shouldProxyAIThroughServerForLocalRunner())
528
+ return getServerProxiedAIClient();
529
+ return getAIClient();
530
+ }
531
+ function getRunnerClientForModel(modelId) {
532
+ if (shouldProxyAIThroughServerForLocalRunner())
533
+ return getServerProxiedClientForModel(modelId);
534
+ return getClientForModel(modelId);
535
+ }
518
536
  function getModel(type) {
519
537
  return MODEL_TASK_MAP[getActiveModelId()][type];
520
538
  }
@@ -585,7 +603,7 @@ var require_ai_provider = __commonJS({
585
603
  const create = async (params, options) => {
586
604
  const modelId = typeof params.model === "string" ? params.model : "";
587
605
  if (!modelId) {
588
- throw new Error("server-proxied AI client requires params.model (catalog id)");
606
+ throw new Error("server-proxied AI client requires params.model");
589
607
  }
590
608
  const body = {
591
609
  modelId,
@@ -755,6 +773,21 @@ var require_mobile_source_parser = __commonJS({
755
773
  const attrText = inner.slice(tag.length);
756
774
  return { tag, attrs: parseAttributes(attrText), selfClosing };
757
775
  }
776
+ function findTagEnd(xml, from) {
777
+ let quote = null;
778
+ for (let i = from; i < xml.length; i++) {
779
+ const ch = xml[i];
780
+ if (quote !== null) {
781
+ if (ch === quote)
782
+ quote = null;
783
+ } else if (ch === '"' || ch === "'") {
784
+ quote = ch;
785
+ } else if (ch === ">") {
786
+ return i;
787
+ }
788
+ }
789
+ return -1;
790
+ }
758
791
  function tokenize(xml) {
759
792
  if (!xml || typeof xml !== "string")
760
793
  return null;
@@ -777,7 +810,7 @@ var require_mobile_source_parser = __commonJS({
777
810
  i = end === -1 ? len : end + 3;
778
811
  continue;
779
812
  }
780
- const gt = xml.indexOf(">", lt + 1);
813
+ const gt = findTagEnd(xml, lt + 1);
781
814
  if (gt === -1)
782
815
  break;
783
816
  const tagBody = xml.slice(lt + 1, gt);
@@ -1563,6 +1596,10 @@ function buildAppiumCode(testName, target, steps) {
1563
1596
  ` }`,
1564
1597
  `}`,
1565
1598
  ``,
1599
+ `async function __clearFocused(driver: WebdriverIO.Browser) {`,
1600
+ ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).clearValue();`,
1601
+ `}`,
1602
+ ``,
1566
1603
  `async function __textCandidates(el: WebdriverIO.Element) {`,
1567
1604
  ` const __values: string[] = [];`,
1568
1605
  ` const __push = (__value: unknown) => {`,
@@ -1656,6 +1693,12 @@ function buildAppiumCode(testName, target, steps) {
1656
1693
  lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1657
1694
  } else if (step.action === "clear" && hasSelector) {
1658
1695
  lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
1696
+ } else if (step.action === "clear" && bounds) {
1697
+ const x = Math.round(bounds.x + bounds.width / 2);
1698
+ const y = Math.round(bounds.y + bounds.height / 2);
1699
+ lines.push(` await __tapAt(driver, ${x}, ${y});`);
1700
+ lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1701
+ lines.push(` await __clearFocused(driver);`);
1659
1702
  } else if (step.action === "assertVisible" && hasSelector) {
1660
1703
  lines.push(` if (!(await (await findFirst(driver, ${candidatesLiteral})).isDisplayed())) {`);
1661
1704
  lines.push(` throw new Error('Assertion failed: element not displayed: ' + ${toJsStringLiteral(candidatesLiteral)});`);
@@ -1663,6 +1706,7 @@ function buildAppiumCode(testName, target, steps) {
1663
1706
  } else if (step.action === "assertText" && hasSelector) {
1664
1707
  lines.push(` {`);
1665
1708
  lines.push(` const __expected = ${toJsStringLiteral(step.assertion ?? "")};`);
1709
+ lines.push(` if (__expected.length === 0) throw new Error('assertText step is missing expected text');`);
1666
1710
  lines.push(` const __actual = await __textCandidates(await findFirst(driver, ${candidatesLiteral}));`);
1667
1711
  lines.push(` if (!__actual.some((__value) => __value.includes(__expected))) {`);
1668
1712
  lines.push(` throw new Error('Assertion failed: expected text to contain ' + JSON.stringify(__expected) + ' but got ' + JSON.stringify(__actual));`);
@@ -4371,6 +4415,7 @@ var require_mobile_observation = __commonJS({
4371
4415
  Object.defineProperty(exports, "__esModule", { value: true });
4372
4416
  exports.renderMobileObservation = renderMobileObservation;
4373
4417
  exports.summarizeScreenForEvidence = summarizeScreenForEvidence;
4418
+ exports.buildCollectedElementsForScreen = buildCollectedElementsForScreen;
4374
4419
  var DEFAULT_MAX_ELEMENTS = 60;
4375
4420
  var MAX_LABEL_LENGTH = 80;
4376
4421
  var EVIDENCE_LABEL_LIMIT = 40;
@@ -4412,6 +4457,46 @@ var require_mobile_observation = __commonJS({
4412
4457
  }
4413
4458
  return { elementCount, actionableCount, labels };
4414
4459
  }
4460
+ function buildCollectedElementsForScreen(elements, opts = {}) {
4461
+ const navigationRefs = opts.navigationRefs ?? EMPTY_REF_SET;
4462
+ const out = [];
4463
+ const seen = /* @__PURE__ */ new Set();
4464
+ for (const element of elements) {
4465
+ if (!element.actionable)
4466
+ continue;
4467
+ const collected = toCollectedPageElement(element, navigationRefs.has(element.ref));
4468
+ const identity = `${collected.elementType}|${collected.testId ?? ""}|${collected.label ?? ""}`;
4469
+ if (seen.has(identity))
4470
+ continue;
4471
+ seen.add(identity);
4472
+ out.push(collected);
4473
+ }
4474
+ return out;
4475
+ }
4476
+ var EMPTY_REF_SET = /* @__PURE__ */ new Set();
4477
+ function toCollectedPageElement(element, triggersNavigation) {
4478
+ const label = element.label?.trim() || element.text?.trim() || void 0;
4479
+ const stableId = element.accessibilityId?.trim() || element.resourceId?.trim() || void 0;
4480
+ const locators = [];
4481
+ if (stableId)
4482
+ locators.push({ strategy: "getByTestId", value: stableId, confidence: 0.95 });
4483
+ const xpath = element.xpath?.trim();
4484
+ if (xpath)
4485
+ locators.push({ strategy: "css", value: xpath, confidence: 0.4 });
4486
+ const collected = {
4487
+ elementType: element.role || "View",
4488
+ triggersNavigation
4489
+ };
4490
+ if (label)
4491
+ collected.label = label;
4492
+ if (stableId)
4493
+ collected.testId = stableId;
4494
+ if (locators.length > 0)
4495
+ collected.locators = locators;
4496
+ if (element.enabled === false)
4497
+ collected.isDisabled = true;
4498
+ return collected;
4499
+ }
4415
4500
  function buildHeaderLine(screen) {
4416
4501
  const parts = ["screen:"];
4417
4502
  const name = screenDisplayName(screen.screenSignal);
@@ -4860,7 +4945,7 @@ var require_mobile_explorer = __commonJS({
4860
4945
  }
4861
4946
  return sections.join("\n");
4862
4947
  }
4863
- function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId) {
4948
+ function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId, existingScreens) {
4864
4949
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
4865
4950
  const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
4866
4951
  const surveyBody = `You are a mobile QA exploration agent driving a real ${platformLabel} app on a connected device. Your goal: MAP every reachable screen so downstream agents know what to test. You are NOT testing \u2014 you are building a complete screen map.
@@ -4906,13 +4991,18 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
4906
4991
  ## Application Brief
4907
4992
  ${appBrief}
4908
4993
  Use this to prioritize which screens and flows to cover.` : "";
4909
- return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}`;
4994
+ const existingScreensBlock = mode === "survey" && existingScreens && existingScreens.length > 0 ? `
4995
+
4996
+ ## ALREADY DISCOVERED SCREENS (skip these; find new)
4997
+ These screens were mapped in a previous survey. Do NOT re-map them \u2014 use your budget to find NEW screens and flows not listed here. If you land on one of these, move on to something new.
4998
+ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.name}"` : ""}${s.screenType ? ` [${s.screenType}]` : ""}`).join("\n")}` : "";
4999
+ return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}${existingScreensBlock}`;
4910
5000
  }
4911
5001
  function buildKickoffMessage(mode) {
4912
5002
  return mode === "survey" ? "Start by calling mobile_snapshot on the launch screen, then capture_screen. Map the entire app: open every tab, menu, and one row per list. Record transitions as you go." : "Start by calling mobile_snapshot on the launch screen. Then use the features: fill fields, submit forms, open menus, and observe outcomes. Record journeys after meaningful flows.";
4913
5003
  }
4914
5004
  async function runMobileExplorePhase(args) {
4915
- const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot } = args;
5005
+ const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot, onScreenshotUpload, existingScreens } = args;
4916
5006
  const logs2 = [];
4917
5007
  const log2 = (line) => {
4918
5008
  logs2.push(line);
@@ -4936,6 +5026,9 @@ Use this to prioritize which screens and flows to cover.` : "";
4936
5026
  log2(`Budget: ${maxIterations} iterations | app: ${ctx.target?.appId ?? "(unknown)"}`);
4937
5027
  const screenshots = [];
4938
5028
  const pageScreenshots = {};
5029
+ const pageScreenshotKeys = {};
5030
+ const uploadedScreenIds = /* @__PURE__ */ new Set();
5031
+ const pendingScreenshotUploads = [];
4939
5032
  const collectedTransitions = [];
4940
5033
  const journeys = [];
4941
5034
  const counters = {
@@ -4954,6 +5047,14 @@ Use this to prioritize which screens and flows to cover.` : "";
4954
5047
  screenshots.push(base64);
4955
5048
  if (screenId)
4956
5049
  pageScreenshots[screenId] = base64;
5050
+ if (screenId && onScreenshotUpload && !uploadedScreenIds.has(screenId)) {
5051
+ uploadedScreenIds.add(screenId);
5052
+ pendingScreenshotUploads.push(onScreenshotUpload(screenId, base64).then((key) => {
5053
+ if (key)
5054
+ pageScreenshotKeys[screenId] = key;
5055
+ }).catch(() => {
5056
+ }));
5057
+ }
4957
5058
  try {
4958
5059
  onScreenshot?.(base64);
4959
5060
  } catch {
@@ -5058,7 +5159,7 @@ Use this to prioritize which screens and flows to cover.` : "";
5058
5159
  return null;
5059
5160
  return { element, bounds: element.bounds };
5060
5161
  };
5061
- const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId);
5162
+ const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId, existingScreens);
5062
5163
  const kickoff = buildKickoffMessage(mode);
5063
5164
  const recentExchanges = [];
5064
5165
  const transientDirectives = [];
@@ -5266,6 +5367,9 @@ Exploration complete: ${summary}`);
5266
5367
  summary = `Explore phase aborted (${truncatedReason}) after mapping ${state2.getScreenCount()} screen(s).`;
5267
5368
  }
5268
5369
  }
5370
+ if (pendingScreenshotUploads.length > 0) {
5371
+ await Promise.allSettled(pendingScreenshotUploads);
5372
+ }
5269
5373
  const collectedPages = state2.buildInMemoryScreenMap();
5270
5374
  counters.pagesVisited = collectedPages.length;
5271
5375
  if (counters.pagesDiscovered < collectedPages.length) {
@@ -5300,6 +5404,9 @@ Exploration complete: ${summary}`);
5300
5404
  if (Object.keys(pageScreenshots).length > 0) {
5301
5405
  result.pageScreenshots = pageScreenshots;
5302
5406
  }
5407
+ if (Object.keys(pageScreenshotKeys).length > 0) {
5408
+ result.pageScreenshotKeys = pageScreenshotKeys;
5409
+ }
5303
5410
  if (truncated) {
5304
5411
  result.truncated = true;
5305
5412
  result.truncatedReason = truncatedReason;
@@ -5801,6 +5908,44 @@ var require_snapshot_parser = __commonJS({
5801
5908
  };
5802
5909
  var SUBMIT_LABEL_RE = /^(submit|sign\s*in|sign\s*up|log\s*in|register|create|save|send|confirm|continue|next|apply|post|publish|add|update|go)$/i;
5803
5910
  var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
5911
+ var RICH_CONTEXT_ROLES = /* @__PURE__ */ new Set(["table", "grid", "row", "radiogroup"]);
5912
+ var RICH_CONTEXT_LABEL_MAX = 60;
5913
+ var VALUE_BEARING_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "combobox", "spinbutton", "listbox", "slider"]);
5914
+ var SENSITIVE_VALUE_LABEL_RE = /\bpass(?:word|code|phrase)?\b|\bpassport\b|\bpwd\b|\bsecrets?\b|\btokens?\b|\botp\b|one[- ]?time|\bpin\b|\bcvv\b|\bcvc\b|card\s*number|\bssn\b|social\s*security|security\s*(?:answer|question)|api[-_ ]?key|private\s*key|\bauth\b|authoriz|authenticat|credential/i;
5915
+ var REDACTED_VALUE = "[redacted]";
5916
+ var VALUE_TEXT_MAX = 120;
5917
+ var SIGNAL_TEXT_RE = /\b(?:showing|displaying|page)\b[\s\S]*\b(?:of|results?|items?|entries)\b|\b(?:no|nothing)\b[\s\S]*\b(?:found|yet|available|results?|items?)\b|\bempty\b|\b(?:error|invalid|required|failed|expired?|success(?:fully)?|saved|updated|deleted|created|welcome)\b/i;
5918
+ var STATIC_MESSAGES_MAX = 12;
5919
+ var OPTIONS_PER_ELEMENT_MAX = 24;
5920
+ var URL_CHILD_LINE_RE = /^(\s*)- \/url:\s*(.+)$/;
5921
+ var MESSAGE_TOKEN_RE = /\b(?:sk|pk|rk|ak|key|tok)[-_][A-Za-z0-9_-]{8,}\b|\b[A-Za-z0-9_-]{24,}\b/g;
5922
+ function stripYamlQuotes(raw) {
5923
+ const trimmed = raw.trim();
5924
+ if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
5925
+ return trimmed.slice(1, -1);
5926
+ }
5927
+ return trimmed;
5928
+ }
5929
+ function extractTrailingText(attrs) {
5930
+ const lastBracket = attrs.lastIndexOf("]");
5931
+ const tail = lastBracket >= 0 ? attrs.slice(lastBracket + 1) : attrs;
5932
+ const match = /^\s*:\s+(.+)$/.exec(tail);
5933
+ if (!match)
5934
+ return null;
5935
+ const text = stripYamlQuotes(match[1]);
5936
+ if (!text || /^(?:\||\|-|>|>-)$/.test(text))
5937
+ return null;
5938
+ return text;
5939
+ }
5940
+ function isSensitiveValueField(name, placeholder) {
5941
+ return SENSITIVE_VALUE_LABEL_RE.test(`${name ?? ""} ${placeholder ?? ""}`);
5942
+ }
5943
+ function scrubMessageText(text) {
5944
+ return text.replace(MESSAGE_TOKEN_RE, REDACTED_VALUE);
5945
+ }
5946
+ function capText(text, max) {
5947
+ return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
5948
+ }
5804
5949
  function isProbablyDynamicTestId(testId) {
5805
5950
  if (testId.length < 6)
5806
5951
  return false;
@@ -5876,9 +6021,12 @@ var require_snapshot_parser = __commonJS({
5876
6021
  }
5877
6022
  return e.name;
5878
6023
  }
5879
- function parseSnapshot(snapshotText) {
6024
+ function parseSnapshot(snapshotText, options = {}) {
6025
+ const richParse = options.richParse ?? process.env.DISCOVERY_SNAPSHOT_RICH_PARSE === "true";
5880
6026
  const lines = snapshotText.split("\n");
5881
6027
  const elements = [];
6028
+ const liveMessages = [];
6029
+ const textMessages = [];
5882
6030
  const seenRefs = /* @__PURE__ */ new Set();
5883
6031
  let parsedElementLineCount = 0;
5884
6032
  let genericLabelCount = 0;
@@ -5887,6 +6035,21 @@ var require_snapshot_parser = __commonJS({
5887
6035
  const contextStack = [];
5888
6036
  const completedForms = [];
5889
6037
  for (const line of lines) {
6038
+ if (richParse) {
6039
+ const urlMatch = URL_CHILD_LINE_RE.exec(line);
6040
+ if (urlMatch) {
6041
+ const urlIndent = urlMatch[1].length;
6042
+ for (let i = elements.length - 1; i >= 0; i--) {
6043
+ const el = elements[i];
6044
+ if (el.indentDepth < urlIndent) {
6045
+ if (el.role === "link" && !el.href)
6046
+ el.href = capText(stripYamlQuotes(urlMatch[2]), 300);
6047
+ break;
6048
+ }
6049
+ }
6050
+ continue;
6051
+ }
6052
+ }
5890
6053
  const match = ELEMENT_LINE_RE.exec(line);
5891
6054
  if (!match)
5892
6055
  continue;
@@ -5908,7 +6071,7 @@ var require_snapshot_parser = __commonJS({
5908
6071
  }
5909
6072
  const headingLevelMatch = LEVEL_RE.exec(attrs);
5910
6073
  const headingLevel = headingLevelMatch ? Number.parseInt(headingLevelMatch[1], 10) : null;
5911
- const contextLabel = buildContextLabel(role, name ?? null, headingLevel);
6074
+ const contextLabel = buildContextLabel(role, name ?? null, headingLevel, richParse);
5912
6075
  if (contextLabel) {
5913
6076
  contextStack.push({ indent: indentDepth, label: contextLabel });
5914
6077
  }
@@ -5921,6 +6084,40 @@ var require_snapshot_parser = __commonJS({
5921
6084
  });
5922
6085
  continue;
5923
6086
  }
6087
+ if (richParse) {
6088
+ if (role === "option" && name) {
6089
+ for (let i = elements.length - 1; i >= 0; i--) {
6090
+ const el = elements[i];
6091
+ if (el.indentDepth < indentDepth) {
6092
+ if (el.role === "combobox" || el.role === "listbox") {
6093
+ el.options ??= [];
6094
+ if (el.options.length < OPTIONS_PER_ELEMENT_MAX) {
6095
+ el.options.push({ label: capText(name, 80), selected: parseBooleanState(SELECTED_RE.exec(attrs)) === true });
6096
+ }
6097
+ }
6098
+ break;
6099
+ }
6100
+ }
6101
+ }
6102
+ if (role === "alert" || role === "status" || role === "paragraph" || role === "text") {
6103
+ const text = extractTrailingText(attrs) ?? (name || null);
6104
+ const isLiveRegion = role === "alert" || role === "status";
6105
+ if (text && (isLiveRegion || SIGNAL_TEXT_RE.test(text))) {
6106
+ const capped = capText(scrubMessageText(text), VALUE_TEXT_MAX);
6107
+ const ref2 = REF_RE.exec(attrs)?.[1] ?? null;
6108
+ if (isLiveRegion) {
6109
+ const textDupIndex = textMessages.findIndex((m) => m.text === capped);
6110
+ if (textDupIndex >= 0)
6111
+ textMessages.splice(textDupIndex, 1);
6112
+ if (liveMessages.length < STATIC_MESSAGES_MAX && !liveMessages.some((m) => m.text === capped)) {
6113
+ liveMessages.push({ role, text: capped, ref: ref2 });
6114
+ }
6115
+ } else if (textMessages.length < STATIC_MESSAGES_MAX && !textMessages.some((m) => m.text === capped) && !liveMessages.some((m) => m.text === capped)) {
6116
+ textMessages.push({ role: "text", text: capped, ref: ref2 });
6117
+ }
6118
+ }
6119
+ }
6120
+ }
5924
6121
  if (!INTERACTIVE_ROLES.has(role))
5925
6122
  continue;
5926
6123
  const refMatch = REF_RE.exec(attrs);
@@ -5953,6 +6150,13 @@ var require_snapshot_parser = __commonJS({
5953
6150
  }
5954
6151
  const isLink = role === "link";
5955
6152
  const isSubmitButton = role === "button" && name != null && SUBMIT_LABEL_RE.test(name);
6153
+ let value = null;
6154
+ if (richParse && VALUE_BEARING_ROLES.has(role)) {
6155
+ const trailing = extractTrailingText(attrs);
6156
+ if (trailing) {
6157
+ value = isSensitiveValueField(name ?? null, placeholder) ? REDACTED_VALUE : capText(trailing, VALUE_TEXT_MAX);
6158
+ }
6159
+ }
5956
6160
  const element = {
5957
6161
  ref,
5958
6162
  role,
@@ -5974,7 +6178,10 @@ var require_snapshot_parser = __commonJS({
5974
6178
  pressed,
5975
6179
  headingLevel,
5976
6180
  currentState,
5977
- containerPath
6181
+ containerPath,
6182
+ href: null,
6183
+ value,
6184
+ options: null
5978
6185
  };
5979
6186
  if (isGenericLabel(element.name))
5980
6187
  genericLabelCount++;
@@ -6002,6 +6209,9 @@ var require_snapshot_parser = __commonJS({
6002
6209
  return {
6003
6210
  elements,
6004
6211
  formGroups: completedForms,
6212
+ // live regions first: they carry their own reservation so an end-of-body toast can
6213
+ // never be starved out by signal-matching plain text earlier in the document.
6214
+ staticMessages: [...liveMessages, ...textMessages].slice(0, STATIC_MESSAGES_MAX),
6005
6215
  rawLineCount: lines.length,
6006
6216
  parsedElementLineCount,
6007
6217
  genericLabelCount,
@@ -6112,12 +6322,17 @@ var require_snapshot_parser = __commonJS({
6112
6322
  return "mixed";
6113
6323
  return false;
6114
6324
  }
6115
- function buildContextLabel(role, name, headingLevel) {
6325
+ function buildContextLabel(role, name, headingLevel, richContext = false) {
6116
6326
  if (role === "heading") {
6117
6327
  if (!name)
6118
6328
  return null;
6119
6329
  return headingLevel ? `heading${headingLevel}:${name}` : `heading:${name}`;
6120
6330
  }
6331
+ if (richContext && RICH_CONTEXT_ROLES.has(role)) {
6332
+ if (!name)
6333
+ return null;
6334
+ return `${role}:${capText(name.replace(/>/g, "\u203A"), RICH_CONTEXT_LABEL_MAX)}`;
6335
+ }
6121
6336
  if (!CONTEXT_ROLES.has(role))
6122
6337
  return null;
6123
6338
  if (name)
@@ -7827,7 +8042,7 @@ Each entry is exactly one of:
7827
8042
  const systemPrompt = options.systemPromptOverride ?? (context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports.SCENARIO_SYSTEM_PROMPT);
7828
8043
  const userPrompt = options.userPromptOverride ?? formatEvidenceForPlanner(compressed, evidence, context);
7829
8044
  logs2.push(`AI planner: ~${Math.ceil(userPrompt.length / 3.5)} input tokens`);
7830
- const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)());
8045
+ const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
7831
8046
  const messages = [
7832
8047
  { role: "system", content: systemPrompt },
7833
8048
  { role: "user", content: userPrompt }
@@ -7835,18 +8050,22 @@ Each entry is exactly one of:
7835
8050
  const callStart = Date.now();
7836
8051
  let response;
7837
8052
  try {
7838
- response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => aiClient.chat.completions.create({
7839
- model: agentModel,
7840
- messages,
7841
- max_tokens: plannerMaxTokens,
7842
- temperature: 0.3,
7843
- ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
7844
- ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
7845
- })), {
8053
+ response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
8054
+ arm();
8055
+ return aiClient.chat.completions.create({
8056
+ model: agentModel,
8057
+ messages,
8058
+ max_tokens: plannerMaxTokens,
8059
+ temperature: 0.3,
8060
+ ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
8061
+ ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
8062
+ }, { signal });
8063
+ }), {
7846
8064
  label: "ai-planner",
7847
8065
  baseDelayMs: 5e3,
7848
8066
  maxRetries: 2,
7849
- onRetry: (attempt, status, delay) => logs2.push(`AI planner call failed (${status ?? "error"}) \u2014 retry ${attempt}/2 in ${Math.round(delay / 1e3)}s`)
8067
+ deferTimeoutUntilArmed: true,
8068
+ onRetry: (attempt, reason, delay) => logs2.push(`AI planner call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
7850
8069
  });
7851
8070
  } catch (err) {
7852
8071
  const message = err instanceof Error ? err.message : String(err);
@@ -7912,18 +8131,25 @@ Each entry is exactly one of:
7912
8131
  const retryStart = Date.now();
7913
8132
  let retryResponse = null;
7914
8133
  try {
7915
- retryResponse = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => aiClient.chat.completions.create({
7916
- model: agentModel,
7917
- messages: [
7918
- ...messages,
7919
- { role: "assistant", content: raw },
7920
- { role: "user", content: retryContext }
7921
- ],
7922
- max_tokens: plannerMaxTokens,
7923
- temperature: 0.3,
7924
- ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
7925
- ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
7926
- })), { label: "ai-planner-retry" });
8134
+ retryResponse = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
8135
+ arm();
8136
+ return aiClient.chat.completions.create({
8137
+ model: agentModel,
8138
+ messages: [
8139
+ ...messages,
8140
+ { role: "assistant", content: raw },
8141
+ { role: "user", content: retryContext }
8142
+ ],
8143
+ max_tokens: plannerMaxTokens,
8144
+ temperature: 0.3,
8145
+ ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
8146
+ ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
8147
+ }, { signal });
8148
+ }), {
8149
+ label: "ai-planner-retry",
8150
+ deferTimeoutUntilArmed: true,
8151
+ onRetry: (attempt, reason, delay) => logs2.push(`AI planner retry call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
8152
+ });
7927
8153
  } catch (err) {
7928
8154
  const message = err instanceof Error ? err.message : String(err);
7929
8155
  logs2.push(`AI planner retry failed (${message}) \u2014 using first-pass result`);
@@ -8451,12 +8677,12 @@ ${constraints.join("\n")}`);
8451
8677
  const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8452
8678
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
8453
8679
  for (const state2 of orderedScreens) {
8454
- const groupName = state2.screenType === "auth" ? "Authentication" : screenDisplayName(state2);
8680
+ const groupName = state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(state2);
8455
8681
  const group = groups.get(groupName) ?? {
8456
8682
  name: groupName,
8457
8683
  description: `Native mobile screens related to ${groupName}.`,
8458
8684
  routes: [],
8459
- criticality: state2.screenType === "auth" ? "critical" : "medium",
8685
+ criticality: state2.screenType?.toLowerCase() === "auth" ? "critical" : "medium",
8460
8686
  pages: [],
8461
8687
  flows: []
8462
8688
  };
@@ -8479,7 +8705,7 @@ ${constraints.join("\n")}`);
8479
8705
  const to = graph.screens.get(transition.toScreenId);
8480
8706
  if (!from || !to)
8481
8707
  continue;
8482
- const groupName = from.screenType === "auth" ? "Authentication" : screenDisplayName(from);
8708
+ const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(from);
8483
8709
  const group = groups.get(groupName);
8484
8710
  if (!group)
8485
8711
  continue;
@@ -8524,9 +8750,9 @@ ${constraints.join("\n")}`);
8524
8750
  scenarios.push({
8525
8751
  name,
8526
8752
  type: "HAPPY_PATH",
8527
- priority: state2.screenType === "auth" ? 1 : 3,
8753
+ priority: state2.screenType?.toLowerCase() === "auth" ? 1 : 3,
8528
8754
  variant: "happy",
8529
- featureGroup: state2.screenType === "auth" ? "Authentication" : screenName,
8755
+ featureGroup: state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenName,
8530
8756
  goal: `User can reach and inspect the ${screenName} screen`,
8531
8757
  steps,
8532
8758
  expectedOutcomes: [{
@@ -8672,6 +8898,7 @@ var require_mobile_generator = __commonJS({
8672
8898
  var mobile_observation_js_1 = require_mobile_observation();
8673
8899
  var mobile_boundary_js_1 = require_mobile_boundary();
8674
8900
  var MAX_SCENARIO_ITERATIONS = 30;
8901
+ var MAX_CONSECUTIVE_INFRA_FAILURES = 3;
8675
8902
  var MAX_RECENT_EXCHANGES = 24;
8676
8903
  var OBSERVATION_MAX_ELEMENTS = 50;
8677
8904
  function finalizeAction(input) {
@@ -9396,14 +9623,31 @@ ${statePacket}` },
9396
9623
  if (!snapshot || snapshot.screen.elements.length === 0)
9397
9624
  return null;
9398
9625
  const elements = snapshot.screen.elements;
9399
- const withStableId = elements.filter((el) => hasStableId(el));
9400
- const nonButton = withStableId.find((el) => !/button/i.test(el.role));
9626
+ const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
9627
+ const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
9628
+ if (contentfulNonButton)
9629
+ return contentfulNonButton;
9630
+ const nonButton = stableAnchors.find((el) => !/button/i.test(el.role));
9401
9631
  if (nonButton)
9402
9632
  return nonButton;
9403
- if (withStableId.length > 0)
9404
- return withStableId[0];
9405
- const labelled = elements.find((el) => nonEmpty2(el.label) || nonEmpty2(el.text));
9406
- return labelled ?? elements[0];
9633
+ if (stableAnchors.length > 0)
9634
+ return stableAnchors[0];
9635
+ const labelled = elements.find((el) => !isAlwaysPresentContainer(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
9636
+ return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
9637
+ }
9638
+ var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
9639
+ var ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS = /* @__PURE__ */ new Set([
9640
+ "android:id/content",
9641
+ "android:id/decor",
9642
+ "android:id/action_bar_root",
9643
+ "android:id/statusBarBackground",
9644
+ "android:id/navigationBarBackground"
9645
+ ]);
9646
+ function isAlwaysPresentContainer(element) {
9647
+ if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
9648
+ return true;
9649
+ const resourceId = element.resourceId?.trim();
9650
+ return !!resourceId && ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS.has(resourceId);
9407
9651
  }
9408
9652
  function appendHybridFloor(actions, recording, platform3) {
9409
9653
  const anchor = pickLandingAnchor(recording.lastSnapshot);
@@ -9462,7 +9706,7 @@ ${statePacket}` },
9462
9706
  return test;
9463
9707
  }
9464
9708
  async function runMobileGeneratePhase(args) {
9465
- const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot } = args;
9709
+ const { scenarios: allScenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot, skipScenarioNames, onInfraStop } = args;
9466
9710
  const log2 = (line) => {
9467
9711
  try {
9468
9712
  onLog?.(line);
@@ -9477,8 +9721,16 @@ ${statePacket}` },
9477
9721
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
9478
9722
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
9479
9723
  const targetAppId = ctx.target?.appId?.trim() || void 0;
9724
+ const skipSet = new Set(skipScenarioNames ?? []);
9725
+ const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
9726
+ if (skipSet.size > 0) {
9727
+ log2(`Resume: skipping ${allScenarios.length - scenarios.length} already-generated scenario(s); ${scenarios.length} remaining to generate`);
9728
+ }
9480
9729
  log2(`Mobile generator starting \u2014 platform: ${platform3}, provider: ${provider}, model: ${agentModel}, scenarios: ${scenarios.length}`);
9481
9730
  const tests = [];
9731
+ const generatedScenarioNames = /* @__PURE__ */ new Set();
9732
+ let consecutiveInfraFailures = 0;
9733
+ let infraStopped = false;
9482
9734
  for (let i = 0; i < scenarios.length; i++) {
9483
9735
  const scenario = scenarios[i];
9484
9736
  log2(`
@@ -9499,6 +9751,7 @@ ${statePacket}` },
9499
9751
  targetAppId,
9500
9752
  log: log2
9501
9753
  });
9754
+ consecutiveInfraFailures = 0;
9502
9755
  const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
9503
9756
  if (!test) {
9504
9757
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
@@ -9515,13 +9768,31 @@ ${statePacket}` },
9515
9768
  log2(` onTestGenerated callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
9516
9769
  }
9517
9770
  tests.push(test);
9771
+ generatedScenarioNames.add(scenario.name);
9518
9772
  } catch (err) {
9773
+ if ((0, ai_retry_js_1.isAIProviderError)(err)) {
9774
+ consecutiveInfraFailures += 1;
9775
+ log2(` scenario "${scenario.name}" failed \u2014 AI provider error (${consecutiveInfraFailures}/${MAX_CONSECUTIVE_INFRA_FAILURES}): ${err instanceof Error ? err.message : String(err)}`);
9776
+ if (consecutiveInfraFailures >= MAX_CONSECUTIVE_INFRA_FAILURES) {
9777
+ const remainingScenarioNames = scenarios.filter((s) => !generatedScenarioNames.has(s.name)).map((s) => s.name);
9778
+ infraStopped = true;
9779
+ log2(`
9780
+ \u26D4 Stopping mobile generation: ${consecutiveInfraFailures} consecutive AI provider failures \u2014 the provider appears overloaded/unavailable. ${tests.length}/${scenarios.length} tests generated and saved; the remaining ${remainingScenarioNames.length} scenario(s) will resume later.`);
9781
+ try {
9782
+ onInfraStop?.({ remainingScenarioNames, reason: "ai_rate_limit" });
9783
+ } catch (cbErr) {
9784
+ log2(` onInfraStop callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
9785
+ }
9786
+ break;
9787
+ }
9788
+ continue;
9789
+ }
9519
9790
  log2(` scenario "${scenario.name}" failed: ${err instanceof Error ? err.message : String(err)} \u2014 skipping.`);
9520
9791
  continue;
9521
9792
  }
9522
9793
  }
9523
9794
  log2(`
9524
- Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified).`);
9795
+ Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified)${infraStopped ? " \u2014 PAUSED on AI provider overload" : ""}.`);
9525
9796
  return tests;
9526
9797
  }
9527
9798
  }
@@ -12800,6 +13071,10 @@ var require_snapshot_observation = __commonJS({
12800
13071
  var snapshot_parser_js_1 = require_snapshot_parser();
12801
13072
  var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
12802
13073
  var NOISE_LABEL_RE = /^(skip\s+to|skip\s+nav|back\s+to\s+top|cookie|accept\s+(all\s+)?cookies)$/i;
13074
+ var DIALOG_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog\b/i;
13075
+ var FORM_SEGMENT_RE = /(?:^|>\s*)form\b/i;
13076
+ var TABPANEL_SEGMENT_RE = /(?:^|>\s*)tabpanel\b/i;
13077
+ var DIALOG_NAME_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog:([^>]+)/i;
12803
13078
  var INTERACTIVE_ROLE_RE = /^(button|link|textbox|searchbox|checkbox|radio|switch|combobox|listbox|option|menuitem|tab|slider|spinbutton|textarea)$/i;
12804
13079
  var INTERACTIVE_ELEMENT_TYPE_RE = /^(button|link|input|textarea|select|checkbox|radio|switch|tab|combobox|listbox|option|menuitem|slider|spinbutton)$/i;
12805
13080
  function renderRetrievedSnapshotMemoryBlock(priorSnapshotEvidence, priorDiscoveryCheckpoints, options = {}) {
@@ -13044,10 +13319,12 @@ var require_snapshot_observation = __commonJS({
13044
13319
  summary
13045
13320
  }));
13046
13321
  }
13322
+ var TEXT_DISAMBIGUATING_INPUT_TYPES = /* @__PURE__ */ new Set(["password", "text", "email", "tel", "number", "search", "url", "date", "datetime-local", "time", "month", "week"]);
13047
13323
  function renderCompactSnapshotBlock(page, context = {}, options = {}) {
13048
13324
  const metrics = page.snapshotMetrics ?? page.snapshotArtifact?.metrics;
13049
13325
  const sourceKind = page.snapshotMeta?.sourceKind ?? page.snapshotArtifact?.sourceKind ?? "missing";
13050
13326
  const artifact = page.snapshotArtifact;
13327
+ const richRender = options.richRender ?? process.env.DISCOVERY_SNAPSHOT_RICH_RENDER === "true";
13051
13328
  const activeScope = inferActiveSnapshotScope(page);
13052
13329
  const ranked = rankSnapshotElements(getScopedSnapshotElements(page, activeScope), withScopeContext(context, activeScope));
13053
13330
  const allInteractive = ranked.filter(({ element }) => isInteractiveCandidate(element)).filter(({ element }) => {
@@ -13093,7 +13370,7 @@ var require_snapshot_observation = __commonJS({
13093
13370
  if (activeScope.rootRef) {
13094
13371
  lines.push(` root_ref: ${yamlScalar(activeScope.rootRef)}`);
13095
13372
  }
13096
- const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText);
13373
+ const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText, richRender);
13097
13374
  if (scopePreview.length > 0) {
13098
13375
  lines.push(" scope_snapshot_preview: |");
13099
13376
  for (const previewLine of scopePreview) {
@@ -13135,6 +13412,19 @@ var require_snapshot_observation = __commonJS({
13135
13412
  lines.push(` - ${yamlScalar(modal)}`);
13136
13413
  }
13137
13414
  }
13415
+ const staticMessages = page.staticMessages ?? [];
13416
+ if (richRender && staticMessages.length > 0) {
13417
+ const messageLimit = 6;
13418
+ const ordered = [...staticMessages].sort((a, b) => messageRolePriority(a.role) - messageRolePriority(b.role));
13419
+ lines.push("messages:");
13420
+ lines.push(" # Non-interactive page text worth asserting on (validation / toast / empty-state / counts). Text is the capture instant \u2014 toasts may have dismissed since.");
13421
+ for (const message of ordered.slice(0, messageLimit)) {
13422
+ lines.push(` - [${message.role}] ${yamlScalar(message.text)}`);
13423
+ }
13424
+ if (ordered.length > messageLimit) {
13425
+ lines.push(` # +${ordered.length - messageLimit} more message(s) not listed`);
13426
+ }
13427
+ }
13138
13428
  lines.push("session_state:");
13139
13429
  if (page.sessionState) {
13140
13430
  lines.push(` authenticated: ${yamlScalar(page.sessionState.authenticated)}`);
@@ -13197,7 +13487,10 @@ var require_snapshot_observation = __commonJS({
13197
13487
  }
13198
13488
  const hasRowContext = group.rowContexts.some((rc) => !!rc);
13199
13489
  if (hasRowContext) {
13200
- lines.push(' distinguish_by_row: # same name in different rows \u2014 scope by this text, e.g. getByRole(role, { name }).filter({ has: getByText("<row>") })');
13490
+ const firstRowRole = group.rowRoles[0] ?? null;
13491
+ const rowRole = firstRowRole && group.rowRoles.every((r) => r === firstRowRole) ? firstRowRole : null;
13492
+ const scopeHint = rowRole ? `getByRole(${yamlScalar(rowRole)}).filter({ has: getByText("<row>", { exact: true }) }).getByRole(${yamlScalar(group.role)}, { name: ${yamlScalar(group.name)} })` : `locate the container by its text (getByText("<row>", { exact: true })), then getByRole(${yamlScalar(group.role)}, { name: ${yamlScalar(group.name)} }) within it`;
13493
+ lines.push(` distinguish_by_row: # same name across rows \u2014 scope by the ROW/card container (not the control); nested same-role containers need an extra cell-level scope: ${scopeHint}`);
13201
13494
  for (let i = 0; i < group.rowContexts.length; i++) {
13202
13495
  const rowContext = group.rowContexts[i];
13203
13496
  if (!rowContext)
@@ -13227,8 +13520,13 @@ var require_snapshot_observation = __commonJS({
13227
13520
  lines.push(` placeholder: ${yamlScalar(rankedElement.element.placeholder)}`);
13228
13521
  }
13229
13522
  const stateTokens = buildStateSummary(rankedElement.element);
13523
+ const addedDisabled = richRender && !!rankedElement.element.isDisabled && !stateTokens.includes("disabled");
13524
+ if (addedDisabled) {
13525
+ stateTokens.unshift("disabled");
13526
+ }
13230
13527
  if (stateTokens.length > 0) {
13231
- lines.push(` state: [${stateTokens.map((token) => yamlScalar(token)).join(", ")}]`);
13528
+ const note = addedDisabled ? " # disabled = captured instant; may enable after valid input \u2014 still exercise fill\u2192submit" : "";
13529
+ lines.push(` state: [${stateTokens.map((token) => yamlScalar(token)).join(", ")}]${note}`);
13232
13530
  }
13233
13531
  if (rankedElement.element.containerPath) {
13234
13532
  lines.push(` container_path: ${yamlScalar(rankedElement.element.containerPath)}`);
@@ -13238,6 +13536,22 @@ var require_snapshot_observation = __commonJS({
13238
13536
  lines.push(` locator: ${yamlScalar((0, snapshot_parser_js_1.formatLocator)(topLocator))}`);
13239
13537
  }
13240
13538
  const enr = rankedElement.element.enrichment;
13539
+ if (richRender && enr?.inputType && TEXT_DISAMBIGUATING_INPUT_TYPES.has(enr.inputType)) {
13540
+ lines.push(` type: ${yamlScalar(enr.inputType)}`);
13541
+ }
13542
+ if (richRender && rankedElement.element.href) {
13543
+ lines.push(` url: ${yamlScalar(rankedElement.element.href)}`);
13544
+ }
13545
+ if (richRender && rankedElement.element.value && enr?.inputType !== "password") {
13546
+ lines.push(` value: ${yamlScalar(rankedElement.element.value)}`);
13547
+ }
13548
+ const elementOptions = rankedElement.element.options ?? [];
13549
+ if (richRender && elementOptions.length > 0) {
13550
+ const optionLimit = 12;
13551
+ const rendered = elementOptions.slice(0, optionLimit).map((option) => yamlScalar(option.selected ? `${option.label} (selected)` : option.label));
13552
+ const overflow = elementOptions.length > optionLimit ? ` # +${elementOptions.length - optionLimit} more option(s)` : "";
13553
+ lines.push(` options: [${rendered.join(", ")}]${overflow}`);
13554
+ }
13241
13555
  if (enr?.rowContext) {
13242
13556
  lines.push(` row_context: ${yamlScalar(enr.rowContext)}`);
13243
13557
  }
@@ -13250,6 +13564,12 @@ var require_snapshot_observation = __commonJS({
13250
13564
  lines.push(` visibility: [${vis.map((token) => yamlScalar(token)).join(", ")}]`);
13251
13565
  }
13252
13566
  }
13567
+ if (richRender) {
13568
+ const hidden = allInteractive.length - interactive.length;
13569
+ if (hidden > 0) {
13570
+ lines.push(` # +${hidden} more interactive element(s) not individually listed (capped) \u2014 some may appear in the duplicates block above; if your target isn't here, narrow the scope or act on it by ref.`);
13571
+ }
13572
+ }
13253
13573
  }
13254
13574
  const recoveredClickables = options.recoveredClickables ?? [];
13255
13575
  if (recoveredClickables.length > 0) {
@@ -13406,11 +13726,11 @@ var require_snapshot_observation = __commonJS({
13406
13726
  score += 10;
13407
13727
  if (element.isExpanded)
13408
13728
  score += 8;
13409
- if (element.containerPath?.includes("dialog"))
13729
+ if (element.containerPath && DIALOG_SEGMENT_RE.test(element.containerPath))
13410
13730
  score += 12;
13411
- if (element.containerPath?.includes("form"))
13731
+ if (element.containerPath && FORM_SEGMENT_RE.test(element.containerPath))
13412
13732
  score += 10;
13413
- if (element.containerPath?.includes("tabpanel"))
13733
+ if (element.containerPath && TABPANEL_SEGMENT_RE.test(element.containerPath))
13414
13734
  score += 8;
13415
13735
  if (element.inputType === "email" || element.inputType === "password" || element.inputType === "search")
13416
13736
  score += 6;
@@ -13481,10 +13801,10 @@ var require_snapshot_observation = __commonJS({
13481
13801
  }
13482
13802
  return merged;
13483
13803
  }
13484
- function buildScopeSnapshotPreview(scopeSnapshotText) {
13804
+ function buildScopeSnapshotPreview(scopeSnapshotText, stripTrailingValues = false) {
13485
13805
  if (!scopeSnapshotText)
13486
13806
  return [];
13487
- return scopeSnapshotText.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 6);
13807
+ return scopeSnapshotText.split("\n").map((line) => line.trim()).map((line) => stripTrailingValues ? line.replace(/\]\s*:\s.+$/, "]") : line).filter(Boolean).slice(0, 6);
13488
13808
  }
13489
13809
  function dedupeLines(lines) {
13490
13810
  const seen = /* @__PURE__ */ new Set();
@@ -13611,11 +13931,12 @@ var require_snapshot_observation = __commonJS({
13611
13931
  const key = `${role}\0${name}`;
13612
13932
  let group = groups.get(key);
13613
13933
  if (!group) {
13614
- group = { role, name, containers: [], rowContexts: [], refs: [] };
13934
+ group = { role, name, containers: [], rowContexts: [], rowRoles: [], refs: [] };
13615
13935
  groups.set(key, group);
13616
13936
  }
13617
13937
  group.containers.push(element.containerPath ?? null);
13618
13938
  group.rowContexts.push(element.enrichment?.rowContext ?? null);
13939
+ group.rowRoles.push(element.enrichment?.rowRole ?? null);
13619
13940
  group.refs.push(element.ref ?? null);
13620
13941
  }
13621
13942
  return [...groups.values()].filter((group) => group.containers.length > 1);
@@ -13625,7 +13946,7 @@ var require_snapshot_observation = __commonJS({
13625
13946
  for (const element of elements) {
13626
13947
  const role = element.role?.toLowerCase() ?? "";
13627
13948
  const elementType = element.elementType?.toLowerCase() ?? "";
13628
- const isDialog = role === "dialog" || elementType === "dialog" || (element.containerPath?.toLowerCase().includes("dialog:") ?? false);
13949
+ const isDialog = role === "dialog" || elementType === "dialog" || DIALOG_NAME_SEGMENT_RE.test(element.containerPath ?? "");
13629
13950
  if (!isDialog)
13630
13951
  continue;
13631
13952
  const hint = qualifyElementLabel(element) ?? dialogNameFromContainerPath(element.containerPath) ?? element.label ?? element.testId ?? "dialog";
@@ -13638,7 +13959,7 @@ var require_snapshot_observation = __commonJS({
13638
13959
  function dialogNameFromContainerPath(containerPath) {
13639
13960
  if (!containerPath)
13640
13961
  return null;
13641
- const match = containerPath.match(/dialog:([^>]+)/i);
13962
+ const match = containerPath.match(DIALOG_NAME_SEGMENT_RE);
13642
13963
  return match ? match[1].trim() : null;
13643
13964
  }
13644
13965
  function renderObservationCue(observation) {
@@ -13647,6 +13968,13 @@ var require_snapshot_observation = __commonJS({
13647
13968
  const base2 = observation.outcomeType === "SUCCESS" && outcome ? outcome : `${action} -> ${outcome || observation.outcomeType}`;
13648
13969
  return base2.length > 120 ? `${base2.slice(0, 117)}...` : base2;
13649
13970
  }
13971
+ function messageRolePriority(role) {
13972
+ if (role === "alert")
13973
+ return 0;
13974
+ if (role === "status")
13975
+ return 1;
13976
+ return 2;
13977
+ }
13650
13978
  function yamlScalar(value) {
13651
13979
  if (value == null || value === "")
13652
13980
  return "null";
@@ -20195,7 +20523,10 @@ ${testOpen}`;
20195
20523
  pressed: se.pressed,
20196
20524
  headingLevel: se.headingLevel,
20197
20525
  currentState: se.currentState,
20198
- containerPath: se.containerPath || void 0
20526
+ containerPath: se.containerPath || void 0,
20527
+ href: se.href || void 0,
20528
+ value: se.value || void 0,
20529
+ options: se.options && se.options.length > 0 ? se.options : void 0
20199
20530
  }));
20200
20531
  const structuredPage = {
20201
20532
  route: pageUrl || "/",
@@ -20203,6 +20534,7 @@ ${testOpen}`;
20203
20534
  pageType: "unknown",
20204
20535
  elements: collectedElements,
20205
20536
  formGroups: parsed.formGroups,
20537
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
20206
20538
  snapshotMetrics: {
20207
20539
  rawLineCount: parsed.rawLineCount,
20208
20540
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -21769,7 +22101,7 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
21769
22101
  let stopped;
21770
22102
  const agentModel = options?.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
21771
22103
  const auditGroupId = `generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
21772
- const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)();
22104
+ const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
21773
22105
  const credentials = variableContext.allTestVariables ?? variableContext.publicTestVariables;
21774
22106
  logs2.push(`Phase 3 \u2014 Generate: ${(plan.scenarios ?? []).length} scenarios, max ${maxTests} tests`);
21775
22107
  logs2.push(`Model: ${agentModel}`);
@@ -235734,7 +236066,7 @@ var require_mcp_healer = __commonJS({
235734
236066
  messages.push(system, firstUser, { role: "user", content: lines.join("\n") }, ...tail);
235735
236067
  }
235736
236068
  var DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS = 6;
235737
- function readPositiveIntEnv(env, name, fallback) {
236069
+ function readPositiveIntEnv2(env, name, fallback) {
235738
236070
  const raw = env[name]?.trim();
235739
236071
  if (!raw || !/^\d+$/.test(raw))
235740
236072
  return fallback;
@@ -235742,7 +236074,7 @@ var require_mcp_healer = __commonJS({
235742
236074
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
235743
236075
  }
235744
236076
  function getScriptHealMaxAttempts(env = process.env) {
235745
- return readPositiveIntEnv(env, "MCP_HEAL_SCRIPT_MAX_ATTEMPTS", DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS);
236077
+ return readPositiveIntEnv2(env, "MCP_HEAL_SCRIPT_MAX_ATTEMPTS", DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS);
235746
236078
  }
235747
236079
  exports.SCRIPT_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Playwright UI test. You will receive the test code, the error message, step-level results, and a screenshot of the failure state.
235748
236080
 
@@ -236078,7 +236410,7 @@ ${lines.join("\n")}
236078
236410
  const landingViolation = (tags ?? []).includes("@landing-violation");
236079
236411
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "strong") : (0, ai_provider_js_1.getModel)("strong");
236080
236412
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
236081
- const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)());
236413
+ const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
236082
236414
  const runNativeTest = options.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
236083
236415
  const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
236084
236416
  const groupId = `script-heal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -236900,10 +237232,10 @@ ${(verifyResult.logs ?? "").slice(0, 2e3)}`;
236900
237232
  var DEFAULT_MAX_PHASE2_ITERATIONS = 4;
236901
237233
  var DEFAULT_MAX_MCP_EXPLORE_ITERATIONS = 25;
236902
237234
  function getMcpHealPhase2MaxIterations(env = process.env) {
236903
- return readPositiveIntEnv(env, "MCP_HEAL_PHASE2_MAX_ITERATIONS", DEFAULT_MAX_PHASE2_ITERATIONS);
237235
+ return readPositiveIntEnv2(env, "MCP_HEAL_PHASE2_MAX_ITERATIONS", DEFAULT_MAX_PHASE2_ITERATIONS);
236904
237236
  }
236905
237237
  function getMcpHealExploreMaxIterations(env = process.env) {
236906
- return readPositiveIntEnv(env, "MCP_HEAL_EXPLORE_MAX_ITERATIONS", DEFAULT_MAX_MCP_EXPLORE_ITERATIONS);
237238
+ return readPositiveIntEnv2(env, "MCP_HEAL_EXPLORE_MAX_ITERATIONS", DEFAULT_MAX_MCP_EXPLORE_ITERATIONS);
236907
237239
  }
236908
237240
  var DEFAULT_MCP_HEAL_MAX_WALL_CLOCK_MS = 10 * 60 * 1e3;
236909
237241
  var DEFAULT_MCP_HEAL_MAX_TOTAL_AI_CALLS = 60;
@@ -236955,7 +237287,7 @@ Only explore the failing page and the area around the broken step.`;
236955
237287
  let lastVideo;
236956
237288
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
236957
237289
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
236958
- const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)());
237290
+ const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
236959
237291
  const runPhase2NativeTest = options?.phase2Harness?.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
236960
237292
  const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
236961
237293
  const groupId = `phase2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -237645,7 +237977,10 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
237645
237977
  pressed: se.pressed,
237646
237978
  headingLevel: se.headingLevel,
237647
237979
  currentState: se.currentState,
237648
- containerPath: se.containerPath || void 0
237980
+ containerPath: se.containerPath || void 0,
237981
+ href: se.href || void 0,
237982
+ value: se.value || void 0,
237983
+ options: se.options && se.options.length > 0 ? se.options : void 0
237649
237984
  }));
237650
237985
  const structuredPage = {
237651
237986
  route: pageUrl || "/",
@@ -237653,6 +237988,7 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
237653
237988
  pageType: "unknown",
237654
237989
  elements: collectedElements,
237655
237990
  formGroups: parsed.formGroups,
237991
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
237656
237992
  snapshotMetrics: {
237657
237993
  rawLineCount: parsed.rawLineCount,
237658
237994
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -238089,7 +238425,7 @@ ${testBlocks}
238089
238425
  - By iteration ${Math.floor(maxIterations * 0.3)}: you should have completed 1-2 tests
238090
238426
  - By iteration ${Math.floor(maxIterations * 0.7)}: you should be past the halfway point
238091
238427
  - At iteration ${Math.floor(maxIterations * 0.9)}: wrap up and call finish_capture`;
238092
- const aiClient = modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)();
238428
+ const aiClient = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
238093
238429
  const messages = [
238094
238430
  { role: "system", content: systemPrompt },
238095
238431
  { role: "user", content: "Start executing the tests. Navigate to the base URL and begin with Test 1." }
@@ -238272,7 +238608,10 @@ ${testBlocks}
238272
238608
  pressed: se.pressed,
238273
238609
  headingLevel: se.headingLevel,
238274
238610
  currentState: se.currentState,
238275
- containerPath: se.containerPath || void 0
238611
+ containerPath: se.containerPath || void 0,
238612
+ href: se.href || void 0,
238613
+ value: se.value || void 0,
238614
+ options: se.options && se.options.length > 0 ? se.options : void 0
238276
238615
  }));
238277
238616
  const structuredPage = {
238278
238617
  route: pageUrl || "/",
@@ -238280,6 +238619,7 @@ ${testBlocks}
238280
238619
  pageType: "unknown",
238281
238620
  elements: collectedElements,
238282
238621
  formGroups: parsed.formGroups,
238622
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
238283
238623
  snapshotMetrics: {
238284
238624
  rawLineCount: parsed.rawLineCount,
238285
238625
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -300069,8 +300409,9 @@ var require_capture_page_normalizer = __commonJS({
300069
300409
  let snapshotElements = [];
300070
300410
  let formGroups = [];
300071
300411
  let snapshotMetrics;
300412
+ let staticMessages;
300072
300413
  if (normalizedSnapshotResult.snapshotText) {
300073
- const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText);
300414
+ const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText, { richParse: context.richParse });
300074
300415
  snapshotMetrics = {
300075
300416
  rawLineCount: parsed.rawLineCount,
300076
300417
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -300099,9 +300440,13 @@ var require_capture_page_normalizer = __commonJS({
300099
300440
  pressed: se.pressed,
300100
300441
  headingLevel: se.headingLevel,
300101
300442
  currentState: se.currentState,
300102
- containerPath: se.containerPath || void 0
300443
+ containerPath: se.containerPath || void 0,
300444
+ href: se.href || void 0,
300445
+ value: se.value || void 0,
300446
+ options: se.options && se.options.length > 0 ? se.options : void 0
300103
300447
  }));
300104
300448
  formGroups = parsed.formGroups;
300449
+ staticMessages = parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0;
300105
300450
  }
300106
300451
  const aiElements = Array.isArray(toolArgs.elements) ? toolArgs.elements.map(normalizeAiElement) : [];
300107
300452
  const noYamlKinds = /* @__PURE__ */ new Set(["missing", "timeout", "error", "empty"]);
@@ -300197,6 +300542,7 @@ var require_capture_page_normalizer = __commonJS({
300197
300542
  } : void 0,
300198
300543
  readinessSignals,
300199
300544
  viewportHints,
300545
+ staticMessages,
300200
300546
  provenCommands: provenCmds,
300201
300547
  observations: rawObservations,
300202
300548
  routeCorrected,
@@ -300505,6 +300851,10 @@ var require_perception_enricher = __commonJS({
300505
300851
  const e = {};
300506
300852
  if (p.rowContext)
300507
300853
  e.rowContext = p.rowContext;
300854
+ if (p.rowContext && p.rowRole)
300855
+ e.rowRole = p.rowRole;
300856
+ if (p.inputType)
300857
+ e.inputType = p.inputType;
300508
300858
  if (p.position && p.position !== "in-view")
300509
300859
  e.position = p.position;
300510
300860
  if (p.occluded)
@@ -300754,6 +301104,23 @@ var require_perception_enricher = __commonJS({
300754
301104
  const cls = (el.getAttribute("class") || "").toLowerCase();
300755
301105
  return /(^|[\s_-])(card|row|item|listitem|cell|tile)([\s_-]|$)/.test(cls);
300756
301106
  };
301107
+ const rowRoleOf = (el) => {
301108
+ const explicit = (el.getAttribute("role") || "").toLowerCase().trim();
301109
+ if (explicit) {
301110
+ return explicit === "row" || explicit === "listitem" || explicit === "article" || explicit === "option" ? explicit : null;
301111
+ }
301112
+ const tag = el.tagName;
301113
+ if (tag === "TR") {
301114
+ const tbl = el.closest("table");
301115
+ const tblRole = tbl ? (tbl.getAttribute("role") || "").toLowerCase().trim() : "";
301116
+ return tblRole === "presentation" || tblRole === "none" ? null : "row";
301117
+ }
301118
+ if (tag === "LI")
301119
+ return el.closest('ul:not([role]),ol:not([role]),menu:not([role]),[role="list"]') ? "listitem" : null;
301120
+ if (tag === "ARTICLE")
301121
+ return "article";
301122
+ return null;
301123
+ };
300757
301124
  const rowContextOf = (el, ownNameLower) => {
300758
301125
  let cur = el.parentElement;
300759
301126
  let depth = 0;
@@ -300772,7 +301139,7 @@ var require_perception_enricher = __commonJS({
300772
301139
  txt = txt.slice(0, 80);
300773
301140
  const low = txt.toLowerCase();
300774
301141
  if (txt && low !== ownNameLower && low.length > 1)
300775
- return txt;
301142
+ return { text: txt, role: rowRoleOf(cur) };
300776
301143
  return null;
300777
301144
  }
300778
301145
  cur = cur.parentElement;
@@ -300862,11 +301229,14 @@ var require_perception_enricher = __commonJS({
300862
301229
  const name = accName(el);
300863
301230
  const lname = lower(name);
300864
301231
  const rect = vis.rect;
301232
+ const rc = rowContextOf(el, lname);
300865
301233
  out.push({
300866
301234
  domOrder: myOrder,
300867
301235
  roleClass: roleClassOf(tag, roleAttr, inputType),
300868
301236
  name: lname,
300869
- rowContext: rowContextOf(el, lname),
301237
+ rowContext: rc ? rc.text : null,
301238
+ rowRole: rc ? rc.role : null,
301239
+ inputType: inputType || null,
300870
301240
  position: positionOf(rect),
300871
301241
  occluded: occlusionOf(el, rect),
300872
301242
  cursorPointer,
@@ -300903,11 +301273,14 @@ var require_perception_enricher = __commonJS({
300903
301273
  if (!name)
300904
301274
  continue;
300905
301275
  const lname = lower(name);
301276
+ const rc = rowContextOf(el, lname);
300906
301277
  out.push({
300907
301278
  domOrder: order++,
300908
301279
  roleClass: "button",
300909
301280
  name: lname,
300910
- rowContext: rowContextOf(el, lname),
301281
+ rowContext: rc ? rc.text : null,
301282
+ rowRole: rc ? rc.role : null,
301283
+ inputType: null,
300911
301284
  position: "in-view",
300912
301285
  occluded: false,
300913
301286
  cursorPointer: true,
@@ -301409,7 +301782,7 @@ var require_discover_explorer = __commonJS({
301409
301782
  var DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP = 100;
301410
301783
  var DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY = 100;
301411
301784
  var DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL = 40;
301412
- function readPositiveIntEnv(env, name, fallback) {
301785
+ function readPositiveIntEnv2(env, name, fallback) {
301413
301786
  const raw = env[name]?.trim();
301414
301787
  if (!raw || !/^\d+$/.test(raw))
301415
301788
  return fallback;
@@ -301540,15 +301913,15 @@ var require_discover_explorer = __commonJS({
301540
301913
  }
301541
301914
  function getMaxIterations(mode, incremental, env = process.env) {
301542
301915
  if (mode === "survey" && incremental) {
301543
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_INCREMENTAL", DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL);
301916
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_INCREMENTAL", DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL);
301544
301917
  }
301545
301918
  if (mode === "survey") {
301546
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_SURVEY", DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY);
301919
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_SURVEY", DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY);
301547
301920
  }
301548
301921
  if (mode === "deep") {
301549
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEEP", DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP);
301922
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEEP", DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP);
301550
301923
  }
301551
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEFAULT", DEFAULT_MAX_EXPLORE_ITERATIONS_DEFAULT);
301924
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEFAULT", DEFAULT_MAX_EXPLORE_ITERATIONS_DEFAULT);
301552
301925
  }
301553
301926
  function updatePendingRouteScreenshot(pending, { imageData, detectedRoute, fallbackRoute }) {
301554
301927
  if (imageData) {
@@ -302274,7 +302647,7 @@ ${inboxPromptBlock}`;
302274
302647
  const browserCreds = variableContext.allTestVariables ?? variableContext.publicTestVariables;
302275
302648
  const { secrets: browserSecretsForHint } = (0, credential_tools_js_1.partitionTestVariables)(browserCreds);
302276
302649
  const hasLoginCredentials = Object.keys(browserSecretsForHint).length > 0;
302277
- const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)();
302650
+ const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
302278
302651
  const isDeepMode = options?.mode === "deep";
302279
302652
  const maxIterations = config?.maxIterationsOverride && config.maxIterationsOverride > 0 ? config.maxIterationsOverride : getMaxIterations(options?.mode ?? "full", options?.incremental);
302280
302653
  const configKeepTail = isDeepMode ? Math.ceil(ctxConfig.keepTail * 1.5) : ctxConfig.keepTail;
@@ -304813,7 +305186,7 @@ var require_auth_capture_run = __commonJS({
304813
305186
  var scrub_credentials_js_1 = require_scrub_credentials();
304814
305187
  var credential_tools_js_1 = require_credential_tools();
304815
305188
  var DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS = 12;
304816
- function readPositiveIntEnv(env, name, fallback) {
305189
+ function readPositiveIntEnv2(env, name, fallback) {
304817
305190
  const raw = env[name]?.trim();
304818
305191
  if (!raw || !/^\d+$/.test(raw))
304819
305192
  return fallback;
@@ -304821,7 +305194,7 @@ var require_auth_capture_run = __commonJS({
304821
305194
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
304822
305195
  }
304823
305196
  function getLoginCaptureMaxIterations(env = process.env) {
304824
- return readPositiveIntEnv(env, "AUTH_CAPTURE_LOGIN_MAX_ITERATIONS", DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS);
305197
+ return readPositiveIntEnv2(env, "AUTH_CAPTURE_LOGIN_MAX_ITERATIONS", DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS);
304825
305198
  }
304826
305199
  var CAPTURE_RATE_LIMIT_MAX_RETRIES = 2;
304827
305200
  var CAPTURE_RATE_LIMIT_MAX_DELAY_MS = 8e3;
@@ -305104,7 +305477,7 @@ ${keyLines || "- (none provided)"}
305104
305477
  }
305105
305478
  var DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS = 18;
305106
305479
  function getRegisterCaptureMaxIterations(env = process.env) {
305107
- return readPositiveIntEnv(env, "AUTH_CAPTURE_REGISTER_MAX_ITERATIONS", DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS);
305480
+ return readPositiveIntEnv2(env, "AUTH_CAPTURE_REGISTER_MAX_ITERATIONS", DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS);
305108
305481
  }
305109
305482
  function buildRegisterCaptureSystemPrompt(baseUrl, availableKeys, maxIterations = getRegisterCaptureMaxIterations()) {
305110
305483
  const keyLines = availableKeys.map((k) => `- \`${k}\``).join("\n");
@@ -311334,6 +311707,7 @@ var require_mobile_exploration_state = __commonJS({
311334
311707
  Object.defineProperty(exports, "__esModule", { value: true });
311335
311708
  exports.MobileExplorationState = void 0;
311336
311709
  exports.deriveScreenId = deriveScreenId;
311710
+ var mobile_observation_js_1 = require_mobile_observation();
311337
311711
  var DEFAULT_STALE_THRESHOLD = 4;
311338
311712
  function deriveScreenId(signal) {
311339
311713
  const namespace = signal.activity ?? signal.bundleId;
@@ -311559,21 +311933,33 @@ var require_mobile_exploration_state = __commonJS({
311559
311933
  * Build the planner evidence shape: one `CollectedPageData` per screen, with
311560
311934
  * the screen-id carried in BOTH `route` (the on-the-wire positional field the
311561
311935
  * server keys screenshots by) AND the explicit `screenId` field (per the
311562
- * Phase-0 `CollectedPageData.screenId` contract). `elements` is left empty —
311563
- * the web-shaped `CollectedPageElement[]` is not the mobile element shape;
311564
- * the mobile planner reads screen identity + names from this evidence and the
311565
- * richer per-element data flows through the generator's own re-drive, not
311566
- * through this map. Insertion order matches first-seen order.
311936
+ * Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
311937
+ * the screen's parsed actionable elements into the platform-neutral
311938
+ * `CollectedPageElement[]` shape the server persists as SiteElements without
311939
+ * this, mobile screens land with zero elements (coverage always 0%, no element
311940
+ * intelligence). `triggersNavigation` is derived from transition-source
311941
+ * membership: an element is flagged when its ref drove a transition FROM this
311942
+ * screen. Insertion order matches first-seen order.
311567
311943
  */
311568
311944
  buildInMemoryScreenMap() {
311569
311945
  const pages = [];
311570
311946
  const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
311947
+ const navRefsByScreen = /* @__PURE__ */ new Map();
311948
+ for (const transition of this.transitions) {
311949
+ if (!transition.viaRef)
311950
+ continue;
311951
+ const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
311952
+ set.add(transition.viaRef);
311953
+ navRefsByScreen.set(transition.fromScreenId, set);
311954
+ }
311571
311955
  for (const state2 of ordered) {
311572
311956
  const page = {
311573
311957
  route: state2.screenId,
311574
311958
  screenId: state2.screenId,
311575
311959
  pageType: state2.screenType ?? "UNKNOWN",
311576
- elements: []
311960
+ elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
311961
+ navigationRefs: navRefsByScreen.get(state2.screenId)
311962
+ })
311577
311963
  };
311578
311964
  if (state2.screenName) {
311579
311965
  page.title = state2.screenName;
@@ -311603,6 +311989,7 @@ var require_mobile_discovery_agent = __commonJS({
311603
311989
  "../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports) {
311604
311990
  "use strict";
311605
311991
  Object.defineProperty(exports, "__esModule", { value: true });
311992
+ exports.getMobileExploreBudget = getMobileExploreBudget;
311606
311993
  exports.runMobileDiscoveryOnRunner = runMobileDiscoveryOnRunner2;
311607
311994
  var mobile_explorer_js_1 = require_mobile_explorer();
311608
311995
  var mobile_discovery_prompts_js_1 = require_mobile_discovery_prompts();
@@ -311616,8 +312003,37 @@ var require_mobile_discovery_agent = __commonJS({
311616
312003
  const message = checkpointErrorMessage(err);
311617
312004
  return /Discovery plan checkpoint rejected|Discovery run is .*not RUNNING|not RUNNING|cancelled/i.test(message);
311618
312005
  }
311619
- var EXPLORE_BUDGET_DEEP = 60;
311620
- var EXPLORE_BUDGET_SURVEY = 40;
312006
+ var MOBILE_EXPLORE_BUDGET_SURVEY_FIRST = 90;
312007
+ var MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL = 40;
312008
+ var MOBILE_EXPLORE_BUDGET_DEEP = 60;
312009
+ var MOBILE_EXPLORE_BUDGET_FULL = 90;
312010
+ function readPositiveIntEnv2(name, fallback, env = process.env) {
312011
+ const raw = env[name]?.trim();
312012
+ if (!raw || !/^\d+$/.test(raw))
312013
+ return fallback;
312014
+ const parsed = Number(raw);
312015
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
312016
+ }
312017
+ function getMobileExploreBudget(mode, incremental, env = process.env) {
312018
+ if (mode === "deep")
312019
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_DEEP", MOBILE_EXPLORE_BUDGET_DEEP, env);
312020
+ if (mode === "full")
312021
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_FULL", MOBILE_EXPLORE_BUDGET_FULL, env);
312022
+ if (incremental) {
312023
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY_INCREMENTAL", MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL, env);
312024
+ }
312025
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY", MOBILE_EXPLORE_BUDGET_SURVEY_FIRST, env);
312026
+ }
312027
+ function compactExistingScreens(siteMap) {
312028
+ if (!siteMap || !Array.isArray(siteMap.pages) || siteMap.pages.length === 0)
312029
+ return void 0;
312030
+ const screens = siteMap.pages.filter((p) => typeof p.route === "string" && p.route.trim().length > 0).map((p) => ({
312031
+ screenId: p.route,
312032
+ ...p.title ? { name: p.title } : {},
312033
+ ...p.pageType ? { screenType: p.pageType } : {}
312034
+ }));
312035
+ return screens.length > 0 ? screens : void 0;
312036
+ }
311621
312037
  function platformForMedium(medium) {
311622
312038
  return medium === "IOS_NATIVE" ? "IOS" : "ANDROID";
311623
312039
  }
@@ -311636,6 +312052,9 @@ var require_mobile_discovery_agent = __commonJS({
311636
312052
  // Native screenshots are multi-megabyte PNGs. The live mirror streams them
311637
312053
  // through heartbeat telemetry; discovery checkpoints keep the screen graph
311638
312054
  // and transitions so large Android surveys cannot exceed server body limits.
312055
+ // D-D6 — pageScreenshotKeys is a small screen-id → storage-key map (NOT base64),
312056
+ // so it DOES ride the checkpoint: the server links each key to SitePage.screenshotKey.
312057
+ ...explore.pageScreenshotKeys && Object.keys(explore.pageScreenshotKeys).length > 0 ? { pageScreenshotKeys: explore.pageScreenshotKeys } : {},
311639
312058
  exploreStats: explore.exploreStats,
311640
312059
  healthObservations: explore.healthObservations
311641
312060
  };
@@ -311650,6 +312069,7 @@ var require_mobile_discovery_agent = __commonJS({
311650
312069
  const onLog = callbacks?.onLog;
311651
312070
  const onAICall = callbacks?.onAICall;
311652
312071
  const onScreenshot = callbacks?.onScreenshot;
312072
+ const onScreenshotUpload = callbacks?.onScreenshotUpload;
311653
312073
  const runExplore = callbacks?.runExplore ?? mobile_explorer_js_1.runMobileExplorePhase;
311654
312074
  const runPlan = callbacks?.runPlan ?? mobile_discovery_prompts_js_1.runMobileAIPlanner;
311655
312075
  const runGenerate = callbacks?.runGenerate ?? mobile_generator_js_1.runMobileGeneratePhase;
@@ -311676,15 +312096,77 @@ var require_mobile_discovery_agent = __commonJS({
311676
312096
  elementsFound: 0,
311677
312097
  transitionsFound: 0
311678
312098
  };
312099
+ const resumeSavedScenarios = ctx.resume?.savedPlan?.scenarios ?? [];
312100
+ const isPlanReuseResume = mode !== "survey" && resumeSavedScenarios.length > 0;
311679
312101
  try {
312102
+ if (isPlanReuseResume) {
312103
+ const alreadyGenerated = ctx.resume?.alreadyGeneratedScenarioNames ?? [];
312104
+ log2(`
312105
+ \u2500\u2500 Resume (plan-reuse): ${resumeSavedScenarios.length} saved scenario(s); ${alreadyGenerated.length} already generated. Skipping explore + plan; generating only the remainder. \u2500\u2500`);
312106
+ const scenarios2 = resumeSavedScenarios;
312107
+ const plans2 = toPlans(scenarios2);
312108
+ if (callbacks?.onPlanReady) {
312109
+ try {
312110
+ await callbacks.onPlanReady({ scenarios: scenarios2, featureGroups: void 0, fallbackUsed: false });
312111
+ log2("Plan checkpoint: reused scenario plan re-POSTed to server");
312112
+ } catch (err) {
312113
+ const message = checkpointErrorMessage(err);
312114
+ if (isTerminalDiscoveryCheckpointError(err)) {
312115
+ log2(`Plan checkpoint rejected for inactive discovery run: ${message}`);
312116
+ throw err;
312117
+ }
312118
+ log2(`Plan checkpoint POST failed (non-fatal): ${message}`);
312119
+ }
312120
+ }
312121
+ let paused = false;
312122
+ let pauseReason;
312123
+ let remainingScenarios;
312124
+ const tests2 = await runGenerate({
312125
+ scenarios: scenarios2,
312126
+ driver,
312127
+ ctx,
312128
+ onLog: (line) => log2(line),
312129
+ onAICall,
312130
+ onScreenshot,
312131
+ onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
312132
+ skipScenarioNames: alreadyGenerated,
312133
+ onInfraStop: (info) => {
312134
+ paused = true;
312135
+ pauseReason = info.reason;
312136
+ remainingScenarios = info.remainingScenarioNames;
312137
+ }
312138
+ });
312139
+ const duration2 = (Date.now() - startTime) / 1e3;
312140
+ const summary2 = `Mobile resume complete: generated ${tests2.length} test(s) from ${scenarios2.length} saved scenario(s) in ${duration2.toFixed(1)}s${paused ? " \u2014 PAUSED again on AI provider overload" : ""}`;
312141
+ log2(`
312142
+ === Mobile Discovery Resume Complete (${duration2.toFixed(1)}s) ===`);
312143
+ log2(summary2);
312144
+ return {
312145
+ collectedPages: [],
312146
+ collectedTransitions: [],
312147
+ tests: tests2,
312148
+ plans: plans2,
312149
+ scenarios: scenarios2.length > 0 ? scenarios2 : void 0,
312150
+ logs: logs2.join("\n"),
312151
+ screenshots,
312152
+ summary: summary2,
312153
+ exploreStats: { pagesDiscovered: 0, pagesVisited: 0, elementsFound: 0, transitionsFound: 0 },
312154
+ duration: duration2,
312155
+ mode,
312156
+ ...paused ? { paused: true, pauseReason, remainingScenarios } : {}
312157
+ // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
312158
+ };
312159
+ }
311680
312160
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
311681
312161
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
311682
- const maxIterations = mode === "deep" ? EXPLORE_BUDGET_DEEP : EXPLORE_BUDGET_SURVEY;
312162
+ const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
312163
+ const existingScreens = compactExistingScreens(ctx.existingSiteMap);
311683
312164
  const explore = await runExplore({
311684
312165
  ctx,
311685
312166
  driver,
311686
312167
  state: state2,
311687
312168
  config: { maxIterations },
312169
+ existingScreens,
311688
312170
  onLog: (line) => log2(line),
311689
312171
  onAICall,
311690
312172
  onScreenshot: (base64) => {
@@ -311694,7 +312176,8 @@ var require_mobile_discovery_agent = __commonJS({
311694
312176
  onScreenshot?.(base64);
311695
312177
  } catch {
311696
312178
  }
311697
- }
312179
+ },
312180
+ onScreenshotUpload
311698
312181
  });
311699
312182
  const collectedTransitions = toCollectedTransitions(explore);
311700
312183
  partialCollectedPages = explore.collectedPages;
@@ -311795,6 +312278,9 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311795
312278
  };
311796
312279
  }
311797
312280
  log2("\n\u2500\u2500 Phase 3: GENERATE \u2500\u2500");
312281
+ let generatePaused = false;
312282
+ let generatePauseReason;
312283
+ let generateRemainingScenarios;
311798
312284
  const tests = await runGenerate({
311799
312285
  scenarios,
311800
312286
  driver,
@@ -311802,7 +312288,15 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311802
312288
  onLog: (line) => log2(line),
311803
312289
  onAICall,
311804
312290
  onScreenshot,
311805
- onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
312291
+ onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
312292
+ // Legacy resume (no saved plan): still skip scenarios already generated by the
312293
+ // paused run so the AI isn't re-invoked for them. Undefined on a fresh run.
312294
+ skipScenarioNames: ctx.resume?.alreadyGeneratedScenarioNames,
312295
+ onInfraStop: (info) => {
312296
+ generatePaused = true;
312297
+ generatePauseReason = info.reason;
312298
+ generateRemainingScenarios = info.remainingScenarioNames;
312299
+ }
311806
312300
  });
311807
312301
  const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
311808
312302
  log2(`
@@ -311818,7 +312312,7 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
311818
312312
  };
311819
312313
  }
311820
312314
  const duration = (Date.now() - startTime) / 1e3;
311821
- const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s`;
312315
+ const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s${generatePaused ? " \u2014 PAUSED on AI provider overload (resumable)" : ""}`;
311822
312316
  log2(`
311823
312317
  === Mobile Discovery Pipeline Complete (${duration.toFixed(1)}s) ===`);
311824
312318
  log2(summary);
@@ -311843,7 +312337,11 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
311843
312337
  summary,
311844
312338
  exploreStats: explore.exploreStats,
311845
312339
  duration,
311846
- mode
312340
+ mode,
312341
+ // D-D4 — a circuit-breaker trip PAUSES the run (resumable) instead of
312342
+ // finalizing COMPLETED/FAILED. The server's isDiscoveryPaused reads these
312343
+ // (forwarded by runner.ts buildFinalBody, byte-identical to the web path).
312344
+ ...generatePaused ? { paused: true, pauseReason: generatePauseReason, remainingScenarios: generateRemainingScenarios } : {}
311847
312345
  // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
311848
312346
  };
311849
312347
  } catch (fatalErr) {
@@ -311889,6 +312387,7 @@ var require_discover_agent = __commonJS({
311889
312387
  Object.defineProperty(exports, "__esModule", { value: true });
311890
312388
  exports.runMobileDiscoveryOnRunner = exports.buildInMemorySiteMap = void 0;
311891
312389
  exports.runCanvasPreflight = runCanvasPreflight;
312390
+ exports.getFatalExploreError = getFatalExploreError;
311892
312391
  exports.runSiteDiscoveryOnRunner = runSiteDiscoveryOnRunner2;
311893
312392
  exports.runFeatureDiscoveryOnRunner = runFeatureDiscoveryOnRunner2;
311894
312393
  var browser_config_js_1 = require_browser_config();
@@ -312071,7 +312570,7 @@ Respond with ONLY valid JSON:
312071
312570
  "keyFindings": "What was discovered - forms, validations, behaviors. Mark inferred items with '(inferred, not tested)'.",
312072
312571
  "testingNotes": "Gotchas, validation behaviors, error messages. List visible-but-untested routes as candidates for future exploration."
312073
312572
  }`;
312074
- const client = modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)();
312573
+ const client = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
312075
312574
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
312076
312575
  const callStart = Date.now();
312077
312576
  const response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => client.chat.completions.create({
@@ -312202,6 +312701,10 @@ Respond with ONLY valid JSON:
312202
312701
  });
312203
312702
  return timed.catch(() => null);
312204
312703
  }
312704
+ function getFatalExploreError(exploreResult) {
312705
+ const prefix = "Fatal error:";
312706
+ return exploreResult.summary.startsWith(prefix) ? exploreResult.summary.slice(prefix.length).trim() || exploreResult.summary : void 0;
312707
+ }
312205
312708
  async function runSiteDiscoveryOnRunner2(ctx, onLog, onAICall, sharedBrowser, onScreenshot, callbackOptions) {
312206
312709
  const logs2 = [];
312207
312710
  const screenshots = [];
@@ -312386,6 +312889,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
312386
312889
  if (loginRunP)
312387
312890
  await loginRunP;
312388
312891
  const duration2 = (Date.now() - startTime) / 1e3;
312892
+ const fatalExploreError = getFatalExploreError(exploreResult);
312389
312893
  return {
312390
312894
  collectedPages: exploreResult.collectedPages,
312391
312895
  collectedTransitions: exploreResult.collectedTransitions,
@@ -312396,6 +312900,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
312396
312900
  pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
312397
312901
  pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
312398
312902
  summary: exploreResult.summary || "Exploration completed but no page data captured.",
312903
+ error: fatalExploreError,
312399
312904
  exploreStats: {
312400
312905
  pagesDiscovered: exploreResult.pagesDiscovered,
312401
312906
  pagesVisited: exploreResult.pagesVisited,
@@ -312937,6 +313442,7 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
312937
313442
  discoveryApiCalls = earlyClose.apiCalls;
312938
313443
  postCloseHealthSummary = earlyClose.healthSummary;
312939
313444
  const duration2 = (Date.now() - startTime) / 1e3;
313445
+ const fatalExploreError = getFatalExploreError(exploreResult);
312940
313446
  return {
312941
313447
  collectedPages: exploreResult.collectedPages,
312942
313448
  collectedTransitions: exploreResult.collectedTransitions,
@@ -312947,7 +313453,8 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
312947
313453
  healthObservations: earlyClose.healthSummary,
312948
313454
  pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
312949
313455
  pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
312950
- summary: "Feature exploration completed but no page data captured.",
313456
+ summary: fatalExploreError ? exploreResult.summary : "Feature exploration completed but no page data captured.",
313457
+ error: fatalExploreError,
312951
313458
  exploreStats: {
312952
313459
  pagesDiscovered: exploreResult.pagesDiscovered,
312953
313460
  pagesVisited: exploreResult.pagesVisited,
@@ -326336,7 +326843,7 @@ var require_package4 = __commonJS({
326336
326843
  "package.json"(exports, module) {
326337
326844
  module.exports = {
326338
326845
  name: "@validate.qa/runner",
326339
- version: "1.0.12",
326846
+ version: "1.0.13",
326340
326847
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
326341
326848
  bin: {
326342
326849
  "validate-runner": "dist/cli.js"
@@ -326344,7 +326851,7 @@ var require_package4 = __commonJS({
326344
326851
  scripts: {
326345
326852
  build: "tsup",
326346
326853
  dev: "tsx src/cli.ts",
326347
- "test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts",
326854
+ "test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts src/services/mobile/mobile-host-proxy.test.ts",
326348
326855
  prepublishOnly: "npm run build"
326349
326856
  },
326350
326857
  files: [
@@ -326571,8 +327078,10 @@ async function startAppiumServer(options) {
326571
327078
  return serverState;
326572
327079
  }
326573
327080
  const port = options?.port ?? APPIUM_PORT;
326574
- const drivers = options?.drivers ?? ["xcuitest", "uiautomator2"];
326575
- const args = ["--port", String(port), "--use-drivers", drivers.join(",")];
327081
+ const args = ["--port", String(port)];
327082
+ if (options?.drivers && options.drivers.length > 0) {
327083
+ args.push("--use-drivers", options.drivers.join(","));
327084
+ }
326576
327085
  intentionalStop = false;
326577
327086
  lastStartOptions = { ...lastStartOptions, ...options };
326578
327087
  if (appiumGaveUp) {
@@ -326582,11 +327091,12 @@ async function startAppiumServer(options) {
326582
327091
  killPort(port);
326583
327092
  return new Promise((resolve, reject) => {
326584
327093
  try {
326585
- appiumProcess = spawn("appium", args, {
327094
+ const child = spawn("appium", args, {
326586
327095
  stdio: ["pipe", "pipe", "pipe"],
326587
327096
  env: { ...process.env },
326588
327097
  detached: false
326589
327098
  });
327099
+ appiumProcess = child;
326590
327100
  serverState.pid = appiumProcess.pid ?? null;
326591
327101
  serverState.port = port;
326592
327102
  appiumProcess.stdout?.on("data", (data) => {
@@ -326603,7 +327113,11 @@ async function startAppiumServer(options) {
326603
327113
  options?.onLog?.(line);
326604
327114
  }
326605
327115
  });
326606
- appiumProcess.on("exit", (code, signal) => {
327116
+ child.on("exit", (code, signal) => {
327117
+ if (appiumProcess !== child) {
327118
+ appendLog(`[appium] Ignoring exit (code=${code}, signal=${signal}) from a superseded process`);
327119
+ return;
327120
+ }
326607
327121
  appendLog(`[appium] Process exited (code=${code}, signal=${signal})`);
326608
327122
  const currentPort = serverState.port;
326609
327123
  const currentRestartCount = serverState.restartCount;
@@ -326642,7 +327156,11 @@ async function startAppiumServer(options) {
326642
327156
  startupAbort.abort();
326643
327157
  reject(err);
326644
327158
  };
326645
- appiumProcess.on("error", (err) => {
327159
+ child.on("error", (err) => {
327160
+ if (appiumProcess !== child) {
327161
+ appendLog(`[appium] Ignoring error from a superseded process: ${err.message}`);
327162
+ return;
327163
+ }
326646
327164
  appendLog(`[appium] Process error: ${err.message}`);
326647
327165
  resetServerState(port);
326648
327166
  settleReject(new Error(`Failed to start Appium: ${err.message}. Is appium installed? Run: npm i -g appium`));
@@ -326761,6 +327279,7 @@ function runCommand(command, args, timeout = 1e4) {
326761
327279
  }
326762
327280
  var realIOSProfilerNegativeUntil = 0;
326763
327281
  var REAL_IOS_PROFILER_TTL_MS = 6e4;
327282
+ var REAL_IOS_NO_UDID_HINT = "no paired UDID \u2014 install libimobiledevice (brew install libimobiledevice) and trust this computer on the device";
326764
327283
  function discoverIOSSimulators() {
326765
327284
  if (platform() !== "darwin") return [];
326766
327285
  const json = runCommand("xcrun", ["simctl", "list", "devices", "--json"]);
@@ -326770,7 +327289,8 @@ function discoverIOSSimulators() {
326770
327289
  const devices = [];
326771
327290
  for (const [runtime, runtimeDevices] of Object.entries(parsed.devices)) {
326772
327291
  const versionMatch = runtime.match(/iOS[- ](\d+(?:[-.]\d+)*)/i);
326773
- const platformVersion = versionMatch ? versionMatch[1].replace(/-/g, ".") : "unknown";
327292
+ if (!versionMatch) continue;
327293
+ const platformVersion = versionMatch[1].replace(/-/g, ".");
326774
327294
  for (const device of runtimeDevices) {
326775
327295
  const state2 = device.state === "Booted" ? "BOOTED" : device.state === "Shutdown" ? "SHUTDOWN" : "LOADING";
326776
327296
  if (state2 === "BOOTED") {
@@ -326843,11 +327363,11 @@ function discoverRealIOSDevices() {
326843
327363
  const version = runCommand("ideviceinfo", ["-u", serial, "-k", "ProductVersion"]) || "unknown";
326844
327364
  devices.push({
326845
327365
  id: serial,
326846
- name,
327366
+ name: `${name} (${REAL_IOS_NO_UDID_HINT})`,
326847
327367
  platform: "IOS",
326848
327368
  platformVersion: version,
326849
327369
  state: "BOOTED",
326850
- isAvailable: true,
327370
+ isAvailable: false,
326851
327371
  isPhysical: true
326852
327372
  });
326853
327373
  }
@@ -326863,11 +327383,11 @@ function discoverRealIOSDevices() {
326863
327383
  if (hasIPhone || hasIPad) {
326864
327384
  devices.push({
326865
327385
  id: "usb-ios-device",
326866
- name: hasIPhone ? "iPhone (USB)" : "iPad (USB)",
327386
+ name: `${hasIPhone ? "iPhone (USB)" : "iPad (USB)"} (${REAL_IOS_NO_UDID_HINT})`,
326867
327387
  platform: "IOS",
326868
327388
  platformVersion: "unknown",
326869
327389
  state: "BOOTED",
326870
- isAvailable: true,
327390
+ isAvailable: false,
326871
327391
  isPhysical: true
326872
327392
  });
326873
327393
  }
@@ -326945,7 +327465,9 @@ function discoverAndroidDevices() {
326945
327465
  const adbDevices = parseAdbDevices();
326946
327466
  return adbDevices.filter((d) => d.type === "device" || d.type === "unauthorized").map((d) => {
326947
327467
  const isEmulator = d.serial.startsWith("emulator-");
326948
- const isAvailable = d.type === "device";
327468
+ const authorized = d.type === "device";
327469
+ const bootCompleted = authorized && getAndroidProp(d.serial, "sys.boot_completed") === "1";
327470
+ const isAvailable = authorized && bootCompleted;
326949
327471
  const name = d.model || d.device || (isEmulator ? "Android Emulator" : "Android Device");
326950
327472
  const platformVersion = isAvailable ? getAndroidProp(d.serial, "ro.build.version.release") || "unknown" : "unknown";
326951
327473
  return {
@@ -326953,7 +327475,10 @@ function discoverAndroidDevices() {
326953
327475
  name,
326954
327476
  platform: "ANDROID",
326955
327477
  platformVersion,
326956
- state: "BOOTED",
327478
+ // LOADING = authorized but still booting; an unauthorized device stays
327479
+ // BOOTED (isAvailable false) so the dashboard shows it with an
327480
+ // "accept USB debugging" hint rather than hiding it.
327481
+ state: authorized && !bootCompleted ? "LOADING" : "BOOTED",
326957
327482
  isAvailable,
326958
327483
  // Mark if it's a real device vs emulator
326959
327484
  ...isEmulator ? {} : { isPhysical: true }
@@ -326999,15 +327524,25 @@ function discoverAllDevices(options) {
326999
327524
  }
327000
327525
  return [...iosDevices, ...androidDevices];
327001
327526
  }
327527
+ var DEVICE_SNAPSHOT_TTL_MS = 8e3;
327528
+ var cachedDeviceSnapshot = null;
327529
+ function getCachedDeviceSnapshot() {
327530
+ const now = Date.now();
327531
+ if (cachedDeviceSnapshot && now - cachedDeviceSnapshot.at < DEVICE_SNAPSHOT_TTL_MS) {
327532
+ return cachedDeviceSnapshot.devices;
327533
+ }
327534
+ const devices = discoverAllDevices();
327535
+ cachedDeviceSnapshot = { at: now, devices };
327536
+ return devices;
327537
+ }
327002
327538
  function getRunnerCapabilities() {
327003
- const iosDevices = discoverIOSDevices();
327004
- const androidDevices = discoverAndroidDevices();
327539
+ const devices = getCachedDeviceSnapshot();
327005
327540
  const isReadyDevice = (device) => device.isAvailable && device.state === "BOOTED";
327006
327541
  return {
327007
327542
  web: true,
327008
327543
  // Always capable of web testing via Playwright
327009
- ios: iosDevices.some(isReadyDevice),
327010
- android: androidDevices.some(isReadyDevice)
327544
+ ios: devices.some((d) => d.platform === "IOS" && isReadyDevice(d)),
327545
+ android: devices.some((d) => d.platform === "ANDROID" && isReadyDevice(d))
327011
327546
  };
327012
327547
  }
327013
327548
 
@@ -327146,6 +327681,9 @@ var MitmProxy = class {
327146
327681
  await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
327147
327682
  await server3.on("request", (req) => this.onRequest(req));
327148
327683
  await server3.on("response", (res) => this.onResponse(res));
327684
+ await server3.on("abort", (req) => {
327685
+ this.inFlight.delete(req.id);
327686
+ });
327149
327687
  await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
327150
327688
  await server3.on("tls-passthrough-opened", (event) => this.onTlsPassthroughOpened(event));
327151
327689
  await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
@@ -327442,6 +327980,107 @@ async function iosSimRemoveCa(deviceId, onLog) {
327442
327980
  function errMsg(err) {
327443
327981
  return err instanceof Error ? err.message : String(err);
327444
327982
  }
327983
+ function parsePrimaryNetworkService(stdout) {
327984
+ for (const line of stdout.split("\n")) {
327985
+ const m = line.match(/^\(\d+\)\s+(.+)$/);
327986
+ if (m) return m[1].trim();
327987
+ }
327988
+ return null;
327989
+ }
327990
+ function parseWebProxyState(stdout) {
327991
+ const field = (label) => {
327992
+ const m = stdout.match(new RegExp(`^${label}:[ \\t]*(.*)$`, "im"));
327993
+ return m ? m[1].trim() : "";
327994
+ };
327995
+ return {
327996
+ enabled: field("Enabled").toLowerCase() === "yes",
327997
+ server: field("Server"),
327998
+ port: field("Port")
327999
+ };
328000
+ }
328001
+ function buildHostProxySetCommands(service, host, port) {
328002
+ const portStr = String(port);
328003
+ return [
328004
+ ["-setwebproxy", service, host, portStr],
328005
+ ["-setsecurewebproxy", service, host, portStr]
328006
+ ];
328007
+ }
328008
+ function buildProxyRestoreArgs(setCmd, stateCmd, service, prior) {
328009
+ if (prior.server) {
328010
+ return [
328011
+ [setCmd, service, prior.server, prior.port || "0"],
328012
+ [stateCmd, service, prior.enabled ? "on" : "off"]
328013
+ ];
328014
+ }
328015
+ return [[stateCmd, service, "off"]];
328016
+ }
328017
+ function buildHostProxyRestoreCommands(service, prior) {
328018
+ return [
328019
+ ...buildProxyRestoreArgs("-setwebproxy", "-setwebproxystate", service, prior.web),
328020
+ ...buildProxyRestoreArgs("-setsecurewebproxy", "-setsecurewebproxystate", service, prior.secure)
328021
+ ];
328022
+ }
328023
+ function hostProxyPriorFromMarker(marker) {
328024
+ return {
328025
+ web: {
328026
+ enabled: Boolean(marker.hostWebProxyWasEnabled),
328027
+ server: marker.hostWebProxyPriorServer ?? "",
328028
+ port: marker.hostWebProxyPriorPort ?? ""
328029
+ },
328030
+ secure: {
328031
+ enabled: Boolean(marker.hostSecureProxyWasEnabled),
328032
+ server: marker.hostSecureProxyPriorServer ?? "",
328033
+ port: marker.hostSecureProxyPriorPort ?? ""
328034
+ }
328035
+ };
328036
+ }
328037
+ function hostProxyControlSupported(platform3, isPhysical, platformName = process.platform) {
328038
+ if (platform3 !== "IOS") return { supported: false };
328039
+ if (isPhysical) {
328040
+ return { supported: false, reason: "physical iOS device requires a manual Wi-Fi HTTP proxy; host proxy not applied" };
328041
+ }
328042
+ if (platformName !== "darwin") {
328043
+ return { supported: false, reason: `iOS simulator host proxy is only controllable on macOS (host is ${platformName})` };
328044
+ }
328045
+ return { supported: true };
328046
+ }
328047
+ async function iosSimConfigureHostProxy(host, port, onLog) {
328048
+ try {
328049
+ const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
328050
+ timeout: DEVICE_CMD_TIMEOUT_MS
328051
+ });
328052
+ const service = parsePrimaryNetworkService(order);
328053
+ if (!service) {
328054
+ return { configured: false, reason: "iOS simulator host proxy could not be configured: no active macOS network service found" };
328055
+ }
328056
+ const [{ stdout: webOut }, { stdout: secureOut }] = await Promise.all([
328057
+ execFileAsync("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
328058
+ execFileAsync("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
328059
+ ]);
328060
+ const prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
328061
+ for (const args of buildHostProxySetCommands(service, host, port)) {
328062
+ await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328063
+ }
328064
+ onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
328065
+ return { configured: true, service, prior };
328066
+ } catch (err) {
328067
+ return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
328068
+ }
328069
+ }
328070
+ async function iosSimRestoreHostProxy(service, prior, onLog) {
328071
+ const restore = prior ?? {
328072
+ web: { enabled: false, server: "", port: "" },
328073
+ secure: { enabled: false, server: "", port: "" }
328074
+ };
328075
+ for (const args of buildHostProxyRestoreCommands(service, restore)) {
328076
+ try {
328077
+ await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328078
+ } catch (err) {
328079
+ onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg(err)}`);
328080
+ }
328081
+ }
328082
+ onLog?.(`[mobile-net] macOS host proxy restored on "${service}".`);
328083
+ }
327445
328084
  var activeCleanups = /* @__PURE__ */ new Map();
327446
328085
  var exitHandlersInstalled = false;
327447
328086
  function markerPath(sessionKey) {
@@ -327476,6 +328115,14 @@ function restoreDeviceSync(marker, onLog) {
327476
328115
  run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
327477
328116
  }
327478
328117
  }
328118
+ if (marker.platform === "IOS" && marker.hostProxySet && marker.hostProxyService && process.platform === "darwin") {
328119
+ for (const args of buildHostProxyRestoreCommands(marker.hostProxyService, hostProxyPriorFromMarker(marker))) {
328120
+ try {
328121
+ execFileSync3("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
328122
+ } catch {
328123
+ }
328124
+ }
328125
+ }
327479
328126
  onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
327480
328127
  }
327481
328128
  async function reconcilePendingCleanups(onLog) {
@@ -327487,6 +328134,8 @@ async function reconcilePendingCleanups(onLog) {
327487
328134
  }
327488
328135
  for (const file of files) {
327489
328136
  if (!file.endsWith(".json")) continue;
328137
+ const sessionKey = file.slice(0, -".json".length);
328138
+ if (activeCleanups.has(sessionKey)) continue;
327490
328139
  const full = join2(PENDING_CLEANUP_DIR, file);
327491
328140
  try {
327492
328141
  const raw = await readFile(full, "utf-8");
@@ -327516,8 +328165,10 @@ function ensureExitHandlers() {
327516
328165
  for (const signal of ["SIGTERM", "SIGINT"]) {
327517
328166
  process.on(signal, () => {
327518
328167
  drain();
327519
- process.removeAllListeners(signal);
327520
- process.kill(process.pid, signal);
328168
+ if (process.listenerCount(signal) <= 1) {
328169
+ process.removeAllListeners(signal);
328170
+ process.kill(process.pid, signal);
328171
+ }
327521
328172
  });
327522
328173
  }
327523
328174
  }
@@ -327567,6 +328218,9 @@ async function startMobileNetworkCapture(options) {
327567
328218
  let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
327568
328219
  let proxySet = false;
327569
328220
  let caInstalledOnDevice = false;
328221
+ let hostProxySet = false;
328222
+ let hostProxyService;
328223
+ let hostProxyPrior;
327570
328224
  try {
327571
328225
  if (platform3 === "ANDROID") {
327572
328226
  proxySet = await androidSetProxy(deviceId, hostPort, onLog);
@@ -327581,25 +328235,50 @@ async function startMobileNetworkCapture(options) {
327581
328235
  }
327582
328236
  } else {
327583
328237
  if (!isPhysical && caCertPath) {
327584
- const { trusted, note } = await iosSimTrustCa(deviceId, caCertPath);
328238
+ const { trusted } = await iosSimTrustCa(deviceId, caCertPath);
327585
328239
  caInstalledOnDevice = trusted;
327586
- wiringReason = note;
328240
+ }
328241
+ const support = hostProxyControlSupported(platform3, isPhysical);
328242
+ if (support.supported) {
328243
+ const hp = await iosSimConfigureHostProxy(proxyHost, proxyPort, onLog);
328244
+ if (hp.configured) {
328245
+ hostProxySet = true;
328246
+ hostProxyService = hp.service;
328247
+ hostProxyPrior = hp.prior;
328248
+ wiringReason = "iOS simulator traffic routed via the macOS host proxy; HTTPS interception pending until the app completes a TLS handshake through the proxy" + (caCertPath ? ` (trust ${caCertPath} in the simulator if the app rejects our CA).` : ".");
328249
+ } else {
328250
+ wiringReason = hp.reason ?? "iOS simulator host proxy could not be configured";
328251
+ onLog?.(`[mobile-net] ${wiringReason}`);
328252
+ }
327587
328253
  } else if (isPhysical) {
327588
328254
  wiringReason = "Physical iOS HTTPS is tunneled without MITM by default. Set the Wi-Fi HTTP proxy and trust the CA manually, then run with VALIDATEQA_MOBILE_FORCE_TLS_MITM=1 to decrypt HTTPS bodies" + (caCertPath ? ` (${caCertPath}).` : ".");
328255
+ } else {
328256
+ wiringReason = support.reason ?? "iOS simulator host proxy is unsupported on this host";
328257
+ onLog?.(`[mobile-net] ${wiringReason}`);
327589
328258
  }
327590
328259
  }
327591
328260
  } catch (err) {
327592
328261
  wiringReason = `device wiring degraded: ${errMsg(err)}`;
327593
328262
  onLog?.(`[mobile-net] ${wiringReason}`);
327594
328263
  }
327595
- if (proxySet || caInstalledOnDevice) {
328264
+ if (proxySet || caInstalledOnDevice || hostProxySet) {
327596
328265
  const marker = {
327597
328266
  platform: platform3,
327598
328267
  deviceId,
327599
328268
  isPhysical,
327600
328269
  proxySet,
327601
328270
  originalProxy,
327602
- caInstalledOnDevice
328271
+ caInstalledOnDevice,
328272
+ ...hostProxySet ? {
328273
+ hostProxySet: true,
328274
+ hostProxyService,
328275
+ hostWebProxyWasEnabled: hostProxyPrior?.web.enabled,
328276
+ hostWebProxyPriorServer: hostProxyPrior?.web.server,
328277
+ hostWebProxyPriorPort: hostProxyPrior?.web.port,
328278
+ hostSecureProxyWasEnabled: hostProxyPrior?.secure.enabled,
328279
+ hostSecureProxyPriorServer: hostProxyPrior?.secure.server,
328280
+ hostSecureProxyPriorPort: hostProxyPrior?.secure.port
328281
+ } : {}
327603
328282
  };
327604
328283
  activeCleanups.set(sessionKey, marker);
327605
328284
  await writePendingCleanup(sessionKey, marker);
@@ -327619,6 +328298,9 @@ async function startMobileNetworkCapture(options) {
327619
328298
  if (proxySet && platform3 === "ANDROID") {
327620
328299
  await androidClearProxy(deviceId, originalProxy, onLog);
327621
328300
  }
328301
+ if (hostProxySet && hostProxyService && platform3 === "IOS" && !isPhysical) {
328302
+ await iosSimRestoreHostProxy(hostProxyService, hostProxyPrior, onLog);
328303
+ }
327622
328304
  if (caInstalledOnDevice && capturedCaCertPath) {
327623
328305
  if (platform3 === "ANDROID") {
327624
328306
  await androidRemoveCa(deviceId, onLog);
@@ -327654,6 +328336,9 @@ async function startMobileNetworkCapture(options) {
327654
328336
 
327655
328337
  // src/services/appium-executor.ts
327656
328338
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
328339
+ var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
328340
+ var WD_ACTION_TIMEOUT_MS = 3e4;
328341
+ var MAX_SESSION_CREATE_ATTEMPTS = 3;
327657
328342
  var execFileAsync2 = promisify2(execFile2);
327658
328343
  var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
327659
328344
  var WebDriverTransportError = class extends Error {
@@ -327667,13 +328352,21 @@ function isTransportError(err) {
327667
328352
  return err instanceof WebDriverTransportError || typeof err === "object" && err !== null && err.isTransport === true;
327668
328353
  }
327669
328354
  async function wdRequest(path, options) {
328355
+ const isSessionCreate = (options?.method ?? "GET").toUpperCase() === "POST" && path === "/session";
328356
+ const timeoutMs = isSessionCreate ? WD_SESSION_CREATE_TIMEOUT_MS : WD_ACTION_TIMEOUT_MS;
327670
328357
  let res;
327671
328358
  try {
327672
328359
  res = await fetch(`http://localhost:${getAppiumState().port}${path}`, {
327673
328360
  ...options,
328361
+ signal: AbortSignal.timeout(timeoutMs),
327674
328362
  headers: { "Content-Type": "application/json", ...options?.headers }
327675
328363
  });
327676
328364
  } catch (err) {
328365
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
328366
+ throw new WebDriverTransportError(
328367
+ `WebDriver request to ${path} timed out after ${timeoutMs}ms (device/Appium unresponsive)`
328368
+ );
328369
+ }
327677
328370
  throw new WebDriverTransportError(`WebDriver connection error: ${err instanceof Error ? err.message : String(err)}`);
327678
328371
  }
327679
328372
  if (!res.ok) {
@@ -327758,6 +328451,31 @@ async function createSession(target) {
327758
328451
  if (!sessionId) throw new Error("Appium session creation returned no session ID");
327759
328452
  return sessionId;
327760
328453
  }
328454
+ var TERMINAL_SESSION_START = /not installed|no such (app|file)|could not confirm|bundle ?id .*(not|missing)|xcodeorgid|signing|provisioning|not authorized|unauthorized/i;
328455
+ var RETRYABLE_SESSION_START = /timed out|timeout|webdriveragent|xcodebuild|instruments|could not connect|lockdownd|failed to (start|launch|create)|unable to launch|session is either terminated|unavailable|econnrefused|connection error|xcuitest|\b50[234]\b/i;
328456
+ function isRetryableSessionStartError(err) {
328457
+ const message = err instanceof Error ? err.message : String(err);
328458
+ if (TERMINAL_SESSION_START.test(message)) return false;
328459
+ if (isTransportError(err)) return true;
328460
+ return RETRYABLE_SESSION_START.test(message);
328461
+ }
328462
+ async function createSessionWithRetry(target, onRetry) {
328463
+ let lastErr;
328464
+ for (let attempt = 1; attempt <= MAX_SESSION_CREATE_ATTEMPTS; attempt++) {
328465
+ try {
328466
+ return await createSession(target);
328467
+ } catch (err) {
328468
+ lastErr = err;
328469
+ if (attempt >= MAX_SESSION_CREATE_ATTEMPTS || !isRetryableSessionStartError(err)) throw err;
328470
+ const delayMs = Math.pow(2, attempt) * 1e3;
328471
+ onRetry?.(
328472
+ `Appium session start failed (attempt ${attempt}/${MAX_SESSION_CREATE_ATTEMPTS}, retrying in ${delayMs}ms): ${err instanceof Error ? err.message : String(err)}`
328473
+ );
328474
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
328475
+ }
328476
+ }
328477
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
328478
+ }
327761
328479
  async function openDeepLink(sessionId, platform3, deeplink, appId) {
327762
328480
  const args = platform3 === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
327763
328481
  await wdRequest(`/session/${sessionId}/execute/sync`, {
@@ -327859,6 +328577,61 @@ async function takeScreenshot(sessionId) {
327859
328577
  return null;
327860
328578
  }
327861
328579
  }
328580
+ function mobileRecordingEnabled() {
328581
+ const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
328582
+ return value !== "0" && value !== "false";
328583
+ }
328584
+ function readPositiveIntEnv(name, fallback, min, max) {
328585
+ const raw = process.env[name];
328586
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
328587
+ if (!Number.isFinite(parsed)) return fallback;
328588
+ return Math.max(min, Math.min(max, parsed));
328589
+ }
328590
+ async function startScreenRecording(sessionId, platform3, log2) {
328591
+ if (!mobileRecordingEnabled()) {
328592
+ log2("[mobile-video] recording disabled by VALIDATEQA_MOBILE_RUN_VIDEO=0");
328593
+ return false;
328594
+ }
328595
+ const timeLimit = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_SECONDS", 180, 5, 180);
328596
+ const options = {
328597
+ timeLimit: String(timeLimit),
328598
+ forceRestart: true
328599
+ };
328600
+ if (platform3 === "ANDROID") {
328601
+ options.bitRate = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_BITRATE", 75e4, 1e5, 4e6);
328602
+ } else {
328603
+ options.videoQuality = process.env.VALIDATEQA_MOBILE_RECORDING_IOS_QUALITY || "low";
328604
+ }
328605
+ try {
328606
+ await wdRequest(`/session/${sessionId}/appium/start_recording_screen`, {
328607
+ method: "POST",
328608
+ body: JSON.stringify({ options })
328609
+ });
328610
+ log2(`[mobile-video] screen recording started (${timeLimit}s max)`);
328611
+ return true;
328612
+ } catch (err) {
328613
+ log2(`[mobile-video] screen recording unavailable: ${err instanceof Error ? err.message : String(err)}`);
328614
+ return false;
328615
+ }
328616
+ }
328617
+ async function stopScreenRecording(sessionId, log2) {
328618
+ try {
328619
+ const result = await wdRequest(`/session/${sessionId}/appium/stop_recording_screen`, {
328620
+ method: "POST",
328621
+ body: JSON.stringify({ options: {} })
328622
+ });
328623
+ const value = typeof result?.value === "string" ? result.value.trim() : "";
328624
+ if (!value) {
328625
+ log2("[mobile-video] screen recording stopped but Appium returned no video");
328626
+ return void 0;
328627
+ }
328628
+ log2(`[mobile-video] screen recording captured (${Math.round(value.length / 1024)}KB base64)`);
328629
+ return value;
328630
+ } catch (err) {
328631
+ log2(`[mobile-video] screen recording stop failed: ${err instanceof Error ? err.message : String(err)}`);
328632
+ return void 0;
328633
+ }
328634
+ }
327862
328635
  function boundsFromStep(step) {
327863
328636
  const bounds = step.metadata?.bounds;
327864
328637
  if (bounds && typeof bounds === "object" && !Array.isArray(bounds) && typeof bounds.x === "number" && typeof bounds.y === "number" && typeof bounds.width === "number" && typeof bounds.height === "number") {
@@ -327978,7 +328751,8 @@ async function sendKeysToFocusedInput(sessionId, value) {
327978
328751
  });
327979
328752
  }
327980
328753
  function encodeAndroidInputText(value) {
327981
- return value.replace(/%/g, "%25").replace(/\s/g, "%s");
328754
+ const inputEncoded = value.replace(/%/g, "%25").replace(/\s/g, "%s");
328755
+ return `'${inputEncoded.replace(/'/g, `'\\''`)}'`;
327982
328756
  }
327983
328757
  async function sendAndroidTextToFocusedInput(deviceId, value) {
327984
328758
  if (!deviceId) {
@@ -328236,6 +329010,9 @@ async function executeMobileTest(options) {
328236
329010
  let sessionId = null;
328237
329011
  let capture = null;
328238
329012
  let captureStopped = false;
329013
+ let recordingStarted = false;
329014
+ let recordingStopped = false;
329015
+ let video;
328239
329016
  const stopCapture = async () => {
328240
329017
  if (!capture || captureStopped) return apiCalls;
328241
329018
  captureStopped = true;
@@ -328250,6 +329027,15 @@ async function executeMobileTest(options) {
328250
329027
  }
328251
329028
  return apiCalls;
328252
329029
  };
329030
+ const stopRecording = async () => {
329031
+ if (!sessionId || !recordingStarted || recordingStopped) return video;
329032
+ recordingStopped = true;
329033
+ const captured = await stopScreenRecording(sessionId, log2);
329034
+ if (captured) {
329035
+ video = captured;
329036
+ }
329037
+ return video;
329038
+ };
328253
329039
  try {
328254
329040
  log2("Checking Appium server health...");
328255
329041
  const health = await checkAppiumHealth();
@@ -328303,13 +329089,13 @@ async function executeMobileTest(options) {
328303
329089
  }
328304
329090
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
328305
329091
  try {
328306
- sessionId = await createSession({
329092
+ sessionId = await createSessionWithRetry({
328307
329093
  appId: options.target.appId,
328308
329094
  platform: options.target.platform,
328309
329095
  deviceId: selectedDevice.id,
328310
329096
  platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
328311
329097
  isPhysical: selectedDevice.isPhysical
328312
- });
329098
+ }, (msg) => log2(msg));
328313
329099
  } catch (err) {
328314
329100
  const base2 = err instanceof Error ? err.message : String(err);
328315
329101
  if (selectedDevice.isPhysical) {
@@ -328324,6 +329110,7 @@ async function executeMobileTest(options) {
328324
329110
  throw err;
328325
329111
  }
328326
329112
  log2(`Session created: ${sessionId}`);
329113
+ recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
328327
329114
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
328328
329115
  try {
328329
329116
  log2(`Opening deep link: ${options.target.deeplink}`);
@@ -328384,7 +329171,9 @@ async function executeMobileTest(options) {
328384
329171
  break;
328385
329172
  }
328386
329173
  }
328387
- await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
329174
+ if (step.action !== "home" && step.action !== "back") {
329175
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
329176
+ }
328388
329177
  } catch (err) {
328389
329178
  status = "failed";
328390
329179
  stepError = err instanceof Error ? err.message : String(err);
@@ -328413,10 +329202,12 @@ async function executeMobileTest(options) {
328413
329202
  const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
328414
329203
  const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
328415
329204
  await stopCapture();
329205
+ await stopRecording();
328416
329206
  return {
328417
329207
  passed: allPassed,
328418
329208
  stepResults,
328419
329209
  screenshots,
329210
+ ...video ? { video, videoMimeType: "video/mp4" } : {},
328420
329211
  apiCalls,
328421
329212
  logs: logLines.join("\n"),
328422
329213
  error: firstError,
@@ -328427,10 +329218,12 @@ async function executeMobileTest(options) {
328427
329218
  const errorMsg = err instanceof Error ? err.message : String(err);
328428
329219
  log2(`Fatal error: ${errorMsg}`);
328429
329220
  await stopCapture();
329221
+ await stopRecording();
328430
329222
  return {
328431
329223
  passed: false,
328432
329224
  stepResults,
328433
329225
  screenshots,
329226
+ ...video ? { video, videoMimeType: "video/mp4" } : {},
328434
329227
  apiCalls,
328435
329228
  logs: logLines.join("\n"),
328436
329229
  error: errorMsg,
@@ -328442,6 +329235,7 @@ async function executeMobileTest(options) {
328442
329235
  };
328443
329236
  } finally {
328444
329237
  await stopCapture();
329238
+ await stopRecording();
328445
329239
  if (sessionId) {
328446
329240
  log2("Tearing down Appium session...");
328447
329241
  await deleteSession(sessionId);
@@ -328482,7 +329276,7 @@ async function createMobileDiscoverySession(target, options) {
328482
329276
  isPhysical: selectedDevice.isPhysical === true,
328483
329277
  platformVersion: selectedDevice.platformVersion
328484
329278
  });
328485
- const sessionId = await createSession({
329279
+ const sessionId = await createSessionWithRetry({
328486
329280
  appId: target.appId,
328487
329281
  platform: target.platform,
328488
329282
  deviceId: selectedDevice.id,
@@ -329293,7 +330087,7 @@ function stopSessionReaper() {
329293
330087
  }
329294
330088
  }
329295
330089
  async function reapIdleSession() {
329296
- if (!state.appiumSessionId || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
330090
+ if (!state.appiumSessionId || mirrorSessionOwner !== "recording" || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
329297
330091
  return;
329298
330092
  }
329299
330093
  const sessionId = state.appiumSessionId;
@@ -331810,6 +332604,11 @@ ${finalVerifyResult.logs ?? ""}`;
331810
332604
  });
331811
332605
  requestLiveBrowserHeartbeat();
331812
332606
  },
332607
+ // D-D6 — upload ONE baseline PNG per newly-observed screen through the
332608
+ // same streaming /page-screenshot relay the web path uses (mobile has no
332609
+ // route, so the screen-id is the key). The returned storage key rides the
332610
+ // explore checkpoint so the server links it to SitePage.screenshotKey.
332611
+ onScreenshotUpload: (screenId, base64) => onScreenshot(screenId, base64),
331813
332612
  onExploreComplete,
331814
332613
  onTestGenerated,
331815
332614
  onPlanReady
@@ -332152,6 +332951,8 @@ ${result2.logs}`);
332152
332951
  status,
332153
332952
  stepResults: result2.stepResults,
332154
332953
  screenshots: result2.screenshots,
332954
+ video: result2.video,
332955
+ videoMimeType: result2.videoMimeType,
332155
332956
  apiCalls: result2.apiCalls,
332156
332957
  logs: result2.logs,
332157
332958
  error: reportedError,
@@ -332463,7 +333264,7 @@ async function startRunner(opts) {
332463
333264
  };
332464
333265
  const sendHeartbeat = async () => {
332465
333266
  try {
332466
- const devices = enableMobile ? discoverAllDevices() : [];
333267
+ const devices = enableMobile ? getCachedDeviceSnapshot() : [];
332467
333268
  const capabilities = enableMobile ? {
332468
333269
  web: true,
332469
333270
  ios: devices.some((d) => d.platform === "IOS" && d.isAvailable && d.state === "BOOTED"),