@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.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();
@@ -86290,11 +86353,25 @@ var require_api_call_redaction = __commonJS({
86290
86353
  const lower = name.toLowerCase();
86291
86354
  return headers.find((h) => h.name?.toLowerCase() === lower)?.value;
86292
86355
  }
86356
+ var ENUM_LIKE_FIELD_NAME = /status|state|phase|role|tier|kind|mode|level|category|priority|visibility|^type$|_type$/i;
86357
+ var NEVER_SAMPLE_FIELD_NAME = /password|token|secret|api.?key|auth|bearer|credential|session|email|phone|address|ssn/i;
86358
+ var ENUM_LIKE_VALUE = /^[A-Za-z][A-Za-z0-9_-]{0,29}$/;
86359
+ function enumSampleHint(key, val) {
86360
+ if (typeof val !== "string")
86361
+ return null;
86362
+ if (NEVER_SAMPLE_FIELD_NAME.test(key))
86363
+ return null;
86364
+ if (!ENUM_LIKE_FIELD_NAME.test(key))
86365
+ return null;
86366
+ if (!ENUM_LIKE_VALUE.test(val))
86367
+ return null;
86368
+ return val;
86369
+ }
86293
86370
  function extractBodyShape(body) {
86294
86371
  if (!body)
86295
86372
  return void 0;
86296
86373
  try {
86297
- let describeValue = function(val, depth) {
86374
+ let describeValue = function(val, depth, key) {
86298
86375
  if (val === null)
86299
86376
  return "null";
86300
86377
  if (Array.isArray(val)) {
@@ -86311,10 +86388,16 @@ var require_api_call_redaction = __commonJS({
86311
86388
  const subFields = Object.keys(v).slice(0, 15).join(",");
86312
86389
  return subFields ? `${k}:{${subFields}}` : k;
86313
86390
  }
86314
- return k;
86391
+ const hint = enumSampleHint(k, v);
86392
+ return hint ? `${k}(e.g. "${hint}")` : k;
86315
86393
  });
86316
86394
  return `object{${fieldDescs.join(",")}}`;
86317
86395
  }
86396
+ if (key) {
86397
+ const hint = enumSampleHint(key, val);
86398
+ if (hint)
86399
+ return `string (e.g. "${hint}")`;
86400
+ }
86318
86401
  return typeof val;
86319
86402
  };
86320
86403
  const parsed = JSON.parse(body);
@@ -86322,7 +86405,7 @@ var require_api_call_redaction = __commonJS({
86322
86405
  return void 0;
86323
86406
  const shape = {};
86324
86407
  for (const [key, val] of Object.entries(parsed)) {
86325
- shape[key] = describeValue(val, 1);
86408
+ shape[key] = describeValue(val, 1, key);
86326
86409
  }
86327
86410
  return Object.keys(shape).length > 0 ? shape : void 0;
86328
86411
  } catch {
@@ -87937,6 +88020,7 @@ var require_playwright_native = __commonJS({
87937
88020
  var PACKAGE_DIR = (0, path_1.resolve)(__dirname, "..", "..");
87938
88021
  var RUNS_DIR = process.env.RUNS_DIR || (0, path_1.join)(PACKAGE_DIR, ".qa-runs");
87939
88022
  var PW_CLI = require.resolve("@playwright/test/cli");
88023
+ var LIVE_BROWSER_STATE_POLL_INTERVAL_MS = 500;
87940
88024
  async function executeTestViaNativePlaywright2(options) {
87941
88025
  const { playwrightCode, baseUrl, testName, testVariables, authStateCapturePath, storageStatePath, extraHttpHeaders, authStandingMode, validateContext, liveBrowserHandle, onLiveBrowserSnapshot } = options;
87942
88026
  const logs2 = [];
@@ -88026,7 +88110,7 @@ test.beforeEach(async ({ page }, testInfo) => {
88026
88110
  await __qaLiveBrowserCapture();
88027
88111
  __qaLiveBrowserInterval = setInterval(() => {
88028
88112
  void __qaLiveBrowserCapture?.();
88029
- }, 500);
88113
+ }, ${LIVE_BROWSER_STATE_POLL_INTERVAL_MS});
88030
88114
  });
88031
88115
 
88032
88116
  test.afterEach(async () => {
@@ -88443,7 +88527,7 @@ ${passed ? "\u2705" : "\u274C"} Test ${passed ? "PASSED" : "FAILED"}${error2 ? `
88443
88527
  };
88444
88528
  const interval = setInterval(() => {
88445
88529
  void poll();
88446
- }, 1e3);
88530
+ }, LIVE_BROWSER_STATE_POLL_INTERVAL_MS);
88447
88531
  interval.unref?.();
88448
88532
  void poll();
88449
88533
  return async () => {
@@ -97641,6 +97725,12 @@ ${testOpen}`;
97641
97725
  }
97642
97726
  }
97643
97727
  }
97728
+ const droppedByCalls = codeForMatcherScan.match(/\.get(?:Role|Text|Label|Placeholder|TestId|AltText|Title)\s*\(/g) ?? [];
97729
+ for (const call of droppedByCalls) {
97730
+ const methodName = call.replace(/[\s(]+$/, "").slice(1);
97731
+ logs2?.push(` \u2717 Rejected "${t.name}": "${methodName}" is not a real Playwright method \u2014 likely "${methodName.replace("get", "getBy")}" with "By" dropped`);
97732
+ return false;
97733
+ }
97644
97734
  if (code.includes("// TODO")) {
97645
97735
  logs2?.push(` \u26A0 "${t.name}" contains TODO comment \u2014 marking as unverified`);
97646
97736
  t.tags = t.tags.filter((tag) => tag !== "@verified");
@@ -352078,16 +352168,16 @@ var require_live_browser_registry = __commonJS({
352078
352168
  "use strict";
352079
352169
  Object.defineProperty(exports2, "__esModule", { value: true });
352080
352170
  exports2.liveBrowserRegistry = void 0;
352171
+ var shared_1 = (init_dist(), __toCommonJS(dist_exports));
352081
352172
  var THUMBNAIL_INTERVAL_MS = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_INTERVAL_MS ?? "4000", 10) || 4e3;
352082
352173
  var THUMBNAIL_QUALITY = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_QUALITY ?? "60", 10) || 60;
352083
- var BROWSER_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_BROWSER_THUMBNAIL_MAX_BASE64_BYTES ?? "400000", 10) || 4e5;
352084
- var MOBILE_THUMBNAIL_MAX_BASE64_BYTES = parseInt(process.env.LIVE_MOBILE_THUMBNAIL_MAX_BASE64_BYTES ?? "4500000", 10) || 45e5;
352085
352174
  var STALE_SESSION_MS = parseInt(process.env.LIVE_BROWSER_STALE_MS ?? `${10 * 60 * 1e3}`, 10) || 10 * 60 * 1e3;
352086
352175
  var MAX_SESSIONS = parseInt(process.env.LIVE_BROWSER_MAX_SESSIONS ?? "32", 10) || 32;
352087
352176
  var LiveBrowserRegistry = class {
352088
352177
  entries = /* @__PURE__ */ new Map();
352089
352178
  snapshotTimer = null;
352090
352179
  capturing = false;
352180
+ publisher = null;
352091
352181
  /** Register a new session. Returns a handle the caller must hold and
352092
352182
  * ultimately call .unregister() on. */
352093
352183
  register(meta) {
@@ -352101,7 +352191,10 @@ var require_live_browser_registry = __commonJS({
352101
352191
  lastUpdatedAt: now,
352102
352192
  page: null,
352103
352193
  isCapturing: false,
352104
- pendingCapture: false
352194
+ pendingCapture: false,
352195
+ screencast: null,
352196
+ screencastStarting: false,
352197
+ lastPublishedAt: 0
352105
352198
  };
352106
352199
  this.entries.set(meta.sessionId, entry);
352107
352200
  this.ensureSnapshotLoop();
@@ -352114,19 +352207,40 @@ var require_live_browser_registry = __commonJS({
352114
352207
  unregister: () => this.unregister(meta.sessionId)
352115
352208
  };
352116
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
+ }
352117
352217
  unregister(sessionId) {
352118
352218
  const entry = this.entries.get(sessionId);
352119
352219
  if (!entry)
352120
352220
  return;
352121
- entry.page = null;
352122
- this.entries.delete(sessionId);
352221
+ this.dropEntry(sessionId, entry);
352123
352222
  if (this.entries.size === 0)
352124
352223
  this.stopSnapshotLoop();
352125
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
+ }
352126
352238
  setPage(sessionId, page) {
352127
352239
  const entry = this.entries.get(sessionId);
352128
352240
  if (!entry)
352129
352241
  return;
352242
+ if (entry.screencast && entry.screencast.page !== page)
352243
+ void this.stopScreencast(entry);
352130
352244
  entry.page = page;
352131
352245
  entry.lastUpdatedAt = Date.now();
352132
352246
  if (page) {
@@ -352136,6 +352250,119 @@ var require_live_browser_registry = __commonJS({
352136
352250
  entry.currentUrl = url;
352137
352251
  } catch {
352138
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 {
352139
352366
  }
352140
352367
  }
352141
352368
  setCurrentUrl(sessionId, url) {
@@ -352147,7 +352374,7 @@ var require_live_browser_registry = __commonJS({
352147
352374
  const previousUrl = entry.currentUrl;
352148
352375
  entry.currentUrl = url;
352149
352376
  entry.lastUpdatedAt = Date.now();
352150
- if (url && previousUrl && url !== previousUrl) {
352377
+ if (url && previousUrl && url !== previousUrl && !entry.screencast) {
352151
352378
  entry.pendingCapture = true;
352152
352379
  const schedule = (delay) => {
352153
352380
  setTimeout(() => {
@@ -352168,6 +352395,11 @@ var require_live_browser_registry = __commonJS({
352168
352395
  schedule(100);
352169
352396
  }
352170
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
+ */
352171
352403
  updateSnapshot(sessionId, snapshot) {
352172
352404
  const entry = this.entries.get(sessionId);
352173
352405
  if (!entry)
@@ -352176,12 +352408,10 @@ var require_live_browser_registry = __commonJS({
352176
352408
  entry.currentUrl = snapshot.currentUrl;
352177
352409
  }
352178
352410
  const thumbnail = snapshot.thumbnailJpegBase64;
352179
- const thumbnailLimit = entry.meta.surface === "mobile" ? MOBILE_THUMBNAIL_MAX_BASE64_BYTES : BROWSER_THUMBNAIL_MAX_BASE64_BYTES;
352180
- if (thumbnail && thumbnail.length <= thumbnailLimit) {
352181
- entry.thumbnailJpegBase64 = thumbnail;
352182
- entry.thumbnailCapturedAt = typeof snapshot.thumbnailCapturedAt === "number" && Number.isFinite(snapshot.thumbnailCapturedAt) ? snapshot.thumbnailCapturedAt : Date.now();
352183
- entry.thumbnailWidth = typeof snapshot.thumbnailWidth === "number" && Number.isFinite(snapshot.thumbnailWidth) ? snapshot.thumbnailWidth : void 0;
352184
- 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);
352185
352415
  }
352186
352416
  entry.lastUpdatedAt = Date.now();
352187
352417
  }
@@ -352224,9 +352454,11 @@ var require_live_browser_registry = __commonJS({
352224
352454
  async captureAllNow() {
352225
352455
  await this.captureAll();
352226
352456
  }
352227
- /** Stop the snapshot loop and clear all entries. Idempotent. */
352457
+ /** Stop the snapshot loop, tear down every screencast, and clear all entries. Idempotent. */
352228
352458
  shutdown() {
352229
352459
  this.stopSnapshotLoop();
352460
+ for (const entry of this.entries.values())
352461
+ void this.stopScreencast(entry);
352230
352462
  this.entries.clear();
352231
352463
  }
352232
352464
  ensureSnapshotLoop() {
@@ -352254,15 +352486,13 @@ var require_live_browser_registry = __commonJS({
352254
352486
  }
352255
352487
  }
352256
352488
  if (oldestKey) {
352257
- this.entries.get(oldestKey).page = null;
352258
- this.entries.delete(oldestKey);
352489
+ this.dropEntry(oldestKey, this.entries.get(oldestKey));
352259
352490
  }
352260
352491
  }
352261
352492
  sweepStale(now) {
352262
352493
  for (const [key, entry] of this.entries) {
352263
352494
  if (now - entry.lastUpdatedAt > STALE_SESSION_MS) {
352264
- entry.page = null;
352265
- this.entries.delete(key);
352495
+ this.dropEntry(key, entry);
352266
352496
  }
352267
352497
  }
352268
352498
  }
@@ -352303,6 +352533,11 @@ var require_live_browser_registry = __commonJS({
352303
352533
  entry.currentUrl = url;
352304
352534
  } catch {
352305
352535
  }
352536
+ if (entry.screencast) {
352537
+ if (entry.thumbnailJpegBase64)
352538
+ entry.thumbnailCapturedAt = Date.now();
352539
+ return;
352540
+ }
352306
352541
  const buf = await page.screenshot({
352307
352542
  type: "jpeg",
352308
352543
  quality: THUMBNAIL_QUALITY,
@@ -352310,17 +352545,8 @@ var require_live_browser_registry = __commonJS({
352310
352545
  timeout: 3e3,
352311
352546
  scale: "css"
352312
352547
  });
352313
- const base64 = buf.toString("base64");
352314
- if (base64.length > BROWSER_THUMBNAIL_MAX_BASE64_BYTES) {
352315
- return;
352316
- }
352317
- entry.thumbnailJpegBase64 = base64;
352318
- entry.thumbnailCapturedAt = Date.now();
352319
352548
  const viewport = page.viewportSize?.();
352320
- if (viewport) {
352321
- entry.thumbnailWidth = viewport.width;
352322
- entry.thumbnailHeight = viewport.height;
352323
- }
352549
+ this.acceptFrame(entry, buf.toString("base64"), "image/jpeg", viewport?.width, viewport?.height);
352324
352550
  } catch {
352325
352551
  } finally {
352326
352552
  entry.isCapturing = false;
@@ -353034,7 +353260,7 @@ var require_package4 = __commonJS({
353034
353260
  "package.json"(exports2, module2) {
353035
353261
  module2.exports = {
353036
353262
  name: "@validate.qa/runner",
353037
- version: "1.0.77",
353263
+ version: "1.0.79",
353038
353264
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
353039
353265
  bin: {
353040
353266
  "validate-runner": "dist/cli.js"
@@ -353826,6 +354052,24 @@ async function getScreenshot(sessionPath) {
353826
354052
  return null;
353827
354053
  }
353828
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
+ }
353829
354073
  async function getPageSource(sessionPath) {
353830
354074
  try {
353831
354075
  const value = readValue(await appiumRequest(`${sessionPath}/source`));
@@ -356334,7 +356578,15 @@ function mobileParallelSessionCapabilities(platform3, portOffset = 0, deviceId =
356334
356578
  return (Number.isFinite(parsed) ? parsed : fallback) + offset;
356335
356579
  };
356336
356580
  if (platform3 === "ANDROID") {
356337
- 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
+ };
356338
356590
  }
356339
356591
  const safeDevice = deviceId.replace(/[^A-Za-z0-9._-]+/g, "-") || `device-${offset}`;
356340
356592
  const derivedDataRoot = env.APPIUM_DERIVED_DATA_PATH?.trim() || (0, import_node_path2.join)((0, import_node_os2.tmpdir)(), "validateqa-wda");
@@ -357589,6 +357841,15 @@ function acquireControlPortSlot(deviceId) {
357589
357841
  function releaseControlPortSlot(deviceId) {
357590
357842
  controlPortSlotsByDevice.delete(deviceId);
357591
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
+ }
357592
357853
  var PINNED_DEVICE_SNAPSHOT_TTL_MS = 25 * 6e4;
357593
357854
  var pinnedDeviceSnapshot = null;
357594
357855
  async function executeMobileTest(options) {
@@ -358527,12 +358788,106 @@ function decideMobileDeviceGate(input) {
358527
358788
  }
358528
358789
 
358529
358790
  // src/services/mobile-bridge.ts
358530
- var import_http = require("http");
358791
+ var import_http2 = require("http");
358531
358792
  var import_child_process4 = require("child_process");
358532
358793
  var import_crypto = require("crypto");
358533
358794
  var import_os4 = require("os");
358534
358795
  var import_path4 = require("path");
358535
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
358536
358891
  init_dist();
358537
358892
  var MOBILE_BRIDGE_PORT2 = 9515;
358538
358893
  var MAX_REQUEST_BODY_BYTES = 64 * 1024;
@@ -359086,16 +359441,66 @@ function writeFrameToChannelClient(ch, client, frame) {
359086
359441
  } catch {
359087
359442
  }
359088
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
+ }
359089
359496
  async function pollRunnerChannel(ch) {
359090
359497
  if (ch.clients.size === 0 || ch.polling) return;
359498
+ if (ch.mjpeg) return;
359091
359499
  ch.polling = true;
359092
359500
  try {
359093
359501
  validateSessionId(ch.sessionId);
359094
- const [screenshot, source] = await Promise.all([
359095
- getScreenshot(`/session/${ch.sessionId}`),
359096
- getPageSource(`/session/${ch.sessionId}`)
359097
- ]);
359098
- if (!screenshot && !source) {
359502
+ const screenshot = await getScreenshot(`/session/${ch.sessionId}`);
359503
+ if (!screenshot) {
359099
359504
  ch.nullTicks++;
359100
359505
  if (ch.nullTicks >= MIRROR_DEAD_SESSION_TICKS) {
359101
359506
  const alive = await isSessionAlive(ch.sessionId);
@@ -359108,32 +359513,45 @@ async function pollRunnerChannel(ch) {
359108
359513
  } else {
359109
359514
  ch.nullTicks = 0;
359110
359515
  }
359111
- const frame = { timestamp: Date.now(), screenshot: screenshot ?? null, source: source ?? null };
359112
- ch.lastFrame = frame;
359113
- 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
+ });
359114
359522
  } catch {
359115
359523
  } finally {
359116
359524
  ch.polling = false;
359117
359525
  }
359118
359526
  }
359119
359527
  function ensureChannelPolling(ch) {
359120
- if (ch.pollTimer) return;
359528
+ tryStartChannelMjpeg(ch);
359529
+ if (ch.mjpeg || ch.pollTimer) return;
359121
359530
  ch.pollTimer = setInterval(() => {
359122
359531
  void pollRunnerChannel(ch);
359123
359532
  }, MOBILE_SCREENSHOT_INTERVAL_MS);
359124
359533
  ch.pollTimer.unref?.();
359125
359534
  }
359126
359535
  function stopChannelPollingIfIdle(ch) {
359127
- if (ch.clients.size === 0 && ch.pollTimer) {
359536
+ if (ch.clients.size > 0) return;
359537
+ if (ch.pollTimer) {
359128
359538
  clearInterval(ch.pollTimer);
359129
359539
  ch.pollTimer = null;
359130
359540
  }
359541
+ if (ch.mjpeg) {
359542
+ ch.mjpeg.stop();
359543
+ ch.mjpeg = null;
359544
+ }
359131
359545
  }
359132
359546
  function closeRunnerChannel(ch) {
359133
359547
  if (ch.pollTimer) {
359134
359548
  clearInterval(ch.pollTimer);
359135
359549
  ch.pollTimer = null;
359136
359550
  }
359551
+ if (ch.mjpeg) {
359552
+ ch.mjpeg.stop();
359553
+ ch.mjpeg = null;
359554
+ }
359137
359555
  for (const client of ch.clients) {
359138
359556
  try {
359139
359557
  client.end();
@@ -359530,7 +359948,7 @@ async function startMobileBridge(options) {
359530
359948
  }
359531
359949
  killStaleBridge();
359532
359950
  return new Promise((resolve2, reject) => {
359533
- server2 = (0, import_http.createServer)(route);
359951
+ server2 = (0, import_http2.createServer)(route);
359534
359952
  server2.on("error", (err) => {
359535
359953
  if (err.code === "EADDRINUSE") {
359536
359954
  reject(new Error(`Port ${port} already in use. Another bridge instance may be running.`));
@@ -359718,12 +360136,24 @@ function attachMirrorSession(sessionId, platform3, deviceId) {
359718
360136
  lastFrame: null,
359719
360137
  pollTimer: null,
359720
360138
  nullTicks: 0,
359721
- polling: false
360139
+ polling: false,
360140
+ mjpeg: null,
360141
+ mjpegLastFrameAt: 0,
360142
+ mjpegStarting: false,
360143
+ mjpegUnavailable: false
359722
360144
  };
359723
360145
  ch.sessionId = sessionId;
359724
360146
  ch.platform = platform3;
359725
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;
359726
360154
  runnerChannels.set(deviceId, ch);
360155
+ void applyMjpegSettings(`/session/${sessionId}`, MOBILE_MJPEG_SETTINGS).catch(() => {
360156
+ });
359727
360157
  if (ch.clients.size > 0) {
359728
360158
  ensureChannelPolling(ch);
359729
360159
  void pollRunnerChannel(ch);
@@ -362907,7 +363337,9 @@ ${finalVerifyResult.logs ?? ""}`;
362907
363337
  liveBrowserHandle.updateSnapshot({
362908
363338
  currentUrl: mobileTarget.appId,
362909
363339
  thumbnailJpegBase64: base64,
362910
- thumbnailCapturedAt: Date.now()
363340
+ thumbnailCapturedAt: Date.now(),
363341
+ // Appium's screenshot endpoint returns PNG despite the field name.
363342
+ mimeType: "image/png"
362911
363343
  });
362912
363344
  requestLiveBrowserHeartbeat();
362913
363345
  },
@@ -364152,6 +364584,22 @@ async function startRunner(opts) {
364152
364584
  } catch {
364153
364585
  }
364154
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);
364155
364603
  let liveBrowserHeartbeatInFlight = false;
364156
364604
  let liveBrowserHeartbeatPending = false;
364157
364605
  let lastLiveBrowserHeartbeatAt = 0;