@validate.qa/runner 1.0.15 → 1.0.16

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 +301 -27
  2. package/dist/cli.mjs +301 -27
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -66792,16 +66792,16 @@ ${statePacket}` },
66792
66792
  }
66793
66793
  const screen = screenName();
66794
66794
  if (accessibilityId) {
66795
- const bundle2 = { xpath: `//*[@content-desc=${xpathLiteral(accessibilityId)} or @name=${xpathLiteral(accessibilityId)}]`, className: "", accessibilityId };
66795
+ const bundle2 = { xpath: `//*[@content-desc=${xpathLiteral2(accessibilityId)} or @name=${xpathLiteral2(accessibilityId)}]`, className: "", accessibilityId };
66796
66796
  recordAction({ type: "ASSERT_VISIBLE", selector: accessibilityId, metadata: { screenName: screen, targetLabel: accessibilityId, locatorBundle: bundle2 } });
66797
66797
  return { ok: true, label: accessibilityId };
66798
66798
  }
66799
66799
  const safeText = text;
66800
- const bundle = { xpath: `//*[@text=${xpathLiteral(safeText)} or @label=${xpathLiteral(safeText)}]`, className: "", text: safeText };
66800
+ const bundle = { xpath: `//*[@text=${xpathLiteral2(safeText)} or @label=${xpathLiteral2(safeText)}]`, className: "", text: safeText };
66801
66801
  recordAction({ type: "ASSERT_VISIBLE", selector: bundle.xpath, metadata: { screenName: screen, targetLabel: safeText, locatorBundle: bundle } });
66802
66802
  return { ok: true, label: safeText };
66803
66803
  }
