@validate.qa/runner 1.0.78 → 1.0.80

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 +649 -56
  2. package/dist/cli.mjs +647 -54
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -1893,6 +1893,57 @@ var init_constants = __esm({
1893
1893
  }
1894
1894
  });
1895
1895
 
1896
+ // ../shared/dist/live-frame.js
1897
+ function isValidLiveFrame(frame) {
1898
+ if (!frame || typeof frame !== "object")
1899
+ return false;
1900
+ const f = frame;
1901
+ if (typeof f.sessionId !== "string" || f.sessionId.length === 0 || f.sessionId.length > 200)
1902
+ return false;
1903
+ if (typeof f.projectId !== "string" || f.projectId.length === 0 || f.projectId.length > 200)
1904
+ return false;
1905
+ if (typeof f.capturedAt !== "number" || !Number.isFinite(f.capturedAt))
1906
+ return false;
1907
+ if (f.mimeType !== "image/jpeg" && f.mimeType !== "image/png")
1908
+ return false;
1909
+ if (typeof f.base64 !== "string" || f.base64.length === 0)
1910
+ return false;
1911
+ if (f.base64.length > LIVE_FRAME_PUSH_MAX_BASE64_BYTES)
1912
+ return false;
1913
+ return true;
1914
+ }
1915
+ var readEnvInt, LIVE_FRAME_PUSH_MAX_BASE64_BYTES, LIVE_THUMBNAIL_MAX_BASE64_BYTES, LIVE_FRAME_MAX_DIMENSION, LIVE_FRAME_JPEG_QUALITY, LIVE_FRAME_MIN_PUSH_INTERVAL_MS, LIVE_FRAME_UNFOCUSED_INTERVAL_MS, LIVE_FRAME_IDLE_PUSH_INTERVAL_MS, LIVE_FRAME_STALE_MS, LIVE_FRAME_MAX_SESSIONS_PER_PROJECT, MOBILE_MJPEG_SETTINGS, MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS, MOBILE_MJPEG_MAX_FRAME_BYTES;
1916
+ var init_live_frame = __esm({
1917
+ "../shared/dist/live-frame.js"() {
1918
+ "use strict";
1919
+ readEnvInt = (name, fallback) => {
1920
+ if (typeof process === "undefined")
1921
+ return fallback;
1922
+ const parsed = parseInt(process.env?.[name] ?? "", 10);
1923
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
1924
+ };
1925
+ LIVE_FRAME_PUSH_MAX_BASE64_BYTES = readEnvInt("LIVE_FRAME_PUSH_MAX_BASE64_BYTES", 45e5);
1926
+ LIVE_THUMBNAIL_MAX_BASE64_BYTES = readEnvInt("LIVE_THUMBNAIL_MAX_BASE64_BYTES", 4e5);
1927
+ LIVE_FRAME_MAX_DIMENSION = readEnvInt("LIVE_FRAME_MAX_DIMENSION", 900);
1928
+ LIVE_FRAME_JPEG_QUALITY = readEnvInt("LIVE_FRAME_JPEG_QUALITY", 55);
1929
+ LIVE_FRAME_MIN_PUSH_INTERVAL_MS = readEnvInt("LIVE_FRAME_MIN_PUSH_INTERVAL_MS", 125);
1930
+ LIVE_FRAME_UNFOCUSED_INTERVAL_MS = readEnvInt("LIVE_FRAME_UNFOCUSED_INTERVAL_MS", 2e3);
1931
+ LIVE_FRAME_IDLE_PUSH_INTERVAL_MS = readEnvInt("LIVE_FRAME_IDLE_PUSH_INTERVAL_MS", 5e3);
1932
+ LIVE_FRAME_STALE_MS = readEnvInt("LIVE_FRAME_STALE_MS", 3e4);
1933
+ LIVE_FRAME_MAX_SESSIONS_PER_PROJECT = readEnvInt("LIVE_FRAME_MAX_SESSIONS_PER_PROJECT", 32);
1934
+ MOBILE_MJPEG_SETTINGS = {
1935
+ /** Screenshots per second the broadcaster emits (both drivers: range 1-60). */
1936
+ framerate: readEnvInt("MOBILE_MJPEG_FRAMERATE", 10),
1937
+ /** Downscale percentage. UiAutomator2 defaults to 50, XCUITest to 100. */
1938
+ scalingFactor: readEnvInt("MOBILE_MJPEG_SCALING_FACTOR", 50),
1939
+ /** JPEG compression factor (both drivers: range 1-100). */
1940
+ quality: readEnvInt("MOBILE_MJPEG_QUALITY", 50)
1941
+ };
1942
+ MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS = readEnvInt("MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS", 6e3);
1943
+ MOBILE_MJPEG_MAX_FRAME_BYTES = readEnvInt("MOBILE_MJPEG_MAX_FRAME_BYTES", 8 * 1024 * 1024);
1944
+ }
1945
+ });
1946
+
1896
1947
  // ../shared/dist/project-key.js
