@autifyhq/aximo-cli 0.4.0 → 0.6.0
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/README.md +219 -38
- package/dist/cli.js +5 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +18 -0
- package/dist/commands/device/device-config.d.ts +14 -0
- package/dist/commands/device/device-config.js +46 -0
- package/dist/commands/device/device-config.test.d.ts +1 -0
- package/dist/commands/device/device-config.test.js +56 -0
- package/dist/commands/device/doctor.d.ts +13 -0
- package/dist/commands/device/doctor.js +142 -25
- package/dist/commands/device/doctor.test.js +61 -1
- package/dist/commands/device/register.d.ts +2 -0
- package/dist/commands/device/register.js +50 -32
- package/dist/commands/device/register.test.d.ts +1 -0
- package/dist/commands/device/register.test.js +95 -0
- package/dist/commands/device/unregister.d.ts +5 -3
- package/dist/commands/device/unregister.js +32 -33
- package/dist/commands/device/unregister.test.d.ts +1 -0
- package/dist/commands/device/unregister.test.js +73 -0
- package/dist/commands/mobile/connect-core.d.ts +12 -0
- package/dist/commands/mobile/connect-core.js +88 -0
- package/dist/commands/mobile/connect.d.ts +9 -0
- package/dist/commands/mobile/connect.js +35 -0
- package/dist/commands/mobile/connect.test.d.ts +1 -0
- package/dist/commands/mobile/connect.test.js +87 -0
- package/dist/commands/mobile/doctor.d.ts +28 -0
- package/dist/commands/mobile/doctor.js +162 -0
- package/dist/commands/mobile/index.d.ts +2 -0
- package/dist/commands/mobile/index.js +16 -0
- package/dist/commands/mobile/install.d.ts +43 -0
- package/dist/commands/mobile/install.js +76 -0
- package/dist/commands/mobile/install.test.d.ts +1 -0
- package/dist/commands/mobile/install.test.js +104 -0
- package/dist/commands/mobile/list.d.ts +10 -0
- package/dist/commands/mobile/list.js +30 -0
- package/dist/commands/mobile/paths.d.ts +14 -0
- package/dist/commands/mobile/paths.js +71 -0
- package/dist/commands/mobile/register-core.d.ts +19 -0
- package/dist/commands/mobile/register-core.js +46 -0
- package/dist/commands/mobile/register.d.ts +37 -0
- package/dist/commands/mobile/register.js +57 -0
- package/dist/commands/mobile/register.test.d.ts +1 -0
- package/dist/commands/mobile/register.test.js +91 -0
- package/dist/commands/mobile/setup.d.ts +27 -0
- package/dist/commands/mobile/setup.js +100 -0
- package/dist/commands/mobile/setup.test.d.ts +1 -0
- package/dist/commands/mobile/setup.test.js +49 -0
- package/dist/commands/mobile/unregister.d.ts +14 -0
- package/dist/commands/mobile/unregister.js +37 -0
- package/dist/commands/schema.js +29 -0
- package/dist/commands/session/create.d.ts +5 -0
- package/dist/commands/session/create.js +6 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mobile-control/app-install.d.ts +21 -0
- package/dist/mobile-control/app-install.js +28 -0
- package/dist/mobile-control/app-install.test.d.ts +1 -0
- package/dist/mobile-control/app-install.test.js +78 -0
- package/dist/mobile-control/appium-controller.d.ts +42 -0
- package/dist/mobile-control/appium-controller.js +372 -0
- package/dist/mobile-control/appium-controller.test.d.ts +1 -0
- package/dist/mobile-control/appium-controller.test.js +252 -0
- package/dist/mobile-control/appium-import.d.ts +7 -0
- package/dist/mobile-control/appium-import.js +6 -0
- package/dist/mobile-control/appium-import.test.d.ts +1 -0
- package/dist/mobile-control/appium-import.test.js +9 -0
- package/dist/mobile-control/appium-server.d.ts +30 -0
- package/dist/mobile-control/appium-server.js +110 -0
- package/dist/mobile-control/appium-server.test.d.ts +1 -0
- package/dist/mobile-control/appium-server.test.js +108 -0
- package/dist/mobile-control/controller.d.ts +22 -0
- package/dist/mobile-control/controller.js +1 -0
- package/dist/mobile-control/devices.d.ts +23 -0
- package/dist/mobile-control/devices.js +129 -0
- package/dist/mobile-control/devices.test.d.ts +1 -0
- package/dist/mobile-control/devices.test.js +75 -0
- package/dist/mobile-control/exec.d.ts +6 -0
- package/dist/mobile-control/exec.js +13 -0
- package/dist/mobile-control/ios-signing.d.ts +28 -0
- package/dist/mobile-control/ios-signing.js +38 -0
- package/dist/mobile-control/ios-signing.test.d.ts +1 -0
- package/dist/mobile-control/ios-signing.test.js +73 -0
- package/dist/mobile-control/tunnel.d.ts +15 -0
- package/dist/mobile-control/tunnel.js +165 -0
- package/dist/mobile-control/tunnel.test.d.ts +1 -0
- package/dist/mobile-control/tunnel.test.js +155 -0
- package/dist/mobile-control/webdriver.d.ts +31 -0
- package/dist/mobile-control/webdriver.js +137 -0
- package/dist/mobile-control/webdriver.test.d.ts +1 -0
- package/dist/mobile-control/webdriver.test.js +45 -0
- package/dist/proxy.d.ts +11 -0
- package/dist/proxy.js +85 -0
- package/dist/proxy.test.d.ts +1 -0
- package/dist/proxy.test.js +72 -0
- package/dist/types.d.ts +1 -0
- package/package.json +5 -2
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { homedir, platform } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getAximoDir } from "../commands/device/paths.js";
|
|
6
|
+
import { importAppium } from "./appium-import.js";
|
|
7
|
+
export function getAppiumHome() {
|
|
8
|
+
return join(getAximoDir(), "appium-home");
|
|
9
|
+
}
|
|
10
|
+
function isValidSdk(dir, exists) {
|
|
11
|
+
return exists(dir) && exists(join(dir, "platform-tools"));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the Android SDK location. Honours ANDROID_HOME / ANDROID_SDK_ROOT when
|
|
15
|
+
* set and valid, else probes the standard per-OS install location. Appium's
|
|
16
|
+
* UiAutomator2 driver requires this even when `adb` is already on PATH.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveAndroidSdk(deps = {}) {
|
|
19
|
+
const env = deps.env ?? process.env;
|
|
20
|
+
const home = deps.home ?? homedir();
|
|
21
|
+
const os = deps.platform ?? platform();
|
|
22
|
+
const exists = deps.exists ?? existsSync;
|
|
23
|
+
for (const candidate of [env["ANDROID_HOME"], env["ANDROID_SDK_ROOT"]]) {
|
|
24
|
+
if (candidate && isValidSdk(candidate, exists))
|
|
25
|
+
return candidate;
|
|
26
|
+
}
|
|
27
|
+
const standard = os === "darwin"
|
|
28
|
+
? join(home, "Library", "Android", "sdk")
|
|
29
|
+
: os === "win32"
|
|
30
|
+
? join(env["LOCALAPPDATA"] ?? join(home, "AppData", "Local"), "Android", "Sdk")
|
|
31
|
+
: join(home, "Android", "Sdk");
|
|
32
|
+
return isValidSdk(standard, exists) ? standard : null;
|
|
33
|
+
}
|
|
34
|
+
export async function pickFreePort() {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const srv = createServer();
|
|
37
|
+
srv.on("error", reject);
|
|
38
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
39
|
+
const address = srv.address();
|
|
40
|
+
if (address && typeof address === "object") {
|
|
41
|
+
const { port } = address;
|
|
42
|
+
srv.close(() => resolve(port));
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
srv.close(() => reject(new Error("Could not determine a free port")));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const defaultLaunch = async (args) => {
|
|
51
|
+
const mod = await importAppium();
|
|
52
|
+
return mod.main(args);
|
|
53
|
+
};
|
|
54
|
+
async function pollReady(endpoint, timeoutMs) {
|
|
55
|
+
const deadline = Date.now() + timeoutMs;
|
|
56
|
+
let lastError;
|
|
57
|
+
while (Date.now() < deadline) {
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(`${endpoint}/status`);
|
|
60
|
+
if (res.ok)
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
lastError = error;
|
|
65
|
+
}
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
67
|
+
}
|
|
68
|
+
const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
|
|
69
|
+
throw new Error(`Appium server did not become ready within ${timeoutMs}ms${detail}`);
|
|
70
|
+
}
|
|
71
|
+
export async function startAppiumServer(options = {}) {
|
|
72
|
+
const launch = options.launch ?? defaultLaunch;
|
|
73
|
+
const appiumHome = options.appiumHome ?? getAppiumHome();
|
|
74
|
+
const useDrivers = options.useDrivers ?? ["uiautomator2"];
|
|
75
|
+
const readyTimeoutMs = options.readyTimeoutMs ?? 30_000;
|
|
76
|
+
// Appium's UiAutomator2 driver reads ANDROID_HOME/ANDROID_SDK_ROOT from the
|
|
77
|
+
// environment; it runs in-process via main(), so we export a resolved SDK
|
|
78
|
+
// path here when neither is set, otherwise sessions fail to start.
|
|
79
|
+
if (!process.env["ANDROID_HOME"] && !process.env["ANDROID_SDK_ROOT"]) {
|
|
80
|
+
const sdk = resolveAndroidSdk();
|
|
81
|
+
if (sdk) {
|
|
82
|
+
process.env["ANDROID_HOME"] = sdk;
|
|
83
|
+
process.env["ANDROID_SDK_ROOT"] = sdk;
|
|
84
|
+
console.error(`Using auto-detected Android SDK at ${sdk} (set ANDROID_HOME to override).`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const port = await pickFreePort();
|
|
88
|
+
const endpoint = `http://127.0.0.1:${port}`;
|
|
89
|
+
const server = await launch({
|
|
90
|
+
port,
|
|
91
|
+
basePath: "/",
|
|
92
|
+
host: "127.0.0.1",
|
|
93
|
+
appiumHome,
|
|
94
|
+
useDrivers,
|
|
95
|
+
loglevel: "warn",
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
await pollReady(endpoint, readyTimeoutMs);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
await server.close().catch(() => { });
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
endpoint,
|
|
106
|
+
stop: async () => {
|
|
107
|
+
await server.close();
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { pickFreePort, resolveAndroidSdk, startAppiumServer, } from "./appium-server.js";
|
|
4
|
+
const realFetch = globalThis.fetch;
|
|
5
|
+
beforeAll(() => {
|
|
6
|
+
process.env["AXIMO_HOME"] = "/tmp/aximo-appium-test";
|
|
7
|
+
});
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
globalThis.fetch = realFetch;
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
describe("pickFreePort", () => {
|
|
13
|
+
it("returns a usable, positive port", async () => {
|
|
14
|
+
const port = await pickFreePort();
|
|
15
|
+
expect(port).toBeGreaterThan(0);
|
|
16
|
+
expect(port).toBeLessThan(65_536);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
describe("startAppiumServer", () => {
|
|
20
|
+
it("launches on a free port, polls /status, and stops", async () => {
|
|
21
|
+
let statusHits = 0;
|
|
22
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
23
|
+
const url = String(input);
|
|
24
|
+
expect(url).toContain("/status");
|
|
25
|
+
statusHits++;
|
|
26
|
+
return new Response("{}", { status: statusHits >= 2 ? 200 : 503 });
|
|
27
|
+
});
|
|
28
|
+
const close = vi.fn(async () => { });
|
|
29
|
+
const launchArgs = [];
|
|
30
|
+
const launch = async (args) => {
|
|
31
|
+
launchArgs.push(args);
|
|
32
|
+
return { close };
|
|
33
|
+
};
|
|
34
|
+
const handle = await startAppiumServer({ launch, readyTimeoutMs: 5000 });
|
|
35
|
+
expect(handle.endpoint).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
|
36
|
+
const args = launchArgs[0];
|
|
37
|
+
expect(args["host"]).toBe("127.0.0.1");
|
|
38
|
+
expect(args["basePath"]).toBe("/");
|
|
39
|
+
expect(typeof args["port"]).toBe("number");
|
|
40
|
+
expect(args["useDrivers"]).toEqual(["uiautomator2"]);
|
|
41
|
+
expect(statusHits).toBeGreaterThanOrEqual(2);
|
|
42
|
+
await handle.stop();
|
|
43
|
+
expect(close).toHaveBeenCalledOnce();
|
|
44
|
+
});
|
|
45
|
+
it("closes the server and throws when readiness times out", async () => {
|
|
46
|
+
globalThis.fetch = vi.fn(async () => new Response("nope", { status: 503 }));
|
|
47
|
+
const close = vi.fn(async () => { });
|
|
48
|
+
const launch = async () => ({ close });
|
|
49
|
+
await expect(startAppiumServer({ launch, readyTimeoutMs: 300 })).rejects.toThrow(/did not become ready/);
|
|
50
|
+
expect(close).toHaveBeenCalledOnce();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("resolveAndroidSdk", () => {
|
|
54
|
+
const exists = (valid) => (path) => path === valid || path === join(valid, "platform-tools");
|
|
55
|
+
it("honours a valid ANDROID_HOME", () => {
|
|
56
|
+
const result = resolveAndroidSdk({
|
|
57
|
+
env: { ANDROID_HOME: "/opt/sdk" },
|
|
58
|
+
exists: exists("/opt/sdk"),
|
|
59
|
+
});
|
|
60
|
+
expect(result).toBe("/opt/sdk");
|
|
61
|
+
});
|
|
62
|
+
it("falls back to ANDROID_SDK_ROOT when ANDROID_HOME is invalid", () => {
|
|
63
|
+
const result = resolveAndroidSdk({
|
|
64
|
+
env: { ANDROID_HOME: "/bad", ANDROID_SDK_ROOT: "/opt/root" },
|
|
65
|
+
exists: exists("/opt/root"),
|
|
66
|
+
});
|
|
67
|
+
expect(result).toBe("/opt/root");
|
|
68
|
+
});
|
|
69
|
+
it("probes the standard macOS location when env is unset", () => {
|
|
70
|
+
const expected = join("/Users/dev", "Library", "Android", "sdk");
|
|
71
|
+
const result = resolveAndroidSdk({
|
|
72
|
+
env: {},
|
|
73
|
+
home: "/Users/dev",
|
|
74
|
+
platform: "darwin",
|
|
75
|
+
exists: exists(expected),
|
|
76
|
+
});
|
|
77
|
+
expect(result).toBe(expected);
|
|
78
|
+
});
|
|
79
|
+
it("probes the standard Linux location when env is unset", () => {
|
|
80
|
+
const expected = join("/home/dev", "Android", "Sdk");
|
|
81
|
+
const result = resolveAndroidSdk({
|
|
82
|
+
env: {},
|
|
83
|
+
home: "/home/dev",
|
|
84
|
+
platform: "linux",
|
|
85
|
+
exists: exists(expected),
|
|
86
|
+
});
|
|
87
|
+
expect(result).toBe(expected);
|
|
88
|
+
});
|
|
89
|
+
it("probes LOCALAPPDATA on Windows when env is unset", () => {
|
|
90
|
+
const expected = join("C:\\Users\\dev\\AppData\\Local", "Android", "Sdk");
|
|
91
|
+
const result = resolveAndroidSdk({
|
|
92
|
+
env: { LOCALAPPDATA: "C:\\Users\\dev\\AppData\\Local" },
|
|
93
|
+
home: "C:\\Users\\dev",
|
|
94
|
+
platform: "win32",
|
|
95
|
+
exists: exists(expected),
|
|
96
|
+
});
|
|
97
|
+
expect(result).toBe(expected);
|
|
98
|
+
});
|
|
99
|
+
it("returns null when no SDK is found", () => {
|
|
100
|
+
const result = resolveAndroidSdk({
|
|
101
|
+
env: {},
|
|
102
|
+
home: "/home/dev",
|
|
103
|
+
platform: "linux",
|
|
104
|
+
exists: () => false,
|
|
105
|
+
});
|
|
106
|
+
expect(result).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ActionResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
error?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface MobileController {
|
|
6
|
+
screenshot(): Promise<Buffer>;
|
|
7
|
+
tap(x: number, y: number): Promise<void>;
|
|
8
|
+
doubleTap(x: number, y: number): Promise<void>;
|
|
9
|
+
longPress(x: number, y: number, durationMs: number): Promise<void>;
|
|
10
|
+
swipe(x1: number, y1: number, x2: number, y2: number, durationMs: number): Promise<void>;
|
|
11
|
+
typeText(text: string): Promise<ActionResult>;
|
|
12
|
+
pressKey(key: string): Promise<ActionResult>;
|
|
13
|
+
launchApp(app: string): Promise<ActionResult>;
|
|
14
|
+
findElement(text: string): Promise<ActionResult>;
|
|
15
|
+
clearText(): Promise<ActionResult>;
|
|
16
|
+
hideKeyboard(): Promise<ActionResult>;
|
|
17
|
+
getDeviceTime?(): Promise<{
|
|
18
|
+
datetime: string;
|
|
19
|
+
timezone: string;
|
|
20
|
+
} | null>;
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ExecFn } from "./exec.js";
|
|
2
|
+
export interface AndroidDevice {
|
|
3
|
+
serial: string;
|
|
4
|
+
state: string;
|
|
5
|
+
model?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Parse `adb devices -l` into serial + state (+ model when present). */
|
|
8
|
+
export declare function listAndroidDevices(exec?: ExecFn): Promise<AndroidDevice[]>;
|
|
9
|
+
/** Resolve an adb serial: explicit (validated), else the single connected phone. */
|
|
10
|
+
export declare function resolveAndroidSerial(preferred: string | undefined, exec?: ExecFn): Promise<string>;
|
|
11
|
+
export interface IosPhysicalDevice {
|
|
12
|
+
udid: string;
|
|
13
|
+
name: string;
|
|
14
|
+
state?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Resolve an iOS udid: explicit (validated), else the single connected device. */
|
|
17
|
+
export declare function resolveIosUdid(preferred: string | undefined, exec?: ExecFn): Promise<string>;
|
|
18
|
+
/**
|
|
19
|
+
* List physically-connected iOS devices via `idb list-targets --json` (one JSON
|
|
20
|
+
* object per line). Only entries whose `type` is `device` are returned. Returns
|
|
21
|
+
* an empty list when idb is not installed; a real idb failure is surfaced.
|
|
22
|
+
*/
|
|
23
|
+
export declare function listIosPhysicalDevices(exec?: ExecFn): Promise<IosPhysicalDevice[]>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { execBinary } from "./exec.js";
|
|
2
|
+
/** Parse `adb devices -l` into serial + state (+ model when present). */
|
|
3
|
+
export async function listAndroidDevices(exec = execBinary) {
|
|
4
|
+
const { stdout } = await exec("adb", ["devices", "-l"]);
|
|
5
|
+
const lines = stdout.toString("utf-8").split("\n").slice(1);
|
|
6
|
+
const result = [];
|
|
7
|
+
for (const raw of lines) {
|
|
8
|
+
const line = raw.trim();
|
|
9
|
+
if (!line)
|
|
10
|
+
continue;
|
|
11
|
+
const parts = line.split(/\s+/);
|
|
12
|
+
const serial = parts[0];
|
|
13
|
+
const state = parts[1];
|
|
14
|
+
if (!serial || !state)
|
|
15
|
+
continue;
|
|
16
|
+
const modelToken = parts.find((p) => p.startsWith("model:"));
|
|
17
|
+
const device = { serial, state };
|
|
18
|
+
if (modelToken)
|
|
19
|
+
device.model = modelToken.slice("model:".length);
|
|
20
|
+
result.push(device);
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
/** Resolve an adb serial: explicit (validated), else the single connected phone. */
|
|
25
|
+
export async function resolveAndroidSerial(preferred, exec = execBinary) {
|
|
26
|
+
const devices = await listAndroidDevices(exec);
|
|
27
|
+
if (preferred) {
|
|
28
|
+
if (preferred.startsWith("emulator-")) {
|
|
29
|
+
throw new Error("Emulators are not supported — register a physical phone connected over USB only.");
|
|
30
|
+
}
|
|
31
|
+
const match = devices.find((d) => d.serial === preferred);
|
|
32
|
+
if (!match) {
|
|
33
|
+
throw new Error(`No Android device with serial '${preferred}' is connected.`);
|
|
34
|
+
}
|
|
35
|
+
if (match.state === "unauthorized") {
|
|
36
|
+
throw new Error("Android device is unauthorized. Accept the RSA prompt on the device, then retry.");
|
|
37
|
+
}
|
|
38
|
+
if (match.state === "offline") {
|
|
39
|
+
throw new Error("Android device is offline — reconnect the USB cable and unlock the phone.");
|
|
40
|
+
}
|
|
41
|
+
if (match.state !== "device") {
|
|
42
|
+
throw new Error(`Android device '${preferred}' is in state '${match.state}' — reconnect and unlock the phone.`);
|
|
43
|
+
}
|
|
44
|
+
return match.serial;
|
|
45
|
+
}
|
|
46
|
+
// Physical phones only: exclude emulator serials (mirrors list.ts).
|
|
47
|
+
const usable = devices.filter((d) => d.state === "device" && !d.serial.startsWith("emulator-"));
|
|
48
|
+
if (usable.length === 1 && usable[0])
|
|
49
|
+
return usable[0].serial;
|
|
50
|
+
if (usable.length === 0) {
|
|
51
|
+
const unauthorized = devices.find((d) => d.state === "unauthorized");
|
|
52
|
+
if (unauthorized) {
|
|
53
|
+
throw new Error("Android device is unauthorized. Accept the RSA prompt on the device, then retry.");
|
|
54
|
+
}
|
|
55
|
+
const offline = devices.find((d) => d.state === "offline");
|
|
56
|
+
if (offline) {
|
|
57
|
+
throw new Error("Android device is offline — reconnect the USB cable and unlock the phone.");
|
|
58
|
+
}
|
|
59
|
+
throw new Error("No connected Android devices found. Connect a phone over USB.");
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`Multiple Android devices found; pass --serial. Available: ${usable
|
|
62
|
+
.map((d) => d.serial)
|
|
63
|
+
.join(", ")}`);
|
|
64
|
+
}
|
|
65
|
+
/** Resolve an iOS udid: explicit (validated), else the single connected device. */
|
|
66
|
+
export async function resolveIosUdid(preferred, exec = execBinary) {
|
|
67
|
+
const devices = await listIosPhysicalDevices(exec);
|
|
68
|
+
if (preferred) {
|
|
69
|
+
const match = devices.find((d) => d.udid === preferred);
|
|
70
|
+
if (!match) {
|
|
71
|
+
throw new Error(`No iOS device with udid '${preferred}' is connected. ` +
|
|
72
|
+
"Connect it over USB, unlock it, and trust this computer.");
|
|
73
|
+
}
|
|
74
|
+
return match.udid;
|
|
75
|
+
}
|
|
76
|
+
if (devices.length === 1 && devices[0])
|
|
77
|
+
return devices[0].udid;
|
|
78
|
+
if (devices.length === 0) {
|
|
79
|
+
throw new Error("No connected iOS devices found. Connect an iPhone/iPad over USB, unlock it, and " +
|
|
80
|
+
"trust this computer. Verify with `idb list-targets` (or `aximo mobile doctor` if idb is missing).");
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`Multiple iOS devices found; pass --udid. Available: ${devices
|
|
83
|
+
.map((d) => `${d.name} (${d.udid})`)
|
|
84
|
+
.join(", ")}`);
|
|
85
|
+
}
|
|
86
|
+
/** True when a spawn failed because the binary isn't installed (ENOENT). */
|
|
87
|
+
function isCommandNotFound(err) {
|
|
88
|
+
if (err?.code === "ENOENT")
|
|
89
|
+
return true;
|
|
90
|
+
return err instanceof Error && /\bENOENT\b/.test(err.message);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* List physically-connected iOS devices via `idb list-targets --json` (one JSON
|
|
94
|
+
* object per line). Only entries whose `type` is `device` are returned. Returns
|
|
95
|
+
* an empty list when idb is not installed; a real idb failure is surfaced.
|
|
96
|
+
*/
|
|
97
|
+
export async function listIosPhysicalDevices(exec = execBinary) {
|
|
98
|
+
let stdout;
|
|
99
|
+
try {
|
|
100
|
+
({ stdout } = await exec("idb", ["list-targets", "--json"]));
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
// idb not installed → "no iOS devices"; any other idb failure should surface.
|
|
104
|
+
if (isCommandNotFound(err))
|
|
105
|
+
return [];
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
const result = [];
|
|
109
|
+
for (const line of stdout.toString("utf-8").split("\n")) {
|
|
110
|
+
const trimmed = line.trim();
|
|
111
|
+
if (!trimmed)
|
|
112
|
+
continue;
|
|
113
|
+
try {
|
|
114
|
+
const entry = JSON.parse(trimmed);
|
|
115
|
+
if (entry.type === "device" &&
|
|
116
|
+
typeof entry.udid === "string" &&
|
|
117
|
+
typeof entry.name === "string") {
|
|
118
|
+
const device = { udid: entry.udid, name: entry.name };
|
|
119
|
+
if (typeof entry.state === "string")
|
|
120
|
+
device.state = entry.state;
|
|
121
|
+
result.push(device);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// ignore non-JSON lines
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { listAndroidDevices, listIosPhysicalDevices, resolveAndroidSerial, resolveIosUdid, } from "./devices.js";
|
|
3
|
+
function fixed(stdout) {
|
|
4
|
+
return async () => ({ stdout: Buffer.from(stdout), stderr: "" });
|
|
5
|
+
}
|
|
6
|
+
function iosTargets(...devices) {
|
|
7
|
+
return async () => ({
|
|
8
|
+
stdout: Buffer.from(devices.map((d) => JSON.stringify(d)).join("\n")),
|
|
9
|
+
stderr: "",
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
const PHONE = { udid: "00008030-ABC", name: "iPhone", type: "device", state: "connected" };
|
|
13
|
+
const SIM = { udid: "SIM-1", name: "iPhone 15", type: "simulator", state: "Booted" };
|
|
14
|
+
describe("listAndroidDevices / resolveAndroidSerial", () => {
|
|
15
|
+
it("parses adb devices -l output", async () => {
|
|
16
|
+
const exec = fixed("List of devices attached\nR1 device model:Pixel_7\nR2 unauthorized\n");
|
|
17
|
+
const devices = await listAndroidDevices(exec);
|
|
18
|
+
expect(devices).toEqual([
|
|
19
|
+
{ serial: "R1", state: "device", model: "Pixel_7" },
|
|
20
|
+
{ serial: "R2", state: "unauthorized" },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
it("auto-selects the only online device", async () => {
|
|
24
|
+
const exec = fixed("List of devices attached\nR1 device\n");
|
|
25
|
+
await expect(resolveAndroidSerial(undefined, exec)).resolves.toBe("R1");
|
|
26
|
+
});
|
|
27
|
+
it("reports an actionable error for an unauthorized-only device", async () => {
|
|
28
|
+
const exec = fixed("List of devices attached\nR1 unauthorized\n");
|
|
29
|
+
await expect(resolveAndroidSerial(undefined, exec)).rejects.toThrow(/unauthorized/i);
|
|
30
|
+
});
|
|
31
|
+
it("rejects an explicitly-passed emulator serial", async () => {
|
|
32
|
+
const exec = fixed("List of devices attached\nemulator-5554 device\n");
|
|
33
|
+
await expect(resolveAndroidSerial("emulator-5554", exec)).rejects.toThrow(/emulator/i);
|
|
34
|
+
});
|
|
35
|
+
it("rejects an explicitly-passed unauthorized device", async () => {
|
|
36
|
+
const exec = fixed("List of devices attached\nR1 unauthorized\n");
|
|
37
|
+
await expect(resolveAndroidSerial("R1", exec)).rejects.toThrow(/unauthorized/i);
|
|
38
|
+
});
|
|
39
|
+
it("rejects an explicitly-passed offline device", async () => {
|
|
40
|
+
const exec = fixed("List of devices attached\nR1 offline\n");
|
|
41
|
+
await expect(resolveAndroidSerial("R1", exec)).rejects.toThrow(/offline/i);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("listIosPhysicalDevices / resolveIosUdid", () => {
|
|
45
|
+
it("returns only physical devices (ignores simulators)", async () => {
|
|
46
|
+
const devices = await listIosPhysicalDevices(iosTargets(PHONE, SIM));
|
|
47
|
+
expect(devices).toEqual([{ udid: "00008030-ABC", name: "iPhone", state: "connected" }]);
|
|
48
|
+
});
|
|
49
|
+
it("returns an empty list when idb is not installed (ENOENT)", async () => {
|
|
50
|
+
const exec = async () => {
|
|
51
|
+
throw new Error("spawn idb ENOENT");
|
|
52
|
+
};
|
|
53
|
+
await expect(listIosPhysicalDevices(exec)).resolves.toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
it("surfaces a real idb failure instead of masking it as no devices", async () => {
|
|
56
|
+
const exec = async () => {
|
|
57
|
+
throw new Error("idb list-targets --json failed: could not connect to companion");
|
|
58
|
+
};
|
|
59
|
+
await expect(listIosPhysicalDevices(exec)).rejects.toThrow(/companion/);
|
|
60
|
+
});
|
|
61
|
+
it("auto-selects the only connected device", async () => {
|
|
62
|
+
await expect(resolveIosUdid(undefined, iosTargets(PHONE))).resolves.toBe("00008030-ABC");
|
|
63
|
+
});
|
|
64
|
+
it("errors when no devices are connected", async () => {
|
|
65
|
+
await expect(resolveIosUdid(undefined, iosTargets())).rejects.toThrow(/no connected ios/i);
|
|
66
|
+
});
|
|
67
|
+
it("requires --udid when multiple devices are connected", async () => {
|
|
68
|
+
const second = { ...PHONE, udid: "00008030-DEF", name: "iPad" };
|
|
69
|
+
await expect(resolveIosUdid(undefined, iosTargets(PHONE, second))).rejects.toThrow(/--udid/);
|
|
70
|
+
});
|
|
71
|
+
it("validates an explicitly-passed udid", async () => {
|
|
72
|
+
await expect(resolveIosUdid("nope", iosTargets(PHONE))).rejects.toThrow(/no ios device/i);
|
|
73
|
+
await expect(resolveIosUdid("00008030-ABC", iosTargets(PHONE))).resolves.toBe("00008030-ABC");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
// Explicit argv, no shell — payloads can't inject into the local machine.
|
|
3
|
+
export const execBinary = (file, args) => new Promise((resolve, reject) => {
|
|
4
|
+
execFile(file, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
5
|
+
const stderrText = stderr.toString("utf-8");
|
|
6
|
+
if (error) {
|
|
7
|
+
const message = stderrText.trim() || error.message;
|
|
8
|
+
reject(new Error(`${file} ${args.join(" ")} failed: ${message}`));
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
resolve({ stdout, stderr: stderrText });
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iOS WebDriverAgent signing configuration.
|
|
3
|
+
*
|
|
4
|
+
* Driving a physical iPhone goes through Appium's XCUITest driver, which builds
|
|
5
|
+
* and code-signs a WebDriverAgent (WDA) runner onto the device. That build needs
|
|
6
|
+
* an Apple Developer signing identity. These values are supplied via env vars and
|
|
7
|
+
* forwarded as XCUITest capabilities. Simulators need no signing, so the caps are
|
|
8
|
+
* only emitted when a team id is configured.
|
|
9
|
+
*
|
|
10
|
+
* The CLI-native `AXIMO_IOS_*` names take precedence; Autify's MobileLink
|
|
11
|
+
* (mobile-web) names — `XCODE_TEAM_ID` / `XCODE_SIGN_ID` / `WDA_BUNDLE_ID` — are
|
|
12
|
+
* accepted as fallbacks so an already-configured MobileLink host needs no changes.
|
|
13
|
+
*/
|
|
14
|
+
export interface IosSigningConfig {
|
|
15
|
+
/** Apple Developer Team ID, forwarded as `appium:xcodeOrgId`. */
|
|
16
|
+
teamId?: string;
|
|
17
|
+
/** Code-signing identity; defaults to "Apple Development". */
|
|
18
|
+
signingId: string;
|
|
19
|
+
/** WDA bundle id backed by a valid provisioning profile. */
|
|
20
|
+
wdaBundleId: string;
|
|
21
|
+
/** Optional prebuilt-WDA DerivedData path to skip per-session WDA rebuilds. */
|
|
22
|
+
derivedDataPath?: string;
|
|
23
|
+
}
|
|
24
|
+
type Env = Record<string, string | undefined>;
|
|
25
|
+
export declare function resolveIosSigning(env?: Env): IosSigningConfig;
|
|
26
|
+
/** Map signing config to XCUITest capabilities; empty unless a team id is set. */
|
|
27
|
+
export declare function iosSigningCapabilities(config: IosSigningConfig): Record<string, unknown>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const DEFAULT_SIGNING_ID = "Apple Development";
|
|
2
|
+
const DEFAULT_WDA_BUNDLE_ID = "com.facebook.WebDriverAgentRunner";
|
|
3
|
+
export function resolveIosSigning(env = process.env) {
|
|
4
|
+
// AXIMO_IOS_* is CLI-native; XCODE_*/WDA_BUNDLE_ID mirror MobileLink (mobile-web).
|
|
5
|
+
const pick = (...keys) => {
|
|
6
|
+
for (const key of keys) {
|
|
7
|
+
const value = env[key]?.trim();
|
|
8
|
+
if (value)
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
return undefined;
|
|
12
|
+
};
|
|
13
|
+
const teamId = pick("AXIMO_IOS_TEAM_ID", "XCODE_TEAM_ID");
|
|
14
|
+
const derivedDataPath = pick("AXIMO_IOS_DERIVED_DATA_PATH");
|
|
15
|
+
const config = {
|
|
16
|
+
signingId: pick("AXIMO_IOS_SIGNING_ID", "XCODE_SIGN_ID") || DEFAULT_SIGNING_ID,
|
|
17
|
+
wdaBundleId: pick("AXIMO_IOS_WDA_BUNDLE_ID", "WDA_BUNDLE_ID") || DEFAULT_WDA_BUNDLE_ID,
|
|
18
|
+
};
|
|
19
|
+
if (teamId)
|
|
20
|
+
config.teamId = teamId;
|
|
21
|
+
if (derivedDataPath)
|
|
22
|
+
config.derivedDataPath = derivedDataPath;
|
|
23
|
+
return config;
|
|
24
|
+
}
|
|
25
|
+
/** Map signing config to XCUITest capabilities; empty unless a team id is set. */
|
|
26
|
+
export function iosSigningCapabilities(config) {
|
|
27
|
+
if (!config.teamId)
|
|
28
|
+
return {};
|
|
29
|
+
const caps = {
|
|
30
|
+
"appium:xcodeOrgId": config.teamId,
|
|
31
|
+
"appium:xcodeSigningId": config.signingId,
|
|
32
|
+
"appium:updatedWDABundleId": config.wdaBundleId,
|
|
33
|
+
"appium:allowProvisioningDeviceRegistration": true,
|
|
34
|
+
};
|
|
35
|
+
if (config.derivedDataPath)
|
|
36
|
+
caps["appium:derivedDataPath"] = config.derivedDataPath;
|
|
37
|
+
return caps;
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { iosSigningCapabilities, resolveIosSigning } from "./ios-signing.js";
|
|
3
|
+
describe("resolveIosSigning", () => {
|
|
4
|
+
it("falls back to defaults when env is empty", () => {
|
|
5
|
+
const config = resolveIosSigning({});
|
|
6
|
+
expect(config).toEqual({
|
|
7
|
+
signingId: "Apple Development",
|
|
8
|
+
wdaBundleId: "com.facebook.WebDriverAgentRunner",
|
|
9
|
+
});
|
|
10
|
+
expect(config.teamId).toBeUndefined();
|
|
11
|
+
});
|
|
12
|
+
it("reads team id, signing id, wda bundle id and derived data path from env", () => {
|
|
13
|
+
const config = resolveIosSigning({
|
|
14
|
+
AXIMO_IOS_TEAM_ID: "ABCDE12345",
|
|
15
|
+
AXIMO_IOS_SIGNING_ID: "Apple Distribution",
|
|
16
|
+
AXIMO_IOS_WDA_BUNDLE_ID: "com.example.WDA",
|
|
17
|
+
AXIMO_IOS_DERIVED_DATA_PATH: "/tmp/wda",
|
|
18
|
+
});
|
|
19
|
+
expect(config).toEqual({
|
|
20
|
+
teamId: "ABCDE12345",
|
|
21
|
+
signingId: "Apple Distribution",
|
|
22
|
+
wdaBundleId: "com.example.WDA",
|
|
23
|
+
derivedDataPath: "/tmp/wda",
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
it("treats blank env values as unset", () => {
|
|
27
|
+
const config = resolveIosSigning({ AXIMO_IOS_TEAM_ID: " " });
|
|
28
|
+
expect(config.teamId).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
it("falls back to the MobileLink (XCODE_*/WDA_BUNDLE_ID) names", () => {
|
|
31
|
+
const config = resolveIosSigning({
|
|
32
|
+
XCODE_TEAM_ID: "ABCDE12345",
|
|
33
|
+
XCODE_SIGN_ID: "Apple Distribution",
|
|
34
|
+
WDA_BUNDLE_ID: "com.example.WDA",
|
|
35
|
+
});
|
|
36
|
+
expect(config).toEqual({
|
|
37
|
+
teamId: "ABCDE12345",
|
|
38
|
+
signingId: "Apple Distribution",
|
|
39
|
+
wdaBundleId: "com.example.WDA",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
it("prefers AXIMO_IOS_* over the MobileLink fallbacks", () => {
|
|
43
|
+
const config = resolveIosSigning({
|
|
44
|
+
AXIMO_IOS_TEAM_ID: "AXIMO00000",
|
|
45
|
+
XCODE_TEAM_ID: "XCODE00000",
|
|
46
|
+
AXIMO_IOS_SIGNING_ID: "Apple Development",
|
|
47
|
+
XCODE_SIGN_ID: "Apple Distribution",
|
|
48
|
+
});
|
|
49
|
+
expect(config.teamId).toBe("AXIMO00000");
|
|
50
|
+
expect(config.signingId).toBe("Apple Development");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("iosSigningCapabilities", () => {
|
|
54
|
+
it("is empty without a team id (simulator / automatic signing)", () => {
|
|
55
|
+
expect(iosSigningCapabilities(resolveIosSigning({}))).toEqual({});
|
|
56
|
+
});
|
|
57
|
+
it("emits XCUITest signing caps when a team id is set", () => {
|
|
58
|
+
const caps = iosSigningCapabilities(resolveIosSigning({ AXIMO_IOS_TEAM_ID: "ABCDE12345" }));
|
|
59
|
+
expect(caps).toEqual({
|
|
60
|
+
"appium:xcodeOrgId": "ABCDE12345",
|
|
61
|
+
"appium:xcodeSigningId": "Apple Development",
|
|
62
|
+
"appium:updatedWDABundleId": "com.facebook.WebDriverAgentRunner",
|
|
63
|
+
"appium:allowProvisioningDeviceRegistration": true,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
it("includes derivedDataPath when configured", () => {
|
|
67
|
+
const caps = iosSigningCapabilities(resolveIosSigning({
|
|
68
|
+
AXIMO_IOS_TEAM_ID: "ABCDE12345",
|
|
69
|
+
AXIMO_IOS_DERIVED_DATA_PATH: "/tmp/wda",
|
|
70
|
+
}));
|
|
71
|
+
expect(caps["appium:derivedDataPath"]).toBe("/tmp/wda");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Socket } from "socket.io-client";
|
|
2
|
+
import type { MobileController } from "./controller.js";
|
|
3
|
+
export interface RunControlTunnelOptions {
|
|
4
|
+
wsUrl: string;
|
|
5
|
+
deviceToken: string;
|
|
6
|
+
deviceName: string;
|
|
7
|
+
controller: MobileController;
|
|
8
|
+
/** Installs the app under test on the device (fetch by upload id -> adb/idb install). */
|
|
9
|
+
installApp?: (args: {
|
|
10
|
+
uploadId: string;
|
|
11
|
+
}) => Promise<void>;
|
|
12
|
+
connect?: (wsUrl: string, deviceToken: string) => Socket;
|
|
13
|
+
}
|
|
14
|
+
export declare function registerHandlers(socket: Socket, controller: MobileController, installApp?: RunControlTunnelOptions["installApp"]): void;
|
|
15
|
+
export declare function runControlTunnel(options: RunControlTunnelOptions): Promise<void>;
|