@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.
- package/README.md +40 -3
- package/dist/cli.js +14 -4
- package/dist/commands/mcp/server.test.js +4 -1
- package/dist/commands/mcp/tools.js +43 -0
- package/dist/commands/mcp/tools.test.js +104 -0
- package/dist/commands/mobile/connect-core.js +1 -1
- package/dist/commands/mobile/doctor.d.ts +20 -3
- package/dist/commands/mobile/doctor.js +180 -13
- package/dist/commands/mobile/doctor.test.js +79 -3
- package/dist/desktop-server/agent-sync.d.ts +21 -1
- package/dist/desktop-server/agent-sync.js +6 -2
- package/dist/desktop-server/bundle-sync.d.ts +25 -0
- package/dist/desktop-server/bundle-sync.js +128 -0
- package/dist/desktop-server/bundle-sync.test.d.ts +1 -0
- package/dist/desktop-server/bundle-sync.test.js +189 -0
- package/dist/desktop-server/cli-version.d.ts +21 -0
- package/dist/desktop-server/cli-version.js +41 -0
- package/dist/desktop-server/cli-version.test.d.ts +1 -0
- package/dist/desktop-server/cli-version.test.js +58 -0
- package/dist/desktop-server/offline.d.ts +6 -0
- package/dist/desktop-server/offline.js +44 -0
- package/dist/desktop-server/offline.test.d.ts +1 -0
- package/dist/desktop-server/offline.test.js +74 -0
- package/dist/desktop-server/setup-deps.test.d.ts +1 -0
- package/dist/desktop-server/setup-deps.test.js +112 -0
- package/dist/desktop-server/setup-python.test.d.ts +1 -0
- package/dist/desktop-server/setup-python.test.js +272 -0
- package/dist/desktop-server/setup.d.ts +26 -1
- package/dist/desktop-server/setup.js +136 -29
- package/dist/mobile-control/android-recovery.d.ts +5 -0
- package/dist/mobile-control/android-recovery.js +19 -0
- package/dist/mobile-control/android-recovery.test.d.ts +1 -0
- package/dist/mobile-control/android-recovery.test.js +33 -0
- package/dist/mobile-control/appium-controller.d.ts +4 -0
- package/dist/mobile-control/appium-controller.js +15 -1
- package/dist/mobile-control/appium-controller.test.js +78 -0
- package/dist/mobile-control/appium-import.js +19 -3
- package/dist/mobile-control/ios-tunnel.d.ts +28 -8
- package/dist/mobile-control/ios-tunnel.js +63 -27
- package/dist/mobile-control/ios-tunnel.test.js +153 -14
- package/dist/mobile-control/mobile-errors.js +6 -0
- package/dist/mobile-control/mobile-errors.test.js +3 -0
- package/dist/types.d.ts +49 -0
- 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
|
|
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
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 () =>
|
|
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 () =>
|
|
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
|
});
|