@autifyhq/aximo-cli 0.5.0 → 0.6.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 (36) hide show
  1. package/README.md +181 -64
  2. package/dist/cli.js +4 -0
  3. package/dist/client.d.ts +2 -0
  4. package/dist/client.js +18 -0
  5. package/dist/commands/mobile/connect-core.js +27 -13
  6. package/dist/commands/mobile/doctor.js +22 -3
  7. package/dist/commands/mobile/index.js +1 -0
  8. package/dist/commands/mobile/install.d.ts +43 -0
  9. package/dist/commands/mobile/install.js +76 -0
  10. package/dist/commands/mobile/install.test.d.ts +1 -0
  11. package/dist/commands/mobile/install.test.js +104 -0
  12. package/dist/commands/mobile/register.d.ts +6 -1
  13. package/dist/commands/mobile/register.js +12 -6
  14. package/dist/commands/mobile/register.test.js +34 -3
  15. package/dist/commands/schema.js +15 -8
  16. package/dist/mobile-control/app-install.d.ts +21 -0
  17. package/dist/mobile-control/app-install.js +28 -0
  18. package/dist/mobile-control/app-install.test.d.ts +1 -0
  19. package/dist/mobile-control/app-install.test.js +78 -0
  20. package/dist/mobile-control/appium-controller.d.ts +3 -0
  21. package/dist/mobile-control/appium-controller.js +5 -0
  22. package/dist/mobile-control/devices.d.ts +3 -1
  23. package/dist/mobile-control/devices.js +33 -3
  24. package/dist/mobile-control/devices.test.js +41 -1
  25. package/dist/mobile-control/ios-signing.d.ts +28 -0
  26. package/dist/mobile-control/ios-signing.js +38 -0
  27. package/dist/mobile-control/ios-signing.test.d.ts +1 -0
  28. package/dist/mobile-control/ios-signing.test.js +73 -0
  29. package/dist/mobile-control/tunnel.d.ts +5 -1
  30. package/dist/mobile-control/tunnel.js +15 -2
  31. package/dist/mobile-control/tunnel.test.js +1 -1
  32. package/dist/proxy.d.ts +11 -0
  33. package/dist/proxy.js +85 -0
  34. package/dist/proxy.test.d.ts +1 -0
  35. package/dist/proxy.test.js +72 -0
  36. package/package.json +3 -2
