@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.
- package/dist/index.js +638 -565
- 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.
|
|
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
|
|
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
|
-
|
|
16916
|
-
|
|
16917
|
-
|
|
16918
|
-
|
|
16919
|
-
|
|
16920
|
-
|
|
16921
|
-
function
|
|
16922
|
-
|
|
16923
|
-
|
|
16924
|
-
|
|
16925
|
-
|
|
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
|
-
|
|
16932
|
-
|
|
16933
|
-
mjpegScalingFactor: 40
|
|
16915
|
+
udid: bySerial.serial,
|
|
16916
|
+
...bySerial.avdName ? { avd: bySerial.avdName } : {}
|
|
16934
16917
|
};
|
|
16935
16918
|
}
|
|
16936
|
-
|
|
16937
|
-
|
|
16938
|
-
|
|
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
|
|
16942
|
-
|
|
16943
|
-
|
|
16944
|
-
|
|
16945
|
-
|
|
16946
|
-
|
|
16947
|
-
|
|
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
|
-
|
|
16952
|
-
|
|
16953
|
-
|
|
16954
|
-
|
|
16955
|
-
|
|
16956
|
-
if (
|
|
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
|
-
|
|
16962
|
+
return null;
|
|
16960
16963
|
}
|
|
16961
|
-
function
|
|
16962
|
-
|
|
16964
|
+
async function resolveAdb() {
|
|
16965
|
+
const sdk = androidSdkRoot();
|
|
16966
|
+
return resolveTool([...sdk ? [join6(sdk, "platform-tools/adb")] : [], "adb"]);
|
|
16963
16967
|
}
|
|
16964
|
-
async function
|
|
16965
|
-
const
|
|
16966
|
-
|
|
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
|
-
|
|
16986
|
-
|
|
16987
|
-
|
|
16988
|
-
|
|
16989
|
-
|
|
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
|
-
|
|
16993
|
-
const
|
|
16994
|
-
if (!
|
|
16995
|
-
return;
|
|
16996
|
-
|
|
16997
|
-
|
|
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
|
-
|
|
17000
|
-
} catch
|
|
17001
|
-
|
|
17020
|
+
parsed = JSON.parse(stdout);
|
|
17021
|
+
} catch {
|
|
17022
|
+
return [];
|
|
17002
17023
|
}
|
|
17003
|
-
|
|
17004
|
-
|
|
17005
|
-
|
|
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
|
-
|
|
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
|
|
17013
|
-
|
|
17014
|
-
|
|
17015
|
-
|
|
17016
|
-
|
|
17017
|
-
|
|
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
|
|
17021
|
-
|
|
17056
|
+
function isPhoneOrTablet(deviceType) {
|
|
17057
|
+
if (!deviceType)
|
|
17058
|
+
return false;
|
|
17059
|
+
return /^(iPhone|iPad)$/i.test(deviceType);
|
|
17022
17060
|
}
|
|
17023
|
-
|
|
17024
|
-
|
|
17025
|
-
|
|
17026
|
-
|
|
17027
|
-
|
|
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
|
|
17030
|
-
const
|
|
17031
|
-
const
|
|
17032
|
-
|
|
17033
|
-
|
|
17034
|
-
|
|
17035
|
-
|
|
17036
|
-
|
|
17037
|
-
|
|
17038
|
-
|
|
17039
|
-
|
|
17040
|
-
|
|
17041
|
-
|
|
17042
|
-
|
|
17043
|
-
|
|
17044
|
-
|
|
17045
|
-
|
|
17046
|
-
|
|
17047
|
-
|
|
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
|
-
|
|
17072
|
-
|
|
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
|
-
|
|
17075
|
-
|
|
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
|
-
|
|
17078
|
-
|
|
17079
|
-
|
|
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
|
|
17273
|
+
return { physical, runningEmulators, runningAvdNames };
|
|
17083
17274
|
}
|
|
17084
|
-
|
|
17085
|
-
|
|
17086
|
-
|
|
17087
|
-
|
|
17088
|
-
|
|
17089
|
-
|
|
17090
|
-
|
|
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
|
-
|
|
17093
|
-
|
|
17094
|
-
|
|
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
|
-
|
|
17097
|
-
|
|
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
|
-
|
|
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 =
|
|
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",
|
|
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