@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.js CHANGED
@@ -1888,6 +1888,57 @@ var init_constants = __esm({
1888
1888
  }
1889
1889
  });
1890
1890
 
1891
+ // ../shared/dist/live-frame.js
1892
+ function isValidLiveFrame(frame) {
1893
+ if (!frame || typeof frame !== "object")
1894
+ return false;
1895
+ const f = frame;
1896
+ if (typeof f.sessionId !== "string" || f.sessionId.length === 0 || f.sessionId.length > 200)
1897
+ return false;
1898
+ if (typeof f.projectId !== "string" || f.projectId.length === 0 || f.projectId.length > 200)
1899
+ return false;
1900
+ if (typeof f.capturedAt !== "number" || !Number.isFinite(f.capturedAt))
1901
+ return false;
1902
+ if (f.mimeType !== "image/jpeg" && f.mimeType !== "image/png")
1903
+ return false;
1904
+ if (typeof f.base64 !== "string" || f.base64.length === 0)
1905
+ return false;
1906
+ if (f.base64.length > LIVE_FRAME_PUSH_MAX_BASE64_BYTES)
1907
+ return false;
1908
+ return true;
1909
+ }
1910
+ 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;
1911
+ var init_live_frame = __esm({
1912
+ "../shared/dist/live-frame.js"() {
1913
+ "use strict";
1914
+ readEnvInt = (name, fallback) => {
1915
+ if (typeof process === "undefined")
1916
+ return fallback;
1917
+ const parsed = parseInt(process.env?.[name] ?? "", 10);
1918
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
1919
+ };
1920
+ LIVE_FRAME_PUSH_MAX_BASE64_BYTES = readEnvInt("LIVE_FRAME_PUSH_MAX_BASE64_BYTES", 45e5);
1921
+ LIVE_THUMBNAIL_MAX_BASE64_BYTES = readEnvInt("LIVE_THUMBNAIL_MAX_BASE64_BYTES", 4e5);
1922
+ LIVE_FRAME_MAX_DIMENSION = readEnvInt("LIVE_FRAME_MAX_DIMENSION", 900);
1923
+ LIVE_FRAME_JPEG_QUALITY = readEnvInt("LIVE_FRAME_JPEG_QUALITY", 55);
1924
+ LIVE_FRAME_MIN_PUSH_INTERVAL_MS = readEnvInt("LIVE_FRAME_MIN_PUSH_INTERVAL_MS", 125);
1925
+ LIVE_FRAME_UNFOCUSED_INTERVAL_MS = readEnvInt("LIVE_FRAME_UNFOCUSED_INTERVAL_MS", 2e3);
1926
+ LIVE_FRAME_IDLE_PUSH_INTERVAL_MS = readEnvInt("LIVE_FRAME_IDLE_PUSH_INTERVAL_MS", 5e3);
1927
+ LIVE_FRAME_STALE_MS = readEnvInt("LIVE_FRAME_STALE_MS", 3e4);
1928
+ LIVE_FRAME_MAX_SESSIONS_PER_PROJECT = readEnvInt("LIVE_FRAME_MAX_SESSIONS_PER_PROJECT", 32);
1929
+ MOBILE_MJPEG_SETTINGS = {
1930
+ /** Screenshots per second the broadcaster emits (both drivers: range 1-60). */
1931
+ framerate: readEnvInt("MOBILE_MJPEG_FRAMERATE", 10),
1932
+ /** Downscale percentage. UiAutomator2 defaults to 50, XCUITest to 100. */
1933
+ scalingFactor: readEnvInt("MOBILE_MJPEG_SCALING_FACTOR", 50),
1934
+ /** JPEG compression factor (both drivers: range 1-100). */
1935
+ quality: readEnvInt("MOBILE_MJPEG_QUALITY", 50)
1936
+ };
1937
+ MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS = readEnvInt("MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS", 6e3);
1938
+ MOBILE_MJPEG_MAX_FRAME_BYTES = readEnvInt("MOBILE_MJPEG_MAX_FRAME_BYTES", 8 * 1024 * 1024);
1939
+ }
1940
+ });
1941
+
1891
1942
  // ../shared/dist/project-key.js