@@ -0,0 +1,104 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { runMobileInstall } from "./install.js";
6
+ const ONE_ANDROID = "List of devices attached\nR1 device\n";
7
+ const ONE_IOS = `${JSON.stringify({ udid: "00008030-ABC", name: "iPhone", type: "device" })}\n`;
8
+ let dir;
9
+ let apkPath;
10
+ let ipaPath;
11
+ let txtPath;
12
+ beforeAll(() => {
13
+ dir = mkdtempSync(join(tmpdir(), "aximo-mob-install-"));
14
+ apkPath = join(dir, "app.apk");
15
+ ipaPath = join(dir, "app.ipa");
16
+ txtPath = join(dir, "app.txt");
17
+ for (const p of [apkPath, ipaPath, txtPath])
18
+ writeFileSync(p, "x");
19
+ });
20
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
21
+ /** Mock adb/idb: serve device discovery and capture the `install` invocation. */
22
+ function execWith(opts = {}) {
23
+ let captured = { file: "", args: [] };
24
+ const exec = async (file, args) => {
25
+ if (file === "adb" && args[0] === "devices") {
26
+ return { stdout: Buffer.from(ONE_ANDROID), stderr: "" };
27
+ }
28
+ if (file === "idb" && args[0] === "list-targets") {
29
+ return { stdout: Buffer.from(ONE_IOS), stderr: "" };
30
+ }
31
+ if ((file === "adb" || file === "idb") && args.includes("install")) {
32
+ captured = { file, args };
33
+ if (opts.throwMsg)
34
+ throw new Error(opts.throwMsg);
35
+ return { stdout: Buffer.from(opts.out ?? "Success"), stderr: "" };
36
+ }
37
+ throw new Error(`unexpected exec: ${file} ${args.join(" ")}`);
38
+ };
39
+ return { exec, install: () => captured };
40
+ }
41
+ describe("runMobileInstall", () => {
42
+ it("requires --app", async () => {
43
+ const { exec } = execWith();
44
+ const res = await runMobileInstall({}, exec);
45
+ expect(res.ok).toBe(false);
46
+ if (!res.ok)
47
+ expect(res.error.code).toBe("validation_error");
48
+ });
49
+ it("errors when the app file is missing", async () => {
50
+ const { exec } = execWith();
51
+ const res = await runMobileInstall({ app: "definitely-not-here-xyz.apk" }, exec);
52
+ expect(res.ok).toBe(false);
53
+ if (!res.ok)
54
+ expect(res.error.message).toMatch(/not found/i);
55
+ });
56
+ it("errors when the platform cannot be inferred", async () => {
57
+ const { exec } = execWith();
58
+ const res = await runMobileInstall({ app: txtPath }, exec);
59
+ expect(res.ok).toBe(false);
60
+ if (!res.ok)
61
+ expect(res.error.message).toMatch(/determine platform/i);
62
+ });
63
+ it("installs an .apk via `adb -s <serial> install -r`", async () => {
64
+ const { exec, install } = execWith({ out: "Performing Streamed Install\nSuccess" });
65
+ const res = await runMobileInstall({ app: apkPath }, exec);
66
+ expect(res.ok).toBe(true);
67
+ if (res.ok) {
68
+ expect(res.data.platform).toBe("android");
69
+ expect(res.data.serial).toBe("R1");
70
+ expect(res.data.udid).toBeUndefined();
71
+ expect(res.data.output).toContain("Success");
72
+ }
73
+ expect(install().file).toBe("adb");
74
+ expect(install().args.slice(0, 2)).toEqual(["-s", "R1"]);
75
+ expect(install().args).toEqual(expect.arrayContaining(["install", "-r"]));
76
+ });
77
+ it("installs an .ipa via `idb install --udid <udid>`", async () => {
78
+ const { exec, install } = execWith({ out: "Installed" });
79
+ const res = await runMobileInstall({ app: ipaPath }, exec);
80
+ expect(res.ok).toBe(true);
81
+ if (res.ok) {
82
+ expect(res.data.platform).toBe("ios");
83
+ expect(res.data.udid).toBe("00008030-ABC");
84
+ expect(res.data.serial).toBeUndefined();
85
+ }
86
+ expect(install().file).toBe("idb");
87
+ expect(install().args).toEqual(["install", "--udid", "00008030-ABC", ipaPath]);
88
+ });
89
+ it("honors an explicit --platform override", async () => {
90
+ const { exec, install } = execWith();
91
+ const res = await runMobileInstall({ app: txtPath, platform: "android" }, exec);
92
+ expect(res.ok).toBe(true);
93
+ expect(install().file).toBe("adb");
94
+ });
95
+ it("maps an install failure to an error envelope", async () => {
96
+ const { exec } = execWith({ throwMsg: "adb: failed to install: INSTALL_FAILED_INVALID_APK" });
97
+ const res = await runMobileInstall({ app: apkPath }, exec);
98
+ expect(res.ok).toBe(false);
99
+ if (!res.ok) {
100
+ expect(res.error.code).toBe("api_error");
101
+ expect(res.error.message).toMatch(/INSTALL_FAILED/);
102
+ }
103
+ });
104
+ });
@@ -6,6 +6,7 @@ export interface MobileRegisterCmdOptions {
6
6
  name: string;
7
7
  platform: string;
8
8
  serial?: string | undefined;
9
+ udid?: string | undefined;
9
10
  os?: string | undefined;
10
11
  }
11
12
  export declare function runMobileRegister(options: MobileRegisterCmdOptions, deps?: ClientDeps, exec?: ExecFn): Promise<Envelope<MobileRegisterResult>>;
@@ -17,13 +18,17 @@ declare const _default: import("citty").CommandDef<{
17
18
  };
18
19
  readonly platform: {
19
20
  readonly type: "string";
20
- readonly description: "Device platform (only `android` is supported today)";
21
+ readonly description: "Device platform (`android` or `ios`)";
21
22
  readonly default: "android";
22
23
  };
23
24
  readonly serial: {
24
25
  readonly type: "string";
25
26
  readonly description: "Android adb serial (defaults to the connected one)";
26
27
  };