66804
- function xpathLiteral(value) {
66804
+ function xpathLiteral2(value) {
66805
66805
  if (!value.includes("'"))
66806
66806
  return `'${value}'`;
66807
66807
  if (!value.includes('"'))
@@ -328118,7 +328118,7 @@ var require_package4 = __commonJS({
328118
328118
  "package.json"(exports2, module2) {
328119
328119
  module2.exports = {
328120
328120
  name: "@validate.qa/runner",
328121
- version: "1.0.15",
328121
+ version: "1.0.16",
328122
328122
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328123
328123
  bin: {
328124
328124
  "validate-runner": "dist/cli.js"
@@ -329723,7 +329723,7 @@ async function getWindowSize(sessionId, retries = 3) {
329723
329723
  }
329724
329724
  throw new Error("Failed to get window size: invalid dimensions returned");
329725
329725
  }
329726
- function realDeviceCapabilities(platform3) {
329726
+ function realDeviceCapabilities(platform3, portOffset = 0) {
329727
329727
  const env = process.env;
329728
329728
  const caps = {};
329729
329729
  const num = (v) => {
@@ -329731,6 +329731,7 @@ function realDeviceCapabilities(platform3) {
329731
329731
  const n = Number.parseInt(v, 10);
329732
329732
  return Number.isFinite(n) ? n : void 0;
329733
329733
  };
329734
+ const offset = Number.isFinite(portOffset) && portOffset > 0 ? Math.floor(portOffset) : 0;
329734
329735
  if (platform3 === "IOS") {
329735
329736
  if (env.APPIUM_XCODE_ORG_ID) {
329736
329737
  caps["appium:xcodeOrgId"] = env.APPIUM_XCODE_ORG_ID;
@@ -329740,15 +329741,15 @@ function realDeviceCapabilities(platform3) {
329740
329741
  }
329741
329742
  if (env.APPIUM_UPDATED_WDA_BUNDLE_ID) caps["appium:updatedWDABundleId"] = env.APPIUM_UPDATED_WDA_BUNDLE_ID;
329742
329743
  const wdaPort = num(env.APPIUM_WDA_LOCAL_PORT);
329743
- if (wdaPort !== void 0) caps["appium:wdaLocalPort"] = wdaPort;
329744
+ if (wdaPort !== void 0) caps["appium:wdaLocalPort"] = wdaPort + offset;
329744
329745
  } else {
329745
329746
  const systemPort = num(env.APPIUM_SYSTEM_PORT);
329746
- if (systemPort !== void 0) caps["appium:systemPort"] = systemPort;
329747
+ if (systemPort !== void 0) caps["appium:systemPort"] = systemPort + offset;
329747
329748
  }
329748
329749
  return caps;
329749
329750
  }
329750
329751
  async function createSession(target) {
329751
- const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform) : {};
329752
+ const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform, target.portOffset) : {};
329752
329753
  const capabilities = target.platform === "IOS" ? {
329753
329754
  platformName: "iOS",
329754
329755
  "appium:automationName": "XCUITest",
@@ -330023,6 +330024,46 @@ function buildLocatorStrategies(target) {
330023
330024
  (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
330024
330025
  );
330025
330026
  }
330027
+ function dedupeStrategies(strategies) {
330028
+ return strategies.filter(
330029
+ (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
330030
+ );
330031
+ }
330032
+ function xpathLiteral(value) {
330033
+ if (!value.includes("'")) return `'${value}'`;
330034
+ if (!value.includes('"')) return `"${value}"`;
330035
+ const parts = value.split("'").map((p) => `'${p}'`);
330036
+ return `concat(${parts.join(`, "'", `)})`;
330037
+ }
330038
+ function identityTextXpath(value) {
330039
+ const lit = xpathLiteral(value);
330040
+ return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
330041
+ }
330042
+ function isIdentityStrategy(strategy) {
330043
+ if (strategy.using === "accessibility id" || strategy.using === "id") return true;
330044
+ if (strategy.using === "xpath") return strategy.value.includes("@");
330045
+ return false;
330046
+ }
330047
+ function buildIdentityLocatorStrategies(target) {
330048
+ if (!target) return [];
330049
+ const strategies = [];
330050
+ const bundle = target.locatorBundle;
330051
+ if (bundle) {
330052
+ if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
330053
+ if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
330054
+ for (const value of [bundle.text, bundle.contentDesc]) {
330055
+ const trimmed = value?.trim();
330056
+ if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
330057
+ }
330058
+ if (bundle.xpath && bundle.xpath.includes("@")) strategies.push({ using: "xpath", value: bundle.xpath });
330059
+ }
330060
+ for (const value of [target.primary, ...target.fallbacks ?? []]) {
330061
+ for (const strategy of locatorStrategyForValue(value)) {
330062
+ if (isIdentityStrategy(strategy)) strategies.push(strategy);
330063
+ }
330064
+ }
330065
+ return dedupeStrategies(strategies);
330066
+ }
330026
330067
  function readStepTimeout(step) {
330027
330068
  const value = step.metadata?.timeout;
330028
330069
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
@@ -330030,10 +330071,12 @@ function readStepTimeout(step) {
330030
330071
  }
330031
330072
  return void 0;
330032
330073
  }
330033
- async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS) {
330034
- const strategies = buildLocatorStrategies(target);
330074
+ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
330075
+ const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target) : buildLocatorStrategies(target);
330035
330076
  if (strategies.length === 0) {
330036
- throw new Error("No locator strategies available for this step");
330077
+ throw new Error(
330078
+ opts?.identityOnly ? "No identity locator (accessibility id / resource id / text) available for this step \u2014 cannot verify the screen" : "No locator strategies available for this step"
330079
+ );
330037
330080
  }
330038
330081
  const startTime = Date.now();
330039
330082
  let lastError = "";
@@ -330142,7 +330185,7 @@ async function executeTap(sessionId, step) {
330142
330185
  });
330143
330186
  return;
330144
330187
  } catch (error2) {
330145
- if (!bounds) throw error2;
330188
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330146
330189
  }
330147
330190
  }
330148
330191
  if (!bounds) {
@@ -330175,7 +330218,7 @@ async function executeType(sessionId, step, context) {
330175
330218
  if (!lastInputError) return;
330176
330219
  elementId = null;
330177
330220
  } catch (error2) {
330178
- if (!bounds) throw error2;
330221
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330179
330222
  lastInputError = error2;
330180
330223
  }
330181
330224
  }
@@ -330230,7 +330273,7 @@ async function executeClear(sessionId, step) {
330230
330273
  try {
330231
330274
  elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330232
330275
  } catch (error2) {
330233
- if (!bounds) throw error2;
330276
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330234
330277
  }
330235
330278
  }
330236
330279
  if (!elementId && bounds) {
@@ -330301,10 +330344,10 @@ async function executeHome(sessionId, platform3) {
330301
330344
  }
330302
330345
  async function executeWaitForElement(sessionId, step) {
330303
330346
  const timeout = readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS;
330304
- await findElement(sessionId, step.target, timeout);
330347
+ await findElement(sessionId, step.target, timeout, { identityOnly: true });
330305
330348
  }
330306
330349
  async function executeAssertVisible(sessionId, step) {
330307
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330350
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
330308
330351
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
330309
330352
  if (!result?.value) {
330310
330353
  throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
@@ -330312,7 +330355,7 @@ async function executeAssertVisible(sessionId, step) {
330312
330355
  }
330313
330356
  async function executeAssertText(sessionId, step) {
330314
330357
  if (!step.assertion) throw new Error("assertText step requires an assertion value");
330315
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330358
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
330316
330359
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
330317
330360
  const actualText = typeof result?.value === "string" ? result.value : "";
330318
330361
  const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
@@ -330347,6 +330390,7 @@ async function executeMobileTest(options) {
330347
330390
  const screenshots = [];
330348
330391
  let apiCalls = [];
330349
330392
  let sessionId = null;
330393
+ let selectedDevice;
330350
330394
  let capture = null;
330351
330395
  let captureStopped = false;
330352
330396
  let recordingStarted = false;
@@ -330394,7 +330438,6 @@ async function executeMobileTest(options) {
330394
330438
  `No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
330395
330439
  );
330396
330440
  }
330397
- let selectedDevice;
330398
330441
  if (options.target.assignedDeviceId) {
330399
330442
  selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
330400
330443
  if (!selectedDevice) {
@@ -330435,13 +330478,15 @@ async function executeMobileTest(options) {
330435
330478
  log2(`Warning: Could not retrieve installed apps list. Proceeding with test execution.`);
330436
330479
  }
330437
330480
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
330481
+ const leasedPortOffset = Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id));
330438
330482
  try {
330439
330483
  sessionId = await createSessionWithRetry({
330440
330484
  appId: options.target.appId,
330441
330485
  platform: options.target.platform,
330442
330486
  deviceId: selectedDevice.id,
330443
330487
  platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
330444
- isPhysical: selectedDevice.isPhysical
330488
+ isPhysical: selectedDevice.isPhysical,
330489
+ portOffset: leasedPortOffset
330445
330490
  }, (msg) => log2(msg));
330446
330491
  } catch (err) {
330447
330492
  const base2 = err instanceof Error ? err.message : String(err);
@@ -330457,6 +330502,12 @@ async function executeMobileTest(options) {
330457
330502
  throw err;
330458
330503
  }
330459
330504
  log2(`Session created: ${sessionId}`);
330505
+ if (selectedDevice.id) {
330506
+ try {
330507
+ options.onSessionReady?.(sessionId, selectedDevice.id, options.target.platform);
330508
+ } catch {
330509
+ }
330510
+ }
330460
330511
  recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
330461
330512
  log2("Opening a fresh app instance for the test...");
330462
330513
  await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
@@ -330469,8 +330520,26 @@ async function executeMobileTest(options) {
330469
330520
  }
330470
330521
  }
330471
330522
  await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
330523
+ let entryDrift = false;
330524
+ const entryStep = options.steps.find(
330525
+ (s) => s.action !== "launchApp" && buildIdentityLocatorStrategies(s.target).length > 0
330526
+ );
330527
+ if (entryStep) {
330528
+ try {
330529
+ await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
330530
+ } catch (err) {
330531
+ if (isTransportError(err)) throw err;
330532
+ entryDrift = true;
330533
+ const driftMsg = `App started on an unexpected screen \u2014 the recorded flow's first element ("${entryStep.target?.primary ?? entryStep.description}") is not present, so the replay was aborted before it could blind-tap stale coordinates (screen drift; the app may be on onboarding or a different screen than when the test was recorded).`;
330534
+ log2(` \u2717 Entry-screen drift: ${driftMsg}`);
330535
+ stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
330536
+ const driftShot = await takeScreenshot(sessionId).catch(() => null);
330537
+ screenshots.push(driftShot ?? "");
330538
+ }
330539
+ }
330472
330540
  let environmental = null;
330473
330541
  for (const step of options.steps) {
330542
+ if (entryDrift) break;
330474
330543
  const stepStart = Date.now();
330475
330544
  log2(`Step ${step.order}: ${step.action} - ${step.description}`);
330476
330545
  let status = "passed";
@@ -330548,7 +330617,7 @@ async function executeMobileTest(options) {
330548
330617
  break;
330549
330618
  }
330550
330619
  }
330551
- const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330620
+ const allPassed = !environmental && stepResults.length > 0 && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330552
330621
  const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
330553
330622
  await stopCapture();
330554
330623
  await stopRecording();
@@ -330561,6 +330630,8 @@ async function executeMobileTest(options) {
330561
330630
  logs: logLines.join("\n"),
330562
330631
  error: firstError,
330563
330632
  duration: Date.now() - startTime,
330633
+ deviceId: selectedDevice?.id,
330634
+ deviceName: selectedDevice?.name,
330564
330635
  environmental: environmental ?? void 0
330565
330636
  };
330566
330637
  } catch (err) {
@@ -330577,6 +330648,8 @@ async function executeMobileTest(options) {
330577
330648
  logs: logLines.join("\n"),
330578
330649
  error: errorMsg,
330579
330650
  duration: Date.now() - startTime,
330651
+ deviceId: selectedDevice?.id,
330652
+ deviceName: selectedDevice?.name,
330580
330653
  // A throw before/around the step loop is a setup/environment problem
330581
330654
  // (no device booted, app not installed, session could not be created,
330582
330655
  // Appium unreachable) — never a genuine test assertion failure.
@@ -330586,6 +330659,12 @@ async function executeMobileTest(options) {
330586
330659
  await stopCapture();
330587
330660
  await stopRecording();
330588
330661
  if (sessionId) {
330662
+ if (selectedDevice?.id) {
330663
+ try {
330664
+ options.onSessionEnding?.(selectedDevice.id);
330665
+ } catch {
330666
+ }
330667
+ }
330589
330668
  try {
330590
330669
  log2("Closing the app on the device...");
330591
330670
  await terminateTargetApp(sessionId, options.target.platform, options.target.appId);
@@ -330644,7 +330723,9 @@ async function createMobileDiscoverySession(target, options) {
330644
330723
  platform: target.platform,
330645
330724
  deviceId: selectedDevice.id,
330646
330725
  platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
330647
- isPhysical: selectedDevice.isPhysical
330726
+ isPhysical: selectedDevice.isPhysical,
330727
+ // Distinct control port per concurrent physical device (no-op for emulators/sims).
330728
+ portOffset: Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id))
330648
330729
  });
330649
330730
  if (target.launchMode === "DEEP_LINK" && target.deeplink) {
330650
330731
  try {
@@ -330656,6 +330737,7 @@ async function createMobileDiscoverySession(target, options) {
330656
330737
  return {
330657
330738
  sessionId,
330658
330739
  deviceId: selectedDevice.id,
330740
+ deviceName: selectedDevice.name,
330659
330741
  isPhysical: selectedDevice.isPhysical === true,
330660
330742
  platform: target.platform,
330661
330743
  appId: target.appId,
@@ -331381,7 +331463,146 @@ async function handleSessionStop(_req, res) {
331381
331463
  stopSessionReaper();
331382
331464
  sendJSON(res, 200, { ok: true });
331383
331465
  }
331466
+ var runnerChannels = /* @__PURE__ */ new Map();
331467
+ function readDeviceIdParam(req) {
331468
+ try {
331469
+ const url = new URL(req.url || "", "http://localhost");
331470
+ const raw = url.searchParams.get("deviceId");
331471
+ if (!raw) return null;
331472
+ const trimmed = raw.trim();
331473
+ if (!trimmed || trimmed.length > MAX_DEVICE_ID_LENGTH || !DEVICE_ID_PATTERN.test(trimmed)) return null;
331474
+ return trimmed;
331475
+ } catch {
331476
+ return null;
331477
+ }
331478
+ }
331479
+ async function isSessionAlive(sessionId) {
331480
+ try {
331481
+ validateSessionId(sessionId);
331482
+ await appiumRequest2(`/session/${sessionId}/window/rect`);
331483
+ return true;
331484
+ } catch (err) {
331485
+ const msg = err instanceof Error ? err.message : String(err);
331486
+ return !/no such session|invalid session id|session is either terminated|deleted session/i.test(msg);
331487
+ }
331488
+ }
331489
+ function writeFrameToChannelClient(ch, client, frame) {
331490
+ try {
331491
+ const buffered = typeof client.writableLength === "number" ? client.writableLength : typeof client.socket?.writableLength === "number" ? client.socket.writableLength : 0;
331492
+ if (buffered > SSE_MAX_BUFFERED_BYTES) {
331493
+ try {
331494
+ client.end();
331495
+ } catch {
331496
+ }
331497
+ ch.clients.delete(client);
331498
+ stopChannelPollingIfIdle(ch);
331499
+ return;
331500
+ }
331501
+ client.write(`data: ${JSON.stringify(frame)}
331502
+
331503
+ `);
331504
+ } catch {
331505
+ }
331506
+ }
331507
+ async function pollRunnerChannel(ch) {
331508
+ if (ch.clients.size === 0 || ch.polling) return;
331509
+ ch.polling = true;
331510
+ try {
331511
+ validateSessionId(ch.sessionId);
331512
+ const [screenshot, source] = await Promise.all([
331513
+ getScreenshot(`/session/${ch.sessionId}`),
331514
+ getPageSource(`/session/${ch.sessionId}`)
331515
+ ]);
331516
+ if (!screenshot && !source) {
331517
+ ch.nullTicks++;
331518
+ if (ch.nullTicks >= MIRROR_DEAD_SESSION_TICKS) {
331519
+ const alive = await isSessionAlive(ch.sessionId);
331520
+ if (!alive) {
331521
+ closeRunnerChannel(ch);
331522
+ return;
331523
+ }
331524
+ ch.nullTicks = 0;
331525
+ }
331526
+ } else {
331527
+ ch.nullTicks = 0;
331528
+ }
331529
+ const frame = { timestamp: Date.now(), screenshot: screenshot ?? null, source: source ?? null };
331530
+ ch.lastFrame = frame;
331531
+ for (const client of ch.clients) writeFrameToChannelClient(ch, client, frame);
331532
+ } catch {
331533
+ } finally {
331534
+ ch.polling = false;
331535
+ }
331536
+ }
331537
+ function ensureChannelPolling(ch) {
331538
+ if (ch.pollTimer) return;
331539
+ ch.pollTimer = setInterval(() => {
331540
+ void pollRunnerChannel(ch);
331541
+ }, MOBILE_SCREENSHOT_INTERVAL_MS);
331542
+ ch.pollTimer.unref?.();
331543
+ }
331544
+ function stopChannelPollingIfIdle(ch) {
331545
+ if (ch.clients.size === 0 && ch.pollTimer) {
331546
+ clearInterval(ch.pollTimer);
331547
+ ch.pollTimer = null;
331548
+ }
331549
+ }
331550
+ function closeRunnerChannel(ch) {
331551
+ if (ch.pollTimer) {
331552
+ clearInterval(ch.pollTimer);
331553
+ ch.pollTimer = null;
331554
+ }
331555
+ for (const client of ch.clients) {
331556
+ try {
331557
+ client.end();
331558
+ } catch {
331559
+ }
331560
+ }
331561
+ ch.clients.clear();
331562
+ ch.lastFrame = null;
331563
+ runnerChannels.delete(ch.deviceId);
331564
+ }
331565
+ function closeAllRunnerChannels() {
331566
+ for (const ch of [...runnerChannels.values()]) closeRunnerChannel(ch);
331567
+ }
331568
+ async function serveRunnerMirror(req, res, ch) {
331569
+ res.writeHead(200, {
331570
+ "Content-Type": "text/event-stream",
331571
+ "Cache-Control": "no-cache",
331572
+ "Connection": "keep-alive",
331573
+ "Access-Control-Allow-Origin": getAllowedOrigin(req),
331574
+ "X-Content-Type-Options": "nosniff"
331575
+ });
331576
+ ch.clients.add(res);
331577
+ ensureChannelPolling(ch);
331578
+ if (ch.lastFrame) {
331579
+ writeFrameToChannelClient(ch, res, ch.lastFrame);
331580
+ } else {
331581
+ await pollRunnerChannel(ch);
331582
+ }
331583
+ req.on("close", () => {
331584
+ ch.clients.delete(res);
331585
+ stopChannelPollingIfIdle(ch);
331586
+ });
331587
+ }
331384
331588
  async function handleMirror(req, res) {
331589
+ const deviceId = readDeviceIdParam(req);
331590
+ if (deviceId) {
331591
+ const ch = runnerChannels.get(deviceId);
331592
+ if (ch) {
331593
+ await serveRunnerMirror(req, res, ch);
331594
+ return;
331595
+ }
331596
+ sendJSON(res, 404, { error: `No live mirror for device ${deviceId}.` });
331597
+ return;
331598
+ }
331599
+ if (!state.appiumSessionId && runnerChannels.size === 1) {
331600
+ const soleChannel = runnerChannels.values().next().value;
331601
+ if (soleChannel) {
331602
+ await serveRunnerMirror(req, res, soleChannel);
331603
+ return;
331604
+ }
331605
+ }
331385
331606
  if (!state.appiumSessionId) {
331386
331607
  sendJSON(res, 400, { error: "No active Appium session. Call /bridge/session/start first." });
331387
331608
  return;
@@ -331828,6 +332049,7 @@ async function startMobileBridge(options) {
331828
332049
  }
331829
332050
  async function stopMobileBridge() {
331830
332051
  stopAllMirrorClients();
332052
+ closeAllRunnerChannels();
331831
332053
  stopSessionReaper();
331832
332054
  clearBridgePidFile();
331833
332055
  if (state.appiumSessionId) {
@@ -331876,8 +332098,31 @@ function getBridgeState() {
331876
332098
  function setExecutorBusy(busy) {
331877
332099
  executorBusyCount = busy ? executorBusyCount + 1 : Math.max(0, executorBusyCount - 1);
331878
332100
  }
331879
- function attachMirrorSession(sessionId, platform3) {
332101
+ function attachMirrorSession(sessionId, platform3, deviceId) {
331880
332102
  validateSessionId(sessionId);
332103
+ if (deviceId) {
332104
+ const existing = runnerChannels.get(deviceId);
332105
+ if (existing && existing.sessionId !== sessionId) closeRunnerChannel(existing);
332106
+ const ch = runnerChannels.get(deviceId) ?? {
332107
+ deviceId,
332108
+ sessionId,
332109
+ platform: platform3,
332110
+ clients: /* @__PURE__ */ new Set(),
332111
+ lastFrame: null,
332112
+ pollTimer: null,
332113
+ nullTicks: 0,
332114
+ polling: false
332115
+ };
332116
+ ch.sessionId = sessionId;
332117
+ ch.platform = platform3;
332118
+ ch.nullTicks = 0;
332119
+ runnerChannels.set(deviceId, ch);
332120
+ if (ch.clients.size > 0) {
332121
+ ensureChannelPolling(ch);
332122
+ void pollRunnerChannel(ch);
332123
+ }
332124
+ return;
332125
+ }
331881
332126
  if (state.appiumSessionId && state.appiumSessionId !== sessionId) {
331882
332127
  stopAllMirrorClients();
331883
332128
  }
@@ -331892,8 +332137,12 @@ function attachMirrorSession(sessionId, platform3) {
331892
332137
  void pollAndBroadcastFrame();
331893
332138
  }
331894
332139
  }
331895
- function detachMirrorSession(sessionId) {
331896
- if (sessionId && state.appiumSessionId && state.appiumSessionId !== sessionId) return;
332140
+ function detachMirrorSession(deviceId) {
332141
+ if (deviceId) {
332142
+ const ch = runnerChannels.get(deviceId);
332143
+ if (ch) closeRunnerChannel(ch);
332144
+ return;
332145
+ }
331897
332146
  state.appiumSessionId = null;
331898
332147
  state.platform = null;
331899
332148
  mirrorSessionOwner = null;
@@ -334079,7 +334328,8 @@ ${finalVerifyResult.logs ?? ""}`;
334079
334328
  testName: ctx.featureName ?? "Mobile discovery",
334080
334329
  note: `${platform3} mobile discovery`,
334081
334330
  surface: "mobile",
334082
- mobilePlatform: platform3
334331
+ mobilePlatform: platform3,
334332
+ ...assignedDeviceId ? { deviceId: assignedDeviceId } : {}
334083
334333
  });
334084
334334
  requestLiveBrowserHeartbeat(true);
334085
334335
  mobileOnLog(`Mobile discovery (${ctx.mode}) \u2014 ${platform3} / ${mobileTarget.appId}`);
@@ -334097,6 +334347,7 @@ ${finalVerifyResult.logs ?? ""}`;
334097
334347
  deeplink: mobileTarget.deeplink
334098
334348
  }, {
334099
334349
  onDeviceSelected: async (device) => {
334350
+ liveBrowserHandle.patch({ deviceId: device.deviceId, deviceName: device.name });
334100
334351
  try {
334101
334352
  capture = await startMobileNetworkCapture({
334102
334353
  platform: platform3,
@@ -334115,7 +334366,7 @@ ${finalVerifyResult.logs ?? ""}`;
334115
334366
  }
334116
334367
  });
334117
334368
  mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
334118
- attachMirrorSession(session.sessionId, platform3);
334369
+ attachMirrorSession(session.sessionId, platform3, session.deviceId);
334119
334370
  const driver = new MobileBridgeDriver({
334120
334371
  sessionId: session.sessionId,
334121
334372
  platform: platform3,
@@ -334159,7 +334410,7 @@ ${finalVerifyResult.logs ?? ""}`;
334159
334410
  }
334160
334411
  }
334161
334412
  if (session) {
334162
- detachMirrorSession(session.sessionId);
334413
+ detachMirrorSession(session.deviceId);
334163
334414
  try {
334164
334415
  await session.stop();
334165
334416
  } catch {
@@ -334436,6 +334687,14 @@ ${finalVerifyResult.logs ?? ""}`;
334436
334687
  return;
334437
334688
  }
334438
334689
  log.info(`\u{1F4F1} Mobile test - ${target.platform} / ${target.appId}`);
334690
+ const leasedDeviceName = assignedDeviceId ? getCachedDeviceSnapshot().find((d) => d.id === assignedDeviceId)?.name : void 0;
334691
+ liveBrowserHandle.patch({
334692
+ surface: "mobile",
334693
+ mobilePlatform: target.platform,
334694
+ ...assignedDeviceId ? { deviceId: assignedDeviceId } : {},
334695
+ ...leasedDeviceName ? { deviceName: leasedDeviceName } : {}
334696
+ });
334697
+ requestLiveBrowserHeartbeat(true);
334439
334698
  const mobileSteps = run2.steps ?? [];
334440
334699
  if (mobileSteps.length === 0) {
334441
334700
  await postResult(opts.serverUrl, headers, run2.runId, {
@@ -334464,6 +334723,15 @@ ${finalVerifyResult.logs ?? ""}`;
334464
334723
  assignedDeviceId,
334465
334724
  launchMode: target.launchMode,
334466
334725
  deeplink: target.deeplink
334726
+ },
334727
+ // Stream this run to its OWN per-device dashboard tile (keyed by the
334728
+ // leased device id) so N parallel Appium runs each show a live mirror.
334729
+ onSessionReady: (sessionId, deviceId, sessionPlatform) => {
334730
+ attachMirrorSession(sessionId, sessionPlatform, deviceId);
334731
+ requestLiveBrowserHeartbeat(true);
334732
+ },
334733
+ onSessionEnding: (deviceId) => {
334734
+ detachMirrorSession(deviceId);
334467
334735
  }
334468
334736
  });
334469
334737
  attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
@@ -334479,6 +334747,12 @@ ${result2.logs}`);
334479
334747
  if (attemptLogs.length > 1) {
334480
334748
  result2 = { ...result2, logs: attemptLogs.join("\n\n") };
334481
334749
  }
334750
+ if (result2.deviceId || result2.deviceName) {
334751
+ liveBrowserHandle.patch({
334752
+ ...result2.deviceId ? { deviceId: result2.deviceId } : {},
334753
+ ...result2.deviceName ? { deviceName: result2.deviceName } : {}
334754
+ });
334755
+ }
334482
334756
  const duration2 = Date.now() - startTime;
334483
334757
  const status = result2.environmental ? "ERROR" : result2.passed ? "PASSED" : "FAILED";
334484
334758
  const reportedError = result2.environmental ? `CONFIG_ISSUE: ${result2.environmental}` : result2.error ?? void 0;
package/dist/cli.mjs CHANGED
@@ -66797,16 +66797,16 @@ ${statePacket}` },
66797
66797
  }
66798
66798
  const screen = screenName();
66799
66799
  if (accessibilityId) {
66800
- const bundle2 = { xpath: `//*[@content-desc=${xpathLiteral(accessibilityId)} or @name=${xpathLiteral(accessibilityId)}]`, className: "", accessibilityId };
66800
+ const bundle2 = { xpath: `//*[@content-desc=${xpathLiteral2(accessibilityId)} or @name=${xpathLiteral2(accessibilityId)}]`, className: "", accessibilityId };
66801
66801
  recordAction({ type: "ASSERT_VISIBLE", selector: accessibilityId, metadata: { screenName: screen, targetLabel: accessibilityId, locatorBundle: bundle2 } });
66802
66802
  return { ok: true, label: accessibilityId };
66803
66803
  }
66804
66804
  const safeText = text;
66805
- const bundle = { xpath: `//*[@text=${xpathLiteral(safeText)} or @label=${xpathLiteral(safeText)}]`, className: "", text: safeText };
66805
+ const bundle = { xpath: `//*[@text=${xpathLiteral2(safeText)} or @label=${xpathLiteral2(safeText)}]`, className: "", text: safeText };
66806
66806
  recordAction({ type: "ASSERT_VISIBLE", selector: bundle.xpath, metadata: { screenName: screen, targetLabel: safeText, locatorBundle: bundle } });
66807
66807
  return { ok: true, label: safeText };
66808
66808
  }
66809
- function xpathLiteral(value) {
66809
+ function xpathLiteral2(value) {
66810
66810
  if (!value.includes("'"))
66811
66811
  return `'${value}'`;
66812
66812
  if (!value.includes('"'))
@@ -328123,7 +328123,7 @@ var require_package4 = __commonJS({
328123
328123
  "package.json"(exports, module) {
328124
328124
  module.exports = {
328125
328125
  name: "@validate.qa/runner",
328126
- version: "1.0.15",
328126
+ version: "1.0.16",
328127
328127
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
328128
328128
  bin: {
328129
328129
  "validate-runner": "dist/cli.js"
@@ -329731,7 +329731,7 @@ async function getWindowSize(sessionId, retries = 3) {
329731
329731
  }
329732
329732
  throw new Error("Failed to get window size: invalid dimensions returned");
329733
329733
  }
329734
- function realDeviceCapabilities(platform3) {
329734
+ function realDeviceCapabilities(platform3, portOffset = 0) {
329735
329735
  const env = process.env;
329736
329736
  const caps = {};
329737
329737
  const num = (v) => {
@@ -329739,6 +329739,7 @@ function realDeviceCapabilities(platform3) {
329739
329739
  const n = Number.parseInt(v, 10);
329740
329740
  return Number.isFinite(n) ? n : void 0;
329741
329741
  };
329742
+ const offset = Number.isFinite(portOffset) && portOffset > 0 ? Math.floor(portOffset) : 0;
329742
329743
  if (platform3 === "IOS") {
329743
329744
  if (env.APPIUM_XCODE_ORG_ID) {
329744
329745
  caps["appium:xcodeOrgId"] = env.APPIUM_XCODE_ORG_ID;
@@ -329748,15 +329749,15 @@ function realDeviceCapabilities(platform3) {
329748
329749
  }
329749
329750
  if (env.APPIUM_UPDATED_WDA_BUNDLE_ID) caps["appium:updatedWDABundleId"] = env.APPIUM_UPDATED_WDA_BUNDLE_ID;
329750
329751
  const wdaPort = num(env.APPIUM_WDA_LOCAL_PORT);
329751
- if (wdaPort !== void 0) caps["appium:wdaLocalPort"] = wdaPort;
329752
+ if (wdaPort !== void 0) caps["appium:wdaLocalPort"] = wdaPort + offset;
329752
329753
  } else {
329753
329754
  const systemPort = num(env.APPIUM_SYSTEM_PORT);
329754
- if (systemPort !== void 0) caps["appium:systemPort"] = systemPort;
329755
+ if (systemPort !== void 0) caps["appium:systemPort"] = systemPort + offset;
329755
329756
  }
329756
329757
  return caps;
329757
329758
  }
329758
329759
  async function createSession(target) {
329759
- const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform) : {};
329760
+ const realCaps = target.isPhysical ? realDeviceCapabilities(target.platform, target.portOffset) : {};
329760
329761
  const capabilities = target.platform === "IOS" ? {
329761
329762
  platformName: "iOS",
329762
329763
  "appium:automationName": "XCUITest",
@@ -330031,6 +330032,46 @@ function buildLocatorStrategies(target) {
330031
330032
  (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
330032
330033
  );
330033
330034
  }
330035
+ function dedupeStrategies(strategies) {
330036
+ return strategies.filter(
330037
+ (strategy, index, list) => list.findIndex((candidate) => candidate.using === strategy.using && candidate.value === strategy.value) === index
330038
+ );
330039
+ }
330040
+ function xpathLiteral(value) {
330041
+ if (!value.includes("'")) return `'${value}'`;
330042
+ if (!value.includes('"')) return `"${value}"`;
330043
+ const parts = value.split("'").map((p) => `'${p}'`);
330044
+ return `concat(${parts.join(`, "'", `)})`;
330045
+ }
330046
+ function identityTextXpath(value) {
330047
+ const lit = xpathLiteral(value);
330048
+ return `//*[@text=${lit} or @content-desc=${lit} or @label=${lit} or @name=${lit} or @value=${lit}]`;
330049
+ }
330050
+ function isIdentityStrategy(strategy) {
330051
+ if (strategy.using === "accessibility id" || strategy.using === "id") return true;
330052
+ if (strategy.using === "xpath") return strategy.value.includes("@");
330053
+ return false;
330054
+ }
330055
+ function buildIdentityLocatorStrategies(target) {
330056
+ if (!target) return [];
330057
+ const strategies = [];
330058
+ const bundle = target.locatorBundle;
330059
+ if (bundle) {
330060
+ if (bundle.accessibilityId) strategies.push({ using: "accessibility id", value: bundle.accessibilityId });
330061
+ if (bundle.resourceId) strategies.push({ using: "id", value: bundle.resourceId });
330062
+ for (const value of [bundle.text, bundle.contentDesc]) {
330063
+ const trimmed = value?.trim();
330064
+ if (trimmed) strategies.push({ using: "xpath", value: identityTextXpath(trimmed) });
330065
+ }
330066
+ if (bundle.xpath && bundle.xpath.includes("@")) strategies.push({ using: "xpath", value: bundle.xpath });
330067
+ }
330068
+ for (const value of [target.primary, ...target.fallbacks ?? []]) {
330069
+ for (const strategy of locatorStrategyForValue(value)) {
330070
+ if (isIdentityStrategy(strategy)) strategies.push(strategy);
330071
+ }
330072
+ }
330073
+ return dedupeStrategies(strategies);
330074
+ }
330034
330075
  function readStepTimeout(step) {
330035
330076
  const value = step.metadata?.timeout;
330036
330077
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
@@ -330038,10 +330079,12 @@ function readStepTimeout(step) {
330038
330079
  }
330039
330080
  return void 0;
330040
330081
  }
330041
- async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS) {
330042
- const strategies = buildLocatorStrategies(target);
330082
+ async function findElement(sessionId, target, timeoutMs = DEFAULT_STEP_TIMEOUT_MS, opts) {
330083
+ const strategies = opts?.identityOnly ? buildIdentityLocatorStrategies(target) : buildLocatorStrategies(target);
330043
330084
  if (strategies.length === 0) {
330044
- throw new Error("No locator strategies available for this step");
330085
+ throw new Error(
330086
+ opts?.identityOnly ? "No identity locator (accessibility id / resource id / text) available for this step \u2014 cannot verify the screen" : "No locator strategies available for this step"
330087
+ );
330045
330088
  }
330046
330089
  const startTime = Date.now();
330047
330090
  let lastError = "";
@@ -330150,7 +330193,7 @@ async function executeTap(sessionId, step) {
330150
330193
  });
330151
330194
  return;
330152
330195
  } catch (error2) {
330153
- if (!bounds) throw error2;
330196
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330154
330197
  }
330155
330198
  }
330156
330199
  if (!bounds) {
@@ -330183,7 +330226,7 @@ async function executeType(sessionId, step, context) {
330183
330226
  if (!lastInputError) return;
330184
330227
  elementId = null;
330185
330228
  } catch (error2) {
330186
- if (!bounds) throw error2;
330229
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330187
330230
  lastInputError = error2;
330188
330231
  }
330189
330232
  }
@@ -330238,7 +330281,7 @@ async function executeClear(sessionId, step) {
330238
330281
  try {
330239
330282
  elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330240
330283
  } catch (error2) {
330241
- if (!bounds) throw error2;
330284
+ if (isTransportError(error2) || !bounds || buildLocatorStrategies(step.target).length > 0) throw error2;
330242
330285
  }
330243
330286
  }
330244
330287
  if (!elementId && bounds) {
@@ -330309,10 +330352,10 @@ async function executeHome(sessionId, platform3) {
330309
330352
  }
330310
330353
  async function executeWaitForElement(sessionId, step) {
330311
330354
  const timeout = readStepTimeout(step) ?? DEFAULT_STEP_TIMEOUT_MS;
330312
- await findElement(sessionId, step.target, timeout);
330355
+ await findElement(sessionId, step.target, timeout, { identityOnly: true });
330313
330356
  }
330314
330357
  async function executeAssertVisible(sessionId, step) {
330315
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330358
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
330316
330359
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/displayed`);
330317
330360
  if (!result?.value) {
330318
330361
  throw new Error(`Element found but not visible: ${step.target?.primary ?? "unknown"}`);
@@ -330320,7 +330363,7 @@ async function executeAssertVisible(sessionId, step) {
330320
330363
  }
330321
330364
  async function executeAssertText(sessionId, step) {
330322
330365
  if (!step.assertion) throw new Error("assertText step requires an assertion value");
330323
- const elementId = await findElement(sessionId, step.target, readStepTimeout(step));
330366
+ const elementId = await findElement(sessionId, step.target, readStepTimeout(step), { identityOnly: true });
330324
330367
  const result = await wdRequest(`/session/${sessionId}/element/${elementId}/text`);
330325
330368
  const actualText = typeof result?.value === "string" ? result.value : "";
330326
330369
  const textCandidates = await readElementTextCandidates(sessionId, elementId, actualText);
@@ -330355,6 +330398,7 @@ async function executeMobileTest(options) {
330355
330398
  const screenshots = [];
330356
330399
  let apiCalls = [];
330357
330400
  let sessionId = null;
330401
+ let selectedDevice;
330358
330402
  let capture = null;
330359
330403
  let captureStopped = false;
330360
330404
  let recordingStarted = false;
@@ -330402,7 +330446,6 @@ async function executeMobileTest(options) {
330402
330446
  `No ${platformFilter} ${platformFilter === "IOS" ? "simulator" : "emulator"} booted. Start one via ${platformFilter === "IOS" ? "Simulator.app" : "Android Studio"} first.`
330403
330447
  );
330404
330448
  }
330405
- let selectedDevice;
330406
330449
  if (options.target.assignedDeviceId) {
330407
330450
  selectedDevice = matchingDevices.find((d) => d.id === options.target.assignedDeviceId);
330408
330451
  if (!selectedDevice) {
@@ -330443,13 +330486,15 @@ async function executeMobileTest(options) {
330443
330486
  log2(`Warning: Could not retrieve installed apps list. Proceeding with test execution.`);
330444
330487
  }
330445
330488
  log2(`Starting Appium session for ${options.target.appId} on ${selectedDevice.name}...`);
330489
+ const leasedPortOffset = Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id));
330446
330490
  try {
330447
330491
  sessionId = await createSessionWithRetry({
330448
330492
  appId: options.target.appId,
330449
330493
  platform: options.target.platform,
330450
330494
  deviceId: selectedDevice.id,
330451
330495
  platformVersion: options.target.platformVersion ?? selectedDevice.platformVersion,
330452
- isPhysical: selectedDevice.isPhysical
330496
+ isPhysical: selectedDevice.isPhysical,
330497
+ portOffset: leasedPortOffset
330453
330498
  }, (msg) => log2(msg));
330454
330499
  } catch (err) {
330455
330500
  const base2 = err instanceof Error ? err.message : String(err);
@@ -330465,6 +330510,12 @@ async function executeMobileTest(options) {
330465
330510
  throw err;
330466
330511
  }
330467
330512
  log2(`Session created: ${sessionId}`);
330513
+ if (selectedDevice.id) {
330514
+ try {
330515
+ options.onSessionReady?.(sessionId, selectedDevice.id, options.target.platform);
330516
+ } catch {
330517
+ }
330518
+ }
330468
330519
  recordingStarted = await startScreenRecording(sessionId, options.target.platform, log2);
330469
330520
  log2("Opening a fresh app instance for the test...");
330470
330521
  await freshLaunchTargetApp(sessionId, options.target.platform, options.target.appId, log2);
@@ -330477,8 +330528,26 @@ async function executeMobileTest(options) {
330477
330528
  }
330478
330529
  }
330479
330530
  await restoreTargetAppIfExternal(sessionId, options.target, log2, "session start", false);
330531
+ let entryDrift = false;
330532
+ const entryStep = options.steps.find(
330533
+ (s) => s.action !== "launchApp" && buildIdentityLocatorStrategies(s.target).length > 0
330534
+ );
330535
+ if (entryStep) {
330536
+ try {
330537
+ await findElement(sessionId, entryStep.target, DEFAULT_STEP_TIMEOUT_MS, { identityOnly: true });
330538
+ } catch (err) {
330539
+ if (isTransportError(err)) throw err;
330540
+ entryDrift = true;
330541
+ const driftMsg = `App started on an unexpected screen \u2014 the recorded flow's first element ("${entryStep.target?.primary ?? entryStep.description}") is not present, so the replay was aborted before it could blind-tap stale coordinates (screen drift; the app may be on onboarding or a different screen than when the test was recorded).`;
330542
+ log2(` \u2717 Entry-screen drift: ${driftMsg}`);
330543
+ stepResults.push({ stepOrder: entryStep.order, status: "failed", duration: 0, error: driftMsg });
330544
+ const driftShot = await takeScreenshot(sessionId).catch(() => null);
330545
+ screenshots.push(driftShot ?? "");
330546
+ }
330547
+ }
330480
330548
  let environmental = null;
330481
330549
  for (const step of options.steps) {
330550
+ if (entryDrift) break;
330482
330551
  const stepStart = Date.now();
330483
330552
  log2(`Step ${step.order}: ${step.action} - ${step.description}`);
330484
330553
  let status = "passed";
@@ -330556,7 +330625,7 @@ async function executeMobileTest(options) {
330556
330625
  break;
330557
330626
  }
330558
330627
  }
330559
- const allPassed = !environmental && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330628
+ const allPassed = !environmental && stepResults.length > 0 && stepResults.every((r) => r.status === "passed" || r.status === "skipped");
330560
330629
  const firstError = environmental ?? (stepResults.find((r) => r.status === "failed")?.error ?? null);
330561
330630
  await stopCapture();
330562
330631
  await stopRecording();
@@ -330569,6 +330638,8 @@ async function executeMobileTest(options) {
330569
330638
  logs: logLines.join("\n"),
330570
330639
  error: firstError,
330571
330640
  duration: Date.now() - startTime,
330641
+ deviceId: selectedDevice?.id,
330642
+ deviceName: selectedDevice?.name,
330572
330643
  environmental: environmental ?? void 0
330573
330644
  };
330574
330645
  } catch (err) {
@@ -330585,6 +330656,8 @@ async function executeMobileTest(options) {
330585
330656
  logs: logLines.join("\n"),
330586
330657
  error: errorMsg,
330587
330658
  duration: Date.now() - startTime,
330659
+ deviceId: selectedDevice?.id,
330660
+ deviceName: selectedDevice?.name,
330588
330661
  // A throw before/around the step loop is a setup/environment problem
330589
330662
  // (no device booted, app not installed, session could not be created,
330590
330663
  // Appium unreachable) — never a genuine test assertion failure.
@@ -330594,6 +330667,12 @@ async function executeMobileTest(options) {
330594
330667
  await stopCapture();
330595
330668
  await stopRecording();
330596
330669
  if (sessionId) {
330670
+ if (selectedDevice?.id) {
330671
+ try {
330672
+ options.onSessionEnding?.(selectedDevice.id);
330673
+ } catch {
330674
+ }
330675
+ }
330597
330676
  try {
330598
330677
  log2("Closing the app on the device...");
330599
330678
  await terminateTargetApp(sessionId, options.target.platform, options.target.appId);
@@ -330652,7 +330731,9 @@ async function createMobileDiscoverySession(target, options) {
330652
330731
  platform: target.platform,
330653
330732
  deviceId: selectedDevice.id,
330654
330733
  platformVersion: target.platformVersion ?? selectedDevice.platformVersion,
330655
- isPhysical: selectedDevice.isPhysical
330734
+ isPhysical: selectedDevice.isPhysical,
330735
+ // Distinct control port per concurrent physical device (no-op for emulators/sims).
330736
+ portOffset: Math.max(0, matchingDevices.findIndex((d) => d.id === selectedDevice?.id))
330656
330737
  });
330657
330738
  if (target.launchMode === "DEEP_LINK" && target.deeplink) {
330658
330739
  try {
@@ -330664,6 +330745,7 @@ async function createMobileDiscoverySession(target, options) {
330664
330745
  return {
330665
330746
  sessionId,
330666
330747
  deviceId: selectedDevice.id,
330748
+ deviceName: selectedDevice.name,
330667
330749
  isPhysical: selectedDevice.isPhysical === true,
330668
330750
  platform: target.platform,
330669
330751
  appId: target.appId,
@@ -331389,7 +331471,146 @@ async function handleSessionStop(_req, res) {
331389
331471
  stopSessionReaper();
331390
331472
  sendJSON(res, 200, { ok: true });
331391
331473
  }
331474
+ var runnerChannels = /* @__PURE__ */ new Map();
331475
+ function readDeviceIdParam(req) {
331476
+ try {
331477
+ const url = new URL(req.url || "", "http://localhost");
331478
+ const raw = url.searchParams.get("deviceId");
331479
+ if (!raw) return null;
331480
+ const trimmed = raw.trim();
331481
+ if (!trimmed || trimmed.length > MAX_DEVICE_ID_LENGTH || !DEVICE_ID_PATTERN.test(trimmed)) return null;
331482
+ return trimmed;
331483
+ } catch {
331484
+ return null;
331485
+ }
331486
+ }
331487
+ async function isSessionAlive(sessionId) {
331488
+ try {
331489
+ validateSessionId(sessionId);
331490
+ await appiumRequest2(`/session/${sessionId}/window/rect`);
331491
+ return true;
331492
+ } catch (err) {
331493
+ const msg = err instanceof Error ? err.message : String(err);
331494
+ return !/no such session|invalid session id|session is either terminated|deleted session/i.test(msg);
331495
+ }
331496
+ }
331497
+ function writeFrameToChannelClient(ch, client, frame) {
331498
+ try {
331499
+ const buffered = typeof client.writableLength === "number" ? client.writableLength : typeof client.socket?.writableLength === "number" ? client.socket.writableLength : 0;
331500
+ if (buffered > SSE_MAX_BUFFERED_BYTES) {
331501
+ try {
331502
+ client.end();
331503
+ } catch {
331504
+ }
331505
+ ch.clients.delete(client);
331506
+ stopChannelPollingIfIdle(ch);
331507
+ return;
331508
+ }
331509
+ client.write(`data: ${JSON.stringify(frame)}
331510
+
331511
+ `);
331512
+ } catch {
331513
+ }
331514
+ }
331515
+ async function pollRunnerChannel(ch) {
331516
+ if (ch.clients.size === 0 || ch.polling) return;
331517
+ ch.polling = true;
331518
+ try {
331519
+ validateSessionId(ch.sessionId);
331520
+ const [screenshot, source] = await Promise.all([
331521
+ getScreenshot(`/session/${ch.sessionId}`),
331522
+ getPageSource(`/session/${ch.sessionId}`)
331523
+ ]);
331524
+ if (!screenshot && !source) {
331525
+ ch.nullTicks++;
331526
+ if (ch.nullTicks >= MIRROR_DEAD_SESSION_TICKS) {
331527
+ const alive = await isSessionAlive(ch.sessionId);
331528
+ if (!alive) {
331529
+ closeRunnerChannel(ch);
331530
+ return;
331531
+ }
331532
+ ch.nullTicks = 0;
331533
+ }
331534
+ } else {
331535
+ ch.nullTicks = 0;
331536
+ }
331537
+ const frame = { timestamp: Date.now(), screenshot: screenshot ?? null, source: source ?? null };
331538
+ ch.lastFrame = frame;
331539
+ for (const client of ch.clients) writeFrameToChannelClient(ch, client, frame);
331540
+ } catch {
331541
+ } finally {
331542
+ ch.polling = false;
331543
+ }
331544
+ }
331545
+ function ensureChannelPolling(ch) {
331546
+ if (ch.pollTimer) return;
331547
+ ch.pollTimer = setInterval(() => {
331548
+ void pollRunnerChannel(ch);
331549
+ }, MOBILE_SCREENSHOT_INTERVAL_MS);
331550
+ ch.pollTimer.unref?.();
331551
+ }
331552
+ function stopChannelPollingIfIdle(ch) {
331553
+ if (ch.clients.size === 0 && ch.pollTimer) {
331554
+ clearInterval(ch.pollTimer);
331555
+ ch.pollTimer = null;
331556
+ }
331557
+ }
331558
+ function closeRunnerChannel(ch) {
331559
+ if (ch.pollTimer) {
331560
+ clearInterval(ch.pollTimer);
331561
+ ch.pollTimer = null;
331562
+ }
331563
+ for (const client of ch.clients) {
331564
+ try {
331565
+ client.end();
331566
+ } catch {
331567
+ }
331568
+ }
331569
+ ch.clients.clear();
331570
+ ch.lastFrame = null;
331571
+ runnerChannels.delete(ch.deviceId);
331572
+ }
331573
+ function closeAllRunnerChannels() {
331574
+ for (const ch of [...runnerChannels.values()]) closeRunnerChannel(ch);
331575
+ }
331576
+ async function serveRunnerMirror(req, res, ch) {
331577
+ res.writeHead(200, {
331578
+ "Content-Type": "text/event-stream",
331579
+ "Cache-Control": "no-cache",
331580
+ "Connection": "keep-alive",
331581
+ "Access-Control-Allow-Origin": getAllowedOrigin(req),
331582
+ "X-Content-Type-Options": "nosniff"
331583
+ });
331584
+ ch.clients.add(res);
331585
+ ensureChannelPolling(ch);
331586
+ if (ch.lastFrame) {
331587
+ writeFrameToChannelClient(ch, res, ch.lastFrame);
331588
+ } else {
331589
+ await pollRunnerChannel(ch);
331590
+ }
331591
+ req.on("close", () => {
331592
+ ch.clients.delete(res);
331593
+ stopChannelPollingIfIdle(ch);
331594
+ });
331595
+ }
331392
331596
  async function handleMirror(req, res) {
331597
+ const deviceId = readDeviceIdParam(req);
331598
+ if (deviceId) {
331599
+ const ch = runnerChannels.get(deviceId);
331600
+ if (ch) {
331601
+ await serveRunnerMirror(req, res, ch);
331602
+ return;
331603
+ }
331604
+ sendJSON(res, 404, { error: `No live mirror for device ${deviceId}.` });
331605
+ return;
331606
+ }
331607
+ if (!state.appiumSessionId && runnerChannels.size === 1) {
331608
+ const soleChannel = runnerChannels.values().next().value;
331609
+ if (soleChannel) {
331610
+ await serveRunnerMirror(req, res, soleChannel);
331611
+ return;
331612
+ }
331613
+ }
331393
331614
  if (!state.appiumSessionId) {
331394
331615
  sendJSON(res, 400, { error: "No active Appium session. Call /bridge/session/start first." });
331395
331616
  return;
@@ -331836,6 +332057,7 @@ async function startMobileBridge(options) {
331836
332057
  }
331837
332058
  async function stopMobileBridge() {
331838
332059
  stopAllMirrorClients();
332060
+ closeAllRunnerChannels();
331839
332061
  stopSessionReaper();
331840
332062
  clearBridgePidFile();
331841
332063
  if (state.appiumSessionId) {
@@ -331884,8 +332106,31 @@ function getBridgeState() {
331884
332106
  function setExecutorBusy(busy) {
331885
332107
  executorBusyCount = busy ? executorBusyCount + 1 : Math.max(0, executorBusyCount - 1);
331886
332108
  }
331887
- function attachMirrorSession(sessionId, platform3) {
332109
+ function attachMirrorSession(sessionId, platform3, deviceId) {
331888
332110
  validateSessionId(sessionId);
332111
+ if (deviceId) {
332112
+ const existing = runnerChannels.get(deviceId);
332113
+ if (existing && existing.sessionId !== sessionId) closeRunnerChannel(existing);
332114
+ const ch = runnerChannels.get(deviceId) ?? {
332115
+ deviceId,
332116
+ sessionId,
332117
+ platform: platform3,
332118
+ clients: /* @__PURE__ */ new Set(),
332119
+ lastFrame: null,
332120
+ pollTimer: null,
332121
+ nullTicks: 0,
332122
+ polling: false
332123
+ };
332124
+ ch.sessionId = sessionId;
332125
+ ch.platform = platform3;
332126
+ ch.nullTicks = 0;
332127
+ runnerChannels.set(deviceId, ch);
332128
+ if (ch.clients.size > 0) {
332129
+ ensureChannelPolling(ch);
332130
+ void pollRunnerChannel(ch);
332131
+ }
332132
+ return;
332133
+ }
331889
332134
  if (state.appiumSessionId && state.appiumSessionId !== sessionId) {
331890
332135
  stopAllMirrorClients();
331891
332136
  }
@@ -331900,8 +332145,12 @@ function attachMirrorSession(sessionId, platform3) {
331900
332145
  void pollAndBroadcastFrame();
331901
332146
  }
331902
332147
  }
331903
- function detachMirrorSession(sessionId) {
331904
- if (sessionId && state.appiumSessionId && state.appiumSessionId !== sessionId) return;
332148
+ function detachMirrorSession(deviceId) {
332149
+ if (deviceId) {
332150
+ const ch = runnerChannels.get(deviceId);
332151
+ if (ch) closeRunnerChannel(ch);
332152
+ return;
332153
+ }
331905
332154
  state.appiumSessionId = null;
331906
332155
  state.platform = null;
331907
332156
  mirrorSessionOwner = null;
@@ -334087,7 +334336,8 @@ ${finalVerifyResult.logs ?? ""}`;
334087
334336
  testName: ctx.featureName ?? "Mobile discovery",
334088
334337
  note: `${platform3} mobile discovery`,
334089
334338
  surface: "mobile",
334090
- mobilePlatform: platform3
334339
+ mobilePlatform: platform3,
334340
+ ...assignedDeviceId ? { deviceId: assignedDeviceId } : {}
334091
334341
  });
334092
334342
  requestLiveBrowserHeartbeat(true);
334093
334343
  mobileOnLog(`Mobile discovery (${ctx.mode}) \u2014 ${platform3} / ${mobileTarget.appId}`);
@@ -334105,6 +334355,7 @@ ${finalVerifyResult.logs ?? ""}`;
334105
334355
  deeplink: mobileTarget.deeplink
334106
334356
  }, {
334107
334357
  onDeviceSelected: async (device) => {
334358
+ liveBrowserHandle.patch({ deviceId: device.deviceId, deviceName: device.name });
334108
334359
  try {
334109
334360
  capture = await startMobileNetworkCapture({
334110
334361
  platform: platform3,
@@ -334123,7 +334374,7 @@ ${finalVerifyResult.logs ?? ""}`;
334123
334374
  }
334124
334375
  });
334125
334376
  mobileOnLog(`Appium session created: ${session.sessionId} on device ${session.deviceId}`);
334126
- attachMirrorSession(session.sessionId, platform3);
334377
+ attachMirrorSession(session.sessionId, platform3, session.deviceId);
334127
334378
  const driver = new MobileBridgeDriver({
334128
334379
  sessionId: session.sessionId,
334129
334380
  platform: platform3,
@@ -334167,7 +334418,7 @@ ${finalVerifyResult.logs ?? ""}`;
334167
334418
  }
334168
334419
  }
334169
334420
  if (session) {
334170
- detachMirrorSession(session.sessionId);
334421
+ detachMirrorSession(session.deviceId);
334171
334422
  try {
334172
334423
  await session.stop();
334173
334424
  } catch {
@@ -334444,6 +334695,14 @@ ${finalVerifyResult.logs ?? ""}`;
334444
334695
  return;
334445
334696
  }
334446
334697
  log.info(`\u{1F4F1} Mobile test - ${target.platform} / ${target.appId}`);
334698
+ const leasedDeviceName = assignedDeviceId ? getCachedDeviceSnapshot().find((d) => d.id === assignedDeviceId)?.name : void 0;
334699
+ liveBrowserHandle.patch({
334700
+ surface: "mobile",
334701
+ mobilePlatform: target.platform,
334702
+ ...assignedDeviceId ? { deviceId: assignedDeviceId } : {},
334703
+ ...leasedDeviceName ? { deviceName: leasedDeviceName } : {}
334704
+ });
334705
+ requestLiveBrowserHeartbeat(true);
334447
334706
  const mobileSteps = run2.steps ?? [];
334448
334707
  if (mobileSteps.length === 0) {
334449
334708
  await postResult(opts.serverUrl, headers, run2.runId, {
@@ -334472,6 +334731,15 @@ ${finalVerifyResult.logs ?? ""}`;
334472
334731
  assignedDeviceId,
334473
334732
  launchMode: target.launchMode,
334474
334733
  deeplink: target.deeplink
334734
+ },
334735
+ // Stream this run to its OWN per-device dashboard tile (keyed by the
334736
+ // leased device id) so N parallel Appium runs each show a live mirror.
334737
+ onSessionReady: (sessionId, deviceId, sessionPlatform) => {
334738
+ attachMirrorSession(sessionId, sessionPlatform, deviceId);
334739
+ requestLiveBrowserHeartbeat(true);
334740
+ },
334741
+ onSessionEnding: (deviceId) => {
334742
+ detachMirrorSession(deviceId);
334475
334743
  }
334476
334744
  });
334477
334745
  attemptLogs.push(`--- Appium attempt ${attempt + 1}/${maxRetries + 1} ---
@@ -334487,6 +334755,12 @@ ${result2.logs}`);
334487
334755
  if (attemptLogs.length > 1) {
334488
334756
  result2 = { ...result2, logs: attemptLogs.join("\n\n") };
334489
334757
  }
334758
+ if (result2.deviceId || result2.deviceName) {
334759
+ liveBrowserHandle.patch({
334760
+ ...result2.deviceId ? { deviceId: result2.deviceId } : {},
334761
+ ...result2.deviceName ? { deviceName: result2.deviceName } : {}
334762
+ });
334763
+ }
334490
334764
  const duration2 = Date.now() - startTime;
334491
334765
  const status = result2.environmental ? "ERROR" : result2.passed ? "PASSED" : "FAILED";
334492
334766
  const reportedError = result2.environmental ? `CONFIG_ISSUE: ${result2.environmental}` : result2.error ?? void 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@validate.qa/runner",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
5
5
  "bin": {
6
6
  "validate-runner": "dist/cli.js"