@yoqa/runner 0.3.17 → 0.3.18

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 +638 -565
  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.17",
16804
+ version: "0.3.18",
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",
@@ -16896,207 +16896,666 @@ function abortAllMjpegProxies() {
16896
16896
  import { mkdir as mkdir3 } from "fs/promises";
16897
16897
  import { createServer as createServer2 } from "net";
16898
16898
  import { homedir as homedir6 } from "os";
16899
- import { join as join6 } from "path";
16899
+ import { join as join7 } from "path";
16900
16900
  import { remote } from "webdriverio";
16901
- var YOQA_ROOT4 = join6(homedir6(), ".yoqa");
16902
- var DEFAULT_MJPEG_PORT = Number(process.env.YOQA_MJPEG_PORT ?? "9100");
16903
- var SCREENSHOT_DIR = join6(YOQA_ROOT4, "runs", "screenshots");
16904
- var MJPEG_SETTINGS_BASE = {
16905
- mjpegServerScreenshotQuality: 35,
16906
- mjpegScalingFactor: 50
16907
- };
16908
- var openByDeviceId = new Map;
16909
- var exclusiveReleaseListener = null;
16910
- function onDeviceSessionExclusiveRelease(listener) {
16911
- exclusiveReleaseListener = listener;
16912
- }
16913
- var DEAD_SESSION_RE = /session does not exist|invalid session id|no such session|terminated or not started|session is either terminated/i;
16914
16901
 
16915
- class DeadSessionError extends Error {
16916
- constructor(message = "Device session ended") {
16917
- super(message);
16918
- this.name = "DeadSessionError";
16919
- }
16920
- }
16921
- function isDeadSessionError(error) {
16922
- if (error instanceof DeadSessionError)
16923
- return true;
16924
- const message = error instanceof Error ? error.message : String(error);
16925
- return DEAD_SESSION_RE.test(message);
16926
- }
16927
- function mjpegSettingsForDevice(options) {
16928
- const physicalIos = options.platform === "ios" && looksLikePhysicalIosUdid(options.deviceId);
16929
- if (physicalIos) {
16902
+ // src/domains/devices/application.ts
16903
+ import { mkdtemp as mkdtemp2, readFile, rm as rm2 } from "fs/promises";
16904
+ import { tmpdir as tmpdir2 } from "os";
16905
+ import { join as join6 } from "path";
16906
+
16907
+ // src/domains/devices/android-identity.ts
16908
+ function matchAndroidAppiumIdentity(deviceId, connected, knownAvds = []) {
16909
+ const id = deviceId.trim();
16910
+ if (!id)
16911
+ return {};
16912
+ const bySerial = connected.find((device) => device.serial === id);
16913
+ if (bySerial) {
16930
16914
  return {
16931
- mjpegServerFramerate: 15,
16932
- mjpegServerScreenshotQuality: 25,
16933
- mjpegScalingFactor: 40
16915
+ udid: bySerial.serial,
16916
+ ...bySerial.avdName ? { avd: bySerial.avdName } : {}
16934
16917
  };
16935
16918
  }
16936
- return {
16937
- ...MJPEG_SETTINGS_BASE,
16938
- mjpegServerFramerate: 60
16939
- };
16919
+ const byAvd = connected.find((device) => device.avdName === id);
16920
+ if (byAvd) {
16921
+ return { udid: byAvd.serial, avd: byAvd.avdName ?? id };
16922
+ }
16923
+ if (knownAvds.includes(id)) {
16924
+ return { avd: id };
16925
+ }
16926
+ return { udid: id };
16940
16927
  }
16941
- function isPortFree2(port) {
16942
- return new Promise((resolve) => {
16943
- const server = createServer2();
16944
- server.unref();
16945
- server.once("error", () => resolve(false));
16946
- server.listen(port, APPIUM_HOST, () => {
16947
- server.close(() => resolve(true));
16928
+ function parseAdbEmuAvdName(stdout) {
16929
+ const name = stdout.split(/\r?\n/).map((line) => line.trim()).find((line) => line && line !== "OK");
16930
+ return name || null;
16931
+ }
16932
+
16933
+ // src/domains/devices/application.ts
16934
+ async function runCommand3(command, options) {
16935
+ try {
16936
+ const proc = Bun.spawn(command, {
16937
+ env: options?.env ? { ...process.env, ...options.env } : process.env,
16938
+ stdout: "pipe",
16939
+ stderr: "pipe"
16948
16940
  });
16949
- });
16941
+ const [stdout, stderr, exitCode] = await Promise.all([
16942
+ new Response(proc.stdout).text(),
16943
+ new Response(proc.stderr).text(),
16944
+ proc.exited
16945
+ ]);
16946
+ return { stdout, stderr, exitCode };
16947
+ } catch {
16948
+ return { stdout: "", stderr: `failed to spawn: ${command[0]}`, exitCode: 127 };
16949
+ }
16950
16950
  }
16951
- async function pickMjpegPort() {
16952
- if (await isPortFree2(DEFAULT_MJPEG_PORT))
16953
- return DEFAULT_MJPEG_PORT;
16954
- for (let offset = 1;offset <= 40; offset++) {
16955
- const candidate = DEFAULT_MJPEG_PORT + offset;
16956
- if (await isPortFree2(candidate))
16951
+ function androidSdkRoot() {
16952
+ return resolveAndroidSdkRoot();
16953
+ }
16954
+ async function resolveTool(candidates) {
16955
+ for (const candidate of candidates) {
16956
+ if (!candidate)
16957
+ continue;
16958
+ const { exitCode } = await runCommand3(["test", "-x", candidate]);
16959
+ if (exitCode === 0)
16957
16960
  return candidate;
16958
16961
  }
16959
- throw new Error(`No free MJPEG port near ${DEFAULT_MJPEG_PORT}. Quit other streams or set YOQA_MJPEG_PORT.`);
16962
+ return null;
16960
16963
  }
16961
- function mjpegUpstreamUrl(mjpegPort) {
16962
- return `http://127.0.0.1:${mjpegPort}/`;
16964
+ async function resolveAdb() {
16965
+ const sdk = androidSdkRoot();
16966
+ return resolveTool([...sdk ? [join6(sdk, "platform-tools/adb")] : [], "adb"]);
16963
16967
  }
16964
- async function probeMjpegStream(mjpegPort, timeoutMs = 4000) {
16965
- const deadline = Date.now() + timeoutMs;
16966
- while (Date.now() < deadline) {
16967
- const controller = new AbortController;
16968
- const timer = setTimeout(() => controller.abort(), 800);
16969
- try {
16970
- const response = await fetch(mjpegUpstreamUrl(mjpegPort), {
16971
- signal: controller.signal,
16972
- headers: { Accept: "multipart/x-mixed-replace,image/jpeg,*/*" }
16973
- });
16974
- if (response.ok) {
16975
- controller.abort();
16976
- return true;
16977
- }
16978
- } catch {} finally {
16979
- clearTimeout(timer);
16980
- }
16981
- await Bun.sleep(250);
16982
- }
16983
- return false;
16968
+ async function resolveEmulator() {
16969
+ const sdk = androidSdkRoot();
16970
+ return resolveTool([...sdk ? [join6(sdk, "emulator/emulator")] : [], "emulator"]);
16984
16971
  }
16985
- async function applyMjpegSettings(browser, options) {
16986
- try {
16987
- await browser.updateSettings(mjpegSettingsForDevice(options));
16988
- } catch (error) {
16989
- console.warn("[yoqa-runner] MJPEG settings update failed:", error instanceof Error ? error.message : error);
16990
- }
16972
+ function formatIosVersion(version2) {
16973
+ const trimmed = version2.trim();
16974
+ if (!trimmed)
16975
+ return "iOS";
16976
+ if (/^iOS\b/i.test(trimmed))
16977
+ return trimmed;
16978
+ return `iOS ${trimmed}`;
16991
16979
  }
16992
- async function releaseExistingSession(deviceId) {
16993
- const existing = openByDeviceId.get(deviceId);
16994
- if (!existing)
16995
- return;
16996
- openByDeviceId.delete(deviceId);
16997
- exclusiveReleaseListener?.(deviceId);
16980
+ function formatAndroidVersion(apiOrVersion) {
16981
+ const trimmed = apiOrVersion.trim();
16982
+ if (!trimmed)
16983
+ return "Android";
16984
+ if (/^Android\b/i.test(trimmed))
16985
+ return trimmed;
16986
+ if (/^API\b/i.test(trimmed))
16987
+ return trimmed;
16988
+ if (/^\d+(\.\d+)?$/.test(trimmed))
16989
+ return `API ${trimmed}`;
16990
+ return `Android ${trimmed}`;
16991
+ }
16992
+ function titleCaseWords(value) {
16993
+ return value.split(/\s+/).filter(Boolean).map((word) => {
16994
+ if (/^[a-z]$/i.test(word))
16995
+ return word.toUpperCase();
16996
+ if (/^\d/.test(word))
16997
+ return word;
16998
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
16999
+ }).join(" ");
17000
+ }
17001
+ function runtimeVersionFromIdentifier(runtimeId) {
17002
+ const match = runtimeId.match(/iOS-([\d-]+)$/i);
17003
+ if (!match?.[1])
17004
+ return "iOS";
17005
+ return formatIosVersion(match[1].replace(/-/g, "."));
17006
+ }
17007
+ async function listIosSimulators(includeUnavailable) {
17008
+ const { stdout, exitCode } = await runCommand3([
17009
+ "xcrun",
17010
+ "simctl",
17011
+ "list",
17012
+ "devices",
17013
+ "available",
17014
+ "-j"
17015
+ ]);
17016
+ if (exitCode !== 0)
17017
+ return [];
17018
+ let parsed;
16998
17019
  try {
16999
- await existing.quit();
17000
- } catch (error) {
17001
- console.warn("[yoqa-runner] quit prior Device Session for exclusivity:", error instanceof Error ? error.message : error);
17020
+ parsed = JSON.parse(stdout);
17021
+ } catch {
17022
+ return [];
17002
17023
  }
17003
- }
17004
- function capabilitiesToRecord(caps) {
17005
- const out = {};
17006
- for (const cap of caps) {
17007
- const key = cap.key.trim();
17008
- if (!key)
17024
+ const devices = [];
17025
+ for (const [runtimeId, runtimeDevices] of Object.entries(parsed.devices ?? {})) {
17026
+ if (!/iOS/i.test(runtimeId))
17009
17027
  continue;
17010
- out[key] = cap.value;
17028
+ const osVersion = runtimeVersionFromIdentifier(runtimeId);
17029
+ for (const device of runtimeDevices) {
17030
+ if (!device.udid || !device.name)
17031
+ continue;
17032
+ if (device.isAvailable === false)
17033
+ continue;
17034
+ const state = device.state ?? "Unknown";
17035
+ if (!includeUnavailable && state !== "Booted")
17036
+ continue;
17037
+ devices.push({
17038
+ id: device.udid,
17039
+ name: device.name,
17040
+ osVersion,
17041
+ platform: "ios",
17042
+ kind: "simulator",
17043
+ state,
17044
+ model: device.deviceTypeIdentifier?.split(".").pop()?.replace(/-/g, " ")
17045
+ });
17046
+ }
17011
17047
  }
17012
- return out;
17013
- }
17014
- function mergeCapabilities(appCaps, caseCaps) {
17015
- return {
17016
- ...capabilitiesToRecord(appCaps),
17017
- ...capabilitiesToRecord(caseCaps)
17018
- };
17048
+ return devices.sort((a, b) => {
17049
+ if (a.state === "Booted" && b.state !== "Booted")
17050
+ return -1;
17051
+ if (b.state === "Booted" && a.state !== "Booted")
17052
+ return 1;
17053
+ return a.name.localeCompare(b.name);
17054
+ });
17019
17055
  }
17020
- function looksLikePhysicalIosUdid(udid) {
17021
- return /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16}$/.test(udid.trim());
17056
+ function isPhoneOrTablet(deviceType) {
17057
+ if (!deviceType)
17058
+ return false;
17059
+ return /^(iPhone|iPad)$/i.test(deviceType);
17022
17060
  }
17023
- var PHYSICAL_IOS_SESSION_TIMEOUT_MS = 60000;
17024
- var SIMULATOR_WDA_SESSION_TIMEOUT_MS = 600000;
17025
- var SIMULATOR_WDA_DERIVED_DATA = join6(YOQA_ROOT4, "wda-sim");
17026
- function iosSessionCreateTimeoutMs(deviceId) {
17027
- return looksLikePhysicalIosUdid(deviceId) ? PHYSICAL_IOS_SESSION_TIMEOUT_MS : SIMULATOR_WDA_SESSION_TIMEOUT_MS;
17061
+ function physicalIosState(connection) {
17062
+ const tunnel = connection?.tunnelState?.toLowerCase() ?? "";
17063
+ if (tunnel.includes("connected"))
17064
+ return "connected";
17065
+ if (tunnel.includes("unavailable"))
17066
+ return "unavailable";
17067
+ if (tunnel.includes("disconnected"))
17068
+ return "disconnected";
17069
+ if (connection?.pairingState === "paired")
17070
+ return "paired";
17071
+ return tunnel || connection?.pairingState || "unknown";
17028
17072
  }
17029
- async function buildW3cCapabilities(options, mjpegPort) {
17030
- const merged = mergeCapabilities(options.appCaps, options.caseCaps);
17031
- const platformName = options.platform === "ios" ? "iOS" : "Android";
17032
- const automationName = options.platform === "ios" ? "XCUITest" : "UiAutomator2";
17033
- const caps = {
17034
- platformName,
17035
- "appium:automationName": automationName,
17036
- "appium:udid": options.deviceId,
17037
- "appium:newCommandTimeout": 3600,
17038
- "appium:mjpegServerPort": mjpegPort,
17039
- ...Object.fromEntries(Object.entries(merged).map(([key, value]) => key.includes(":") ? [key, value] : [`appium:${key}`, value]))
17040
- };
17041
- if (options.platform === "ios" && options.bundleId && !caps["appium:bundleId"]) {
17042
- caps["appium:bundleId"] = options.bundleId;
17043
- }
17044
- if (options.platform === "android" && options.appPackage && !caps["appium:appPackage"]) {
17045
- caps["appium:appPackage"] = options.appPackage;
17046
- }
17047
- if (options.platform === "ios") {
17048
- const physical = looksLikePhysicalIosUdid(options.deviceId);
17049
- if (physical) {
17050
- const prep = await loadDevicePrep(options.deviceId);
17051
- if (!prep) {
17052
- throw new Error(`iOS device ${options.deviceId} is not prepared. Run device setup so WebDriverAgent is installed before starting a run.`);
17053
- }
17054
- if (caps["appium:usePreinstalledWDA"] === undefined) {
17055
- caps["appium:usePreinstalledWDA"] = true;
17056
- }
17057
- if (!caps["appium:updatedWDABundleId"]) {
17058
- caps["appium:updatedWDABundleId"] = prep.bundleId;
17059
- }
17060
- if (!caps["appium:xcodeOrgId"]) {
17061
- caps["appium:xcodeOrgId"] = prep.developmentTeam;
17062
- }
17063
- if (!caps["appium:xcodeSigningId"]) {
17064
- caps["appium:xcodeSigningId"] = "Apple Development";
17065
- }
17066
- }
17067
- const wdaTimeoutMs = iosSessionCreateTimeoutMs(options.deviceId);
17068
- if (caps["appium:wdaLaunchTimeout"] === undefined) {
17069
- caps["appium:wdaLaunchTimeout"] = wdaTimeoutMs;
17073
+ async function listIosPhysicalDevices(includeUnavailable) {
17074
+ const dir = await mkdtemp2(join6(tmpdir2(), "yoqa-devices-"));
17075
+ const jsonPath = join6(dir, "devices.json");
17076
+ try {
17077
+ const { exitCode } = await runCommand3([
17078
+ "xcrun",
17079
+ "devicectl",
17080
+ "list",
17081
+ "devices",
17082
+ "--json-output",
17083
+ jsonPath
17084
+ ]);
17085
+ if (exitCode !== 0)
17086
+ return [];
17087
+ let parsed;
17088
+ try {
17089
+ parsed = JSON.parse(await readFile(jsonPath, "utf8"));
17090
+ } catch {
17091
+ return [];
17070
17092
  }
17071
- if (caps["appium:wdaConnectionTimeout"] === undefined) {
17072
- caps["appium:wdaConnectionTimeout"] = wdaTimeoutMs;
17093
+ const devices = [];
17094
+ for (const item of parsed.result?.devices ?? []) {
17095
+ const hardware = item.hardwareProperties;
17096
+ const props = item.deviceProperties;
17097
+ if (!hardware?.udid)
17098
+ continue;
17099
+ if (hardware.reality && hardware.reality !== "physical")
17100
+ continue;
17101
+ if (!isPhoneOrTablet(hardware.deviceType))
17102
+ continue;
17103
+ if (hardware.platform && !/^iOS$/i.test(hardware.platform))
17104
+ continue;
17105
+ const state = physicalIosState(item.connectionProperties);
17106
+ if (!includeUnavailable && state === "unavailable")
17107
+ continue;
17108
+ const modelName = hardware.marketingName ?? hardware.productType ?? "iPhone";
17109
+ const owner = props?.name?.trim() || undefined;
17110
+ devices.push({
17111
+ id: hardware.udid,
17112
+ name: modelName,
17113
+ owner,
17114
+ osVersion: formatIosVersion(props?.osVersionNumber ?? ""),
17115
+ platform: "ios",
17116
+ kind: "physical",
17117
+ state,
17118
+ model: hardware.productType
17119
+ });
17073
17120
  }
17074
- if (caps["appium:waitForIdleTimeout"] === undefined) {
17075
- caps["appium:waitForIdleTimeout"] = 0;
17121
+ return devices.sort((a, b) => a.name.localeCompare(b.name));
17122
+ } finally {
17123
+ await rm2(dir, { recursive: true, force: true });
17124
+ }
17125
+ }
17126
+ async function listIosDevices(options = {}) {
17127
+ const includeUnavailable = options.includeUnavailable ?? true;
17128
+ const [physical, simulators] = await Promise.all([
17129
+ listIosPhysicalDevices(includeUnavailable),
17130
+ listIosSimulators(includeUnavailable)
17131
+ ]);
17132
+ return [...physical, ...simulators];
17133
+ }
17134
+ function parseAdbDevices(output) {
17135
+ const lines = output.split(`
17136
+ `).map((line) => line.trim()).filter(Boolean);
17137
+ const results = [];
17138
+ for (const line of lines) {
17139
+ if (line.startsWith("List of devices"))
17140
+ continue;
17141
+ const parts = line.split(/\s+/);
17142
+ const serial = parts[0];
17143
+ const state = parts[1];
17144
+ if (!serial || !state)
17145
+ continue;
17146
+ const meta = {};
17147
+ for (const part of parts.slice(2)) {
17148
+ const eq2 = part.indexOf(":");
17149
+ if (eq2 <= 0)
17150
+ continue;
17151
+ meta[part.slice(0, eq2)] = part.slice(eq2 + 1);
17076
17152
  }
17077
- if (!physical && caps["appium:derivedDataPath"] === undefined) {
17078
- await mkdir3(SIMULATOR_WDA_DERIVED_DATA, { recursive: true });
17079
- caps["appium:derivedDataPath"] = SIMULATOR_WDA_DERIVED_DATA;
17153
+ results.push({
17154
+ serial,
17155
+ state,
17156
+ product: meta.product,
17157
+ model: meta.model,
17158
+ device: meta.device,
17159
+ transportId: meta.transport_id
17160
+ });
17161
+ }
17162
+ return results;
17163
+ }
17164
+ function humanizeAvdName(avdName) {
17165
+ return titleCaseWords(avdName.replace(/_/g, " "));
17166
+ }
17167
+ function humanizeModel(model) {
17168
+ if (!model)
17169
+ return;
17170
+ return titleCaseWords(model.replace(/_/g, " "));
17171
+ }
17172
+ async function findAvdConfigPath(avdName) {
17173
+ const home = process.env.HOME ?? "";
17174
+ const roots = [join6(home, ".android/avd"), join6(home, "Library/Android/sdk/.android/avd")];
17175
+ for (const root of roots) {
17176
+ const direct = join6(root, `${avdName}.avd`, "config.ini");
17177
+ try {
17178
+ await readFile(direct, "utf8");
17179
+ return direct;
17180
+ } catch {}
17181
+ try {
17182
+ const glob = new Bun.Glob("*.avd/config.ini");
17183
+ for await (const match of glob.scan({ cwd: root, onlyFiles: true })) {
17184
+ const path = join6(root, match);
17185
+ const text2 = await readFile(path, "utf8");
17186
+ const idLine = text2.split(`
17187
+ `).find((line) => line.startsWith("AvdId="));
17188
+ const id = idLine?.slice("AvdId=".length).trim();
17189
+ if (id === avdName)
17190
+ return path;
17191
+ }
17192
+ } catch {}
17193
+ }
17194
+ return null;
17195
+ }
17196
+ async function readAvdConfig(avdName) {
17197
+ const path = await findAvdConfigPath(avdName);
17198
+ if (!path)
17199
+ return { api: null, deviceName: null };
17200
+ try {
17201
+ const text2 = await readFile(path, "utf8");
17202
+ const imageLine = text2.split(`
17203
+ `).find((line) => line.includes("system-images") && line.includes("android-"));
17204
+ const apiMatch = imageLine?.match(/android-([\d.]+)/);
17205
+ const deviceLine = text2.split(`
17206
+ `).find((entry) => entry.startsWith("hw.device.name="));
17207
+ const deviceRaw = deviceLine?.slice("hw.device.name=".length).trim();
17208
+ return {
17209
+ api: apiMatch?.[1] ?? null,
17210
+ deviceName: humanizeModel(deviceRaw) ?? null
17211
+ };
17212
+ } catch {
17213
+ return { api: null, deviceName: null };
17214
+ }
17215
+ }
17216
+ async function adbAvdName(adb, serial) {
17217
+ const fromProp = await adbGetProp(adb, serial, "ro.boot.qemu.avd_name") ?? await adbGetProp(adb, serial, "ro.kernel.qemu.avd_name");
17218
+ if (fromProp)
17219
+ return fromProp;
17220
+ const { stdout, exitCode } = await runCommand3([adb, "-s", serial, "emu", "avd", "name"]);
17221
+ if (exitCode !== 0)
17222
+ return null;
17223
+ return parseAdbEmuAvdName(stdout);
17224
+ }
17225
+ async function adbGetProp(adb, serial, prop) {
17226
+ const { stdout, exitCode } = await runCommand3([adb, "-s", serial, "shell", "getprop", prop]);
17227
+ if (exitCode !== 0)
17228
+ return null;
17229
+ const value = stdout.trim();
17230
+ return value || null;
17231
+ }
17232
+ async function listAndroidAdbDevices(adb) {
17233
+ const { stdout, exitCode } = await runCommand3([adb, "devices", "-l"]);
17234
+ if (exitCode !== 0) {
17235
+ return { physical: [], runningEmulators: [], runningAvdNames: new Set };
17236
+ }
17237
+ const entries = parseAdbDevices(stdout);
17238
+ const physical = [];
17239
+ const runningEmulators = [];
17240
+ const runningAvdNames = new Set;
17241
+ for (const entry of entries) {
17242
+ const isEmulator = entry.serial.startsWith("emulator-") || entry.product === "sdk_gphone";
17243
+ if (isEmulator) {
17244
+ const avdName = await adbAvdName(adb, entry.serial);
17245
+ if (avdName)
17246
+ runningAvdNames.add(avdName);
17247
+ const avdConfig = avdName ? await readAvdConfig(avdName) : null;
17248
+ const release2 = await adbGetProp(adb, entry.serial, "ro.build.version.release") ?? avdConfig?.api ?? null;
17249
+ const model2 = humanizeModel(entry.model) ?? avdConfig?.deviceName ?? (avdName ? humanizeAvdName(avdName) : entry.serial);
17250
+ runningEmulators.push({
17251
+ id: avdName ?? entry.serial,
17252
+ name: model2,
17253
+ osVersion: formatAndroidVersion(release2 ?? ""),
17254
+ platform: "android",
17255
+ kind: "emulator",
17256
+ state: entry.state === "device" ? "online" : entry.state,
17257
+ model: avdName ?? entry.model
17258
+ });
17259
+ continue;
17080
17260
  }
17261
+ const release = await adbGetProp(adb, entry.serial, "ro.build.version.release");
17262
+ const model = humanizeModel(entry.model) ?? await adbGetProp(adb, entry.serial, "ro.product.model") ?? entry.serial;
17263
+ physical.push({
17264
+ id: entry.serial,
17265
+ name: model,
17266
+ osVersion: formatAndroidVersion(release ?? ""),
17267
+ platform: "android",
17268
+ kind: "physical",
17269
+ state: entry.state,
17270
+ model: entry.model
17271
+ });
17081
17272
  }
17082
- return caps;
17273
+ return { physical, runningEmulators, runningAvdNames };
17083
17274
  }
17084
-
17085
- class ActionGate {
17086
- locked = false;
17087
- gesture = null;
17088
- deferredTap = null;
17089
- isPointerActive() {
17090
- return this.gesture != null || this.deferredTap != null;
17275
+ async function listAndroidAvds(emulatorBin, runningAvdNames, includeUnavailable) {
17276
+ const { stdout, exitCode } = await runCommand3([emulatorBin, "-list-avds"]);
17277
+ if (exitCode !== 0)
17278
+ return [];
17279
+ const names = stdout.split(`
17280
+ `).map((line) => line.trim()).filter(Boolean);
17281
+ const devices = [];
17282
+ for (const avdName of names) {
17283
+ const isRunning = runningAvdNames.has(avdName);
17284
+ if (!includeUnavailable && !isRunning)
17285
+ continue;
17286
+ if (isRunning)
17287
+ continue;
17288
+ const { api, deviceName } = await readAvdConfig(avdName);
17289
+ devices.push({
17290
+ id: avdName,
17291
+ name: deviceName ?? humanizeAvdName(avdName),
17292
+ osVersion: formatAndroidVersion(api ?? ""),
17293
+ platform: "android",
17294
+ kind: "emulator",
17295
+ state: "offline",
17296
+ model: avdName
17297
+ });
17091
17298
  }
17092
- async withLock(fn) {
17093
- if (this.gesture || this.deferredTap) {
17094
- throw new Error("Device is busy with live pointer control");
17299
+ return devices;
17300
+ }
17301
+ async function resolveAndroidAppiumIdentity(deviceId) {
17302
+ const [adb, emulatorBin] = await Promise.all([resolveAdb(), resolveEmulator()]);
17303
+ const connected = [];
17304
+ if (adb) {
17305
+ const { stdout, exitCode } = await runCommand3([adb, "devices", "-l"]);
17306
+ if (exitCode === 0) {
17307
+ for (const entry of parseAdbDevices(stdout)) {
17308
+ const isEmulator = entry.serial.startsWith("emulator-") || entry.product === "sdk_gphone";
17309
+ connected.push({
17310
+ serial: entry.serial,
17311
+ avdName: isEmulator ? await adbAvdName(adb, entry.serial) : null
17312
+ });
17313
+ }
17095
17314
  }
17096
- if (this.locked) {
17097
- throw new Error("Device is busy with another action");
17315
+ }
17316
+ const knownAvds = [];
17317
+ if (emulatorBin) {
17318
+ const { stdout, exitCode } = await runCommand3([emulatorBin, "-list-avds"]);
17319
+ if (exitCode === 0) {
17320
+ knownAvds.push(...stdout.split(`
17321
+ `).map((line) => line.trim()).filter(Boolean));
17098
17322
  }
17099
- this.locked = true;
17323
+ }
17324
+ return matchAndroidAppiumIdentity(deviceId, connected, knownAvds);
17325
+ }
17326
+ async function listAndroidDevices(options = {}) {
17327
+ const includeUnavailable = options.includeUnavailable ?? true;
17328
+ const [adb, emulatorBin] = await Promise.all([resolveAdb(), resolveEmulator()]);
17329
+ const adbResult = adb ? await listAndroidAdbDevices(adb) : { physical: [], runningEmulators: [], runningAvdNames: new Set };
17330
+ const offlineAvds = emulatorBin != null ? await listAndroidAvds(emulatorBin, adbResult.runningAvdNames, includeUnavailable) : [];
17331
+ const physical = includeUnavailable ? adbResult.physical : adbResult.physical.filter((device) => device.state === "device");
17332
+ const emulators = [...adbResult.runningEmulators, ...offlineAvds].sort((a, b) => {
17333
+ const aOnline = a.state === "online" ? 0 : 1;
17334
+ const bOnline = b.state === "online" ? 0 : 1;
17335
+ if (aOnline !== bOnline)
17336
+ return aOnline - bOnline;
17337
+ return a.name.localeCompare(b.name);
17338
+ });
17339
+ return [...physical, ...emulators];
17340
+ }
17341
+ async function listDevices(platform, options = {}) {
17342
+ if (platform === "ios")
17343
+ return listIosDevices(options);
17344
+ return listAndroidDevices(options);
17345
+ }
17346
+
17347
+ // src/domains/devices/session.ts
17348
+ var YOQA_ROOT4 = join7(homedir6(), ".yoqa");
17349
+ var DEFAULT_MJPEG_PORT = Number(process.env.YOQA_MJPEG_PORT ?? "9100");
17350
+ var SCREENSHOT_DIR = join7(YOQA_ROOT4, "runs", "screenshots");
17351
+ var MJPEG_SETTINGS_BASE = {
17352
+ mjpegServerScreenshotQuality: 35,
17353
+ mjpegScalingFactor: 50
17354
+ };
17355
+ var openByDeviceId = new Map;
17356
+ var exclusiveReleaseListener = null;
17357
+ function onDeviceSessionExclusiveRelease(listener) {
17358
+ exclusiveReleaseListener = listener;
17359
+ }
17360
+ var DEAD_SESSION_RE = /session does not exist|invalid session id|no such session|terminated or not started|session is either terminated/i;
17361
+
17362
+ class DeadSessionError extends Error {
17363
+ constructor(message = "Device session ended") {
17364
+ super(message);
17365
+ this.name = "DeadSessionError";
17366
+ }
17367
+ }
17368
+ function isDeadSessionError(error) {
17369
+ if (error instanceof DeadSessionError)
17370
+ return true;
17371
+ const message = error instanceof Error ? error.message : String(error);
17372
+ return DEAD_SESSION_RE.test(message);
17373
+ }
17374
+ function mjpegSettingsForDevice(options) {
17375
+ const physicalIos = options.platform === "ios" && looksLikePhysicalIosUdid(options.deviceId);
17376
+ if (physicalIos) {
17377
+ return {
17378
+ mjpegServerFramerate: 15,
17379
+ mjpegServerScreenshotQuality: 25,
17380
+ mjpegScalingFactor: 40
17381
+ };
17382
+ }
17383
+ return {
17384
+ ...MJPEG_SETTINGS_BASE,
17385
+ mjpegServerFramerate: 60
17386
+ };
17387
+ }
17388
+ function isPortFree2(port) {
17389
+ return new Promise((resolve) => {
17390
+ const server = createServer2();
17391
+ server.unref();
17392
+ server.once("error", () => resolve(false));
17393
+ server.listen(port, APPIUM_HOST, () => {
17394
+ server.close(() => resolve(true));
17395
+ });
17396
+ });
17397
+ }
17398
+ async function pickMjpegPort() {
17399
+ if (await isPortFree2(DEFAULT_MJPEG_PORT))
17400
+ return DEFAULT_MJPEG_PORT;
17401
+ for (let offset = 1;offset <= 40; offset++) {
17402
+ const candidate = DEFAULT_MJPEG_PORT + offset;
17403
+ if (await isPortFree2(candidate))
17404
+ return candidate;
17405
+ }
17406
+ throw new Error(`No free MJPEG port near ${DEFAULT_MJPEG_PORT}. Quit other streams or set YOQA_MJPEG_PORT.`);
17407
+ }
17408
+ function mjpegUpstreamUrl(mjpegPort) {
17409
+ return `http://127.0.0.1:${mjpegPort}/`;
17410
+ }
17411
+ async function probeMjpegStream(mjpegPort, timeoutMs = 4000) {
17412
+ const deadline = Date.now() + timeoutMs;
17413
+ while (Date.now() < deadline) {
17414
+ const controller = new AbortController;
17415
+ const timer = setTimeout(() => controller.abort(), 800);
17416
+ try {
17417
+ const response = await fetch(mjpegUpstreamUrl(mjpegPort), {
17418
+ signal: controller.signal,
17419
+ headers: { Accept: "multipart/x-mixed-replace,image/jpeg,*/*" }
17420
+ });
17421
+ if (response.ok) {
17422
+ controller.abort();
17423
+ return true;
17424
+ }
17425
+ } catch {} finally {
17426
+ clearTimeout(timer);
17427
+ }
17428
+ await Bun.sleep(250);
17429
+ }
17430
+ return false;
17431
+ }
17432
+ async function applyMjpegSettings(browser, options) {
17433
+ try {
17434
+ await browser.updateSettings(mjpegSettingsForDevice(options));
17435
+ } catch (error) {
17436
+ console.warn("[yoqa-runner] MJPEG settings update failed:", error instanceof Error ? error.message : error);
17437
+ }
17438
+ }
17439
+ async function releaseExistingSession(deviceId) {
17440
+ const existing = openByDeviceId.get(deviceId);
17441
+ if (!existing)
17442
+ return;
17443
+ openByDeviceId.delete(deviceId);
17444
+ exclusiveReleaseListener?.(deviceId);
17445
+ try {
17446
+ await existing.quit();
17447
+ } catch (error) {
17448
+ console.warn("[yoqa-runner] quit prior Device Session for exclusivity:", error instanceof Error ? error.message : error);
17449
+ }
17450
+ }
17451
+ function capabilitiesToRecord(caps) {
17452
+ const out = {};
17453
+ for (const cap of caps) {
17454
+ const key = cap.key.trim();
17455
+ if (!key)
17456
+ continue;
17457
+ out[key] = cap.value;
17458
+ }
17459
+ return out;
17460
+ }
17461
+ function mergeCapabilities(appCaps, caseCaps) {
17462
+ return {
17463
+ ...capabilitiesToRecord(appCaps),
17464
+ ...capabilitiesToRecord(caseCaps)
17465
+ };
17466
+ }
17467
+ function looksLikePhysicalIosUdid(udid) {
17468
+ return /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16}$/.test(udid.trim());
17469
+ }
17470
+ var PHYSICAL_IOS_SESSION_TIMEOUT_MS = 60000;
17471
+ var SIMULATOR_WDA_SESSION_TIMEOUT_MS = 600000;
17472
+ var SIMULATOR_WDA_DERIVED_DATA = join7(YOQA_ROOT4, "wda-sim");
17473
+ function iosSessionCreateTimeoutMs(deviceId) {
17474
+ return looksLikePhysicalIosUdid(deviceId) ? PHYSICAL_IOS_SESSION_TIMEOUT_MS : SIMULATOR_WDA_SESSION_TIMEOUT_MS;
17475
+ }
17476
+ async function buildW3cCapabilities(options, mjpegPort) {
17477
+ const merged = mergeCapabilities(options.appCaps, options.caseCaps);
17478
+ const platformName = options.platform === "ios" ? "iOS" : "Android";
17479
+ const automationName = options.platform === "ios" ? "XCUITest" : "UiAutomator2";
17480
+ const caps = {
17481
+ platformName,
17482
+ "appium:automationName": automationName,
17483
+ "appium:newCommandTimeout": 3600,
17484
+ "appium:mjpegServerPort": mjpegPort,
17485
+ ...Object.fromEntries(Object.entries(merged).map(([key, value]) => key.includes(":") ? [key, value] : [`appium:${key}`, value]))
17486
+ };
17487
+ if (options.platform === "ios") {
17488
+ if (caps["appium:udid"] === undefined) {
17489
+ caps["appium:udid"] = options.deviceId;
17490
+ }
17491
+ if (options.bundleId && !caps["appium:bundleId"]) {
17492
+ caps["appium:bundleId"] = options.bundleId;
17493
+ }
17494
+ const physical = looksLikePhysicalIosUdid(options.deviceId);
17495
+ if (physical) {
17496
+ const prep = await loadDevicePrep(options.deviceId);
17497
+ if (!prep) {
17498
+ throw new Error(`iOS device ${options.deviceId} is not prepared. Run device setup so WebDriverAgent is installed before starting a run.`);
17499
+ }
17500
+ if (caps["appium:usePreinstalledWDA"] === undefined) {
17501
+ caps["appium:usePreinstalledWDA"] = true;
17502
+ }
17503
+ if (!caps["appium:updatedWDABundleId"]) {
17504
+ caps["appium:updatedWDABundleId"] = prep.bundleId;
17505
+ }
17506
+ if (!caps["appium:xcodeOrgId"]) {
17507
+ caps["appium:xcodeOrgId"] = prep.developmentTeam;
17508
+ }
17509
+ if (!caps["appium:xcodeSigningId"]) {
17510
+ caps["appium:xcodeSigningId"] = "Apple Development";
17511
+ }
17512
+ }
17513
+ const wdaTimeoutMs = iosSessionCreateTimeoutMs(options.deviceId);
17514
+ if (caps["appium:wdaLaunchTimeout"] === undefined) {
17515
+ caps["appium:wdaLaunchTimeout"] = wdaTimeoutMs;
17516
+ }
17517
+ if (caps["appium:wdaConnectionTimeout"] === undefined) {
17518
+ caps["appium:wdaConnectionTimeout"] = wdaTimeoutMs;
17519
+ }
17520
+ if (caps["appium:waitForIdleTimeout"] === undefined) {
17521
+ caps["appium:waitForIdleTimeout"] = 0;
17522
+ }
17523
+ if (!physical && caps["appium:derivedDataPath"] === undefined) {
17524
+ await mkdir3(SIMULATOR_WDA_DERIVED_DATA, { recursive: true });
17525
+ caps["appium:derivedDataPath"] = SIMULATOR_WDA_DERIVED_DATA;
17526
+ }
17527
+ }
17528
+ if (options.platform === "android") {
17529
+ const requested = String(caps["appium:udid"] ?? options.deviceId);
17530
+ const identity = await resolveAndroidAppiumIdentity(requested);
17531
+ if (identity.udid) {
17532
+ caps["appium:udid"] = identity.udid;
17533
+ }
17534
+ if (identity.avd && caps["appium:avd"] === undefined) {
17535
+ caps["appium:avd"] = identity.avd;
17536
+ }
17537
+ if (options.appPackage && !caps["appium:appPackage"]) {
17538
+ caps["appium:appPackage"] = options.appPackage;
17539
+ }
17540
+ }
17541
+ return caps;
17542
+ }
17543
+
17544
+ class ActionGate {
17545
+ locked = false;
17546
+ gesture = null;
17547
+ deferredTap = null;
17548
+ isPointerActive() {
17549
+ return this.gesture != null || this.deferredTap != null;
17550
+ }
17551
+ async withLock(fn) {
17552
+ if (this.gesture || this.deferredTap) {
17553
+ throw new Error("Device is busy with live pointer control");
17554
+ }
17555
+ if (this.locked) {
17556
+ throw new Error("Device is busy with another action");
17557
+ }
17558
+ this.locked = true;
17100
17559
  try {
17101
17560
  return await fn();
17102
17561
  } finally {
@@ -17369,7 +17828,7 @@ async function createDeviceSession(options) {
17369
17828
  const screenshot = async () => {
17370
17829
  await mkdir3(SCREENSHOT_DIR, { recursive: true });
17371
17830
  const frame = await captureFrame();
17372
- const path = join6(SCREENSHOT_DIR, `shot_${Date.now()}_${crypto.randomUUID()}.png`);
17831
+ const path = join7(SCREENSHOT_DIR, `shot_${Date.now()}_${crypto.randomUUID()}.png`);
17373
17832
  await Bun.write(path, Uint8Array.from(Buffer.from(frame.base64, "base64")));
17374
17833
  return { path, base64: frame.base64 };
17375
17834
  };
@@ -24193,8 +24652,13 @@ async function installBuildOnDevice(options) {
24193
24652
  }
24194
24653
  throw new Error(`Cannot install iOS build at ${build.path}. Use a .app on simulator or install .ipa via ideviceinstaller.`);
24195
24654
  }
24655
+ const identity = await resolveAndroidAppiumIdentity(deviceId);
24656
+ const serial = identity.udid;
24657
+ if (!serial) {
24658
+ throw new Error(`Android emulator ${deviceId} is not running. Boot it, then retry the install.`);
24659
+ }
24196
24660
  const adb = Bun.which("adb") ?? "adb";
24197
- const proc = Bun.spawn([adb, "-s", deviceId, "install", "-r", build.path], {
24661
+ const proc = Bun.spawn([adb, "-s", serial, "install", "-r", build.path], {
24198
24662
  stdout: "pipe",
24199
24663
  stderr: "pipe"
24200
24664
  });
@@ -25017,397 +25481,6 @@ function createCatalogRoutes() {
25017
25481
  return app;
25018
25482
  }
25019
25483
 
25020
- // src/domains/devices/application.ts
25021
- import { mkdtemp as mkdtemp2, readFile, rm as rm2 } from "fs/promises";
25022
- import { tmpdir as tmpdir2 } from "os";
25023
- import { join as join7 } from "path";
25024
- async function runCommand3(command, options) {
25025
- try {
25026
- const proc = Bun.spawn(command, {
25027
- env: options?.env ? { ...process.env, ...options.env } : process.env,
25028
- stdout: "pipe",
25029
- stderr: "pipe"
25030
- });
25031
- const [stdout, stderr, exitCode] = await Promise.all([
25032
- new Response(proc.stdout).text(),
25033
- new Response(proc.stderr).text(),
25034
- proc.exited
25035
- ]);
25036
- return { stdout, stderr, exitCode };
25037
- } catch {
25038
- return { stdout: "", stderr: `failed to spawn: ${command[0]}`, exitCode: 127 };
25039
- }
25040
- }
25041
- function androidSdkRoot() {
25042
- return resolveAndroidSdkRoot();
25043
- }
25044
- async function resolveTool(candidates) {
25045
- for (const candidate of candidates) {
25046
- if (!candidate)
25047
- continue;
25048
- const { exitCode } = await runCommand3(["test", "-x", candidate]);
25049
- if (exitCode === 0)
25050
- return candidate;
25051
- }
25052
- return null;
25053
- }
25054
- async function resolveAdb() {
25055
- const sdk = androidSdkRoot();
25056
- return resolveTool([...sdk ? [join7(sdk, "platform-tools/adb")] : [], "adb"]);
25057
- }
25058
- async function resolveEmulator() {
25059
- const sdk = androidSdkRoot();
25060
- return resolveTool([...sdk ? [join7(sdk, "emulator/emulator")] : [], "emulator"]);
25061
- }
25062
- function formatIosVersion(version2) {
25063
- const trimmed = version2.trim();
25064
- if (!trimmed)
25065
- return "iOS";
25066
- if (/^iOS\b/i.test(trimmed))
25067
- return trimmed;
25068
- return `iOS ${trimmed}`;
25069
- }
25070
- function formatAndroidVersion(apiOrVersion) {
25071
- const trimmed = apiOrVersion.trim();
25072
- if (!trimmed)
25073
- return "Android";
25074
- if (/^Android\b/i.test(trimmed))
25075
- return trimmed;
25076
- if (/^API\b/i.test(trimmed))
25077
- return trimmed;
25078
- if (/^\d+(\.\d+)?$/.test(trimmed))
25079
- return `API ${trimmed}`;
25080
- return `Android ${trimmed}`;
25081
- }
25082
- function titleCaseWords(value) {
25083
- return value.split(/\s+/).filter(Boolean).map((word) => {
25084
- if (/^[a-z]$/i.test(word))
25085
- return word.toUpperCase();
25086
- if (/^\d/.test(word))
25087
- return word;
25088
- return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
25089
- }).join(" ");
25090
- }
25091
- function runtimeVersionFromIdentifier(runtimeId) {
25092
- const match2 = runtimeId.match(/iOS-([\d-]+)$/i);
25093
- if (!match2?.[1])
25094
- return "iOS";
25095
- return formatIosVersion(match2[1].replace(/-/g, "."));
25096
- }
25097
- async function listIosSimulators(includeUnavailable) {
25098
- const { stdout, exitCode } = await runCommand3([
25099
- "xcrun",
25100
- "simctl",
25101
- "list",
25102
- "devices",
25103
- "available",
25104
- "-j"
25105
- ]);
25106
- if (exitCode !== 0)
25107
- return [];
25108
- let parsed;
25109
- try {
25110
- parsed = JSON.parse(stdout);
25111
- } catch {
25112
- return [];
25113
- }
25114
- const devices = [];
25115
- for (const [runtimeId, runtimeDevices] of Object.entries(parsed.devices ?? {})) {
25116
- if (!/iOS/i.test(runtimeId))
25117
- continue;
25118
- const osVersion = runtimeVersionFromIdentifier(runtimeId);
25119
- for (const device of runtimeDevices) {
25120
- if (!device.udid || !device.name)
25121
- continue;
25122
- if (device.isAvailable === false)
25123
- continue;
25124
- const state = device.state ?? "Unknown";
25125
- if (!includeUnavailable && state !== "Booted")
25126
- continue;
25127
- devices.push({
25128
- id: device.udid,
25129
- name: device.name,
25130
- osVersion,
25131
- platform: "ios",
25132
- kind: "simulator",
25133
- state,
25134
- model: device.deviceTypeIdentifier?.split(".").pop()?.replace(/-/g, " ")
25135
- });
25136
- }
25137
- }
25138
- return devices.sort((a, b) => {
25139
- if (a.state === "Booted" && b.state !== "Booted")
25140
- return -1;
25141
- if (b.state === "Booted" && a.state !== "Booted")
25142
- return 1;
25143
- return a.name.localeCompare(b.name);
25144
- });
25145
- }
25146
- function isPhoneOrTablet(deviceType) {
25147
- if (!deviceType)
25148
- return false;
25149
- return /^(iPhone|iPad)$/i.test(deviceType);
25150
- }
25151
- function physicalIosState(connection) {
25152
- const tunnel = connection?.tunnelState?.toLowerCase() ?? "";
25153
- if (tunnel.includes("connected"))
25154
- return "connected";
25155
- if (tunnel.includes("unavailable"))
25156
- return "unavailable";
25157
- if (tunnel.includes("disconnected"))
25158
- return "disconnected";
25159
- if (connection?.pairingState === "paired")
25160
- return "paired";
25161
- return tunnel || connection?.pairingState || "unknown";
25162
- }
25163
- async function listIosPhysicalDevices(includeUnavailable) {
25164
- const dir = await mkdtemp2(join7(tmpdir2(), "yoqa-devices-"));
25165
- const jsonPath = join7(dir, "devices.json");
25166
- try {
25167
- const { exitCode } = await runCommand3([
25168
- "xcrun",
25169
- "devicectl",
25170
- "list",
25171
- "devices",
25172
- "--json-output",
25173
- jsonPath
25174
- ]);
25175
- if (exitCode !== 0)
25176
- return [];
25177
- let parsed;
25178
- try {
25179
- parsed = JSON.parse(await readFile(jsonPath, "utf8"));
25180
- } catch {
25181
- return [];
25182
- }
25183
- const devices = [];
25184
- for (const item of parsed.result?.devices ?? []) {
25185
- const hardware = item.hardwareProperties;
25186
- const props = item.deviceProperties;
25187
- if (!hardware?.udid)
25188
- continue;
25189
- if (hardware.reality && hardware.reality !== "physical")
25190
- continue;
25191
- if (!isPhoneOrTablet(hardware.deviceType))
25192
- continue;
25193
- if (hardware.platform && !/^iOS$/i.test(hardware.platform))
25194
- continue;
25195
- const state = physicalIosState(item.connectionProperties);
25196
- if (!includeUnavailable && state === "unavailable")
25197
- continue;
25198
- const modelName = hardware.marketingName ?? hardware.productType ?? "iPhone";
25199
- const owner = props?.name?.trim() || undefined;
25200
- devices.push({
25201
- id: hardware.udid,
25202
- name: modelName,
25203
- owner,
25204
- osVersion: formatIosVersion(props?.osVersionNumber ?? ""),
25205
- platform: "ios",
25206
- kind: "physical",
25207
- state,
25208
- model: hardware.productType
25209
- });
25210
- }
25211
- return devices.sort((a, b) => a.name.localeCompare(b.name));
25212
- } finally {
25213
- await rm2(dir, { recursive: true, force: true });
25214
- }
25215
- }
25216
- async function listIosDevices(options = {}) {
25217
- const includeUnavailable = options.includeUnavailable ?? true;
25218
- const [physical, simulators] = await Promise.all([
25219
- listIosPhysicalDevices(includeUnavailable),
25220
- listIosSimulators(includeUnavailable)
25221
- ]);
25222
- return [...physical, ...simulators];
25223
- }
25224
- function parseAdbDevices(output) {
25225
- const lines = output.split(`
25226
- `).map((line) => line.trim()).filter(Boolean);
25227
- const results = [];
25228
- for (const line of lines) {
25229
- if (line.startsWith("List of devices"))
25230
- continue;
25231
- const parts = line.split(/\s+/);
25232
- const serial = parts[0];
25233
- const state = parts[1];
25234
- if (!serial || !state)
25235
- continue;
25236
- const meta = {};
25237
- for (const part of parts.slice(2)) {
25238
- const eq2 = part.indexOf(":");
25239
- if (eq2 <= 0)
25240
- continue;
25241
- meta[part.slice(0, eq2)] = part.slice(eq2 + 1);
25242
- }
25243
- results.push({
25244
- serial,
25245
- state,
25246
- product: meta.product,
25247
- model: meta.model,
25248
- device: meta.device,
25249
- transportId: meta.transport_id
25250
- });
25251
- }
25252
- return results;
25253
- }
25254
- function humanizeAvdName(avdName) {
25255
- return titleCaseWords(avdName.replace(/_/g, " "));
25256
- }
25257
- function humanizeModel(model) {
25258
- if (!model)
25259
- return;
25260
- return titleCaseWords(model.replace(/_/g, " "));
25261
- }
25262
- async function findAvdConfigPath(avdName) {
25263
- const home = process.env.HOME ?? "";
25264
- const roots = [join7(home, ".android/avd"), join7(home, "Library/Android/sdk/.android/avd")];
25265
- for (const root of roots) {
25266
- const direct = join7(root, `${avdName}.avd`, "config.ini");
25267
- try {
25268
- await readFile(direct, "utf8");
25269
- return direct;
25270
- } catch {}
25271
- try {
25272
- const glob = new Bun.Glob("*.avd/config.ini");
25273
- for await (const match2 of glob.scan({ cwd: root, onlyFiles: true })) {
25274
- const path = join7(root, match2);
25275
- const text2 = await readFile(path, "utf8");
25276
- const idLine = text2.split(`
25277
- `).find((line) => line.startsWith("AvdId="));
25278
- const id = idLine?.slice("AvdId=".length).trim();
25279
- if (id === avdName)
25280
- return path;
25281
- }
25282
- } catch {}
25283
- }
25284
- return null;
25285
- }
25286
- async function readAvdConfig(avdName) {
25287
- const path = await findAvdConfigPath(avdName);
25288
- if (!path)
25289
- return { api: null, deviceName: null };
25290
- try {
25291
- const text2 = await readFile(path, "utf8");
25292
- const imageLine = text2.split(`
25293
- `).find((line) => line.includes("system-images") && line.includes("android-"));
25294
- const apiMatch = imageLine?.match(/android-([\d.]+)/);
25295
- const deviceLine = text2.split(`
25296
- `).find((entry) => entry.startsWith("hw.device.name="));
25297
- const deviceRaw = deviceLine?.slice("hw.device.name=".length).trim();
25298
- return {
25299
- api: apiMatch?.[1] ?? null,
25300
- deviceName: humanizeModel(deviceRaw) ?? null
25301
- };
25302
- } catch {
25303
- return { api: null, deviceName: null };
25304
- }
25305
- }
25306
- async function adbAvdName(adb, serial) {
25307
- const { stdout, exitCode } = await runCommand3([adb, "-s", serial, "emu", "avd", "name"]);
25308
- if (exitCode !== 0)
25309
- return null;
25310
- const name = stdout.trim().split(`
25311
- `)[0]?.trim();
25312
- return name || null;
25313
- }
25314
- async function adbGetProp(adb, serial, prop) {
25315
- const { stdout, exitCode } = await runCommand3([adb, "-s", serial, "shell", "getprop", prop]);
25316
- if (exitCode !== 0)
25317
- return null;
25318
- const value = stdout.trim();
25319
- return value || null;
25320
- }
25321
- async function listAndroidAdbDevices(adb) {
25322
- const { stdout, exitCode } = await runCommand3([adb, "devices", "-l"]);
25323
- if (exitCode !== 0) {
25324
- return { physical: [], runningEmulators: [], runningAvdNames: new Set };
25325
- }
25326
- const entries = parseAdbDevices(stdout);
25327
- const physical = [];
25328
- const runningEmulators = [];
25329
- const runningAvdNames = new Set;
25330
- for (const entry of entries) {
25331
- const isEmulator = entry.serial.startsWith("emulator-") || entry.product === "sdk_gphone";
25332
- if (isEmulator) {
25333
- const avdName = await adbAvdName(adb, entry.serial);
25334
- if (avdName)
25335
- runningAvdNames.add(avdName);
25336
- const avdConfig = avdName ? await readAvdConfig(avdName) : null;
25337
- const release2 = await adbGetProp(adb, entry.serial, "ro.build.version.release") ?? avdConfig?.api ?? null;
25338
- const model2 = humanizeModel(entry.model) ?? avdConfig?.deviceName ?? (avdName ? humanizeAvdName(avdName) : entry.serial);
25339
- runningEmulators.push({
25340
- id: avdName ?? entry.serial,
25341
- name: model2,
25342
- osVersion: formatAndroidVersion(release2 ?? ""),
25343
- platform: "android",
25344
- kind: "emulator",
25345
- state: entry.state === "device" ? "online" : entry.state,
25346
- model: avdName ?? entry.model
25347
- });
25348
- continue;
25349
- }
25350
- const release = await adbGetProp(adb, entry.serial, "ro.build.version.release");
25351
- const model = humanizeModel(entry.model) ?? await adbGetProp(adb, entry.serial, "ro.product.model") ?? entry.serial;
25352
- physical.push({
25353
- id: entry.serial,
25354
- name: model,
25355
- osVersion: formatAndroidVersion(release ?? ""),
25356
- platform: "android",
25357
- kind: "physical",
25358
- state: entry.state,
25359
- model: entry.model
25360
- });
25361
- }
25362
- return { physical, runningEmulators, runningAvdNames };
25363
- }
25364
- async function listAndroidAvds(emulatorBin, runningAvdNames, includeUnavailable) {
25365
- const { stdout, exitCode } = await runCommand3([emulatorBin, "-list-avds"]);
25366
- if (exitCode !== 0)
25367
- return [];
25368
- const names = stdout.split(`
25369
- `).map((line) => line.trim()).filter(Boolean);
25370
- const devices = [];
25371
- for (const avdName of names) {
25372
- const isRunning = runningAvdNames.has(avdName);
25373
- if (!includeUnavailable && !isRunning)
25374
- continue;
25375
- if (isRunning)
25376
- continue;
25377
- const { api, deviceName } = await readAvdConfig(avdName);
25378
- devices.push({
25379
- id: avdName,
25380
- name: deviceName ?? humanizeAvdName(avdName),
25381
- osVersion: formatAndroidVersion(api ?? ""),
25382
- platform: "android",
25383
- kind: "emulator",
25384
- state: "offline",
25385
- model: avdName
25386
- });
25387
- }
25388
- return devices;
25389
- }
25390
- async function listAndroidDevices(options = {}) {
25391
- const includeUnavailable = options.includeUnavailable ?? true;
25392
- const [adb, emulatorBin] = await Promise.all([resolveAdb(), resolveEmulator()]);
25393
- const adbResult = adb ? await listAndroidAdbDevices(adb) : { physical: [], runningEmulators: [], runningAvdNames: new Set };
25394
- const offlineAvds = emulatorBin != null ? await listAndroidAvds(emulatorBin, adbResult.runningAvdNames, includeUnavailable) : [];
25395
- const physical = includeUnavailable ? adbResult.physical : adbResult.physical.filter((device) => device.state === "device");
25396
- const emulators = [...adbResult.runningEmulators, ...offlineAvds].sort((a, b) => {
25397
- const aOnline = a.state === "online" ? 0 : 1;
25398
- const bOnline = b.state === "online" ? 0 : 1;
25399
- if (aOnline !== bOnline)
25400
- return aOnline - bOnline;
25401
- return a.name.localeCompare(b.name);
25402
- });
25403
- return [...physical, ...emulators];
25404
- }
25405
- async function listDevices(platform, options = {}) {
25406
- if (platform === "ios")
25407
- return listIosDevices(options);
25408
- return listAndroidDevices(options);
25409
- }
25410
-
25411
25484
  // src/interfaces/http/devices.ts
25412
25485
  function createDevicesRoutes() {
25413
25486
  const app = new Hono2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoqa/runner",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
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",