@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.js CHANGED
@@ -1888,6 +1888,56 @@ 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_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_STALE_MS = readEnvInt("LIVE_FRAME_STALE_MS", 3e4);
1927
+ LIVE_FRAME_MAX_SESSIONS_PER_PROJECT = readEnvInt("LIVE_FRAME_MAX_SESSIONS_PER_PROJECT", 32);
1928
+ MOBILE_MJPEG_SETTINGS = {
1929
+ /** Screenshots per second the broadcaster emits (both drivers: range 1-60). */
1930
+ framerate: readEnvInt("MOBILE_MJPEG_FRAMERATE", 10),
1931
+ /** Downscale percentage. UiAutomator2 defaults to 50, XCUITest to 100. */
1932
+ scalingFactor: readEnvInt("MOBILE_MJPEG_SCALING_FACTOR", 50),
1933
+ /** JPEG compression factor (both drivers: range 1-100). */
1934
+ quality: readEnvInt("MOBILE_MJPEG_QUALITY", 50)
1935
+ };
1936
+ MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS = readEnvInt("MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS", 6e3);
1937
+ MOBILE_MJPEG_MAX_FRAME_BYTES = readEnvInt("MOBILE_MJPEG_MAX_FRAME_BYTES", 8 * 1024 * 1024);
1938
+ }
1939
+ });
1940
+
1891
1941
  // ../shared/dist/project-key.js
