@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.mjs CHANGED
@@ -238,6 +238,9 @@ var require_ai_provider = __commonJS({
238
238
  exports.getNoThinkingRequestOptions = getNoThinkingRequestOptions;
239
239
  exports.getAIClient = getAIClient;
240
240
  exports.getClientForModel = getClientForModel;
241
+ exports.shouldProxyAIThroughServerForLocalRunner = shouldProxyAIThroughServerForLocalRunner;
242
+ exports.getRunnerAIClient = getRunnerAIClient;
243
+ exports.getRunnerClientForModel = getRunnerClientForModel;
241
244
  exports.getModel = getModel;
242
245
  exports.resolveModelForTask = resolveModelForTask;
243
246
  exports.getAvailableModels = getAvailableModels;
@@ -268,14 +271,16 @@ var require_ai_provider = __commonJS({
268
271
  { 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 },
269
272
  { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", provider: "anthropic", description: "Anthropic - fast & intelligent, 200k ctx", contextWindow: 2e5 },
270
273
  { id: "nvidia-nemotron-nano", label: "NVIDIA Nemotron Nano", provider: "nvidia", description: "NVIDIA NIM - Nemotron 30B Nano, reasoning model, 128k ctx", contextWindow: 128e3 },
271
- { 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 }
274
+ { 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 },
275
+ { id: "deepseek-v4-flash", label: "DeepSeek V4 Flash", provider: "deepseek", description: "DeepSeek - V4 Flash, OpenAI-compatible, 1M ctx", contextWindow: 1e6 }
272
276
  ];
273
277
  var PROVIDER_DEFAULT_MODEL = {
274
278
  openai: "gpt-4o",
275
279
  xai: "grok-build-0.1",
276
280
  anthropic: "claude-sonnet-4-6",
277
281
  minimax: "minimax-m3",
278
- nvidia: "nvidia-nemotron-nano"
282
+ nvidia: "nvidia-nemotron-nano",
283
+ deepseek: "deepseek-v4-flash"
279
284
  };
280
285
  function getContextConfig(modelId) {
281
286
  const info = exports.AI_MODEL_CATALOG.find((m) => m.id === modelId);
@@ -326,7 +331,8 @@ var require_ai_provider = __commonJS({
326
331
  "minimax-m2.7-highspeed": { strong: "MiniMax-M2.7-highspeed", fast: "MiniMax-M2.7-highspeed", agent: "MiniMax-M2.7-highspeed" },
327
332
  "claude-sonnet-4-6": { strong: "claude-sonnet-4-6", fast: "claude-sonnet-4-6", agent: "claude-sonnet-4-6" },
328
333
  "nvidia-nemotron-nano": { strong: "nvidia/nemotron-3-nano-30b-a3b", fast: "nvidia/nemotron-3-nano-30b-a3b", agent: "nvidia/nemotron-3-nano-30b-a3b" },
329
- "nvidia-kimi-k2.6": { strong: "moonshotai/kimi-k2.6", fast: "moonshotai/kimi-k2.6", agent: "moonshotai/kimi-k2.6" }
334
+ "nvidia-kimi-k2.6": { strong: "moonshotai/kimi-k2.6", fast: "moonshotai/kimi-k2.6", agent: "moonshotai/kimi-k2.6" },
335
+ "deepseek-v4-flash": { strong: "deepseek-v4-flash", fast: "deepseek-v4-flash", agent: "deepseek-v4-flash" }
330
336
  };
331
337
  var _requestModelStorage = new async_hooks_1.AsyncLocalStorage();
332
338
  function withModelContext(modelId, fn) {
@@ -388,6 +394,9 @@ var require_ai_provider = __commonJS({
388
394
  if (modelId === "nvidia/nemotron-3-nano-30b-a3b") {
389
395
  return { chat_template_kwargs: { enable_thinking: false } };
390
396
  }
397
+ if (modelId === "deepseek-v4-flash") {
398
+ return { thinking: { type: "disabled" } };
399
+ }
391
400
  return {};
392
401
  }
393
402
  exports.MINIMAX_FALLBACK_CHAIN = ["MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7"];
@@ -489,6 +498,11 @@ var require_ai_provider = __commonJS({
489
498
  if (!apiKey)
490
499
  throw new Error("NVIDIA_API_KEY is required when using an NVIDIA model");
491
500
  client = new openai_1.default({ apiKey, baseURL: process.env.NVIDIA_API_BASE ?? "https://integrate.api.nvidia.com/v1" });
501
+ } else if (provider === "deepseek") {
502
+ const apiKey = process.env.DEEPSEEK_API_KEY;
503
+ if (!apiKey)
504
+ throw new Error("DEEPSEEK_API_KEY is required when using a DeepSeek model");
505
+ client = new openai_1.default({ apiKey, baseURL: process.env.DEEPSEEK_API_BASE ?? "https://api.deepseek.com" });
492
506
  } else {
493
507
  const apiKey = process.env.OPENAI_API_KEY;
494
508
  if (!apiKey)
@@ -504,6 +518,21 @@ var require_ai_provider = __commonJS({
504
518
  function getClientForModel(modelId) {
505
519
  return buildClientForProvider(getProviderForModel(modelId));
506
520
  }
521
+ function shouldProxyAIThroughServerForLocalRunner() {
522
+ const serverUrl = (process.env.SERVER_URL ?? "").trim();
523
+ const token = (process.env.AUTH_TOKEN || process.env.RUNNER_TOKEN || "").trim();
524
+ return serverUrl.length > 0 && token.startsWith("urt_");
525
+ }
526
+ function getRunnerAIClient() {
527
+ if (shouldProxyAIThroughServerForLocalRunner())
528
+ return getServerProxiedAIClient();
529
+ return getAIClient();
530
+ }
531
+ function getRunnerClientForModel(modelId) {
532
+ if (shouldProxyAIThroughServerForLocalRunner())
533
+ return getServerProxiedClientForModel(modelId);
534
+ return getClientForModel(modelId);
535
+ }
507
536
  function getModel(type) {
508
537
  return MODEL_TASK_MAP[getActiveModelId()][type];
509
538
  }
@@ -522,6 +551,8 @@ var require_ai_provider = __commonJS({
522
551
  return !!process.env.ANTHROPIC_API_KEY?.trim();
523
552
  if (provider === "nvidia")
524
553
  return !!process.env.NVIDIA_API_KEY?.trim();
554
+ if (provider === "deepseek")
555
+ return !!process.env.DEEPSEEK_API_KEY?.trim();
525
556
  return false;
526
557
  }
527
558
  function getAvailableModels() {
@@ -572,7 +603,7 @@ var require_ai_provider = __commonJS({
572
603
  const create = async (params, options) => {
573
604
  const modelId = typeof params.model === "string" ? params.model : "";
574
605
  if (!modelId) {
575
- throw new Error("server-proxied AI client requires params.model (catalog id)");
606
+ throw new Error("server-proxied AI client requires params.model");
576
607
  }
577
608
  const body = {
578
609
  modelId,
@@ -742,6 +773,21 @@ var require_mobile_source_parser = __commonJS({
742
773
  const attrText = inner.slice(tag.length);
743
774
  return { tag, attrs: parseAttributes(attrText), selfClosing };
744
775
  }
776
+ function findTagEnd(xml, from) {
777
+ let quote = null;
778
+ for (let i = from; i < xml.length; i++) {
779
+ const ch = xml[i];
780
+ if (quote !== null) {
781
+ if (ch === quote)
782
+ quote = null;
783
+ } else if (ch === '"' || ch === "'") {
784
+ quote = ch;
785
+ } else if (ch === ">") {
786
+ return i;
787
+ }
788
+ }
789
+ return -1;
790
+ }
745
791
  function tokenize(xml) {
746
792
  if (!xml || typeof xml !== "string")
747
793
  return null;
@@ -764,7 +810,7 @@ var require_mobile_source_parser = __commonJS({
764
810
  i = end === -1 ? len : end + 3;
765
811
  continue;
766
812
  }
767
- const gt = xml.indexOf(">", lt + 1);
813
+ const gt = findTagEnd(xml, lt + 1);
768
814
  if (gt === -1)
769
815
  break;
770
816
  const tagBody = xml.slice(lt + 1, gt);
@@ -1549,6 +1595,25 @@ function buildAppiumCode(testName, target, steps) {
1549
1595
  ` await driver.keys(text);`,
1550
1596
  ` }`,
1551
1597
  `}`,
1598
+ ``,
1599
+ `async function __clearFocused(driver: WebdriverIO.Browser) {`,
1600
+ ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).clearValue();`,
1601
+ `}`,
1602
+ ``,
1603
+ `async function __textCandidates(el: WebdriverIO.Element) {`,
1604
+ ` const __values: string[] = [];`,
1605
+ ` const __push = (__value: unknown) => {`,
1606
+ ` if (typeof __value !== 'string') return;`,
1607
+ ` const __trimmed = __value.trim();`,
1608
+ ` if (__trimmed.length === 0 || __values.includes(__trimmed)) return;`,
1609
+ ` __values.push(__trimmed);`,
1610
+ ` };`,
1611
+ ` __push(await el.getText().catch(() => ''));`,
1612
+ ` for (const __attr of ['text', 'content-desc', 'contentDescription', 'name', 'label', 'value']) {`,
1613
+ ` __push(await el.getAttribute(__attr).catch(() => ''));`,
1614
+ ` }`,
1615
+ ` return __values;`,
1616
+ `}`,
1552
1617
  ...hasSwipe ? [
1553
1618
  ``,
1554
1619
  `// Read the device window with a few retries (matches the runtime executor).`,
@@ -1628,6 +1693,12 @@ function buildAppiumCode(testName, target, steps) {
1628
1693
  lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1629
1694
  } else if (step.action === "clear" && hasSelector) {
1630
1695
  lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
1696
+ } else if (step.action === "clear" && bounds) {
1697
+ const x = Math.round(bounds.x + bounds.width / 2);
1698
+ const y = Math.round(bounds.y + bounds.height / 2);
1699
+ lines.push(` await __tapAt(driver, ${x}, ${y});`);
1700
+ lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1701
+ lines.push(` await __clearFocused(driver);`);
1631
1702
  } else if (step.action === "assertVisible" && hasSelector) {
1632
1703
  lines.push(` if (!(await (await findFirst(driver, ${candidatesLiteral})).isDisplayed())) {`);
1633
1704
  lines.push(` throw new Error('Assertion failed: element not displayed: ' + ${toJsStringLiteral(candidatesLiteral)});`);
@@ -1635,8 +1706,9 @@ function buildAppiumCode(testName, target, steps) {
1635
1706
  } else if (step.action === "assertText" && hasSelector) {
1636
1707
  lines.push(` {`);
1637
1708
  lines.push(` const __expected = ${toJsStringLiteral(step.assertion ?? "")};`);
1638
- lines.push(` const __actual = await (await findFirst(driver, ${candidatesLiteral})).getText();`);
1639
- lines.push(` if (!__actual.includes(__expected)) {`);
1709
+ lines.push(` if (__expected.length === 0) throw new Error('assertText step is missing expected text');`);
1710
+ lines.push(` const __actual = await __textCandidates(await findFirst(driver, ${candidatesLiteral}));`);
1711
+ lines.push(` if (!__actual.some((__value) => __value.includes(__expected))) {`);
1640
1712
  lines.push(` throw new Error('Assertion failed: expected text to contain ' + JSON.stringify(__expected) + ' but got ' + JSON.stringify(__actual));`);
1641
1713
  lines.push(` }`);
1642
1714
  lines.push(` }`);
@@ -4343,6 +4415,7 @@ var require_mobile_observation = __commonJS({
4343
4415
  Object.defineProperty(exports, "__esModule", { value: true });
4344
4416
  exports.renderMobileObservation = renderMobileObservation;
4345
4417
  exports.summarizeScreenForEvidence = summarizeScreenForEvidence;
4418
+ exports.buildCollectedElementsForScreen = buildCollectedElementsForScreen;
4346
4419
  var DEFAULT_MAX_ELEMENTS = 60;
4347
4420
  var MAX_LABEL_LENGTH = 80;
4348
4421
  var EVIDENCE_LABEL_LIMIT = 40;
@@ -4384,6 +4457,46 @@ var require_mobile_observation = __commonJS({
4384
4457
  }
4385
4458
  return { elementCount, actionableCount, labels };
4386
4459
  }
4460
+ function buildCollectedElementsForScreen(elements, opts = {}) {
4461
+ const navigationRefs = opts.navigationRefs ?? EMPTY_REF_SET;
4462
+ const out = [];
4463
+ const seen = /* @__PURE__ */ new Set();
4464
+ for (const element of elements) {
4465
+ if (!element.actionable)
4466
+ continue;
4467
+ const collected = toCollectedPageElement(element, navigationRefs.has(element.ref));
4468
+ const identity = `${collected.elementType}|${collected.testId ?? ""}|${collected.label ?? ""}`;
4469
+ if (seen.has(identity))
4470
+ continue;
4471
+ seen.add(identity);
4472
+ out.push(collected);
4473
+ }
4474
+ return out;
4475
+ }
4476
+ var EMPTY_REF_SET = /* @__PURE__ */ new Set();
4477
+ function toCollectedPageElement(element, triggersNavigation) {
4478
+ const label = element.label?.trim() || element.text?.trim() || void 0;
4479
+ const stableId = element.accessibilityId?.trim() || element.resourceId?.trim() || void 0;
4480
+ const locators = [];
4481
+ if (stableId)
4482
+ locators.push({ strategy: "getByTestId", value: stableId, confidence: 0.95 });
4483
+ const xpath = element.xpath?.trim();
4484
+ if (xpath)
4485
+ locators.push({ strategy: "css", value: xpath, confidence: 0.4 });
4486
+ const collected = {
4487
+ elementType: element.role || "View",
4488
+ triggersNavigation
4489
+ };
4490
+ if (label)
4491
+ collected.label = label;
4492
+ if (stableId)
4493
+ collected.testId = stableId;
4494
+ if (locators.length > 0)
4495
+ collected.locators = locators;
4496
+ if (element.enabled === false)
4497
+ collected.isDisabled = true;
4498
+ return collected;
4499
+ }
4387
4500
  function buildHeaderLine(screen) {
4388
4501
  const parts = ["screen:"];
4389
4502
  const name = screenDisplayName(screen.screenSignal);
@@ -4832,7 +4945,7 @@ var require_mobile_explorer = __commonJS({
4832
4945
  }
4833
4946
  return sections.join("\n");
4834
4947
  }
4835
- function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId) {
4948
+ function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId, existingScreens) {
4836
4949
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
4837
4950
  const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
4838
4951
  const surveyBody = `You are a mobile QA exploration agent driving a real ${platformLabel} app on a connected device. Your goal: MAP every reachable screen so downstream agents know what to test. You are NOT testing \u2014 you are building a complete screen map.
@@ -4878,13 +4991,18 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
4878
4991
  ## Application Brief
4879
4992
  ${appBrief}
4880
4993
  Use this to prioritize which screens and flows to cover.` : "";
4881
- return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}`;
4994
+ const existingScreensBlock = mode === "survey" && existingScreens && existingScreens.length > 0 ? `
4995
+
4996
+ ## ALREADY DISCOVERED SCREENS (skip these; find new)
4997
+ These screens were mapped in a previous survey. Do NOT re-map them \u2014 use your budget to find NEW screens and flows not listed here. If you land on one of these, move on to something new.
4998
+ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.name}"` : ""}${s.screenType ? ` [${s.screenType}]` : ""}`).join("\n")}` : "";
4999
+ return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}${existingScreensBlock}`;
4882
5000
  }
4883
5001
  function buildKickoffMessage(mode) {
4884
5002
  return mode === "survey" ? "Start by calling mobile_snapshot on the launch screen, then capture_screen. Map the entire app: open every tab, menu, and one row per list. Record transitions as you go." : "Start by calling mobile_snapshot on the launch screen. Then use the features: fill fields, submit forms, open menus, and observe outcomes. Record journeys after meaningful flows.";
4885
5003
  }
4886
5004
  async function runMobileExplorePhase(args) {
4887
- const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot } = args;
5005
+ const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot, onScreenshotUpload, existingScreens } = args;
4888
5006
  const logs2 = [];
4889
5007
  const log2 = (line) => {
4890
5008
  logs2.push(line);
@@ -4908,6 +5026,9 @@ Use this to prioritize which screens and flows to cover.` : "";
4908
5026
  log2(`Budget: ${maxIterations} iterations | app: ${ctx.target?.appId ?? "(unknown)"}`);
4909
5027
  const screenshots = [];
4910
5028
  const pageScreenshots = {};
5029
+ const pageScreenshotKeys = {};
5030
+ const uploadedScreenIds = /* @__PURE__ */ new Set();
5031
+ const pendingScreenshotUploads = [];
4911
5032
  const collectedTransitions = [];
4912
5033
  const journeys = [];
4913
5034
  const counters = {
@@ -4926,6 +5047,14 @@ Use this to prioritize which screens and flows to cover.` : "";
4926
5047
  screenshots.push(base64);
4927
5048
  if (screenId)
4928
5049
  pageScreenshots[screenId] = base64;
5050
+ if (screenId && onScreenshotUpload && !uploadedScreenIds.has(screenId)) {
5051
+ uploadedScreenIds.add(screenId);
5052
+ pendingScreenshotUploads.push(onScreenshotUpload(screenId, base64).then((key) => {
5053
+ if (key)
5054
+ pageScreenshotKeys[screenId] = key;
5055
+ }).catch(() => {
5056
+ }));
5057
+ }
4929
5058
  try {
4930
5059
  onScreenshot?.(base64);
4931
5060
  } catch {
@@ -5030,7 +5159,7 @@ Use this to prioritize which screens and flows to cover.` : "";
5030
5159
  return null;
5031
5160
  return { element, bounds: element.bounds };
5032
5161
  };
5033
- const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId);
5162
+ const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId, existingScreens);
5034
5163
  const kickoff = buildKickoffMessage(mode);
5035
5164
  const recentExchanges = [];
5036
5165
  const transientDirectives = [];
@@ -5238,6 +5367,9 @@ Exploration complete: ${summary}`);
5238
5367
  summary = `Explore phase aborted (${truncatedReason}) after mapping ${state2.getScreenCount()} screen(s).`;
5239
5368
  }
5240
5369
  }
5370
+ if (pendingScreenshotUploads.length > 0) {
5371
+ await Promise.allSettled(pendingScreenshotUploads);
5372
+ }
5241
5373
  const collectedPages = state2.buildInMemoryScreenMap();
5242
5374
  counters.pagesVisited = collectedPages.length;
5243
5375
  if (counters.pagesDiscovered < collectedPages.length) {
@@ -5272,6 +5404,9 @@ Exploration complete: ${summary}`);
5272
5404
  if (Object.keys(pageScreenshots).length > 0) {
5273
5405
  result.pageScreenshots = pageScreenshots;
5274
5406
  }
5407
+ if (Object.keys(pageScreenshotKeys).length > 0) {
5408
+ result.pageScreenshotKeys = pageScreenshotKeys;
5409
+ }
5275
5410
  if (truncated) {
5276
5411
  result.truncated = true;
5277
5412
  result.truncatedReason = truncatedReason;
@@ -5773,6 +5908,44 @@ var require_snapshot_parser = __commonJS({
5773
5908
  };
5774
5909
  var SUBMIT_LABEL_RE = /^(submit|sign\s*in|sign\s*up|log\s*in|register|create|save|send|confirm|continue|next|apply|post|publish|add|update|go)$/i;
5775
5910
  var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
5911
+ var RICH_CONTEXT_ROLES = /* @__PURE__ */ new Set(["table", "grid", "row", "radiogroup"]);
5912
+ var RICH_CONTEXT_LABEL_MAX = 60;
5913
+ var VALUE_BEARING_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "combobox", "spinbutton", "listbox", "slider"]);
5914
+ var SENSITIVE_VALUE_LABEL_RE = /\bpass(?:word|code|phrase)?\b|\bpassport\b|\bpwd\b|\bsecrets?\b|\btokens?\b|\botp\b|one[- ]?time|\bpin\b|\bcvv\b|\bcvc\b|card\s*number|\bssn\b|social\s*security|security\s*(?:answer|question)|api[-_ ]?key|private\s*key|\bauth\b|authoriz|authenticat|credential/i;
5915
+ var REDACTED_VALUE = "[redacted]";
5916
+ var VALUE_TEXT_MAX = 120;
5917
+ var SIGNAL_TEXT_RE = /\b(?:showing|displaying|page)\b[\s\S]*\b(?:of|results?|items?|entries)\b|\b(?:no|nothing)\b[\s\S]*\b(?:found|yet|available|results?|items?)\b|\bempty\b|\b(?:error|invalid|required|failed|expired?|success(?:fully)?|saved|updated|deleted|created|welcome)\b/i;
5918
+ var STATIC_MESSAGES_MAX = 12;
5919
+ var OPTIONS_PER_ELEMENT_MAX = 24;
5920
+ var URL_CHILD_LINE_RE = /^(\s*)- \/url:\s*(.+)$/;
5921
+ var MESSAGE_TOKEN_RE = /\b(?:sk|pk|rk|ak|key|tok)[-_][A-Za-z0-9_-]{8,}\b|\b[A-Za-z0-9_-]{24,}\b/g;
5922
+ function stripYamlQuotes(raw) {
5923
+ const trimmed = raw.trim();
5924
+ if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
5925
+ return trimmed.slice(1, -1);
5926
+ }
5927
+ return trimmed;
5928
+ }
5929
+ function extractTrailingText(attrs) {
5930
+ const lastBracket = attrs.lastIndexOf("]");
5931
+ const tail = lastBracket >= 0 ? attrs.slice(lastBracket + 1) : attrs;
5932
+ const match = /^\s*:\s+(.+)$/.exec(tail);
5933
+ if (!match)
5934
+ return null;
5935
+ const text = stripYamlQuotes(match[1]);
5936
+ if (!text || /^(?:\||\|-|>|>-)$/.test(text))
5937
+ return null;
5938
+ return text;
5939
+ }
5940
+ function isSensitiveValueField(name, placeholder) {
5941
+ return SENSITIVE_VALUE_LABEL_RE.test(`${name ?? ""} ${placeholder ?? ""}`);
5942
+ }
5943
+ function scrubMessageText(text) {
5944
+ return text.replace(MESSAGE_TOKEN_RE, REDACTED_VALUE);
5945
+ }
5946
+ function capText(text, max) {
5947
+ return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
5948
+ }
5776
5949
  function isProbablyDynamicTestId(testId) {
5777
5950
  if (testId.length < 6)
5778
5951
  return false;
@@ -5848,9 +6021,12 @@ var require_snapshot_parser = __commonJS({
5848
6021
  }
5849
6022
  return e.name;
5850
6023
  }
5851
- function parseSnapshot(snapshotText) {
6024
+ function parseSnapshot(snapshotText, options = {}) {
6025
+ const richParse = options.richParse ?? process.env.DISCOVERY_SNAPSHOT_RICH_PARSE === "true";
5852
6026
  const lines = snapshotText.split("\n");
5853
6027
  const elements = [];
6028
+ const liveMessages = [];
6029
+ const textMessages = [];
5854
6030
  const seenRefs = /* @__PURE__ */ new Set();
5855
6031
  let parsedElementLineCount = 0;
5856
6032
  let genericLabelCount = 0;
@@ -5859,6 +6035,21 @@ var require_snapshot_parser = __commonJS({
5859
6035
  const contextStack = [];
5860
6036
  const completedForms = [];
5861
6037
  for (const line of lines) {
6038
+ if (richParse) {
6039
+ const urlMatch = URL_CHILD_LINE_RE.exec(line);
6040
+ if (urlMatch) {
6041
+ const urlIndent = urlMatch[1].length;
6042
+ for (let i = elements.length - 1; i >= 0; i--) {
6043
+ const el = elements[i];
6044
+ if (el.indentDepth < urlIndent) {
6045
+ if (el.role === "link" && !el.href)
6046
+ el.href = capText(stripYamlQuotes(urlMatch[2]), 300);
6047
+ break;
6048
+ }
6049
+ }
6050
+ continue;
6051
+ }
6052
+ }
5862
6053
  const match = ELEMENT_LINE_RE.exec(line);
5863
6054
  if (!match)
5864
6055
  continue;
@@ -5880,7 +6071,7 @@ var require_snapshot_parser = __commonJS({
5880
6071
  }
5881
6072
  const headingLevelMatch = LEVEL_RE.exec(attrs);
5882
6073
  const headingLevel = headingLevelMatch ? Number.parseInt(headingLevelMatch[1], 10) : null;
5883
- const contextLabel = buildContextLabel(role, name ?? null, headingLevel);
6074
+ const contextLabel = buildContextLabel(role, name ?? null, headingLevel, richParse);
5884
6075
  if (contextLabel) {
5885
6076
  contextStack.push({ indent: indentDepth, label: contextLabel });
5886
6077
  }
@@ -5893,6 +6084,40 @@ var require_snapshot_parser = __commonJS({
5893
6084
  });
5894
6085
  continue;
5895
6086
  }
6087
+ if (richParse) {
6088
+ if (role === "option" && name) {
6089
+ for (let i = elements.length - 1; i >= 0; i--) {
6090
+ const el = elements[i];
6091
+ if (el.indentDepth < indentDepth) {
6092
+ if (el.role === "combobox" || el.role === "listbox") {
6093
+ el.options ??= [];
6094
+ if (el.options.length < OPTIONS_PER_ELEMENT_MAX) {
6095
+ el.options.push({ label: capText(name, 80), selected: parseBooleanState(SELECTED_RE.exec(attrs)) === true });
6096
+ }
6097
+ }
6098
+ break;
6099
+ }
6100
+ }
6101
+ }
6102
+ if (role === "alert" || role === "status" || role === "paragraph" || role === "text") {
6103
+ const text = extractTrailingText(attrs) ?? (name || null);
6104
+ const isLiveRegion = role === "alert" || role === "status";
6105
+ if (text && (isLiveRegion || SIGNAL_TEXT_RE.test(text))) {
6106
+ const capped = capText(scrubMessageText(text), VALUE_TEXT_MAX);
6107
+ const ref2 = REF_RE.exec(attrs)?.[1] ?? null;
6108
+ if (isLiveRegion) {
6109
+ const textDupIndex = textMessages.findIndex((m) => m.text === capped);
6110
+ if (textDupIndex >= 0)
6111
+ textMessages.splice(textDupIndex, 1);
6112
+ if (liveMessages.length < STATIC_MESSAGES_MAX && !liveMessages.some((m) => m.text === capped)) {
6113
+ liveMessages.push({ role, text: capped, ref: ref2 });
6114
+ }
6115
+ } else if (textMessages.length < STATIC_MESSAGES_MAX && !textMessages.some((m) => m.text === capped) && !liveMessages.some((m) => m.text === capped)) {
6116
+ textMessages.push({ role: "text", text: capped, ref: ref2 });
6117
+ }
6118
+ }
6119
+ }
6120
+ }
5896
6121
  if (!INTERACTIVE_ROLES.has(role))
5897
6122
  continue;
5898
6123
  const refMatch = REF_RE.exec(attrs);
@@ -5925,6 +6150,13 @@ var require_snapshot_parser = __commonJS({
5925
6150
  }
5926
6151
  const isLink = role === "link";
5927
6152
  const isSubmitButton = role === "button" && name != null && SUBMIT_LABEL_RE.test(name);
6153
+ let value = null;
6154
+ if (richParse && VALUE_BEARING_ROLES.has(role)) {
6155
+ const trailing = extractTrailingText(attrs);
6156
+ if (trailing) {
6157
+ value = isSensitiveValueField(name ?? null, placeholder) ? REDACTED_VALUE : capText(trailing, VALUE_TEXT_MAX);
6158
+ }
6159
+ }
5928
6160
  const element = {
5929
6161
  ref,
5930
6162
  role,
@@ -5946,7 +6178,10 @@ var require_snapshot_parser = __commonJS({
5946
6178
  pressed,
5947
6179
  headingLevel,
5948
6180
  currentState,
5949
- containerPath
6181
+ containerPath,
6182
+ href: null,
6183
+ value,
6184
+ options: null
5950
6185
  };
5951
6186
  if (isGenericLabel(element.name))
5952
6187
  genericLabelCount++;
@@ -5974,6 +6209,9 @@ var require_snapshot_parser = __commonJS({
5974
6209
  return {
5975
6210
  elements,
5976
6211
  formGroups: completedForms,
6212
+ // live regions first: they carry their own reservation so an end-of-body toast can
6213
+ // never be starved out by signal-matching plain text earlier in the document.
6214
+ staticMessages: [...liveMessages, ...textMessages].slice(0, STATIC_MESSAGES_MAX),
5977
6215
  rawLineCount: lines.length,
5978
6216
  parsedElementLineCount,
5979
6217
  genericLabelCount,
@@ -6084,12 +6322,17 @@ var require_snapshot_parser = __commonJS({
6084
6322
  return "mixed";
6085
6323
  return false;
6086
6324
  }
6087
- function buildContextLabel(role, name, headingLevel) {
6325
+ function buildContextLabel(role, name, headingLevel, richContext = false) {
6088
6326
  if (role === "heading") {
6089
6327
  if (!name)
6090
6328
  return null;
6091
6329
  return headingLevel ? `heading${headingLevel}:${name}` : `heading:${name}`;
6092
6330
  }
6331
+ if (richContext && RICH_CONTEXT_ROLES.has(role)) {
6332
+ if (!name)
6333
+ return null;
6334
+ return `${role}:${capText(name.replace(/>/g, "\u203A"), RICH_CONTEXT_LABEL_MAX)}`;
6335
+ }
6093
6336
  if (!CONTEXT_ROLES.has(role))
6094
6337
  return null;
6095
6338
  if (name)
@@ -7799,7 +8042,7 @@ Each entry is exactly one of:
7799
8042
  const systemPrompt = options.systemPromptOverride ?? (context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports.SCENARIO_SYSTEM_PROMPT);
7800
8043
  const userPrompt = options.userPromptOverride ?? formatEvidenceForPlanner(compressed, evidence, context);
7801
8044
  logs2.push(`AI planner: ~${Math.ceil(userPrompt.length / 3.5)} input tokens`);
7802
- const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)());
8045
+ const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
7803
8046
  const messages = [
7804
8047
  { role: "system", content: systemPrompt },
7805
8048
  { role: "user", content: userPrompt }
@@ -7807,18 +8050,22 @@ Each entry is exactly one of:
7807
8050
  const callStart = Date.now();
7808
8051
  let response;
7809
8052
  try {
7810
- response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => aiClient.chat.completions.create({
7811
- model: agentModel,
7812
- messages,
7813
- max_tokens: plannerMaxTokens,
7814
- temperature: 0.3,
7815
- ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
7816
- ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
7817
- })), {
8053
+ response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
8054
+ arm();
8055
+ return aiClient.chat.completions.create({
8056
+ model: agentModel,
8057
+ messages,
8058
+ max_tokens: plannerMaxTokens,
8059
+ temperature: 0.3,
8060
+ ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
8061
+ ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
8062
+ }, { signal });
8063
+ }), {
7818
8064
  label: "ai-planner",
7819
8065
  baseDelayMs: 5e3,
7820
8066
  maxRetries: 2,
7821
- onRetry: (attempt, status, delay) => logs2.push(`AI planner call failed (${status ?? "error"}) \u2014 retry ${attempt}/2 in ${Math.round(delay / 1e3)}s`)
8067
+ deferTimeoutUntilArmed: true,
8068
+ onRetry: (attempt, reason, delay) => logs2.push(`AI planner call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
7822
8069
  });
7823
8070
  } catch (err) {
7824
8071
  const message = err instanceof Error ? err.message : String(err);
@@ -7884,18 +8131,25 @@ Each entry is exactly one of:
7884
8131
  const retryStart = Date.now();
7885
8132
  let retryResponse = null;
7886
8133
  try {
7887
- retryResponse = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => aiClient.chat.completions.create({
7888
- model: agentModel,
7889
- messages: [
7890
- ...messages,
7891
- { role: "assistant", content: raw },
7892
- { role: "user", content: retryContext }
7893
- ],
7894
- max_tokens: plannerMaxTokens,
7895
- temperature: 0.3,
7896
- ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
7897
- ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
7898
- })), { label: "ai-planner-retry" });
8134
+ retryResponse = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
8135
+ arm();
8136
+ return aiClient.chat.completions.create({
8137
+ model: agentModel,
8138
+ messages: [
8139
+ ...messages,
8140
+ { role: "assistant", content: raw },
8141
+ { role: "user", content: retryContext }
8142
+ ],
8143
+ max_tokens: plannerMaxTokens,
8144
+ temperature: 0.3,
8145
+ ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
8146
+ ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
8147
+ }, { signal });
8148
+ }), {
8149
+ label: "ai-planner-retry",
8150
+ deferTimeoutUntilArmed: true,
8151
+ onRetry: (attempt, reason, delay) => logs2.push(`AI planner retry call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
8152
+ });
7899
8153
  } catch (err) {
7900
8154
  const message = err instanceof Error ? err.message : String(err);
7901
8155
  logs2.push(`AI planner retry failed (${message}) \u2014 using first-pass result`);
@@ -8423,12 +8677,12 @@ ${constraints.join("\n")}`);
8423
8677
  const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8424
8678
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
8425
8679
  for (const state2 of orderedScreens) {
8426
- const groupName = state2.screenType === "auth" ? "Authentication" : screenDisplayName(state2);
8680
+ const groupName = state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(state2);
8427
8681
  const group = groups.get(groupName) ?? {
8428
8682
  name: groupName,
8429
8683
  description: `Native mobile screens related to ${groupName}.`,
8430
8684
  routes: [],
8431
- criticality: state2.screenType === "auth" ? "critical" : "medium",
8685
+ criticality: state2.screenType?.toLowerCase() === "auth" ? "critical" : "medium",
8432
8686
  pages: [],
8433
8687
  flows: []
8434
8688
  };
@@ -8451,7 +8705,7 @@ ${constraints.join("\n")}`);
8451
8705
  const to = graph.screens.get(transition.toScreenId);
8452
8706
  if (!from || !to)
8453
8707
  continue;
8454
- const groupName = from.screenType === "auth" ? "Authentication" : screenDisplayName(from);
8708
+ const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(from);
8455
8709
  const group = groups.get(groupName);
8456
8710
  if (!group)
8457
8711
  continue;
@@ -8496,9 +8750,9 @@ ${constraints.join("\n")}`);
8496
8750
  scenarios.push({
8497
8751
  name,
8498
8752
  type: "HAPPY_PATH",
8499
- priority: state2.screenType === "auth" ? 1 : 3,
8753
+ priority: state2.screenType?.toLowerCase() === "auth" ? 1 : 3,
8500
8754
  variant: "happy",
8501
- featureGroup: state2.screenType === "auth" ? "Authentication" : screenName,
8755
+ featureGroup: state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenName,
8502
8756
  goal: `User can reach and inspect the ${screenName} screen`,
8503
8757
  steps,
8504
8758
  expectedOutcomes: [{
@@ -8644,6 +8898,7 @@ var require_mobile_generator = __commonJS({
8644
8898
  var mobile_observation_js_1 = require_mobile_observation();
8645
8899
  var mobile_boundary_js_1 = require_mobile_boundary();
8646
8900
  var MAX_SCENARIO_ITERATIONS = 30;
8901
+ var MAX_CONSECUTIVE_INFRA_FAILURES = 3;
8647
8902
  var MAX_RECENT_EXCHANGES = 24;
8648
8903
  var OBSERVATION_MAX_ELEMENTS = 50;
8649
8904
  function finalizeAction(input) {
@@ -9368,14 +9623,31 @@ ${statePacket}` },
9368
9623
  if (!snapshot || snapshot.screen.elements.length === 0)
9369
9624
  return null;
9370
9625
  const elements = snapshot.screen.elements;
9371
- const withStableId = elements.filter((el) => hasStableId(el));
9372
- const nonButton = withStableId.find((el) => !/button/i.test(el.role));
9626
+ const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
9627
+ const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
9628
+ if (contentfulNonButton)
9629
+ return contentfulNonButton;
9630
+ const nonButton = stableAnchors.find((el) => !/button/i.test(el.role));
9373
9631
  if (nonButton)
9374
9632
  return nonButton;
9375
- if (withStableId.length > 0)
9376
- return withStableId[0];
9377
- const labelled = elements.find((el) => nonEmpty2(el.label) || nonEmpty2(el.text));
9378
- return labelled ?? elements[0];
9633
+ if (stableAnchors.length > 0)
9634
+ return stableAnchors[0];
9635
+ const labelled = elements.find((el) => !isAlwaysPresentContainer(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
9636
+ return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
9637
+ }
9638
+ var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
9639
+ var ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS = /* @__PURE__ */ new Set([
9640
+ "android:id/content",
9641
+ "android:id/decor",
9642
+ "android:id/action_bar_root",
9643
+ "android:id/statusBarBackground",
9644
+ "android:id/navigationBarBackground"
9645
+ ]);
9646
+ function isAlwaysPresentContainer(element) {
9647
+ if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
9648
+ return true;
9649
+ const resourceId = element.resourceId?.trim();
9650
+ return !!resourceId && ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS.has(resourceId);
9379
9651
  }
9380
9652
  function appendHybridFloor(actions, recording, platform3) {
9381
9653
  const anchor = pickLandingAnchor(recording.lastSnapshot);
@@ -9434,7 +9706,7 @@ ${statePacket}` },
9434
9706
  return test;
9435
9707
  }
9436
9708
  async function runMobileGeneratePhase(args) {
9437
- const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot } = args;
9709
+ const { scenarios: allScenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot, skipScenarioNames, onInfraStop } = args;
9438
9710
  const log2 = (line) => {
9439
9711
  try {
9440
9712
  onLog?.(line);
@@ -9449,8 +9721,16 @@ ${statePacket}` },
9449
9721
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
9450
9722
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
9451
9723
  const targetAppId = ctx.target?.appId?.trim() || void 0;
9724
+ const skipSet = new Set(skipScenarioNames ?? []);
9725
+ const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
9726
+ if (skipSet.size > 0) {
9727
+ log2(`Resume: skipping ${allScenarios.length - scenarios.length} already-generated scenario(s); ${scenarios.length} remaining to generate`);
9728
+ }
9452
9729
  log2(`Mobile generator starting \u2014 platform: ${platform3}, provider: ${provider}, model: ${agentModel}, scenarios: ${scenarios.length}`);
9453
9730
  const tests = [];
9731
+ const generatedScenarioNames = /* @__PURE__ */ new Set();
9732
+ let consecutiveInfraFailures = 0;
9733
+ let infraStopped = false;
9454
9734
  for (let i = 0; i < scenarios.length; i++) {
9455
9735
  const scenario = scenarios[i];
9456
9736
  log2(`
@@ -9471,6 +9751,7 @@ ${statePacket}` },
9471
9751
  targetAppId,
9472
9752
  log: log2
9473
9753
  });
9754
+ consecutiveInfraFailures = 0;
9474
9755
  const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
9475
9756
  if (!test) {
9476
9757
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
@@ -9487,13 +9768,31 @@ ${statePacket}` },
9487
9768
  log2(` onTestGenerated callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
9488
9769
  }
9489
9770
  tests.push(test);
9771
+ generatedScenarioNames.add(scenario.name);
9490
9772
  } catch (err) {
9773
+ if ((0, ai_retry_js_1.isAIProviderError)(err)) {
9774
+ consecutiveInfraFailures += 1;
9775
+ log2(` scenario "${scenario.name}" failed \u2014 AI provider error (${consecutiveInfraFailures}/${MAX_CONSECUTIVE_INFRA_FAILURES}): ${err instanceof Error ? err.message : String(err)}`);
9776
+ if (consecutiveInfraFailures >= MAX_CONSECUTIVE_INFRA_FAILURES) {
9777
+ const remainingScenarioNames = scenarios.filter((s) => !generatedScenarioNames.has(s.name)).map((s) => s.name);
9778
+ infraStopped = true;
9779
+ log2(`
9780
+ \u26D4 Stopping mobile generation: ${consecutiveInfraFailures} consecutive AI provider failures \u2014 the provider appears overloaded/unavailable. ${tests.length}/${scenarios.length} tests generated and saved; the remaining ${remainingScenarioNames.length} scenario(s) will resume later.`);
9781
+ try {
9782
+ onInfraStop?.({ remainingScenarioNames, reason: "ai_rate_limit" });
9783
+ } catch (cbErr) {
9784
+ log2(` onInfraStop callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
9785
+ }
9786
+ break;
9787
+ }
9788
+ continue;
9789
+ }
9491
9790
  log2(` scenario "${scenario.name}" failed: ${err instanceof Error ? err.message : String(err)} \u2014 skipping.`);
9492
9791
  continue;
9493
9792
  }
9494
9793
  }
9495
9794
  log2(`
9496
- Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified).`);
9795
+ Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified)${infraStopped ? " \u2014 PAUSED on AI provider overload" : ""}.`);
9497
9796
  return tests;
9498
9797
  }
9499
9798
  }
@@ -12772,6 +13071,10 @@ var require_snapshot_observation = __commonJS({
12772
13071
  var snapshot_parser_js_1 = require_snapshot_parser();
12773
13072
  var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
12774
13073
  var NOISE_LABEL_RE = /^(skip\s+to|skip\s+nav|back\s+to\s+top|cookie|accept\s+(all\s+)?cookies)$/i;
13074
+ var DIALOG_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog\b/i;
13075
+ var FORM_SEGMENT_RE = /(?:^|>\s*)form\b/i;
13076
+ var TABPANEL_SEGMENT_RE = /(?:^|>\s*)tabpanel\b/i;
13077
+ var DIALOG_NAME_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog:([^>]+)/i;
12775
13078
  var INTERACTIVE_ROLE_RE = /^(button|link|textbox|searchbox|checkbox|radio|switch|combobox|listbox|option|menuitem|tab|slider|spinbutton|textarea)$/i;
12776
13079
  var INTERACTIVE_ELEMENT_TYPE_RE = /^(button|link|input|textarea|select|checkbox|radio|switch|tab|combobox|listbox|option|menuitem|slider|spinbutton)$/i;
12777
13080
  function renderRetrievedSnapshotMemoryBlock(priorSnapshotEvidence, priorDiscoveryCheckpoints, options = {}) {
@@ -13016,10 +13319,12 @@ var require_snapshot_observation = __commonJS({
13016
13319
  summary
13017
13320
  }));
13018
13321
  }
13322
+ var TEXT_DISAMBIGUATING_INPUT_TYPES = /* @__PURE__ */ new Set(["password", "text", "email", "tel", "number", "search", "url", "date", "datetime-local", "time", "month", "week"]);
13019
13323
  function renderCompactSnapshotBlock(page, context = {}, options = {}) {
13020
13324
  const metrics = page.snapshotMetrics ?? page.snapshotArtifact?.metrics;
13021
13325
  const sourceKind = page.snapshotMeta?.sourceKind ?? page.snapshotArtifact?.sourceKind ?? "missing";
13022
13326
  const artifact = page.snapshotArtifact;
13327
+ const richRender = options.richRender ?? process.env.DISCOVERY_SNAPSHOT_RICH_RENDER === "true";
13023
13328
  const activeScope = inferActiveSnapshotScope(page);
13024
13329
  const ranked = rankSnapshotElements(getScopedSnapshotElements(page, activeScope), withScopeContext(context, activeScope));
13025
13330
  const allInteractive = ranked.filter(({ element }) => isInteractiveCandidate(element)).filter(({ element }) => {
@@ -13065,7 +13370,7 @@ var require_snapshot_observation = __commonJS({
13065
13370
  if (activeScope.rootRef) {
13066
13371
  lines.push(` root_ref: ${yamlScalar(activeScope.rootRef)}`);
13067
13372
  }
13068
- const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText);
13373
+ const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText, richRender);
13069
13374
  if (scopePreview.length > 0) {
13070
13375
  lines.push(" scope_snapshot_preview: |");
13071
13376
  for (const previewLine of scopePreview) {
@@ -13107,6 +13412,19 @@ var require_snapshot_observation = __commonJS({
13107
13412
  lines.push(` - ${yamlScalar(modal)}`);
13108
13413
  }
13109
13414
  }
13415
+ const staticMessages = page.staticMessages ?? [];
13416
+ if (richRender && staticMessages.length > 0) {
13417
+ const messageLimit = 6;
13418
+ const ordered = [...staticMessages].sort((a, b) => messageRolePriority(a.role) - messageRolePriority(b.role));
13419
+ lines.push("messages:");
13420
+ lines.push(" # Non-interactive page text worth asserting on (validation / toast / empty-state / counts). Text is the capture instant \u2014 toasts may have dismissed since.");
13421
+ for (const message of ordered.slice(0, messageLimit)) {
13422
+ lines.push(` - [${message.role}] ${yamlScalar(message.text)}`);
13423
+ }
13424
+ if (ordered.length > messageLimit) {
13425
+ lines.push(` # +${ordered.length - messageLimit} more message(s) not listed`);
13426
+ }
13427
+ }
13110
13428
  lines.push("session_state:");
13111
13429
  if (page.sessionState) {
13112
13430
  lines.push(` authenticated: ${yamlScalar(page.sessionState.authenticated)}`);
@@ -13169,7 +13487,10 @@ var require_snapshot_observation = __commonJS({
13169
13487
  }
13170
13488
  const hasRowContext = group.rowContexts.some((rc) => !!rc);
13171
13489
  if (hasRowContext) {
13172
- lines.push(' distinguish_by_row: # same name in different rows \u2014 scope by this text, e.g. getByRole(role, { name }).filter({ has: getByText("<row>") })');
13490
+ const firstRowRole = group.rowRoles[0] ?? null;
13491
+ const rowRole = firstRowRole && group.rowRoles.every((r) => r === firstRowRole) ? firstRowRole : null;
13492
+ const scopeHint = rowRole ? `getByRole(${yamlScalar(rowRole)}).filter({ has: getByText("<row>", { exact: true }) }).getByRole(${yamlScalar(group.role)}, { name: ${yamlScalar(group.name)} })` : `locate the container by its text (getByText("<row>", { exact: true })), then getByRole(${yamlScalar(group.role)}, { name: ${yamlScalar(group.name)} }) within it`;
13493
+ lines.push(` distinguish_by_row: # same name across rows \u2014 scope by the ROW/card container (not the control); nested same-role containers need an extra cell-level scope: ${scopeHint}`);
13173
13494
  for (let i = 0; i < group.rowContexts.length; i++) {
13174
13495
  const rowContext = group.rowContexts[i];
13175
13496
  if (!rowContext)
@@ -13199,8 +13520,13 @@ var require_snapshot_observation = __commonJS({
13199
13520
  lines.push(` placeholder: ${yamlScalar(rankedElement.element.placeholder)}`);
13200
13521
  }
13201
13522
  const stateTokens = buildStateSummary(rankedElement.element);
13523
+ const addedDisabled = richRender && !!rankedElement.element.isDisabled && !stateTokens.includes("disabled");
13524
+ if (addedDisabled) {
13525
+ stateTokens.unshift("disabled");
13526
+ }
13202
13527
  if (stateTokens.length > 0) {
13203
- lines.push(` state: [${stateTokens.map((token) => yamlScalar(token)).join(", ")}]`);
13528
+ const note = addedDisabled ? " # disabled = captured instant; may enable after valid input \u2014 still exercise fill\u2192submit" : "";
13529
+ lines.push(` state: [${stateTokens.map((token) => yamlScalar(token)).join(", ")}]${note}`);
13204
13530
  }
13205
13531
  if (rankedElement.element.containerPath) {
13206
13532
  lines.push(` container_path: ${yamlScalar(rankedElement.element.containerPath)}`);
@@ -13210,6 +13536,22 @@ var require_snapshot_observation = __commonJS({
13210
13536
  lines.push(` locator: ${yamlScalar((0, snapshot_parser_js_1.formatLocator)(topLocator))}`);
13211
13537
  }
13212
13538
  const enr = rankedElement.element.enrichment;
13539
+ if (richRender && enr?.inputType && TEXT_DISAMBIGUATING_INPUT_TYPES.has(enr.inputType)) {
13540
+ lines.push(` type: ${yamlScalar(enr.inputType)}`);
13541
+ }
13542
+ if (richRender && rankedElement.element.href) {
13543
+ lines.push(` url: ${yamlScalar(rankedElement.element.href)}`);
13544
+ }
13545
+ if (richRender && rankedElement.element.value && enr?.inputType !== "password") {
13546
+ lines.push(` value: ${yamlScalar(rankedElement.element.value)}`);
13547
+ }
13548
+ const elementOptions = rankedElement.element.options ?? [];
13549
+ if (richRender && elementOptions.length > 0) {
13550
+ const optionLimit = 12;
13551
+ const rendered = elementOptions.slice(0, optionLimit).map((option) => yamlScalar(option.selected ? `${option.label} (selected)` : option.label));
13552
+ const overflow = elementOptions.length > optionLimit ? ` # +${elementOptions.length - optionLimit} more option(s)` : "";
13553
+ lines.push(` options: [${rendered.join(", ")}]${overflow}`);
13554
+ }
13213
13555
  if (enr?.rowContext) {
13214
13556
  lines.push(` row_context: ${yamlScalar(enr.rowContext)}`);
13215
13557
  }
@@ -13222,6 +13564,12 @@ var require_snapshot_observation = __commonJS({
13222
13564
  lines.push(` visibility: [${vis.map((token) => yamlScalar(token)).join(", ")}]`);
13223
13565
  }
13224
13566
  }
13567
+ if (richRender) {
13568
+ const hidden = allInteractive.length - interactive.length;
13569
+ if (hidden > 0) {
13570
+ lines.push(` # +${hidden} more interactive element(s) not individually listed (capped) \u2014 some may appear in the duplicates block above; if your target isn't here, narrow the scope or act on it by ref.`);
13571
+ }
13572
+ }
13225
13573
  }
13226
13574
  const recoveredClickables = options.recoveredClickables ?? [];
13227
13575
  if (recoveredClickables.length > 0) {
@@ -13378,11 +13726,11 @@ var require_snapshot_observation = __commonJS({
13378
13726
  score += 10;
13379
13727
  if (element.isExpanded)
13380
13728
  score += 8;
13381
- if (element.containerPath?.includes("dialog"))
13729
+ if (element.containerPath && DIALOG_SEGMENT_RE.test(element.containerPath))
13382
13730
  score += 12;
13383
- if (element.containerPath?.includes("form"))
13731
+ if (element.containerPath && FORM_SEGMENT_RE.test(element.containerPath))
13384
13732
  score += 10;
13385
- if (element.containerPath?.includes("tabpanel"))
13733
+ if (element.containerPath && TABPANEL_SEGMENT_RE.test(element.containerPath))
13386
13734
  score += 8;
13387
13735
  if (element.inputType === "email" || element.inputType === "password" || element.inputType === "search")
13388
13736
  score += 6;
@@ -13453,10 +13801,10 @@ var require_snapshot_observation = __commonJS({
13453
13801
  }
13454
13802
  return merged;
13455
13803
  }
13456
- function buildScopeSnapshotPreview(scopeSnapshotText) {
13804
+ function buildScopeSnapshotPreview(scopeSnapshotText, stripTrailingValues = false) {
13457
13805
  if (!scopeSnapshotText)
13458
13806
  return [];
13459
- return scopeSnapshotText.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 6);
13807
+ return scopeSnapshotText.split("\n").map((line) => line.trim()).map((line) => stripTrailingValues ? line.replace(/\]\s*:\s.+$/, "]") : line).filter(Boolean).slice(0, 6);
13460
13808
  }
13461
13809
  function dedupeLines(lines) {
13462
13810
  const seen = /* @__PURE__ */ new Set();
@@ -13583,11 +13931,12 @@ var require_snapshot_observation = __commonJS({
13583
13931
  const key = `${role}\0${name}`;
13584
13932
  let group = groups.get(key);
13585
13933
  if (!group) {
13586
- group = { role, name, containers: [], rowContexts: [], refs: [] };
13934
+ group = { role, name, containers: [], rowContexts: [], rowRoles: [], refs: [] };
13587
13935
  groups.set(key, group);
13588
13936
  }
13589
13937
  group.containers.push(element.containerPath ?? null);
13590
13938
  group.rowContexts.push(element.enrichment?.rowContext ?? null);
13939
+ group.rowRoles.push(element.enrichment?.rowRole ?? null);
13591
13940
  group.refs.push(element.ref ?? null);
13592
13941
  }
13593
13942
  return [...groups.values()].filter((group) => group.containers.length > 1);
@@ -13597,7 +13946,7 @@ var require_snapshot_observation = __commonJS({
13597
13946
  for (const element of elements) {
13598
13947
  const role = element.role?.toLowerCase() ?? "";
13599
13948
  const elementType = element.elementType?.toLowerCase() ?? "";
13600
- const isDialog = role === "dialog" || elementType === "dialog" || (element.containerPath?.toLowerCase().includes("dialog:") ?? false);
13949
+ const isDialog = role === "dialog" || elementType === "dialog" || DIALOG_NAME_SEGMENT_RE.test(element.containerPath ?? "");
13601
13950
  if (!isDialog)
13602
13951
  continue;
13603
13952
  const hint = qualifyElementLabel(element) ?? dialogNameFromContainerPath(element.containerPath) ?? element.label ?? element.testId ?? "dialog";
@@ -13610,7 +13959,7 @@ var require_snapshot_observation = __commonJS({
13610
13959
  function dialogNameFromContainerPath(containerPath) {
13611
13960
  if (!containerPath)
13612
13961
  return null;
13613
- const match = containerPath.match(/dialog:([^>]+)/i);
13962
+ const match = containerPath.match(DIALOG_NAME_SEGMENT_RE);
13614
13963
  return match ? match[1].trim() : null;
13615
13964
  }
13616
13965
  function renderObservationCue(observation) {
@@ -13619,6 +13968,13 @@ var require_snapshot_observation = __commonJS({
13619
13968
  const base2 = observation.outcomeType === "SUCCESS" && outcome ? outcome : `${action} -> ${outcome || observation.outcomeType}`;
13620
13969
  return base2.length > 120 ? `${base2.slice(0, 117)}...` : base2;
13621
13970
  }
13971
+ function messageRolePriority(role) {
13972
+ if (role === "alert")
13973
+ return 0;
13974
+ if (role === "status")
13975
+ return 1;
13976
+ return 2;
13977
+ }
13622
13978
  function yamlScalar(value) {
13623
13979
  if (value == null || value === "")
13624
13980
  return "null";
@@ -20167,7 +20523,10 @@ ${testOpen}`;
20167
20523
  pressed: se.pressed,
20168
20524
  headingLevel: se.headingLevel,
20169
20525
  currentState: se.currentState,
20170
- containerPath: se.containerPath || void 0
20526
+ containerPath: se.containerPath || void 0,
20527
+ href: se.href || void 0,
20528
+ value: se.value || void 0,
20529
+ options: se.options && se.options.length > 0 ? se.options : void 0
20171
20530
  }));
20172
20531
  const structuredPage = {
20173
20532
  route: pageUrl || "/",
@@ -20175,6 +20534,7 @@ ${testOpen}`;
20175
20534
  pageType: "unknown",
20176
20535
  elements: collectedElements,
20177
20536
  formGroups: parsed.formGroups,
20537
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
20178
20538
  snapshotMetrics: {
20179
20539
  rawLineCount: parsed.rawLineCount,
20180
20540
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -21741,7 +22101,7 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
21741
22101
  let stopped;
21742
22102
  const agentModel = options?.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
21743
22103
  const auditGroupId = `generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
21744
- const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)();
22104
+ const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
21745
22105
  const credentials = variableContext.allTestVariables ?? variableContext.publicTestVariables;
21746
22106
  logs2.push(`Phase 3 \u2014 Generate: ${(plan.scenarios ?? []).length} scenarios, max ${maxTests} tests`);
21747
22107
  logs2.push(`Model: ${agentModel}`);
@@ -235706,7 +236066,7 @@ var require_mcp_healer = __commonJS({
235706
236066
  messages.push(system, firstUser, { role: "user", content: lines.join("\n") }, ...tail);
235707
236067
  }
235708
236068
  var DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS = 6;
235709
- function readPositiveIntEnv(env, name, fallback) {
236069
+ function readPositiveIntEnv2(env, name, fallback) {
235710
236070
  const raw = env[name]?.trim();
235711
236071
  if (!raw || !/^\d+$/.test(raw))
235712
236072
  return fallback;
@@ -235714,7 +236074,7 @@ var require_mcp_healer = __commonJS({
235714
236074
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
235715
236075
  }
235716
236076
  function getScriptHealMaxAttempts(env = process.env) {
235717
- return readPositiveIntEnv(env, "MCP_HEAL_SCRIPT_MAX_ATTEMPTS", DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS);
236077
+ return readPositiveIntEnv2(env, "MCP_HEAL_SCRIPT_MAX_ATTEMPTS", DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS);
235718
236078
  }
235719
236079
  exports.SCRIPT_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Playwright UI test. You will receive the test code, the error message, step-level results, and a screenshot of the failure state.
235720
236080
 
@@ -236050,7 +236410,7 @@ ${lines.join("\n")}
236050
236410
  const landingViolation = (tags ?? []).includes("@landing-violation");
236051
236411
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "strong") : (0, ai_provider_js_1.getModel)("strong");
236052
236412
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
236053
- const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)());
236413
+ const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
236054
236414
  const runNativeTest = options.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
236055
236415
  const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
236056
236416
  const groupId = `script-heal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -236872,10 +237232,10 @@ ${(verifyResult.logs ?? "").slice(0, 2e3)}`;
236872
237232
  var DEFAULT_MAX_PHASE2_ITERATIONS = 4;
236873
237233
  var DEFAULT_MAX_MCP_EXPLORE_ITERATIONS = 25;
236874
237234
  function getMcpHealPhase2MaxIterations(env = process.env) {
236875
- return readPositiveIntEnv(env, "MCP_HEAL_PHASE2_MAX_ITERATIONS", DEFAULT_MAX_PHASE2_ITERATIONS);
237235
+ return readPositiveIntEnv2(env, "MCP_HEAL_PHASE2_MAX_ITERATIONS", DEFAULT_MAX_PHASE2_ITERATIONS);
236876
237236
  }
236877
237237
  function getMcpHealExploreMaxIterations(env = process.env) {
236878
- return readPositiveIntEnv(env, "MCP_HEAL_EXPLORE_MAX_ITERATIONS", DEFAULT_MAX_MCP_EXPLORE_ITERATIONS);
237238
+ return readPositiveIntEnv2(env, "MCP_HEAL_EXPLORE_MAX_ITERATIONS", DEFAULT_MAX_MCP_EXPLORE_ITERATIONS);
236879
237239
  }
236880
237240
  var DEFAULT_MCP_HEAL_MAX_WALL_CLOCK_MS = 10 * 60 * 1e3;
236881
237241
  var DEFAULT_MCP_HEAL_MAX_TOTAL_AI_CALLS = 60;
@@ -236927,7 +237287,7 @@ Only explore the failing page and the area around the broken step.`;
236927
237287
  let lastVideo;
236928
237288
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
236929
237289
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
236930
- const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)());
237290
+ const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
236931
237291
  const runPhase2NativeTest = options?.phase2Harness?.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
236932
237292
  const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
236933
237293
  const groupId = `phase2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -237617,7 +237977,10 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
237617
237977
  pressed: se.pressed,
237618
237978
  headingLevel: se.headingLevel,
237619
237979
  currentState: se.currentState,
237620
- containerPath: se.containerPath || void 0
237980
+ containerPath: se.containerPath || void 0,
237981
+ href: se.href || void 0,
237982
+ value: se.value || void 0,
237983
+ options: se.options && se.options.length > 0 ? se.options : void 0
237621
237984
  }));
237622
237985
  const structuredPage = {
237623
237986
  route: pageUrl || "/",
@@ -237625,6 +237988,7 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
237625
237988
  pageType: "unknown",
237626
237989
  elements: collectedElements,
237627
237990
  formGroups: parsed.formGroups,
237991
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
237628
237992
  snapshotMetrics: {
237629
237993
  rawLineCount: parsed.rawLineCount,
237630
237994
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -238061,7 +238425,7 @@ ${testBlocks}
238061
238425
  - By iteration ${Math.floor(maxIterations * 0.3)}: you should have completed 1-2 tests
238062
238426
  - By iteration ${Math.floor(maxIterations * 0.7)}: you should be past the halfway point
238063
238427
  - At iteration ${Math.floor(maxIterations * 0.9)}: wrap up and call finish_capture`;
238064
- const aiClient = modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)();
238428
+ const aiClient = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
238065
238429
  const messages = [
238066
238430
  { role: "system", content: systemPrompt },
238067
238431
  { role: "user", content: "Start executing the tests. Navigate to the base URL and begin with Test 1." }
@@ -238244,7 +238608,10 @@ ${testBlocks}
238244
238608
  pressed: se.pressed,
238245
238609
  headingLevel: se.headingLevel,
238246
238610
  currentState: se.currentState,
238247
- containerPath: se.containerPath || void 0
238611
+ containerPath: se.containerPath || void 0,
238612
+ href: se.href || void 0,
238613
+ value: se.value || void 0,
238614
+ options: se.options && se.options.length > 0 ? se.options : void 0
238248
238615
  }));
238249
238616
  const structuredPage = {
238250
238617
  route: pageUrl || "/",
@@ -238252,6 +238619,7 @@ ${testBlocks}
238252
238619
  pageType: "unknown",
238253
238620
  elements: collectedElements,
238254
238621
  formGroups: parsed.formGroups,
238622
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
238255
238623
  snapshotMetrics: {
238256
238624
  rawLineCount: parsed.rawLineCount,
238257
238625
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -300041,8 +300409,9 @@ var require_capture_page_normalizer = __commonJS({
300041
300409
  let snapshotElements = [];
300042
300410
  let formGroups = [];
300043
300411
  let snapshotMetrics;
300412
+ let staticMessages;
300044
300413
  if (normalizedSnapshotResult.snapshotText) {
300045
- const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText);
300414
+ const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText, { richParse: context.richParse });
300046
300415
  snapshotMetrics = {
300047
300416
  rawLineCount: parsed.rawLineCount,
300048
300417
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -300071,9 +300440,13 @@ var require_capture_page_normalizer = __commonJS({
300071
300440
  pressed: se.pressed,
300072
300441
  headingLevel: se.headingLevel,
300073
300442
  currentState: se.currentState,
300074
- containerPath: se.containerPath || void 0
300443
+ containerPath: se.containerPath || void 0,
300444
+ href: se.href || void 0,
300445
+ value: se.value || void 0,
300446
+ options: se.options && se.options.length > 0 ? se.options : void 0
300075
300447
  }));
300076
300448
  formGroups = parsed.formGroups;
300449
+ staticMessages = parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0;
300077
300450
  }
300078
300451
  const aiElements = Array.isArray(toolArgs.elements) ? toolArgs.elements.map(normalizeAiElement) : [];
300079
300452
  const noYamlKinds = /* @__PURE__ */ new Set(["missing", "timeout", "error", "empty"]);
@@ -300169,6 +300542,7 @@ var require_capture_page_normalizer = __commonJS({
300169
300542
  } : void 0,
300170
300543
  readinessSignals,
300171
300544
  viewportHints,
300545
+ staticMessages,
300172
300546
  provenCommands: provenCmds,
300173
300547
  observations: rawObservations,
300174
300548
  routeCorrected,
@@ -300477,6 +300851,10 @@ var require_perception_enricher = __commonJS({
300477
300851
  const e = {};
300478
300852
  if (p.rowContext)
300479
300853
  e.rowContext = p.rowContext;
300854
+ if (p.rowContext && p.rowRole)
300855
+ e.rowRole = p.rowRole;
300856
+ if (p.inputType)
300857
+ e.inputType = p.inputType;
300480
300858
  if (p.position && p.position !== "in-view")
300481
300859
  e.position = p.position;
300482
300860
  if (p.occluded)
@@ -300726,6 +301104,23 @@ var require_perception_enricher = __commonJS({
300726
301104
  const cls = (el.getAttribute("class") || "").toLowerCase();
300727
301105
  return /(^|[\s_-])(card|row|item|listitem|cell|tile)([\s_-]|$)/.test(cls);
300728
301106
  };
301107
+ const rowRoleOf = (el) => {
301108
+ const explicit = (el.getAttribute("role") || "").toLowerCase().trim();
301109
+ if (explicit) {
301110
+ return explicit === "row" || explicit === "listitem" || explicit === "article" || explicit === "option" ? explicit : null;
301111
+ }
301112
+ const tag = el.tagName;
301113
+ if (tag === "TR") {
301114
+ const tbl = el.closest("table");
301115
+ const tblRole = tbl ? (tbl.getAttribute("role") || "").toLowerCase().trim() : "";
301116
+ return tblRole === "presentation" || tblRole === "none" ? null : "row";
301117
+ }
301118
+ if (tag === "LI")
301119
+ return el.closest('ul:not([role]),ol:not([role]),menu:not([role]),[role="list"]') ? "listitem" : null;
301120
+ if (tag === "ARTICLE")
301121
+ return "article";
301122
+ return null;
301123
+ };
300729
301124
  const rowContextOf = (el, ownNameLower) => {
300730
301125
  let cur = el.parentElement;
300731
301126
  let depth = 0;
@@ -300744,7 +301139,7 @@ var require_perception_enricher = __commonJS({
300744
301139
  txt = txt.slice(0, 80);
300745
301140
  const low = txt.toLowerCase();
300746
301141
  if (txt && low !== ownNameLower && low.length > 1)
300747
- return txt;
301142
+ return { text: txt, role: rowRoleOf(cur) };
300748
301143
  return null;
300749
301144
  }
300750
301145
  cur = cur.parentElement;
@@ -300834,11 +301229,14 @@ var require_perception_enricher = __commonJS({
300834
301229
  const name = accName(el);
300835
301230
  const lname = lower(name);
300836
301231
  const rect = vis.rect;
301232
+ const rc = rowContextOf(el, lname);
300837
301233
  out.push({
300838
301234
  domOrder: myOrder,
300839
301235
  roleClass: roleClassOf(tag, roleAttr, inputType),
300840
301236
  name: lname,
300841
- rowContext: rowContextOf(el, lname),
301237
+ rowContext: rc ? rc.text : null,
301238
+ rowRole: rc ? rc.role : null,
301239
+ inputType: inputType || null,
300842
301240
  position: positionOf(rect),
300843
301241
  occluded: occlusionOf(el, rect),
300844
301242
  cursorPointer,
@@ -300875,11 +301273,14 @@ var require_perception_enricher = __commonJS({
300875
301273
  if (!name)
300876
301274
  continue;
300877
301275
  const lname = lower(name);
301276
+ const rc = rowContextOf(el, lname);
300878
301277
  out.push({
300879
301278
  domOrder: order++,
300880
301279
  roleClass: "button",
300881
301280
  name: lname,
300882
- rowContext: rowContextOf(el, lname),
301281
+ rowContext: rc ? rc.text : null,
301282
+ rowRole: rc ? rc.role : null,
301283
+ inputType: null,
300883
301284
  position: "in-view",
300884
301285
  occluded: false,
300885
301286
  cursorPointer: true,
@@ -301381,7 +301782,7 @@ var require_discover_explorer = __commonJS({
301381
301782
  var DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP = 100;
301382
301783
  var DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY = 100;
301383
301784
  var DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL = 40;
301384
- function readPositiveIntEnv(env, name, fallback) {
301785
+ function readPositiveIntEnv2(env, name, fallback) {
301385
301786
  const raw = env[name]?.trim();
301386
301787
  if (!raw || !/^\d+$/.test(raw))
301387
301788
  return fallback;
@@ -301512,15 +301913,15 @@ var require_discover_explorer = __commonJS({
301512
301913
  }
301513
301914
  function getMaxIterations(mode, incremental, env = process.env) {
301514
301915
  if (mode === "survey" && incremental) {
301515
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_INCREMENTAL", DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL);
301916
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_INCREMENTAL", DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL);
301516
301917
  }
301517
301918
  if (mode === "survey") {
301518
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_SURVEY", DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY);
301919
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_SURVEY", DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY);
301519
301920
  }
301520
301921
  if (mode === "deep") {
301521
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEEP", DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP);
301922
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEEP", DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP);
301522
301923
  }
301523
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEFAULT", DEFAULT_MAX_EXPLORE_ITERATIONS_DEFAULT);
301924
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEFAULT", DEFAULT_MAX_EXPLORE_ITERATIONS_DEFAULT);
301524
301925
  }
301525
301926
  function updatePendingRouteScreenshot(pending, { imageData, detectedRoute, fallbackRoute }) {
301526
301927
  if (imageData) {
@@ -302246,7 +302647,7 @@ ${inboxPromptBlock}`;
302246
302647
  const browserCreds = variableContext.allTestVariables ?? variableContext.publicTestVariables;
302247
302648
  const { secrets: browserSecretsForHint } = (0, credential_tools_js_1.partitionTestVariables)(browserCreds);
302248
302649
  const hasLoginCredentials = Object.keys(browserSecretsForHint).length > 0;
302249
- const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)();
302650
+ const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
302250
302651
  const isDeepMode = options?.mode === "deep";
302251
302652
  const maxIterations = config?.maxIterationsOverride && config.maxIterationsOverride > 0 ? config.maxIterationsOverride : getMaxIterations(options?.mode ?? "full", options?.incremental);
302252
302653
  const configKeepTail = isDeepMode ? Math.ceil(ctxConfig.keepTail * 1.5) : ctxConfig.keepTail;
@@ -304785,7 +305186,7 @@ var require_auth_capture_run = __commonJS({
304785
305186
  var scrub_credentials_js_1 = require_scrub_credentials();
304786
305187
  var credential_tools_js_1 = require_credential_tools();
304787
305188
  var DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS = 12;
304788
- function readPositiveIntEnv(env, name, fallback) {
305189
+ function readPositiveIntEnv2(env, name, fallback) {
304789
305190
  const raw = env[name]?.trim();
304790
305191
  if (!raw || !/^\d+$/.test(raw))
304791
305192
  return fallback;
@@ -304793,7 +305194,7 @@ var require_auth_capture_run = __commonJS({
304793
305194
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
304794
305195
  }
304795
305196
  function getLoginCaptureMaxIterations(env = process.env) {
304796
- return readPositiveIntEnv(env, "AUTH_CAPTURE_LOGIN_MAX_ITERATIONS", DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS);
305197
+ return readPositiveIntEnv2(env, "AUTH_CAPTURE_LOGIN_MAX_ITERATIONS", DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS);
304797
305198
  }
304798
305199
  var CAPTURE_RATE_LIMIT_MAX_RETRIES = 2;
304799
305200
  var CAPTURE_RATE_LIMIT_MAX_DELAY_MS = 8e3;
@@ -305076,7 +305477,7 @@ ${keyLines || "- (none provided)"}
305076
305477
  }
305077
305478
  var DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS = 18;
305078
305479
  function getRegisterCaptureMaxIterations(env = process.env) {
305079
- return readPositiveIntEnv(env, "AUTH_CAPTURE_REGISTER_MAX_ITERATIONS", DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS);
305480
+ return readPositiveIntEnv2(env, "AUTH_CAPTURE_REGISTER_MAX_ITERATIONS", DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS);
305080
305481
  }
305081
305482
  function buildRegisterCaptureSystemPrompt(baseUrl, availableKeys, maxIterations = getRegisterCaptureMaxIterations()) {
305082
305483
  const keyLines = availableKeys.map((k) => `- \`${k}\``).join("\n");
@@ -311306,6 +311707,7 @@ var require_mobile_exploration_state = __commonJS({
311306
311707
  Object.defineProperty(exports, "__esModule", { value: true });
311307
311708
  exports.MobileExplorationState = void 0;
311308
311709
  exports.deriveScreenId = deriveScreenId;
311710
+ var mobile_observation_js_1 = require_mobile_observation();
311309
311711
  var DEFAULT_STALE_THRESHOLD = 4;
311310
311712
  function deriveScreenId(signal) {
311311
311713
  const namespace = signal.activity ?? signal.bundleId;
@@ -311531,21 +311933,33 @@ var require_mobile_exploration_state = __commonJS({
311531
311933
  * Build the planner evidence shape: one `CollectedPageData` per screen, with
311532
311934
  * the screen-id carried in BOTH `route` (the on-the-wire positional field the
311533
311935
  * server keys screenshots by) AND the explicit `screenId` field (per the
311534
- * Phase-0 `CollectedPageData.screenId` contract). `elements` is left empty —
311535
- * the web-shaped `CollectedPageElement[]` is not the mobile element shape;
311536
- * the mobile planner reads screen identity + names from this evidence and the
311537
- * richer per-element data flows through the generator's own re-drive, not
311538
- * through this map. Insertion order matches first-seen order.
311936
+ * Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
311937
+ * the screen's parsed actionable elements into the platform-neutral
311938
+ * `CollectedPageElement[]` shape the server persists as SiteElements without
311939
+ * this, mobile screens land with zero elements (coverage always 0%, no element
311940
+ * intelligence). `triggersNavigation` is derived from transition-source
311941
+ * membership: an element is flagged when its ref drove a transition FROM this
311942
+ * screen. Insertion order matches first-seen order.
311539
311943
  */
311540
311944
  buildInMemoryScreenMap() {
311541
311945
  const pages = [];
311542
311946
  const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
311947
+ const navRefsByScreen = /* @__PURE__ */ new Map();
311948
+ for (const transition of this.transitions) {
311949
+ if (!transition.viaRef)
311950
+ continue;
311951
+ const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
311952
+ set.add(transition.viaRef);
311953
+ navRefsByScreen.set(transition.fromScreenId, set);
311954
+ }
311543
311955
  for (const state2 of ordered) {
311544
311956
  const page = {
311545
311957
  route: state2.screenId,
311546
311958
  screenId: state2.screenId,
311547
311959
  pageType: state2.screenType ?? "UNKNOWN",
311548
- elements: []
311960
+ elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
311961
+ navigationRefs: navRefsByScreen.get(state2.screenId)
311962
+ })
311549
311963
  };
311550
311964
  if (state2.screenName) {
311551
311965
  page.title = state2.screenName;
@@ -311575,14 +311989,51 @@ var require_mobile_discovery_agent = __commonJS({
311575
311989
  "../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports) {
311576
311990
  "use strict";
311577
311991
  Object.defineProperty(exports, "__esModule", { value: true });
311992
+ exports.getMobileExploreBudget = getMobileExploreBudget;
311578
311993
  exports.runMobileDiscoveryOnRunner = runMobileDiscoveryOnRunner2;
311579
311994
  var mobile_explorer_js_1 = require_mobile_explorer();
311580
311995
  var mobile_discovery_prompts_js_1 = require_mobile_discovery_prompts();
311581
311996
  var mobile_generator_js_1 = require_mobile_generator();
311582
311997
  var mobile_exploration_state_js_1 = require_mobile_exploration_state();
311583
311998
  var discover_planner_js_1 = require_discover_planner();
311584
- var EXPLORE_BUDGET_DEEP = 60;
311585
- var EXPLORE_BUDGET_SURVEY = 40;
311999
+ function checkpointErrorMessage(err) {
312000
+ return err instanceof Error ? err.message : String(err);
312001
+ }
312002
+ function isTerminalDiscoveryCheckpointError(err) {
312003
+ const message = checkpointErrorMessage(err);
312004
+ return /Discovery plan checkpoint rejected|Discovery run is .*not RUNNING|not RUNNING|cancelled/i.test(message);
312005
+ }
312006
+ var MOBILE_EXPLORE_BUDGET_SURVEY_FIRST = 90;
312007
+ var MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL = 40;
312008
+ var MOBILE_EXPLORE_BUDGET_DEEP = 60;
312009
+ var MOBILE_EXPLORE_BUDGET_FULL = 90;
312010
+ function readPositiveIntEnv2(name, fallback, env = process.env) {
312011
+ const raw = env[name]?.trim();
312012
+ if (!raw || !/^\d+$/.test(raw))
312013
+ return fallback;
312014
+ const parsed = Number(raw);
312015
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
312016
+ }
312017
+ function getMobileExploreBudget(mode, incremental, env = process.env) {
312018
+ if (mode === "deep")
312019
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_DEEP", MOBILE_EXPLORE_BUDGET_DEEP, env);
312020
+ if (mode === "full")
312021
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_FULL", MOBILE_EXPLORE_BUDGET_FULL, env);
312022
+ if (incremental) {
312023
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY_INCREMENTAL", MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL, env);
312024
+ }
312025
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY", MOBILE_EXPLORE_BUDGET_SURVEY_FIRST, env);
312026
+ }
312027
+ function compactExistingScreens(siteMap) {
312028
+ if (!siteMap || !Array.isArray(siteMap.pages) || siteMap.pages.length === 0)
312029
+ return void 0;
312030
+ const screens = siteMap.pages.filter((p) => typeof p.route === "string" && p.route.trim().length > 0).map((p) => ({
312031
+ screenId: p.route,
312032
+ ...p.title ? { name: p.title } : {},
312033
+ ...p.pageType ? { screenType: p.pageType } : {}
312034
+ }));
312035
+ return screens.length > 0 ? screens : void 0;
312036
+ }
311586
312037
  function platformForMedium(medium) {
311587
312038
  return medium === "IOS_NATIVE" ? "IOS" : "ANDROID";
311588
312039
  }
@@ -311601,6 +312052,9 @@ var require_mobile_discovery_agent = __commonJS({
311601
312052
  // Native screenshots are multi-megabyte PNGs. The live mirror streams them
311602
312053
  // through heartbeat telemetry; discovery checkpoints keep the screen graph
311603
312054
  // and transitions so large Android surveys cannot exceed server body limits.
312055
+ // D-D6 — pageScreenshotKeys is a small screen-id → storage-key map (NOT base64),
312056
+ // so it DOES ride the checkpoint: the server links each key to SitePage.screenshotKey.
312057
+ ...explore.pageScreenshotKeys && Object.keys(explore.pageScreenshotKeys).length > 0 ? { pageScreenshotKeys: explore.pageScreenshotKeys } : {},
311604
312058
  exploreStats: explore.exploreStats,
311605
312059
  healthObservations: explore.healthObservations
311606
312060
  };
@@ -311615,6 +312069,7 @@ var require_mobile_discovery_agent = __commonJS({
311615
312069
  const onLog = callbacks?.onLog;
311616
312070
  const onAICall = callbacks?.onAICall;
311617
312071
  const onScreenshot = callbacks?.onScreenshot;
312072
+ const onScreenshotUpload = callbacks?.onScreenshotUpload;
311618
312073
  const runExplore = callbacks?.runExplore ?? mobile_explorer_js_1.runMobileExplorePhase;
311619
312074
  const runPlan = callbacks?.runPlan ?? mobile_discovery_prompts_js_1.runMobileAIPlanner;
311620
312075
  const runGenerate = callbacks?.runGenerate ?? mobile_generator_js_1.runMobileGeneratePhase;
@@ -311641,15 +312096,77 @@ var require_mobile_discovery_agent = __commonJS({
311641
312096
  elementsFound: 0,
311642
312097
  transitionsFound: 0
311643
312098
  };
312099
+ const resumeSavedScenarios = ctx.resume?.savedPlan?.scenarios ?? [];
312100
+ const isPlanReuseResume = mode !== "survey" && resumeSavedScenarios.length > 0;
311644
312101
  try {
312102
+ if (isPlanReuseResume) {
312103
+ const alreadyGenerated = ctx.resume?.alreadyGeneratedScenarioNames ?? [];
312104
+ log2(`
312105
+ \u2500\u2500 Resume (plan-reuse): ${resumeSavedScenarios.length} saved scenario(s); ${alreadyGenerated.length} already generated. Skipping explore + plan; generating only the remainder. \u2500\u2500`);
312106
+ const scenarios2 = resumeSavedScenarios;
312107
+ const plans2 = toPlans(scenarios2);
312108
+ if (callbacks?.onPlanReady) {
312109
+ try {
312110
+ await callbacks.onPlanReady({ scenarios: scenarios2, featureGroups: void 0, fallbackUsed: false });
312111
+ log2("Plan checkpoint: reused scenario plan re-POSTed to server");
312112
+ } catch (err) {
312113
+ const message = checkpointErrorMessage(err);
312114
+ if (isTerminalDiscoveryCheckpointError(err)) {
312115
+ log2(`Plan checkpoint rejected for inactive discovery run: ${message}`);
312116
+ throw err;
312117
+ }
312118
+ log2(`Plan checkpoint POST failed (non-fatal): ${message}`);
312119
+ }
312120
+ }
312121
+ let paused = false;
312122
+ let pauseReason;
312123
+ let remainingScenarios;
312124
+ const tests2 = await runGenerate({
312125
+ scenarios: scenarios2,
312126
+ driver,
312127
+ ctx,
312128
+ onLog: (line) => log2(line),
312129
+ onAICall,
312130
+ onScreenshot,
312131
+ onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
312132
+ skipScenarioNames: alreadyGenerated,
312133
+ onInfraStop: (info) => {
312134
+ paused = true;
312135
+ pauseReason = info.reason;
312136
+ remainingScenarios = info.remainingScenarioNames;
312137
+ }
312138
+ });
312139
+ const duration2 = (Date.now() - startTime) / 1e3;
312140
+ const summary2 = `Mobile resume complete: generated ${tests2.length} test(s) from ${scenarios2.length} saved scenario(s) in ${duration2.toFixed(1)}s${paused ? " \u2014 PAUSED again on AI provider overload" : ""}`;
312141
+ log2(`
312142
+ === Mobile Discovery Resume Complete (${duration2.toFixed(1)}s) ===`);
312143
+ log2(summary2);
312144
+ return {
312145
+ collectedPages: [],
312146
+ collectedTransitions: [],
312147
+ tests: tests2,
312148
+ plans: plans2,
312149
+ scenarios: scenarios2.length > 0 ? scenarios2 : void 0,
312150
+ logs: logs2.join("\n"),
312151
+ screenshots,
312152
+ summary: summary2,
312153
+ exploreStats: { pagesDiscovered: 0, pagesVisited: 0, elementsFound: 0, transitionsFound: 0 },
312154
+ duration: duration2,
312155
+ mode,
312156
+ ...paused ? { paused: true, pauseReason, remainingScenarios } : {}
312157
+ // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
312158
+ };
312159
+ }
311645
312160
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
311646
312161
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
311647
- const maxIterations = mode === "deep" ? EXPLORE_BUDGET_DEEP : EXPLORE_BUDGET_SURVEY;
312162
+ const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
312163
+ const existingScreens = compactExistingScreens(ctx.existingSiteMap);
311648
312164
  const explore = await runExplore({
311649
312165
  ctx,
311650
312166
  driver,
311651
312167
  state: state2,
311652
312168
  config: { maxIterations },
312169
+ existingScreens,
311653
312170
  onLog: (line) => log2(line),
311654
312171
  onAICall,
311655
312172
  onScreenshot: (base64) => {
@@ -311659,7 +312176,8 @@ var require_mobile_discovery_agent = __commonJS({
311659
312176
  onScreenshot?.(base64);
311660
312177
  } catch {
311661
312178
  }
311662
- }
312179
+ },
312180
+ onScreenshotUpload
311663
312181
  });
311664
312182
  const collectedTransitions = toCollectedTransitions(explore);
311665
312183
  partialCollectedPages = explore.collectedPages;
@@ -311711,14 +312229,22 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311711
312229
  const plans = toPlans(scenarios);
311712
312230
  const coverageGaps = planResult.coverageGaps && planResult.coverageGaps.length > 0 ? planResult.coverageGaps : void 0;
311713
312231
  if (callbacks?.onPlanReady) {
311714
- await callbacks.onPlanReady({
311715
- scenarios,
311716
- featureGroups,
311717
- fallbackUsed: planResult.fallbackUsed ?? false,
311718
- fallbackReason: planResult.fallbackReason
311719
- }).then(() => log2("Plan checkpoint: scenario plan POSTed to server")).catch((err) => {
311720
- log2(`Plan checkpoint POST failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
311721
- });
312232
+ try {
312233
+ await callbacks.onPlanReady({
312234
+ scenarios,
312235
+ featureGroups,
312236
+ fallbackUsed: planResult.fallbackUsed ?? false,
312237
+ fallbackReason: planResult.fallbackReason
312238
+ });
312239
+ log2("Plan checkpoint: scenario plan POSTed to server");
312240
+ } catch (err) {
312241
+ const message = checkpointErrorMessage(err);
312242
+ if (isTerminalDiscoveryCheckpointError(err)) {
312243
+ log2(`Plan checkpoint rejected for inactive discovery run: ${message}`);
312244
+ throw err;
312245
+ }
312246
+ log2(`Plan checkpoint POST failed (non-fatal): ${message}`);
312247
+ }
311722
312248
  }
311723
312249
  if (isSurvey) {
311724
312250
  const duration2 = (Date.now() - startTime) / 1e3;
@@ -311752,6 +312278,9 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311752
312278
  };
311753
312279
  }
311754
312280
  log2("\n\u2500\u2500 Phase 3: GENERATE \u2500\u2500");
312281
+ let generatePaused = false;
312282
+ let generatePauseReason;
312283
+ let generateRemainingScenarios;
311755
312284
  const tests = await runGenerate({
311756
312285
  scenarios,
311757
312286
  driver,
@@ -311759,7 +312288,15 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311759
312288
  onLog: (line) => log2(line),
311760
312289
  onAICall,
311761
312290
  onScreenshot,
311762
- onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
312291
+ onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
312292
+ // Legacy resume (no saved plan): still skip scenarios already generated by the
312293
+ // paused run so the AI isn't re-invoked for them. Undefined on a fresh run.
312294
+ skipScenarioNames: ctx.resume?.alreadyGeneratedScenarioNames,
312295
+ onInfraStop: (info) => {
312296
+ generatePaused = true;
312297
+ generatePauseReason = info.reason;
312298
+ generateRemainingScenarios = info.remainingScenarioNames;
312299
+ }
311763
312300
  });
311764
312301
  const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
311765
312302
  log2(`
@@ -311775,7 +312312,7 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
311775
312312
  };
311776
312313
  }
311777
312314
  const duration = (Date.now() - startTime) / 1e3;
311778
- const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s`;
312315
+ const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s${generatePaused ? " \u2014 PAUSED on AI provider overload (resumable)" : ""}`;
311779
312316
  log2(`
311780
312317
  === Mobile Discovery Pipeline Complete (${duration.toFixed(1)}s) ===`);
311781
312318
  log2(summary);
@@ -311800,7 +312337,11 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
311800
312337
  summary,
311801
312338
  exploreStats: explore.exploreStats,
311802
312339
  duration,
311803
- mode
312340
+ mode,
312341
+ // D-D4 — a circuit-breaker trip PAUSES the run (resumable) instead of
312342
+ // finalizing COMPLETED/FAILED. The server's isDiscoveryPaused reads these
312343
+ // (forwarded by runner.ts buildFinalBody, byte-identical to the web path).
312344
+ ...generatePaused ? { paused: true, pauseReason: generatePauseReason, remainingScenarios: generateRemainingScenarios } : {}
311804
312345
  // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
311805
312346
  };
311806
312347
  } catch (fatalErr) {
@@ -311846,6 +312387,7 @@ var require_discover_agent = __commonJS({
311846
312387
  Object.defineProperty(exports, "__esModule", { value: true });
311847
312388
  exports.runMobileDiscoveryOnRunner = exports.buildInMemorySiteMap = void 0;
311848
312389
  exports.runCanvasPreflight = runCanvasPreflight;
312390
+ exports.getFatalExploreError = getFatalExploreError;
311849
312391
  exports.runSiteDiscoveryOnRunner = runSiteDiscoveryOnRunner2;
311850
312392
  exports.runFeatureDiscoveryOnRunner = runFeatureDiscoveryOnRunner2;
311851
312393
  var browser_config_js_1 = require_browser_config();
@@ -312028,7 +312570,7 @@ Respond with ONLY valid JSON:
312028
312570
  "keyFindings": "What was discovered - forms, validations, behaviors. Mark inferred items with '(inferred, not tested)'.",
312029
312571
  "testingNotes": "Gotchas, validation behaviors, error messages. List visible-but-untested routes as candidates for future exploration."
312030
312572
  }`;
312031
- const client = modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)();
312573
+ const client = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
312032
312574
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
312033
312575
  const callStart = Date.now();
312034
312576
  const response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => client.chat.completions.create({
@@ -312159,6 +312701,10 @@ Respond with ONLY valid JSON:
312159
312701
  });
312160
312702
  return timed.catch(() => null);
312161
312703
  }
312704
+ function getFatalExploreError(exploreResult) {
312705
+ const prefix = "Fatal error:";
312706
+ return exploreResult.summary.startsWith(prefix) ? exploreResult.summary.slice(prefix.length).trim() || exploreResult.summary : void 0;
312707
+ }
312162
312708
  async function runSiteDiscoveryOnRunner2(ctx, onLog, onAICall, sharedBrowser, onScreenshot, callbackOptions) {
312163
312709
  const logs2 = [];
312164
312710
  const screenshots = [];
@@ -312343,6 +312889,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
312343
312889
  if (loginRunP)
312344
312890
  await loginRunP;
312345
312891
  const duration2 = (Date.now() - startTime) / 1e3;
312892
+ const fatalExploreError = getFatalExploreError(exploreResult);
312346
312893
  return {
312347
312894
  collectedPages: exploreResult.collectedPages,
312348
312895
  collectedTransitions: exploreResult.collectedTransitions,
@@ -312353,6 +312900,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
312353
312900
  pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
312354
312901
  pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
312355
312902
  summary: exploreResult.summary || "Exploration completed but no page data captured.",
312903
+ error: fatalExploreError,
312356
312904
  exploreStats: {
312357
312905
  pagesDiscovered: exploreResult.pagesDiscovered,
312358
312906
  pagesVisited: exploreResult.pagesVisited,
@@ -312894,6 +313442,7 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
312894
313442
  discoveryApiCalls = earlyClose.apiCalls;
312895
313443
  postCloseHealthSummary = earlyClose.healthSummary;
312896
313444
  const duration2 = (Date.now() - startTime) / 1e3;
313445
+ const fatalExploreError = getFatalExploreError(exploreResult);
312897
313446
  return {
312898
313447
  collectedPages: exploreResult.collectedPages,
312899
313448
  collectedTransitions: exploreResult.collectedTransitions,
@@ -312904,7 +313453,8 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
312904
313453
  healthObservations: earlyClose.healthSummary,
312905
313454
  pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
312906
313455
  pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
312907
- summary: "Feature exploration completed but no page data captured.",
313456
+ summary: fatalExploreError ? exploreResult.summary : "Feature exploration completed but no page data captured.",
313457
+ error: fatalExploreError,
312908
313458
  exploreStats: {
312909
313459
  pagesDiscovered: exploreResult.pagesDiscovered,
312910
313460
  pagesVisited: exploreResult.pagesVisited,
@@ -326293,7 +326843,7 @@ var require_package4 = __commonJS({
326293
326843
  "package.json"(exports, module) {
326294
326844
  module.exports = {
326295
326845
  name: "@validate.qa/runner",
326296
- version: "1.0.9",
326846
+ version: "1.0.13",
326297
326847
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
326298
326848
  bin: {
326299
326849
  "validate-runner": "dist/cli.js"
@@ -326301,7 +326851,7 @@ var require_package4 = __commonJS({
326301
326851
  scripts: {
326302
326852
  build: "tsup",
326303
326853
  dev: "tsx src/cli.ts",
326304
- "test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts",
326854
+ "test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts src/services/mobile/mobile-host-proxy.test.ts",
326305
326855
  prepublishOnly: "npm run build"
326306
326856
  },
326307
326857
  files: [
@@ -326528,8 +327078,10 @@ async function startAppiumServer(options) {
326528
327078
  return serverState;
326529
327079
  }
326530
327080
  const port = options?.port ?? APPIUM_PORT;
326531
- const drivers = options?.drivers ?? ["xcuitest", "uiautomator2"];
326532
- const args = ["--port", String(port), "--use-drivers", drivers.join(",")];
327081
+ const args = ["--port", String(port)];
327082
+ if (options?.drivers && options.drivers.length > 0) {
327083
+ args.push("--use-drivers", options.drivers.join(","));
327084
+ }
326533
327085
  intentionalStop = false;
326534
327086
  lastStartOptions = { ...lastStartOptions, ...options };
326535
327087
  if (appiumGaveUp) {
@@ -326539,11 +327091,12 @@ async function startAppiumServer(options) {
326539
327091
  killPort(port);
326540
327092
  return new Promise((resolve, reject) => {
326541
327093
  try {
326542
- appiumProcess = spawn("appium", args, {
327094
+ const child = spawn("appium", args, {
326543
327095
  stdio: ["pipe", "pipe", "pipe"],
326544
327096
  env: { ...process.env },
326545
327097
  detached: false
326546
327098
  });
327099
+ appiumProcess = child;
326547
327100
  serverState.pid = appiumProcess.pid ?? null;
326548
327101
  serverState.port = port;
326549
327102
  appiumProcess.stdout?.on("data", (data) => {
@@ -326560,7 +327113,11 @@ async function startAppiumServer(options) {
326560
327113
  options?.onLog?.(line);
326561
327114
  }
326562
327115
  });
326563
- appiumProcess.on("exit", (code, signal) => {
327116
+ child.on("exit", (code, signal) => {
327117
+ if (appiumProcess !== child) {
327118
+ appendLog(`[appium] Ignoring exit (code=${code}, signal=${signal}) from a superseded process`);
327119
+ return;
327120
+ }
326564
327121
  appendLog(`[appium] Process exited (code=${code}, signal=${signal})`);
326565
327122
  const currentPort = serverState.port;
326566
327123
  const currentRestartCount = serverState.restartCount;
@@ -326599,7 +327156,11 @@ async function startAppiumServer(options) {
326599
327156
  startupAbort.abort();
326600
327157
  reject(err);
326601
327158
  };
326602
- appiumProcess.on("error", (err) => {
327159
+ child.on("error", (err) => {
327160
+ if (appiumProcess !== child) {
327161
+ appendLog(`[appium] Ignoring error from a superseded process: ${err.message}`);
327162
+ return;
327163
+ }
326603
327164
  appendLog(`[appium] Process error: ${err.message}`);
326604
327165
  resetServerState(port);
326605
327166
  settleReject(new Error(`Failed to start Appium: ${err.message}. Is appium installed? Run: npm i -g appium`));
@@ -326718,6 +327279,7 @@ function runCommand(command, args, timeout = 1e4) {
326718
327279
  }
326719
327280
  var realIOSProfilerNegativeUntil = 0;
326720
327281
  var REAL_IOS_PROFILER_TTL_MS = 6e4;
327282
+ var REAL_IOS_NO_UDID_HINT = "no paired UDID \u2014 install libimobiledevice (brew install libimobiledevice) and trust this computer on the device";
326721
327283
  function discoverIOSSimulators() {
326722
327284
  if (platform() !== "darwin") return [];
326723
327285
  const json = runCommand("xcrun", ["simctl", "list", "devices", "--json"]);
@@ -326727,7 +327289,8 @@ function discoverIOSSimulators() {
326727
327289
  const devices = [];
326728
327290
  for (const [runtime, runtimeDevices] of Object.entries(parsed.devices)) {
326729
327291
  const versionMatch = runtime.match(/iOS[- ](\d+(?:[-.]\d+)*)/i);
326730
- const platformVersion = versionMatch ? versionMatch[1].replace(/-/g, ".") : "unknown";
327292
+ if (!versionMatch) continue;
327293
+ const platformVersion = versionMatch[1].replace(/-/g, ".");
326731
327294
  for (const device of runtimeDevices) {
326732
327295
  const state2 = device.state === "Booted" ? "BOOTED" : device.state === "Shutdown" ? "SHUTDOWN" : "LOADING";
326733
327296
  if (state2 === "BOOTED") {
@@ -326800,11 +327363,11 @@ function discoverRealIOSDevices() {
326800
327363
  const version = runCommand("ideviceinfo", ["-u", serial, "-k", "ProductVersion"]) || "unknown";
326801
327364
  devices.push({
326802
327365
  id: serial,
326803
- name,
327366
+ name: `${name} (${REAL_IOS_NO_UDID_HINT})`,
326804
327367
  platform: "IOS",
326805
327368
  platformVersion: version,
326806
327369
  state: "BOOTED",
326807
- isAvailable: true,
327370
+ isAvailable: false,
326808
327371
  isPhysical: true
326809
327372
  });
326810
327373
  }
@@ -326820,11 +327383,11 @@ function discoverRealIOSDevices() {
326820
327383
  if (hasIPhone || hasIPad) {
326821
327384
  devices.push({
326822
327385
  id: "usb-ios-device",
326823
- name: hasIPhone ? "iPhone (USB)" : "iPad (USB)",
327386
+ name: `${hasIPhone ? "iPhone (USB)" : "iPad (USB)"} (${REAL_IOS_NO_UDID_HINT})`,
326824
327387
  platform: "IOS",
326825
327388
  platformVersion: "unknown",
326826
327389
  state: "BOOTED",
326827
- isAvailable: true,
327390
+ isAvailable: false,
326828
327391
  isPhysical: true
326829
327392
  });
326830
327393
  }
@@ -326902,7 +327465,9 @@ function discoverAndroidDevices() {
326902
327465
  const adbDevices = parseAdbDevices();
326903
327466
  return adbDevices.filter((d) => d.type === "device" || d.type === "unauthorized").map((d) => {
326904
327467
  const isEmulator = d.serial.startsWith("emulator-");
326905
- const isAvailable = d.type === "device";
327468
+ const authorized = d.type === "device";
327469
+ const bootCompleted = authorized && getAndroidProp(d.serial, "sys.boot_completed") === "1";
327470
+ const isAvailable = authorized && bootCompleted;
326906
327471
  const name = d.model || d.device || (isEmulator ? "Android Emulator" : "Android Device");
326907
327472
  const platformVersion = isAvailable ? getAndroidProp(d.serial, "ro.build.version.release") || "unknown" : "unknown";
326908
327473
  return {
@@ -326910,7 +327475,10 @@ function discoverAndroidDevices() {
326910
327475
  name,
326911
327476
  platform: "ANDROID",
326912
327477
  platformVersion,
326913
- state: "BOOTED",
327478
+ // LOADING = authorized but still booting; an unauthorized device stays
327479
+ // BOOTED (isAvailable false) so the dashboard shows it with an
327480
+ // "accept USB debugging" hint rather than hiding it.
327481
+ state: authorized && !bootCompleted ? "LOADING" : "BOOTED",
326914
327482
  isAvailable,
326915
327483
  // Mark if it's a real device vs emulator
326916
327484
  ...isEmulator ? {} : { isPhysical: true }
@@ -326956,15 +327524,25 @@ function discoverAllDevices(options) {
326956
327524
  }
326957
327525
  return [...iosDevices, ...androidDevices];
326958
327526
  }
327527
+ var DEVICE_SNAPSHOT_TTL_MS = 8e3;
327528
+ var cachedDeviceSnapshot = null;
327529
+ function getCachedDeviceSnapshot() {
327530
+ const now = Date.now();
327531
+ if (cachedDeviceSnapshot && now - cachedDeviceSnapshot.at < DEVICE_SNAPSHOT_TTL_MS) {
327532
+ return cachedDeviceSnapshot.devices;
327533
+ }
327534
+ const devices = discoverAllDevices();
327535
+ cachedDeviceSnapshot = { at: now, devices };
327536
+ return devices;
327537
+ }
326959
327538
  function getRunnerCapabilities() {
326960
- const iosDevices = discoverIOSDevices();
326961
- const androidDevices = discoverAndroidDevices();
327539
+ const devices = getCachedDeviceSnapshot();
326962
327540
  const isReadyDevice = (device) => device.isAvailable && device.state === "BOOTED";
326963
327541
  return {
326964
327542
  web: true,
326965
327543
  // Always capable of web testing via Playwright
326966
- ios: iosDevices.some(isReadyDevice),
326967
- android: androidDevices.some(isReadyDevice)
327544
+ ios: devices.some((d) => d.platform === "IOS" && isReadyDevice(d)),
327545
+ android: devices.some((d) => d.platform === "ANDROID" && isReadyDevice(d))
326968
327546
  };
326969
327547
  }
326970
327548
 
@@ -327103,6 +327681,9 @@ var MitmProxy = class {
327103
327681
  await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
327104
327682
  await server3.on("request", (req) => this.onRequest(req));
327105
327683
  await server3.on("response", (res) => this.onResponse(res));
327684
+ await server3.on("abort", (req) => {
327685
+ this.inFlight.delete(req.id);
327686
+ });
327106
327687
  await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
327107
327688
  await server3.on("tls-passthrough-opened", (event) => this.onTlsPassthroughOpened(event));
327108
327689
  await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
@@ -327399,6 +327980,107 @@ async function iosSimRemoveCa(deviceId, onLog) {
327399
327980
  function errMsg(err) {
327400
327981
  return err instanceof Error ? err.message : String(err);
327401
327982
  }
327983
+ function parsePrimaryNetworkService(stdout) {
327984
+ for (const line of stdout.split("\n")) {
327985
+ const m = line.match(/^\(\d+\)\s+(.+)$/);
327986
+ if (m) return m[1].trim();
327987
+ }
327988
+ return null;
327989
+ }
327990
+ function parseWebProxyState(stdout) {
327991
+ const field = (label) => {
327992
+ const m = stdout.match(new RegExp(`^${label}:[ \\t]*(.*)$`, "im"));
327993
+ return m ? m[1].trim() : "";
327994
+ };
327995
+ return {
327996
+ enabled: field("Enabled").toLowerCase() === "yes",
327997
+ server: field("Server"),
327998
+ port: field("Port")
327999
+ };
328000
+ }
328001
+ function buildHostProxySetCommands(service, host, port) {
328002
+ const portStr = String(port);
328003
+ return [
328004
+ ["-setwebproxy", service, host, portStr],
328005
+ ["-setsecurewebproxy", service, host, portStr]
328006
+ ];
328007
+ }
328008
+ function buildProxyRestoreArgs(setCmd, stateCmd, service, prior) {
328009
+ if (prior.server) {
328010
+ return [
328011
+ [setCmd, service, prior.server, prior.port || "0"],
328012
+ [stateCmd, service, prior.enabled ? "on" : "off"]
328013
+ ];
328014
+ }
328015
+ return [[stateCmd, service, "off"]];
328016
+ }
328017
+ function buildHostProxyRestoreCommands(service, prior) {
328018
+ return [
328019
+ ...buildProxyRestoreArgs("-setwebproxy", "-setwebproxystate", service, prior.web),
328020
+ ...buildProxyRestoreArgs("-setsecurewebproxy", "-setsecurewebproxystate", service, prior.secure)
328021
+ ];
328022
+ }
328023
+ function hostProxyPriorFromMarker(marker) {
328024
+ return {
328025
+ web: {
328026
+ enabled: Boolean(marker.hostWebProxyWasEnabled),
328027
+ server: marker.hostWebProxyPriorServer ?? "",
328028
+ port: marker.hostWebProxyPriorPort ?? ""
328029
+ },
328030
+ secure: {
328031
+ enabled: Boolean(marker.hostSecureProxyWasEnabled),
328032
+ server: marker.hostSecureProxyPriorServer ?? "",
328033
+ port: marker.hostSecureProxyPriorPort ?? ""
328034
+ }
328035
+ };
328036
+ }
328037
+ function hostProxyControlSupported(platform3, isPhysical, platformName = process.platform) {
328038
+ if (platform3 !== "IOS") return { supported: false };
328039
+ if (isPhysical) {
328040
+ return { supported: false, reason: "physical iOS device requires a manual Wi-Fi HTTP proxy; host proxy not applied" };
328041
+ }
328042
+ if (platformName !== "darwin") {
328043
+ return { supported: false, reason: `iOS simulator host proxy is only controllable on macOS (host is ${platformName})` };
328044
+ }
328045
+ return { supported: true };
328046
+ }
328047
+ async function iosSimConfigureHostProxy(host, port, onLog) {
328048
+ try {
328049
+ const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
328050
+ timeout: DEVICE_CMD_TIMEOUT_MS
328051
+ });
328052
+ const service = parsePrimaryNetworkService(order);
328053
+ if (!service) {
328054
+ return { configured: false, reason: "iOS simulator host proxy could not be configured: no active macOS network service found" };
328055
+ }
328056
+ const [{ stdout: webOut }, { stdout: secureOut }] = await Promise.all([
328057
+ execFileAsync("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
328058
+ execFileAsync("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
328059
+ ]);
328060
+ const prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
328061
+ for (const args of buildHostProxySetCommands(service, host, port)) {
328062
+ await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328063
+ }
328064
+ onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
328065
+ return { configured: true, service, prior };
328066
+ } catch (err) {
328067
+ return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
328068
+ }
328069
+ }
328070
+ async function iosSimRestoreHostProxy(service, prior, onLog) {
328071
+ const restore = prior ?? {
328072
+ web: { enabled: false, server: "", port: "" },
328073
+ secure: { enabled: false, server: "", port: "" }
328074
+ };
328075
+ for (const args of buildHostProxyRestoreCommands(service, restore)) {
328076
+ try {
328077
+ await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328078
+ } catch (err) {
328079
+ onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg(err)}`);
328080
+ }
328081
+ }
328082
+ onLog?.(`[mobile-net] macOS host proxy restored on "${service}".`);
328083
+ }
327402
328084
  var activeCleanups = /* @__PURE__ */ new Map();
327403
328085
  var exitHandlersInstalled = false;
327404
328086
  function markerPath(sessionKey) {
@@ -327433,6 +328115,14 @@ function restoreDeviceSync(marker, onLog) {
327433
328115
  run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
327434
328116
  }
327435
328117
  }
328118
+ if (marker.platform === "IOS" && marker.hostProxySet && marker.hostProxyService && process.platform === "darwin") {
328119
+ for (const args of buildHostProxyRestoreCommands(marker.hostProxyService, hostProxyPriorFromMarker(marker))) {
328120
+ try {
328121
+ execFileSync3("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
328122
+ } catch {
328123
+ }
328124
+ }
328125
+ }
327436
328126
  onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
327437
328127
  }
327438
328128
  async function reconcilePendingCleanups(onLog) {
@@ -327444,6 +328134,8 @@ async function reconcilePendingCleanups(onLog) {
327444
328134
  }
327445
328135
  for (const file of files) {
327446
328136
  if (!file.endsWith(".json")) continue;
328137
+ const sessionKey = file.slice(0, -".json".length);
328138
+ if (activeCleanups.has(sessionKey)) continue;
327447
328139
  const full = join2(PENDING_CLEANUP_DIR, file);
327448
328140
  try {
327449
328141
  const raw = await readFile(full, "utf-8");
@@ -327473,8 +328165,10 @@ function ensureExitHandlers() {
327473
328165
  for (const signal of ["SIGTERM", "SIGINT"]) {
327474
328166
  process.on(signal, () => {
327475
328167
  drain();
327476
- process.removeAllListeners(signal);
327477
- process.kill(process.pid, signal);
328168
+ if (process.listenerCount(signal) <= 1) {
328169
+ process.removeAllListeners(signal);
328170
+ process.kill(process.pid, signal);
328171
+ }
327478
328172
  });
327479
328173
  }
327480
328174
  }
@@ -327524,6 +328218,9 @@ async function startMobileNetworkCapture(options) {
327524
328218
  let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
327525
328219
  let proxySet = false;
327526
328220
  let caInstalledOnDevice = false;
328221
+ let hostProxySet = false;
328222
+ let hostProxyService;
328223
+ let hostProxyPrior;
327527
328224
  try {
327528
328225
  if (platform3 === "ANDROID") {
327529
328226
  proxySet = await androidSetProxy(deviceId, hostPort, onLog);
@@ -327538,25 +328235,50 @@ async function startMobileNetworkCapture(options) {
327538
328235
  }
327539
328236
  } else {
327540
328237
  if (!isPhysical && caCertPath) {
327541
- const { trusted, note } = await iosSimTrustCa(deviceId, caCertPath);
328238
+ const { trusted } = await iosSimTrustCa(deviceId, caCertPath);
327542
328239
  caInstalledOnDevice = trusted;
327543
- wiringReason = note;
328240
+ }
328241
+ const support = hostProxyControlSupported(platform3, isPhysical);
328242
+ if (support.supported) {
328243
+ const hp = await iosSimConfigureHostProxy(proxyHost, proxyPort, onLog);
328244
+ if (hp.configured) {
328245
+ hostProxySet = true;
328246
+ hostProxyService = hp.service;
328247
+ hostProxyPrior = hp.prior;
328248
+ wiringReason = "iOS simulator traffic routed via the macOS host proxy; HTTPS interception pending until the app completes a TLS handshake through the proxy" + (caCertPath ? ` (trust ${caCertPath} in the simulator if the app rejects our CA).` : ".");
328249
+ } else {
328250
+ wiringReason = hp.reason ?? "iOS simulator host proxy could not be configured";
328251
+ onLog?.(`[mobile-net] ${wiringReason}`);
328252
+ }
327544
328253
  } else if (isPhysical) {
327545
328254
  wiringReason = "Physical iOS HTTPS is tunneled without MITM by default. Set the Wi-Fi HTTP proxy and trust the CA manually, then run with VALIDATEQA_MOBILE_FORCE_TLS_MITM=1 to decrypt HTTPS bodies" + (caCertPath ? ` (${caCertPath}).` : ".");
328255
+ } else {
328256
+ wiringReason = support.reason ?? "iOS simulator host proxy is unsupported on this host";
328257
+ onLog?.(`[mobile-net] ${wiringReason}`);
327546
328258
  }
327547
328259
  }
327548
328260
  } catch (err) {
327549
328261
  wiringReason = `device wiring degraded: ${errMsg(err)}`;
327550
328262
  onLog?.(`[mobile-net] ${wiringReason}`);
327551
328263
  }
327552
- if (proxySet || caInstalledOnDevice) {
328264
+ if (proxySet || caInstalledOnDevice || hostProxySet) {
327553
328265
  const marker = {
327554
328266
  platform: platform3,
327555
328267
  deviceId,
327556
328268
  isPhysical,
327557
328269
  proxySet,
327558
328270
  originalProxy,
327559
- caInstalledOnDevice
328271
+ caInstalledOnDevice,
328272
+ ...hostProxySet ? {
328273
+ hostProxySet: true,
328274
+ hostProxyService,
328275
+ hostWebProxyWasEnabled: hostProxyPrior?.web.enabled,
328276
+ hostWebProxyPriorServer: hostProxyPrior?.web.server,
328277
+ hostWebProxyPriorPort: hostProxyPrior?.web.port,
328278
+ hostSecureProxyWasEnabled: hostProxyPrior?.secure.enabled,
328279
+ hostSecureProxyPriorServer: hostProxyPrior?.secure.server,
328280
+ hostSecureProxyPriorPort: hostProxyPrior?.secure.port
328281
+ } : {}
327560
328282
  };
327561
328283
  activeCleanups.set(sessionKey, marker);
327562
328284
  await writePendingCleanup(sessionKey, marker);
@@ -327576,6 +328298,9 @@ async function startMobileNetworkCapture(options) {
327576
328298
  if (proxySet && platform3 === "ANDROID") {
327577
328299
  await androidClearProxy(deviceId, originalProxy, onLog);
327578
328300
  }
328301
+ if (hostProxySet && hostProxyService && platform3 === "IOS" && !isPhysical) {
328302
+ await iosSimRestoreHostProxy(hostProxyService, hostProxyPrior, onLog);
328303
+ }
327579
328304
  if (caInstalledOnDevice && capturedCaCertPath) {
327580
328305
  if (platform3 === "ANDROID") {
327581
328306
  await androidRemoveCa(deviceId, onLog);
@@ -327611,7 +328336,11 @@ async function startMobileNetworkCapture(options) {
327611
328336
 
327612
328337
  // src/services/appium-executor.ts
327613
328338
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
328339
+ var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
328340
+ var WD_ACTION_TIMEOUT_MS = 3e4;
328341
+ var MAX_SESSION_CREATE_ATTEMPTS = 3;
327614
328342
  var execFileAsync2 = promisify2(execFile2);
328343
+ var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
327615
328344
  var WebDriverTransportError = class extends Error {
327616
328345
  isTransport = true;
327617
328346
  constructor(message) {
@@ -327623,13 +328352,21 @@ function isTransportError(err) {
327623
328352
  return err instanceof WebDriverTransportError || typeof err === "object" && err !== null && err.isTransport === true;
327624
328353
  }
327625
328354
  async function wdRequest(path, options) {
328355
+ const isSessionCreate = (options?.method ?? "GET").toUpperCase() === "POST" && path === "/session";
328356
+ const timeoutMs = isSessionCreate ? WD_SESSION_CREATE_TIMEOUT_MS : WD_ACTION_TIMEOUT_MS;
327626
328357
  let res;
327627
328358
  try {
327628
328359
  res = await fetch(`http://localhost:${getAppiumState().port}${path}`, {
327629
328360
  ...options,
328361
+ signal: AbortSignal.timeout(timeoutMs),
327630
328362
  headers: { "Content-Type": "application/json", ...options?.headers }
327631
328363
  });
327632
328364
  } catch (err) {
328365
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
328366
+ throw new WebDriverTransportError(
328367
+ `WebDriver request to ${path} timed out after ${timeoutMs}ms (device/Appium unresponsive)`
328368
+ );
328369
+ }
327633
328370
  throw new WebDriverTransportError(`WebDriver connection error: ${err instanceof Error ? err.message : String(err)}`);
327634
328371
  }
327635
328372
  if (!res.ok) {
@@ -327714,6 +328451,31 @@ async function createSession(target) {
327714
328451
  if (!sessionId) throw new Error("Appium session creation returned no session ID");
327715
328452
  return sessionId;
327716
328453
  }
328454
+ var TERMINAL_SESSION_START = /not installed|no such (app|file)|could not confirm|bundle ?id .*(not|missing)|xcodeorgid|signing|provisioning|not authorized|unauthorized/i;
328455
+ var RETRYABLE_SESSION_START = /timed out|timeout|webdriveragent|xcodebuild|instruments|could not connect|lockdownd|failed to (start|launch|create)|unable to launch|session is either terminated|unavailable|econnrefused|connection error|xcuitest|\b50[234]\b/i;
328456
+ function isRetryableSessionStartError(err) {
328457
+ const message = err instanceof Error ? err.message : String(err);
328458
+ if (TERMINAL_SESSION_START.test(message)) return false;
328459
+ if (isTransportError(err)) return true;
328460
+ return RETRYABLE_SESSION_START.test(message);
328461
+ }
328462
+ async function createSessionWithRetry(target, onRetry) {
328463
+ let lastErr;
328464
+ for (let attempt = 1; attempt <= MAX_SESSION_CREATE_ATTEMPTS; attempt++) {
328465
+ try {
328466
+ return await createSession(target);
328467
+ } catch (err) {
328468
+ lastErr = err;
328469
+ if (attempt >= MAX_SESSION_CREATE_ATTEMPTS || !isRetryableSessionStartError(err)) throw err;
328470
+ const delayMs = Math.pow(2, attempt) * 1e3;
328471
+ onRetry?.(
328472
+ `Appium session start failed (attempt ${attempt}/${MAX_SESSION_CREATE_ATTEMPTS}, retrying in ${delayMs}ms): ${err instanceof Error ? err.message : String(err)}`
328473
+ );
328474
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
328475
+ }
328476
+ }
328477
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
328478
+ }
327717
328479
  async function openDeepLink(sessionId, platform3, deeplink, appId) {
327718
328480
  const args = platform3 === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
327719
328481
  await wdRequest(`/session/${sessionId}/execute/sync`, {
@@ -327815,6 +328577,61 @@ async function takeScreenshot(sessionId) {
327815
328577
  return null;
327816
328578
  }
327817
328579
  }
328580
+ function mobileRecordingEnabled() {
328581
+ const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
328582
+ return value !== "0" && value !== "false";
328583
+ }
328584
+ function readPositiveIntEnv(name, fallback, min, max) {
328585
+ const raw = process.env[name];
328586
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
328587
+ if (!Number.isFinite(parsed)) return fallback;
328588
+ return Math.max(min, Math.min(max, parsed));
328589
+ }
328590
+ async function startScreenRecording(sessionId, platform3, log2) {
328591
+ if (!mobileRecordingEnabled()) {
328592
+ log2("[mobile-video] recording disabled by VALIDATEQA_MOBILE_RUN_VIDEO=0");
328593
+ return false;
328594
+ }
328595
+ const timeLimit = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_SECONDS", 180, 5, 180);
328596
+ const options = {
328597
+ timeLimit: String(timeLimit),
328598
+ forceRestart: true
328599
+ };
328600
+ if (platform3 === "ANDROID") {
328601
+ options.bitRate = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_BITRATE", 75e4, 1e5, 4e6);
328602
+ } else {
328603
+ options.videoQuality = process.env.VALIDATEQA_MOBILE_RECORDING_IOS_QUALITY || "low";
328604
+ }
328605
+ try {
328606
+ await wdRequest(`/session/${sessionId}/appium/start_recording_screen`, {
328607
+ method: "POST",
328608
+ body: JSON.stringify({ options })
328609
+ });
328610
+ log2(`[mobile-video] screen recording started (${timeLimit}s max)`);
328611
+ return true;
328612
+ } catch (err) {
328613
+ log2(`[mobile-video] screen recording unavailable: ${err instanceof Error ? err.message : String(err)}`);
328614
+ return false;
328615
+ }
328616
+ }
328617
+ async function stopScreenRecording(sessionId, log2) {
328618
+ try {
328619
+ const result = await wdRequest(`/session/${sessionId}/appium/stop_recording_screen`, {
328620
+ method: "POST",
328621
+ body: JSON.stringify({ options: {} })
328622
+ });
328623
+ const value = typeof result?.value === "string" ? result.value.trim() : "";
328624
+ if (!value) {
328625
+ log2("[mobile-video] screen recording stopped but Appium returned no video");
328626
+ return void 0;
328627
+ }
328628
+ log2(`[mobile-video] screen recording captured (${Math.round(value.length / 1024)}KB base64)`);
328629
+ return value;
328630
+ } catch (err) {
328631
+ log2(`[mobile-video] screen recording stop failed: ${err instanceof Error ? err.message : String(err)}`);
328632
+ return void 0;
328633
+ }
328634
+ }
327818
328635
  function boundsFromStep(step) {
327819
328636
  const bounds = step.metadata?.bounds;
327820
328637
  if (bounds && typeof bounds === "object" && !Array.isArray(bounds) && typeof bounds.x === "number" && typeof bounds.y === "number" && typeof bounds.width === "number" && typeof bounds.height === "number") {
@@ -327934,7 +328751,8 @@ async function sendKeysToFocusedInput(sessionId, value) {
327934
328751
  });
327935
328752
  }
327936
328753
  function encodeAndroidInputText(value) {
327937
- return value.replace(/%/g, "%25").replace(/\s/g, "%s");
328754
+ const inputEncoded = value.replace(/%/g, "%25").replace(/\s/g, "%s");
328755
+ return `'${inputEncoded.replace(/'/g, `'\\''`)}'`;
327938
328756
  }
327939
328757
  async function sendAndroidTextToFocusedInput(deviceId, value) {
327940
328758
  if (!deviceId) {
@@ -328158,9 +328976,29 @@ async function executeAssertText(sessionId, step) {
328158
328976
  const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
328159
328977
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
328160
328978
  const actualText = typeof result?.value === "string" ? result.value : "";
328161
- if (!actualText.includes(step.assertion)) {
328162
- throw new Error(`Text assertion failed: expected "${step.assertion}" to be contained in "${actualText}"`);
328979
+ const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
328980
+ if (!textCandidates.some((value) => value.includes(step.assertion))) {
328981
+ throw new Error(`Text assertion failed: expected "${step.assertion}" to be contained in ${JSON.stringify(textCandidates)}`);
328982
+ }
328983
+ }
328984
+ async function readElementTextCandidates(sessionId, elementId, actualText) {
328985
+ const values = [];
328986
+ const push = (value) => {
328987
+ if (typeof value !== "string") return;
328988
+ const trimmed = value.trim();
328989
+ if (trimmed.length === 0 || values.includes(trimmed)) return;
328990
+ values.push(trimmed);
328991
+ };
328992
+ push(actualText);
328993
+ for (const attr of MOBILE_TEXT_ATTRIBUTE_NAMES) {
328994
+ try {
328995
+ const result = await wdRequest(`/session/${sessionId}/element/${elementId}/attribute/${encodeURIComponent(attr)}`);
328996
+ push(result?.value);
328997
+ } catch (err) {
328998
+ if (isTransportError(err)) throw err;
328999
+ }
328163
329000
  }
329001
+ return values;
328164
329002
  }
328165
329003
  async function executeMobileTest(options) {
328166
329004
  const logLines = [];
@@ -328172,6 +329010,9 @@ async function executeMobileTest(options) {
328172
329010
  let sessionId = null;
328173
329011
  let capture = null;
328174
329012
  let captureStopped = false;
329013
+ let recordingStarted = false;
329014
+ let recordingStopped = false;
329015
+ let video;
328175
329016
  const stopCapture = async () => {
328176
329017
  if (!capture || captureStopped) return apiCalls;
328177
329018
  captureStopped = true;
@@ -328186,6 +329027,15 @@ async function executeMobileTest(options) {
328186
329027
  }
328187
329028
  return apiCalls;
328188
329029
  };
329030
+ const stopRecording = async () => {
329031
+ if (!sessionId || !recordingStarted || recordingStopped) return video;
329032
+ recordingStopped = true;
329033
+ const captured = await stopScreenRecording(sessionId, log2);
329034
+ if (captured) {
329035
+ video = captured;
329036
+ }
329037
+ return video;
329038
+ };
328189
329039
  try {
328190
329040
  log2("Checking Appium server health...");
328191
329041
  const health = await checkAppiumHealth();
@@ -328239,13 +329089,13 @@ async function executeMobileTest(options) {
328239
329089
  }
328240
329090
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
328241
329091
  try {
328242
- sessionId = await createSession({
329092
+ sessionId = await createSessionWithRetry({
328243
329093
  appId: options.target.appId,
328244
329094
  platform: options.target.platform,
328245
329095
  deviceId: selectedDevice.id,
328246
329096
  platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
328247
329097
  isPhysical: selectedDevice.isPhysical
328248
- });
329098
+ }, (msg) => log2(msg));
328249
329099
  } catch (err) {
328250
329100
  const base2 = err instanceof Error ? err.message : String(err);
328251
329101
  if (selectedDevice.isPhysical) {
@@ -328260,6 +329110,7 @@ async function executeMobileTest(options) {
328260
329110
  throw err;
328261
329111
  }
328262
329112
  log2(`Session created: ${sessionId}`);
329113
+ recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
328263
329114
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
328264
329115
  try {
328265
329116
  log2(`Opening deep link: ${options.target.deeplink}`);
@@ -328320,7 +329171,9 @@ async function executeMobileTest(options) {
328320
329171
  break;
328321
329172
  }
328322
329173
  }
328323
- await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
329174
+ if (step.action !== "home" && step.action !== "back") {
329175
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
329176
+ }
328324
329177
  } catch (err) {
328325
329178
  status = "failed";
328326
329179
  stepError = err instanceof Error ? err.message : String(err);
@@ -328349,10 +329202,12 @@ async function executeMobileTest(options) {
328349
329202
  const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
328350
329203
  const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
328351
329204
  await stopCapture();
329205
+ await stopRecording();
328352
329206
  return {
328353
329207
  passed: allPassed,
328354
329208
  stepResults,
328355
329209
  screenshots,
329210
+ ...video ? { video, videoMimeType: "video/mp4" } : {},
328356
329211
  apiCalls,
328357
329212
  logs: logLines.join("\n"),
328358
329213
  error: firstError,
@@ -328363,10 +329218,12 @@ async function executeMobileTest(options) {
328363
329218
  const errorMsg = err instanceof Error ? err.message : String(err);
328364
329219
  log2(`Fatal error: ${errorMsg}`);
328365
329220
  await stopCapture();
329221
+ await stopRecording();
328366
329222
  return {
328367
329223
  passed: false,
328368
329224
  stepResults,
328369
329225
  screenshots,
329226
+ ...video ? { video, videoMimeType: "video/mp4" } : {},
328370
329227
  apiCalls,
328371
329228
  logs: logLines.join("\n"),
328372
329229
  error: errorMsg,
@@ -328378,6 +329235,7 @@ async function executeMobileTest(options) {
328378
329235
  };
328379
329236
  } finally {
328380
329237
  await stopCapture();
329238
+ await stopRecording();
328381
329239
  if (sessionId) {
328382
329240
  log2("Tearing down Appium session...");
328383
329241
  await deleteSession(sessionId);
@@ -328418,7 +329276,7 @@ async function createMobileDiscoverySession(target, options) {
328418
329276
  isPhysical: selectedDevice.isPhysical === true,
328419
329277
  platformVersion: selectedDevice.platformVersion
328420
329278
  });
328421
- const sessionId = await createSession({
329279
+ const sessionId = await createSessionWithRetry({
328422
329280
  appId: target.appId,
328423
329281
  platform: target.platform,
328424
329282
  deviceId: selectedDevice.id,
@@ -329229,7 +330087,7 @@ function stopSessionReaper() {
329229
330087
  }
329230
330088
  }
329231
330089
  async function reapIdleSession() {
329232
- if (!state.appiumSessionId || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
330090
+ if (!state.appiumSessionId || mirrorSessionOwner !== "recording" || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
329233
330091
  return;
329234
330092
  }
329235
330093
  const sessionId = state.appiumSessionId;
@@ -331746,6 +332604,11 @@ ${finalVerifyResult.logs ?? ""}`;
331746
332604
  });
331747
332605
  requestLiveBrowserHeartbeat();
331748
332606
  },
332607
+ // D-D6 — upload ONE baseline PNG per newly-observed screen through the
332608
+ // same streaming /page-screenshot relay the web path uses (mobile has no
332609
+ // route, so the screen-id is the key). The returned storage key rides the
332610
+ // explore checkpoint so the server links it to SitePage.screenshotKey.
332611
+ onScreenshotUpload: (screenId, base64) => onScreenshot(screenId, base64),
331749
332612
  onExploreComplete,
331750
332613
  onTestGenerated,
331751
332614
  onPlanReady
@@ -332048,23 +332911,39 @@ ${finalVerifyResult.logs ?? ""}`;
332048
332911
  log.fail("ERROR - No mobile steps");
332049
332912
  return;
332050
332913
  }
332914
+ const maxRetries = typeof run2.retryCount === "number" && Number.isFinite(run2.retryCount) ? Math.max(0, Math.min(2, Math.floor(run2.retryCount))) : 0;
332051
332915
  setExecutorBusy(true);
332052
- let result2;
332916
+ let result2 = null;
332917
+ const attemptLogs = [];
332053
332918
  try {
332054
- result2 = await executeMobileTest({
332055
- steps: mobileSteps,
332056
- target: {
332057
- appId: target.appId,
332058
- platform: target.platform,
332059
- platformVersion: target.platformVersion,
332060
- recordedDeviceId: target.recordedDeviceId,
332061
- launchMode: target.launchMode,
332062
- deeplink: target.deeplink
332063
- }
332064
- });
332919
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
332920
+ if (attempt > 0) {
332921
+ log.info(`Retrying mobile test after failed Appium step (${attempt}/${maxRetries})`);
332922
+ }
332923
+ result2 = await executeMobileTest({
332924
+ steps: mobileSteps,
332925
+ target: {
332926
+ appId: target.appId,
332927
+ platform: target.platform,
332928
+ platformVersion: target.platformVersion,
332929
+ recordedDeviceId: target.recordedDeviceId,
332930
+ launchMode: target.launchMode,
332931
+ deeplink: target.deeplink
332932
+ }
332933
+ });
332934
+ attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
332935
+ ${result2.logs}`);
332936
+ if (result2.passed || result2.environmental || attempt >= maxRetries) break;
332937
+ }
332065
332938
  } finally {
332066
332939
  setExecutorBusy(false);
332067
332940
  }
332941
+ if (!result2) {
332942
+ throw new Error("Appium execution did not produce a result.");
332943
+ }
332944
+ if (attemptLogs.length > 1) {
332945
+ result2 = { ...result2, logs: attemptLogs.join("\n\n") };
332946
+ }
332068
332947
  const duration2 = Date.now() - startTime;
332069
332948
  const status = result2.environmental ? "ERROR" : result2.passed ? "PASSED" : "FAILED";
332070
332949
  const reportedError = result2.environmental ? `CONFIG_ISSUE: ${result2.environmental}` : result2.error ?? void 0;
@@ -332072,6 +332951,8 @@ ${finalVerifyResult.logs ?? ""}`;
332072
332951
  status,
332073
332952
  stepResults: result2.stepResults,
332074
332953
  screenshots: result2.screenshots,
332954
+ video: result2.video,
332955
+ videoMimeType: result2.videoMimeType,
332075
332956
  apiCalls: result2.apiCalls,
332076
332957
  logs: result2.logs,
332077
332958
  error: reportedError,
@@ -332383,7 +333264,7 @@ async function startRunner(opts) {
332383
333264
  };
332384
333265
  const sendHeartbeat = async () => {
332385
333266
  try {
332386
- const devices = enableMobile ? discoverAllDevices() : [];
333267
+ const devices = enableMobile ? getCachedDeviceSnapshot() : [];
332387
333268
  const capabilities = enableMobile ? {
332388
333269
  web: true,
332389
333270
  ios: devices.some((d) => d.platform === "IOS" && d.isAvailable && d.state === "BOOTED"),