1897
1948
  function isValidProjectKey(key) {
1898
1949
  return typeof key === "string" && PROJECT_KEY_PATTERN.test(key) && !RESERVED_PROJECT_KEYS.has(key);
@@ -6315,6 +6366,15 @@ __export(dist_exports, {
6315
6366
  GROUP_LABELS: () => GROUP_LABELS,
6316
6367
  LEGACY_TTS_MODEL_MAP: () => LEGACY_TTS_MODEL_MAP,
6317
6368
  LEGACY_TTS_VOICE_ID_MAP: () => LEGACY_TTS_VOICE_ID_MAP,
6369
+ LIVE_FRAME_IDLE_PUSH_INTERVAL_MS: () => LIVE_FRAME_IDLE_PUSH_INTERVAL_MS,
6370
+ LIVE_FRAME_JPEG_QUALITY: () => LIVE_FRAME_JPEG_QUALITY,
6371
+ LIVE_FRAME_MAX_DIMENSION: () => LIVE_FRAME_MAX_DIMENSION,
6372
+ LIVE_FRAME_MAX_SESSIONS_PER_PROJECT: () => LIVE_FRAME_MAX_SESSIONS_PER_PROJECT,
6373
+ LIVE_FRAME_MIN_PUSH_INTERVAL_MS: () => LIVE_FRAME_MIN_PUSH_INTERVAL_MS,
6374
+ LIVE_FRAME_PUSH_MAX_BASE64_BYTES: () => LIVE_FRAME_PUSH_MAX_BASE64_BYTES,
6375
+ LIVE_FRAME_STALE_MS: () => LIVE_FRAME_STALE_MS,
6376
+ LIVE_FRAME_UNFOCUSED_INTERVAL_MS: () => LIVE_FRAME_UNFOCUSED_INTERVAL_MS,
6377
+ LIVE_THUMBNAIL_MAX_BASE64_BYTES: () => LIVE_THUMBNAIL_MAX_BASE64_BYTES,
6318
6378
  MAX_CREDIT_TOPUP_CENTS: () => MAX_CREDIT_TOPUP_CENTS,
6319
6379
  MAX_DEEPLINK_LENGTH: () => MAX_DEEPLINK_LENGTH,
6320
6380
  MIN_CREDIT_TOPUP_CENTS: () => MIN_CREDIT_TOPUP_CENTS,
@@ -6326,6 +6386,9 @@ __export(dist_exports, {
6326
6386
  MOBILE_DEFAULT_SWIPE_DISTANCE: () => MOBILE_DEFAULT_SWIPE_DISTANCE,
6327
6387
  MOBILE_FOCUS_SETTLE_MS: () => MOBILE_FOCUS_SETTLE_MS,
6328
6388
  MOBILE_MAX_SWIPE_DISTANCE: () => MOBILE_MAX_SWIPE_DISTANCE,
6389
+ MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS: () => MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS,
6390
+ MOBILE_MJPEG_MAX_FRAME_BYTES: () => MOBILE_MJPEG_MAX_FRAME_BYTES,
6391
+ MOBILE_MJPEG_SETTINGS: () => MOBILE_MJPEG_SETTINGS,
6329
6392
  MOBILE_PLATFORMS: () => MOBILE_PLATFORMS,
6330
6393
  MOBILE_POST_ACTION_SETTLE_MS: () => MOBILE_POST_ACTION_SETTLE_MS,
6331
6394
  MOBILE_SCREENSHOT_INTERVAL_MS: () => MOBILE_SCREENSHOT_INTERVAL_MS,
@@ -6438,6 +6501,7 @@ __export(dist_exports, {
6438
6501
  isTestReadyForPlatform: () => isTestReadyForPlatform,
6439
6502
  isTrialExpired: () => isTrialExpired,
6440
6503
  isUIVariant: () => isUIVariant,
6504
+ isValidLiveFrame: () => isValidLiveFrame,
6441
6505
  isValidMobileAppId: () => isValidMobileAppId,
6442
6506
  isValidProjectKey: () => isValidProjectKey,
6443
6507
  isValidSttProvider: () => isValidSttProvider,
@@ -6500,6 +6564,7 @@ var init_dist = __esm({
6500
6564
  "use strict";
6501
6565
  init_types();
6502
6566
  init_constants();
6567
+ init_live_frame();
6503
6568
  init_project_key();
6504
6569
  init_mobile_test_generation();
6505
6570
  init_mobile_step_healer();
@@ -87962,6 +88027,7 @@ var require_playwright_native = __commonJS({
87962
88027
  var PACKAGE_DIR = (0, path_1.resolve)(__dirname, "..", "..");
87963
88028
  var RUNS_DIR = process.env.RUNS_DIR || (0, path_1.join)(PACKAGE_DIR, ".qa-runs");
87964
88029
  var PW_CLI = __require.resolve("@playwright/test/cli");
88030
+ var LIVE_BROWSER_STATE_POLL_INTERVAL_MS = 500;
87965
88031
  async function executeTestViaNativePlaywright2(options) {
87966
88032
  const { playwrightCode, baseUrl, testName, testVariables, authStateCapturePath, storageStatePath, extraHttpHeaders, authStandingMode, validateContext, liveBrowserHandle, onLiveBrowserSnapshot } = options;
87967
88033
  const logs2 = [];
@@ -88051,7 +88117,7 @@ test.beforeEach(async ({ page }, testInfo) => {
88051
88117
  await __qaLiveBrowserCapture();
88052
88118
  __qaLiveBrowserInterval = setInterval(() => {
88053
88119
  void __qaLiveBrowserCapture?.();
88054
- }, 500);
88120
+ }, ${LIVE_BROWSER_STATE_POLL_INTERVAL_MS});
88055
88121
  });
88056
88122
 
88057
88123
  test.afterEach(async () => {
@@ -88468,7 +88534,7 @@ ${passed ? "\u2705" : "\u274C"} Test ${passed ? "PASSED" : "FAILED"}${error2 ? `
88468
88534
  };
88469
88535
  const interval = setInterval(() => {
88470
88536
  void poll();
88471
- }, 1e3);
88537
+ }, LIVE_BROWSER_STATE_POLL_INTERVAL_MS);
88472
88538
  interval.unref?.();
88473
88539
  void poll();
88474
88540
  return async () => {
@@ -320992,6 +321058,7 @@ var require_primary_action_ledger = __commonJS({
320992
321058
  exports.isLedgerActive = isLedgerActive;
320993
321059
  exports.isGateEnforcing = isGateEnforcing;
320994
321060
  exports.isSteeringActive = isSteeringActive;
321061
+ exports.isPrimaryActionCoverageGapEnabled = isPrimaryActionCoverageGapEnabled;
320995
321062
  exports.leadingVerb = leadingVerb;
320996
321063
  exports.classifyPrimaryAction = classifyPrimaryAction;
320997
321064
  exports.containerScopeOf = containerScopeOf;
@@ -321006,6 +321073,8 @@ var require_primary_action_ledger = __commonJS({
321006
321073
  exports.classifyActionOutcome = classifyActionOutcome;
321007
321074
  exports.detectPrimaryActions = detectPrimaryActions;
321008
321075
  exports.summarizeLedger = summarizeLedger;
321076
+ exports.summarizeUnexercisedPrimaries = summarizeUnexercisedPrimaries;
321077
+ exports.buildUnexercisedPrimaryCoverageGaps = buildUnexercisedPrimaryCoverageGaps;
321009
321078
  var reveal_diff_js_1 = require_reveal_diff();
321010
321079
  var exploration_parser_js_1 = require_exploration_parser();
321011
321080
  function getPrimaryActionGateMode(env = process.env) {
@@ -321025,6 +321094,10 @@ var require_primary_action_ledger = __commonJS({
321025
321094
  function isSteeringActive(mode) {
321026
321095
  return mode === "advisory" || mode === "enforce";
321027
321096
  }
321097
+ function isPrimaryActionCoverageGapEnabled(env = process.env) {
321098
+ const raw = (env.DISCOVERY_PRIMARY_ACTION_COVERAGE_GAP ?? "").trim().toLowerCase();
321099
+ return raw === "1" || raw === "true" || raw === "on" || raw === "enabled";
321100
+ }
321028
321101
  exports.PRIMARY_ACTION_VERB_RE = /^(run|analyze|analyse|generate|regenerate|process|submit|save|create|send|upload|search|export|import|calculate|recalculate|compute|convert|start|scan|translate|transcribe|render|publish|sync|deploy|enrich|summar(?:ize|ise))\b/i;
321029
321102
  exports.AMBIGUOUS_ACTION_VERB_RE = /^(continue|next|apply|go|proceed|confirm|update|add|edit|ok|done|finish|complete|approve|enable|activate)\b/i;
321030
321103
  exports.DESTRUCTIVE_ACTION_VERB_RE = /^(delete|remove|archive|reset|disconnect|deactivate|destroy|purge|revoke|discard|drop|wipe|erase|unsubscribe|cancel|clear|trash)\b/i;
@@ -321288,6 +321361,47 @@ var require_primary_action_ledger = __commonJS({
321288
321361
  }
321289
321362
  return `actions=${c.total} primary{detected=${c.detected} attempted=${c.attempted} captured=${c.captured} blocked=${c.blocked} exempt=${c.exempt}} ambiguous=${c.ambiguous}`;
321290
321363
  }
321364
+ function summarizeUnexercisedPrimaries(ledger, routeInScope, cap = 12) {
321365
+ const out = [];
321366
+ for (const e of getBlockingActionDebt(ledger, routeInScope)) {
321367
+ if (e.status !== "DETECTED" && e.status !== "ATTEMPTED")
321368
+ continue;
321369
+ out.push({ label: e.label, route: e.route, containerScope: e.containerScope, status: e.status });
321370
+ if (out.length >= cap)
321371
+ break;
321372
+ }
321373
+ return out;
321374
+ }
321375
+ var REGEXP_META_RE = /[.*+?^${}()|[\]\\]/g;
321376
+ function labelCoveredByPlan(normLabel, plannedLower) {
321377
+ if (!normLabel.includes(" "))
321378
+ return false;
321379
+ const escaped = normLabel.replace(REGEXP_META_RE, "\\$&");
321380
+ return new RegExp(`(^|\\W)${escaped}($|\\W)`).test(plannedLower);
321381
+ }
321382
+ function buildUnexercisedPrimaryCoverageGaps(unexercised, plannedScenarioText, nowIso) {
321383
+ const gaps = [];
321384
+ const seen = /* @__PURE__ */ new Set();
321385
+ const planned = plannedScenarioText.toLowerCase();
321386
+ for (const a of unexercised) {
321387
+ const norm = normLabelKey(a.label);
321388
+ const key = `${norm}|${a.route}`;
321389
+ if (seen.has(key))
321390
+ continue;
321391
+ seen.add(key);
321392
+ if (labelCoveredByPlan(norm, planned))
321393
+ continue;
321394
+ const clickedNote = a.status === "ATTEMPTED" ? "clicked, but no result state was captured" : "never clicked";
321395
+ gaps.push({
321396
+ scenarioName: `Exercise "${a.label}"`,
321397
+ reason: `Primary action "${a.label}" was detected on ${a.route} but never exercised to an observed result (${clickedNote}).`,
321398
+ suggestion: `Exercise "${a.label}" to a captured result \u2014 open its result modal/panel and capture it BEFORE closing \u2014 or schedule a deep run scoped to this action, so a real test can be authored.`,
321399
+ unmetPrerequisites: [],
321400
+ detectedAt: nowIso
321401
+ });
321402
+ }
321403
+ return gaps;
321404
+ }
321291
321405
  }
321292
321406
  });
321293
321407
 
@@ -322776,6 +322890,9 @@ var require_exploration_prompt_builder = __commonJS({
322776
322890
  "These are the feature's core actions \u2014 what it EXISTS to do. You are on a PROVISIONED TEST ACCOUNT, so exercising them is the point of this exploration. For each: click it, wait for it to finish (an async job can take 10-60s \u2014 keep waiting and capturing, do not give up early), then capture_page the result (a new panel, row, score, status, or route change).",
322777
322891
  `Spending in-app COMPUTE credits is expected and budgeted \u2014 running an Analyze / Generate / Run / Process ONCE to observe its output is exactly what you should do; an "N of M used" usage or AI-credit meter is NOT a reason to skip. These were auto-screened, but YOU are the final check: if you can see a specific listed action actually charges REAL money at a checkout (Buy / Upgrade / Subscribe), makes an irreversible or destructive change (Delete / Wipe / Deploy to production), or sends a message to real people (Send email / Invite), SKIP just that one \u2014 note it as blocked with the reason and move on. Don't finish until every other action below has produced an observed outcome:`
322778
322892
  ];
322893
+ if ((0, primary_action_ledger_js_1.isPrimaryActionCoverageGapEnabled)()) {
322894
+ out.push("If the result opens in a modal, dialog, or panel, call capture_page WHILE IT IS STILL OPEN \u2014 do not close or dismiss it first, or the result state is never recorded and the action does not count as covered. An action reachable only from inside this feature's own result modal is in-scope: exercise it to a captured result before closing, even if it generates content that belongs to another builder.");
322895
+ }
322779
322896
  for (const e of debt.slice(0, 8)) {
322780
322897
  const note = e.status === "ATTEMPTED" ? " \u2014 clicked but no result captured yet" : "";
322781
322898
  out.push(` - "${e.label}" on ${e.route}${note}`);
@@ -326596,6 +326713,7 @@ ${inbox_tools_js_1.EMAIL_VERIFICATION_UNAVAILABLE_OVERRIDE_BLOCK}` : ""}`;
326596
326713
  const primaryActionMode = (0, primary_action_ledger_js_1.getPrimaryActionGateMode)();
326597
326714
  const primaryActionLedgerActive = (0, primary_action_ledger_js_1.isLedgerActive)(primaryActionMode) && options?.mode === "deep";
326598
326715
  const primaryActionEnforcing = primaryActionLedgerActive && (0, primary_action_ledger_js_1.isGateEnforcing)(primaryActionMode);
326716
+ const primaryActionCoverageGapEnabled = primaryActionLedgerActive && (0, primary_action_ledger_js_1.isPrimaryActionCoverageGapEnabled)();
326599
326717
  const prevRouteSignatures = /* @__PURE__ */ new Map();
326600
326718
  const prevInteractionCountsByRoute = /* @__PURE__ */ new Map();
326601
326719
  let previousCaptureRoute = null;
@@ -326606,6 +326724,16 @@ ${inbox_tools_js_1.EMAIL_VERIFICATION_UNAVAILABLE_OVERRIDE_BLOCK}` : ""}`;
326606
326724
  let finishBounce = (0, survey_link_frontier_js_1.initialFinishBounceState)();
326607
326725
  let lastLandedPageUrl = null;
326608
326726
  const state2 = (0, exploration_state_js_1.createExplorationState)();
326727
+ const buildUnexercisedPrimarySummary = () => {
326728
+ if (!primaryActionCoverageGapEnabled)
326729
+ return void 0;
326730
+ const fa = options?.focusAreas ?? [];
326731
+ if (fa.length === 0)
326732
+ return void 0;
326733
+ const otherRoutes = options?.featureContext?.otherFeatures?.flatMap((f) => f.pages);
326734
+ const summary2 = (0, primary_action_ledger_js_1.summarizeUnexercisedPrimaries)(state2.actionLedger, (r) => (0, exploration_parser_js_1.routeIsWithinFocusAreas)(r, fa, otherRoutes), 12);
326735
+ return summary2.length > 0 ? summary2 : void 0;
326736
+ };
326609
326737
  function ingestLinksFromText(rawText) {
326610
326738
  if (!linkFrontierEnabled || !rawText || !rawText.includes("- /url:"))
326611
326739
  return;
@@ -328836,6 +328964,7 @@ Exploration ended after ${iteration} iterations without calling finish_explorati
328836
328964
  summary: state2.summary || summary,
328837
328965
  collectedPages: state2.collectedPages,
328838
328966
  collectedTransitions: state2.collectedTransitions,
328967
+ unexercisedPrimaryActions: buildUnexercisedPrimarySummary(),
328839
328968
  iterationTimeline: iterationTimeline.length > 0 ? iterationTimeline : void 0
328840
328969
  };
328841
328970
  }
@@ -328930,6 +329059,7 @@ Fatal error: ${msg}`);
328930
329059
  summary: `Fatal error: ${msg}`,
328931
329060
  collectedPages: state2.collectedPages,
328932
329061
  collectedTransitions: state2.collectedTransitions,
329062
+ unexercisedPrimaryActions: buildUnexercisedPrimarySummary(),
328933
329063
  iterationTimeline: iterationTimeline.length > 0 ? iterationTimeline : void 0,
328934
329064
  truncated: true,
328935
329065
  truncatedReason,
@@ -337116,6 +337246,7 @@ var require_discover_agent = __commonJS({
337116
337246
  var discover_planner_js_1 = require_discover_planner();
337117
337247
  var discover_generator_js_1 = require_discover_generator();
337118
337248
  var discover_ai_planner_js_1 = require_discover_ai_planner();
337249
+ var primary_action_ledger_js_1 = require_primary_action_ledger();
337119
337250
  var discovery_lint_flags_js_1 = require_discovery_lint_flags();
337120
337251
  var verification_inbox_js_1 = require_verification_inbox();
337121
337252
  var inbox_tools_js_1 = require_inbox_tools();
@@ -338418,6 +338549,16 @@ Plan: ${planResult.scenarios.length} scenarios (budget: ${featureTestBudget})`);
338418
338549
  checkpointFallbackReason = planResult.fallbackReason;
338419
338550
  plannerWarning = planResult.warning;
338420
338551
  plannerCoverageGaps = planResult.coverageGaps;
338552
+ const unexercisedPrimaries = exploreResult.unexercisedPrimaryActions ?? [];
338553
+ if (unexercisedPrimaries.length > 0) {
338554
+ const plannedText = planResult.scenarios.map((s) => `${s.name} ${s.goal ?? ""} ${(s.targetPages ?? []).join(" ")}`).join(" ");
338555
+ const ledgerGaps = (0, primary_action_ledger_js_1.buildUnexercisedPrimaryCoverageGaps)(unexercisedPrimaries, plannedText, (/* @__PURE__ */ new Date()).toISOString());
338556
+ if (ledgerGaps.length > 0) {
338557
+ plannerCoverageGaps = [...plannerCoverageGaps ?? [], ...ledgerGaps];
338558
+ }
338559
+ const suppressed = unexercisedPrimaries.length - ledgerGaps.length;
338560
+ logs2.push(` [primary-action-ledger] ${unexercisedPrimaries.length} in-scope primary action(s) un-exercised; surfaced ${ledgerGaps.length} as coverage gap(s)${suppressed > 0 ? ` (${suppressed} already covered by the plan / deduped)` : ""}`);
338561
+ }
338421
338562
  }
338422
338563
  if (callbackOptions?.onPlanReady) {
338423
338564
  try {
@@ -352109,16 +352250,16 @@ var require_live_browser_registry = __commonJS({
352109
352250
  "use strict";
352110
352251
  Object.defineProperty(exports, "__esModule", { value: true });
352111
352252
  exports.liveBrowserRegistry = void 0;
352253
+ var shared_1 = (init_dist(), __toCommonJS(dist_exports));
352112
352254
  var THUMBNAIL_INTERVAL_MS = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_INTERVAL_MS ?? "4000", 10) || 4e3;
352113
352255
  var THUMBNAIL_QUALITY = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_QUALITY ?? "60", 10) || 60;
352114
- var BROWSER_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_MAX_BASE64_BYTES ?? "400000", 10) || 4e5;
352115
- var MOBILE_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_MOBILE_THUMBNAIL_MAX_BASE64_BYTES ?? "4500000", 10) || 45e5;
352116
352256
  var STALE_SESSION_MS = parseInt(process.env.LIVE_BROWSER_STALE_MS ?? `${10 * 60 * 1e3}`, 10) || 10 * 60 * 1e3;
352117
352257
  var MAX_SESSIONS = parseInt(process.env.LIVE_BROWSER_MAX_SESSIONS ?? "32", 10) || 32;
352118
352258
  var LiveBrowserRegistry = class {
352119
352259
  entries = /* @__PURE__ */ new Map();
352120
352260
  snapshotTimer = null;
352121
352261
  capturing = false;
352262
+ publisher = null;
352122
352263
  /** Register a new session. Returns a handle the caller must hold and
352123
352264
  * ultimately call .unregister() on. */
352124
352265
  register(meta) {
@@ -352132,7 +352273,10 @@ var require_live_browser_registry = __commonJS({
352132
352273
  lastUpdatedAt: now,
352133
352274
  page: null,
352134
352275
  isCapturing: false,
352135
- pendingCapture: false
352276
+ pendingCapture: false,
352277
+ screencast: null,
352278
+ screencastStarting: false,
352279
+ lastPublishedAt: 0
352136
352280
  };
352137
352281
  this.entries.set(meta.sessionId, entry);
352138
352282
  this.ensureSnapshotLoop();
@@ -352145,19 +352289,40 @@ var require_live_browser_registry = __commonJS({
352145
352289
  unregister: () => this.unregister(meta.sessionId)
352146
352290
  };
352147
352291
  }
352292
+ /**
352293
+ * Install the real-time frame sink. Idempotent; the runner calls this once at
352294
+ * startup, before any session registers.
352295
+ */
352296
+ setFramePublisher(publisher) {
352297
+ this.publisher = publisher;
352298
+ }
352148
352299
  unregister(sessionId) {
352149
352300
  const entry = this.entries.get(sessionId);
352150
352301
  if (!entry)
352151
352302
  return;
352152
- entry.page = null;
352153
- this.entries.delete(sessionId);
352303
+ this.dropEntry(sessionId, entry);
352154
352304
  if (this.entries.size === 0)
352155
352305
  this.stopSnapshotLoop();
352156
352306
  }
352307
+ /**
352308
+ * The ONLY way an entry leaves `entries`. Centralised because a screencast
352309
+ * outlives the map otherwise: its frame handler closes over the entry rather
352310
+ * than looking it up, so an entry deleted without stopScreencast() leaves
352311
+ * Chromium encoding and acking frames for a page nobody is watching, holding
352312
+ * the CDP session and Page alive. Eviction and the staleness sweep both used
352313
+ * to delete directly and leaked exactly that way.
352314
+ */
352315
+ dropEntry(sessionId, entry) {
352316
+ void this.stopScreencast(entry);
352317
+ entry.page = null;
352318
+ this.entries.delete(sessionId);
352319
+ }
352157
352320
  setPage(sessionId, page) {
352158
352321
  const entry = this.entries.get(sessionId);
352159
352322
  if (!entry)
352160
352323
  return;
352324
+ if (entry.screencast && entry.screencast.page !== page)
352325
+ void this.stopScreencast(entry);
352161
352326
  entry.page = page;
352162
352327
  entry.lastUpdatedAt = Date.now();
352163
352328
  if (page) {
@@ -352167,6 +352332,119 @@ var require_live_browser_registry = __commonJS({
352167
352332
  entry.currentUrl = url;
352168
352333
  } catch {
352169
352334
  }
352335
+ void this.startScreencast(entry, page);
352336
+ }
352337
+ }
352338
+ /**
352339
+ * Begin a CDP screencast for this page. Chromium then pushes a JPEG on every
352340
+ * paint, already downscaled to LIVE_FRAME_MAX_DIMENSION — which is the only
352341
+ * way this runner can downscale at all, since it ships no image library.
352342
+ *
352343
+ * Best-effort by design: any failure leaves `screencast` null and the polling
352344
+ * screenshot loop keeps serving the entry exactly as before.
352345
+ */
352346
+ async startScreencast(entry, page) {
352347
+ if (!page || entry.screencast || entry.screencastStarting)
352348
+ return;
352349
+ if (page.isClosed?.())
352350
+ return;
352351
+ entry.screencastStarting = true;
352352
+ try {
352353
+ const client = await page.context().newCDPSession(page);
352354
+ const binding = { client, page, stopped: false };
352355
+ client.on("Page.screencastFrame", (params) => {
352356
+ void client.send("Page.screencastFrameAck", { sessionId: params.sessionId }).catch(() => {
352357
+ });
352358
+ if (binding.stopped)
352359
+ return;
352360
+ if (this.entries.get(entry.meta.sessionId) !== entry)
352361
+ return;
352362
+ if (typeof params.data !== "string" || params.data.length === 0)
352363
+ return;
352364
+ const width = params.metadata?.deviceWidth;
352365
+ const height = params.metadata?.deviceHeight;
352366
+ this.acceptFrame(entry, params.data, "image/jpeg", width, height);
352367
+ });
352368
+ page.once("close", () => {
352369
+ void this.stopScreencast(entry);
352370
+ });
352371
+ page.once("crash", () => {
352372
+ void this.stopScreencast(entry);
352373
+ });
352374
+ await client.send("Page.enable");
352375
+ await client.send("Page.startScreencast", {
352376
+ format: "jpeg",
352377
+ quality: shared_1.LIVE_FRAME_JPEG_QUALITY,
352378
+ // Absolute bounds, aspect-preserving — Chromium does the resize.
352379
+ maxWidth: shared_1.LIVE_FRAME_MAX_DIMENSION,
352380
+ maxHeight: shared_1.LIVE_FRAME_MAX_DIMENSION,
352381
+ everyNthFrame: 1
352382
+ });
352383
+ if (this.entries.get(entry.meta.sessionId) !== entry || entry.page !== page) {
352384
+ binding.stopped = true;
352385
+ await client.send("Page.stopScreencast").catch(() => {
352386
+ });
352387
+ await client.detach().catch(() => {
352388
+ });
352389
+ return;
352390
+ }
352391
+ entry.screencast = binding;
352392
+ } catch {
352393
+ } finally {
352394
+ entry.screencastStarting = false;
352395
+ }
352396
+ }
352397
+ async stopScreencast(entry) {
352398
+ const binding = entry.screencast;
352399
+ if (!binding || binding.stopped)
352400
+ return;
352401
+ binding.stopped = true;
352402
+ entry.screencast = null;
352403
+ try {
352404
+ await binding.client.send("Page.stopScreencast");
352405
+ } catch {
352406
+ }
352407
+ try {
352408
+ await binding.client.detach();
352409
+ } catch {
352410
+ }
352411
+ }
352412
+ /**
352413
+ * Record a freshly captured frame and forward it to the real-time publisher.
352414
+ * The stored copy (used by the heartbeat fallback) is only replaced when it
352415
+ * fits the heartbeat budget, but the publisher — which has a far larger budget
352416
+ * of its own — always sees the frame.
352417
+ */
352418
+ acceptFrame(entry, base64, mimeType, width, height) {
352419
+ const now = Date.now();
352420
+ entry.lastUpdatedAt = now;
352421
+ if (base64.length <= shared_1.LIVE_THUMBNAIL_MAX_BASE64_BYTES) {
352422
+ entry.thumbnailJpegBase64 = base64;
352423
+ entry.thumbnailCapturedAt = now;
352424
+ if (width !== void 0)
352425
+ entry.thumbnailWidth = width;
352426
+ if (height !== void 0)
352427
+ entry.thumbnailHeight = height;
352428
+ }
352429
+ const publisher = this.publisher;
352430
+ if (!publisher)
352431
+ return;
352432
+ if (now - entry.lastPublishedAt < shared_1.LIVE_FRAME_MIN_PUSH_INTERVAL_MS)
352433
+ return;
352434
+ entry.lastPublishedAt = now;
352435
+ try {
352436
+ publisher({
352437
+ sessionId: entry.meta.sessionId,
352438
+ projectId: entry.meta.projectId,
352439
+ runId: entry.meta.runId,
352440
+ capturedAt: now,
352441
+ mimeType,
352442
+ width,
352443
+ height,
352444
+ surface: entry.meta.surface,
352445
+ base64
352446
+ });
352447
+ } catch {
352170
352448
  }
352171
352449
  }
352172
352450
  setCurrentUrl(sessionId, url) {
@@ -352178,7 +352456,7 @@ var require_live_browser_registry = __commonJS({
352178
352456
  const previousUrl = entry.currentUrl;
352179
352457
  entry.currentUrl = url;
352180
352458
  entry.lastUpdatedAt = Date.now();
352181
- if (url && previousUrl && url !== previousUrl) {
352459
+ if (url && previousUrl && url !== previousUrl && !entry.screencast) {
352182
352460
  entry.pendingCapture = true;
352183
352461
  const schedule = (delay) => {
352184
352462
  setTimeout(() => {
@@ -352199,6 +352477,11 @@ var require_live_browser_registry = __commonJS({
352199
352477
  schedule(100);
352200
352478
  }
352201
352479
  }
352480
+ /**
352481
+ * Ingest a frame captured outside this module — the mobile mirror and the
352482
+ * spawned `playwright test` child (whose Page lives in another process, so no
352483
+ * CDP screencast is reachable from here) both arrive this way.
352484
+ */
352202
352485
  updateSnapshot(sessionId, snapshot) {
352203
352486
  const entry = this.entries.get(sessionId);
352204
352487
  if (!entry)
@@ -352207,12 +352490,10 @@ var require_live_browser_registry = __commonJS({
352207
352490
  entry.currentUrl = snapshot.currentUrl;
352208
352491
  }
352209
352492
  const thumbnail = snapshot.thumbnailJpegBase64;
352210
- const thumbnailLimit = entry.meta.surface === "mobile" ? MOBILE_THUMBNAIL_MAX_BASE64_BYTES : BROWSER_THUMBNAIL_MAX_BASE64_BYTES;
352211
- if (thumbnail && thumbnail.length <= thumbnailLimit) {
352212
- entry.thumbnailJpegBase64 = thumbnail;
352213
- entry.thumbnailCapturedAt = typeof snapshot.thumbnailCapturedAt === "number" && Number.isFinite(snapshot.thumbnailCapturedAt) ? snapshot.thumbnailCapturedAt : Date.now();
352214
- entry.thumbnailWidth = typeof snapshot.thumbnailWidth === "number" && Number.isFinite(snapshot.thumbnailWidth) ? snapshot.thumbnailWidth : void 0;
352215
- entry.thumbnailHeight = typeof snapshot.thumbnailHeight === "number" && Number.isFinite(snapshot.thumbnailHeight) ? snapshot.thumbnailHeight : void 0;
352493
+ if (thumbnail) {
352494
+ const width = typeof snapshot.thumbnailWidth === "number" && Number.isFinite(snapshot.thumbnailWidth) ? snapshot.thumbnailWidth : void 0;
352495
+ const height = typeof snapshot.thumbnailHeight === "number" && Number.isFinite(snapshot.thumbnailHeight) ? snapshot.thumbnailHeight : void 0;
352496
+ this.acceptFrame(entry, thumbnail, snapshot.mimeType ?? "image/jpeg", width, height);
352216
352497
  }
352217
352498
  entry.lastUpdatedAt = Date.now();
352218
352499
  }
@@ -352255,9 +352536,11 @@ var require_live_browser_registry = __commonJS({
352255
352536
  async captureAllNow() {
352256
352537
  await this.captureAll();
352257
352538
  }
352258
- /** Stop the snapshot loop and clear all entries. Idempotent. */
352539
+ /** Stop the snapshot loop, tear down every screencast, and clear all entries. Idempotent. */
352259
352540
  shutdown() {
352260
352541
  this.stopSnapshotLoop();
352542
+ for (const entry of this.entries.values())
352543
+ void this.stopScreencast(entry);
352261
352544
  this.entries.clear();
352262
352545
  }
352263
352546
  ensureSnapshotLoop() {
@@ -352285,15 +352568,13 @@ var require_live_browser_registry = __commonJS({
352285
352568
  }
352286
352569
  }
352287
352570
  if (oldestKey) {
352288
- this.entries.get(oldestKey).page = null;
352289
- this.entries.delete(oldestKey);
352571
+ this.dropEntry(oldestKey, this.entries.get(oldestKey));
352290
352572
  }
352291
352573
  }
352292
352574
  sweepStale(now) {
352293
352575
  for (const [key, entry] of this.entries) {
352294
352576
  if (now - entry.lastUpdatedAt > STALE_SESSION_MS) {
352295
- entry.page = null;
352296
- this.entries.delete(key);
352577
+ this.dropEntry(key, entry);
352297
352578
  }
352298
352579
  }
352299
352580
  }
@@ -352334,6 +352615,11 @@ var require_live_browser_registry = __commonJS({
352334
352615
  entry.currentUrl = url;
352335
352616
  } catch {
352336
352617
  }
352618
+ if (entry.screencast) {
352619
+ if (entry.thumbnailJpegBase64)
352620
+ entry.thumbnailCapturedAt = Date.now();
352621
+ return;
352622
+ }
352337
352623
  const buf = await page.screenshot({
352338
352624
  type: "jpeg",
352339
352625
  quality: THUMBNAIL_QUALITY,
@@ -352341,17 +352627,8 @@ var require_live_browser_registry = __commonJS({
352341
352627
  timeout: 3e3,
352342
352628
  scale: "css"
352343
352629
  });
352344
- const base64 = buf.toString("base64");
352345
- if (base64.length > BROWSER_THUMBNAIL_MAX_BASE64_BYTES) {
352346
- return;
352347
- }
352348
- entry.thumbnailJpegBase64 = base64;
352349
- entry.thumbnailCapturedAt = Date.now();
352350
352630
  const viewport = page.viewportSize?.();
352351
- if (viewport) {
352352
- entry.thumbnailWidth = viewport.width;
352353
- entry.thumbnailHeight = viewport.height;
352354
- }
352631
+ this.acceptFrame(entry, buf.toString("base64"), "image/jpeg", viewport?.width, viewport?.height);
352355
352632
  } catch {
352356
352633
  } finally {
352357
352634
  entry.isCapturing = false;
@@ -353065,7 +353342,7 @@ var require_package4 = __commonJS({
353065
353342
  "package.json"(exports, module) {
353066
353343
  module.exports = {
353067
353344
  name: "@validate.qa/runner",
353068
- version: "1.0.78",
353345
+ version: "1.0.80",
353069
353346
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353070
353347
  bin: {
353071
353348
  "validate-runner": "dist/cli.js"
@@ -353334,6 +353611,9 @@ async function enforceRunnerUpToDate(currentVersion, opts = {}) {
353334
353611
  return result;
353335
353612
  }
353336
353613
 
353614
+ // src/runner.ts
353615
+ init_dist();
353616
+
353337
353617
  // src/services/appium-executor.ts
353338
353618
  init_dist();
353339
353619
 
@@ -353857,6 +354137,24 @@ async function getScreenshot(sessionPath) {
353857
354137
  return null;
353858
354138
  }
353859
354139
  }
354140
+ async function applyMjpegSettings(sessionPath, settings) {
354141
+ try {
354142
+ await appiumRequest(`${sessionPath}/appium/settings`, {
354143
+ method: "POST",
354144
+ body: JSON.stringify({
354145
+ settings: {
354146
+ mjpegServerFramerate: settings.framerate,
354147
+ mjpegScalingFactor: settings.scalingFactor,
354148
+ mjpegServerScreenshotQuality: settings.quality
354149
+ }
354150
+ })
354151
+ });
354152
+ return true;
354153
+ } catch (err) {
354154
+ if (isAppiumDriverDeadError(err)) throw err;
354155
+ return false;
354156
+ }
354157
+ }
353860
354158
  async function getPageSource(sessionPath) {
353861
354159
  try {
353862
354160
  const value = readValue(await appiumRequest(`${sessionPath}/source`));
@@ -356368,7 +356666,15 @@ function mobileParallelSessionCapabilities(platform3, portOffset = 0, deviceId =
356368
356666
  return (Number.isFinite(parsed) ? parsed : fallback) + offset;
356369
356667
  };
356370
356668
  if (platform3 === "ANDROID") {
356371
- return { "appium:systemPort": readPort("APPIUM_SYSTEM_PORT", 8200) };
356669
+ return {
356670
+ "appium:systemPort": readPort("APPIUM_SYSTEM_PORT", 8200),
356671
+ // Host port that UiAutomator2 forwards the on-device MJPEG broadcaster
356672
+ // (device port 7810) to. Without this the live mirror has to poll
356673
+ // WebDriver /screenshot for full-resolution PNGs, which the runner cannot
356674
+ // downscale itself. Base differs from iOS's 9100 so a concurrent
356675
+ // Android+iOS pair can never collide on a shared slot offset.
356676
+ "appium:mjpegServerPort": readPort("APPIUM_ANDROID_MJPEG_SERVER_PORT", 7810)
356677
+ };
356372
356678
  }
356373
356679
  const safeDevice = deviceId.replace(/[^A-Za-z0-9._-]+/g, "-") || `device-${offset}`;
356374
356680
  const derivedDataRoot = env.APPIUM_DERIVED_DATA_PATH?.trim() || join5(tmpdir3(), "validateqa-wda");
@@ -357623,6 +357929,15 @@ function acquireControlPortSlot(deviceId) {
357623
357929
  function releaseControlPortSlot(deviceId) {
357624
357930
  controlPortSlotsByDevice.delete(deviceId);
357625
357931
  }
357932
+ function getControlPortSlot(deviceId) {
357933
+ return controlPortSlotsByDevice.get(deviceId);
357934
+ }
357935
+ function mobileMjpegPortForDevice(platform3, deviceId, env = process.env) {
357936
+ const slot = getControlPortSlot(deviceId);
357937
+ if (slot === void 0) return null;
357938
+ const port = mobileParallelSessionCapabilities(platform3, slot, deviceId, env)["appium:mjpegServerPort"];
357939
+ return typeof port === "number" && Number.isFinite(port) ? port : null;
357940
+ }
357626
357941
  var PINNED_DEVICE_SNAPSHOT_TTL_MS = 25 * 6e4;
357627
357942
  var pinnedDeviceSnapshot = null;
357628
357943
  async function executeMobileTest(options) {
@@ -358567,8 +358882,103 @@ import { randomBytes, timingSafeEqual } from "crypto";
358567
358882
  import { homedir as homedir2 } from "os";
358568
358883
  import { join as join6, dirname } from "path";
358569
358884
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3 } from "fs";
358885
+
358886
+ // src/services/mobile/mjpeg-stream.ts
358887
+ init_dist();
358888
+ import { request as httpRequest } from "http";
358889
+ var JPEG_SOI = Buffer.from([255, 216]);
358890
+ var JPEG_EOI = Buffer.from([255, 217]);
358891
+ function startMjpegStream(options) {
358892
+ const { port, host = "127.0.0.1", onFrame, onError } = options;
358893
+ let stopped = false;
358894
+ let req = null;
358895
+ let response = null;
358896
+ let buffer = Buffer.alloc(0);
358897
+ const fail = (error2) => {
358898
+ if (stopped) return;
358899
+ stopped = true;
358900
+ try {
358901
+ req?.destroy();
358902
+ } catch {
358903
+ }
358904
+ try {
358905
+ response?.destroy();
358906
+ } catch {
358907
+ }
358908
+ buffer = Buffer.alloc(0);
358909
+ onError?.(error2);
358910
+ };
358911
+ const consume = (chunk) => {
358912
+ if (stopped) return;
358913
+ buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]);
358914
+ for (; ; ) {
358915
+ const start = buffer.indexOf(JPEG_SOI);
358916
+ if (start < 0) {
358917
+ if (buffer.length > 1) buffer = buffer.subarray(buffer.length - 1);
358918
+ return;
358919
+ }
358920
+ const end = buffer.indexOf(JPEG_EOI, start + JPEG_SOI.length);
358921
+ if (end < 0) {
358922
+ if (start > 0) buffer = buffer.subarray(start);
358923
+ if (buffer.length > MOBILE_MJPEG_MAX_FRAME_BYTES) buffer = Buffer.alloc(0);
358924
+ return;
358925
+ }
358926
+ const frameEnd = end + JPEG_EOI.length;
358927
+ const frame = buffer.subarray(start, frameEnd);
358928
+ buffer = buffer.subarray(frameEnd);
358929
+ try {
358930
+ onFrame(frame);
358931
+ } catch {
358932
+ }
358933
+ }
358934
+ };
358935
+ try {
358936
+ req = httpRequest(
358937
+ { host, port, path: "/", method: "GET", headers: { Connection: "keep-alive" } },
358938
+ (res) => {
358939
+ response = res;
358940
+ if (stopped) {
358941
+ res.destroy();
358942
+ return;
358943
+ }
358944
+ if (res.statusCode && res.statusCode >= 400) {
358945
+ fail(new Error(`MJPEG broadcaster returned HTTP ${res.statusCode}`));
358946
+ res.destroy();
358947
+ return;
358948
+ }
358949
+ res.on("data", (chunk) => consume(chunk));
358950
+ res.on("end", () => fail(new Error("MJPEG stream ended")));
358951
+ res.on("error", (err) => fail(err));
358952
+ }
358953
+ );
358954
+ req.on("error", (err) => fail(err));
358955
+ req.setTimeout(1e4, () => fail(new Error("MJPEG connect timeout")));
358956
+ req.end();
358957
+ } catch (err) {
358958
+ fail(err instanceof Error ? err : new Error(String(err)));
358959
+ }
358960
+ return {
358961
+ stop: () => {
358962
+ if (stopped) return;
358963
+ stopped = true;
358964
+ try {
358965
+ req?.destroy();
358966
+ } catch {
358967
+ }
358968
+ try {
358969
+ response?.destroy();
358970
+ } catch {
358971
+ }
358972
+ buffer = Buffer.alloc(0);
358973
+ },
358974
+ isActive: () => !stopped
358975
+ };
358976
+ }
358977
+
358978
+ // src/services/mobile-bridge.ts
358570
358979
  init_dist();
358571
358980
  var MOBILE_BRIDGE_PORT2 = 9515;
358981
+ var RELAY_DEMAND_POLL_MS = 2e3;
358572
358982
  var MAX_REQUEST_BODY_BYTES = 64 * 1024;
358573
358983
  var MAX_TEXT_INPUT_LENGTH = 4e3;
358574
358984
  var MAX_APP_ID_LENGTH = 255;
@@ -359080,6 +359490,18 @@ async function handleSessionStop(_req, res) {
359080
359490
  sendJSON(res, 200, { ok: true });
359081
359491
  }
359082
359492
  var runnerChannels = /* @__PURE__ */ new Map();
359493
+ var relayDemandTimer = null;
359494
+ function setMirrorRelay(deviceId, relay) {
359495
+ const ch = runnerChannels.get(deviceId);
359496
+ if (!ch) return;
359497
+ ch.relay = relay;
359498
+ if (relay) {
359499
+ ensureRelayDemandSupervisor();
359500
+ if (hasChannelDemand(ch)) ensureChannelPolling(ch);
359501
+ } else {
359502
+ stopChannelCaptureIfIdle(ch);
359503
+ }
359504
+ }
359083
359505
  function readDeviceIdParam(req) {
359084
359506
  try {
359085
359507
  const url = new URL(req.url || "", "http://localhost");
@@ -359111,7 +359533,7 @@ function writeFrameToChannelClient(ch, client, frame) {
359111
359533
  } catch {
359112
359534
  }
359113
359535
  ch.clients.delete(client);
359114
- stopChannelPollingIfIdle(ch);
359536
+ stopChannelCaptureIfIdle(ch);
359115
359537
  return;
359116
359538
  }
359117
359539
  client.write(`data: ${JSON.stringify(frame)}
@@ -359120,16 +359542,95 @@ function writeFrameToChannelClient(ch, client, frame) {
359120
359542
  } catch {
359121
359543
  }
359122
359544
  }
359545
+ function broadcastChannelFrame(ch, frame) {
359546
+ ch.lastFrame = frame;
359547
+ for (const client of ch.clients) writeFrameToChannelClient(ch, client, frame);
359548
+ if (ch.relay && frame.screenshot) {
359549
+ try {
359550
+ ch.relay.onFrame(frame.screenshot, frame.mimeType ?? "image/png");
359551
+ } catch {
359552
+ }
359553
+ }
359554
+ }
359555
+ function tryStartChannelMjpeg(ch) {
359556
+ if (ch.mjpeg || ch.mjpegStarting || ch.mjpegUnavailable) return;
359557
+ const port = mobileMjpegPortForDevice(ch.platform, ch.deviceId);
359558
+ if (port === null) {
359559
+ ch.mjpegUnavailable = true;
359560
+ return;
359561
+ }
359562
+ ch.mjpegStarting = true;
359563
+ const handle = startMjpegStream({
359564
+ port,
359565
+ onFrame: (jpeg) => {
359566
+ const isOwned = ch.mjpeg === handle;
359567
+ const isCandidate = ch.mjpeg === null && ch.mjpegStarting;
359568
+ if (!isOwned && !isCandidate) return;
359569
+ ch.mjpegLastFrameAt = Date.now();
359570
+ ch.nullTicks = 0;
359571
+ if (isCandidate) {
359572
+ ch.mjpeg = handle;
359573
+ ch.mjpegStarting = false;
359574
+ if (ch.pollTimer) {
359575
+ clearInterval(ch.pollTimer);
359576
+ ch.pollTimer = null;
359577
+ }
359578
+ }
359579
+ broadcastChannelFrame(ch, {
359580
+ timestamp: ch.mjpegLastFrameAt,
359581
+ screenshot: jpeg.toString("base64"),
359582
+ source: null,
359583
+ mimeType: "image/jpeg"
359584
+ });
359585
+ },
359586
+ onError: () => {
359587
+ const wasActive = ch.mjpeg === handle;
359588
+ ch.mjpeg = null;
359589
+ ch.mjpegStarting = false;
359590
+ ch.mjpegUnavailable = true;
359591
+ if (wasActive && hasChannelDemand(ch)) ensureChannelPolling(ch);
359592
+ }
359593
+ });
359594
+ const firstFrameDeadline = setTimeout(() => {
359595
+ if (ch.mjpeg) return;
359596
+ ch.mjpegStarting = false;
359597
+ ch.mjpegUnavailable = true;
359598
+ handle.stop();
359599
+ if (hasChannelDemand(ch)) ensureChannelPolling(ch);
359600
+ }, MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS);
359601
+ firstFrameDeadline.unref?.();
359602
+ }
359603
+ function hasChannelDemand(ch) {
359604
+ if (ch.clients.size > 0) return true;
359605
+ if (!ch.relay) return false;
359606
+ try {
359607
+ return ch.relay.wanted();
359608
+ } catch {
359609
+ return false;
359610
+ }
359611
+ }
359612
+ function ensureRelayDemandSupervisor() {
359613
+ if (relayDemandTimer) return;
359614
+ relayDemandTimer = setInterval(() => {
359615
+ for (const ch of [...runnerChannels.values()]) {
359616
+ if (hasChannelDemand(ch)) ensureChannelPolling(ch);
359617
+ else stopChannelCaptureIfIdle(ch);
359618
+ }
359619
+ if (runnerChannels.size === 0 && relayDemandTimer) {
359620
+ clearInterval(relayDemandTimer);
359621
+ relayDemandTimer = null;
359622
+ }
359623
+ }, RELAY_DEMAND_POLL_MS);
359624
+ relayDemandTimer.unref?.();
359625
+ }
359123
359626
  async function pollRunnerChannel(ch) {
359124
- if (ch.clients.size === 0 || ch.polling) return;
359627
+ if (!hasChannelDemand(ch) || ch.polling) return;
359628
+ if (ch.mjpeg) return;
359125
359629
  ch.polling = true;
359126
359630
  try {
359127
359631
  validateSessionId(ch.sessionId);
359128
- const [screenshot, source] = await Promise.all([
359129
- getScreenshot(`/session/${ch.sessionId}`),
359130
- getPageSource(`/session/${ch.sessionId}`)
359131
- ]);
359132
- if (!screenshot && !source) {
359632
+ const screenshot = await getScreenshot(`/session/${ch.sessionId}`);
359633
+ if (!screenshot) {
359133
359634
  ch.nullTicks++;
359134
359635
  if (ch.nullTicks >= MIRROR_DEAD_SESSION_TICKS) {
359135
359636
  const alive = await isSessionAlive(ch.sessionId);
@@ -359142,32 +359643,47 @@ async function pollRunnerChannel(ch) {
359142
359643
  } else {
359143
359644
  ch.nullTicks = 0;
359144
359645
  }
359145
- const frame = { timestamp: Date.now(), screenshot: screenshot ?? null, source: source ?? null };
359146
- ch.lastFrame = frame;
359147
- for (const client of ch.clients) writeFrameToChannelClient(ch, client, frame);
359646
+ broadcastChannelFrame(ch, {
359647
+ timestamp: Date.now(),
359648
+ screenshot: screenshot ?? null,
359649
+ source: null,
359650
+ mimeType: "image/png"
359651
+ });
359148
359652
  } catch {
359149
359653
  } finally {
359150
359654
  ch.polling = false;
359151
359655
  }
359152
359656
  }
359153
359657
  function ensureChannelPolling(ch) {
359154
- if (ch.pollTimer) return;
359658
+ if (!hasChannelDemand(ch)) return;
359659
+ tryStartChannelMjpeg(ch);
359660
+ if (ch.mjpeg || ch.pollTimer) return;
359155
359661
  ch.pollTimer = setInterval(() => {
359156
359662
  void pollRunnerChannel(ch);
359157
359663
  }, MOBILE_SCREENSHOT_INTERVAL_MS);
359158
359664
  ch.pollTimer.unref?.();
359159
359665
  }
359160
- function stopChannelPollingIfIdle(ch) {
359161
- if (ch.clients.size === 0 && ch.pollTimer) {
359666
+ function stopChannelCaptureIfIdle(ch) {
359667
+ if (hasChannelDemand(ch)) return;
359668
+ if (ch.pollTimer) {
359162
359669
  clearInterval(ch.pollTimer);
359163
359670
  ch.pollTimer = null;
359164
359671
  }
359672
+ if (ch.mjpeg) {
359673
+ ch.mjpeg.stop();
359674
+ ch.mjpeg = null;
359675
+ }
359165
359676
  }
359166
359677
  function closeRunnerChannel(ch) {
359167
359678
  if (ch.pollTimer) {
359168
359679
  clearInterval(ch.pollTimer);
359169
359680
  ch.pollTimer = null;
359170
359681
  }
359682
+ if (ch.mjpeg) {
359683
+ ch.mjpeg.stop();
359684
+ ch.mjpeg = null;
359685
+ }
359686
+ ch.relay = null;
359171
359687
  for (const client of ch.clients) {
359172
359688
  try {
359173
359689
  client.end();
@@ -359176,7 +359692,7 @@ function closeRunnerChannel(ch) {
359176
359692
  }
359177
359693
  ch.clients.clear();
359178
359694
  ch.lastFrame = null;
359179
- runnerChannels.delete(ch.deviceId);
359695
+ if (runnerChannels.get(ch.deviceId) === ch) runnerChannels.delete(ch.deviceId);
359180
359696
  }
359181
359697
  function closeAllRunnerChannels() {
359182
359698
  for (const ch of [...runnerChannels.values()]) closeRunnerChannel(ch);
@@ -359198,7 +359714,7 @@ async function serveRunnerMirror(req, res, ch) {
359198
359714
  }
359199
359715
  req.on("close", () => {
359200
359716
  ch.clients.delete(res);
359201
- stopChannelPollingIfIdle(ch);
359717
+ stopChannelCaptureIfIdle(ch);
359202
359718
  });
359203
359719
  }
359204
359720
  async function handleMirror(req, res) {
@@ -359752,13 +360268,26 @@ function attachMirrorSession(sessionId, platform3, deviceId) {
359752
360268
  lastFrame: null,
359753
360269
  pollTimer: null,
359754
360270
  nullTicks: 0,
359755
- polling: false
360271
+ polling: false,
360272
+ mjpeg: null,
360273
+ mjpegLastFrameAt: 0,
360274
+ mjpegStarting: false,
360275
+ mjpegUnavailable: false,
360276
+ relay: null
359756
360277
  };
359757
360278
  ch.sessionId = sessionId;
359758
360279
  ch.platform = platform3;
359759
360280
  ch.nullTicks = 0;
360281
+ if (ch.mjpeg) {
360282
+ ch.mjpeg.stop();
360283
+ ch.mjpeg = null;
360284
+ }
360285
+ ch.mjpegUnavailable = false;
360286
+ ch.mjpegStarting = false;
359760
360287
  runnerChannels.set(deviceId, ch);
359761
- if (ch.clients.size > 0) {
360288
+ void applyMjpegSettings(`/session/${sessionId}`, MOBILE_MJPEG_SETTINGS).catch(() => {
360289
+ });
360290
+ if (hasChannelDemand(ch)) {
359762
360291
  ensureChannelPolling(ch);
359763
360292
  void pollRunnerChannel(ch);
359764
360293
  }
@@ -360730,6 +361259,13 @@ function attachMobileDiscoveryNetworkContext(calls, result, appId) {
360730
361259
  }));
360731
361260
  }
360732
361261
  var runnerPackage = require_package4();
361262
+ var MIRROR_RELAY_FRESH_MS = 3e3;
361263
+ var liveFrameWatchedByProject = /* @__PURE__ */ new Map();
361264
+ var liveFrameIdleProbeAt = /* @__PURE__ */ new Map();
361265
+ var LIVE_FRAME_IDLE_PROBE_MAX_ENTRIES = 256;
361266
+ function isLiveFrameWatched(projectId) {
361267
+ return !projectId || liveFrameWatchedByProject.get(projectId) !== false;
361268
+ }
360733
361269
  var SECRET_VAR_PATTERN = /password|secret|token|api[_]?key|pass\b|credential|bearer|pat\b/i;
360734
361270
  var IS_CLOUD = parseBoolFlag(process.env.RUNNER_CLOUD);
360735
361271
  var RUNNER_NAME = runnerPackage.name ?? "@validate.qa/runner";
@@ -361507,6 +362043,22 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
361507
362043
  testCaseId: run2.testCaseId ?? void 0
361508
362044
  });
361509
362045
  requestLiveBrowserHeartbeat(true);
362046
+ let lastMirrorRelayFrameAt = 0;
362047
+ const attachMirrorWithRelay = (sessionId, platform3, deviceId) => {
362048
+ attachMirrorSession(sessionId, platform3, deviceId);
362049
+ if (!deviceId) return;
362050
+ setMirrorRelay(deviceId, {
362051
+ onFrame: (base64, mimeType) => {
362052
+ lastMirrorRelayFrameAt = Date.now();
362053
+ liveBrowserHandle.updateSnapshot({
362054
+ thumbnailJpegBase64: base64,
362055
+ thumbnailCapturedAt: Date.now(),
362056
+ mimeType
362057
+ });
362058
+ },
362059
+ wanted: () => isLiveFrameWatched(run2.projectId)
362060
+ });
362061
+ };
361510
362062
  const liveBrowserHeartbeatInterval = setInterval(() => {
361511
362063
  liveBrowserHandle.patch({});
361512
362064
  requestLiveBrowserHeartbeat();
@@ -362410,7 +362962,7 @@ ${finalVerifyResult.logs ?? ""}`;
362410
362962
  deeplink: harvestTarget.deeplink
362411
362963
  },
362412
362964
  onSessionReady: (sessionId, deviceId, sessionPlatform) => {
362413
- attachMirrorSession(sessionId, sessionPlatform, deviceId);
362965
+ attachMirrorWithRelay(sessionId, sessionPlatform, deviceId);
362414
362966
  const name = getCachedDeviceSnapshot().find((d) => d.id === deviceId)?.name;
362415
362967
  liveBrowserHandle.patch({ deviceId, ...name ? { deviceName: name } : {} });
362416
362968
  requestLiveBrowserHeartbeat(true);
@@ -362880,7 +363432,7 @@ ${finalVerifyResult.logs ?? ""}`;
362880
363432
  }
362881
363433
  );
362882
363434
  mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
362883
- attachMirrorSession(session.sessionId, platform3, session.deviceId);
363435
+ attachMirrorWithRelay(session.sessionId, platform3, session.deviceId);
362884
363436
  const driver = new MobileBridgeDriver({
362885
363437
  sessionId: session.sessionId,
362886
363438
  platform: platform3,
@@ -362917,7 +363469,7 @@ ${finalVerifyResult.logs ?? ""}`;
362917
363469
  throw new Error("discovery teardown started during session recovery \u2014 replacement session closed");
362918
363470
  }
362919
363471
  session = replacement;
362920
- attachMirrorSession(replacement.sessionId, platform3, replacement.deviceId);
363472
+ attachMirrorWithRelay(replacement.sessionId, platform3, replacement.deviceId);
362921
363473
  liveBrowserHandle.patch({
362922
363474
  deviceId: replacement.deviceId,
362923
363475
  deviceName: replacement.deviceName
@@ -362938,10 +363490,16 @@ ${finalVerifyResult.logs ?? ""}`;
362938
363490
  // UI a reliable mobile fallback when the direct bridge SSE is
362939
363491
  // still opening or blocked by the user's network/tunnel.
362940
363492
  onScreenshot: (base64) => {
363493
+ if (Date.now() - lastMirrorRelayFrameAt < MIRROR_RELAY_FRESH_MS) {
363494
+ liveBrowserHandle.updateSnapshot({ currentUrl: mobileTarget.appId });
363495
+ return;
363496
+ }
362941
363497
  liveBrowserHandle.updateSnapshot({
362942
363498
  currentUrl: mobileTarget.appId,
362943
363499
  thumbnailJpegBase64: base64,
362944
- thumbnailCapturedAt: Date.now()
363500
+ thumbnailCapturedAt: Date.now(),
363501
+ // Appium's screenshot endpoint returns PNG despite the field name.
363502
+ mimeType: "image/png"
362945
363503
  });
362946
363504
  requestLiveBrowserHeartbeat();
362947
363505
  },
@@ -363408,7 +363966,7 @@ ${finalVerifyResult.logs ?? ""}`;
363408
363966
  deeplink: target.deeplink
363409
363967
  });
363410
363968
  liveSession = opened;
363411
- attachMirrorSession(opened.sessionId, target.platform, opened.deviceId);
363969
+ attachMirrorWithRelay(opened.sessionId, target.platform, opened.deviceId);
363412
363970
  bridgeDriver = new MobileBridgeDriver({
363413
363971
  sessionId: opened.sessionId,
363414
363972
  platform: target.platform,
@@ -363598,7 +364156,7 @@ ${finalVerifyResult.logs ?? ""}`;
363598
364156
  // Stream this run to its OWN per-device dashboard tile (keyed by the
363599
364157
  // leased device id) so N parallel Appium runs each show a live mirror.
363600
364158
  onSessionReady: (sessionId, deviceId, sessionPlatform) => {
363601
- attachMirrorSession(sessionId, sessionPlatform, deviceId);
364159
+ attachMirrorWithRelay(sessionId, sessionPlatform, deviceId);
363602
364160
  requestLiveBrowserHeartbeat(true);
363603
364161
  },
363604
364162
  onSessionEnding: (deviceId) => {
@@ -364186,6 +364744,41 @@ async function startRunner(opts) {
364186
364744
  } catch {
364187
364745
  }
364188
364746
  };
364747
+ const liveFramePushInFlight = /* @__PURE__ */ new Set();
364748
+ const publishLiveFrame = (frame) => {
364749
+ if (!frame.projectId) return;
364750
+ if (liveFramePushInFlight.has(frame.sessionId)) return;
364751
+ if (!isLiveFrameWatched(frame.projectId)) {
364752
+ const lastProbeAt = liveFrameIdleProbeAt.get(frame.sessionId) ?? 0;
364753
+ if (frame.capturedAt - lastProbeAt < LIVE_FRAME_IDLE_PUSH_INTERVAL_MS) return;
364754
+ if (liveFrameIdleProbeAt.size >= LIVE_FRAME_IDLE_PROBE_MAX_ENTRIES) {
364755
+ const expiredBefore = frame.capturedAt - LIVE_FRAME_IDLE_PUSH_INTERVAL_MS;
364756
+ for (const [sessionId, probedAt] of liveFrameIdleProbeAt) {
364757
+ if (probedAt >= expiredBefore) break;
364758
+ liveFrameIdleProbeAt.delete(sessionId);
364759
+ }
364760
+ }
364761
+ liveFrameIdleProbeAt.set(frame.sessionId, frame.capturedAt);
364762
+ }
364763
+ liveFramePushInFlight.add(frame.sessionId);
364764
+ const frameProjectId = frame.projectId;
364765
+ void fetch(`${serverUrl}/api/runner/live-frame`, {
364766
+ method: "POST",
364767
+ headers,
364768
+ body: JSON.stringify(frame),
364769
+ signal: AbortSignal.timeout(5e3)
364770
+ }).then(async (res) => {
364771
+ if (!res.ok) return;
364772
+ const ack = await res.json().catch(() => null);
364773
+ if (ack && typeof ack.watching === "boolean") {
364774
+ liveFrameWatchedByProject.set(frameProjectId, ack.watching);
364775
+ }
364776
+ }).catch(() => {
364777
+ }).finally(() => {
364778
+ liveFramePushInFlight.delete(frame.sessionId);
364779
+ });
364780
+ };
364781
+ import_runner_core11.liveBrowserRegistry.setFramePublisher(publishLiveFrame);
364189
364782
  let liveBrowserHeartbeatInFlight = false;
364190
364783
  let liveBrowserHeartbeatPending = false;
364191
364784
  let lastLiveBrowserHeartbeatAt = 0;