@autifyhq/aximo-cli 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -546,7 +546,7 @@ Run `aximo mobile doctor --platform ios` to see what was discovered (identity, t
546
546
 
547
547
  > **On-device steps you can't skip** (Apple requires them once per phone): enable **Developer Mode** (Settings → Privacy & Security → Developer Mode; the phone restarts), tap **Trust This Computer**, and — after the first install of an app signed by your team — **trust the developer certificate** (Settings → General → VPN & Device Management → your "Apple Development" profile → Trust; the phone needs internet). On **free/personal teams** profiles expire every ~7 days; the CLI re-mints them automatically, but a re-mint **resets the certificate trust**, so expect that trust tap to recur weekly. Free teams are also capped at 10 App IDs per week.
548
548
  >
549
- > On **iOS 17+**, `aximo mobile connect` also starts a small **device tunnel that needs admin rights** — it prompts once for your macOS password / Touch ID (reused on later runs, so at most once per boot). The tunnel deliberately keeps running after `connect` exits; stop it anytime with `sudo pkill -f tunnel-creation`. Devices below iOS 17 skip the tunnel entirely, and Android never uses it. If anything mentions "iOS X is not installed", run `xcodebuild -downloadPlatform iOS` (only the classic build flow needs it).
549
+ > On **iOS 17+**, `aximo mobile connect` also starts a small **device tunnel that needs admin rights** — it prompts once for your macOS password / Touch ID (reused on later runs; it only re-prompts when the running tunnel has no tunnel for your device — e.g. it was started before the phone was attached — and must be restarted). The tunnel deliberately keeps running after `connect` exits; stop it anytime with `sudo pkill -f tunnel-creation`. Devices below iOS 17 skip the tunnel entirely, and Android never uses it. If anything mentions "iOS X is not installed", run `xcodebuild -downloadPlatform iOS` (only the classic build flow needs it).
550
550
 
551
551
  ### Enable USB debugging on the phone
552
552
 
@@ -125,7 +125,7 @@ export async function connectWithConfig(config, options) {
125
125
  console.error(`iOS ${iosMajor} device — skipping the RemoteXPC tunnel (not needed).`);
126
126
  }
127
127
  else {
128
- const tunnel = await ensureIosTunnel();
128
+ const tunnel = await ensureIosTunnel({ udid: config.target });
129
129
  tunnelRunning = tunnel.started || tunnel.reused;
130
130
  if (tunnel.reused)
131
131
  console.error("Reusing the running iOS device tunnel.");
@@ -23,11 +23,18 @@ export interface MobileDoctorReport {
23
23
  * APKs have wedged instrumentation. Only called when adb itself is available.
24
24
  */
25
25
  export declare function checkAndroidDevice(exec?: ExecFn): Promise<Check[]>;
26
- export declare function checkIosTunnel(isHealthy?: (port: number) => Promise<boolean>): Promise<Check>;
26
+ export interface IosTunnelTarget {
27
+ name: string;
28
+ /** Device udid the registry must have a tunnel for. */
29
+ target: string;
30
+ }
31
+ export declare function checkIosTunnel(listUdids?: (port: number) => Promise<string[] | null>, targets?: IosTunnelTarget[]): Promise<Check>;
27
32
  export interface RunMobileDoctorOptions {
28
33
  platform?: "ios" | "android" | undefined;
29
- /** Injectable tunnel probe for tests. */
30
- isTunnelHealthy?: (port: number) => Promise<boolean>;
34
+ /** Injectable tunnel-registry probe for tests. */
35
+ listTunnelUdids?: (port: number) => Promise<string[] | null>;
36
+ /** Registered iOS devices the tunnel registry should serve (defaults to on-disk configs). */
37
+ iosTunnelTargets?: IosTunnelTarget[];
31
38
  /** Injectable adb runner for tests (device-state checks). */
32
39
  exec?: ExecFn;
33
40
  }
@@ -10,7 +10,8 @@ import { getAppiumHome, resolveAndroidSdk } from "../../mobile-control/appium-se
10
10
  import { listAndroidDevices } from "../../mobile-control/devices.js";
11
11
  import { execBinary } from "../../mobile-control/exec.js";
12
12
  import { resolveIosSigningAuto } from "../../mobile-control/ios-signing.js";
13
- import { DEFAULT_TUNNEL_REGISTRY_PORT, isTunnelHealthy } from "../../mobile-control/ios-tunnel.js";
13
+ import { DEFAULT_TUNNEL_REGISTRY_PORT, listTunnelUdids } from "../../mobile-control/ios-tunnel.js";
14
+ import { listMobileDeviceConfigs } from "./paths.js";
14
15
  function which(cmd) {
15
16
  const probe = platform() === "win32" ? "where" : "which";
16
17
  const result = spawnSync(probe, [cmd], { encoding: "utf-8" });
@@ -283,21 +284,35 @@ async function probeInstrumentation(serial, exec) {
283
284
  }
284
285
  // The RemoteXPC tunnel is host-level (a registry port), so doctor can probe it
285
286
  // without a connected device. `connect` starts it automatically, so a missing
286
- // tunnel is informational, not a failure.
287
- export async function checkIosTunnel(isHealthy = isTunnelHealthy) {
288
- const running = await isHealthy(DEFAULT_TUNNEL_REGISTRY_PORT);
289
- return running
290
- ? {
291
- name: "ios tunnel",
292
- status: "pass",
293
- message: `RemoteXPC tunnel running (registry port ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
294
- }
295
- : {
287
+ // tunnel is informational, not a failure. A running tunnel that lacks a
288
+ // registered device IS a failure mode though: XCUITest 12+ trusts the registry
289
+ // over usbmuxd/devicectl, so such sessions die with "Unknown device or
290
+ // simulator UDID" no matter how visible the phone is.
291
+ export async function checkIosTunnel(listUdids = listTunnelUdids, targets = []) {
292
+ const udids = await listUdids(DEFAULT_TUNNEL_REGISTRY_PORT);
293
+ if (udids === null) {
294
+ return {
296
295
  name: "ios tunnel",
297
296
  status: "info",
298
297
  message: "RemoteXPC tunnel not running",
299
298
  hint: "iOS 17+ needs it to reach WebDriverAgent; `aximo mobile connect` starts it automatically (prompts for admin).",
300
299
  };
300
+ }
301
+ const missing = targets.filter((t) => !udids.includes(t.target));
302
+ if (missing.length > 0) {
303
+ const list = missing.map((m) => `${m.name} (${m.target})`).join(", ");
304
+ return {
305
+ name: "ios tunnel",
306
+ status: "warn",
307
+ message: `RemoteXPC tunnel running but its registry has no tunnel for ${list} — sessions fail with 'Unknown device or simulator UDID'`,
308
+ hint: "Plug the phone in if it isn't connected. If it is, the daemon was likely started before it was attached — `aximo mobile connect` restarts it automatically; manual: sudo pkill -f tunnel-creation, then reconnect.",
309
+ };
310
+ }
311
+ return {
312
+ name: "ios tunnel",
313
+ status: "pass",
314
+ message: `RemoteXPC tunnel running (registry port ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
315
+ };
301
316
  }
302
317
  export async function runMobileDoctor(options = {}) {
303
318
  const checks = [await checkAppium()];
@@ -312,7 +327,11 @@ export async function runMobileDoctor(options = {}) {
312
327
  if (options.platform !== "android") {
313
328
  checks.push(...(await checkIos()));
314
329
  if (platform() === "darwin") {
315
- checks.push(await checkIosTunnel(options.isTunnelHealthy));
330
+ const targets = options.iosTunnelTargets ??
331
+ listMobileDeviceConfigs()
332
+ .filter((c) => c.platform === "ios")
333
+ .map((c) => ({ name: c.name, target: c.target }));
334
+ checks.push(await checkIosTunnel(options.listTunnelUdids, targets));
316
335
  }
317
336
  }
318
337
  const summary = { pass: 0, warn: 0, fail: 0, info: 0 };
@@ -67,12 +67,23 @@ describe("checkAndroidDevice", () => {
67
67
  });
68
68
  describe("checkIosTunnel", () => {
69
69
  it("passes when the RemoteXPC tunnel is reachable", async () => {
70
- const check = await checkIosTunnel(async () => true);
70
+ const check = await checkIosTunnel(async () => []);
71
71
  expect(check.status).toBe("pass");
72
72
  expect(check.name).toBe("ios tunnel");
73
73
  });
74
+ it("passes when the registry serves all registered devices", async () => {
75
+ const check = await checkIosTunnel(async () => ["00008030-AAAA"], [{ name: "My iPhone", target: "00008030-AAAA" }]);
76
+ expect(check.status).toBe("pass");
77
+ });
78
+ it("warns when the registry has no tunnel for a registered device", async () => {
79
+ const check = await checkIosTunnel(async () => [], [{ name: "My iPhone", target: "00008030-AAAA" }]);
80
+ expect(check.status).toBe("warn");
81
+ expect(check.message).toContain("My iPhone (00008030-AAAA)");
82
+ expect(check.message).toContain("Unknown device or simulator UDID");
83
+ expect(check.hint).toContain("pkill -f tunnel-creation");
84
+ });
74
85
  it("is informational (not a failure) when the tunnel is down", async () => {
75
- const check = await checkIosTunnel(async () => false);
86
+ const check = await checkIosTunnel(async () => null, [{ name: "My iPhone", target: "00008030-AAAA" }]);
76
87
  expect(check.status).toBe("info");
77
88
  expect(check.hint).toContain("aximo mobile connect");
78
89
  });
@@ -9,16 +9,25 @@ export declare const DEFAULT_TUNNEL_REGISTRY_PORT = 42314;
9
9
  /** How to stop the elevated tunnel daemon — surfaced whenever we start or leave it running. */
10
10
  export declare const TUNNEL_STOP_COMMAND = "sudo pkill -f tunnel-creation";
11
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.
12
+ * Fetch the tunnel registry on `port` and return the device udids it has
13
+ * tunnels for, or null when no valid registry answers. The response must parse
14
+ * as a registry (`{"tunnels": {...}}`) — a raw TCP connect is not enough, as an
15
+ * unrelated listener on the port would be mistaken for the tunnel and surface
16
+ * much later as an opaque XCUITest error.
17
+ *
18
+ * The udid list matters because the XCUITest driver (12+) validates the
19
+ * session's udid against this registry whenever it answers, ignoring what
20
+ * devicectl/usbmuxd can see — a running registry without the target device
21
+ * guarantees "Unknown device or simulator UDID".
15
22
  */
16
- export declare function isTunnelHealthy(port?: number, host?: string, timeoutMs?: number): Promise<boolean>;
23
+ export declare function listTunnelUdids(port?: number, host?: string, timeoutMs?: number): Promise<string[] | null>;
17
24
  export interface TunnelScriptOptions {
18
25
  appiumHome: string;
19
26
  nodePath: string;
20
27
  appiumBin: string;
21
28
  logFile: string;
29
+ /** Kill any running tunnel-creation daemon first (its registry lacks the target device). */
30
+ restart?: boolean;
22
31
  }
23
32
  /**
24
33
  * A self-contained shell script that launches the driver's `tunnel-creation` as a
@@ -36,8 +45,15 @@ export interface EnsureIosTunnelOptions {
36
45
  appiumBin?: string;
37
46
  /** Directory for the generated tunnel script + log (defaults to $AXIMO_HOME/ios-tunnel). */
38
47
  dir?: string;
48
+ /**
49
+ * Device udid the registry must have a tunnel for. A running daemon without
50
+ * it (started before the phone was attached, or its creation failed) is
51
+ * restarted — reusing it would guarantee an "Unknown device or simulator
52
+ * UDID" session failure from XCUITest 12+.
53
+ */
54
+ udid?: string;
39
55
  elevate?: Elevator;
40
- isHealthy?: (port: number) => Promise<boolean>;
56
+ listUdids?: (port: number) => Promise<string[] | null>;
41
57
  writeScript?: (path: string, content: string, dir: string) => void;
42
58
  readyTimeoutMs?: number;
43
59
  log?: (message: string) => void;
@@ -46,12 +62,16 @@ export interface EnsureIosTunnelOptions {
46
62
  export interface EnsureIosTunnelResult {
47
63
  reused: boolean;
48
64
  started: boolean;
65
+ /** True when a running daemon was replaced because it had no tunnel for `udid`. */
66
+ restarted: boolean;
49
67
  skipped: boolean;
50
68
  port: number;
51
69
  }
52
70
  /**
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.
71
+ * Ensure the RemoteXPC tunnel is running — with a tunnel for `udid` when given —
72
+ * so XCUITest can find the device and reach WebDriverAgent. Reuses a running
73
+ * daemon whose registry serves the device (no prompt); restarts one that
74
+ * doesn't; otherwise elevates once via the macOS auth dialog to start it.
75
+ * No-op when AXIMO_IOS_SKIP_TUNNEL is set.
56
76
  */
57
77
  export declare function ensureIosTunnel(options?: EnsureIosTunnelOptions): Promise<EnsureIosTunnelResult>;
@@ -16,22 +16,32 @@ export const DEFAULT_TUNNEL_REGISTRY_PORT = 42314;
16
16
  /** How to stop the elevated tunnel daemon — surfaced whenever we start or leave it running. */
17
17
  export const TUNNEL_STOP_COMMAND = "sudo pkill -f tunnel-creation";
18
18
  /**
19
- * True when the tunnel registry server answers its HTTP API on `port`. A raw
20
- * TCP connect is not enough — an unrelated listener on the port would be
21
- * mistaken for the tunnel and surface much later as an opaque XCUITest error.
19
+ * Fetch the tunnel registry on `port` and return the device udids it has
20
+ * tunnels for, or null when no valid registry answers. The response must parse
21
+ * as a registry (`{"tunnels": {...}}`) — a raw TCP connect is not enough, as an
22
+ * unrelated listener on the port would be mistaken for the tunnel and surface
23
+ * much later as an opaque XCUITest error.
24
+ *
25
+ * The udid list matters because the XCUITest driver (12+) validates the
26
+ * session's udid against this registry whenever it answers, ignoring what
27
+ * devicectl/usbmuxd can see — a running registry without the target device
28
+ * guarantees "Unknown device or simulator UDID".
22
29
  */
23
- export async function isTunnelHealthy(port = DEFAULT_TUNNEL_REGISTRY_PORT, host = "127.0.0.1", timeoutMs = 1500) {
30
+ export async function listTunnelUdids(port = DEFAULT_TUNNEL_REGISTRY_PORT, host = "127.0.0.1", timeoutMs = 1500) {
24
31
  try {
25
32
  const res = await fetch(`http://${host}:${port}/remotexpc/tunnels`, {
26
33
  signal: AbortSignal.timeout(timeoutMs),
27
34
  });
28
35
  if (!res.ok)
29
- return false;
30
- await res.json();
31
- return true;
36
+ return null;
37
+ const body = (await res.json());
38
+ if (!body || typeof body !== "object" || !body.tunnels || typeof body.tunnels !== "object") {
39
+ return null;
40
+ }
41
+ return Object.keys(body.tunnels);
32
42
  }
33
43
  catch {
34
- return false;
44
+ return null;
35
45
  }
36
46
  }
37
47
  /** Single-quote a value for safe embedding in a POSIX shell script. */
@@ -65,6 +75,9 @@ export function buildTunnelScript(opts) {
65
75
  "#!/bin/sh",
66
76
  `export APPIUM_HOME=${shQuote(opts.appiumHome)}`,
67
77
  'export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"',
78
+ // The old daemon must die before the new one binds the registry port. The
79
+ // pattern can't match this script (its cmdline is `/bin/sh <path>`).
80
+ ...(opts.restart ? ["pkill -f tunnel-creation 2>/dev/null; sleep 1"] : []),
68
81
  `exec perl -e ${shQuote(DAEMONIZE_PERL)} ${shQuote(opts.logFile)} ${shQuote(opts.nodePath)} ${shQuote(opts.appiumBin)} driver run xcuitest tunnel-creation`,
69
82
  "",
70
83
  ].join("\n");
@@ -91,35 +104,47 @@ const defaultWriteScript = (path, content, dir) => {
91
104
  mkdirSync(dir, { recursive: true });
92
105
  writeFileSync(path, content, { mode: 0o700 });
93
106
  };
94
- async function pollHealthy(isHealthy, port, timeoutMs, logFile) {
107
+ async function pollReady(listUdids, port, udid, timeoutMs, logFile) {
95
108
  const deadline = Date.now() + timeoutMs;
109
+ let last = null;
96
110
  while (Date.now() < deadline) {
97
- if (await isHealthy(port))
111
+ last = await listUdids(port);
112
+ if (last !== null && (!udid || last.includes(udid)))
98
113
  return;
99
114
  await new Promise((resolve) => setTimeout(resolve, 500));
100
115
  }
116
+ if (last !== null && udid) {
117
+ throw new Error(`The iOS device tunnel is running but did not create a tunnel for device ${udid} ` +
118
+ `within ${timeoutMs}ms. Make sure the phone is connected over USB, unlocked, and ` +
119
+ `trusts this Mac, then retry. Check ${logFile} for the daemon's output; stop it ` +
120
+ `with: ${TUNNEL_STOP_COMMAND}`);
121
+ }
101
122
  throw new Error(`iOS device tunnel did not become ready within ${timeoutMs}ms. ` +
102
123
  `Check ${logFile} for the daemon's output. A partially-started daemon may still ` +
103
124
  `be running; stop it before retrying: ${TUNNEL_STOP_COMMAND}`);
104
125
  }
105
126
  /**
106
- * Ensure the RemoteXPC tunnel is running so XCUITest can reach WebDriverAgent.
107
- * Reuses an already-running tunnel (no prompt); otherwise elevates once via the
108
- * macOS auth dialog to start it. No-op when AXIMO_IOS_SKIP_TUNNEL is set.
127
+ * Ensure the RemoteXPC tunnel is running — with a tunnel for `udid` when given —
128
+ * so XCUITest can find the device and reach WebDriverAgent. Reuses a running
129
+ * daemon whose registry serves the device (no prompt); restarts one that
130
+ * doesn't; otherwise elevates once via the macOS auth dialog to start it.
131
+ * No-op when AXIMO_IOS_SKIP_TUNNEL is set.
109
132
  */
110
133
  export async function ensureIosTunnel(options = {}) {
111
134
  const env = options.env ?? process.env;
112
135
  const port = options.port ?? DEFAULT_TUNNEL_REGISTRY_PORT;
113
- const isHealthy = options.isHealthy ?? ((p) => isTunnelHealthy(p));
136
+ const listUdids = options.listUdids ?? ((p) => listTunnelUdids(p));
114
137
  const log = options.log ?? ((m) => console.error(m));
115
138
  if (envFlag(env, "AXIMO_IOS_SKIP_TUNNEL")) {
116
- return { reused: false, started: false, skipped: true, port };
139
+ return { reused: false, started: false, restarted: false, skipped: true, port };
117
140
  }
118
- // A healthy tunnel is reused as-is — this is what makes the prompt appear at
119
- // most once per boot rather than once per `connect`.
120
- if (await isHealthy(port)) {
121
- return { reused: true, started: false, skipped: false, port };
141
+ // A tunnel serving the device is reused as-is — this is what makes the prompt
142
+ // appear at most once per boot rather than once per `connect`.
143
+ const existing = await listUdids(port);
144
+ if (existing !== null && (!options.udid || existing.includes(options.udid))) {
145
+ return { reused: true, started: false, restarted: false, skipped: false, port };
122
146
  }
147
+ const restarting = existing !== null;
123
148
  const appiumHome = options.appiumHome ?? getAppiumHome();
124
149
  const nodePath = options.nodePath ?? process.execPath;
125
150
  const appiumBin = options.appiumBin ?? resolveAppiumBin();
@@ -127,8 +152,12 @@ export async function ensureIosTunnel(options = {}) {
127
152
  const scriptPath = join(dir, "tunnel.sh");
128
153
  const logFile = join(dir, "tunnel.log");
129
154
  const writeScript = options.writeScript ?? defaultWriteScript;
130
- writeScript(scriptPath, buildTunnelScript({ appiumHome, nodePath, appiumBin, logFile }), dir);
131
- log("Starting the iOS device tunnel (needs admin). Approve the macOS password / Touch ID prompt…");
155
+ writeScript(scriptPath, buildTunnelScript({ appiumHome, nodePath, appiumBin, logFile, restart: restarting }), dir);
156
+ log(restarting
157
+ ? `The running iOS device tunnel has no tunnel for this device (${options.udid}) — ` +
158
+ "sessions would fail with 'Unknown device or simulator UDID'. Restarting it " +
159
+ "(needs admin). Approve the macOS password / Touch ID prompt…"
160
+ : "Starting the iOS device tunnel (needs admin). Approve the macOS password / Touch ID prompt…");
132
161
  const elevate = options.elevate ?? osascriptElevate;
133
162
  try {
134
163
  await elevate(scriptPath);
@@ -136,15 +165,22 @@ export async function ensureIosTunnel(options = {}) {
136
165
  catch (err) {
137
166
  const detail = err instanceof Error ? err.message : String(err);
138
167
  const declined = /User canceled|-128/i.test(detail);
168
+ const verb = restarting ? "restarted" : "started";
139
169
  const lead = declined
140
- ? "Admin approval was declined, so the iOS device tunnel wasn't started."
141
- : `Could not start the iOS device tunnel: ${detail}.`;
170
+ ? `Admin approval was declined, so the iOS device tunnel wasn't ${verb}.`
171
+ : `Could not ${restarting ? "restart" : "start"} the iOS device tunnel: ${detail}.`;
172
+ // In the restart path the stale daemon still holds the registry port, so the
173
+ // manual command must include the stop step or it fails to bind.
174
+ const manual = restarting ? `stop the stale daemon first (${TUNNEL_STOP_COMMAND}), then ` : "";
142
175
  throw new Error(`${lead} It is required for iOS 17+ to reach WebDriverAgent. Approve the macOS prompt and ` +
143
- `retry, or start it yourself: sudo APPIUM_HOME=${shQuote(appiumHome)} ${shQuote(nodePath)} ` +
144
- `${shQuote(appiumBin)} driver run xcuitest tunnel-creation`);
176
+ `retry, or ${manual}start it yourself: sudo APPIUM_HOME=${shQuote(appiumHome)} ` +
177
+ `${shQuote(nodePath)} ${shQuote(appiumBin)} driver run xcuitest tunnel-creation`);
145
178
  }
146
- await pollHealthy(isHealthy, port, options.readyTimeoutMs ?? 45_000, logFile);
179
+ // Per-device tunnel creation (lockdown, CoreDeviceProxy, RSD catalog discovery)
180
+ // takes 30-60s on top of daemon boot, so the device wait gets a longer default.
181
+ const readyTimeoutMs = options.readyTimeoutMs ?? (options.udid ? 90_000 : 45_000);
182
+ await pollReady(listUdids, port, options.udid, readyTimeoutMs, logFile);
147
183
  log(`iOS device tunnel is up (registry on port ${port}). It keeps running in the background ` +
148
184
  `so later connects don't prompt again; stop it anytime with: ${TUNNEL_STOP_COMMAND}`);
149
- return { reused: false, started: true, skipped: false, port };
185
+ return { reused: false, started: true, restarted: restarting, skipped: false, port };
150
186
  }
@@ -1,5 +1,6 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { buildTunnelScript, DEFAULT_TUNNEL_REGISTRY_PORT, ensureIosTunnel } from "./ios-tunnel.js";
1
+ import { createServer } from "node:http";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { buildTunnelScript, DEFAULT_TUNNEL_REGISTRY_PORT, ensureIosTunnel, listTunnelUdids, } from "./ios-tunnel.js";
3
4
  describe("buildTunnelScript", () => {
4
5
  it("launches tunnel-creation as a setsid-detached daemon with APPIUM_HOME set", () => {
5
6
  const script = buildTunnelScript({
@@ -26,6 +27,63 @@ describe("buildTunnelScript", () => {
26
27
  });
27
28
  expect(script).toContain("export APPIUM_HOME='/home/o'\\''brien/.aximo'");
28
29
  });
30
+ it("kills the old daemon before starting when restart is set", () => {
31
+ const opts = {
32
+ appiumHome: "/ah",
33
+ nodePath: "/node",
34
+ appiumBin: "/appium",
35
+ logFile: "/l.log",
36
+ };
37
+ const restart = buildTunnelScript({ ...opts, restart: true });
38
+ expect(restart).toContain("pkill -f tunnel-creation");
39
+ // pkill must come before the daemon launch, not after.
40
+ expect(restart.indexOf("pkill")).toBeLessThan(restart.indexOf("exec perl"));
41
+ expect(buildTunnelScript(opts)).not.toContain("pkill");
42
+ });
43
+ });
44
+ describe("listTunnelUdids", () => {
45
+ let server;
46
+ afterEach(async () => {
47
+ const open = server;
48
+ server = undefined;
49
+ if (open)
50
+ await new Promise((resolve) => open.close(() => resolve()));
51
+ });
52
+ const listen = (handler) => new Promise((resolve) => {
53
+ server = createServer(handler).listen(0, "127.0.0.1", () => {
54
+ const address = server?.address();
55
+ resolve(typeof address === "object" && address ? address.port : 0);
56
+ });
57
+ });
58
+ it("returns the udids the registry has tunnels for", async () => {
59
+ const port = await listen((req, res) => {
60
+ expect(req.url).toBe("/remotexpc/tunnels");
61
+ res.setHeader("content-type", "application/json");
62
+ res.end(JSON.stringify({ tunnels: { "00008030-AAAA": {}, "00008130-BBBB": {} } }));
63
+ });
64
+ await expect(listTunnelUdids(port)).resolves.toEqual(["00008030-AAAA", "00008130-BBBB"]);
65
+ });
66
+ it("returns null when nothing listens on the port", async () => {
67
+ const port = await listen((_req, res) => res.end());
68
+ const closed = new Promise((resolve) => server?.close(() => resolve()));
69
+ await closed;
70
+ server = undefined;
71
+ await expect(listTunnelUdids(port)).resolves.toBeNull();
72
+ });
73
+ it("returns null when the listener is not a tunnel registry", async () => {
74
+ const port = await listen((_req, res) => {
75
+ res.setHeader("content-type", "application/json");
76
+ res.end(JSON.stringify({ hello: "world" }));
77
+ });
78
+ await expect(listTunnelUdids(port)).resolves.toBeNull();
79
+ });
80
+ it("returns null on a non-JSON or error response", async () => {
81
+ const port = await listen((_req, res) => {
82
+ res.statusCode = 500;
83
+ res.end("boom");
84
+ });
85
+ await expect(listTunnelUdids(port)).resolves.toBeNull();
86
+ });
29
87
  });
30
88
  describe("ensureIosTunnel", () => {
31
89
  const stubs = () => ({
@@ -36,35 +94,73 @@ describe("ensureIosTunnel", () => {
36
94
  it("reuses a healthy tunnel without prompting", async () => {
37
95
  const s = stubs();
38
96
  const result = await ensureIosTunnel({
39
- isHealthy: async () => true,
97
+ listUdids: async () => [],
40
98
  ...s,
41
99
  env: {},
42
100
  });
43
101
  expect(result).toEqual({
44
102
  reused: true,
45
103
  started: false,
104
+ restarted: false,
46
105
  skipped: false,
47
106
  port: DEFAULT_TUNNEL_REGISTRY_PORT,
48
107
  });
49
108
  expect(s.elevate).not.toHaveBeenCalled();
50
109
  expect(s.writeScript).not.toHaveBeenCalled();
51
110
  });
111
+ it("reuses a tunnel that serves the requested device", async () => {
112
+ const s = stubs();
113
+ const result = await ensureIosTunnel({
114
+ udid: "00008030-AAAA",
115
+ listUdids: async () => ["00008030-AAAA"],
116
+ ...s,
117
+ env: {},
118
+ });
119
+ expect(result.reused).toBe(true);
120
+ expect(s.elevate).not.toHaveBeenCalled();
121
+ });
122
+ it("restarts a running tunnel whose registry lacks the device", async () => {
123
+ const s = stubs();
124
+ const listUdids = vi
125
+ .fn()
126
+ .mockResolvedValueOnce([]) // pre-check: registry up, no tunnel for the phone
127
+ .mockResolvedValue(["00008030-AAAA"]); // poll after restart
128
+ const result = await ensureIosTunnel({
129
+ udid: "00008030-AAAA",
130
+ listUdids,
131
+ appiumHome: "/ah",
132
+ nodePath: "/node",
133
+ appiumBin: "/appium",
134
+ dir: "/tun",
135
+ readyTimeoutMs: 2000,
136
+ ...s,
137
+ env: {},
138
+ });
139
+ expect(result.restarted).toBe(true);
140
+ expect(result.started).toBe(true);
141
+ expect(result.reused).toBe(false);
142
+ expect(s.elevate).toHaveBeenCalledWith("/tun/tunnel.sh");
143
+ // The elevated script must replace the old daemon, not race it for the port.
144
+ const script = s.writeScript.mock.calls[0]?.[1];
145
+ expect(script).toContain("pkill -f tunnel-creation");
146
+ expect(s.log).toHaveBeenCalledWith(expect.stringContaining("no tunnel for this device"));
147
+ });
52
148
  it("is a no-op when AXIMO_IOS_SKIP_TUNNEL is set", async () => {
53
149
  const s = stubs();
54
- const isHealthy = vi.fn(async () => false);
150
+ const listUdids = vi.fn(async () => null);
55
151
  const result = await ensureIosTunnel({
56
- isHealthy,
152
+ listUdids,
57
153
  ...s,
58
154
  env: { AXIMO_IOS_SKIP_TUNNEL: "1" },
59
155
  });
60
156
  expect(result.skipped).toBe(true);
61
- expect(isHealthy).not.toHaveBeenCalled();
157
+ expect(listUdids).not.toHaveBeenCalled();
62
158
  expect(s.elevate).not.toHaveBeenCalled();
63
159
  });
64
160
  it('does not skip for AXIMO_IOS_SKIP_TUNNEL="0"', async () => {
65
161
  const s = stubs();
66
162
  const result = await ensureIosTunnel({
67
- isHealthy: async () => true,
163
+ listUdids: async () => [],
68
164
  ...s,
69
165
  env: { AXIMO_IOS_SKIP_TUNNEL: "0" },
70
166
  });
@@ -74,7 +170,7 @@ describe("ensureIosTunnel", () => {
74
170
  it("points at the tunnel log when the daemon never becomes healthy", async () => {
75
171
  const s = stubs();
76
172
  await expect(ensureIosTunnel({
77
- isHealthy: async () => false,
173
+ listUdids: async () => null,
78
174
  appiumHome: "/ah",
79
175
  nodePath: "/node",
80
176
  appiumBin: "/appium",
@@ -84,14 +180,40 @@ describe("ensureIosTunnel", () => {
84
180
  env: {},
85
181
  })).rejects.toThrow(/tunnel\.log.*pkill -f tunnel-creation/s);
86
182
  });
183
+ it("explains when the registry never creates a tunnel for the device", async () => {
184
+ const s = stubs();
185
+ await expect(ensureIosTunnel({
186
+ udid: "00008030-AAAA",
187
+ listUdids: async () => null,
188
+ appiumHome: "/ah",
189
+ nodePath: "/node",
190
+ appiumBin: "/appium",
191
+ dir: "/tun",
192
+ readyTimeoutMs: 700,
193
+ ...s,
194
+ env: {},
195
+ })).rejects.toThrow(/did not become ready/);
196
+ const s2 = stubs();
197
+ await expect(ensureIosTunnel({
198
+ udid: "00008030-AAAA",
199
+ listUdids: async () => ["00008130-OTHER"],
200
+ appiumHome: "/ah",
201
+ nodePath: "/node",
202
+ appiumBin: "/appium",
203
+ dir: "/tun",
204
+ readyTimeoutMs: 700,
205
+ ...s2,
206
+ env: {},
207
+ })).rejects.toThrow(/did not create a tunnel for device 00008030-AAAA.*connected over USB/s);
208
+ });
87
209
  it("elevates and starts the tunnel when none is running", async () => {
88
210
  const s = stubs();
89
- const isHealthy = vi
211
+ const listUdids = vi
90
212
  .fn()
91
- .mockResolvedValueOnce(false) // pre-check
92
- .mockResolvedValue(true); // poll after elevate
213
+ .mockResolvedValueOnce(null) // pre-check
214
+ .mockResolvedValue([]); // poll after elevate
93
215
  const result = await ensureIosTunnel({
94
- isHealthy,
216
+ listUdids,
95
217
  appiumHome: "/ah",
96
218
  nodePath: "/node",
97
219
  appiumBin: "/appium",
@@ -101,20 +223,37 @@ describe("ensureIosTunnel", () => {
101
223
  env: {},
102
224
  });
103
225
  expect(result.started).toBe(true);
226
+ expect(result.restarted).toBe(false);
104
227
  expect(s.writeScript).toHaveBeenCalledOnce();
105
228
  expect(s.elevate).toHaveBeenCalledWith("/tun/tunnel.sh");
229
+ const script = s.writeScript.mock.calls[0]?.[1];
230
+ expect(script).not.toContain("pkill");
106
231
  });
107
232
  it("throws actionable guidance when the admin prompt is declined", async () => {
108
233
  const s = stubs();
109
234
  s.elevate.mockRejectedValueOnce(new Error("User canceled."));
110
235
  await expect(ensureIosTunnel({
111
- isHealthy: async () => false,
236
+ listUdids: async () => null,
237
+ appiumHome: "/ah",
238
+ nodePath: "/node",
239
+ appiumBin: "/appium",
240
+ dir: "/tun",
241
+ ...s,
242
+ env: {},
243
+ })).rejects.toThrow(/Admin approval was declined.*wasn't started.*driver run xcuitest tunnel-creation/s);
244
+ });
245
+ it("tells the user to stop the stale daemon when a declined restart leaves it running", async () => {
246
+ const s = stubs();
247
+ s.elevate.mockRejectedValueOnce(new Error("User canceled."));
248
+ await expect(ensureIosTunnel({
249
+ udid: "00008030-AAAA",
250
+ listUdids: async () => [],
112
251
  appiumHome: "/ah",
113
252
  nodePath: "/node",
114
253
  appiumBin: "/appium",
115
254
  dir: "/tun",
116
255
  ...s,
117
256
  env: {},
118
- })).rejects.toThrow(/Admin approval was declined.*driver run xcuitest tunnel-creation/s);
257
+ })).rejects.toThrow(/wasn't restarted.*stop the stale daemon first \(sudo pkill -f tunnel-creation\), then start it yourself/s);
119
258
  });
120
259
  });
@@ -41,6 +41,12 @@ const IOS_RULES = [
41
41
  test: /Connection refused to port \d+|Tunnel registry port not found|RemoteXPC|tunnel is not available/i,
42
42
  hint: "The iOS device tunnel isn't reachable. `aximo mobile connect` starts it (needs admin); confirm with `aximo mobile doctor`. Set AXIMO_IOS_SKIP_TUNNEL only if you manage it yourself.",
43
43
  },
44
+ {
45
+ // XCUITest 12+ trusts the tunnel registry over usbmuxd/devicectl when it
46
+ // answers, so a registry without this device fails even a visible phone.
47
+ test: /Unknown device or simulator UDID/i,
48
+ hint: "Appium's device list did not include this phone. If it shows in `xcrun devicectl list devices`, the running tunnel daemon has no tunnel for it — re-run `aximo mobile connect` (it restarts the daemon), or stop it first: sudo pkill -f tunnel-creation. Otherwise reconnect USB (data cable, no hub), unlock the phone, and re-trust this Mac.",
49
+ },
44
50
  {
45
51
  // "is locked" (not "is unlocked") plus XCUITest's "could not be, unlocked" phrasing.
46
52
  test: /(device|screen).{0,30}\bis locked|could not be,? unlocked|passcode/i,
@@ -19,6 +19,9 @@ describe("mobileErrorHint (iOS)", () => {
19
19
  it("matches a refused connection on a dynamic wdaLocalPort", () => {
20
20
  expect(mobileErrorHint("Connection refused to port 51234", "ios")).toContain("device tunnel");
21
21
  });
22
+ it("explains the stale tunnel registry behind 'Unknown device or simulator UDID'", () => {
23
+ expect(mobileErrorHint("Unknown device or simulator UDID: '00008030-001D78CC2653402E'", "ios")).toContain("tunnel daemon has no tunnel for it");
24
+ });
22
25
  it("matches the driver's RemoteXPC tunnel errors", () => {
23
26
  expect(mobileErrorHint("RemoteXPC tunnel is not available for this session", "ios")).toContain("device tunnel");
24
27
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autifyhq/aximo-cli",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Autify Aximo Command Line Interface (CLI)",
5
5
  "keywords": [
6
6
  "autify",