@autifyhq/aximo-cli 0.5.0 → 0.7.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 +196 -64
- package/dist/cli.js +4 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +33 -0
- package/dist/commands/mobile/connect-core.d.ts +2 -1
- package/dist/commands/mobile/connect-core.js +127 -15
- package/dist/commands/mobile/connect.js +3 -1
- package/dist/commands/mobile/doctor.d.ts +3 -0
- package/dist/commands/mobile/doctor.js +55 -6
- package/dist/commands/mobile/doctor.test.d.ts +1 -0
- package/dist/commands/mobile/doctor.test.js +14 -0
- package/dist/commands/mobile/index.js +1 -0
- package/dist/commands/mobile/install.d.ts +50 -0
- package/dist/commands/mobile/install.js +107 -0
- package/dist/commands/mobile/install.test.d.ts +1 -0
- package/dist/commands/mobile/install.test.js +142 -0
- package/dist/commands/mobile/list.d.ts +2 -0
- package/dist/commands/mobile/list.js +17 -11
- package/dist/commands/mobile/register.d.ts +6 -1
- package/dist/commands/mobile/register.js +12 -6
- package/dist/commands/mobile/register.test.js +34 -3
- package/dist/commands/mobile/unregister.js +11 -6
- package/dist/commands/schema.js +15 -8
- package/dist/mobile-control/app-install.d.ts +34 -0
- package/dist/mobile-control/app-install.js +55 -0
- package/dist/mobile-control/app-install.test.d.ts +1 -0
- package/dist/mobile-control/app-install.test.js +113 -0
- package/dist/mobile-control/appium-controller.d.ts +11 -0
- package/dist/mobile-control/appium-controller.js +24 -4
- package/dist/mobile-control/bundle-id-map.d.ts +9 -0
- package/dist/mobile-control/bundle-id-map.js +27 -0
- package/dist/mobile-control/devices.d.ts +8 -3
- package/dist/mobile-control/devices.js +57 -5
- package/dist/mobile-control/devices.test.js +41 -1
- package/dist/mobile-control/env-flags.d.ts +5 -0
- package/dist/mobile-control/env-flags.js +10 -0
- package/dist/mobile-control/env-flags.test.d.ts +1 -0
- package/dist/mobile-control/env-flags.test.js +21 -0
- package/dist/mobile-control/ios-auto-resign.d.ts +45 -0
- package/dist/mobile-control/ios-auto-resign.js +108 -0
- package/dist/mobile-control/ios-auto-resign.test.d.ts +1 -0
- package/dist/mobile-control/ios-auto-resign.test.js +150 -0
- package/dist/mobile-control/ios-devicectl.d.ts +39 -0
- package/dist/mobile-control/ios-devicectl.js +87 -0
- package/dist/mobile-control/ios-devicectl.test.d.ts +1 -0
- package/dist/mobile-control/ios-devicectl.test.js +110 -0
- package/dist/mobile-control/ios-identity.d.ts +27 -0
- package/dist/mobile-control/ios-identity.js +49 -0
- package/dist/mobile-control/ios-identity.test.d.ts +1 -0
- package/dist/mobile-control/ios-identity.test.js +102 -0
- package/dist/mobile-control/ios-mint.d.ts +39 -0
- package/dist/mobile-control/ios-mint.js +78 -0
- package/dist/mobile-control/ios-mint.test.d.ts +1 -0
- package/dist/mobile-control/ios-mint.test.js +94 -0
- package/dist/mobile-control/ios-profiles.d.ts +46 -0
- package/dist/mobile-control/ios-profiles.js +106 -0
- package/dist/mobile-control/ios-profiles.test.d.ts +1 -0
- package/dist/mobile-control/ios-profiles.test.js +154 -0
- package/dist/mobile-control/ios-resign.d.ts +37 -0
- package/dist/mobile-control/ios-resign.js +101 -0
- package/dist/mobile-control/ios-resign.test.d.ts +1 -0
- package/dist/mobile-control/ios-resign.test.js +84 -0
- package/dist/mobile-control/ios-session-error.d.ts +10 -0
- package/dist/mobile-control/ios-session-error.js +34 -0
- package/dist/mobile-control/ios-session-error.test.d.ts +1 -0
- package/dist/mobile-control/ios-session-error.test.js +30 -0
- package/dist/mobile-control/ios-signing.d.ts +56 -0
- package/dist/mobile-control/ios-signing.js +94 -0
- package/dist/mobile-control/ios-signing.test.d.ts +1 -0
- package/dist/mobile-control/ios-signing.test.js +160 -0
- package/dist/mobile-control/ios-tunnel.d.ts +54 -0
- package/dist/mobile-control/ios-tunnel.js +130 -0
- package/dist/mobile-control/ios-tunnel.test.d.ts +1 -0
- package/dist/mobile-control/ios-tunnel.test.js +115 -0
- package/dist/mobile-control/ios-wda-cache.d.ts +39 -0
- package/dist/mobile-control/ios-wda-cache.js +157 -0
- package/dist/mobile-control/ios-wda-cache.test.d.ts +1 -0
- package/dist/mobile-control/ios-wda-cache.test.js +128 -0
- package/dist/mobile-control/ios-wda-diagnostics.d.ts +14 -0
- package/dist/mobile-control/ios-wda-diagnostics.js +138 -0
- package/dist/mobile-control/ios-wda-diagnostics.test.d.ts +1 -0
- package/dist/mobile-control/ios-wda-diagnostics.test.js +77 -0
- package/dist/mobile-control/ios-wda-preinstall.d.ts +64 -0
- package/dist/mobile-control/ios-wda-preinstall.js +162 -0
- package/dist/mobile-control/ios-wda-preinstall.test.d.ts +1 -0
- package/dist/mobile-control/ios-wda-preinstall.test.js +107 -0
- package/dist/mobile-control/mobile-errors.d.ts +12 -0
- package/dist/mobile-control/mobile-errors.js +91 -0
- package/dist/mobile-control/mobile-errors.test.d.ts +1 -0
- package/dist/mobile-control/mobile-errors.test.js +48 -0
- package/dist/mobile-control/tunnel.d.ts +5 -1
- package/dist/mobile-control/tunnel.js +60 -3
- package/dist/mobile-control/tunnel.test.js +1 -1
- 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/package.json +4 -2
|
@@ -6,6 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import { formatSuccess, printResult } from "../../output.js";
|
|
7
7
|
import { importAppium } from "../../mobile-control/appium-import.js";
|
|
8
8
|
import { getAppiumHome, resolveAndroidSdk } from "../../mobile-control/appium-server.js";
|
|
9
|
+
import { resolveIosSigningAuto } from "../../mobile-control/ios-signing.js";
|
|
10
|
+
import { DEFAULT_TUNNEL_REGISTRY_PORT, isTunnelHealthy } from "../../mobile-control/ios-tunnel.js";
|
|
9
11
|
function which(cmd) {
|
|
10
12
|
const probe = platform() === "win32" ? "where" : "which";
|
|
11
13
|
const result = spawnSync(probe, [cmd], { encoding: "utf-8" });
|
|
@@ -77,7 +79,7 @@ function checkAndroid() {
|
|
|
77
79
|
});
|
|
78
80
|
return checks;
|
|
79
81
|
}
|
|
80
|
-
function checkIos() {
|
|
82
|
+
async function checkIos() {
|
|
81
83
|
if (platform() !== "darwin") {
|
|
82
84
|
return [
|
|
83
85
|
{ name: "ios", status: "info", message: "iOS device control is only available on macOS" },
|
|
@@ -97,25 +99,72 @@ function checkIos() {
|
|
|
97
99
|
? { name: "xcrun", status: "pass", message: xcrun }
|
|
98
100
|
: {
|
|
99
101
|
name: "xcrun",
|
|
100
|
-
status: "
|
|
102
|
+
status: "fail",
|
|
101
103
|
message: "xcrun not found",
|
|
102
|
-
hint: "Install Xcode command line tools: xcode-select --install. Appium's XCUITest driver delegates to Xcode.",
|
|
104
|
+
hint: "Install Xcode command line tools: xcode-select --install. Appium's XCUITest driver delegates to Xcode and cannot run without it.",
|
|
105
|
+
});
|
|
106
|
+
const idb = which("idb");
|
|
107
|
+
checks.push(idb
|
|
108
|
+
? { name: "idb", status: "info", message: idb }
|
|
109
|
+
: {
|
|
110
|
+
name: "idb",
|
|
111
|
+
status: "info",
|
|
112
|
+
message: "idb not found (optional)",
|
|
113
|
+
hint: "Device discovery and installs use Xcode's devicectl; idb is only a fallback. Install it with `brew install idb-companion && pipx install fb-idb` if devicectl can't see your device.",
|
|
114
|
+
});
|
|
115
|
+
// Zero-config signing: the identity + team come from the keychain; env vars
|
|
116
|
+
// (AXIMO_IOS_TEAM_ID etc.) are overrides, not requirements.
|
|
117
|
+
const signing = await resolveIosSigningAuto();
|
|
118
|
+
checks.push(signing.teamId
|
|
119
|
+
? {
|
|
120
|
+
name: "ios signing",
|
|
121
|
+
status: "pass",
|
|
122
|
+
message: signing.identityHash
|
|
123
|
+
? `signing identity found (team ${signing.teamId}, WDA id ${signing.wdaBundleId})`
|
|
124
|
+
: `signing team id set (${signing.teamId}) but no matching keychain identity`,
|
|
125
|
+
}
|
|
126
|
+
: {
|
|
127
|
+
name: "ios signing",
|
|
128
|
+
status: "warn",
|
|
129
|
+
message: "no Apple Development signing identity found",
|
|
130
|
+
hint: "Open Xcode → Settings → Accounts, add your Apple ID, then Manage Certificates → + → Apple Development. A free Apple ID works (with weekly re-signing); AXIMO_IOS_TEAM_ID overrides team selection on multi-team machines.",
|
|
103
131
|
});
|
|
104
132
|
checks.push({
|
|
105
133
|
name: "ios webdriveragent",
|
|
106
134
|
status: "info",
|
|
107
|
-
message: "
|
|
108
|
-
hint: "
|
|
135
|
+
message: "On iOS 17+ a prebuilt WebDriverAgent is downloaded, re-signed and preinstalled (no Xcode build); older devices fall back to the Xcode build flow.",
|
|
136
|
+
hint: "One-time on the phone: enable Developer Mode (Settings → Privacy & Security), trust this computer, and trust the developer certificate (Settings → General → VPN & Device Management) after the first install.",
|
|
109
137
|
});
|
|
110
138
|
return checks;
|
|
111
139
|
}
|
|
140
|
+
// The RemoteXPC tunnel is host-level (a registry port), so doctor can probe it
|
|
141
|
+
// without a connected device. `connect` starts it automatically, so a missing
|
|
142
|
+
// tunnel is informational, not a failure.
|
|
143
|
+
export async function checkIosTunnel(isHealthy = isTunnelHealthy) {
|
|
144
|
+
const running = await isHealthy(DEFAULT_TUNNEL_REGISTRY_PORT);
|
|
145
|
+
return running
|
|
146
|
+
? {
|
|
147
|
+
name: "ios tunnel",
|
|
148
|
+
status: "pass",
|
|
149
|
+
message: `RemoteXPC tunnel running (registry port ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
|
|
150
|
+
}
|
|
151
|
+
: {
|
|
152
|
+
name: "ios tunnel",
|
|
153
|
+
status: "info",
|
|
154
|
+
message: "RemoteXPC tunnel not running",
|
|
155
|
+
hint: "iOS 17+ needs it to reach WebDriverAgent; `aximo mobile connect` starts it automatically (prompts for admin).",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
112
158
|
export async function runMobileDoctor(options = {}) {
|
|
113
159
|
const checks = [await checkAppium()];
|
|
114
160
|
if (options.platform !== "ios") {
|
|
115
161
|
checks.push(...checkAndroid());
|
|
116
162
|
}
|
|
117
163
|
if (options.platform !== "android") {
|
|
118
|
-
checks.push(...checkIos());
|
|
164
|
+
checks.push(...(await checkIos()));
|
|
165
|
+
if (platform() === "darwin") {
|
|
166
|
+
checks.push(await checkIosTunnel(options.isTunnelHealthy));
|
|
167
|
+
}
|
|
119
168
|
}
|
|
120
169
|
const summary = { pass: 0, warn: 0, fail: 0, info: 0 };
|
|
121
170
|
for (const c of checks)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { checkIosTunnel } from "./doctor.js";
|
|
3
|
+
describe("checkIosTunnel", () => {
|
|
4
|
+
it("passes when the RemoteXPC tunnel is reachable", async () => {
|
|
5
|
+
const check = await checkIosTunnel(async () => true);
|
|
6
|
+
expect(check.status).toBe("pass");
|
|
7
|
+
expect(check.name).toBe("ios tunnel");
|
|
8
|
+
});
|
|
9
|
+
it("is informational (not a failure) when the tunnel is down", async () => {
|
|
10
|
+
const check = await checkIosTunnel(async () => false);
|
|
11
|
+
expect(check.status).toBe("info");
|
|
12
|
+
expect(check.hint).toContain("aximo mobile connect");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -7,6 +7,7 @@ export default defineCommand({
|
|
|
7
7
|
subCommands: {
|
|
8
8
|
list: () => import("./list.js").then((m) => m.default),
|
|
9
9
|
register: () => import("./register.js").then((m) => m.default),
|
|
10
|
+
install: () => import("./install.js").then((m) => m.default),
|
|
10
11
|
unregister: () => import("./unregister.js").then((m) => m.default),
|
|
11
12
|
connect: () => import("./connect.js").then((m) => m.default),
|
|
12
13
|
setup: () => import("./setup.js").then((m) => m.default),
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type ExecFn } from "../../mobile-control/exec.js";
|
|
2
|
+
import { ensureInstallableIpa } from "../../mobile-control/ios-auto-resign.js";
|
|
3
|
+
import { installIosApp } from "../../mobile-control/ios-devicectl.js";
|
|
4
|
+
import type { Envelope } from "../../types.js";
|
|
5
|
+
import type { MobilePlatform } from "./paths.js";
|
|
6
|
+
export interface MobileInstallResult {
|
|
7
|
+
platform: MobilePlatform;
|
|
8
|
+
/** adb serial (Android). */
|
|
9
|
+
serial?: string;
|
|
10
|
+
/** device udid (iOS). */
|
|
11
|
+
udid?: string;
|
|
12
|
+
app: string;
|
|
13
|
+
output: string;
|
|
14
|
+
/** iOS only: set when the app was auto-re-signed for this device. */
|
|
15
|
+
resigned?: boolean;
|
|
16
|
+
originalBundleId?: string;
|
|
17
|
+
installedBundleId?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface MobileInstallOptions {
|
|
20
|
+
app?: string | undefined;
|
|
21
|
+
platform?: string | undefined;
|
|
22
|
+
serial?: string | undefined;
|
|
23
|
+
udid?: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Install an app on the connected phone: Android `.apk` via `adb install -r`,
|
|
27
|
+
* iOS `.ipa` via devicectl — auto-re-signed for the device first when its
|
|
28
|
+
* signature doesn't already cover it (disable with AXIMO_IOS_NO_RESIGN).
|
|
29
|
+
*/
|
|
30
|
+
export declare function runMobileInstall(options: MobileInstallOptions, exec?: ExecFn, ensureInstallable?: typeof ensureInstallableIpa, installIos?: typeof installIosApp): Promise<Envelope<MobileInstallResult>>;
|
|
31
|
+
declare const _default: import("citty").CommandDef<{
|
|
32
|
+
readonly app: {
|
|
33
|
+
readonly type: "string";
|
|
34
|
+
readonly description: "Path to the .apk or .ipa to install";
|
|
35
|
+
readonly required: true;
|
|
36
|
+
};
|
|
37
|
+
readonly platform: {
|
|
38
|
+
readonly type: "string";
|
|
39
|
+
readonly description: "android | ios (inferred from the file extension)";
|
|
40
|
+
};
|
|
41
|
+
readonly serial: {
|
|
42
|
+
readonly type: "string";
|
|
43
|
+
readonly description: "Android adb serial (defaults to the connected one)";
|
|
44
|
+
};
|
|
45
|
+
readonly udid: {
|
|
46
|
+
readonly type: "string";
|
|
47
|
+
readonly description: "iOS device udid (defaults to the connected one)";
|
|
48
|
+
};
|
|
49
|
+
}>;
|
|
50
|
+
export default _default;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { extname, resolve } from "node:path";
|
|
4
|
+
import { defineCommand } from "citty";
|
|
5
|
+
import { resolveAndroidSerial, resolveIosUdid } from "../../mobile-control/devices.js";
|
|
6
|
+
import { execBinary } from "../../mobile-control/exec.js";
|
|
7
|
+
import { ensureInstallableIpa } from "../../mobile-control/ios-auto-resign.js";
|
|
8
|
+
import { installIosApp } from "../../mobile-control/ios-devicectl.js";
|
|
9
|
+
import { mobileErrorHint } from "../../mobile-control/mobile-errors.js";
|
|
10
|
+
import { formatError, formatSuccess, printResult } from "../../output.js";
|
|
11
|
+
import { parsePlatformArg } from "./register-core.js";
|
|
12
|
+
function inferPlatform(appPath) {
|
|
13
|
+
const ext = extname(appPath).toLowerCase();
|
|
14
|
+
if (ext === ".apk")
|
|
15
|
+
return "android";
|
|
16
|
+
if (ext === ".ipa")
|
|
17
|
+
return "ios";
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Install an app on the connected phone: Android `.apk` via `adb install -r`,
|
|
22
|
+
* iOS `.ipa` via devicectl — auto-re-signed for the device first when its
|
|
23
|
+
* signature doesn't already cover it (disable with AXIMO_IOS_NO_RESIGN).
|
|
24
|
+
*/
|
|
25
|
+
export async function runMobileInstall(options, exec = execBinary, ensureInstallable = ensureInstallableIpa, installIos = installIosApp) {
|
|
26
|
+
if (!options.app) {
|
|
27
|
+
return formatError("validation_error", "App path is required (--app <path-to-.apk|.ipa>).", false);
|
|
28
|
+
}
|
|
29
|
+
const appPath = resolve(options.app);
|
|
30
|
+
if (!existsSync(appPath)) {
|
|
31
|
+
return formatError("validation_error", `App not found: ${appPath}`, false);
|
|
32
|
+
}
|
|
33
|
+
const platform = options.platform ? parsePlatformArg(options.platform) : inferPlatform(appPath);
|
|
34
|
+
if (!platform) {
|
|
35
|
+
return formatError("validation_error", "Could not determine platform; pass --platform android|ios (or use a .apk/.ipa file).", false);
|
|
36
|
+
}
|
|
37
|
+
let target;
|
|
38
|
+
try {
|
|
39
|
+
target =
|
|
40
|
+
platform === "ios"
|
|
41
|
+
? await resolveIosUdid(options.udid, exec)
|
|
42
|
+
: await resolveAndroidSerial(options.serial, exec);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return formatError("validation_error", err instanceof Error ? err.message : String(err), false);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
if (platform === "android") {
|
|
49
|
+
const { stdout } = await exec("adb", ["-s", target, "install", "-r", appPath]);
|
|
50
|
+
return formatSuccess({
|
|
51
|
+
platform,
|
|
52
|
+
serial: target,
|
|
53
|
+
app: appPath,
|
|
54
|
+
output: stdout.toString("utf-8").trim(),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const ensured = await ensureInstallable({
|
|
58
|
+
ipaPath: appPath,
|
|
59
|
+
udid: target,
|
|
60
|
+
log: (message) => console.error(message),
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
const output = await installIos(target, ensured.ipaPath, exec);
|
|
64
|
+
return formatSuccess({
|
|
65
|
+
platform,
|
|
66
|
+
udid: target,
|
|
67
|
+
app: appPath,
|
|
68
|
+
output,
|
|
69
|
+
...(ensured.resigned
|
|
70
|
+
? {
|
|
71
|
+
resigned: true,
|
|
72
|
+
originalBundleId: ensured.originalBundleId,
|
|
73
|
+
installedBundleId: ensured.installedBundleId,
|
|
74
|
+
}
|
|
75
|
+
: {}),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
if (ensured.resigned)
|
|
80
|
+
await rm(ensured.ipaPath, { force: true }).catch(() => { });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
85
|
+
return formatError("api_error", message, false, mobileErrorHint(message, platform));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export default defineCommand({
|
|
89
|
+
meta: {
|
|
90
|
+
name: "install",
|
|
91
|
+
description: "Install an app on the connected phone (.apk via adb, .ipa auto-re-signed + devicectl)",
|
|
92
|
+
},
|
|
93
|
+
args: {
|
|
94
|
+
app: { type: "string", description: "Path to the .apk or .ipa to install", required: true },
|
|
95
|
+
platform: { type: "string", description: "android | ios (inferred from the file extension)" },
|
|
96
|
+
serial: { type: "string", description: "Android adb serial (defaults to the connected one)" },
|
|
97
|
+
udid: { type: "string", description: "iOS device udid (defaults to the connected one)" },
|
|
98
|
+
},
|
|
99
|
+
async run({ args }) {
|
|
100
|
+
printResult(await runMobileInstall({
|
|
101
|
+
app: args.app,
|
|
102
|
+
platform: args.platform,
|
|
103
|
+
serial: args.serial,
|
|
104
|
+
udid: args.udid,
|
|
105
|
+
}));
|
|
106
|
+
},
|
|
107
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, vi } 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 devicectl, auto-resigning when needed", async () => {
|
|
78
|
+
const { exec } = execWith();
|
|
79
|
+
const ensureInstallable = vi.fn(async (_opts) => ({
|
|
80
|
+
ipaPath: join(dir, "app-resigned.ipa"),
|
|
81
|
+
resigned: true,
|
|
82
|
+
originalBundleId: "com.example.app",
|
|
83
|
+
installedBundleId: "com.aximo.resign.team.xctrunner",
|
|
84
|
+
}));
|
|
85
|
+
const installIos = vi.fn(async () => "App installed:");
|
|
86
|
+
const res = await runMobileInstall({ app: ipaPath }, exec, ensureInstallable, installIos);
|
|
87
|
+
expect(res.ok).toBe(true);
|
|
88
|
+
if (res.ok) {
|
|
89
|
+
expect(res.data.platform).toBe("ios");
|
|
90
|
+
expect(res.data.udid).toBe("00008030-ABC");
|
|
91
|
+
expect(res.data.serial).toBeUndefined();
|
|
92
|
+
expect(res.data.resigned).toBe(true);
|
|
93
|
+
expect(res.data.originalBundleId).toBe("com.example.app");
|
|
94
|
+
expect(res.data.installedBundleId).toBe("com.aximo.resign.team.xctrunner");
|
|
95
|
+
}
|
|
96
|
+
expect(vi.mocked(ensureInstallable).mock.calls[0]?.[0]).toMatchObject({
|
|
97
|
+
ipaPath,
|
|
98
|
+
udid: "00008030-ABC",
|
|
99
|
+
});
|
|
100
|
+
expect(installIos).toHaveBeenCalledWith("00008030-ABC", join(dir, "app-resigned.ipa"), exec);
|
|
101
|
+
});
|
|
102
|
+
it("omits resign metadata when the .ipa was already installable", async () => {
|
|
103
|
+
const { exec } = execWith();
|
|
104
|
+
const ensureInstallable = vi.fn(async () => ({
|
|
105
|
+
ipaPath,
|
|
106
|
+
resigned: false,
|
|
107
|
+
originalBundleId: "com.example.app",
|
|
108
|
+
installedBundleId: "com.example.app",
|
|
109
|
+
}));
|
|
110
|
+
const installIos = vi.fn(async () => "App installed:");
|
|
111
|
+
const res = await runMobileInstall({ app: ipaPath }, exec, ensureInstallable, installIos);
|
|
112
|
+
expect(res.ok).toBe(true);
|
|
113
|
+
if (res.ok)
|
|
114
|
+
expect(res.data.resigned).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
it("maps an iOS resign failure to an error envelope with hint", async () => {
|
|
117
|
+
const { exec } = execWith();
|
|
118
|
+
const ensureInstallable = vi.fn(async () => {
|
|
119
|
+
throw new Error("app.ipa is FairPlay-encrypted (an App Store download)");
|
|
120
|
+
});
|
|
121
|
+
const installIos = vi.fn(async () => "");
|
|
122
|
+
const res = await runMobileInstall({ app: ipaPath }, exec, ensureInstallable, installIos);
|
|
123
|
+
expect(res.ok).toBe(false);
|
|
124
|
+
if (!res.ok)
|
|
125
|
+
expect(res.error.message).toMatch(/FairPlay/);
|
|
126
|
+
});
|
|
127
|
+
it("honors an explicit --platform override", async () => {
|
|
128
|
+
const { exec, install } = execWith();
|
|
129
|
+
const res = await runMobileInstall({ app: txtPath, platform: "android" }, exec);
|
|
130
|
+
expect(res.ok).toBe(true);
|
|
131
|
+
expect(install().file).toBe("adb");
|
|
132
|
+
});
|
|
133
|
+
it("maps an install failure to an error envelope", async () => {
|
|
134
|
+
const { exec } = execWith({ throwMsg: "adb: failed to install: INSTALL_FAILED_INVALID_APK" });
|
|
135
|
+
const res = await runMobileInstall({ app: apkPath }, exec);
|
|
136
|
+
expect(res.ok).toBe(false);
|
|
137
|
+
if (!res.ok) {
|
|
138
|
+
expect(res.error.code).toBe("api_error");
|
|
139
|
+
expect(res.error.message).toMatch(/INSTALL_FAILED/);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -4,6 +4,8 @@ import type { ExecFn } from "../../mobile-control/exec.js";
|
|
|
4
4
|
export interface MobileListResult {
|
|
5
5
|
androidDevices: AndroidDevice[];
|
|
6
6
|
iosDevices: IosPhysicalDevice[];
|
|
7
|
+
/** Per-toolchain discovery failures (broken adb/idb) — an empty list alone is ambiguous. */
|
|
8
|
+
warnings?: string[];
|
|
7
9
|
}
|
|
8
10
|
export declare function runMobileList(exec?: ExecFn): Promise<Envelope<MobileListResult>>;
|
|
9
11
|
declare const _default: import("citty").CommandDef<{}>;
|
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { formatSuccess, printResult } from "../../output.js";
|
|
3
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
4
|
export async function runMobileList(exec) {
|
|
5
|
+
const warnings = [];
|
|
6
|
+
const safe = async (label, fn, fallback) => {
|
|
7
|
+
try {
|
|
8
|
+
return await fn();
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
warnings.push(`${label}: ${err instanceof Error ? err.message : String(err)}`);
|
|
12
|
+
return fallback;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
13
15
|
const [allAndroid, iosDevices] = await Promise.all([
|
|
14
|
-
safe(() => listAndroidDevices(exec), []),
|
|
15
|
-
safe(() => listIosPhysicalDevices(exec), []),
|
|
16
|
+
safe("android (adb)", () => listAndroidDevices(exec), []),
|
|
17
|
+
safe("ios (idb)", () => listIosPhysicalDevices(exec), []),
|
|
16
18
|
]);
|
|
17
19
|
// Physical phones only: drop emulator serials.
|
|
18
20
|
const androidDevices = allAndroid.filter((d) => !d.serial.startsWith("emulator-"));
|
|
19
|
-
return formatSuccess({
|
|
21
|
+
return formatSuccess({
|
|
22
|
+
androidDevices,
|
|
23
|
+
iosDevices,
|
|
24
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
25
|
+
});
|
|
20
26
|
}
|
|
21
27
|
export default defineCommand({
|
|
22
28
|
meta: {
|
|
@@ -6,6 +6,7 @@ export interface MobileRegisterCmdOptions {
|
|
|
6
6
|
name: string;
|
|
7
7
|
platform: string;
|
|
8
8
|
serial?: string | undefined;
|
|
9
|
+
udid?: string | undefined;
|
|
9
10
|
os?: string | undefined;
|
|
10
11
|
}
|
|
11
12
|
export declare function runMobileRegister(options: MobileRegisterCmdOptions, deps?: ClientDeps, exec?: ExecFn): Promise<Envelope<MobileRegisterResult>>;
|
|
@@ -17,13 +18,17 @@ declare const _default: import("citty").CommandDef<{
|
|
|
17
18
|
};
|
|
18
19
|
readonly platform: {
|
|
19
20
|
readonly type: "string";
|
|
20
|
-
readonly description: "Device platform (
|
|
21
|
+
readonly description: "Device platform (`android` or `ios`)";
|
|
21
22
|
readonly default: "android";
|
|
22
23
|
};
|
|
23
24
|
readonly serial: {
|
|
24
25
|
readonly type: "string";
|
|
25
26
|
readonly description: "Android adb serial (defaults to the connected one)";
|
|
26
27
|
};
|
|
28
|
+
readonly udid: {
|
|
29
|
+
readonly type: "string";
|
|
30
|
+
readonly description: "iOS device udid (defaults to the connected one)";
|
|
31
|
+
};
|
|
27
32
|
readonly os: {
|
|
28
33
|
readonly type: "string";
|
|
29
34
|
readonly description: "Optional OS version label";
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import {} from "../../client.js";
|
|
3
3
|
import { formatError, printResult } from "../../output.js";
|
|
4
|
-
import { resolveAndroidSerial } from "../../mobile-control/devices.js";
|
|
4
|
+
import { resolveAndroidSerial, resolveIosUdid } from "../../mobile-control/devices.js";
|
|
5
5
|
import { parsePlatformArg, registerMobileDevice, } from "./register-core.js";
|
|
6
6
|
export async function runMobileRegister(options, deps, exec) {
|
|
7
7
|
const platform = parsePlatformArg(options.platform);
|
|
8
|
-
if (platform
|
|
9
|
-
return formatError("validation_error", "
|
|
8
|
+
if (!platform) {
|
|
9
|
+
return formatError("validation_error", "Invalid --platform; expected `android` or `ios`.", false);
|
|
10
10
|
}
|
|
11
11
|
if (!options.name) {
|
|
12
12
|
return formatError("validation_error", "Device name is required (--name).", false);
|
|
13
13
|
}
|
|
14
|
+
// Resolve the device target: an adb serial for Android, a udid for iOS.
|
|
14
15
|
let target;
|
|
15
16
|
try {
|
|
16
|
-
target =
|
|
17
|
+
target =
|
|
18
|
+
platform === "ios"
|
|
19
|
+
? await resolveIosUdid(options.udid, exec)
|
|
20
|
+
: await resolveAndroidSerial(options.serial, exec);
|
|
17
21
|
}
|
|
18
22
|
catch (err) {
|
|
19
23
|
return formatError("validation_error", err instanceof Error ? err.message : String(err), false);
|
|
@@ -28,16 +32,17 @@ export async function runMobileRegister(options, deps, exec) {
|
|
|
28
32
|
export default defineCommand({
|
|
29
33
|
meta: {
|
|
30
34
|
name: "register",
|
|
31
|
-
description: "Register a physically-connected Android phone as an Aximo device",
|
|
35
|
+
description: "Register a physically-connected Android phone or iPhone as an Aximo device",
|
|
32
36
|
},
|
|
33
37
|
args: {
|
|
34
38
|
name: { type: "string", description: "Device name", required: true },
|
|
35
39
|
platform: {
|
|
36
40
|
type: "string",
|
|
37
|
-
description: "Device platform (
|
|
41
|
+
description: "Device platform (`android` or `ios`)",
|
|
38
42
|
default: "android",
|
|
39
43
|
},
|
|
40
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)" },
|
|
41
46
|
os: { type: "string", description: "Optional OS version label" },
|
|
42
47
|
},
|
|
43
48
|
async run({ args }) {
|
|
@@ -45,6 +50,7 @@ export default defineCommand({
|
|
|
45
50
|
name: args.name,
|
|
46
51
|
platform: args.platform,
|
|
47
52
|
serial: args.serial,
|
|
53
|
+
udid: args.udid,
|
|
48
54
|
os: args.os,
|
|
49
55
|
}));
|
|
50
56
|
},
|
|
@@ -26,6 +26,20 @@ const oneAndroidDevice = async () => ({
|
|
|
26
26
|
stdout: Buffer.from("List of devices attached\nR58M12345 device model:Pixel_7\n"),
|
|
27
27
|
stderr: "",
|
|
28
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
|
+
};
|
|
29
43
|
describe("runMobileRegister", () => {
|
|
30
44
|
it("posts the resolved android serial and writes config", async () => {
|
|
31
45
|
let body;
|
|
@@ -49,12 +63,29 @@ describe("runMobileRegister", () => {
|
|
|
49
63
|
expect(saved).toMatchObject({ platform: "android", target: "R58M12345" });
|
|
50
64
|
expect(saved).not.toHaveProperty("category");
|
|
51
65
|
});
|
|
52
|
-
it("
|
|
53
|
-
|
|
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" });
|
|
54
85
|
expect(result.ok).toBe(false);
|
|
55
86
|
if (!result.ok) {
|
|
56
87
|
expect(result.error.code).toBe("validation_error");
|
|
57
|
-
expect(result.error.message).toContain("
|
|
88
|
+
expect(result.error.message).toContain("Invalid --platform");
|
|
58
89
|
}
|
|
59
90
|
});
|
|
60
91
|
});
|
|
@@ -12,15 +12,20 @@ export async function runMobileUnregister(options, deps) {
|
|
|
12
12
|
const { deviceId } = resolved.config;
|
|
13
13
|
try {
|
|
14
14
|
await request(`/api/devices/user/${deviceId}`, { method: "DELETE" }, deps ?? {});
|
|
15
|
-
const configPath = getMobileDeviceConfigPath(deviceId);
|
|
16
|
-
if (existsSync(configPath)) {
|
|
17
|
-
unlinkSync(configPath);
|
|
18
|
-
}
|
|
19
|
-
return formatSuccess({ success: true });
|
|
20
15
|
}
|
|
21
16
|
catch (err) {
|
|
22
|
-
|
|
17
|
+
// 404 = the server row is already gone (deleted via the web UI). Still clear
|
|
18
|
+
// the local config, otherwise the device can never be unregistered locally.
|
|
19
|
+
const status = err.status;
|
|
20
|
+
if (status !== 404) {
|
|
21
|
+
return handleApiError(err);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const configPath = getMobileDeviceConfigPath(deviceId);
|
|
25
|
+
if (existsSync(configPath)) {
|
|
26
|
+
unlinkSync(configPath);
|
|
23
27
|
}
|
|
28
|
+
return formatSuccess({ success: true });
|
|
24
29
|
}
|
|
25
30
|
export default defineCommand({
|
|
26
31
|
meta: { name: "unregister", description: "Unregister a local mobile device" },
|