1892
1942
  function isValidProjectKey(key) {
1893
1943
  return typeof key === "string" && PROJECT_KEY_PATTERN.test(key) && !RESERVED_PROJECT_KEYS.has(key);
@@ -6310,6 +6360,14 @@ __export(dist_exports, {
6310
6360
  GROUP_LABELS: () => GROUP_LABELS,
6311
6361
  LEGACY_TTS_MODEL_MAP: () => LEGACY_TTS_MODEL_MAP,
6312
6362
  LEGACY_TTS_VOICE_ID_MAP: () => LEGACY_TTS_VOICE_ID_MAP,
6363
+ LIVE_FRAME_JPEG_QUALITY: () => LIVE_FRAME_JPEG_QUALITY,
6364
+ LIVE_FRAME_MAX_DIMENSION: () => LIVE_FRAME_MAX_DIMENSION,
6365
+ LIVE_FRAME_MAX_SESSIONS_PER_PROJECT: () => LIVE_FRAME_MAX_SESSIONS_PER_PROJECT,
6366
+ LIVE_FRAME_MIN_PUSH_INTERVAL_MS: () => LIVE_FRAME_MIN_PUSH_INTERVAL_MS,
6367
+ LIVE_FRAME_PUSH_MAX_BASE64_BYTES: () => LIVE_FRAME_PUSH_MAX_BASE64_BYTES,
6368
+ LIVE_FRAME_STALE_MS: () => LIVE_FRAME_STALE_MS,
6369
+ LIVE_FRAME_UNFOCUSED_INTERVAL_MS: () => LIVE_FRAME_UNFOCUSED_INTERVAL_MS,
6370
+ LIVE_THUMBNAIL_MAX_BASE64_BYTES: () => LIVE_THUMBNAIL_MAX_BASE64_BYTES,
6313
6371
  MAX_CREDIT_TOPUP_CENTS: () => MAX_CREDIT_TOPUP_CENTS,
6314
6372
  MAX_DEEPLINK_LENGTH: () => MAX_DEEPLINK_LENGTH,
6315
6373
  MIN_CREDIT_TOPUP_CENTS: () => MIN_CREDIT_TOPUP_CENTS,
@@ -6321,6 +6379,9 @@ __export(dist_exports, {
6321
6379
  MOBILE_DEFAULT_SWIPE_DISTANCE: () => MOBILE_DEFAULT_SWIPE_DISTANCE,
6322
6380
  MOBILE_FOCUS_SETTLE_MS: () => MOBILE_FOCUS_SETTLE_MS,
6323
6381
  MOBILE_MAX_SWIPE_DISTANCE: () => MOBILE_MAX_SWIPE_DISTANCE,
6382
+ MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS: () => MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS,
6383
+ MOBILE_MJPEG_MAX_FRAME_BYTES: () => MOBILE_MJPEG_MAX_FRAME_BYTES,
6384
+ MOBILE_MJPEG_SETTINGS: () => MOBILE_MJPEG_SETTINGS,
6324
6385
  MOBILE_PLATFORMS: () => MOBILE_PLATFORMS,
6325
6386
  MOBILE_POST_ACTION_SETTLE_MS: () => MOBILE_POST_ACTION_SETTLE_MS,
6326
6387
  MOBILE_SCREENSHOT_INTERVAL_MS: () => MOBILE_SCREENSHOT_INTERVAL_MS,
@@ -6433,6 +6494,7 @@ __export(dist_exports, {
6433
6494
  isTestReadyForPlatform: () => isTestReadyForPlatform,
6434
6495
  isTrialExpired: () => isTrialExpired,
6435
6496
  isUIVariant: () => isUIVariant,
6497
+ isValidLiveFrame: () => isValidLiveFrame,
6436
6498
  isValidMobileAppId: () => isValidMobileAppId,
6437
6499
  isValidProjectKey: () => isValidProjectKey,
6438
6500
  isValidSttProvider: () => isValidSttProvider,
@@ -6495,6 +6557,7 @@ var init_dist = __esm({
6495
6557
  "use strict";
6496
6558
  init_types();
6497
6559
  init_constants();
6560
+ init_live_frame();
6498
6561
  init_project_key();
6499
6562
  init_mobile_test_generation();
6500
6563
  init_mobile_step_healer();
@@ -87957,6 +88020,7 @@ var require_playwright_native = __commonJS({
87957
88020
  var PACKAGE_DIR = (0, path_1.resolve)(__dirname, "..", "..");
87958
88021
  var RUNS_DIR = process.env.RUNS_DIR || (0, path_1.join)(PACKAGE_DIR, ".qa-runs");
87959
88022
  var PW_CLI = require.resolve("@playwright/test/cli");
88023
+ var LIVE_BROWSER_STATE_POLL_INTERVAL_MS = 500;
87960
88024
  async function executeTestViaNativePlaywright2(options) {
87961
88025
  const { playwrightCode, baseUrl, testName, testVariables, authStateCapturePath, storageStatePath, extraHttpHeaders, authStandingMode, validateContext, liveBrowserHandle, onLiveBrowserSnapshot } = options;
87962
88026
  const logs2 = [];
@@ -88046,7 +88110,7 @@ test.beforeEach(async ({ page }, testInfo) => {
88046
88110
  await __qaLiveBrowserCapture();
88047
88111
  __qaLiveBrowserInterval = setInterval(() => {
88048
88112
  void __qaLiveBrowserCapture?.();
88049
- }, 500);
88113
+ }, ${LIVE_BROWSER_STATE_POLL_INTERVAL_MS});
88050
88114
  });
88051
88115
 
88052
88116
  test.afterEach(async () => {
@@ -88463,7 +88527,7 @@ ${passed ? "\u2705" : "\u274C"} Test ${passed ? "PASSED" : "FAILED"}${error2 ? `
88463
88527
  };
88464
88528
  const interval = setInterval(() => {
88465
88529
  void poll();
88466
- }, 1e3);
88530
+ }, LIVE_BROWSER_STATE_POLL_INTERVAL_MS);
88467
88531
  interval.unref?.();
88468
88532
  void poll();
88469
88533
  return async () => {
@@ -352104,16 +352168,16 @@ var require_live_browser_registry = __commonJS({
352104
352168
  "use strict";
352105
352169
  Object.defineProperty(exports2, "__esModule", { value: true });
352106
352170
  exports2.liveBrowserRegistry = void 0;
352171
+ var shared_1 = (init_dist(), __toCommonJS(dist_exports));
352107
352172
  var THUMBNAIL_INTERVAL_MS = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_INTERVAL_MS ?? "4000", 10) || 4e3;
352108
352173
  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
352174
  var STALE_SESSION_MS = parseInt(process.env.LIVE_BROWSER_STALE_MS ?? `${10 * 60 * 1e3}`, 10) || 10 * 60 * 1e3;
352112
352175
  var MAX_SESSIONS = parseInt(process.env.LIVE_BROWSER_MAX_SESSIONS ?? "32", 10) || 32;
352113
352176
  var LiveBrowserRegistry = class {
352114
352177
  entries = /* @__PURE__ */ new Map();
352115
352178
  snapshotTimer = null;
352116
352179
  capturing = false;
352180
+ publisher = null;
352117
352181
  /** Register a new session. Returns a handle the caller must hold and
352118
352182
  * ultimately call .unregister() on. */
352119
352183
  register(meta) {
@@ -352127,7 +352191,10 @@ var require_live_browser_registry = __commonJS({
352127
352191
  lastUpdatedAt: now,
352128
352192
  page: null,
352129
352193
  isCapturing: false,
352130
- pendingCapture: false
352194
+ pendingCapture: false,
352195
+ screencast: null,
352196
+ screencastStarting: false,
352197
+ lastPublishedAt: 0
352131
352198
  };
352132
352199
  this.entries.set(meta.sessionId, entry);
352133
352200
  this.ensureSnapshotLoop();
@@ -352140,19 +352207,40 @@ var require_live_browser_registry = __commonJS({
352140
352207
  unregister: () => this.unregister(meta.sessionId)
352141
352208
  };
352142
352209
  }
352210
+ /**
352211
+ * Install the real-time frame sink. Idempotent; the runner calls this once at
352212
+ * startup, before any session registers.
352213
+ */
352214
+ setFramePublisher(publisher) {
352215
+ this.publisher = publisher;
352216
+ }
352143
352217
  unregister(sessionId) {
352144
352218
  const entry = this.entries.get(sessionId);
352145
352219
  if (!entry)
352146
352220
  return;
352147
- entry.page = null;
352148
- this.entries.delete(sessionId);
352221
+ this.dropEntry(sessionId, entry);
352149
352222
  if (this.entries.size === 0)
352150
352223
  this.stopSnapshotLoop();
352151
352224
  }
352225
+ /**
352226
+ * The ONLY way an entry leaves `entries`. Centralised because a screencast
352227
+ * outlives the map otherwise: its frame handler closes over the entry rather
352228
+ * than looking it up, so an entry deleted without stopScreencast() leaves
352229
+ * Chromium encoding and acking frames for a page nobody is watching, holding
352230
+ * the CDP session and Page alive. Eviction and the staleness sweep both used
352231
+ * to delete directly and leaked exactly that way.
352232
+ */
352233
+ dropEntry(sessionId, entry) {
352234
+ void this.stopScreencast(entry);
352235
+ entry.page = null;
352236
+ this.entries.delete(sessionId);
352237
+ }
352152
352238
  setPage(sessionId, page) {
352153
352239
  const entry = this.entries.get(sessionId);
352154
352240
  if (!entry)
352155
352241
  return;
352242
+ if (entry.screencast && entry.screencast.page !== page)
352243
+ void this.stopScreencast(entry);
352156
352244
  entry.page = page;
352157
352245
  entry.lastUpdatedAt = Date.now();
352158
352246
  if (page) {
@@ -352162,6 +352250,119 @@ var require_live_browser_registry = __commonJS({
352162
352250
  entry.currentUrl = url;
352163
352251
  } catch {
352164
352252
  }
352253
+ void this.startScreencast(entry, page);
352254
+ }
352255
+ }
352256
+ /**
352257
+ * Begin a CDP screencast for this page. Chromium then pushes a JPEG on every
352258
+ * paint, already downscaled to LIVE_FRAME_MAX_DIMENSION — which is the only
352259
+ * way this runner can downscale at all, since it ships no image library.
352260
+ *
352261
+ * Best-effort by design: any failure leaves `screencast` null and the polling
352262
+ * screenshot loop keeps serving the entry exactly as before.
352263
+ */
352264
+ async startScreencast(entry, page) {
352265
+ if (!page || entry.screencast || entry.screencastStarting)
352266
+ return;
352267
+ if (page.isClosed?.())
352268
+ return;
352269
+ entry.screencastStarting = true;
352270
+ try {
352271
+ const client = await page.context().newCDPSession(page);
352272
+ const binding = { client, page, stopped: false };
352273
+ client.on("Page.screencastFrame", (params) => {
352274
+ void client.send("Page.screencastFrameAck", { sessionId: params.sessionId }).catch(() => {
352275
+ });
352276
+ if (binding.stopped)
352277
+ return;
352278
+ if (this.entries.get(entry.meta.sessionId) !== entry)
352279
+ return;
352280
+ if (typeof params.data !== "string" || params.data.length === 0)
352281
+ return;
352282
+ const width = params.metadata?.deviceWidth;
352283
+ const height = params.metadata?.deviceHeight;
352284
+ this.acceptFrame(entry, params.data, "image/jpeg", width, height);
352285
+ });
352286
+ page.once("close", () => {
352287
+ void this.stopScreencast(entry);
352288
+ });
352289
+ page.once("crash", () => {
352290
+ void this.stopScreencast(entry);
352291
+ });
352292
+ await client.send("Page.enable");
352293
+ await client.send("Page.startScreencast", {
352294
+ format: "jpeg",
352295
+ quality: shared_1.LIVE_FRAME_JPEG_QUALITY,
352296
+ // Absolute bounds, aspect-preserving — Chromium does the resize.
352297
+ maxWidth: shared_1.LIVE_FRAME_MAX_DIMENSION,
352298
+ maxHeight: shared_1.LIVE_FRAME_MAX_DIMENSION,
352299
+ everyNthFrame: 1
352300
+ });
352301
+ if (this.entries.get(entry.meta.sessionId) !== entry || entry.page !== page) {
352302
+ binding.stopped = true;
352303
+ await client.send("Page.stopScreencast").catch(() => {
352304
+ });
352305
+ await client.detach().catch(() => {
352306
+ });
352307
+ return;
352308
+ }
352309
+ entry.screencast = binding;
352310
+ } catch {
352311
+ } finally {
352312
+ entry.screencastStarting = false;
352313
+ }
352314
+ }
352315
+ async stopScreencast(entry) {
352316
+ const binding = entry.screencast;
352317
+ if (!binding || binding.stopped)
352318
+ return;
352319
+ binding.stopped = true;
352320
+ entry.screencast = null;
352321
+ try {
352322
+ await binding.client.send("Page.stopScreencast");
352323
+ } catch {
352324
+ }
352325
+ try {
352326
+ await binding.client.detach();
352327
+ } catch {
352328
+ }
352329
+ }
352330
+ /**
352331
+ * Record a freshly captured frame and forward it to the real-time publisher.
352332
+ * The stored copy (used by the heartbeat fallback) is only replaced when it
352333
+ * fits the heartbeat budget, but the publisher — which has a far larger budget
352334
+ * of its own — always sees the frame.
352335
+ */
352336
+ acceptFrame(entry, base64, mimeType, width, height) {
352337
+ const now = Date.now();
352338
+ entry.lastUpdatedAt = now;
352339
+ if (base64.length <= shared_1.LIVE_THUMBNAIL_MAX_BASE64_BYTES) {
352340
+ entry.thumbnailJpegBase64 = base64;
352341
+ entry.thumbnailCapturedAt = now;
352342
+ if (width !== void 0)
352343
+ entry.thumbnailWidth = width;
352344
+ if (height !== void 0)
352345
+ entry.thumbnailHeight = height;
352346
+ }
352347
+ const publisher = this.publisher;
352348
+ if (!publisher)
352349
+ return;
352350
+ if (now - entry.lastPublishedAt < shared_1.LIVE_FRAME_MIN_PUSH_INTERVAL_MS)
352351
+ return;
352352
+ entry.lastPublishedAt = now;
352353
+ try {
352354
+ publisher({
352355
+ sessionId: entry.meta.sessionId,
352356
+ projectId: entry.meta.projectId,
352357
+ runId: entry.meta.runId,
352358
+ capturedAt: now,
352359
+ mimeType,
352360
+ width,
352361
+ height,
352362
+ surface: entry.meta.surface,
352363
+ base64
352364
+ });
352365
+ } catch {
352165
352366
  }
352166
352367
  }
352167
352368
  setCurrentUrl(sessionId, url) {
@@ -352173,7 +352374,7 @@ var require_live_browser_registry = __commonJS({
352173
352374
  const previousUrl = entry.currentUrl;
352174
352375
  entry.currentUrl = url;
352175
352376
  entry.lastUpdatedAt = Date.now();
352176
- if (url && previousUrl && url !== previousUrl) {
352377
+ if (url && previousUrl && url !== previousUrl && !entry.screencast) {
352177
352378
  entry.pendingCapture = true;
352178
352379
  const schedule = (delay) => {
352179
352380
  setTimeout(() => {
@@ -352194,6 +352395,11 @@ var require_live_browser_registry = __commonJS({
352194
352395
  schedule(100);
352195
352396
  }
352196
352397
  }
352398
+ /**
352399
+ * Ingest a frame captured outside this module — the mobile mirror and the
352400
+ * spawned `playwright test` child (whose Page lives in another process, so no
352401
+ * CDP screencast is reachable from here) both arrive this way.
352402
+ */
352197
352403
  updateSnapshot(sessionId, snapshot) {
352198
352404
  const entry = this.entries.get(sessionId);
352199
352405
  if (!entry)
@@ -352202,12 +352408,10 @@ var require_live_browser_registry = __commonJS({
352202
352408
  entry.currentUrl = snapshot.currentUrl;
352203
352409
  }
352204
352410
  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;
352411
+ if (thumbnail) {
352412
+ const width = typeof snapshot.thumbnailWidth === "number" && Number.isFinite(snapshot.thumbnailWidth) ? snapshot.thumbnailWidth : void 0;
352413
+ const height = typeof snapshot.thumbnailHeight === "number" && Number.isFinite(snapshot.thumbnailHeight) ? snapshot.thumbnailHeight : void 0;
352414
+ this.acceptFrame(entry, thumbnail, snapshot.mimeType ?? "image/jpeg", width, height);
352211
352415
  }
352212
352416
  entry.lastUpdatedAt = Date.now();
352213
352417
  }
@@ -352250,9 +352454,11 @@ var require_live_browser_registry = __commonJS({
352250
352454
  async captureAllNow() {
352251
352455
  await this.captureAll();
352252
352456
  }
352253
- /** Stop the snapshot loop and clear all entries. Idempotent. */
352457
+ /** Stop the snapshot loop, tear down every screencast, and clear all entries. Idempotent. */
352254
352458
  shutdown() {
352255
352459
  this.stopSnapshotLoop();
352460
+ for (const entry of this.entries.values())
352461
+ void this.stopScreencast(entry);
352256
352462
  this.entries.clear();
352257
352463
  }
352258
352464
  ensureSnapshotLoop() {
@@ -352280,15 +352486,13 @@ var require_live_browser_registry = __commonJS({
352280
352486
  }
352281
352487
  }
352282
352488
  if (oldestKey) {
352283
- this.entries.get(oldestKey).page = null;
352284
- this.entries.delete(oldestKey);
352489
+ this.dropEntry(oldestKey, this.entries.get(oldestKey));
352285
352490
  }
352286
352491
  }
352287
352492
  sweepStale(now) {
352288
352493
  for (const [key, entry] of this.entries) {
352289
352494
  if (now - entry.lastUpdatedAt > STALE_SESSION_MS) {
352290
- entry.page = null;
352291
- this.entries.delete(key);
352495
+ this.dropEntry(key, entry);
352292
352496
  }
352293
352497
  }
352294
352498
  }
@@ -352329,6 +352533,11 @@ var require_live_browser_registry = __commonJS({
352329
352533
  entry.currentUrl = url;
352330
352534
  } catch {
352331
352535
  }
352536
+ if (entry.screencast) {
352537
+ if (entry.thumbnailJpegBase64)
352538
+ entry.thumbnailCapturedAt = Date.now();
352539
+ return;
352540
+ }
352332
352541
  const buf = await page.screenshot({
352333
352542
  type: "jpeg",
352334
352543
  quality: THUMBNAIL_QUALITY,
@@ -352336,17 +352545,8 @@ var require_live_browser_registry = __commonJS({
352336
352545
  timeout: 3e3,
352337
352546
  scale: "css"
352338
352547
  });
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
352548
  const viewport = page.viewportSize?.();
352346
- if (viewport) {
352347
- entry.thumbnailWidth = viewport.width;
352348
- entry.thumbnailHeight = viewport.height;
352349
- }
352549
+ this.acceptFrame(entry, buf.toString("base64"), "image/jpeg", viewport?.width, viewport?.height);
352350
352550
  } catch {
352351
352551
  } finally {
352352
352552
  entry.isCapturing = false;
@@ -353060,7 +353260,7 @@ var require_package4 = __commonJS({
353060
353260
  "package.json"(exports2, module2) {
353061
353261
  module2.exports = {
353062
353262
  name: "@validate.qa/runner",
353063
- version: "1.0.78",
353263
+ version: "1.0.79",
353064
353264
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353065
353265
  bin: {
353066
353266
  "validate-runner": "dist/cli.js"
@@ -353852,6 +354052,24 @@ async function getScreenshot(sessionPath) {
353852
354052
  return null;
353853
354053
  }
353854
354054
  }
354055
+ async function applyMjpegSettings(sessionPath, settings) {
354056
+ try {
354057
+ await appiumRequest(`${sessionPath}/appium/settings`, {
354058
+ method: "POST",
354059
+ body: JSON.stringify({
354060
+ settings: {
354061
+ mjpegServerFramerate: settings.framerate,
354062
+ mjpegScalingFactor: settings.scalingFactor,
354063
+ mjpegServerScreenshotQuality: settings.quality
354064
+ }
354065
+ })
354066
+ });
354067
+ return true;
354068
+ } catch (err) {
354069
+ if (isAppiumDriverDeadError(err)) throw err;
354070
+ return false;
354071
+ }
354072
+ }
353855
354073
  async function getPageSource(sessionPath) {
353856
354074
  try {
353857
354075
  const value = readValue(await appiumRequest(`${sessionPath}/source`));
@@ -356360,7 +356578,15 @@ function mobileParallelSessionCapabilities(platform3, portOffset = 0, deviceId =
356360
356578
  return (Number.isFinite(parsed) ? parsed : fallback) + offset;
356361
356579
  };
356362
356580
  if (platform3 === "ANDROID") {
356363
- return { "appium:systemPort": readPort("APPIUM_SYSTEM_PORT", 8200) };
356581
+ return {
356582
+ "appium:systemPort": readPort("APPIUM_SYSTEM_PORT", 8200),
356583
+ // Host port that UiAutomator2 forwards the on-device MJPEG broadcaster
356584
+ // (device port 7810) to. Without this the live mirror has to poll
356585
+ // WebDriver /screenshot for full-resolution PNGs, which the runner cannot
356586
+ // downscale itself. Base differs from iOS's 9100 so a concurrent
356587
+ // Android+iOS pair can never collide on a shared slot offset.
356588
+ "appium:mjpegServerPort": readPort("APPIUM_ANDROID_MJPEG_SERVER_PORT", 7810)
356589
+ };
356364
356590
  }
356365
356591
  const safeDevice = deviceId.replace(/[^A-Za-z0-9._-]+/g, "-") || `device-${offset}`;
356366
356592
  const derivedDataRoot = env.APPIUM_DERIVED_DATA_PATH?.trim() || (0, import_node_path2.join)((0, import_node_os2.tmpdir)(), "validateqa-wda");
@@ -357615,6 +357841,15 @@ function acquireControlPortSlot(deviceId) {
357615
357841
  function releaseControlPortSlot(deviceId) {
357616
357842
  controlPortSlotsByDevice.delete(deviceId);
357617
357843
  }
357844
+ function getControlPortSlot(deviceId) {
357845
+ return controlPortSlotsByDevice.get(deviceId);
357846
+ }
357847
+ function mobileMjpegPortForDevice(platform3, deviceId, env = process.env) {
357848
+ const slot = getControlPortSlot(deviceId);
357849
+ if (slot === void 0) return null;
357850
+ const port = mobileParallelSessionCapabilities(platform3, slot, deviceId, env)["appium:mjpegServerPort"];
357851
+ return typeof port === "number" && Number.isFinite(port) ? port : null;
357852
+ }
357618
357853
  var PINNED_DEVICE_SNAPSHOT_TTL_MS = 25 * 6e4;
357619
357854
  var pinnedDeviceSnapshot = null;
357620
357855
  async function executeMobileTest(options) {
@@ -358553,12 +358788,106 @@ function decideMobileDeviceGate(input) {
358553
358788
  }
358554
358789
 
358555
358790
  // src/services/mobile-bridge.ts
358556
- var import_http = require("http");
358791
+ var import_http2 = require("http");
358557
358792
  var import_child_process4 = require("child_process");
358558
358793
  var import_crypto = require("crypto");
358559
358794
  var import_os4 = require("os");
358560
358795
  var import_path4 = require("path");
358561
358796
  var import_fs3 = require("fs");
358797
+
358798
+ // src/services/mobile/mjpeg-stream.ts
358799
+ var import_http = require("http");
358800
+ init_dist();
358801
+ var JPEG_SOI = Buffer.from([255, 216]);
358802
+ var JPEG_EOI = Buffer.from([255, 217]);
358803
+ function startMjpegStream(options) {
358804
+ const { port, host = "127.0.0.1", onFrame, onError } = options;
358805
+ let stopped = false;
358806
+ let req = null;
358807
+ let response = null;
358808
+ let buffer = Buffer.alloc(0);
358809
+ const fail = (error2) => {
358810
+ if (stopped) return;
358811
+ stopped = true;
358812
+ try {
358813
+ req?.destroy();
358814
+ } catch {
358815
+ }
358816
+ try {
358817
+ response?.destroy();
358818
+ } catch {
358819
+ }
358820
+ buffer = Buffer.alloc(0);
358821
+ onError?.(error2);
358822
+ };
358823
+ const consume = (chunk) => {
358824
+ if (stopped) return;
358825
+ buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]);
358826
+ for (; ; ) {
358827
+ const start = buffer.indexOf(JPEG_SOI);
358828
+ if (start < 0) {
358829
+ if (buffer.length > 1) buffer = buffer.subarray(buffer.length - 1);
358830
+ return;
358831
+ }
358832
+ const end = buffer.indexOf(JPEG_EOI, start + JPEG_SOI.length);
358833
+ if (end < 0) {
358834
+ if (start > 0) buffer = buffer.subarray(start);
358835
+ if (buffer.length > MOBILE_MJPEG_MAX_FRAME_BYTES) buffer = Buffer.alloc(0);
358836
+ return;
358837
+ }
358838
+ const frameEnd = end + JPEG_EOI.length;
358839
+ const frame = buffer.subarray(start, frameEnd);
358840
+ buffer = buffer.subarray(frameEnd);
358841
+ try {
358842
+ onFrame(frame);
358843
+ } catch {
358844
+ }
358845
+ }
358846
+ };
358847
+ try {
358848
+ req = (0, import_http.request)(
358849
+ { host, port, path: "/", method: "GET", headers: { Connection: "keep-alive" } },
358850
+ (res) => {
358851
+ response = res;
358852
+ if (stopped) {
358853
+ res.destroy();
358854
+ return;
358855
+ }
358856
+ if (res.statusCode && res.statusCode >= 400) {
358857
+ fail(new Error(`MJPEG broadcaster returned HTTP ${res.statusCode}`));
358858
+ res.destroy();
358859
+ return;
358860
+ }
358861
+ res.on("data", (chunk) => consume(chunk));
358862
+ res.on("end", () => fail(new Error("MJPEG stream ended")));
358863
+ res.on("error", (err) => fail(err));
358864
+ }
358865
+ );
358866
+ req.on("error", (err) => fail(err));
358867
+ req.setTimeout(1e4, () => fail(new Error("MJPEG connect timeout")));
358868
+ req.end();
358869
+ } catch (err) {
358870
+ fail(err instanceof Error ? err : new Error(String(err)));
358871
+ }
358872
+ return {
358873
+ stop: () => {
358874
+ if (stopped) return;
358875
+ stopped = true;
358876
+ try {
358877
+ req?.destroy();
358878
+ } catch {
358879
+ }
358880
+ try {
358881
+ response?.destroy();
358882
+ } catch {
358883
+ }
358884
+ buffer = Buffer.alloc(0);
358885
+ },
358886
+ isActive: () => !stopped
358887
+ };
358888
+ }
358889
+
358890
+ // src/services/mobile-bridge.ts
358562
358891
  init_dist();
358563
358892
  var MOBILE_BRIDGE_PORT2 = 9515;
358564
358893
  var MAX_REQUEST_BODY_BYTES = 64 * 1024;
@@ -359112,16 +359441,66 @@ function writeFrameToChannelClient(ch, client, frame) {
359112
359441
  } catch {
359113
359442
  }
359114
359443
  }
359444
+ function broadcastChannelFrame(ch, frame) {
359445
+ ch.lastFrame = frame;
359446
+ for (const client of ch.clients) writeFrameToChannelClient(ch, client, frame);
359447
+ }
359448
+ function tryStartChannelMjpeg(ch) {
359449
+ if (ch.mjpeg || ch.mjpegStarting || ch.mjpegUnavailable) return;
359450
+ const port = mobileMjpegPortForDevice(ch.platform, ch.deviceId);
359451
+ if (port === null) {
359452
+ ch.mjpegUnavailable = true;
359453
+ return;
359454
+ }
359455
+ ch.mjpegStarting = true;
359456
+ const handle = startMjpegStream({
359457
+ port,
359458
+ onFrame: (jpeg) => {
359459
+ const isOwned = ch.mjpeg === handle;
359460
+ const isCandidate = ch.mjpeg === null && ch.mjpegStarting;
359461
+ if (!isOwned && !isCandidate) return;
359462
+ ch.mjpegLastFrameAt = Date.now();
359463
+ ch.nullTicks = 0;
359464
+ if (isCandidate) {
359465
+ ch.mjpeg = handle;
359466
+ ch.mjpegStarting = false;
359467
+ if (ch.pollTimer) {
359468
+ clearInterval(ch.pollTimer);
359469
+ ch.pollTimer = null;
359470
+ }
359471
+ }
359472
+ broadcastChannelFrame(ch, {
359473
+ timestamp: ch.mjpegLastFrameAt,
359474
+ screenshot: jpeg.toString("base64"),
359475
+ source: null,
359476
+ mimeType: "image/jpeg"
359477
+ });
359478
+ },
359479
+ onError: () => {
359480
+ const wasActive = ch.mjpeg === handle;
359481
+ ch.mjpeg = null;
359482
+ ch.mjpegStarting = false;
359483
+ ch.mjpegUnavailable = true;
359484
+ if (wasActive && ch.clients.size > 0) ensureChannelPolling(ch);
359485
+ }
359486
+ });
359487
+ const firstFrameDeadline = setTimeout(() => {
359488
+ if (ch.mjpeg) return;
359489
+ ch.mjpegStarting = false;
359490
+ ch.mjpegUnavailable = true;
359491
+ handle.stop();
359492
+ if (ch.clients.size > 0) ensureChannelPolling(ch);
359493
+ }, MOBILE_MJPEG_FIRST_FRAME_TIMEOUT_MS);
359494
+ firstFrameDeadline.unref?.();
359495
+ }
359115
359496
  async function pollRunnerChannel(ch) {
359116
359497
  if (ch.clients.size === 0 || ch.polling) return;
359498
+ if (ch.mjpeg) return;
359117
359499
  ch.polling = true;
359118
359500
  try {
359119
359501
  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) {
359502
+ const screenshot = await getScreenshot(`/session/${ch.sessionId}`);
359503
+ if (!screenshot) {
359125
359504
  ch.nullTicks++;
359126
359505
  if (ch.nullTicks >= MIRROR_DEAD_SESSION_TICKS) {
359127
359506
  const alive = await isSessionAlive(ch.sessionId);
@@ -359134,32 +359513,45 @@ async function pollRunnerChannel(ch) {
359134
359513
  } else {
359135
359514
  ch.nullTicks = 0;
359136
359515
  }
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);
359516
+ broadcastChannelFrame(ch, {
359517
+ timestamp: Date.now(),
359518
+ screenshot: screenshot ?? null,
359519
+ source: null,
359520
+ mimeType: "image/png"
359521
+ });
359140
359522
  } catch {
359141
359523
  } finally {
359142
359524
  ch.polling = false;
359143
359525
  }
359144
359526
  }
359145
359527
  function ensureChannelPolling(ch) {
359146
- if (ch.pollTimer) return;
359528
+ tryStartChannelMjpeg(ch);
359529
+ if (ch.mjpeg || ch.pollTimer) return;
359147
359530
  ch.pollTimer = setInterval(() => {
359148
359531
  void pollRunnerChannel(ch);
359149
359532
  }, MOBILE_SCREENSHOT_INTERVAL_MS);
359150
359533
  ch.pollTimer.unref?.();
359151
359534
  }
359152
359535
  function stopChannelPollingIfIdle(ch) {
359153
- if (ch.clients.size === 0 && ch.pollTimer) {
359536
+ if (ch.clients.size > 0) return;
359537
+ if (ch.pollTimer) {
359154
359538
  clearInterval(ch.pollTimer);
359155
359539
  ch.pollTimer = null;
359156
359540
  }
359541
+ if (ch.mjpeg) {
359542
+ ch.mjpeg.stop();
359543
+ ch.mjpeg = null;
359544
+ }
359157
359545
  }
359158
359546
  function closeRunnerChannel(ch) {
359159
359547
  if (ch.pollTimer) {
359160
359548
  clearInterval(ch.pollTimer);
359161
359549
  ch.pollTimer = null;
359162
359550
  }
359551
+ if (ch.mjpeg) {
359552
+ ch.mjpeg.stop();
359553
+ ch.mjpeg = null;
359554
+ }
359163
359555
  for (const client of ch.clients) {
359164
359556
  try {
359165
359557
  client.end();
@@ -359556,7 +359948,7 @@ async function startMobileBridge(options) {
359556
359948
  }
359557
359949
  killStaleBridge();
359558
359950
  return new Promise((resolve2, reject) => {
359559
- server2 = (0, import_http.createServer)(route);
359951
+ server2 = (0, import_http2.createServer)(route);
359560
359952
  server2.on("error", (err) => {
359561
359953
  if (err.code === "EADDRINUSE") {
359562
359954
  reject(new Error(`Port ${port} already in use. Another bridge instance may be running.`));
@@ -359744,12 +360136,24 @@ function attachMirrorSession(sessionId, platform3, deviceId) {
359744
360136
  lastFrame: null,
359745
360137
  pollTimer: null,
359746
360138
  nullTicks: 0,
359747
- polling: false
360139
+ polling: false,
360140
+ mjpeg: null,
360141
+ mjpegLastFrameAt: 0,
360142
+ mjpegStarting: false,
360143
+ mjpegUnavailable: false
359748
360144
  };
359749
360145
  ch.sessionId = sessionId;
359750
360146
  ch.platform = platform3;
359751
360147
  ch.nullTicks = 0;
360148
+ if (ch.mjpeg) {
360149
+ ch.mjpeg.stop();
360150
+ ch.mjpeg = null;
360151
+ }
360152
+ ch.mjpegUnavailable = false;
360153
+ ch.mjpegStarting = false;
359752
360154
  runnerChannels.set(deviceId, ch);
360155
+ void applyMjpegSettings(`/session/${sessionId}`, MOBILE_MJPEG_SETTINGS).catch(() => {
360156
+ });
359753
360157
  if (ch.clients.size > 0) {
359754
360158
  ensureChannelPolling(ch);
359755
360159
  void pollRunnerChannel(ch);
@@ -362933,7 +363337,9 @@ ${finalVerifyResult.logs ?? ""}`;
362933
363337
  liveBrowserHandle.updateSnapshot({
362934
363338
  currentUrl: mobileTarget.appId,
362935
363339
  thumbnailJpegBase64: base64,
362936
- thumbnailCapturedAt: Date.now()
363340
+ thumbnailCapturedAt: Date.now(),
363341
+ // Appium's screenshot endpoint returns PNG despite the field name.
363342
+ mimeType: "image/png"
362937
363343
  });
362938
363344
  requestLiveBrowserHeartbeat();
362939
363345
  },
@@ -364178,6 +364584,22 @@ async function startRunner(opts) {
364178
364584
  } catch {
364179
364585
  }
364180
364586
  };
364587
+ const liveFramePushInFlight = /* @__PURE__ */ new Set();
364588
+ const publishLiveFrame = (frame) => {
364589
+ if (!frame.projectId) return;
364590
+ if (liveFramePushInFlight.has(frame.sessionId)) return;
364591
+ liveFramePushInFlight.add(frame.sessionId);
364592
+ void fetch(`${serverUrl}/api/runner/live-frame`, {
364593
+ method: "POST",
364594
+ headers,
364595
+ body: JSON.stringify(frame),
364596
+ signal: AbortSignal.timeout(5e3)
364597
+ }).catch(() => {
364598
+ }).finally(() => {
364599
+ liveFramePushInFlight.delete(frame.sessionId);
364600
+ });
364601
+ };
364602
+ import_runner_core11.liveBrowserRegistry.setFramePublisher(publishLiveFrame);
364181
364603
  let liveBrowserHeartbeatInFlight = false;
364182
364604
  let liveBrowserHeartbeatPending = false;
364183
364605
  let lastLiveBrowserHeartbeatAt = 0;