@autifyhq/aximo-cli 0.6.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 +79 -64
- package/dist/client.js +16 -1
- package/dist/commands/mobile/connect-core.d.ts +2 -1
- package/dist/commands/mobile/connect-core.js +104 -6
- package/dist/commands/mobile/connect.js +3 -1
- package/dist/commands/mobile/doctor.d.ts +3 -0
- package/dist/commands/mobile/doctor.js +44 -14
- package/dist/commands/mobile/doctor.test.d.ts +1 -0
- package/dist/commands/mobile/doctor.test.js +14 -0
- package/dist/commands/mobile/install.d.ts +9 -2
- package/dist/commands/mobile/install.js +43 -12
- package/dist/commands/mobile/install.test.js +44 -6
- package/dist/commands/mobile/list.d.ts +2 -0
- package/dist/commands/mobile/list.js +17 -11
- package/dist/commands/mobile/unregister.js +11 -6
- package/dist/mobile-control/app-install.d.ts +18 -5
- package/dist/mobile-control/app-install.js +33 -6
- package/dist/mobile-control/app-install.test.js +42 -7
- package/dist/mobile-control/appium-controller.d.ts +8 -0
- package/dist/mobile-control/appium-controller.js +19 -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 +6 -3
- package/dist/mobile-control/devices.js +26 -4
- 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 +33 -5
- package/dist/mobile-control/ios-signing.js +56 -0
- package/dist/mobile-control/ios-signing.test.js +87 -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.js +45 -1
- package/package.json +2 -1
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
2
3
|
import { extname, resolve } from "node:path";
|
|
3
4
|
import { defineCommand } from "citty";
|
|
4
5
|
import { resolveAndroidSerial, resolveIosUdid } from "../../mobile-control/devices.js";
|
|
5
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";
|
|
6
10
|
import { formatError, formatSuccess, printResult } from "../../output.js";
|
|
7
11
|
import { parsePlatformArg } from "./register-core.js";
|
|
8
12
|
function inferPlatform(appPath) {
|
|
@@ -15,9 +19,10 @@ function inferPlatform(appPath) {
|
|
|
15
19
|
}
|
|
16
20
|
/**
|
|
17
21
|
* Install an app on the connected phone: Android `.apk` via `adb install -r`,
|
|
18
|
-
* iOS `.ipa` via
|
|
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).
|
|
19
24
|
*/
|
|
20
|
-
export async function runMobileInstall(options, exec = execBinary) {
|
|
25
|
+
export async function runMobileInstall(options, exec = execBinary, ensureInstallable = ensureInstallableIpa, installIos = installIosApp) {
|
|
21
26
|
if (!options.app) {
|
|
22
27
|
return formatError("validation_error", "App path is required (--app <path-to-.apk|.ipa>).", false);
|
|
23
28
|
}
|
|
@@ -40,24 +45,50 @@ export async function runMobileInstall(options, exec = execBinary) {
|
|
|
40
45
|
return formatError("validation_error", err instanceof Error ? err.message : String(err), false);
|
|
41
46
|
}
|
|
42
47
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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),
|
|
51
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
|
+
}
|
|
52
82
|
}
|
|
53
83
|
catch (err) {
|
|
54
|
-
|
|
84
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
85
|
+
return formatError("api_error", message, false, mobileErrorHint(message, platform));
|
|
55
86
|
}
|
|
56
87
|
}
|
|
57
88
|
export default defineCommand({
|
|
58
89
|
meta: {
|
|
59
90
|
name: "install",
|
|
60
|
-
description: "Install an app on the connected phone (.apk via adb, .ipa
|
|
91
|
+
description: "Install an app on the connected phone (.apk via adb, .ipa auto-re-signed + devicectl)",
|
|
61
92
|
},
|
|
62
93
|
args: {
|
|
63
94
|
app: { type: "string", description: "Path to the .apk or .ipa to install", required: true },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -74,17 +74,55 @@ describe("runMobileInstall", () => {
|
|
|
74
74
|
expect(install().args.slice(0, 2)).toEqual(["-s", "R1"]);
|
|
75
75
|
expect(install().args).toEqual(expect.arrayContaining(["install", "-r"]));
|
|
76
76
|
});
|
|
77
|
-
it("installs an .ipa via
|
|
78
|
-
const { exec
|
|
79
|
-
const
|
|
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);
|
|
80
87
|
expect(res.ok).toBe(true);
|
|
81
88
|
if (res.ok) {
|
|
82
89
|
expect(res.data.platform).toBe("ios");
|
|
83
90
|
expect(res.data.udid).toBe("00008030-ABC");
|
|
84
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");
|
|
85
95
|
}
|
|
86
|
-
expect(
|
|
87
|
-
|
|
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/);
|
|
88
126
|
});
|
|
89
127
|
it("honors an explicit --platform override", async () => {
|
|
90
128
|
const { exec, install } = execWith();
|
|
@@ -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: {
|
|
@@ -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" },
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type ClientDeps } from "../client.js";
|
|
2
2
|
import { type ExecFn } from "./exec.js";
|
|
3
|
+
import { ensureInstallableIpa } from "./ios-auto-resign.js";
|
|
4
|
+
import { installIosApp } from "./ios-devicectl.js";
|
|
3
5
|
export interface InstallUploadedAppOptions {
|
|
4
6
|
/** Aximo upload id of the app under test. */
|
|
5
7
|
uploadId: string;
|
|
@@ -9,13 +11,24 @@ export interface InstallUploadedAppOptions {
|
|
|
9
11
|
exec?: ExecFn;
|
|
10
12
|
/** Client overrides (apiKey/baseUrl/fetchImpl) — used in tests. */
|
|
11
13
|
deps?: ClientDeps;
|
|
14
|
+
/** Progress reporting for the (potentially slow) resign path. */
|
|
15
|
+
log?: (message: string) => void;
|
|
16
|
+
/** Injectable for tests. */
|
|
17
|
+
ensureInstallable?: typeof ensureInstallableIpa;
|
|
18
|
+
installIos?: typeof installIosApp;
|
|
19
|
+
}
|
|
20
|
+
export interface InstallUploadedAppResult {
|
|
21
|
+
output: string;
|
|
22
|
+
/** iOS only: set when the app was auto-re-signed for this device. */
|
|
23
|
+
originalBundleId?: string;
|
|
24
|
+
installedBundleId?: string;
|
|
12
25
|
}
|
|
13
26
|
/**
|
|
14
27
|
* Download the app under test from Aximo (authenticated via the org API key, which
|
|
15
28
|
* redirects to short-lived storage) and install it on the connected device —
|
|
16
|
-
* Android `.apk` via `adb install -r`, iOS `.ipa` via
|
|
17
|
-
*
|
|
29
|
+
* Android `.apk` via `adb install -r`, iOS `.ipa` via devicectl, auto-re-signed for
|
|
30
|
+
* the device first when its signature doesn't already cover it (what AWS Device
|
|
31
|
+
* Farm does server-side). The app binary never crosses the control tunnel; only
|
|
32
|
+
* the opaque upload id does.
|
|
18
33
|
*/
|
|
19
|
-
export declare function installUploadedApp(opts: InstallUploadedAppOptions): Promise<
|
|
20
|
-
output: string;
|
|
21
|
-
}>;
|
|
34
|
+
export declare function installUploadedApp(opts: InstallUploadedAppOptions): Promise<InstallUploadedAppResult>;
|
|
@@ -4,25 +4,52 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { requestToFile } from "../client.js";
|
|
6
6
|
import { execBinary } from "./exec.js";
|
|
7
|
+
import { ensureInstallableIpa } from "./ios-auto-resign.js";
|
|
8
|
+
import { installIosApp } from "./ios-devicectl.js";
|
|
9
|
+
import { withMobileHint } from "./mobile-errors.js";
|
|
7
10
|
/**
|
|
8
11
|
* Download the app under test from Aximo (authenticated via the org API key, which
|
|
9
12
|
* redirects to short-lived storage) and install it on the connected device —
|
|
10
|
-
* Android `.apk` via `adb install -r`, iOS `.ipa` via
|
|
11
|
-
*
|
|
13
|
+
* Android `.apk` via `adb install -r`, iOS `.ipa` via devicectl, auto-re-signed for
|
|
14
|
+
* the device first when its signature doesn't already cover it (what AWS Device
|
|
15
|
+
* Farm does server-side). The app binary never crosses the control tunnel; only
|
|
16
|
+
* the opaque upload id does.
|
|
12
17
|
*/
|
|
13
18
|
export async function installUploadedApp(opts) {
|
|
14
19
|
const exec = opts.exec ?? execBinary;
|
|
15
20
|
const ext = opts.platform === "ios" ? ".ipa" : ".apk";
|
|
16
21
|
const tmpPath = join(tmpdir(), `aximo-install-${randomBytes(8).toString("hex")}${ext}`);
|
|
22
|
+
let resignedPath;
|
|
17
23
|
try {
|
|
18
24
|
// Stream the (authenticated) download straight to disk — apps can be large.
|
|
19
25
|
await requestToFile(`/api/devices/apps/${encodeURIComponent(opts.uploadId)}/download`, tmpPath, opts.deps ?? {});
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
:
|
|
23
|
-
|
|
26
|
+
if (opts.platform === "android") {
|
|
27
|
+
const { stdout } = await exec("adb", ["-s", opts.target, "install", "-r", tmpPath]);
|
|
28
|
+
return { output: stdout.toString("utf-8").trim() };
|
|
29
|
+
}
|
|
30
|
+
const ensured = await (opts.ensureInstallable ?? ensureInstallableIpa)({
|
|
31
|
+
ipaPath: tmpPath,
|
|
32
|
+
udid: opts.target,
|
|
33
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
34
|
+
});
|
|
35
|
+
if (ensured.resigned)
|
|
36
|
+
resignedPath = ensured.ipaPath;
|
|
37
|
+
const output = await (opts.installIos ?? installIosApp)(opts.target, ensured.ipaPath, exec);
|
|
38
|
+
return ensured.resigned
|
|
39
|
+
? {
|
|
40
|
+
output,
|
|
41
|
+
originalBundleId: ensured.originalBundleId,
|
|
42
|
+
installedBundleId: ensured.installedBundleId,
|
|
43
|
+
}
|
|
44
|
+
: { output };
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
48
|
+
throw new Error(withMobileHint(message, opts.platform));
|
|
24
49
|
}
|
|
25
50
|
finally {
|
|
26
51
|
await rm(tmpPath, { force: true }).catch(() => { });
|
|
52
|
+
if (resignedPath)
|
|
53
|
+
await rm(resignedPath, { force: true }).catch(() => { });
|
|
27
54
|
}
|
|
28
55
|
}
|
|
@@ -49,20 +49,55 @@ describe("installUploadedApp", () => {
|
|
|
49
49
|
expect(call?.args.slice(0, 4)).toEqual(["-s", "R1", "install", "-r"]);
|
|
50
50
|
expect(call?.args[4]).toMatch(/\.apk$/);
|
|
51
51
|
});
|
|
52
|
-
it("installs an iOS app via
|
|
53
|
-
const { exec
|
|
52
|
+
it("auto-resigns when needed and installs an iOS app via devicectl", async () => {
|
|
53
|
+
const { exec } = captureExec();
|
|
54
54
|
const { impl } = fakeFetch(true);
|
|
55
|
-
|
|
55
|
+
const ensureInstallable = vi.fn(async (opts) => ({
|
|
56
|
+
ipaPath: opts.ipaPath.replace(/\.ipa$/, "-resigned.ipa"),
|
|
57
|
+
resigned: true,
|
|
58
|
+
originalBundleId: "com.example.app",
|
|
59
|
+
installedBundleId: "com.aximo.resign.team.xctrunner",
|
|
60
|
+
}));
|
|
61
|
+
const installIos = vi.fn(async () => "App installed:");
|
|
62
|
+
const res = await installUploadedApp({
|
|
56
63
|
uploadId: "up-9",
|
|
57
64
|
platform: "ios",
|
|
58
65
|
target: "00008030-ABC",
|
|
59
66
|
exec,
|
|
60
67
|
deps: { ...DEPS, fetchImpl: impl },
|
|
68
|
+
ensureInstallable,
|
|
69
|
+
installIos,
|
|
61
70
|
});
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
expect(
|
|
71
|
+
expect(vi.mocked(ensureInstallable).mock.calls[0]?.[0]).toMatchObject({
|
|
72
|
+
udid: "00008030-ABC",
|
|
73
|
+
});
|
|
74
|
+
expect(installIos).toHaveBeenCalledWith("00008030-ABC", expect.stringMatching(/-resigned\.ipa$/), exec);
|
|
75
|
+
expect(res).toEqual({
|
|
76
|
+
output: "App installed:",
|
|
77
|
+
originalBundleId: "com.example.app",
|
|
78
|
+
installedBundleId: "com.aximo.resign.team.xctrunner",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
it("installs an already-installable iOS app without resign metadata", async () => {
|
|
82
|
+
const { exec } = captureExec();
|
|
83
|
+
const { impl } = fakeFetch(true);
|
|
84
|
+
const ensureInstallable = vi.fn(async (opts) => ({
|
|
85
|
+
ipaPath: opts.ipaPath,
|
|
86
|
+
resigned: false,
|
|
87
|
+
originalBundleId: "com.example.app",
|
|
88
|
+
installedBundleId: "com.example.app",
|
|
89
|
+
}));
|
|
90
|
+
const installIos = vi.fn(async () => "App installed:");
|
|
91
|
+
const res = await installUploadedApp({
|
|
92
|
+
uploadId: "up-9",
|
|
93
|
+
platform: "ios",
|
|
94
|
+
target: "00008030-ABC",
|
|
95
|
+
exec,
|
|
96
|
+
deps: { ...DEPS, fetchImpl: impl },
|
|
97
|
+
ensureInstallable,
|
|
98
|
+
installIos,
|
|
99
|
+
});
|
|
100
|
+
expect(res).toEqual({ output: "App installed:" });
|
|
66
101
|
});
|
|
67
102
|
it("throws when the authenticated download fails", async () => {
|
|
68
103
|
const { exec } = captureExec();
|
|
@@ -6,6 +6,12 @@ export interface AppiumControllerOptions {
|
|
|
6
6
|
platform: "ios" | "android";
|
|
7
7
|
target: string;
|
|
8
8
|
iosSigning?: IosSigningConfig;
|
|
9
|
+
/**
|
|
10
|
+
* Local port XCUITest forwards to WebDriverAgent; unique-per-session avoids
|
|
11
|
+
* collisions. A provider is resolved just before session creation so the port
|
|
12
|
+
* isn't picked long before it is bound.
|
|
13
|
+
*/
|
|
14
|
+
wdaLocalPort?: number | (() => Promise<number>);
|
|
9
15
|
transport?: WebDriverTransport;
|
|
10
16
|
}
|
|
11
17
|
export declare class AppiumController implements MobileController {
|
|
@@ -13,6 +19,8 @@ export declare class AppiumController implements MobileController {
|
|
|
13
19
|
private readonly platform;
|
|
14
20
|
private readonly target;
|
|
15
21
|
private readonly iosSigning;
|
|
22
|
+
private readonly wdaLocalPort;
|
|
23
|
+
private resolvedWdaPort;
|
|
16
24
|
private sessionReady;
|
|
17
25
|
private scaling;
|
|
18
26
|
constructor(opts: AppiumControllerOptions);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { enrichIosSessionError } from "./ios-session-error.js";
|
|
1
2
|
import { iosSigningCapabilities } from "./ios-signing.js";
|
|
2
3
|
import { fetchTransport, WebDriverSession } from "./webdriver.js";
|
|
3
4
|
const ANDROID_KEY_CODES = {
|
|
@@ -26,12 +27,15 @@ export class AppiumController {
|
|
|
26
27
|
platform;
|
|
27
28
|
target;
|
|
28
29
|
iosSigning;
|
|
30
|
+
wdaLocalPort;
|
|
31
|
+
resolvedWdaPort;
|
|
29
32
|
sessionReady = null;
|
|
30
33
|
scaling = null;
|
|
31
34
|
constructor(opts) {
|
|
32
35
|
this.platform = opts.platform;
|
|
33
36
|
this.target = opts.target;
|
|
34
37
|
this.iosSigning = opts.iosSigning;
|
|
38
|
+
this.wdaLocalPort = opts.wdaLocalPort;
|
|
35
39
|
const transport = opts.transport ?? fetchTransport(opts.endpoint);
|
|
36
40
|
this.session = new WebDriverSession(transport);
|
|
37
41
|
}
|
|
@@ -51,17 +55,28 @@ export class AppiumController {
|
|
|
51
55
|
"appium:automationName": "XCUITest",
|
|
52
56
|
"appium:udid": this.target,
|
|
53
57
|
"appium:newCommandTimeout": 0,
|
|
58
|
+
// Unique local->device WDA port so a second connected phone doesn't collide on 8100.
|
|
59
|
+
...(this.resolvedWdaPort ? { "appium:wdaLocalPort": this.resolvedWdaPort } : {}),
|
|
54
60
|
// WDA code-signing caps for physical iPhones (empty unless a team id is set).
|
|
55
61
|
...(this.iosSigning ? iosSigningCapabilities(this.iosSigning) : {}),
|
|
56
62
|
};
|
|
57
63
|
}
|
|
58
64
|
ensureSession() {
|
|
59
65
|
if (!this.sessionReady) {
|
|
60
|
-
this.sessionReady =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
.
|
|
66
|
+
this.sessionReady = (async () => {
|
|
67
|
+
this.resolvedWdaPort =
|
|
68
|
+
typeof this.wdaLocalPort === "function" ? await this.wdaLocalPort() : this.wdaLocalPort;
|
|
69
|
+
await this.session.createSession(this.capabilities());
|
|
70
|
+
})().catch(async (err) => {
|
|
64
71
|
this.sessionReady = null;
|
|
72
|
+
// A retry must re-pick a free port — the one resolved here may be taken by then.
|
|
73
|
+
this.resolvedWdaPort = undefined;
|
|
74
|
+
// XCUITest reports session-start failures generically ("xcodebuild failed
|
|
75
|
+
// with code 65"); enrich with the real reason from the WDA .xcresult and
|
|
76
|
+
// an actionable hint so the user isn't left with an opaque error.
|
|
77
|
+
if (this.platform === "ios") {
|
|
78
|
+
throw await enrichIosSessionError(err, this.iosSigning?.derivedDataPath);
|
|
79
|
+
}
|
|
65
80
|
throw err;
|
|
66
81
|
});
|
|
67
82
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MobileController } from "./controller.js";
|
|
2
|
+
/**
|
|
3
|
+
* Re-signing can remap an app's bundle id (free teams can't always register the
|
|
4
|
+
* original id), but the Aximo backend keeps launching apps by their ORIGINAL id
|
|
5
|
+
* from the upload metadata. This wrapper translates launch targets through the
|
|
6
|
+
* original→installed map that app installs populate, so remapping stays
|
|
7
|
+
* invisible to the cloud side.
|
|
8
|
+
*/
|
|
9
|
+
export declare function withBundleIdTranslation(controller: MobileController, map: ReadonlyMap<string, string>): MobileController;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-signing can remap an app's bundle id (free teams can't always register the
|
|
3
|
+
* original id), but the Aximo backend keeps launching apps by their ORIGINAL id
|
|
4
|
+
* from the upload metadata. This wrapper translates launch targets through the
|
|
5
|
+
* original→installed map that app installs populate, so remapping stays
|
|
6
|
+
* invisible to the cloud side.
|
|
7
|
+
*/
|
|
8
|
+
export function withBundleIdTranslation(controller, map) {
|
|
9
|
+
const wrapped = {
|
|
10
|
+
screenshot: () => controller.screenshot(),
|
|
11
|
+
tap: (x, y) => controller.tap(x, y),
|
|
12
|
+
doubleTap: (x, y) => controller.doubleTap(x, y),
|
|
13
|
+
longPress: (x, y, durationMs) => controller.longPress(x, y, durationMs),
|
|
14
|
+
swipe: (x1, y1, x2, y2, durationMs) => controller.swipe(x1, y1, x2, y2, durationMs),
|
|
15
|
+
typeText: (text) => controller.typeText(text),
|
|
16
|
+
pressKey: (key) => controller.pressKey(key),
|
|
17
|
+
launchApp: (app) => controller.launchApp(map.get(app) ?? app),
|
|
18
|
+
findElement: (text) => controller.findElement(text),
|
|
19
|
+
clearText: () => controller.clearText(),
|
|
20
|
+
hideKeyboard: () => controller.hideKeyboard(),
|
|
21
|
+
close: () => controller.close(),
|
|
22
|
+
};
|
|
23
|
+
if (controller.getDeviceTime) {
|
|
24
|
+
wrapped.getDeviceTime = () => controller.getDeviceTime?.() ?? Promise.resolve(null);
|
|
25
|
+
}
|
|
26
|
+
return wrapped;
|
|
27
|
+
}
|
|
@@ -16,8 +16,11 @@ export interface IosPhysicalDevice {
|
|
|
16
16
|
/** Resolve an iOS udid: explicit (validated), else the single connected device. */
|
|
17
17
|
export declare function resolveIosUdid(preferred: string | undefined, exec?: ExecFn): Promise<string>;
|
|
18
18
|
/**
|
|
19
|
-
* List physically-connected iOS devices
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* List physically-connected iOS devices by MERGING devicectl and idb results
|
|
20
|
+
* (deduped by udid, devicectl wins). devicectl (CoreDevice) only sees iOS 17+
|
|
21
|
+
* devices, so an early-return on its results would make an iOS ≤16 phone vanish
|
|
22
|
+
* whenever a newer one is also attached — idb covers the older devices. idb is
|
|
23
|
+
* optional: not-installed means "no extra devices"; a real idb failure is only
|
|
24
|
+
* surfaced when devicectl found nothing (otherwise discovery still works).
|
|
22
25
|
*/
|
|
23
26
|
export declare function listIosPhysicalDevices(exec?: ExecFn): Promise<IosPhysicalDevice[]>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execBinary } from "./exec.js";
|
|
2
|
+
import { listIosDevicesViaDevicectl } from "./ios-devicectl.js";
|
|
2
3
|
/** Parse `adb devices -l` into serial + state (+ model when present). */
|
|
3
4
|
export async function listAndroidDevices(exec = execBinary) {
|
|
4
5
|
const { stdout } = await exec("adb", ["devices", "-l"]);
|
|
@@ -77,7 +78,7 @@ export async function resolveIosUdid(preferred, exec = execBinary) {
|
|
|
77
78
|
return devices[0].udid;
|
|
78
79
|
if (devices.length === 0) {
|
|
79
80
|
throw new Error("No connected iOS devices found. Connect an iPhone/iPad over USB, unlock it, and " +
|
|
80
|
-
"trust this computer. Verify with `
|
|
81
|
+
"trust this computer. Verify with `xcrun devicectl list devices` (or `aximo mobile doctor`).");
|
|
81
82
|
}
|
|
82
83
|
throw new Error(`Multiple iOS devices found; pass --udid. Available: ${devices
|
|
83
84
|
.map((d) => `${d.name} (${d.udid})`)
|
|
@@ -90,11 +91,32 @@ function isCommandNotFound(err) {
|
|
|
90
91
|
return err instanceof Error && /\bENOENT\b/.test(err.message);
|
|
91
92
|
}
|
|
92
93
|
/**
|
|
93
|
-
* List physically-connected iOS devices
|
|
94
|
-
*
|
|
95
|
-
*
|
|
94
|
+
* List physically-connected iOS devices by MERGING devicectl and idb results
|
|
95
|
+
* (deduped by udid, devicectl wins). devicectl (CoreDevice) only sees iOS 17+
|
|
96
|
+
* devices, so an early-return on its results would make an iOS ≤16 phone vanish
|
|
97
|
+
* whenever a newer one is also attached — idb covers the older devices. idb is
|
|
98
|
+
* optional: not-installed means "no extra devices"; a real idb failure is only
|
|
99
|
+
* surfaced when devicectl found nothing (otherwise discovery still works).
|
|
96
100
|
*/
|
|
97
101
|
export async function listIosPhysicalDevices(exec = execBinary) {
|
|
102
|
+
const byUdid = new Map();
|
|
103
|
+
for (const d of await listIosDevicesViaDevicectl(exec)) {
|
|
104
|
+
byUdid.set(d.udid, { udid: d.udid, name: d.name ?? d.model ?? d.udid, state: "connected" });
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
for (const d of await listIosViaIdb(exec)) {
|
|
108
|
+
if (!byUdid.has(d.udid))
|
|
109
|
+
byUdid.set(d.udid, d);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (byUdid.size === 0)
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
return [...byUdid.values()];
|
|
117
|
+
}
|
|
118
|
+
/** `idb list-targets --json` (one JSON object per line), physical devices only. */
|
|
119
|
+
async function listIosViaIdb(exec) {
|
|
98
120
|
let stdout;
|
|
99
121
|
try {
|
|
100
122
|
({ stdout } = await exec("idb", ["list-targets", "--json"]));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boolean env-var toggle: set and not an explicit "off" value. Plain truthiness
|
|
3
|
+
* would treat AXIMO_IOS_SKIP_TUNNEL=0 or =false as enabled, which surprises users.
|
|
4
|
+
*/
|
|
5
|
+
export function envFlag(env, name) {
|
|
6
|
+
const value = env[name]?.trim().toLowerCase();
|
|
7
|
+
if (!value)
|
|
8
|
+
return false;
|
|
9
|
+
return !["0", "false", "no", "off"].includes(value);
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { envFlag } from "./env-flags.js";
|
|
3
|
+
describe("envFlag", () => {
|
|
4
|
+
it("is false when unset or empty", () => {
|
|
5
|
+
expect(envFlag({}, "X")).toBe(false);
|
|
6
|
+
expect(envFlag({ X: "" }, "X")).toBe(false);
|
|
7
|
+
expect(envFlag({ X: " " }, "X")).toBe(false);
|
|
8
|
+
});
|
|
9
|
+
it("is true for ordinary truthy values", () => {
|
|
10
|
+
expect(envFlag({ X: "1" }, "X")).toBe(true);
|
|
11
|
+
expect(envFlag({ X: "true" }, "X")).toBe(true);
|
|
12
|
+
expect(envFlag({ X: "yes" }, "X")).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
it("treats explicit off-values as false", () => {
|
|
15
|
+
expect(envFlag({ X: "0" }, "X")).toBe(false);
|
|
16
|
+
expect(envFlag({ X: "false" }, "X")).toBe(false);
|
|
17
|
+
expect(envFlag({ X: "FALSE" }, "X")).toBe(false);
|
|
18
|
+
expect(envFlag({ X: "no" }, "X")).toBe(false);
|
|
19
|
+
expect(envFlag({ X: "off" }, "X")).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|