@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.js CHANGED
@@ -233,6 +233,9 @@ var require_ai_provider = __commonJS({
233
233
  exports2.getNoThinkingRequestOptions = getNoThinkingRequestOptions;
234
234
  exports2.getAIClient = getAIClient;
235
235
  exports2.getClientForModel = getClientForModel;
236
+ exports2.shouldProxyAIThroughServerForLocalRunner = shouldProxyAIThroughServerForLocalRunner;
237
+ exports2.getRunnerAIClient = getRunnerAIClient;
238
+ exports2.getRunnerClientForModel = getRunnerClientForModel;
236
239
  exports2.getModel = getModel;
237
240
  exports2.resolveModelForTask = resolveModelForTask;
238
241
  exports2.getAvailableModels = getAvailableModels;
@@ -510,6 +513,21 @@ var require_ai_provider = __commonJS({
510
513
  function getClientForModel(modelId) {
511
514
  return buildClientForProvider(getProviderForModel(modelId));
512
515
  }
516
+ function shouldProxyAIThroughServerForLocalRunner() {
517
+ const serverUrl = (process.env.SERVER_URL ?? "").trim();
518
+ const token = (process.env.AUTH_TOKEN || process.env.RUNNER_TOKEN || "").trim();
519
+ return serverUrl.length > 0 && token.startsWith("urt_");
520
+ }
521
+ function getRunnerAIClient() {
522
+ if (shouldProxyAIThroughServerForLocalRunner())
523
+ return getServerProxiedAIClient();
524
+ return getAIClient();
525
+ }
526
+ function getRunnerClientForModel(modelId) {
527
+ if (shouldProxyAIThroughServerForLocalRunner())
528
+ return getServerProxiedClientForModel(modelId);
529
+ return getClientForModel(modelId);
530
+ }
513
531
  function getModel(type) {
514
532
  return MODEL_TASK_MAP[getActiveModelId()][type];
515
533
  }
@@ -580,7 +598,7 @@ var require_ai_provider = __commonJS({
580
598
  const create = async (params, options) => {
581
599
  const modelId = typeof params.model === "string" ? params.model : "";
582
600
  if (!modelId) {
583
- throw new Error("server-proxied AI client requires params.model (catalog id)");
601
+ throw new Error("server-proxied AI client requires params.model");
584
602
  }
585
603
  const body = {
586
604
  modelId,
@@ -750,6 +768,21 @@ var require_mobile_source_parser = __commonJS({
750
768
  const attrText = inner.slice(tag.length);
751
769
  return { tag, attrs: parseAttributes(attrText), selfClosing };
752
770
  }
771
+ function findTagEnd(xml, from) {
772
+ let quote = null;
773
+ for (let i = from; i < xml.length; i++) {
774
+ const ch = xml[i];
775
+ if (quote !== null) {
776
+ if (ch === quote)
777
+ quote = null;
778
+ } else if (ch === '"' || ch === "'") {
779
+ quote = ch;
780
+ } else if (ch === ">") {
781
+ return i;
782
+ }
783
+ }
784
+ return -1;
785
+ }
753
786
  function tokenize(xml) {
754
787
  if (!xml || typeof xml !== "string")
755
788
  return null;
@@ -772,7 +805,7 @@ var require_mobile_source_parser = __commonJS({
772
805
  i = end === -1 ? len : end + 3;
773
806
  continue;
774
807
  }
775
- const gt = xml.indexOf(">", lt + 1);
808
+ const gt = findTagEnd(xml, lt + 1);
776
809
  if (gt === -1)
777
810
  break;
778
811
  const tagBody = xml.slice(lt + 1, gt);
@@ -1558,6 +1591,10 @@ function buildAppiumCode(testName, target, steps) {
1558
1591
  ` }`,
1559
1592
  `}`,
1560
1593
  ``,
1594
+ `async function __clearFocused(driver: WebdriverIO.Browser) {`,
1595
+ ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).clearValue();`,
1596
+ `}`,
1597
+ ``,
1561
1598
  `async function __textCandidates(el: WebdriverIO.Element) {`,
1562
1599
  ` const __values: string[] = [];`,
1563
1600
  ` const __push = (__value: unknown) => {`,
@@ -1651,6 +1688,12 @@ function buildAppiumCode(testName, target, steps) {
1651
1688
  lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1652
1689
  } else if (step.action === "clear" && hasSelector) {
1653
1690
  lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
1691
+ } else if (step.action === "clear" && bounds) {
1692
+ const x = Math.round(bounds.x + bounds.width / 2);
1693
+ const y = Math.round(bounds.y + bounds.height / 2);
1694
+ lines.push(` await __tapAt(driver, ${x}, ${y});`);
1695
+ lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1696
+ lines.push(` await __clearFocused(driver);`);
1654
1697
  } else if (step.action === "assertVisible" && hasSelector) {
1655
1698
  lines.push(` if (!(await (await findFirst(driver, ${candidatesLiteral})).isDisplayed())) {`);
1656
1699
  lines.push(` throw new Error('Assertion failed: element not displayed: ' + ${toJsStringLiteral(candidatesLiteral)});`);
@@ -1658,6 +1701,7 @@ function buildAppiumCode(testName, target, steps) {
1658
1701
  } else if (step.action === "assertText" && hasSelector) {
1659
1702
  lines.push(` {`);
1660
1703
  lines.push(` const __expected = ${toJsStringLiteral(step.assertion ?? "")};`);
1704
+ lines.push(` if (__expected.length === 0) throw new Error('assertText step is missing expected text');`);
1661
1705
  lines.push(` const __actual = await __textCandidates(await findFirst(driver, ${candidatesLiteral}));`);
1662
1706
  lines.push(` if (!__actual.some((__value) => __value.includes(__expected))) {`);
1663
1707
  lines.push(` throw new Error('Assertion failed: expected text to contain ' + JSON.stringify(__expected) + ' but got ' + JSON.stringify(__actual));`);
@@ -4366,6 +4410,7 @@ var require_mobile_observation = __commonJS({
4366
4410
  Object.defineProperty(exports2, "__esModule", { value: true });
4367
4411
  exports2.renderMobileObservation = renderMobileObservation;
4368
4412
  exports2.summarizeScreenForEvidence = summarizeScreenForEvidence;
4413
+ exports2.buildCollectedElementsForScreen = buildCollectedElementsForScreen;
4369
4414
  var DEFAULT_MAX_ELEMENTS = 60;
4370
4415
  var MAX_LABEL_LENGTH = 80;
4371
4416
  var EVIDENCE_LABEL_LIMIT = 40;
@@ -4407,6 +4452,46 @@ var require_mobile_observation = __commonJS({
4407
4452
  }
4408
4453
  return { elementCount, actionableCount, labels };
4409
4454
  }
4455
+ function buildCollectedElementsForScreen(elements, opts = {}) {
4456
+ const navigationRefs = opts.navigationRefs ?? EMPTY_REF_SET;
4457
+ const out = [];
4458
+ const seen = /* @__PURE__ */ new Set();
4459
+ for (const element of elements) {
4460
+ if (!element.actionable)
4461
+ continue;
4462
+ const collected = toCollectedPageElement(element, navigationRefs.has(element.ref));
4463
+ const identity = `${collected.elementType}|${collected.testId ?? ""}|${collected.label ?? ""}`;
4464
+ if (seen.has(identity))
4465
+ continue;
4466
+ seen.add(identity);
4467
+ out.push(collected);
4468
+ }
4469
+ return out;
4470
+ }
4471
+ var EMPTY_REF_SET = /* @__PURE__ */ new Set();
4472
+ function toCollectedPageElement(element, triggersNavigation) {
4473
+ const label = element.label?.trim() || element.text?.trim() || void 0;
4474
+ const stableId = element.accessibilityId?.trim() || element.resourceId?.trim() || void 0;
4475
+ const locators = [];
4476
+ if (stableId)
4477
+ locators.push({ strategy: "getByTestId", value: stableId, confidence: 0.95 });
4478
+ const xpath = element.xpath?.trim();
4479
+ if (xpath)
4480
+ locators.push({ strategy: "css", value: xpath, confidence: 0.4 });
4481
+ const collected = {
4482
+ elementType: element.role || "View",
4483
+ triggersNavigation
4484
+ };
4485
+ if (label)
4486
+ collected.label = label;
4487
+ if (stableId)
4488
+ collected.testId = stableId;
4489
+ if (locators.length > 0)
4490
+ collected.locators = locators;
4491
+ if (element.enabled === false)
4492
+ collected.isDisabled = true;
4493
+ return collected;
4494
+ }
4410
4495
  function buildHeaderLine(screen) {
4411
4496
  const parts = ["screen:"];
4412
4497
  const name = screenDisplayName(screen.screenSignal);
@@ -4855,7 +4940,7 @@ var require_mobile_explorer = __commonJS({
4855
4940
  }
4856
4941
  return sections.join("\n");
4857
4942
  }
4858
- function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId) {
4943
+ function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId, existingScreens) {
4859
4944
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
4860
4945
  const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
4861
4946
  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.
@@ -4901,13 +4986,18 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
4901
4986
  ## Application Brief
4902
4987
  ${appBrief}
4903
4988
  Use this to prioritize which screens and flows to cover.` : "";
4904
- return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}`;
4989
+ const existingScreensBlock = mode === "survey" && existingScreens && existingScreens.length > 0 ? `
4990
+
4991
+ ## ALREADY DISCOVERED SCREENS (skip these; find new)
4992
+ 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.
4993
+ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.name}"` : ""}${s.screenType ? ` [${s.screenType}]` : ""}`).join("\n")}` : "";
4994
+ return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}${existingScreensBlock}`;
4905
4995
  }
4906
4996
  function buildKickoffMessage(mode) {
4907
4997
  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.";
4908
4998
  }
4909
4999
  async function runMobileExplorePhase(args) {
4910
- const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot } = args;
5000
+ const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot, onScreenshotUpload, existingScreens } = args;
4911
5001
  const logs2 = [];
4912
5002
  const log2 = (line) => {
4913
5003
  logs2.push(line);
@@ -4931,6 +5021,9 @@ Use this to prioritize which screens and flows to cover.` : "";
4931
5021
  log2(`Budget: ${maxIterations} iterations | app: ${ctx.target?.appId ?? "(unknown)"}`);
4932
5022
  const screenshots = [];
4933
5023
  const pageScreenshots = {};
5024
+ const pageScreenshotKeys = {};
5025
+ const uploadedScreenIds = /* @__PURE__ */ new Set();
5026
+ const pendingScreenshotUploads = [];
4934
5027
  const collectedTransitions = [];
4935
5028
  const journeys = [];
4936
5029
  const counters = {
@@ -4949,6 +5042,14 @@ Use this to prioritize which screens and flows to cover.` : "";
4949
5042
  screenshots.push(base64);
4950
5043
  if (screenId)
4951
5044
  pageScreenshots[screenId] = base64;
5045
+ if (screenId && onScreenshotUpload && !uploadedScreenIds.has(screenId)) {
5046
+ uploadedScreenIds.add(screenId);
5047
+ pendingScreenshotUploads.push(onScreenshotUpload(screenId, base64).then((key) => {
5048
+ if (key)
5049
+ pageScreenshotKeys[screenId] = key;
5050
+ }).catch(() => {
5051
+ }));
5052
+ }
4952
5053
  try {
4953
5054
  onScreenshot?.(base64);
4954
5055
  } catch {
@@ -5053,7 +5154,7 @@ Use this to prioritize which screens and flows to cover.` : "";
5053
5154
  return null;
5054
5155
  return { element, bounds: element.bounds };
5055
5156
  };
5056
- const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId);
5157
+ const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId, existingScreens);
5057
5158
  const kickoff = buildKickoffMessage(mode);
5058
5159
  const recentExchanges = [];
5059
5160
  const transientDirectives = [];
@@ -5261,6 +5362,9 @@ Exploration complete: ${summary}`);
5261
5362
  summary = `Explore phase aborted (${truncatedReason}) after mapping ${state2.getScreenCount()} screen(s).`;
5262
5363
  }
5263
5364
  }
5365
+ if (pendingScreenshotUploads.length > 0) {
5366
+ await Promise.allSettled(pendingScreenshotUploads);
5367
+ }
5264
5368
  const collectedPages = state2.buildInMemoryScreenMap();
5265
5369
  counters.pagesVisited = collectedPages.length;
5266
5370
  if (counters.pagesDiscovered < collectedPages.length) {
@@ -5295,6 +5399,9 @@ Exploration complete: ${summary}`);
5295
5399
  if (Object.keys(pageScreenshots).length > 0) {
5296
5400
  result.pageScreenshots = pageScreenshots;
5297
5401
  }
5402
+ if (Object.keys(pageScreenshotKeys).length > 0) {
5403
+ result.pageScreenshotKeys = pageScreenshotKeys;
5404
+ }
5298
5405
  if (truncated) {
5299
5406
  result.truncated = true;
5300
5407
  result.truncatedReason = truncatedReason;
@@ -5796,6 +5903,44 @@ var require_snapshot_parser = __commonJS({
5796
5903
  };
5797
5904
  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;
5798
5905
  var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
5906
+ var RICH_CONTEXT_ROLES = /* @__PURE__ */ new Set(["table", "grid", "row", "radiogroup"]);
5907
+ var RICH_CONTEXT_LABEL_MAX = 60;
5908
+ var VALUE_BEARING_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "combobox", "spinbutton", "listbox", "slider"]);
5909
+ 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;
5910
+ var REDACTED_VALUE = "[redacted]";
5911
+ var VALUE_TEXT_MAX = 120;
5912
+ 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;
5913
+ var STATIC_MESSAGES_MAX = 12;
5914
+ var OPTIONS_PER_ELEMENT_MAX = 24;
5915
+ var URL_CHILD_LINE_RE = /^(\s*)- \/url:\s*(.+)$/;
5916
+ 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;
5917
+ function stripYamlQuotes(raw) {
5918
+ const trimmed = raw.trim();
5919
+ if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
5920
+ return trimmed.slice(1, -1);
5921
+ }
5922
+ return trimmed;
5923
+ }
5924
+ function extractTrailingText(attrs) {
5925
+ const lastBracket = attrs.lastIndexOf("]");
5926
+ const tail = lastBracket >= 0 ? attrs.slice(lastBracket + 1) : attrs;
5927
+ const match = /^\s*:\s+(.+)$/.exec(tail);
5928
+ if (!match)
5929
+ return null;
5930
+ const text = stripYamlQuotes(match[1]);
5931
+ if (!text || /^(?:\||\|-|>|>-)$/.test(text))
5932
+ return null;
5933
+ return text;
5934
+ }
5935
+ function isSensitiveValueField(name, placeholder) {
5936
+ return SENSITIVE_VALUE_LABEL_RE.test(`${name ?? ""} ${placeholder ?? ""}`);
5937
+ }
5938
+ function scrubMessageText(text) {
5939
+ return text.replace(MESSAGE_TOKEN_RE, REDACTED_VALUE);
5940
+ }
5941
+ function capText(text, max) {
5942
+ return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
5943
+ }
5799
5944
  function isProbablyDynamicTestId(testId) {
5800
5945
  if (testId.length < 6)
5801
5946
  return false;
@@ -5871,9 +6016,12 @@ var require_snapshot_parser = __commonJS({
5871
6016
  }
5872
6017
  return e.name;
5873
6018
  }
5874
- function parseSnapshot(snapshotText) {
6019
+ function parseSnapshot(snapshotText, options = {}) {
6020
+ const richParse = options.richParse ?? process.env.DISCOVERY_SNAPSHOT_RICH_PARSE === "true";
5875
6021
  const lines = snapshotText.split("\n");
5876
6022
  const elements = [];
6023
+ const liveMessages = [];
6024
+ const textMessages = [];
5877
6025
  const seenRefs = /* @__PURE__ */ new Set();
5878
6026
  let parsedElementLineCount = 0;
5879
6027
  let genericLabelCount = 0;
@@ -5882,6 +6030,21 @@ var require_snapshot_parser = __commonJS({
5882
6030
  const contextStack = [];
5883
6031
  const completedForms = [];
5884
6032
  for (const line of lines) {
6033
+ if (richParse) {
6034
+ const urlMatch = URL_CHILD_LINE_RE.exec(line);
6035
+ if (urlMatch) {
6036
+ const urlIndent = urlMatch[1].length;
6037
+ for (let i = elements.length - 1; i >= 0; i--) {
6038
+ const el = elements[i];
6039
+ if (el.indentDepth < urlIndent) {
6040
+ if (el.role === "link" && !el.href)
6041
+ el.href = capText(stripYamlQuotes(urlMatch[2]), 300);
6042
+ break;
6043
+ }
6044
+ }
6045
+ continue;
6046
+ }
6047
+ }
5885
6048
  const match = ELEMENT_LINE_RE.exec(line);
5886
6049
  if (!match)
5887
6050
  continue;
@@ -5903,7 +6066,7 @@ var require_snapshot_parser = __commonJS({
5903
6066
  }
5904
6067
  const headingLevelMatch = LEVEL_RE.exec(attrs);
5905
6068
  const headingLevel = headingLevelMatch ? Number.parseInt(headingLevelMatch[1], 10) : null;
5906
- const contextLabel = buildContextLabel(role, name ?? null, headingLevel);
6069
+ const contextLabel = buildContextLabel(role, name ?? null, headingLevel, richParse);
5907
6070
  if (contextLabel) {
5908
6071
  contextStack.push({ indent: indentDepth, label: contextLabel });
5909
6072
  }
@@ -5916,6 +6079,40 @@ var require_snapshot_parser = __commonJS({
5916
6079
  });
5917
6080
  continue;
5918
6081
  }
6082
+ if (richParse) {
6083
+ if (role === "option" && name) {
6084
+ for (let i = elements.length - 1; i >= 0; i--) {
6085
+ const el = elements[i];
6086
+ if (el.indentDepth < indentDepth) {
6087
+ if (el.role === "combobox" || el.role === "listbox") {
6088
+ el.options ??= [];
6089
+ if (el.options.length < OPTIONS_PER_ELEMENT_MAX) {
6090
+ el.options.push({ label: capText(name, 80), selected: parseBooleanState(SELECTED_RE.exec(attrs)) === true });
6091
+ }
6092
+ }
6093
+ break;
6094
+ }
6095
+ }
6096
+ }
6097
+ if (role === "alert" || role === "status" || role === "paragraph" || role === "text") {
6098
+ const text = extractTrailingText(attrs) ?? (name || null);
6099
+ const isLiveRegion = role === "alert" || role === "status";
6100
+ if (text && (isLiveRegion || SIGNAL_TEXT_RE.test(text))) {
6101
+ const capped = capText(scrubMessageText(text), VALUE_TEXT_MAX);
6102
+ const ref2 = REF_RE.exec(attrs)?.[1] ?? null;
6103
+ if (isLiveRegion) {
6104
+ const textDupIndex = textMessages.findIndex((m) => m.text === capped);
6105
+ if (textDupIndex >= 0)
6106
+ textMessages.splice(textDupIndex, 1);
6107
+ if (liveMessages.length < STATIC_MESSAGES_MAX && !liveMessages.some((m) => m.text === capped)) {
6108
+ liveMessages.push({ role, text: capped, ref: ref2 });
6109
+ }
6110
+ } else if (textMessages.length < STATIC_MESSAGES_MAX && !textMessages.some((m) => m.text === capped) && !liveMessages.some((m) => m.text === capped)) {
6111
+ textMessages.push({ role: "text", text: capped, ref: ref2 });
6112
+ }
6113
+ }
6114
+ }
6115
+ }
5919
6116
  if (!INTERACTIVE_ROLES.has(role))
5920
6117
  continue;
5921
6118
  const refMatch = REF_RE.exec(attrs);
@@ -5948,6 +6145,13 @@ var require_snapshot_parser = __commonJS({
5948
6145
  }
5949
6146
  const isLink = role === "link";
5950
6147
  const isSubmitButton = role === "button" && name != null && SUBMIT_LABEL_RE.test(name);
6148
+ let value = null;
6149
+ if (richParse && VALUE_BEARING_ROLES.has(role)) {
6150
+ const trailing = extractTrailingText(attrs);
6151
+ if (trailing) {
6152
+ value = isSensitiveValueField(name ?? null, placeholder) ? REDACTED_VALUE : capText(trailing, VALUE_TEXT_MAX);
6153
+ }
6154
+ }
5951
6155
  const element = {
5952
6156
  ref,
5953
6157
  role,
@@ -5969,7 +6173,10 @@ var require_snapshot_parser = __commonJS({
5969
6173
  pressed,
5970
6174
  headingLevel,
5971
6175
  currentState,
5972
- containerPath
6176
+ containerPath,
6177
+ href: null,
6178
+ value,
6179
+ options: null
5973
6180
  };
5974
6181
  if (isGenericLabel(element.name))
5975
6182
  genericLabelCount++;
@@ -5997,6 +6204,9 @@ var require_snapshot_parser = __commonJS({
5997
6204
  return {
5998
6205
  elements,
5999
6206
  formGroups: completedForms,
6207
+ // live regions first: they carry their own reservation so an end-of-body toast can
6208
+ // never be starved out by signal-matching plain text earlier in the document.
6209
+ staticMessages: [...liveMessages, ...textMessages].slice(0, STATIC_MESSAGES_MAX),
6000
6210
  rawLineCount: lines.length,
6001
6211
  parsedElementLineCount,
6002
6212
  genericLabelCount,
@@ -6107,12 +6317,17 @@ var require_snapshot_parser = __commonJS({
6107
6317
  return "mixed";
6108
6318
  return false;
6109
6319
  }
6110
- function buildContextLabel(role, name, headingLevel) {
6320
+ function buildContextLabel(role, name, headingLevel, richContext = false) {
6111
6321
  if (role === "heading") {
6112
6322
  if (!name)
6113
6323
  return null;
6114
6324
  return headingLevel ? `heading${headingLevel}:${name}` : `heading:${name}`;
6115
6325
  }
6326
+ if (richContext && RICH_CONTEXT_ROLES.has(role)) {
6327
+ if (!name)
6328
+ return null;
6329
+ return `${role}:${capText(name.replace(/>/g, "\u203A"), RICH_CONTEXT_LABEL_MAX)}`;
6330
+ }
6116
6331
  if (!CONTEXT_ROLES.has(role))
6117
6332
  return null;
6118
6333
  if (name)
@@ -7822,7 +8037,7 @@ Each entry is exactly one of:
7822
8037
  const systemPrompt = options.systemPromptOverride ?? (context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports2.SCENARIO_SYSTEM_PROMPT);
7823
8038
  const userPrompt = options.userPromptOverride ?? formatEvidenceForPlanner(compressed, evidence, context);
7824
8039
  logs2.push(`AI planner: ~${Math.ceil(userPrompt.length / 3.5)} input tokens`);
7825
- const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)());
8040
+ const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
7826
8041
  const messages = [
7827
8042
  { role: "system", content: systemPrompt },
7828
8043
  { role: "user", content: userPrompt }
@@ -7830,18 +8045,22 @@ Each entry is exactly one of:
7830
8045
  const callStart = Date.now();
7831
8046
  let response;
7832
8047
  try {
7833
- response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => aiClient.chat.completions.create({
7834
- model: agentModel,
7835
- messages,
7836
- max_tokens: plannerMaxTokens,
7837
- temperature: 0.3,
7838
- ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
7839
- ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
7840
- })), {
8048
+ response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
8049
+ arm();
8050
+ return aiClient.chat.completions.create({
8051
+ model: agentModel,
8052
+ messages,
8053
+ max_tokens: plannerMaxTokens,
8054
+ temperature: 0.3,
8055
+ ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
8056
+ ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
8057
+ }, { signal });
8058
+ }), {
7841
8059
  label: "ai-planner",
7842
8060
  baseDelayMs: 5e3,
7843
8061
  maxRetries: 2,
7844
- onRetry: (attempt, status, delay) => logs2.push(`AI planner call failed (${status ?? "error"}) \u2014 retry ${attempt}/2 in ${Math.round(delay / 1e3)}s`)
8062
+ deferTimeoutUntilArmed: true,
8063
+ onRetry: (attempt, reason, delay) => logs2.push(`AI planner call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
7845
8064
  });
7846
8065
  } catch (err) {
7847
8066
  const message = err instanceof Error ? err.message : String(err);
@@ -7907,18 +8126,25 @@ Each entry is exactly one of:
7907
8126
  const retryStart = Date.now();
7908
8127
  let retryResponse = null;
7909
8128
  try {
7910
- retryResponse = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => aiClient.chat.completions.create({
7911
- model: agentModel,
7912
- messages: [
7913
- ...messages,
7914
- { role: "assistant", content: raw },
7915
- { role: "user", content: retryContext }
7916
- ],
7917
- max_tokens: plannerMaxTokens,
7918
- temperature: 0.3,
7919
- ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
7920
- ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
7921
- })), { label: "ai-planner-retry" });
8129
+ retryResponse = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
8130
+ arm();
8131
+ return aiClient.chat.completions.create({
8132
+ model: agentModel,
8133
+ messages: [
8134
+ ...messages,
8135
+ { role: "assistant", content: raw },
8136
+ { role: "user", content: retryContext }
8137
+ ],
8138
+ max_tokens: plannerMaxTokens,
8139
+ temperature: 0.3,
8140
+ ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
8141
+ ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
8142
+ }, { signal });
8143
+ }), {
8144
+ label: "ai-planner-retry",
8145
+ deferTimeoutUntilArmed: true,
8146
+ onRetry: (attempt, reason, delay) => logs2.push(`AI planner retry call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
8147
+ });
7922
8148
  } catch (err) {
7923
8149
  const message = err instanceof Error ? err.message : String(err);
7924
8150
  logs2.push(`AI planner retry failed (${message}) \u2014 using first-pass result`);
@@ -8446,12 +8672,12 @@ ${constraints.join("\n")}`);
8446
8672
  const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8447
8673
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
8448
8674
  for (const state2 of orderedScreens) {
8449
- const groupName = state2.screenType === "auth" ? "Authentication" : screenDisplayName(state2);
8675
+ const groupName = state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(state2);
8450
8676
  const group = groups.get(groupName) ?? {
8451
8677
  name: groupName,
8452
8678
  description: `Native mobile screens related to ${groupName}.`,
8453
8679
  routes: [],
8454
- criticality: state2.screenType === "auth" ? "critical" : "medium",
8680
+ criticality: state2.screenType?.toLowerCase() === "auth" ? "critical" : "medium",
8455
8681
  pages: [],
8456
8682
  flows: []
8457
8683
  };
@@ -8474,7 +8700,7 @@ ${constraints.join("\n")}`);
8474
8700
  const to = graph.screens.get(transition.toScreenId);
8475
8701
  if (!from || !to)
8476
8702
  continue;
8477
- const groupName = from.screenType === "auth" ? "Authentication" : screenDisplayName(from);
8703
+ const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(from);
8478
8704
  const group = groups.get(groupName);
8479
8705
  if (!group)
8480
8706
  continue;
@@ -8519,9 +8745,9 @@ ${constraints.join("\n")}`);
8519
8745
  scenarios.push({
8520
8746
  name,
8521
8747
  type: "HAPPY_PATH",
8522
- priority: state2.screenType === "auth" ? 1 : 3,
8748
+ priority: state2.screenType?.toLowerCase() === "auth" ? 1 : 3,
8523
8749
  variant: "happy",
8524
- featureGroup: state2.screenType === "auth" ? "Authentication" : screenName,
8750
+ featureGroup: state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenName,
8525
8751
  goal: `User can reach and inspect the ${screenName} screen`,
8526
8752
  steps,
8527
8753
  expectedOutcomes: [{
@@ -8667,6 +8893,7 @@ var require_mobile_generator = __commonJS({
8667
8893
  var mobile_observation_js_1 = require_mobile_observation();
8668
8894
  var mobile_boundary_js_1 = require_mobile_boundary();
8669
8895
  var MAX_SCENARIO_ITERATIONS = 30;
8896
+ var MAX_CONSECUTIVE_INFRA_FAILURES = 3;
8670
8897
  var MAX_RECENT_EXCHANGES = 24;
8671
8898
  var OBSERVATION_MAX_ELEMENTS = 50;
8672
8899
  function finalizeAction(input) {
@@ -9391,14 +9618,31 @@ ${statePacket}` },
9391
9618
  if (!snapshot || snapshot.screen.elements.length === 0)
9392
9619
  return null;
9393
9620
  const elements = snapshot.screen.elements;
9394
- const withStableId = elements.filter((el) => hasStableId(el));
9395
- const nonButton = withStableId.find((el) => !/button/i.test(el.role));
9621
+ const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
9622
+ const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
9623
+ if (contentfulNonButton)
9624
+ return contentfulNonButton;
9625
+ const nonButton = stableAnchors.find((el) => !/button/i.test(el.role));
9396
9626
  if (nonButton)
9397
9627
  return nonButton;
9398
- if (withStableId.length > 0)
9399
- return withStableId[0];
9400
- const labelled = elements.find((el) => nonEmpty2(el.label) || nonEmpty2(el.text));
9401
- return labelled ?? elements[0];
9628
+ if (stableAnchors.length > 0)
9629
+ return stableAnchors[0];
9630
+ const labelled = elements.find((el) => !isAlwaysPresentContainer(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
9631
+ return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
9632
+ }
9633
+ var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
9634
+ var ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS = /* @__PURE__ */ new Set([
9635
+ "android:id/content",
9636
+ "android:id/decor",
9637
+ "android:id/action_bar_root",
9638
+ "android:id/statusBarBackground",
9639
+ "android:id/navigationBarBackground"
9640
+ ]);
9641
+ function isAlwaysPresentContainer(element) {
9642
+ if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
9643
+ return true;
9644
+ const resourceId = element.resourceId?.trim();
9645
+ return !!resourceId && ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS.has(resourceId);
9402
9646
  }
9403
9647
  function appendHybridFloor(actions, recording, platform3) {
9404
9648
  const anchor = pickLandingAnchor(recording.lastSnapshot);
@@ -9457,7 +9701,7 @@ ${statePacket}` },
9457
9701
  return test;
9458
9702
  }
9459
9703
  async function runMobileGeneratePhase(args) {
9460
- const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot } = args;
9704
+ const { scenarios: allScenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot, skipScenarioNames, onInfraStop } = args;
9461
9705
  const log2 = (line) => {
9462
9706
  try {
9463
9707
  onLog?.(line);
@@ -9472,8 +9716,16 @@ ${statePacket}` },
9472
9716
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
9473
9717
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
9474
9718
  const targetAppId = ctx.target?.appId?.trim() || void 0;
9719
+ const skipSet = new Set(skipScenarioNames ?? []);
9720
+ const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
9721
+ if (skipSet.size > 0) {
9722
+ log2(`Resume: skipping ${allScenarios.length - scenarios.length} already-generated scenario(s); ${scenarios.length} remaining to generate`);
9723
+ }
9475
9724
  log2(`Mobile generator starting \u2014 platform: ${platform3}, provider: ${provider}, model: ${agentModel}, scenarios: ${scenarios.length}`);
9476
9725
  const tests = [];
9726
+ const generatedScenarioNames = /* @__PURE__ */ new Set();
9727
+ let consecutiveInfraFailures = 0;
9728
+ let infraStopped = false;
9477
9729
  for (let i = 0; i < scenarios.length; i++) {
9478
9730
  const scenario = scenarios[i];
9479
9731
  log2(`
@@ -9494,6 +9746,7 @@ ${statePacket}` },
9494
9746
  targetAppId,
9495
9747
  log: log2
9496
9748
  });
9749
+ consecutiveInfraFailures = 0;
9497
9750
  const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
9498
9751
  if (!test) {
9499
9752
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
@@ -9510,13 +9763,31 @@ ${statePacket}` },
9510
9763
  log2(` onTestGenerated callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
9511
9764
  }
9512
9765
  tests.push(test);
9766
+ generatedScenarioNames.add(scenario.name);
9513
9767
  } catch (err) {
9768
+ if ((0, ai_retry_js_1.isAIProviderError)(err)) {
9769
+ consecutiveInfraFailures += 1;
9770
+ log2(` scenario "${scenario.name}" failed \u2014 AI provider error (${consecutiveInfraFailures}/${MAX_CONSECUTIVE_INFRA_FAILURES}): ${err instanceof Error ? err.message : String(err)}`);
9771
+ if (consecutiveInfraFailures >= MAX_CONSECUTIVE_INFRA_FAILURES) {
9772
+ const remainingScenarioNames = scenarios.filter((s) => !generatedScenarioNames.has(s.name)).map((s) => s.name);
9773
+ infraStopped = true;
9774
+ log2(`
9775
+ \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.`);
9776
+ try {
9777
+ onInfraStop?.({ remainingScenarioNames, reason: "ai_rate_limit" });
9778
+ } catch (cbErr) {
9779
+ log2(` onInfraStop callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
9780
+ }
9781
+ break;
9782
+ }
9783
+ continue;
9784
+ }
9514
9785
  log2(` scenario "${scenario.name}" failed: ${err instanceof Error ? err.message : String(err)} \u2014 skipping.`);
9515
9786
  continue;
9516
9787
  }
9517
9788
  }
9518
9789
  log2(`
9519
- Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified).`);
9790
+ 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" : ""}.`);
9520
9791
  return tests;
9521
9792
  }
9522
9793
  }
@@ -12795,6 +13066,10 @@ var require_snapshot_observation = __commonJS({
12795
13066
  var snapshot_parser_js_1 = require_snapshot_parser();
12796
13067
  var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
12797
13068
  var NOISE_LABEL_RE = /^(skip\s+to|skip\s+nav|back\s+to\s+top|cookie|accept\s+(all\s+)?cookies)$/i;
13069
+ var DIALOG_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog\b/i;
13070
+ var FORM_SEGMENT_RE = /(?:^|>\s*)form\b/i;
13071
+ var TABPANEL_SEGMENT_RE = /(?:^|>\s*)tabpanel\b/i;
13072
+ var DIALOG_NAME_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog:([^>]+)/i;
12798
13073
  var INTERACTIVE_ROLE_RE = /^(button|link|textbox|searchbox|checkbox|radio|switch|combobox|listbox|option|menuitem|tab|slider|spinbutton|textarea)$/i;
12799
13074
  var INTERACTIVE_ELEMENT_TYPE_RE = /^(button|link|input|textarea|select|checkbox|radio|switch|tab|combobox|listbox|option|menuitem|slider|spinbutton)$/i;
12800
13075
  function renderRetrievedSnapshotMemoryBlock(priorSnapshotEvidence, priorDiscoveryCheckpoints, options = {}) {
@@ -13039,10 +13314,12 @@ var require_snapshot_observation = __commonJS({
13039
13314
  summary
13040
13315
  }));
13041
13316
  }
13317
+ var TEXT_DISAMBIGUATING_INPUT_TYPES = /* @__PURE__ */ new Set(["password", "text", "email", "tel", "number", "search", "url", "date", "datetime-local", "time", "month", "week"]);
13042
13318
  function renderCompactSnapshotBlock(page, context = {}, options = {}) {
13043
13319
  const metrics = page.snapshotMetrics ?? page.snapshotArtifact?.metrics;
13044
13320
  const sourceKind = page.snapshotMeta?.sourceKind ?? page.snapshotArtifact?.sourceKind ?? "missing";
13045
13321
  const artifact = page.snapshotArtifact;
13322
+ const richRender = options.richRender ?? process.env.DISCOVERY_SNAPSHOT_RICH_RENDER === "true";
13046
13323
  const activeScope = inferActiveSnapshotScope(page);
13047
13324
  const ranked = rankSnapshotElements(getScopedSnapshotElements(page, activeScope), withScopeContext(context, activeScope));
13048
13325
  const allInteractive = ranked.filter(({ element }) => isInteractiveCandidate(element)).filter(({ element }) => {
@@ -13088,7 +13365,7 @@ var require_snapshot_observation = __commonJS({
13088
13365
  if (activeScope.rootRef) {
13089
13366
  lines.push(` root_ref: ${yamlScalar(activeScope.rootRef)}`);
13090
13367
  }
13091
- const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText);
13368
+ const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText, richRender);
13092
13369
  if (scopePreview.length > 0) {
13093
13370
  lines.push(" scope_snapshot_preview: |");
13094
13371
  for (const previewLine of scopePreview) {
@@ -13130,6 +13407,19 @@ var require_snapshot_observation = __commonJS({
13130
13407
  lines.push(` - ${yamlScalar(modal)}`);
13131
13408
  }
13132
13409
  }
13410
+ const staticMessages = page.staticMessages ?? [];
13411
+ if (richRender && staticMessages.length > 0) {
13412
+ const messageLimit = 6;
13413
+ const ordered = [...staticMessages].sort((a, b) => messageRolePriority(a.role) - messageRolePriority(b.role));
13414
+ lines.push("messages:");
13415
+ 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.");
13416
+ for (const message of ordered.slice(0, messageLimit)) {
13417
+ lines.push(` - [${message.role}] ${yamlScalar(message.text)}`);
13418
+ }
13419
+ if (ordered.length > messageLimit) {
13420
+ lines.push(` # +${ordered.length - messageLimit} more message(s) not listed`);
13421
+ }
13422
+ }
13133
13423
  lines.push("session_state:");
13134
13424
  if (page.sessionState) {
13135
13425
  lines.push(` authenticated: ${yamlScalar(page.sessionState.authenticated)}`);
@@ -13192,7 +13482,10 @@ var require_snapshot_observation = __commonJS({
13192
13482
  }
13193
13483
  const hasRowContext = group.rowContexts.some((rc) => !!rc);
13194
13484
  if (hasRowContext) {
13195
- lines.push(' distinguish_by_row: # same name in different rows \u2014 scope by this text, e.g. getByRole(role, { name }).filter({ has: getByText("<row>") })');
13485
+ const firstRowRole = group.rowRoles[0] ?? null;
13486
+ const rowRole = firstRowRole && group.rowRoles.every((r) => r === firstRowRole) ? firstRowRole : null;
13487
+ 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`;
13488
+ 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}`);
13196
13489
  for (let i = 0; i < group.rowContexts.length; i++) {
13197
13490
  const rowContext = group.rowContexts[i];
13198
13491
  if (!rowContext)
@@ -13222,8 +13515,13 @@ var require_snapshot_observation = __commonJS({
13222
13515
  lines.push(` placeholder: ${yamlScalar(rankedElement.element.placeholder)}`);
13223
13516
  }
13224
13517
  const stateTokens = buildStateSummary(rankedElement.element);
13518
+ const addedDisabled = richRender && !!rankedElement.element.isDisabled && !stateTokens.includes("disabled");
13519
+ if (addedDisabled) {
13520
+ stateTokens.unshift("disabled");
13521
+ }
13225
13522
  if (stateTokens.length > 0) {
13226
- lines.push(` state: [${stateTokens.map((token) => yamlScalar(token)).join(", ")}]`);
13523
+ const note = addedDisabled ? " # disabled = captured instant; may enable after valid input \u2014 still exercise fill\u2192submit" : "";
13524
+ lines.push(` state: [${stateTokens.map((token) => yamlScalar(token)).join(", ")}]${note}`);
13227
13525
  }
13228
13526
  if (rankedElement.element.containerPath) {
13229
13527
  lines.push(` container_path: ${yamlScalar(rankedElement.element.containerPath)}`);
@@ -13233,6 +13531,22 @@ var require_snapshot_observation = __commonJS({
13233
13531
  lines.push(` locator: ${yamlScalar((0, snapshot_parser_js_1.formatLocator)(topLocator))}`);
13234
13532
  }
13235
13533
  const enr = rankedElement.element.enrichment;
13534
+ if (richRender && enr?.inputType && TEXT_DISAMBIGUATING_INPUT_TYPES.has(enr.inputType)) {
13535
+ lines.push(` type: ${yamlScalar(enr.inputType)}`);
13536
+ }
13537
+ if (richRender && rankedElement.element.href) {
13538
+ lines.push(` url: ${yamlScalar(rankedElement.element.href)}`);
13539
+ }
13540
+ if (richRender && rankedElement.element.value && enr?.inputType !== "password") {
13541
+ lines.push(` value: ${yamlScalar(rankedElement.element.value)}`);
13542
+ }
13543
+ const elementOptions = rankedElement.element.options ?? [];
13544
+ if (richRender && elementOptions.length > 0) {
13545
+ const optionLimit = 12;
13546
+ const rendered = elementOptions.slice(0, optionLimit).map((option) => yamlScalar(option.selected ? `${option.label} (selected)` : option.label));
13547
+ const overflow = elementOptions.length > optionLimit ? ` # +${elementOptions.length - optionLimit} more option(s)` : "";
13548
+ lines.push(` options: [${rendered.join(", ")}]${overflow}`);
13549
+ }
13236
13550
  if (enr?.rowContext) {
13237
13551
  lines.push(` row_context: ${yamlScalar(enr.rowContext)}`);
13238
13552
  }
@@ -13245,6 +13559,12 @@ var require_snapshot_observation = __commonJS({
13245
13559
  lines.push(` visibility: [${vis.map((token) => yamlScalar(token)).join(", ")}]`);
13246
13560
  }
13247
13561
  }
13562
+ if (richRender) {
13563
+ const hidden = allInteractive.length - interactive.length;
13564
+ if (hidden > 0) {
13565
+ 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.`);
13566
+ }
13567
+ }
13248
13568
  }
13249
13569
  const recoveredClickables = options.recoveredClickables ?? [];
13250
13570
  if (recoveredClickables.length > 0) {
@@ -13401,11 +13721,11 @@ var require_snapshot_observation = __commonJS({
13401
13721
  score += 10;
13402
13722
  if (element.isExpanded)
13403
13723
  score += 8;
13404
- if (element.containerPath?.includes("dialog"))
13724
+ if (element.containerPath && DIALOG_SEGMENT_RE.test(element.containerPath))
13405
13725
  score += 12;
13406
- if (element.containerPath?.includes("form"))
13726
+ if (element.containerPath && FORM_SEGMENT_RE.test(element.containerPath))
13407
13727
  score += 10;
13408
- if (element.containerPath?.includes("tabpanel"))
13728
+ if (element.containerPath && TABPANEL_SEGMENT_RE.test(element.containerPath))
13409
13729
  score += 8;
13410
13730
  if (element.inputType === "email" || element.inputType === "password" || element.inputType === "search")
13411
13731
  score += 6;
@@ -13476,10 +13796,10 @@ var require_snapshot_observation = __commonJS({
13476
13796
  }
13477
13797
  return merged;
13478
13798
  }
13479
- function buildScopeSnapshotPreview(scopeSnapshotText) {
13799
+ function buildScopeSnapshotPreview(scopeSnapshotText, stripTrailingValues = false) {
13480
13800
  if (!scopeSnapshotText)
13481
13801
  return [];
13482
- return scopeSnapshotText.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 6);
13802
+ return scopeSnapshotText.split("\n").map((line) => line.trim()).map((line) => stripTrailingValues ? line.replace(/\]\s*:\s.+$/, "]") : line).filter(Boolean).slice(0, 6);
13483
13803
  }
13484
13804
  function dedupeLines(lines) {
13485
13805
  const seen = /* @__PURE__ */ new Set();
@@ -13606,11 +13926,12 @@ var require_snapshot_observation = __commonJS({
13606
13926
  const key = `${role}\0${name}`;
13607
13927
  let group = groups.get(key);
13608
13928
  if (!group) {
13609
- group = { role, name, containers: [], rowContexts: [], refs: [] };
13929
+ group = { role, name, containers: [], rowContexts: [], rowRoles: [], refs: [] };
13610
13930
  groups.set(key, group);
13611
13931
  }
13612
13932
  group.containers.push(element.containerPath ?? null);
13613
13933
  group.rowContexts.push(element.enrichment?.rowContext ?? null);
13934
+ group.rowRoles.push(element.enrichment?.rowRole ?? null);
13614
13935
  group.refs.push(element.ref ?? null);
13615
13936
  }
13616
13937
  return [...groups.values()].filter((group) => group.containers.length > 1);
@@ -13620,7 +13941,7 @@ var require_snapshot_observation = __commonJS({
13620
13941
  for (const element of elements) {
13621
13942
  const role = element.role?.toLowerCase() ?? "";
13622
13943
  const elementType = element.elementType?.toLowerCase() ?? "";
13623
- const isDialog = role === "dialog" || elementType === "dialog" || (element.containerPath?.toLowerCase().includes("dialog:") ?? false);
13944
+ const isDialog = role === "dialog" || elementType === "dialog" || DIALOG_NAME_SEGMENT_RE.test(element.containerPath ?? "");
13624
13945
  if (!isDialog)
13625
13946
  continue;
13626
13947
  const hint = qualifyElementLabel(element) ?? dialogNameFromContainerPath(element.containerPath) ?? element.label ?? element.testId ?? "dialog";
@@ -13633,7 +13954,7 @@ var require_snapshot_observation = __commonJS({
13633
13954
  function dialogNameFromContainerPath(containerPath) {
13634
13955
  if (!containerPath)
13635
13956
  return null;
13636
- const match = containerPath.match(/dialog:([^>]+)/i);
13957
+ const match = containerPath.match(DIALOG_NAME_SEGMENT_RE);
13637
13958
  return match ? match[1].trim() : null;
13638
13959
  }
13639
13960
  function renderObservationCue(observation) {
@@ -13642,6 +13963,13 @@ var require_snapshot_observation = __commonJS({
13642
13963
  const base2 = observation.outcomeType === "SUCCESS" && outcome ? outcome : `${action} -> ${outcome || observation.outcomeType}`;
13643
13964
  return base2.length > 120 ? `${base2.slice(0, 117)}...` : base2;
13644
13965
  }
13966
+ function messageRolePriority(role) {
13967
+ if (role === "alert")
13968
+ return 0;
13969
+ if (role === "status")
13970
+ return 1;
13971
+ return 2;
13972
+ }
13645
13973
  function yamlScalar(value) {
13646
13974
  if (value == null || value === "")
13647
13975
  return "null";
@@ -20190,7 +20518,10 @@ ${testOpen}`;
20190
20518
  pressed: se.pressed,
20191
20519
  headingLevel: se.headingLevel,
20192
20520
  currentState: se.currentState,
20193
- containerPath: se.containerPath || void 0
20521
+ containerPath: se.containerPath || void 0,
20522
+ href: se.href || void 0,
20523
+ value: se.value || void 0,
20524
+ options: se.options && se.options.length > 0 ? se.options : void 0
20194
20525
  }));
20195
20526
  const structuredPage = {
20196
20527
  route: pageUrl || "/",
@@ -20198,6 +20529,7 @@ ${testOpen}`;
20198
20529
  pageType: "unknown",
20199
20530
  elements: collectedElements,
20200
20531
  formGroups: parsed.formGroups,
20532
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
20201
20533
  snapshotMetrics: {
20202
20534
  rawLineCount: parsed.rawLineCount,
20203
20535
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -21764,7 +22096,7 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
21764
22096
  let stopped;
21765
22097
  const agentModel = options?.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
21766
22098
  const auditGroupId = `generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
21767
- const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)();
22099
+ const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
21768
22100
  const credentials = variableContext.allTestVariables ?? variableContext.publicTestVariables;
21769
22101
  logs2.push(`Phase 3 \u2014 Generate: ${(plan.scenarios ?? []).length} scenarios, max ${maxTests} tests`);
21770
22102
  logs2.push(`Model: ${agentModel}`);
@@ -235729,7 +236061,7 @@ var require_mcp_healer = __commonJS({
235729
236061
  messages.push(system, firstUser, { role: "user", content: lines.join("\n") }, ...tail);
235730
236062
  }
235731
236063
  var DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS = 6;
235732
- function readPositiveIntEnv(env, name, fallback) {
236064
+ function readPositiveIntEnv2(env, name, fallback) {
235733
236065
  const raw = env[name]?.trim();
235734
236066
  if (!raw || !/^\d+$/.test(raw))
235735
236067
  return fallback;
@@ -235737,7 +236069,7 @@ var require_mcp_healer = __commonJS({
235737
236069
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
235738
236070
  }
235739
236071
  function getScriptHealMaxAttempts(env = process.env) {
235740
- return readPositiveIntEnv(env, "MCP_HEAL_SCRIPT_MAX_ATTEMPTS", DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS);
236072
+ return readPositiveIntEnv2(env, "MCP_HEAL_SCRIPT_MAX_ATTEMPTS", DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS);
235741
236073
  }
235742
236074
  exports2.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.
235743
236075
 
@@ -236073,7 +236405,7 @@ ${lines.join("\n")}
236073
236405
  const landingViolation = (tags ?? []).includes("@landing-violation");
236074
236406
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "strong") : (0, ai_provider_js_1.getModel)("strong");
236075
236407
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
236076
- const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)());
236408
+ const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
236077
236409
  const runNativeTest = options.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
236078
236410
  const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
236079
236411
  const groupId = `script-heal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -236895,10 +237227,10 @@ ${(verifyResult.logs ?? "").slice(0, 2e3)}`;
236895
237227
  var DEFAULT_MAX_PHASE2_ITERATIONS = 4;
236896
237228
  var DEFAULT_MAX_MCP_EXPLORE_ITERATIONS = 25;
236897
237229
  function getMcpHealPhase2MaxIterations(env = process.env) {
236898
- return readPositiveIntEnv(env, "MCP_HEAL_PHASE2_MAX_ITERATIONS", DEFAULT_MAX_PHASE2_ITERATIONS);
237230
+ return readPositiveIntEnv2(env, "MCP_HEAL_PHASE2_MAX_ITERATIONS", DEFAULT_MAX_PHASE2_ITERATIONS);
236899
237231
  }
236900
237232
  function getMcpHealExploreMaxIterations(env = process.env) {
236901
- return readPositiveIntEnv(env, "MCP_HEAL_EXPLORE_MAX_ITERATIONS", DEFAULT_MAX_MCP_EXPLORE_ITERATIONS);
237233
+ return readPositiveIntEnv2(env, "MCP_HEAL_EXPLORE_MAX_ITERATIONS", DEFAULT_MAX_MCP_EXPLORE_ITERATIONS);
236902
237234
  }
236903
237235
  var DEFAULT_MCP_HEAL_MAX_WALL_CLOCK_MS = 10 * 60 * 1e3;
236904
237236
  var DEFAULT_MCP_HEAL_MAX_TOTAL_AI_CALLS = 60;
@@ -236950,7 +237282,7 @@ Only explore the failing page and the area around the broken step.`;
236950
237282
  let lastVideo;
236951
237283
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
236952
237284
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
236953
- const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)());
237285
+ const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
236954
237286
  const runPhase2NativeTest = options?.phase2Harness?.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
236955
237287
  const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
236956
237288
  const groupId = `phase2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -237640,7 +237972,10 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
237640
237972
  pressed: se.pressed,
237641
237973
  headingLevel: se.headingLevel,
237642
237974
  currentState: se.currentState,
237643
- containerPath: se.containerPath || void 0
237975
+ containerPath: se.containerPath || void 0,
237976
+ href: se.href || void 0,
237977
+ value: se.value || void 0,
237978
+ options: se.options && se.options.length > 0 ? se.options : void 0
237644
237979
  }));
237645
237980
  const structuredPage = {
237646
237981
  route: pageUrl || "/",
@@ -237648,6 +237983,7 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
237648
237983
  pageType: "unknown",
237649
237984
  elements: collectedElements,
237650
237985
  formGroups: parsed.formGroups,
237986
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
237651
237987
  snapshotMetrics: {
237652
237988
  rawLineCount: parsed.rawLineCount,
237653
237989
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -238084,7 +238420,7 @@ ${testBlocks}
238084
238420
  - By iteration ${Math.floor(maxIterations * 0.3)}: you should have completed 1-2 tests
238085
238421
  - By iteration ${Math.floor(maxIterations * 0.7)}: you should be past the halfway point
238086
238422
  - At iteration ${Math.floor(maxIterations * 0.9)}: wrap up and call finish_capture`;
238087
- const aiClient = modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)();
238423
+ const aiClient = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
238088
238424
  const messages = [
238089
238425
  { role: "system", content: systemPrompt },
238090
238426
  { role: "user", content: "Start executing the tests. Navigate to the base URL and begin with Test 1." }
@@ -238267,7 +238603,10 @@ ${testBlocks}
238267
238603
  pressed: se.pressed,
238268
238604
  headingLevel: se.headingLevel,
238269
238605
  currentState: se.currentState,
238270
- containerPath: se.containerPath || void 0
238606
+ containerPath: se.containerPath || void 0,
238607
+ href: se.href || void 0,
238608
+ value: se.value || void 0,
238609
+ options: se.options && se.options.length > 0 ? se.options : void 0
238271
238610
  }));
238272
238611
  const structuredPage = {
238273
238612
  route: pageUrl || "/",
@@ -238275,6 +238614,7 @@ ${testBlocks}
238275
238614
  pageType: "unknown",
238276
238615
  elements: collectedElements,
238277
238616
  formGroups: parsed.formGroups,
238617
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
238278
238618
  snapshotMetrics: {
238279
238619
  rawLineCount: parsed.rawLineCount,
238280
238620
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -300064,8 +300404,9 @@ var require_capture_page_normalizer = __commonJS({
300064
300404
  let snapshotElements = [];
300065
300405
  let formGroups = [];
300066
300406
  let snapshotMetrics;
300407
+ let staticMessages;
300067
300408
  if (normalizedSnapshotResult.snapshotText) {
300068
- const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText);
300409
+ const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText, { richParse: context.richParse });
300069
300410
  snapshotMetrics = {
300070
300411
  rawLineCount: parsed.rawLineCount,
300071
300412
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -300094,9 +300435,13 @@ var require_capture_page_normalizer = __commonJS({
300094
300435
  pressed: se.pressed,
300095
300436
  headingLevel: se.headingLevel,
300096
300437
  currentState: se.currentState,
300097
- containerPath: se.containerPath || void 0
300438
+ containerPath: se.containerPath || void 0,
300439
+ href: se.href || void 0,
300440
+ value: se.value || void 0,
300441
+ options: se.options && se.options.length > 0 ? se.options : void 0
300098
300442
  }));
300099
300443
  formGroups = parsed.formGroups;
300444
+ staticMessages = parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0;
300100
300445
  }
300101
300446
  const aiElements = Array.isArray(toolArgs.elements) ? toolArgs.elements.map(normalizeAiElement) : [];
300102
300447
  const noYamlKinds = /* @__PURE__ */ new Set(["missing", "timeout", "error", "empty"]);
@@ -300192,6 +300537,7 @@ var require_capture_page_normalizer = __commonJS({
300192
300537
  } : void 0,
300193
300538
  readinessSignals,
300194
300539
  viewportHints,
300540
+ staticMessages,
300195
300541
  provenCommands: provenCmds,
300196
300542
  observations: rawObservations,
300197
300543
  routeCorrected,
@@ -300500,6 +300846,10 @@ var require_perception_enricher = __commonJS({
300500
300846
  const e = {};
300501
300847
  if (p.rowContext)
300502
300848
  e.rowContext = p.rowContext;
300849
+ if (p.rowContext && p.rowRole)
300850
+ e.rowRole = p.rowRole;
300851
+ if (p.inputType)
300852
+ e.inputType = p.inputType;
300503
300853
  if (p.position && p.position !== "in-view")
300504
300854
  e.position = p.position;
300505
300855
  if (p.occluded)
@@ -300749,6 +301099,23 @@ var require_perception_enricher = __commonJS({
300749
301099
  const cls = (el.getAttribute("class") || "").toLowerCase();
300750
301100
  return /(^|[\s_-])(card|row|item|listitem|cell|tile)([\s_-]|$)/.test(cls);
300751
301101
  };
301102
+ const rowRoleOf = (el) => {
301103
+ const explicit = (el.getAttribute("role") || "").toLowerCase().trim();
301104
+ if (explicit) {
301105
+ return explicit === "row" || explicit === "listitem" || explicit === "article" || explicit === "option" ? explicit : null;
301106
+ }
301107
+ const tag = el.tagName;
301108
+ if (tag === "TR") {
301109
+ const tbl = el.closest("table");
301110
+ const tblRole = tbl ? (tbl.getAttribute("role") || "").toLowerCase().trim() : "";
301111
+ return tblRole === "presentation" || tblRole === "none" ? null : "row";
301112
+ }
301113
+ if (tag === "LI")
301114
+ return el.closest('ul:not([role]),ol:not([role]),menu:not([role]),[role="list"]') ? "listitem" : null;
301115
+ if (tag === "ARTICLE")
301116
+ return "article";
301117
+ return null;
301118
+ };
300752
301119
  const rowContextOf = (el, ownNameLower) => {
300753
301120
  let cur = el.parentElement;
300754
301121
  let depth = 0;
@@ -300767,7 +301134,7 @@ var require_perception_enricher = __commonJS({
300767
301134
  txt = txt.slice(0, 80);
300768
301135
  const low = txt.toLowerCase();
300769
301136
  if (txt && low !== ownNameLower && low.length > 1)
300770
- return txt;
301137
+ return { text: txt, role: rowRoleOf(cur) };
300771
301138
  return null;
300772
301139
  }
300773
301140
  cur = cur.parentElement;
@@ -300857,11 +301224,14 @@ var require_perception_enricher = __commonJS({
300857
301224
  const name = accName(el);
300858
301225
  const lname = lower(name);
300859
301226
  const rect = vis.rect;
301227
+ const rc = rowContextOf(el, lname);
300860
301228
  out.push({
300861
301229
  domOrder: myOrder,
300862
301230
  roleClass: roleClassOf(tag, roleAttr, inputType),
300863
301231
  name: lname,
300864
- rowContext: rowContextOf(el, lname),
301232
+ rowContext: rc ? rc.text : null,
301233
+ rowRole: rc ? rc.role : null,
301234
+ inputType: inputType || null,
300865
301235
  position: positionOf(rect),
300866
301236
  occluded: occlusionOf(el, rect),
300867
301237
  cursorPointer,
@@ -300898,11 +301268,14 @@ var require_perception_enricher = __commonJS({
300898
301268
  if (!name)
300899
301269
  continue;
300900
301270
  const lname = lower(name);
301271
+ const rc = rowContextOf(el, lname);
300901
301272
  out.push({
300902
301273
  domOrder: order++,
300903
301274
  roleClass: "button",
300904
301275
  name: lname,
300905
- rowContext: rowContextOf(el, lname),
301276
+ rowContext: rc ? rc.text : null,
301277
+ rowRole: rc ? rc.role : null,
301278
+ inputType: null,
300906
301279
  position: "in-view",
300907
301280
  occluded: false,
300908
301281
  cursorPointer: true,
@@ -301404,7 +301777,7 @@ var require_discover_explorer = __commonJS({
301404
301777
  var DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP = 100;
301405
301778
  var DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY = 100;
301406
301779
  var DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL = 40;
301407
- function readPositiveIntEnv(env, name, fallback) {
301780
+ function readPositiveIntEnv2(env, name, fallback) {
301408
301781
  const raw = env[name]?.trim();
301409
301782
  if (!raw || !/^\d+$/.test(raw))
301410
301783
  return fallback;
@@ -301535,15 +301908,15 @@ var require_discover_explorer = __commonJS({
301535
301908
  }
301536
301909
  function getMaxIterations(mode, incremental, env = process.env) {
301537
301910
  if (mode === "survey" && incremental) {
301538
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_INCREMENTAL", DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL);
301911
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_INCREMENTAL", DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL);
301539
301912
  }
301540
301913
  if (mode === "survey") {
301541
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_SURVEY", DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY);
301914
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_SURVEY", DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY);
301542
301915
  }
301543
301916
  if (mode === "deep") {
301544
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEEP", DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP);
301917
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEEP", DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP);
301545
301918
  }
301546
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEFAULT", DEFAULT_MAX_EXPLORE_ITERATIONS_DEFAULT);
301919
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEFAULT", DEFAULT_MAX_EXPLORE_ITERATIONS_DEFAULT);
301547
301920
  }
301548
301921
  function updatePendingRouteScreenshot(pending, { imageData, detectedRoute, fallbackRoute }) {
301549
301922
  if (imageData) {
@@ -302269,7 +302642,7 @@ ${inboxPromptBlock}`;
302269
302642
  const browserCreds = variableContext.allTestVariables ?? variableContext.publicTestVariables;
302270
302643
  const { secrets: browserSecretsForHint } = (0, credential_tools_js_1.partitionTestVariables)(browserCreds);
302271
302644
  const hasLoginCredentials = Object.keys(browserSecretsForHint).length > 0;
302272
- const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)();
302645
+ const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
302273
302646
  const isDeepMode = options?.mode === "deep";
302274
302647
  const maxIterations = config?.maxIterationsOverride && config.maxIterationsOverride > 0 ? config.maxIterationsOverride : getMaxIterations(options?.mode ?? "full", options?.incremental);
302275
302648
  const configKeepTail = isDeepMode ? Math.ceil(ctxConfig.keepTail * 1.5) : ctxConfig.keepTail;
@@ -304808,7 +305181,7 @@ var require_auth_capture_run = __commonJS({
304808
305181
  var scrub_credentials_js_1 = require_scrub_credentials();
304809
305182
  var credential_tools_js_1 = require_credential_tools();
304810
305183
  var DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS = 12;
304811
- function readPositiveIntEnv(env, name, fallback) {
305184
+ function readPositiveIntEnv2(env, name, fallback) {
304812
305185
  const raw = env[name]?.trim();
304813
305186
  if (!raw || !/^\d+$/.test(raw))
304814
305187
  return fallback;
@@ -304816,7 +305189,7 @@ var require_auth_capture_run = __commonJS({
304816
305189
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
304817
305190
  }
304818
305191
  function getLoginCaptureMaxIterations(env = process.env) {
304819
- return readPositiveIntEnv(env, "AUTH_CAPTURE_LOGIN_MAX_ITERATIONS", DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS);
305192
+ return readPositiveIntEnv2(env, "AUTH_CAPTURE_LOGIN_MAX_ITERATIONS", DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS);
304820
305193
  }
304821
305194
  var CAPTURE_RATE_LIMIT_MAX_RETRIES = 2;
304822
305195
  var CAPTURE_RATE_LIMIT_MAX_DELAY_MS = 8e3;
@@ -305099,7 +305472,7 @@ ${keyLines || "- (none provided)"}
305099
305472
  }
305100
305473
  var DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS = 18;
305101
305474
  function getRegisterCaptureMaxIterations(env = process.env) {
305102
- return readPositiveIntEnv(env, "AUTH_CAPTURE_REGISTER_MAX_ITERATIONS", DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS);
305475
+ return readPositiveIntEnv2(env, "AUTH_CAPTURE_REGISTER_MAX_ITERATIONS", DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS);
305103
305476
  }
305104
305477
  function buildRegisterCaptureSystemPrompt(baseUrl, availableKeys, maxIterations = getRegisterCaptureMaxIterations()) {
305105
305478
  const keyLines = availableKeys.map((k) => `- \`${k}\``).join("\n");
@@ -311329,6 +311702,7 @@ var require_mobile_exploration_state = __commonJS({
311329
311702
  Object.defineProperty(exports2, "__esModule", { value: true });
311330
311703
  exports2.MobileExplorationState = void 0;
311331
311704
  exports2.deriveScreenId = deriveScreenId;
311705
+ var mobile_observation_js_1 = require_mobile_observation();
311332
311706
  var DEFAULT_STALE_THRESHOLD = 4;
311333
311707
  function deriveScreenId(signal) {
311334
311708
  const namespace = signal.activity ?? signal.bundleId;
@@ -311554,21 +311928,33 @@ var require_mobile_exploration_state = __commonJS({
311554
311928
  * Build the planner evidence shape: one `CollectedPageData` per screen, with
311555
311929
  * the screen-id carried in BOTH `route` (the on-the-wire positional field the
311556
311930
  * server keys screenshots by) AND the explicit `screenId` field (per the
311557
- * Phase-0 `CollectedPageData.screenId` contract). `elements` is left empty —
311558
- * the web-shaped `CollectedPageElement[]` is not the mobile element shape;
311559
- * the mobile planner reads screen identity + names from this evidence and the
311560
- * richer per-element data flows through the generator's own re-drive, not
311561
- * through this map. Insertion order matches first-seen order.
311931
+ * Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
311932
+ * the screen's parsed actionable elements into the platform-neutral
311933
+ * `CollectedPageElement[]` shape the server persists as SiteElements without
311934
+ * this, mobile screens land with zero elements (coverage always 0%, no element
311935
+ * intelligence). `triggersNavigation` is derived from transition-source
311936
+ * membership: an element is flagged when its ref drove a transition FROM this
311937
+ * screen. Insertion order matches first-seen order.
311562
311938
  */
311563
311939
  buildInMemoryScreenMap() {
311564
311940
  const pages = [];
311565
311941
  const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
311942
+ const navRefsByScreen = /* @__PURE__ */ new Map();
311943
+ for (const transition of this.transitions) {
311944
+ if (!transition.viaRef)
311945
+ continue;
311946
+ const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
311947
+ set.add(transition.viaRef);
311948
+ navRefsByScreen.set(transition.fromScreenId, set);
311949
+ }
311566
311950
  for (const state2 of ordered) {
311567
311951
  const page = {
311568
311952
  route: state2.screenId,
311569
311953
  screenId: state2.screenId,
311570
311954
  pageType: state2.screenType ?? "UNKNOWN",
311571
- elements: []
311955
+ elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
311956
+ navigationRefs: navRefsByScreen.get(state2.screenId)
311957
+ })
311572
311958
  };
311573
311959
  if (state2.screenName) {
311574
311960
  page.title = state2.screenName;
@@ -311598,6 +311984,7 @@ var require_mobile_discovery_agent = __commonJS({
311598
311984
  "../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports2) {
311599
311985
  "use strict";
311600
311986
  Object.defineProperty(exports2, "__esModule", { value: true });
311987
+ exports2.getMobileExploreBudget = getMobileExploreBudget;
311601
311988
  exports2.runMobileDiscoveryOnRunner = runMobileDiscoveryOnRunner2;
311602
311989
  var mobile_explorer_js_1 = require_mobile_explorer();
311603
311990
  var mobile_discovery_prompts_js_1 = require_mobile_discovery_prompts();
@@ -311611,8 +311998,37 @@ var require_mobile_discovery_agent = __commonJS({
311611
311998
  const message = checkpointErrorMessage(err);
311612
311999
  return /Discovery plan checkpoint rejected|Discovery run is .*not RUNNING|not RUNNING|cancelled/i.test(message);
311613
312000
  }
311614
- var EXPLORE_BUDGET_DEEP = 60;
311615
- var EXPLORE_BUDGET_SURVEY = 40;
312001
+ var MOBILE_EXPLORE_BUDGET_SURVEY_FIRST = 90;
312002
+ var MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL = 40;
312003
+ var MOBILE_EXPLORE_BUDGET_DEEP = 60;
312004
+ var MOBILE_EXPLORE_BUDGET_FULL = 90;
312005
+ function readPositiveIntEnv2(name, fallback, env = process.env) {
312006
+ const raw = env[name]?.trim();
312007
+ if (!raw || !/^\d+$/.test(raw))
312008
+ return fallback;
312009
+ const parsed = Number(raw);
312010
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
312011
+ }
312012
+ function getMobileExploreBudget(mode, incremental, env = process.env) {
312013
+ if (mode === "deep")
312014
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_DEEP", MOBILE_EXPLORE_BUDGET_DEEP, env);
312015
+ if (mode === "full")
312016
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_FULL", MOBILE_EXPLORE_BUDGET_FULL, env);
312017
+ if (incremental) {
312018
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY_INCREMENTAL", MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL, env);
312019
+ }
312020
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY", MOBILE_EXPLORE_BUDGET_SURVEY_FIRST, env);
312021
+ }
312022
+ function compactExistingScreens(siteMap) {
312023
+ if (!siteMap || !Array.isArray(siteMap.pages) || siteMap.pages.length === 0)
312024
+ return void 0;
312025
+ const screens = siteMap.pages.filter((p) => typeof p.route === "string" && p.route.trim().length > 0).map((p) => ({
312026
+ screenId: p.route,
312027
+ ...p.title ? { name: p.title } : {},
312028
+ ...p.pageType ? { screenType: p.pageType } : {}
312029
+ }));
312030
+ return screens.length > 0 ? screens : void 0;
312031
+ }
311616
312032
  function platformForMedium(medium) {
311617
312033
  return medium === "IOS_NATIVE" ? "IOS" : "ANDROID";
311618
312034
  }
@@ -311631,6 +312047,9 @@ var require_mobile_discovery_agent = __commonJS({
311631
312047
  // Native screenshots are multi-megabyte PNGs. The live mirror streams them
311632
312048
  // through heartbeat telemetry; discovery checkpoints keep the screen graph
311633
312049
  // and transitions so large Android surveys cannot exceed server body limits.
312050
+ // D-D6 — pageScreenshotKeys is a small screen-id → storage-key map (NOT base64),
312051
+ // so it DOES ride the checkpoint: the server links each key to SitePage.screenshotKey.
312052
+ ...explore.pageScreenshotKeys && Object.keys(explore.pageScreenshotKeys).length > 0 ? { pageScreenshotKeys: explore.pageScreenshotKeys } : {},
311634
312053
  exploreStats: explore.exploreStats,
311635
312054
  healthObservations: explore.healthObservations
311636
312055
  };
@@ -311645,6 +312064,7 @@ var require_mobile_discovery_agent = __commonJS({
311645
312064
  const onLog = callbacks?.onLog;
311646
312065
  const onAICall = callbacks?.onAICall;
311647
312066
  const onScreenshot = callbacks?.onScreenshot;
312067
+ const onScreenshotUpload = callbacks?.onScreenshotUpload;
311648
312068
  const runExplore = callbacks?.runExplore ?? mobile_explorer_js_1.runMobileExplorePhase;
311649
312069
  const runPlan = callbacks?.runPlan ?? mobile_discovery_prompts_js_1.runMobileAIPlanner;
311650
312070
  const runGenerate = callbacks?.runGenerate ?? mobile_generator_js_1.runMobileGeneratePhase;
@@ -311671,15 +312091,77 @@ var require_mobile_discovery_agent = __commonJS({
311671
312091
  elementsFound: 0,
311672
312092
  transitionsFound: 0
311673
312093
  };
312094
+ const resumeSavedScenarios = ctx.resume?.savedPlan?.scenarios ?? [];
312095
+ const isPlanReuseResume = mode !== "survey" && resumeSavedScenarios.length > 0;
311674
312096
  try {
312097
+ if (isPlanReuseResume) {
312098
+ const alreadyGenerated = ctx.resume?.alreadyGeneratedScenarioNames ?? [];
312099
+ log2(`
312100
+ \u2500\u2500 Resume (plan-reuse): ${resumeSavedScenarios.length} saved scenario(s); ${alreadyGenerated.length} already generated. Skipping explore + plan; generating only the remainder. \u2500\u2500`);
312101
+ const scenarios2 = resumeSavedScenarios;
312102
+ const plans2 = toPlans(scenarios2);
312103
+ if (callbacks?.onPlanReady) {
312104
+ try {
312105
+ await callbacks.onPlanReady({ scenarios: scenarios2, featureGroups: void 0, fallbackUsed: false });
312106
+ log2("Plan checkpoint: reused scenario plan re-POSTed to server");
312107
+ } catch (err) {
312108
+ const message = checkpointErrorMessage(err);
312109
+ if (isTerminalDiscoveryCheckpointError(err)) {
312110
+ log2(`Plan checkpoint rejected for inactive discovery run: ${message}`);
312111
+ throw err;
312112
+ }
312113
+ log2(`Plan checkpoint POST failed (non-fatal): ${message}`);
312114
+ }
312115
+ }
312116
+ let paused = false;
312117
+ let pauseReason;
312118
+ let remainingScenarios;
312119
+ const tests2 = await runGenerate({
312120
+ scenarios: scenarios2,
312121
+ driver,
312122
+ ctx,
312123
+ onLog: (line) => log2(line),
312124
+ onAICall,
312125
+ onScreenshot,
312126
+ onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
312127
+ skipScenarioNames: alreadyGenerated,
312128
+ onInfraStop: (info) => {
312129
+ paused = true;
312130
+ pauseReason = info.reason;
312131
+ remainingScenarios = info.remainingScenarioNames;
312132
+ }
312133
+ });
312134
+ const duration2 = (Date.now() - startTime) / 1e3;
312135
+ 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" : ""}`;
312136
+ log2(`
312137
+ === Mobile Discovery Resume Complete (${duration2.toFixed(1)}s) ===`);
312138
+ log2(summary2);
312139
+ return {
312140
+ collectedPages: [],
312141
+ collectedTransitions: [],
312142
+ tests: tests2,
312143
+ plans: plans2,
312144
+ scenarios: scenarios2.length > 0 ? scenarios2 : void 0,
312145
+ logs: logs2.join("\n"),
312146
+ screenshots,
312147
+ summary: summary2,
312148
+ exploreStats: { pagesDiscovered: 0, pagesVisited: 0, elementsFound: 0, transitionsFound: 0 },
312149
+ duration: duration2,
312150
+ mode,
312151
+ ...paused ? { paused: true, pauseReason, remainingScenarios } : {}
312152
+ // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
312153
+ };
312154
+ }
311675
312155
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
311676
312156
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
311677
- const maxIterations = mode === "deep" ? EXPLORE_BUDGET_DEEP : EXPLORE_BUDGET_SURVEY;
312157
+ const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
312158
+ const existingScreens = compactExistingScreens(ctx.existingSiteMap);
311678
312159
  const explore = await runExplore({
311679
312160
  ctx,
311680
312161
  driver,
311681
312162
  state: state2,
311682
312163
  config: { maxIterations },
312164
+ existingScreens,
311683
312165
  onLog: (line) => log2(line),
311684
312166
  onAICall,
311685
312167
  onScreenshot: (base64) => {
@@ -311689,7 +312171,8 @@ var require_mobile_discovery_agent = __commonJS({
311689
312171
  onScreenshot?.(base64);
311690
312172
  } catch {
311691
312173
  }
311692
- }
312174
+ },
312175
+ onScreenshotUpload
311693
312176
  });
311694
312177
  const collectedTransitions = toCollectedTransitions(explore);
311695
312178
  partialCollectedPages = explore.collectedPages;
@@ -311790,6 +312273,9 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311790
312273
  };
311791
312274
  }
311792
312275
  log2("\n\u2500\u2500 Phase 3: GENERATE \u2500\u2500");
312276
+ let generatePaused = false;
312277
+ let generatePauseReason;
312278
+ let generateRemainingScenarios;
311793
312279
  const tests = await runGenerate({
311794
312280
  scenarios,
311795
312281
  driver,
@@ -311797,7 +312283,15 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311797
312283
  onLog: (line) => log2(line),
311798
312284
  onAICall,
311799
312285
  onScreenshot,
311800
- onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
312286
+ onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
312287
+ // Legacy resume (no saved plan): still skip scenarios already generated by the
312288
+ // paused run so the AI isn't re-invoked for them. Undefined on a fresh run.
312289
+ skipScenarioNames: ctx.resume?.alreadyGeneratedScenarioNames,
312290
+ onInfraStop: (info) => {
312291
+ generatePaused = true;
312292
+ generatePauseReason = info.reason;
312293
+ generateRemainingScenarios = info.remainingScenarioNames;
312294
+ }
311801
312295
  });
311802
312296
  const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
311803
312297
  log2(`
@@ -311813,7 +312307,7 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
311813
312307
  };
311814
312308
  }
311815
312309
  const duration = (Date.now() - startTime) / 1e3;
311816
- const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s`;
312310
+ 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)" : ""}`;
311817
312311
  log2(`
311818
312312
  === Mobile Discovery Pipeline Complete (${duration.toFixed(1)}s) ===`);
311819
312313
  log2(summary);
@@ -311838,7 +312332,11 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
311838
312332
  summary,
311839
312333
  exploreStats: explore.exploreStats,
311840
312334
  duration,
311841
- mode
312335
+ mode,
312336
+ // D-D4 — a circuit-breaker trip PAUSES the run (resumable) instead of
312337
+ // finalizing COMPLETED/FAILED. The server's isDiscoveryPaused reads these
312338
+ // (forwarded by runner.ts buildFinalBody, byte-identical to the web path).
312339
+ ...generatePaused ? { paused: true, pauseReason: generatePauseReason, remainingScenarios: generateRemainingScenarios } : {}
311842
312340
  // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
311843
312341
  };
311844
312342
  } catch (fatalErr) {
@@ -311884,6 +312382,7 @@ var require_discover_agent = __commonJS({
311884
312382
  Object.defineProperty(exports2, "__esModule", { value: true });
311885
312383
  exports2.runMobileDiscoveryOnRunner = exports2.buildInMemorySiteMap = void 0;
311886
312384
  exports2.runCanvasPreflight = runCanvasPreflight;
312385
+ exports2.getFatalExploreError = getFatalExploreError;
311887
312386
  exports2.runSiteDiscoveryOnRunner = runSiteDiscoveryOnRunner2;
311888
312387
  exports2.runFeatureDiscoveryOnRunner = runFeatureDiscoveryOnRunner2;
311889
312388
  var browser_config_js_1 = require_browser_config();
@@ -312066,7 +312565,7 @@ Respond with ONLY valid JSON:
312066
312565
  "keyFindings": "What was discovered - forms, validations, behaviors. Mark inferred items with '(inferred, not tested)'.",
312067
312566
  "testingNotes": "Gotchas, validation behaviors, error messages. List visible-but-untested routes as candidates for future exploration."
312068
312567
  }`;
312069
- const client = modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)();
312568
+ const client = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
312070
312569
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
312071
312570
  const callStart = Date.now();
312072
312571
  const response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => client.chat.completions.create({
@@ -312197,6 +312696,10 @@ Respond with ONLY valid JSON:
312197
312696
  });
312198
312697
  return timed.catch(() => null);
312199
312698
  }
312699
+ function getFatalExploreError(exploreResult) {
312700
+ const prefix = "Fatal error:";
312701
+ return exploreResult.summary.startsWith(prefix) ? exploreResult.summary.slice(prefix.length).trim() || exploreResult.summary : void 0;
312702
+ }
312200
312703
  async function runSiteDiscoveryOnRunner2(ctx, onLog, onAICall, sharedBrowser, onScreenshot, callbackOptions) {
312201
312704
  const logs2 = [];
312202
312705
  const screenshots = [];
@@ -312381,6 +312884,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
312381
312884
  if (loginRunP)
312382
312885
  await loginRunP;
312383
312886
  const duration2 = (Date.now() - startTime) / 1e3;
312887
+ const fatalExploreError = getFatalExploreError(exploreResult);
312384
312888
  return {
312385
312889
  collectedPages: exploreResult.collectedPages,
312386
312890
  collectedTransitions: exploreResult.collectedTransitions,
@@ -312391,6 +312895,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
312391
312895
  pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
312392
312896
  pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
312393
312897
  summary: exploreResult.summary || "Exploration completed but no page data captured.",
312898
+ error: fatalExploreError,
312394
312899
  exploreStats: {
312395
312900
  pagesDiscovered: exploreResult.pagesDiscovered,
312396
312901
  pagesVisited: exploreResult.pagesVisited,
@@ -312932,6 +313437,7 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
312932
313437
  discoveryApiCalls = earlyClose.apiCalls;
312933
313438
  postCloseHealthSummary = earlyClose.healthSummary;
312934
313439
  const duration2 = (Date.now() - startTime) / 1e3;
313440
+ const fatalExploreError = getFatalExploreError(exploreResult);
312935
313441
  return {
312936
313442
  collectedPages: exploreResult.collectedPages,
312937
313443
  collectedTransitions: exploreResult.collectedTransitions,
@@ -312942,7 +313448,8 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
312942
313448
  healthObservations: earlyClose.healthSummary,
312943
313449
  pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
312944
313450
  pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
312945
- summary: "Feature exploration completed but no page data captured.",
313451
+ summary: fatalExploreError ? exploreResult.summary : "Feature exploration completed but no page data captured.",
313452
+ error: fatalExploreError,
312946
313453
  exploreStats: {
312947
313454
  pagesDiscovered: exploreResult.pagesDiscovered,
312948
313455
  pagesVisited: exploreResult.pagesVisited,
@@ -326331,7 +326838,7 @@ var require_package4 = __commonJS({
326331
326838
  "package.json"(exports2, module2) {
326332
326839
  module2.exports = {
326333
326840
  name: "@validate.qa/runner",
326334
- version: "1.0.12",
326841
+ version: "1.0.13",
326335
326842
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
326336
326843
  bin: {
326337
326844
  "validate-runner": "dist/cli.js"
@@ -326339,7 +326846,7 @@ var require_package4 = __commonJS({
326339
326846
  scripts: {
326340
326847
  build: "tsup",
326341
326848
  dev: "tsx src/cli.ts",
326342
- "test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts",
326849
+ "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",
326343
326850
  prepublishOnly: "npm run build"
326344
326851
  },
326345
326852
  files: [
@@ -326566,8 +327073,10 @@ async function startAppiumServer(options) {
326566
327073
  return serverState;
326567
327074
  }
326568
327075
  const port = options?.port ?? APPIUM_PORT;
326569
- const drivers = options?.drivers ?? ["xcuitest", "uiautomator2"];
326570
- const args = ["--port", String(port), "--use-drivers", drivers.join(",")];
327076
+ const args = ["--port", String(port)];
327077
+ if (options?.drivers && options.drivers.length > 0) {
327078
+ args.push("--use-drivers", options.drivers.join(","));
327079
+ }
326571
327080
  intentionalStop = false;
326572
327081
  lastStartOptions = { ...lastStartOptions, ...options };
326573
327082
  if (appiumGaveUp) {
@@ -326577,11 +327086,12 @@ async function startAppiumServer(options) {
326577
327086
  killPort(port);
326578
327087
  return new Promise((resolve, reject) => {
326579
327088
  try {
326580
- appiumProcess = (0, import_child_process.spawn)("appium", args, {
327089
+ const child = (0, import_child_process.spawn)("appium", args, {
326581
327090
  stdio: ["pipe", "pipe", "pipe"],
326582
327091
  env: { ...process.env },
326583
327092
  detached: false
326584
327093
  });
327094
+ appiumProcess = child;
326585
327095
  serverState.pid = appiumProcess.pid ?? null;
326586
327096
  serverState.port = port;
326587
327097
  appiumProcess.stdout?.on("data", (data) => {
@@ -326598,7 +327108,11 @@ async function startAppiumServer(options) {
326598
327108
  options?.onLog?.(line);
326599
327109
  }
326600
327110
  });
326601
- appiumProcess.on("exit", (code, signal) => {
327111
+ child.on("exit", (code, signal) => {
327112
+ if (appiumProcess !== child) {
327113
+ appendLog(`[appium] Ignoring exit (code=${code}, signal=${signal}) from a superseded process`);
327114
+ return;
327115
+ }
326602
327116
  appendLog(`[appium] Process exited (code=${code}, signal=${signal})`);
326603
327117
  const currentPort = serverState.port;
326604
327118
  const currentRestartCount = serverState.restartCount;
@@ -326637,7 +327151,11 @@ async function startAppiumServer(options) {
326637
327151
  startupAbort.abort();
326638
327152
  reject(err);
326639
327153
  };
326640
- appiumProcess.on("error", (err) => {
327154
+ child.on("error", (err) => {
327155
+ if (appiumProcess !== child) {
327156
+ appendLog(`[appium] Ignoring error from a superseded process: ${err.message}`);
327157
+ return;
327158
+ }
326641
327159
  appendLog(`[appium] Process error: ${err.message}`);
326642
327160
  resetServerState(port);
326643
327161
  settleReject(new Error(`Failed to start Appium: ${err.message}. Is appium installed? Run: npm i -g appium`));
@@ -326756,6 +327274,7 @@ function runCommand(command, args, timeout = 1e4) {
326756
327274
  }
326757
327275
  var realIOSProfilerNegativeUntil = 0;
326758
327276
  var REAL_IOS_PROFILER_TTL_MS = 6e4;
327277
+ var REAL_IOS_NO_UDID_HINT = "no paired UDID \u2014 install libimobiledevice (brew install libimobiledevice) and trust this computer on the device";
326759
327278
  function discoverIOSSimulators() {
326760
327279
  if ((0, import_os2.platform)() !== "darwin") return [];
326761
327280
  const json = runCommand("xcrun", ["simctl", "list", "devices", "--json"]);
@@ -326765,7 +327284,8 @@ function discoverIOSSimulators() {
326765
327284
  const devices = [];
326766
327285
  for (const [runtime, runtimeDevices] of Object.entries(parsed.devices)) {
326767
327286
  const versionMatch = runtime.match(/iOS[- ](\d+(?:[-.]\d+)*)/i);
326768
- const platformVersion = versionMatch ? versionMatch[1].replace(/-/g, ".") : "unknown";
327287
+ if (!versionMatch) continue;
327288
+ const platformVersion = versionMatch[1].replace(/-/g, ".");
326769
327289
  for (const device of runtimeDevices) {
326770
327290
  const state2 = device.state === "Booted" ? "BOOTED" : device.state === "Shutdown" ? "SHUTDOWN" : "LOADING";
326771
327291
  if (state2 === "BOOTED") {
@@ -326838,11 +327358,11 @@ function discoverRealIOSDevices() {
326838
327358
  const version = runCommand("ideviceinfo", ["-u", serial, "-k", "ProductVersion"]) || "unknown";
326839
327359
  devices.push({
326840
327360
  id: serial,
326841
- name,
327361
+ name: `${name} (${REAL_IOS_NO_UDID_HINT})`,
326842
327362
  platform: "IOS",
326843
327363
  platformVersion: version,
326844
327364
  state: "BOOTED",
326845
- isAvailable: true,
327365
+ isAvailable: false,
326846
327366
  isPhysical: true
326847
327367
  });
326848
327368
  }
@@ -326858,11 +327378,11 @@ function discoverRealIOSDevices() {
326858
327378
  if (hasIPhone || hasIPad) {
326859
327379
  devices.push({
326860
327380
  id: "usb-ios-device",
326861
- name: hasIPhone ? "iPhone (USB)" : "iPad (USB)",
327381
+ name: `${hasIPhone ? "iPhone (USB)" : "iPad (USB)"} (${REAL_IOS_NO_UDID_HINT})`,
326862
327382
  platform: "IOS",
326863
327383
  platformVersion: "unknown",
326864
327384
  state: "BOOTED",
326865
- isAvailable: true,
327385
+ isAvailable: false,
326866
327386
  isPhysical: true
326867
327387
  });
326868
327388
  }
@@ -326940,7 +327460,9 @@ function discoverAndroidDevices() {
326940
327460
  const adbDevices = parseAdbDevices();
326941
327461
  return adbDevices.filter((d) => d.type === "device" || d.type === "unauthorized").map((d) => {
326942
327462
  const isEmulator = d.serial.startsWith("emulator-");
326943
- const isAvailable = d.type === "device";
327463
+ const authorized = d.type === "device";
327464
+ const bootCompleted = authorized && getAndroidProp(d.serial, "sys.boot_completed") === "1";
327465
+ const isAvailable = authorized && bootCompleted;
326944
327466
  const name = d.model || d.device || (isEmulator ? "Android Emulator" : "Android Device");
326945
327467
  const platformVersion = isAvailable ? getAndroidProp(d.serial, "ro.build.version.release") || "unknown" : "unknown";
326946
327468
  return {
@@ -326948,7 +327470,10 @@ function discoverAndroidDevices() {
326948
327470
  name,
326949
327471
  platform: "ANDROID",
326950
327472
  platformVersion,
326951
- state: "BOOTED",
327473
+ // LOADING = authorized but still booting; an unauthorized device stays
327474
+ // BOOTED (isAvailable false) so the dashboard shows it with an
327475
+ // "accept USB debugging" hint rather than hiding it.
327476
+ state: authorized && !bootCompleted ? "LOADING" : "BOOTED",
326952
327477
  isAvailable,
326953
327478
  // Mark if it's a real device vs emulator
326954
327479
  ...isEmulator ? {} : { isPhysical: true }
@@ -326994,15 +327519,25 @@ function discoverAllDevices(options) {
326994
327519
  }
326995
327520
  return [...iosDevices, ...androidDevices];
326996
327521
  }
327522
+ var DEVICE_SNAPSHOT_TTL_MS = 8e3;
327523
+ var cachedDeviceSnapshot = null;
327524
+ function getCachedDeviceSnapshot() {
327525
+ const now = Date.now();
327526
+ if (cachedDeviceSnapshot && now - cachedDeviceSnapshot.at < DEVICE_SNAPSHOT_TTL_MS) {
327527
+ return cachedDeviceSnapshot.devices;
327528
+ }
327529
+ const devices = discoverAllDevices();
327530
+ cachedDeviceSnapshot = { at: now, devices };
327531
+ return devices;
327532
+ }
326997
327533
  function getRunnerCapabilities() {
326998
- const iosDevices = discoverIOSDevices();
326999
- const androidDevices = discoverAndroidDevices();
327534
+ const devices = getCachedDeviceSnapshot();
327000
327535
  const isReadyDevice = (device) => device.isAvailable && device.state === "BOOTED";
327001
327536
  return {
327002
327537
  web: true,
327003
327538
  // Always capable of web testing via Playwright
327004
- ios: iosDevices.some(isReadyDevice),
327005
- android: androidDevices.some(isReadyDevice)
327539
+ ios: devices.some((d) => d.platform === "IOS" && isReadyDevice(d)),
327540
+ android: devices.some((d) => d.platform === "ANDROID" && isReadyDevice(d))
327006
327541
  };
327007
327542
  }
327008
327543
 
@@ -327138,6 +327673,9 @@ var MitmProxy = class {
327138
327673
  await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
327139
327674
  await server3.on("request", (req) => this.onRequest(req));
327140
327675
  await server3.on("response", (res) => this.onResponse(res));
327676
+ await server3.on("abort", (req) => {
327677
+ this.inFlight.delete(req.id);
327678
+ });
327141
327679
  await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
327142
327680
  await server3.on("tls-passthrough-opened", (event) => this.onTlsPassthroughOpened(event));
327143
327681
  await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
@@ -327434,6 +327972,107 @@ async function iosSimRemoveCa(deviceId, onLog) {
327434
327972
  function errMsg(err) {
327435
327973
  return err instanceof Error ? err.message : String(err);
327436
327974
  }
327975
+ function parsePrimaryNetworkService(stdout) {
327976
+ for (const line of stdout.split("\n")) {
327977
+ const m = line.match(/^\(\d+\)\s+(.+)$/);
327978
+ if (m) return m[1].trim();
327979
+ }
327980
+ return null;
327981
+ }
327982
+ function parseWebProxyState(stdout) {
327983
+ const field = (label) => {
327984
+ const m = stdout.match(new RegExp(`^${label}:[ \\t]*(.*)$`, "im"));
327985
+ return m ? m[1].trim() : "";
327986
+ };
327987
+ return {
327988
+ enabled: field("Enabled").toLowerCase() === "yes",
327989
+ server: field("Server"),
327990
+ port: field("Port")
327991
+ };
327992
+ }
327993
+ function buildHostProxySetCommands(service, host, port) {
327994
+ const portStr = String(port);
327995
+ return [
327996
+ ["-setwebproxy", service, host, portStr],
327997
+ ["-setsecurewebproxy", service, host, portStr]
327998
+ ];
327999
+ }
328000
+ function buildProxyRestoreArgs(setCmd, stateCmd, service, prior) {
328001
+ if (prior.server) {
328002
+ return [
328003
+ [setCmd, service, prior.server, prior.port || "0"],
328004
+ [stateCmd, service, prior.enabled ? "on" : "off"]
328005
+ ];
328006
+ }
328007
+ return [[stateCmd, service, "off"]];
328008
+ }
328009
+ function buildHostProxyRestoreCommands(service, prior) {
328010
+ return [
328011
+ ...buildProxyRestoreArgs("-setwebproxy", "-setwebproxystate", service, prior.web),
328012
+ ...buildProxyRestoreArgs("-setsecurewebproxy", "-setsecurewebproxystate", service, prior.secure)
328013
+ ];
328014
+ }
328015
+ function hostProxyPriorFromMarker(marker) {
328016
+ return {
328017
+ web: {
328018
+ enabled: Boolean(marker.hostWebProxyWasEnabled),
328019
+ server: marker.hostWebProxyPriorServer ?? "",
328020
+ port: marker.hostWebProxyPriorPort ?? ""
328021
+ },
328022
+ secure: {
328023
+ enabled: Boolean(marker.hostSecureProxyWasEnabled),
328024
+ server: marker.hostSecureProxyPriorServer ?? "",
328025
+ port: marker.hostSecureProxyPriorPort ?? ""
328026
+ }
328027
+ };
328028
+ }
328029
+ function hostProxyControlSupported(platform3, isPhysical, platformName = process.platform) {
328030
+ if (platform3 !== "IOS") return { supported: false };
328031
+ if (isPhysical) {
328032
+ return { supported: false, reason: "physical iOS device requires a manual Wi-Fi HTTP proxy; host proxy not applied" };
328033
+ }
328034
+ if (platformName !== "darwin") {
328035
+ return { supported: false, reason: `iOS simulator host proxy is only controllable on macOS (host is ${platformName})` };
328036
+ }
328037
+ return { supported: true };
328038
+ }
328039
+ async function iosSimConfigureHostProxy(host, port, onLog) {
328040
+ try {
328041
+ const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
328042
+ timeout: DEVICE_CMD_TIMEOUT_MS
328043
+ });
328044
+ const service = parsePrimaryNetworkService(order);
328045
+ if (!service) {
328046
+ return { configured: false, reason: "iOS simulator host proxy could not be configured: no active macOS network service found" };
328047
+ }
328048
+ const [{ stdout: webOut }, { stdout: secureOut }] = await Promise.all([
328049
+ execFileAsync("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
328050
+ execFileAsync("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
328051
+ ]);
328052
+ const prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
328053
+ for (const args of buildHostProxySetCommands(service, host, port)) {
328054
+ await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328055
+ }
328056
+ onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
328057
+ return { configured: true, service, prior };
328058
+ } catch (err) {
328059
+ return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
328060
+ }
328061
+ }
328062
+ async function iosSimRestoreHostProxy(service, prior, onLog) {
328063
+ const restore = prior ?? {
328064
+ web: { enabled: false, server: "", port: "" },
328065
+ secure: { enabled: false, server: "", port: "" }
328066
+ };
328067
+ for (const args of buildHostProxyRestoreCommands(service, restore)) {
328068
+ try {
328069
+ await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328070
+ } catch (err) {
328071
+ onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg(err)}`);
328072
+ }
328073
+ }
328074
+ onLog?.(`[mobile-net] macOS host proxy restored on "${service}".`);
328075
+ }
327437
328076
  var activeCleanups = /* @__PURE__ */ new Map();
327438
328077
  var exitHandlersInstalled = false;
327439
328078
  function markerPath(sessionKey) {
@@ -327468,6 +328107,14 @@ function restoreDeviceSync(marker, onLog) {
327468
328107
  run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
327469
328108
  }
327470
328109
  }
328110
+ if (marker.platform === "IOS" && marker.hostProxySet && marker.hostProxyService && process.platform === "darwin") {
328111
+ for (const args of buildHostProxyRestoreCommands(marker.hostProxyService, hostProxyPriorFromMarker(marker))) {
328112
+ try {
328113
+ (0, import_node_child_process.execFileSync)("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
328114
+ } catch {
328115
+ }
328116
+ }
328117
+ }
327471
328118
  onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
327472
328119
  }
327473
328120
  async function reconcilePendingCleanups(onLog) {
@@ -327479,6 +328126,8 @@ async function reconcilePendingCleanups(onLog) {
327479
328126
  }
327480
328127
  for (const file of files) {
327481
328128
  if (!file.endsWith(".json")) continue;
328129
+ const sessionKey = file.slice(0, -".json".length);
328130
+ if (activeCleanups.has(sessionKey)) continue;
327482
328131
  const full = (0, import_node_path.join)(PENDING_CLEANUP_DIR, file);
327483
328132
  try {
327484
328133
  const raw = await (0, import_promises.readFile)(full, "utf-8");
@@ -327508,8 +328157,10 @@ function ensureExitHandlers() {
327508
328157
  for (const signal of ["SIGTERM", "SIGINT"]) {
327509
328158
  process.on(signal, () => {
327510
328159
  drain();
327511
- process.removeAllListeners(signal);
327512
- process.kill(process.pid, signal);
328160
+ if (process.listenerCount(signal) <= 1) {
328161
+ process.removeAllListeners(signal);
328162
+ process.kill(process.pid, signal);
328163
+ }
327513
328164
  });
327514
328165
  }
327515
328166
  }
@@ -327559,6 +328210,9 @@ async function startMobileNetworkCapture(options) {
327559
328210
  let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
327560
328211
  let proxySet = false;
327561
328212
  let caInstalledOnDevice = false;
328213
+ let hostProxySet = false;
328214
+ let hostProxyService;
328215
+ let hostProxyPrior;
327562
328216
  try {
327563
328217
  if (platform3 === "ANDROID") {
327564
328218
  proxySet = await androidSetProxy(deviceId, hostPort, onLog);
@@ -327573,25 +328227,50 @@ async function startMobileNetworkCapture(options) {
327573
328227
  }
327574
328228
  } else {
327575
328229
  if (!isPhysical && caCertPath) {
327576
- const { trusted, note } = await iosSimTrustCa(deviceId, caCertPath);
328230
+ const { trusted } = await iosSimTrustCa(deviceId, caCertPath);
327577
328231
  caInstalledOnDevice = trusted;
327578
- wiringReason = note;
328232
+ }
328233
+ const support = hostProxyControlSupported(platform3, isPhysical);
328234
+ if (support.supported) {
328235
+ const hp = await iosSimConfigureHostProxy(proxyHost, proxyPort, onLog);
328236
+ if (hp.configured) {
328237
+ hostProxySet = true;
328238
+ hostProxyService = hp.service;
328239
+ hostProxyPrior = hp.prior;
328240
+ 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).` : ".");
328241
+ } else {
328242
+ wiringReason = hp.reason ?? "iOS simulator host proxy could not be configured";
328243
+ onLog?.(`[mobile-net] ${wiringReason}`);
328244
+ }
327579
328245
  } else if (isPhysical) {
327580
328246
  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}).` : ".");
328247
+ } else {
328248
+ wiringReason = support.reason ?? "iOS simulator host proxy is unsupported on this host";
328249
+ onLog?.(`[mobile-net] ${wiringReason}`);
327581
328250
  }
327582
328251
  }
327583
328252
  } catch (err) {
327584
328253
  wiringReason = `device wiring degraded: ${errMsg(err)}`;
327585
328254
  onLog?.(`[mobile-net] ${wiringReason}`);
327586
328255
  }
327587
- if (proxySet || caInstalledOnDevice) {
328256
+ if (proxySet || caInstalledOnDevice || hostProxySet) {
327588
328257
  const marker = {
327589
328258
  platform: platform3,
327590
328259
  deviceId,
327591
328260
  isPhysical,
327592
328261
  proxySet,
327593
328262
  originalProxy,
327594
- caInstalledOnDevice
328263
+ caInstalledOnDevice,
328264
+ ...hostProxySet ? {
328265
+ hostProxySet: true,
328266
+ hostProxyService,
328267
+ hostWebProxyWasEnabled: hostProxyPrior?.web.enabled,
328268
+ hostWebProxyPriorServer: hostProxyPrior?.web.server,
328269
+ hostWebProxyPriorPort: hostProxyPrior?.web.port,
328270
+ hostSecureProxyWasEnabled: hostProxyPrior?.secure.enabled,
328271
+ hostSecureProxyPriorServer: hostProxyPrior?.secure.server,
328272
+ hostSecureProxyPriorPort: hostProxyPrior?.secure.port
328273
+ } : {}
327595
328274
  };
327596
328275
  activeCleanups.set(sessionKey, marker);
327597
328276
  await writePendingCleanup(sessionKey, marker);
@@ -327611,6 +328290,9 @@ async function startMobileNetworkCapture(options) {
327611
328290
  if (proxySet && platform3 === "ANDROID") {
327612
328291
  await androidClearProxy(deviceId, originalProxy, onLog);
327613
328292
  }
328293
+ if (hostProxySet && hostProxyService && platform3 === "IOS" && !isPhysical) {
328294
+ await iosSimRestoreHostProxy(hostProxyService, hostProxyPrior, onLog);
328295
+ }
327614
328296
  if (caInstalledOnDevice && capturedCaCertPath) {
327615
328297
  if (platform3 === "ANDROID") {
327616
328298
  await androidRemoveCa(deviceId, onLog);
@@ -327646,6 +328328,9 @@ async function startMobileNetworkCapture(options) {
327646
328328
 
327647
328329
  // src/services/appium-executor.ts
327648
328330
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
328331
+ var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
328332
+ var WD_ACTION_TIMEOUT_MS = 3e4;
328333
+ var MAX_SESSION_CREATE_ATTEMPTS = 3;
327649
328334
  var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
327650
328335
  var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
327651
328336
  var WebDriverTransportError = class extends Error {
@@ -327659,13 +328344,21 @@ function isTransportError(err) {
327659
328344
  return err instanceof WebDriverTransportError || typeof err === "object" && err !== null && err.isTransport === true;
327660
328345
  }
327661
328346
  async function wdRequest(path, options) {
328347
+ const isSessionCreate = (options?.method ?? "GET").toUpperCase() === "POST" && path === "/session";
328348
+ const timeoutMs = isSessionCreate ? WD_SESSION_CREATE_TIMEOUT_MS : WD_ACTION_TIMEOUT_MS;
327662
328349
  let res;
327663
328350
  try {
327664
328351
  res = await fetch(`http://localhost:${getAppiumState().port}${path}`, {
327665
328352
  ...options,
328353
+ signal: AbortSignal.timeout(timeoutMs),
327666
328354
  headers: { "Content-Type": "application/json", ...options?.headers }
327667
328355
  });
327668
328356
  } catch (err) {
328357
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
328358
+ throw new WebDriverTransportError(
328359
+ `WebDriver request to ${path} timed out after ${timeoutMs}ms (device/Appium unresponsive)`
328360
+ );
328361
+ }
327669
328362
  throw new WebDriverTransportError(`WebDriver connection error: ${err instanceof Error ? err.message : String(err)}`);
327670
328363
  }
327671
328364
  if (!res.ok) {
@@ -327750,6 +328443,31 @@ async function createSession(target) {
327750
328443
  if (!sessionId) throw new Error("Appium session creation returned no session ID");
327751
328444
  return sessionId;
327752
328445
  }
328446
+ var TERMINAL_SESSION_START = /not installed|no such (app|file)|could not confirm|bundle ?id .*(not|missing)|xcodeorgid|signing|provisioning|not authorized|unauthorized/i;
328447
+ 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;
328448
+ function isRetryableSessionStartError(err) {
328449
+ const message = err instanceof Error ? err.message : String(err);
328450
+ if (TERMINAL_SESSION_START.test(message)) return false;
328451
+ if (isTransportError(err)) return true;
328452
+ return RETRYABLE_SESSION_START.test(message);
328453
+ }
328454
+ async function createSessionWithRetry(target, onRetry) {
328455
+ let lastErr;
328456
+ for (let attempt = 1; attempt <= MAX_SESSION_CREATE_ATTEMPTS; attempt++) {
328457
+ try {
328458
+ return await createSession(target);
328459
+ } catch (err) {
328460
+ lastErr = err;
328461
+ if (attempt >= MAX_SESSION_CREATE_ATTEMPTS || !isRetryableSessionStartError(err)) throw err;
328462
+ const delayMs = Math.pow(2, attempt) * 1e3;
328463
+ onRetry?.(
328464
+ `Appium session start failed (attempt ${attempt}/${MAX_SESSION_CREATE_ATTEMPTS}, retrying in ${delayMs}ms): ${err instanceof Error ? err.message : String(err)}`
328465
+ );
328466
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
328467
+ }
328468
+ }
328469
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
328470
+ }
327753
328471
  async function openDeepLink(sessionId, platform3, deeplink, appId) {
327754
328472
  const args = platform3 === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
327755
328473
  await wdRequest(`/session/${sessionId}/execute/sync`, {
@@ -327851,6 +328569,61 @@ async function takeScreenshot(sessionId) {
327851
328569
  return null;
327852
328570
  }
327853
328571
  }
328572
+ function mobileRecordingEnabled() {
328573
+ const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
328574
+ return value !== "0" && value !== "false";
328575
+ }
328576
+ function readPositiveIntEnv(name, fallback, min, max) {
328577
+ const raw = process.env[name];
328578
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
328579
+ if (!Number.isFinite(parsed)) return fallback;
328580
+ return Math.max(min, Math.min(max, parsed));
328581
+ }
328582
+ async function startScreenRecording(sessionId, platform3, log2) {
328583
+ if (!mobileRecordingEnabled()) {
328584
+ log2("[mobile-video] recording disabled by VALIDATEQA_MOBILE_RUN_VIDEO=0");
328585
+ return false;
328586
+ }
328587
+ const timeLimit = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_SECONDS", 180, 5, 180);
328588
+ const options = {
328589
+ timeLimit: String(timeLimit),
328590
+ forceRestart: true
328591
+ };
328592
+ if (platform3 === "ANDROID") {
328593
+ options.bitRate = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_BITRATE", 75e4, 1e5, 4e6);
328594
+ } else {
328595
+ options.videoQuality = process.env.VALIDATEQA_MOBILE_RECORDING_IOS_QUALITY || "low";
328596
+ }
328597
+ try {
328598
+ await wdRequest(`/session/${sessionId}/appium/start_recording_screen`, {
328599
+ method: "POST",
328600
+ body: JSON.stringify({ options })
328601
+ });
328602
+ log2(`[mobile-video] screen recording started (${timeLimit}s max)`);
328603
+ return true;
328604
+ } catch (err) {
328605
+ log2(`[mobile-video] screen recording unavailable: ${err instanceof Error ? err.message : String(err)}`);
328606
+ return false;
328607
+ }
328608
+ }
328609
+ async function stopScreenRecording(sessionId, log2) {
328610
+ try {
328611
+ const result = await wdRequest(`/session/${sessionId}/appium/stop_recording_screen`, {
328612
+ method: "POST",
328613
+ body: JSON.stringify({ options: {} })
328614
+ });
328615
+ const value = typeof result?.value === "string" ? result.value.trim() : "";
328616
+ if (!value) {
328617
+ log2("[mobile-video] screen recording stopped but Appium returned no video");
328618
+ return void 0;
328619
+ }
328620
+ log2(`[mobile-video] screen recording captured (${Math.round(value.length / 1024)}KB base64)`);
328621
+ return value;
328622
+ } catch (err) {
328623
+ log2(`[mobile-video] screen recording stop failed: ${err instanceof Error ? err.message : String(err)}`);
328624
+ return void 0;
328625
+ }
328626
+ }
327854
328627
  function boundsFromStep(step) {
327855
328628
  const bounds = step.metadata?.bounds;
327856
328629
  if (bounds && typeof bounds === "object" && !Array.isArray(bounds) && typeof bounds.x === "number" && typeof bounds.y === "number" && typeof bounds.width === "number" && typeof bounds.height === "number") {
@@ -327970,7 +328743,8 @@ async function sendKeysToFocusedInput(sessionId, value) {
327970
328743
  });
327971
328744
  }
327972
328745
  function encodeAndroidInputText(value) {
327973
- return value.replace(/%/g, "%25").replace(/\s/g, "%s");
328746
+ const inputEncoded = value.replace(/%/g, "%25").replace(/\s/g, "%s");
328747
+ return `'${inputEncoded.replace(/'/g, `'\\''`)}'`;
327974
328748
  }
327975
328749
  async function sendAndroidTextToFocusedInput(deviceId, value) {
327976
328750
  if (!deviceId) {
@@ -328228,6 +329002,9 @@ async function executeMobileTest(options) {
328228
329002
  let sessionId = null;
328229
329003
  let capture = null;
328230
329004
  let captureStopped = false;
329005
+ let recordingStarted = false;
329006
+ let recordingStopped = false;
329007
+ let video;
328231
329008
  const stopCapture = async () => {
328232
329009
  if (!capture || captureStopped) return apiCalls;
328233
329010
  captureStopped = true;
@@ -328242,6 +329019,15 @@ async function executeMobileTest(options) {
328242
329019
  }
328243
329020
  return apiCalls;
328244
329021
  };
329022
+ const stopRecording = async () => {
329023
+ if (!sessionId || !recordingStarted || recordingStopped) return video;
329024
+ recordingStopped = true;
329025
+ const captured = await stopScreenRecording(sessionId, log2);
329026
+ if (captured) {
329027
+ video = captured;
329028
+ }
329029
+ return video;
329030
+ };
328245
329031
  try {
328246
329032
  log2("Checking Appium server health...");
328247
329033
  const health = await checkAppiumHealth();
@@ -328295,13 +329081,13 @@ async function executeMobileTest(options) {
328295
329081
  }
328296
329082
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
328297
329083
  try {
328298
- sessionId = await createSession({
329084
+ sessionId = await createSessionWithRetry({
328299
329085
  appId: options.target.appId,
328300
329086
  platform: options.target.platform,
328301
329087
  deviceId: selectedDevice.id,
328302
329088
  platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
328303
329089
  isPhysical: selectedDevice.isPhysical
328304
- });
329090
+ }, (msg) => log2(msg));
328305
329091
  } catch (err) {
328306
329092
  const base2 = err instanceof Error ? err.message : String(err);
328307
329093
  if (selectedDevice.isPhysical) {
@@ -328316,6 +329102,7 @@ async function executeMobileTest(options) {
328316
329102
  throw err;
328317
329103
  }
328318
329104
  log2(`Session created: ${sessionId}`);
329105
+ recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
328319
329106
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
328320
329107
  try {
328321
329108
  log2(`Opening deep link: ${options.target.deeplink}`);
@@ -328376,7 +329163,9 @@ async function executeMobileTest(options) {
328376
329163
  break;
328377
329164
  }
328378
329165
  }
328379
- await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
329166
+ if (step.action !== "home" && step.action !== "back") {
329167
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
329168
+ }
328380
329169
  } catch (err) {
328381
329170
  status = "failed";
328382
329171
  stepError = err instanceof Error ? err.message : String(err);
@@ -328405,10 +329194,12 @@ async function executeMobileTest(options) {
328405
329194
  const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
328406
329195
  const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
328407
329196
  await stopCapture();
329197
+ await stopRecording();
328408
329198
  return {
328409
329199
  passed: allPassed,
328410
329200
  stepResults,
328411
329201
  screenshots,
329202
+ ...video ? { video, videoMimeType: "video/mp4" } : {},
328412
329203
  apiCalls,
328413
329204
  logs: logLines.join("\n"),
328414
329205
  error: firstError,
@@ -328419,10 +329210,12 @@ async function executeMobileTest(options) {
328419
329210
  const errorMsg = err instanceof Error ? err.message : String(err);
328420
329211
  log2(`Fatal error: ${errorMsg}`);
328421
329212
  await stopCapture();
329213
+ await stopRecording();
328422
329214
  return {
328423
329215
  passed: false,
328424
329216
  stepResults,
328425
329217
  screenshots,
329218
+ ...video ? { video, videoMimeType: "video/mp4" } : {},
328426
329219
  apiCalls,
328427
329220
  logs: logLines.join("\n"),
328428
329221
  error: errorMsg,
@@ -328434,6 +329227,7 @@ async function executeMobileTest(options) {
328434
329227
  };
328435
329228
  } finally {
328436
329229
  await stopCapture();
329230
+ await stopRecording();
328437
329231
  if (sessionId) {
328438
329232
  log2("Tearing down Appium session...");
328439
329233
  await deleteSession(sessionId);
@@ -328474,7 +329268,7 @@ async function createMobileDiscoverySession(target, options) {
328474
329268
  isPhysical: selectedDevice.isPhysical === true,
328475
329269
  platformVersion: selectedDevice.platformVersion
328476
329270
  });
328477
- const sessionId = await createSession({
329271
+ const sessionId = await createSessionWithRetry({
328478
329272
  appId: target.appId,
328479
329273
  platform: target.platform,
328480
329274
  deviceId: selectedDevice.id,
@@ -329285,7 +330079,7 @@ function stopSessionReaper() {
329285
330079
  }
329286
330080
  }
329287
330081
  async function reapIdleSession() {
329288
- if (!state.appiumSessionId || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
330082
+ if (!state.appiumSessionId || mirrorSessionOwner !== "recording" || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
329289
330083
  return;
329290
330084
  }
329291
330085
  const sessionId = state.appiumSessionId;
@@ -331802,6 +332596,11 @@ ${finalVerifyResult.logs ?? ""}`;
331802
332596
  });
331803
332597
  requestLiveBrowserHeartbeat();
331804
332598
  },
332599
+ // D-D6 — upload ONE baseline PNG per newly-observed screen through the
332600
+ // same streaming /page-screenshot relay the web path uses (mobile has no
332601
+ // route, so the screen-id is the key). The returned storage key rides the
332602
+ // explore checkpoint so the server links it to SitePage.screenshotKey.
332603
+ onScreenshotUpload: (screenId, base64) => onScreenshot(screenId, base64),
331805
332604
  onExploreComplete,
331806
332605
  onTestGenerated,
331807
332606
  onPlanReady
@@ -332144,6 +332943,8 @@ ${result2.logs}`);
332144
332943
  status,
332145
332944
  stepResults: result2.stepResults,
332146
332945
  screenshots: result2.screenshots,
332946
+ video: result2.video,
332947
+ videoMimeType: result2.videoMimeType,
332147
332948
  apiCalls: result2.apiCalls,
332148
332949
  logs: result2.logs,
332149
332950
  error: reportedError,
@@ -332455,7 +333256,7 @@ async function startRunner(opts) {
332455
333256
  };
332456
333257
  const sendHeartbeat = async () => {
332457
333258
  try {
332458
- const devices = enableMobile ? discoverAllDevices() : [];
333259
+ const devices = enableMobile ? getCachedDeviceSnapshot() : [];
332459
333260
  const capabilities = enableMobile ? {
332460
333261
  web: true,
332461
333262
  ios: devices.some((d) => d.platform === "IOS" && d.isAvailable && d.state === "BOOTED"),