@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.
Files changed (81) hide show
  1. package/README.md +79 -64
  2. package/dist/client.js +16 -1
  3. package/dist/commands/mobile/connect-core.d.ts +2 -1
  4. package/dist/commands/mobile/connect-core.js +104 -6
  5. package/dist/commands/mobile/connect.js +3 -1
  6. package/dist/commands/mobile/doctor.d.ts +3 -0
  7. package/dist/commands/mobile/doctor.js +44 -14
  8. package/dist/commands/mobile/doctor.test.d.ts +1 -0
  9. package/dist/commands/mobile/doctor.test.js +14 -0
  10. package/dist/commands/mobile/install.d.ts +9 -2
  11. package/dist/commands/mobile/install.js +43 -12
  12. package/dist/commands/mobile/install.test.js +44 -6
  13. package/dist/commands/mobile/list.d.ts +2 -0
  14. package/dist/commands/mobile/list.js +17 -11
  15. package/dist/commands/mobile/unregister.js +11 -6
  16. package/dist/mobile-control/app-install.d.ts +18 -5
  17. package/dist/mobile-control/app-install.js +33 -6
  18. package/dist/mobile-control/app-install.test.js +42 -7
  19. package/dist/mobile-control/appium-controller.d.ts +8 -0
  20. package/dist/mobile-control/appium-controller.js +19 -4
  21. package/dist/mobile-control/bundle-id-map.d.ts +9 -0
  22. package/dist/mobile-control/bundle-id-map.js +27 -0
  23. package/dist/mobile-control/devices.d.ts +6 -3
  24. package/dist/mobile-control/devices.js +26 -4
  25. package/dist/mobile-control/env-flags.d.ts +5 -0
  26. package/dist/mobile-control/env-flags.js +10 -0
  27. package/dist/mobile-control/env-flags.test.d.ts +1 -0
  28. package/dist/mobile-control/env-flags.test.js +21 -0
  29. package/dist/mobile-control/ios-auto-resign.d.ts +45 -0
  30. package/dist/mobile-control/ios-auto-resign.js +108 -0
  31. package/dist/mobile-control/ios-auto-resign.test.d.ts +1 -0
  32. package/dist/mobile-control/ios-auto-resign.test.js +150 -0
  33. package/dist/mobile-control/ios-devicectl.d.ts +39 -0
  34. package/dist/mobile-control/ios-devicectl.js +87 -0
  35. package/dist/mobile-control/ios-devicectl.test.d.ts +1 -0
  36. package/dist/mobile-control/ios-devicectl.test.js +110 -0
  37. package/dist/mobile-control/ios-identity.d.ts +27 -0
  38. package/dist/mobile-control/ios-identity.js +49 -0
  39. package/dist/mobile-control/ios-identity.test.d.ts +1 -0
  40. package/dist/mobile-control/ios-identity.test.js +102 -0
  41. package/dist/mobile-control/ios-mint.d.ts +39 -0
  42. package/dist/mobile-control/ios-mint.js +78 -0
  43. package/dist/mobile-control/ios-mint.test.d.ts +1 -0
  44. package/dist/mobile-control/ios-mint.test.js +94 -0
  45. package/dist/mobile-control/ios-profiles.d.ts +46 -0
  46. package/dist/mobile-control/ios-profiles.js +106 -0
  47. package/dist/mobile-control/ios-profiles.test.d.ts +1 -0
  48. package/dist/mobile-control/ios-profiles.test.js +154 -0
  49. package/dist/mobile-control/ios-resign.d.ts +37 -0
  50. package/dist/mobile-control/ios-resign.js +101 -0
  51. package/dist/mobile-control/ios-resign.test.d.ts +1 -0
  52. package/dist/mobile-control/ios-resign.test.js +84 -0
  53. package/dist/mobile-control/ios-session-error.d.ts +10 -0
  54. package/dist/mobile-control/ios-session-error.js +34 -0
  55. package/dist/mobile-control/ios-session-error.test.d.ts +1 -0
  56. package/dist/mobile-control/ios-session-error.test.js +30 -0
  57. package/dist/mobile-control/ios-signing.d.ts +33 -5
  58. package/dist/mobile-control/ios-signing.js +56 -0
  59. package/dist/mobile-control/ios-signing.test.js +87 -0
  60. package/dist/mobile-control/ios-tunnel.d.ts +54 -0
  61. package/dist/mobile-control/ios-tunnel.js +130 -0
  62. package/dist/mobile-control/ios-tunnel.test.d.ts +1 -0
  63. package/dist/mobile-control/ios-tunnel.test.js +115 -0
  64. package/dist/mobile-control/ios-wda-cache.d.ts +39 -0
  65. package/dist/mobile-control/ios-wda-cache.js +157 -0
  66. package/dist/mobile-control/ios-wda-cache.test.d.ts +1 -0
  67. package/dist/mobile-control/ios-wda-cache.test.js +128 -0
  68. package/dist/mobile-control/ios-wda-diagnostics.d.ts +14 -0
  69. package/dist/mobile-control/ios-wda-diagnostics.js +138 -0
  70. package/dist/mobile-control/ios-wda-diagnostics.test.d.ts +1 -0
  71. package/dist/mobile-control/ios-wda-diagnostics.test.js +77 -0
  72. package/dist/mobile-control/ios-wda-preinstall.d.ts +64 -0
  73. package/dist/mobile-control/ios-wda-preinstall.js +162 -0
  74. package/dist/mobile-control/ios-wda-preinstall.test.d.ts +1 -0
  75. package/dist/mobile-control/ios-wda-preinstall.test.js +107 -0
  76. package/dist/mobile-control/mobile-errors.d.ts +12 -0
  77. package/dist/mobile-control/mobile-errors.js +91 -0
  78. package/dist/mobile-control/mobile-errors.test.d.ts +1 -0
  79. package/dist/mobile-control/mobile-errors.test.js +48 -0
  80. package/dist/mobile-control/tunnel.js +45 -1
  81. 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 `idb install`. The iOS build must be signed for the device.
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
- const { stdout } = platform === "ios"
44
- ? await exec("idb", ["install", "--udid", target, appPath])
45
- : await exec("adb", ["-s", target, "install", "-r", appPath]);
46
- return formatSuccess({
47
- platform,
48
- ...(platform === "ios" ? { udid: target } : { serial: target }),
49
- app: appPath,
50
- output: stdout.toString("utf-8").trim(),
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
- return formatError("api_error", err instanceof Error ? err.message : String(err), false);
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 via idb)",
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 `idb install --udid <udid>`", async () => {
78
- const { exec, install } = execWith({ out: "Installed" });
79
- const res = await runMobileInstall({ app: ipaPath }, exec);
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(install().file).toBe("idb");
87
- expect(install().args).toEqual(["install", "--udid", "00008030-ABC", ipaPath]);
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({ androidDevices, iosDevices });
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
- return handleApiError(err);
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 `idb install`. The app binary
17
- * never crosses the control tunnel; only the opaque upload id does.
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 `idb install`. The app binary
11
- * never crosses the control tunnel; only the opaque upload id does.
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
- const { stdout } = opts.platform === "ios"
21
- ? await exec("idb", ["install", "--udid", opts.target, tmpPath])
22
- : await exec("adb", ["-s", opts.target, "install", "-r", tmpPath]);
23
- return { output: stdout.toString("utf-8").trim() };
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 idb", async () => {
53
- const { exec, calls } = captureExec();
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
- await installUploadedApp({
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
- const call = calls()[0];
63
- expect(call?.file).toBe("idb");
64
- expect(call?.args.slice(0, 3)).toEqual(["install", "--udid", "00008030-ABC"]);
65
- expect(call?.args[3]).toMatch(/\.ipa$/);
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 = this.session
61
- .createSession(this.capabilities())
62
- .then(() => undefined)
63
- .catch((err) => {
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 via `idb list-targets --json` (one JSON
20
- * object per line). Only entries whose `type` is `device` are returned. Returns
21
- * an empty list when idb is not installed; a real idb failure is surfaced.
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 `idb list-targets` (or `aximo mobile doctor` if idb is missing).");
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 via `idb list-targets --json` (one JSON
94
- * object per line). Only entries whose `type` is `device` are returned. Returns
95
- * an empty list when idb is not installed; a real idb failure is surfaced.
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,5 @@
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 declare function envFlag(env: NodeJS.ProcessEnv, name: string): boolean;
@@ -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
+ });