@autifyhq/aximo-cli 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -2
- 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/doctor.d.ts +10 -0
- package/dist/commands/mobile/doctor.js +149 -1
- package/dist/commands/mobile/doctor.test.js +66 -1
- 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/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
|
|
@@ -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 () => {
|
|
@@ -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,20 @@ export interface MobileDoctorReport {
|
|
|
15
16
|
info: number;
|
|
16
17
|
};
|
|
17
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[]>;
|
|
18
26
|
export declare function checkIosTunnel(isHealthy?: (port: number) => Promise<boolean>): Promise<Check>;
|
|
19
27
|
export interface RunMobileDoctorOptions {
|
|
20
28
|
platform?: "ios" | "android" | undefined;
|
|
21
29
|
/** Injectable tunnel probe for tests. */
|
|
22
30
|
isTunnelHealthy?: (port: number) => Promise<boolean>;
|
|
31
|
+
/** Injectable adb runner for tests (device-state checks). */
|
|
32
|
+
exec?: ExecFn;
|
|
23
33
|
}
|
|
24
34
|
export declare function runMobileDoctor(options?: RunMobileDoctorOptions): Promise<Envelope<MobileDoctorReport>>;
|
|
25
35
|
export declare function renderDoctorReport(data: MobileDoctorReport): string;
|
|
@@ -7,6 +7,8 @@ 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
13
|
import { DEFAULT_TUNNEL_REGISTRY_PORT, isTunnelHealthy } from "../../mobile-control/ios-tunnel.js";
|
|
12
14
|
function which(cmd) {
|
|
@@ -138,6 +140,147 @@ async function checkIos() {
|
|
|
138
140
|
});
|
|
139
141
|
return checks;
|
|
140
142
|
}
|
|
143
|
+
const SECURE_SETTINGS_PROBE_KEY = "aximo_doctor_probe";
|
|
144
|
+
const UIA2_TEST_PACKAGE = "io.appium.uiautomator2.server.test";
|
|
145
|
+
/**
|
|
146
|
+
* Device-state checks for the connected Android phone: visibility/authorization,
|
|
147
|
+
* whether adb may write secure settings (ColorOS-family ROMs deny it until
|
|
148
|
+
* "Disable Permission Monitoring" is enabled), and whether stale Appium helper
|
|
149
|
+
* APKs have wedged instrumentation. Only called when adb itself is available.
|
|
150
|
+
*/
|
|
151
|
+
export async function checkAndroidDevice(exec = execBinary) {
|
|
152
|
+
let phones;
|
|
153
|
+
try {
|
|
154
|
+
phones = (await listAndroidDevices(exec)).filter((d) => !d.serial.startsWith("emulator-"));
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
return [
|
|
158
|
+
{
|
|
159
|
+
name: "android device",
|
|
160
|
+
status: "warn",
|
|
161
|
+
message: `could not list devices: ${err instanceof Error ? err.message : String(err)}`,
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
const ready = phones.filter((d) => d.state === "device");
|
|
166
|
+
const first = ready[0];
|
|
167
|
+
if (!first) {
|
|
168
|
+
if (phones.some((d) => d.state === "unauthorized")) {
|
|
169
|
+
return [
|
|
170
|
+
{
|
|
171
|
+
name: "android device",
|
|
172
|
+
status: "fail",
|
|
173
|
+
message: "Android device is unauthorized",
|
|
174
|
+
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.",
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
if (phones.some((d) => d.state === "offline")) {
|
|
179
|
+
return [
|
|
180
|
+
{
|
|
181
|
+
name: "android device",
|
|
182
|
+
status: "fail",
|
|
183
|
+
message: "Android device is offline",
|
|
184
|
+
hint: "Reconnect the USB cable and unlock the phone.",
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
return [
|
|
189
|
+
{
|
|
190
|
+
name: "android device",
|
|
191
|
+
status: "info",
|
|
192
|
+
message: "no Android phone detected over USB",
|
|
193
|
+
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).",
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
const extra = ready.length > 1 ? ` — ${ready.length} connected, probing the first` : "";
|
|
198
|
+
const checks = [
|
|
199
|
+
{
|
|
200
|
+
name: "android device",
|
|
201
|
+
status: "pass",
|
|
202
|
+
message: `${first.serial}${first.model ? ` (${first.model})` : ""}${extra}`,
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
checks.push(await probeSecureSettings(first.serial, exec));
|
|
206
|
+
const wedged = await probeInstrumentation(first.serial, exec);
|
|
207
|
+
if (wedged)
|
|
208
|
+
checks.push(wedged);
|
|
209
|
+
return checks;
|
|
210
|
+
}
|
|
211
|
+
// Behavioral probe: a harmless global-settings write, cleaned up immediately.
|
|
212
|
+
// This is the exact permission the driver needs for the hidden-api policy and
|
|
213
|
+
// the Unicode IME (text input) — ColorOS/OxygenOS deny it to the adb shell
|
|
214
|
+
// until "Disable Permission Monitoring" is enabled (GEN-2741).
|
|
215
|
+
async function probeSecureSettings(serial, exec) {
|
|
216
|
+
try {
|
|
217
|
+
await exec("adb", [
|
|
218
|
+
"-s",
|
|
219
|
+
serial,
|
|
220
|
+
"shell",
|
|
221
|
+
"settings",
|
|
222
|
+
"put",
|
|
223
|
+
"global",
|
|
224
|
+
SECURE_SETTINGS_PROBE_KEY,
|
|
225
|
+
"1",
|
|
226
|
+
]);
|
|
227
|
+
await exec("adb", [
|
|
228
|
+
"-s",
|
|
229
|
+
serial,
|
|
230
|
+
"shell",
|
|
231
|
+
"settings",
|
|
232
|
+
"delete",
|
|
233
|
+
"global",
|
|
234
|
+
SECURE_SETTINGS_PROBE_KEY,
|
|
235
|
+
]).catch(() => { });
|
|
236
|
+
return { name: "secure settings", status: "pass", message: "adb can write secure settings" };
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return {
|
|
240
|
+
name: "secure settings",
|
|
241
|
+
status: "warn",
|
|
242
|
+
message: "the device blocks adb secure-settings writes",
|
|
243
|
+
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.",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Stale helper APKs from an older driver wedge instrumentation: the package is
|
|
248
|
+
// installed but Android no longer registers its test runner. Best-effort probe;
|
|
249
|
+
// silent when the APKs are absent (fresh device) or healthy.
|
|
250
|
+
async function probeInstrumentation(serial, exec) {
|
|
251
|
+
try {
|
|
252
|
+
const { stdout: pkgs } = await exec("adb", [
|
|
253
|
+
"-s",
|
|
254
|
+
serial,
|
|
255
|
+
"shell",
|
|
256
|
+
"pm",
|
|
257
|
+
"list",
|
|
258
|
+
"packages",
|
|
259
|
+
UIA2_TEST_PACKAGE,
|
|
260
|
+
]);
|
|
261
|
+
if (!pkgs.toString("utf-8").includes(`package:${UIA2_TEST_PACKAGE}`))
|
|
262
|
+
return null;
|
|
263
|
+
const { stdout: instr } = await exec("adb", [
|
|
264
|
+
"-s",
|
|
265
|
+
serial,
|
|
266
|
+
"shell",
|
|
267
|
+
"pm",
|
|
268
|
+
"list",
|
|
269
|
+
"instrumentation",
|
|
270
|
+
]);
|
|
271
|
+
if (instr.toString("utf-8").includes(`${UIA2_TEST_PACKAGE}/`))
|
|
272
|
+
return null;
|
|
273
|
+
return {
|
|
274
|
+
name: "appium instrumentation",
|
|
275
|
+
status: "warn",
|
|
276
|
+
message: "stale Appium helper apps: instrumentation is not registered (sessions fail with 'instrumentation process cannot be initialized')",
|
|
277
|
+
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.",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
141
284
|
// The RemoteXPC tunnel is host-level (a registry port), so doctor can probe it
|
|
142
285
|
// without a connected device. `connect` starts it automatically, so a missing
|
|
143
286
|
// tunnel is informational, not a failure.
|
|
@@ -159,7 +302,12 @@ export async function checkIosTunnel(isHealthy = isTunnelHealthy) {
|
|
|
159
302
|
export async function runMobileDoctor(options = {}) {
|
|
160
303
|
const checks = [await checkAppium()];
|
|
161
304
|
if (options.platform !== "ios") {
|
|
162
|
-
|
|
305
|
+
const androidChecks = checkAndroid();
|
|
306
|
+
checks.push(...androidChecks);
|
|
307
|
+
// Device-state checks need a working adb; skip them when the host check failed.
|
|
308
|
+
if (androidChecks.some((c) => c.name === "adb" && c.status === "pass")) {
|
|
309
|
+
checks.push(...(await checkAndroidDevice(options.exec)));
|
|
310
|
+
}
|
|
163
311
|
}
|
|
164
312
|
if (options.platform !== "android") {
|
|
165
313
|
checks.push(...(await checkIos()));
|
|
@@ -1,5 +1,70 @@
|
|
|
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
70
|
const check = await checkIosTunnel(async () => true);
|
|
@@ -3,17 +3,37 @@ export interface AgentAssetInfo {
|
|
|
3
3
|
sha256: string;
|
|
4
4
|
size: number;
|
|
5
5
|
}
|
|
6
|
+
export interface BundleFileInfo extends AgentAssetInfo {
|
|
7
|
+
/** Path under /api/devices/agent/bundle/ that serves this file. */
|
|
8
|
+
path: string;
|
|
9
|
+
}
|
|
10
|
+
export interface BundlePythonInfo extends BundleFileInfo {
|
|
11
|
+
/** python-build-standalone archive filename, which encodes version + target. */
|
|
12
|
+
archive: string;
|
|
13
|
+
}
|
|
6
14
|
export interface AgentManifest {
|
|
7
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The offline setup fields below are optional additive fields on v2 — the
|
|
17
|
+
* server does not bump the version for them, because every published CLI
|
|
18
|
+
* rejects a version it does not recognise. Never gate the offline path on
|
|
19
|
+
* this number; gate it on whether those fields are present.
|
|
20
|
+
*/
|
|
21
|
+
manifestVersion: 2 | 3;
|
|
8
22
|
version: string;
|
|
23
|
+
minCliVersion?: string;
|
|
9
24
|
script: AgentAssetInfo;
|
|
10
25
|
requirements: AgentAssetInfo;
|
|
26
|
+
/** Offline setup assets, keyed by `${platform}-${arch}`. Absent = use GitHub/PyPI. */
|
|
27
|
+
python?: Record<string, BundlePythonInfo>;
|
|
28
|
+
wheels?: Record<string, BundleFileInfo[]>;
|
|
11
29
|
}
|
|
12
30
|
export interface EnsureAgentResult {
|
|
13
31
|
scriptPath: string;
|
|
14
32
|
requirementsPath: string;
|
|
15
33
|
changed: boolean;
|
|
16
34
|
version: string;
|
|
35
|
+
/** The server's manifest, so callers can see what it offers. */
|
|
36
|
+
manifest: AgentManifest;
|
|
17
37
|
}
|
|
18
38
|
export declare function ensureAgent(deps?: ClientDeps): Promise<EnsureAgentResult>;
|
|
19
39
|
export declare function readCachedManifest(): AgentManifest | null;
|
|
@@ -18,7 +18,7 @@ function readLocalManifest() {
|
|
|
18
18
|
if (!parsed || typeof parsed !== "object")
|
|
19
19
|
return null;
|
|
20
20
|
const manifest = parsed;
|
|
21
|
-
if (manifest.manifestVersion === 2 &&
|
|
21
|
+
if ((manifest.manifestVersion === 2 || manifest.manifestVersion === 3) &&
|
|
22
22
|
typeof manifest.version === "string" &&
|
|
23
23
|
manifest.script?.sha256 &&
|
|
24
24
|
manifest.requirements?.sha256) {
|
|
@@ -58,7 +58,9 @@ function validateManifest(raw) {
|
|
|
58
58
|
throw new Error("Invalid agent manifest: response is not an object");
|
|
59
59
|
}
|
|
60
60
|
const m = raw;
|
|
61
|
-
|
|
61
|
+
// v2 servers predate the offline assets; accept them so a newer CLI still
|
|
62
|
+
// works against an on-prem server that hasn't been upgraded yet.
|
|
63
|
+
if (m.manifestVersion !== 2 && m.manifestVersion !== 3) {
|
|
62
64
|
throw new Error(`Unsupported agent manifest version: ${m.manifestVersion}. Update aximo-cli to the latest version.`);
|
|
63
65
|
}
|
|
64
66
|
if (typeof m.version !== "string" ||
|
|
@@ -111,6 +113,7 @@ export async function ensureAgent(deps) {
|
|
|
111
113
|
requirementsPath: getAgentRequirementsPath(),
|
|
112
114
|
changed: false,
|
|
113
115
|
version: server.version,
|
|
116
|
+
manifest: server,
|
|
114
117
|
};
|
|
115
118
|
}
|
|
116
119
|
const [scriptBuffer, requirementsBuffer] = await Promise.all([
|
|
@@ -134,6 +137,7 @@ export async function ensureAgent(deps) {
|
|
|
134
137
|
requirementsPath: getAgentRequirementsPath(),
|
|
135
138
|
changed: true,
|
|
136
139
|
version: server.version,
|
|
140
|
+
manifest: server,
|
|
137
141
|
};
|
|
138
142
|
}
|
|
139
143
|
export function readCachedManifest() {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ClientDeps } from "../client.js";
|
|
2
|
+
import type { AgentManifest, BundlePythonInfo } from "./agent-sync.js";
|
|
3
|
+
/** Key the manifest uses for per-platform assets, e.g. win32-x64. */
|
|
4
|
+
export declare function platformKey(): string;
|
|
5
|
+
/**
|
|
6
|
+
* The server's Python archive for this platform, when it matches what this CLI
|
|
7
|
+
* expects.
|
|
8
|
+
*
|
|
9
|
+
* An archive name encodes the interpreter version and target, so comparing it to
|
|
10
|
+
* the name the CLI would have downloaded is the version check: a server offering
|
|
11
|
+
* a different interpreter than this CLI is pinned to is ignored rather than
|
|
12
|
+
* installed, because the CLI's compiled-in SHA256 would reject it anyway.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveServerPython(manifest: AgentManifest, expectedArchive: string): BundlePythonInfo | undefined;
|
|
15
|
+
/** Download the server's Python archive into the cache and verify it. */
|
|
16
|
+
export declare function downloadServerPython(info: BundlePythonInfo, deps?: ClientDeps): Promise<string>;
|
|
17
|
+
/**
|
|
18
|
+
* Mirror the server's wheels for this platform into the cache.
|
|
19
|
+
*
|
|
20
|
+
* Returns the directory to point pip at, or undefined when the server offers no
|
|
21
|
+
* wheels for this platform. Files the manifest no longer lists are removed:
|
|
22
|
+
* a leftover wheel from an older release could otherwise satisfy a requirement
|
|
23
|
+
* with the wrong version.
|
|
24
|
+
*/
|
|
25
|
+
export declare function ensureServerWheels(manifest: AgentManifest, deps?: ClientDeps): Promise<string | undefined>;
|