@validate.qa/runner 1.0.9 → 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 +1035 -154
  2. package/dist/cli.mjs +1035 -154
  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;
@@ -263,14 +266,16 @@ var require_ai_provider = __commonJS({
263
266
  { id: "minimax-m2.7", label: "MiniMax M2.7", provider: "minimax", description: "Fast M2.7 tier (Highspeed); auto-fails over to standard M2.7, ~200K ctx", contextWindow: 2e5 },
264
267
  { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", provider: "anthropic", description: "Anthropic - fast & intelligent, 200k ctx", contextWindow: 2e5 },
265
268
  { id: "nvidia-nemotron-nano", label: "NVIDIA Nemotron Nano", provider: "nvidia", description: "NVIDIA NIM - Nemotron 30B Nano, reasoning model, 128k ctx", contextWindow: 128e3 },
266
- { id: "nvidia-kimi-k2.6", label: "NVIDIA Kimi K2.6", provider: "nvidia", description: "NVIDIA NIM - Moonshot Kimi K2.6, multimodal coding and agentic model, 256k ctx", contextWindow: 256e3 }
269
+ { id: "nvidia-kimi-k2.6", label: "NVIDIA Kimi K2.6", provider: "nvidia", description: "NVIDIA NIM - Moonshot Kimi K2.6, multimodal coding and agentic model, 256k ctx", contextWindow: 256e3 },
270
+ { id: "deepseek-v4-flash", label: "DeepSeek V4 Flash", provider: "deepseek", description: "DeepSeek - V4 Flash, OpenAI-compatible, 1M ctx", contextWindow: 1e6 }
267
271
  ];
268
272
  var PROVIDER_DEFAULT_MODEL = {
269
273
  openai: "gpt-4o",
270
274
  xai: "grok-build-0.1",
271
275
  anthropic: "claude-sonnet-4-6",
272
276
  minimax: "minimax-m3",
273
- nvidia: "nvidia-nemotron-nano"
277
+ nvidia: "nvidia-nemotron-nano",
278
+ deepseek: "deepseek-v4-flash"
274
279
  };
275
280
  function getContextConfig(modelId) {
276
281
  const info = exports2.AI_MODEL_CATALOG.find((m) => m.id === modelId);
@@ -321,7 +326,8 @@ var require_ai_provider = __commonJS({
321
326
  "minimax-m2.7-highspeed": { strong: "MiniMax-M2.7-highspeed", fast: "MiniMax-M2.7-highspeed", agent: "MiniMax-M2.7-highspeed" },
322
327
  "claude-sonnet-4-6": { strong: "claude-sonnet-4-6", fast: "claude-sonnet-4-6", agent: "claude-sonnet-4-6" },
323
328
  "nvidia-nemotron-nano": { strong: "nvidia/nemotron-3-nano-30b-a3b", fast: "nvidia/nemotron-3-nano-30b-a3b", agent: "nvidia/nemotron-3-nano-30b-a3b" },
324
- "nvidia-kimi-k2.6": { strong: "moonshotai/kimi-k2.6", fast: "moonshotai/kimi-k2.6", agent: "moonshotai/kimi-k2.6" }
329
+ "nvidia-kimi-k2.6": { strong: "moonshotai/kimi-k2.6", fast: "moonshotai/kimi-k2.6", agent: "moonshotai/kimi-k2.6" },
330
+ "deepseek-v4-flash": { strong: "deepseek-v4-flash", fast: "deepseek-v4-flash", agent: "deepseek-v4-flash" }
325
331
  };
326
332
  var _requestModelStorage = new async_hooks_1.AsyncLocalStorage();
327
333
  function withModelContext(modelId, fn) {
@@ -383,6 +389,9 @@ var require_ai_provider = __commonJS({
383
389
  if (modelId === "nvidia/nemotron-3-nano-30b-a3b") {
384
390
  return { chat_template_kwargs: { enable_thinking: false } };
385
391
  }
392
+ if (modelId === "deepseek-v4-flash") {
393
+ return { thinking: { type: "disabled" } };
394
+ }
386
395
  return {};
387
396
  }
388
397
  exports2.MINIMAX_FALLBACK_CHAIN = ["MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7"];
@@ -484,6 +493,11 @@ var require_ai_provider = __commonJS({
484
493
  if (!apiKey)
485
494
  throw new Error("NVIDIA_API_KEY is required when using an NVIDIA model");
486
495
  client = new openai_1.default({ apiKey, baseURL: process.env.NVIDIA_API_BASE ?? "https://integrate.api.nvidia.com/v1" });
496
+ } else if (provider === "deepseek") {
497
+ const apiKey = process.env.DEEPSEEK_API_KEY;
498
+ if (!apiKey)
499
+ throw new Error("DEEPSEEK_API_KEY is required when using a DeepSeek model");
500
+ client = new openai_1.default({ apiKey, baseURL: process.env.DEEPSEEK_API_BASE ?? "https://api.deepseek.com" });
487
501
  } else {
488
502
  const apiKey = process.env.OPENAI_API_KEY;
489
503
  if (!apiKey)
@@ -499,6 +513,21 @@ var require_ai_provider = __commonJS({
499
513
  function getClientForModel(modelId) {
500
514
  return buildClientForProvider(getProviderForModel(modelId));
501
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
+ }
502
531
  function getModel(type) {
503
532
  return MODEL_TASK_MAP[getActiveModelId()][type];
504
533
  }
@@ -517,6 +546,8 @@ var require_ai_provider = __commonJS({
517
546
  return !!process.env.ANTHROPIC_API_KEY?.trim();
518
547
  if (provider === "nvidia")
519
548
  return !!process.env.NVIDIA_API_KEY?.trim();
549
+ if (provider === "deepseek")
550
+ return !!process.env.DEEPSEEK_API_KEY?.trim();
520
551
  return false;
521
552
  }
522
553
  function getAvailableModels() {
@@ -567,7 +598,7 @@ var require_ai_provider = __commonJS({
567
598
  const create = async (params, options) => {
568
599
  const modelId = typeof params.model === "string" ? params.model : "";
569
600
  if (!modelId) {
570
- throw new Error("server-proxied AI client requires params.model (catalog id)");
601
+ throw new Error("server-proxied AI client requires params.model");
571
602
  }
572
603
  const body = {
573
604
  modelId,
@@ -737,6 +768,21 @@ var require_mobile_source_parser = __commonJS({
737
768
  const attrText = inner.slice(tag.length);
738
769
  return { tag, attrs: parseAttributes(attrText), selfClosing };
739
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
+ }
740
786
  function tokenize(xml) {
741
787
  if (!xml || typeof xml !== "string")
742
788
  return null;
@@ -759,7 +805,7 @@ var require_mobile_source_parser = __commonJS({
759
805
  i = end === -1 ? len : end + 3;
760
806
  continue;
761
807
  }
762
- const gt = xml.indexOf(">", lt + 1);
808
+ const gt = findTagEnd(xml, lt + 1);
763
809
  if (gt === -1)
764
810
  break;
765
811
  const tagBody = xml.slice(lt + 1, gt);
@@ -1544,6 +1590,25 @@ function buildAppiumCode(testName, target, steps) {
1544
1590
  ` await driver.keys(text);`,
1545
1591
  ` }`,
1546
1592
  `}`,
1593
+ ``,
1594
+ `async function __clearFocused(driver: WebdriverIO.Browser) {`,
1595
+ ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).clearValue();`,
1596
+ `}`,
1597
+ ``,
1598
+ `async function __textCandidates(el: WebdriverIO.Element) {`,
1599
+ ` const __values: string[] = [];`,
1600
+ ` const __push = (__value: unknown) => {`,
1601
+ ` if (typeof __value !== 'string') return;`,
1602
+ ` const __trimmed = __value.trim();`,
1603
+ ` if (__trimmed.length === 0 || __values.includes(__trimmed)) return;`,
1604
+ ` __values.push(__trimmed);`,
1605
+ ` };`,
1606
+ ` __push(await el.getText().catch(() => ''));`,
1607
+ ` for (const __attr of ['text', 'content-desc', 'contentDescription', 'name', 'label', 'value']) {`,
1608
+ ` __push(await el.getAttribute(__attr).catch(() => ''));`,
1609
+ ` }`,
1610
+ ` return __values;`,
1611
+ `}`,
1547
1612
  ...hasSwipe ? [
1548
1613
  ``,
1549
1614
  `// Read the device window with a few retries (matches the runtime executor).`,
@@ -1623,6 +1688,12 @@ function buildAppiumCode(testName, target, steps) {
1623
1688
  lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1624
1689
  } else if (step.action === "clear" && hasSelector) {
1625
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);`);
1626
1697
  } else if (step.action === "assertVisible" && hasSelector) {
1627
1698
  lines.push(` if (!(await (await findFirst(driver, ${candidatesLiteral})).isDisplayed())) {`);
1628
1699
  lines.push(` throw new Error('Assertion failed: element not displayed: ' + ${toJsStringLiteral(candidatesLiteral)});`);
@@ -1630,8 +1701,9 @@ function buildAppiumCode(testName, target, steps) {
1630
1701
  } else if (step.action === "assertText" && hasSelector) {
1631
1702
  lines.push(` {`);
1632
1703
  lines.push(` const __expected = ${toJsStringLiteral(step.assertion ?? "")};`);
1633
- lines.push(` const __actual = await (await findFirst(driver, ${candidatesLiteral})).getText();`);
1634
- lines.push(` if (!__actual.includes(__expected)) {`);
1704
+ lines.push(` if (__expected.length === 0) throw new Error('assertText step is missing expected text');`);
1705
+ lines.push(` const __actual = await __textCandidates(await findFirst(driver, ${candidatesLiteral}));`);
1706
+ lines.push(` if (!__actual.some((__value) => __value.includes(__expected))) {`);
1635
1707
  lines.push(` throw new Error('Assertion failed: expected text to contain ' + JSON.stringify(__expected) + ' but got ' + JSON.stringify(__actual));`);
1636
1708
  lines.push(` }`);
1637
1709
  lines.push(` }`);
@@ -4338,6 +4410,7 @@ var require_mobile_observation = __commonJS({
4338
4410
  Object.defineProperty(exports2, "__esModule", { value: true });
4339
4411
  exports2.renderMobileObservation = renderMobileObservation;
4340
4412
  exports2.summarizeScreenForEvidence = summarizeScreenForEvidence;
4413
+ exports2.buildCollectedElementsForScreen = buildCollectedElementsForScreen;
4341
4414
  var DEFAULT_MAX_ELEMENTS = 60;
4342
4415
  var MAX_LABEL_LENGTH = 80;
4343
4416
  var EVIDENCE_LABEL_LIMIT = 40;
@@ -4379,6 +4452,46 @@ var require_mobile_observation = __commonJS({
4379
4452
  }
4380
4453
  return { elementCount, actionableCount, labels };
4381
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
+ }
4382
4495
  function buildHeaderLine(screen) {
4383
4496
  const parts = ["screen:"];
4384
4497
  const name = screenDisplayName(screen.screenSignal);
@@ -4827,7 +4940,7 @@ var require_mobile_explorer = __commonJS({
4827
4940
  }
4828
4941
  return sections.join("\n");
4829
4942
  }
4830
- function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId) {
4943
+ function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId, existingScreens) {
4831
4944
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
4832
4945
  const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
4833
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.
@@ -4873,13 +4986,18 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
4873
4986
  ## Application Brief
4874
4987
  ${appBrief}
4875
4988
  Use this to prioritize which screens and flows to cover.` : "";
4876
- 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}`;
4877
4995
  }
4878
4996
  function buildKickoffMessage(mode) {
4879
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.";
4880
4998
  }
4881
4999
  async function runMobileExplorePhase(args) {
4882
- const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot } = args;
5000
+ const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot, onScreenshotUpload, existingScreens } = args;
4883
5001
  const logs2 = [];
4884
5002
  const log2 = (line) => {
4885
5003
  logs2.push(line);
@@ -4903,6 +5021,9 @@ Use this to prioritize which screens and flows to cover.` : "";
4903
5021
  log2(`Budget: ${maxIterations} iterations | app: ${ctx.target?.appId ?? "(unknown)"}`);
4904
5022
  const screenshots = [];
4905
5023
  const pageScreenshots = {};
5024
+ const pageScreenshotKeys = {};
5025
+ const uploadedScreenIds = /* @__PURE__ */ new Set();
5026
+ const pendingScreenshotUploads = [];
4906
5027
  const collectedTransitions = [];
4907
5028
  const journeys = [];
4908
5029
  const counters = {
@@ -4921,6 +5042,14 @@ Use this to prioritize which screens and flows to cover.` : "";
4921
5042
  screenshots.push(base64);
4922
5043
  if (screenId)
4923
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
+ }
4924
5053
  try {
4925
5054
  onScreenshot?.(base64);
4926
5055
  } catch {
@@ -5025,7 +5154,7 @@ Use this to prioritize which screens and flows to cover.` : "";
5025
5154
  return null;
5026
5155
  return { element, bounds: element.bounds };
5027
5156
  };
5028
- const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId);
5157
+ const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId, existingScreens);
5029
5158
  const kickoff = buildKickoffMessage(mode);
5030
5159
  const recentExchanges = [];
5031
5160
  const transientDirectives = [];
@@ -5233,6 +5362,9 @@ Exploration complete: ${summary}`);
5233
5362
  summary = `Explore phase aborted (${truncatedReason}) after mapping ${state2.getScreenCount()} screen(s).`;
5234
5363
  }
5235
5364
  }
5365
+ if (pendingScreenshotUploads.length > 0) {
5366
+ await Promise.allSettled(pendingScreenshotUploads);
5367
+ }
5236
5368
  const collectedPages = state2.buildInMemoryScreenMap();
5237
5369
  counters.pagesVisited = collectedPages.length;
5238
5370
  if (counters.pagesDiscovered < collectedPages.length) {
@@ -5267,6 +5399,9 @@ Exploration complete: ${summary}`);
5267
5399
  if (Object.keys(pageScreenshots).length > 0) {
5268
5400
  result.pageScreenshots = pageScreenshots;
5269
5401
  }
5402
+ if (Object.keys(pageScreenshotKeys).length > 0) {
5403
+ result.pageScreenshotKeys = pageScreenshotKeys;
5404
+ }
5270
5405
  if (truncated) {
5271
5406
  result.truncated = true;
5272
5407
  result.truncatedReason = truncatedReason;
@@ -5768,6 +5903,44 @@ var require_snapshot_parser = __commonJS({
5768
5903
  };
5769
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;
5770
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
+ }
5771
5944
  function isProbablyDynamicTestId(testId) {
5772
5945
  if (testId.length < 6)
5773
5946
  return false;
@@ -5843,9 +6016,12 @@ var require_snapshot_parser = __commonJS({
5843
6016
  }
5844
6017
  return e.name;
5845
6018
  }
5846
- function parseSnapshot(snapshotText) {
6019
+ function parseSnapshot(snapshotText, options = {}) {
6020
+ const richParse = options.richParse ?? process.env.DISCOVERY_SNAPSHOT_RICH_PARSE === "true";
5847
6021
  const lines = snapshotText.split("\n");
5848
6022
  const elements = [];
6023
+ const liveMessages = [];
6024
+ const textMessages = [];
5849
6025
  const seenRefs = /* @__PURE__ */ new Set();
5850
6026
  let parsedElementLineCount = 0;
5851
6027
  let genericLabelCount = 0;
@@ -5854,6 +6030,21 @@ var require_snapshot_parser = __commonJS({
5854
6030
  const contextStack = [];
5855
6031
  const completedForms = [];
5856
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
+ }
5857
6048
  const match = ELEMENT_LINE_RE.exec(line);
5858
6049
  if (!match)
5859
6050
  continue;
@@ -5875,7 +6066,7 @@ var require_snapshot_parser = __commonJS({
5875
6066
  }
5876
6067
  const headingLevelMatch = LEVEL_RE.exec(attrs);
5877
6068
  const headingLevel = headingLevelMatch ? Number.parseInt(headingLevelMatch[1], 10) : null;
5878
- const contextLabel = buildContextLabel(role, name ?? null, headingLevel);
6069
+ const contextLabel = buildContextLabel(role, name ?? null, headingLevel, richParse);
5879
6070
  if (contextLabel) {
5880
6071
  contextStack.push({ indent: indentDepth, label: contextLabel });
5881
6072
  }
@@ -5888,6 +6079,40 @@ var require_snapshot_parser = __commonJS({
5888
6079
  });
5889
6080
  continue;
5890
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
+ }
5891
6116
  if (!INTERACTIVE_ROLES.has(role))
5892
6117
  continue;
5893
6118
  const refMatch = REF_RE.exec(attrs);
@@ -5920,6 +6145,13 @@ var require_snapshot_parser = __commonJS({
5920
6145
  }
5921
6146
  const isLink = role === "link";
5922
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
+ }
5923
6155
  const element = {
5924
6156
  ref,
5925
6157
  role,
@@ -5941,7 +6173,10 @@ var require_snapshot_parser = __commonJS({
5941
6173
  pressed,
5942
6174
  headingLevel,
5943
6175
  currentState,
5944
- containerPath
6176
+ containerPath,
6177
+ href: null,
6178
+ value,
6179
+ options: null
5945
6180
  };
5946
6181
  if (isGenericLabel(element.name))
5947
6182
  genericLabelCount++;
@@ -5969,6 +6204,9 @@ var require_snapshot_parser = __commonJS({
5969
6204
  return {
5970
6205
  elements,
5971
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),
5972
6210
  rawLineCount: lines.length,
5973
6211
  parsedElementLineCount,
5974
6212
  genericLabelCount,
@@ -6079,12 +6317,17 @@ var require_snapshot_parser = __commonJS({
6079
6317
  return "mixed";
6080
6318
  return false;
6081
6319
  }
6082
- function buildContextLabel(role, name, headingLevel) {
6320
+ function buildContextLabel(role, name, headingLevel, richContext = false) {
6083
6321
  if (role === "heading") {
6084
6322
  if (!name)
6085
6323
  return null;
6086
6324
  return headingLevel ? `heading${headingLevel}:${name}` : `heading:${name}`;
6087
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
+ }
6088
6331
  if (!CONTEXT_ROLES.has(role))
6089
6332
  return null;
6090
6333
  if (name)
@@ -7794,7 +8037,7 @@ Each entry is exactly one of:
7794
8037
  const systemPrompt = options.systemPromptOverride ?? (context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports2.SCENARIO_SYSTEM_PROMPT);
7795
8038
  const userPrompt = options.userPromptOverride ?? formatEvidenceForPlanner(compressed, evidence, context);
7796
8039
  logs2.push(`AI planner: ~${Math.ceil(userPrompt.length / 3.5)} input tokens`);
7797
- 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)());
7798
8041
  const messages = [
7799
8042
  { role: "system", content: systemPrompt },
7800
8043
  { role: "user", content: userPrompt }
@@ -7802,18 +8045,22 @@ Each entry is exactly one of:
7802
8045
  const callStart = Date.now();
7803
8046
  let response;
7804
8047
  try {
7805
- response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => aiClient.chat.completions.create({
7806
- model: agentModel,
7807
- messages,
7808
- max_tokens: plannerMaxTokens,
7809
- temperature: 0.3,
7810
- ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
7811
- ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
7812
- })), {
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
+ }), {
7813
8059
  label: "ai-planner",
7814
8060
  baseDelayMs: 5e3,
7815
8061
  maxRetries: 2,
7816
- 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`)
7817
8064
  });
7818
8065
  } catch (err) {
7819
8066
  const message = err instanceof Error ? err.message : String(err);
@@ -7879,18 +8126,25 @@ Each entry is exactly one of:
7879
8126
  const retryStart = Date.now();
7880
8127
  let retryResponse = null;
7881
8128
  try {
7882
- retryResponse = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => aiClient.chat.completions.create({
7883
- model: agentModel,
7884
- messages: [
7885
- ...messages,
7886
- { role: "assistant", content: raw },
7887
- { role: "user", content: retryContext }
7888
- ],
7889
- max_tokens: plannerMaxTokens,
7890
- temperature: 0.3,
7891
- ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
7892
- ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
7893
- })), { 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
+ });
7894
8148
  } catch (err) {
7895
8149
  const message = err instanceof Error ? err.message : String(err);
7896
8150
  logs2.push(`AI planner retry failed (${message}) \u2014 using first-pass result`);
@@ -8418,12 +8672,12 @@ ${constraints.join("\n")}`);
8418
8672
  const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8419
8673
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
8420
8674
  for (const state2 of orderedScreens) {
8421
- const groupName = state2.screenType === "auth" ? "Authentication" : screenDisplayName(state2);
8675
+ const groupName = state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(state2);
8422
8676
  const group = groups.get(groupName) ?? {
8423
8677
  name: groupName,
8424
8678
  description: `Native mobile screens related to ${groupName}.`,
8425
8679
  routes: [],
8426
- criticality: state2.screenType === "auth" ? "critical" : "medium",
8680
+ criticality: state2.screenType?.toLowerCase() === "auth" ? "critical" : "medium",
8427
8681
  pages: [],
8428
8682
  flows: []
8429
8683
  };
@@ -8446,7 +8700,7 @@ ${constraints.join("\n")}`);
8446
8700
  const to = graph.screens.get(transition.toScreenId);
8447
8701
  if (!from || !to)
8448
8702
  continue;
8449
- const groupName = from.screenType === "auth" ? "Authentication" : screenDisplayName(from);
8703
+ const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(from);
8450
8704
  const group = groups.get(groupName);
8451
8705
  if (!group)
8452
8706
  continue;
@@ -8491,9 +8745,9 @@ ${constraints.join("\n")}`);
8491
8745
  scenarios.push({
8492
8746
  name,
8493
8747
  type: "HAPPY_PATH",
8494
- priority: state2.screenType === "auth" ? 1 : 3,
8748
+ priority: state2.screenType?.toLowerCase() === "auth" ? 1 : 3,
8495
8749
  variant: "happy",
8496
- featureGroup: state2.screenType === "auth" ? "Authentication" : screenName,
8750
+ featureGroup: state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenName,
8497
8751
  goal: `User can reach and inspect the ${screenName} screen`,
8498
8752
  steps,
8499
8753
  expectedOutcomes: [{
@@ -8639,6 +8893,7 @@ var require_mobile_generator = __commonJS({
8639
8893
  var mobile_observation_js_1 = require_mobile_observation();
8640
8894
  var mobile_boundary_js_1 = require_mobile_boundary();
8641
8895
  var MAX_SCENARIO_ITERATIONS = 30;
8896
+ var MAX_CONSECUTIVE_INFRA_FAILURES = 3;
8642
8897
  var MAX_RECENT_EXCHANGES = 24;
8643
8898
  var OBSERVATION_MAX_ELEMENTS = 50;
8644
8899
  function finalizeAction(input) {
@@ -9363,14 +9618,31 @@ ${statePacket}` },
9363
9618
  if (!snapshot || snapshot.screen.elements.length === 0)
9364
9619
  return null;
9365
9620
  const elements = snapshot.screen.elements;
9366
- const withStableId = elements.filter((el) => hasStableId(el));
9367
- 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));
9368
9626
  if (nonButton)
9369
9627
  return nonButton;
9370
- if (withStableId.length > 0)
9371
- return withStableId[0];
9372
- const labelled = elements.find((el) => nonEmpty2(el.label) || nonEmpty2(el.text));
9373
- 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);
9374
9646
  }
9375
9647
  function appendHybridFloor(actions, recording, platform3) {
9376
9648
  const anchor = pickLandingAnchor(recording.lastSnapshot);
@@ -9429,7 +9701,7 @@ ${statePacket}` },
9429
9701
  return test;
9430
9702
  }
9431
9703
  async function runMobileGeneratePhase(args) {
9432
- const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot } = args;
9704
+ const { scenarios: allScenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot, skipScenarioNames, onInfraStop } = args;
9433
9705
  const log2 = (line) => {
9434
9706
  try {
9435
9707
  onLog?.(line);
@@ -9444,8 +9716,16 @@ ${statePacket}` },
9444
9716
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
9445
9717
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
9446
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
+ }
9447
9724
  log2(`Mobile generator starting \u2014 platform: ${platform3}, provider: ${provider}, model: ${agentModel}, scenarios: ${scenarios.length}`);
9448
9725
  const tests = [];
9726
+ const generatedScenarioNames = /* @__PURE__ */ new Set();
9727
+ let consecutiveInfraFailures = 0;
9728
+ let infraStopped = false;
9449
9729
  for (let i = 0; i < scenarios.length; i++) {
9450
9730
  const scenario = scenarios[i];
9451
9731
  log2(`
@@ -9466,6 +9746,7 @@ ${statePacket}` },
9466
9746
  targetAppId,
9467
9747
  log: log2
9468
9748
  });
9749
+ consecutiveInfraFailures = 0;
9469
9750
  const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
9470
9751
  if (!test) {
9471
9752
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
@@ -9482,13 +9763,31 @@ ${statePacket}` },
9482
9763
  log2(` onTestGenerated callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
9483
9764
  }
9484
9765
  tests.push(test);
9766
+ generatedScenarioNames.add(scenario.name);
9485
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
+ }
9486
9785
  log2(` scenario "${scenario.name}" failed: ${err instanceof Error ? err.message : String(err)} \u2014 skipping.`);
9487
9786
  continue;
9488
9787
  }
9489
9788
  }
9490
9789
  log2(`
9491
- 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" : ""}.`);
9492
9791
  return tests;
9493
9792
  }
9494
9793
  }
@@ -12767,6 +13066,10 @@ var require_snapshot_observation = __commonJS({
12767
13066
  var snapshot_parser_js_1 = require_snapshot_parser();
12768
13067
  var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
12769
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;
12770
13073
  var INTERACTIVE_ROLE_RE = /^(button|link|textbox|searchbox|checkbox|radio|switch|combobox|listbox|option|menuitem|tab|slider|spinbutton|textarea)$/i;
12771
13074
  var INTERACTIVE_ELEMENT_TYPE_RE = /^(button|link|input|textarea|select|checkbox|radio|switch|tab|combobox|listbox|option|menuitem|slider|spinbutton)$/i;
12772
13075
  function renderRetrievedSnapshotMemoryBlock(priorSnapshotEvidence, priorDiscoveryCheckpoints, options = {}) {
@@ -13011,10 +13314,12 @@ var require_snapshot_observation = __commonJS({
13011
13314
  summary
13012
13315
  }));
13013
13316
  }
13317
+ var TEXT_DISAMBIGUATING_INPUT_TYPES = /* @__PURE__ */ new Set(["password", "text", "email", "tel", "number", "search", "url", "date", "datetime-local", "time", "month", "week"]);
13014
13318
  function renderCompactSnapshotBlock(page, context = {}, options = {}) {
13015
13319
  const metrics = page.snapshotMetrics ?? page.snapshotArtifact?.metrics;
13016
13320
  const sourceKind = page.snapshotMeta?.sourceKind ?? page.snapshotArtifact?.sourceKind ?? "missing";
13017
13321
  const artifact = page.snapshotArtifact;
13322
+ const richRender = options.richRender ?? process.env.DISCOVERY_SNAPSHOT_RICH_RENDER === "true";
13018
13323
  const activeScope = inferActiveSnapshotScope(page);
13019
13324
  const ranked = rankSnapshotElements(getScopedSnapshotElements(page, activeScope), withScopeContext(context, activeScope));
13020
13325
  const allInteractive = ranked.filter(({ element }) => isInteractiveCandidate(element)).filter(({ element }) => {
@@ -13060,7 +13365,7 @@ var require_snapshot_observation = __commonJS({
13060
13365
  if (activeScope.rootRef) {
13061
13366
  lines.push(` root_ref: ${yamlScalar(activeScope.rootRef)}`);
13062
13367
  }
13063
- const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText);
13368
+ const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText, richRender);
13064
13369
  if (scopePreview.length > 0) {
13065
13370
  lines.push(" scope_snapshot_preview: |");
13066
13371
  for (const previewLine of scopePreview) {
@@ -13102,6 +13407,19 @@ var require_snapshot_observation = __commonJS({
13102
13407
  lines.push(` - ${yamlScalar(modal)}`);
13103
13408
  }
13104
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
+ }
13105
13423
  lines.push("session_state:");
13106
13424
  if (page.sessionState) {
13107
13425
  lines.push(` authenticated: ${yamlScalar(page.sessionState.authenticated)}`);
@@ -13164,7 +13482,10 @@ var require_snapshot_observation = __commonJS({
13164
13482
  }
13165
13483
  const hasRowContext = group.rowContexts.some((rc) => !!rc);
13166
13484
  if (hasRowContext) {
13167
- 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}`);
13168
13489
  for (let i = 0; i < group.rowContexts.length; i++) {
13169
13490
  const rowContext = group.rowContexts[i];
13170
13491
  if (!rowContext)
@@ -13194,8 +13515,13 @@ var require_snapshot_observation = __commonJS({
13194
13515
  lines.push(` placeholder: ${yamlScalar(rankedElement.element.placeholder)}`);
13195
13516
  }
13196
13517
  const stateTokens = buildStateSummary(rankedElement.element);
13518
+ const addedDisabled = richRender && !!rankedElement.element.isDisabled && !stateTokens.includes("disabled");
13519
+ if (addedDisabled) {
13520
+ stateTokens.unshift("disabled");
13521
+ }
13197
13522
  if (stateTokens.length > 0) {
13198
- 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}`);
13199
13525
  }
13200
13526
  if (rankedElement.element.containerPath) {
13201
13527
  lines.push(` container_path: ${yamlScalar(rankedElement.element.containerPath)}`);
@@ -13205,6 +13531,22 @@ var require_snapshot_observation = __commonJS({
13205
13531
  lines.push(` locator: ${yamlScalar((0, snapshot_parser_js_1.formatLocator)(topLocator))}`);
13206
13532
  }
13207
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
+ }
13208
13550
  if (enr?.rowContext) {
13209
13551
  lines.push(` row_context: ${yamlScalar(enr.rowContext)}`);
13210
13552
  }
@@ -13217,6 +13559,12 @@ var require_snapshot_observation = __commonJS({
13217
13559
  lines.push(` visibility: [${vis.map((token) => yamlScalar(token)).join(", ")}]`);
13218
13560
  }
13219
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
+ }
13220
13568
  }
13221
13569
  const recoveredClickables = options.recoveredClickables ?? [];
13222
13570
  if (recoveredClickables.length > 0) {
@@ -13373,11 +13721,11 @@ var require_snapshot_observation = __commonJS({
13373
13721
  score += 10;
13374
13722
  if (element.isExpanded)
13375
13723
  score += 8;
13376
- if (element.containerPath?.includes("dialog"))
13724
+ if (element.containerPath && DIALOG_SEGMENT_RE.test(element.containerPath))
13377
13725
  score += 12;
13378
- if (element.containerPath?.includes("form"))
13726
+ if (element.containerPath && FORM_SEGMENT_RE.test(element.containerPath))
13379
13727
  score += 10;
13380
- if (element.containerPath?.includes("tabpanel"))
13728
+ if (element.containerPath && TABPANEL_SEGMENT_RE.test(element.containerPath))
13381
13729
  score += 8;
13382
13730
  if (element.inputType === "email" || element.inputType === "password" || element.inputType === "search")
13383
13731
  score += 6;
@@ -13448,10 +13796,10 @@ var require_snapshot_observation = __commonJS({
13448
13796
  }
13449
13797
  return merged;
13450
13798
  }
13451
- function buildScopeSnapshotPreview(scopeSnapshotText) {
13799
+ function buildScopeSnapshotPreview(scopeSnapshotText, stripTrailingValues = false) {
13452
13800
  if (!scopeSnapshotText)
13453
13801
  return [];
13454
- 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);
13455
13803
  }
13456
13804
  function dedupeLines(lines) {
13457
13805
  const seen = /* @__PURE__ */ new Set();
@@ -13578,11 +13926,12 @@ var require_snapshot_observation = __commonJS({
13578
13926
  const key = `${role}\0${name}`;
13579
13927
  let group = groups.get(key);
13580
13928
  if (!group) {
13581
- group = { role, name, containers: [], rowContexts: [], refs: [] };
13929
+ group = { role, name, containers: [], rowContexts: [], rowRoles: [], refs: [] };
13582
13930
  groups.set(key, group);
13583
13931
  }
13584
13932
  group.containers.push(element.containerPath ?? null);
13585
13933
  group.rowContexts.push(element.enrichment?.rowContext ?? null);
13934
+ group.rowRoles.push(element.enrichment?.rowRole ?? null);
13586
13935
  group.refs.push(element.ref ?? null);
13587
13936
  }
13588
13937
  return [...groups.values()].filter((group) => group.containers.length > 1);
@@ -13592,7 +13941,7 @@ var require_snapshot_observation = __commonJS({
13592
13941
  for (const element of elements) {
13593
13942
  const role = element.role?.toLowerCase() ?? "";
13594
13943
  const elementType = element.elementType?.toLowerCase() ?? "";
13595
- 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 ?? "");
13596
13945
  if (!isDialog)
13597
13946
  continue;
13598
13947
  const hint = qualifyElementLabel(element) ?? dialogNameFromContainerPath(element.containerPath) ?? element.label ?? element.testId ?? "dialog";
@@ -13605,7 +13954,7 @@ var require_snapshot_observation = __commonJS({
13605
13954
  function dialogNameFromContainerPath(containerPath) {
13606
13955
  if (!containerPath)
13607
13956
  return null;
13608
- const match = containerPath.match(/dialog:([^>]+)/i);
13957
+ const match = containerPath.match(DIALOG_NAME_SEGMENT_RE);
13609
13958
  return match ? match[1].trim() : null;
13610
13959
  }
13611
13960
  function renderObservationCue(observation) {
@@ -13614,6 +13963,13 @@ var require_snapshot_observation = __commonJS({
13614
13963
  const base2 = observation.outcomeType === "SUCCESS" && outcome ? outcome : `${action} -> ${outcome || observation.outcomeType}`;
13615
13964
  return base2.length > 120 ? `${base2.slice(0, 117)}...` : base2;
13616
13965
  }
13966
+ function messageRolePriority(role) {
13967
+ if (role === "alert")
13968
+ return 0;
13969
+ if (role === "status")
13970
+ return 1;
13971
+ return 2;
13972
+ }
13617
13973
  function yamlScalar(value) {
13618
13974
  if (value == null || value === "")
13619
13975
  return "null";
@@ -20162,7 +20518,10 @@ ${testOpen}`;
20162
20518
  pressed: se.pressed,
20163
20519
  headingLevel: se.headingLevel,
20164
20520
  currentState: se.currentState,
20165
- 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
20166
20525
  }));
20167
20526
  const structuredPage = {
20168
20527
  route: pageUrl || "/",
@@ -20170,6 +20529,7 @@ ${testOpen}`;
20170
20529
  pageType: "unknown",
20171
20530
  elements: collectedElements,
20172
20531
  formGroups: parsed.formGroups,
20532
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
20173
20533
  snapshotMetrics: {
20174
20534
  rawLineCount: parsed.rawLineCount,
20175
20535
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -21736,7 +22096,7 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
21736
22096
  let stopped;
21737
22097
  const agentModel = options?.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
21738
22098
  const auditGroupId = `generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
21739
- 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)();
21740
22100
  const credentials = variableContext.allTestVariables ?? variableContext.publicTestVariables;
21741
22101
  logs2.push(`Phase 3 \u2014 Generate: ${(plan.scenarios ?? []).length} scenarios, max ${maxTests} tests`);
21742
22102
  logs2.push(`Model: ${agentModel}`);
@@ -235701,7 +236061,7 @@ var require_mcp_healer = __commonJS({
235701
236061
  messages.push(system, firstUser, { role: "user", content: lines.join("\n") }, ...tail);
235702
236062
  }
235703
236063
  var DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS = 6;
235704
- function readPositiveIntEnv(env, name, fallback) {
236064
+ function readPositiveIntEnv2(env, name, fallback) {
235705
236065
  const raw = env[name]?.trim();
235706
236066
  if (!raw || !/^\d+$/.test(raw))
235707
236067
  return fallback;
@@ -235709,7 +236069,7 @@ var require_mcp_healer = __commonJS({
235709
236069
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
235710
236070
  }
235711
236071
  function getScriptHealMaxAttempts(env = process.env) {
235712
- 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);
235713
236073
  }
235714
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.
235715
236075
 
@@ -236045,7 +236405,7 @@ ${lines.join("\n")}
236045
236405
  const landingViolation = (tags ?? []).includes("@landing-violation");
236046
236406
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "strong") : (0, ai_provider_js_1.getModel)("strong");
236047
236407
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
236048
- 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)());
236049
236409
  const runNativeTest = options.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
236050
236410
  const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
236051
236411
  const groupId = `script-heal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -236867,10 +237227,10 @@ ${(verifyResult.logs ?? "").slice(0, 2e3)}`;
236867
237227
  var DEFAULT_MAX_PHASE2_ITERATIONS = 4;
236868
237228
  var DEFAULT_MAX_MCP_EXPLORE_ITERATIONS = 25;
236869
237229
  function getMcpHealPhase2MaxIterations(env = process.env) {
236870
- 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);
236871
237231
  }
236872
237232
  function getMcpHealExploreMaxIterations(env = process.env) {
236873
- 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);
236874
237234
  }
236875
237235
  var DEFAULT_MCP_HEAL_MAX_WALL_CLOCK_MS = 10 * 60 * 1e3;
236876
237236
  var DEFAULT_MCP_HEAL_MAX_TOTAL_AI_CALLS = 60;
@@ -236922,7 +237282,7 @@ Only explore the failing page and the area around the broken step.`;
236922
237282
  let lastVideo;
236923
237283
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
236924
237284
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
236925
- 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)());
236926
237286
  const runPhase2NativeTest = options?.phase2Harness?.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
236927
237287
  const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
236928
237288
  const groupId = `phase2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -237612,7 +237972,10 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
237612
237972
  pressed: se.pressed,
237613
237973
  headingLevel: se.headingLevel,
237614
237974
  currentState: se.currentState,
237615
- 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
237616
237979
  }));
237617
237980
  const structuredPage = {
237618
237981
  route: pageUrl || "/",
@@ -237620,6 +237983,7 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
237620
237983
  pageType: "unknown",
237621
237984
  elements: collectedElements,
237622
237985
  formGroups: parsed.formGroups,
237986
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
237623
237987
  snapshotMetrics: {
237624
237988
  rawLineCount: parsed.rawLineCount,
237625
237989
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -238056,7 +238420,7 @@ ${testBlocks}
238056
238420
  - By iteration ${Math.floor(maxIterations * 0.3)}: you should have completed 1-2 tests
238057
238421
  - By iteration ${Math.floor(maxIterations * 0.7)}: you should be past the halfway point
238058
238422
  - At iteration ${Math.floor(maxIterations * 0.9)}: wrap up and call finish_capture`;
238059
- 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)();
238060
238424
  const messages = [
238061
238425
  { role: "system", content: systemPrompt },
238062
238426
  { role: "user", content: "Start executing the tests. Navigate to the base URL and begin with Test 1." }
@@ -238239,7 +238603,10 @@ ${testBlocks}
238239
238603
  pressed: se.pressed,
238240
238604
  headingLevel: se.headingLevel,
238241
238605
  currentState: se.currentState,
238242
- 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
238243
238610
  }));
238244
238611
  const structuredPage = {
238245
238612
  route: pageUrl || "/",
@@ -238247,6 +238614,7 @@ ${testBlocks}
238247
238614
  pageType: "unknown",
238248
238615
  elements: collectedElements,
238249
238616
  formGroups: parsed.formGroups,
238617
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
238250
238618
  snapshotMetrics: {
238251
238619
  rawLineCount: parsed.rawLineCount,
238252
238620
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -300036,8 +300404,9 @@ var require_capture_page_normalizer = __commonJS({
300036
300404
  let snapshotElements = [];
300037
300405
  let formGroups = [];
300038
300406
  let snapshotMetrics;
300407
+ let staticMessages;
300039
300408
  if (normalizedSnapshotResult.snapshotText) {
300040
- const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText);
300409
+ const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText, { richParse: context.richParse });
300041
300410
  snapshotMetrics = {
300042
300411
  rawLineCount: parsed.rawLineCount,
300043
300412
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -300066,9 +300435,13 @@ var require_capture_page_normalizer = __commonJS({
300066
300435
  pressed: se.pressed,
300067
300436
  headingLevel: se.headingLevel,
300068
300437
  currentState: se.currentState,
300069
- 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
300070
300442
  }));
300071
300443
  formGroups = parsed.formGroups;
300444
+ staticMessages = parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0;
300072
300445
  }
300073
300446
  const aiElements = Array.isArray(toolArgs.elements) ? toolArgs.elements.map(normalizeAiElement) : [];
300074
300447
  const noYamlKinds = /* @__PURE__ */ new Set(["missing", "timeout", "error", "empty"]);
@@ -300164,6 +300537,7 @@ var require_capture_page_normalizer = __commonJS({
300164
300537
  } : void 0,
300165
300538
  readinessSignals,
300166
300539
  viewportHints,
300540
+ staticMessages,
300167
300541
  provenCommands: provenCmds,
300168
300542
  observations: rawObservations,
300169
300543
  routeCorrected,
@@ -300472,6 +300846,10 @@ var require_perception_enricher = __commonJS({
300472
300846
  const e = {};
300473
300847
  if (p.rowContext)
300474
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;
300475
300853
  if (p.position && p.position !== "in-view")
300476
300854
  e.position = p.position;
300477
300855
  if (p.occluded)
@@ -300721,6 +301099,23 @@ var require_perception_enricher = __commonJS({
300721
301099
  const cls = (el.getAttribute("class") || "").toLowerCase();
300722
301100
  return /(^|[\s_-])(card|row|item|listitem|cell|tile)([\s_-]|$)/.test(cls);
300723
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
+ };
300724
301119
  const rowContextOf = (el, ownNameLower) => {
300725
301120
  let cur = el.parentElement;
300726
301121
  let depth = 0;
@@ -300739,7 +301134,7 @@ var require_perception_enricher = __commonJS({
300739
301134
  txt = txt.slice(0, 80);
300740
301135
  const low = txt.toLowerCase();
300741
301136
  if (txt && low !== ownNameLower && low.length > 1)
300742
- return txt;
301137
+ return { text: txt, role: rowRoleOf(cur) };
300743
301138
  return null;
300744
301139
  }
300745
301140
  cur = cur.parentElement;
@@ -300829,11 +301224,14 @@ var require_perception_enricher = __commonJS({
300829
301224
  const name = accName(el);
300830
301225
  const lname = lower(name);
300831
301226
  const rect = vis.rect;
301227
+ const rc = rowContextOf(el, lname);
300832
301228
  out.push({
300833
301229
  domOrder: myOrder,
300834
301230
  roleClass: roleClassOf(tag, roleAttr, inputType),
300835
301231
  name: lname,
300836
- rowContext: rowContextOf(el, lname),
301232
+ rowContext: rc ? rc.text : null,
301233
+ rowRole: rc ? rc.role : null,
301234
+ inputType: inputType || null,
300837
301235
  position: positionOf(rect),
300838
301236
  occluded: occlusionOf(el, rect),
300839
301237
  cursorPointer,
@@ -300870,11 +301268,14 @@ var require_perception_enricher = __commonJS({
300870
301268
  if (!name)
300871
301269
  continue;
300872
301270
  const lname = lower(name);
301271
+ const rc = rowContextOf(el, lname);
300873
301272
  out.push({
300874
301273
  domOrder: order++,
300875
301274
  roleClass: "button",
300876
301275
  name: lname,
300877
- rowContext: rowContextOf(el, lname),
301276
+ rowContext: rc ? rc.text : null,
301277
+ rowRole: rc ? rc.role : null,
301278
+ inputType: null,
300878
301279
  position: "in-view",
300879
301280
  occluded: false,
300880
301281
  cursorPointer: true,
@@ -301376,7 +301777,7 @@ var require_discover_explorer = __commonJS({
301376
301777
  var DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP = 100;
301377
301778
  var DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY = 100;
301378
301779
  var DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL = 40;
301379
- function readPositiveIntEnv(env, name, fallback) {
301780
+ function readPositiveIntEnv2(env, name, fallback) {
301380
301781
  const raw = env[name]?.trim();
301381
301782
  if (!raw || !/^\d+$/.test(raw))
301382
301783
  return fallback;
@@ -301507,15 +301908,15 @@ var require_discover_explorer = __commonJS({
301507
301908
  }
301508
301909
  function getMaxIterations(mode, incremental, env = process.env) {
301509
301910
  if (mode === "survey" && incremental) {
301510
- 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);
301511
301912
  }
301512
301913
  if (mode === "survey") {
301513
- 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);
301514
301915
  }
301515
301916
  if (mode === "deep") {
301516
- 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);
301517
301918
  }
301518
- 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);
301519
301920
  }
301520
301921
  function updatePendingRouteScreenshot(pending, { imageData, detectedRoute, fallbackRoute }) {
301521
301922
  if (imageData) {
@@ -302241,7 +302642,7 @@ ${inboxPromptBlock}`;
302241
302642
  const browserCreds = variableContext.allTestVariables ?? variableContext.publicTestVariables;
302242
302643
  const { secrets: browserSecretsForHint } = (0, credential_tools_js_1.partitionTestVariables)(browserCreds);
302243
302644
  const hasLoginCredentials = Object.keys(browserSecretsForHint).length > 0;
302244
- 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)();
302245
302646
  const isDeepMode = options?.mode === "deep";
302246
302647
  const maxIterations = config?.maxIterationsOverride && config.maxIterationsOverride > 0 ? config.maxIterationsOverride : getMaxIterations(options?.mode ?? "full", options?.incremental);
302247
302648
  const configKeepTail = isDeepMode ? Math.ceil(ctxConfig.keepTail * 1.5) : ctxConfig.keepTail;
@@ -304780,7 +305181,7 @@ var require_auth_capture_run = __commonJS({
304780
305181
  var scrub_credentials_js_1 = require_scrub_credentials();
304781
305182
  var credential_tools_js_1 = require_credential_tools();
304782
305183
  var DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS = 12;
304783
- function readPositiveIntEnv(env, name, fallback) {
305184
+ function readPositiveIntEnv2(env, name, fallback) {
304784
305185
  const raw = env[name]?.trim();
304785
305186
  if (!raw || !/^\d+$/.test(raw))
304786
305187
  return fallback;
@@ -304788,7 +305189,7 @@ var require_auth_capture_run = __commonJS({
304788
305189
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
304789
305190
  }
304790
305191
  function getLoginCaptureMaxIterations(env = process.env) {
304791
- 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);
304792
305193
  }
304793
305194
  var CAPTURE_RATE_LIMIT_MAX_RETRIES = 2;
304794
305195
  var CAPTURE_RATE_LIMIT_MAX_DELAY_MS = 8e3;
@@ -305071,7 +305472,7 @@ ${keyLines || "- (none provided)"}
305071
305472
  }
305072
305473
  var DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS = 18;
305073
305474
  function getRegisterCaptureMaxIterations(env = process.env) {
305074
- 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);
305075
305476
  }
305076
305477
  function buildRegisterCaptureSystemPrompt(baseUrl, availableKeys, maxIterations = getRegisterCaptureMaxIterations()) {
305077
305478
  const keyLines = availableKeys.map((k) => `- \`${k}\``).join("\n");
@@ -311301,6 +311702,7 @@ var require_mobile_exploration_state = __commonJS({
311301
311702
  Object.defineProperty(exports2, "__esModule", { value: true });
311302
311703
  exports2.MobileExplorationState = void 0;
311303
311704
  exports2.deriveScreenId = deriveScreenId;
311705
+ var mobile_observation_js_1 = require_mobile_observation();
311304
311706
  var DEFAULT_STALE_THRESHOLD = 4;
311305
311707
  function deriveScreenId(signal) {
311306
311708
  const namespace = signal.activity ?? signal.bundleId;
@@ -311526,21 +311928,33 @@ var require_mobile_exploration_state = __commonJS({
311526
311928
  * Build the planner evidence shape: one `CollectedPageData` per screen, with
311527
311929
  * the screen-id carried in BOTH `route` (the on-the-wire positional field the
311528
311930
  * server keys screenshots by) AND the explicit `screenId` field (per the
311529
- * Phase-0 `CollectedPageData.screenId` contract). `elements` is left empty —
311530
- * the web-shaped `CollectedPageElement[]` is not the mobile element shape;
311531
- * the mobile planner reads screen identity + names from this evidence and the
311532
- * richer per-element data flows through the generator's own re-drive, not
311533
- * 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.
311534
311938
  */
311535
311939
  buildInMemoryScreenMap() {
311536
311940
  const pages = [];
311537
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
+ }
311538
311950
  for (const state2 of ordered) {
311539
311951
  const page = {
311540
311952
  route: state2.screenId,
311541
311953
  screenId: state2.screenId,
311542
311954
  pageType: state2.screenType ?? "UNKNOWN",
311543
- elements: []
311955
+ elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
311956
+ navigationRefs: navRefsByScreen.get(state2.screenId)
311957
+ })
311544
311958
  };
311545
311959
  if (state2.screenName) {
311546
311960
  page.title = state2.screenName;
@@ -311570,14 +311984,51 @@ var require_mobile_discovery_agent = __commonJS({
311570
311984
  "../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports2) {
311571
311985
  "use strict";
311572
311986
  Object.defineProperty(exports2, "__esModule", { value: true });
311987
+ exports2.getMobileExploreBudget = getMobileExploreBudget;
311573
311988
  exports2.runMobileDiscoveryOnRunner = runMobileDiscoveryOnRunner2;
311574
311989
  var mobile_explorer_js_1 = require_mobile_explorer();
311575
311990
  var mobile_discovery_prompts_js_1 = require_mobile_discovery_prompts();
311576
311991
  var mobile_generator_js_1 = require_mobile_generator();
311577
311992
  var mobile_exploration_state_js_1 = require_mobile_exploration_state();
311578
311993
  var discover_planner_js_1 = require_discover_planner();
311579
- var EXPLORE_BUDGET_DEEP = 60;
311580
- var EXPLORE_BUDGET_SURVEY = 40;
311994
+ function checkpointErrorMessage(err) {
311995
+ return err instanceof Error ? err.message : String(err);
311996
+ }
311997
+ function isTerminalDiscoveryCheckpointError(err) {
311998
+ const message = checkpointErrorMessage(err);
311999
+ return /Discovery plan checkpoint rejected|Discovery run is .*not RUNNING|not RUNNING|cancelled/i.test(message);
312000
+ }
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
+ }
311581
312032
  function platformForMedium(medium) {
311582
312033
  return medium === "IOS_NATIVE" ? "IOS" : "ANDROID";
311583
312034
  }
@@ -311596,6 +312047,9 @@ var require_mobile_discovery_agent = __commonJS({
311596
312047
  // Native screenshots are multi-megabyte PNGs. The live mirror streams them
311597
312048
  // through heartbeat telemetry; discovery checkpoints keep the screen graph
311598
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 } : {},
311599
312053
  exploreStats: explore.exploreStats,
311600
312054
  healthObservations: explore.healthObservations
311601
312055
  };
@@ -311610,6 +312064,7 @@ var require_mobile_discovery_agent = __commonJS({
311610
312064
  const onLog = callbacks?.onLog;
311611
312065
  const onAICall = callbacks?.onAICall;
311612
312066
  const onScreenshot = callbacks?.onScreenshot;
312067
+ const onScreenshotUpload = callbacks?.onScreenshotUpload;
311613
312068
  const runExplore = callbacks?.runExplore ?? mobile_explorer_js_1.runMobileExplorePhase;
311614
312069
  const runPlan = callbacks?.runPlan ?? mobile_discovery_prompts_js_1.runMobileAIPlanner;
311615
312070
  const runGenerate = callbacks?.runGenerate ?? mobile_generator_js_1.runMobileGeneratePhase;
@@ -311636,15 +312091,77 @@ var require_mobile_discovery_agent = __commonJS({
311636
312091
  elementsFound: 0,
311637
312092
  transitionsFound: 0
311638
312093
  };
312094
+ const resumeSavedScenarios = ctx.resume?.savedPlan?.scenarios ?? [];
312095
+ const isPlanReuseResume = mode !== "survey" && resumeSavedScenarios.length > 0;
311639
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
+ }
311640
312155
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
311641
312156
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
311642
- const maxIterations = mode === "deep" ? EXPLORE_BUDGET_DEEP : EXPLORE_BUDGET_SURVEY;
312157
+ const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
312158
+ const existingScreens = compactExistingScreens(ctx.existingSiteMap);
311643
312159
  const explore = await runExplore({
311644
312160
  ctx,
311645
312161
  driver,
311646
312162
  state: state2,
311647
312163
  config: { maxIterations },
312164
+ existingScreens,
311648
312165
  onLog: (line) => log2(line),
311649
312166
  onAICall,
311650
312167
  onScreenshot: (base64) => {
@@ -311654,7 +312171,8 @@ var require_mobile_discovery_agent = __commonJS({
311654
312171
  onScreenshot?.(base64);
311655
312172
  } catch {
311656
312173
  }
311657
- }
312174
+ },
312175
+ onScreenshotUpload
311658
312176
  });
311659
312177
  const collectedTransitions = toCollectedTransitions(explore);
311660
312178
  partialCollectedPages = explore.collectedPages;
@@ -311706,14 +312224,22 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311706
312224
  const plans = toPlans(scenarios);
311707
312225
  const coverageGaps = planResult.coverageGaps && planResult.coverageGaps.length > 0 ? planResult.coverageGaps : void 0;
311708
312226
  if (callbacks?.onPlanReady) {
311709
- await callbacks.onPlanReady({
311710
- scenarios,
311711
- featureGroups,
311712
- fallbackUsed: planResult.fallbackUsed ?? false,
311713
- fallbackReason: planResult.fallbackReason
311714
- }).then(() => log2("Plan checkpoint: scenario plan POSTed to server")).catch((err) => {
311715
- log2(`Plan checkpoint POST failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
311716
- });
312227
+ try {
312228
+ await callbacks.onPlanReady({
312229
+ scenarios,
312230
+ featureGroups,
312231
+ fallbackUsed: planResult.fallbackUsed ?? false,
312232
+ fallbackReason: planResult.fallbackReason
312233
+ });
312234
+ log2("Plan checkpoint: scenario plan POSTed to server");
312235
+ } catch (err) {
312236
+ const message = checkpointErrorMessage(err);
312237
+ if (isTerminalDiscoveryCheckpointError(err)) {
312238
+ log2(`Plan checkpoint rejected for inactive discovery run: ${message}`);
312239
+ throw err;
312240
+ }
312241
+ log2(`Plan checkpoint POST failed (non-fatal): ${message}`);
312242
+ }
311717
312243
  }
311718
312244
  if (isSurvey) {
311719
312245
  const duration2 = (Date.now() - startTime) / 1e3;
@@ -311747,6 +312273,9 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311747
312273
  };
311748
312274
  }
311749
312275
  log2("\n\u2500\u2500 Phase 3: GENERATE \u2500\u2500");
312276
+ let generatePaused = false;
312277
+ let generatePauseReason;
312278
+ let generateRemainingScenarios;
311750
312279
  const tests = await runGenerate({
311751
312280
  scenarios,
311752
312281
  driver,
@@ -311754,7 +312283,15 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311754
312283
  onLog: (line) => log2(line),
311755
312284
  onAICall,
311756
312285
  onScreenshot,
311757
- 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
+ }
311758
312295
  });
311759
312296
  const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
311760
312297
  log2(`
@@ -311770,7 +312307,7 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
311770
312307
  };
311771
312308
  }
311772
312309
  const duration = (Date.now() - startTime) / 1e3;
311773
- 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)" : ""}`;
311774
312311
  log2(`
311775
312312
  === Mobile Discovery Pipeline Complete (${duration.toFixed(1)}s) ===`);
311776
312313
  log2(summary);
@@ -311795,7 +312332,11 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
311795
312332
  summary,
311796
312333
  exploreStats: explore.exploreStats,
311797
312334
  duration,
311798
- 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 } : {}
311799
312340
  // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
311800
312341
  };
311801
312342
  } catch (fatalErr) {
@@ -311841,6 +312382,7 @@ var require_discover_agent = __commonJS({
311841
312382
  Object.defineProperty(exports2, "__esModule", { value: true });
311842
312383
  exports2.runMobileDiscoveryOnRunner = exports2.buildInMemorySiteMap = void 0;
311843
312384
  exports2.runCanvasPreflight = runCanvasPreflight;
312385
+ exports2.getFatalExploreError = getFatalExploreError;
311844
312386
  exports2.runSiteDiscoveryOnRunner = runSiteDiscoveryOnRunner2;
311845
312387
  exports2.runFeatureDiscoveryOnRunner = runFeatureDiscoveryOnRunner2;
311846
312388
  var browser_config_js_1 = require_browser_config();
@@ -312023,7 +312565,7 @@ Respond with ONLY valid JSON:
312023
312565
  "keyFindings": "What was discovered - forms, validations, behaviors. Mark inferred items with '(inferred, not tested)'.",
312024
312566
  "testingNotes": "Gotchas, validation behaviors, error messages. List visible-but-untested routes as candidates for future exploration."
312025
312567
  }`;
312026
- 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)();
312027
312569
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
312028
312570
  const callStart = Date.now();
312029
312571
  const response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => client.chat.completions.create({
@@ -312154,6 +312696,10 @@ Respond with ONLY valid JSON:
312154
312696
  });
312155
312697
  return timed.catch(() => null);
312156
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
+ }
312157
312703
  async function runSiteDiscoveryOnRunner2(ctx, onLog, onAICall, sharedBrowser, onScreenshot, callbackOptions) {
312158
312704
  const logs2 = [];
312159
312705
  const screenshots = [];
@@ -312338,6 +312884,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
312338
312884
  if (loginRunP)
312339
312885
  await loginRunP;
312340
312886
  const duration2 = (Date.now() - startTime) / 1e3;
312887
+ const fatalExploreError = getFatalExploreError(exploreResult);
312341
312888
  return {
312342
312889
  collectedPages: exploreResult.collectedPages,
312343
312890
  collectedTransitions: exploreResult.collectedTransitions,
@@ -312348,6 +312895,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
312348
312895
  pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
312349
312896
  pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
312350
312897
  summary: exploreResult.summary || "Exploration completed but no page data captured.",
312898
+ error: fatalExploreError,
312351
312899
  exploreStats: {
312352
312900
  pagesDiscovered: exploreResult.pagesDiscovered,
312353
312901
  pagesVisited: exploreResult.pagesVisited,
@@ -312889,6 +313437,7 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
312889
313437
  discoveryApiCalls = earlyClose.apiCalls;
312890
313438
  postCloseHealthSummary = earlyClose.healthSummary;
312891
313439
  const duration2 = (Date.now() - startTime) / 1e3;
313440
+ const fatalExploreError = getFatalExploreError(exploreResult);
312892
313441
  return {
312893
313442
  collectedPages: exploreResult.collectedPages,
312894
313443
  collectedTransitions: exploreResult.collectedTransitions,
@@ -312899,7 +313448,8 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
312899
313448
  healthObservations: earlyClose.healthSummary,
312900
313449
  pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
312901
313450
  pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
312902
- 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,
312903
313453
  exploreStats: {
312904
313454
  pagesDiscovered: exploreResult.pagesDiscovered,
312905
313455
  pagesVisited: exploreResult.pagesVisited,
@@ -326288,7 +326838,7 @@ var require_package4 = __commonJS({
326288
326838
  "package.json"(exports2, module2) {
326289
326839
  module2.exports = {
326290
326840
  name: "@validate.qa/runner",
326291
- version: "1.0.9",
326841
+ version: "1.0.13",
326292
326842
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
326293
326843
  bin: {
326294
326844
  "validate-runner": "dist/cli.js"
@@ -326296,7 +326846,7 @@ var require_package4 = __commonJS({
326296
326846
  scripts: {
326297
326847
  build: "tsup",
326298
326848
  dev: "tsx src/cli.ts",
326299
- "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",
326300
326850
  prepublishOnly: "npm run build"
326301
326851
  },
326302
326852
  files: [
@@ -326523,8 +327073,10 @@ async function startAppiumServer(options) {
326523
327073
  return serverState;
326524
327074
  }
326525
327075
  const port = options?.port ?? APPIUM_PORT;
326526
- const drivers = options?.drivers ?? ["xcuitest", "uiautomator2"];
326527
- 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
+ }
326528
327080
  intentionalStop = false;
326529
327081
  lastStartOptions = { ...lastStartOptions, ...options };
326530
327082
  if (appiumGaveUp) {
@@ -326534,11 +327086,12 @@ async function startAppiumServer(options) {
326534
327086
  killPort(port);
326535
327087
  return new Promise((resolve, reject) => {
326536
327088
  try {
326537
- appiumProcess = (0, import_child_process.spawn)("appium", args, {
327089
+ const child = (0, import_child_process.spawn)("appium", args, {
326538
327090
  stdio: ["pipe", "pipe", "pipe"],
326539
327091
  env: { ...process.env },
326540
327092
  detached: false
326541
327093
  });
327094
+ appiumProcess = child;
326542
327095
  serverState.pid = appiumProcess.pid ?? null;
326543
327096
  serverState.port = port;
326544
327097
  appiumProcess.stdout?.on("data", (data) => {
@@ -326555,7 +327108,11 @@ async function startAppiumServer(options) {
326555
327108
  options?.onLog?.(line);
326556
327109
  }
326557
327110
  });
326558
- 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
+ }
326559
327116
  appendLog(`[appium] Process exited (code=${code}, signal=${signal})`);
326560
327117
  const currentPort = serverState.port;
326561
327118
  const currentRestartCount = serverState.restartCount;
@@ -326594,7 +327151,11 @@ async function startAppiumServer(options) {
326594
327151
  startupAbort.abort();
326595
327152
  reject(err);
326596
327153
  };
326597
- 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
+ }
326598
327159
  appendLog(`[appium] Process error: ${err.message}`);
326599
327160
  resetServerState(port);
326600
327161
  settleReject(new Error(`Failed to start Appium: ${err.message}. Is appium installed? Run: npm i -g appium`));
@@ -326713,6 +327274,7 @@ function runCommand(command, args, timeout = 1e4) {
326713
327274
  }
326714
327275
  var realIOSProfilerNegativeUntil = 0;
326715
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";
326716
327278
  function discoverIOSSimulators() {
326717
327279
  if ((0, import_os2.platform)() !== "darwin") return [];
326718
327280
  const json = runCommand("xcrun", ["simctl", "list", "devices", "--json"]);
@@ -326722,7 +327284,8 @@ function discoverIOSSimulators() {
326722
327284
  const devices = [];
326723
327285
  for (const [runtime, runtimeDevices] of Object.entries(parsed.devices)) {
326724
327286
  const versionMatch = runtime.match(/iOS[- ](\d+(?:[-.]\d+)*)/i);
326725
- const platformVersion = versionMatch ? versionMatch[1].replace(/-/g, ".") : "unknown";
327287
+ if (!versionMatch) continue;
327288
+ const platformVersion = versionMatch[1].replace(/-/g, ".");
326726
327289
  for (const device of runtimeDevices) {
326727
327290
  const state2 = device.state === "Booted" ? "BOOTED" : device.state === "Shutdown" ? "SHUTDOWN" : "LOADING";
326728
327291
  if (state2 === "BOOTED") {
@@ -326795,11 +327358,11 @@ function discoverRealIOSDevices() {
326795
327358
  const version = runCommand("ideviceinfo", ["-u", serial, "-k", "ProductVersion"]) || "unknown";
326796
327359
  devices.push({
326797
327360
  id: serial,
326798
- name,
327361
+ name: `${name} (${REAL_IOS_NO_UDID_HINT})`,
326799
327362
  platform: "IOS",
326800
327363
  platformVersion: version,
326801
327364
  state: "BOOTED",
326802
- isAvailable: true,
327365
+ isAvailable: false,
326803
327366
  isPhysical: true
326804
327367
  });
326805
327368
  }
@@ -326815,11 +327378,11 @@ function discoverRealIOSDevices() {
326815
327378
  if (hasIPhone || hasIPad) {
326816
327379
  devices.push({
326817
327380
  id: "usb-ios-device",
326818
- name: hasIPhone ? "iPhone (USB)" : "iPad (USB)",
327381
+ name: `${hasIPhone ? "iPhone (USB)" : "iPad (USB)"} (${REAL_IOS_NO_UDID_HINT})`,
326819
327382
  platform: "IOS",
326820
327383
  platformVersion: "unknown",
326821
327384
  state: "BOOTED",
326822
- isAvailable: true,
327385
+ isAvailable: false,
326823
327386
  isPhysical: true
326824
327387
  });
326825
327388
  }
@@ -326897,7 +327460,9 @@ function discoverAndroidDevices() {
326897
327460
  const adbDevices = parseAdbDevices();
326898
327461
  return adbDevices.filter((d) => d.type === "device" || d.type === "unauthorized").map((d) => {
326899
327462
  const isEmulator = d.serial.startsWith("emulator-");
326900
- 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;
326901
327466
  const name = d.model || d.device || (isEmulator ? "Android Emulator" : "Android Device");
326902
327467
  const platformVersion = isAvailable ? getAndroidProp(d.serial, "ro.build.version.release") || "unknown" : "unknown";
326903
327468
  return {
@@ -326905,7 +327470,10 @@ function discoverAndroidDevices() {
326905
327470
  name,
326906
327471
  platform: "ANDROID",
326907
327472
  platformVersion,
326908
- 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",
326909
327477
  isAvailable,
326910
327478
  // Mark if it's a real device vs emulator
326911
327479
  ...isEmulator ? {} : { isPhysical: true }
@@ -326951,15 +327519,25 @@ function discoverAllDevices(options) {
326951
327519
  }
326952
327520
  return [...iosDevices, ...androidDevices];
326953
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
+ }
326954
327533
  function getRunnerCapabilities() {
326955
- const iosDevices = discoverIOSDevices();
326956
- const androidDevices = discoverAndroidDevices();
327534
+ const devices = getCachedDeviceSnapshot();
326957
327535
  const isReadyDevice = (device) => device.isAvailable && device.state === "BOOTED";
326958
327536
  return {
326959
327537
  web: true,
326960
327538
  // Always capable of web testing via Playwright
326961
- ios: iosDevices.some(isReadyDevice),
326962
- android: androidDevices.some(isReadyDevice)
327539
+ ios: devices.some((d) => d.platform === "IOS" && isReadyDevice(d)),
327540
+ android: devices.some((d) => d.platform === "ANDROID" && isReadyDevice(d))
326963
327541
  };
326964
327542
  }
326965
327543
 
@@ -327095,6 +327673,9 @@ var MitmProxy = class {
327095
327673
  await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
327096
327674
  await server3.on("request", (req) => this.onRequest(req));
327097
327675
  await server3.on("response", (res) => this.onResponse(res));
327676
+ await server3.on("abort", (req) => {
327677
+ this.inFlight.delete(req.id);
327678
+ });
327098
327679
  await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
327099
327680
  await server3.on("tls-passthrough-opened", (event) => this.onTlsPassthroughOpened(event));
327100
327681
  await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
@@ -327391,6 +327972,107 @@ async function iosSimRemoveCa(deviceId, onLog) {
327391
327972
  function errMsg(err) {
327392
327973
  return err instanceof Error ? err.message : String(err);
327393
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
+ }
327394
328076
  var activeCleanups = /* @__PURE__ */ new Map();
327395
328077
  var exitHandlersInstalled = false;
327396
328078
  function markerPath(sessionKey) {
@@ -327425,6 +328107,14 @@ function restoreDeviceSync(marker, onLog) {
327425
328107
  run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
327426
328108
  }
327427
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
+ }
327428
328118
  onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
327429
328119
  }
327430
328120
  async function reconcilePendingCleanups(onLog) {
@@ -327436,6 +328126,8 @@ async function reconcilePendingCleanups(onLog) {
327436
328126
  }
327437
328127
  for (const file of files) {
327438
328128
  if (!file.endsWith(".json")) continue;
328129
+ const sessionKey = file.slice(0, -".json".length);
328130
+ if (activeCleanups.has(sessionKey)) continue;
327439
328131
  const full = (0, import_node_path.join)(PENDING_CLEANUP_DIR, file);
327440
328132
  try {
327441
328133
  const raw = await (0, import_promises.readFile)(full, "utf-8");
@@ -327465,8 +328157,10 @@ function ensureExitHandlers() {
327465
328157
  for (const signal of ["SIGTERM", "SIGINT"]) {
327466
328158
  process.on(signal, () => {
327467
328159
  drain();
327468
- process.removeAllListeners(signal);
327469
- process.kill(process.pid, signal);
328160
+ if (process.listenerCount(signal) <= 1) {
328161
+ process.removeAllListeners(signal);
328162
+ process.kill(process.pid, signal);
328163
+ }
327470
328164
  });
327471
328165
  }
327472
328166
  }
@@ -327516,6 +328210,9 @@ async function startMobileNetworkCapture(options) {
327516
328210
  let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
327517
328211
  let proxySet = false;
327518
328212
  let caInstalledOnDevice = false;
328213
+ let hostProxySet = false;
328214
+ let hostProxyService;
328215
+ let hostProxyPrior;
327519
328216
  try {
327520
328217
  if (platform3 === "ANDROID") {
327521
328218
  proxySet = await androidSetProxy(deviceId, hostPort, onLog);
@@ -327530,25 +328227,50 @@ async function startMobileNetworkCapture(options) {
327530
328227
  }
327531
328228
  } else {
327532
328229
  if (!isPhysical && caCertPath) {
327533
- const { trusted, note } = await iosSimTrustCa(deviceId, caCertPath);
328230
+ const { trusted } = await iosSimTrustCa(deviceId, caCertPath);
327534
328231
  caInstalledOnDevice = trusted;
327535
- 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
+ }
327536
328245
  } else if (isPhysical) {
327537
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}`);
327538
328250
  }
327539
328251
  }
327540
328252
  } catch (err) {
327541
328253
  wiringReason = `device wiring degraded: ${errMsg(err)}`;
327542
328254
  onLog?.(`[mobile-net] ${wiringReason}`);
327543
328255
  }
327544
- if (proxySet || caInstalledOnDevice) {
328256
+ if (proxySet || caInstalledOnDevice || hostProxySet) {
327545
328257
  const marker = {
327546
328258
  platform: platform3,
327547
328259
  deviceId,
327548
328260
  isPhysical,
327549
328261
  proxySet,
327550
328262
  originalProxy,
327551
- 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
+ } : {}
327552
328274
  };
327553
328275
  activeCleanups.set(sessionKey, marker);
327554
328276
  await writePendingCleanup(sessionKey, marker);
@@ -327568,6 +328290,9 @@ async function startMobileNetworkCapture(options) {
327568
328290
  if (proxySet && platform3 === "ANDROID") {
327569
328291
  await androidClearProxy(deviceId, originalProxy, onLog);
327570
328292
  }
328293
+ if (hostProxySet && hostProxyService && platform3 === "IOS" && !isPhysical) {
328294
+ await iosSimRestoreHostProxy(hostProxyService, hostProxyPrior, onLog);
328295
+ }
327571
328296
  if (caInstalledOnDevice && capturedCaCertPath) {
327572
328297
  if (platform3 === "ANDROID") {
327573
328298
  await androidRemoveCa(deviceId, onLog);
@@ -327603,7 +328328,11 @@ async function startMobileNetworkCapture(options) {
327603
328328
 
327604
328329
  // src/services/appium-executor.ts
327605
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;
327606
328334
  var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
328335
+ var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
327607
328336
  var WebDriverTransportError = class extends Error {
327608
328337
  isTransport = true;
327609
328338
  constructor(message) {
@@ -327615,13 +328344,21 @@ function isTransportError(err) {
327615
328344
  return err instanceof WebDriverTransportError || typeof err === "object" && err !== null && err.isTransport === true;
327616
328345
  }
327617
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;
327618
328349
  let res;
327619
328350
  try {
327620
328351
  res = await fetch(`http://localhost:${getAppiumState().port}${path}`, {
327621
328352
  ...options,
328353
+ signal: AbortSignal.timeout(timeoutMs),
327622
328354
  headers: { "Content-Type": "application/json", ...options?.headers }
327623
328355
  });
327624
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
+ }
327625
328362
  throw new WebDriverTransportError(`WebDriver connection error: ${err instanceof Error ? err.message : String(err)}`);
327626
328363
  }
327627
328364
  if (!res.ok) {
@@ -327706,6 +328443,31 @@ async function createSession(target) {
327706
328443
  if (!sessionId) throw new Error("Appium session creation returned no session ID");
327707
328444
  return sessionId;
327708
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
+ }
327709
328471
  async function openDeepLink(sessionId, platform3, deeplink, appId) {
327710
328472
  const args = platform3 === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
327711
328473
  await wdRequest(`/session/${sessionId}/execute/sync`, {
@@ -327807,6 +328569,61 @@ async function takeScreenshot(sessionId) {
327807
328569
  return null;
327808
328570
  }
327809
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
+ }
327810
328627
  function boundsFromStep(step) {
327811
328628
  const bounds = step.metadata?.bounds;
327812
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") {
@@ -327926,7 +328743,8 @@ async function sendKeysToFocusedInput(sessionId, value) {
327926
328743
  });
327927
328744
  }
327928
328745
  function encodeAndroidInputText(value) {
327929
- 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, `'\\''`)}'`;
327930
328748
  }
327931
328749
  async function sendAndroidTextToFocusedInput(deviceId, value) {
327932
328750
  if (!deviceId) {
@@ -328150,9 +328968,29 @@ async function executeAssertText(sessionId, step) {
328150
328968
  const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
328151
328969
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
328152
328970
  const actualText = typeof result?.value === "string" ? result.value : "";
328153
- if (!actualText.includes(step.assertion)) {
328154
- throw new Error(`Text assertion failed: expected "${step.assertion}" to be contained in "${actualText}"`);
328971
+ const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
328972
+ if (!textCandidates.some((value) => value.includes(step.assertion))) {
328973
+ throw new Error(`Text assertion failed: expected "${step.assertion}" to be contained in ${JSON.stringify(textCandidates)}`);
328974
+ }
328975
+ }
328976
+ async function readElementTextCandidates(sessionId, elementId, actualText) {
328977
+ const values = [];
328978
+ const push = (value) => {
328979
+ if (typeof value !== "string") return;
328980
+ const trimmed = value.trim();
328981
+ if (trimmed.length === 0 || values.includes(trimmed)) return;
328982
+ values.push(trimmed);
328983
+ };
328984
+ push(actualText);
328985
+ for (const attr of MOBILE_TEXT_ATTRIBUTE_NAMES) {
328986
+ try {
328987
+ const result = await wdRequest(`/session/${sessionId}/element/${elementId}/attribute/${encodeURIComponent(attr)}`);
328988
+ push(result?.value);
328989
+ } catch (err) {
328990
+ if (isTransportError(err)) throw err;
328991
+ }
328155
328992
  }
328993
+ return values;
328156
328994
  }
328157
328995
  async function executeMobileTest(options) {
328158
328996
  const logLines = [];
@@ -328164,6 +329002,9 @@ async function executeMobileTest(options) {
328164
329002
  let sessionId = null;
328165
329003
  let capture = null;
328166
329004
  let captureStopped = false;
329005
+ let recordingStarted = false;
329006
+ let recordingStopped = false;
329007
+ let video;
328167
329008
  const stopCapture = async () => {
328168
329009
  if (!capture || captureStopped) return apiCalls;
328169
329010
  captureStopped = true;
@@ -328178,6 +329019,15 @@ async function executeMobileTest(options) {
328178
329019
  }
328179
329020
  return apiCalls;
328180
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
+ };
328181
329031
  try {
328182
329032
  log2("Checking Appium server health...");
328183
329033
  const health = await checkAppiumHealth();
@@ -328231,13 +329081,13 @@ async function executeMobileTest(options) {
328231
329081
  }
328232
329082
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
328233
329083
  try {
328234
- sessionId = await createSession({
329084
+ sessionId = await createSessionWithRetry({
328235
329085
  appId: options.target.appId,
328236
329086
  platform: options.target.platform,
328237
329087
  deviceId: selectedDevice.id,
328238
329088
  platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
328239
329089
  isPhysical: selectedDevice.isPhysical
328240
- });
329090
+ }, (msg) => log2(msg));
328241
329091
  } catch (err) {
328242
329092
  const base2 = err instanceof Error ? err.message : String(err);
328243
329093
  if (selectedDevice.isPhysical) {
@@ -328252,6 +329102,7 @@ async function executeMobileTest(options) {
328252
329102
  throw err;
328253
329103
  }
328254
329104
  log2(`Session created: ${sessionId}`);
329105
+ recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
328255
329106
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
328256
329107
  try {
328257
329108
  log2(`Opening deep link: ${options.target.deeplink}`);
@@ -328312,7 +329163,9 @@ async function executeMobileTest(options) {
328312
329163
  break;
328313
329164
  }
328314
329165
  }
328315
- 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
+ }
328316
329169
  } catch (err) {
328317
329170
  status = "failed";
328318
329171
  stepError = err instanceof Error ? err.message : String(err);
@@ -328341,10 +329194,12 @@ async function executeMobileTest(options) {
328341
329194
  const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
328342
329195
  const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
328343
329196
  await stopCapture();
329197
+ await stopRecording();
328344
329198
  return {
328345
329199
  passed: allPassed,
328346
329200
  stepResults,
328347
329201
  screenshots,
329202
+ ...video ? { video, videoMimeType: "video/mp4" } : {},
328348
329203
  apiCalls,
328349
329204
  logs: logLines.join("\n"),
328350
329205
  error: firstError,
@@ -328355,10 +329210,12 @@ async function executeMobileTest(options) {
328355
329210
  const errorMsg = err instanceof Error ? err.message : String(err);
328356
329211
  log2(`Fatal error: ${errorMsg}`);
328357
329212
  await stopCapture();
329213
+ await stopRecording();
328358
329214
  return {
328359
329215
  passed: false,
328360
329216
  stepResults,
328361
329217
  screenshots,
329218
+ ...video ? { video, videoMimeType: "video/mp4" } : {},
328362
329219
  apiCalls,
328363
329220
  logs: logLines.join("\n"),
328364
329221
  error: errorMsg,
@@ -328370,6 +329227,7 @@ async function executeMobileTest(options) {
328370
329227
  };
328371
329228
  } finally {
328372
329229
  await stopCapture();
329230
+ await stopRecording();
328373
329231
  if (sessionId) {
328374
329232
  log2("Tearing down Appium session...");
328375
329233
  await deleteSession(sessionId);
@@ -328410,7 +329268,7 @@ async function createMobileDiscoverySession(target, options) {
328410
329268
  isPhysical: selectedDevice.isPhysical === true,
328411
329269
  platformVersion: selectedDevice.platformVersion
328412
329270
  });
328413
- const sessionId = await createSession({
329271
+ const sessionId = await createSessionWithRetry({
328414
329272
  appId: target.appId,
328415
329273
  platform: target.platform,
328416
329274
  deviceId: selectedDevice.id,
@@ -329221,7 +330079,7 @@ function stopSessionReaper() {
329221
330079
  }
329222
330080
  }
329223
330081
  async function reapIdleSession() {
329224
- 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) {
329225
330083
  return;
329226
330084
  }
329227
330085
  const sessionId = state.appiumSessionId;
@@ -331738,6 +332596,11 @@ ${finalVerifyResult.logs ?? ""}`;
331738
332596
  });
331739
332597
  requestLiveBrowserHeartbeat();
331740
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),
331741
332604
  onExploreComplete,
331742
332605
  onTestGenerated,
331743
332606
  onPlanReady
@@ -332040,23 +332903,39 @@ ${finalVerifyResult.logs ?? ""}`;
332040
332903
  log.fail("ERROR - No mobile steps");
332041
332904
  return;
332042
332905
  }
332906
+ const maxRetries = typeof run2.retryCount === "number" && Number.isFinite(run2.retryCount) ? Math.max(0, Math.min(2, Math.floor(run2.retryCount))) : 0;
332043
332907
  setExecutorBusy(true);
332044
- let result2;
332908
+ let result2 = null;
332909
+ const attemptLogs = [];
332045
332910
  try {
332046
- result2 = await executeMobileTest({
332047
- steps: mobileSteps,
332048
- target: {
332049
- appId: target.appId,
332050
- platform: target.platform,
332051
- platformVersion: target.platformVersion,
332052
- recordedDeviceId: target.recordedDeviceId,
332053
- launchMode: target.launchMode,
332054
- deeplink: target.deeplink
332055
- }
332056
- });
332911
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
332912
+ if (attempt > 0) {
332913
+ log.info(`Retrying mobile test after failed Appium step (${attempt}/${maxRetries})`);
332914
+ }
332915
+ result2 = await executeMobileTest({
332916
+ steps: mobileSteps,
332917
+ target: {
332918
+ appId: target.appId,
332919
+ platform: target.platform,
332920
+ platformVersion: target.platformVersion,
332921
+ recordedDeviceId: target.recordedDeviceId,
332922
+ launchMode: target.launchMode,
332923
+ deeplink: target.deeplink
332924
+ }
332925
+ });
332926
+ attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
332927
+ ${result2.logs}`);
332928
+ if (result2.passed || result2.environmental || attempt >= maxRetries) break;
332929
+ }
332057
332930
  } finally {
332058
332931
  setExecutorBusy(false);
332059
332932
  }
332933
+ if (!result2) {
332934
+ throw new Error("Appium execution did not produce a result.");
332935
+ }
332936
+ if (attemptLogs.length > 1) {
332937
+ result2 = { ...result2, logs: attemptLogs.join("\n\n") };
332938
+ }
332060
332939
  const duration2 = Date.now() - startTime;
332061
332940
  const status = result2.environmental ? "ERROR" : result2.passed ? "PASSED" : "FAILED";
332062
332941
  const reportedError = result2.environmental ? `CONFIG_ISSUE: ${result2.environmental}` : result2.error ?? void 0;
@@ -332064,6 +332943,8 @@ ${finalVerifyResult.logs ?? ""}`;
332064
332943
  status,
332065
332944
  stepResults: result2.stepResults,
332066
332945
  screenshots: result2.screenshots,
332946
+ video: result2.video,
332947
+ videoMimeType: result2.videoMimeType,
332067
332948
  apiCalls: result2.apiCalls,
332068
332949
  logs: result2.logs,
332069
332950
  error: reportedError,
@@ -332375,7 +333256,7 @@ async function startRunner(opts) {
332375
333256
  };
332376
333257
  const sendHeartbeat = async () => {
332377
333258
  try {
332378
- const devices = enableMobile ? discoverAllDevices() : [];
333259
+ const devices = enableMobile ? getCachedDeviceSnapshot() : [];
332379
333260
  const capabilities = enableMobile ? {
332380
333261
  web: true,
332381
333262
  ios: devices.some((d) => d.platform === "IOS" && d.isAvailable && d.state === "BOOTED"),