28
+ readonly udid: {
29
+ readonly type: "string";
30
+ readonly description: "iOS device udid (defaults to the connected one)";
31
+ };
27
32
  readonly os: {
28
33
  readonly type: "string";
29
34
  readonly description: "Optional OS version label";
@@ -1,19 +1,23 @@
1
1
  import { defineCommand } from "citty";
2
2
  import {} from "../../client.js";
3
3
  import { formatError, printResult } from "../../output.js";
4
- import { resolveAndroidSerial } from "../../mobile-control/devices.js";
4
+ import { resolveAndroidSerial, resolveIosUdid } from "../../mobile-control/devices.js";
5
5
  import { parsePlatformArg, registerMobileDevice, } from "./register-core.js";
6
6
  export async function runMobileRegister(options, deps, exec) {
7
7
  const platform = parsePlatformArg(options.platform);
8
- if (platform !== "android") {
9
- return formatError("validation_error", "Only --platform android is supported (iOS local devices are coming in a future release).", false);
8
+ if (!platform) {
9
+ return formatError("validation_error", "Invalid --platform; expected `android` or `ios`.", false);
10
10
  }
11
11
  if (!options.name) {
12
12
  return formatError("validation_error", "Device name is required (--name).", false);
13
13
  }
14
+ // Resolve the device target: an adb serial for Android, a udid for iOS.
14
15
  let target;
15
16
  try {
16
- target = await resolveAndroidSerial(options.serial, exec);
17
+ target =
18
+ platform === "ios"
19
+ ? await resolveIosUdid(options.udid, exec)
20
+ : await resolveAndroidSerial(options.serial, exec);
17
21
  }
18
22
  catch (err) {
19
23
  return formatError("validation_error", err instanceof Error ? err.message : String(err), false);
@@ -28,16 +32,17 @@ export async function runMobileRegister(options, deps, exec) {
28
32
  export default defineCommand({
29
33
  meta: {
30
34
  name: "register",
31
- description: "Register a physically-connected Android phone as an Aximo device",
35
+ description: "Register a physically-connected Android phone or iPhone as an Aximo device",
32
36
  },
33
37
  args: {
34
38
  name: { type: "string", description: "Device name", required: true },
35
39
  platform: {
36
40
  type: "string",
37
- description: "Device platform (only `android` is supported today)",
41
+ description: "Device platform (`android` or `ios`)",
38
42
  default: "android",
39
43
  },
40
44
  serial: { type: "string", description: "Android adb serial (defaults to the connected one)" },
45
+ udid: { type: "string", description: "iOS device udid (defaults to the connected one)" },
41
46
  os: { type: "string", description: "Optional OS version label" },
42
47
  },
43
48
  async run({ args }) {
@@ -45,6 +50,7 @@ export default defineCommand({
45
50
  name: args.name,
46
51
  platform: args.platform,
47
52
  serial: args.serial,
53
+ udid: args.udid,
48
54
  os: args.os,
49
55
  }));
50
56
  },
@@ -26,6 +26,20 @@ const oneAndroidDevice = async () => ({
26
26
  stdout: Buffer.from("List of devices attached\nR58M12345 device model:Pixel_7\n"),
27
27
  stderr: "",
28
28
  });
29
+ const oneIosDevice = async (file, args) => {
30
+ if (file === "idb" && args[0] === "list-targets") {
31
+ return {
32
+ stdout: Buffer.from(`${JSON.stringify({
33
+ udid: "00008030-ABCDEF",
34
+ name: "Abdul's iPhone",
35
+ type: "device",
36
+ state: "connected",
37
+ })}\n`),
38
+ stderr: "",
39
+ };
40
+ }
41
+ throw new Error(`unexpected exec: ${file} ${args.join(" ")}`);
42
+ };
29
43
  describe("runMobileRegister", () => {
30
44
  it("posts the resolved android serial and writes config", async () => {
31
45
  let body;
@@ -49,12 +63,29 @@ describe("runMobileRegister", () => {
49
63
  expect(saved).toMatchObject({ platform: "android", target: "R58M12345" });
50
64
  expect(saved).not.toHaveProperty("category");
51
65
  });
52
- it("rejects iOS registration as unsupported", async () => {
53
- const result = await runMobileRegister({ name: "iPhone", platform: "ios" });
66
+ it("posts the resolved iOS udid and writes config", async () => {
67
+ let body;
68
+ server.use(http.post(`${TEST_BASE_URL}/api/devices/register`, async ({ request }) => {
69
+ body = (await request.json());
70
+ return HttpResponse.json({ deviceId: "dev-ios", deviceToken: "tok-ios" }, { status: 201 });
71
+ }));
72
+ const result = await runMobileRegister({ name: "iPhone", platform: "ios" }, undefined, oneIosDevice);
73
+ expect(result.ok).toBe(true);
74
+ if (!result.ok)
75
+ return;
76
+ expect(body).toMatchObject({ name: "iPhone", platform: "ios" });
77
+ expect(result.data.target).toBe("00008030-ABCDEF");
78
+ const configPath = join(tempHome, "mobile-devices", "dev-ios.json");
79
+ expect(existsSync(configPath)).toBe(true);
80
+ const saved = JSON.parse(readFileSync(configPath, "utf-8"));
81
+ expect(saved).toMatchObject({ platform: "ios", target: "00008030-ABCDEF" });
82
+ });
83
+ it("rejects an unknown platform", async () => {
84
+ const result = await runMobileRegister({ name: "x", platform: "windows" });
54
85
  expect(result.ok).toBe(false);
55
86
  if (!result.ok) {
56
87
  expect(result.error.code).toBe("validation_error");
57
- expect(result.error.message).toContain("Only --platform android is supported");
88
+ expect(result.error.message).toContain("Invalid --platform");
58
89
  }
59
90
  });
60
91
  });
@@ -120,25 +120,32 @@ export function getSchema() {
120
120
  arg("available-only", "boolean", ""),
121
121
  ]),
122
122
  ]),
123
- cmd("mobile", "Register and connect a physically-connected Android phone (via adb)", [], [
124
- cmd("list", "List physically-connected phones (adb)"),
125
- cmd("register", "Register a physically-connected Android phone as an Aximo device", [
123
+ cmd("mobile", "Register and connect a physically-connected Android phone (adb) or iPhone (idb/Xcode)", [], [
124
+ cmd("list", "List physically-connected phones (Android via adb, iOS via idb)"),
125
+ cmd("register", "Register a physically-connected Android phone or iPhone as an Aximo device", [
126
126
  arg("name", "string", "Device name", true),
127
- arg("platform", "string", "Device platform (only `android` is supported today)"),
127
+ arg("platform", "string", "Device platform (`android` or `ios`)"),
128
128
  arg("serial", "string", "Android adb serial (defaults to the connected one)"),
129
+ arg("udid", "string", "iOS device udid (defaults to the connected one)"),
129
130
  arg("os", "string", "Optional OS version label"),
130
131
  ]),
132
+ cmd("install", "Install an app on the connected phone (.apk via adb, .ipa via idb)", [
133
+ arg("app", "string", "Path to the .apk or .ipa to install", true),
134
+ arg("platform", "string", "android | ios (inferred from the file extension)"),
135
+ arg("serial", "string", "Android adb serial (defaults to the connected one)"),
136
+ arg("udid", "string", "iOS device udid (defaults to the connected one)"),
137
+ ]),
131
138
  cmd("unregister", "Unregister a local mobile device", [
132
139
  arg("device-id", "string", "Device ID (defaults to the sole registered local device)"),
133
140
  ]),
134
- cmd("connect", "Connect a registered Android phone via a CLI-managed Appium server", [
141
+ cmd("connect", "Connect a registered phone via a CLI-managed Appium server", [
135
142
  arg("device-id", "string", "Registered device id (required if multiple)"),
136
143
  ]),
137
144
  cmd("setup", "Install the Appium drivers used to drive registered phones", [
138
- arg("platform", "string", "Limit checks to android"),
145
+ arg("platform", "string", "Limit to ios | android driver"),
139
146
  ]),
140
- cmd("doctor", "Diagnose physical-device tooling (adb authorization, Appium setup)", [
141
- arg("platform", "string", "Limit checks to android"),
147
+ cmd("doctor", "Diagnose physical-device tooling (adb/idb, Xcode, Appium setup)", [
148
+ arg("platform", "string", "Limit checks to ios | android"),
142
149
  ]),
143
150
  ]),
144
151
  cmd("upload", "Upload commands", [], [
@@ -0,0 +1,21 @@
1
+ import { type ClientDeps } from "../client.js";
2
+ import { type ExecFn } from "./exec.js";
3
+ export interface InstallUploadedAppOptions {
4
+ /** Aximo upload id of the app under test. */
5
+ uploadId: string;
6
+ platform: "ios" | "android";
7
+ /** adb serial (Android) or udid (iOS) of the registered device. */
8
+ target: string;
9
+ exec?: ExecFn;
10
+ /** Client overrides (apiKey/baseUrl/fetchImpl) — used in tests. */
11
+ deps?: ClientDeps;
12
+ }
13
+ /**
14
+ * Download the app under test from Aximo (authenticated via the org API key, which
15
+ * 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.
18
+ */
19
+ export declare function installUploadedApp(opts: InstallUploadedAppOptions): Promise<{
20
+ output: string;
21
+ }>;
@@ -0,0 +1,28 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { requestToFile } from "../client.js";
6
+ import { execBinary } from "./exec.js";
7
+ /**
8
+ * Download the app under test from Aximo (authenticated via the org API key, which
9
+ * 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.
12
+ */
13
+ export async function installUploadedApp(opts) {
14
+ const exec = opts.exec ?? execBinary;
15
+ const ext = opts.platform === "ios" ? ".ipa" : ".apk";
16
+ const tmpPath = join(tmpdir(), `aximo-install-${randomBytes(8).toString("hex")}${ext}`);
17
+ try {
18
+ // Stream the (authenticated) download straight to disk — apps can be large.
19
+ 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() };
24
+ }
25
+ finally {
26
+ await rm(tmpPath, { force: true }).catch(() => { });
27
+ }
28
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { installUploadedApp } from "./app-install.js";
3
+ const DEPS = { apiKey: "test-key", baseUrl: "https://aximo.test" };
4
+ function fakeFetch(ok, bytes = "apk-bytes") {
5
+ let seen = "";
6
+ const impl = vi.fn(async (url) => {
7
+ seen = url;
8
+ return {
9
+ ok,
10
+ status: ok ? 200 : 403,
11
+ statusText: ok ? "OK" : "Forbidden",
12
+ text: async () => (ok ? "" : "forbidden"),
13
+ body: ok
14
+ ? new ReadableStream({
15
+ start(controller) {
16
+ controller.enqueue(new TextEncoder().encode(bytes));
17
+ controller.close();
18
+ },
19
+ })
20
+ : null,
21
+ headers: { get: () => null },
22
+ };
23
+ });
24
+ return { impl, url: () => seen };
25
+ }
26
+ function captureExec() {
27
+ const calls = [];
28
+ const exec = async (file, args) => {
29
+ calls.push({ file, args });
30
+ return { stdout: Buffer.from("Success"), stderr: "" };
31
+ };
32
+ return { exec, calls: () => calls };
33
+ }
34
+ describe("installUploadedApp", () => {
35
+ it("fetches the app authenticated by upload id and installs via adb", async () => {
36
+ const { exec, calls } = captureExec();
37
+ const { impl, url } = fakeFetch(true);
38
+ const res = await installUploadedApp({
39
+ uploadId: "up-123",
40
+ platform: "android",
41
+ target: "R1",
42
+ exec,
43
+ deps: { ...DEPS, fetchImpl: impl },
44
+ });
45
+ expect(res.output).toContain("Success");
46
+ expect(url()).toBe("https://aximo.test/api/devices/apps/up-123/download");
47
+ const call = calls()[0];
48
+ expect(call?.file).toBe("adb");
49
+ expect(call?.args.slice(0, 4)).toEqual(["-s", "R1", "install", "-r"]);
50
+ expect(call?.args[4]).toMatch(/\.apk$/);
51
+ });
52
+ it("installs an iOS app via idb", async () => {
53
+ const { exec, calls } = captureExec();
54
+ const { impl } = fakeFetch(true);
55
+ await installUploadedApp({
56
+ uploadId: "up-9",
57
+ platform: "ios",
58
+ target: "00008030-ABC",
59
+ exec,
60
+ deps: { ...DEPS, fetchImpl: impl },
61
+ });
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$/);
66
+ });
67
+ it("throws when the authenticated download fails", async () => {
68
+ const { exec } = captureExec();
69
+ const { impl } = fakeFetch(false);
70
+ await expect(installUploadedApp({
71
+ uploadId: "missing",
72
+ platform: "android",
73
+ target: "R1",
74
+ exec,
75
+ deps: { ...DEPS, fetchImpl: impl },
76
+ })).rejects.toThrow();
77
+ });
78
+ });
@@ -1,15 +1,18 @@
1
1
  import type { ActionResult, MobileController } from "./controller.js";
2
+ import { type IosSigningConfig } from "./ios-signing.js";
2
3
  import { type WebDriverTransport } from "./webdriver.js";
3
4
  export interface AppiumControllerOptions {
4
5
  endpoint: string;
5
6
  platform: "ios" | "android";
6
7
  target: string;
8
+ iosSigning?: IosSigningConfig;
7
9
  transport?: WebDriverTransport;
8
10
  }
9
11
  export declare class AppiumController implements MobileController {
10
12
  private readonly session;
11
13
  private readonly platform;
12
14
  private readonly target;
15
+ private readonly iosSigning;
13
16
  private sessionReady;
14
17
  private scaling;
15
18
  constructor(opts: AppiumControllerOptions);
@@ -1,3 +1,4 @@
1
+ import { iosSigningCapabilities } from "./ios-signing.js";
1
2
  import { fetchTransport, WebDriverSession } from "./webdriver.js";
2
3
  const ANDROID_KEY_CODES = {
3
4
  enter: 66,
@@ -24,11 +25,13 @@ export class AppiumController {
24
25
  session;
25
26
  platform;
26
27
  target;
28
+ iosSigning;
27
29
  sessionReady = null;
28
30
  scaling = null;
29
31
  constructor(opts) {
30
32
  this.platform = opts.platform;
31
33
  this.target = opts.target;
34
+ this.iosSigning = opts.iosSigning;
32
35
  const transport = opts.transport ?? fetchTransport(opts.endpoint);
33
36
  this.session = new WebDriverSession(transport);
34
37
  }
@@ -48,6 +51,8 @@ export class AppiumController {
48
51
  "appium:automationName": "XCUITest",
49
52
  "appium:udid": this.target,
50
53
  "appium:newCommandTimeout": 0,
54
+ // WDA code-signing caps for physical iPhones (empty unless a team id is set).
55
+ ...(this.iosSigning ? iosSigningCapabilities(this.iosSigning) : {}),
51
56
  };
52
57
  }
53
58
  ensureSession() {
@@ -13,9 +13,11 @@ export interface IosPhysicalDevice {
13
13
  name: string;
14
14
  state?: string;
15
15
  }
16
+ /** Resolve an iOS udid: explicit (validated), else the single connected device. */
17
+ export declare function resolveIosUdid(preferred: string | undefined, exec?: ExecFn): Promise<string>;
16
18
  /**
17
19
  * List physically-connected iOS devices via `idb list-targets --json` (one JSON
18
20
  * object per line). Only entries whose `type` is `device` are returned. Returns
19
- * an empty list if idb is unavailable.
21
+ * an empty list when idb is not installed; a real idb failure is surfaced.
20
22
  */
21
23
  export declare function listIosPhysicalDevices(exec?: ExecFn): Promise<IosPhysicalDevice[]>;
@@ -62,18 +62,48 @@ export async function resolveAndroidSerial(preferred, exec = execBinary) {
62
62
  .map((d) => d.serial)
63
63
  .join(", ")}`);
64
64
  }
65
+ /** Resolve an iOS udid: explicit (validated), else the single connected device. */
66
+ export async function resolveIosUdid(preferred, exec = execBinary) {
67
+ const devices = await listIosPhysicalDevices(exec);
68
+ if (preferred) {
69
+ const match = devices.find((d) => d.udid === preferred);
70
+ if (!match) {
71
+ throw new Error(`No iOS device with udid '${preferred}' is connected. ` +
72
+ "Connect it over USB, unlock it, and trust this computer.");
73
+ }
74
+ return match.udid;
75
+ }
76
+ if (devices.length === 1 && devices[0])
77
+ return devices[0].udid;
78
+ if (devices.length === 0) {
79
+ 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
+ }
82
+ throw new Error(`Multiple iOS devices found; pass --udid. Available: ${devices
83
+ .map((d) => `${d.name} (${d.udid})`)
84
+ .join(", ")}`);
85
+ }
86
+ /** True when a spawn failed because the binary isn't installed (ENOENT). */
87
+ function isCommandNotFound(err) {
88
+ if (err?.code === "ENOENT")
89
+ return true;
90
+ return err instanceof Error && /\bENOENT\b/.test(err.message);
91
+ }
65
92
  /**
66
93
  * List physically-connected iOS devices via `idb list-targets --json` (one JSON
67
94
  * object per line). Only entries whose `type` is `device` are returned. Returns
68
- * an empty list if idb is unavailable.
95
+ * an empty list when idb is not installed; a real idb failure is surfaced.
69
96
  */
70
97
  export async function listIosPhysicalDevices(exec = execBinary) {
71
98
  let stdout;
72
99
  try {
73
100
  ({ stdout } = await exec("idb", ["list-targets", "--json"]));
74
101
  }
75
- catch {
76
- return [];
102
+ catch (err) {
103
+ // idb not installed → "no iOS devices"; any other idb failure should surface.
104
+ if (isCommandNotFound(err))
105
+ return [];
106
+ throw err;
77
107
  }
78
108
  const result = [];
79
109
  for (const line of stdout.toString("utf-8").split("\n")) {
@@ -1,8 +1,16 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { listAndroidDevices, resolveAndroidSerial } from "./devices.js";
2
+ import { listAndroidDevices, listIosPhysicalDevices, resolveAndroidSerial, resolveIosUdid, } from "./devices.js";
3
3
  function fixed(stdout) {
4
4
  return async () => ({ stdout: Buffer.from(stdout), stderr: "" });
5
5
  }
6
+ function iosTargets(...devices) {
7
+ return async () => ({
8
+ stdout: Buffer.from(devices.map((d) => JSON.stringify(d)).join("\n")),
9
+ stderr: "",
10
+ });
11
+ }
12
+ const PHONE = { udid: "00008030-ABC", name: "iPhone", type: "device", state: "connected" };
13
+ const SIM = { udid: "SIM-1", name: "iPhone 15", type: "simulator", state: "Booted" };
6
14
  describe("listAndroidDevices / resolveAndroidSerial", () => {
7
15
  it("parses adb devices -l output", async () => {
8
16
  const exec = fixed("List of devices attached\nR1 device model:Pixel_7\nR2 unauthorized\n");
@@ -33,3 +41,35 @@ describe("listAndroidDevices / resolveAndroidSerial", () => {
33
41
  await expect(resolveAndroidSerial("R1", exec)).rejects.toThrow(/offline/i);
34
42
  });
35
43
  });
44
+ describe("listIosPhysicalDevices / resolveIosUdid", () => {
45
+ it("returns only physical devices (ignores simulators)", async () => {
46
+ const devices = await listIosPhysicalDevices(iosTargets(PHONE, SIM));
47
+ expect(devices).toEqual([{ udid: "00008030-ABC", name: "iPhone", state: "connected" }]);
48
+ });
49
+ it("returns an empty list when idb is not installed (ENOENT)", async () => {
50
+ const exec = async () => {
51
+ throw new Error("spawn idb ENOENT");
52
+ };
53
+ await expect(listIosPhysicalDevices(exec)).resolves.toEqual([]);
54
+ });
55
+ it("surfaces a real idb failure instead of masking it as no devices", async () => {
56
+ const exec = async () => {
57
+ throw new Error("idb list-targets --json failed: could not connect to companion");
58
+ };
59
+ await expect(listIosPhysicalDevices(exec)).rejects.toThrow(/companion/);
60
+ });
61
+ it("auto-selects the only connected device", async () => {
62
+ await expect(resolveIosUdid(undefined, iosTargets(PHONE))).resolves.toBe("00008030-ABC");
63
+ });
64
+ it("errors when no devices are connected", async () => {
65
+ await expect(resolveIosUdid(undefined, iosTargets())).rejects.toThrow(/no connected ios/i);
66
+ });
67
+ it("requires --udid when multiple devices are connected", async () => {
68
+ const second = { ...PHONE, udid: "00008030-DEF", name: "iPad" };
69
+ await expect(resolveIosUdid(undefined, iosTargets(PHONE, second))).rejects.toThrow(/--udid/);
70
+ });
71
+ it("validates an explicitly-passed udid", async () => {
72
+ await expect(resolveIosUdid("nope", iosTargets(PHONE))).rejects.toThrow(/no ios device/i);
73
+ await expect(resolveIosUdid("00008030-ABC", iosTargets(PHONE))).resolves.toBe("00008030-ABC");
74
+ });
75
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * iOS WebDriverAgent signing configuration.
3
+ *
4
+ * Driving a physical iPhone goes through Appium's XCUITest driver, which builds
5
+ * and code-signs a WebDriverAgent (WDA) runner onto the device. That build needs
6
+ * an Apple Developer signing identity. These values are supplied via env vars and
7
+ * forwarded as XCUITest capabilities. Simulators need no signing, so the caps are
8
+ * only emitted when a team id is configured.
9
+ *
10
+ * The CLI-native `AXIMO_IOS_*` names take precedence; Autify's MobileLink
11
+ * (mobile-web) names — `XCODE_TEAM_ID` / `XCODE_SIGN_ID` / `WDA_BUNDLE_ID` — are
12
+ * accepted as fallbacks so an already-configured MobileLink host needs no changes.
13
+ */
14
+ export interface IosSigningConfig {
15
+ /** Apple Developer Team ID, forwarded as `appium:xcodeOrgId`. */
16
+ teamId?: string;
17
+ /** Code-signing identity; defaults to "Apple Development". */
18
+ signingId: string;
19
+ /** WDA bundle id backed by a valid provisioning profile. */
20
+ wdaBundleId: string;
21
+ /** Optional prebuilt-WDA DerivedData path to skip per-session WDA rebuilds. */
22
+ derivedDataPath?: string;
23
+ }
24
+ type Env = Record<string, string | undefined>;
25
+ export declare function resolveIosSigning(env?: Env): IosSigningConfig;
26
+ /** Map signing config to XCUITest capabilities; empty unless a team id is set. */
27
+ export declare function iosSigningCapabilities(config: IosSigningConfig): Record<string, unknown>;
28
+ export {};
@@ -0,0 +1,38 @@
1
+ const DEFAULT_SIGNING_ID = "Apple Development";
2
+ const DEFAULT_WDA_BUNDLE_ID = "com.facebook.WebDriverAgentRunner";
3
+ export function resolveIosSigning(env = process.env) {
4
+ // AXIMO_IOS_* is CLI-native; XCODE_*/WDA_BUNDLE_ID mirror MobileLink (mobile-web).
5
+ const pick = (...keys) => {
6
+ for (const key of keys) {
7
+ const value = env[key]?.trim();
8
+ if (value)
9
+ return value;
10
+ }
11
+ return undefined;
12
+ };
13
+ const teamId = pick("AXIMO_IOS_TEAM_ID", "XCODE_TEAM_ID");
14
+ const derivedDataPath = pick("AXIMO_IOS_DERIVED_DATA_PATH");
15
+ const config = {
16
+ signingId: pick("AXIMO_IOS_SIGNING_ID", "XCODE_SIGN_ID") || DEFAULT_SIGNING_ID,
17
+ wdaBundleId: pick("AXIMO_IOS_WDA_BUNDLE_ID", "WDA_BUNDLE_ID") || DEFAULT_WDA_BUNDLE_ID,
18
+ };
19
+ if (teamId)
20
+ config.teamId = teamId;
21
+ if (derivedDataPath)
22
+ config.derivedDataPath = derivedDataPath;
23
+ return config;
24
+ }
25
+ /** Map signing config to XCUITest capabilities; empty unless a team id is set. */
26
+ export function iosSigningCapabilities(config) {
27
+ if (!config.teamId)
28
+ return {};
29
+ const caps = {
30
+ "appium:xcodeOrgId": config.teamId,
31
+ "appium:xcodeSigningId": config.signingId,
32
+ "appium:updatedWDABundleId": config.wdaBundleId,
33
+ "appium:allowProvisioningDeviceRegistration": true,
34
+ };
35
+ if (config.derivedDataPath)
36
+ caps["appium:derivedDataPath"] = config.derivedDataPath;
37
+ return caps;
38
+ }
@@ -0,0 +1 @@
1
+ export {};