@autifyhq/aximo-cli 0.6.0 → 0.8.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 +88 -70
- package/dist/client.js +16 -1
- package/dist/commands/mobile/connect-core.d.ts +2 -1
- package/dist/commands/mobile/connect-core.js +112 -9
- package/dist/commands/mobile/connect.js +6 -3
- package/dist/commands/mobile/connect.test.js +7 -1
- package/dist/commands/mobile/doctor.d.ts +4 -0
- package/dist/commands/mobile/doctor.js +62 -16
- package/dist/commands/mobile/doctor.test.d.ts +1 -0
- package/dist/commands/mobile/doctor.test.js +28 -0
- package/dist/commands/mobile/install.d.ts +10 -2
- package/dist/commands/mobile/install.js +57 -15
- package/dist/commands/mobile/install.test.js +44 -6
- package/dist/commands/mobile/list.d.ts +3 -0
- package/dist/commands/mobile/list.js +40 -14
- package/dist/commands/mobile/list.test.d.ts +1 -0
- package/dist/commands/mobile/list.test.js +24 -0
- package/dist/commands/mobile/output.d.ts +13 -0
- package/dist/commands/mobile/output.js +21 -0
- package/dist/commands/mobile/output.test.d.ts +1 -0
- package/dist/commands/mobile/output.test.js +54 -0
- package/dist/commands/mobile/register.d.ts +1 -0
- package/dist/commands/mobile/register.js +11 -3
- package/dist/commands/mobile/setup.d.ts +1 -0
- package/dist/commands/mobile/setup.js +7 -2
- package/dist/commands/mobile/unregister.d.ts +7 -3
- package/dist/commands/mobile/unregister.js +20 -9
- 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/exec.d.ts +5 -1
- package/dist/mobile-control/exec.js +2 -2
- 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 +57 -0
- package/dist/mobile-control/ios-tunnel.js +150 -0
- package/dist/mobile-control/ios-tunnel.test.d.ts +1 -0
- package/dist/mobile-control/ios-tunnel.test.js +120 -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 +165 -0
- package/dist/mobile-control/ios-wda-preinstall.test.d.ts +1 -0
- package/dist/mobile-control/ios-wda-preinstall.test.js +111 -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 +79 -2
- package/dist/mobile-control/tunnel.test.js +82 -0
- package/package.json +2 -1
|
@@ -3,7 +3,8 @@ import { execFile } from "node:child_process";
|
|
|
3
3
|
import { existsSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { formatError, formatSuccess
|
|
6
|
+
import { formatError, formatSuccess } from "../../output.js";
|
|
7
|
+
import { printMobileResult } from "./output.js";
|
|
7
8
|
import { getAppiumHome } from "../../mobile-control/appium-server.js";
|
|
8
9
|
import { runMobileDoctor } from "./doctor.js";
|
|
9
10
|
const DRIVER_PACKAGE = {
|
|
@@ -82,6 +83,10 @@ export async function runMobileSetup(options = {}) {
|
|
|
82
83
|
printNextSteps(report.ok ? report.data.checks : []);
|
|
83
84
|
return formatSuccess({ appiumHome, drivers: results });
|
|
84
85
|
}
|
|
86
|
+
export function renderSetupResult(data) {
|
|
87
|
+
const lines = data.drivers.map((d) => ` - ${d.driver}: ${d.alreadyInstalled ? "already installed" : "installed"}`);
|
|
88
|
+
return [`Appium home: ${data.appiumHome}`, ...lines].join("\n");
|
|
89
|
+
}
|
|
85
90
|
export default defineCommand({
|
|
86
91
|
meta: {
|
|
87
92
|
name: "setup",
|
|
@@ -95,6 +100,6 @@ export default defineCommand({
|
|
|
95
100
|
if (args.platform === "ios" || args.platform === "android")
|
|
96
101
|
options.platform = args.platform;
|
|
97
102
|
const result = await runMobileSetup(options);
|
|
98
|
-
|
|
103
|
+
printMobileResult(result, { render: renderSetupResult });
|
|
99
104
|
},
|
|
100
105
|
});
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { type ClientDeps } from "../../client.js";
|
|
2
2
|
import type { Envelope } from "../../types.js";
|
|
3
|
+
export interface MobileUnregisterResult {
|
|
4
|
+
success: boolean;
|
|
5
|
+
deviceId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
}
|
|
3
8
|
export declare function runMobileUnregister(options: {
|
|
4
9
|
deviceId?: string;
|
|
5
|
-
}, deps?: ClientDeps): Promise<Envelope<
|
|
6
|
-
|
|
7
|
-
}>>;
|
|
10
|
+
}, deps?: ClientDeps): Promise<Envelope<MobileUnregisterResult>>;
|
|
11
|
+
export declare function renderUnregisterResult(data: MobileUnregisterResult): string;
|
|
8
12
|
declare const _default: import("citty").CommandDef<{
|
|
9
13
|
readonly "device-id": {
|
|
10
14
|
readonly type: "string";
|
|
@@ -2,25 +2,34 @@ import { defineCommand } from "citty";
|
|
|
2
2
|
import { existsSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { request } from "../../client.js";
|
|
4
4
|
import { handleApiError } from "../../errors.js";
|
|
5
|
-
import { formatSuccess
|
|
5
|
+
import { formatSuccess } from "../../output.js";
|
|
6
6
|
import { resolveDeviceConfig } from "./connect-core.js";
|
|
7
|
+
import { printMobileResult } from "./output.js";
|
|
7
8
|
import { getMobileDeviceConfigPath } from "./paths.js";
|
|
8
9
|
export async function runMobileUnregister(options, deps) {
|
|
9
10
|
const resolved = resolveDeviceConfig(options.deviceId);
|
|
10
11
|
if (!resolved.ok)
|
|
11
12
|
return resolved;
|
|
12
|
-
const { deviceId } = resolved.config;
|
|
13
|
+
const { deviceId, name } = resolved.config;
|
|
13
14
|
try {
|
|
14
15
|
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
16
|
}
|
|
21
17
|
catch (err) {
|
|
22
|
-
|
|
18
|
+
// 404 = the server row is already gone (deleted via the web UI). Still clear
|
|
19
|
+
// the local config, otherwise the device can never be unregistered locally.
|
|
20
|
+
const status = err.status;
|
|
21
|
+
if (status !== 404) {
|
|
22
|
+
return handleApiError(err);
|
|
23
|
+
}
|
|
23
24
|
}
|
|
25
|
+
const configPath = getMobileDeviceConfigPath(deviceId);
|
|
26
|
+
if (existsSync(configPath)) {
|
|
27
|
+
unlinkSync(configPath);
|
|
28
|
+
}
|
|
29
|
+
return formatSuccess({ success: true, deviceId, name });
|
|
30
|
+
}
|
|
31
|
+
export function renderUnregisterResult(data) {
|
|
32
|
+
return `Unregistered "${data.name}" (device ${data.deviceId}).`;
|
|
24
33
|
}
|
|
25
34
|
export default defineCommand({
|
|
26
35
|
meta: { name: "unregister", description: "Unregister a local mobile device" },
|
|
@@ -32,6 +41,8 @@ export default defineCommand({
|
|
|
32
41
|
},
|
|
33
42
|
async run({ args }) {
|
|
34
43
|
const deviceId = args["device-id"];
|
|
35
|
-
|
|
44
|
+
printMobileResult(await runMobileUnregister(deviceId ? { deviceId } : {}), {
|
|
45
|
+
render: renderUnregisterResult,
|
|
46
|
+
});
|
|
36
47
|
},
|
|
37
48
|
});
|
|
@@ -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
|
+
});
|
|
@@ -2,5 +2,9 @@ export interface ExecResult {
|
|
|
2
2
|
stdout: Buffer;
|
|
3
3
|
stderr: string;
|
|
4
4
|
}
|
|
5
|
-
export
|
|
5
|
+
export interface ExecOptions {
|
|
6
|
+
/** Working directory for the child (e.g. to zip relative paths). */
|
|
7
|
+
cwd?: string;
|
|
8
|
+
}
|
|
9
|
+
export type ExecFn = (file: string, args: string[], opts?: ExecOptions) => Promise<ExecResult>;
|
|
6
10
|
export declare const execBinary: ExecFn;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
// Explicit argv, no shell — payloads can't inject into the local machine.
|
|
3
|
-
export const execBinary = (file, args) => new Promise((resolve, reject) => {
|
|
4
|
-
execFile(file, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
3
|
+
export const execBinary = (file, args, opts) => new Promise((resolve, reject) => {
|
|
4
|
+
execFile(file, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024, ...(opts?.cwd ? { cwd: opts.cwd } : {}) }, (error, stdout, stderr) => {
|
|
5
5
|
const stderrText = stderr.toString("utf-8");
|
|
6
6
|
if (error) {
|
|
7
7
|
const message = stderrText.trim() || error.message;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { listSigningIdentities } from "./ios-identity.js";
|
|
2
|
+
import { mintProfileViaWdaRig } from "./ios-mint.js";
|
|
3
|
+
import { listProvisioningProfiles } from "./ios-profiles.js";
|
|
4
|
+
import { inspectIpa, resignIpa } from "./ios-resign.js";
|
|
5
|
+
/**
|
|
6
|
+
* Zero-config auto-resign: make any dev/ad-hoc .ipa installable on the target
|
|
7
|
+
* device, mirroring what AWS Device Farm does to every upload.
|
|
8
|
+
*
|
|
9
|
+
* Decision ladder (first hit wins):
|
|
10
|
+
* 1. IPA already installs on this device → leave it alone.
|
|
11
|
+
* 2. A local profile covers the app's own bundle id + this UDID → re-sign,
|
|
12
|
+
* keeping the bundle id (wildcard/paid-team and rebuilt-locally cases).
|
|
13
|
+
* 3. Fall back to the dedicated Aximo resign App ID for this team — reuse its
|
|
14
|
+
* profile, or mint one via the Xcode automatic-signing rig (free teams) —
|
|
15
|
+
* and re-sign with the bundle id remapped. The caller gets the remapped id
|
|
16
|
+
* back so app launches can be translated.
|
|
17
|
+
*
|
|
18
|
+
* FairPlay-encrypted (App Store) binaries are rejected with a clear error; the
|
|
19
|
+
* whole step can be disabled with AXIMO_IOS_NO_RESIGN.
|
|
20
|
+
*/
|
|
21
|
+
export interface AutoResignResult {
|
|
22
|
+
/** Path to install: the original IPA, or a re-signed temp copy. */
|
|
23
|
+
ipaPath: string;
|
|
24
|
+
/** True when `ipaPath` is a temp file the caller should delete after install. */
|
|
25
|
+
resigned: boolean;
|
|
26
|
+
originalBundleId: string;
|
|
27
|
+
/** Bundle id actually installed (differs from original when remapped). */
|
|
28
|
+
installedBundleId: string;
|
|
29
|
+
}
|
|
30
|
+
/** Stable per-team bundle id used for remapped resigns (minted once, reused). */
|
|
31
|
+
export declare function aximoResignBundleId(teamId: string): string;
|
|
32
|
+
export interface EnsureInstallableOptions {
|
|
33
|
+
ipaPath: string;
|
|
34
|
+
udid: string;
|
|
35
|
+
env?: NodeJS.ProcessEnv;
|
|
36
|
+
/** Progress reporting (console.error in the CLI; captured in tests). */
|
|
37
|
+
log?: (message: string) => void;
|
|
38
|
+
now?: number;
|
|
39
|
+
inspect?: typeof inspectIpa;
|
|
40
|
+
listIdentities?: typeof listSigningIdentities;
|
|
41
|
+
listProfiles?: typeof listProvisioningProfiles;
|
|
42
|
+
mint?: typeof mintProfileViaWdaRig;
|
|
43
|
+
resign?: typeof resignIpa;
|
|
44
|
+
}
|
|
45
|
+
export declare function ensureInstallableIpa(options: EnsureInstallableOptions): Promise<AutoResignResult>;
|