@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
@@ -0,0 +1,101 @@
1
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { execBinary } from "./exec.js";
6
+ import { parseProfileXml } from "./ios-profiles.js";
7
+ const TOP_LEVEL_PLIST = /^Payload\/[^/]+\.app\/Info\.plist$/;
8
+ /** Inspect an IPA's identity/signature surface without a full extraction. */
9
+ export async function inspectIpa(ipaPath, exec = execBinary) {
10
+ const { stdout } = await exec("unzip", ["-Z1", ipaPath]);
11
+ const entries = stdout.toString("utf-8").split("\n");
12
+ const plistEntry = entries.find((e) => TOP_LEVEL_PLIST.test(e.trim()))?.trim();
13
+ if (!plistEntry) {
14
+ throw new Error(`Not a valid .ipa (no Payload/<app>.app/Info.plist): ${ipaPath}`);
15
+ }
16
+ const appDir = plistEntry.slice(0, -"/Info.plist".length);
17
+ const profileEntry = `${appDir}/embedded.mobileprovision`;
18
+ const hasProfile = entries.some((e) => e.trim() === profileEntry);
19
+ const scratch = mkdtempSync(join(tmpdir(), "aximo-ipa-inspect-"));
20
+ try {
21
+ const wanted = [plistEntry, ...(hasProfile ? [profileEntry] : [])];
22
+ await exec("unzip", ["-o", "-q", ipaPath, ...wanted, "-d", scratch]);
23
+ const plistPath = join(scratch, plistEntry);
24
+ const bundleId = (await exec("plutil", ["-extract", "CFBundleIdentifier", "raw", plistPath])).stdout
25
+ .toString("utf-8")
26
+ .trim();
27
+ if (!bundleId)
28
+ throw new Error(`Could not read CFBundleIdentifier from ${ipaPath}`);
29
+ let profile = null;
30
+ if (hasProfile) {
31
+ try {
32
+ const { stdout: xml } = await exec("security", [
33
+ "cms",
34
+ "-D",
35
+ "-i",
36
+ join(scratch, profileEntry),
37
+ ]);
38
+ profile = parseProfileXml(xml.toString("utf-8"), profileEntry);
39
+ }
40
+ catch {
41
+ profile = null; // undecodable profile → treated as not installable
42
+ }
43
+ }
44
+ const encrypted = await readEncryption(ipaPath, appDir, plistPath, scratch, exec);
45
+ return { bundleId, profile, encrypted };
46
+ }
47
+ finally {
48
+ rmSync(scratch, { recursive: true, force: true });
49
+ }
50
+ }
51
+ /** FairPlay check: extract just the main binary and read LC_ENCRYPTION_INFO. */
52
+ async function readEncryption(ipaPath, appDir, plistPath, scratch, exec) {
53
+ try {
54
+ const executable = (await exec("plutil", ["-extract", "CFBundleExecutable", "raw", plistPath])).stdout
55
+ .toString("utf-8")
56
+ .trim();
57
+ // Plain filename only — a crafted CFBundleExecutable must not become a zip
58
+ // pattern or filesystem path segment (traversal during extraction).
59
+ if (!executable || /[/\\]/.test(executable) || executable === "..")
60
+ return null;
61
+ const binEntry = `${appDir}/${executable}`;
62
+ await exec("unzip", ["-o", "-q", ipaPath, binEntry, "-d", scratch]);
63
+ const { stdout } = await exec("otool", ["-l", join(scratch, binEntry)]);
64
+ const cryptid = stdout.toString("utf-8").match(/cryptid\s+(\d+)/);
65
+ return cryptid?.[1] ? cryptid[1] !== "0" : null;
66
+ }
67
+ catch {
68
+ return null; // best-effort; a resign attempt will surface real problems
69
+ }
70
+ }
71
+ /** True when the IPA (per its embedded profile) can install on `udid` as-is. */
72
+ export function isInstallableOn(info, udid, now = Date.now()) {
73
+ const profile = info.profile;
74
+ if (!profile)
75
+ return false;
76
+ if (profile.expirationDate.getTime() <= now)
77
+ return false;
78
+ return profile.provisionedDevices === null || profile.provisionedDevices.includes(udid);
79
+ }
80
+ function applesignBin() {
81
+ return createRequire(import.meta.url).resolve("applesign/bin/applesign.js");
82
+ }
83
+ /** Re-sign an IPA via the bundled applesign (never PATH-dependent). */
84
+ export async function resignIpa(options) {
85
+ const exec = options.exec ?? execBinary;
86
+ const args = [
87
+ applesignBin(),
88
+ "-i",
89
+ options.identityHash,
90
+ "-m",
91
+ options.profilePath,
92
+ ...(options.bundleId ? ["-b", options.bundleId] : []),
93
+ "-o",
94
+ options.outPath,
95
+ options.ipaPath,
96
+ ];
97
+ await exec(process.execPath, args);
98
+ if (!existsSync(options.outPath)) {
99
+ throw new Error(`applesign reported success but ${options.outPath} was not created.`);
100
+ }
101
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { describe, expect, it } from "vitest";
3
+ import { isInstallableOn, resignIpa } from "./ios-resign.js";
4
+ const NOW = new Date("2026-07-13T00:00:00Z").getTime();
5
+ function profile(over) {
6
+ return {
7
+ path: "/p",
8
+ name: "p",
9
+ teamId: "TESTTEAM99",
10
+ bundleId: "com.example.app",
11
+ isWildcard: false,
12
+ expirationDate: new Date("2026-07-20T00:00:00Z"),
13
+ provisionedDevices: ["UDID-1"],
14
+ ...over,
15
+ };
16
+ }
17
+ describe("isInstallableOn", () => {
18
+ const info = (p) => ({
19
+ bundleId: "com.example.app",
20
+ profile: p,
21
+ encrypted: false,
22
+ });
23
+ it("requires an embedded profile", () => {
24
+ expect(isInstallableOn(info(null), "UDID-1", NOW)).toBe(false);
25
+ });
26
+ it("requires the profile to be unexpired and cover the udid", () => {
27
+ expect(isInstallableOn(info(profile({})), "UDID-1", NOW)).toBe(true);
28
+ expect(isInstallableOn(info(profile({})), "OTHER", NOW)).toBe(false);
29
+ const expired = profile({ expirationDate: new Date("2026-07-01T00:00:00Z") });
30
+ expect(isInstallableOn(info(expired), "UDID-1", NOW)).toBe(false);
31
+ });
32
+ it("treats a missing device list (enterprise) as covering any device", () => {
33
+ expect(isInstallableOn(info(profile({ provisionedDevices: null })), "ANY", NOW)).toBe(true);
34
+ });
35
+ });
36
+ describe("resignIpa", () => {
37
+ it("runs the bundled applesign with identity, profile, remap, and output", async () => {
38
+ let seen = [];
39
+ const exec = async (file, args) => {
40
+ seen = [file, ...args];
41
+ const out = args[args.indexOf("-o") + 1];
42
+ if (out)
43
+ writeFileSync(out, "ipa");
44
+ return { stdout: Buffer.from(""), stderr: "" };
45
+ };
46
+ const outPath = `${process.env["TMPDIR"] ?? "/tmp"}/aximo-test-resign-out.ipa`;
47
+ await resignIpa({
48
+ ipaPath: "/apps/in.ipa",
49
+ outPath,
50
+ identityHash: "A".repeat(40),
51
+ profilePath: "/profiles/p.mobileprovision",
52
+ bundleId: "com.remapped.id",
53
+ exec,
54
+ });
55
+ expect(seen[0]).toBe(process.execPath);
56
+ expect(seen[1]).toMatch(/applesign/);
57
+ expect(seen).toEqual(expect.arrayContaining([
58
+ "-i",
59
+ "A".repeat(40),
60
+ "-m",
61
+ "/profiles/p.mobileprovision",
62
+ "-b",
63
+ "com.remapped.id",
64
+ "-o",
65
+ outPath,
66
+ "/apps/in.ipa",
67
+ ]));
68
+ });
69
+ it("omits -b without a remap and fails when no output is produced", async () => {
70
+ let seen = [];
71
+ const exec = async (file, args) => {
72
+ seen = [file, ...args];
73
+ return { stdout: Buffer.from(""), stderr: "" };
74
+ };
75
+ await expect(resignIpa({
76
+ ipaPath: "/apps/in.ipa",
77
+ outPath: "/nonexistent-dir/never-created.ipa",
78
+ identityHash: "A".repeat(40),
79
+ profilePath: "/profiles/p.mobileprovision",
80
+ exec,
81
+ })).rejects.toThrow(/was not created/);
82
+ expect(seen).not.toContain("-b");
83
+ });
84
+ });
@@ -0,0 +1,10 @@
1
+ import { readWdaFailureReason } from "./ios-wda-diagnostics.js";
2
+ /**
3
+ * Turn a raw XCUITest session-start failure into a transparent, actionable error:
4
+ * the driver's message, the real reason pulled from the WDA `.xcresult` (e.g. an
5
+ * untrusted developer cert), and the matching fix hint. The reason is what lets
6
+ * the hint fire — the driver's own message is usually too generic to match.
7
+ */
8
+ export declare function enrichIosSessionError(err: unknown, derivedDataPath?: string, deps?: {
9
+ readReason?: typeof readWdaFailureReason;
10
+ }): Promise<Error>;
@@ -0,0 +1,34 @@
1
+ import { mobileErrorHint } from "./mobile-errors.js";
2
+ import { readWdaFailureReason } from "./ios-wda-diagnostics.js";
3
+ /**
4
+ * Turn a raw XCUITest session-start failure into a transparent, actionable error:
5
+ * the driver's message, the real reason pulled from the WDA `.xcresult` (e.g. an
6
+ * untrusted developer cert), and the matching fix hint. The reason is what lets
7
+ * the hint fire — the driver's own message is usually too generic to match.
8
+ */
9
+ export async function enrichIosSessionError(err, derivedDataPath, deps = {}) {
10
+ const base = err instanceof Error ? err.message : String(err);
11
+ let reason = null;
12
+ try {
13
+ const read = deps.readReason ?? readWdaFailureReason;
14
+ const options = {};
15
+ if (derivedDataPath !== undefined)
16
+ options.derivedDataPath = derivedDataPath;
17
+ reason = await read(options);
18
+ }
19
+ catch {
20
+ // Best-effort diagnostics: fall back to just the driver's message.
21
+ }
22
+ const hint = mobileErrorHint(reason ?? base, "ios");
23
+ const parts = ["Could not start the iOS session (WebDriverAgent failed to launch)."];
24
+ if (reason)
25
+ parts.push(`Reason: ${reason}`);
26
+ parts.push(`Details: ${base}`);
27
+ if (hint)
28
+ parts.push(`Fix: ${hint}`);
29
+ parts.push("More: run `aximo mobile doctor --platform ios`.");
30
+ const enriched = new Error(parts.join("\n"));
31
+ if (err instanceof Error && err.stack)
32
+ enriched.stack = err.stack;
33
+ return enriched;
34
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { enrichIosSessionError } from "./ios-session-error.js";
3
+ describe("enrichIosSessionError", () => {
4
+ it("surfaces the xcresult reason and the matching trust hint", async () => {
5
+ const readReason = vi.fn(async () => "The Developer App Certificate is not trusted.");
6
+ const enriched = await enrichIosSessionError(new Error("WebDriver POST /session failed: xcodebuild failed with code 65"), "/dd", { readReason });
7
+ expect(enriched.message).toContain("Reason: The Developer App Certificate is not trusted.");
8
+ expect(enriched.message).toContain("VPN & Device Management"); // the trust hint fired
9
+ expect(enriched.message).toContain("xcodebuild failed with code 65"); // original detail kept
10
+ expect(enriched.message).toContain("aximo mobile doctor");
11
+ });
12
+ it("falls back to the driver message when no xcresult reason is available", async () => {
13
+ const readReason = vi.fn(async () => null);
14
+ const enriched = await enrichIosSessionError(new Error("xcodebuild failed with code 65"), "/dd", {
15
+ readReason,
16
+ });
17
+ expect(enriched.message).not.toContain("Reason:");
18
+ expect(enriched.message).toContain("xcodebuild failed with code 65");
19
+ // Generic WDA hint still fires from the driver message.
20
+ expect(enriched.message).toContain("Fix:");
21
+ });
22
+ it("never throws even if reason extraction fails", async () => {
23
+ const readReason = vi.fn(async () => {
24
+ throw new Error("xcresulttool missing");
25
+ });
26
+ const enriched = await enrichIosSessionError(new Error("boom"), undefined, { readReason });
27
+ expect(enriched).toBeInstanceOf(Error);
28
+ expect(enriched.message).toContain("Details: boom");
29
+ });
30
+ });
@@ -1,11 +1,15 @@
1
+ import { type ExecFn } from "./exec.js";
2
+ import { listSigningIdentities } from "./ios-identity.js";
3
+ import { listProvisioningProfiles } from "./ios-profiles.js";
1
4
  /**
2
5
  * iOS WebDriverAgent signing configuration.
3
6
  *
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.
7
+ * Driving a physical iPhone goes through Appium's XCUITest driver, which needs a
8
+ * development-signed WebDriverAgent (WDA) runner on the device. Configuration is
9
+ * zero-config by default — `resolveIosSigningAuto` discovers the identity and
10
+ * Team ID from the keychain and derives/reuses a WDA bundle id from the installed
11
+ * profiles — with env vars as overrides. Simulators need no signing, so the caps
12
+ * are only emitted when a team id is available.
9
13
  *
10
14
  * The CLI-native `AXIMO_IOS_*` names take precedence; Autify's MobileLink
11
15
  * (mobile-web) names — `XCODE_TEAM_ID` / `XCODE_SIGN_ID` / `WDA_BUNDLE_ID` — are
@@ -20,9 +24,33 @@ export interface IosSigningConfig {
20
24
  wdaBundleId: string;
21
25
  /** Optional prebuilt-WDA DerivedData path to skip per-session WDA rebuilds. */
22
26
  derivedDataPath?: string;
27
+ /** Reuse the cached WDA build in `derivedDataPath` instead of rebuilding+signing. */
28
+ usePrebuiltWDA?: boolean;
29
+ /** Launch the already-installed WDA runner instead of building (iOS 17+). */
30
+ usePreinstalledWDA?: boolean;
31
+ /** Keychain identity hash (drives re-signing; discovered, never a capability). */
32
+ identityHash?: string;
23
33
  }
24
34
  type Env = Record<string, string | undefined>;
25
35
  export declare function resolveIosSigning(env?: Env): IosSigningConfig;
26
36
  /** Map signing config to XCUITest capabilities; empty unless a team id is set. */
27
37
  export declare function iosSigningCapabilities(config: IosSigningConfig): Record<string, unknown>;
38
+ /** Aximo-owned WDA bundle id for teams with no prior WDA profile. */
39
+ export declare function defaultWdaBundleId(teamId: string): string;
40
+ export interface ResolveIosSigningAutoOptions {
41
+ env?: Env;
42
+ exec?: ExecFn;
43
+ /** Device UDID — profiles are matched against it when deriving the WDA id. */
44
+ udid?: string;
45
+ now?: number;
46
+ listIdentities?: typeof listSigningIdentities;
47
+ listProfiles?: typeof listProvisioningProfiles;
48
+ }
49
+ /**
50
+ * Zero-config signing resolution: start from the env config, then fill the gaps
51
+ * from the keychain (identity + Team ID) and installed profiles (WDA bundle id —
52
+ * reuse the id of a still-valid runner profile so no re-mint is needed; derive an
53
+ * Aximo-owned id otherwise). Explicit env values always win.
54
+ */
55
+ export declare function resolveIosSigningAuto(options?: ResolveIosSigningAutoOptions): Promise<IosSigningConfig>;
28
56
  export {};
@@ -1,3 +1,6 @@
1
+ import { execBinary } from "./exec.js";
2
+ import { listSigningIdentities, selectIdentity } from "./ios-identity.js";
3
+ import { listProvisioningProfiles } from "./ios-profiles.js";
1
4
  const DEFAULT_SIGNING_ID = "Apple Development";
2
5
  const DEFAULT_WDA_BUNDLE_ID = "com.facebook.WebDriverAgentRunner";
3
6
  export function resolveIosSigning(env = process.env) {
@@ -24,6 +27,14 @@ export function resolveIosSigning(env = process.env) {
24
27
  }
25
28
  /** Map signing config to XCUITest capabilities; empty unless a team id is set. */
26
29
  export function iosSigningCapabilities(config) {
30
+ // Preinstalled runner: the driver launches it via devicectl — no build, so no
31
+ // signing caps; mixing in derivedDataPath/usePrebuiltWDA would fight it.
32
+ if (config.usePreinstalledWDA) {
33
+ return {
34
+ "appium:usePreinstalledWDA": true,
35
+ "appium:updatedWDABundleId": config.wdaBundleId,
36
+ };
37
+ }
27
38
  if (!config.teamId)
28
39
  return {};
29
40
  const caps = {
@@ -34,5 +45,50 @@ export function iosSigningCapabilities(config) {
34
45
  };
35
46
  if (config.derivedDataPath)
36
47
  caps["appium:derivedDataPath"] = config.derivedDataPath;
48
+ if (config.usePrebuiltWDA)
49
+ caps["appium:usePrebuiltWDA"] = true;
37
50
  return caps;
38
51
  }
52
+ const WDA_RUNNER_SUFFIX = ".xctrunner";
53
+ /** Aximo-owned WDA bundle id for teams with no prior WDA profile. */
54
+ export function defaultWdaBundleId(teamId) {
55
+ return `com.aximo.wda.${teamId.toLowerCase()}`;
56
+ }
57
+ /**
58
+ * Zero-config signing resolution: start from the env config, then fill the gaps
59
+ * from the keychain (identity + Team ID) and installed profiles (WDA bundle id —
60
+ * reuse the id of a still-valid runner profile so no re-mint is needed; derive an
61
+ * Aximo-owned id otherwise). Explicit env values always win.
62
+ */
63
+ export async function resolveIosSigningAuto(options = {}) {
64
+ const env = options.env ?? process.env;
65
+ const exec = options.exec ?? execBinary;
66
+ const config = resolveIosSigning(env);
67
+ const identity = selectIdentity(await (options.listIdentities ?? listSigningIdentities)(exec), config.teamId);
68
+ if (identity)
69
+ config.identityHash = identity.hash;
70
+ if (!config.teamId && identity?.teamId)
71
+ config.teamId = identity.teamId;
72
+ const wdaExplicit = Boolean(env["AXIMO_IOS_WDA_BUNDLE_ID"]?.trim() || env["WDA_BUNDLE_ID"]?.trim());
73
+ if (!wdaExplicit && config.teamId) {
74
+ const profiles = await (options.listProfiles ?? listProvisioningProfiles)({ exec });
75
+ const now = options.now ?? Date.now();
76
+ // Reuse a valid runner profile for this team/device: no re-mint, and existing
77
+ // setups (e.g. MobileLink-style com.<user>.WebDriverAgentRunner) keep working.
78
+ const runnerProfile = profiles.find((p) => p.teamId === config.teamId &&
79
+ p.bundleId.endsWith(WDA_RUNNER_SUFFIX) &&
80
+ isWdaRunnerId(p.bundleId.slice(0, -WDA_RUNNER_SUFFIX.length)) &&
81
+ p.expirationDate.getTime() > now &&
82
+ (!options.udid ||
83
+ p.provisionedDevices === null ||
84
+ p.provisionedDevices.includes(options.udid)));
85
+ config.wdaBundleId = runnerProfile
86
+ ? runnerProfile.bundleId.slice(0, -WDA_RUNNER_SUFFIX.length)
87
+ : defaultWdaBundleId(config.teamId);
88
+ }
89
+ return config;
90
+ }
91
+ /** Only reuse profiles that are unmistakably WDA runners, never app-resign ones. */
92
+ function isWdaRunnerId(baseId) {
93
+ return baseId.endsWith("WebDriverAgentRunner") || baseId.startsWith("com.aximo.wda.");
94
+ }
@@ -71,3 +71,90 @@ describe("iosSigningCapabilities", () => {
71
71
  expect(caps["appium:derivedDataPath"]).toBe("/tmp/wda");
72
72
  });
73
73
  });
74
+ describe("resolveIosSigningAuto", async () => {
75
+ const { resolveIosSigningAuto, defaultWdaBundleId } = await import("./ios-signing.js");
76
+ const identity = {
77
+ hash: "0B0DF9CFF90EC3358C9A2CB52F006EB83FD7748C",
78
+ name: "Apple Development: test@example.com (ABC123DEF4)",
79
+ teamId: "TESTTEAM99",
80
+ };
81
+ const runnerProfile = {
82
+ path: "/p/wda.mobileprovision",
83
+ name: "wda",
84
+ teamId: "TESTTEAM99",
85
+ bundleId: "com.someone.WebDriverAgentRunner.xctrunner",
86
+ isWildcard: false,
87
+ expirationDate: new Date("2999-01-01T00:00:00Z"),
88
+ provisionedDevices: ["UDID-1"],
89
+ };
90
+ it("discovers team + identity from the keychain when env is empty", async () => {
91
+ const config = await resolveIosSigningAuto({
92
+ env: {},
93
+ udid: "UDID-1",
94
+ listIdentities: async () => [identity],
95
+ listProfiles: async () => [],
96
+ });
97
+ expect(config.teamId).toBe("TESTTEAM99");
98
+ expect(config.identityHash).toBe(identity.hash);
99
+ expect(config.wdaBundleId).toBe(defaultWdaBundleId("TESTTEAM99"));
100
+ });
101
+ it("lets env overrides win over discovery", async () => {
102
+ const config = await resolveIosSigningAuto({
103
+ env: { AXIMO_IOS_TEAM_ID: "ENVTEAM000", AXIMO_IOS_WDA_BUNDLE_ID: "com.env.WDA" },
104
+ udid: "UDID-1",
105
+ listIdentities: async () => [identity],
106
+ listProfiles: async () => [runnerProfile],
107
+ });
108
+ expect(config.teamId).toBe("ENVTEAM000");
109
+ expect(config.wdaBundleId).toBe("com.env.WDA"); // explicit id blocks derivation
110
+ expect(config.identityHash).toBeUndefined(); // no identity matches ENVTEAM000
111
+ });
112
+ it("reuses a valid runner profile's base id for this team + device", async () => {
113
+ const config = await resolveIosSigningAuto({
114
+ env: {},
115
+ udid: "UDID-1",
116
+ listIdentities: async () => [identity],
117
+ listProfiles: async () => [runnerProfile],
118
+ });
119
+ expect(config.wdaBundleId).toBe("com.someone.WebDriverAgentRunner");
120
+ });
121
+ it("ignores expired, other-device, other-team, and non-WDA runner profiles", async () => {
122
+ const expired = { ...runnerProfile, expirationDate: new Date("2000-01-01T00:00:00Z") };
123
+ const otherDevice = { ...runnerProfile, provisionedDevices: ["OTHER"] };
124
+ const otherTeam = { ...runnerProfile, teamId: "OTHERTEAM0" };
125
+ const resignProfile = {
126
+ ...runnerProfile,
127
+ bundleId: "com.aximo.resign.testteam99.xctrunner",
128
+ };
129
+ const config = await resolveIosSigningAuto({
130
+ env: {},
131
+ udid: "UDID-1",
132
+ listIdentities: async () => [identity],
133
+ listProfiles: async () => [expired, otherDevice, otherTeam, resignProfile],
134
+ });
135
+ expect(config.wdaBundleId).toBe(defaultWdaBundleId("TESTTEAM99"));
136
+ });
137
+ it("accepts com.aximo.wda.* profiles as reusable runner ids", async () => {
138
+ const aximoWda = {
139
+ ...runnerProfile,
140
+ bundleId: "com.aximo.wda.testteam99.xctrunner",
141
+ };
142
+ const config = await resolveIosSigningAuto({
143
+ env: {},
144
+ udid: "UDID-1",
145
+ listIdentities: async () => [identity],
146
+ listProfiles: async () => [aximoWda],
147
+ });
148
+ expect(config.wdaBundleId).toBe("com.aximo.wda.testteam99");
149
+ });
150
+ it("returns an env-free config with defaults when nothing is discoverable", async () => {
151
+ const config = await resolveIosSigningAuto({
152
+ env: {},
153
+ listIdentities: async () => [],
154
+ listProfiles: async () => [],
155
+ });
156
+ expect(config.teamId).toBeUndefined();
157
+ expect(config.identityHash).toBeUndefined();
158
+ expect(config.wdaBundleId).toBe("com.facebook.WebDriverAgentRunner");
159
+ });
160
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * iOS 17+/18+ device automation goes through Appium's XCUITest driver, which can
3
+ * only reach WebDriverAgent on the device through a RemoteXPC/CoreDevice tunnel.
4
+ * The driver ships a `tunnel-creation` script that must run as root (it creates a
5
+ * `utun` interface) and exposes a tunnel registry server on a fixed local port.
6
+ * Older iOS and self-managed setups can opt out with AXIMO_IOS_SKIP_TUNNEL.
7
+ */
8
+ export declare const DEFAULT_TUNNEL_REGISTRY_PORT = 42314;
9
+ /** How to stop the elevated tunnel daemon — surfaced whenever we start or leave it running. */
10
+ export declare const TUNNEL_STOP_COMMAND = "sudo pkill -f tunnel-creation";
11
+ /**
12
+ * True when the tunnel registry server answers its HTTP API on `port`. A raw
13
+ * TCP connect is not enough — an unrelated listener on the port would be
14
+ * mistaken for the tunnel and surface much later as an opaque XCUITest error.
15
+ */
16
+ export declare function isTunnelHealthy(port?: number, host?: string, timeoutMs?: number): Promise<boolean>;
17
+ export interface TunnelScriptOptions {
18
+ appiumHome: string;
19
+ nodePath: string;
20
+ appiumBin: string;
21
+ logFile: string;
22
+ }
23
+ /**
24
+ * A self-contained shell script that launches the driver's `tunnel-creation` as a
25
+ * fully detached daemon (own session, via {@link DAEMONIZE_PERL}). Run as root via
26
+ * the macOS auth prompt; it returns immediately while the tunnel keeps running
27
+ * (health-checked via the registry port).
28
+ */
29
+ export declare function buildTunnelScript(opts: TunnelScriptOptions): string;
30
+ /** Prompt for admin rights (password/Touch ID) and run the tunnel script as root. */
31
+ export type Elevator = (scriptPath: string) => Promise<void>;
32
+ export interface EnsureIosTunnelOptions {
33
+ port?: number;
34
+ appiumHome?: string;
35
+ nodePath?: string;
36
+ appiumBin?: string;
37
+ /** Directory for the generated tunnel script + log (defaults to $AXIMO_HOME/ios-tunnel). */
38
+ dir?: string;
39
+ elevate?: Elevator;
40
+ isHealthy?: (port: number) => Promise<boolean>;
41
+ writeScript?: (path: string, content: string, dir: string) => void;
42
+ readyTimeoutMs?: number;
43
+ log?: (message: string) => void;
44
+ env?: NodeJS.ProcessEnv;
45
+ }
46
+ export interface EnsureIosTunnelResult {
47
+ reused: boolean;
48
+ started: boolean;
49
+ skipped: boolean;
50
+ port: number;
51
+ }
52
+ /**
53
+ * Ensure the RemoteXPC tunnel is running so XCUITest can reach WebDriverAgent.
54
+ * Reuses an already-running tunnel (no prompt); otherwise elevates once via the
55
+ * macOS auth dialog to start it. No-op when AXIMO_IOS_SKIP_TUNNEL is set.
56
+ */
57
+ export declare function ensureIosTunnel(options?: EnsureIosTunnelOptions): Promise<EnsureIosTunnelResult>;