@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.
Files changed (96) hide show
  1. package/README.md +88 -70
  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 +112 -9
  5. package/dist/commands/mobile/connect.js +6 -3
  6. package/dist/commands/mobile/connect.test.js +7 -1
  7. package/dist/commands/mobile/doctor.d.ts +4 -0
  8. package/dist/commands/mobile/doctor.js +62 -16
  9. package/dist/commands/mobile/doctor.test.d.ts +1 -0
  10. package/dist/commands/mobile/doctor.test.js +28 -0
  11. package/dist/commands/mobile/install.d.ts +10 -2
  12. package/dist/commands/mobile/install.js +57 -15
  13. package/dist/commands/mobile/install.test.js +44 -6
  14. package/dist/commands/mobile/list.d.ts +3 -0
  15. package/dist/commands/mobile/list.js +40 -14
  16. package/dist/commands/mobile/list.test.d.ts +1 -0
  17. package/dist/commands/mobile/list.test.js +24 -0
  18. package/dist/commands/mobile/output.d.ts +13 -0
  19. package/dist/commands/mobile/output.js +21 -0
  20. package/dist/commands/mobile/output.test.d.ts +1 -0
  21. package/dist/commands/mobile/output.test.js +54 -0
  22. package/dist/commands/mobile/register.d.ts +1 -0
  23. package/dist/commands/mobile/register.js +11 -3
  24. package/dist/commands/mobile/setup.d.ts +1 -0
  25. package/dist/commands/mobile/setup.js +7 -2
  26. package/dist/commands/mobile/unregister.d.ts +7 -3
  27. package/dist/commands/mobile/unregister.js +20 -9
  28. package/dist/mobile-control/app-install.d.ts +18 -5
  29. package/dist/mobile-control/app-install.js +33 -6
  30. package/dist/mobile-control/app-install.test.js +42 -7
  31. package/dist/mobile-control/appium-controller.d.ts +8 -0
  32. package/dist/mobile-control/appium-controller.js +19 -4
  33. package/dist/mobile-control/bundle-id-map.d.ts +9 -0
  34. package/dist/mobile-control/bundle-id-map.js +27 -0
  35. package/dist/mobile-control/devices.d.ts +6 -3
  36. package/dist/mobile-control/devices.js +26 -4
  37. package/dist/mobile-control/env-flags.d.ts +5 -0
  38. package/dist/mobile-control/env-flags.js +10 -0
  39. package/dist/mobile-control/env-flags.test.d.ts +1 -0
  40. package/dist/mobile-control/env-flags.test.js +21 -0
  41. package/dist/mobile-control/exec.d.ts +5 -1
  42. package/dist/mobile-control/exec.js +2 -2
  43. package/dist/mobile-control/ios-auto-resign.d.ts +45 -0
  44. package/dist/mobile-control/ios-auto-resign.js +108 -0
  45. package/dist/mobile-control/ios-auto-resign.test.d.ts +1 -0
  46. package/dist/mobile-control/ios-auto-resign.test.js +150 -0
  47. package/dist/mobile-control/ios-devicectl.d.ts +39 -0
  48. package/dist/mobile-control/ios-devicectl.js +87 -0
  49. package/dist/mobile-control/ios-devicectl.test.d.ts +1 -0
  50. package/dist/mobile-control/ios-devicectl.test.js +110 -0
  51. package/dist/mobile-control/ios-identity.d.ts +27 -0
  52. package/dist/mobile-control/ios-identity.js +49 -0
  53. package/dist/mobile-control/ios-identity.test.d.ts +1 -0
  54. package/dist/mobile-control/ios-identity.test.js +102 -0
  55. package/dist/mobile-control/ios-mint.d.ts +39 -0
  56. package/dist/mobile-control/ios-mint.js +78 -0
  57. package/dist/mobile-control/ios-mint.test.d.ts +1 -0
  58. package/dist/mobile-control/ios-mint.test.js +94 -0
  59. package/dist/mobile-control/ios-profiles.d.ts +46 -0
  60. package/dist/mobile-control/ios-profiles.js +106 -0
  61. package/dist/mobile-control/ios-profiles.test.d.ts +1 -0
  62. package/dist/mobile-control/ios-profiles.test.js +154 -0
  63. package/dist/mobile-control/ios-resign.d.ts +37 -0
  64. package/dist/mobile-control/ios-resign.js +101 -0
  65. package/dist/mobile-control/ios-resign.test.d.ts +1 -0
  66. package/dist/mobile-control/ios-resign.test.js +84 -0
  67. package/dist/mobile-control/ios-session-error.d.ts +10 -0
  68. package/dist/mobile-control/ios-session-error.js +34 -0
  69. package/dist/mobile-control/ios-session-error.test.d.ts +1 -0
  70. package/dist/mobile-control/ios-session-error.test.js +30 -0
  71. package/dist/mobile-control/ios-signing.d.ts +33 -5
  72. package/dist/mobile-control/ios-signing.js +56 -0
  73. package/dist/mobile-control/ios-signing.test.js +87 -0
  74. package/dist/mobile-control/ios-tunnel.d.ts +57 -0
  75. package/dist/mobile-control/ios-tunnel.js +150 -0
  76. package/dist/mobile-control/ios-tunnel.test.d.ts +1 -0
  77. package/dist/mobile-control/ios-tunnel.test.js +120 -0
  78. package/dist/mobile-control/ios-wda-cache.d.ts +39 -0
  79. package/dist/mobile-control/ios-wda-cache.js +157 -0
  80. package/dist/mobile-control/ios-wda-cache.test.d.ts +1 -0
  81. package/dist/mobile-control/ios-wda-cache.test.js +128 -0
  82. package/dist/mobile-control/ios-wda-diagnostics.d.ts +14 -0
  83. package/dist/mobile-control/ios-wda-diagnostics.js +138 -0
  84. package/dist/mobile-control/ios-wda-diagnostics.test.d.ts +1 -0
  85. package/dist/mobile-control/ios-wda-diagnostics.test.js +77 -0
  86. package/dist/mobile-control/ios-wda-preinstall.d.ts +64 -0
  87. package/dist/mobile-control/ios-wda-preinstall.js +165 -0
  88. package/dist/mobile-control/ios-wda-preinstall.test.d.ts +1 -0
  89. package/dist/mobile-control/ios-wda-preinstall.test.js +111 -0
  90. package/dist/mobile-control/mobile-errors.d.ts +12 -0
  91. package/dist/mobile-control/mobile-errors.js +91 -0
  92. package/dist/mobile-control/mobile-errors.test.d.ts +1 -0
  93. package/dist/mobile-control/mobile-errors.test.js +48 -0
  94. package/dist/mobile-control/tunnel.js +79 -2
  95. package/dist/mobile-control/tunnel.test.js +82 -0
  96. 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, printResult } from "../../output.js";
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
- printResult(result);
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
- success: boolean;
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, printResult } from "../../output.js";
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
- return handleApiError(err);
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
- printResult(await runMobileUnregister(deviceId ? { deviceId } : {}));
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 `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
+ });
@@ -2,5 +2,9 @@ export interface ExecResult {
2
2
  stdout: Buffer;
3
3
  stderr: string;
4
4
  }
5
- export type ExecFn = (file: string, args: string[]) => Promise<ExecResult>;
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>;