@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,104 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { runMobileInstall } from "./install.js";
|
|
6
|
+
const ONE_ANDROID = "List of devices attached\nR1 device\n";
|
|
7
|
+
const ONE_IOS = `${JSON.stringify({ udid: "00008030-ABC", name: "iPhone", type: "device" })}\n`;
|
|
8
|
+
let dir;
|
|
9
|
+
let apkPath;
|
|
10
|
+
let ipaPath;
|
|
11
|
+
let txtPath;
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
dir = mkdtempSync(join(tmpdir(), "aximo-mob-install-"));
|
|
14
|
+
apkPath = join(dir, "app.apk");
|
|
15
|
+
ipaPath = join(dir, "app.ipa");
|
|
16
|
+
txtPath = join(dir, "app.txt");
|
|
17
|
+
for (const p of [apkPath, ipaPath, txtPath])
|
|
18
|
+
writeFileSync(p, "x");
|
|
19
|
+
});
|
|
20
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
21
|
+
/** Mock adb/idb: serve device discovery and capture the `install` invocation. */
|
|
22
|
+
function execWith(opts = {}) {
|
|
23
|
+
let captured = { file: "", args: [] };
|
|
24
|
+
const exec = async (file, args) => {
|
|
25
|
+
if (file === "adb" && args[0] === "devices") {
|
|
26
|
+
return { stdout: Buffer.from(ONE_ANDROID), stderr: "" };
|
|
27
|
+
}
|
|
28
|
+
if (file === "idb" && args[0] === "list-targets") {
|
|
29
|
+
return { stdout: Buffer.from(ONE_IOS), stderr: "" };
|
|
30
|
+
}
|
|
31
|
+
if ((file === "adb" || file === "idb") && args.includes("install")) {
|
|
32
|
+
captured = { file, args };
|
|
33
|
+
if (opts.throwMsg)
|
|
34
|
+
throw new Error(opts.throwMsg);
|
|
35
|
+
return { stdout: Buffer.from(opts.out ?? "Success"), stderr: "" };
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`unexpected exec: ${file} ${args.join(" ")}`);
|
|
38
|
+
};
|
|
39
|
+
return { exec, install: () => captured };
|
|
40
|
+
}
|
|
41
|
+
describe("runMobileInstall", () => {
|
|
42
|
+
it("requires --app", async () => {
|
|
43
|
+
const { exec } = execWith();
|
|
44
|
+
const res = await runMobileInstall({}, exec);
|
|
45
|
+
expect(res.ok).toBe(false);
|
|
46
|
+
if (!res.ok)
|
|
47
|
+
expect(res.error.code).toBe("validation_error");
|
|
48
|
+
});
|
|
49
|
+
it("errors when the app file is missing", async () => {
|
|
50
|
+
const { exec } = execWith();
|
|
51
|
+
const res = await runMobileInstall({ app: "definitely-not-here-xyz.apk" }, exec);
|
|
52
|
+
expect(res.ok).toBe(false);
|
|
53
|
+
if (!res.ok)
|
|
54
|
+
expect(res.error.message).toMatch(/not found/i);
|
|
55
|
+
});
|
|
56
|
+
it("errors when the platform cannot be inferred", async () => {
|
|
57
|
+
const { exec } = execWith();
|
|
58
|
+
const res = await runMobileInstall({ app: txtPath }, exec);
|
|
59
|
+
expect(res.ok).toBe(false);
|
|
60
|
+
if (!res.ok)
|
|
61
|
+
expect(res.error.message).toMatch(/determine platform/i);
|
|
62
|
+
});
|
|
63
|
+
it("installs an .apk via `adb -s <serial> install -r`", async () => {
|
|
64
|
+
const { exec, install } = execWith({ out: "Performing Streamed Install\nSuccess" });
|
|
65
|
+
const res = await runMobileInstall({ app: apkPath }, exec);
|
|
66
|
+
expect(res.ok).toBe(true);
|
|
67
|
+
if (res.ok) {
|
|
68
|
+
expect(res.data.platform).toBe("android");
|
|
69
|
+
expect(res.data.serial).toBe("R1");
|
|
70
|
+
expect(res.data.udid).toBeUndefined();
|
|
71
|
+
expect(res.data.output).toContain("Success");
|
|
72
|
+
}
|
|
73
|
+
expect(install().file).toBe("adb");
|
|
74
|
+
expect(install().args.slice(0, 2)).toEqual(["-s", "R1"]);
|
|
75
|
+
expect(install().args).toEqual(expect.arrayContaining(["install", "-r"]));
|
|
76
|
+
});
|
|
77
|
+
it("installs an .ipa via `idb install --udid <udid>`", async () => {
|
|
78
|
+
const { exec, install } = execWith({ out: "Installed" });
|
|
79
|
+
const res = await runMobileInstall({ app: ipaPath }, exec);
|
|
80
|
+
expect(res.ok).toBe(true);
|
|
81
|
+
if (res.ok) {
|
|
82
|
+
expect(res.data.platform).toBe("ios");
|
|
83
|
+
expect(res.data.udid).toBe("00008030-ABC");
|
|
84
|
+
expect(res.data.serial).toBeUndefined();
|
|
85
|
+
}
|
|
86
|
+
expect(install().file).toBe("idb");
|
|
87
|
+
expect(install().args).toEqual(["install", "--udid", "00008030-ABC", ipaPath]);
|
|
88
|
+
});
|
|
89
|
+
it("honors an explicit --platform override", async () => {
|
|
90
|
+
const { exec, install } = execWith();
|
|
91
|
+
const res = await runMobileInstall({ app: txtPath, platform: "android" }, exec);
|
|
92
|
+
expect(res.ok).toBe(true);
|
|
93
|
+
expect(install().file).toBe("adb");
|
|
94
|
+
});
|
|
95
|
+
it("maps an install failure to an error envelope", async () => {
|
|
96
|
+
const { exec } = execWith({ throwMsg: "adb: failed to install: INSTALL_FAILED_INVALID_APK" });
|
|
97
|
+
const res = await runMobileInstall({ app: apkPath }, exec);
|
|
98
|
+
expect(res.ok).toBe(false);
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
expect(res.error.code).toBe("api_error");
|
|
101
|
+
expect(res.error.message).toMatch(/INSTALL_FAILED/);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Envelope } from "../../types.js";
|
|
2
|
+
import { type AndroidDevice, type IosPhysicalDevice } from "../../mobile-control/devices.js";
|
|
3
|
+
import type { ExecFn } from "../../mobile-control/exec.js";
|
|
4
|
+
export interface MobileListResult {
|
|
5
|
+
androidDevices: AndroidDevice[];
|
|
6
|
+
iosDevices: IosPhysicalDevice[];
|
|
7
|
+
}
|
|
8
|
+
export declare function runMobileList(exec?: ExecFn): Promise<Envelope<MobileListResult>>;
|
|
9
|
+
declare const _default: import("citty").CommandDef<{}>;
|
|
10
|
+
export default _default;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { formatSuccess, printResult } from "../../output.js";
|
|
3
|
+
import { listAndroidDevices, listIosPhysicalDevices, } from "../../mobile-control/devices.js";
|
|
4
|
+
async function safe(fn, fallback) {
|
|
5
|
+
try {
|
|
6
|
+
return await fn();
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return fallback;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export async function runMobileList(exec) {
|
|
13
|
+
const [allAndroid, iosDevices] = await Promise.all([
|
|
14
|
+
safe(() => listAndroidDevices(exec), []),
|
|
15
|
+
safe(() => listIosPhysicalDevices(exec), []),
|
|
16
|
+
]);
|
|
17
|
+
// Physical phones only: drop emulator serials.
|
|
18
|
+
const androidDevices = allAndroid.filter((d) => !d.serial.startsWith("emulator-"));
|
|
19
|
+
return formatSuccess({ androidDevices, iosDevices });
|
|
20
|
+
}
|
|
21
|
+
export default defineCommand({
|
|
22
|
+
meta: {
|
|
23
|
+
name: "list",
|
|
24
|
+
description: "List physically-connected phones (Android via adb, iOS via idb)",
|
|
25
|
+
},
|
|
26
|
+
args: {},
|
|
27
|
+
async run() {
|
|
28
|
+
printResult(await runMobileList());
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type MobilePlatform = "ios" | "android";
|
|
2
|
+
export interface MobileDeviceConfig {
|
|
3
|
+
deviceId: string;
|
|
4
|
+
deviceToken: string;
|
|
5
|
+
name: string;
|
|
6
|
+
platform: MobilePlatform;
|
|
7
|
+
/** udid for iOS, adb serial for Android. */
|
|
8
|
+
target: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function getMobileDevicesDir(): string;
|
|
11
|
+
export declare function getMobileDeviceConfigPath(deviceId: string): string;
|
|
12
|
+
export declare function writeMobileDeviceConfig(config: MobileDeviceConfig): string;
|
|
13
|
+
export declare function readMobileDeviceConfig(deviceId: string): MobileDeviceConfig | null;
|
|
14
|
+
export declare function listMobileDeviceConfigs(): MobileDeviceConfig[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAximoDir } from "../device/paths.js";
|
|
4
|
+
export function getMobileDevicesDir() {
|
|
5
|
+
return join(getAximoDir(), "mobile-devices");
|
|
6
|
+
}
|
|
7
|
+
export function getMobileDeviceConfigPath(deviceId) {
|
|
8
|
+
return join(getMobileDevicesDir(), `${deviceId}.json`);
|
|
9
|
+
}
|
|
10
|
+
export function writeMobileDeviceConfig(config) {
|
|
11
|
+
const dir = getMobileDevicesDir();
|
|
12
|
+
mkdirSync(dir, { recursive: true });
|
|
13
|
+
const path = getMobileDeviceConfigPath(config.deviceId);
|
|
14
|
+
writeFileSync(path, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
15
|
+
try {
|
|
16
|
+
chmodSync(path, 0o600);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// chmod may fail on Windows; file-level ACLs apply instead.
|
|
20
|
+
}
|
|
21
|
+
return path;
|
|
22
|
+
}
|
|
23
|
+
function parseConfig(raw) {
|
|
24
|
+
if (!raw || typeof raw !== "object")
|
|
25
|
+
return null;
|
|
26
|
+
const c = raw;
|
|
27
|
+
if (typeof c.deviceId !== "string" ||
|
|
28
|
+
typeof c.deviceToken !== "string" ||
|
|
29
|
+
typeof c.name !== "string" ||
|
|
30
|
+
(c.platform !== "ios" && c.platform !== "android") ||
|
|
31
|
+
typeof c.target !== "string") {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
deviceId: c.deviceId,
|
|
36
|
+
deviceToken: c.deviceToken,
|
|
37
|
+
name: c.name,
|
|
38
|
+
platform: c.platform,
|
|
39
|
+
target: c.target,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function readMobileDeviceConfig(deviceId) {
|
|
43
|
+
const path = getMobileDeviceConfigPath(deviceId);
|
|
44
|
+
if (!existsSync(path))
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
return parseConfig(JSON.parse(readFileSync(path, "utf-8")));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function listMobileDeviceConfigs() {
|
|
54
|
+
const dir = getMobileDevicesDir();
|
|
55
|
+
if (!existsSync(dir))
|
|
56
|
+
return [];
|
|
57
|
+
const result = [];
|
|
58
|
+
for (const file of readdirSync(dir)) {
|
|
59
|
+
if (!file.endsWith(".json"))
|
|
60
|
+
continue;
|
|
61
|
+
try {
|
|
62
|
+
const parsed = parseConfig(JSON.parse(readFileSync(join(dir, file), "utf-8")));
|
|
63
|
+
if (parsed)
|
|
64
|
+
result.push(parsed);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// skip unreadable entries
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ClientDeps } from "../../client.js";
|
|
2
|
+
import type { Envelope } from "../../types.js";
|
|
3
|
+
import { type MobilePlatform } from "./paths.js";
|
|
4
|
+
export interface MobileRegisterResult {
|
|
5
|
+
deviceId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
platform: MobilePlatform;
|
|
8
|
+
target: string;
|
|
9
|
+
configPath: string;
|
|
10
|
+
}
|
|
11
|
+
export interface MobileRegisterOptions {
|
|
12
|
+
name: string;
|
|
13
|
+
platform: MobilePlatform;
|
|
14
|
+
target: string;
|
|
15
|
+
os?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Shared registration: POST /api/devices/register and persist the config. */
|
|
18
|
+
export declare function registerMobileDevice(options: MobileRegisterOptions, deps?: ClientDeps): Promise<Envelope<MobileRegisterResult>>;
|
|
19
|
+
export declare function parsePlatformArg(value: string | undefined): MobilePlatform | null;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { request } from "../../client.js";
|
|
2
|
+
import { handleApiError } from "../../errors.js";
|
|
3
|
+
import { formatError, formatSuccess } from "../../output.js";
|
|
4
|
+
import { writeMobileDeviceConfig } from "./paths.js";
|
|
5
|
+
/** Shared registration: POST /api/devices/register and persist the config. */
|
|
6
|
+
export async function registerMobileDevice(options, deps) {
|
|
7
|
+
if (!options.name) {
|
|
8
|
+
return formatError("validation_error", "Device name is required (--name).", false);
|
|
9
|
+
}
|
|
10
|
+
if (!options.target) {
|
|
11
|
+
return formatError("validation_error", options.platform === "ios"
|
|
12
|
+
? "Could not resolve a device target (--udid)."
|
|
13
|
+
: "Could not resolve a device target (--serial).", false);
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const body = {
|
|
17
|
+
name: options.name,
|
|
18
|
+
platform: options.platform,
|
|
19
|
+
};
|
|
20
|
+
if (options.os)
|
|
21
|
+
body["os"] = options.os;
|
|
22
|
+
const res = await request("/api/devices/register", { method: "POST", body }, deps ?? {});
|
|
23
|
+
const configPath = writeMobileDeviceConfig({
|
|
24
|
+
deviceId: res.deviceId,
|
|
25
|
+
deviceToken: res.deviceToken,
|
|
26
|
+
name: options.name,
|
|
27
|
+
platform: options.platform,
|
|
28
|
+
target: options.target,
|
|
29
|
+
});
|
|
30
|
+
return formatSuccess({
|
|
31
|
+
deviceId: res.deviceId,
|
|
32
|
+
name: options.name,
|
|
33
|
+
platform: options.platform,
|
|
34
|
+
target: options.target,
|
|
35
|
+
configPath,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return handleApiError(err);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function parsePlatformArg(value) {
|
|
43
|
+
if (value === "ios" || value === "android")
|
|
44
|
+
return value;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ClientDeps } from "../../client.js";
|
|
2
|
+
import type { Envelope } from "../../types.js";
|
|
3
|
+
import type { ExecFn } from "../../mobile-control/exec.js";
|
|
4
|
+
import { type MobileRegisterResult } from "./register-core.js";
|
|
5
|
+
export interface MobileRegisterCmdOptions {
|
|
6
|
+
name: string;
|
|
7
|
+
platform: string;
|
|
8
|
+
serial?: string | undefined;
|
|
9
|
+
udid?: string | undefined;
|
|
10
|
+
os?: string | undefined;
|
|
11
|
+
}
|
|
12
|
+
export declare function runMobileRegister(options: MobileRegisterCmdOptions, deps?: ClientDeps, exec?: ExecFn): Promise<Envelope<MobileRegisterResult>>;
|
|
13
|
+
declare const _default: import("citty").CommandDef<{
|
|
14
|
+
readonly name: {
|
|
15
|
+
readonly type: "string";
|
|
16
|
+
readonly description: "Device name";
|
|
17
|
+
readonly required: true;
|
|
18
|
+
};
|
|
19
|
+
readonly platform: {
|
|
20
|
+
readonly type: "string";
|
|
21
|
+
readonly description: "Device platform (`android` or `ios`)";
|
|
22
|
+
readonly default: "android";
|
|
23
|
+
};
|
|
24
|
+
readonly serial: {
|
|
25
|
+
readonly type: "string";
|
|
26
|
+
readonly description: "Android adb serial (defaults to the connected one)";
|
|
27
|
+
};
|
|
28
|
+
readonly udid: {
|
|
29
|
+
readonly type: "string";
|
|
30
|
+
readonly description: "iOS device udid (defaults to the connected one)";
|
|
31
|
+
};
|
|
32
|
+
readonly os: {
|
|
33
|
+
readonly type: "string";
|
|
34
|
+
readonly description: "Optional OS version label";
|
|
35
|
+
};
|
|
36
|
+
}>;
|
|
37
|
+
export default _default;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import {} from "../../client.js";
|
|
3
|
+
import { formatError, printResult } from "../../output.js";
|
|
4
|
+
import { resolveAndroidSerial, resolveIosUdid } from "../../mobile-control/devices.js";
|
|
5
|
+
import { parsePlatformArg, registerMobileDevice, } from "./register-core.js";
|
|
6
|
+
export async function runMobileRegister(options, deps, exec) {
|
|
7
|
+
const platform = parsePlatformArg(options.platform);
|
|
8
|
+
if (!platform) {
|
|
9
|
+
return formatError("validation_error", "Invalid --platform; expected `android` or `ios`.", false);
|
|
10
|
+
}
|
|
11
|
+
if (!options.name) {
|
|
12
|
+
return formatError("validation_error", "Device name is required (--name).", false);
|
|
13
|
+
}
|
|
14
|
+
// Resolve the device target: an adb serial for Android, a udid for iOS.
|
|
15
|
+
let target;
|
|
16
|
+
try {
|
|
17
|
+
target =
|
|
18
|
+
platform === "ios"
|
|
19
|
+
? await resolveIosUdid(options.udid, exec)
|
|
20
|
+
: await resolveAndroidSerial(options.serial, exec);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
return formatError("validation_error", err instanceof Error ? err.message : String(err), false);
|
|
24
|
+
}
|
|
25
|
+
return registerMobileDevice({
|
|
26
|
+
name: options.name,
|
|
27
|
+
platform,
|
|
28
|
+
target,
|
|
29
|
+
...(options.os ? { os: options.os } : {}),
|
|
30
|
+
}, deps);
|
|
31
|
+
}
|
|
32
|
+
export default defineCommand({
|
|
33
|
+
meta: {
|
|
34
|
+
name: "register",
|
|
35
|
+
description: "Register a physically-connected Android phone or iPhone as an Aximo device",
|
|
36
|
+
},
|
|
37
|
+
args: {
|
|
38
|
+
name: { type: "string", description: "Device name", required: true },
|
|
39
|
+
platform: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "Device platform (`android` or `ios`)",
|
|
42
|
+
default: "android",
|
|
43
|
+
},
|
|
44
|
+
serial: { type: "string", description: "Android adb serial (defaults to the connected one)" },
|
|
45
|
+
udid: { type: "string", description: "iOS device udid (defaults to the connected one)" },
|
|
46
|
+
os: { type: "string", description: "Optional OS version label" },
|
|
47
|
+
},
|
|
48
|
+
async run({ args }) {
|
|
49
|
+
printResult(await runMobileRegister({
|
|
50
|
+
name: args.name,
|
|
51
|
+
platform: args.platform,
|
|
52
|
+
serial: args.serial,
|
|
53
|
+
udid: args.udid,
|
|
54
|
+
os: args.os,
|
|
55
|
+
}));
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { server, http, HttpResponse, TEST_BASE_URL } from "../../test-utils.js";
|
|
6
|
+
import { runMobileRegister } from "./register.js";
|
|
7
|
+
let tempHome;
|
|
8
|
+
beforeAll(() => {
|
|
9
|
+
tempHome = mkdtempSync(join(tmpdir(), "aximo-mob-reg-"));
|
|
10
|
+
process.env["AXIMO_HOME"] = tempHome;
|
|
11
|
+
process.env["AXIMO_API_KEY"] = "test-key";
|
|
12
|
+
process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
|
|
13
|
+
server.listen();
|
|
14
|
+
});
|
|
15
|
+
afterAll(() => {
|
|
16
|
+
delete process.env["AXIMO_HOME"];
|
|
17
|
+
server.close();
|
|
18
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
22
|
+
mkdirSync(tempHome, { recursive: true });
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => server.resetHandlers());
|
|
25
|
+
const oneAndroidDevice = async () => ({
|
|
26
|
+
stdout: Buffer.from("List of devices attached\nR58M12345 device model:Pixel_7\n"),
|
|
27
|
+
stderr: "",
|
|
28
|
+
});
|
|
29
|
+
const oneIosDevice = async (file, args) => {
|
|
30
|
+
if (file === "idb" && args[0] === "list-targets") {
|
|
31
|
+
return {
|
|
32
|
+
stdout: Buffer.from(`${JSON.stringify({
|
|
33
|
+
udid: "00008030-ABCDEF",
|
|
34
|
+
name: "Abdul's iPhone",
|
|
35
|
+
type: "device",
|
|
36
|
+
state: "connected",
|
|
37
|
+
})}\n`),
|
|
38
|
+
stderr: "",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`unexpected exec: ${file} ${args.join(" ")}`);
|
|
42
|
+
};
|
|
43
|
+
describe("runMobileRegister", () => {
|
|
44
|
+
it("posts the resolved android serial and writes config", async () => {
|
|
45
|
+
let body;
|
|
46
|
+
server.use(http.post(`${TEST_BASE_URL}/api/devices/register`, async ({ request }) => {
|
|
47
|
+
body = (await request.json());
|
|
48
|
+
return HttpResponse.json({ deviceId: "dev-9", deviceToken: "tok-9" }, { status: 201 });
|
|
49
|
+
}));
|
|
50
|
+
const result = await runMobileRegister({ name: "My Pixel", platform: "android" }, undefined, oneAndroidDevice);
|
|
51
|
+
expect(result.ok).toBe(true);
|
|
52
|
+
if (!result.ok)
|
|
53
|
+
return;
|
|
54
|
+
expect(body).toMatchObject({
|
|
55
|
+
name: "My Pixel",
|
|
56
|
+
platform: "android",
|
|
57
|
+
});
|
|
58
|
+
expect(body).not.toHaveProperty("category");
|
|
59
|
+
expect(result.data.target).toBe("R58M12345");
|
|
60
|
+
const configPath = join(tempHome, "mobile-devices", "dev-9.json");
|
|
61
|
+
expect(existsSync(configPath)).toBe(true);
|
|
62
|
+
const saved = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
63
|
+
expect(saved).toMatchObject({ platform: "android", target: "R58M12345" });
|
|
64
|
+
expect(saved).not.toHaveProperty("category");
|
|
65
|
+
});
|
|
66
|
+
it("posts the resolved iOS udid and writes config", async () => {
|
|
67
|
+
let body;
|
|
68
|
+
server.use(http.post(`${TEST_BASE_URL}/api/devices/register`, async ({ request }) => {
|
|
69
|
+
body = (await request.json());
|
|
70
|
+
return HttpResponse.json({ deviceId: "dev-ios", deviceToken: "tok-ios" }, { status: 201 });
|
|
71
|
+
}));
|
|
72
|
+
const result = await runMobileRegister({ name: "iPhone", platform: "ios" }, undefined, oneIosDevice);
|
|
73
|
+
expect(result.ok).toBe(true);
|
|
74
|
+
if (!result.ok)
|
|
75
|
+
return;
|
|
76
|
+
expect(body).toMatchObject({ name: "iPhone", platform: "ios" });
|
|
77
|
+
expect(result.data.target).toBe("00008030-ABCDEF");
|
|
78
|
+
const configPath = join(tempHome, "mobile-devices", "dev-ios.json");
|
|
79
|
+
expect(existsSync(configPath)).toBe(true);
|
|
80
|
+
const saved = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
81
|
+
expect(saved).toMatchObject({ platform: "ios", target: "00008030-ABCDEF" });
|
|
82
|
+
});
|
|
83
|
+
it("rejects an unknown platform", async () => {
|
|
84
|
+
const result = await runMobileRegister({ name: "x", platform: "windows" });
|
|
85
|
+
expect(result.ok).toBe(false);
|
|
86
|
+
if (!result.ok) {
|
|
87
|
+
expect(result.error.code).toBe("validation_error");
|
|
88
|
+
expect(result.error.message).toContain("Invalid --platform");
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Envelope } from "../../types.js";
|
|
2
|
+
import { type MobileDoctorReport } from "./doctor.js";
|
|
3
|
+
export interface DriverSetupResult {
|
|
4
|
+
driver: string;
|
|
5
|
+
alreadyInstalled: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface SetupResult {
|
|
8
|
+
appiumHome: string;
|
|
9
|
+
drivers: DriverSetupResult[];
|
|
10
|
+
}
|
|
11
|
+
export type DriverInstaller = (driver: string, appiumHome: string) => Promise<void>;
|
|
12
|
+
export interface RunMobileSetupOptions {
|
|
13
|
+
platform?: "ios" | "android" | undefined;
|
|
14
|
+
install?: DriverInstaller;
|
|
15
|
+
appiumHome?: string;
|
|
16
|
+
doctor?: (options: {
|
|
17
|
+
platform?: "ios" | "android" | undefined;
|
|
18
|
+
}) => Promise<Envelope<MobileDoctorReport>>;
|
|
19
|
+
}
|
|
20
|
+
export declare function runMobileSetup(options?: RunMobileSetupOptions): Promise<Envelope<SetupResult>>;
|
|
21
|
+
declare const _default: import("citty").CommandDef<{
|
|
22
|
+
readonly platform: {
|
|
23
|
+
readonly type: "string";
|
|
24
|
+
readonly description: "Limit to ios | android driver";
|
|
25
|
+
};
|
|
26
|
+
}>;
|
|
27
|
+
export default _default;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { formatError, formatSuccess, printResult } from "../../output.js";
|
|
7
|
+
import { getAppiumHome } from "../../mobile-control/appium-server.js";
|
|
8
|
+
import { runMobileDoctor } from "./doctor.js";
|
|
9
|
+
const DRIVER_PACKAGE = {
|
|
10
|
+
uiautomator2: "appium-uiautomator2-driver",
|
|
11
|
+
xcuitest: "appium-xcuitest-driver",
|
|
12
|
+
};
|
|
13
|
+
function resolveAppiumBin() {
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
return require.resolve("appium");
|
|
16
|
+
}
|
|
17
|
+
const defaultInstaller = (driver, appiumHome) => new Promise((resolve, reject) => {
|
|
18
|
+
execFile(process.execPath, [resolveAppiumBin(), "driver", "install", driver], { env: { ...process.env, APPIUM_HOME: appiumHome }, maxBuffer: 16 * 1024 * 1024 }, (error, _stdout, stderr) => {
|
|
19
|
+
if (error) {
|
|
20
|
+
reject(new Error(stderr?.toString().trim() || error.message));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
resolve();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
function isInstalled(driver, appiumHome) {
|
|
27
|
+
const pkg = DRIVER_PACKAGE[driver];
|
|
28
|
+
return pkg ? existsSync(join(appiumHome, "node_modules", pkg)) : false;
|
|
29
|
+
}
|
|
30
|
+
// After installing drivers, tell the user what is still required to drive a real
|
|
31
|
+
// device (adb / Xcode / Developer Mode) rather than implying setup is complete.
|
|
32
|
+
function printNextSteps(checks) {
|
|
33
|
+
const blockers = checks.filter((c) => c.status === "fail" || c.status === "warn");
|
|
34
|
+
const notes = checks.filter((c) => c.status === "info");
|
|
35
|
+
if (blockers.length === 0) {
|
|
36
|
+
console.error("\nAll required prerequisites are satisfied.");
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
console.error("\nDrivers installed, but you still need to set up:");
|
|
40
|
+
for (const c of blockers) {
|
|
41
|
+
console.error(` - ${c.message}`);
|
|
42
|
+
if (c.hint)
|
|
43
|
+
console.error(` ${c.hint}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const c of notes) {
|
|
47
|
+
console.error(`\nNote: ${c.message}`);
|
|
48
|
+
if (c.hint)
|
|
49
|
+
console.error(` ${c.hint}`);
|
|
50
|
+
}
|
|
51
|
+
console.error("\nNext: aximo mobile register --platform android -> aximo mobile connect (recheck: aximo mobile doctor)");
|
|
52
|
+
}
|
|
53
|
+
export async function runMobileSetup(options = {}) {
|
|
54
|
+
const appiumHome = options.appiumHome ?? getAppiumHome();
|
|
55
|
+
const install = options.install ?? defaultInstaller;
|
|
56
|
+
mkdirSync(appiumHome, { recursive: true });
|
|
57
|
+
const drivers = [];
|
|
58
|
+
if (options.platform !== "ios")
|
|
59
|
+
drivers.push("uiautomator2");
|
|
60
|
+
if (options.platform !== "android")
|
|
61
|
+
drivers.push("xcuitest");
|
|
62
|
+
const results = [];
|
|
63
|
+
for (const driver of drivers) {
|
|
64
|
+
if (isInstalled(driver, appiumHome)) {
|
|
65
|
+
console.error(`Appium driver "${driver}" already installed.`);
|
|
66
|
+
results.push({ driver, alreadyInstalled: true });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
console.error(`Installing Appium driver "${driver}"...`);
|
|
70
|
+
try {
|
|
71
|
+
await install(driver, appiumHome);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
return formatError("api_error", `Failed to install Appium driver "${driver}": ${err instanceof Error ? err.message : String(err)}`, true);
|
|
75
|
+
}
|
|
76
|
+
console.error(`Installed Appium driver "${driver}".`);
|
|
77
|
+
results.push({ driver, alreadyInstalled: false });
|
|
78
|
+
}
|
|
79
|
+
// Surface remaining prerequisites as concise guidance; `doctor` owns the full report.
|
|
80
|
+
const runDoctor = options.doctor ?? runMobileDoctor;
|
|
81
|
+
const report = await runDoctor({ platform: options.platform });
|
|
82
|
+
printNextSteps(report.ok ? report.data.checks : []);
|
|
83
|
+
return formatSuccess({ appiumHome, drivers: results });
|
|
84
|
+
}
|
|
85
|
+
export default defineCommand({
|
|
86
|
+
meta: {
|
|
87
|
+
name: "setup",
|
|
88
|
+
description: "Install the Appium drivers used to drive registered phones",
|
|
89
|
+
},
|
|
90
|
+
args: {
|
|
91
|
+
platform: { type: "string", description: "Limit to ios | android driver" },
|
|
92
|
+
},
|
|
93
|
+
async run({ args }) {
|
|
94
|
+
const options = {};
|
|
95
|
+
if (args.platform === "ios" || args.platform === "android")
|
|
96
|
+
options.platform = args.platform;
|
|
97
|
+
const result = await runMobileSetup(options);
|
|
98
|
+
printResult(result);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdtempSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { runMobileSetup } from "./setup.js";
|
|
6
|
+
function fakeDoctor(checks) {
|
|
7
|
+
return async () => ({
|
|
8
|
+
ok: true,
|
|
9
|
+
data: { checks, summary: { pass: 0, warn: 0, fail: 0, info: 0 } },
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
describe("runMobileSetup", () => {
|
|
13
|
+
it("installs both drivers and runs the prerequisite check", async () => {
|
|
14
|
+
const appiumHome = mkdtempSync(join(tmpdir(), "aximo-setup-"));
|
|
15
|
+
const installed = [];
|
|
16
|
+
let doctorCalled = false;
|
|
17
|
+
const res = await runMobileSetup({
|
|
18
|
+
appiumHome,
|
|
19
|
+
install: async (driver) => {
|
|
20
|
+
installed.push(driver);
|
|
21
|
+
},
|
|
22
|
+
doctor: async () => {
|
|
23
|
+
doctorCalled = true;
|
|
24
|
+
return fakeDoctor([])();
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
expect(installed).toEqual(["uiautomator2", "xcuitest"]);
|
|
28
|
+
expect(doctorCalled).toBe(true);
|
|
29
|
+
expect(res.ok).toBe(true);
|
|
30
|
+
if (res.ok) {
|
|
31
|
+
expect(res.data.drivers.map((d) => d.driver)).toEqual(["uiautomator2", "xcuitest"]);
|
|
32
|
+
// checks are surfaced as guidance, not embedded in setup's JSON result
|
|
33
|
+
expect(res.data).not.toHaveProperty("checks");
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
it("limits to one driver with a platform filter", async () => {
|
|
37
|
+
const appiumHome = mkdtempSync(join(tmpdir(), "aximo-setup-"));
|
|
38
|
+
const installed = [];
|
|
39
|
+
await runMobileSetup({
|
|
40
|
+
appiumHome,
|
|
41
|
+
platform: "android",
|
|
42
|
+
install: async (driver) => {
|
|
43
|
+
installed.push(driver);
|
|
44
|
+
},
|
|
45
|
+
doctor: fakeDoctor([]),
|
|
46
|
+
});
|
|
47
|
+
expect(installed).toEqual(["uiautomator2"]);
|
|
48
|
+
});
|
|
49
|
+
});
|