@validate.qa/runner 1.0.78 → 1.0.79

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 +467 -45
  2. package/dist/cli.mjs +465 -43
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -1893,6 +1893,56 @@ 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_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_STALE_MS = readEnvInt("LIVE_FRAME_STALE_MS", 3e4);
1932
+ LIVE_FRAME_MAX_SESSIONS_PER_PROJECT = readEnvInt("LIVE_FRAME_MAX_SESSIONS_PER_PROJECT", 32);
1933
+ MOBILE_MJPEG_SETTINGS = {
1934
+ /** Screenshots per second the broadcaster emits (both drivers: range 1-60). */
1935
+ framerate: readEnvInt("MOBILE_MJPEG_FRAMERATE", 10),
1936
+ /** Downscale percentage. UiAutomator2 defaults to 50, XCUITest to 100. */
1937
+ scalingFactor: readEnvInt("MOBILE_MJPEG_SCALING_FACTOR", 50),
1938
+ /** JPEG compression factor (both drivers: range 1-100). */
1939
+ quality: readEnvInt("MOBILE_MJPEG_QUALITY", 50)
1940
+ };
1941
+ MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS = readEnvInt("MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS", 6e3);
1942
+ MOBILE_MJPEG_MAX_FRAME_BYTES = readEnvInt("MOBILE_MJPEG_MAX_FRAME_BYTES", 8 * 1024 * 1024);
1943
+ }
1944
+ });
1945
+
1896
1946
  // ../shared/dist/project-key.js
