@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.mjs CHANGED
@@ -238,6 +238,9 @@ var require_ai_provider = __commonJS({
238
238
  exports.getNoThinkingRequestOptions = getNoThinkingRequestOptions;
239
239
  exports.getAIClient = getAIClient;
240
240
  exports.getClientForModel = getClientForModel;
241
+ exports.shouldProxyAIThroughServerForLocalRunner = shouldProxyAIThroughServerForLocalRunner;
242
+ exports.getRunnerAIClient = getRunnerAIClient;
243
+ exports.getRunnerClientForModel = getRunnerClientForModel;
241
244
  exports.getModel = getModel;
242
245
  exports.resolveModelForTask = resolveModelForTask;
243
246
  exports.getAvailableModels = getAvailableModels;
@@ -515,6 +518,21 @@ var require_ai_provider = __commonJS({
515
518
  function getClientForModel(modelId) {
516
519
  return buildClientForProvider(getProviderForModel(modelId));
517
520
  }
521
+ function shouldProxyAIThroughServerForLocalRunner() {
522
+ const serverUrl = (process.env.SERVER_URL ?? "").trim();
523
+ const token = (process.env.AUTH_TOKEN || process.env.RUNNER_TOKEN || "").trim();
524
+ return serverUrl.length > 0 && token.startsWith("urt_");
525
+ }
526
+ function getRunnerAIClient() {
527
+ if (shouldProxyAIThroughServerForLocalRunner())
528
+ return getServerProxiedAIClient();
529
+ return getAIClient();
530
+ }
531
+ function getRunnerClientForModel(modelId) {
532
+ if (shouldProxyAIThroughServerForLocalRunner())
533
+ return getServerProxiedClientForModel(modelId);
534
+ return getClientForModel(modelId);
535
+ }
518
536
  function getModel(type) {
519
537
  return MODEL_TASK_MAP[getActiveModelId()][type];
520
538
  }
@@ -585,7 +603,7 @@ var require_ai_provider = __commonJS({
585
603
  const create = async (params, options) => {
586
604
  const modelId = typeof params.model === "string" ? params.model : "";
587
605
  if (!modelId) {
588
- throw new Error("server-proxied AI client requires params.model (catalog id)");
606
+ throw new Error("server-proxied AI client requires params.model");
589
607
  }
590
608
  const body = {
591
609
  modelId,
@@ -755,6 +773,21 @@ var require_mobile_source_parser = __commonJS({
755
773
  const attrText = inner.slice(tag.length);
756
774
  return { tag, attrs: parseAttributes(attrText), selfClosing };
757
775
  }
776
+ function findTagEnd(xml, from) {
777
+ let quote = null;
778
+ for (let i = from; i < xml.length; i++) {
779
+ const ch = xml[i];
780
+ if (quote !== null) {
781
+ if (ch === quote)
782
+ quote = null;
783
+ } else if (ch === '"' || ch === "'") {
784
+ quote = ch;
785
+ } else if (ch === ">") {
786
+ return i;
787
+ }
788
+ }
789
+ return -1;
790
+ }
758
791
  function tokenize(xml) {
759
792
  if (!xml || typeof xml !== "string")
760
793
  return null;
@@ -777,7 +810,7 @@ var require_mobile_source_parser = __commonJS({
777
810
  i = end === -1 ? len : end + 3;
778
811
  continue;
779
812
  }
780
- const gt = xml.indexOf(">", lt + 1);
813
+ const gt = findTagEnd(xml, lt + 1);
781
814
  if (gt === -1)
782
815
  break;
783
816
  const tagBody = xml.slice(lt + 1, gt);
@@ -1329,6 +1362,9 @@ var init_constants = __esm({
1329
1362
  function toJsStringLiteral(value) {
1330
1363
  return JSON.stringify(value);
1331
1364
  }
1365
+ function toInlineCommentText(value) {
1366
+ return value.replace(/[\r\n\u2028\u2029]+/g, " ");
1367
+ }
1332
1368
  function isPlainObject(value) {
1333
1369
  return typeof value === "object" && value !== null && !Array.isArray(value);
1334
1370
  }
@@ -1534,7 +1570,7 @@ function buildAppiumCode(testName, target, steps) {
1534
1570
  `// 3. A booted device/simulator matching the capabilities below`,
1535
1571
  `// This is NOT a WDIO testrunner spec: it uses the standalone remote() client`,
1536
1572
  `// (driver.$, no auto-injected $/expect/describe globals) so it has no harness`,
1537
- `// dependency and runs with a plain \`tsx ${testName}.spec.ts\`.`,
1573
+ `// dependency and runs with a plain \`tsx ${toInlineCommentText(testName)}.spec.ts\`.`,
1538
1574
  `import { remote } from 'webdriverio';`,
1539
1575
  ``,
1540
1576
  `// Resolve the first selector that exists, mirroring the runner's locator`,
@@ -1563,6 +1599,10 @@ function buildAppiumCode(testName, target, steps) {
1563
1599
  ` }`,
1564
1600
  `}`,
1565
1601
  ``,
1602
+ `async function __clearFocused(driver: WebdriverIO.Browser) {`,
1603
+ ` await (await driver.getActiveElement() as unknown as WebdriverIO.Element).clearValue();`,
1604
+ `}`,
1605
+ ``,
1566
1606
  `async function __textCandidates(el: WebdriverIO.Element) {`,
1567
1607
  ` const __values: string[] = [];`,
1568
1608
  ` const __push = (__value: unknown) => {`,
@@ -1613,7 +1653,7 @@ function buildAppiumCode(testName, target, steps) {
1613
1653
  ` // Canonical execution source: steps JSON`
1614
1654
  ];
1615
1655
  const stepLines = steps.flatMap((step) => {
1616
- const lines = [` // Step ${step.order + 1}: ${step.description}`];
1656
+ const lines = [` // Step ${step.order + 1}: ${toInlineCommentText(step.description)}`];
1617
1657
  const candidates = selectorCandidatesForStep(step);
1618
1658
  const candidatesLiteral = `[${candidates.map(toJsStringLiteral).join(", ")}]`;
1619
1659
  const hasSelector = candidates.length > 0;
@@ -1656,6 +1696,12 @@ function buildAppiumCode(testName, target, steps) {
1656
1696
  lines.push(` await __typeIntoFocused(driver, ${toJsStringLiteral(step.value ?? "")});`);
1657
1697
  } else if (step.action === "clear" && hasSelector) {
1658
1698
  lines.push(` await (await findFirst(driver, ${candidatesLiteral})).clearValue();`);
1699
+ } else if (step.action === "clear" && bounds) {
1700
+ const x = Math.round(bounds.x + bounds.width / 2);
1701
+ const y = Math.round(bounds.y + bounds.height / 2);
1702
+ lines.push(` await __tapAt(driver, ${x}, ${y});`);
1703
+ lines.push(` await driver.pause(${MOBILE_FOCUS_SETTLE_MS});`);
1704
+ lines.push(` await __clearFocused(driver);`);
1659
1705
  } else if (step.action === "assertVisible" && hasSelector) {
1660
1706
  lines.push(` if (!(await (await findFirst(driver, ${candidatesLiteral})).isDisplayed())) {`);
1661
1707
  lines.push(` throw new Error('Assertion failed: element not displayed: ' + ${toJsStringLiteral(candidatesLiteral)});`);
@@ -1663,6 +1709,7 @@ function buildAppiumCode(testName, target, steps) {
1663
1709
  } else if (step.action === "assertText" && hasSelector) {
1664
1710
  lines.push(` {`);
1665
1711
  lines.push(` const __expected = ${toJsStringLiteral(step.assertion ?? "")};`);
1712
+ lines.push(` if (__expected.length === 0) throw new Error('assertText step is missing expected text');`);
1666
1713
  lines.push(` const __actual = await __textCandidates(await findFirst(driver, ${candidatesLiteral}));`);
1667
1714
  lines.push(` if (!__actual.some((__value) => __value.includes(__expected))) {`);
1668
1715
  lines.push(` throw new Error('Assertion failed: expected text to contain ' + JSON.stringify(__expected) + ' but got ' + JSON.stringify(__actual));`);
@@ -4371,6 +4418,7 @@ var require_mobile_observation = __commonJS({
4371
4418
  Object.defineProperty(exports, "__esModule", { value: true });
4372
4419
  exports.renderMobileObservation = renderMobileObservation;
4373
4420
  exports.summarizeScreenForEvidence = summarizeScreenForEvidence;
4421
+ exports.buildCollectedElementsForScreen = buildCollectedElementsForScreen;
4374
4422
  var DEFAULT_MAX_ELEMENTS = 60;
4375
4423
  var MAX_LABEL_LENGTH = 80;
4376
4424
  var EVIDENCE_LABEL_LIMIT = 40;
@@ -4412,6 +4460,46 @@ var require_mobile_observation = __commonJS({
4412
4460
  }
4413
4461
  return { elementCount, actionableCount, labels };
4414
4462
  }
4463
+ function buildCollectedElementsForScreen(elements, opts = {}) {
4464
+ const navigationRefs = opts.navigationRefs ?? EMPTY_REF_SET;
4465
+ const out = [];
4466
+ const seen = /* @__PURE__ */ new Set();
4467
+ for (const element of elements) {
4468
+ if (!element.actionable)
4469
+ continue;
4470
+ const collected = toCollectedPageElement(element, navigationRefs.has(element.ref));
4471
+ const identity = `${collected.elementType}|${collected.testId ?? ""}|${collected.label ?? ""}`;
4472
+ if (seen.has(identity))
4473
+ continue;
4474
+ seen.add(identity);
4475
+ out.push(collected);
4476
+ }
4477
+ return out;
4478
+ }
4479
+ var EMPTY_REF_SET = /* @__PURE__ */ new Set();
4480
+ function toCollectedPageElement(element, triggersNavigation) {
4481
+ const label = element.label?.trim() || element.text?.trim() || void 0;
4482
+ const stableId = element.accessibilityId?.trim() || element.resourceId?.trim() || void 0;
4483
+ const locators = [];
4484
+ if (stableId)
4485
+ locators.push({ strategy: "getByTestId", value: stableId, confidence: 0.95 });
4486
+ const xpath = element.xpath?.trim();
4487
+ if (xpath)
4488
+ locators.push({ strategy: "css", value: xpath, confidence: 0.4 });
4489
+ const collected = {
4490
+ elementType: element.role || "View",
4491
+ triggersNavigation
4492
+ };
4493
+ if (label)
4494
+ collected.label = label;
4495
+ if (stableId)
4496
+ collected.testId = stableId;
4497
+ if (locators.length > 0)
4498
+ collected.locators = locators;
4499
+ if (element.enabled === false)
4500
+ collected.isDisabled = true;
4501
+ return collected;
4502
+ }
4415
4503
  function buildHeaderLine(screen) {
4416
4504
  const parts = ["screen:"];
4417
4505
  const name = screenDisplayName(screen.screenSignal);
@@ -4860,7 +4948,7 @@ var require_mobile_explorer = __commonJS({
4860
4948
  }
4861
4949
  return sections.join("\n");
4862
4950
  }
4863
- function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId) {
4951
+ function buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, appBrief, targetAppId, existingScreens) {
4864
4952
  const platformLabel = platform3 === "IOS" ? "iOS" : "Android";
4865
4953
  const targetLine = targetAppId ? ` Target app package/bundle: ${targetAppId}.` : "";
4866
4954
  const surveyBody = `You are a mobile QA exploration agent driving a real ${platformLabel} app on a connected device. Your goal: MAP every reachable screen so downstream agents know what to test. You are NOT testing \u2014 you are building a complete screen map.
@@ -4906,13 +4994,18 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
4906
4994
  ## Application Brief
4907
4995
  ${appBrief}
4908
4996
  Use this to prioritize which screens and flows to cover.` : "";
4909
- return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}`;
4997
+ const existingScreensBlock = mode === "survey" && existingScreens && existingScreens.length > 0 ? `
4998
+
4999
+ ## ALREADY DISCOVERED SCREENS (skip these; find new)
5000
+ 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.
5001
+ ${existingScreens.slice(0, 100).map((s) => `- ${s.screenId}${s.name ? ` "${s.name}"` : ""}${s.screenType ? ` [${s.screenType}]` : ""}`).join("\n")}` : "";
5002
+ return `${mode === "survey" ? surveyBody : deepBody}${briefBlock}${existingScreensBlock}`;
4910
5003
  }
4911
5004
  function buildKickoffMessage(mode) {
4912
5005
  return mode === "survey" ? "Start by calling mobile_snapshot on the launch screen, then capture_screen. Map the entire app: open every tab, menu, and one row per list. Record transitions as you go." : "Start by calling mobile_snapshot on the launch screen. Then use the features: fill fields, submit forms, open menus, and observe outcomes. Record journeys after meaningful flows.";
4913
5006
  }
4914
5007
  async function runMobileExplorePhase(args) {
4915
- const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot } = args;
5008
+ const { ctx, driver, state: state2, config, onLog, onAICall, onScreenshot, onScreenshotUpload, existingScreens } = args;
4916
5009
  const logs2 = [];
4917
5010
  const log2 = (line) => {
4918
5011
  logs2.push(line);
@@ -4936,6 +5029,9 @@ Use this to prioritize which screens and flows to cover.` : "";
4936
5029
  log2(`Budget: ${maxIterations} iterations | app: ${ctx.target?.appId ?? "(unknown)"}`);
4937
5030
  const screenshots = [];
4938
5031
  const pageScreenshots = {};
5032
+ const pageScreenshotKeys = {};
5033
+ const uploadedScreenIds = /* @__PURE__ */ new Set();
5034
+ const pendingScreenshotUploads = [];
4939
5035
  const collectedTransitions = [];
4940
5036
  const journeys = [];
4941
5037
  const counters = {
@@ -4954,6 +5050,14 @@ Use this to prioritize which screens and flows to cover.` : "";
4954
5050
  screenshots.push(base64);
4955
5051
  if (screenId)
4956
5052
  pageScreenshots[screenId] = base64;
5053
+ if (screenId && onScreenshotUpload && !uploadedScreenIds.has(screenId)) {
5054
+ uploadedScreenIds.add(screenId);
5055
+ pendingScreenshotUploads.push(onScreenshotUpload(screenId, base64).then((key) => {
5056
+ if (key)
5057
+ pageScreenshotKeys[screenId] = key;
5058
+ }).catch(() => {
5059
+ }));
5060
+ }
4957
5061
  try {
4958
5062
  onScreenshot?.(base64);
4959
5063
  } catch {
@@ -5058,7 +5162,7 @@ Use this to prioritize which screens and flows to cover.` : "";
5058
5162
  return null;
5059
5163
  return { element, bounds: element.bounds };
5060
5164
  };
5061
- const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId);
5165
+ const systemPrompt = buildSystemPrompt(mode, platform3, enableRecordJourney, maxIterations, ctx.appBrief, targetAppId, existingScreens);
5062
5166
  const kickoff = buildKickoffMessage(mode);
5063
5167
  const recentExchanges = [];
5064
5168
  const transientDirectives = [];
@@ -5266,6 +5370,9 @@ Exploration complete: ${summary}`);
5266
5370
  summary = `Explore phase aborted (${truncatedReason}) after mapping ${state2.getScreenCount()} screen(s).`;
5267
5371
  }
5268
5372
  }
5373
+ if (pendingScreenshotUploads.length > 0) {
5374
+ await Promise.allSettled(pendingScreenshotUploads);
5375
+ }
5269
5376
  const collectedPages = state2.buildInMemoryScreenMap();
5270
5377
  counters.pagesVisited = collectedPages.length;
5271
5378
  if (counters.pagesDiscovered < collectedPages.length) {
@@ -5300,6 +5407,9 @@ Exploration complete: ${summary}`);
5300
5407
  if (Object.keys(pageScreenshots).length > 0) {
5301
5408
  result.pageScreenshots = pageScreenshots;
5302
5409
  }
5410
+ if (Object.keys(pageScreenshotKeys).length > 0) {
5411
+ result.pageScreenshotKeys = pageScreenshotKeys;
5412
+ }
5303
5413
  if (truncated) {
5304
5414
  result.truncated = true;
5305
5415
  result.truncatedReason = truncatedReason;
@@ -5801,6 +5911,44 @@ var require_snapshot_parser = __commonJS({
5801
5911
  };
5802
5912
  var SUBMIT_LABEL_RE = /^(submit|sign\s*in|sign\s*up|log\s*in|register|create|save|send|confirm|continue|next|apply|post|publish|add|update|go)$/i;
5803
5913
  var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
5914
+ var RICH_CONTEXT_ROLES = /* @__PURE__ */ new Set(["table", "grid", "row", "radiogroup"]);
5915
+ var RICH_CONTEXT_LABEL_MAX = 60;
5916
+ var VALUE_BEARING_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "combobox", "spinbutton", "listbox", "slider"]);
5917
+ 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;
5918
+ var REDACTED_VALUE = "[redacted]";
5919
+ var VALUE_TEXT_MAX = 120;
5920
+ 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;
5921
+ var STATIC_MESSAGES_MAX = 12;
5922
+ var OPTIONS_PER_ELEMENT_MAX = 24;
5923
+ var URL_CHILD_LINE_RE = /^(\s*)- \/url:\s*(.+)$/;
5924
+ 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;
5925
+ function stripYamlQuotes(raw) {
5926
+ const trimmed = raw.trim();
5927
+ if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
5928
+ return trimmed.slice(1, -1);
5929
+ }
5930
+ return trimmed;
5931
+ }
5932
+ function extractTrailingText(attrs) {
5933
+ const lastBracket = attrs.lastIndexOf("]");
5934
+ const tail = lastBracket >= 0 ? attrs.slice(lastBracket + 1) : attrs;
5935
+ const match = /^\s*:\s+(.+)$/.exec(tail);
5936
+ if (!match)
5937
+ return null;
5938
+ const text = stripYamlQuotes(match[1]);
5939
+ if (!text || /^(?:\||\|-|>|>-)$/.test(text))
5940
+ return null;
5941
+ return text;
5942
+ }
5943
+ function isSensitiveValueField(name, placeholder) {
5944
+ return SENSITIVE_VALUE_LABEL_RE.test(`${name ?? ""} ${placeholder ?? ""}`);
5945
+ }
5946
+ function scrubMessageText(text) {
5947
+ return text.replace(MESSAGE_TOKEN_RE, REDACTED_VALUE);
5948
+ }
5949
+ function capText(text, max) {
5950
+ return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
5951
+ }
5804
5952
  function isProbablyDynamicTestId(testId) {
5805
5953
  if (testId.length < 6)
5806
5954
  return false;
@@ -5876,9 +6024,12 @@ var require_snapshot_parser = __commonJS({
5876
6024
  }
5877
6025
  return e.name;
5878
6026
  }
5879
- function parseSnapshot(snapshotText) {
6027
+ function parseSnapshot(snapshotText, options = {}) {
6028
+ const richParse = options.richParse ?? process.env.DISCOVERY_SNAPSHOT_RICH_PARSE === "true";
5880
6029
  const lines = snapshotText.split("\n");
5881
6030
  const elements = [];
6031
+ const liveMessages = [];
6032
+ const textMessages = [];
5882
6033
  const seenRefs = /* @__PURE__ */ new Set();
5883
6034
  let parsedElementLineCount = 0;
5884
6035
  let genericLabelCount = 0;
@@ -5887,6 +6038,21 @@ var require_snapshot_parser = __commonJS({
5887
6038
  const contextStack = [];
5888
6039
  const completedForms = [];
5889
6040
  for (const line of lines) {
6041
+ if (richParse) {
6042
+ const urlMatch = URL_CHILD_LINE_RE.exec(line);
6043
+ if (urlMatch) {
6044
+ const urlIndent = urlMatch[1].length;
6045
+ for (let i = elements.length - 1; i >= 0; i--) {
6046
+ const el = elements[i];
6047
+ if (el.indentDepth < urlIndent) {
6048
+ if (el.role === "link" && !el.href)
6049
+ el.href = capText(stripYamlQuotes(urlMatch[2]), 300);
6050
+ break;
6051
+ }
6052
+ }
6053
+ continue;
6054
+ }
6055
+ }
5890
6056
  const match = ELEMENT_LINE_RE.exec(line);
5891
6057
  if (!match)
5892
6058
  continue;
@@ -5908,7 +6074,7 @@ var require_snapshot_parser = __commonJS({
5908
6074
  }
5909
6075
  const headingLevelMatch = LEVEL_RE.exec(attrs);
5910
6076
  const headingLevel = headingLevelMatch ? Number.parseInt(headingLevelMatch[1], 10) : null;
5911
- const contextLabel = buildContextLabel(role, name ?? null, headingLevel);
6077
+ const contextLabel = buildContextLabel(role, name ?? null, headingLevel, richParse);
5912
6078
  if (contextLabel) {
5913
6079
  contextStack.push({ indent: indentDepth, label: contextLabel });
5914
6080
  }
@@ -5921,6 +6087,40 @@ var require_snapshot_parser = __commonJS({
5921
6087
  });
5922
6088
  continue;
5923
6089
  }
6090
+ if (richParse) {
6091
+ if (role === "option" && name) {
6092
+ for (let i = elements.length - 1; i >= 0; i--) {
6093
+ const el = elements[i];
6094
+ if (el.indentDepth < indentDepth) {
6095
+ if (el.role === "combobox" || el.role === "listbox") {
6096
+ el.options ??= [];
6097
+ if (el.options.length < OPTIONS_PER_ELEMENT_MAX) {
6098
+ el.options.push({ label: capText(name, 80), selected: parseBooleanState(SELECTED_RE.exec(attrs)) === true });
6099
+ }
6100
+ }
6101
+ break;
6102
+ }
6103
+ }
6104
+ }
6105
+ if (role === "alert" || role === "status" || role === "paragraph" || role === "text") {
6106
+ const text = extractTrailingText(attrs) ?? (name || null);
6107
+ const isLiveRegion = role === "alert" || role === "status";
6108
+ if (text && (isLiveRegion || SIGNAL_TEXT_RE.test(text))) {
6109
+ const capped = capText(scrubMessageText(text), VALUE_TEXT_MAX);
6110
+ const ref2 = REF_RE.exec(attrs)?.[1] ?? null;
6111
+ if (isLiveRegion) {
6112
+ const textDupIndex = textMessages.findIndex((m) => m.text === capped);
6113
+ if (textDupIndex >= 0)
6114
+ textMessages.splice(textDupIndex, 1);
6115
+ if (liveMessages.length < STATIC_MESSAGES_MAX && !liveMessages.some((m) => m.text === capped)) {
6116
+ liveMessages.push({ role, text: capped, ref: ref2 });
6117
+ }
6118
+ } else if (textMessages.length < STATIC_MESSAGES_MAX && !textMessages.some((m) => m.text === capped) && !liveMessages.some((m) => m.text === capped)) {
6119
+ textMessages.push({ role: "text", text: capped, ref: ref2 });
6120
+ }
6121
+ }
6122
+ }
6123
+ }
5924
6124
  if (!INTERACTIVE_ROLES.has(role))
5925
6125
  continue;
5926
6126
  const refMatch = REF_RE.exec(attrs);
@@ -5953,6 +6153,13 @@ var require_snapshot_parser = __commonJS({
5953
6153
  }
5954
6154
  const isLink = role === "link";
5955
6155
  const isSubmitButton = role === "button" && name != null && SUBMIT_LABEL_RE.test(name);
6156
+ let value = null;
6157
+ if (richParse && VALUE_BEARING_ROLES.has(role)) {
6158
+ const trailing = extractTrailingText(attrs);
6159
+ if (trailing) {
6160
+ value = isSensitiveValueField(name ?? null, placeholder) ? REDACTED_VALUE : capText(trailing, VALUE_TEXT_MAX);
6161
+ }
6162
+ }
5956
6163
  const element = {
5957
6164
  ref,
5958
6165
  role,
@@ -5974,7 +6181,10 @@ var require_snapshot_parser = __commonJS({
5974
6181
  pressed,
5975
6182
  headingLevel,
5976
6183
  currentState,
5977
- containerPath
6184
+ containerPath,
6185
+ href: null,
6186
+ value,
6187
+ options: null
5978
6188
  };
5979
6189
  if (isGenericLabel(element.name))
5980
6190
  genericLabelCount++;
@@ -6002,6 +6212,9 @@ var require_snapshot_parser = __commonJS({
6002
6212
  return {
6003
6213
  elements,
6004
6214
  formGroups: completedForms,
6215
+ // live regions first: they carry their own reservation so an end-of-body toast can
6216
+ // never be starved out by signal-matching plain text earlier in the document.
6217
+ staticMessages: [...liveMessages, ...textMessages].slice(0, STATIC_MESSAGES_MAX),
6005
6218
  rawLineCount: lines.length,
6006
6219
  parsedElementLineCount,
6007
6220
  genericLabelCount,
@@ -6112,12 +6325,17 @@ var require_snapshot_parser = __commonJS({
6112
6325
  return "mixed";
6113
6326
  return false;
6114
6327
  }
6115
- function buildContextLabel(role, name, headingLevel) {
6328
+ function buildContextLabel(role, name, headingLevel, richContext = false) {
6116
6329
  if (role === "heading") {
6117
6330
  if (!name)
6118
6331
  return null;
6119
6332
  return headingLevel ? `heading${headingLevel}:${name}` : `heading:${name}`;
6120
6333
  }
6334
+ if (richContext && RICH_CONTEXT_ROLES.has(role)) {
6335
+ if (!name)
6336
+ return null;
6337
+ return `${role}:${capText(name.replace(/>/g, "\u203A"), RICH_CONTEXT_LABEL_MAX)}`;
6338
+ }
6121
6339
  if (!CONTEXT_ROLES.has(role))
6122
6340
  return null;
6123
6341
  if (name)
@@ -7827,7 +8045,7 @@ Each entry is exactly one of:
7827
8045
  const systemPrompt = options.systemPromptOverride ?? (context.mode === "survey" ? SURVEY_SYSTEM_PROMPT : exports.SCENARIO_SYSTEM_PROMPT);
7828
8046
  const userPrompt = options.userPromptOverride ?? formatEvidenceForPlanner(compressed, evidence, context);
7829
8047
  logs2.push(`AI planner: ~${Math.ceil(userPrompt.length / 3.5)} input tokens`);
7830
- const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)());
8048
+ const aiClient = options.aiClient ?? (options.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
7831
8049
  const messages = [
7832
8050
  { role: "system", content: systemPrompt },
7833
8051
  { role: "user", content: userPrompt }
@@ -7835,18 +8053,22 @@ Each entry is exactly one of:
7835
8053
  const callStart = Date.now();
7836
8054
  let response;
7837
8055
  try {
7838
- response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => aiClient.chat.completions.create({
7839
- model: agentModel,
7840
- messages,
7841
- max_tokens: plannerMaxTokens,
7842
- temperature: 0.3,
7843
- ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
7844
- ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
7845
- })), {
8056
+ response = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
8057
+ arm();
8058
+ return aiClient.chat.completions.create({
8059
+ model: agentModel,
8060
+ messages,
8061
+ max_tokens: plannerMaxTokens,
8062
+ temperature: 0.3,
8063
+ ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
8064
+ ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
8065
+ }, { signal });
8066
+ }), {
7846
8067
  label: "ai-planner",
7847
8068
  baseDelayMs: 5e3,
7848
8069
  maxRetries: 2,
7849
- onRetry: (attempt, status, delay) => logs2.push(`AI planner call failed (${status ?? "error"}) \u2014 retry ${attempt}/2 in ${Math.round(delay / 1e3)}s`)
8070
+ deferTimeoutUntilArmed: true,
8071
+ onRetry: (attempt, reason, delay) => logs2.push(`AI planner call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
7850
8072
  });
7851
8073
  } catch (err) {
7852
8074
  const message = err instanceof Error ? err.message : String(err);
@@ -7912,18 +8134,25 @@ Each entry is exactly one of:
7912
8134
  const retryStart = Date.now();
7913
8135
  let retryResponse = null;
7914
8136
  try {
7915
- retryResponse = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => aiClient.chat.completions.create({
7916
- model: agentModel,
7917
- messages: [
7918
- ...messages,
7919
- { role: "assistant", content: raw },
7920
- { role: "user", content: retryContext }
7921
- ],
7922
- max_tokens: plannerMaxTokens,
7923
- temperature: 0.3,
7924
- ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
7925
- ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
7926
- })), { label: "ai-planner-retry" });
8137
+ retryResponse = await (0, ai_retry_js_1.resilientAICall)((signal, arm) => (0, ai_queue_js_1.queuedAICall)(() => {
8138
+ arm();
8139
+ return aiClient.chat.completions.create({
8140
+ model: agentModel,
8141
+ messages: [
8142
+ ...messages,
8143
+ { role: "assistant", content: raw },
8144
+ { role: "user", content: retryContext }
8145
+ ],
8146
+ max_tokens: plannerMaxTokens,
8147
+ temperature: 0.3,
8148
+ ...(0, ai_provider_js_1.getJsonObjectResponseFormat)(agentModel),
8149
+ ...(0, ai_provider_js_1.getNoThinkingRequestOptions)(agentModel)
8150
+ }, { signal });
8151
+ }), {
8152
+ label: "ai-planner-retry",
8153
+ deferTimeoutUntilArmed: true,
8154
+ onRetry: (attempt, reason, delay) => logs2.push(`AI planner retry call failed (${reason}) \u2014 retry ${attempt} in ${Math.round(delay / 1e3)}s`)
8155
+ });
7927
8156
  } catch (err) {
7928
8157
  const message = err instanceof Error ? err.message : String(err);
7929
8158
  logs2.push(`AI planner retry failed (${message}) \u2014 using first-pass result`);
@@ -8451,12 +8680,12 @@ ${constraints.join("\n")}`);
8451
8680
  const orderedScreens = targetAppScreens(graph, appId).filter((state2) => state2.screenType?.toLowerCase() !== "error").sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
8452
8681
  const includedScreenIds = new Set(orderedScreens.map((state2) => state2.screenId));
8453
8682
  for (const state2 of orderedScreens) {
8454
- const groupName = state2.screenType === "auth" ? "Authentication" : screenDisplayName(state2);
8683
+ const groupName = state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(state2);
8455
8684
  const group = groups.get(groupName) ?? {
8456
8685
  name: groupName,
8457
8686
  description: `Native mobile screens related to ${groupName}.`,
8458
8687
  routes: [],
8459
- criticality: state2.screenType === "auth" ? "critical" : "medium",
8688
+ criticality: state2.screenType?.toLowerCase() === "auth" ? "critical" : "medium",
8460
8689
  pages: [],
8461
8690
  flows: []
8462
8691
  };
@@ -8479,7 +8708,7 @@ ${constraints.join("\n")}`);
8479
8708
  const to = graph.screens.get(transition.toScreenId);
8480
8709
  if (!from || !to)
8481
8710
  continue;
8482
- const groupName = from.screenType === "auth" ? "Authentication" : screenDisplayName(from);
8711
+ const groupName = from.screenType?.toLowerCase() === "auth" ? "Authentication" : screenDisplayName(from);
8483
8712
  const group = groups.get(groupName);
8484
8713
  if (!group)
8485
8714
  continue;
@@ -8524,9 +8753,9 @@ ${constraints.join("\n")}`);
8524
8753
  scenarios.push({
8525
8754
  name,
8526
8755
  type: "HAPPY_PATH",
8527
- priority: state2.screenType === "auth" ? 1 : 3,
8756
+ priority: state2.screenType?.toLowerCase() === "auth" ? 1 : 3,
8528
8757
  variant: "happy",
8529
- featureGroup: state2.screenType === "auth" ? "Authentication" : screenName,
8758
+ featureGroup: state2.screenType?.toLowerCase() === "auth" ? "Authentication" : screenName,
8530
8759
  goal: `User can reach and inspect the ${screenName} screen`,
8531
8760
  steps,
8532
8761
  expectedOutcomes: [{
@@ -8672,6 +8901,7 @@ var require_mobile_generator = __commonJS({
8672
8901
  var mobile_observation_js_1 = require_mobile_observation();
8673
8902
  var mobile_boundary_js_1 = require_mobile_boundary();
8674
8903
  var MAX_SCENARIO_ITERATIONS = 30;
8904
+ var MAX_CONSECUTIVE_INFRA_FAILURES = 3;
8675
8905
  var MAX_RECENT_EXCHANGES = 24;
8676
8906
  var OBSERVATION_MAX_ELEMENTS = 50;
8677
8907
  function finalizeAction(input) {
@@ -9396,14 +9626,31 @@ ${statePacket}` },
9396
9626
  if (!snapshot || snapshot.screen.elements.length === 0)
9397
9627
  return null;
9398
9628
  const elements = snapshot.screen.elements;
9399
- const withStableId = elements.filter((el) => hasStableId(el));
9400
- const nonButton = withStableId.find((el) => !/button/i.test(el.role));
9629
+ const stableAnchors = elements.filter((el) => hasStableId(el) && !isAlwaysPresentContainer(el));
9630
+ const contentfulNonButton = stableAnchors.find((el) => !/button/i.test(el.role) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
9631
+ if (contentfulNonButton)
9632
+ return contentfulNonButton;
9633
+ const nonButton = stableAnchors.find((el) => !/button/i.test(el.role));
9401
9634
  if (nonButton)
9402
9635
  return nonButton;
9403
- if (withStableId.length > 0)
9404
- return withStableId[0];
9405
- const labelled = elements.find((el) => nonEmpty2(el.label) || nonEmpty2(el.text));
9406
- return labelled ?? elements[0];
9636
+ if (stableAnchors.length > 0)
9637
+ return stableAnchors[0];
9638
+ const labelled = elements.find((el) => !isAlwaysPresentContainer(el) && (nonEmpty2(el.label) || nonEmpty2(el.text)));
9639
+ return labelled ?? elements.find((el) => !isAlwaysPresentContainer(el)) ?? elements[0];
9640
+ }
9641
+ var ALWAYS_PRESENT_CONTAINER_ROLES = /^(XCUIElementTypeApplication|XCUIElementTypeWindow)$/;
9642
+ var ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS = /* @__PURE__ */ new Set([
9643
+ "android:id/content",
9644
+ "android:id/decor",
9645
+ "android:id/action_bar_root",
9646
+ "android:id/statusBarBackground",
9647
+ "android:id/navigationBarBackground"
9648
+ ]);
9649
+ function isAlwaysPresentContainer(element) {
9650
+ if (ALWAYS_PRESENT_CONTAINER_ROLES.test(element.role))
9651
+ return true;
9652
+ const resourceId = element.resourceId?.trim();
9653
+ return !!resourceId && ALWAYS_PRESENT_CONTAINER_RESOURCE_IDS.has(resourceId);
9407
9654
  }
9408
9655
  function appendHybridFloor(actions, recording, platform3) {
9409
9656
  const anchor = pickLandingAnchor(recording.lastSnapshot);
@@ -9457,12 +9704,31 @@ ${statePacket}` },
9457
9704
  steps: compiled.steps,
9458
9705
  playwrightCode: "",
9459
9706
  verified,
9460
- tags
9707
+ tags,
9708
+ // Carry the planner scenario identity so incremental/resume can match an
9709
+ // already-generated scenario reliably (scenarioRef.name === the plan's
9710
+ // scenario name). Without this the resume skip-set falls back to the
9711
+ // model-chosen test name and may regenerate completed scenarios. Mobile has
9712
+ // no live command/locator ledger, so evidence is an empty (but valid) bundle
9713
+ // — scenarioRef is the load-bearing field for the skip-set.
9714
+ healingContext: {
9715
+ scenarioRef: {
9716
+ name: scenario.name,
9717
+ featureGroup: scenario.featureGroup,
9718
+ goal: scenario.goal,
9719
+ type: scenario.type,
9720
+ variant: scenario.variant,
9721
+ entryRoute: scenario.entryRoute,
9722
+ startFromLanding: scenario.startFromLanding,
9723
+ targetPages: [...scenario.targetPages]
9724
+ },
9725
+ evidence: { observedLocators: [], observedTransitions: [] }
9726
+ }
9461
9727
  };
9462
9728
  return test;
9463
9729
  }
9464
9730
  async function runMobileGeneratePhase(args) {
9465
- const { scenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot } = args;
9731
+ const { scenarios: allScenarios, driver, ctx, config, onLog, onAICall, onTestGenerated, onScreenshot, skipScenarioNames, onInfraStop } = args;
9466
9732
  const log2 = (line) => {
9467
9733
  try {
9468
9734
  onLog?.(line);
@@ -9477,8 +9743,16 @@ ${statePacket}` },
9477
9743
  const aiClient = modelOverride ? (0, ai_provider_js_1.getMobileClientForModel)(modelOverride) : (0, ai_provider_js_1.getMobileAIClient)();
9478
9744
  const auditGroupId = `mobile-generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
9479
9745
  const targetAppId = ctx.target?.appId?.trim() || void 0;
9746
+ const skipSet = new Set(skipScenarioNames ?? []);
9747
+ const scenarios = skipSet.size > 0 ? allScenarios.filter((s) => !skipSet.has(s.name)) : allScenarios;
9748
+ if (skipSet.size > 0) {
9749
+ log2(`Resume: skipping ${allScenarios.length - scenarios.length} already-generated scenario(s); ${scenarios.length} remaining to generate`);
9750
+ }
9480
9751
  log2(`Mobile generator starting \u2014 platform: ${platform3}, provider: ${provider}, model: ${agentModel}, scenarios: ${scenarios.length}`);
9481
9752
  const tests = [];
9753
+ const generatedScenarioNames = /* @__PURE__ */ new Set();
9754
+ let consecutiveInfraFailures = 0;
9755
+ let infraStopped = false;
9482
9756
  for (let i = 0; i < scenarios.length; i++) {
9483
9757
  const scenario = scenarios[i];
9484
9758
  log2(`
@@ -9499,6 +9773,7 @@ ${statePacket}` },
9499
9773
  targetAppId,
9500
9774
  log: log2
9501
9775
  });
9776
+ consecutiveInfraFailures = 0;
9502
9777
  const test = assembleTest({ scenario, recording, ctx, platform: platform3, log: log2 });
9503
9778
  if (!test) {
9504
9779
  log2(` scenario "${scenario.name}" produced no compilable test \u2014 skipped.`);
@@ -9515,13 +9790,31 @@ ${statePacket}` },
9515
9790
  log2(` onTestGenerated callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
9516
9791
  }
9517
9792
  tests.push(test);
9793
+ generatedScenarioNames.add(scenario.name);
9518
9794
  } catch (err) {
9795
+ if ((0, ai_retry_js_1.isAIProviderError)(err)) {
9796
+ consecutiveInfraFailures += 1;
9797
+ log2(` scenario "${scenario.name}" failed \u2014 AI provider error (${consecutiveInfraFailures}/${MAX_CONSECUTIVE_INFRA_FAILURES}): ${err instanceof Error ? err.message : String(err)}`);
9798
+ if (consecutiveInfraFailures >= MAX_CONSECUTIVE_INFRA_FAILURES) {
9799
+ const remainingScenarioNames = scenarios.filter((s) => !generatedScenarioNames.has(s.name)).map((s) => s.name);
9800
+ infraStopped = true;
9801
+ log2(`
9802
+ \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.`);
9803
+ try {
9804
+ onInfraStop?.({ remainingScenarioNames, reason: "ai_rate_limit" });
9805
+ } catch (cbErr) {
9806
+ log2(` onInfraStop callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}`);
9807
+ }
9808
+ break;
9809
+ }
9810
+ continue;
9811
+ }
9519
9812
  log2(` scenario "${scenario.name}" failed: ${err instanceof Error ? err.message : String(err)} \u2014 skipping.`);
9520
9813
  continue;
9521
9814
  }
9522
9815
  }
9523
9816
  log2(`
9524
- Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified).`);
9817
+ Mobile generate phase complete: ${tests.length}/${scenarios.length} test(s) generated (${tests.filter((t) => t.verified).length} verified)${infraStopped ? " \u2014 PAUSED on AI provider overload" : ""}.`);
9525
9818
  return tests;
9526
9819
  }
9527
9820
  }
@@ -12800,6 +13093,10 @@ var require_snapshot_observation = __commonJS({
12800
13093
  var snapshot_parser_js_1 = require_snapshot_parser();
12801
13094
  var GENERIC_LABEL_RE = /^(input field|textbox|button|link|item|field|value|tab)$/i;
12802
13095
  var NOISE_LABEL_RE = /^(skip\s+to|skip\s+nav|back\s+to\s+top|cookie|accept\s+(all\s+)?cookies)$/i;
13096
+ var DIALOG_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog\b/i;
13097
+ var FORM_SEGMENT_RE = /(?:^|>\s*)form\b/i;
13098
+ var TABPANEL_SEGMENT_RE = /(?:^|>\s*)tabpanel\b/i;
13099
+ var DIALOG_NAME_SEGMENT_RE = /(?:^|>\s*)(?:alert)?dialog:([^>]+)/i;
12803
13100
  var INTERACTIVE_ROLE_RE = /^(button|link|textbox|searchbox|checkbox|radio|switch|combobox|listbox|option|menuitem|tab|slider|spinbutton|textarea)$/i;
12804
13101
  var INTERACTIVE_ELEMENT_TYPE_RE = /^(button|link|input|textarea|select|checkbox|radio|switch|tab|combobox|listbox|option|menuitem|slider|spinbutton)$/i;
12805
13102
  function renderRetrievedSnapshotMemoryBlock(priorSnapshotEvidence, priorDiscoveryCheckpoints, options = {}) {
@@ -13044,10 +13341,12 @@ var require_snapshot_observation = __commonJS({
13044
13341
  summary
13045
13342
  }));
13046
13343
  }
13344
+ var TEXT_DISAMBIGUATING_INPUT_TYPES = /* @__PURE__ */ new Set(["password", "text", "email", "tel", "number", "search", "url", "date", "datetime-local", "time", "month", "week"]);
13047
13345
  function renderCompactSnapshotBlock(page, context = {}, options = {}) {
13048
13346
  const metrics = page.snapshotMetrics ?? page.snapshotArtifact?.metrics;
13049
13347
  const sourceKind = page.snapshotMeta?.sourceKind ?? page.snapshotArtifact?.sourceKind ?? "missing";
13050
13348
  const artifact = page.snapshotArtifact;
13349
+ const richRender = options.richRender ?? process.env.DISCOVERY_SNAPSHOT_RICH_RENDER === "true";
13051
13350
  const activeScope = inferActiveSnapshotScope(page);
13052
13351
  const ranked = rankSnapshotElements(getScopedSnapshotElements(page, activeScope), withScopeContext(context, activeScope));
13053
13352
  const allInteractive = ranked.filter(({ element }) => isInteractiveCandidate(element)).filter(({ element }) => {
@@ -13093,7 +13392,7 @@ var require_snapshot_observation = __commonJS({
13093
13392
  if (activeScope.rootRef) {
13094
13393
  lines.push(` root_ref: ${yamlScalar(activeScope.rootRef)}`);
13095
13394
  }
13096
- const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText);
13395
+ const scopePreview = buildScopeSnapshotPreview(activeScope.scopeSnapshotText, richRender);
13097
13396
  if (scopePreview.length > 0) {
13098
13397
  lines.push(" scope_snapshot_preview: |");
13099
13398
  for (const previewLine of scopePreview) {
@@ -13135,6 +13434,19 @@ var require_snapshot_observation = __commonJS({
13135
13434
  lines.push(` - ${yamlScalar(modal)}`);
13136
13435
  }
13137
13436
  }
13437
+ const staticMessages = page.staticMessages ?? [];
13438
+ if (richRender && staticMessages.length > 0) {
13439
+ const messageLimit = 6;
13440
+ const ordered = [...staticMessages].sort((a, b) => messageRolePriority(a.role) - messageRolePriority(b.role));
13441
+ lines.push("messages:");
13442
+ 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.");
13443
+ for (const message of ordered.slice(0, messageLimit)) {
13444
+ lines.push(` - [${message.role}] ${yamlScalar(message.text)}`);
13445
+ }
13446
+ if (ordered.length > messageLimit) {
13447
+ lines.push(` # +${ordered.length - messageLimit} more message(s) not listed`);
13448
+ }
13449
+ }
13138
13450
  lines.push("session_state:");
13139
13451
  if (page.sessionState) {
13140
13452
  lines.push(` authenticated: ${yamlScalar(page.sessionState.authenticated)}`);
@@ -13197,7 +13509,10 @@ var require_snapshot_observation = __commonJS({
13197
13509
  }
13198
13510
  const hasRowContext = group.rowContexts.some((rc) => !!rc);
13199
13511
  if (hasRowContext) {
13200
- lines.push(' distinguish_by_row: # same name in different rows \u2014 scope by this text, e.g. getByRole(role, { name }).filter({ has: getByText("<row>") })');
13512
+ const firstRowRole = group.rowRoles[0] ?? null;
13513
+ const rowRole = firstRowRole && group.rowRoles.every((r) => r === firstRowRole) ? firstRowRole : null;
13514
+ 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`;
13515
+ lines.push(` distinguish_by_row: # same name across rows \u2014 scope by the ROW/card container (not the control); nested same-role containers need an extra cell-level scope: ${scopeHint}`);
13201
13516
  for (let i = 0; i < group.rowContexts.length; i++) {
13202
13517
  const rowContext = group.rowContexts[i];
13203
13518
  if (!rowContext)
@@ -13227,8 +13542,13 @@ var require_snapshot_observation = __commonJS({
13227
13542
  lines.push(` placeholder: ${yamlScalar(rankedElement.element.placeholder)}`);
13228
13543
  }
13229
13544
  const stateTokens = buildStateSummary(rankedElement.element);
13545
+ const addedDisabled = richRender && !!rankedElement.element.isDisabled && !stateTokens.includes("disabled");
13546
+ if (addedDisabled) {
13547
+ stateTokens.unshift("disabled");
13548
+ }
13230
13549
  if (stateTokens.length > 0) {
13231
- lines.push(` state: [${stateTokens.map((token) => yamlScalar(token)).join(", ")}]`);
13550
+ const note = addedDisabled ? " # disabled = captured instant; may enable after valid input \u2014 still exercise fill\u2192submit" : "";
13551
+ lines.push(` state: [${stateTokens.map((token) => yamlScalar(token)).join(", ")}]${note}`);
13232
13552
  }
13233
13553
  if (rankedElement.element.containerPath) {
13234
13554
  lines.push(` container_path: ${yamlScalar(rankedElement.element.containerPath)}`);
@@ -13238,6 +13558,22 @@ var require_snapshot_observation = __commonJS({
13238
13558
  lines.push(` locator: ${yamlScalar((0, snapshot_parser_js_1.formatLocator)(topLocator))}`);
13239
13559
  }
13240
13560
  const enr = rankedElement.element.enrichment;
13561
+ if (richRender && enr?.inputType && TEXT_DISAMBIGUATING_INPUT_TYPES.has(enr.inputType)) {
13562
+ lines.push(` type: ${yamlScalar(enr.inputType)}`);
13563
+ }
13564
+ if (richRender && rankedElement.element.href) {
13565
+ lines.push(` url: ${yamlScalar(rankedElement.element.href)}`);
13566
+ }
13567
+ if (richRender && rankedElement.element.value && enr?.inputType !== "password") {
13568
+ lines.push(` value: ${yamlScalar(rankedElement.element.value)}`);
13569
+ }
13570
+ const elementOptions = rankedElement.element.options ?? [];
13571
+ if (richRender && elementOptions.length > 0) {
13572
+ const optionLimit = 12;
13573
+ const rendered = elementOptions.slice(0, optionLimit).map((option) => yamlScalar(option.selected ? `${option.label} (selected)` : option.label));
13574
+ const overflow = elementOptions.length > optionLimit ? ` # +${elementOptions.length - optionLimit} more option(s)` : "";
13575
+ lines.push(` options: [${rendered.join(", ")}]${overflow}`);
13576
+ }
13241
13577
  if (enr?.rowContext) {
13242
13578
  lines.push(` row_context: ${yamlScalar(enr.rowContext)}`);
13243
13579
  }
@@ -13250,6 +13586,12 @@ var require_snapshot_observation = __commonJS({
13250
13586
  lines.push(` visibility: [${vis.map((token) => yamlScalar(token)).join(", ")}]`);
13251
13587
  }
13252
13588
  }
13589
+ if (richRender) {
13590
+ const hidden = allInteractive.length - interactive.length;
13591
+ if (hidden > 0) {
13592
+ 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.`);
13593
+ }
13594
+ }
13253
13595
  }
13254
13596
  const recoveredClickables = options.recoveredClickables ?? [];
13255
13597
  if (recoveredClickables.length > 0) {
@@ -13406,11 +13748,11 @@ var require_snapshot_observation = __commonJS({
13406
13748
  score += 10;
13407
13749
  if (element.isExpanded)
13408
13750
  score += 8;
13409
- if (element.containerPath?.includes("dialog"))
13751
+ if (element.containerPath && DIALOG_SEGMENT_RE.test(element.containerPath))
13410
13752
  score += 12;
13411
- if (element.containerPath?.includes("form"))
13753
+ if (element.containerPath && FORM_SEGMENT_RE.test(element.containerPath))
13412
13754
  score += 10;
13413
- if (element.containerPath?.includes("tabpanel"))
13755
+ if (element.containerPath && TABPANEL_SEGMENT_RE.test(element.containerPath))
13414
13756
  score += 8;
13415
13757
  if (element.inputType === "email" || element.inputType === "password" || element.inputType === "search")
13416
13758
  score += 6;
@@ -13481,10 +13823,10 @@ var require_snapshot_observation = __commonJS({
13481
13823
  }
13482
13824
  return merged;
13483
13825
  }
13484
- function buildScopeSnapshotPreview(scopeSnapshotText) {
13826
+ function buildScopeSnapshotPreview(scopeSnapshotText, stripTrailingValues = false) {
13485
13827
  if (!scopeSnapshotText)
13486
13828
  return [];
13487
- return scopeSnapshotText.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 6);
13829
+ return scopeSnapshotText.split("\n").map((line) => line.trim()).map((line) => stripTrailingValues ? line.replace(/\]\s*:\s.+$/, "]") : line).filter(Boolean).slice(0, 6);
13488
13830
  }
13489
13831
  function dedupeLines(lines) {
13490
13832
  const seen = /* @__PURE__ */ new Set();
@@ -13611,11 +13953,12 @@ var require_snapshot_observation = __commonJS({
13611
13953
  const key = `${role}\0${name}`;
13612
13954
  let group = groups.get(key);
13613
13955
  if (!group) {
13614
- group = { role, name, containers: [], rowContexts: [], refs: [] };
13956
+ group = { role, name, containers: [], rowContexts: [], rowRoles: [], refs: [] };
13615
13957
  groups.set(key, group);
13616
13958
  }
13617
13959
  group.containers.push(element.containerPath ?? null);
13618
13960
  group.rowContexts.push(element.enrichment?.rowContext ?? null);
13961
+ group.rowRoles.push(element.enrichment?.rowRole ?? null);
13619
13962
  group.refs.push(element.ref ?? null);
13620
13963
  }
13621
13964
  return [...groups.values()].filter((group) => group.containers.length > 1);
@@ -13625,7 +13968,7 @@ var require_snapshot_observation = __commonJS({
13625
13968
  for (const element of elements) {
13626
13969
  const role = element.role?.toLowerCase() ?? "";
13627
13970
  const elementType = element.elementType?.toLowerCase() ?? "";
13628
- const isDialog = role === "dialog" || elementType === "dialog" || (element.containerPath?.toLowerCase().includes("dialog:") ?? false);
13971
+ const isDialog = role === "dialog" || elementType === "dialog" || DIALOG_NAME_SEGMENT_RE.test(element.containerPath ?? "");
13629
13972
  if (!isDialog)
13630
13973
  continue;
13631
13974
  const hint = qualifyElementLabel(element) ?? dialogNameFromContainerPath(element.containerPath) ?? element.label ?? element.testId ?? "dialog";
@@ -13638,7 +13981,7 @@ var require_snapshot_observation = __commonJS({
13638
13981
  function dialogNameFromContainerPath(containerPath) {
13639
13982
  if (!containerPath)
13640
13983
  return null;
13641
- const match = containerPath.match(/dialog:([^>]+)/i);
13984
+ const match = containerPath.match(DIALOG_NAME_SEGMENT_RE);
13642
13985
  return match ? match[1].trim() : null;
13643
13986
  }
13644
13987
  function renderObservationCue(observation) {
@@ -13647,6 +13990,13 @@ var require_snapshot_observation = __commonJS({
13647
13990
  const base2 = observation.outcomeType === "SUCCESS" && outcome ? outcome : `${action} -> ${outcome || observation.outcomeType}`;
13648
13991
  return base2.length > 120 ? `${base2.slice(0, 117)}...` : base2;
13649
13992
  }
13993
+ function messageRolePriority(role) {
13994
+ if (role === "alert")
13995
+ return 0;
13996
+ if (role === "status")
13997
+ return 1;
13998
+ return 2;
13999
+ }
13650
14000
  function yamlScalar(value) {
13651
14001
  if (value == null || value === "")
13652
14002
  return "null";
@@ -20195,7 +20545,10 @@ ${testOpen}`;
20195
20545
  pressed: se.pressed,
20196
20546
  headingLevel: se.headingLevel,
20197
20547
  currentState: se.currentState,
20198
- containerPath: se.containerPath || void 0
20548
+ containerPath: se.containerPath || void 0,
20549
+ href: se.href || void 0,
20550
+ value: se.value || void 0,
20551
+ options: se.options && se.options.length > 0 ? se.options : void 0
20199
20552
  }));
20200
20553
  const structuredPage = {
20201
20554
  route: pageUrl || "/",
@@ -20203,6 +20556,7 @@ ${testOpen}`;
20203
20556
  pageType: "unknown",
20204
20557
  elements: collectedElements,
20205
20558
  formGroups: parsed.formGroups,
20559
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
20206
20560
  snapshotMetrics: {
20207
20561
  rawLineCount: parsed.rawLineCount,
20208
20562
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -21769,7 +22123,7 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
21769
22123
  let stopped;
21770
22124
  const agentModel = options?.modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(options.modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
21771
22125
  const auditGroupId = `generate-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
21772
- const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)();
22126
+ const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
21773
22127
  const credentials = variableContext.allTestVariables ?? variableContext.publicTestVariables;
21774
22128
  logs2.push(`Phase 3 \u2014 Generate: ${(plan.scenarios ?? []).length} scenarios, max ${maxTests} tests`);
21775
22129
  logs2.push(`Model: ${agentModel}`);
@@ -235734,7 +236088,7 @@ var require_mcp_healer = __commonJS({
235734
236088
  messages.push(system, firstUser, { role: "user", content: lines.join("\n") }, ...tail);
235735
236089
  }
235736
236090
  var DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS = 6;
235737
- function readPositiveIntEnv(env, name, fallback) {
236091
+ function readPositiveIntEnv2(env, name, fallback) {
235738
236092
  const raw = env[name]?.trim();
235739
236093
  if (!raw || !/^\d+$/.test(raw))
235740
236094
  return fallback;
@@ -235742,7 +236096,7 @@ var require_mcp_healer = __commonJS({
235742
236096
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
235743
236097
  }
235744
236098
  function getScriptHealMaxAttempts(env = process.env) {
235745
- return readPositiveIntEnv(env, "MCP_HEAL_SCRIPT_MAX_ATTEMPTS", DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS);
236099
+ return readPositiveIntEnv2(env, "MCP_HEAL_SCRIPT_MAX_ATTEMPTS", DEFAULT_MAX_SCRIPT_HEAL_ATTEMPTS);
235746
236100
  }
235747
236101
  exports.SCRIPT_HEAL_SYSTEM_PROMPT = `You are a QA engineer fixing a failing Playwright UI test. You will receive the test code, the error message, step-level results, and a screenshot of the failure state.
235748
236102
 
@@ -236078,7 +236432,7 @@ ${lines.join("\n")}
236078
236432
  const landingViolation = (tags ?? []).includes("@landing-violation");
236079
236433
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "strong") : (0, ai_provider_js_1.getModel)("strong");
236080
236434
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
236081
- const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)());
236435
+ const aiClient = options.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
236082
236436
  const runNativeTest = options.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
236083
236437
  const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
236084
236438
  const groupId = `script-heal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -236900,10 +237254,10 @@ ${(verifyResult.logs ?? "").slice(0, 2e3)}`;
236900
237254
  var DEFAULT_MAX_PHASE2_ITERATIONS = 4;
236901
237255
  var DEFAULT_MAX_MCP_EXPLORE_ITERATIONS = 25;
236902
237256
  function getMcpHealPhase2MaxIterations(env = process.env) {
236903
- return readPositiveIntEnv(env, "MCP_HEAL_PHASE2_MAX_ITERATIONS", DEFAULT_MAX_PHASE2_ITERATIONS);
237257
+ return readPositiveIntEnv2(env, "MCP_HEAL_PHASE2_MAX_ITERATIONS", DEFAULT_MAX_PHASE2_ITERATIONS);
236904
237258
  }
236905
237259
  function getMcpHealExploreMaxIterations(env = process.env) {
236906
- return readPositiveIntEnv(env, "MCP_HEAL_EXPLORE_MAX_ITERATIONS", DEFAULT_MAX_MCP_EXPLORE_ITERATIONS);
237260
+ return readPositiveIntEnv2(env, "MCP_HEAL_EXPLORE_MAX_ITERATIONS", DEFAULT_MAX_MCP_EXPLORE_ITERATIONS);
236907
237261
  }
236908
237262
  var DEFAULT_MCP_HEAL_MAX_WALL_CLOCK_MS = 10 * 60 * 1e3;
236909
237263
  var DEFAULT_MCP_HEAL_MAX_TOTAL_AI_CALLS = 60;
@@ -236955,7 +237309,7 @@ Only explore the failing page and the area around the broken step.`;
236955
237309
  let lastVideo;
236956
237310
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
236957
237311
  const provider = modelOverride ? (0, ai_provider_js_1.getProviderForModel)(modelOverride) : (0, ai_provider_js_1.getAIProvider)();
236958
- const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)());
237312
+ const aiClient = options?.phase2Harness?.aiClient ?? (modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)());
236959
237313
  const runPhase2NativeTest = options?.phase2Harness?.runNativeTest ?? playwright_native_js_1.executeTestViaNativePlaywright;
236960
237314
  const modelSupportsVision = /gpt-4o|claude-3|claude-4|minimax|grok/i.test(agentModel ?? "");
236961
237315
  const groupId = `phase2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -237645,7 +237999,10 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
237645
237999
  pressed: se.pressed,
237646
238000
  headingLevel: se.headingLevel,
237647
238001
  currentState: se.currentState,
237648
- containerPath: se.containerPath || void 0
238002
+ containerPath: se.containerPath || void 0,
238003
+ href: se.href || void 0,
238004
+ value: se.value || void 0,
238005
+ options: se.options && se.options.length > 0 ? se.options : void 0
237649
238006
  }));
237650
238007
  const structuredPage = {
237651
238008
  route: pageUrl || "/",
@@ -237653,6 +238010,7 @@ Fix the syntax error and provide ONLY the complete corrected Playwright test as
237653
238010
  pageType: "unknown",
237654
238011
  elements: collectedElements,
237655
238012
  formGroups: parsed.formGroups,
238013
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
237656
238014
  snapshotMetrics: {
237657
238015
  rawLineCount: parsed.rawLineCount,
237658
238016
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -238089,7 +238447,7 @@ ${testBlocks}
238089
238447
  - By iteration ${Math.floor(maxIterations * 0.3)}: you should have completed 1-2 tests
238090
238448
  - By iteration ${Math.floor(maxIterations * 0.7)}: you should be past the halfway point
238091
238449
  - At iteration ${Math.floor(maxIterations * 0.9)}: wrap up and call finish_capture`;
238092
- const aiClient = modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)();
238450
+ const aiClient = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
238093
238451
  const messages = [
238094
238452
  { role: "system", content: systemPrompt },
238095
238453
  { role: "user", content: "Start executing the tests. Navigate to the base URL and begin with Test 1." }
@@ -238272,7 +238630,10 @@ ${testBlocks}
238272
238630
  pressed: se.pressed,
238273
238631
  headingLevel: se.headingLevel,
238274
238632
  currentState: se.currentState,
238275
- containerPath: se.containerPath || void 0
238633
+ containerPath: se.containerPath || void 0,
238634
+ href: se.href || void 0,
238635
+ value: se.value || void 0,
238636
+ options: se.options && se.options.length > 0 ? se.options : void 0
238276
238637
  }));
238277
238638
  const structuredPage = {
238278
238639
  route: pageUrl || "/",
@@ -238280,6 +238641,7 @@ ${testBlocks}
238280
238641
  pageType: "unknown",
238281
238642
  elements: collectedElements,
238282
238643
  formGroups: parsed.formGroups,
238644
+ staticMessages: parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0,
238283
238645
  snapshotMetrics: {
238284
238646
  rawLineCount: parsed.rawLineCount,
238285
238647
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -300069,8 +300431,9 @@ var require_capture_page_normalizer = __commonJS({
300069
300431
  let snapshotElements = [];
300070
300432
  let formGroups = [];
300071
300433
  let snapshotMetrics;
300434
+ let staticMessages;
300072
300435
  if (normalizedSnapshotResult.snapshotText) {
300073
- const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText);
300436
+ const parsed = (0, snapshot_parser_js_1.parseSnapshot)(normalizedSnapshotResult.snapshotText, { richParse: context.richParse });
300074
300437
  snapshotMetrics = {
300075
300438
  rawLineCount: parsed.rawLineCount,
300076
300439
  parsedElementLineCount: parsed.parsedElementLineCount,
@@ -300099,9 +300462,13 @@ var require_capture_page_normalizer = __commonJS({
300099
300462
  pressed: se.pressed,
300100
300463
  headingLevel: se.headingLevel,
300101
300464
  currentState: se.currentState,
300102
- containerPath: se.containerPath || void 0
300465
+ containerPath: se.containerPath || void 0,
300466
+ href: se.href || void 0,
300467
+ value: se.value || void 0,
300468
+ options: se.options && se.options.length > 0 ? se.options : void 0
300103
300469
  }));
300104
300470
  formGroups = parsed.formGroups;
300471
+ staticMessages = parsed.staticMessages.length > 0 ? parsed.staticMessages : void 0;
300105
300472
  }
300106
300473
  const aiElements = Array.isArray(toolArgs.elements) ? toolArgs.elements.map(normalizeAiElement) : [];
300107
300474
  const noYamlKinds = /* @__PURE__ */ new Set(["missing", "timeout", "error", "empty"]);
@@ -300197,6 +300564,7 @@ var require_capture_page_normalizer = __commonJS({
300197
300564
  } : void 0,
300198
300565
  readinessSignals,
300199
300566
  viewportHints,
300567
+ staticMessages,
300200
300568
  provenCommands: provenCmds,
300201
300569
  observations: rawObservations,
300202
300570
  routeCorrected,
@@ -300505,6 +300873,10 @@ var require_perception_enricher = __commonJS({
300505
300873
  const e = {};
300506
300874
  if (p.rowContext)
300507
300875
  e.rowContext = p.rowContext;
300876
+ if (p.rowContext && p.rowRole)
300877
+ e.rowRole = p.rowRole;
300878
+ if (p.inputType)
300879
+ e.inputType = p.inputType;
300508
300880
  if (p.position && p.position !== "in-view")
300509
300881
  e.position = p.position;
300510
300882
  if (p.occluded)
@@ -300754,6 +301126,23 @@ var require_perception_enricher = __commonJS({
300754
301126
  const cls = (el.getAttribute("class") || "").toLowerCase();
300755
301127
  return /(^|[\s_-])(card|row|item|listitem|cell|tile)([\s_-]|$)/.test(cls);
300756
301128
  };
301129
+ const rowRoleOf = (el) => {
301130
+ const explicit = (el.getAttribute("role") || "").toLowerCase().trim();
301131
+ if (explicit) {
301132
+ return explicit === "row" || explicit === "listitem" || explicit === "article" || explicit === "option" ? explicit : null;
301133
+ }
301134
+ const tag = el.tagName;
301135
+ if (tag === "TR") {
301136
+ const tbl = el.closest("table");
301137
+ const tblRole = tbl ? (tbl.getAttribute("role") || "").toLowerCase().trim() : "";
301138
+ return tblRole === "presentation" || tblRole === "none" ? null : "row";
301139
+ }
301140
+ if (tag === "LI")
301141
+ return el.closest('ul:not([role]),ol:not([role]),menu:not([role]),[role="list"]') ? "listitem" : null;
301142
+ if (tag === "ARTICLE")
301143
+ return "article";
301144
+ return null;
301145
+ };
300757
301146
  const rowContextOf = (el, ownNameLower) => {
300758
301147
  let cur = el.parentElement;
300759
301148
  let depth = 0;
@@ -300772,7 +301161,7 @@ var require_perception_enricher = __commonJS({
300772
301161
  txt = txt.slice(0, 80);
300773
301162
  const low = txt.toLowerCase();
300774
301163
  if (txt && low !== ownNameLower && low.length > 1)
300775
- return txt;
301164
+ return { text: txt, role: rowRoleOf(cur) };
300776
301165
  return null;
300777
301166
  }
300778
301167
  cur = cur.parentElement;
@@ -300862,11 +301251,14 @@ var require_perception_enricher = __commonJS({
300862
301251
  const name = accName(el);
300863
301252
  const lname = lower(name);
300864
301253
  const rect = vis.rect;
301254
+ const rc = rowContextOf(el, lname);
300865
301255
  out.push({
300866
301256
  domOrder: myOrder,
300867
301257
  roleClass: roleClassOf(tag, roleAttr, inputType),
300868
301258
  name: lname,
300869
- rowContext: rowContextOf(el, lname),
301259
+ rowContext: rc ? rc.text : null,
301260
+ rowRole: rc ? rc.role : null,
301261
+ inputType: inputType || null,
300870
301262
  position: positionOf(rect),
300871
301263
  occluded: occlusionOf(el, rect),
300872
301264
  cursorPointer,
@@ -300903,11 +301295,14 @@ var require_perception_enricher = __commonJS({
300903
301295
  if (!name)
300904
301296
  continue;
300905
301297
  const lname = lower(name);
301298
+ const rc = rowContextOf(el, lname);
300906
301299
  out.push({
300907
301300
  domOrder: order++,
300908
301301
  roleClass: "button",
300909
301302
  name: lname,
300910
- rowContext: rowContextOf(el, lname),
301303
+ rowContext: rc ? rc.text : null,
301304
+ rowRole: rc ? rc.role : null,
301305
+ inputType: null,
300911
301306
  position: "in-view",
300912
301307
  occluded: false,
300913
301308
  cursorPointer: true,
@@ -301409,7 +301804,7 @@ var require_discover_explorer = __commonJS({
301409
301804
  var DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP = 100;
301410
301805
  var DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY = 100;
301411
301806
  var DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL = 40;
301412
- function readPositiveIntEnv(env, name, fallback) {
301807
+ function readPositiveIntEnv2(env, name, fallback) {
301413
301808
  const raw = env[name]?.trim();
301414
301809
  if (!raw || !/^\d+$/.test(raw))
301415
301810
  return fallback;
@@ -301540,15 +301935,15 @@ var require_discover_explorer = __commonJS({
301540
301935
  }
301541
301936
  function getMaxIterations(mode, incremental, env = process.env) {
301542
301937
  if (mode === "survey" && incremental) {
301543
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_INCREMENTAL", DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL);
301938
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_INCREMENTAL", DEFAULT_MAX_EXPLORE_ITERATIONS_INCREMENTAL);
301544
301939
  }
301545
301940
  if (mode === "survey") {
301546
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_SURVEY", DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY);
301941
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_SURVEY", DEFAULT_MAX_EXPLORE_ITERATIONS_SURVEY);
301547
301942
  }
301548
301943
  if (mode === "deep") {
301549
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEEP", DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP);
301944
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEEP", DEFAULT_MAX_EXPLORE_ITERATIONS_DEEP);
301550
301945
  }
301551
- return readPositiveIntEnv(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEFAULT", DEFAULT_MAX_EXPLORE_ITERATIONS_DEFAULT);
301946
+ return readPositiveIntEnv2(env, "DISCOVERY_EXPLORE_MAX_ITERATIONS_DEFAULT", DEFAULT_MAX_EXPLORE_ITERATIONS_DEFAULT);
301552
301947
  }
301553
301948
  function updatePendingRouteScreenshot(pending, { imageData, detectedRoute, fallbackRoute }) {
301554
301949
  if (imageData) {
@@ -302274,7 +302669,7 @@ ${inboxPromptBlock}`;
302274
302669
  const browserCreds = variableContext.allTestVariables ?? variableContext.publicTestVariables;
302275
302670
  const { secrets: browserSecretsForHint } = (0, credential_tools_js_1.partitionTestVariables)(browserCreds);
302276
302671
  const hasLoginCredentials = Object.keys(browserSecretsForHint).length > 0;
302277
- const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getAIClient)();
302672
+ const aiClient = options?.modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(options.modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
302278
302673
  const isDeepMode = options?.mode === "deep";
302279
302674
  const maxIterations = config?.maxIterationsOverride && config.maxIterationsOverride > 0 ? config.maxIterationsOverride : getMaxIterations(options?.mode ?? "full", options?.incremental);
302280
302675
  const configKeepTail = isDeepMode ? Math.ceil(ctxConfig.keepTail * 1.5) : ctxConfig.keepTail;
@@ -304813,7 +305208,7 @@ var require_auth_capture_run = __commonJS({
304813
305208
  var scrub_credentials_js_1 = require_scrub_credentials();
304814
305209
  var credential_tools_js_1 = require_credential_tools();
304815
305210
  var DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS = 12;
304816
- function readPositiveIntEnv(env, name, fallback) {
305211
+ function readPositiveIntEnv2(env, name, fallback) {
304817
305212
  const raw = env[name]?.trim();
304818
305213
  if (!raw || !/^\d+$/.test(raw))
304819
305214
  return fallback;
@@ -304821,7 +305216,7 @@ var require_auth_capture_run = __commonJS({
304821
305216
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
304822
305217
  }
304823
305218
  function getLoginCaptureMaxIterations(env = process.env) {
304824
- return readPositiveIntEnv(env, "AUTH_CAPTURE_LOGIN_MAX_ITERATIONS", DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS);
305219
+ return readPositiveIntEnv2(env, "AUTH_CAPTURE_LOGIN_MAX_ITERATIONS", DEFAULT_MAX_LOGIN_CAPTURE_ITERATIONS);
304825
305220
  }
304826
305221
  var CAPTURE_RATE_LIMIT_MAX_RETRIES = 2;
304827
305222
  var CAPTURE_RATE_LIMIT_MAX_DELAY_MS = 8e3;
@@ -305104,7 +305499,7 @@ ${keyLines || "- (none provided)"}
305104
305499
  }
305105
305500
  var DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS = 18;
305106
305501
  function getRegisterCaptureMaxIterations(env = process.env) {
305107
- return readPositiveIntEnv(env, "AUTH_CAPTURE_REGISTER_MAX_ITERATIONS", DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS);
305502
+ return readPositiveIntEnv2(env, "AUTH_CAPTURE_REGISTER_MAX_ITERATIONS", DEFAULT_MAX_REGISTER_CAPTURE_ITERATIONS);
305108
305503
  }
305109
305504
  function buildRegisterCaptureSystemPrompt(baseUrl, availableKeys, maxIterations = getRegisterCaptureMaxIterations()) {
305110
305505
  const keyLines = availableKeys.map((k) => `- \`${k}\``).join("\n");
@@ -311334,6 +311729,7 @@ var require_mobile_exploration_state = __commonJS({
311334
311729
  Object.defineProperty(exports, "__esModule", { value: true });
311335
311730
  exports.MobileExplorationState = void 0;
311336
311731
  exports.deriveScreenId = deriveScreenId;
311732
+ var mobile_observation_js_1 = require_mobile_observation();
311337
311733
  var DEFAULT_STALE_THRESHOLD = 4;
311338
311734
  function deriveScreenId(signal) {
311339
311735
  const namespace = signal.activity ?? signal.bundleId;
@@ -311559,21 +311955,33 @@ var require_mobile_exploration_state = __commonJS({
311559
311955
  * Build the planner evidence shape: one `CollectedPageData` per screen, with
311560
311956
  * the screen-id carried in BOTH `route` (the on-the-wire positional field the
311561
311957
  * server keys screenshots by) AND the explicit `screenId` field (per the
311562
- * Phase-0 `CollectedPageData.screenId` contract). `elements` is left empty —
311563
- * the web-shaped `CollectedPageElement[]` is not the mobile element shape;
311564
- * the mobile planner reads screen identity + names from this evidence and the
311565
- * richer per-element data flows through the generator's own re-drive, not
311566
- * through this map. Insertion order matches first-seen order.
311958
+ * Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
311959
+ * the screen's parsed actionable elements into the platform-neutral
311960
+ * `CollectedPageElement[]` shape the server persists as SiteElements without
311961
+ * this, mobile screens land with zero elements (coverage always 0%, no element
311962
+ * intelligence). `triggersNavigation` is derived from transition-source
311963
+ * membership: an element is flagged when its ref drove a transition FROM this
311964
+ * screen. Insertion order matches first-seen order.
311567
311965
  */
311568
311966
  buildInMemoryScreenMap() {
311569
311967
  const pages = [];
311570
311968
  const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
311969
+ const navRefsByScreen = /* @__PURE__ */ new Map();
311970
+ for (const transition of this.transitions) {
311971
+ if (!transition.viaRef)
311972
+ continue;
311973
+ const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
311974
+ set.add(transition.viaRef);
311975
+ navRefsByScreen.set(transition.fromScreenId, set);
311976
+ }
311571
311977
  for (const state2 of ordered) {
311572
311978
  const page = {
311573
311979
  route: state2.screenId,
311574
311980
  screenId: state2.screenId,
311575
311981
  pageType: state2.screenType ?? "UNKNOWN",
311576
- elements: []
311982
+ elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
311983
+ navigationRefs: navRefsByScreen.get(state2.screenId)
311984
+ })
311577
311985
  };
311578
311986
  if (state2.screenName) {
311579
311987
  page.title = state2.screenName;
@@ -311603,6 +312011,7 @@ var require_mobile_discovery_agent = __commonJS({
311603
312011
  "../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports) {
311604
312012
  "use strict";
311605
312013
  Object.defineProperty(exports, "__esModule", { value: true });
312014
+ exports.getMobileExploreBudget = getMobileExploreBudget;
311606
312015
  exports.runMobileDiscoveryOnRunner = runMobileDiscoveryOnRunner2;
311607
312016
  var mobile_explorer_js_1 = require_mobile_explorer();
311608
312017
  var mobile_discovery_prompts_js_1 = require_mobile_discovery_prompts();
@@ -311616,8 +312025,37 @@ var require_mobile_discovery_agent = __commonJS({
311616
312025
  const message = checkpointErrorMessage(err);
311617
312026
  return /Discovery plan checkpoint rejected|Discovery run is .*not RUNNING|not RUNNING|cancelled/i.test(message);
311618
312027
  }
311619
- var EXPLORE_BUDGET_DEEP = 60;
311620
- var EXPLORE_BUDGET_SURVEY = 40;
312028
+ var MOBILE_EXPLORE_BUDGET_SURVEY_FIRST = 90;
312029
+ var MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL = 40;
312030
+ var MOBILE_EXPLORE_BUDGET_DEEP = 60;
312031
+ var MOBILE_EXPLORE_BUDGET_FULL = 90;
312032
+ function readPositiveIntEnv2(name, fallback, env = process.env) {
312033
+ const raw = env[name]?.trim();
312034
+ if (!raw || !/^\d+$/.test(raw))
312035
+ return fallback;
312036
+ const parsed = Number(raw);
312037
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
312038
+ }
312039
+ function getMobileExploreBudget(mode, incremental, env = process.env) {
312040
+ if (mode === "deep")
312041
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_DEEP", MOBILE_EXPLORE_BUDGET_DEEP, env);
312042
+ if (mode === "full")
312043
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_FULL", MOBILE_EXPLORE_BUDGET_FULL, env);
312044
+ if (incremental) {
312045
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY_INCREMENTAL", MOBILE_EXPLORE_BUDGET_SURVEY_INCREMENTAL, env);
312046
+ }
312047
+ return readPositiveIntEnv2("MOBILE_EXPLORE_MAX_ITERATIONS_SURVEY", MOBILE_EXPLORE_BUDGET_SURVEY_FIRST, env);
312048
+ }
312049
+ function compactExistingScreens(siteMap) {
312050
+ if (!siteMap || !Array.isArray(siteMap.pages) || siteMap.pages.length === 0)
312051
+ return void 0;
312052
+ const screens = siteMap.pages.filter((p) => typeof p.route === "string" && p.route.trim().length > 0).map((p) => ({
312053
+ screenId: p.route,
312054
+ ...p.title ? { name: p.title } : {},
312055
+ ...p.pageType ? { screenType: p.pageType } : {}
312056
+ }));
312057
+ return screens.length > 0 ? screens : void 0;
312058
+ }
311621
312059
  function platformForMedium(medium) {
311622
312060
  return medium === "IOS_NATIVE" ? "IOS" : "ANDROID";
311623
312061
  }
@@ -311636,6 +312074,9 @@ var require_mobile_discovery_agent = __commonJS({
311636
312074
  // Native screenshots are multi-megabyte PNGs. The live mirror streams them
311637
312075
  // through heartbeat telemetry; discovery checkpoints keep the screen graph
311638
312076
  // and transitions so large Android surveys cannot exceed server body limits.
312077
+ // D-D6 — pageScreenshotKeys is a small screen-id → storage-key map (NOT base64),
312078
+ // so it DOES ride the checkpoint: the server links each key to SitePage.screenshotKey.
312079
+ ...explore.pageScreenshotKeys && Object.keys(explore.pageScreenshotKeys).length > 0 ? { pageScreenshotKeys: explore.pageScreenshotKeys } : {},
311639
312080
  exploreStats: explore.exploreStats,
311640
312081
  healthObservations: explore.healthObservations
311641
312082
  };
@@ -311650,6 +312091,7 @@ var require_mobile_discovery_agent = __commonJS({
311650
312091
  const onLog = callbacks?.onLog;
311651
312092
  const onAICall = callbacks?.onAICall;
311652
312093
  const onScreenshot = callbacks?.onScreenshot;
312094
+ const onScreenshotUpload = callbacks?.onScreenshotUpload;
311653
312095
  const runExplore = callbacks?.runExplore ?? mobile_explorer_js_1.runMobileExplorePhase;
311654
312096
  const runPlan = callbacks?.runPlan ?? mobile_discovery_prompts_js_1.runMobileAIPlanner;
311655
312097
  const runGenerate = callbacks?.runGenerate ?? mobile_generator_js_1.runMobileGeneratePhase;
@@ -311676,15 +312118,77 @@ var require_mobile_discovery_agent = __commonJS({
311676
312118
  elementsFound: 0,
311677
312119
  transitionsFound: 0
311678
312120
  };
312121
+ const resumeSavedScenarios = ctx.resume?.savedPlan?.scenarios ?? [];
312122
+ const isPlanReuseResume = mode !== "survey" && resumeSavedScenarios.length > 0;
311679
312123
  try {
312124
+ if (isPlanReuseResume) {
312125
+ const alreadyGenerated = ctx.resume?.alreadyGeneratedScenarioNames ?? [];
312126
+ log2(`
312127
+ \u2500\u2500 Resume (plan-reuse): ${resumeSavedScenarios.length} saved scenario(s); ${alreadyGenerated.length} already generated. Skipping explore + plan; generating only the remainder. \u2500\u2500`);
312128
+ const scenarios2 = resumeSavedScenarios;
312129
+ const plans2 = toPlans(scenarios2);
312130
+ if (callbacks?.onPlanReady) {
312131
+ try {
312132
+ await callbacks.onPlanReady({ scenarios: scenarios2, featureGroups: void 0, fallbackUsed: false });
312133
+ log2("Plan checkpoint: reused scenario plan re-POSTed to server");
312134
+ } catch (err) {
312135
+ const message = checkpointErrorMessage(err);
312136
+ if (isTerminalDiscoveryCheckpointError(err)) {
312137
+ log2(`Plan checkpoint rejected for inactive discovery run: ${message}`);
312138
+ throw err;
312139
+ }
312140
+ log2(`Plan checkpoint POST failed (non-fatal): ${message}`);
312141
+ }
312142
+ }
312143
+ let paused = false;
312144
+ let pauseReason;
312145
+ let remainingScenarios;
312146
+ const tests2 = await runGenerate({
312147
+ scenarios: scenarios2,
312148
+ driver,
312149
+ ctx,
312150
+ onLog: (line) => log2(line),
312151
+ onAICall,
312152
+ onScreenshot,
312153
+ onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
312154
+ skipScenarioNames: alreadyGenerated,
312155
+ onInfraStop: (info) => {
312156
+ paused = true;
312157
+ pauseReason = info.reason;
312158
+ remainingScenarios = info.remainingScenarioNames;
312159
+ }
312160
+ });
312161
+ const duration2 = (Date.now() - startTime) / 1e3;
312162
+ 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" : ""}`;
312163
+ log2(`
312164
+ === Mobile Discovery Resume Complete (${duration2.toFixed(1)}s) ===`);
312165
+ log2(summary2);
312166
+ return {
312167
+ collectedPages: [],
312168
+ collectedTransitions: [],
312169
+ tests: tests2,
312170
+ plans: plans2,
312171
+ scenarios: scenarios2.length > 0 ? scenarios2 : void 0,
312172
+ logs: logs2.join("\n"),
312173
+ screenshots,
312174
+ summary: summary2,
312175
+ exploreStats: { pagesDiscovered: 0, pagesVisited: 0, elementsFound: 0, transitionsFound: 0 },
312176
+ duration: duration2,
312177
+ mode,
312178
+ ...paused ? { paused: true, pauseReason, remainingScenarios } : {}
312179
+ // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
312180
+ };
312181
+ }
311680
312182
  log2("\n\u2500\u2500 Phase 1: EXPLORE \u2500\u2500");
311681
312183
  const state2 = new mobile_exploration_state_js_1.MobileExplorationState();
311682
- const maxIterations = mode === "deep" ? EXPLORE_BUDGET_DEEP : EXPLORE_BUDGET_SURVEY;
312184
+ const maxIterations = getMobileExploreBudget(mode, ctx.incremental);
312185
+ const existingScreens = compactExistingScreens(ctx.existingSiteMap);
311683
312186
  const explore = await runExplore({
311684
312187
  ctx,
311685
312188
  driver,
311686
312189
  state: state2,
311687
312190
  config: { maxIterations },
312191
+ existingScreens,
311688
312192
  onLog: (line) => log2(line),
311689
312193
  onAICall,
311690
312194
  onScreenshot: (base64) => {
@@ -311694,7 +312198,8 @@ var require_mobile_discovery_agent = __commonJS({
311694
312198
  onScreenshot?.(base64);
311695
312199
  } catch {
311696
312200
  }
311697
- }
312201
+ },
312202
+ onScreenshotUpload
311698
312203
  });
311699
312204
  const collectedTransitions = toCollectedTransitions(explore);
311700
312205
  partialCollectedPages = explore.collectedPages;
@@ -311795,6 +312300,9 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311795
312300
  };
311796
312301
  }
311797
312302
  log2("\n\u2500\u2500 Phase 3: GENERATE \u2500\u2500");
312303
+ let generatePaused = false;
312304
+ let generatePauseReason;
312305
+ let generateRemainingScenarios;
311798
312306
  const tests = await runGenerate({
311799
312307
  scenarios,
311800
312308
  driver,
@@ -311802,7 +312310,15 @@ Plan: ${planResult.scenarios.length} scenarios, ${planResult.featureGroups.lengt
311802
312310
  onLog: (line) => log2(line),
311803
312311
  onAICall,
311804
312312
  onScreenshot,
311805
- onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" })
312313
+ onTestGenerated: (test) => callbacks?.onTestGenerated?.(test, { phase: "verified" }),
312314
+ // Legacy resume (no saved plan): still skip scenarios already generated by the
312315
+ // paused run so the AI isn't re-invoked for them. Undefined on a fresh run.
312316
+ skipScenarioNames: ctx.resume?.alreadyGeneratedScenarioNames,
312317
+ onInfraStop: (info) => {
312318
+ generatePaused = true;
312319
+ generatePauseReason = info.reason;
312320
+ generateRemainingScenarios = info.remainingScenarioNames;
312321
+ }
311806
312322
  });
311807
312323
  const generateElapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
311808
312324
  log2(`
@@ -311818,7 +312334,7 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
311818
312334
  };
311819
312335
  }
311820
312336
  const duration = (Date.now() - startTime) / 1e3;
311821
- const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s`;
312337
+ const summary = `Mobile discovery complete: explored ${explore.exploreStats.pagesDiscovered} screens, planned ${scenarios.length} scenarios, generated ${tests.length} tests in ${duration.toFixed(1)}s${generatePaused ? " \u2014 PAUSED on AI provider overload (resumable)" : ""}`;
311822
312338
  log2(`
311823
312339
  === Mobile Discovery Pipeline Complete (${duration.toFixed(1)}s) ===`);
311824
312340
  log2(summary);
@@ -311843,7 +312359,11 @@ Generation complete: ${tests.length} tests generated (${generateElapsed}s elapse
311843
312359
  summary,
311844
312360
  exploreStats: explore.exploreStats,
311845
312361
  duration,
311846
- mode
312362
+ mode,
312363
+ // D-D4 — a circuit-breaker trip PAUSES the run (resumable) instead of
312364
+ // finalizing COMPLETED/FAILED. The server's isDiscoveryPaused reads these
312365
+ // (forwarded by runner.ts buildFinalBody, byte-identical to the web path).
312366
+ ...generatePaused ? { paused: true, pauseReason: generatePauseReason, remainingScenarios: generateRemainingScenarios } : {}
311847
312367
  // apiCalls intentionally UNSET — CHUNK K (runner.ts) merges them.
311848
312368
  };
311849
312369
  } catch (fatalErr) {
@@ -311889,6 +312409,7 @@ var require_discover_agent = __commonJS({
311889
312409
  Object.defineProperty(exports, "__esModule", { value: true });
311890
312410
  exports.runMobileDiscoveryOnRunner = exports.buildInMemorySiteMap = void 0;
311891
312411
  exports.runCanvasPreflight = runCanvasPreflight;
312412
+ exports.getFatalExploreError = getFatalExploreError;
311892
312413
  exports.runSiteDiscoveryOnRunner = runSiteDiscoveryOnRunner2;
311893
312414
  exports.runFeatureDiscoveryOnRunner = runFeatureDiscoveryOnRunner2;
311894
312415
  var browser_config_js_1 = require_browser_config();
@@ -312071,7 +312592,7 @@ Respond with ONLY valid JSON:
312071
312592
  "keyFindings": "What was discovered - forms, validations, behaviors. Mark inferred items with '(inferred, not tested)'.",
312072
312593
  "testingNotes": "Gotchas, validation behaviors, error messages. List visible-but-untested routes as candidates for future exploration."
312073
312594
  }`;
312074
- const client = modelOverride ? (0, ai_provider_js_1.getClientForModel)(modelOverride) : (0, ai_provider_js_1.getAIClient)();
312595
+ const client = modelOverride ? (0, ai_provider_js_1.getRunnerClientForModel)(modelOverride) : (0, ai_provider_js_1.getRunnerAIClient)();
312075
312596
  const agentModel = modelOverride ? (0, ai_provider_js_1.resolveModelForTask)(modelOverride, "agent") : (0, ai_provider_js_1.getModel)("agent");
312076
312597
  const callStart = Date.now();
312077
312598
  const response = await (0, ai_retry_js_1.withAIRetry)(() => (0, ai_queue_js_1.queuedAICall)(() => client.chat.completions.create({
@@ -312202,6 +312723,10 @@ Respond with ONLY valid JSON:
312202
312723
  });
312203
312724
  return timed.catch(() => null);
312204
312725
  }
312726
+ function getFatalExploreError(exploreResult) {
312727
+ const prefix = "Fatal error:";
312728
+ return exploreResult.summary.startsWith(prefix) ? exploreResult.summary.slice(prefix.length).trim() || exploreResult.summary : void 0;
312729
+ }
312205
312730
  async function runSiteDiscoveryOnRunner2(ctx, onLog, onAICall, sharedBrowser, onScreenshot, callbackOptions) {
312206
312731
  const logs2 = [];
312207
312732
  const screenshots = [];
@@ -312386,6 +312911,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
312386
312911
  if (loginRunP)
312387
312912
  await loginRunP;
312388
312913
  const duration2 = (Date.now() - startTime) / 1e3;
312914
+ const fatalExploreError = getFatalExploreError(exploreResult);
312389
312915
  return {
312390
312916
  collectedPages: exploreResult.collectedPages,
312391
312917
  collectedTransitions: exploreResult.collectedTransitions,
@@ -312396,6 +312922,7 @@ Explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResult.elemen
312396
312922
  pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
312397
312923
  pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
312398
312924
  summary: exploreResult.summary || "Exploration completed but no page data captured.",
312925
+ error: fatalExploreError,
312399
312926
  exploreStats: {
312400
312927
  pagesDiscovered: exploreResult.pagesDiscovered,
312401
312928
  pagesVisited: exploreResult.pagesVisited,
@@ -312937,6 +313464,7 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
312937
313464
  discoveryApiCalls = earlyClose.apiCalls;
312938
313465
  postCloseHealthSummary = earlyClose.healthSummary;
312939
313466
  const duration2 = (Date.now() - startTime) / 1e3;
313467
+ const fatalExploreError = getFatalExploreError(exploreResult);
312940
313468
  return {
312941
313469
  collectedPages: exploreResult.collectedPages,
312942
313470
  collectedTransitions: exploreResult.collectedTransitions,
@@ -312947,7 +313475,8 @@ Feature explore complete: ${exploreResult.pagesDiscovered} pages, ${exploreResul
312947
313475
  healthObservations: earlyClose.healthSummary,
312948
313476
  pageScreenshots: onScreenshot ? void 0 : Object.fromEntries(mergedScreenshots),
312949
313477
  pageScreenshotKeys: onScreenshot ? Object.fromEntries(screenshotKeys) : void 0,
312950
- summary: "Feature exploration completed but no page data captured.",
313478
+ summary: fatalExploreError ? exploreResult.summary : "Feature exploration completed but no page data captured.",
313479
+ error: fatalExploreError,
312951
313480
  exploreStats: {
312952
313481
  pagesDiscovered: exploreResult.pagesDiscovered,
312953
313482
  pagesVisited: exploreResult.pagesVisited,
@@ -326336,7 +326865,7 @@ var require_package4 = __commonJS({
326336
326865
  "package.json"(exports, module) {
326337
326866
  module.exports = {
326338
326867
  name: "@validate.qa/runner",
326339
- version: "1.0.12",
326868
+ version: "1.0.14",
326340
326869
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
326341
326870
  bin: {
326342
326871
  "validate-runner": "dist/cli.js"
@@ -326344,7 +326873,7 @@ var require_package4 = __commonJS({
326344
326873
  scripts: {
326345
326874
  build: "tsup",
326346
326875
  dev: "tsx src/cli.ts",
326347
- "test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts",
326876
+ "test:mobile": "node --import tsx --test src/runner.mobile-ai-env.test.ts src/services/mobile/mobile-network-capture.smoke.test.ts src/services/mobile/mobile-host-proxy.test.ts",
326348
326877
  prepublishOnly: "npm run build"
326349
326878
  },
326350
326879
  files: [
@@ -326571,8 +327100,10 @@ async function startAppiumServer(options) {
326571
327100
  return serverState;
326572
327101
  }
326573
327102
  const port = options?.port ?? APPIUM_PORT;
326574
- const drivers = options?.drivers ?? ["xcuitest", "uiautomator2"];
326575
- const args = ["--port", String(port), "--use-drivers", drivers.join(",")];
327103
+ const args = ["--port", String(port)];
327104
+ if (options?.drivers && options.drivers.length > 0) {
327105
+ args.push("--use-drivers", options.drivers.join(","));
327106
+ }
326576
327107
  intentionalStop = false;
326577
327108
  lastStartOptions = { ...lastStartOptions, ...options };
326578
327109
  if (appiumGaveUp) {
@@ -326582,11 +327113,12 @@ async function startAppiumServer(options) {
326582
327113
  killPort(port);
326583
327114
  return new Promise((resolve, reject) => {
326584
327115
  try {
326585
- appiumProcess = spawn("appium", args, {
327116
+ const child = spawn("appium", args, {
326586
327117
  stdio: ["pipe", "pipe", "pipe"],
326587
327118
  env: { ...process.env },
326588
327119
  detached: false
326589
327120
  });
327121
+ appiumProcess = child;
326590
327122
  serverState.pid = appiumProcess.pid ?? null;
326591
327123
  serverState.port = port;
326592
327124
  appiumProcess.stdout?.on("data", (data) => {
@@ -326603,7 +327135,11 @@ async function startAppiumServer(options) {
326603
327135
  options?.onLog?.(line);
326604
327136
  }
326605
327137
  });
326606
- appiumProcess.on("exit", (code, signal) => {
327138
+ child.on("exit", (code, signal) => {
327139
+ if (appiumProcess !== child) {
327140
+ appendLog(`[appium] Ignoring exit (code=${code}, signal=${signal}) from a superseded process`);
327141
+ return;
327142
+ }
326607
327143
  appendLog(`[appium] Process exited (code=${code}, signal=${signal})`);
326608
327144
  const currentPort = serverState.port;
326609
327145
  const currentRestartCount = serverState.restartCount;
@@ -326642,7 +327178,11 @@ async function startAppiumServer(options) {
326642
327178
  startupAbort.abort();
326643
327179
  reject(err);
326644
327180
  };
326645
- appiumProcess.on("error", (err) => {
327181
+ child.on("error", (err) => {
327182
+ if (appiumProcess !== child) {
327183
+ appendLog(`[appium] Ignoring error from a superseded process: ${err.message}`);
327184
+ return;
327185
+ }
326646
327186
  appendLog(`[appium] Process error: ${err.message}`);
326647
327187
  resetServerState(port);
326648
327188
  settleReject(new Error(`Failed to start Appium: ${err.message}. Is appium installed? Run: npm i -g appium`));
@@ -326761,6 +327301,7 @@ function runCommand(command, args, timeout = 1e4) {
326761
327301
  }
326762
327302
  var realIOSProfilerNegativeUntil = 0;
326763
327303
  var REAL_IOS_PROFILER_TTL_MS = 6e4;
327304
+ var REAL_IOS_NO_UDID_HINT = "no paired UDID \u2014 install libimobiledevice (brew install libimobiledevice) and trust this computer on the device";
326764
327305
  function discoverIOSSimulators() {
326765
327306
  if (platform() !== "darwin") return [];
326766
327307
  const json = runCommand("xcrun", ["simctl", "list", "devices", "--json"]);
@@ -326770,7 +327311,8 @@ function discoverIOSSimulators() {
326770
327311
  const devices = [];
326771
327312
  for (const [runtime, runtimeDevices] of Object.entries(parsed.devices)) {
326772
327313
  const versionMatch = runtime.match(/iOS[- ](\d+(?:[-.]\d+)*)/i);
326773
- const platformVersion = versionMatch ? versionMatch[1].replace(/-/g, ".") : "unknown";
327314
+ if (!versionMatch) continue;
327315
+ const platformVersion = versionMatch[1].replace(/-/g, ".");
326774
327316
  for (const device of runtimeDevices) {
326775
327317
  const state2 = device.state === "Booted" ? "BOOTED" : device.state === "Shutdown" ? "SHUTDOWN" : "LOADING";
326776
327318
  if (state2 === "BOOTED") {
@@ -326843,11 +327385,11 @@ function discoverRealIOSDevices() {
326843
327385
  const version = runCommand("ideviceinfo", ["-u", serial, "-k", "ProductVersion"]) || "unknown";
326844
327386
  devices.push({
326845
327387
  id: serial,
326846
- name,
327388
+ name: `${name} (${REAL_IOS_NO_UDID_HINT})`,
326847
327389
  platform: "IOS",
326848
327390
  platformVersion: version,
326849
327391
  state: "BOOTED",
326850
- isAvailable: true,
327392
+ isAvailable: false,
326851
327393
  isPhysical: true
326852
327394
  });
326853
327395
  }
@@ -326863,11 +327405,11 @@ function discoverRealIOSDevices() {
326863
327405
  if (hasIPhone || hasIPad) {
326864
327406
  devices.push({
326865
327407
  id: "usb-ios-device",
326866
- name: hasIPhone ? "iPhone (USB)" : "iPad (USB)",
327408
+ name: `${hasIPhone ? "iPhone (USB)" : "iPad (USB)"} (${REAL_IOS_NO_UDID_HINT})`,
326867
327409
  platform: "IOS",
326868
327410
  platformVersion: "unknown",
326869
327411
  state: "BOOTED",
326870
- isAvailable: true,
327412
+ isAvailable: false,
326871
327413
  isPhysical: true
326872
327414
  });
326873
327415
  }
@@ -326945,7 +327487,9 @@ function discoverAndroidDevices() {
326945
327487
  const adbDevices = parseAdbDevices();
326946
327488
  return adbDevices.filter((d) => d.type === "device" || d.type === "unauthorized").map((d) => {
326947
327489
  const isEmulator = d.serial.startsWith("emulator-");
326948
- const isAvailable = d.type === "device";
327490
+ const authorized = d.type === "device";
327491
+ const bootCompleted = authorized && getAndroidProp(d.serial, "sys.boot_completed") === "1";
327492
+ const isAvailable = authorized && bootCompleted;
326949
327493
  const name = d.model || d.device || (isEmulator ? "Android Emulator" : "Android Device");
326950
327494
  const platformVersion = isAvailable ? getAndroidProp(d.serial, "ro.build.version.release") || "unknown" : "unknown";
326951
327495
  return {
@@ -326953,7 +327497,10 @@ function discoverAndroidDevices() {
326953
327497
  name,
326954
327498
  platform: "ANDROID",
326955
327499
  platformVersion,
326956
- state: "BOOTED",
327500
+ // LOADING = authorized but still booting; an unauthorized device stays
327501
+ // BOOTED (isAvailable false) so the dashboard shows it with an
327502
+ // "accept USB debugging" hint rather than hiding it.
327503
+ state: authorized && !bootCompleted ? "LOADING" : "BOOTED",
326957
327504
  isAvailable,
326958
327505
  // Mark if it's a real device vs emulator
326959
327506
  ...isEmulator ? {} : { isPhysical: true }
@@ -326999,15 +327546,25 @@ function discoverAllDevices(options) {
326999
327546
  }
327000
327547
  return [...iosDevices, ...androidDevices];
327001
327548
  }
327549
+ var DEVICE_SNAPSHOT_TTL_MS = 8e3;
327550
+ var cachedDeviceSnapshot = null;
327551
+ function getCachedDeviceSnapshot() {
327552
+ const now = Date.now();
327553
+ if (cachedDeviceSnapshot && now - cachedDeviceSnapshot.at < DEVICE_SNAPSHOT_TTL_MS) {
327554
+ return cachedDeviceSnapshot.devices;
327555
+ }
327556
+ const devices = discoverAllDevices();
327557
+ cachedDeviceSnapshot = { at: now, devices };
327558
+ return devices;
327559
+ }
327002
327560
  function getRunnerCapabilities() {
327003
- const iosDevices = discoverIOSDevices();
327004
- const androidDevices = discoverAndroidDevices();
327561
+ const devices = getCachedDeviceSnapshot();
327005
327562
  const isReadyDevice = (device) => device.isAvailable && device.state === "BOOTED";
327006
327563
  return {
327007
327564
  web: true,
327008
327565
  // Always capable of web testing via Playwright
327009
- ios: iosDevices.some(isReadyDevice),
327010
- android: androidDevices.some(isReadyDevice)
327566
+ ios: devices.some((d) => d.platform === "IOS" && isReadyDevice(d)),
327567
+ android: devices.some((d) => d.platform === "ANDROID" && isReadyDevice(d))
327011
327568
  };
327012
327569
  }
327013
327570
 
@@ -327030,6 +327587,8 @@ var DEVICE_CMD_TIMEOUT_MS = 15e3;
327030
327587
  var CA_KEY_BITS = 2048;
327031
327588
  var ANDROID_EMULATOR_HOST_LOOPBACK = "10.0.2.2";
327032
327589
  var PENDING_CLEANUP_DIR = join2(tmpdir(), "validateqa-mobile-capture-pending");
327590
+ var IOS_SIM_HOST_PROXY_ENV = "VALIDATEQA_MOBILE_IOS_SIM_HOST_PROXY";
327591
+ var STALE_MARKER_RECONCILE_MS = 6 * 60 * 60 * 1e3;
327033
327592
  function envFlag(name) {
327034
327593
  const value = (process.env[name] ?? "").trim().toLowerCase();
327035
327594
  return value === "1" || value === "true" || value === "yes" || value === "on";
@@ -327146,6 +327705,9 @@ var MitmProxy = class {
327146
327705
  await server3.forAnyRequest().thenPassThrough({ ignoreHostHttpsErrors: true });
327147
327706
  await server3.on("request", (req) => this.onRequest(req));
327148
327707
  await server3.on("response", (res) => this.onResponse(res));
327708
+ await server3.on("abort", (req) => {
327709
+ this.inFlight.delete(req.id);
327710
+ });
327149
327711
  await server3.on("tls-client-error", (failure) => this.onTlsClientError(failure));
327150
327712
  await server3.on("tls-passthrough-opened", (event) => this.onTlsPassthroughOpened(event));
327151
327713
  await (preferredPort > 0 ? server3.start(preferredPort) : server3.start());
@@ -327442,6 +328004,125 @@ async function iosSimRemoveCa(deviceId, onLog) {
327442
328004
  function errMsg(err) {
327443
328005
  return err instanceof Error ? err.message : String(err);
327444
328006
  }
328007
+ function parsePrimaryNetworkService(stdout) {
328008
+ for (const line of stdout.split("\n")) {
328009
+ const m = line.match(/^\(\d+\)\s+(.+)$/);
328010
+ if (m) return m[1].trim();
328011
+ }
328012
+ return null;
328013
+ }
328014
+ function parseWebProxyState(stdout) {
328015
+ const field = (label) => {
328016
+ const m = stdout.match(new RegExp(`^${label}:[ \\t]*(.*)$`, "im"));
328017
+ return m ? m[1].trim() : "";
328018
+ };
328019
+ return {
328020
+ enabled: field("Enabled").toLowerCase() === "yes",
328021
+ server: field("Server"),
328022
+ port: field("Port")
328023
+ };
328024
+ }
328025
+ function buildHostProxySetCommands(service, host, port) {
328026
+ const portStr = String(port);
328027
+ return [
328028
+ ["-setwebproxy", service, host, portStr],
328029
+ ["-setsecurewebproxy", service, host, portStr]
328030
+ ];
328031
+ }
328032
+ function buildProxyRestoreArgs(setCmd, stateCmd, service, prior) {
328033
+ if (prior.server) {
328034
+ return [
328035
+ [setCmd, service, prior.server, prior.port || "0"],
328036
+ [stateCmd, service, prior.enabled ? "on" : "off"]
328037
+ ];
328038
+ }
328039
+ return [[stateCmd, service, "off"]];
328040
+ }
328041
+ function buildHostProxyRestoreCommands(service, prior) {
328042
+ return [
328043
+ ...buildProxyRestoreArgs("-setwebproxy", "-setwebproxystate", service, prior.web),
328044
+ ...buildProxyRestoreArgs("-setsecurewebproxy", "-setsecurewebproxystate", service, prior.secure)
328045
+ ];
328046
+ }
328047
+ function hostProxyPriorFromMarker(marker) {
328048
+ return {
328049
+ web: {
328050
+ enabled: Boolean(marker.hostWebProxyWasEnabled),
328051
+ server: marker.hostWebProxyPriorServer ?? "",
328052
+ port: marker.hostWebProxyPriorPort ?? ""
328053
+ },
328054
+ secure: {
328055
+ enabled: Boolean(marker.hostSecureProxyWasEnabled),
328056
+ server: marker.hostSecureProxyPriorServer ?? "",
328057
+ port: marker.hostSecureProxyPriorPort ?? ""
328058
+ }
328059
+ };
328060
+ }
328061
+ function hostProxyControlSupported(platform3, isPhysical, platformName = process.platform, optedIn = envFlag(IOS_SIM_HOST_PROXY_ENV)) {
328062
+ if (platform3 !== "IOS") return { supported: false };
328063
+ if (isPhysical) {
328064
+ return { supported: false, reason: "physical iOS device requires a manual Wi-Fi HTTP proxy; host proxy not applied" };
328065
+ }
328066
+ if (platformName !== "darwin") {
328067
+ return { supported: false, reason: `iOS simulator host proxy is only controllable on macOS (host is ${platformName})` };
328068
+ }
328069
+ if (!optedIn) {
328070
+ return {
328071
+ supported: false,
328072
+ 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.`
328073
+ };
328074
+ }
328075
+ return { supported: true };
328076
+ }
328077
+ async function iosSimConfigureHostProxy(host, port, onLog) {
328078
+ let service;
328079
+ let prior;
328080
+ try {
328081
+ const { stdout: order } = await execFileAsync("networksetup", ["-listnetworkserviceorder"], {
328082
+ timeout: DEVICE_CMD_TIMEOUT_MS
328083
+ });
328084
+ const svc = parsePrimaryNetworkService(order);
328085
+ if (!svc) {
328086
+ return { configured: false, reason: "iOS simulator host proxy could not be configured: no active macOS network service found" };
328087
+ }
328088
+ service = svc;
328089
+ const [{ stdout: webOut }, { stdout: secureOut }] = await Promise.all([
328090
+ execFileAsync("networksetup", ["-getwebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS }),
328091
+ execFileAsync("networksetup", ["-getsecurewebproxy", service], { timeout: DEVICE_CMD_TIMEOUT_MS })
328092
+ ]);
328093
+ prior = { web: parseWebProxyState(webOut), secure: parseWebProxyState(secureOut) };
328094
+ for (const args of buildHostProxySetCommands(service, host, port)) {
328095
+ await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328096
+ }
328097
+ onLog?.(`[mobile-net] macOS host proxy set on "${service}" \u2192 ${host}:${port} (iOS simulator shares the host network).`);
328098
+ return { configured: true, service, prior };
328099
+ } catch (err) {
328100
+ if (service && prior) {
328101
+ onLog?.(`[mobile-net] host proxy set failed mid-way \u2014 rolling back "${service}" to prior state: ${errMsg(err)}`);
328102
+ for (const args of buildHostProxyRestoreCommands(service, prior)) {
328103
+ try {
328104
+ await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328105
+ } catch {
328106
+ }
328107
+ }
328108
+ }
328109
+ return { configured: false, reason: `iOS simulator host proxy could not be configured: ${errMsg(err)}` };
328110
+ }
328111
+ }
328112
+ async function iosSimRestoreHostProxy(service, prior, onLog) {
328113
+ const restore = prior ?? {
328114
+ web: { enabled: false, server: "", port: "" },
328115
+ secure: { enabled: false, server: "", port: "" }
328116
+ };
328117
+ for (const args of buildHostProxyRestoreCommands(service, restore)) {
328118
+ try {
328119
+ await execFileAsync("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS });
328120
+ } catch (err) {
328121
+ onLog?.(`[mobile-net] networksetup restore (${args[0]}) failed: ${errMsg(err)}`);
328122
+ }
328123
+ }
328124
+ onLog?.(`[mobile-net] macOS host proxy restored on "${service}".`);
328125
+ }
327445
328126
  var activeCleanups = /* @__PURE__ */ new Map();
327446
328127
  var exitHandlersInstalled = false;
327447
328128
  function markerPath(sessionKey) {
@@ -327476,6 +328157,14 @@ function restoreDeviceSync(marker, onLog) {
327476
328157
  run2(["-s", marker.deviceId, "shell", "rm", "-f", "/sdcard/validateqa-mobile-ca.pem"]);
327477
328158
  }
327478
328159
  }
328160
+ if (marker.platform === "IOS" && marker.hostProxySet && marker.hostProxyService && process.platform === "darwin") {
328161
+ for (const args of buildHostProxyRestoreCommands(marker.hostProxyService, hostProxyPriorFromMarker(marker))) {
328162
+ try {
328163
+ execFileSync3("networksetup", args, { timeout: DEVICE_CMD_TIMEOUT_MS, stdio: "ignore" });
328164
+ } catch {
328165
+ }
328166
+ }
328167
+ }
327479
328168
  onLog?.(`[mobile-net] restored device ${marker.deviceId} from pending-cleanup marker.`);
327480
328169
  }
327481
328170
  async function reconcilePendingCleanups(onLog) {
@@ -327487,6 +328176,8 @@ async function reconcilePendingCleanups(onLog) {
327487
328176
  }
327488
328177
  for (const file of files) {
327489
328178
  if (!file.endsWith(".json")) continue;
328179
+ const sessionKey = file.slice(0, -".json".length);
328180
+ if (activeCleanups.has(sessionKey)) continue;
327490
328181
  const full = join2(PENDING_CLEANUP_DIR, file);
327491
328182
  try {
327492
328183
  const raw = await readFile(full, "utf-8");
@@ -327516,8 +328207,10 @@ function ensureExitHandlers() {
327516
328207
  for (const signal of ["SIGTERM", "SIGINT"]) {
327517
328208
  process.on(signal, () => {
327518
328209
  drain();
327519
- process.removeAllListeners(signal);
327520
- process.kill(process.pid, signal);
328210
+ if (process.listenerCount(signal) <= 1) {
328211
+ process.removeAllListeners(signal);
328212
+ process.kill(process.pid, signal);
328213
+ }
327521
328214
  });
327522
328215
  }
327523
328216
  }
@@ -327567,6 +328260,9 @@ async function startMobileNetworkCapture(options) {
327567
328260
  let wiringReason = "HTTPS interception pending: capture begins once the device routes a TLS handshake through the proxy.";
327568
328261
  let proxySet = false;
327569
328262
  let caInstalledOnDevice = false;
328263
+ let hostProxySet = false;
328264
+ let hostProxyService;
328265
+ let hostProxyPrior;
327570
328266
  try {
327571
328267
  if (platform3 === "ANDROID") {
327572
328268
  proxySet = await androidSetProxy(deviceId, hostPort, onLog);
@@ -327581,25 +328277,50 @@ async function startMobileNetworkCapture(options) {
327581
328277
  }
327582
328278
  } else {
327583
328279
  if (!isPhysical && caCertPath) {
327584
- const { trusted, note } = await iosSimTrustCa(deviceId, caCertPath);
328280
+ const { trusted } = await iosSimTrustCa(deviceId, caCertPath);
327585
328281
  caInstalledOnDevice = trusted;
327586
- wiringReason = note;
328282
+ }
328283
+ const support = hostProxyControlSupported(platform3, isPhysical);
328284
+ if (support.supported) {
328285
+ const hp = await iosSimConfigureHostProxy(proxyHost, proxyPort, onLog);
328286
+ if (hp.configured) {
328287
+ hostProxySet = true;
328288
+ hostProxyService = hp.service;
328289
+ hostProxyPrior = hp.prior;
328290
+ 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).` : ".");
328291
+ } else {
328292
+ wiringReason = hp.reason ?? "iOS simulator host proxy could not be configured";
328293
+ onLog?.(`[mobile-net] ${wiringReason}`);
328294
+ }
327587
328295
  } else if (isPhysical) {
327588
328296
  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}).` : ".");
328297
+ } else {
328298
+ wiringReason = support.reason ?? "iOS simulator host proxy is unsupported on this host";
328299
+ onLog?.(`[mobile-net] ${wiringReason}`);
327589
328300
  }
327590
328301
  }
327591
328302
  } catch (err) {
327592
328303
  wiringReason = `device wiring degraded: ${errMsg(err)}`;
327593
328304
  onLog?.(`[mobile-net] ${wiringReason}`);
327594
328305
  }
327595
- if (proxySet || caInstalledOnDevice) {
328306
+ if (proxySet || caInstalledOnDevice || hostProxySet) {
327596
328307
  const marker = {
327597
328308
  platform: platform3,
327598
328309
  deviceId,
327599
328310
  isPhysical,
327600
328311
  proxySet,
327601
328312
  originalProxy,
327602
- caInstalledOnDevice
328313
+ caInstalledOnDevice,
328314
+ ...hostProxySet ? {
328315
+ hostProxySet: true,
328316
+ hostProxyService,
328317
+ hostWebProxyWasEnabled: hostProxyPrior?.web.enabled,
328318
+ hostWebProxyPriorServer: hostProxyPrior?.web.server,
328319
+ hostWebProxyPriorPort: hostProxyPrior?.web.port,
328320
+ hostSecureProxyWasEnabled: hostProxyPrior?.secure.enabled,
328321
+ hostSecureProxyPriorServer: hostProxyPrior?.secure.server,
328322
+ hostSecureProxyPriorPort: hostProxyPrior?.secure.port
328323
+ } : {}
327603
328324
  };
327604
328325
  activeCleanups.set(sessionKey, marker);
327605
328326
  await writePendingCleanup(sessionKey, marker);
@@ -327619,6 +328340,9 @@ async function startMobileNetworkCapture(options) {
327619
328340
  if (proxySet && platform3 === "ANDROID") {
327620
328341
  await androidClearProxy(deviceId, originalProxy, onLog);
327621
328342
  }
328343
+ if (hostProxySet && hostProxyService && platform3 === "IOS" && !isPhysical) {
328344
+ await iosSimRestoreHostProxy(hostProxyService, hostProxyPrior, onLog);
328345
+ }
327622
328346
  if (caInstalledOnDevice && capturedCaCertPath) {
327623
328347
  if (platform3 === "ANDROID") {
327624
328348
  await androidRemoveCa(deviceId, onLog);
@@ -327654,6 +328378,9 @@ async function startMobileNetworkCapture(options) {
327654
328378
 
327655
328379
  // src/services/appium-executor.ts
327656
328380
  var DEFAULT_STEP_TIMEOUT_MS = 1e4;
328381
+ var WD_SESSION_CREATE_TIMEOUT_MS = 12e4;
328382
+ var WD_ACTION_TIMEOUT_MS = 3e4;
328383
+ var MAX_SESSION_CREATE_ATTEMPTS = 3;
327657
328384
  var execFileAsync2 = promisify2(execFile2);
327658
328385
  var MOBILE_TEXT_ATTRIBUTE_NAMES = ["text", "content-desc", "contentDescription", "name", "label", "value"];
327659
328386
  var WebDriverTransportError = class extends Error {
@@ -327667,13 +328394,21 @@ function isTransportError(err) {
327667
328394
  return err instanceof WebDriverTransportError || typeof err === "object" && err !== null && err.isTransport === true;
327668
328395
  }
327669
328396
  async function wdRequest(path, options) {
328397
+ const isSessionCreate = (options?.method ?? "GET").toUpperCase() === "POST" && path === "/session";
328398
+ const timeoutMs = isSessionCreate ? WD_SESSION_CREATE_TIMEOUT_MS : WD_ACTION_TIMEOUT_MS;
327670
328399
  let res;
327671
328400
  try {
327672
328401
  res = await fetch(`http://localhost:${getAppiumState().port}${path}`, {
327673
328402
  ...options,
328403
+ signal: AbortSignal.timeout(timeoutMs),
327674
328404
  headers: { "Content-Type": "application/json", ...options?.headers }
327675
328405
  });
327676
328406
  } catch (err) {
328407
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
328408
+ throw new WebDriverTransportError(
328409
+ `WebDriver request to ${path} timed out after ${timeoutMs}ms (device/Appium unresponsive)`
328410
+ );
328411
+ }
327677
328412
  throw new WebDriverTransportError(`WebDriver connection error: ${err instanceof Error ? err.message : String(err)}`);
327678
328413
  }
327679
328414
  if (!res.ok) {
@@ -327758,6 +328493,31 @@ async function createSession(target) {
327758
328493
  if (!sessionId) throw new Error("Appium session creation returned no session ID");
327759
328494
  return sessionId;
327760
328495
  }
328496
+ var TERMINAL_SESSION_START = /not installed|no such (app|file)|could not confirm|bundle ?id .*(not|missing)|xcodeorgid|signing|provisioning|not authorized|unauthorized/i;
328497
+ 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;
328498
+ function isRetryableSessionStartError(err) {
328499
+ const message = err instanceof Error ? err.message : String(err);
328500
+ if (TERMINAL_SESSION_START.test(message)) return false;
328501
+ if (isTransportError(err)) return true;
328502
+ return RETRYABLE_SESSION_START.test(message);
328503
+ }
328504
+ async function createSessionWithRetry(target, onRetry) {
328505
+ let lastErr;
328506
+ for (let attempt = 1; attempt <= MAX_SESSION_CREATE_ATTEMPTS; attempt++) {
328507
+ try {
328508
+ return await createSession(target);
328509
+ } catch (err) {
328510
+ lastErr = err;
328511
+ if (attempt >= MAX_SESSION_CREATE_ATTEMPTS || !isRetryableSessionStartError(err)) throw err;
328512
+ const delayMs = Math.pow(2, attempt) * 1e3;
328513
+ onRetry?.(
328514
+ `Appium session start failed (attempt ${attempt}/${MAX_SESSION_CREATE_ATTEMPTS}, retrying in ${delayMs}ms): ${err instanceof Error ? err.message : String(err)}`
328515
+ );
328516
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
328517
+ }
328518
+ }
328519
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
328520
+ }
327761
328521
  async function openDeepLink(sessionId, platform3, deeplink, appId) {
327762
328522
  const args = platform3 === "IOS" ? [{ url: deeplink, bundleId: appId }] : [{ url: deeplink, package: appId }];
327763
328523
  await wdRequest(`/session/${sessionId}/execute/sync`, {
@@ -327859,6 +328619,61 @@ async function takeScreenshot(sessionId) {
327859
328619
  return null;
327860
328620
  }
327861
328621
  }
328622
+ function mobileRecordingEnabled() {
328623
+ const value = process.env.VALIDATEQA_MOBILE_RUN_VIDEO;
328624
+ return value !== "0" && value !== "false";
328625
+ }
328626
+ function readPositiveIntEnv(name, fallback, min, max) {
328627
+ const raw = process.env[name];
328628
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
328629
+ if (!Number.isFinite(parsed)) return fallback;
328630
+ return Math.max(min, Math.min(max, parsed));
328631
+ }
328632
+ async function startScreenRecording(sessionId, platform3, log2) {
328633
+ if (!mobileRecordingEnabled()) {
328634
+ log2("[mobile-video] recording disabled by VALIDATEQA_MOBILE_RUN_VIDEO=0");
328635
+ return false;
328636
+ }
328637
+ const timeLimit = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_SECONDS", 180, 5, 180);
328638
+ const options = {
328639
+ timeLimit: String(timeLimit),
328640
+ forceRestart: true
328641
+ };
328642
+ if (platform3 === "ANDROID") {
328643
+ options.bitRate = readPositiveIntEnv("VALIDATEQA_MOBILE_RECORDING_BITRATE", 75e4, 1e5, 4e6);
328644
+ } else {
328645
+ options.videoQuality = process.env.VALIDATEQA_MOBILE_RECORDING_IOS_QUALITY || "low";
328646
+ }
328647
+ try {
328648
+ await wdRequest(`/session/${sessionId}/appium/start_recording_screen`, {
328649
+ method: "POST",
328650
+ body: JSON.stringify({ options })
328651
+ });
328652
+ log2(`[mobile-video] screen recording started (${timeLimit}s max)`);
328653
+ return true;
328654
+ } catch (err) {
328655
+ log2(`[mobile-video] screen recording unavailable: ${err instanceof Error ? err.message : String(err)}`);
328656
+ return false;
328657
+ }
328658
+ }
328659
+ async function stopScreenRecording(sessionId, log2) {
328660
+ try {
328661
+ const result = await wdRequest(`/session/${sessionId}/appium/stop_recording_screen`, {
328662
+ method: "POST",
328663
+ body: JSON.stringify({ options: {} })
328664
+ });
328665
+ const value = typeof result?.value === "string" ? result.value.trim() : "";
328666
+ if (!value) {
328667
+ log2("[mobile-video] screen recording stopped but Appium returned no video");
328668
+ return void 0;
328669
+ }
328670
+ log2(`[mobile-video] screen recording captured (${Math.round(value.length / 1024)}KB base64)`);
328671
+ return value;
328672
+ } catch (err) {
328673
+ log2(`[mobile-video] screen recording stop failed: ${err instanceof Error ? err.message : String(err)}`);
328674
+ return void 0;
328675
+ }
328676
+ }
327862
328677
  function boundsFromStep(step) {
327863
328678
  const bounds = step.metadata?.bounds;
327864
328679
  if (bounds && typeof bounds === "object" && !Array.isArray(bounds) && typeof bounds.x === "number" && typeof bounds.y === "number" && typeof bounds.width === "number" && typeof bounds.height === "number") {
@@ -327978,7 +328793,8 @@ async function sendKeysToFocusedInput(sessionId, value) {
327978
328793
  });
327979
328794
  }
327980
328795
  function encodeAndroidInputText(value) {
327981
- return value.replace(/%/g, "%25").replace(/\s/g, "%s");
328796
+ const inputEncoded = value.replace(/%/g, "%25").replace(/\s/g, "%s");
328797
+ return `'${inputEncoded.replace(/'/g, `'\\''`)}'`;
327982
328798
  }
327983
328799
  async function sendAndroidTextToFocusedInput(deviceId, value) {
327984
328800
  if (!deviceId) {
@@ -328236,6 +329052,9 @@ async function executeMobileTest(options) {
328236
329052
  let sessionId = null;
328237
329053
  let capture = null;
328238
329054
  let captureStopped = false;
329055
+ let recordingStarted = false;
329056
+ let recordingStopped = false;
329057
+ let video;
328239
329058
  const stopCapture = async () => {
328240
329059
  if (!capture || captureStopped) return apiCalls;
328241
329060
  captureStopped = true;
@@ -328250,6 +329069,15 @@ async function executeMobileTest(options) {
328250
329069
  }
328251
329070
  return apiCalls;
328252
329071
  };
329072
+ const stopRecording = async () => {
329073
+ if (!sessionId || !recordingStarted || recordingStopped) return video;
329074
+ recordingStopped = true;
329075
+ const captured = await stopScreenRecording(sessionId, log2);
329076
+ if (captured) {
329077
+ video = captured;
329078
+ }
329079
+ return video;
329080
+ };
328253
329081
  try {
328254
329082
  log2("Checking Appium server health...");
328255
329083
  const health = await checkAppiumHealth();
@@ -328303,13 +329131,13 @@ async function executeMobileTest(options) {
328303
329131
  }
328304
329132
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
328305
329133
  try {
328306
- sessionId = await createSession({
329134
+ sessionId = await createSessionWithRetry({
328307
329135
  appId: options.target.appId,
328308
329136
  platform: options.target.platform,
328309
329137
  deviceId: selectedDevice.id,
328310
329138
  platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
328311
329139
  isPhysical: selectedDevice.isPhysical
328312
- });
329140
+ }, (msg) => log2(msg));
328313
329141
  } catch (err) {
328314
329142
  const base2 = err instanceof Error ? err.message : String(err);
328315
329143
  if (selectedDevice.isPhysical) {
@@ -328324,6 +329152,7 @@ async function executeMobileTest(options) {
328324
329152
  throw err;
328325
329153
  }
328326
329154
  log2(`Session created: ${sessionId}`);
329155
+ recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
328327
329156
  if (options.target.launchMode === "DEEP_LINK" && options.target.deeplink) {
328328
329157
  try {
328329
329158
  log2(`Opening deep link: ${options.target.deeplink}`);
@@ -328384,7 +329213,9 @@ async function executeMobileTest(options) {
328384
329213
  break;
328385
329214
  }
328386
329215
  }
328387
- await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
329216
+ if (step.action !== "home" && step.action !== "back") {
329217
+ await restoreTargetAppIfExternal(sessionId, options.target, log2, `step ${step.order} (${step.action})`, true);
329218
+ }
328388
329219
  } catch (err) {
328389
329220
  status = "failed";
328390
329221
  stepError = err instanceof Error ? err.message : String(err);
@@ -328413,10 +329244,12 @@ async function executeMobileTest(options) {
328413
329244
  const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
328414
329245
  const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
328415
329246
  await stopCapture();
329247
+ await stopRecording();
328416
329248
  return {
328417
329249
  passed: allPassed,
328418
329250
  stepResults,
328419
329251
  screenshots,
329252
+ ...video ? { video, videoMimeType: "video/mp4" } : {},
328420
329253
  apiCalls,
328421
329254
  logs: logLines.join("\n"),
328422
329255
  error: firstError,
@@ -328427,10 +329260,12 @@ async function executeMobileTest(options) {
328427
329260
  const errorMsg = err instanceof Error ? err.message : String(err);
328428
329261
  log2(`Fatal error: ${errorMsg}`);
328429
329262
  await stopCapture();
329263
+ await stopRecording();
328430
329264
  return {
328431
329265
  passed: false,
328432
329266
  stepResults,
328433
329267
  screenshots,
329268
+ ...video ? { video, videoMimeType: "video/mp4" } : {},
328434
329269
  apiCalls,
328435
329270
  logs: logLines.join("\n"),
328436
329271
  error: errorMsg,
@@ -328442,6 +329277,7 @@ async function executeMobileTest(options) {
328442
329277
  };
328443
329278
  } finally {
328444
329279
  await stopCapture();
329280
+ await stopRecording();
328445
329281
  if (sessionId) {
328446
329282
  log2("Tearing down Appium session...");
328447
329283
  await deleteSession(sessionId);
@@ -328482,7 +329318,7 @@ async function createMobileDiscoverySession(target, options) {
328482
329318
  isPhysical: selectedDevice.isPhysical === true,
328483
329319
  platformVersion: selectedDevice.platformVersion
328484
329320
  });
328485
- const sessionId = await createSession({
329321
+ const sessionId = await createSessionWithRetry({
328486
329322
  appId: target.appId,
328487
329323
  platform: target.platform,
328488
329324
  deviceId: selectedDevice.id,
@@ -329293,7 +330129,7 @@ function stopSessionReaper() {
329293
330129
  }
329294
330130
  }
329295
330131
  async function reapIdleSession() {
329296
- if (!state.appiumSessionId || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
330132
+ if (!state.appiumSessionId || mirrorSessionOwner !== "recording" || state.mirrorClients.size > 0 || mirrorClientsEmptyAt === null || Date.now() - mirrorClientsEmptyAt < SESSION_IDLE_GRACE_MS) {
329297
330133
  return;
329298
330134
  }
329299
330135
  const sessionId = state.appiumSessionId;
@@ -330109,6 +330945,79 @@ function printDoctorReport(report) {
330109
330945
  console.log("");
330110
330946
  }
330111
330947
 
330948
+ // src/result-payload-budget.ts
330949
+ var SERVER_RESULT_BODY_LIMIT_BYTES = 50 * 1024 * 1024;
330950
+ var RESULT_BODY_BUDGET_BYTES = 48 * 1024 * 1024;
330951
+ var SERVER_VIDEO_STORE_CAP_BYTES = 24 * 1024 * 1024;
330952
+ function serializedBytes(value) {
330953
+ return Buffer.byteLength(JSON.stringify(value) ?? "", "utf8");
330954
+ }
330955
+ function budgetResultPayload(result, maxBytes = RESULT_BODY_BUDGET_BYTES, videoStoreCapBytes = SERVER_VIDEO_STORE_CAP_BYTES) {
330956
+ const oversizedVideo = typeof result.video === "string" && result.video.length > videoStoreCapBytes;
330957
+ let bytes = serializedBytes(result);
330958
+ if (bytes <= maxBytes && !oversizedVideo) {
330959
+ return { payload: result, shed: [], bytes };
330960
+ }
330961
+ const shed = [];
330962
+ let payload = result;
330963
+ const drop = (fields, label) => {
330964
+ const next = { ...payload };
330965
+ for (const field of fields) next[field] = void 0;
330966
+ payload = next;
330967
+ shed.push(label);
330968
+ bytes = serializedBytes(payload);
330969
+ };
330970
+ if ((bytes > maxBytes || oversizedVideo) && payload.video != null) {
330971
+ drop(["video", "videoMimeType"], "video");
330972
+ }
330973
+ if (bytes > maxBytes && Array.isArray(payload.screenshots) && payload.screenshots.length > 0) {
330974
+ const shots = [...payload.screenshots];
330975
+ const bySizeDesc = shots.map((shot, i) => ({ i, len: typeof shot === "string" ? shot.length : 0 })).sort((a, b) => b.len - a.len);
330976
+ const overshoot = bytes - maxBytes;
330977
+ let shaved = 0;
330978
+ let blanked = 0;
330979
+ for (const { i, len } of bySizeDesc) {
330980
+ if (shaved >= overshoot || len === 0) break;
330981
+ if (shots[i] === "") continue;
330982
+ shots[i] = "";
330983
+ shaved += len;
330984
+ blanked++;
330985
+ }
330986
+ if (blanked > 0) {
330987
+ payload = { ...payload, screenshots: shots };
330988
+ bytes = serializedBytes(payload);
330989
+ shed.push(blanked === shots.length ? "screenshots" : `${blanked}/${shots.length} screenshots`);
330990
+ }
330991
+ }
330992
+ if (bytes > maxBytes && payload.harApiCalls != null) drop(["harApiCalls"], "harApiCalls");
330993
+ if (bytes > maxBytes && payload.apiCalls != null) drop(["apiCalls"], "apiCalls");
330994
+ if (shed.length > 0) {
330995
+ const note = `
330996
+
330997
+ \u26A0 [runner] Result media trimmed before upload \u2014 payload exceeded the ${Math.round(maxBytes / (1024 * 1024))}MB server limit; dropped: ${shed.join(", ")}.`;
330998
+ payload = {
330999
+ ...payload,
331000
+ logs: (typeof payload.logs === "string" ? payload.logs : "") + note
331001
+ };
331002
+ bytes = serializedBytes(payload);
331003
+ }
331004
+ if (bytes > maxBytes && typeof payload.logs === "string" && payload.logs.length > 4096) {
331005
+ const over = bytes - maxBytes;
331006
+ const targetLen = Math.max(2048, payload.logs.length - over - 1024);
331007
+ if (targetLen < payload.logs.length) {
331008
+ const half = Math.floor(targetLen / 2);
331009
+ const head = payload.logs.slice(0, half);
331010
+ const tail = payload.logs.slice(payload.logs.length - half);
331011
+ payload = { ...payload, logs: `${head}
331012
+ \u2026 [logs truncated to fit upload] \u2026
331013
+ ${tail}` };
331014
+ if (!shed.includes("logs (truncated)")) shed.push("logs (truncated)");
331015
+ bytes = serializedBytes(payload);
331016
+ }
331017
+ }
331018
+ return { payload, shed, bytes };
331019
+ }
331020
+
330112
331021
  // src/runner.ts
330113
331022
  init_dist();
330114
331023
  var import_runner_core12 = __toESM(require_dist2());
@@ -330277,15 +331186,33 @@ async function claimRun(serverUrl, headers, runId) {
330277
331186
  async function postResult(serverUrl, headers, runId, result) {
330278
331187
  const MAX_ATTEMPTS = 3;
330279
331188
  let lastError;
331189
+ const budgeted = budgetResultPayload(result);
331190
+ if (budgeted.shed.length > 0) {
331191
+ console.warn(
331192
+ `[runner] Result payload for run ${runId} exceeded the upload budget (${Math.round(budgeted.bytes / (1024 * 1024))}MB after trim); dropped: ${budgeted.shed.join(", ")}.`
331193
+ );
331194
+ }
331195
+ let payload = budgeted.payload;
330280
331196
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
330281
331197
  try {
330282
331198
  const res = await fetch(`${serverUrl}/api/runner/runs/${runId}/result`, {
330283
331199
  method: "POST",
330284
331200
  headers,
330285
- body: JSON.stringify(result),
331201
+ body: JSON.stringify(payload),
330286
331202
  signal: AbortSignal.timeout(6e4)
330287
331203
  });
330288
331204
  if (res.ok) return;
331205
+ if (res.status === 413) {
331206
+ const minimal = budgetResultPayload(payload, 0);
331207
+ if (minimal.shed.length > 0) {
331208
+ console.warn(
331209
+ `[runner] Server rejected result for run ${runId} as too large (413); retrying with a verdict-only payload (dropped: ${minimal.shed.join(", ")}).`
331210
+ );
331211
+ payload = minimal.payload;
331212
+ lastError = new Error("HTTP 413 (payload trimmed, retrying)");
331213
+ continue;
331214
+ }
331215
+ }
330289
331216
  lastError = new Error(`HTTP ${res.status}`);
330290
331217
  } catch (err) {
330291
331218
  lastError = err instanceof Error ? err : new Error(String(err));
@@ -331810,14 +332737,20 @@ ${finalVerifyResult.logs ?? ""}`;
331810
332737
  });
331811
332738
  requestLiveBrowserHeartbeat();
331812
332739
  },
332740
+ // D-D6 — upload ONE baseline PNG per newly-observed screen through the
332741
+ // same streaming /page-screenshot relay the web path uses (mobile has no
332742
+ // route, so the screen-id is the key). The returned storage key rides the
332743
+ // explore checkpoint so the server links it to SitePage.screenshotKey.
332744
+ onScreenshotUpload: (screenId, base64) => onScreenshot(screenId, base64),
331813
332745
  onExploreComplete,
331814
332746
  onTestGenerated,
331815
332747
  onPlanReady
331816
332748
  });
331817
332749
  } finally {
331818
- if (capture) {
332750
+ const activeCapture = capture;
332751
+ if (activeCapture) {
331819
332752
  try {
331820
- const apiCalls = await capture.stop();
332753
+ const apiCalls = await activeCapture.stop();
331821
332754
  if (discoveryResult) {
331822
332755
  discoveryResult.apiCalls = apiCalls.length ? attachMobileDiscoveryNetworkContext(apiCalls, discoveryResult, session?.appId ?? mobileTarget.appId) : void 0;
331823
332756
  }
@@ -332152,6 +333085,8 @@ ${result2.logs}`);
332152
333085
  status,
332153
333086
  stepResults: result2.stepResults,
332154
333087
  screenshots: result2.screenshots,
333088
+ video: result2.video,
333089
+ videoMimeType: result2.videoMimeType,
332155
333090
  apiCalls: result2.apiCalls,
332156
333091
  logs: result2.logs,
332157
333092
  error: reportedError,
@@ -332463,7 +333398,7 @@ async function startRunner(opts) {
332463
333398
  };
332464
333399
  const sendHeartbeat = async () => {
332465
333400
  try {
332466
- const devices = enableMobile ? discoverAllDevices() : [];
333401
+ const devices = enableMobile ? getCachedDeviceSnapshot() : [];
332467
333402
  const capabilities = enableMobile ? {
332468
333403
  web: true,
332469
333404
  ios: devices.some((d) => d.platform === "IOS" && d.isAvailable && d.state === "BOOTED"),