@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
package/README.md CHANGED
@@ -222,7 +222,7 @@ Terminology note: **`aximo device list` returns mobile devices** from Device Far
222
222
 
223
223
  ### Prerequisites
224
224
 
225
- - **Node.js 20.18+**, plus `aximo-cli` installed globally (see [Install](#install)).
225
+ - **Node.js 20.18+**, plus `aximo-cli` installed globally (see [Install](#install)). On-prem releases also ship a single-file executable that needs neither — see [Air-gapped networks](#air-gapped-networks-no-internet-access).
226
226
  - **An Aximo API key**, configured via `aximo configure` or env vars.
227
227
  - **Active desktop session** — the agent uses `pyautogui` for screenshots and input, so the screen must be unlocked and awake while tests run. On **macOS**, grant the terminal **Accessibility** and **Screen Recording** permissions (System Settings → Privacy & Security) — without them actions silently do nothing.
228
228
  - **Outbound WebSocket to port 443** — most corporate firewalls allow this; if yours doesn't, see [Troubleshooting](#byod-troubleshooting).
@@ -298,6 +298,41 @@ export SSL_CERT_FILE="$HOME/.aximo/agent-ca-bundle.pem"
298
298
 
299
299
  Persist whichever variables you use in your shell profile. Public-CA servers (e.g. `aximo.autify.com`) need none of this.
300
300
 
301
+ ### Air-gapped networks (no internet access)
302
+
303
+ Normally setup reaches the internet three times: npm for the CLI, GitHub for the embedded Python, and PyPI for the agent's packages. With no internet, all three fail.
304
+
305
+ On-prem releases carry all three, and the server hands out the last two — a test machine only needs to reach your Aximo server, which it must anyway to register and run tests. See the on-prem installation guide for the full walkthrough; in short:
306
+
307
+ ```powershell
308
+ # 1. Copy cli-bundle\cli\aximo-<version>-win32-x64.exe off the server onto the machine.
309
+ # One self-contained file: no npm, no Node.js install needed.
310
+ C:\Aximo\aximo.exe configure # base URL + API key
311
+
312
+ # 2. The interpreter and packages come from your server, not the internet.
313
+ C:\Aximo\aximo.exe device setup
314
+ C:\Aximo\aximo.exe device register --name "My PC"
315
+ C:\Aximo\aximo.exe device connect
316
+ ```
317
+
318
+ Nothing declares "I am air-gapped" — the server advertises what it can supply, and the CLI uses GitHub and PyPI only when it cannot. Cloud and proxied setups are unaffected.
319
+
320
+ **Staging the files by hand.** If you need the interpreter and wheels on a machine that cannot reach the server either (preparing a golden image, for example), point `AXIMO_OFFLINE_DIR` at a directory holding them:
321
+
322
+ ```text
323
+ $AXIMO_OFFLINE_DIR/
324
+ ├── cpython-3.12.13+20260414-<target>-install_only.tar.gz
325
+ └── wheels/*.whl
326
+ ```
327
+
328
+ Staged files take precedence over the server. The archive is verified against the same checksum the CLI uses for the GitHub download, so a wrong or corrupted file is rejected rather than installed. Set the variable to an empty value to force the normal online path.
329
+
330
+ Build the wheels on the platform they will run on — most of the agent's requirements are published as source only, so `pip download --platform ... --only-binary=:all:` cannot resolve them:
331
+
332
+ ```bash
333
+ python -m pip wheel -r requirements.txt -w wheels # run on the target platform, Python 3.12
334
+ ```
335
+
301
336
  ### Managing devices
302
337
 
303
338
  ```bash
@@ -511,7 +546,7 @@ Run `aximo mobile doctor --platform ios` to see what was discovered (identity, t
511
546
 
512
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.
513
548
  >
514
- > 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).
515
550
 
516
551
  ### Enable USB debugging on the phone
517
552
 
@@ -528,6 +563,8 @@ Exact labels vary by manufacturer and Android version, but the flow is:
528
563
 
529
564
  > Keep the phone **unlocked** the first time you run a session: the initial command installs Appium's UiAutomator2 helper and creates the automation session on the device.
530
565
 
566
+ > **OPPO / Realme / OnePlus (ColorOS / OxygenOS):** also enable **Developer options → Disable Permission Monitoring**, then **reboot the phone**. These ROMs block the adb commands Appium depends on (hidden-API policy, the Unicode keyboard used for text input) until this is on — sessions may start, but typing and other tools fail with `WRITE_SECURE_SETTINGS` errors. `aximo mobile doctor` detects this lockdown (the **secure settings** check) and confirms the fix after the reboot. The toggle can silently reset after a system update — re-enable it if the check regresses.
567
+
531
568
  ### First-time setup
532
569
 
533
570
  ```bash
@@ -623,7 +660,7 @@ claude mcp add aximo -- aximo mcp
623
660
 
624
661
  VS Code (Copilot agent mode) uses the same entry under a `servers` key in `.vscode/mcp.json`.
625
662
 
626
- The server exposes 18 tools: 12 read-only ones for inspecting sessions, cases, and plan runs (`aximo_get_session_timeline` is the load-bearing one for Playwright export; `aximo_list_plan_runs` and `aximo_get_plan_run` cover run-status polling) plus 6 that author content (create and delete cases, create plans, save a session as a case). See the [MCP reference](./skills/aximo-cli/references/mcp.md) for per-client config paths, the full tool table, the Playwright export prompt, and a no-client smoke test.
663
+ The server exposes 21 tools: 14 read-only ones for inspecting sessions, cases, plans, and plan runs (`aximo_get_session_timeline` is the load-bearing one for Playwright export; `aximo_list_plans` and `aximo_get_plan` cover plan discovery; `aximo_list_plan_runs` and `aximo_get_plan_run` cover run-status polling) plus 7 that author content (create and delete cases, create and update plans, save a session as a case). See the [MCP reference](./skills/aximo-cli/references/mcp.md) for per-client config paths, the full tool table, the Playwright export prompt, and a no-client smoke test.
627
664
 
628
665
  ## Agent Skills
629
666
 
package/dist/cli.js CHANGED
@@ -35,7 +35,17 @@ const main = defineCommand({
35
35
  mcp: () => import("./commands/mcp/index.js").then((m) => m.default),
36
36
  },
37
37
  });
38
- // Honor HTTP_PROXY / HTTPS_PROXY / NO_PROXY for every fetch the CLI makes (the
39
- // embedded-Python download, API calls). No-op unless a proxy is configured.
40
- await installProxySupport();
41
- runMain(main);
38
+ // Kept out of top-level await so the entry can also be bundled to CommonJS for
39
+ // the single-file build, which has no equivalent.
40
+ async function start() {
41
+ // Honor HTTP_PROXY / HTTPS_PROXY / NO_PROXY for every fetch the CLI makes (the
42
+ // embedded-Python download, API calls). No-op unless a proxy is configured.
43
+ await installProxySupport();
44
+ runMain(main);
45
+ }
46
+ start().catch((err) => {
47
+ // Without this a failure here is an unhandled rejection, which in the single-file
48
+ // build means a bare V8 trace instead of the CLI's own error shape.
49
+ process.stderr.write(`${JSON.stringify({ ok: false, error: { code: "internal_error", message: err instanceof Error ? err.message : String(err), retryable: false } })}\n`);
50
+ process.exitCode = 1;
51
+ });
@@ -24,7 +24,7 @@ async function connect() {
24
24
  };
25
25
  }
26
26
  describe("createMcpServer wiring", () => {
27
- it("exposes all 18 tools through tools/list with valid JSON Schema", async () => {
27
+ it("exposes all 21 tools through tools/list with valid JSON Schema", async () => {
28
28
  const { client, cleanup } = await connect();
29
29
  try {
30
30
  const { tools } = await client.listTools();
@@ -44,6 +44,9 @@ describe("createMcpServer wiring", () => {
44
44
  "aximo_create_plan",
45
45
  "aximo_add_case_to_plan",
46
46
  "aximo_remove_case_from_plan",
47
+ "aximo_list_plans",
48
+ "aximo_get_plan",
49
+ "aximo_update_plan",
47
50
  "aximo_list_plan_runs",
48
51
  "aximo_get_plan_run",
49
52
  "aximo_save_session_as_case",
@@ -260,6 +260,49 @@ export function defineTools(deps = {}) {
260
260
  return await request(`/api/plans/${encodeURIComponent(planId)}/cases/${encodeURIComponent(caseId)}`, { method: "DELETE" }, deps);
261
261
  }),
262
262
  },
263
+ {
264
+ name: "aximo_list_plans",
265
+ title: "List test plans",
266
+ description: "List a workspace's test plans, most recently updated first, with case counts. apiEnabled marks plans that can be triggered through the API. Filter with search (matches title and description). Use aximo_get_plan for a plan's full detail and cases.",
267
+ inputSchema: {
268
+ projectId: z.string().uuid(),
269
+ search: z.string().optional(),
270
+ page: z.number().int().positive().optional(),
271
+ pageSize: z.number().int().positive().max(100).optional(),
272
+ },
273
+ handler: async (input) => call(async () => {
274
+ return await request("/api/plans", { query: buildQuery(input) }, deps);
275
+ }),
276
+ },
277
+ {
278
+ name: "aximo_get_plan",
279
+ title: "Get test plan",
280
+ description: "Get a test plan's full detail: settings (executionMode, stopOnFailure, apiEnabled), plan variables (secret values are masked as ***), and its cases in execution order.",
281
+ inputSchema: {
282
+ planId: z.string().uuid(),
283
+ },
284
+ handler: async ({ planId }) => call(async () => {
285
+ return await request(`/api/plans/${encodeURIComponent(planId)}`, {}, deps);
286
+ }),
287
+ },
288
+ {
289
+ name: "aximo_update_plan",
290
+ title: "Update test plan",
291
+ description: "Update a test plan's title, description, executionMode (parallel or sequential), or stopOnFailure (sequential runs only). Omitted fields are left unchanged; at least one field is required.",
292
+ inputSchema: {
293
+ planId: z.string().uuid(),
294
+ title: z.string().min(1).max(500).optional(),
295
+ description: z.string().optional(),
296
+ executionMode: z.enum(["parallel", "sequential"]).optional(),
297
+ stopOnFailure: z.boolean().optional(),
298
+ },
299
+ handler: async ({ planId, ...body }) => call(async () => {
300
+ if (Object.keys(body).length === 0) {
301
+ throw new Error("at least one field to update is required");
302
+ }
303
+ return await request(`/api/plans/${encodeURIComponent(planId)}`, { method: "PATCH", body }, deps);
304
+ }),
305
+ },
263
306
  {
264
307
  name: "aximo_list_plan_runs",
265
308
  title: "List plan runs",
@@ -36,6 +36,9 @@ describe("defineTools", () => {
36
36
  "aximo_create_plan",
37
37
  "aximo_add_case_to_plan",
38
38
  "aximo_remove_case_from_plan",
39
+ "aximo_list_plans",
40
+ "aximo_get_plan",
41
+ "aximo_update_plan",
39
42
  "aximo_list_plan_runs",
40
43
  "aximo_get_plan_run",
41
44
  "aximo_save_session_as_case",
@@ -143,6 +146,107 @@ describe("aximo_list_projects", () => {
143
146
  expect(new URL(captured.url).pathname).toBe("/api/organizations/projects");
144
147
  });
145
148
  });
149
+ describe("aximo_list_plans", () => {
150
+ const projectId = "44444444-4444-4444-8444-444444444444";
151
+ it("calls /api/plans, passing filters through as query params", async () => {
152
+ let url = null;
153
+ const payload = {
154
+ items: [
155
+ {
156
+ id: "55555555-5555-4555-9555-555555555555",
157
+ projectId,
158
+ title: "Nightly regression",
159
+ description: null,
160
+ apiEnabled: true,
161
+ caseCount: 3,
162
+ hasMobileCases: false,
163
+ createdAt: "2026-07-01T00:00:00.000Z",
164
+ updatedAt: "2026-07-02T00:00:00.000Z",
165
+ createdBy: null,
166
+ },
167
+ ],
168
+ totalCount: 1,
169
+ page: 1,
170
+ pageSize: 25,
171
+ totalPages: 1,
172
+ };
173
+ server.use(http.get(`${TEST_BASE_URL}/api/plans`, ({ request: req }) => {
174
+ url = new URL(req.url);
175
+ return HttpResponse.json(payload);
176
+ }));
177
+ const tool = byName(defineTools(), "aximo_list_plans");
178
+ const result = await tool.handler({ projectId, search: "regression", page: 1, pageSize: 25 });
179
+ expect(result.isError).toBeUndefined();
180
+ expect(parsePayload(result)).toEqual(payload);
181
+ expect(url).not.toBeNull();
182
+ expect(url.searchParams.get("projectId")).toBe(projectId);
183
+ expect(url.searchParams.get("search")).toBe("regression");
184
+ });
185
+ });
186
+ describe("aximo_get_plan", () => {
187
+ const planId = "55555555-5555-4555-9555-555555555555";
188
+ it("calls /api/plans/:id and returns the detail with cases and masked secrets", async () => {
189
+ const payload = {
190
+ id: planId,
191
+ title: "Nightly regression",
192
+ apiEnabled: true,
193
+ executionMode: "sequential",
194
+ stopOnFailure: true,
195
+ variables: [{ name: "PASSWORD", value: "***", isSecret: true }],
196
+ cases: [{ id: "66666666-6666-4666-9666-666666666666", order: 1, type: "web" }],
197
+ };
198
+ server.use(http.get(`${TEST_BASE_URL}/api/plans/${planId}`, () => HttpResponse.json(payload)));
199
+ const tool = byName(defineTools(), "aximo_get_plan");
200
+ const result = await tool.handler({ planId });
201
+ expect(result.isError).toBeUndefined();
202
+ expect(parsePayload(result)).toEqual(payload);
203
+ });
204
+ it("preserves the backend's 404 for an unknown plan", async () => {
205
+ server.use(http.get(`${TEST_BASE_URL}/api/plans/${planId}`, () => HttpResponse.json({ error: "Test plan not found" }, { status: 404 })));
206
+ const tool = byName(defineTools(), "aximo_get_plan");
207
+ const result = await tool.handler({ planId });
208
+ expect(result.isError).toBe(true);
209
+ expect(parsePayload(result).error).toBe("Test plan not found");
210
+ });
211
+ });
212
+ describe("aximo_update_plan", () => {
213
+ const planId = "55555555-5555-4555-9555-555555555555";
214
+ it("PATCHes /api/plans/:id with only the provided fields", async () => {
215
+ let body = null;
216
+ const payload = {
217
+ success: true,
218
+ plan: {
219
+ id: planId,
220
+ title: "Renamed",
221
+ description: null,
222
+ executionMode: "sequential",
223
+ stopOnFailure: false,
224
+ updatedAt: "2026-07-02T00:00:00.000Z",
225
+ },
226
+ };
227
+ server.use(http.patch(`${TEST_BASE_URL}/api/plans/${planId}`, async ({ request: req }) => {
228
+ body = await req.json();
229
+ return HttpResponse.json(payload);
230
+ }));
231
+ const tool = byName(defineTools(), "aximo_update_plan");
232
+ const result = await tool.handler({ planId, title: "Renamed", executionMode: "sequential" });
233
+ expect(result.isError).toBeUndefined();
234
+ expect(parsePayload(result)).toEqual(payload);
235
+ expect(body).toEqual({ title: "Renamed", executionMode: "sequential" });
236
+ });
237
+ it("rejects an update with no fields instead of issuing an empty PATCH", async () => {
238
+ let called = false;
239
+ server.use(http.patch(`${TEST_BASE_URL}/api/plans/${planId}`, () => {
240
+ called = true;
241
+ return HttpResponse.json({ success: true });
242
+ }));
243
+ const tool = byName(defineTools(), "aximo_update_plan");
244
+ const result = await tool.handler({ planId });
245
+ expect(result.isError).toBe(true);
246
+ expect(parsePayload(result).error).toBe("at least one field to update is required");
247
+ expect(called).toBe(false);
248
+ });
249
+ });
146
250
  describe("aximo_list_plan_runs", () => {
147
251
  const planId = "77777777-7777-4777-9777-777777777777";
148
252
  it("calls /api/triggers/plans/:id/runs, passing limit and cursor through", async () => {
@@ -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.");
@@ -1,4 +1,5 @@
1
1
  import type { Envelope } from "../../types.js";
2
+ import { type ExecFn } from "../../mobile-control/exec.js";
2
3
  export type CheckStatus = "pass" | "warn" | "fail" | "info";
3
4
  export interface Check {
4
5
  name: string;
@@ -15,11 +16,27 @@ export interface MobileDoctorReport {
15
16
  info: number;
16
17
  };
17
18
  }
18
- export declare function checkIosTunnel(isHealthy?: (port: number) => Promise<boolean>): Promise<Check>;
19
+ /**
20
+ * Device-state checks for the connected Android phone: visibility/authorization,
21
+ * whether adb may write secure settings (ColorOS-family ROMs deny it until
22
+ * "Disable Permission Monitoring" is enabled), and whether stale Appium helper
23
+ * APKs have wedged instrumentation. Only called when adb itself is available.
24
+ */
25
+ export declare function checkAndroidDevice(exec?: ExecFn): 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>;
19
32
  export interface RunMobileDoctorOptions {
20
33
  platform?: "ios" | "android" | undefined;
21
- /** Injectable tunnel probe for tests. */
22
- 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[];
38
+ /** Injectable adb runner for tests (device-state checks). */
39
+ exec?: ExecFn;
23
40
  }
24
41
  export declare function runMobileDoctor(options?: RunMobileDoctorOptions): Promise<Envelope<MobileDoctorReport>>;
25
42
  export declare function renderDoctorReport(data: MobileDoctorReport): string;
@@ -7,8 +7,11 @@ import { formatSuccess } from "../../output.js";
7
7
  import { printMobileResult } from "./output.js";
8
8
  import { importAppium } from "../../mobile-control/appium-import.js";
9
9
  import { getAppiumHome, resolveAndroidSdk } from "../../mobile-control/appium-server.js";
10
+ import { listAndroidDevices } from "../../mobile-control/devices.js";
11
+ import { execBinary } from "../../mobile-control/exec.js";
10
12
  import { resolveIosSigningAuto } from "../../mobile-control/ios-signing.js";
11
- 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";
12
15
  function which(cmd) {
13
16
  const probe = platform() === "win32" ? "where" : "which";
14
17
  const result = spawnSync(probe, [cmd], { encoding: "utf-8" });
@@ -138,33 +141,197 @@ async function checkIos() {
138
141
  });
139
142
  return checks;
140
143
  }
144
+ const SECURE_SETTINGS_PROBE_KEY = "aximo_doctor_probe";
145
+ const UIA2_TEST_PACKAGE = "io.appium.uiautomator2.server.test";
146
+ /**
147
+ * Device-state checks for the connected Android phone: visibility/authorization,
148
+ * whether adb may write secure settings (ColorOS-family ROMs deny it until
149
+ * "Disable Permission Monitoring" is enabled), and whether stale Appium helper
150
+ * APKs have wedged instrumentation. Only called when adb itself is available.
151
+ */
152
+ export async function checkAndroidDevice(exec = execBinary) {
153
+ let phones;
154
+ try {
155
+ phones = (await listAndroidDevices(exec)).filter((d) => !d.serial.startsWith("emulator-"));
156
+ }
157
+ catch (err) {
158
+ return [
159
+ {
160
+ name: "android device",
161
+ status: "warn",
162
+ message: `could not list devices: ${err instanceof Error ? err.message : String(err)}`,
163
+ },
164
+ ];
165
+ }
166
+ const ready = phones.filter((d) => d.state === "device");
167
+ const first = ready[0];
168
+ if (!first) {
169
+ if (phones.some((d) => d.state === "unauthorized")) {
170
+ return [
171
+ {
172
+ name: "android device",
173
+ status: "fail",
174
+ message: "Android device is unauthorized",
175
+ hint: "Unlock the phone and accept the 'Allow USB debugging?' prompt (tick 'Always allow'). If no prompt appears, replug the cable or revoke USB debugging authorizations in Developer options.",
176
+ },
177
+ ];
178
+ }
179
+ if (phones.some((d) => d.state === "offline")) {
180
+ return [
181
+ {
182
+ name: "android device",
183
+ status: "fail",
184
+ message: "Android device is offline",
185
+ hint: "Reconnect the USB cable and unlock the phone.",
186
+ },
187
+ ];
188
+ }
189
+ return [
190
+ {
191
+ name: "android device",
192
+ status: "info",
193
+ message: "no Android phone detected over USB",
194
+ hint: "Enable Developer options → USB debugging, use a data-capable cable, and accept the 'Allow USB debugging?' prompt (see 'Enable USB debugging on the phone' in the README).",
195
+ },
196
+ ];
197
+ }
198
+ const extra = ready.length > 1 ? ` — ${ready.length} connected, probing the first` : "";
199
+ const checks = [
200
+ {
201
+ name: "android device",
202
+ status: "pass",
203
+ message: `${first.serial}${first.model ? ` (${first.model})` : ""}${extra}`,
204
+ },
205
+ ];
206
+ checks.push(await probeSecureSettings(first.serial, exec));
207
+ const wedged = await probeInstrumentation(first.serial, exec);
208
+ if (wedged)
209
+ checks.push(wedged);
210
+ return checks;
211
+ }
212
+ // Behavioral probe: a harmless global-settings write, cleaned up immediately.
213
+ // This is the exact permission the driver needs for the hidden-api policy and
214
+ // the Unicode IME (text input) — ColorOS/OxygenOS deny it to the adb shell
215
+ // until "Disable Permission Monitoring" is enabled (GEN-2741).
216
+ async function probeSecureSettings(serial, exec) {
217
+ try {
218
+ await exec("adb", [
219
+ "-s",
220
+ serial,
221
+ "shell",
222
+ "settings",
223
+ "put",
224
+ "global",
225
+ SECURE_SETTINGS_PROBE_KEY,
226
+ "1",
227
+ ]);
228
+ await exec("adb", [
229
+ "-s",
230
+ serial,
231
+ "shell",
232
+ "settings",
233
+ "delete",
234
+ "global",
235
+ SECURE_SETTINGS_PROBE_KEY,
236
+ ]).catch(() => { });
237
+ return { name: "secure settings", status: "pass", message: "adb can write secure settings" };
238
+ }
239
+ catch {
240
+ return {
241
+ name: "secure settings",
242
+ status: "warn",
243
+ message: "the device blocks adb secure-settings writes",
244
+ hint: "OPPO/Realme/OnePlus (ColorOS/OxygenOS): enable Developer options → 'Disable Permission Monitoring', reboot the phone, then re-run `aximo mobile doctor`. Without it, sessions start but text input and other adb-backed tools fail. The toggle can reset after a system update.",
245
+ };
246
+ }
247
+ }
248
+ // Stale helper APKs from an older driver wedge instrumentation: the package is
249
+ // installed but Android no longer registers its test runner. Best-effort probe;
250
+ // silent when the APKs are absent (fresh device) or healthy.
251
+ async function probeInstrumentation(serial, exec) {
252
+ try {
253
+ const { stdout: pkgs } = await exec("adb", [
254
+ "-s",
255
+ serial,
256
+ "shell",
257
+ "pm",
258
+ "list",
259
+ "packages",
260
+ UIA2_TEST_PACKAGE,
261
+ ]);
262
+ if (!pkgs.toString("utf-8").includes(`package:${UIA2_TEST_PACKAGE}`))
263
+ return null;
264
+ const { stdout: instr } = await exec("adb", [
265
+ "-s",
266
+ serial,
267
+ "shell",
268
+ "pm",
269
+ "list",
270
+ "instrumentation",
271
+ ]);
272
+ if (instr.toString("utf-8").includes(`${UIA2_TEST_PACKAGE}/`))
273
+ return null;
274
+ return {
275
+ name: "appium instrumentation",
276
+ status: "warn",
277
+ message: "stale Appium helper apps: instrumentation is not registered (sessions fail with 'instrumentation process cannot be initialized')",
278
+ hint: "`aximo mobile connect` repairs this automatically on the next session. Manual fix: adb uninstall io.appium.uiautomator2.server.test, then io.appium.uiautomator2.server.",
279
+ };
280
+ }
281
+ catch {
282
+ return null;
283
+ }
284
+ }
141
285
  // The RemoteXPC tunnel is host-level (a registry port), so doctor can probe it
142
286
  // without a connected device. `connect` starts it automatically, so a missing
143
- // tunnel is informational, not a failure.
144
- export async function checkIosTunnel(isHealthy = isTunnelHealthy) {
145
- const running = await isHealthy(DEFAULT_TUNNEL_REGISTRY_PORT);
146
- return running
147
- ? {
148
- name: "ios tunnel",
149
- status: "pass",
150
- message: `RemoteXPC tunnel running (registry port ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
151
- }
152
- : {
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 {
153
295
  name: "ios tunnel",
154
296
  status: "info",
155
297
  message: "RemoteXPC tunnel not running",
156
298
  hint: "iOS 17+ needs it to reach WebDriverAgent; `aximo mobile connect` starts it automatically (prompts for admin).",
157
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
+ };
158
316
  }
159
317
  export async function runMobileDoctor(options = {}) {
160
318
  const checks = [await checkAppium()];
161
319
  if (options.platform !== "ios") {
162
- checks.push(...checkAndroid());
320
+ const androidChecks = checkAndroid();
321
+ checks.push(...androidChecks);
322
+ // Device-state checks need a working adb; skip them when the host check failed.
323
+ if (androidChecks.some((c) => c.name === "adb" && c.status === "pass")) {
324
+ checks.push(...(await checkAndroidDevice(options.exec)));
325
+ }
163
326
  }
164
327
  if (options.platform !== "android") {
165
328
  checks.push(...(await checkIos()));
166
329
  if (platform() === "darwin") {
167
- 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));
168
335
  }
169
336
  }
170
337
  const summary = { pass: 0, warn: 0, fail: 0, info: 0 };
@@ -1,13 +1,89 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { checkIosTunnel, renderDoctorReport } from "./doctor.js";
2
+ import { checkAndroidDevice, checkIosTunnel, renderDoctorReport } from "./doctor.js";
3
+ // Canned adb: each key is matched against the joined argv; first hit wins.
4
+ function fakeAdb(handlers) {
5
+ return async (_file, args) => {
6
+ const joined = args.join(" ");
7
+ for (const [key, value] of Object.entries(handlers)) {
8
+ if (joined.includes(key)) {
9
+ if (value instanceof Error)
10
+ throw value;
11
+ return { stdout: Buffer.from(value), stderr: "" };
12
+ }
13
+ }
14
+ return { stdout: Buffer.from(""), stderr: "" };
15
+ };
16
+ }
17
+ const DEVICE_LIST = "List of devices attached\nserial-1 device model:Pixel_9a\n";
18
+ describe("checkAndroidDevice", () => {
19
+ it("reports info with USB-debugging guidance when no phone is detected", async () => {
20
+ const checks = await checkAndroidDevice(fakeAdb({ "devices -l": "List of devices attached\n" }));
21
+ expect(checks).toHaveLength(1);
22
+ expect(checks[0]).toMatchObject({ name: "android device", status: "info" });
23
+ expect(checks[0]?.hint).toContain("USB debugging");
24
+ });
25
+ it("fails with the RSA-prompt hint when the phone is unauthorized", async () => {
26
+ const checks = await checkAndroidDevice(fakeAdb({ "devices -l": "List of devices attached\nserial-1 unauthorized\n" }));
27
+ expect(checks[0]).toMatchObject({ name: "android device", status: "fail" });
28
+ expect(checks[0]?.hint).toContain("Allow USB debugging");
29
+ });
30
+ it("fails when the phone is offline", async () => {
31
+ const checks = await checkAndroidDevice(fakeAdb({ "devices -l": "List of devices attached\nserial-1 offline\n" }));
32
+ expect(checks[0]).toMatchObject({ name: "android device", status: "fail" });
33
+ });
34
+ it("passes the device and secure-settings checks on a healthy phone", async () => {
35
+ const checks = await checkAndroidDevice(fakeAdb({ "devices -l": DEVICE_LIST }));
36
+ expect(checks).toHaveLength(2);
37
+ expect(checks[0]).toMatchObject({ name: "android device", status: "pass" });
38
+ expect(checks[0]?.message).toContain("Pixel_9a");
39
+ expect(checks[1]).toMatchObject({ name: "secure settings", status: "pass" });
40
+ });
41
+ it("warns with Disable Permission Monitoring guidance when secure-settings writes are blocked", async () => {
42
+ const checks = await checkAndroidDevice(fakeAdb({
43
+ "devices -l": DEVICE_LIST,
44
+ "settings put": new Error("java.lang.SecurityException: uid 2000 does not have android.permission.WRITE_SECURE_SETTINGS"),
45
+ }));
46
+ expect(checks[1]).toMatchObject({ name: "secure settings", status: "warn" });
47
+ expect(checks[1]?.hint).toContain("Disable Permission Monitoring");
48
+ });
49
+ it("warns about stale helper apps when the test APK is installed but unregistered", async () => {
50
+ const checks = await checkAndroidDevice(fakeAdb({
51
+ "devices -l": DEVICE_LIST,
52
+ "pm list packages": "package:io.appium.uiautomator2.server.test\n",
53
+ "pm list instrumentation": "instrumentation:com.android.something/...\n",
54
+ }));
55
+ const wedged = checks.find((c) => c.name === "appium instrumentation");
56
+ expect(wedged).toMatchObject({ status: "warn" });
57
+ expect(wedged?.hint).toContain("aximo mobile connect");
58
+ });
59
+ it("stays silent about instrumentation when the helper APKs are healthy", async () => {
60
+ const checks = await checkAndroidDevice(fakeAdb({
61
+ "devices -l": DEVICE_LIST,
62
+ "pm list packages": "package:io.appium.uiautomator2.server.test\n",
63
+ "pm list instrumentation": "instrumentation:io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner (target=io.appium.uiautomator2.server.test)\n",
64
+ }));
65
+ expect(checks.find((c) => c.name === "appium instrumentation")).toBeUndefined();
66
+ });
67
+ });
3
68
  describe("checkIosTunnel", () => {
4
69
  it("passes when the RemoteXPC tunnel is reachable", async () => {
5
- const check = await checkIosTunnel(async () => true);
70
+ const check = await checkIosTunnel(async () => []);
6
71
  expect(check.status).toBe("pass");
7
72
  expect(check.name).toBe("ios tunnel");
8
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
+ });
9
85
  it("is informational (not a failure) when the tunnel is down", async () => {
10
- const check = await checkIosTunnel(async () => false);
86
+ const check = await checkIosTunnel(async () => null, [{ name: "My iPhone", target: "00008030-AAAA" }]);
11
87
  expect(check.status).toBe("info");
12
88
  expect(check.hint).toContain("aximo mobile connect");
13
89
  });