@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,73 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
6
|
+
import { runDeviceUnregister } from "./unregister.js";
|
|
7
|
+
let tempHome;
|
|
8
|
+
beforeAll(() => {
|
|
9
|
+
tempHome = mkdtempSync(join(tmpdir(), "aximo-unregister-"));
|
|
10
|
+
process.env["AXIMO_HOME"] = tempHome;
|
|
11
|
+
process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
|
|
12
|
+
process.env["AXIMO_API_KEY"] = "test-key";
|
|
13
|
+
server.listen();
|
|
14
|
+
});
|
|
15
|
+
afterAll(() => {
|
|
16
|
+
delete process.env["AXIMO_HOME"];
|
|
17
|
+
delete process.env["AXIMO_BASE_URL"];
|
|
18
|
+
delete process.env["AXIMO_API_KEY"];
|
|
19
|
+
server.close();
|
|
20
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
24
|
+
mkdirSync(tempHome, { recursive: true });
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => server.resetHandlers());
|
|
27
|
+
function devicePath() {
|
|
28
|
+
return join(tempHome, "device.json");
|
|
29
|
+
}
|
|
30
|
+
function seedDevice(cfg) {
|
|
31
|
+
writeFileSync(devicePath(), JSON.stringify(cfg), { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
describe("runDeviceUnregister", () => {
|
|
34
|
+
it("deletes the server device and removes the local file", async () => {
|
|
35
|
+
seedDevice({ deviceId: "dev-1", deviceToken: "t", name: "x" });
|
|
36
|
+
server.use(http.delete(`${TEST_BASE_URL}/api/devices/user/dev-1`, () => HttpResponse.json({ success: true })));
|
|
37
|
+
const result = await runDeviceUnregister({});
|
|
38
|
+
expect(result.ok).toBe(true);
|
|
39
|
+
if (result.ok)
|
|
40
|
+
expect(result.data.serverDeleted).toBe(true);
|
|
41
|
+
expect(existsSync(devicePath())).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
it("clears the local file even when the server returns 404", async () => {
|
|
44
|
+
seedDevice({ deviceId: "dev-gone", deviceToken: "t", name: "x" });
|
|
45
|
+
server.use(http.delete(`${TEST_BASE_URL}/api/devices/user/dev-gone`, () => HttpResponse.json({ error: "not found" }, { status: 404 })));
|
|
46
|
+
const result = await runDeviceUnregister({});
|
|
47
|
+
expect(result.ok).toBe(true);
|
|
48
|
+
if (result.ok)
|
|
49
|
+
expect(result.data.serverDeleted).toBe(false);
|
|
50
|
+
expect(existsSync(devicePath())).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
it("keeps the local file and errors on a non-404 server error", async () => {
|
|
53
|
+
seedDevice({ deviceId: "dev-1", deviceToken: "t", name: "x" });
|
|
54
|
+
server.use(http.delete(`${TEST_BASE_URL}/api/devices/user/dev-1`, () => HttpResponse.json({ error: "boom" }, { status: 500 })));
|
|
55
|
+
const result = await runDeviceUnregister({});
|
|
56
|
+
expect(result.ok).toBe(false);
|
|
57
|
+
expect(existsSync(devicePath())).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
it("errors when no device id is given and no local config exists", async () => {
|
|
60
|
+
const result = await runDeviceUnregister({});
|
|
61
|
+
expect(result.ok).toBe(false);
|
|
62
|
+
if (!result.ok)
|
|
63
|
+
expect(result.error.code).toBe("validation_error");
|
|
64
|
+
});
|
|
65
|
+
it("uses an explicit device id without removing a differing local file", async () => {
|
|
66
|
+
seedDevice({ deviceId: "local-1", deviceToken: "t", name: "x" });
|
|
67
|
+
server.use(http.delete(`${TEST_BASE_URL}/api/devices/user/other-2`, () => HttpResponse.json({ success: true })));
|
|
68
|
+
const result = await runDeviceUnregister({ deviceId: "other-2" });
|
|
69
|
+
expect(result.ok).toBe(true);
|
|
70
|
+
// The local registration is for a different device, so it stays.
|
|
71
|
+
expect(existsSync(devicePath())).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ErrorEnvelope } from "../../types.js";
|
|
2
|
+
import type { MobileController } from "../../mobile-control/controller.js";
|
|
3
|
+
import { type MobileDeviceConfig } from "./paths.js";
|
|
4
|
+
export declare function getTunnelOrigin(): string;
|
|
5
|
+
interface ConfigOk {
|
|
6
|
+
ok: true;
|
|
7
|
+
config: MobileDeviceConfig;
|
|
8
|
+
}
|
|
9
|
+
export declare function resolveDeviceConfig(deviceId: string | undefined): ConfigOk | ErrorEnvelope;
|
|
10
|
+
export declare function buildController(config: MobileDeviceConfig, endpoint: string): MobileController;
|
|
11
|
+
export declare function connectWithConfig(config: MobileDeviceConfig): Promise<void>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getBaseUrl } from "../../client.js";
|
|
2
|
+
import { formatError } from "../../output.js";
|
|
3
|
+
import { AppiumController } from "../../mobile-control/appium-controller.js";
|
|
4
|
+
import { installUploadedApp } from "../../mobile-control/app-install.js";
|
|
5
|
+
import { startAppiumServer } from "../../mobile-control/appium-server.js";
|
|
6
|
+
import { resolveIosSigning } from "../../mobile-control/ios-signing.js";
|
|
7
|
+
import { runControlTunnel } from "../../mobile-control/tunnel.js";
|
|
8
|
+
import { runMobileDoctor } from "./doctor.js";
|
|
9
|
+
import { listMobileDeviceConfigs, readMobileDeviceConfig, } from "./paths.js";
|
|
10
|
+
export function getTunnelOrigin() {
|
|
11
|
+
return getBaseUrl().replace(/^http/, "ws");
|
|
12
|
+
}
|
|
13
|
+
export function resolveDeviceConfig(deviceId) {
|
|
14
|
+
if (deviceId) {
|
|
15
|
+
const config = readMobileDeviceConfig(deviceId);
|
|
16
|
+
if (!config) {
|
|
17
|
+
return formatError("not_found", `No registered device with id '${deviceId}'.`, false);
|
|
18
|
+
}
|
|
19
|
+
return { ok: true, config };
|
|
20
|
+
}
|
|
21
|
+
const all = listMobileDeviceConfigs();
|
|
22
|
+
if (all.length === 0) {
|
|
23
|
+
return formatError("validation_error", 'No registered devices. Register one first: aximo mobile register --name "My Pixel" --platform android', false);
|
|
24
|
+
}
|
|
25
|
+
if (all.length > 1) {
|
|
26
|
+
return formatError("validation_error", `Multiple devices registered; pass --device-id. Registered: ${all
|
|
27
|
+
.map((c) => `${c.name} (${c.deviceId})`)
|
|
28
|
+
.join(", ")}`, false);
|
|
29
|
+
}
|
|
30
|
+
const only = all[0];
|
|
31
|
+
if (!only) {
|
|
32
|
+
return formatError("validation_error", "No registered devices.", false);
|
|
33
|
+
}
|
|
34
|
+
return { ok: true, config: only };
|
|
35
|
+
}
|
|
36
|
+
export function buildController(config, endpoint) {
|
|
37
|
+
return new AppiumController({
|
|
38
|
+
endpoint,
|
|
39
|
+
platform: config.platform,
|
|
40
|
+
target: config.target,
|
|
41
|
+
...(config.platform === "ios" ? { iosSigning: resolveIosSigning() } : {}),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// A blocking check is any hard failure, plus a missing automation driver
|
|
45
|
+
// (only a warning for `doctor`, but a hard prerequisite for connecting).
|
|
46
|
+
function isBlockingCheck(check, driverName) {
|
|
47
|
+
if (check.status === "fail")
|
|
48
|
+
return true;
|
|
49
|
+
return check.name === driverName && check.status === "warn";
|
|
50
|
+
}
|
|
51
|
+
async function assertPrerequisites(platform) {
|
|
52
|
+
const driverName = platform === "ios" ? "xcuitest driver" : "uiautomator2 driver";
|
|
53
|
+
const report = await runMobileDoctor({ platform });
|
|
54
|
+
const checks = report.ok ? report.data.checks : [];
|
|
55
|
+
const blockers = checks.filter((c) => isBlockingCheck(c, driverName));
|
|
56
|
+
if (blockers.length === 0)
|
|
57
|
+
return;
|
|
58
|
+
const lines = blockers.map((c) => ` - ${c.message}${c.hint ? `\n ${c.hint}` : ""}`);
|
|
59
|
+
const setup = platform === "ios" ? "aximo mobile setup --platform ios" : "aximo mobile setup";
|
|
60
|
+
const doctor = platform === "ios" ? "aximo mobile doctor --platform ios" : "aximo mobile doctor";
|
|
61
|
+
throw new Error(`Missing ${platform} prerequisites:\n${lines.join("\n")}\n` +
|
|
62
|
+
`Run \`${setup}\` and/or fix the above, then re-run. (\`${doctor}\` for details.)`);
|
|
63
|
+
}
|
|
64
|
+
export async function connectWithConfig(config) {
|
|
65
|
+
await assertPrerequisites(config.platform);
|
|
66
|
+
const useDrivers = config.platform === "ios" ? ["xcuitest"] : ["uiautomator2"];
|
|
67
|
+
const server = await startAppiumServer({ useDrivers });
|
|
68
|
+
const controller = buildController(config, server.endpoint);
|
|
69
|
+
try {
|
|
70
|
+
await runControlTunnel({
|
|
71
|
+
wsUrl: getTunnelOrigin(),
|
|
72
|
+
deviceToken: config.deviceToken,
|
|
73
|
+
deviceName: config.name,
|
|
74
|
+
controller,
|
|
75
|
+
installApp: async ({ uploadId }) => {
|
|
76
|
+
await installUploadedApp({
|
|
77
|
+
uploadId,
|
|
78
|
+
platform: config.platform,
|
|
79
|
+
target: config.target,
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await controller.close();
|
|
86
|
+
await server.stop();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MobileDeviceConfig } from "./paths.js";
|
|
2
|
+
export declare function iosPhysicalWarning(config: MobileDeviceConfig): string | null;
|
|
3
|
+
declare const _default: import("citty").CommandDef<{
|
|
4
|
+
readonly "device-id": {
|
|
5
|
+
readonly type: "string";
|
|
6
|
+
readonly description: "Registered device id (required if multiple)";
|
|
7
|
+
};
|
|
8
|
+
}>;
|
|
9
|
+
export default _default;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { formatError, printResult } from "../../output.js";
|
|
3
|
+
import { connectWithConfig, resolveDeviceConfig } from "./connect-core.js";
|
|
4
|
+
const IOS_PHYSICAL_WARNING = "Driving a physical iPhone goes through Appium's XCUITest driver, which builds and " +
|
|
5
|
+
"installs a WebDriverAgent runner on the device. The device must have Developer Mode " +
|
|
6
|
+
"enabled (Settings → Privacy & Security) and be trusted, and WDA needs an Apple Developer " +
|
|
7
|
+
"signing identity; if control fails, run `aximo mobile doctor` for guidance.";
|
|
8
|
+
export function iosPhysicalWarning(config) {
|
|
9
|
+
return config.platform === "ios" ? IOS_PHYSICAL_WARNING : null;
|
|
10
|
+
}
|
|
11
|
+
export default defineCommand({
|
|
12
|
+
meta: {
|
|
13
|
+
name: "connect",
|
|
14
|
+
description: "Connect a registered phone to Aximo via a CLI-managed Appium server",
|
|
15
|
+
},
|
|
16
|
+
args: {
|
|
17
|
+
"device-id": { type: "string", description: "Registered device id (required if multiple)" },
|
|
18
|
+
},
|
|
19
|
+
async run({ args }) {
|
|
20
|
+
const resolved = resolveDeviceConfig(args["device-id"]);
|
|
21
|
+
if (!resolved.ok) {
|
|
22
|
+
printResult(resolved);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const warning = iosPhysicalWarning(resolved.config);
|
|
26
|
+
if (warning)
|
|
27
|
+
console.error(warning);
|
|
28
|
+
try {
|
|
29
|
+
await connectWithConfig(resolved.config);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
printResult(formatError("api_error", error instanceof Error ? error.message : String(error), true));
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { resolveDeviceConfig } from "./connect-core.js";
|
|
6
|
+
import { iosPhysicalWarning } from "./connect.js";
|
|
7
|
+
import { writeMobileDeviceConfig } from "./paths.js";
|
|
8
|
+
let tempHome;
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
tempHome = mkdtempSync(join(tmpdir(), "aximo-mob-connect-"));
|
|
11
|
+
process.env["AXIMO_HOME"] = tempHome;
|
|
12
|
+
});
|
|
13
|
+
afterAll(() => {
|
|
14
|
+
delete process.env["AXIMO_HOME"];
|
|
15
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
19
|
+
mkdirSync(tempHome, { recursive: true });
|
|
20
|
+
});
|
|
21
|
+
describe("iosPhysicalWarning", () => {
|
|
22
|
+
it("returns an Appium/WDA guidance warning for iOS configs", () => {
|
|
23
|
+
const warning = iosPhysicalWarning({
|
|
24
|
+
deviceId: "d1",
|
|
25
|
+
deviceToken: "t",
|
|
26
|
+
name: "iPhone",
|
|
27
|
+
platform: "ios",
|
|
28
|
+
target: "udid",
|
|
29
|
+
});
|
|
30
|
+
expect(warning).toContain("WebDriverAgent");
|
|
31
|
+
expect(warning).toContain("Developer Mode");
|
|
32
|
+
});
|
|
33
|
+
it("returns null for android configs (no warning)", () => {
|
|
34
|
+
const warning = iosPhysicalWarning({
|
|
35
|
+
deviceId: "d2",
|
|
36
|
+
deviceToken: "t",
|
|
37
|
+
name: "Pixel",
|
|
38
|
+
platform: "android",
|
|
39
|
+
target: "serial",
|
|
40
|
+
});
|
|
41
|
+
expect(warning).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("resolveDeviceConfig", () => {
|
|
45
|
+
it("returns validation_error when none are registered", () => {
|
|
46
|
+
const result = resolveDeviceConfig(undefined);
|
|
47
|
+
expect(result.ok).toBe(false);
|
|
48
|
+
if (!result.ok)
|
|
49
|
+
expect(result.error.code).toBe("validation_error");
|
|
50
|
+
});
|
|
51
|
+
it("auto-selects the only registered device", () => {
|
|
52
|
+
writeMobileDeviceConfig({
|
|
53
|
+
deviceId: "only-1",
|
|
54
|
+
deviceToken: "t",
|
|
55
|
+
name: "Solo",
|
|
56
|
+
platform: "android",
|
|
57
|
+
target: "serial-1",
|
|
58
|
+
});
|
|
59
|
+
const result = resolveDeviceConfig(undefined);
|
|
60
|
+
expect(result.ok).toBe(true);
|
|
61
|
+
if (result.ok)
|
|
62
|
+
expect(result.config.deviceId).toBe("only-1");
|
|
63
|
+
});
|
|
64
|
+
it("requires --device-id when multiple are registered", () => {
|
|
65
|
+
for (const id of ["a", "b"]) {
|
|
66
|
+
writeMobileDeviceConfig({
|
|
67
|
+
deviceId: id,
|
|
68
|
+
deviceToken: "t",
|
|
69
|
+
name: id,
|
|
70
|
+
platform: "android",
|
|
71
|
+
target: `serial-${id}`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const ambiguous = resolveDeviceConfig(undefined);
|
|
75
|
+
expect(ambiguous.ok).toBe(false);
|
|
76
|
+
const picked = resolveDeviceConfig("b");
|
|
77
|
+
expect(picked.ok).toBe(true);
|
|
78
|
+
if (picked.ok)
|
|
79
|
+
expect(picked.config.deviceId).toBe("b");
|
|
80
|
+
});
|
|
81
|
+
it("returns not_found for an unknown device id", () => {
|
|
82
|
+
const result = resolveDeviceConfig("ghost");
|
|
83
|
+
expect(result.ok).toBe(false);
|
|
84
|
+
if (!result.ok)
|
|
85
|
+
expect(result.error.code).toBe("not_found");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Envelope } from "../../types.js";
|
|
2
|
+
export type CheckStatus = "pass" | "warn" | "fail" | "info";
|
|
3
|
+
export interface Check {
|
|
4
|
+
name: string;
|
|
5
|
+
status: CheckStatus;
|
|
6
|
+
message: string;
|
|
7
|
+
hint?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface MobileDoctorReport {
|
|
10
|
+
checks: Check[];
|
|
11
|
+
summary: {
|
|
12
|
+
pass: number;
|
|
13
|
+
warn: number;
|
|
14
|
+
fail: number;
|
|
15
|
+
info: number;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export interface RunMobileDoctorOptions {
|
|
19
|
+
platform?: "ios" | "android" | undefined;
|
|
20
|
+
}
|
|
21
|
+
export declare function runMobileDoctor(options?: RunMobileDoctorOptions): Promise<Envelope<MobileDoctorReport>>;
|
|
22
|
+
declare const _default: import("citty").CommandDef<{
|
|
23
|
+
readonly platform: {
|
|
24
|
+
readonly type: "string";
|
|
25
|
+
readonly description: "Limit checks to ios | android";
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
export default _default;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { platform } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { formatSuccess, printResult } from "../../output.js";
|
|
7
|
+
import { importAppium } from "../../mobile-control/appium-import.js";
|
|
8
|
+
import { getAppiumHome, resolveAndroidSdk } from "../../mobile-control/appium-server.js";
|
|
9
|
+
import { resolveIosSigning } from "../../mobile-control/ios-signing.js";
|
|
10
|
+
function which(cmd) {
|
|
11
|
+
const probe = platform() === "win32" ? "where" : "which";
|
|
12
|
+
const result = spawnSync(probe, [cmd], { encoding: "utf-8" });
|
|
13
|
+
return result.status === 0 ? (result.stdout || "").split("\n")[0]?.trim() || cmd : null;
|
|
14
|
+
}
|
|
15
|
+
function driverInstalled(driver) {
|
|
16
|
+
return existsSync(join(getAppiumHome(), "node_modules", `appium-${driver}-driver`));
|
|
17
|
+
}
|
|
18
|
+
function adbInstallHint() {
|
|
19
|
+
switch (platform()) {
|
|
20
|
+
case "darwin":
|
|
21
|
+
return "Install Android platform-tools (brew install --cask android-platform-tools).";
|
|
22
|
+
case "linux":
|
|
23
|
+
return "Install Android platform-tools (apt install android-tools-adb, or your distro's adb package).";
|
|
24
|
+
case "win32":
|
|
25
|
+
return "Install platform-tools via the Android Studio SDK Manager.";
|
|
26
|
+
default:
|
|
27
|
+
return "Install Android platform-tools (adb).";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function checkAppium() {
|
|
31
|
+
try {
|
|
32
|
+
const mod = await importAppium();
|
|
33
|
+
if (typeof mod.main === "function") {
|
|
34
|
+
return { name: "appium", status: "pass", message: "bundled Appium server is importable" };
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
name: "appium",
|
|
38
|
+
status: "fail",
|
|
39
|
+
message: "appium is installed but exposes no programmatic `main`",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
return {
|
|
44
|
+
name: "appium",
|
|
45
|
+
status: "fail",
|
|
46
|
+
message: err instanceof Error ? err.message : String(err),
|
|
47
|
+
hint: "Reinstall the CLI; the bundled Appium dependency appears to be missing.",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function checkAndroid() {
|
|
52
|
+
const checks = [];
|
|
53
|
+
checks.push(driverInstalled("uiautomator2")
|
|
54
|
+
? { name: "uiautomator2 driver", status: "pass", message: "installed" }
|
|
55
|
+
: {
|
|
56
|
+
name: "uiautomator2 driver",
|
|
57
|
+
status: "warn",
|
|
58
|
+
message: "Appium UiAutomator2 driver not installed",
|
|
59
|
+
hint: "Run `aximo mobile setup` to install Appium drivers.",
|
|
60
|
+
});
|
|
61
|
+
const adb = which("adb");
|
|
62
|
+
checks.push(adb
|
|
63
|
+
? { name: "adb", status: "pass", message: adb }
|
|
64
|
+
: {
|
|
65
|
+
name: "adb",
|
|
66
|
+
status: "fail",
|
|
67
|
+
message: "adb not found in PATH",
|
|
68
|
+
hint: `${adbInstallHint()} Appium delegates to adb.`,
|
|
69
|
+
});
|
|
70
|
+
const sdk = resolveAndroidSdk();
|
|
71
|
+
checks.push(sdk
|
|
72
|
+
? { name: "android sdk", status: "pass", message: sdk }
|
|
73
|
+
: {
|
|
74
|
+
name: "android sdk",
|
|
75
|
+
status: "fail",
|
|
76
|
+
message: "Android SDK not found (ANDROID_HOME/ANDROID_SDK_ROOT unset)",
|
|
77
|
+
hint: "Set ANDROID_HOME to your Android SDK — Appium's UiAutomator2 driver requires it even though adb is on PATH — or install it via Android Studio.",
|
|
78
|
+
});
|
|
79
|
+
return checks;
|
|
80
|
+
}
|
|
81
|
+
function checkIos() {
|
|
82
|
+
if (platform() !== "darwin") {
|
|
83
|
+
return [
|
|
84
|
+
{ name: "ios", status: "info", message: "iOS device control is only available on macOS" },
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
const checks = [];
|
|
88
|
+
checks.push(driverInstalled("xcuitest")
|
|
89
|
+
? { name: "xcuitest driver", status: "pass", message: "installed" }
|
|
90
|
+
: {
|
|
91
|
+
name: "xcuitest driver",
|
|
92
|
+
status: "warn",
|
|
93
|
+
message: "Appium XCUITest driver not installed",
|
|
94
|
+
hint: "Run `aximo mobile setup` to install Appium drivers.",
|
|
95
|
+
});
|
|
96
|
+
const xcrun = which("xcrun");
|
|
97
|
+
checks.push(xcrun
|
|
98
|
+
? { name: "xcrun", status: "pass", message: xcrun }
|
|
99
|
+
: {
|
|
100
|
+
name: "xcrun",
|
|
101
|
+
status: "fail",
|
|
102
|
+
message: "xcrun not found",
|
|
103
|
+
hint: "Install Xcode command line tools: xcode-select --install. Appium's XCUITest driver delegates to Xcode and cannot run without it.",
|
|
104
|
+
});
|
|
105
|
+
const idb = which("idb");
|
|
106
|
+
checks.push(idb
|
|
107
|
+
? { name: "idb", status: "pass", message: idb }
|
|
108
|
+
: {
|
|
109
|
+
name: "idb",
|
|
110
|
+
status: "warn",
|
|
111
|
+
message: "idb not found",
|
|
112
|
+
hint: "Install idb (brew install idb-companion && pipx install fb-idb). It's used to discover connected iPhones and install .ipa builds.",
|
|
113
|
+
});
|
|
114
|
+
const teamId = resolveIosSigning().teamId;
|
|
115
|
+
checks.push(teamId
|
|
116
|
+
? { name: "ios signing", status: "pass", message: `signing team id is set (${teamId})` }
|
|
117
|
+
: {
|
|
118
|
+
name: "ios signing",
|
|
119
|
+
status: "warn",
|
|
120
|
+
message: "no signing team id is set",
|
|
121
|
+
hint: "Driving a physical iPhone needs an Apple Developer Team ID to sign WebDriverAgent. Set AXIMO_IOS_TEAM_ID (or MobileLink's XCODE_TEAM_ID), and optionally AXIMO_IOS_SIGNING_ID / AXIMO_IOS_WDA_BUNDLE_ID.",
|
|
122
|
+
});
|
|
123
|
+
checks.push({
|
|
124
|
+
name: "ios webdriveragent",
|
|
125
|
+
status: "info",
|
|
126
|
+
message: "Physical iPhones are driven via Appium's XCUITest driver, which builds and installs a WebDriverAgent runner on the device.",
|
|
127
|
+
hint: "Enable Developer Mode (Settings → Privacy & Security), trust this computer, and ensure the WDA bundle id is backed by a valid provisioning profile.",
|
|
128
|
+
});
|
|
129
|
+
return checks;
|
|
130
|
+
}
|
|
131
|
+
export async function runMobileDoctor(options = {}) {
|
|
132
|
+
const checks = [await checkAppium()];
|
|
133
|
+
if (options.platform !== "ios") {
|
|
134
|
+
checks.push(...checkAndroid());
|
|
135
|
+
}
|
|
136
|
+
if (options.platform !== "android") {
|
|
137
|
+
checks.push(...checkIos());
|
|
138
|
+
}
|
|
139
|
+
const summary = { pass: 0, warn: 0, fail: 0, info: 0 };
|
|
140
|
+
for (const c of checks)
|
|
141
|
+
summary[c.status]++;
|
|
142
|
+
return formatSuccess({ checks, summary });
|
|
143
|
+
}
|
|
144
|
+
export default defineCommand({
|
|
145
|
+
meta: {
|
|
146
|
+
name: "doctor",
|
|
147
|
+
description: "Diagnose Appium prerequisites (bundled server, drivers, adb/Xcode delegates)",
|
|
148
|
+
},
|
|
149
|
+
args: {
|
|
150
|
+
platform: { type: "string", description: "Limit checks to ios | android" },
|
|
151
|
+
},
|
|
152
|
+
async run({ args }) {
|
|
153
|
+
const options = {};
|
|
154
|
+
if (args.platform === "ios" || args.platform === "android")
|
|
155
|
+
options.platform = args.platform;
|
|
156
|
+
const result = await runMobileDoctor(options);
|
|
157
|
+
printResult(result);
|
|
158
|
+
if (result.ok && result.data.summary.fail > 0) {
|
|
159
|
+
process.exitCode = 1;
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
export default defineCommand({
|
|
3
|
+
meta: {
|
|
4
|
+
name: "mobile",
|
|
5
|
+
description: "Register and connect a phone to Aximo via a CLI-managed Appium server",
|
|
6
|
+
},
|
|
7
|
+
subCommands: {
|
|
8
|
+
list: () => import("./list.js").then((m) => m.default),
|
|
9
|
+
register: () => import("./register.js").then((m) => m.default),
|
|
10
|
+
install: () => import("./install.js").then((m) => m.default),
|
|
11
|
+
unregister: () => import("./unregister.js").then((m) => m.default),
|
|
12
|
+
connect: () => import("./connect.js").then((m) => m.default),
|
|
13
|
+
setup: () => import("./setup.js").then((m) => m.default),
|
|
14
|
+
doctor: () => import("./doctor.js").then((m) => m.default),
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ExecFn } from "../../mobile-control/exec.js";
|
|
2
|
+
import type { Envelope } from "../../types.js";
|
|
3
|
+
import type { MobilePlatform } from "./paths.js";
|
|
4
|
+
export interface MobileInstallResult {
|
|
5
|
+
platform: MobilePlatform;
|
|
6
|
+
/** adb serial (Android). */
|
|
7
|
+
serial?: string;
|
|
8
|
+
/** device udid (iOS). */
|
|
9
|
+
udid?: string;
|
|
10
|
+
app: string;
|
|
11
|
+
output: string;
|
|
12
|
+
}
|
|
13
|
+
export interface MobileInstallOptions {
|
|
14
|
+
app?: string | undefined;
|
|
15
|
+
platform?: string | undefined;
|
|
16
|
+
serial?: string | undefined;
|
|
17
|
+
udid?: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Install an app on the connected phone: Android `.apk` via `adb install -r`,
|
|
21
|
+
* iOS `.ipa` via `idb install`. The iOS build must be signed for the device.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runMobileInstall(options: MobileInstallOptions, exec?: ExecFn): Promise<Envelope<MobileInstallResult>>;
|
|
24
|
+
declare const _default: import("citty").CommandDef<{
|
|
25
|
+
readonly app: {
|
|
26
|
+
readonly type: "string";
|
|
27
|
+
readonly description: "Path to the .apk or .ipa to install";
|
|
28
|
+
readonly required: true;
|
|
29
|
+
};
|
|
30
|
+
readonly platform: {
|
|
31
|
+
readonly type: "string";
|
|
32
|
+
readonly description: "android | ios (inferred from the file extension)";
|
|
33
|
+
};
|
|
34
|
+
readonly serial: {
|
|
35
|
+
readonly type: "string";
|
|
36
|
+
readonly description: "Android adb serial (defaults to the connected one)";
|
|
37
|
+
};
|
|
38
|
+
readonly udid: {
|
|
39
|
+
readonly type: "string";
|
|
40
|
+
readonly description: "iOS device udid (defaults to the connected one)";
|
|
41
|
+
};
|
|
42
|
+
}>;
|
|
43
|
+
export default _default;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { extname, resolve } from "node:path";
|
|
3
|
+
import { defineCommand } from "citty";
|
|
4
|
+
import { resolveAndroidSerial, resolveIosUdid } from "../../mobile-control/devices.js";
|
|
5
|
+
import { execBinary } from "../../mobile-control/exec.js";
|
|
6
|
+
import { formatError, formatSuccess, printResult } from "../../output.js";
|
|
7
|
+
import { parsePlatformArg } from "./register-core.js";
|
|
8
|
+
function inferPlatform(appPath) {
|
|
9
|
+
const ext = extname(appPath).toLowerCase();
|
|
10
|
+
if (ext === ".apk")
|
|
11
|
+
return "android";
|
|
12
|
+
if (ext === ".ipa")
|
|
13
|
+
return "ios";
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Install an app on the connected phone: Android `.apk` via `adb install -r`,
|
|
18
|
+
* iOS `.ipa` via `idb install`. The iOS build must be signed for the device.
|
|
19
|
+
*/
|
|
20
|
+
export async function runMobileInstall(options, exec = execBinary) {
|
|
21
|
+
if (!options.app) {
|
|
22
|
+
return formatError("validation_error", "App path is required (--app <path-to-.apk|.ipa>).", false);
|
|
23
|
+
}
|
|
24
|
+
const appPath = resolve(options.app);
|
|
25
|
+
if (!existsSync(appPath)) {
|
|
26
|
+
return formatError("validation_error", `App not found: ${appPath}`, false);
|
|
27
|
+
}
|
|
28
|
+
const platform = options.platform ? parsePlatformArg(options.platform) : inferPlatform(appPath);
|
|
29
|
+
if (!platform) {
|
|
30
|
+
return formatError("validation_error", "Could not determine platform; pass --platform android|ios (or use a .apk/.ipa file).", false);
|
|
31
|
+
}
|
|
32
|
+
let target;
|
|
33
|
+
try {
|
|
34
|
+
target =
|
|
35
|
+
platform === "ios"
|
|
36
|
+
? await resolveIosUdid(options.udid, exec)
|
|
37
|
+
: await resolveAndroidSerial(options.serial, exec);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return formatError("validation_error", err instanceof Error ? err.message : String(err), false);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const { stdout } = platform === "ios"
|
|
44
|
+
? await exec("idb", ["install", "--udid", target, appPath])
|
|
45
|
+
: await exec("adb", ["-s", target, "install", "-r", appPath]);
|
|
46
|
+
return formatSuccess({
|
|
47
|
+
platform,
|
|
48
|
+
...(platform === "ios" ? { udid: target } : { serial: target }),
|
|
49
|
+
app: appPath,
|
|
50
|
+
output: stdout.toString("utf-8").trim(),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
return formatError("api_error", err instanceof Error ? err.message : String(err), false);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export default defineCommand({
|
|
58
|
+
meta: {
|
|
59
|
+
name: "install",
|
|
60
|
+
description: "Install an app on the connected phone (.apk via adb, .ipa via idb)",
|
|
61
|
+
},
|
|
62
|
+
args: {
|
|
63
|
+
app: { type: "string", description: "Path to the .apk or .ipa to install", required: true },
|
|
64
|
+
platform: { type: "string", description: "android | ios (inferred from the file extension)" },
|
|
65
|
+
serial: { type: "string", description: "Android adb serial (defaults to the connected one)" },
|
|
66
|
+
udid: { type: "string", description: "iOS device udid (defaults to the connected one)" },
|
|
67
|
+
},
|
|
68
|
+
async run({ args }) {
|
|
69
|
+
printResult(await runMobileInstall({
|
|
70
|
+
app: args.app,
|
|
71
|
+
platform: args.platform,
|
|
72
|
+
serial: args.serial,
|
|
73
|
+
udid: args.udid,
|
|
74
|
+
}));
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|