@validate.qa/runner 1.0.12 → 1.0.14

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 +1068 -133
  2. package/dist/cli.mjs +1068 -133
  3. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -233,6 +233,9 @@ var require_ai_provider = __commonJS({
233
233
  exports2.getNoThinkingRequestOptions = getNoThinkingRequestOptions;
234
234
  exports2.getAIClient = getAIClient;
235
235
  exports2.getClientForModel = getClientForModel;
236
+ exports2.shouldProxyAIThroughServerForLocalRunner = shouldProxyAIThroughServerForLocalRunner;
237
+ exports2.getRunnerAIClient = getRunnerAIClient;
238
+ exports2.getRunnerClientForModel = getRunnerClientForModel;
236
239
  exports2.getModel = getModel;
237
240
  exports2.resolveModelForTask = resolveModelForTask;
238
241
  exports2.getAvailableModels = getAvailableModels;
@@ -510,6 +513,21 @@ var require_ai_provider = __commonJS({
510
513
  function getClientForModel(modelId) {
511
514
  return buildClientForProvider(getProviderForModel(modelId));
512
515
  }
516
+ function shouldProxyAIThroughServerForLocalRunner() {
517
+ const serverUrl = (process.env.SERVER_URL ?? "").trim();
518
+ const token = (process.env.AUTH_TOKEN || process.env.RUNNER_TOKEN || "").trim();
519
+ return serverUrl.length > 0 && token.startsWith("urt_");
520
+ }
521
+ function getRunnerAIClient() {
522
+ if (shouldProxyAIThroughServerForLocalRunner())
523
+ return getServerProxiedAIClient();
524
+ return getAIClient();
525
+ }
526
+ function getRunnerClientForModel(modelId) {
527
+ if (shouldProxyAIThroughServerForLocalRunner())
528
+ return getServerProxiedClientForModel(modelId);
529
+ return getClientForModel(modelId);
530
+ }
513
531
  function getModel(type) {
514
532
  return MODEL_TASK_MAP[getActiveModelId()][type];
515
533
  }
@@ -580,7 +598,7 @@ var require_ai_provider = __commonJS({
580
598
  const create = async (params, options) => {
581
599
  const modelId = typeof params.model === "string" ? params.model : "";
582
600
  if (!modelId) {
583
- throw new Error("server-proxied AI client requires params.model (catalog id)");
601
+ throw new Error("server-proxied AI client requires params.model");
584
602
  }
585
603
  const body = {
586
604
  modelId,
@@ -750,6 +768,21 @@ var require_mobile_source_parser = __commonJS({
750
768
  const attrText = inner.slice(tag.length);
751
769
  return { tag, attrs: parseAttributes(attrText), selfClosing };
752
770
  }
771
+ function findTagEnd(xml, from) {
772
+ let quote = null;
773
+ for (let i = from; i < xml.length; i++) {
774
+ const ch = xml[i];
775
+ if (quote !== null) {
776
+ if (ch === quote)
777
+ quote = null;
778
+ } else if (ch === '"' || ch === "'") {
779
+ quote = ch;
780
+ } else if (ch === ">") {
781
+ return i;
782
+ }
783
+ }
784
+ return -1;
785
+ }
753
786
  function tokenize(xml) {
754
787
  if (!xml || typeof xml !== "string")
755
788
  return null;
@@ -772,7 +805,7 @@ var require_mobile_source_parser = __commonJS({
772
805
  i = end === -1 ? len : end + 3;
773
806
  continue;
774
807
  }
775
- const gt = xml.indexOf(">", lt + 1);
808
+ const gt = findTagEnd(xml, lt + 1);
776
809
  if (gt === -1)
777
810
  break;
778
811
  const tagBody = xml.slice(lt + 1, gt);
@@ -1324,6 +1357,9 @@ var init_constants = __esm({
1324
1357
  function toJsStringLiteral(value) {
1325
1358
  return JSON.stringify(value);
1326
1359
  }
1360
+ function toInlineCommentText(value) {
1361
+ return value.replace(/[\r\n\u2028\u2029]+/g, " ");
1362
+ }
1327
1363
  function isPlainObject(value) {
1328
1364
  return typeof value === "object" && value !== null && !Array.isArray(value);
1329
1365
  }
@@ -1529,7 +1565,7 @@ function buildAppiumCode(testName, target, steps) {
1529
1565
  `// 3. A booted device/simulator matching the capabilities below`,
1530
1566
  `// This is NOT a WDIO testrunner spec: it uses the standalone remote() client`,
1531
1567
  `// (driver.$, no auto-injected $/expect/describe globals) so it has no harness`,
1532
- `// dependency and runs with a plain \`tsx ${testName}.spec.ts\`.`,
1568
+ `// dependency and runs with a plain \`tsx ${toInlineCommentText(testName)}.spec.ts\`.`,
1533
1569
  `import { remote } from 'webdriverio';`,
1534
1570
  ``,
1535
1571
  `// Resolve the first selector that exists, mirroring the runner's locator`,
@@ -1558,6 +1594,10 @@ function buildAppiumCode(testName, target, steps) {
1558
1594
  ` }`,
1559
1595
  `}`,
1560
1596
  ``,
1597
+ `async function __clearFocused(driver: WebdriverIO.Browser) {`,
1598
+ ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).clearValue();`,
1599
+ `}`,
1600
+ ``,
1561
1601
  `async function __textCandidates(el: WebdriverIO.Element) {`,
1562
1602
  ` const __values: string[] = [];`,
1563
1603
  ` const __push = (__value: unknown) => {`,
@@ -1608,7 +1648,7 @@ function buildAppiumCode(testName, target, steps) {
1608
1648
  ` // Canonical execution source: steps JSON`
1609
1649
  ];
1610
1650
  const stepLines = steps.flatMap((step) => {
1611
- const lines = [` // Step ${step.order + 1}: ${step.description}`];
1651
+ const lines = [` // Step ${step.order + 1}: ${toInlineCommentText(step.description)}`];
1612
1652
  const candidates = selectorCandidatesForStep(step);
1613
1653
  const candidatesLiteral = `[${candidates.map(toJsStringLiteral).join(", ")}]`;
1614
1654
  const hasSelector = candidates.length > 0;
@@ -1651,6 +1691,12 @@ function buildAppiumCode(testName, target, steps) {
1651
1691
  lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1652
1692
  } else if (step.action === "clear" && hasSelector) {
1653
1693
  lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
1694
+ } else if (step.action === "clear" && bounds) {
1695
+ const x = Math.round(bounds.x + bounds.width / 2);
1696
+ const y = Math.round(bounds.y + bounds.height / 2);
1697
+ lines.push(` await __tapAt(driver, ${x}, ${y});`);
1698
+ lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1699
+ lines.push(` await __clearFocused(driver);`);
1654
1700
  } else if (step.action === "assertVisible" && hasSelector) {
1655
1701
  lines.push(` if (!(await (await findFirst(driver, ${candidatesLiteral})).isDisplayed())) {`);
1656
1702
  lines.push(` throw new Error('Assertion failed: element not displayed: ' + ${toJsStringLiteral(candidatesLiteral)});`);
@@ -1658,6 +1704,7 @@ function buildAppiumCode(testName, target, steps) {
1658
1704
  } else if (step.action === "assertText" && hasSelector) {
1659
1705
  lines.push(` {`);
1660
1706
  lines.push(` const __expected = ${toJsStringLiteral(step.assertion ?? "")};`);
1707
+ lines.push(` if (__expected.length === 0) throw new Error('assertText step is missing expected text');`);
1661
1708
  lines.push(` const __actual = await __textCandidates(await findFirst(driver, ${candidatesLiteral}));`);
1662
1709
  lines.push(` if (!__actual.some((__value) => __value.includes(__expected))) {`);
1663
1710
  lines.push(` throw new Error('Assertion failed: expected text to contain ' + JSON.stringify(__expected) + ' but got ' + JSON.stringify(__actual));`);
@@ -4366,6 +4413,7 @@ var require_mobile_observation = __commonJS({
4366
4413
  Object.defineProperty(exports2, "__esModule", { value: true });
4367
4414
  exports2.renderMobileObservation = renderMobileObservation;
4368
4415
  exports2.summarizeScreenForEvidence = summarizeScreenForEvidence;
4416
+ exports2.buildCollectedElementsForScreen = buildCollectedElementsForScreen;
4369
4417
  var DEFAULT_MAX_ELEMENTS = 60;
4370
4418
  var MAX_LABEL_LENGTH = 80;
4371
4419
  var EVIDENCE_LABEL_LIMIT = 40;
@@ -4407,6 +4455,46 @@ var require_mobile_observation = __commonJS({
4407
4455
  }
4408
4456
  return { elementCount, actionableCount, labels };
4409
4457
  }
4458
+ function buildCollectedElementsForScreen(elements, opts = {}) {
4459
+ const navigationRefs = opts.navigationRefs ?? EMPTY_REF_SET;
4460
+ const out = [];
4461
+ const seen = /* @__PURE__ */ new Set();
4462
+ for (const element of elements) {
4463
+ if (!element.actionable)
4464
+ continue;
4465
+ const collected = toCollectedPageElement(element, navigationRefs.has(element.ref));
4466
+ const identity = `${collected.elementType}|${collected.testId ?? ""}|${collected.label ?? ""}`;
4467
+ if (seen.has(identity))
4468
+ continue;
4469
+ seen.add(identity);
4470
+ out.push(collected);
4471
+ }
4472
+ return out;
4473
+ }
4474
+ var EMPTY_REF_SET = /* @__PURE__ */ new Set();
4475
+ function toCollectedPageElement(element, triggersNavigation) {
4476
+ const label = element.label?.trim() || element.text?.trim() || void 0;
4477
+ const stableId = element.accessibilityId?.trim() || element.resourceId?.trim() || void 0;
4478
+ const locators = [];
4479
+ if (stableId)
4480
+ locators.push({ strategy: "getByTestId", value: stableId, confidence: 0.95 });
4481
+ const xpath = element.xpath?.trim();
4482
+ if (xpath)
4483
+ locators.push({ strategy: "css", value: xpath, confidence: 0.4 });
4484
+ const collected = {
4485
+ elementType: element.role || "View",
4486
+ triggersNavigation
4487
+ };
4488
+ if (label)
4489
+ collected.label = label;
4490
+ if (stableId)
4491
+ collected.testId = stableId;
4492
+ if (locators.length > 0)
4493
+ collected.locators = locators;
4494
+ if (element.enabled === false)
4495
+ collected.isDisabled = true;
4496
+ return collected;
4497
+ }
4410
4498
  function buildHeaderLine(screen) {
4411
4499
  const parts = ["screen:"];
4412
4500
  const name = screenDisplayName(screen.screenSignal);
@@ -4855,7 +4943,7 @@ var require_mobile_explorer = __commonJS({
4855
4943
  }
4856
4944
  return sections.join("\n");
4857
4945
  }
4858
- function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId) {
4946
+ function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId, existingScreens) {
4859
4947
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
4860
4948
  const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
4861
4949
  const surveyBody = `You are a mobile QA exploration agent driving a real ${platformLabel} app on a connected device. Your goal: MAP every reachable screen so downstream agents know what to test. You are NOT testing \u2014 you are building a complete screen map.
@@ -4901,13 +4989,18 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
4901
4989
  ## Application Brief
4902
4990
  ${appBrief}
4903
4991
  Use this to prioritize which screens and flows to cover.` : "";
4904
- return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}`;
4992
+ const existingScreensBlock = mode === "survey" && existingScreens && existingScreens.length > 0 ? `
4993
+
4994
+ ## ALREADY DISCOVERED SCREENS (skip these; find new)
4995
+ 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.
4996
+ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.name}"` : ""}${s.screenType ? ` [${s.screenType}]` : ""}`).join("\n")}` : "";
4997
+ return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}${existingScreensBlock}`;
4905
4998
  }
4906
4999
  function buildKickoffMessage(mode) {
4907
5000
  return mode === "survey" ? "Start by calling mobile_snapshot on the launch screen, then capture_screen. Map the entire app: open every tab, menu, and one row per list. Record transitions as you go." : "Start by calling mobile_snapshot on the launch screen. Then use the features: fill fields, submit forms, open menus, and observe outcomes. Record journeys after meaningful flows.";
4908
5001
  }
4909
5002
  async function runMobileExplorePhase(args) {
4910
- const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot } = args;
5003
+ const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot, onScreenshotUpload, existingScreens } = args;
4911
5004
  const logs2 = [];
4912
5005
  const log2 = (line) => {
4913
5006
  logs2.push(line);
@@ -4931,6 +5024,9 @@ Use this to prioritize which screens and flows to cover.` : "";
4931
5024
  log2(`Budget: ${maxIterations} iterations | app: ${ctx.target?.appId ?? "(unknown)"}`);
4932
5025
  const screenshots = [];
4933
5026
  const pageScreenshots = {};
5027
+ const pageScreenshotKeys = {};
5028
+ const uploadedScreenIds = /* @__PURE__ */ new Set();
5029
+ const pendingScreenshotUploads = [];
4934
5030
  const collectedTransitions = [];
4935
5031
  const journeys = [];
4936
5032
  const counters = {
@@ -4949,6 +5045,14 @@ Use this to prioritize which screens and flows to cover.` : "";
4949
5045
  screenshots.push(base64);
4950
5046
  if (screenId)
4951
5047
  pageScreenshots[screenId] = base64;
5048
+ if (screenId && onScreenshotUpload && !uploadedScreenIds.has(screenId)) {
5049
+ uploadedScreenIds.add(screenId);
5050
+ pendingScreenshotUploads.push(onScreenshotUpload(screenId, base64).then((key) => {
5051
+ if (key)
5052
+ pageScreenshotKeys[screenId] = key;
5053
+ }).catch(() => {
5054
+ }));
5055
+ }
4952
5056
  try {
4953
5057
  onScreenshot?.(base64);
4954
5058
  } catch {
@@ -5053,7 +5157,7 @@ Use this to prioritize which screens and flows to cover.` : "";
5053
5157
  return null;
5054
5158
  return { element, bounds: element.bounds };
5055
5159
  };
5056
- const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId);
5160
+ const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId, existingScreens);
5057
5161
  const kickoff = buildKickoffMessage(mode);
5058
5162
  const recentExchanges = [];
5059
5163
  const transientDirectives = [];
@@ -5261,6 +5365,9 @@ Exploration complete: ${summary}`);
5261
5365
  summary = `Explore phase aborted (${truncatedReason}) after mapping ${state2.getScreenCount()} screen(s).`;
5262
5366
  }
5263
5367
  }
5368
+ if (pendingScreenshotUploads.length > 0) {
5369
+ await Promise.allSettled(pendingScreenshotUploads);
5370
+ }
5264
5371
  const collectedPages = state2.buildInMemoryScreenMap();
5265
5372
  counters.pagesVisited = collectedPages.length;
5266
5373
  if (counters.pagesDiscovered < collectedPages.length) {
@@ -5295,6 +5402,9 @@ Exploration complete: ${summary}`);
5295
5402
  if (Object.keys(pageScreenshots).length > 0) {
5296
5403
  result.pageScreenshots = pageScreenshots;
5297
5404
  }
5405
+ if (Object.keys(pageScreenshotKeys).length > 0) {
5406
+ result.pageScreenshotKeys = pageScreenshotKeys;
5407
+ }
5298
5408
  if (truncated) {
5299
5409
  result.truncated = true;
5300
5410
  result.truncatedReason = truncatedReason;
@@ -5796,6 +5906,44 @@ var require_snapshot_parser = __commonJS({
5796
5906
  };
5797
5907
  var SUBMIT_LABEL_RE = /^(submit|sign\s*in|sign\s*up|log\s*in|register|create|save|send|confirm|continue|next|apply|post|publish|add|update|go)$/i;
5798
5908
  var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
5909
+ var RICH_CONTEXT_ROLES = /* @__PURE__ */ new Set(["table", "grid", "row", "radiogroup"]);
5910
+ var RICH_CONTEXT_LABEL_MAX = 60;
5911
+ var VALUE_BEARING_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "combobox", "spinbutton", "listbox", "slider"]);
5912
+ 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;
5913
+ var REDACTED_VALUE = "[redacted]";
5914
+ var VALUE_TEXT_MAX = 120;
5915
+ 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;
5916
+ var STATIC_MESSAGES_MAX = 12;
5917
+ var OPTIONS_PER_ELEMENT_MAX = 24;
5918
+ var URL_CHILD_LINE_RE = /^(\s*)- \/url:\s*(.+)$/;
5919
+ 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;
5920
+ function stripYamlQuotes(raw) {
5921
+ const trimmed = raw.trim();
5922
+ if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
5923
+ return trimmed.slice(1, -1);
5924
+ }
5925
+ return trimmed;
5926
+ }
5927
+ function extractTrailingText(attrs) {
5928
+ const lastBracket = attrs.lastIndexOf("]");
5929
+ const tail = lastBracket >= 0 ? attrs.slice(lastBracket + 1) : attrs;
5930
+ const match = /^\s*:\s+(.+)$/.exec(tail);
5931
+ if (!match)
5932
+ return null;
5933
+ const text = stripYamlQuotes(match[1]);
5934
+ if (!text || /^(?:\||\|-|>|>-)$/.test(text))
5935
+ return null;
5936
+ return text;
5937
+ }
5938
+ function isSensitiveValueField(name, placeholder) {
5939
+ return SENSITIVE_VALUE_LABEL_RE.test(`${name ?? ""} ${placeholder ?? ""}`);
5940
+ }
5941
+ function scrubMessageText(text) {
5942
+ return text.replace(MESSAGE_TOKEN_RE, REDACTED_VALUE);
5943
+ }
5944
+ function capText(text, max) {
5945
+ return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
5946
+ }
5799
5947
  function isProbablyDynamicTestId(testId) {
5800
5948
  if (testId.length < 6)
5801
5949
  return false;
@@ -5871,9 +6019,12 @@ var require_snapshot_parser = __commonJS({
5871
6019
  }
5872
6020
  return e.name;
5873
6021
  }
5874
- function parseSnapshot(snapshotText) {
6022
+ function parseSnapshot(snapshotText, options = {}) {
6023
+ const richParse = options.richParse ?? process.env.DISCOVERY_SNAPSHOT_RICH_PARSE === "true";
5875
6024
  const lines = snapshotText.split("\n");
5876
6025
  const elements = [];
6026
+ const liveMessages = [];
6027
+ const textMessages = [];
5877
6028
  const seenRefs = /* @__PURE__ */ new Set();
5878
6029
  let parsedElementLineCount = 0;
5879
6030
  let genericLabelCount = 0;
@@ -5882,6 +6033,21 @@ var require_snapshot_parser = __commonJS({
5882
6033
  const contextStack = [];
5883
6034
  const completedForms = [];
5884
6035
  for (const line of lines) {
6036
+ if (richParse) {
6037
+ const urlMatch = URL_CHILD_LINE_RE.exec(line);
6038
+ if (urlMatch) {
6039
+ const urlIndent = urlMatch[1].length;
6040
+ for (let i = elements.length - 1; i >= 0; i--) {
6041
+ const el = elements[i];
6042
+ if (el.indentDepth < urlIndent) {
6043
+ if (el.role === "link" && !el.href)
6044
+ el.href = capText(stripYamlQuotes(urlMatch[2]), 300);
6045
+ break;
6046
+ }
6047
+ }
6048
+ continue;
6049
+ }
6050
+ }
5885
6051
  const match = ELEMENT_LINE_RE.exec(line);
5886
6052
  if (!match)
5887
6053
  continue;
@@ -5903,7 +6069,7 @@ var require_snapshot_parser = __commonJS({
5903
6069
  }
5904
6070
  const headingLevelMatch = LEVEL_RE.exec(attrs);
5905
6071
  const headingLevel = headingLevelMatch ? Number.parseInt(headingLevelMatch[1], 10) : null;
5906
- const contextLabel = buildContextLabel(role, name ?? null, headingLevel);
6072
+ const contextLabel = buildContextLabel(role, name ?? null, headingLevel, richParse);
5907
6073
  if (contextLabel) {
5908
6074
  contextStack.push({ indent: indentDepth, label: contextLabel });
5909
6075
  }
@@ -5916,6 +6082,40 @@ var require_snapshot_parser = __commonJS({
5916
6082
  });
5917
6083
  continue;
5918
6084
  }
6085
+ if (richParse) {
6086
+ if (role === "option" && name) {
6087
+ for (let i = elements.length - 1; i >= 0; i--) {
6088
+ const el = elements[i];
6089
+ if (el.indentDepth < indentDepth) {
6090
+ if (el.role === "combobox" || el.role === "listbox") {
6091
+ el.options ??= [];
6092
+ if (el.options.length < OPTIONS_PER_ELEMENT_MAX) {
6093
+ el.options.push({ label: capText(name, 80), selected: parseBooleanState(SELECTED_RE.exec(attrs)) === true });
6094
+ }
6095
+ }
6096
+ break;
6097
+ }
6098
+ }
6099
+ }
6100
+ if (role === "alert" || role === "status" || role === "paragraph" || role === "text") {
6101
+ const text = extractTrailingText(attrs) ?? (name || null);
6102
+ const isLiveRegion = role === "alert" || role === "status";
6103
+ if (text && (isLiveRegion || SIGNAL_TEXT_RE.test(text))) {
6104
+ const capped = capText(scrubMessageText(text), VALUE_TEXT_MAX);
6105
+ const ref2 = REF_RE.exec(attrs)?.[1] ?? null;
6106
+ if (isLiveRegion) {
6107
+ const textDupIndex = textMessages.findIndex((m) => m.text === capped);
6108
+ if (textDupIndex >= 0)
6109
+ textMessages.splice(textDupIndex, 1);
6110
+ if (liveMessages.length < STATIC_MESSAGES_MAX && !liveMessages.some((m) => m.text === capped)) {
6111
+ liveMessages.push({ role, text: capped, ref: ref2 });
6112
+ }
6113
+ } else if (textMessages.length < STATIC_MESSAGES_MAX && !textMessages.some((m) => m.text === capped) && !liveMessages.some((m) => m.text === capped)) {
6114
+ textMessages.push({ role: "text", text: capped, ref: ref2 });
6115
+ }
6116
+ }
6117
+ }
6118
+ }
5919
6119
  if (!INTERACTIVE_ROLES.has(role))
5920
6120
  continue;
5921
6121
  const refMatch = REF_RE.exec(attrs);
@@ -5948,6 +6148,13 @@ var require_snapshot_parser = __commonJS({
5948
6148
  }
5949
6149
  const isLink = role === "link";
5950
6150
  const isSubmitButton = role === "button" && name != null && SUBMIT_LABEL_RE.test(name);
6151
+ let value = null;
6152
+ if (richParse && VALUE_BEARING_ROLES.has(role)) {
6153
+ const trailing = extractTrailingText(attrs);
6154
+ if (trailing) {
6155
+ value = isSensitiveValueField(name ?? null, placeholder) ? REDACTED_VALUE : capText(trailing, VALUE_TEXT_MAX);
6156
+ }
6157
+ }
5951
6158
  const element = {
5952
6159
  ref,
5953
6160
  role,
@@ -5969,7 +6176,10 @@ var require_snapshot_parser = __commonJS({
5969
6176
  pressed,
5970
6177
  headingLevel,
5971
6178
  currentState,
5972
- containerPath
6179
+ containerPath,
6180
+ href: null,
6181
+ value,
6182
+ options: null
5973
6183
  };
5974
6184
  if (isGenericLabel(element.name))
5975
6185
  genericLabelCount++;
@@ -5997,6 +6207,9 @@ var require_snapshot_parser = __commonJS({
5997
6207
  return {
5998
6208
  elements,
5999
6209
  formGroups: completedForms,
6210
+ // live regions first: they carry their own reservation so an end-of-body toast can
6211
+ // never be starved out by signal-matching plain text earlier in the document.
6212
+ staticMessages: [...liveMessages, ...textMessages].slice(0, STATIC_MESSAGES_MAX),
6000
6213
  rawLineCount: lines.length,
6001
6214
  parsedElementLineCount,
6002
6215
  genericLabelCount,
@@ -6107,12 +6320,17 @@ var require_snapshot_parser = __commonJS({
6107
6320
  return "mixed";
6108
6321
  return false;
6109
6322
  }
6110
- function buildContextLabel(role, name, headingLevel) {
6323
+ function buildContextLabel(role, name, headingLevel, richContext = false) {
6111
6324
  if (role === "heading") {
6112
6325
  if (!name)
6113
6326
  return null;
6114
6327
  return headingLevel ? `heading${headingLevel}:${name}` : `heading:${name}`;
6115
6328
  }
6329
+ if (richContext && RICH_CONTEXT_ROLES.has(role)) {
6330
+ if (!name)
6331
+ return null;
6332
+ return `${role}:${capText(name.replace(/>/g, "\u203A"), RICH_CONTEXT_LABEL_MAX)}`;
6333
+ }
6116
6334
  if (!CONTEXT_ROLES.has(role))
6117
6335
  return null;
6118
6336
  if (name)
@@ -7822,7 +8040,7 @@ Each entry is exactly one of:
7822
8040
  const systemPrompt = options.systemPromptOverride ?? (context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports2.SCENARIO_SYSTEM_PROMPT);
7823
8041
  const userPrompt = options.userPromptOverride ?? formatEvidenceForPlanner(compressed, evidence, context);
7824
8042
  logs2.push(`AI planner: ~${Math.ceil(userPrompt.length / 3.5)} input tokens`);
7825
- const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)());
8043
+ const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
7826
8044
  const messages = [
7827
8045
  { role: "system", content: systemPrompt },
7828
8046
  { role: "user", content: userPrompt }
@@ -7830,18 +8048,22 @@ Each entry is exactly one of:
7830
8048
  const callStart = Date.now();
7831
8049
  let response;
7832
8050
  try {
7833
- response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => aiClient.chat.completions.create({
7834
- model: agentModel,
7835
- messages,
7836
- max_tokens: plannerMaxTokens,
7837
- temperature: 0.3,
7838
- ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
7839
- ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
7840
- })), {
8051
+ response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
8052
+ arm();
8053
+ return aiClient.chat.completions.create({
8054
+ model: agentModel,
8055
+ messages,
8056
+ max_tokens: plannerMaxTokens,
8057
+ temperature: 0.3,
8058
+ ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
8059
+ ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
8060
+ }, { signal });
8061
+ }), {
7841
8062
  label: "ai-planner",
7842
8063
  baseDelayMs: 5e3,
7843
8064
  maxRetries: 2,
7844
- onRetry: (attempt, status, delay) => logs2.push(`AI planner call failed (${status ?? "error"}) \u2014 retry ${attempt}/2 in ${Math.round(delay / 1e3)}s`)
8065
+ deferTimeoutUntilArmed: true,
8066
+ onRetry: (attempt, reason, delay) => logs2.push(`AI planner call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
7845
8067
  });
7846
8068
  } catch (err) {
7847
8069
  const message = err instanceof Error ? err.message : String(err);
@@ -7907,18 +8129,25 @@ Each entry is exactly one of:
7907
8129
  const retryStart = Date.now();
7908
8130
  let retryResponse = null;
7909
8131
  try {
7910
- retryResponse = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => aiClient.chat.completions.create({
7911
- model: agentModel,
7912
- messages: [
7913
- ...messages,
7914
- { role: "assistant", content: raw },
7915
- { role: "user", content: retryContext }
7916
- ],
7917
- max_tokens: plannerMaxTokens,
7918
- temperature: 0.3,
7919
- ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
7920
- ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
7921
- })), { label: "ai-planner-retry" });
8132
+ retryResponse = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
8133
+ arm();
8134
+ return aiClient.chat.completions.create({
8135
+ model: agentModel,
8136
+ messages: [
8137
+ ...messages,
8138
+ { role: "assistant", content: raw },
8139
+ { role: "user", content: retryContext }
8140
+ ],
8141
+ max_tokens: plannerMaxTokens,
8142
+ temperature: 0.3,
8143
+ ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
8144
+ ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
8145
+ }, { signal });
8146
+ }), {
8147
+ label: "ai-planner-retry",
8148
+ deferTimeoutUntilArmed: true,
8149
+ onRetry: (attempt, reason, delay) => logs2.push(`AI planner retry call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
8150
+ });
7922
8151
  } catch (err) {
7923
8152
  const message = err instanceof Error ? err.message : String(err);
7924
8153
  logs2.push(`AI planner retry failed (${message}) \u2014 using first-pass result`);
@@ -8446,12 +8675,12 @@ ${constraints.join("\n")}`);
8446
8675
  const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8447
8676
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
8448
8677
  for (const state2 of orderedScreens) {
8449
- const groupName = state2.screenType === "auth" ? "Authentication" : screenDisplayName(state2);
8678
+ const groupName = state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(state2);
8450
8679
  const group = groups.get(groupName) ?? {
8451
8680
  name: groupName,
8452
8681
  description: `Native mobile screens related to ${groupName}.`,
8453
8682
  routes: [],
8454
- criticality: state2.screenType === "auth" ? "critical" : "medium",
8683
+ criticality: state2.screenType?.toLowerCase() === "auth" ? "critical" : "medium",
8455
8684
  pages: [],
8456
8685
  flows: []
8457
8686
  };
@@ -8474,7 +8703,7 @@ ${constraints.join("\n")}`);
8474
8703
  const to = graph.screens.get(transition.toScreenId);
8475
8704
  if (!from || !to)
8476
8705
  continue;
8477
- const groupName = from.screenType === "auth" ? "Authentication" : screenDisplayName(from);
8706
+ const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(from);
8478
8707
  const group = groups.get(groupName);
8479
8708
  if (!group)
8480
8709
  continue;
@@ -8519,9 +8748,9 @@ ${constraints.join("\n")}`);
8519
8748
  scenarios.push({
8520
8749
  name,
8521
8750
  type: "HAPPY_PATH",
8522
- priority: state2.screenType === "auth" ? 1 : 3,
8751
+ priority: state2.screenType?.toLowerCase() === "auth" ? 1 : 3,
8523
8752
  variant: "happy",
8524
- featureGroup: state2.screenType === "auth" ? "Authentication" : screenName,
8753
+ featureGroup: state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenName,
8525
8754
  goal: `User can reach and inspect the ${screenName} screen`,
8526
8755
  steps,
8527
8756
  expectedOutcomes: [{
@@ -8667,6 +8896,7 @@ var require_mobile_generator = __commonJS({
8667
8896
  var mobile_observation_js_1 = require_mobile_observation();
8668
8897
  var mobile_boundary_js_1 = require_mobile_boundary();
8669
8898
  var MAX_SCENARIO_ITERATIONS = 30;
8899
+ var MAX_CONSECUTIVE_INFRA_FAILURES = 3;
8670
8900
  var MAX_RECENT_EXCHANGES = 24;
8671
8901
  var OBSERVATION_MAX_ELEMENTS = 50;
8672
8902
  function finalizeAction(input) {
@@ -9391,14 +9621,31 @@ ${statePacket}` },
9391
9621
  if (!snapshot || snapshot.screen.elements.length === 0)
9392
9622
  return null;
9393
9623
  const elements = snapshot.screen.elements;
9394
- const withStableId = elements.filter((el) => hasStableId(el));
9395
- const nonButton = withStableId.find((el) => !/button/i.test(el.role));
9624
+ const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
9625
+ const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
9626
+ if (contentfulNonButton)
9627
+ return contentfulNonButton;
9628
+ const nonButton = stableAnchors.find((el) => !/button/i.test(el.role));
9396
9629
  if (nonButton)
9397
9630
  return nonButton;
9398
- if (withStableId.length > 0)
9399
- return withStableId[0];
9400
- const labelled = elements.find((el) => nonEmpty2(el.label) || nonEmpty2(el.text));
9401
- return labelled ?? elements[0];
9631
+ if (stableAnchors.length > 0)
9632
+ return stableAnchors[0];
9633
+ const labelled = elements.find((el) => !isAlwaysPresentContainer(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
9634
+ return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
9635
+ }
9636
+ var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
9637
+ var ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS = /* @__PURE__ */ new Set([
9638
+ "android:id/content",
9639
+ "android:id/decor",
9640
+ "android:id/action_bar_root",
9641
+ "android:id/statusBarBackground",
9642
+ "android:id/navigationBarBackground"
9643
+ ]);
9644
+ function isAlwaysPresentContainer(element) {
9645
+ if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
9646
+ return true;
9647
+ const resourceId = element.resourceId?.trim();
9648
+ return !!resourceId && ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS.has(resourceId);
9402
9649
  }
9403
9650
  function appendHybridFloor(actions, recording, platform3) {
9404
9651
  const anchor = pickLandingAnchor(recording.lastSnapshot);
@@ -9452,12 +9699,31 @@ ${statePacket}` },
9452
9699
  steps: compiled.steps,
9453
9700
  playwrightCode: "",
9454
9701
  verified,
9455
- tags
9702
+ tags,
9703
+ // Carry the planner scenario identity so incremental/resume can match an
9704
+ // already-generated scenario reliably (scenarioRef.name === the plan's
9705
+ // scenario name). Without this the resume skip-set falls back to the
9706
+ // model-chosen test name and may regenerate completed scenarios. Mobile has
9707
+ // no live command/locator ledger, so evidence is an empty (but valid) bundle
9708
+ // — scenarioRef is the load-bearing field for the skip-set.
9709
+ healingContext: {
9710
+ scenarioRef: {
9711
+ name: scenario.name,
9712
+ featureGroup: scenario.featureGroup,
9713
+ goal: scenario.goal,
9714
+ type: scenario.type,
9715
+ variant: scenario.variant,
9716
+ entryRoute: scenario.entryRoute,
9717
+ startFromLanding: scenario.startFromLanding,
9718
+ targetPages: [...scenario.targetPages]
9719
+ },
9720
+ evidence: { observedLocators: [], observedTransitions: [] }
9721
+ }
9456
9722
  };
9457
9723
  return test;
9458
9724
  }
9459
9725
  async function runMobileGeneratePhase(args) {
9460
- const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot } = args;
9726
+ const { scenarios: allScenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot, skipScenarioNames, onInfraStop } = args;
9461
9727
  const log2 = (line) => {
9462
9728
  try {
9463
9729
  onLog?.(line);
@@ -9472,8 +9738,16 @@ ${statePacket}` },
9472
9738
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
9473
9739
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
9474
9740
  const targetAppId = ctx.target?.appId?.trim() || void 0;
9741
+ const skipSet = new Set(skipScenarioNames ?? []);
9742
+ const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
9743
+ if (skipSet.size > 0) {
9744
+ log2(`Resume: skipping ${allScenarios.length - scenarios.length} already-generated scenario(s); ${scenarios.length} remaining to generate`);
9745
+ }
9475
9746
  log2(`Mobile generator starting \u2014 platform: ${platform3}, provider: ${provider}, model: ${agentModel}, scenarios: ${scenarios.length}`);
9476
9747
  const tests = [];
9748
+ const generatedScenarioNames = /* @__PURE__ */ new Set();
9749
+ let consecutiveInfraFailures = 0;
9750
+ let infraStopped = false;
9477
9751
  for (let i = 0; i < scenarios.length; i++) {
9478
9752
  const scenario = scenarios[i];
9479
9753
  log2(`
@@ -9494,6 +9768,7 @@ ${statePacket}` },
9494
9768
  targetAppId,
9495
9769
  log: log2
9496
9770
  });
9771
+ consecutiveInfraFailures = 0;
9497
9772
  const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
9498
9773
  if (!test) {
9499
9774
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
@@ -9510,13 +9785,31 @@ ${statePacket}` },
9510
9785
  log2(` onTestGenerated callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
9511
9786
  }
9512
9787
  tests.push(test);
9788
+ generatedScenarioNames.add(scenario.name);
9513
9789
  } catch (err) {
9790
+ if ((0, ai_retry_js_1.isAIProviderError)(err)) {
9791
+ consecutiveInfraFailures += 1;
9792
+ log2(` scenario "${scenario.name}" failed \u2014 AI provider error (${consecutiveInfraFailures}/${MAX_CONSECUTIVE_INFRA_FAILURES}): ${err instanceof Error ? err.message : String(err)}`);
9793
+ if (consecutiveInfraFailures >= MAX_CONSECUTIVE_INFRA_FAILURES) {
9794
+ const remainingScenarioNames = scenarios.filter((s) => !generatedScenarioNames.has(s.name)).map((s) => s.name);
9795
+ infraStopped = true;
9796
+ log2(`
9797
+ \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.`);
9798
+ try {
9799
+ onInfraStop?.({ remainingScenarioNames, reason: "ai_rate_limit" });
9800
+ } catch (cbErr) {
9801
+ log2(` onInfraStop callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
9802
+ }
9803
+ break;
9804
+ }
9805
+ continue;
9806
+ }
9514
9807
  log2(` scenario "${scenario.name}" failed: ${err instanceof Error ? err.message : String(err)} \u2014 skipping.`);
9515
9808
  continue;
9516
9809
  }
9517
9810
  }
9518
9811
  log2(`
9519
- Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified).`);
9812
+ Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified)${infraStopped ? " \u2014 PAUSED on AI provider overload" : ""}.`);
9520
9813
  return tests;
9521
9814
  }
9522
9815
  }
@@ -12795,6 +13088,10 @@ var require_snapshot_observation = __commonJS({
12795
13088
  var snapshot_parser_js_1 = require_snapshot_parser();
12796
13089
  var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
12797
13090
  var NOISE_LABEL_RE = /^(skip\s+to|skip\s+nav|back\s+to\s+top|cookie|accept\s+(all\s+)?cookies)$/i;
13091
+ var DIALOG_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog\b/i;
13092
+ var FORM_SEGMENT_RE = /(?:^|>\s*)form\b/i;
13093
+ var TABPANEL_SEGMENT_RE = /(?:^|>\s*)tabpanel\b/i;
13094
+ var DIALOG_NAME_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog:([^>]+)/i;
12798
13095
  var INTERACTIVE_ROLE_RE = /^(button|link|textbox|searchbox|checkbox|radio|switch|combobox|listbox|option|menuitem|tab|slider|spinbutton|textarea)$/i;
12799
13096
  var INTERACTIVE_ELEMENT_TYPE_RE = /^(button|link|input|textarea|select|checkbox|radio|switch|tab|combobox|listbox|option|menuitem|slider|spinbutton)$/i;
12800
13097
  function renderRetrievedSnapshotMemoryBlock(priorSnapshotEvidence, priorDiscoveryCheckpoints, options = {}) {
@@ -13039,10 +13336,12 @@ var require_snapshot_observation = __commonJS({
13039
13336
  summary
13040
13337
  }));
13041
13338
  }
13339
+ var TEXT_DISAMBIGUATING_INPUT_TYPES = /* @__PURE__ */ new Set(["password", "text", "email", "tel", "number", "search", "url", "date", "datetime-local", "time", "month", "week"]);
13042
13340
  function renderCompactSnapshotBlock(page, context = {}, options = {}) {
13043
13341
  const metrics = page.snapshotMetrics ?? page.snapshotArtifact?.metrics;
13044
13342
  const sourceKind = page.snapshotMeta?.sourceKind ?? page.snapshotArtifact?.sourceKind ?? "missing";
13045
13343
  const artifact = page.snapshotArtifact;
13344
+ const richRender = options.richRender ?? process.env.DISCOVERY_SNAPSHOT_RICH_RENDER === "true";
13046
13345
  const activeScope = inferActiveSnapshotScope(page);
13047
13346
  const ranked = rankSnapshotElements(getScopedSnapshotElements(page, activeScope), withScopeContext(context, activeScope));
13048
13347
  const allInteractive = ranked.filter(({ element }) => isInteractiveCandidate(element)).filter(({ element }) => {
@@ -13088,7 +13387,7 @@ var require_snapshot_observation = __commonJS({
13088
13387
  if (activeScope.rootRef) {
13089
13388
  lines.push(` root_ref: ${yamlScalar(activeScope.rootRef)}`);
13090
13389
  }
13091
- const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText);
13390
+ const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText, richRender);
13092
13391
  if (scopePreview.length > 0) {
13093
13392
  lines.push(" scope_snapshot_preview: |");
13094
13393
  for (const previewLine of scopePreview) {
@@ -13130,6 +13429,19 @@ var require_snapshot_observation = __commonJS({
13130
13429
  lines.push(` - ${yamlScalar(modal)}`);
13131
13430
  }
13132
13431
  }
13432
+ const staticMessages = page.staticMessages ?? [];
13433
+ if (richRender && staticMessages.length > 0) {
13434
+ const messageLimit = 6;
13435
+ const ordered = [...staticMessages].sort((a, b) => messageRolePriority(a.role) - messageRolePriority(b.role));
13436
+ lines.push("messages:");
13437
+ 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.");
13438
+ for (const message of ordered.slice(0, messageLimit)) {
13439
+ lines.push(` - [${message.role}] ${yamlScalar(message.text)}`);
13440
+ }
13441
+ if (ordered.length > messageLimit) {
13442
+ lines.push(` # +${ordered.length - messageLimit} more message(s) not listed`);
13443
+ }
13444
+ }
13133
13445
  lines.push("session_state:");
13134
13446
  if (page.sessionState) {
13135
13447
  lines.push(` authenticated: ${yamlScalar(page.sessionState.authenticated)}`);
@@ -13192,7 +13504,10 @@ var require_snapshot_observation = __commonJS({
13192
13504
  }
13193
13505
  const hasRowContext = group.rowContexts.some((rc) => !!rc);
13194
13506
  if (hasRowContext) {
13195
- lines.push(' distinguish_by_row: # same name in different rows \u2014 scope by this text, e.g. getByRole(role, { name }).filter({ has: getByText("<row>") })');
13507
+ const firstRowRole = group.rowRoles[0] ?? null;
13508
+ const rowRole = firstRowRole && group.rowRoles.every((r) => r === firstRowRole) ? firstRowRole : null;
13509
+ 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`;
13510
+ lines.push(` distinguish_by_row: # same name across rows \u2014 scope by the ROW/card container (not the control); nested same-role containers need an extra cell-level scope: ${scopeHint}`);
13196
13511
  for (let i = 0; i < group.rowContexts.length; i++) {
13197
13512
  const rowContext = group.rowContexts[i];
13198
13513
  if (!rowContext)
@@ -13222,8 +13537,13 @@ var require_snapshot_observation = __commonJS({
13222
13537
  lines.push(` placeholder: ${yamlScalar(rankedElement.element.placeholder)}`);
13223
13538
  }
13224
13539
  const stateTokens = buildStateSummary(rankedElement.element);
13540
+ const addedDisabled = richRender && !!rankedElement.element.isDisabled && !stateTokens.includes("disabled");
13541
+ if (addedDisabled) {
13542
+ stateTokens.unshift("disabled");
13543
+ }
13225
13544
  if (stateTokens.length > 0) {
13226
- lines.push(` state: [${stateTokens.map((token) => yamlScalar(token)).join(", ")}]`);
13545
+ const note = addedDisabled ? " # disabled = captured instant; may enable after valid input \u2014 still exercise fill\u2192submit" : "";
13546
+ lines.push(` state: [${stateTokens.map((token) => yamlScalar(token)).join(", ")}]${note}`);
13227
13547
  }
13228
13548
  if (rankedElement.element.containerPath) {
13229
13549
  lines.push(` container_path: ${yamlScalar(rankedElement.element.containerPath)}`);
@@ -13233,6 +13553,22 @@ var require_snapshot_observation = __commonJS({
13233
13553
  lines.push(` locator: ${yamlScalar((0, snapshot_parser_js_1.formatLocator)(topLocator))}`);
13234
13554
  }
13235
13555
  const enr = rankedElement.element.enrichment;
13556
+ if (richRender && enr?.inputType && TEXT_DISAMBIGUATING_INPUT_TYPES.has(enr.inputType)) {
13557
+ lines.push(` type: ${yamlScalar(enr.inputType)}`);
13558
+ }
13559
+ if (richRender && rankedElement.element.href) {
13560
+ lines.push(` url: ${yamlScalar(rankedElement.element.href)}`);
13561
+ }
13562
+ if (richRender && rankedElement.element.value && enr?.inputType !== "password") {
13563
+ lines.push(` value: ${yamlScalar(rankedElement.element.value)}`);
13564
+ }
13565
+ const elementOptions = rankedElement.element.options ?? [];
13566
+ if (richRender && elementOptions.length > 0) {
13567
+ const optionLimit = 12;
13568
+ const rendered = elementOptions.slice(0, optionLimit).map((option) => yamlScalar(option.selected ? `${option.label} (selected)` : option.label));
13569
+ const overflow = elementOptions.length > optionLimit ? ` # +${elementOptions.length - optionLimit} more option(s)` : "";
13570
+ lines.push(` options: [${rendered.join(", ")}]${overflow}`);
13571
+ }
13236
13572
  if (enr?.rowContext) {
13237
13573
  lines.push(` row_context: ${yamlScalar(enr.rowContext)}`);
13238
13574
  }
@@ -13245,6 +13581,12 @@ var require_snapshot_observation = __commonJS({
13245
13581
  lines.push(` visibility: [${vis.map((token) => yamlScalar(token)).join(", ")}]`);
13246
13582
  }
13247
13583
  }
13584
+ if (richRender) {
13585
+ const hidden = allInteractive.length - interactive.length;
13586
+ if (hidden > 0) {
13587
+ 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.`);
13588
+ }
13589
+ }
13248
13590
  }
13249
13591
  const recoveredClickables = options.recoveredClickables ?? [];
13250
13592
  if (recoveredClickables.length > 0) {
@@ -13401,11 +13743,11 @@ var require_snapshot_observation = __commonJS({
13401
13743
  score += 10;
13402
13744
  if (element.isExpanded)
13403
13745
  score += 8;
13404
- if (element.containerPath?.includes("dialog"))
13746
+ if (element.containerPath && DIALOG_SEGMENT_RE.test(element.containerPath))
13405
13747
  score += 12;
13406
- if (element.containerPath?.includes("form"))
13748
+ if (element.containerPath && FORM_SEGMENT_RE.test(element.containerPath))
13407
13749
  score += 10;
13408
- if (element.containerPath?.includes("tabpanel"))
13750
+ if (element.containerPath && TABPANEL_SEGMENT_RE.test(element.containerPath))
13409
13751
  score += 8;
13410
13752
  if (element.inputType === "email" || element.inputType === "password" || element.inputType === "search")
13411
13753
  score += 6;
@@ -13476,10 +13818,10 @@ var require_snapshot_observation = __commonJS({
13476
13818
  }
13477
13819
  return merged;
13478
13820
  }
13479
- function buildScopeSnapshotPreview(scopeSnapshotText) {
13821
+ function buildScopeSnapshotPreview(scopeSnapshotText, stripTrailingValues = false) {
13480
13822
  if (!scopeSnapshotText)
13481
13823
  return [];
13482
- return scopeSnapshotText.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 6);
13824
+ return scopeSnapshotText.split("\n").map((line) => line.trim()).map((line) => stripTrailingValues ? line.replace(/\]\s*:\s.+$/, "]") : line).filter(Boolean).slice(0, 6);
13483
13825
  }
13484
13826
  function dedupeLines(lines) {
13485
13827
  const seen = /* @__PURE__ */ new Set();
@@ -13606,11 +13948,12 @@ var require_snapshot_observation = __commonJS({
13606
13948
  const key = `${role}\0${name}`;
13607
13949
  let group = groups.get(key);
13608
13950
  if (!group) {
13609
- group = { role, name, containers: [], rowContexts: [], refs: [] };
13951
+ group = { role, name, containers: [], rowContexts: [], rowRoles: [], refs: [] };
13610
13952
  groups.set(key, group);
13611
13953
  }
13612
13954
  group.containers.push(element.containerPath ?? null);
13613
13955
  group.rowContexts.push(element.enrichment?.rowContext ?? null);
13956
+ group.rowRoles.push(element.enrichment?.rowRole ?? null);
13614
13957
  group.refs.push(element.ref ?? null);
13615
13958
  }
13616
13959
  return [...groups.values()].filter((group) => group.containers.length > 1);
@@ -13620,7 +13963,7 @@ var require_snapshot_observation = __commonJS({
13620
13963
  for (const element of elements) {
13621
13964
  const role = element.role?.toLowerCase() ?? "";
13622
13965
  const elementType = element.elementType?.toLowerCase() ?? "";
13623
- const isDialog = role === "dialog" || elementType === "dialog" || (element.containerPath?.toLowerCase().includes("dialog:") ?? false);
13966
+ const isDialog = role === "dialog" || elementType === "dialog" || DIALOG_NAME_SEGMENT_RE.test(element.containerPath ?? "");
13624
13967
  if (!isDialog)
13625
13968
  continue;
13626
13969
  const hint = qualifyElementLabel(element) ?? dialogNameFromContainerPath(element.containerPath) ?? element.label ?? element.testId ?? "dialog";
@@ -13633,7 +13976,7 @@ var require_snapshot_observation = __commonJS({
13633
13976
  function dialogNameFromContainerPath(containerPath) {
13634
13977
  if (!containerPath)
13635
13978
  return null;
13636
- const match = containerPath.match(/dialog:([^>]+)/i);
13979
+ const match = containerPath.match(DIALOG_NAME_SEGMENT_RE);
13637
13980
  return match ? match[1].trim() : null;
13638
13981
  }
13639
13982
  function renderObservationCue(observation) {
@@ -13642,6 +13985,13 @@ var require_snapshot_observation = __commonJS({
13642
13985
  const base2 = observation.outcomeType === "SUCCESS" && outcome ? outcome : `${action} -> ${outcome || observation.outcomeType}`;
13643
13986
  return base2.length > 120 ? `${base2.slice(0, 117)}...` : base2;
13644
13987
  }
13988
+ function messageRolePriority(role) {
13989
+ if (role === "alert")
13990
+ return 0;
13991
+ if (role === "status")
13992
+ return 1;
13993
+ return 2;
13994
+ }
13645
13995
  function yamlScalar(value) {
13646
13996
  if (value == null || value === "")
13647
13997
  return "null";
@@ -20190,7 +20540,10 @@ ${testOpen}`;
20190
20540
  pressed: se.pressed,
20191
20541
  headingLevel: se.headingLevel,
20192
20542
  currentState: se.currentState,
20193
- containerPath: se.containerPath || void 0
20543
+ containerPath: se.containerPath || void 0,
20544
+ href: se.href || void 0,
20545
+ value: se.value || void 0,
20546
+ options: se.options && se.options.length > 0 ? se.options : void 0
20194
20547
  }));
20195
20548
  const structuredPage = {
20196
20549
  route: pageUrl || "/",
@@ -20198,6 +20551,7 @@ ${testOpen}`;
20198
20551
  pageType: "unknown",
20199
20552
  elements: collectedElements,
20200
20553
  formGroups: parsed.formGroups,
20554
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
20201
20555
  snapshotMetrics: {
20202
20556
  rawLineCount: parsed.rawLineCount,
20203
20557
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -21764,7 +22118,7 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
21764
22118
  let stopped;
21765
22119
  const agentModel = options?.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
21766
22120
  const auditGroupId = `generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
21767
- const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)();
22121
+ const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
21768
22122
  const credentials = variableContext.allTestVariables ?? variableContext.publicTestVariables;
21769
22123
  logs2.push(`Phase 3 \u2014 Generate: ${(plan.scenarios ?? []).length} scenarios, max ${maxTests} tests`);
21770
22124
  logs2.push(`Model: ${agentModel}`);
@@ -235729,7 +236083,7 @@ var require_mcp_healer = __commonJS({
235729
236083
  messages.push(system, firstUser, { role: "user", content: lines.join("\n") }, ...tail);
235730
236084
  }
235731
236085
  var DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS = 6;
235732
- function readPositiveIntEnv(env, name, fallback) {
236086
+ function readPositiveIntEnv2(env, name, fallback) {
235733
236087
  const raw = env[name]?.trim();
235734
236088
  if (!raw || !/^\d+$/.test(raw))
235735
236089
  return fallback;
@@ -235737,7 +236091,7 @@ var require_mcp_healer = __commonJS({
235737
236091
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
235738
236092
  }
235739
236093
  function getScriptHealMaxAttempts(env = process.env) {
235740
- return readPositiveIntEnv(env, "MCP_HEAL_SCRIPT_MAX_ATTEMPTS", DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS);
236094
+ return readPositiveIntEnv2(env, "MCP_HEAL_SCRIPT_MAX_ATTEMPTS", DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS);
235741
236095
  }
235742
236096
  exports2.SCRIPT_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Playwright UI test. You will receive the test code, the error message, step-level results, and a screenshot of the failure state.
235743
236097
 
@@ -236073,7 +236427,7 @@ ${lines.join("\n")}
236073
236427
  const landingViolation = (tags ?? []).includes("@landing-violation");
236074
236428
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "strong") : (0, ai_provider_js_1.getModel)("strong");
236075
236429
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
236076
- const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)());
236430
+ const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
236077
236431
  const runNativeTest = options.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
236078
236432
  const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
236079
236433
  const groupId = `script-heal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -236895,10 +237249,10 @@ ${(verifyResult.logs ?? "").slice(0, 2e3)}`;
236895
237249
  var DEFAULT_MAX_PHASE2_ITERATIONS = 4;
236896
237250
  var DEFAULT_MAX_MCP_EXPLORE_ITERATIONS = 25;
236897
237251
  function getMcpHealPhase2MaxIterations(env = process.env) {
236898
- return readPositiveIntEnv(env, "MCP_HEAL_PHASE2_MAX_ITERATIONS", DEFAULT_MAX_PHASE2_ITERATIONS);
237252
+ return readPositiveIntEnv2(env, "MCP_HEAL_PHASE2_MAX_ITERATIONS", DEFAULT_MAX_PHASE2_ITERATIONS);
236899
237253
  }
236900
237254
  function getMcpHealExploreMaxIterations(env = process.env) {
236901
- return readPositiveIntEnv(env, "MCP_HEAL_EXPLORE_MAX_ITERATIONS", DEFAULT_MAX_MCP_EXPLORE_ITERATIONS);
237255
+ return readPositiveIntEnv2(env, "MCP_HEAL_EXPLORE_MAX_ITERATIONS", DEFAULT_MAX_MCP_EXPLORE_ITERATIONS);
236902
237256
  }
236903
237257
  var DEFAULT_MCP_HEAL_MAX_WALL_CLOCK_MS = 10 * 60 * 1e3;
236904
237258
  var DEFAULT_MCP_HEAL_MAX_TOTAL_AI_CALLS = 60;
@@ -236950,7 +237304,7 @@ Only explore the failing page and the area around the broken step.`;
236950
237304
  let lastVideo;
236951
237305
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
236952
237306
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
236953
- const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)());
237307
+ const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
236954
237308
  const runPhase2NativeTest = options?.phase2Harness?.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
236955
237309
  const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
236956
237310
  const groupId = `phase2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -237640,7 +237994,10 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
237640
237994
  pressed: se.pressed,
237641
237995
  headingLevel: se.headingLevel,
237642
237996
  currentState: se.currentState,
237643
- containerPath: se.containerPath || void 0
237997
+ containerPath: se.containerPath || void 0,
237998
+ href: se.href || void 0,
237999
+ value: se.value || void 0,
238000
+ options: se.options && se.options.length > 0 ? se.options : void 0
237644
238001
  }));
237645
238002
  const structuredPage = {
237646
238003
  route: pageUrl || "/",
@@ -237648,6 +238005,7 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
237648
238005
  pageType: "unknown",
237649
238006
  elements: collectedElements,
237650
238007
  formGroups: parsed.formGroups,
238008
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
237651
238009
  snapshotMetrics: {
237652
238010
  rawLineCount: parsed.rawLineCount,
237653
238011
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -238084,7 +238442,7 @@ ${testBlocks}
238084
238442
  - By iteration ${Math.floor(maxIterations * 0.3)}: you should have completed 1-2 tests
238085
238443
  - By iteration ${Math.floor(maxIterations * 0.7)}: you should be past the halfway point
238086
238444
  - At iteration ${Math.floor(maxIterations * 0.9)}: wrap up and call finish_capture`;
238087
- const aiClient = modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)();
238445
+ const aiClient = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
238088
238446
  const messages = [
238089
238447
  { role: "system", content: systemPrompt },
238090
238448
  { role: "user", content: "Start executing the tests. Navigate to the base URL and begin with Test 1." }
@@ -238267,7 +238625,10 @@ ${testBlocks}
238267
238625
  pressed: se.pressed,
238268
238626
  headingLevel: se.headingLevel,
238269
238627
  currentState: se.currentState,
238270
- containerPath: se.containerPath || void 0
238628
+ containerPath: se.containerPath || void 0,
238629
+ href: se.href || void 0,
238630
+ value: se.value || void 0,
238631
+ options: se.options && se.options.length > 0 ? se.options : void 0
238271
238632
  }));
238272
238633
  const structuredPage = {
238273
238634
  route: pageUrl || "/",
@@ -238275,6 +238636,7 @@ ${testBlocks}
238275
238636
  pageType: "unknown",
238276
238637
  elements: collectedElements,
238277
238638
  formGroups: parsed.formGroups,
238639
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
238278
238640
  snapshotMetrics: {
238279
238641
  rawLineCount: parsed.rawLineCount,
238280
238642
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -300064,8 +300426,9 @@ var require_capture_page_normalizer = __commonJS({
300064
300426
  let snapshotElements = [];
300065
300427
  let formGroups = [];
300066
300428
  let snapshotMetrics;
300429
+ let staticMessages;
300067
300430
  if (normalizedSnapshotResult.snapshotText) {
300068
- const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText);
300431
+ const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText, { richParse: context.richParse });
300069
300432
  snapshotMetrics = {
300070
300433
  rawLineCount: parsed.rawLineCount,
300071
300434
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -300094,9 +300457,13 @@ var require_capture_page_normalizer = __commonJS({
300094
300457
  pressed: se.pressed,
300095
300458
  headingLevel: se.headingLevel,
300096
300459
  currentState: se.currentState,
300097
- containerPath: se.containerPath || void 0
300460
+ containerPath: se.containerPath || void 0,
300461
+ href: se.href || void 0,
300462
+ value: se.value || void 0,
300463
+ options: se.options && se.options.length > 0 ? se.options : void 0
300098
300464
  }));
300099
300465
  formGroups = parsed.formGroups;
300466
+ staticMessages = parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0;
300100
300467
  }
300101
300468
  const aiElements = Array.isArray(toolArgs.elements) ? toolArgs.elements.map(normalizeAiElement) : [];
300102
300469
  const noYamlKinds = /* @__PURE__ */ new Set(["missing", "timeout", "error", "empty"]);
@@ -300192,6 +300559,7 @@ var require_capture_page_normalizer = __commonJS({
300192
300559
  } : void 0,
300193
300560
  readinessSignals,
300194
300561
  viewportHints,
300562
+ staticMessages,
300195
300563
  provenCommands: provenCmds,
300196
300564
  observations: rawObservations,
300197
300565
  routeCorrected,
@@ -300500,6 +300868,10 @@ var require_perception_enricher = __commonJS({
300500
300868
  const e = {};
300501
300869
  if (p.rowContext)
300502
300870
  e.rowContext = p.rowContext;
300871
+ if (p.rowContext && p.rowRole)
300872
+ e.rowRole = p.rowRole;
300873
+ if (p.inputType)
300874
+ e.inputType = p.inputType;
300503
300875
  if (p.position && p.position !== "in-view")
300504
300876
  e.position = p.position;
300505
300877
  if (p.occluded)
@@ -300749,6 +301121,23 @@ var require_perception_enricher = __commonJS({
300749
301121
  const cls = (el.getAttribute("class") || "").toLowerCase();
300750
301122
  return /(^|[\s_-])(card|row|item|listitem|cell|tile)([\s_-]|$)/.test(cls);
300751
301123
  };
301124
+ const rowRoleOf = (el) => {
301125
+ const explicit = (el.getAttribute("role") || "").toLowerCase().trim();
301126
+ if (explicit) {
301127
+ return explicit === "row" || explicit === "listitem" || explicit === "article" || explicit === "option" ? explicit : null;
301128
+ }
301129
+ const tag = el.tagName;
301130
+ if (tag === "TR") {
301131
+ const tbl = el.closest("table");
301132
+ const tblRole = tbl ? (tbl.getAttribute("role") || "").toLowerCase().trim() : "";
301133
+ return tblRole === "presentation" || tblRole === "none" ? null : "row";
301134
+ }
301135
+ if (tag === "LI")
301136
+ return el.closest('ul:not([role]),ol:not([role]),menu:not([role]),[role="list"]') ? "listitem" : null;
301137
+ if (tag === "ARTICLE")
301138
+ return "article";
301139
+ return null;
301140
+ };
300752
301141
  const rowContextOf = (el, ownNameLower) => {
300753
301142
  let cur = el.parentElement;
300754
301143
  let depth = 0;
@@ -300767,7 +301156,7 @@ var require_perception_enricher = __commonJS({
300767
301156
  txt = txt.slice(0, 80);
300768
301157
  const low = txt.toLowerCase();
300769
301158
  if (txt && low !== ownNameLower && low.length > 1)
300770
- return txt;
301159
+ return { text: txt, role: rowRoleOf(cur) };
300771
301160
  return null;
300772
301161
  }
300773
301162
  cur = cur.parentElement;
@@ -300857,11 +301246,14 @@ var require_perception_enricher = __commonJS({
300857
301246
  const name = accName(el);
300858
301247
  const lname = lower(name);
300859
301248
  const rect = vis.rect;
301249
+ const rc = rowContextOf(el, lname);
300860
301250
  out.push({
300861
301251
  domOrder: myOrder,
300862
301252
  roleClass: roleClassOf(tag, roleAttr, inputType),
300863
301253
  name: lname,
300864
- rowContext: rowContextOf(el, lname),
301254
+ rowContext: rc ? rc.text : null,
301255
+ rowRole: rc ? rc.role : null,
301256
+ inputType: inputType || null,
300865
301257
  position: positionOf(rect),
300866
301258
  occluded: occlusionOf(el, rect),
300867
301259
  cursorPointer,
@@ -300898,11 +301290,14 @@ var require_perception_enricher = __commonJS({
300898
301290
  if (!name)
300899
301291
  continue;
300900
301292
  const lname = lower(name);
301293
+ const rc = rowContextOf(el, lname);
300901
301294
  out.push({
300902
301295
  domOrder: order++,
300903
301296
  roleClass: "button",
300904
301297
  name: lname,
300905
- rowContext: rowContextOf(el, lname),
301298
+ rowContext: rc ? rc.text : null,
301299
+ rowRole: rc ? rc.role : null,
301300
+ inputType: null,
300906
301301
  position: "in-view",
300907
301302
  occluded: false,
300908
301303
  cursorPointer: true,
@@ -301404,7 +301799,7 @@ var require_discover_explorer = __commonJS({
301404
301799
  var DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP = 100;
301405
301800
  var DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY = 100;
301406
301801
  var DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL = 40;
301407
- function readPositiveIntEnv(env, name, fallback) {
301802
+ function readPositiveIntEnv2(env, name, fallback) {
301408
301803
  const raw = env[name]?.trim();
301409
301804
  if (!raw || !/^\d+$/.test(raw))
301410
301805
  return fallback;
@@ -301535,15 +301930,15 @@ var require_discover_explorer = __commonJS({
301535
301930
  }
301536
301931
  function getMaxIterations(mode, incremental, env = process.env) {
301537
301932
  if (mode === "survey" && incremental) {
301538
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_INCREMENTAL", DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL);
301933
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_INCREMENTAL", DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL);
301539
301934
  }
301540
301935
  if (mode === "survey") {
301541
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_SURVEY", DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY);
301936
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_SURVEY", DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY);
301542
301937
  }
301543
301938
  if (mode === "deep") {
301544
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEEP", DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP);
301939
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEEP", DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP);
301545
301940
  }
301546
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEFAULT", DEFAULT_MAX_EXPLORE_ITERATIONS_DEFAULT);
301941
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEFAULT", DEFAULT_MAX_EXPLORE_ITERATIONS_DEFAULT);
301547
301942
  }
301548
301943
  function updatePendingRouteScreenshot(pending, { imageData, detectedRoute, fallbackRoute }) {
301549
301944
  if (imageData) {
@@ -302269,7 +302664,7 @@ ${inboxPromptBlock}`;
302269
302664
  const browserCreds = variableContext.allTestVariables ?? variableContext.publicTestVariables;
302270
302665
  const { secrets: browserSecretsForHint } = (0, credential_tools_js_1.partitionTestVariables)(browserCreds);
302271
302666
  const hasLoginCredentials = Object.keys(browserSecretsForHint).length > 0;
302272
- const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)();
302667
+ const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
302273
302668
  const isDeepMode = options?.mode === "deep";
302274
302669
  const maxIterations = config?.maxIterationsOverride && config.maxIterationsOverride > 0 ? config.maxIterationsOverride : getMaxIterations(options?.mode ?? "full", options?.incremental);
302275
302670
  const configKeepTail = isDeepMode ? Math.ceil(ctxConfig.keepTail * 1.5) : ctxConfig.keepTail;
@@ -304808,7 +305203,7 @@ var require_auth_capture_run = __commonJS({
304808
305203
  var scrub_credentials_js_1 = require_scrub_credentials();
304809
305204
  var credential_tools_js_1 = require_credential_tools();
304810
305205
  var DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS = 12;
304811
- function readPositiveIntEnv(env, name, fallback) {
305206
+ function readPositiveIntEnv2(env, name, fallback) {
304812
305207
  const raw = env[name]?.trim();
304813
305208
  if (!raw || !/^\d+$/.test(raw))
304814
305209
  return fallback;
@@ -304816,7 +305211,7 @@ var require_auth_capture_run = __commonJS({
304816
305211
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
304817
305212
  }
304818
305213
  function getLoginCaptureMaxIterations(env = process.env) {
304819
- return readPositiveIntEnv(env, "AUTH_CAPTURE_LOGIN_MAX_ITERATIONS", DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS);
305214
+ return readPositiveIntEnv2(env, "AUTH_CAPTURE_LOGIN_MAX_ITERATIONS", DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS);
304820
305215
  }
304821
305216
  var CAPTURE_RATE_LIMIT_MAX_RETRIES = 2;
304822
305217
  var CAPTURE_RATE_LIMIT_MAX_DELAY_MS = 8e3;
@@ -305099,7 +305494,7 @@ ${keyLines || "- (none provided)"}
305099
305494
  }
305100
305495
  var DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS = 18;
305101
305496
  function getRegisterCaptureMaxIterations(env = process.env) {
305102
- return readPositiveIntEnv(env, "AUTH_CAPTURE_REGISTER_MAX_ITERATIONS", DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS);
305497
+ return readPositiveIntEnv2(env, "AUTH_CAPTURE_REGISTER_MAX_ITERATIONS", DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS);
305103
305498
  }
305104
305499
  function buildRegisterCaptureSystemPrompt(baseUrl, availableKeys, maxIterations = getRegisterCaptureMaxIterations()) {
305105
305500
  const keyLines = availableKeys.map((k) => `- \`${k}\``).join("\n");
@@ -311329,6 +311724,7 @@ var require_mobile_exploration_state = __commonJS({
311329
311724
  Object.defineProperty(exports2, "__esModule", { value: true });
311330
311725
  exports2.MobileExplorationState = void 0;
311331
311726
  exports2.deriveScreenId = deriveScreenId;
311727
+ var mobile_observation_js_1 = require_mobile_observation();
311332
311728
  var DEFAULT_STALE_THRESHOLD = 4;
311333
311729
  function deriveScreenId(signal) {
311334
311730
  const namespace = signal.activity ?? signal.bundleId;
@@ -311554,21 +311950,33 @@ var require_mobile_exploration_state = __commonJS({
311554
311950
  * Build the planner evidence shape: one `CollectedPageData` per screen, with
311555
311951
  * the screen-id carried in BOTH `route` (the on-the-wire positional field the
311556
311952
  * server keys screenshots by) AND the explicit `screenId` field (per the
311557
- * Phase-0 `CollectedPageData.screenId` contract). `elements` is left empty —
311558
- * the web-shaped `CollectedPageElement[]` is not the mobile element shape;
311559
- * the mobile planner reads screen identity + names from this evidence and the
311560
- * richer per-element data flows through the generator's own re-drive, not
311561
- * through this map. Insertion order matches first-seen order.
311953
+ * Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
311954
+ * the screen's parsed actionable elements into the platform-neutral
311955
+ * `CollectedPageElement[]` shape the server persists as SiteElements without
311956
+ * this, mobile screens land with zero elements (coverage always 0%, no element
311957
+ * intelligence). `triggersNavigation` is derived from transition-source
311958
+ * membership: an element is flagged when its ref drove a transition FROM this
311959
+ * screen. Insertion order matches first-seen order.
311562
311960
  */
311563
311961
  buildInMemoryScreenMap() {
311564
311962
  const pages = [];
311565
311963
  const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
311964
+ const navRefsByScreen = /* @__PURE__ */ new Map();
311965
+ for (const transition of this.transitions) {
311966
+ if (!transition.viaRef)
311967
+ continue;
311968
+ const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
311969
+ set.add(transition.viaRef);
311970
+ navRefsByScreen.set(transition.fromScreenId, set);
311971
+ }
311566
311972
  for (const state2 of ordered) {
311567
311973
  const page = {
311568
311974
  route: state2.screenId,
311569
311975
  screenId: state2.screenId,
311570
311976
  pageType: state2.screenType ?? "UNKNOWN",
311571
- elements: []
311977
+ elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
311978
+ navigationRefs: navRefsByScreen.get(state2.screenId)
311979
+ })
311572
311980
  };
311573
311981
  if (state2.screenName) {
311574
311982
  page.title = state2.screenName;
@@ -311598,6 +312006,7 @@ var require_mobile_discovery_agent = __commonJS({
311598
312006
  "../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports2) {
311599
312007
  "use strict";
311600
312008
  Object.defineProperty(exports2, "__esModule", { value: true });
312009
+ exports2.getMobileExploreBudget = getMobileExploreBudget;
311601
312010
  exports2.runMobileDiscoveryOnRunner = runMobileDiscoveryOnRunner2;
311602
312011
  var mobile_explorer_js_1 = require_mobile_explorer();
311603
312012
  var mobile_discovery_prompts_js_1 = require_mobile_discovery_prompts();
@@ -311611,8 +312020,37 @@ var require_mobile_discovery_agent = __commonJS({
311611
312020
  const message = checkpointErrorMessage(err);
311612
312021
  return /Discovery plan checkpoint rejected|Discovery run is .*not RUNNING|not RUNNING|cancelled/i.test(message);
311613
312022
  }
311614
- var EXPLORE_BUDGET_DEEP = 60;
311615
- var EXPLORE_BUDGET_SURVEY = 40;
312023
+ var MOBILE_EXPLORE_BUDGET_SURVEY_FIRST = 90;
312024
+ var MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL = 40;
312025
+ var MOBILE_EXPLORE_BUDGET_DEEP = 60;
312026
+ var MOBILE_EXPLORE_BUDGET_FULL = 90;
312027
+ function readPositiveIntEnv2(name, fallback, env = process.env) {
312028
+ const raw = env[name]?.trim();
312029
+ if (!raw || !/^\d+$/.test(raw))
312030
+ return fallback;
312031
+ const parsed = Number(raw);
312032
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
312033
+ }
312034
+ function getMobileExploreBudget(mode, incremental, env = process.env) {
312035
+ if (mode === "deep")
312036
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_DEEP", MOBILE_EXPLORE_BUDGET_DEEP, env);
312037
+ if (mode === "full")
312038
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_FULL", MOBILE_EXPLORE_BUDGET_FULL, env);
312039
+ if (incremental) {
312040
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY_INCREMENTAL", MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL, env);
312041
+ }
312042
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY", MOBILE_EXPLORE_BUDGET_SURVEY_FIRST, env);
312043
+ }
312044
+ function compactExistingScreens(siteMap) {
312045
+ if (!siteMap || !Array.isArray(siteMap.pages) || siteMap.pages.length === 0)
312046
+ return void 0;
312047
+ const screens = siteMap.pages.filter((p) => typeof p.route === "string" && p.route.trim().length > 0).map((p) => ({
312048
+ screenId: p.route,
312049
+ ...p.title ? { name: p.title } : {},
312050
+ ...p.pageType ? { screenType: p.pageType } : {}
312051
+ }));
312052
+ return screens.length > 0 ? screens : void 0;
312053
+ }
311616
312054
  function platformForMedium(medium) {
311617
312055
  return medium === "IOS_NATIVE" ? "IOS" : "ANDROID";
311618
312056
  }
@@ -311631,6 +312069,9 @@ var require_mobile_discovery_agent = __commonJS({
311631
312069
  // Native screenshots are multi-megabyte PNGs. The live mirror streams them
311632
312070
  // through heartbeat telemetry; discovery checkpoints keep the screen graph
311633
312071
  // and transitions so large Android surveys cannot exceed server body limits.
312072
+ // D-D6 — pageScreenshotKeys is a small screen-id → storage-key map (NOT base64),
312073
+ // so it DOES ride the checkpoint: the server links each key to SitePage.screenshotKey.
312074
+ ...explore.pageScreenshotKeys && Object.keys(explore.pageScreenshotKeys).length > 0 ? { pageScreenshotKeys: explore.pageScreenshotKeys } : {},
311634
312075
  exploreStats: explore.exploreStats,
311635
312076
  healthObservations: explore.healthObservations
311636
312077
  };
@@ -311645,6 +312086,7 @@ var require_mobile_discovery_agent = __commonJS({
311645
312086
  const onLog = callbacks?.onLog;
311646
312087
  const onAICall = callbacks?.onAICall;
311647
312088
  const onScreenshot = callbacks?.onScreenshot;
312089
+ const onScreenshotUpload = callbacks?.onScreenshotUpload;
311648
312090
  const runExplore = callbacks?.runExplore ?? mobile_explorer_js_1.runMobileExplorePhase;
311649
312091
  const runPlan = callbacks?.runPlan ?? mobile_discovery_prompts_js_1.runMobileAIPlanner;
311650
312092
  const runGenerate = callbacks?.runGenerate ?? mobile_generator_js_1.runMobileGeneratePhase;
@@ -311671,15 +312113,77 @@ var require_mobile_discovery_agent = __commonJS({
311671
312113
  elementsFound: 0,
311672
312114
  transitionsFound: 0
311673
312115
  };
312116
+ const resumeSavedScenarios = ctx.resume?.savedPlan?.scenarios ?? [];
312117
+ const isPlanReuseResume = mode !== "survey" && resumeSavedScenarios.length > 0;
311674
312118
  try {
312119
+ if (isPlanReuseResume) {
312120
+ const alreadyGenerated = ctx.resume?.alreadyGeneratedScenarioNames ?? [];
312121
+ log2(`
312122
+ \u2500\u2500 Resume (plan-reuse): ${resumeSavedScenarios.length} saved scenario(s); ${alreadyGenerated.length} already generated. Skipping explore + plan; generating only the remainder. \u2500\u2500`);
312123
+ const scenarios2 = resumeSavedScenarios;
312124
+ const plans2 = toPlans(scenarios2);
312125
+ if (callbacks?.onPlanReady) {
312126
+ try {
312127
+ await callbacks.onPlanReady({ scenarios: scenarios2, featureGroups: void 0, fallbackUsed: false });
312128
+ log2("Plan checkpoint: reused scenario plan re-POSTed to server");
312129
+ } catch (err) {
312130
+ const message = checkpointErrorMessage(err);
312131
+ if (isTerminalDiscoveryCheckpointError(err)) {
312132
+ log2(`Plan checkpoint rejected for inactive discovery run: ${message}`);
312133
+ throw err;
312134
+ }
312135
+ log2(`Plan checkpoint POST failed (non-fatal): ${message}`);
312136
+ }
312137
+ }
312138
+ let paused = false;
312139
+ let pauseReason;
312140
+ let remainingScenarios;
312141
+ const tests2 = await runGenerate({
312142
+ scenarios: scenarios2,
312143
+ driver,
312144
+ ctx,
312145
+ onLog: (line) => log2(line),
312146
+ onAICall,
312147
+ onScreenshot,
312148
+ onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
312149
+ skipScenarioNames: alreadyGenerated,
312150
+ onInfraStop: (info) => {
312151
+ paused = true;
312152
+ pauseReason = info.reason;
312153
+ remainingScenarios = info.remainingScenarioNames;
312154
+ }
312155
+ });
312156
+ const duration2 = (Date.now() - startTime) / 1e3;
312157
+ 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" : ""}`;
312158
+ log2(`
312159
+ === Mobile Discovery Resume Complete (${duration2.toFixed(1)}s) ===`);
312160
+ log2(summary2);
312161
+ return {
312162
+ collectedPages: [],
312163
+ collectedTransitions: [],
312164
+ tests: tests2,
312165
+ plans: plans2,
312166
+ scenarios: scenarios2.length > 0 ? scenarios2 : void 0,
312167
+ logs: logs2.join("\n"),
312168
+ screenshots,
312169
+ summary: summary2,
312170
+ exploreStats: { pagesDiscovered: 0, pagesVisited: 0, elementsFound: 0, transitionsFound: 0 },
312171
+ duration: duration2,
312172
+ mode,
312173
+ ...paused ? { paused: true, pauseReason, remainingScenarios } : {}
312174
+ // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
312175
+ };
312176
+ }
311675
312177
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
311676
312178
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
311677
- const maxIterations = mode === "deep" ? EXPLORE_BUDGET_DEEP : EXPLORE_BUDGET_SURVEY;
312179
+ const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
312180
+ const existingScreens = compactExistingScreens(ctx.existingSiteMap);
311678
312181
  const explore = await runExplore({
311679
312182
  ctx,
311680
312183
  driver,
311681
312184
  state: state2,
311682
312185
  config: { maxIterations },
312186
+ existingScreens,
311683
312187
  onLog: (line) => log2(line),
311684
312188
  onAICall,
311685
312189
  onScreenshot: (base64) => {
@@ -311689,7 +312193,8 @@ var require_mobile_discovery_agent = __commonJS({
311689
312193
  onScreenshot?.(base64);
311690
312194
  } catch {
311691
312195
  }
311692
- }
312196
+ },
312197
+ onScreenshotUpload
311693
312198
  });
311694
312199
  const collectedTransitions = toCollectedTransitions(explore);
311695
312200
  partialCollectedPages = explore.collectedPages;
@@ -311790,6 +312295,9 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311790
312295
  };
311791
312296
  }
311792
312297
  log2("\n\u2500\u2500 Phase 3: GENERATE \u2500\u2500");
312298
+ let generatePaused = false;
312299
+ let generatePauseReason;
312300
+ let generateRemainingScenarios;
311793
312301
  const tests = await runGenerate({
311794
312302
  scenarios,
311795
312303
  driver,
@@ -311797,7 +312305,15 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311797
312305
  onLog: (line) => log2(line),
311798
312306
  onAICall,
311799
312307
  onScreenshot,
311800
- onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
312308
+ onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
312309
+ // Legacy resume (no saved plan): still skip scenarios already generated by the
312310
+ // paused run so the AI isn't re-invoked for them. Undefined on a fresh run.
312311
+ skipScenarioNames: ctx.resume?.alreadyGeneratedScenarioNames,
312312
+ onInfraStop: (info) => {
312313
+ generatePaused = true;
312314
+ generatePauseReason = info.reason;
312315
+ generateRemainingScenarios = info.remainingScenarioNames;
312316
+ }
311801
312317
  });
311802
312318
  const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
311803
312319
  log2(`
@@ -311813,7 +312329,7 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
311813
312329
  };
311814
312330
  }
311815
312331
  const duration = (Date.now() - startTime) / 1e3;
311816
- const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s`;
312332
+ const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s${generatePaused ? " \u2014 PAUSED on AI provider overload (resumable)" : ""}`;
311817
312333
  log2(`
311818
312334
  === Mobile Discovery Pipeline Complete (${duration.toFixed(1)}s) ===`);
311819
312335
  log2(summary);
@@ -311838,7 +312354,11 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
311838
312354
  summary,
311839
312355
  exploreStats: explore.exploreStats,
311840
312356
  duration,
311841
- mode
312357
+ mode,
312358
+ // D-D4 — a circuit-breaker trip PAUSES the run (resumable) instead of
312359
+ // finalizing COMPLETED/FAILED. The server's isDiscoveryPaused reads these
312360
+ // (forwarded by runner.ts buildFinalBody, byte-identical to the web path).
312361
+ ...generatePaused ? { paused: true, pauseReason: generatePauseReason, remainingScenarios: generateRemainingScenarios } : {}
311842
312362
  // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
311843
312363
  };
311844
312364
  } catch (fatalErr) {
@@ -311884,6 +312404,7 @@ var require_discover_agent = __commonJS({
311884
312404
  Object.defineProperty(exports2, "__esModule", { value: true });
311885
312405
  exports2.runMobileDiscoveryOnRunner = exports2.buildInMemorySiteMap = void 0;
311886
312406
  exports2.runCanvasPreflight = runCanvasPreflight;
312407
+ exports2.getFatalExploreError = getFatalExploreError;
311887
312408
  exports2.runSiteDiscoveryOnRunner = runSiteDiscoveryOnRunner2;
311888
312409
  exports2.runFeatureDiscoveryOnRunner = runFeatureDiscoveryOnRunner2;
311889
312410
  var browser_config_js_1 = require_browser_config();
@@ -312066,7 +312587,7 @@ Respond with ONLY valid JSON:
312066
312587
  "keyFindings": "What was discovered - forms, validations, behaviors. Mark inferred items with '(inferred, not tested)'.",
312067
312588
  "testingNotes": "Gotchas, validation behaviors, error messages. List visible-but-untested routes as candidates for future exploration."
312068
312589
  }`;
312069
- const client = modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)();
312590
+ const client = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
312070
312591
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
312071
312592
  const callStart = Date.now();
312072
312593
  const response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => client.chat.completions.create({
@@ -312197,6 +312718,10 @@ Respond with ONLY valid JSON:
312197
312718
  });
312198
312719
  return timed.catch(() => null);
312199
312720
  }
312721
+ function getFatalExploreError(exploreResult) {
312722
+ const prefix = "Fatal error:";
312723
+ return exploreResult.summary.startsWith(prefix) ? exploreResult.summary.slice(prefix.length).trim() || exploreResult.summary : void 0;
312724
+ }
312200
312725
  async function runSiteDiscoveryOnRunner2(ctx, onLog, onAICall, sharedBrowser, onScreenshot, callbackOptions) {
312201
312726
  const logs2 = [];
312202
312727
  const screenshots = [];
@@ -312381,6 +312906,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
312381
312906
  if (loginRunP)
312382
312907
  await loginRunP;
312383
312908
  const duration2 = (Date.now() - startTime) / 1e3;
312909
+ const fatalExploreError = getFatalExploreError(exploreResult);
312384
312910
  return {
312385
312911
  collectedPages: exploreResult.collectedPages,
312386
312912
  collectedTransitions: exploreResult.collectedTransitions,
@@ -312391,6 +312917,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
312391
312917
  pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
312392
312918
  pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
312393
312919
  summary: exploreResult.summary || "Exploration completed but no page data captured.",
312920
+ error: fatalExploreError,
312394
312921
  exploreStats: {
312395
312922
  pagesDiscovered: exploreResult.pagesDiscovered,
312396
312923
  pagesVisited: exploreResult.pagesVisited,
@@ -312932,6 +313459,7 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
312932
313459
  discoveryApiCalls = earlyClose.apiCalls;
312933
313460
  postCloseHealthSummary = earlyClose.healthSummary;
312934
313461
  const duration2 = (Date.now() - startTime) / 1e3;
313462
+ const fatalExploreError = getFatalExploreError(exploreResult);
312935
313463
  return {
312936
313464
  collectedPages: exploreResult.collectedPages,
312937
313465
  collectedTransitions: exploreResult.collectedTransitions,
@@ -312942,7 +313470,8 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
312942
313470
  healthObservations: earlyClose.healthSummary,
312943
313471
  pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
312944
313472
  pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
312945
- summary: "Feature exploration completed but no page data captured.",
313473
+ summary: fatalExploreError ? exploreResult.summary : "Feature exploration completed but no page data captured.",
313474
+ error: fatalExploreError,
312946
313475
  exploreStats: {
312947
313476
  pagesDiscovered: exploreResult.pagesDiscovered,
312948
313477
  pagesVisited: exploreResult.pagesVisited,
@@ -326331,7 +326860,7 @@ var require_package4 = __commonJS({
326331
326860
  "package.json"(exports2, module2) {
326332
326861
  module2.exports = {
326333
326862
  name: "@validate.qa/runner",
326334
- version: "1.0.12",
326863
+ version: "1.0.14",
326335
326864
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
326336
326865
  bin: {
326337
326866
  "validate-runner": "dist/cli.js"
@@ -326339,7 +326868,7 @@ var require_package4 = __commonJS({
326339
326868
  scripts: {
326340
326869
  build: "tsup",
326341
326870
  dev: "tsx src/cli.ts",
326342
- "test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts",
326871
+ "test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts src/services/mobile/mobile-host-proxy.test.ts",
326343
326872
  prepublishOnly: "npm run build"
326344
326873
  },
326345
326874
  files: [
@@ -326566,8 +327095,10 @@ async function startAppiumServer(options) {
326566
327095
  return serverState;
326567
327096
  }
326568
327097
  const port = options?.port ?? APPIUM_PORT;
326569
- const drivers = options?.drivers ?? ["xcuitest", "uiautomator2"];
326570
- const args = ["--port", String(port), "--use-drivers", drivers.join(",")];
327098
+ const args = ["--port", String(port)];
327099
+ if (options?.drivers && options.drivers.length > 0) {
327100
+ args.push("--use-drivers", options.drivers.join(","));
327101
+ }
326571
327102
  intentionalStop = false;
326572
327103
  lastStartOptions = { ...lastStartOptions, ...options };
326573
327104
  if (appiumGaveUp) {
@@ -326577,11 +327108,12 @@ async function startAppiumServer(options) {
326577
327108
  killPort(port);
326578
327109
  return new Promise((resolve, reject) => {
326579
327110
  try {
326580
- appiumProcess = (0, import_child_process.spawn)("appium", args, {
327111
+ const child = (0, import_child_process.spawn)("appium", args, {
326581
327112
  stdio: ["pipe", "pipe", "pipe"],
326582
327113
  env: { ...process.env },
326583
327114
  detached: false
326584
327115
  });
327116
+ appiumProcess = child;
326585
327117
  serverState.pid = appiumProcess.pid ?? null;
326586
327118
  serverState.port = port;
326587
327119
  appiumProcess.stdout?.on("data", (data) => {
@@ -326598,7 +327130,11 @@ async function startAppiumServer(options) {
326598
327130
  options?.onLog?.(line);
326599
327131
  }
326600
327132
  });
326601
- appiumProcess.on("exit", (code, signal) => {
327133
+ child.on("exit", (code, signal) => {
327134
+ if (appiumProcess !== child) {
327135
+ appendLog(`[appium] Ignoring exit (code=${code}, signal=${signal}) from a superseded process`);
327136
+ return;
327137
+ }
326602
327138
  appendLog(`[appium] Process exited (code=${code}, signal=${signal})`);
326603
327139
  const currentPort = serverState.port;
326604
327140
  const currentRestartCount = serverState.restartCount;
@@ -326637,7 +327173,11 @@ async function startAppiumServer(options) {
326637
327173
  startupAbort.abort();
326638
327174
  reject(err);
326639
327175
  };
326640
- appiumProcess.on("error", (err) => {
327176
+ child.on("error", (err) => {
327177
+ if (appiumProcess !== child) {
327178
+ appendLog(`[appium] Ignoring error from a superseded process: ${err.message}`);
327179
+ return;
327180
+ }
326641
327181
  appendLog(`[appium] Process error: ${err.message}`);
326642
327182
  resetServerState(port);
326643
327183
  settleReject(new Error(`Failed to start Appium: ${err.message}. Is appium installed? Run: npm i -g appium`));
@@ -326756,6 +327296,7 @@ function runCommand(command, args, timeout = 1e4) {
326756
327296
  }
326757
327297
  var realIOSProfilerNegativeUntil = 0;
326758
327298
  var REAL_IOS_PROFILER_TTL_MS = 6e4;
327299
+ var REAL_IOS_NO_UDID_HINT = "no paired UDID \u2014 install libimobiledevice (brew install libimobiledevice) and trust this computer on the device";
326759
327300
  function discoverIOSSimulators() {
326760
327301
  if ((0, import_os2.platform)() !== "darwin") return [];
326761
327302
  const json = runCommand("xcrun", ["simctl", "list", "devices", "--json"]);
@@ -326765,7 +327306,8 @@ function discoverIOSSimulators() {
326765
327306
  const devices = [];
326766
327307
  for (const [runtime, runtimeDevices] of Object.entries(parsed.devices)) {
326767
327308
  const versionMatch = runtime.match(/iOS[- ](\d+(?:[-.]\d+)*)/i);
326768
- const platformVersion = versionMatch ? versionMatch[1].replace(/-/g, ".") : "unknown";
327309
+ if (!versionMatch) continue;
327310
+ const platformVersion = versionMatch[1].replace(/-/g, ".");
326769
327311
  for (const device of runtimeDevices) {
326770
327312
  const state2 = device.state === "Booted" ? "BOOTED" : device.state === "Shutdown" ? "SHUTDOWN" : "LOADING";
326771
327313
  if (state2 === "BOOTED") {
@@ -326838,11 +327380,11 @@ function discoverRealIOSDevices() {
326838
327380
  const version = runCommand("ideviceinfo", ["-u", serial, "-k", "ProductVersion"]) || "unknown";
326839
327381
  devices.push({
326840
327382
  id: serial,
326841
- name,
327383
+ name: `${name} (${REAL_IOS_NO_UDID_HINT})`,
326842
327384
  platform: "IOS",
326843
327385
  platformVersion: version,
326844
327386
  state: "BOOTED",
326845
- isAvailable: true,
327387
+ isAvailable: false,
326846
327388
  isPhysical: true
326847
327389
  });
326848
327390
  }
@@ -326858,11 +327400,11 @@ function discoverRealIOSDevices() {
326858
327400
  if (hasIPhone || hasIPad) {
326859
327401
  devices.push({
326860
327402
  id: "usb-ios-device",
326861
- name: hasIPhone ? "iPhone (USB)" : "iPad (USB)",
327403
+ name: `${hasIPhone ? "iPhone (USB)" : "iPad (USB)"} (${REAL_IOS_NO_UDID_HINT})`,
326862
327404
  platform: "IOS",
326863
327405
  platformVersion: "unknown",
326864
327406
  state: "BOOTED",
326865
- isAvailable: true,
327407
+ isAvailable: false,
326866
327408
  isPhysical: true
326867
327409
  });
326868
327410
  }
@@ -326940,7 +327482,9 @@ function discoverAndroidDevices() {
326940
327482
  const adbDevices = parseAdbDevices();
326941
327483
  return adbDevices.filter((d) => d.type === "device" || d.type === "unauthorized").map((d) => {
326942
327484
  const isEmulator = d.serial.startsWith("emulator-");
326943
- const isAvailable = d.type === "device";
327485
+ const authorized = d.type === "device";
327486
+ const bootCompleted = authorized && getAndroidProp(d.serial, "sys.boot_completed") === "1";
327487
+ const isAvailable = authorized && bootCompleted;
326944
327488
  const name = d.model || d.device || (isEmulator ? "Android Emulator" : "Android Device");
326945
327489
  const platformVersion = isAvailable ? getAndroidProp(d.serial, "ro.build.version.release") || "unknown" : "unknown";
326946
327490
  return {
@@ -326948,7 +327492,10 @@ function discoverAndroidDevices() {
326948
327492
  name,
326949
327493
  platform: "ANDROID",
326950
327494
  platformVersion,
326951
- state: "BOOTED",
327495
+ // LOADING = authorized but still booting; an unauthorized device stays
327496
+ // BOOTED (isAvailable false) so the dashboard shows it with an
327497
+ // "accept USB debugging" hint rather than hiding it.
327498
+ state: authorized && !bootCompleted ? "LOADING" : "BOOTED",
326952
327499
  isAvailable,
326953
327500
  // Mark if it's a real device vs emulator
326954
327501
  ...isEmulator ? {} : { isPhysical: true }
@@ -326994,15 +327541,25 @@ function discoverAllDevices(options) {
326994
327541
  }
326995
327542
  return [...iosDevices, ...androidDevices];
326996
327543
  }
327544
+ var DEVICE_SNAPSHOT_TTL_MS = 8e3;
327545
+ var cachedDeviceSnapshot = null;
327546
+ function getCachedDeviceSnapshot() {
327547
+ const now = Date.now();
327548
+ if (cachedDeviceSnapshot && now - cachedDeviceSnapshot.at < DEVICE_SNAPSHOT_TTL_MS) {
327549
+ return cachedDeviceSnapshot.devices;
327550
+ }
327551
+ const devices = discoverAllDevices();
327552
+ cachedDeviceSnapshot = { at: now, devices };
327553
+ return devices;
327554
+ }
326997
327555
  function getRunnerCapabilities() {
326998
- const iosDevices = discoverIOSDevices();
326999
- const androidDevices = discoverAndroidDevices();
327556
+ const devices = getCachedDeviceSnapshot();
327000
327557
  const isReadyDevice = (device) => device.isAvailable && device.state === "BOOTED";
327001
327558
  return {
327002
327559
  web: true,
327003
327560
  // Always capable of web testing via Playwright
327004
- ios: iosDevices.some(isReadyDevice),
327005
- android: androidDevices.some(isReadyDevice)
327561
+ ios: devices.some((d) => d.platform === "IOS" && isReadyDevice(d)),
327562
+ android: devices.some((d) => d.platform === "ANDROID" && isReadyDevice(d))
327006
327563
  };
327007
327564
  }
327008
327565
 
@@ -327022,6 +327579,8 @@ var DEVICE_CMD_TIMEOUT_MS = 15e3;
327022
327579
  var CA_KEY_BITS = 2048;
327023
327580
  var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
327024
327581
  var PENDING_CLEANUP_DIR = (0, import_node_path.join)((0, import_node_os.tmpdir)(), "validateqa-mobile-capture-pending");
327582
+ var IOS_SIM_HOST_PROXY_ENV = "VALIDATEQA_MOBILE_IOS_SIM_HOST_PROXY";
327583
+ var STALE_MARKER_RECONCILE_MS = 6 * 60 * 60 * 1e3;
327025
327584
  function envFlag(name) {
327026
327585
  const value = (process.env[name] ?? "").trim().toLowerCase();
327027
327586
  return value === "1" || value === "true" || value === "yes" || value === "on";
@@ -327138,6 +327697,9 @@ var MitmProxy = class {
327138
327697
  await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
327139
327698
  await server3.on("request", (req) => this.onRequest(req));
327140
327699
  await server3.on("response", (res) => this.onResponse(res));
327700
+ await server3.on("abort", (req) => {
327701
+ this.inFlight.delete(req.id);
327702
+ });
327141
327703
  await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
327142
327704
  await server3.on("tls-passthrough-opened", (event) => this.onTlsPassthroughOpened(event));
327143
327705
  await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
@@ -327434,6 +327996,125 @@ async function iosSimRemoveCa(deviceId, onLog) {
327434
327996
  function errMsg(err) {
327435
327997
  return err instanceof Error ? err.message : String(err);
327436
327998
  }
327999
+ function parsePrimaryNetworkService(stdout) {
328000
+ for (const line of stdout.split("\n")) {
328001
+ const m = line.match(/^\(\d+\)\s+(.+)$/);
328002
+ if (m) return m[1].trim();
328003
+ }
328004
+ return null;
328005
+ }
328006
+ function parseWebProxyState(stdout) {
328007
+ const field = (label) => {
328008
+ const m = stdout.match(new RegExp(`^${label}:[ \\t]*(.*)$`, "im"));
328009
+ return m ? m[1].trim() : "";
328010
+ };
328011
+ return {
328012
+ enabled: field("Enabled").toLowerCase() === "yes",
328013
+ server: field("Server"),
328014
+ port: field("Port")
328015
+ };
328016
+ }
328017
+ function buildHostProxySetCommands(service, host, port) {
328018
+ const portStr = String(port);
328019
+ return [
328020
+ ["-setwebproxy", service, host, portStr],
328021
+ ["-setsecurewebproxy", service, host, portStr]
328022
+ ];
328023
+ }
328024
+ function buildProxyRestoreArgs(setCmd, stateCmd, service, prior) {
328025
+ if (prior.server) {
328026
+ return [
328027
+ [setCmd, service, prior.server, prior.port || "0"],
328028
+ [stateCmd, service, prior.enabled ? "on" : "off"]
328029
+ ];
328030
+ }
328031
+ return [[stateCmd, service, "off"]];
328032
+ }
328033
+ function buildHostProxyRestoreCommands(service, prior) {
328034
+ return [
328035
+ ...buildProxyRestoreArgs("-setwebproxy", "-setwebproxystate", service, prior.web),
328036
+ ...buildProxyRestoreArgs("-setsecurewebproxy", "-setsecurewebproxystate", service, prior.secure)
328037
+ ];
328038
+ }
328039
+ function hostProxyPriorFromMarker(marker) {
328040
+ return {
328041
+ web: {
328042
+ enabled: Boolean(marker.hostWebProxyWasEnabled),
328043
+ server: marker.hostWebProxyPriorServer ?? "",
328044
+ port: marker.hostWebProxyPriorPort ?? ""
328045
+ },
328046
+ secure: {
328047
+ enabled: Boolean(marker.hostSecureProxyWasEnabled),
328048
+ server: marker.hostSecureProxyPriorServer ?? "",
328049
+ port: marker.hostSecureProxyPriorPort ?? ""
328050
+ }
328051
+ };
328052
+ }
328053
+ function hostProxyControlSupported(platform3, isPhysical, platformName = process.platform, optedIn = envFlag(IOS_SIM_HOST_PROXY_ENV)) {
328054
+ if (platform3 !== "IOS") return { supported: false };
328055
+ if (isPhysical) {
328056
+ return { supported: false, reason: "physical iOS device requires a manual Wi-Fi HTTP proxy; host proxy not applied" };
328057
+ }
328058
+ if (platformName !== "darwin") {
328059
+ return { supported: false, reason: `iOS simulator host proxy is only controllable on macOS (host is ${platformName})` };
328060
+ }
328061
+ if (!optedIn) {
328062
+ return {
328063
+ supported: false,
328064
+ reason: `iOS simulator HTTPS body capture is disabled: it requires repointing the macOS SYSTEM-WIDE HTTP/HTTPS proxy, which would break host HTTPS (Safari/Chrome/Electron) and capture the host's own traffic for this run. Not applied without consent \u2014 set ${IOS_SIM_HOST_PROXY_ENV}=1 to enable it.`
328065
+ };
328066
+ }
328067
+ return { supported: true };
328068
+ }
328069
+ async function iosSimConfigureHostProxy(host, port, onLog) {
328070
+ let service;
328071
+ let prior;
328072
+ try {
328073
+ const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
328074
+ timeout: DEVICE_CMD_TIMEOUT_MS
328075
+ });
328076
+ const svc = parsePrimaryNetworkService(order);
328077
+ if (!svc) {
328078
+ return { configured: false, reason: "iOS simulator host proxy could not be configured: no active macOS network service found" };
328079
+ }
328080
+ service = svc;
328081
+ const [{ stdout: webOut }, { stdout: secureOut }] = await Promise.all([
328082
+ execFileAsync("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
328083
+ execFileAsync("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
328084
+ ]);
328085
+ prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
328086
+ for (const args of buildHostProxySetCommands(service, host, port)) {
328087
+ await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328088
+ }
328089
+ onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
328090
+ return { configured: true, service, prior };
328091
+ } catch (err) {
328092
+ if (service && prior) {
328093
+ onLog?.(`[mobile-net] host proxy set failed mid-way \u2014 rolling back "${service}" to prior state: ${errMsg(err)}`);
328094
+ for (const args of buildHostProxyRestoreCommands(service, prior)) {
328095
+ try {
328096
+ await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328097
+ } catch {
328098
+ }
328099
+ }
328100
+ }
328101
+ return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
328102
+ }
328103
+ }
328104
+ async function iosSimRestoreHostProxy(service, prior, onLog) {
328105
+ const restore = prior ?? {
328106
+ web: { enabled: false, server: "", port: "" },
328107
+ secure: { enabled: false, server: "", port: "" }
328108
+ };
328109
+ for (const args of buildHostProxyRestoreCommands(service, restore)) {
328110
+ try {
328111
+ await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328112
+ } catch (err) {
328113
+ onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg(err)}`);
328114
+ }
328115
+ }
328116
+ onLog?.(`[mobile-net] macOS host proxy restored on "${service}".`);
328117
+ }
327437
328118
  var activeCleanups = /* @__PURE__ */ new Map();
327438
328119
  var exitHandlersInstalled = false;
327439
328120
  function markerPath(sessionKey) {
@@ -327468,6 +328149,14 @@ function restoreDeviceSync(marker, onLog) {
327468
328149
  run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
327469
328150
  }
327470
328151
  }
328152
+ if (marker.platform === "IOS" && marker.hostProxySet && marker.hostProxyService && process.platform === "darwin") {
328153
+ for (const args of buildHostProxyRestoreCommands(marker.hostProxyService, hostProxyPriorFromMarker(marker))) {
328154
+ try {
328155
+ (0, import_node_child_process.execFileSync)("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
328156
+ } catch {
328157
+ }
328158
+ }
328159
+ }
327471
328160
  onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
327472
328161
  }
327473
328162
  async function reconcilePendingCleanups(onLog) {
@@ -327479,6 +328168,8 @@ async function reconcilePendingCleanups(onLog) {
327479
328168
  }
327480
328169
  for (const file of files) {
327481
328170
  if (!file.endsWith(".json")) continue;
328171
+ const sessionKey = file.slice(0, -".json".length);
328172
+ if (activeCleanups.has(sessionKey)) continue;
327482
328173
  const full = (0, import_node_path.join)(PENDING_CLEANUP_DIR, file);
327483
328174
  try {
327484
328175
  const raw = await (0, import_promises.readFile)(full, "utf-8");
@@ -327508,8 +328199,10 @@ function ensureExitHandlers() {
327508
328199
  for (const signal of ["SIGTERM", "SIGINT"]) {
327509
328200
  process.on(signal, () => {
327510
328201
  drain();
327511
- process.removeAllListeners(signal);
327512
- process.kill(process.pid, signal);
328202
+ if (process.listenerCount(signal) <= 1) {
328203
+ process.removeAllListeners(signal);
328204
+ process.kill(process.pid, signal);
328205
+ }
327513
328206
  });
327514
328207
  }
327515
328208
  }
@@ -327559,6 +328252,9 @@ async function startMobileNetworkCapture(options) {
327559
328252
  let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
327560
328253
  let proxySet = false;
327561
328254
  let caInstalledOnDevice = false;
328255
+ let hostProxySet = false;
328256
+ let hostProxyService;
328257
+ let hostProxyPrior;
327562
328258
  try {
327563
328259
  if (platform3 === "ANDROID") {
327564
328260
  proxySet = await androidSetProxy(deviceId, hostPort, onLog);
@@ -327573,25 +328269,50 @@ async function startMobileNetworkCapture(options) {
327573
328269
  }
327574
328270
  } else {
327575
328271
  if (!isPhysical && caCertPath) {
327576
- const { trusted, note } = await iosSimTrustCa(deviceId, caCertPath);
328272
+ const { trusted } = await iosSimTrustCa(deviceId, caCertPath);
327577
328273
  caInstalledOnDevice = trusted;
327578
- wiringReason = note;
328274
+ }
328275
+ const support = hostProxyControlSupported(platform3, isPhysical);
328276
+ if (support.supported) {
328277
+ const hp = await iosSimConfigureHostProxy(proxyHost, proxyPort, onLog);
328278
+ if (hp.configured) {
328279
+ hostProxySet = true;
328280
+ hostProxyService = hp.service;
328281
+ hostProxyPrior = hp.prior;
328282
+ 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).` : ".");
328283
+ } else {
328284
+ wiringReason = hp.reason ?? "iOS simulator host proxy could not be configured";
328285
+ onLog?.(`[mobile-net] ${wiringReason}`);
328286
+ }
327579
328287
  } else if (isPhysical) {
327580
328288
  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}).` : ".");
328289
+ } else {
328290
+ wiringReason = support.reason ?? "iOS simulator host proxy is unsupported on this host";
328291
+ onLog?.(`[mobile-net] ${wiringReason}`);
327581
328292
  }
327582
328293
  }
327583
328294
  } catch (err) {
327584
328295
  wiringReason = `device wiring degraded: ${errMsg(err)}`;
327585
328296
  onLog?.(`[mobile-net] ${wiringReason}`);
327586
328297
  }
327587
- if (proxySet || caInstalledOnDevice) {
328298
+ if (proxySet || caInstalledOnDevice || hostProxySet) {
327588
328299
  const marker = {
327589
328300
  platform: platform3,
327590
328301
  deviceId,
327591
328302
  isPhysical,
327592
328303
  proxySet,
327593
328304
  originalProxy,
327594
- caInstalledOnDevice
328305
+ caInstalledOnDevice,
328306
+ ...hostProxySet ? {
328307
+ hostProxySet: true,
328308
+ hostProxyService,
328309
+ hostWebProxyWasEnabled: hostProxyPrior?.web.enabled,
328310
+ hostWebProxyPriorServer: hostProxyPrior?.web.server,
328311
+ hostWebProxyPriorPort: hostProxyPrior?.web.port,
328312
+ hostSecureProxyWasEnabled: hostProxyPrior?.secure.enabled,
328313
+ hostSecureProxyPriorServer: hostProxyPrior?.secure.server,
328314
+ hostSecureProxyPriorPort: hostProxyPrior?.secure.port
328315
+ } : {}
327595
328316
  };
327596
328317
  activeCleanups.set(sessionKey, marker);
327597
328318
  await writePendingCleanup(sessionKey, marker);
@@ -327611,6 +328332,9 @@ async function startMobileNetworkCapture(options) {
327611
328332
  if (proxySet && platform3 === "ANDROID") {
327612
328333
  await androidClearProxy(deviceId, originalProxy, onLog);
327613
328334
  }
328335
+ if (hostProxySet && hostProxyService && platform3 === "IOS" && !isPhysical) {
328336
+ await iosSimRestoreHostProxy(hostProxyService, hostProxyPrior, onLog);
328337
+ }
327614
328338
  if (caInstalledOnDevice && capturedCaCertPath) {
327615
328339
  if (platform3 === "ANDROID") {
327616
328340
  await androidRemoveCa(deviceId, onLog);
@@ -327646,6 +328370,9 @@ async function startMobileNetworkCapture(options) {
327646
328370
 
327647
328371
  // src/services/appium-executor.ts
327648
328372
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
328373
+ var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
328374
+ var WD_ACTION_TIMEOUT_MS = 3e4;
328375
+ var MAX_SESSION_CREATE_ATTEMPTS = 3;
327649
328376
  var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
327650
328377
  var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
327651
328378
  var WebDriverTransportError = class extends Error {
@@ -327659,13 +328386,21 @@ function isTransportError(err) {
327659
328386
  return err instanceof WebDriverTransportError || typeof err === "object" && err !== null && err.isTransport === true;
327660
328387
  }
327661
328388
  async function wdRequest(path, options) {
328389
+ const isSessionCreate = (options?.method ?? "GET").toUpperCase() === "POST" && path === "/session";
328390
+ const timeoutMs = isSessionCreate ? WD_SESSION_CREATE_TIMEOUT_MS : WD_ACTION_TIMEOUT_MS;
327662
328391
  let res;
327663
328392
  try {
327664
328393
  res = await fetch(`http://localhost:${getAppiumState().port}${path}`, {
327665
328394
  ...options,
328395
+ signal: AbortSignal.timeout(timeoutMs),
327666
328396
  headers: { "Content-Type": "application/json", ...options?.headers }
327667
328397
  });
327668
328398
  } catch (err) {
328399
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
328400
+ throw new WebDriverTransportError(
328401
+ `WebDriver request to ${path} timed out after ${timeoutMs}ms (device/Appium unresponsive)`
328402
+ );
328403
+ }
327669
328404
  throw new WebDriverTransportError(`WebDriver connection error: ${err instanceof Error ? err.message : String(err)}`);
327670
328405
  }
327671
328406
  if (!res.ok) {
@@ -327750,6 +328485,31 @@ async function createSession(target) {
327750
328485
  if (!sessionId) throw new Error("Appium session creation returned no session ID");
327751
328486
  return sessionId;
327752
328487
  }
328488
+ var TERMINAL_SESSION_START = /not installed|no such (app|file)|could not confirm|bundle ?id .*(not|missing)|xcodeorgid|signing|provisioning|not authorized|unauthorized/i;
328489
+ 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;
328490
+ function isRetryableSessionStartError(err) {
328491
+ const message = err instanceof Error ? err.message : String(err);
328492
+ if (TERMINAL_SESSION_START.test(message)) return false;
328493
+ if (isTransportError(err)) return true;
328494
+ return RETRYABLE_SESSION_START.test(message);
328495
+ }
328496
+ async function createSessionWithRetry(target, onRetry) {
328497
+ let lastErr;
328498
+ for (let attempt = 1; attempt <= MAX_SESSION_CREATE_ATTEMPTS; attempt++) {
328499
+ try {
328500
+ return await createSession(target);
328501
+ } catch (err) {
328502
+ lastErr = err;
328503
+ if (attempt >= MAX_SESSION_CREATE_ATTEMPTS || !isRetryableSessionStartError(err)) throw err;
328504
+ const delayMs = Math.pow(2, attempt) * 1e3;
328505
+ onRetry?.(
328506
+ `Appium session start failed (attempt ${attempt}/${MAX_SESSION_CREATE_ATTEMPTS}, retrying in ${delayMs}ms): ${err instanceof Error ? err.message : String(err)}`
328507
+ );
328508
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
328509
+ }
328510
+ }
328511
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
328512
+ }
327753
328513
  async function openDeepLink(sessionId, platform3, deeplink, appId) {
327754
328514
  const args = platform3 === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
327755
328515
  await wdRequest(`/session/${sessionId}/execute/sync`, {
@@ -327851,6 +328611,61 @@ async function takeScreenshot(sessionId) {
327851
328611
  return null;
327852
328612
  }
327853
328613
  }
328614
+ function mobileRecordingEnabled() {
328615
+ const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
328616
+ return value !== "0" && value !== "false";
328617
+ }
328618
+ function readPositiveIntEnv(name, fallback, min, max) {
328619
+ const raw = process.env[name];
328620
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
328621
+ if (!Number.isFinite(parsed)) return fallback;
328622
+ return Math.max(min, Math.min(max, parsed));
328623
+ }
328624
+ async function startScreenRecording(sessionId, platform3, log2) {
328625
+ if (!mobileRecordingEnabled()) {
328626
+ log2("[mobile-video] recording disabled by VALIDATEQA_MOBILE_RUN_VIDEO=0");
328627
+ return false;
328628
+ }
328629
+ const timeLimit = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_SECONDS", 180, 5, 180);
328630
+ const options = {
328631
+ timeLimit: String(timeLimit),
328632
+ forceRestart: true
328633
+ };
328634
+ if (platform3 === "ANDROID") {
328635
+ options.bitRate = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_BITRATE", 75e4, 1e5, 4e6);
328636
+ } else {
328637
+ options.videoQuality = process.env.VALIDATEQA_MOBILE_RECORDING_IOS_QUALITY || "low";
328638
+ }
328639
+ try {
328640
+ await wdRequest(`/session/${sessionId}/appium/start_recording_screen`, {
328641
+ method: "POST",
328642
+ body: JSON.stringify({ options })
328643
+ });
328644
+ log2(`[mobile-video] screen recording started (${timeLimit}s max)`);
328645
+ return true;
328646
+ } catch (err) {
328647
+ log2(`[mobile-video] screen recording unavailable: ${err instanceof Error ? err.message : String(err)}`);
328648
+ return false;
328649
+ }
328650
+ }
328651
+ async function stopScreenRecording(sessionId, log2) {
328652
+ try {
328653
+ const result = await wdRequest(`/session/${sessionId}/appium/stop_recording_screen`, {
328654
+ method: "POST",
328655
+ body: JSON.stringify({ options: {} })
328656
+ });
328657
+ const value = typeof result?.value === "string" ? result.value.trim() : "";
328658
+ if (!value) {
328659
+ log2("[mobile-video] screen recording stopped but Appium returned no video");
328660
+ return void 0;
328661
+ }
328662
+ log2(`[mobile-video] screen recording captured (${Math.round(value.length / 1024)}KB base64)`);
328663
+ return value;
328664
+ } catch (err) {
328665
+ log2(`[mobile-video] screen recording stop failed: ${err instanceof Error ? err.message : String(err)}`);
328666
+ return void 0;
328667
+ }
328668
+ }
327854
328669
  function boundsFromStep(step) {
327855
328670
  const bounds = step.metadata?.bounds;
327856
328671
  if (bounds && typeof bounds === "object" && !Array.isArray(bounds) && typeof bounds.x === "number" && typeof bounds.y === "number" && typeof bounds.width === "number" && typeof bounds.height === "number") {
@@ -327970,7 +328785,8 @@ async function sendKeysToFocusedInput(sessionId, value) {
327970
328785
  });
327971
328786
  }
327972
328787
  function encodeAndroidInputText(value) {
327973
- return value.replace(/%/g, "%25").replace(/\s/g, "%s");
328788
+ const inputEncoded = value.replace(/%/g, "%25").replace(/\s/g, "%s");
328789
+ return `'${inputEncoded.replace(/'/g, `'\\''`)}'`;
327974
328790
  }
327975
328791
  async function sendAndroidTextToFocusedInput(deviceId, value) {
327976
328792
  if (!deviceId) {
@@ -328228,6 +329044,9 @@ async function executeMobileTest(options) {
328228
329044
  let sessionId = null;
328229
329045
  let capture = null;
328230
329046
  let captureStopped = false;
329047
+ let recordingStarted = false;
329048
+ let recordingStopped = false;
329049
+ let video;
328231
329050
  const stopCapture = async () => {
328232
329051
  if (!capture || captureStopped) return apiCalls;
328233
329052
  captureStopped = true;
@@ -328242,6 +329061,15 @@ async function executeMobileTest(options) {
328242
329061
  }
328243
329062
  return apiCalls;
328244
329063
  };
329064
+ const stopRecording = async () => {
329065
+ if (!sessionId || !recordingStarted || recordingStopped) return video;
329066
+ recordingStopped = true;
329067
+ const captured = await stopScreenRecording(sessionId, log2);
329068
+ if (captured) {
329069
+ video = captured;
329070
+ }
329071
+ return video;
329072
+ };
328245
329073
  try {
328246
329074
  log2("Checking Appium server health...");
328247
329075
  const health = await checkAppiumHealth();
@@ -328295,13 +329123,13 @@ async function executeMobileTest(options) {
328295
329123
  }
328296
329124
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
328297
329125
  try {
328298
- sessionId = await createSession({
329126
+ sessionId = await createSessionWithRetry({
328299
329127
  appId: options.target.appId,
328300
329128
  platform: options.target.platform,
328301
329129
  deviceId: selectedDevice.id,
328302
329130
  platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
328303
329131
  isPhysical: selectedDevice.isPhysical
328304
- });
329132
+ }, (msg) => log2(msg));
328305
329133
  } catch (err) {
328306
329134
  const base2 = err instanceof Error ? err.message : String(err);
328307
329135
  if (selectedDevice.isPhysical) {
@@ -328316,6 +329144,7 @@ async function executeMobileTest(options) {
328316
329144
  throw err;
328317
329145
  }
328318
329146
  log2(`Session created: ${sessionId}`);
329147
+ recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
328319
329148
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
328320
329149
  try {
328321
329150
  log2(`Opening deep link: ${options.target.deeplink}`);
@@ -328376,7 +329205,9 @@ async function executeMobileTest(options) {
328376
329205
  break;
328377
329206
  }
328378
329207
  }
328379
- await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
329208
+ if (step.action !== "home" && step.action !== "back") {
329209
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
329210
+ }
328380
329211
  } catch (err) {
328381
329212
  status = "failed";
328382
329213
  stepError = err instanceof Error ? err.message : String(err);
@@ -328405,10 +329236,12 @@ async function executeMobileTest(options) {
328405
329236
  const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
328406
329237
  const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
328407
329238
  await stopCapture();
329239
+ await stopRecording();
328408
329240
  return {
328409
329241
  passed: allPassed,
328410
329242
  stepResults,
328411
329243
  screenshots,
329244
+ ...video ? { video, videoMimeType: "video/mp4" } : {},
328412
329245
  apiCalls,
328413
329246
  logs: logLines.join("\n"),
328414
329247
  error: firstError,
@@ -328419,10 +329252,12 @@ async function executeMobileTest(options) {
328419
329252
  const errorMsg = err instanceof Error ? err.message : String(err);
328420
329253
  log2(`Fatal error: ${errorMsg}`);
328421
329254
  await stopCapture();
329255
+ await stopRecording();
328422
329256
  return {
328423
329257
  passed: false,
328424
329258
  stepResults,
328425
329259
  screenshots,
329260
+ ...video ? { video, videoMimeType: "video/mp4" } : {},
328426
329261
  apiCalls,
328427
329262
  logs: logLines.join("\n"),
328428
329263
  error: errorMsg,
@@ -328434,6 +329269,7 @@ async function executeMobileTest(options) {
328434
329269
  };
328435
329270
  } finally {
328436
329271
  await stopCapture();
329272
+ await stopRecording();
328437
329273
  if (sessionId) {
328438
329274
  log2("Tearing down Appium session...");
328439
329275
  await deleteSession(sessionId);
@@ -328474,7 +329310,7 @@ async function createMobileDiscoverySession(target, options) {
328474
329310
  isPhysical: selectedDevice.isPhysical === true,
328475
329311
  platformVersion: selectedDevice.platformVersion
328476
329312
  });
328477
- const sessionId = await createSession({
329313
+ const sessionId = await createSessionWithRetry({
328478
329314
  appId: target.appId,
328479
329315
  platform: target.platform,
328480
329316
  deviceId: selectedDevice.id,
@@ -329285,7 +330121,7 @@ function stopSessionReaper() {
329285
330121
  }
329286
330122
  }
329287
330123
  async function reapIdleSession() {
329288
- if (!state.appiumSessionId || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
330124
+ if (!state.appiumSessionId || mirrorSessionOwner !== "recording" || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
329289
330125
  return;
329290
330126
  }
329291
330127
  const sessionId = state.appiumSessionId;
@@ -330101,6 +330937,79 @@ function printDoctorReport(report) {
330101
330937
  console.log("");
330102
330938
  }
330103
330939
 
330940
+ // src/result-payload-budget.ts
330941
+ var SERVER_RESULT_BODY_LIMIT_BYTES = 50 * 1024 * 1024;
330942
+ var RESULT_BODY_BUDGET_BYTES = 48 * 1024 * 1024;
330943
+ var SERVER_VIDEO_STORE_CAP_BYTES = 24 * 1024 * 1024;
330944
+ function serializedBytes(value) {
330945
+ return Buffer.byteLength(JSON.stringify(value) ?? "", "utf8");
330946
+ }
330947
+ function budgetResultPayload(result, maxBytes = RESULT_BODY_BUDGET_BYTES, videoStoreCapBytes = SERVER_VIDEO_STORE_CAP_BYTES) {
330948
+ const oversizedVideo = typeof result.video === "string" && result.video.length > videoStoreCapBytes;
330949
+ let bytes = serializedBytes(result);
330950
+ if (bytes <= maxBytes && !oversizedVideo) {
330951
+ return { payload: result, shed: [], bytes };
330952
+ }
330953
+ const shed = [];
330954
+ let payload = result;
330955
+ const drop = (fields, label) => {
330956
+ const next = { ...payload };
330957
+ for (const field of fields) next[field] = void 0;
330958
+ payload = next;
330959
+ shed.push(label);
330960
+ bytes = serializedBytes(payload);
330961
+ };
330962
+ if ((bytes > maxBytes || oversizedVideo) && payload.video != null) {
330963
+ drop(["video", "videoMimeType"], "video");
330964
+ }
330965
+ if (bytes > maxBytes && Array.isArray(payload.screenshots) && payload.screenshots.length > 0) {
330966
+ const shots = [...payload.screenshots];
330967
+ const bySizeDesc = shots.map((shot, i) => ({ i, len: typeof shot === "string" ? shot.length : 0 })).sort((a, b) => b.len - a.len);
330968
+ const overshoot = bytes - maxBytes;
330969
+ let shaved = 0;
330970
+ let blanked = 0;
330971
+ for (const { i, len } of bySizeDesc) {
330972
+ if (shaved >= overshoot || len === 0) break;
330973
+ if (shots[i] === "") continue;
330974
+ shots[i] = "";
330975
+ shaved += len;
330976
+ blanked++;
330977
+ }
330978
+ if (blanked > 0) {
330979
+ payload = { ...payload, screenshots: shots };
330980
+ bytes = serializedBytes(payload);
330981
+ shed.push(blanked === shots.length ? "screenshots" : `${blanked}/${shots.length} screenshots`);
330982
+ }
330983
+ }
330984
+ if (bytes > maxBytes && payload.harApiCalls != null) drop(["harApiCalls"], "harApiCalls");
330985
+ if (bytes > maxBytes && payload.apiCalls != null) drop(["apiCalls"], "apiCalls");
330986
+ if (shed.length > 0) {
330987
+ const note = `
330988
+
330989
+ \u26A0 [runner] Result media trimmed before upload \u2014 payload exceeded the ${Math.round(maxBytes / (1024 * 1024))}MB server limit; dropped: ${shed.join(", ")}.`;
330990
+ payload = {
330991
+ ...payload,
330992
+ logs: (typeof payload.logs === "string" ? payload.logs : "") + note
330993
+ };
330994
+ bytes = serializedBytes(payload);
330995
+ }
330996
+ if (bytes > maxBytes && typeof payload.logs === "string" && payload.logs.length > 4096) {
330997
+ const over = bytes - maxBytes;
330998
+ const targetLen = Math.max(2048, payload.logs.length - over - 1024);
330999
+ if (targetLen < payload.logs.length) {
331000
+ const half = Math.floor(targetLen / 2);
331001
+ const head = payload.logs.slice(0, half);
331002
+ const tail = payload.logs.slice(payload.logs.length - half);
331003
+ payload = { ...payload, logs: `${head}
331004
+ \u2026 [logs truncated to fit upload] \u2026
331005
+ ${tail}` };
331006
+ if (!shed.includes("logs (truncated)")) shed.push("logs (truncated)");
331007
+ bytes = serializedBytes(payload);
331008
+ }
331009
+ }
331010
+ return { payload, shed, bytes };
331011
+ }
331012
+
330104
331013
  // src/runner.ts
330105
331014
  init_dist();
330106
331015
  var import_runner_core12 = __toESM(require_dist2());
@@ -330269,15 +331178,33 @@ async function claimRun(serverUrl, headers, runId) {
330269
331178
  async function postResult(serverUrl, headers, runId, result) {
330270
331179
  const MAX_ATTEMPTS = 3;
330271
331180
  let lastError;
331181
+ const budgeted = budgetResultPayload(result);
331182
+ if (budgeted.shed.length > 0) {
331183
+ console.warn(
331184
+ `[runner] Result payload for run ${runId} exceeded the upload budget (${Math.round(budgeted.bytes / (1024 * 1024))}MB after trim); dropped: ${budgeted.shed.join(", ")}.`
331185
+ );
331186
+ }
331187
+ let payload = budgeted.payload;
330272
331188
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
330273
331189
  try {
330274
331190
  const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/result`, {
330275
331191
  method: "POST",
330276
331192
  headers,
330277
- body: JSON.stringify(result),
331193
+ body: JSON.stringify(payload),
330278
331194
  signal: AbortSignal.timeout(6e4)
330279
331195
  });
330280
331196
  if (res.ok) return;
331197
+ if (res.status === 413) {
331198
+ const minimal = budgetResultPayload(payload, 0);
331199
+ if (minimal.shed.length > 0) {
331200
+ console.warn(
331201
+ `[runner] Server rejected result for run ${runId} as too large (413); retrying with a verdict-only payload (dropped: ${minimal.shed.join(", ")}).`
331202
+ );
331203
+ payload = minimal.payload;
331204
+ lastError = new Error("HTTP 413 (payload trimmed, retrying)");
331205
+ continue;
331206
+ }
331207
+ }
330281
331208
  lastError = new Error(`HTTP ${res.status}`);
330282
331209
  } catch (err) {
330283
331210
  lastError = err instanceof Error ? err : new Error(String(err));
@@ -331802,14 +332729,20 @@ ${finalVerifyResult.logs ?? ""}`;
331802
332729
  });
331803
332730
  requestLiveBrowserHeartbeat();
331804
332731
  },
332732
+ // D-D6 — upload ONE baseline PNG per newly-observed screen through the
332733
+ // same streaming /page-screenshot relay the web path uses (mobile has no
332734
+ // route, so the screen-id is the key). The returned storage key rides the
332735
+ // explore checkpoint so the server links it to SitePage.screenshotKey.
332736
+ onScreenshotUpload: (screenId, base64) => onScreenshot(screenId, base64),
331805
332737
  onExploreComplete,
331806
332738
  onTestGenerated,
331807
332739
  onPlanReady
331808
332740
  });
331809
332741
  } finally {
331810
- if (capture) {
332742
+ const activeCapture = capture;
332743
+ if (activeCapture) {
331811
332744
  try {
331812
- const apiCalls = await capture.stop();
332745
+ const apiCalls = await activeCapture.stop();
331813
332746
  if (discoveryResult) {
331814
332747
  discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
331815
332748
  }
@@ -332144,6 +333077,8 @@ ${result2.logs}`);
332144
333077
  status,
332145
333078
  stepResults: result2.stepResults,
332146
333079
  screenshots: result2.screenshots,
333080
+ video: result2.video,
333081
+ videoMimeType: result2.videoMimeType,
332147
333082
  apiCalls: result2.apiCalls,
332148
333083
  logs: result2.logs,
332149
333084
  error: reportedError,
@@ -332455,7 +333390,7 @@ async function startRunner(opts) {
332455
333390
  };
332456
333391
  const sendHeartbeat = async () => {
332457
333392
  try {
332458
- const devices = enableMobile ? discoverAllDevices() : [];
333393
+ const devices = enableMobile ? getCachedDeviceSnapshot() : [];
332459
333394
  const capabilities = enableMobile ? {
332460
333395
  web: true,
332461
333396
  ios: devices.some((d) => d.platform === "IOS" && d.isAvailable && d.state === "BOOTED"),