1892
1943
  function isValidProjectKey(key) {
1893
1944
  return typeof key === "string" && PROJECT_KEY_PATTERN.test(key) && !RESERVED_PROJECT_KEYS.has(key);
@@ -6310,6 +6361,15 @@ __export(dist_exports, {
6310
6361
  GROUP_LABELS: () => GROUP_LABELS,
6311
6362
  LEGACY_TTS_MODEL_MAP: () => LEGACY_TTS_MODEL_MAP,
6312
6363
  LEGACY_TTS_VOICE_ID_MAP: () => LEGACY_TTS_VOICE_ID_MAP,
6364
+ LIVE_FRAME_IDLE_PUSH_INTERVAL_MS: () => LIVE_FRAME_IDLE_PUSH_INTERVAL_MS,
6365
+ LIVE_FRAME_JPEG_QUALITY: () => LIVE_FRAME_JPEG_QUALITY,
6366
+ LIVE_FRAME_MAX_DIMENSION: () => LIVE_FRAME_MAX_DIMENSION,
6367
+ LIVE_FRAME_MAX_SESSIONS_PER_PROJECT: () => LIVE_FRAME_MAX_SESSIONS_PER_PROJECT,
6368
+ LIVE_FRAME_MIN_PUSH_INTERVAL_MS: () => LIVE_FRAME_MIN_PUSH_INTERVAL_MS,
6369
+ LIVE_FRAME_PUSH_MAX_BASE64_BYTES: () => LIVE_FRAME_PUSH_MAX_BASE64_BYTES,
6370
+ LIVE_FRAME_STALE_MS: () => LIVE_FRAME_STALE_MS,
6371
+ LIVE_FRAME_UNFOCUSED_INTERVAL_MS: () => LIVE_FRAME_UNFOCUSED_INTERVAL_MS,
6372
+ LIVE_THUMBNAIL_MAX_BASE64_BYTES: () => LIVE_THUMBNAIL_MAX_BASE64_BYTES,
6313
6373
  MAX_CREDIT_TOPUP_CENTS: () => MAX_CREDIT_TOPUP_CENTS,
6314
6374
  MAX_DEEPLINK_LENGTH: () => MAX_DEEPLINK_LENGTH,
6315
6375
  MIN_CREDIT_TOPUP_CENTS: () => MIN_CREDIT_TOPUP_CENTS,
@@ -6321,6 +6381,9 @@ __export(dist_exports, {
6321
6381
  MOBILE_DEFAULT_SWIPE_DISTANCE: () => MOBILE_DEFAULT_SWIPE_DISTANCE,
6322
6382
  MOBILE_FOCUS_SETTLE_MS: () => MOBILE_FOCUS_SETTLE_MS,
6323
6383
  MOBILE_MAX_SWIPE_DISTANCE: () => MOBILE_MAX_SWIPE_DISTANCE,
6384
+ MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS: () => MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS,
6385
+ MOBILE_MJPEG_MAX_FRAME_BYTES: () => MOBILE_MJPEG_MAX_FRAME_BYTES,
6386
+ MOBILE_MJPEG_SETTINGS: () => MOBILE_MJPEG_SETTINGS,
6324
6387
  MOBILE_PLATFORMS: () => MOBILE_PLATFORMS,
6325
6388
  MOBILE_POST_ACTION_SETTLE_MS: () => MOBILE_POST_ACTION_SETTLE_MS,
6326
6389
  MOBILE_SCREENSHOT_INTERVAL_MS: () => MOBILE_SCREENSHOT_INTERVAL_MS,
@@ -6433,6 +6496,7 @@ __export(dist_exports, {
6433
6496
  isTestReadyForPlatform: () => isTestReadyForPlatform,
6434
6497
  isTrialExpired: () => isTrialExpired,
6435
6498
  isUIVariant: () => isUIVariant,
6499
+ isValidLiveFrame: () => isValidLiveFrame,
6436
6500
  isValidMobileAppId: () => isValidMobileAppId,
6437
6501
  isValidProjectKey: () => isValidProjectKey,
6438
6502
  isValidSttProvider: () => isValidSttProvider,
@@ -6495,6 +6559,7 @@ var init_dist = __esm({
6495
6559
  "use strict";
6496
6560
  init_types();
6497
6561
  init_constants();
6562
+ init_live_frame();
6498
6563
  init_project_key();
6499
6564
  init_mobile_test_generation();
6500
6565
  init_mobile_step_healer();
@@ -87957,6 +88022,7 @@ var require_playwright_native = __commonJS({
87957
88022
  var PACKAGE_DIR = (0, path_1.resolve)(__dirname, "..", "..");
87958
88023
  var RUNS_DIR = process.env.RUNS_DIR || (0, path_1.join)(PACKAGE_DIR, ".qa-runs");
87959
88024
  var PW_CLI = require.resolve("@playwright/test/cli");
88025
+ var LIVE_BROWSER_STATE_POLL_INTERVAL_MS = 500;
87960
88026
  async function executeTestViaNativePlaywright2(options) {
87961
88027
  const { playwrightCode, baseUrl, testName, testVariables, authStateCapturePath, storageStatePath, extraHttpHeaders, authStandingMode, validateContext, liveBrowserHandle, onLiveBrowserSnapshot } = options;
87962
88028
  const logs2 = [];
@@ -88046,7 +88112,7 @@ test.beforeEach(async ({ page }, testInfo) => {
88046
88112
  await __qaLiveBrowserCapture();
88047
88113
  __qaLiveBrowserInterval = setInterval(() => {
88048
88114
  void __qaLiveBrowserCapture?.();
88049
- }, 500);
88115
+ }, ${LIVE_BROWSER_STATE_POLL_INTERVAL_MS});
88050
88116
  });
88051
88117
 
88052
88118
  test.afterEach(async () => {
@@ -88463,7 +88529,7 @@ ${passed ? "\u2705" : "\u274C"} Test ${passed ? "PASSED" : "FAILED"}${error2 ? `
88463
88529
  };
88464
88530
  const interval = setInterval(() => {
88465
88531
  void poll();
88466
- }, 1e3);
88532
+ }, LIVE_BROWSER_STATE_POLL_INTERVAL_MS);
88467
88533
  interval.unref?.();
88468
88534
  void poll();
88469
88535
  return async () => {
@@ -320987,6 +321053,7 @@ var require_primary_action_ledger = __commonJS({
320987
321053
  exports2.isLedgerActive = isLedgerActive;
320988
321054
  exports2.isGateEnforcing = isGateEnforcing;
320989
321055
  exports2.isSteeringActive = isSteeringActive;
321056
+ exports2.isPrimaryActionCoverageGapEnabled = isPrimaryActionCoverageGapEnabled;
320990
321057
  exports2.leadingVerb = leadingVerb;
320991
321058
  exports2.classifyPrimaryAction = classifyPrimaryAction;
320992
321059
  exports2.containerScopeOf = containerScopeOf;
@@ -321001,6 +321068,8 @@ var require_primary_action_ledger = __commonJS({
321001
321068
  exports2.classifyActionOutcome = classifyActionOutcome;
321002
321069
  exports2.detectPrimaryActions = detectPrimaryActions;
321003
321070
  exports2.summarizeLedger = summarizeLedger;
321071
+ exports2.summarizeUnexercisedPrimaries = summarizeUnexercisedPrimaries;
321072
+ exports2.buildUnexercisedPrimaryCoverageGaps = buildUnexercisedPrimaryCoverageGaps;
321004
321073
  var reveal_diff_js_1 = require_reveal_diff();
321005
321074
  var exploration_parser_js_1 = require_exploration_parser();
321006
321075
  function getPrimaryActionGateMode(env = process.env) {
@@ -321020,6 +321089,10 @@ var require_primary_action_ledger = __commonJS({
321020
321089
  function isSteeringActive(mode) {
321021
321090
  return mode === "advisory" || mode === "enforce";
321022
321091
  }
321092
+ function isPrimaryActionCoverageGapEnabled(env = process.env) {
321093
+ const raw = (env.DISCOVERY_PRIMARY_ACTION_COVERAGE_GAP ?? "").trim().toLowerCase();
321094
+ return raw === "1" || raw === "true" || raw === "on" || raw === "enabled";
321095
+ }
321023
321096
  exports2.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;
321024
321097
  exports2.AMBIGUOUS_ACTION_VERB_RE = /^(continue|next|apply|go|proceed|confirm|update|add|edit|ok|done|finish|complete|approve|enable|activate)\b/i;
321025
321098
  exports2.DESTRUCTIVE_ACTION_VERB_RE = /^(delete|remove|archive|reset|disconnect|deactivate|destroy|purge|revoke|discard|drop|wipe|erase|unsubscribe|cancel|clear|trash)\b/i;
@@ -321283,6 +321356,47 @@ var require_primary_action_ledger = __commonJS({
321283
321356
  }
321284
321357
  return `actions=${c.total} primary{detected=${c.detected} attempted=${c.attempted} captured=${c.captured} blocked=${c.blocked} exempt=${c.exempt}} ambiguous=${c.ambiguous}`;
321285
321358
  }
321359
+ function summarizeUnexercisedPrimaries(ledger, routeInScope, cap = 12) {
321360
+ const out = [];
321361
+ for (const e of getBlockingActionDebt(ledger, routeInScope)) {
321362
+ if (e.status !== "DETECTED" && e.status !== "ATTEMPTED")
321363
+ continue;
321364
+ out.push({ label: e.label, route: e.route, containerScope: e.containerScope, status: e.status });
321365
+ if (out.length >= cap)
321366
+ break;
321367
+ }
321368
+ return out;
321369
+ }
321370
+ var REGEXP_META_RE = /[.*+?^${}()|[\]\\]/g;
321371
+ function labelCoveredByPlan(normLabel, plannedLower) {
321372
+ if (!normLabel.includes(" "))
321373
+ return false;
321374
+ const escaped = normLabel.replace(REGEXP_META_RE, "\\$&");
321375
+ return new RegExp(`(^|\\W)${escaped}($|\\W)`).test(plannedLower);
321376
+ }
321377
+ function buildUnexercisedPrimaryCoverageGaps(unexercised, plannedScenarioText, nowIso) {
321378
+ const gaps = [];
321379
+ const seen = /* @__PURE__ */ new Set();
321380
+ const planned = plannedScenarioText.toLowerCase();
321381
+ for (const a of unexercised) {
321382
+ const norm = normLabelKey(a.label);
321383
+ const key = `${norm}|${a.route}`;
321384
+ if (seen.has(key))
321385
+ continue;
321386
+ seen.add(key);
321387
+ if (labelCoveredByPlan(norm, planned))
321388
+ continue;
321389
+ const clickedNote = a.status === "ATTEMPTED" ? "clicked, but no result state was captured" : "never clicked";
321390
+ gaps.push({
321391
+ scenarioName: `Exercise "${a.label}"`,
321392
+ reason: `Primary action "${a.label}" was detected on ${a.route} but never exercised to an observed result (${clickedNote}).`,
321393
+ 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.`,
321394
+ unmetPrerequisites: [],
321395
+ detectedAt: nowIso
321396
+ });
321397
+ }
321398
+ return gaps;
321399
+ }
321286
321400
  }
321287
321401
  });
321288
321402
 
@@ -322771,6 +322885,9 @@ var require_exploration_prompt_builder = __commonJS({
322771
322885
  "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).",
322772
322886
  `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:`
322773
322887
  ];
322888
+ if ((0, primary_action_ledger_js_1.isPrimaryActionCoverageGapEnabled)()) {
322889
+ 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.");
322890
+ }
322774
322891
  for (const e of debt.slice(0, 8)) {
322775
322892
  const note = e.status === "ATTEMPTED" ? " \u2014 clicked but no result captured yet" : "";
322776
322893
  out.push(` - "${e.label}" on ${e.route}${note}`);
@@ -326591,6 +326708,7 @@ ${inbox_tools_js_1.EMAIL_VERIFICATION_UNAVAILABLE_OVERRIDE_BLOCK}` : ""}`;
326591
326708
  const primaryActionMode = (0, primary_action_ledger_js_1.getPrimaryActionGateMode)();
326592
326709
  const primaryActionLedgerActive = (0, primary_action_ledger_js_1.isLedgerActive)(primaryActionMode) && options?.mode === "deep";
326593
326710
  const primaryActionEnforcing = primaryActionLedgerActive && (0, primary_action_ledger_js_1.isGateEnforcing)(primaryActionMode);
326711
+ const primaryActionCoverageGapEnabled = primaryActionLedgerActive && (0, primary_action_ledger_js_1.isPrimaryActionCoverageGapEnabled)();
326594
326712
  const prevRouteSignatures = /* @__PURE__ */ new Map();
326595
326713
  const prevInteractionCountsByRoute = /* @__PURE__ */ new Map();
326596
326714
  let previousCaptureRoute = null;
@@ -326601,6 +326719,16 @@ ${inbox_tools_js_1.EMAIL_VERIFICATION_UNAVAILABLE_OVERRIDE_BLOCK}` : ""}`;
326601
326719
  let finishBounce = (0, survey_link_frontier_js_1.initialFinishBounceState)();
326602
326720
  let lastLandedPageUrl = null;
326603
326721
  const state2 = (0, exploration_state_js_1.createExplorationState)();
326722
+ const buildUnexercisedPrimarySummary = () => {
326723
+ if (!primaryActionCoverageGapEnabled)
326724
+ return void 0;
326725
+ const fa = options?.focusAreas ?? [];
326726
+ if (fa.length === 0)
326727
+ return void 0;
326728
+ const otherRoutes = options?.featureContext?.otherFeatures?.flatMap((f) => f.pages);
326729
+ const summary2 = (0, primary_action_ledger_js_1.summarizeUnexercisedPrimaries)(state2.actionLedger, (r) => (0, exploration_parser_js_1.routeIsWithinFocusAreas)(r, fa, otherRoutes), 12);
326730
+ return summary2.length > 0 ? summary2 : void 0;
326731
+ };
326604
326732
  function ingestLinksFromText(rawText) {
326605
326733
  if (!linkFrontierEnabled || !rawText || !rawText.includes("- /url:"))
326606
326734
  return;
@@ -328831,6 +328959,7 @@ Exploration ended after ${iteration} iterations without calling finish_explorati
328831
328959
  summary: state2.summary || summary,
328832
328960
  collectedPages: state2.collectedPages,
328833
328961
  collectedTransitions: state2.collectedTransitions,
328962
+ unexercisedPrimaryActions: buildUnexercisedPrimarySummary(),
328834
328963
  iterationTimeline: iterationTimeline.length > 0 ? iterationTimeline : void 0
328835
328964
  };
328836
328965
  }
@@ -328925,6 +329054,7 @@ Fatal error: ${msg}`);
328925
329054
  summary: `Fatal error: ${msg}`,
328926
329055
  collectedPages: state2.collectedPages,
328927
329056
  collectedTransitions: state2.collectedTransitions,
329057
+ unexercisedPrimaryActions: buildUnexercisedPrimarySummary(),
328928
329058
  iterationTimeline: iterationTimeline.length > 0 ? iterationTimeline : void 0,
328929
329059
  truncated: true,
328930
329060
  truncatedReason,
@@ -337111,6 +337241,7 @@ var require_discover_agent = __commonJS({
337111
337241
  var discover_planner_js_1 = require_discover_planner();
337112
337242
  var discover_generator_js_1 = require_discover_generator();
337113
337243
  var discover_ai_planner_js_1 = require_discover_ai_planner();
337244
+ var primary_action_ledger_js_1 = require_primary_action_ledger();
337114
337245
  var discovery_lint_flags_js_1 = require_discovery_lint_flags();
337115
337246
  var verification_inbox_js_1 = require_verification_inbox();
337116
337247
  var inbox_tools_js_1 = require_inbox_tools();
@@ -338413,6 +338544,16 @@ Plan: ${planResult.scenarios.length} scenarios (budget: ${featureTestBudget})`);
338413
338544
  checkpointFallbackReason = planResult.fallbackReason;
338414
338545
  plannerWarning = planResult.warning;
338415
338546
  plannerCoverageGaps = planResult.coverageGaps;
338547
+ const unexercisedPrimaries = exploreResult.unexercisedPrimaryActions ?? [];
338548
+ if (unexercisedPrimaries.length > 0) {
338549
+ const plannedText = planResult.scenarios.map((s) => `${s.name} ${s.goal ?? ""} ${(s.targetPages ?? []).join(" ")}`).join(" ");
338550
+ const ledgerGaps = (0, primary_action_ledger_js_1.buildUnexercisedPrimaryCoverageGaps)(unexercisedPrimaries, plannedText, (/* @__PURE__ */ new Date()).toISOString());
338551
+ if (ledgerGaps.length > 0) {
338552
+ plannerCoverageGaps = [...plannerCoverageGaps ?? [], ...ledgerGaps];
338553
+ }
338554
+ const suppressed = unexercisedPrimaries.length - ledgerGaps.length;
338555
+ 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)` : ""}`);
338556
+ }
338416
338557
  }
338417
338558
  if (callbackOptions?.onPlanReady) {
338418
338559
  try {
@@ -352104,16 +352245,16 @@ var require_live_browser_registry = __commonJS({
352104
352245
  "use strict";
352105
352246
  Object.defineProperty(exports2, "__esModule", { value: true });
352106
352247
  exports2.liveBrowserRegistry = void 0;
352248
+ var shared_1 = (init_dist(), __toCommonJS(dist_exports));
352107
352249
  var THUMBNAIL_INTERVAL_MS = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_INTERVAL_MS ?? "4000", 10) || 4e3;
352108
352250
  var THUMBNAIL_QUALITY = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_QUALITY ?? "60", 10) || 60;
352109
- var BROWSER_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_MAX_BASE64_BYTES ?? "400000", 10) || 4e5;
352110
- var MOBILE_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_MOBILE_THUMBNAIL_MAX_BASE64_BYTES ?? "4500000", 10) || 45e5;
352111
352251
  var STALE_SESSION_MS = parseInt(process.env.LIVE_BROWSER_STALE_MS ?? `${10 * 60 * 1e3}`, 10) || 10 * 60 * 1e3;
352112
352252
  var MAX_SESSIONS = parseInt(process.env.LIVE_BROWSER_MAX_SESSIONS ?? "32", 10) || 32;
352113
352253
  var LiveBrowserRegistry = class {
352114
352254
  entries = /* @__PURE__ */ new Map();
352115
352255
  snapshotTimer = null;
352116
352256
  capturing = false;
352257
+ publisher = null;
352117
352258
  /** Register a new session. Returns a handle the caller must hold and
352118
352259
  * ultimately call .unregister() on. */
352119
352260
  register(meta) {
@@ -352127,7 +352268,10 @@ var require_live_browser_registry = __commonJS({
352127
352268
  lastUpdatedAt: now,
352128
352269
  page: null,
352129
352270
  isCapturing: false,
352130
- pendingCapture: false
352271
+ pendingCapture: false,
352272
+ screencast: null,
352273
+ screencastStarting: false,
352274
+ lastPublishedAt: 0
352131
352275
  };
352132
352276
  this.entries.set(meta.sessionId, entry);
352133
352277
  this.ensureSnapshotLoop();
@@ -352140,19 +352284,40 @@ var require_live_browser_registry = __commonJS({
352140
352284
  unregister: () => this.unregister(meta.sessionId)
352141
352285
  };
352142
352286
  }
352287
+ /**
352288
+ * Install the real-time frame sink. Idempotent; the runner calls this once at
352289
+ * startup, before any session registers.
352290
+ */
352291
+ setFramePublisher(publisher) {
352292
+ this.publisher = publisher;
352293
+ }
352143
352294
  unregister(sessionId) {
352144
352295
  const entry = this.entries.get(sessionId);
352145
352296
  if (!entry)
352146
352297
  return;
352147
- entry.page = null;
352148
- this.entries.delete(sessionId);
352298
+ this.dropEntry(sessionId, entry);
352149
352299
  if (this.entries.size === 0)
352150
352300
  this.stopSnapshotLoop();
352151
352301
  }
352302
+ /**
352303
+ * The ONLY way an entry leaves `entries`. Centralised because a screencast
352304
+ * outlives the map otherwise: its frame handler closes over the entry rather
352305
+ * than looking it up, so an entry deleted without stopScreencast() leaves
352306
+ * Chromium encoding and acking frames for a page nobody is watching, holding
352307
+ * the CDP session and Page alive. Eviction and the staleness sweep both used
352308
+ * to delete directly and leaked exactly that way.
352309
+ */
352310
+ dropEntry(sessionId, entry) {
352311
+ void this.stopScreencast(entry);
352312
+ entry.page = null;
352313
+ this.entries.delete(sessionId);
352314
+ }
352152
352315
  setPage(sessionId, page) {
352153
352316
  const entry = this.entries.get(sessionId);
352154
352317
  if (!entry)
352155
352318
  return;
352319
+ if (entry.screencast && entry.screencast.page !== page)
352320
+ void this.stopScreencast(entry);
352156
352321
  entry.page = page;
352157
352322
  entry.lastUpdatedAt = Date.now();
352158
352323
  if (page) {
@@ -352162,6 +352327,119 @@ var require_live_browser_registry = __commonJS({
352162
352327
  entry.currentUrl = url;
352163
352328
  } catch {
352164
352329
  }
352330
+ void this.startScreencast(entry, page);
352331
+ }
352332
+ }
352333
+ /**
352334
+ * Begin a CDP screencast for this page. Chromium then pushes a JPEG on every
352335
+ * paint, already downscaled to LIVE_FRAME_MAX_DIMENSION — which is the only
352336
+ * way this runner can downscale at all, since it ships no image library.
352337
+ *
352338
+ * Best-effort by design: any failure leaves `screencast` null and the polling
352339
+ * screenshot loop keeps serving the entry exactly as before.
352340
+ */
352341
+ async startScreencast(entry, page) {
352342
+ if (!page || entry.screencast || entry.screencastStarting)
352343
+ return;
352344
+ if (page.isClosed?.())
352345
+ return;
352346
+ entry.screencastStarting = true;
352347
+ try {
352348
+ const client = await page.context().newCDPSession(page);
352349
+ const binding = { client, page, stopped: false };
352350
+ client.on("Page.screencastFrame", (params) => {
352351
+ void client.send("Page.screencastFrameAck", { sessionId: params.sessionId }).catch(() => {
352352
+ });
352353
+ if (binding.stopped)
352354
+ return;
352355
+ if (this.entries.get(entry.meta.sessionId) !== entry)
352356
+ return;
352357
+ if (typeof params.data !== "string" || params.data.length === 0)
352358
+ return;
352359
+ const width = params.metadata?.deviceWidth;
352360
+ const height = params.metadata?.deviceHeight;
352361
+ this.acceptFrame(entry, params.data, "image/jpeg", width, height);
352362
+ });
352363
+ page.once("close", () => {
352364
+ void this.stopScreencast(entry);
352365
+ });
352366
+ page.once("crash", () => {
352367
+ void this.stopScreencast(entry);
352368
+ });
352369
+ await client.send("Page.enable");
352370
+ await client.send("Page.startScreencast", {
352371
+ format: "jpeg",
352372
+ quality: shared_1.LIVE_FRAME_JPEG_QUALITY,
352373
+ // Absolute bounds, aspect-preserving — Chromium does the resize.
352374
+ maxWidth: shared_1.LIVE_FRAME_MAX_DIMENSION,
352375
+ maxHeight: shared_1.LIVE_FRAME_MAX_DIMENSION,
352376
+ everyNthFrame: 1
352377
+ });
352378
+ if (this.entries.get(entry.meta.sessionId) !== entry || entry.page !== page) {
352379
+ binding.stopped = true;
352380
+ await client.send("Page.stopScreencast").catch(() => {
352381
+ });
352382
+ await client.detach().catch(() => {
352383
+ });
352384
+ return;
352385
+ }
352386
+ entry.screencast = binding;
352387
+ } catch {
352388
+ } finally {
352389
+ entry.screencastStarting = false;
352390
+ }
352391
+ }
352392
+ async stopScreencast(entry) {
352393
+ const binding = entry.screencast;
352394
+ if (!binding || binding.stopped)
352395
+ return;
352396
+ binding.stopped = true;
352397
+ entry.screencast = null;
352398
+ try {
352399
+ await binding.client.send("Page.stopScreencast");
352400
+ } catch {
352401
+ }
352402
+ try {
352403
+ await binding.client.detach();
352404
+ } catch {
352405
+ }
352406
+ }
352407
+ /**
352408
+ * Record a freshly captured frame and forward it to the real-time publisher.
352409
+ * The stored copy (used by the heartbeat fallback) is only replaced when it
352410
+ * fits the heartbeat budget, but the publisher — which has a far larger budget
352411
+ * of its own — always sees the frame.
352412
+ */
352413
+ acceptFrame(entry, base64, mimeType, width, height) {
352414
+ const now = Date.now();
352415
+ entry.lastUpdatedAt = now;
352416
+ if (base64.length <= shared_1.LIVE_THUMBNAIL_MAX_BASE64_BYTES) {
352417
+ entry.thumbnailJpegBase64 = base64;
352418
+ entry.thumbnailCapturedAt = now;
352419
+ if (width !== void 0)
352420
+ entry.thumbnailWidth = width;
352421
+ if (height !== void 0)
352422
+ entry.thumbnailHeight = height;
352423
+ }
352424
+ const publisher = this.publisher;
352425
+ if (!publisher)
352426
+ return;
352427
+ if (now - entry.lastPublishedAt < shared_1.LIVE_FRAME_MIN_PUSH_INTERVAL_MS)
352428
+ return;
352429
+ entry.lastPublishedAt = now;
352430
+ try {
352431
+ publisher({
352432
+ sessionId: entry.meta.sessionId,
352433
+ projectId: entry.meta.projectId,
352434
+ runId: entry.meta.runId,
352435
+ capturedAt: now,
352436
+ mimeType,
352437
+ width,
352438
+ height,
352439
+ surface: entry.meta.surface,
352440
+ base64
352441
+ });
352442
+ } catch {
352165
352443
  }
352166
352444
  }
352167
352445
  setCurrentUrl(sessionId, url) {
@@ -352173,7 +352451,7 @@ var require_live_browser_registry = __commonJS({
352173
352451
  const previousUrl = entry.currentUrl;
352174
352452
  entry.currentUrl = url;
352175
352453
  entry.lastUpdatedAt = Date.now();
352176
- if (url && previousUrl && url !== previousUrl) {
352454
+ if (url && previousUrl && url !== previousUrl && !entry.screencast) {
352177
352455
  entry.pendingCapture = true;
352178
352456
  const schedule = (delay) => {
352179
352457
  setTimeout(() => {
@@ -352194,6 +352472,11 @@ var require_live_browser_registry = __commonJS({
352194
352472
  schedule(100);
352195
352473
  }
352196
352474
  }
352475
+ /**
352476
+ * Ingest a frame captured outside this module — the mobile mirror and the
352477
+ * spawned `playwright test` child (whose Page lives in another process, so no
352478
+ * CDP screencast is reachable from here) both arrive this way.
352479
+ */
352197
352480
  updateSnapshot(sessionId, snapshot) {
352198
352481
  const entry = this.entries.get(sessionId);
352199
352482
  if (!entry)
@@ -352202,12 +352485,10 @@ var require_live_browser_registry = __commonJS({
352202
352485
  entry.currentUrl = snapshot.currentUrl;
352203
352486
  }
352204
352487
  const thumbnail = snapshot.thumbnailJpegBase64;
352205
- const thumbnailLimit = entry.meta.surface === "mobile" ? MOBILE_THUMBNAIL_MAX_BASE64_BYTES : BROWSER_THUMBNAIL_MAX_BASE64_BYTES;
352206
- if (thumbnail && thumbnail.length <= thumbnailLimit) {
352207
- entry.thumbnailJpegBase64 = thumbnail;
352208
- entry.thumbnailCapturedAt = typeof snapshot.thumbnailCapturedAt === "number" && Number.isFinite(snapshot.thumbnailCapturedAt) ? snapshot.thumbnailCapturedAt : Date.now();
352209
- entry.thumbnailWidth = typeof snapshot.thumbnailWidth === "number" && Number.isFinite(snapshot.thumbnailWidth) ? snapshot.thumbnailWidth : void 0;
352210
- entry.thumbnailHeight = typeof snapshot.thumbnailHeight === "number" && Number.isFinite(snapshot.thumbnailHeight) ? snapshot.thumbnailHeight : void 0;
352488
+ if (thumbnail) {
352489
+ const width = typeof snapshot.thumbnailWidth === "number" && Number.isFinite(snapshot.thumbnailWidth) ? snapshot.thumbnailWidth : void 0;
352490
+ const height = typeof snapshot.thumbnailHeight === "number" && Number.isFinite(snapshot.thumbnailHeight) ? snapshot.thumbnailHeight : void 0;
352491
+ this.acceptFrame(entry, thumbnail, snapshot.mimeType ?? "image/jpeg", width, height);
352211
352492
  }
352212
352493
  entry.lastUpdatedAt = Date.now();
352213
352494
  }
@@ -352250,9 +352531,11 @@ var require_live_browser_registry = __commonJS({
352250
352531
  async captureAllNow() {
352251
352532
  await this.captureAll();
352252
352533
  }
352253
- /** Stop the snapshot loop and clear all entries. Idempotent. */
352534
+ /** Stop the snapshot loop, tear down every screencast, and clear all entries. Idempotent. */
352254
352535
  shutdown() {
352255
352536
  this.stopSnapshotLoop();
352537
+ for (const entry of this.entries.values())
352538
+ void this.stopScreencast(entry);
352256
352539
  this.entries.clear();
352257
352540
  }
352258
352541
  ensureSnapshotLoop() {
@@ -352280,15 +352563,13 @@ var require_live_browser_registry = __commonJS({
352280
352563
  }
352281
352564
  }
352282
352565
  if (oldestKey) {
352283
- this.entries.get(oldestKey).page = null;
352284
- this.entries.delete(oldestKey);
352566
+ this.dropEntry(oldestKey, this.entries.get(oldestKey));
352285
352567
  }
352286
352568
  }
352287
352569
  sweepStale(now) {
352288
352570
  for (const [key, entry] of this.entries) {
352289
352571
  if (now - entry.lastUpdatedAt > STALE_SESSION_MS) {
352290
- entry.page = null;
352291
- this.entries.delete(key);
352572
+ this.dropEntry(key, entry);
352292
352573
  }
352293
352574
  }
352294
352575
  }
@@ -352329,6 +352610,11 @@ var require_live_browser_registry = __commonJS({
352329
352610
  entry.currentUrl = url;
352330
352611
  } catch {
352331
352612
  }
352613
+ if (entry.screencast) {
352614
+ if (entry.thumbnailJpegBase64)
352615
+ entry.thumbnailCapturedAt = Date.now();
352616
+ return;
352617
+ }
352332
352618
  const buf = await page.screenshot({
352333
352619
  type: "jpeg",
352334
352620
  quality: THUMBNAIL_QUALITY,
@@ -352336,17 +352622,8 @@ var require_live_browser_registry = __commonJS({
352336
352622
  timeout: 3e3,
352337
352623
  scale: "css"
352338
352624
  });
352339
- const base64 = buf.toString("base64");
352340
- if (base64.length > BROWSER_THUMBNAIL_MAX_BASE64_BYTES) {
352341
- return;
352342
- }
352343
- entry.thumbnailJpegBase64 = base64;
352344
- entry.thumbnailCapturedAt = Date.now();
352345
352625
  const viewport = page.viewportSize?.();
352346
- if (viewport) {
352347
- entry.thumbnailWidth = viewport.width;
352348
- entry.thumbnailHeight = viewport.height;
352349
- }
352626
+ this.acceptFrame(entry, buf.toString("base64"), "image/jpeg", viewport?.width, viewport?.height);
352350
352627
  } catch {
352351
352628
  } finally {
352352
352629
  entry.isCapturing = false;
@@ -353060,7 +353337,7 @@ var require_package4 = __commonJS({
353060
353337
  "package.json"(exports2, module2) {
353061
353338
  module2.exports = {
353062
353339
  name: "@validate.qa/runner",
353063
- version: "1.0.78",
353340
+ version: "1.0.80",
353064
353341
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353065
353342
  bin: {
353066
353343
  "validate-runner": "dist/cli.js"
@@ -353329,6 +353606,9 @@ async function enforceRunnerUpToDate(currentVersion, opts = {}) {
353329
353606
  return result;
353330
353607
  }
353331
353608
 
353609
+ // src/runner.ts
353610
+ init_dist();
353611
+
353332
353612
  // src/services/appium-executor.ts
353333
353613
  init_dist();
353334
353614
 
@@ -353852,6 +354132,24 @@ async function getScreenshot(sessionPath) {
353852
354132
  return null;
353853
354133
  }
353854
354134
  }
354135
+ async function applyMjpegSettings(sessionPath, settings) {
354136
+ try {
354137
+ await appiumRequest(`${sessionPath}/appium/settings`, {
354138
+ method: "POST",
354139
+ body: JSON.stringify({
354140
+ settings: {
354141
+ mjpegServerFramerate: settings.framerate,
354142
+ mjpegScalingFactor: settings.scalingFactor,
354143
+ mjpegServerScreenshotQuality: settings.quality
354144
+ }
354145
+ })
354146
+ });
354147
+ return true;
354148
+ } catch (err) {
354149
+ if (isAppiumDriverDeadError(err)) throw err;
354150
+ return false;
354151
+ }
354152
+ }
353855
354153
  async function getPageSource(sessionPath) {
353856
354154
  try {
353857
354155
  const value = readValue(await appiumRequest(`${sessionPath}/source`));
@@ -356360,7 +356658,15 @@ function mobileParallelSessionCapabilities(platform3, portOffset = 0, deviceId =
356360
356658
  return (Number.isFinite(parsed) ? parsed : fallback) + offset;
356361
356659
  };
356362
356660
  if (platform3 === "ANDROID") {
356363
- return { "appium:systemPort": readPort("APPIUM_SYSTEM_PORT", 8200) };
356661
+ return {
356662
+ "appium:systemPort": readPort("APPIUM_SYSTEM_PORT", 8200),
356663
+ // Host port that UiAutomator2 forwards the on-device MJPEG broadcaster
356664
+ // (device port 7810) to. Without this the live mirror has to poll
356665
+ // WebDriver /screenshot for full-resolution PNGs, which the runner cannot
356666
+ // downscale itself. Base differs from iOS's 9100 so a concurrent
356667
+ // Android+iOS pair can never collide on a shared slot offset.
356668
+ "appium:mjpegServerPort": readPort("APPIUM_ANDROID_MJPEG_SERVER_PORT", 7810)
356669
+ };
356364
356670
  }
356365
356671
  const safeDevice = deviceId.replace(/[^A-Za-z0-9._-]+/g, "-") || `device-${offset}`;
356366
356672
  const derivedDataRoot = env.APPIUM_DERIVED_DATA_PATH?.trim() || (0, import_node_path2.join)((0, import_node_os2.tmpdir)(), "validateqa-wda");
@@ -357615,6 +357921,15 @@ function acquireControlPortSlot(deviceId) {
357615
357921
  function releaseControlPortSlot(deviceId) {
357616
357922
  controlPortSlotsByDevice.delete(deviceId);
357617
357923
  }
357924
+ function getControlPortSlot(deviceId) {
357925
+ return controlPortSlotsByDevice.get(deviceId);
357926
+ }
357927
+ function mobileMjpegPortForDevice(platform3, deviceId, env = process.env) {
357928
+ const slot = getControlPortSlot(deviceId);
357929
+ if (slot === void 0) return null;
357930
+ const port = mobileParallelSessionCapabilities(platform3, slot, deviceId, env)["appium:mjpegServerPort"];
357931
+ return typeof port === "number" && Number.isFinite(port) ? port : null;
357932
+ }
357618
357933
  var PINNED_DEVICE_SNAPSHOT_TTL_MS = 25 * 6e4;
357619
357934
  var pinnedDeviceSnapshot = null;
357620
357935
  async function executeMobileTest(options) {
@@ -358553,14 +358868,109 @@ function decideMobileDeviceGate(input) {
358553
358868
  }
358554
358869
 
358555
358870
  // src/services/mobile-bridge.ts
358556
- var import_http = require("http");
358871
+ var import_http2 = require("http");
358557
358872
  var import_child_process4 = require("child_process");
358558
358873
  var import_crypto = require("crypto");
358559
358874
  var import_os4 = require("os");
358560
358875
  var import_path4 = require("path");
358561
358876
  var import_fs3 = require("fs");
358877
+
358878
+ // src/services/mobile/mjpeg-stream.ts
358879
+ var import_http = require("http");
358880
+ init_dist();
358881
+ var JPEG_SOI = Buffer.from([255, 216]);
358882
+ var JPEG_EOI = Buffer.from([255, 217]);
358883
+ function startMjpegStream(options) {
358884
+ const { port, host = "127.0.0.1", onFrame, onError } = options;
358885
+ let stopped = false;
358886
+ let req = null;
358887
+ let response = null;
358888
+ let buffer = Buffer.alloc(0);
358889
+ const fail = (error2) => {
358890
+ if (stopped) return;
358891
+ stopped = true;
358892
+ try {
358893
+ req?.destroy();
358894
+ } catch {
358895
+ }
358896
+ try {
358897
+ response?.destroy();
358898
+ } catch {
358899
+ }
358900
+ buffer = Buffer.alloc(0);
358901
+ onError?.(error2);
358902
+ };
358903
+ const consume = (chunk) => {
358904
+ if (stopped) return;
358905
+ buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]);
358906
+ for (; ; ) {
358907
+ const start = buffer.indexOf(JPEG_SOI);
358908
+ if (start < 0) {
358909
+ if (buffer.length > 1) buffer = buffer.subarray(buffer.length - 1);
358910
+ return;
358911
+ }
358912
+ const end = buffer.indexOf(JPEG_EOI, start + JPEG_SOI.length);
358913
+ if (end < 0) {
358914
+ if (start > 0) buffer = buffer.subarray(start);
358915
+ if (buffer.length > MOBILE_MJPEG_MAX_FRAME_BYTES) buffer = Buffer.alloc(0);
358916
+ return;
358917
+ }
358918
+ const frameEnd = end + JPEG_EOI.length;
358919
+ const frame = buffer.subarray(start, frameEnd);
358920
+ buffer = buffer.subarray(frameEnd);
358921
+ try {
358922
+ onFrame(frame);
358923
+ } catch {
358924
+ }
358925
+ }
358926
+ };
358927
+ try {
358928
+ req = (0, import_http.request)(
358929
+ { host, port, path: "/", method: "GET", headers: { Connection: "keep-alive" } },
358930
+ (res) => {
358931
+ response = res;
358932
+ if (stopped) {
358933
+ res.destroy();
358934
+ return;
358935
+ }
358936
+ if (res.statusCode && res.statusCode >= 400) {
358937
+ fail(new Error(`MJPEG broadcaster returned HTTP ${res.statusCode}`));
358938
+ res.destroy();
358939
+ return;
358940
+ }
358941
+ res.on("data", (chunk) => consume(chunk));
358942
+ res.on("end", () => fail(new Error("MJPEG stream ended")));
358943
+ res.on("error", (err) => fail(err));
358944
+ }
358945
+ );
358946
+ req.on("error", (err) => fail(err));
358947
+ req.setTimeout(1e4, () => fail(new Error("MJPEG connect timeout")));
358948
+ req.end();
358949
+ } catch (err) {
358950
+ fail(err instanceof Error ? err : new Error(String(err)));
358951
+ }
358952
+ return {
358953
+ stop: () => {
358954
+ if (stopped) return;
358955
+ stopped = true;
358956
+ try {
358957
+ req?.destroy();
358958
+ } catch {
358959
+ }
358960
+ try {
358961
+ response?.destroy();
358962
+ } catch {
358963
+ }
358964
+ buffer = Buffer.alloc(0);
358965
+ },
358966
+ isActive: () => !stopped
358967
+ };
358968
+ }
358969
+
358970
+ // src/services/mobile-bridge.ts
358562
358971
  init_dist();
358563
358972
  var MOBILE_BRIDGE_PORT2 = 9515;
358973
+ var RELAY_DEMAND_POLL_MS = 2e3;
358564
358974
  var MAX_REQUEST_BODY_BYTES = 64 * 1024;
358565
358975
  var MAX_TEXT_INPUT_LENGTH = 4e3;
358566
358976
  var MAX_APP_ID_LENGTH = 255;
@@ -359072,6 +359482,18 @@ async function handleSessionStop(_req, res) {
359072
359482
  sendJSON(res, 200, { ok: true });
359073
359483
  }
359074
359484
  var runnerChannels = /* @__PURE__ */ new Map();
359485
+ var relayDemandTimer = null;
359486
+ function setMirrorRelay(deviceId, relay) {
359487
+ const ch = runnerChannels.get(deviceId);
359488
+ if (!ch) return;
359489
+ ch.relay = relay;
359490
+ if (relay) {
359491
+ ensureRelayDemandSupervisor();
359492
+ if (hasChannelDemand(ch)) ensureChannelPolling(ch);
359493
+ } else {
359494
+ stopChannelCaptureIfIdle(ch);
359495
+ }
359496
+ }
359075
359497
  function readDeviceIdParam(req) {
359076
359498
  try {
359077
359499
  const url = new URL(req.url || "", "http://localhost");
@@ -359103,7 +359525,7 @@ function writeFrameToChannelClient(ch, client, frame) {
359103
359525
  } catch {
359104
359526
  }
359105
359527
  ch.clients.delete(client);
359106
- stopChannelPollingIfIdle(ch);
359528
+ stopChannelCaptureIfIdle(ch);
359107
359529
  return;
359108
359530
  }
359109
359531
  client.write(`data: ${JSON.stringify(frame)}
@@ -359112,16 +359534,95 @@ function writeFrameToChannelClient(ch, client, frame) {
359112
359534
  } catch {
359113
359535
  }
359114
359536
  }
359537
+ function broadcastChannelFrame(ch, frame) {
359538
+ ch.lastFrame = frame;
359539
+ for (const client of ch.clients) writeFrameToChannelClient(ch, client, frame);
359540
+ if (ch.relay && frame.screenshot) {
359541
+ try {
359542
+ ch.relay.onFrame(frame.screenshot, frame.mimeType ?? "image/png");
359543
+ } catch {
359544
+ }
359545
+ }
359546
+ }
359547
+ function tryStartChannelMjpeg(ch) {
359548
+ if (ch.mjpeg || ch.mjpegStarting || ch.mjpegUnavailable) return;
359549
+ const port = mobileMjpegPortForDevice(ch.platform, ch.deviceId);
359550
+ if (port === null) {
359551
+ ch.mjpegUnavailable = true;
359552
+ return;
359553
+ }
359554
+ ch.mjpegStarting = true;
359555
+ const handle = startMjpegStream({
359556
+ port,
359557
+ onFrame: (jpeg) => {
359558
+ const isOwned = ch.mjpeg === handle;
359559
+ const isCandidate = ch.mjpeg === null && ch.mjpegStarting;
359560
+ if (!isOwned && !isCandidate) return;
359561
+ ch.mjpegLastFrameAt = Date.now();
359562
+ ch.nullTicks = 0;
359563
+ if (isCandidate) {
359564
+ ch.mjpeg = handle;
359565
+ ch.mjpegStarting = false;
359566
+ if (ch.pollTimer) {
359567
+ clearInterval(ch.pollTimer);
359568
+ ch.pollTimer = null;
359569
+ }
359570
+ }
359571
+ broadcastChannelFrame(ch, {
359572
+ timestamp: ch.mjpegLastFrameAt,
359573
+ screenshot: jpeg.toString("base64"),
359574
+ source: null,
359575
+ mimeType: "image/jpeg"
359576
+ });
359577
+ },
359578
+ onError: () => {
359579
+ const wasActive = ch.mjpeg === handle;
359580
+ ch.mjpeg = null;
359581
+ ch.mjpegStarting = false;
359582
+ ch.mjpegUnavailable = true;
359583
+ if (wasActive && hasChannelDemand(ch)) ensureChannelPolling(ch);
359584
+ }
359585
+ });
359586
+ const firstFrameDeadline = setTimeout(() => {
359587
+ if (ch.mjpeg) return;
359588
+ ch.mjpegStarting = false;
359589
+ ch.mjpegUnavailable = true;
359590
+ handle.stop();
359591
+ if (hasChannelDemand(ch)) ensureChannelPolling(ch);
359592
+ }, MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS);
359593
+ firstFrameDeadline.unref?.();
359594
+ }
359595
+ function hasChannelDemand(ch) {
359596
+ if (ch.clients.size > 0) return true;
359597
+ if (!ch.relay) return false;
359598
+ try {
359599
+ return ch.relay.wanted();
359600
+ } catch {
359601
+ return false;
359602
+ }
359603
+ }
359604
+ function ensureRelayDemandSupervisor() {
359605
+ if (relayDemandTimer) return;
359606
+ relayDemandTimer = setInterval(() => {
359607
+ for (const ch of [...runnerChannels.values()]) {
359608
+ if (hasChannelDemand(ch)) ensureChannelPolling(ch);
359609
+ else stopChannelCaptureIfIdle(ch);
359610
+ }
359611
+ if (runnerChannels.size === 0 && relayDemandTimer) {
359612
+ clearInterval(relayDemandTimer);
359613
+ relayDemandTimer = null;
359614
+ }
359615
+ }, RELAY_DEMAND_POLL_MS);
359616
+ relayDemandTimer.unref?.();
359617
+ }
359115
359618
  async function pollRunnerChannel(ch) {
359116
- if (ch.clients.size === 0 || ch.polling) return;
359619
+ if (!hasChannelDemand(ch) || ch.polling) return;
359620
+ if (ch.mjpeg) return;
359117
359621
  ch.polling = true;
359118
359622
  try {
359119
359623
  validateSessionId(ch.sessionId);
359120
- const [screenshot, source] = await Promise.all([
359121
- getScreenshot(`/session/${ch.sessionId}`),
359122
- getPageSource(`/session/${ch.sessionId}`)
359123
- ]);
359124
- if (!screenshot && !source) {
359624
+ const screenshot = await getScreenshot(`/session/${ch.sessionId}`);
359625
+ if (!screenshot) {
359125
359626
  ch.nullTicks++;
359126
359627
  if (ch.nullTicks >= MIRROR_DEAD_SESSION_TICKS) {
359127
359628
  const alive = await isSessionAlive(ch.sessionId);
@@ -359134,32 +359635,47 @@ async function pollRunnerChannel(ch) {
359134
359635
  } else {
359135
359636
  ch.nullTicks = 0;
359136
359637
  }
359137
- const frame = { timestamp: Date.now(), screenshot: screenshot ?? null, source: source ?? null };
359138
- ch.lastFrame = frame;
359139
- for (const client of ch.clients) writeFrameToChannelClient(ch, client, frame);
359638
+ broadcastChannelFrame(ch, {
359639
+ timestamp: Date.now(),
359640
+ screenshot: screenshot ?? null,
359641
+ source: null,
359642
+ mimeType: "image/png"
359643
+ });
359140
359644
  } catch {
359141
359645
  } finally {
359142
359646
  ch.polling = false;
359143
359647
  }
359144
359648
  }
359145
359649
  function ensureChannelPolling(ch) {
359146
- if (ch.pollTimer) return;
359650
+ if (!hasChannelDemand(ch)) return;
359651
+ tryStartChannelMjpeg(ch);
359652
+ if (ch.mjpeg || ch.pollTimer) return;
359147
359653
  ch.pollTimer = setInterval(() => {
359148
359654
  void pollRunnerChannel(ch);
359149
359655
  }, MOBILE_SCREENSHOT_INTERVAL_MS);
359150
359656
  ch.pollTimer.unref?.();
359151
359657
  }
359152
- function stopChannelPollingIfIdle(ch) {
359153
- if (ch.clients.size === 0 && ch.pollTimer) {
359658
+ function stopChannelCaptureIfIdle(ch) {
359659
+ if (hasChannelDemand(ch)) return;
359660
+ if (ch.pollTimer) {
359154
359661
  clearInterval(ch.pollTimer);
359155
359662
  ch.pollTimer = null;
359156
359663
  }
359664
+ if (ch.mjpeg) {
359665
+ ch.mjpeg.stop();
359666
+ ch.mjpeg = null;
359667
+ }
359157
359668
  }
359158
359669
  function closeRunnerChannel(ch) {
359159
359670
  if (ch.pollTimer) {
359160
359671
  clearInterval(ch.pollTimer);
359161
359672
  ch.pollTimer = null;
359162
359673
  }
359674
+ if (ch.mjpeg) {
359675
+ ch.mjpeg.stop();
359676
+ ch.mjpeg = null;
359677
+ }
359678
+ ch.relay = null;
359163
359679
  for (const client of ch.clients) {
359164
359680
  try {
359165
359681
  client.end();
@@ -359168,7 +359684,7 @@ function closeRunnerChannel(ch) {
359168
359684
  }
359169
359685
  ch.clients.clear();
359170
359686
  ch.lastFrame = null;
359171
- runnerChannels.delete(ch.deviceId);
359687
+ if (runnerChannels.get(ch.deviceId) === ch) runnerChannels.delete(ch.deviceId);
359172
359688
  }
359173
359689
  function closeAllRunnerChannels() {
359174
359690
  for (const ch of [...runnerChannels.values()]) closeRunnerChannel(ch);
@@ -359190,7 +359706,7 @@ async function serveRunnerMirror(req, res, ch) {
359190
359706
  }
359191
359707
  req.on("close", () => {
359192
359708
  ch.clients.delete(res);
359193
- stopChannelPollingIfIdle(ch);
359709
+ stopChannelCaptureIfIdle(ch);
359194
359710
  });
359195
359711
  }
359196
359712
  async function handleMirror(req, res) {
@@ -359556,7 +360072,7 @@ async function startMobileBridge(options) {
359556
360072
  }
359557
360073
  killStaleBridge();
359558
360074
  return new Promise((resolve2, reject) => {
359559
- server2 = (0, import_http.createServer)(route);
360075
+ server2 = (0, import_http2.createServer)(route);
359560
360076
  server2.on("error", (err) => {
359561
360077
  if (err.code === "EADDRINUSE") {
359562
360078
  reject(new Error(`Port ${port} already in use. Another bridge instance may be running.`));
@@ -359744,13 +360260,26 @@ function attachMirrorSession(sessionId, platform3, deviceId) {
359744
360260
  lastFrame: null,
359745
360261
  pollTimer: null,
359746
360262
  nullTicks: 0,
359747
- polling: false
360263
+ polling: false,
360264
+ mjpeg: null,
360265
+ mjpegLastFrameAt: 0,
360266
+ mjpegStarting: false,
360267
+ mjpegUnavailable: false,
360268
+ relay: null
359748
360269
  };
359749
360270
  ch.sessionId = sessionId;
359750
360271
  ch.platform = platform3;
359751
360272
  ch.nullTicks = 0;
360273
+ if (ch.mjpeg) {
360274
+ ch.mjpeg.stop();
360275
+ ch.mjpeg = null;
360276
+ }
360277
+ ch.mjpegUnavailable = false;
360278
+ ch.mjpegStarting = false;
359752
360279
  runnerChannels.set(deviceId, ch);
359753
- if (ch.clients.size > 0) {
360280
+ void applyMjpegSettings(`/session/${sessionId}`, MOBILE_MJPEG_SETTINGS).catch(() => {
360281
+ });
360282
+ if (hasChannelDemand(ch)) {
359754
360283
  ensureChannelPolling(ch);
359755
360284
  void pollRunnerChannel(ch);
359756
360285
  }
@@ -360722,6 +361251,13 @@ function attachMobileDiscoveryNetworkContext(calls, result, appId) {
360722
361251
  }));
360723
361252
  }
360724
361253
  var runnerPackage = require_package4();
361254
+ var MIRROR_RELAY_FRESH_MS = 3e3;
361255
+ var liveFrameWatchedByProject = /* @__PURE__ */ new Map();
361256
+ var liveFrameIdleProbeAt = /* @__PURE__ */ new Map();
361257
+ var LIVE_FRAME_IDLE_PROBE_MAX_ENTRIES = 256;
361258
+ function isLiveFrameWatched(projectId) {
361259
+ return !projectId || liveFrameWatchedByProject.get(projectId) !== false;
361260
+ }
360725
361261
  var SECRET_VAR_PATTERN = /password|secret|token|api[_]?key|pass\b|credential|bearer|pat\b/i;
360726
361262
  var IS_CLOUD = parseBoolFlag(process.env.RUNNER_CLOUD);
360727
361263
  var RUNNER_NAME = runnerPackage.name ?? "@validate.qa/runner";
@@ -361499,6 +362035,22 @@ async function executeRun(run2, opts, headers, requestLiveBrowserHeartbeat = ()
361499
362035
  testCaseId: run2.testCaseId ?? void 0
361500
362036
  });
361501
362037
  requestLiveBrowserHeartbeat(true);
362038
+ let lastMirrorRelayFrameAt = 0;
362039
+ const attachMirrorWithRelay = (sessionId, platform3, deviceId) => {
362040
+ attachMirrorSession(sessionId, platform3, deviceId);
362041
+ if (!deviceId) return;
362042
+ setMirrorRelay(deviceId, {
362043
+ onFrame: (base64, mimeType) => {
362044
+ lastMirrorRelayFrameAt = Date.now();
362045
+ liveBrowserHandle.updateSnapshot({
362046
+ thumbnailJpegBase64: base64,
362047
+ thumbnailCapturedAt: Date.now(),
362048
+ mimeType
362049
+ });
362050
+ },
362051
+ wanted: () => isLiveFrameWatched(run2.projectId)
362052
+ });
362053
+ };
361502
362054
  const liveBrowserHeartbeatInterval = setInterval(() => {
361503
362055
  liveBrowserHandle.patch({});
361504
362056
  requestLiveBrowserHeartbeat();
@@ -362402,7 +362954,7 @@ ${finalVerifyResult.logs ?? ""}`;
362402
362954
  deeplink: harvestTarget.deeplink
362403
362955
  },
362404
362956
  onSessionReady: (sessionId, deviceId, sessionPlatform) => {
362405
- attachMirrorSession(sessionId, sessionPlatform, deviceId);
362957
+ attachMirrorWithRelay(sessionId, sessionPlatform, deviceId);
362406
362958
  const name = getCachedDeviceSnapshot().find((d) => d.id === deviceId)?.name;
362407
362959
  liveBrowserHandle.patch({ deviceId, ...name ? { deviceName: name } : {} });
362408
362960
  requestLiveBrowserHeartbeat(true);
@@ -362872,7 +363424,7 @@ ${finalVerifyResult.logs ?? ""}`;
362872
363424
  }
362873
363425
  );
362874
363426
  mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
362875
- attachMirrorSession(session.sessionId, platform3, session.deviceId);
363427
+ attachMirrorWithRelay(session.sessionId, platform3, session.deviceId);
362876
363428
  const driver = new MobileBridgeDriver({
362877
363429
  sessionId: session.sessionId,
362878
363430
  platform: platform3,
@@ -362909,7 +363461,7 @@ ${finalVerifyResult.logs ?? ""}`;
362909
363461
  throw new Error("discovery teardown started during session recovery \u2014 replacement session closed");
362910
363462
  }
362911
363463
  session = replacement;
362912
- attachMirrorSession(replacement.sessionId, platform3, replacement.deviceId);
363464
+ attachMirrorWithRelay(replacement.sessionId, platform3, replacement.deviceId);
362913
363465
  liveBrowserHandle.patch({
362914
363466
  deviceId: replacement.deviceId,
362915
363467
  deviceName: replacement.deviceName
@@ -362930,10 +363482,16 @@ ${finalVerifyResult.logs ?? ""}`;
362930
363482
  // UI a reliable mobile fallback when the direct bridge SSE is
362931
363483
  // still opening or blocked by the user's network/tunnel.
362932
363484
  onScreenshot: (base64) => {
363485
+ if (Date.now() - lastMirrorRelayFrameAt < MIRROR_RELAY_FRESH_MS) {
363486
+ liveBrowserHandle.updateSnapshot({ currentUrl: mobileTarget.appId });
363487
+ return;
363488
+ }
362933
363489
  liveBrowserHandle.updateSnapshot({
362934
363490
  currentUrl: mobileTarget.appId,
362935
363491
  thumbnailJpegBase64: base64,
362936
- thumbnailCapturedAt: Date.now()
363492
+ thumbnailCapturedAt: Date.now(),
363493
+ // Appium's screenshot endpoint returns PNG despite the field name.
363494
+ mimeType: "image/png"
362937
363495
  });
362938
363496
  requestLiveBrowserHeartbeat();
362939
363497
  },
@@ -363400,7 +363958,7 @@ ${finalVerifyResult.logs ?? ""}`;
363400
363958
  deeplink: target.deeplink
363401
363959
  });
363402
363960
  liveSession = opened;
363403
- attachMirrorSession(opened.sessionId, target.platform, opened.deviceId);
363961
+ attachMirrorWithRelay(opened.sessionId, target.platform, opened.deviceId);
363404
363962
  bridgeDriver = new MobileBridgeDriver({
363405
363963
  sessionId: opened.sessionId,
363406
363964
  platform: target.platform,
@@ -363590,7 +364148,7 @@ ${finalVerifyResult.logs ?? ""}`;
363590
364148
  // Stream this run to its OWN per-device dashboard tile (keyed by the
363591
364149
  // leased device id) so N parallel Appium runs each show a live mirror.
363592
364150
  onSessionReady: (sessionId, deviceId, sessionPlatform) => {
363593
- attachMirrorSession(sessionId, sessionPlatform, deviceId);
364151
+ attachMirrorWithRelay(sessionId, sessionPlatform, deviceId);
363594
364152
  requestLiveBrowserHeartbeat(true);
363595
364153
  },
363596
364154
  onSessionEnding: (deviceId) => {
@@ -364178,6 +364736,41 @@ async function startRunner(opts) {
364178
364736
  } catch {
364179
364737
  }
364180
364738
  };
364739
+ const liveFramePushInFlight = /* @__PURE__ */ new Set();
364740
+ const publishLiveFrame = (frame) => {
364741
+ if (!frame.projectId) return;
364742
+ if (liveFramePushInFlight.has(frame.sessionId)) return;
364743
+ if (!isLiveFrameWatched(frame.projectId)) {
364744
+ const lastProbeAt = liveFrameIdleProbeAt.get(frame.sessionId) ?? 0;
364745
+ if (frame.capturedAt - lastProbeAt < LIVE_FRAME_IDLE_PUSH_INTERVAL_MS) return;
364746
+ if (liveFrameIdleProbeAt.size >= LIVE_FRAME_IDLE_PROBE_MAX_ENTRIES) {
364747
+ const expiredBefore = frame.capturedAt - LIVE_FRAME_IDLE_PUSH_INTERVAL_MS;
364748
+ for (const [sessionId, probedAt] of liveFrameIdleProbeAt) {
364749
+ if (probedAt >= expiredBefore) break;
364750
+ liveFrameIdleProbeAt.delete(sessionId);
364751
+ }
364752
+ }
364753
+ liveFrameIdleProbeAt.set(frame.sessionId, frame.capturedAt);
364754
+ }
364755
+ liveFramePushInFlight.add(frame.sessionId);
364756
+ const frameProjectId = frame.projectId;
364757
+ void fetch(`${serverUrl}/api/runner/live-frame`, {
364758
+ method: "POST",
364759
+ headers,
364760
+ body: JSON.stringify(frame),
364761
+ signal: AbortSignal.timeout(5e3)
364762
+ }).then(async (res) => {
364763
+ if (!res.ok) return;
364764
+ const ack = await res.json().catch(() => null);
364765
+ if (ack && typeof ack.watching === "boolean") {
364766
+ liveFrameWatchedByProject.set(frameProjectId, ack.watching);
364767
+ }
364768
+ }).catch(() => {
364769
+ }).finally(() => {
364770
+ liveFramePushInFlight.delete(frame.sessionId);
364771
+ });
364772
+ };
364773
+ import_runner_core11.liveBrowserRegistry.setFramePublisher(publishLiveFrame);
364181
364774
  let liveBrowserHeartbeatInFlight = false;
364182
364775
  let liveBrowserHeartbeatPending = false;
364183
364776
  let lastLiveBrowserHeartbeatAt = 0;