@yoqa/runner 0.3.19 → 0.3.20

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 (2) hide show
  1. package/dist/index.js +138 -36
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -16801,7 +16801,7 @@ function getCatalogDb() {
16801
16801
  // package.json
16802
16802
  var package_default = {
16803
16803
  name: "@yoqa/runner",
16804
- version: "0.3.19",
16804
+ version: "0.3.20",
16805
16805
  description: "Yoqa local runner \u2014 Appium + catalog HTTP API (Bun). Start with yoqa serve or the yoqa-runner bin.",
16806
16806
  license: "UNLICENSED",
16807
16807
  type: "module",
@@ -17353,10 +17353,6 @@ var MJPEG_SETTINGS_BASE = {
17353
17353
  mjpegScalingFactor: 50
17354
17354
  };
17355
17355
  var openByDeviceId = new Map;
17356
- var exclusiveReleaseListener = null;
17357
- function onDeviceSessionExclusiveRelease(listener) {
17358
- exclusiveReleaseListener = listener;
17359
- }
17360
17356
  var DEAD_SESSION_RE = /session does not exist|invalid session id|no such session|terminated or not started|session is either terminated/i;
17361
17357
 
17362
17358
  class DeadSessionError extends Error {
@@ -17441,7 +17437,6 @@ async function releaseExistingSession(deviceId) {
17441
17437
  if (!existing)
17442
17438
  return;
17443
17439
  openByDeviceId.delete(deviceId);
17444
- exclusiveReleaseListener?.(deviceId);
17445
17440
  try {
17446
17441
  await existing.quit();
17447
17442
  } catch (error) {
@@ -17975,21 +17970,32 @@ async function createDeviceSession(options) {
17975
17970
 
17976
17971
  // src/domains/devices/active-session.ts
17977
17972
  var active = null;
17973
+
17974
+ class SessionBusyError extends Error {
17975
+ constructor(message) {
17976
+ super(message);
17977
+ this.name = "SessionBusyError";
17978
+ }
17979
+ }
17978
17980
  var isMissingAppiumSessionError = isDeadSessionError;
17981
+ function toInfo(current) {
17982
+ return {
17983
+ deviceId: current.deviceId,
17984
+ platform: current.platform,
17985
+ connectedAt: current.connectedAt,
17986
+ mjpegPort: current.mjpegPort,
17987
+ streamReady: current.streamReady,
17988
+ streamUrl: current.streamUrl,
17989
+ heldByRun: current.heldByRunId != null
17990
+ };
17991
+ }
17979
17992
  function getActiveSession() {
17980
17993
  return active;
17981
17994
  }
17982
17995
  function getActiveSessionInfo() {
17983
17996
  if (!active)
17984
17997
  return null;
17985
- return {
17986
- deviceId: active.deviceId,
17987
- platform: active.platform,
17988
- connectedAt: active.connectedAt,
17989
- mjpegPort: active.mjpegPort,
17990
- streamReady: active.streamReady,
17991
- streamUrl: active.streamUrl
17992
- };
17998
+ return toInfo(active);
17993
17999
  }
17994
18000
  function requireActiveSession() {
17995
18001
  if (!active) {
@@ -17997,6 +18003,9 @@ function requireActiveSession() {
17997
18003
  }
17998
18004
  return active;
17999
18005
  }
18006
+ function isActiveSessionHeldByRun() {
18007
+ return active?.heldByRunId != null;
18008
+ }
18000
18009
  function abandonActiveSession() {
18001
18010
  if (!active)
18002
18011
  return null;
@@ -18006,20 +18015,12 @@ function abandonActiveSession() {
18006
18015
  console.warn(`[yoqa-runner] abandoned dead Appium session for ${info?.platform} ${info?.deviceId}`);
18007
18016
  return info;
18008
18017
  }
18009
- onDeviceSessionExclusiveRelease((deviceId) => {
18010
- if (active?.deviceId === deviceId) {
18011
- abandonActiveSession();
18012
- }
18013
- });
18014
- async function connectDevice(options) {
18015
- if (active) {
18016
- await disconnectDevice();
18017
- }
18018
+ async function createAndRegister(options) {
18018
18019
  const session = await createDeviceSession({
18019
18020
  platform: options.platform,
18020
18021
  deviceId: options.deviceId,
18021
- appCaps: [],
18022
- caseCaps: [],
18022
+ appCaps: options.appCaps ?? [],
18023
+ caseCaps: options.caseCaps ?? [],
18023
18024
  bundleId: options.bundleId,
18024
18025
  appPackage: options.appPackage,
18025
18026
  onSessionDead: () => {
@@ -18033,8 +18034,19 @@ async function connectDevice(options) {
18033
18034
  mjpegPort: session.mjpegPort,
18034
18035
  streamReady: session.streamReady,
18035
18036
  streamUrl: "/stream.mjpeg",
18036
- session
18037
+ session,
18038
+ heldByRunId: options.heldByRunId
18037
18039
  };
18040
+ return session;
18041
+ }
18042
+ async function connectDevice(options) {
18043
+ if (isActiveSessionHeldByRun()) {
18044
+ throw new SessionBusyError("A run is using the device session. Cancel the run before connecting another device.");
18045
+ }
18046
+ if (active) {
18047
+ await disconnectDevice();
18048
+ }
18049
+ await createAndRegister({ ...options, heldByRunId: null });
18038
18050
  const info = getActiveSessionInfo();
18039
18051
  if (!info) {
18040
18052
  throw new Error("Failed to read active session after connect");
@@ -18044,6 +18056,9 @@ async function connectDevice(options) {
18044
18056
  async function disconnectDevice() {
18045
18057
  if (!active)
18046
18058
  return null;
18059
+ if (isActiveSessionHeldByRun()) {
18060
+ throw new SessionBusyError("A run is using the device session. Cancel the run first.");
18061
+ }
18047
18062
  const info = getActiveSessionInfo();
18048
18063
  const session = active.session;
18049
18064
  active = null;
@@ -18053,6 +18068,58 @@ async function disconnectDevice() {
18053
18068
  } catch {}
18054
18069
  return info;
18055
18070
  }
18071
+ async function acquireSessionForRun(options) {
18072
+ const current = active;
18073
+ if (current?.heldByRunId && current.heldByRunId !== options.runId) {
18074
+ const session2 = await createDeviceSession({
18075
+ platform: options.platform,
18076
+ deviceId: options.deviceId,
18077
+ appCaps: options.appCaps ?? [],
18078
+ caseCaps: options.caseCaps ?? [],
18079
+ bundleId: options.bundleId,
18080
+ appPackage: options.appPackage,
18081
+ onSessionDead: () => {
18082
+ return;
18083
+ }
18084
+ });
18085
+ return { session: session2, shared: false };
18086
+ }
18087
+ if (current && current.deviceId === options.deviceId) {
18088
+ const healthy = await current.session.getWindowSize().then(() => true).catch(() => false);
18089
+ if (healthy) {
18090
+ current.heldByRunId = options.runId;
18091
+ return { session: current.session, shared: true };
18092
+ }
18093
+ console.warn(`[yoqa-runner] active session for ${options.deviceId} is dead \u2014 creating a fresh one for the run`);
18094
+ await disconnectDevice().catch(() => {
18095
+ return;
18096
+ });
18097
+ }
18098
+ if (current) {
18099
+ await disconnectDevice();
18100
+ }
18101
+ const session = await createAndRegister({
18102
+ deviceId: options.deviceId,
18103
+ platform: options.platform,
18104
+ appCaps: options.appCaps,
18105
+ caseCaps: options.caseCaps,
18106
+ bundleId: options.bundleId,
18107
+ appPackage: options.appPackage,
18108
+ heldByRunId: options.runId
18109
+ });
18110
+ return { session, shared: true };
18111
+ }
18112
+ async function releaseSessionFromRun(runId, session, shared) {
18113
+ if (!shared) {
18114
+ await session.quit().catch(() => {
18115
+ return;
18116
+ });
18117
+ return;
18118
+ }
18119
+ if (active && active.heldByRunId === runId) {
18120
+ active.heldByRunId = null;
18121
+ }
18122
+ }
18056
18123
 
18057
18124
  // src/domains/servers/application.ts
18058
18125
  function appiumLabel(info) {
@@ -24351,7 +24418,8 @@ var activeDeviceResponseSchema = exports_external.object({
24351
24418
  connectedAt: exports_external.number().int().nonnegative(),
24352
24419
  mjpegPort: exports_external.number().int().positive().optional(),
24353
24420
  streamReady: exports_external.boolean().optional(),
24354
- streamUrl: exports_external.string().min(1).optional()
24421
+ streamUrl: exports_external.string().min(1).optional(),
24422
+ heldByRun: exports_external.boolean().optional()
24355
24423
  });
24356
24424
  var controlPointerMessageSchema = exports_external.object({
24357
24425
  type: exports_external.literal("pointer"),
@@ -98329,6 +98397,7 @@ async function executeRun(runId) {
98329
98397
  return;
98330
98398
  registerControl(runId);
98331
98399
  let session = null;
98400
+ let sharedSession = true;
98332
98401
  try {
98333
98402
  if (isAborted2(runId)) {
98334
98403
  await persistCancelled(runId);
@@ -98369,14 +98438,17 @@ async function executeRun(runId) {
98369
98438
  }
98370
98439
  }
98371
98440
  const firstCase = run.tests[0] ? await getCase(run.tests[0].caseId) : null;
98372
- session = await createDeviceSession({
98373
- platform: run.platform,
98441
+ const acquired = await acquireSessionForRun({
98442
+ runId,
98374
98443
  deviceId: run.deviceId,
98444
+ platform: run.platform,
98375
98445
  appCaps: app.capabilities,
98376
98446
  caseCaps: firstCase?.capabilities ?? [],
98377
98447
  bundleId: app.iosBundleId || undefined,
98378
98448
  appPackage: app.androidApplicationId || undefined
98379
98449
  });
98450
+ session = acquired.session;
98451
+ sharedSession = acquired.shared;
98380
98452
  if (isAborted2(runId)) {
98381
98453
  await persistCancelled(runId);
98382
98454
  return;
@@ -98451,14 +98523,15 @@ async function executeRun(runId) {
98451
98523
  }
98452
98524
  } finally {
98453
98525
  if (session) {
98454
- await session.quit().catch(() => {
98455
- return;
98456
- });
98526
+ await releaseSessionFromRun(runId, session, sharedSession);
98457
98527
  }
98458
98528
  clearControl(runId);
98459
98529
  }
98460
98530
  }
98461
98531
  async function createRun(input) {
98532
+ if (runControls.size > 0) {
98533
+ throw new RunValidationError("A run is already in progress. Cancel it before starting another.");
98534
+ }
98462
98535
  const uniqueCaseIds = [...new Set(input.caseIds)];
98463
98536
  if (uniqueCaseIds.length === 0) {
98464
98537
  throw new RunValidationError("At least one case id is required");
@@ -98816,6 +98889,12 @@ import { mkdir as mkdir4, rename as rename2 } from "fs/promises";
98816
98889
  import { dirname as dirname4 } from "path";
98817
98890
  function sessionErrorResponse(error40) {
98818
98891
  const message = error40 instanceof Error ? error40.message : String(error40);
98892
+ if (error40 instanceof SessionBusyError) {
98893
+ return {
98894
+ status: 409,
98895
+ body: { error: error40.message }
98896
+ };
98897
+ }
98819
98898
  if (isMissingAppiumSessionError(error40)) {
98820
98899
  abandonActiveSession();
98821
98900
  return {
@@ -98845,6 +98924,9 @@ function createSessionRoutes() {
98845
98924
  const info = await connectDevice(parsed.data);
98846
98925
  return c.json(activeDeviceResponseSchema.parse(info));
98847
98926
  } catch (error40) {
98927
+ const mapped = sessionErrorResponse(error40);
98928
+ if (mapped)
98929
+ return c.json(mapped.body, mapped.status);
98848
98930
  const message = error40 instanceof Error ? error40.message : String(error40);
98849
98931
  return c.json({ error: "Failed to connect device", detail: message }, 500);
98850
98932
  }
@@ -98857,11 +98939,19 @@ function createSessionRoutes() {
98857
98939
  return c.json(activeDeviceResponseSchema.parse(info));
98858
98940
  });
98859
98941
  app.post("/devices/disconnect", async (c) => {
98860
- const info = await disconnectDevice();
98861
- if (!info) {
98862
- return c.json({ error: "No active device session" }, 404);
98942
+ try {
98943
+ const info = await disconnectDevice();
98944
+ if (!info) {
98945
+ return c.json({ error: "No active device session" }, 404);
98946
+ }
98947
+ return c.json(activeDeviceResponseSchema.parse(info));
98948
+ } catch (error40) {
98949
+ const mapped = sessionErrorResponse(error40);
98950
+ if (mapped)
98951
+ return c.json(mapped.body, mapped.status);
98952
+ const message = error40 instanceof Error ? error40.message : String(error40);
98953
+ return c.json({ error: "Failed to disconnect device", detail: message }, 500);
98863
98954
  }
98864
- return c.json(activeDeviceResponseSchema.parse(info));
98865
98955
  });
98866
98956
  app.get("/screen", async (c) => {
98867
98957
  try {
@@ -98982,6 +99072,11 @@ function createSessionRoutes() {
98982
99072
  }
98983
99073
  try {
98984
99074
  const { session } = requireActiveSession();
99075
+ if (isActiveSessionHeldByRun()) {
99076
+ return c.json({
99077
+ error: "A run is using this device session. Cancel the run to interact manually."
99078
+ }, 409);
99079
+ }
98985
99080
  const result = await performAction(session, parsed.data);
98986
99081
  return c.json(actionResponseSchema.parse(result));
98987
99082
  } catch (error40) {
@@ -99110,6 +99205,13 @@ var controlWebSocket = {
99110
99205
  return;
99111
99206
  }
99112
99207
  const msg = result.data;
99208
+ if (isActiveSessionHeldByRun()) {
99209
+ ws.send(JSON.stringify({
99210
+ ok: false,
99211
+ error: "A run is using this device session. Cancel the run to interact manually."
99212
+ }));
99213
+ return;
99214
+ }
99113
99215
  try {
99114
99216
  await active2.session.pointerEvent(msg.phase, msg.x, msg.y, msg.seq);
99115
99217
  if (msg.phase === "end" || msg.phase === "begin") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoqa/runner",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
4
4
  "description": "Yoqa local runner — Appium + catalog HTTP API (Bun). Start with yoqa serve or the yoqa-runner bin.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",