@validate.qa/runner 1.0.77 → 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 +496 -48
  2. package/dist/cli.mjs +494 -46
  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();
@@ -86295,11 +86358,25 @@ var require_api_call_redaction = __commonJS({
86295
86358
  const lower = name.toLowerCase();
86296
86359
  return headers.find((h) => h.name?.toLowerCase() === lower)?.value;
86297
86360
  }
86361
+ var ENUM_LIKE_FIELD_NAME = /status|state|phase|role|tier|kind|mode|level|category|priority|visibility|^type$|_type$/i;
86362
+ var NEVER_SAMPLE_FIELD_NAME = /password|token|secret|api.?key|auth|bearer|credential|session|email|phone|address|ssn/i;
86363
+ var ENUM_LIKE_VALUE = /^[A-Za-z][A-Za-z0-9_-]{0,29}$/;
86364
+ function enumSampleHint(key, val) {
86365
+ if (typeof val !== "string")
86366
+ return null;
86367
+ if (NEVER_SAMPLE_FIELD_NAME.test(key))
86368
+ return null;
86369
+ if (!ENUM_LIKE_FIELD_NAME.test(key))
86370
+ return null;
86371
+ if (!ENUM_LIKE_VALUE.test(val))
86372
+ return null;
86373
+ return val;
86374
+ }
86298
86375
  function extractBodyShape(body) {
86299
86376
  if (!body)
86300
86377
  return void 0;
86301
86378
  try {
86302
- let describeValue = function(val, depth) {
86379
+ let describeValue = function(val, depth, key) {
86303
86380
  if (val === null)
86304
86381
  return "null";
86305
86382
  if (Array.isArray(val)) {
@@ -86316,10 +86393,16 @@ var require_api_call_redaction = __commonJS({
86316
86393
  const subFields = Object.keys(v).slice(0, 15).join(",");
86317
86394
  return subFields ? `${k}:{${subFields}}` : k;
86318
86395
  }
86319
- return k;
86396
+ const hint = enumSampleHint(k, v);
86397
+ return hint ? `${k}(e.g. "${hint}")` : k;
86320
86398
  });
86321
86399
  return `object{${fieldDescs.join(",")}}`;
86322
86400
  }
86401
+ if (key) {
86402
+ const hint = enumSampleHint(key, val);
86403
+ if (hint)
86404
+ return `string (e.g. "${hint}")`;
86405
+ }
86323
86406
  return typeof val;
86324
86407
  };
86325
86408
  const parsed = JSON.parse(body);
@@ -86327,7 +86410,7 @@ var require_api_call_redaction = __commonJS({
86327
86410
  return void 0;
86328
86411
  const shape = {};
86329
86412
  for (const [key, val] of Object.entries(parsed)) {
86330
- shape[key] = describeValue(val, 1);
86413
+ shape[key] = describeValue(val, 1, key);
86331
86414
  }
86332
86415
  return Object.keys(shape).length > 0 ? shape : void 0;
86333
86416
  } catch {
@@ -87942,6 +88025,7 @@ var require_playwright_native = __commonJS({
87942
88025
  var PACKAGE_DIR = (0, path_1.resolve)(__dirname, "..", "..");
87943
88026
  var RUNS_DIR = process.env.RUNS_DIR || (0, path_1.join)(PACKAGE_DIR, ".qa-runs");
87944
88027
  var PW_CLI = __require.resolve("@playwright/test/cli");
88028
+ var LIVE_BROWSER_STATE_POLL_INTERVAL_MS = 500;
87945
88029
  async function executeTestViaNativePlaywright2(options) {
87946
88030
  const { playwrightCode, baseUrl, testName, testVariables, authStateCapturePath, storageStatePath, extraHttpHeaders, authStandingMode, validateContext, liveBrowserHandle, onLiveBrowserSnapshot } = options;
87947
88031
  const logs2 = [];
@@ -88031,7 +88115,7 @@ test.beforeEach(async ({ page }, testInfo) => {
88031
88115
  await __qaLiveBrowserCapture();
88032
88116
  __qaLiveBrowserInterval = setInterval(() => {
88033
88117
  void __qaLiveBrowserCapture?.();
88034
- }, 500);
88118
+ }, ${LIVE_BROWSER_STATE_POLL_INTERVAL_MS});
88035
88119
  });
88036
88120
 
88037
88121
  test.afterEach(async () => {
@@ -88448,7 +88532,7 @@ ${passed ? "\u2705" : "\u274C"} Test ${passed ? "PASSED" : "FAILED"}${error2 ? `
88448
88532
  };
88449
88533
  const interval = setInterval(() => {
88450
88534
  void poll();
88451
- }, 1e3);
88535
+ }, LIVE_BROWSER_STATE_POLL_INTERVAL_MS);
88452
88536
  interval.unref?.();
88453
88537
  void poll();
88454
88538
  return async () => {
@@ -97646,6 +97730,12 @@ ${testOpen}`;
97646
97730
  }
97647
97731
  }
97648
97732
  }
97733
+ const droppedByCalls = codeForMatcherScan.match(/\.get(?:Role|Text|Label|Placeholder|TestId|AltText|Title)\s*\(/g) ?? [];
97734
+ for (const call of droppedByCalls) {
97735
+ const methodName = call.replace(/[\s(]+$/, "").slice(1);
97736
+ logs2?.push(` \u2717 Rejected "${t.name}": "${methodName}" is not a real Playwright method \u2014 likely "${methodName.replace("get", "getBy")}" with "By" dropped`);
97737
+ return false;
97738
+ }
97649
97739
  if (code.includes("// TODO")) {
97650
97740
  logs2?.push(` \u26A0 "${t.name}" contains TODO comment \u2014 marking as unverified`);
97651
97741
  t.tags = t.tags.filter((tag) => tag !== "@verified");
@@ -352083,16 +352173,16 @@ var require_live_browser_registry = __commonJS({
352083
352173
  "use strict";
352084
352174
  Object.defineProperty(exports, "__esModule", { value: true });
352085
352175
  exports.liveBrowserRegistry = void 0;
352176
+ var shared_1 = (init_dist(), __toCommonJS(dist_exports));
352086
352177
  var THUMBNAIL_INTERVAL_MS = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_INTERVAL_MS ?? "4000", 10) || 4e3;
352087
352178
  var THUMBNAIL_QUALITY = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_QUALITY ?? "60", 10) || 60;
352088
- var BROWSER_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_MAX_BASE64_BYTES ?? "400000", 10) || 4e5;
352089
- var MOBILE_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_MOBILE_THUMBNAIL_MAX_BASE64_BYTES ?? "4500000", 10) || 45e5;
352090
352179
  var STALE_SESSION_MS = parseInt(process.env.LIVE_BROWSER_STALE_MS ?? `${10 * 60 * 1e3}`, 10) || 10 * 60 * 1e3;
352091
352180
  var MAX_SESSIONS = parseInt(process.env.LIVE_BROWSER_MAX_SESSIONS ?? "32", 10) || 32;
352092
352181
  var LiveBrowserRegistry = class {
352093
352182
  entries = /* @__PURE__ */ new Map();
352094
352183
  snapshotTimer = null;
352095
352184
  capturing = false;
352185
+ publisher = null;
352096
352186
  /** Register a new session. Returns a handle the caller must hold and
352097
352187
  * ultimately call .unregister() on. */
352098
352188
  register(meta) {
@@ -352106,7 +352196,10 @@ var require_live_browser_registry = __commonJS({
352106
352196
  lastUpdatedAt: now,
352107
352197
  page: null,
352108
352198
  isCapturing: false,
352109
- pendingCapture: false
352199
+ pendingCapture: false,
352200
+ screencast: null,
352201
+ screencastStarting: false,
352202
+ lastPublishedAt: 0
352110
352203
  };
352111
352204
  this.entries.set(meta.sessionId, entry);
352112
352205
  this.ensureSnapshotLoop();
@@ -352119,19 +352212,40 @@ var require_live_browser_registry = __commonJS({
352119
352212
  unregister: () => this.unregister(meta.sessionId)
352120
352213
  };
352121
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
+ }
352122
352222
  unregister(sessionId) {
352123
352223
  const entry = this.entries.get(sessionId);
352124
352224
  if (!entry)
352125
352225
  return;
352126
- entry.page = null;
352127
- this.entries.delete(sessionId);
352226
+ this.dropEntry(sessionId, entry);
352128
352227
  if (this.entries.size === 0)
352129
352228
  this.stopSnapshotLoop();
352130
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
+ }
352131
352243
  setPage(sessionId, page) {
352132
352244
  const entry = this.entries.get(sessionId);
352133
352245
  if (!entry)
352134
352246
  return;
352247
+ if (entry.screencast && entry.screencast.page !== page)
352248
+ void this.stopScreencast(entry);
352135
352249
  entry.page = page;
352136
352250
  entry.lastUpdatedAt = Date.now();
352137
352251
  if (page) {
@@ -352141,6 +352255,119 @@ var require_live_browser_registry = __commonJS({
352141
352255
  entry.currentUrl = url;
352142
352256
  } catch {
352143
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 {
352144
352371
  }
352145
352372
  }
352146
352373
  setCurrentUrl(sessionId, url) {
@@ -352152,7 +352379,7 @@ var require_live_browser_registry = __commonJS({
352152
352379
  const previousUrl = entry.currentUrl;
352153
352380
  entry.currentUrl = url;
352154
352381
  entry.lastUpdatedAt = Date.now();
352155
- if (url && previousUrl && url !== previousUrl) {
352382
+ if (url && previousUrl && url !== previousUrl && !entry.screencast) {
352156
352383
  entry.pendingCapture = true;
352157
352384
  const schedule = (delay) => {
352158
352385
  setTimeout(() => {
@@ -352173,6 +352400,11 @@ var require_live_browser_registry = __commonJS({
352173
352400
  schedule(100);
352174
352401
  }
352175
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
+ */
352176
352408
  updateSnapshot(sessionId, snapshot) {
352177
352409
  const entry = this.entries.get(sessionId);
352178
352410
  if (!entry)
@@ -352181,12 +352413,10 @@ var require_live_browser_registry = __commonJS({
352181
352413
  entry.currentUrl = snapshot.currentUrl;
352182
352414
  }
352183
352415
  const thumbnail = snapshot.thumbnailJpegBase64;
352184
- const thumbnailLimit = entry.meta.surface === "mobile" ? MOBILE_THUMBNAIL_MAX_BASE64_BYTES : BROWSER_THUMBNAIL_MAX_BASE64_BYTES;
352185
- if (thumbnail && thumbnail.length <= thumbnailLimit) {
352186
- entry.thumbnailJpegBase64 = thumbnail;
352187
- entry.thumbnailCapturedAt = typeof snapshot.thumbnailCapturedAt === "number" && Number.isFinite(snapshot.thumbnailCapturedAt) ? snapshot.thumbnailCapturedAt : Date.now();
352188
- entry.thumbnailWidth = typeof snapshot.thumbnailWidth === "number" && Number.isFinite(snapshot.thumbnailWidth) ? snapshot.thumbnailWidth : void 0;
352189
- 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);
352190
352420
  }
352191
352421
  entry.lastUpdatedAt = Date.now();
352192
352422
  }
@@ -352229,9 +352459,11 @@ var require_live_browser_registry = __commonJS({
352229
352459
  async captureAllNow() {
352230
352460
  await this.captureAll();
352231
352461
  }
352232
- /** Stop the snapshot loop and clear all entries. Idempotent. */
352462
+ /** Stop the snapshot loop, tear down every screencast, and clear all entries. Idempotent. */
352233
352463
  shutdown() {
352234
352464
  this.stopSnapshotLoop();
352465
+ for (const entry of this.entries.values())
352466
+ void this.stopScreencast(entry);
352235
352467
  this.entries.clear();
352236
352468
  }
352237
352469
  ensureSnapshotLoop() {
@@ -352259,15 +352491,13 @@ var require_live_browser_registry = __commonJS({
352259
352491
  }
352260
352492
  }
352261
352493
  if (oldestKey) {
352262
- this.entries.get(oldestKey).page = null;
352263
- this.entries.delete(oldestKey);
352494
+ this.dropEntry(oldestKey, this.entries.get(oldestKey));
352264
352495
  }
352265
352496
  }
352266
352497
  sweepStale(now) {
352267
352498
  for (const [key, entry] of this.entries) {
352268
352499
  if (now - entry.lastUpdatedAt > STALE_SESSION_MS) {
352269
- entry.page = null;
352270
- this.entries.delete(key);
352500
+ this.dropEntry(key, entry);
352271
352501
  }
352272
352502
  }
352273
352503
  }
@@ -352308,6 +352538,11 @@ var require_live_browser_registry = __commonJS({
352308
352538
  entry.currentUrl = url;
352309
352539
  } catch {
352310
352540
  }
352541
+ if (entry.screencast) {
352542
+ if (entry.thumbnailJpegBase64)
352543
+ entry.thumbnailCapturedAt = Date.now();
352544
+ return;
352545
+ }
352311
352546
  const buf = await page.screenshot({
352312
352547
  type: "jpeg",
352313
352548
  quality: THUMBNAIL_QUALITY,
@@ -352315,17 +352550,8 @@ var require_live_browser_registry = __commonJS({
352315
352550
  timeout: 3e3,
352316
352551
  scale: "css"
352317
352552
  });
352318
- const base64 = buf.toString("base64");
352319
- if (base64.length > BROWSER_THUMBNAIL_MAX_BASE64_BYTES) {
352320
- return;
352321
- }
352322
- entry.thumbnailJpegBase64 = base64;
352323
- entry.thumbnailCapturedAt = Date.now();
352324
352553
  const viewport = page.viewportSize?.();
352325
- if (viewport) {
352326
- entry.thumbnailWidth = viewport.width;
352327
- entry.thumbnailHeight = viewport.height;
352328
- }
352554
+ this.acceptFrame(entry, buf.toString("base64"), "image/jpeg", viewport?.width, viewport?.height);
352329
352555
  } catch {
352330
352556
  } finally {
352331
352557
  entry.isCapturing = false;
@@ -353039,7 +353265,7 @@ var require_package4 = __commonJS({
353039
353265
  "package.json"(exports, module) {
353040
353266
  module.exports = {
353041
353267
  name: "@validate.qa/runner",
353042
- version: "1.0.77",
353268
+ version: "1.0.79",
353043
353269
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353044
353270
  bin: {
353045
353271
  "validate-runner": "dist/cli.js"
@@ -353831,6 +354057,24 @@ async function getScreenshot(sessionPath) {
353831
354057
  return null;
353832
354058
  }
353833
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
+ }
353834
354078
  async function getPageSource(sessionPath) {
353835
354079
  try {
353836
354080
  const value = readValue(await appiumRequest(`${sessionPath}/source`));
@@ -356342,7 +356586,15 @@ function mobileParallelSessionCapabilities(platform3, portOffset = 0, deviceId =
356342
356586
  return (Number.isFinite(parsed) ? parsed : fallback) + offset;
356343
356587
  };
356344
356588
  if (platform3 === "ANDROID") {
356345
- 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
+ };
356346
356598
  }
356347
356599
  const safeDevice = deviceId.replace(/[^A-Za-z0-9._-]+/g, "-") || `device-${offset}`;
356348
356600
  const derivedDataRoot = env.APPIUM_DERIVED_DATA_PATH?.trim() || join5(tmpdir3(), "validateqa-wda");
@@ -357597,6 +357849,15 @@ function acquireControlPortSlot(deviceId) {
357597
357849
  function releaseControlPortSlot(deviceId) {
357598
357850
  controlPortSlotsByDevice.delete(deviceId);
357599
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
+ }
357600
357861
  var PINNED_DEVICE_SNAPSHOT_TTL_MS = 25 * 6e4;
357601
357862
  var pinnedDeviceSnapshot = null;
357602
357863
  async function executeMobileTest(options) {
@@ -358541,6 +358802,100 @@ import { randomBytes, timingSafeEqual } from "crypto";
358541
358802
  import { homedir as homedir2 } from "os";
358542
358803
  import { join as join6, dirname } from "path";
358543
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
358544
358899
  init_dist();
358545
358900
  var MOBILE_BRIDGE_PORT2 = 9515;
358546
358901
  var MAX_REQUEST_BODY_BYTES = 64 * 1024;
@@ -359094,16 +359449,66 @@ function writeFrameToChannelClient(ch, client, frame) {
359094
359449
  } catch {
359095
359450
  }
359096
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
+ }
359097
359504
  async function pollRunnerChannel(ch) {
359098
359505
  if (ch.clients.size === 0 || ch.polling) return;
359506
+ if (ch.mjpeg) return;
359099
359507
  ch.polling = true;
359100
359508
  try {
359101
359509
  validateSessionId(ch.sessionId);
359102
- const [screenshot, source] = await Promise.all([
359103
- getScreenshot(`/session/${ch.sessionId}`),
359104
- getPageSource(`/session/${ch.sessionId}`)
359105
- ]);
359106
- if (!screenshot && !source) {
359510
+ const screenshot = await getScreenshot(`/session/${ch.sessionId}`);
359511
+ if (!screenshot) {
359107
359512
  ch.nullTicks++;
359108
359513
  if (ch.nullTicks >= MIRROR_DEAD_SESSION_TICKS) {
359109
359514
  const alive = await isSessionAlive(ch.sessionId);
@@ -359116,32 +359521,45 @@ async function pollRunnerChannel(ch) {
359116
359521
  } else {
359117
359522
  ch.nullTicks = 0;
359118
359523
  }
359119
- const frame = { timestamp: Date.now(), screenshot: screenshot ?? null, source: source ?? null };
359120
- ch.lastFrame = frame;
359121
- 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
+ });
359122
359530
  } catch {
359123
359531
  } finally {
359124
359532
  ch.polling = false;
359125
359533
  }
359126
359534
  }
359127
359535
  function ensureChannelPolling(ch) {
359128
- if (ch.pollTimer) return;
359536
+ tryStartChannelMjpeg(ch);
359537
+ if (ch.mjpeg || ch.pollTimer) return;
359129
359538
  ch.pollTimer = setInterval(() => {
359130
359539
  void pollRunnerChannel(ch);
359131
359540
  }, MOBILE_SCREENSHOT_INTERVAL_MS);
359132
359541
  ch.pollTimer.unref?.();
359133
359542
  }
359134
359543
  function stopChannelPollingIfIdle(ch) {
359135
- if (ch.clients.size === 0 && ch.pollTimer) {
359544
+ if (ch.clients.size > 0) return;
359545
+ if (ch.pollTimer) {
359136
359546
  clearInterval(ch.pollTimer);
359137
359547
  ch.pollTimer = null;
359138
359548
  }
359549
+ if (ch.mjpeg) {
359550
+ ch.mjpeg.stop();
359551
+ ch.mjpeg = null;
359552
+ }
359139
359553
  }
359140
359554
  function closeRunnerChannel(ch) {
359141
359555
  if (ch.pollTimer) {
359142
359556
  clearInterval(ch.pollTimer);
359143
359557
  ch.pollTimer = null;
359144
359558
  }
359559
+ if (ch.mjpeg) {
359560
+ ch.mjpeg.stop();
359561
+ ch.mjpeg = null;
359562
+ }
359145
359563
  for (const client of ch.clients) {
359146
359564
  try {
359147
359565
  client.end();
@@ -359726,12 +360144,24 @@ function attachMirrorSession(sessionId, platform3, deviceId) {
359726
360144
  lastFrame: null,
359727
360145
  pollTimer: null,
359728
360146
  nullTicks: 0,
359729
- polling: false
360147
+ polling: false,
360148
+ mjpeg: null,
360149
+ mjpegLastFrameAt: 0,
360150
+ mjpegStarting: false,
360151
+ mjpegUnavailable: false
359730
360152
  };
359731
360153
  ch.sessionId = sessionId;
359732
360154
  ch.platform = platform3;
359733
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;
359734
360162
  runnerChannels.set(deviceId, ch);
360163
+ void applyMjpegSettings(`/session/${sessionId}`, MOBILE_MJPEG_SETTINGS).catch(() => {
360164
+ });
359735
360165
  if (ch.clients.size > 0) {
359736
360166
  ensureChannelPolling(ch);
359737
360167
  void pollRunnerChannel(ch);
@@ -362915,7 +363345,9 @@ ${finalVerifyResult.logs ?? ""}`;
362915
363345
  liveBrowserHandle.updateSnapshot({
362916
363346
  currentUrl: mobileTarget.appId,
362917
363347
  thumbnailJpegBase64: base64,
362918
- thumbnailCapturedAt: Date.now()
363348
+ thumbnailCapturedAt: Date.now(),
363349
+ // Appium's screenshot endpoint returns PNG despite the field name.
363350
+ mimeType: "image/png"
362919
363351
  });
362920
363352
  requestLiveBrowserHeartbeat();
362921
363353
  },
@@ -364160,6 +364592,22 @@ async function startRunner(opts) {
364160
364592
  } catch {
364161
364593
  }
364162
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);
364163
364611
  let liveBrowserHeartbeatInFlight = false;
364164
364612
  let liveBrowserHeartbeatPending = false;
364165
364613
  let lastLiveBrowserHeartbeatAt = 0;