1897
1947
  function isValidProjectKey(key) {
1898
1948
  return typeof key === "string" && PROJECT_KEY_PATTERN.test(key) && !RESERVED_PROJECT_KEYS.has(key);
@@ -6315,6 +6365,14 @@ __export(dist_exports, {
6315
6365
  GROUP_LABELS: () => GROUP_LABELS,
6316
6366
  LEGACY_TTS_MODEL_MAP: () => LEGACY_TTS_MODEL_MAP,
6317
6367
  LEGACY_TTS_VOICE_ID_MAP: () => LEGACY_TTS_VOICE_ID_MAP,
6368
+ LIVE_FRAME_JPEG_QUALITY: () => LIVE_FRAME_JPEG_QUALITY,
6369
+ LIVE_FRAME_MAX_DIMENSION: () => LIVE_FRAME_MAX_DIMENSION,
6370
+ LIVE_FRAME_MAX_SESSIONS_PER_PROJECT: () => LIVE_FRAME_MAX_SESSIONS_PER_PROJECT,
6371
+ LIVE_FRAME_MIN_PUSH_INTERVAL_MS: () => LIVE_FRAME_MIN_PUSH_INTERVAL_MS,
6372
+ LIVE_FRAME_PUSH_MAX_BASE64_BYTES: () => LIVE_FRAME_PUSH_MAX_BASE64_BYTES,
6373
+ LIVE_FRAME_STALE_MS: () => LIVE_FRAME_STALE_MS,
6374
+ LIVE_FRAME_UNFOCUSED_INTERVAL_MS: () => LIVE_FRAME_UNFOCUSED_INTERVAL_MS,
6375
+ LIVE_THUMBNAIL_MAX_BASE64_BYTES: () => LIVE_THUMBNAIL_MAX_BASE64_BYTES,
6318
6376
  MAX_CREDIT_TOPUP_CENTS: () => MAX_CREDIT_TOPUP_CENTS,
6319
6377
  MAX_DEEPLINK_LENGTH: () => MAX_DEEPLINK_LENGTH,
6320
6378
  MIN_CREDIT_TOPUP_CENTS: () => MIN_CREDIT_TOPUP_CENTS,
@@ -6326,6 +6384,9 @@ __export(dist_exports, {
6326
6384
  MOBILE_DEFAULT_SWIPE_DISTANCE: () => MOBILE_DEFAULT_SWIPE_DISTANCE,
6327
6385
  MOBILE_FOCUS_SETTLE_MS: () => MOBILE_FOCUS_SETTLE_MS,
6328
6386
  MOBILE_MAX_SWIPE_DISTANCE: () => MOBILE_MAX_SWIPE_DISTANCE,
6387
+ MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS: () => MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS,
6388
+ MOBILE_MJPEG_MAX_FRAME_BYTES: () => MOBILE_MJPEG_MAX_FRAME_BYTES,
6389
+ MOBILE_MJPEG_SETTINGS: () => MOBILE_MJPEG_SETTINGS,
6329
6390
  MOBILE_PLATFORMS: () => MOBILE_PLATFORMS,
6330
6391
  MOBILE_POST_ACTION_SETTLE_MS: () => MOBILE_POST_ACTION_SETTLE_MS,
6331
6392
  MOBILE_SCREENSHOT_INTERVAL_MS: () => MOBILE_SCREENSHOT_INTERVAL_MS,
@@ -6438,6 +6499,7 @@ __export(dist_exports, {
6438
6499
  isTestReadyForPlatform: () => isTestReadyForPlatform,
6439
6500
  isTrialExpired: () => isTrialExpired,
6440
6501
  isUIVariant: () => isUIVariant,
6502
+ isValidLiveFrame: () => isValidLiveFrame,
6441
6503
  isValidMobileAppId: () => isValidMobileAppId,
6442
6504
  isValidProjectKey: () => isValidProjectKey,
6443
6505
  isValidSttProvider: () => isValidSttProvider,
@@ -6500,6 +6562,7 @@ var init_dist = __esm({
6500
6562
  "use strict";
6501
6563
  init_types();
6502
6564
  init_constants();
6565
+ init_live_frame();
6503
6566
  init_project_key();
6504
6567
  init_mobile_test_generation();
6505
6568
  init_mobile_step_healer();
@@ -87962,6 +88025,7 @@ var require_playwright_native = __commonJS({
87962
88025
  var PACKAGE_DIR = (0, path_1.resolve)(__dirname, "..", "..");
87963
88026
  var RUNS_DIR = process.env.RUNS_DIR || (0, path_1.join)(PACKAGE_DIR, ".qa-runs");
87964
88027
  var PW_CLI = __require.resolve("@playwright/test/cli");
88028
+ var LIVE_BROWSER_STATE_POLL_INTERVAL_MS = 500;
87965
88029
  async function executeTestViaNativePlaywright2(options) {
87966
88030
  const { playwrightCode, baseUrl, testName, testVariables, authStateCapturePath, storageStatePath, extraHttpHeaders, authStandingMode, validateContext, liveBrowserHandle, onLiveBrowserSnapshot } = options;
87967
88031
  const logs2 = [];
@@ -88051,7 +88115,7 @@ test.beforeEach(async ({ page }, testInfo) => {
88051
88115
  await __qaLiveBrowserCapture();
88052
88116
  __qaLiveBrowserInterval = setInterval(() => {
88053
88117
  void __qaLiveBrowserCapture?.();
88054
- }, 500);
88118
+ }, ${LIVE_BROWSER_STATE_POLL_INTERVAL_MS});
88055
88119
  });
88056
88120
 
88057
88121
  test.afterEach(async () => {
@@ -88468,7 +88532,7 @@ ${passed ? "\u2705" : "\u274C"} Test ${passed ? "PASSED" : "FAILED"}${error2 ? `
88468
88532
  };
88469
88533
  const interval = setInterval(() => {
88470
88534
  void poll();
88471
- }, 1e3);
88535
+ }, LIVE_BROWSER_STATE_POLL_INTERVAL_MS);
88472
88536
  interval.unref?.();
88473
88537
  void poll();
88474
88538
  return async () => {
@@ -352109,16 +352173,16 @@ var require_live_browser_registry = __commonJS({
352109
352173
  "use strict";
352110
352174
  Object.defineProperty(exports, "__esModule", { value: true });
352111
352175
  exports.liveBrowserRegistry = void 0;
352176
+ var shared_1 = (init_dist(), __toCommonJS(dist_exports));
352112
352177
  var THUMBNAIL_INTERVAL_MS = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_INTERVAL_MS ?? "4000", 10) || 4e3;
352113
352178
  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
352179
  var STALE_SESSION_MS = parseInt(process.env.LIVE_BROWSER_STALE_MS ?? `${10 * 60 * 1e3}`, 10) || 10 * 60 * 1e3;
352117
352180
  var MAX_SESSIONS = parseInt(process.env.LIVE_BROWSER_MAX_SESSIONS ?? "32", 10) || 32;
352118
352181
  var LiveBrowserRegistry = class {
352119
352182
  entries = /* @__PURE__ */ new Map();
352120
352183
  snapshotTimer = null;
352121
352184
  capturing = false;
352185
+ publisher = null;
352122
352186
  /** Register a new session. Returns a handle the caller must hold and
352123
352187
  * ultimately call .unregister() on. */
352124
352188
  register(meta) {
@@ -352132,7 +352196,10 @@ var require_live_browser_registry = __commonJS({
352132
352196
  lastUpdatedAt: now,
352133
352197
  page: null,
352134
352198
  isCapturing: false,
352135
- pendingCapture: false
352199
+ pendingCapture: false,
352200
+ screencast: null,
352201
+ screencastStarting: false,
352202
+ lastPublishedAt: 0
352136
352203
  };
352137
352204
  this.entries.set(meta.sessionId, entry);
352138
352205
  this.ensureSnapshotLoop();
@@ -352145,19 +352212,40 @@ var require_live_browser_registry = __commonJS({
352145
352212
  unregister: () => this.unregister(meta.sessionId)
352146
352213
  };
352147
352214
  }
352215
+ /**
352216
+ * Install the real-time frame sink. Idempotent; the runner calls this once at
352217
+ * startup, before any session registers.
352218
+ */
352219
+ setFramePublisher(publisher) {
352220
+ this.publisher = publisher;
352221
+ }
352148
352222
  unregister(sessionId) {
352149
352223
  const entry = this.entries.get(sessionId);
352150
352224
  if (!entry)
352151
352225
  return;
352152
- entry.page = null;
352153
- this.entries.delete(sessionId);
352226
+ this.dropEntry(sessionId, entry);
352154
352227
  if (this.entries.size === 0)
352155
352228
  this.stopSnapshotLoop();
352156
352229
  }
352230
+ /**
352231
+ * The ONLY way an entry leaves `entries`. Centralised because a screencast
352232
+ * outlives the map otherwise: its frame handler closes over the entry rather
352233
+ * than looking it up, so an entry deleted without stopScreencast() leaves
352234
+ * Chromium encoding and acking frames for a page nobody is watching, holding
352235
+ * the CDP session and Page alive. Eviction and the staleness sweep both used
352236
+ * to delete directly and leaked exactly that way.
352237
+ */
352238
+ dropEntry(sessionId, entry) {
352239
+ void this.stopScreencast(entry);
352240
+ entry.page = null;
352241
+ this.entries.delete(sessionId);
352242
+ }
352157
352243
  setPage(sessionId, page) {
352158
352244
  const entry = this.entries.get(sessionId);
352159
352245
  if (!entry)
352160
352246
  return;
352247
+ if (entry.screencast && entry.screencast.page !== page)
352248
+ void this.stopScreencast(entry);
352161
352249
  entry.page = page;
352162
352250
  entry.lastUpdatedAt = Date.now();
352163
352251
  if (page) {
@@ -352167,6 +352255,119 @@ var require_live_browser_registry = __commonJS({
352167
352255
  entry.currentUrl = url;
352168
352256
  } catch {
352169
352257
  }
352258
+ void this.startScreencast(entry, page);
352259
+ }
352260
+ }
352261
+ /**
352262
+ * Begin a CDP screencast for this page. Chromium then pushes a JPEG on every
352263
+ * paint, already downscaled to LIVE_FRAME_MAX_DIMENSION — which is the only
352264
+ * way this runner can downscale at all, since it ships no image library.
352265
+ *
352266
+ * Best-effort by design: any failure leaves `screencast` null and the polling
352267
+ * screenshot loop keeps serving the entry exactly as before.
352268
+ */
352269
+ async startScreencast(entry, page) {
352270
+ if (!page || entry.screencast || entry.screencastStarting)
352271
+ return;
352272
+ if (page.isClosed?.())
352273
+ return;
352274
+ entry.screencastStarting = true;
352275
+ try {
352276
+ const client = await page.context().newCDPSession(page);
352277
+ const binding = { client, page, stopped: false };
352278
+ client.on("Page.screencastFrame", (params) => {
352279
+ void client.send("Page.screencastFrameAck", { sessionId: params.sessionId }).catch(() => {
352280
+ });
352281
+ if (binding.stopped)
352282
+ return;
352283
+ if (this.entries.get(entry.meta.sessionId) !== entry)
352284
+ return;
352285
+ if (typeof params.data !== "string" || params.data.length === 0)
352286
+ return;
352287
+ const width = params.metadata?.deviceWidth;
352288
+ const height = params.metadata?.deviceHeight;
352289
+ this.acceptFrame(entry, params.data, "image/jpeg", width, height);
352290
+ });
352291
+ page.once("close", () => {
352292
+ void this.stopScreencast(entry);
352293
+ });
352294
+ page.once("crash", () => {
352295
+ void this.stopScreencast(entry);
352296
+ });
352297
+ await client.send("Page.enable");
352298
+ await client.send("Page.startScreencast", {
352299
+ format: "jpeg",
352300
+ quality: shared_1.LIVE_FRAME_JPEG_QUALITY,
352301
+ // Absolute bounds, aspect-preserving — Chromium does the resize.
352302
+ maxWidth: shared_1.LIVE_FRAME_MAX_DIMENSION,
352303
+ maxHeight: shared_1.LIVE_FRAME_MAX_DIMENSION,
352304
+ everyNthFrame: 1
352305
+ });
352306
+ if (this.entries.get(entry.meta.sessionId) !== entry || entry.page !== page) {
352307
+ binding.stopped = true;
352308
+ await client.send("Page.stopScreencast").catch(() => {
352309
+ });
352310
+ await client.detach().catch(() => {
352311
+ });
352312
+ return;
352313
+ }
352314
+ entry.screencast = binding;
352315
+ } catch {
352316
+ } finally {
352317
+ entry.screencastStarting = false;
352318
+ }
352319
+ }
352320
+ async stopScreencast(entry) {
352321
+ const binding = entry.screencast;
352322
+ if (!binding || binding.stopped)
352323
+ return;
352324
+ binding.stopped = true;
352325
+ entry.screencast = null;
352326
+ try {
352327
+ await binding.client.send("Page.stopScreencast");
352328
+ } catch {
352329
+ }
352330
+ try {
352331
+ await binding.client.detach();
352332
+ } catch {
352333
+ }
352334
+ }
352335
+ /**
352336
+ * Record a freshly captured frame and forward it to the real-time publisher.
352337
+ * The stored copy (used by the heartbeat fallback) is only replaced when it
352338
+ * fits the heartbeat budget, but the publisher — which has a far larger budget
352339
+ * of its own — always sees the frame.
352340
+ */
352341
+ acceptFrame(entry, base64, mimeType, width, height) {
352342
+ const now = Date.now();
352343
+ entry.lastUpdatedAt = now;
352344
+ if (base64.length <= shared_1.LIVE_THUMBNAIL_MAX_BASE64_BYTES) {
352345
+ entry.thumbnailJpegBase64 = base64;
352346
+ entry.thumbnailCapturedAt = now;
352347
+ if (width !== void 0)
352348
+ entry.thumbnailWidth = width;
352349
+ if (height !== void 0)
352350
+ entry.thumbnailHeight = height;
352351
+ }
352352
+ const publisher = this.publisher;
352353
+ if (!publisher)
352354
+ return;
352355
+ if (now - entry.lastPublishedAt < shared_1.LIVE_FRAME_MIN_PUSH_INTERVAL_MS)
352356
+ return;
352357
+ entry.lastPublishedAt = now;
352358
+ try {
352359
+ publisher({
352360
+ sessionId: entry.meta.sessionId,
352361
+ projectId: entry.meta.projectId,
352362
+ runId: entry.meta.runId,
352363
+ capturedAt: now,
352364
+ mimeType,
352365
+ width,
352366
+ height,
352367
+ surface: entry.meta.surface,
352368
+ base64
352369
+ });
352370
+ } catch {
352170
352371
  }
352171
352372
  }
352172
352373
  setCurrentUrl(sessionId, url) {
@@ -352178,7 +352379,7 @@ var require_live_browser_registry = __commonJS({
352178
352379
  const previousUrl = entry.currentUrl;
352179
352380
  entry.currentUrl = url;
352180
352381
  entry.lastUpdatedAt = Date.now();
352181
- if (url && previousUrl && url !== previousUrl) {
352382
+ if (url && previousUrl && url !== previousUrl && !entry.screencast) {
352182
352383
  entry.pendingCapture = true;
352183
352384
  const schedule = (delay) => {
352184
352385
  setTimeout(() => {
@@ -352199,6 +352400,11 @@ var require_live_browser_registry = __commonJS({
352199
352400
  schedule(100);
352200
352401
  }
352201
352402
  }
352403
+ /**
352404
+ * Ingest a frame captured outside this module — the mobile mirror and the
352405
+ * spawned `playwright test` child (whose Page lives in another process, so no
352406
+ * CDP screencast is reachable from here) both arrive this way.
352407
+ */
352202
352408
  updateSnapshot(sessionId, snapshot) {
352203
352409
  const entry = this.entries.get(sessionId);
352204
352410
  if (!entry)
@@ -352207,12 +352413,10 @@ var require_live_browser_registry = __commonJS({
352207
352413
  entry.currentUrl = snapshot.currentUrl;
352208
352414
  }
352209
352415
  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;
352416
+ if (thumbnail) {
352417
+ const width = typeof snapshot.thumbnailWidth === "number" && Number.isFinite(snapshot.thumbnailWidth) ? snapshot.thumbnailWidth : void 0;
352418
+ const height = typeof snapshot.thumbnailHeight === "number" && Number.isFinite(snapshot.thumbnailHeight) ? snapshot.thumbnailHeight : void 0;
352419
+ this.acceptFrame(entry, thumbnail, snapshot.mimeType ?? "image/jpeg", width, height);
352216
352420
  }
352217
352421
  entry.lastUpdatedAt = Date.now();
352218
352422
  }
@@ -352255,9 +352459,11 @@ var require_live_browser_registry = __commonJS({
352255
352459
  async captureAllNow() {
352256
352460
  await this.captureAll();
352257
352461
  }
352258
- /** Stop the snapshot loop and clear all entries. Idempotent. */
352462
+ /** Stop the snapshot loop, tear down every screencast, and clear all entries. Idempotent. */
352259
352463
  shutdown() {
352260
352464
  this.stopSnapshotLoop();
352465
+ for (const entry of this.entries.values())
352466
+ void this.stopScreencast(entry);
352261
352467
  this.entries.clear();
352262
352468
  }
352263
352469
  ensureSnapshotLoop() {
@@ -352285,15 +352491,13 @@ var require_live_browser_registry = __commonJS({
352285
352491
  }
352286
352492
  }
352287
352493
  if (oldestKey) {
352288
- this.entries.get(oldestKey).page = null;
352289
- this.entries.delete(oldestKey);
352494
+ this.dropEntry(oldestKey, this.entries.get(oldestKey));
352290
352495
  }
352291
352496
  }
352292
352497
  sweepStale(now) {
352293
352498
  for (const [key, entry] of this.entries) {
352294
352499
  if (now - entry.lastUpdatedAt > STALE_SESSION_MS) {
352295
- entry.page = null;
352296
- this.entries.delete(key);
352500
+ this.dropEntry(key, entry);
352297
352501
  }
352298
352502
  }
352299
352503
  }
@@ -352334,6 +352538,11 @@ var require_live_browser_registry = __commonJS({
352334
352538
  entry.currentUrl = url;
352335
352539
  } catch {
352336
352540
  }
352541
+ if (entry.screencast) {
352542
+ if (entry.thumbnailJpegBase64)
352543
+ entry.thumbnailCapturedAt = Date.now();
352544
+ return;
352545
+ }
352337
352546
  const buf = await page.screenshot({
352338
352547
  type: "jpeg",
352339
352548
  quality: THUMBNAIL_QUALITY,
@@ -352341,17 +352550,8 @@ var require_live_browser_registry = __commonJS({
352341
352550
  timeout: 3e3,
352342
352551
  scale: "css"
352343
352552
  });
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
352553
  const viewport = page.viewportSize?.();
352351
- if (viewport) {
352352
- entry.thumbnailWidth = viewport.width;
352353
- entry.thumbnailHeight = viewport.height;
352354
- }
352554
+ this.acceptFrame(entry, buf.toString("base64"), "image/jpeg", viewport?.width, viewport?.height);
352355
352555
  } catch {
352356
352556
  } finally {
352357
352557
  entry.isCapturing = false;
@@ -353065,7 +353265,7 @@ var require_package4 = __commonJS({
353065
353265
  "package.json"(exports, module) {
353066
353266
  module.exports = {
353067
353267
  name: "@validate.qa/runner",
353068
- version: "1.0.78",
353268
+ version: "1.0.79",
353069
353269
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353070
353270
  bin: {
353071
353271
  "validate-runner": "dist/cli.js"
@@ -353857,6 +354057,24 @@ async function getScreenshot(sessionPath) {
353857
354057
  return null;
353858
354058
  }
353859
354059
  }
354060
+ async function applyMjpegSettings(sessionPath, settings) {
354061
+ try {
354062
+ await appiumRequest(`${sessionPath}/appium/settings`, {
354063
+ method: "POST",
354064
+ body: JSON.stringify({
354065
+ settings: {
354066
+ mjpegServerFramerate: settings.framerate,
354067
+ mjpegScalingFactor: settings.scalingFactor,
354068
+ mjpegServerScreenshotQuality: settings.quality
354069
+ }
354070
+ })
354071
+ });
354072
+ return true;
354073
+ } catch (err) {
354074
+ if (isAppiumDriverDeadError(err)) throw err;
354075
+ return false;
354076
+ }
354077
+ }
353860
354078
  async function getPageSource(sessionPath) {
353861
354079
  try {
353862
354080
  const value = readValue(await appiumRequest(`${sessionPath}/source`));
@@ -356368,7 +356586,15 @@ function mobileParallelSessionCapabilities(platform3, portOffset = 0, deviceId =
356368
356586
  return (Number.isFinite(parsed) ? parsed : fallback) + offset;
356369
356587
  };
356370
356588
  if (platform3 === "ANDROID") {
356371
- return { "appium:systemPort": readPort("APPIUM_SYSTEM_PORT", 8200) };
356589
+ return {
356590
+ "appium:systemPort": readPort("APPIUM_SYSTEM_PORT", 8200),
356591
+ // Host port that UiAutomator2 forwards the on-device MJPEG broadcaster
356592
+ // (device port 7810) to. Without this the live mirror has to poll
356593
+ // WebDriver /screenshot for full-resolution PNGs, which the runner cannot
356594
+ // downscale itself. Base differs from iOS's 9100 so a concurrent
356595
+ // Android+iOS pair can never collide on a shared slot offset.
356596
+ "appium:mjpegServerPort": readPort("APPIUM_ANDROID_MJPEG_SERVER_PORT", 7810)
356597
+ };
356372
356598
  }
356373
356599
  const safeDevice = deviceId.replace(/[^A-Za-z0-9._-]+/g, "-") || `device-${offset}`;
356374
356600
  const derivedDataRoot = env.APPIUM_DERIVED_DATA_PATH?.trim() || join5(tmpdir3(), "validateqa-wda");
@@ -357623,6 +357849,15 @@ function acquireControlPortSlot(deviceId) {
357623
357849
  function releaseControlPortSlot(deviceId) {
357624
357850
  controlPortSlotsByDevice.delete(deviceId);
357625
357851
  }
357852
+ function getControlPortSlot(deviceId) {
357853
+ return controlPortSlotsByDevice.get(deviceId);
357854
+ }
357855
+ function mobileMjpegPortForDevice(platform3, deviceId, env = process.env) {
357856
+ const slot = getControlPortSlot(deviceId);
357857
+ if (slot === void 0) return null;
357858
+ const port = mobileParallelSessionCapabilities(platform3, slot, deviceId, env)["appium:mjpegServerPort"];
357859
+ return typeof port === "number" && Number.isFinite(port) ? port : null;
357860
+ }
357626
357861
  var PINNED_DEVICE_SNAPSHOT_TTL_MS = 25 * 6e4;
357627
357862
  var pinnedDeviceSnapshot = null;
357628
357863
  async function executeMobileTest(options) {
@@ -358567,6 +358802,100 @@ import { randomBytes, timingSafeEqual } from "crypto";
358567
358802
  import { homedir as homedir2 } from "os";
358568
358803
  import { join as join6, dirname } from "path";
358569
358804
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3 } from "fs";
358805
+
358806
+ // src/services/mobile/mjpeg-stream.ts
358807
+ init_dist();
358808
+ import { request as httpRequest } from "http";
358809
+ var JPEG_SOI = Buffer.from([255, 216]);
358810
+ var JPEG_EOI = Buffer.from([255, 217]);
358811
+ function startMjpegStream(options) {
358812
+ const { port, host = "127.0.0.1", onFrame, onError } = options;
358813
+ let stopped = false;
358814
+ let req = null;
358815
+ let response = null;
358816
+ let buffer = Buffer.alloc(0);
358817
+ const fail = (error2) => {
358818
+ if (stopped) return;
358819
+ stopped = true;
358820
+ try {
358821
+ req?.destroy();
358822
+ } catch {
358823
+ }
358824
+ try {
358825
+ response?.destroy();
358826
+ } catch {
358827
+ }
358828
+ buffer = Buffer.alloc(0);
358829
+ onError?.(error2);
358830
+ };
358831
+ const consume = (chunk) => {
358832
+ if (stopped) return;
358833
+ buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]);
358834
+ for (; ; ) {
358835
+ const start = buffer.indexOf(JPEG_SOI);
358836
+ if (start < 0) {
358837
+ if (buffer.length > 1) buffer = buffer.subarray(buffer.length - 1);
358838
+ return;
358839
+ }
358840
+ const end = buffer.indexOf(JPEG_EOI, start + JPEG_SOI.length);
358841
+ if (end < 0) {
358842
+ if (start > 0) buffer = buffer.subarray(start);
358843
+ if (buffer.length > MOBILE_MJPEG_MAX_FRAME_BYTES) buffer = Buffer.alloc(0);
358844
+ return;
358845
+ }
358846
+ const frameEnd = end + JPEG_EOI.length;
358847
+ const frame = buffer.subarray(start, frameEnd);
358848
+ buffer = buffer.subarray(frameEnd);
358849
+ try {
358850
+ onFrame(frame);
358851
+ } catch {
358852
+ }
358853
+ }
358854
+ };
358855
+ try {
358856
+ req = httpRequest(
358857
+ { host, port, path: "/", method: "GET", headers: { Connection: "keep-alive" } },
358858
+ (res) => {
358859
+ response = res;
358860
+ if (stopped) {
358861
+ res.destroy();
358862
+ return;
358863
+ }
358864
+ if (res.statusCode && res.statusCode >= 400) {
358865
+ fail(new Error(`MJPEG broadcaster returned HTTP ${res.statusCode}`));
358866
+ res.destroy();
358867
+ return;
358868
+ }
358869
+ res.on("data", (chunk) => consume(chunk));
358870
+ res.on("end", () => fail(new Error("MJPEG stream ended")));
358871
+ res.on("error", (err) => fail(err));
358872
+ }
358873
+ );
358874
+ req.on("error", (err) => fail(err));
358875
+ req.setTimeout(1e4, () => fail(new Error("MJPEG connect timeout")));
358876
+ req.end();
358877
+ } catch (err) {
358878
+ fail(err instanceof Error ? err : new Error(String(err)));
358879
+ }
358880
+ return {
358881
+ stop: () => {
358882
+ if (stopped) return;
358883
+ stopped = true;
358884
+ try {
358885
+ req?.destroy();
358886
+ } catch {
358887
+ }
358888
+ try {
358889
+ response?.destroy();
358890
+ } catch {
358891
+ }
358892
+ buffer = Buffer.alloc(0);
358893
+ },
358894
+ isActive: () => !stopped
358895
+ };
358896
+ }
358897
+
358898
+ // src/services/mobile-bridge.ts
358570
358899
  init_dist();
358571
358900
  var MOBILE_BRIDGE_PORT2 = 9515;
358572
358901
  var MAX_REQUEST_BODY_BYTES = 64 * 1024;
@@ -359120,16 +359449,66 @@ function writeFrameToChannelClient(ch, client, frame) {
359120
359449
  } catch {
359121
359450
  }
359122
359451
  }
359452
+ function broadcastChannelFrame(ch, frame) {
359453
+ ch.lastFrame = frame;
359454
+ for (const client of ch.clients) writeFrameToChannelClient(ch, client, frame);
359455
+ }
359456
+ function tryStartChannelMjpeg(ch) {
359457
+ if (ch.mjpeg || ch.mjpegStarting || ch.mjpegUnavailable) return;
359458
+ const port = mobileMjpegPortForDevice(ch.platform, ch.deviceId);
359459
+ if (port === null) {
359460
+ ch.mjpegUnavailable = true;
359461
+ return;
359462
+ }
359463
+ ch.mjpegStarting = true;
359464
+ const handle = startMjpegStream({
359465
+ port,
359466
+ onFrame: (jpeg) => {
359467
+ const isOwned = ch.mjpeg === handle;
359468
+ const isCandidate = ch.mjpeg === null && ch.mjpegStarting;
359469
+ if (!isOwned && !isCandidate) return;
359470
+ ch.mjpegLastFrameAt = Date.now();
359471
+ ch.nullTicks = 0;
359472
+ if (isCandidate) {
359473
+ ch.mjpeg = handle;
359474
+ ch.mjpegStarting = false;
359475
+ if (ch.pollTimer) {
359476
+ clearInterval(ch.pollTimer);
359477
+ ch.pollTimer = null;
359478
+ }
359479
+ }
359480
+ broadcastChannelFrame(ch, {
359481
+ timestamp: ch.mjpegLastFrameAt,
359482
+ screenshot: jpeg.toString("base64"),
359483
+ source: null,
359484
+ mimeType: "image/jpeg"
359485
+ });
359486
+ },
359487
+ onError: () => {
359488
+ const wasActive = ch.mjpeg === handle;
359489
+ ch.mjpeg = null;
359490
+ ch.mjpegStarting = false;
359491
+ ch.mjpegUnavailable = true;
359492
+ if (wasActive && ch.clients.size > 0) ensureChannelPolling(ch);
359493
+ }
359494
+ });
359495
+ const firstFrameDeadline = setTimeout(() => {
359496
+ if (ch.mjpeg) return;
359497
+ ch.mjpegStarting = false;
359498
+ ch.mjpegUnavailable = true;
359499
+ handle.stop();
359500
+ if (ch.clients.size > 0) ensureChannelPolling(ch);
359501
+ }, MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS);
359502
+ firstFrameDeadline.unref?.();
359503
+ }
359123
359504
  async function pollRunnerChannel(ch) {
359124
359505
  if (ch.clients.size === 0 || ch.polling) return;
359506
+ if (ch.mjpeg) return;
359125
359507
  ch.polling = true;
359126
359508
  try {
359127
359509
  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) {
359510
+ const screenshot = await getScreenshot(`/session/${ch.sessionId}`);
359511
+ if (!screenshot) {
359133
359512
  ch.nullTicks++;
359134
359513
  if (ch.nullTicks >= MIRROR_DEAD_SESSION_TICKS) {
359135
359514
  const alive = await isSessionAlive(ch.sessionId);
@@ -359142,32 +359521,45 @@ async function pollRunnerChannel(ch) {
359142
359521
  } else {
359143
359522
  ch.nullTicks = 0;
359144
359523
  }
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);
359524
+ broadcastChannelFrame(ch, {
359525
+ timestamp: Date.now(),
359526
+ screenshot: screenshot ?? null,
359527
+ source: null,
359528
+ mimeType: "image/png"
359529
+ });
359148
359530
  } catch {
359149
359531
  } finally {
359150
359532
  ch.polling = false;
359151
359533
  }
359152
359534
  }
359153
359535
  function ensureChannelPolling(ch) {
359154
- if (ch.pollTimer) return;
359536
+ tryStartChannelMjpeg(ch);
359537
+ if (ch.mjpeg || ch.pollTimer) return;
359155
359538
  ch.pollTimer = setInterval(() => {
359156
359539
  void pollRunnerChannel(ch);
359157
359540
  }, MOBILE_SCREENSHOT_INTERVAL_MS);
359158
359541
  ch.pollTimer.unref?.();
359159
359542
  }
359160
359543
  function stopChannelPollingIfIdle(ch) {
359161
- if (ch.clients.size === 0 && ch.pollTimer) {
359544
+ if (ch.clients.size > 0) return;
359545
+ if (ch.pollTimer) {
359162
359546
  clearInterval(ch.pollTimer);
359163
359547
  ch.pollTimer = null;
359164
359548
  }
359549
+ if (ch.mjpeg) {
359550
+ ch.mjpeg.stop();
359551
+ ch.mjpeg = null;
359552
+ }
359165
359553
  }
359166
359554
  function closeRunnerChannel(ch) {
359167
359555
  if (ch.pollTimer) {
359168
359556
  clearInterval(ch.pollTimer);
359169
359557
  ch.pollTimer = null;
359170
359558
  }
359559
+ if (ch.mjpeg) {
359560
+ ch.mjpeg.stop();
359561
+ ch.mjpeg = null;
359562
+ }
359171
359563
  for (const client of ch.clients) {
359172
359564
  try {
359173
359565
  client.end();
@@ -359752,12 +360144,24 @@ function attachMirrorSession(sessionId, platform3, deviceId) {
359752
360144
  lastFrame: null,
359753
360145
  pollTimer: null,
359754
360146
  nullTicks: 0,
359755
- polling: false
360147
+ polling: false,
360148
+ mjpeg: null,
360149
+ mjpegLastFrameAt: 0,
360150
+ mjpegStarting: false,
360151
+ mjpegUnavailable: false
359756
360152
  };
359757
360153
  ch.sessionId = sessionId;
359758
360154
  ch.platform = platform3;
359759
360155
  ch.nullTicks = 0;
360156
+ if (ch.mjpeg) {
360157
+ ch.mjpeg.stop();
360158
+ ch.mjpeg = null;
360159
+ }
360160
+ ch.mjpegUnavailable = false;
360161
+ ch.mjpegStarting = false;
359760
360162
  runnerChannels.set(deviceId, ch);
360163
+ void applyMjpegSettings(`/session/${sessionId}`, MOBILE_MJPEG_SETTINGS).catch(() => {
360164
+ });
359761
360165
  if (ch.clients.size > 0) {
359762
360166
  ensureChannelPolling(ch);
359763
360167
  void pollRunnerChannel(ch);
@@ -362941,7 +363345,9 @@ ${finalVerifyResult.logs ?? ""}`;
362941
363345
  liveBrowserHandle.updateSnapshot({
362942
363346
  currentUrl: mobileTarget.appId,
362943
363347
  thumbnailJpegBase64: base64,
362944
- thumbnailCapturedAt: Date.now()
363348
+ thumbnailCapturedAt: Date.now(),
363349
+ // Appium's screenshot endpoint returns PNG despite the field name.
363350
+ mimeType: "image/png"
362945
363351
  });
362946
363352
  requestLiveBrowserHeartbeat();
362947
363353
  },
@@ -364186,6 +364592,22 @@ async function startRunner(opts) {
364186
364592
  } catch {
364187
364593
  }
364188
364594
  };
364595
+ const liveFramePushInFlight = /* @__PURE__ */ new Set();
364596
+ const publishLiveFrame = (frame) => {
364597
+ if (!frame.projectId) return;
364598
+ if (liveFramePushInFlight.has(frame.sessionId)) return;
364599
+ liveFramePushInFlight.add(frame.sessionId);
364600
+ void fetch(`${serverUrl}/api/runner/live-frame`, {
364601
+ method: "POST",
364602
+ headers,
364603
+ body: JSON.stringify(frame),
364604
+ signal: AbortSignal.timeout(5e3)
364605
+ }).catch(() => {
364606
+ }).finally(() => {
364607
+ liveFramePushInFlight.delete(frame.sessionId);
364608
+ });
364609
+ };
364610
+ import_runner_core11.liveBrowserRegistry.setFramePublisher(publishLiveFrame);
364189
364611
  let liveBrowserHeartbeatInFlight = false;
364190
364612
  let liveBrowserHeartbeatPending = false;
364191
364613
  let lastLiveBrowserHeartbeatAt = 0;