@autifyhq/aximo-cli 0.10.1 → 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.
Files changed (44) hide show
  1. package/README.md +40 -3
  2. package/dist/cli.js +14 -4
  3. package/dist/commands/mcp/server.test.js +4 -1
  4. package/dist/commands/mcp/tools.js +43 -0
  5. package/dist/commands/mcp/tools.test.js +104 -0
  6. package/dist/commands/mobile/connect-core.js +1 -1
  7. package/dist/commands/mobile/doctor.d.ts +20 -3
  8. package/dist/commands/mobile/doctor.js +180 -13
  9. package/dist/commands/mobile/doctor.test.js +79 -3
  10. package/dist/desktop-server/agent-sync.d.ts +21 -1
  11. package/dist/desktop-server/agent-sync.js +6 -2
  12. package/dist/desktop-server/bundle-sync.d.ts +25 -0
  13. package/dist/desktop-server/bundle-sync.js +128 -0
  14. package/dist/desktop-server/bundle-sync.test.d.ts +1 -0
  15. package/dist/desktop-server/bundle-sync.test.js +189 -0
  16. package/dist/desktop-server/cli-version.d.ts +21 -0
  17. package/dist/desktop-server/cli-version.js +41 -0
  18. package/dist/desktop-server/cli-version.test.d.ts +1 -0
  19. package/dist/desktop-server/cli-version.test.js +58 -0
  20. package/dist/desktop-server/offline.d.ts +6 -0
  21. package/dist/desktop-server/offline.js +44 -0
  22. package/dist/desktop-server/offline.test.d.ts +1 -0
  23. package/dist/desktop-server/offline.test.js +74 -0
  24. package/dist/desktop-server/setup-deps.test.d.ts +1 -0
  25. package/dist/desktop-server/setup-deps.test.js +112 -0
  26. package/dist/desktop-server/setup-python.test.d.ts +1 -0
  27. package/dist/desktop-server/setup-python.test.js +272 -0
  28. package/dist/desktop-server/setup.d.ts +26 -1
  29. package/dist/desktop-server/setup.js +136 -29
  30. package/dist/mobile-control/android-recovery.d.ts +5 -0
  31. package/dist/mobile-control/android-recovery.js +19 -0
  32. package/dist/mobile-control/android-recovery.test.d.ts +1 -0
  33. package/dist/mobile-control/android-recovery.test.js +33 -0
  34. package/dist/mobile-control/appium-controller.d.ts +4 -0
  35. package/dist/mobile-control/appium-controller.js +15 -1
  36. package/dist/mobile-control/appium-controller.test.js +78 -0
  37. package/dist/mobile-control/appium-import.js +19 -3
  38. package/dist/mobile-control/ios-tunnel.d.ts +28 -8
  39. package/dist/mobile-control/ios-tunnel.js +63 -27
  40. package/dist/mobile-control/ios-tunnel.test.js +153 -14
  41. package/dist/mobile-control/mobile-errors.js +6 -0
  42. package/dist/mobile-control/mobile-errors.test.js +3 -0
  43. package/dist/types.d.ts +49 -0
  44. package/package.json +4 -1
@@ -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/dist/types.d.ts CHANGED
@@ -109,6 +109,55 @@ export interface PlanRun {
109
109
  status?: string | undefined;
110
110
  [key: string]: unknown;
111
111
  }
112
+ export interface PlanListItem {
113
+ id: string;
114
+ projectId: string;
115
+ title: string;
116
+ description: string | null;
117
+ apiEnabled: boolean;
118
+ caseCount: number;
119
+ hasMobileCases: boolean;
120
+ createdAt: string;
121
+ updatedAt: string;
122
+ createdBy: {
123
+ id: string;
124
+ name: string | null;
125
+ email: string;
126
+ } | null;
127
+ }
128
+ export interface PlanDetail {
129
+ id: string;
130
+ projectId: string;
131
+ title: string;
132
+ description: string | null;
133
+ apiEnabled: boolean;
134
+ executionMode: ExecutionMode;
135
+ stopOnFailure: boolean;
136
+ variables: {
137
+ name: string;
138
+ value: string;
139
+ isSecret: boolean;
140
+ }[];
141
+ caseCount: number;
142
+ createdAt: string;
143
+ updatedAt: string;
144
+ createdBy: {
145
+ id: string;
146
+ name: string | null;
147
+ email: string;
148
+ } | null;
149
+ project: {
150
+ id: string;
151
+ name: string;
152
+ } | null;
153
+ cases: {
154
+ id: string;
155
+ order: number;
156
+ type: string;
157
+ title: string | null;
158
+ lastStatus: string | null;
159
+ }[];
160
+ }
112
161
  export interface PlanRunSummary {
113
162
  id: string;
114
163
  status: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autifyhq/aximo-cli",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "Autify Aximo Command Line Interface (CLI)",
5
5
  "keywords": [
6
6
  "autify",
@@ -52,11 +52,13 @@
52
52
  "@types/node": "^25.6.0",
53
53
  "@types/ws": "^8.18.0",
54
54
  "@vitest/coverage-v8": "4.1.4",
55
+ "esbuild": "0.27.7",
55
56
  "husky": "9.1.7",
56
57
  "msw": "2.13.2",
57
58
  "nano-staged": "1.0.2",
58
59
  "oxfmt": "0.45.0",
59
60
  "oxlint": "1.60.0",
61
+ "postject": "1.0.0-alpha.6",
60
62
  "secretlint": "11.7.1",
61
63
  "tsx": "4.21.0",
62
64
  "typescript": "6.0.2",
@@ -67,6 +69,7 @@
67
69
  },
68
70
  "scripts": {
69
71
  "build": "tsc",
72
+ "build:sea": "node scripts/build-sea.mjs",
70
73
  "cli": "tsx src/cli.ts",
71
74
  "format": "oxfmt --write .",
72
75
  "format:check": "oxfmt --check .",