@autifyhq/aximo-cli 0.3.0 → 0.5.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 +129 -35
- package/dist/cli.js +1 -0
- package/dist/commands/case/case.test.js +34 -0
- package/dist/commands/case/delete.d.ts +20 -0
- package/dist/commands/case/delete.js +39 -0
- package/dist/commands/case/index.js +1 -0
- package/dist/commands/device/device-config.d.ts +14 -0
- package/dist/commands/device/device-config.js +46 -0
- package/dist/commands/device/device-config.test.d.ts +1 -0
- package/dist/commands/device/device-config.test.js +56 -0
- package/dist/commands/device/doctor.d.ts +14 -0
- package/dist/commands/device/doctor.js +155 -29
- package/dist/commands/device/doctor.test.js +69 -3
- package/dist/commands/device/paths.d.ts +1 -0
- package/dist/commands/device/paths.js +3 -0
- package/dist/commands/device/register.d.ts +2 -0
- package/dist/commands/device/register.js +50 -32
- package/dist/commands/device/register.test.d.ts +1 -0
- package/dist/commands/device/register.test.js +95 -0
- package/dist/commands/device/unregister.d.ts +5 -3
- package/dist/commands/device/unregister.js +32 -33
- package/dist/commands/device/unregister.test.d.ts +1 -0
- package/dist/commands/device/unregister.test.js +73 -0
- package/dist/commands/mcp/server.test.js +2 -1
- package/dist/commands/mcp/tools.js +11 -0
- package/dist/commands/mcp/tools.test.js +12 -0
- package/dist/commands/mobile/connect-core.d.ts +12 -0
- package/dist/commands/mobile/connect-core.js +74 -0
- package/dist/commands/mobile/connect.d.ts +9 -0
- package/dist/commands/mobile/connect.js +35 -0
- package/dist/commands/mobile/connect.test.d.ts +1 -0
- package/dist/commands/mobile/connect.test.js +87 -0
- package/dist/commands/mobile/doctor.d.ts +28 -0
- package/dist/commands/mobile/doctor.js +143 -0
- package/dist/commands/mobile/index.d.ts +2 -0
- package/dist/commands/mobile/index.js +15 -0
- package/dist/commands/mobile/list.d.ts +10 -0
- package/dist/commands/mobile/list.js +30 -0
- package/dist/commands/mobile/paths.d.ts +14 -0
- package/dist/commands/mobile/paths.js +71 -0
- package/dist/commands/mobile/register-core.d.ts +19 -0
- package/dist/commands/mobile/register-core.js +46 -0
- package/dist/commands/mobile/register.d.ts +32 -0
- package/dist/commands/mobile/register.js +51 -0
- package/dist/commands/mobile/register.test.d.ts +1 -0
- package/dist/commands/mobile/register.test.js +60 -0
- package/dist/commands/mobile/setup.d.ts +27 -0
- package/dist/commands/mobile/setup.js +100 -0
- package/dist/commands/mobile/setup.test.d.ts +1 -0
- package/dist/commands/mobile/setup.test.js +49 -0
- package/dist/commands/mobile/unregister.d.ts +14 -0
- package/dist/commands/mobile/unregister.js +37 -0
- package/dist/commands/schema.js +22 -0
- package/dist/commands/session/create.d.ts +5 -0
- package/dist/commands/session/create.js +6 -0
- package/dist/desktop-server/setup-tls.test.d.ts +1 -0
- package/dist/desktop-server/setup-tls.test.js +83 -0
- package/dist/desktop-server/setup.d.ts +17 -0
- package/dist/desktop-server/setup.js +53 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mobile-control/appium-controller.d.ts +39 -0
- package/dist/mobile-control/appium-controller.js +367 -0
- package/dist/mobile-control/appium-controller.test.d.ts +1 -0
- package/dist/mobile-control/appium-controller.test.js +252 -0
- package/dist/mobile-control/appium-import.d.ts +7 -0
- package/dist/mobile-control/appium-import.js +6 -0
- package/dist/mobile-control/appium-import.test.d.ts +1 -0
- package/dist/mobile-control/appium-import.test.js +9 -0
- package/dist/mobile-control/appium-server.d.ts +30 -0
- package/dist/mobile-control/appium-server.js +110 -0
- package/dist/mobile-control/appium-server.test.d.ts +1 -0
- package/dist/mobile-control/appium-server.test.js +108 -0
- package/dist/mobile-control/controller.d.ts +22 -0
- package/dist/mobile-control/controller.js +1 -0
- package/dist/mobile-control/devices.d.ts +21 -0
- package/dist/mobile-control/devices.js +99 -0
- package/dist/mobile-control/devices.test.d.ts +1 -0
- package/dist/mobile-control/devices.test.js +35 -0
- package/dist/mobile-control/exec.d.ts +6 -0
- package/dist/mobile-control/exec.js +13 -0
- package/dist/mobile-control/tunnel.d.ts +11 -0
- package/dist/mobile-control/tunnel.js +152 -0
- package/dist/mobile-control/tunnel.test.d.ts +1 -0
- package/dist/mobile-control/tunnel.test.js +155 -0
- package/dist/mobile-control/webdriver.d.ts +31 -0
- package/dist/mobile-control/webdriver.js +137 -0
- package/dist/mobile-control/webdriver.test.d.ts +1 -0
- package/dist/mobile-control/webdriver.test.js +45 -0
- package/dist/types.d.ts +4 -0
- package/package.json +3 -1
|
@@ -1,49 +1,44 @@
|
|
|
1
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
1
2
|
import { defineCommand } from "citty";
|
|
2
|
-
import { existsSync, unlinkSync, readFileSync } from "node:fs";
|
|
3
3
|
import { request } from "../../client.js";
|
|
4
4
|
import { handleApiError } from "../../errors.js";
|
|
5
|
-
import {
|
|
5
|
+
import { formatError, formatSuccess, printResult } from "../../output.js";
|
|
6
|
+
import { readDeviceConfig } from "./device-config.js";
|
|
6
7
|
import { getDeviceConfigPath } from "./paths.js";
|
|
8
|
+
function isNotFound(err) {
|
|
9
|
+
return typeof err === "object" && err !== null && err.status === 404;
|
|
10
|
+
}
|
|
7
11
|
export async function runDeviceUnregister(options, deps) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
try {
|
|
11
|
-
const config = JSON.parse(readFileSync(getDeviceConfigPath(), "utf-8"));
|
|
12
|
-
if (config &&
|
|
13
|
-
typeof config === "object" &&
|
|
14
|
-
typeof config.deviceId === "string") {
|
|
15
|
-
deviceId = config.deviceId;
|
|
16
|
-
}
|
|
17
|
-
else {
|
|
18
|
-
return formatError("validation_error", "Device config is malformed: missing deviceId", false);
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
return formatError("validation_error", "Failed to read device config", false);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
12
|
+
const local = readDeviceConfig();
|
|
13
|
+
const deviceId = options.deviceId ?? local?.deviceId;
|
|
25
14
|
if (!deviceId) {
|
|
15
|
+
if (existsSync(getDeviceConfigPath())) {
|
|
16
|
+
return formatError("validation_error", "Local device config is unreadable. Pass a device ID, or clear it with: aximo device reset --device", false);
|
|
17
|
+
}
|
|
26
18
|
return formatError("validation_error", "No device ID provided and no local device config found. " +
|
|
27
19
|
"Pass a device ID or register first with: aximo device register", false);
|
|
28
20
|
}
|
|
21
|
+
let serverDeleted = true;
|
|
29
22
|
try {
|
|
30
23
|
await request(`/api/devices/user/${deviceId}`, { method: "DELETE" }, deps ?? {});
|
|
31
|
-
if (existsSync(getDeviceConfigPath())) {
|
|
32
|
-
try {
|
|
33
|
-
const config = JSON.parse(readFileSync(getDeviceConfigPath(), "utf-8"));
|
|
34
|
-
if (config.deviceId === deviceId) {
|
|
35
|
-
unlinkSync(getDeviceConfigPath());
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
// non-fatal
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return formatSuccess({ success: true });
|
|
43
24
|
}
|
|
44
25
|
catch (err) {
|
|
45
|
-
|
|
26
|
+
// Already gone server-side — local cleanup is still the goal, so clear the
|
|
27
|
+
// file and report success. Re-surface any other error.
|
|
28
|
+
if (!isNotFound(err))
|
|
29
|
+
return handleApiError(err);
|
|
30
|
+
serverDeleted = false;
|
|
31
|
+
}
|
|
32
|
+
// Remove the local registration when it matches the device we just unregistered.
|
|
33
|
+
if (local && local.deviceId === deviceId && existsSync(getDeviceConfigPath())) {
|
|
34
|
+
try {
|
|
35
|
+
unlinkSync(getDeviceConfigPath());
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// non-fatal
|
|
39
|
+
}
|
|
46
40
|
}
|
|
41
|
+
return formatSuccess({ success: true, serverDeleted });
|
|
47
42
|
}
|
|
48
43
|
export default defineCommand({
|
|
49
44
|
meta: { name: "unregister", description: "Unregister a device" },
|
|
@@ -55,6 +50,10 @@ export default defineCommand({
|
|
|
55
50
|
},
|
|
56
51
|
async run({ args }) {
|
|
57
52
|
const deviceId = args["device-id"];
|
|
58
|
-
|
|
53
|
+
const result = await runDeviceUnregister(deviceId ? { deviceId } : {});
|
|
54
|
+
printResult(result);
|
|
55
|
+
if (result.ok && !result.data.serverDeleted) {
|
|
56
|
+
console.error("\nNote: the device was already absent on the server; cleared the local registration.");
|
|
57
|
+
}
|
|
59
58
|
},
|
|
60
59
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
6
|
+
import { runDeviceUnregister } from "./unregister.js";
|
|
7
|
+
let tempHome;
|
|
8
|
+
beforeAll(() => {
|
|
9
|
+
tempHome = mkdtempSync(join(tmpdir(), "aximo-unregister-"));
|
|
10
|
+
process.env["AXIMO_HOME"] = tempHome;
|
|
11
|
+
process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
|
|
12
|
+
process.env["AXIMO_API_KEY"] = "test-key";
|
|
13
|
+
server.listen();
|
|
14
|
+
});
|
|
15
|
+
afterAll(() => {
|
|
16
|
+
delete process.env["AXIMO_HOME"];
|
|
17
|
+
delete process.env["AXIMO_BASE_URL"];
|
|
18
|
+
delete process.env["AXIMO_API_KEY"];
|
|
19
|
+
server.close();
|
|
20
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
24
|
+
mkdirSync(tempHome, { recursive: true });
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => server.resetHandlers());
|
|
27
|
+
function devicePath() {
|
|
28
|
+
return join(tempHome, "device.json");
|
|
29
|
+
}
|
|
30
|
+
function seedDevice(cfg) {
|
|
31
|
+
writeFileSync(devicePath(), JSON.stringify(cfg), { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
describe("runDeviceUnregister", () => {
|
|
34
|
+
it("deletes the server device and removes the local file", async () => {
|
|
35
|
+
seedDevice({ deviceId: "dev-1", deviceToken: "t", name: "x" });
|
|
36
|
+
server.use(http.delete(`${TEST_BASE_URL}/api/devices/user/dev-1`, () => HttpResponse.json({ success: true })));
|
|
37
|
+
const result = await runDeviceUnregister({});
|
|
38
|
+
expect(result.ok).toBe(true);
|
|
39
|
+
if (result.ok)
|
|
40
|
+
expect(result.data.serverDeleted).toBe(true);
|
|
41
|
+
expect(existsSync(devicePath())).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
it("clears the local file even when the server returns 404", async () => {
|
|
44
|
+
seedDevice({ deviceId: "dev-gone", deviceToken: "t", name: "x" });
|
|
45
|
+
server.use(http.delete(`${TEST_BASE_URL}/api/devices/user/dev-gone`, () => HttpResponse.json({ error: "not found" }, { status: 404 })));
|
|
46
|
+
const result = await runDeviceUnregister({});
|
|
47
|
+
expect(result.ok).toBe(true);
|
|
48
|
+
if (result.ok)
|
|
49
|
+
expect(result.data.serverDeleted).toBe(false);
|
|
50
|
+
expect(existsSync(devicePath())).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
it("keeps the local file and errors on a non-404 server error", async () => {
|
|
53
|
+
seedDevice({ deviceId: "dev-1", deviceToken: "t", name: "x" });
|
|
54
|
+
server.use(http.delete(`${TEST_BASE_URL}/api/devices/user/dev-1`, () => HttpResponse.json({ error: "boom" }, { status: 500 })));
|
|
55
|
+
const result = await runDeviceUnregister({});
|
|
56
|
+
expect(result.ok).toBe(false);
|
|
57
|
+
expect(existsSync(devicePath())).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
it("errors when no device id is given and no local config exists", async () => {
|
|
60
|
+
const result = await runDeviceUnregister({});
|
|
61
|
+
expect(result.ok).toBe(false);
|
|
62
|
+
if (!result.ok)
|
|
63
|
+
expect(result.error.code).toBe("validation_error");
|
|
64
|
+
});
|
|
65
|
+
it("uses an explicit device id without removing a differing local file", async () => {
|
|
66
|
+
seedDevice({ deviceId: "local-1", deviceToken: "t", name: "x" });
|
|
67
|
+
server.use(http.delete(`${TEST_BASE_URL}/api/devices/user/other-2`, () => HttpResponse.json({ success: true })));
|
|
68
|
+
const result = await runDeviceUnregister({ deviceId: "other-2" });
|
|
69
|
+
expect(result.ok).toBe(true);
|
|
70
|
+
// The local registration is for a different device, so it stays.
|
|
71
|
+
expect(existsSync(devicePath())).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -24,7 +24,7 @@ async function connect() {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
describe("createMcpServer wiring", () => {
|
|
27
|
-
it("exposes all
|
|
27
|
+
it("exposes all 16 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();
|
|
@@ -40,6 +40,7 @@ describe("createMcpServer wiring", () => {
|
|
|
40
40
|
"aximo_list_projects",
|
|
41
41
|
"aximo_list_sessions",
|
|
42
42
|
"aximo_create_case",
|
|
43
|
+
"aximo_delete_case",
|
|
43
44
|
"aximo_create_plan",
|
|
44
45
|
"aximo_add_case_to_plan",
|
|
45
46
|
"aximo_remove_case_from_plan",
|
|
@@ -212,6 +212,17 @@ export function defineTools(deps = {}) {
|
|
|
212
212
|
return await request("/api/cases", { method: "POST", body: input }, deps);
|
|
213
213
|
}),
|
|
214
214
|
},
|
|
215
|
+
{
|
|
216
|
+
name: "aximo_delete_case",
|
|
217
|
+
title: "Delete test case",
|
|
218
|
+
description: "Permanently delete a test case. It is removed from any plans it belongs to; past run sessions are kept but detached. This cannot be undone.",
|
|
219
|
+
inputSchema: {
|
|
220
|
+
caseId: z.string().uuid(),
|
|
221
|
+
},
|
|
222
|
+
handler: async ({ caseId }) => call(async () => {
|
|
223
|
+
return await request(`/api/cases/${encodeURIComponent(caseId)}`, { method: "DELETE" }, deps);
|
|
224
|
+
}),
|
|
225
|
+
},
|
|
215
226
|
{
|
|
216
227
|
name: "aximo_create_plan",
|
|
217
228
|
title: "Create test plan",
|
|
@@ -32,6 +32,7 @@ describe("defineTools", () => {
|
|
|
32
32
|
"aximo_list_cases",
|
|
33
33
|
"aximo_get_case",
|
|
34
34
|
"aximo_create_case",
|
|
35
|
+
"aximo_delete_case",
|
|
35
36
|
"aximo_create_plan",
|
|
36
37
|
"aximo_add_case_to_plan",
|
|
37
38
|
"aximo_remove_case_from_plan",
|
|
@@ -255,6 +256,17 @@ describe("write tools", () => {
|
|
|
255
256
|
expect(parsePayload(result)).toEqual({ success: true });
|
|
256
257
|
expect(method).toBe("DELETE");
|
|
257
258
|
});
|
|
259
|
+
it("aximo_delete_case DELETEs the case path", async () => {
|
|
260
|
+
let method = null;
|
|
261
|
+
server.use(http.delete(`${TEST_BASE_URL}/api/cases/c1`, ({ request: req }) => {
|
|
262
|
+
method = req.method;
|
|
263
|
+
return HttpResponse.json({ success: true });
|
|
264
|
+
}));
|
|
265
|
+
const tool = byName(defineTools(), "aximo_delete_case");
|
|
266
|
+
const result = await tool.handler({ caseId: "c1" });
|
|
267
|
+
expect(parsePayload(result)).toEqual({ success: true });
|
|
268
|
+
expect(method).toBe("DELETE");
|
|
269
|
+
});
|
|
258
270
|
it("aximo_save_session_as_case POSTs to the save-as-case path without the id in the body", async () => {
|
|
259
271
|
let body;
|
|
260
272
|
server.use(http.post(`${TEST_BASE_URL}/api/sessions/s1/save-as-case`, async ({ request: req }) => {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ErrorEnvelope } from "../../types.js";
|
|
2
|
+
import type { MobileController } from "../../mobile-control/controller.js";
|
|
3
|
+
import { type MobileDeviceConfig } from "./paths.js";
|
|
4
|
+
export declare function getTunnelOrigin(): string;
|
|
5
|
+
interface ConfigOk {
|
|
6
|
+
ok: true;
|
|
7
|
+
config: MobileDeviceConfig;
|
|
8
|
+
}
|
|
9
|
+
export declare function resolveDeviceConfig(deviceId: string | undefined): ConfigOk | ErrorEnvelope;
|
|
10
|
+
export declare function buildController(config: MobileDeviceConfig, endpoint: string): MobileController;
|
|
11
|
+
export declare function connectWithConfig(config: MobileDeviceConfig): Promise<void>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { getBaseUrl } from "../../client.js";
|
|
2
|
+
import { formatError } from "../../output.js";
|
|
3
|
+
import { AppiumController } from "../../mobile-control/appium-controller.js";
|
|
4
|
+
import { startAppiumServer } from "../../mobile-control/appium-server.js";
|
|
5
|
+
import { runControlTunnel } from "../../mobile-control/tunnel.js";
|
|
6
|
+
import { runMobileDoctor } from "./doctor.js";
|
|
7
|
+
import { listMobileDeviceConfigs, readMobileDeviceConfig, } from "./paths.js";
|
|
8
|
+
export function getTunnelOrigin() {
|
|
9
|
+
return getBaseUrl().replace(/^http/, "ws");
|
|
10
|
+
}
|
|
11
|
+
export function resolveDeviceConfig(deviceId) {
|
|
12
|
+
if (deviceId) {
|
|
13
|
+
const config = readMobileDeviceConfig(deviceId);
|
|
14
|
+
if (!config) {
|
|
15
|
+
return formatError("not_found", `No registered device with id '${deviceId}'.`, false);
|
|
16
|
+
}
|
|
17
|
+
return { ok: true, config };
|
|
18
|
+
}
|
|
19
|
+
const all = listMobileDeviceConfigs();
|
|
20
|
+
if (all.length === 0) {
|
|
21
|
+
return formatError("validation_error", 'No registered devices. Register one first: aximo mobile register --name "My Pixel" --platform android', false);
|
|
22
|
+
}
|
|
23
|
+
if (all.length > 1) {
|
|
24
|
+
return formatError("validation_error", `Multiple devices registered; pass --device-id. Registered: ${all
|
|
25
|
+
.map((c) => `${c.name} (${c.deviceId})`)
|
|
26
|
+
.join(", ")}`, false);
|
|
27
|
+
}
|
|
28
|
+
const only = all[0];
|
|
29
|
+
if (!only) {
|
|
30
|
+
return formatError("validation_error", "No registered devices.", false);
|
|
31
|
+
}
|
|
32
|
+
return { ok: true, config: only };
|
|
33
|
+
}
|
|
34
|
+
export function buildController(config, endpoint) {
|
|
35
|
+
return new AppiumController({ endpoint, platform: config.platform, target: config.target });
|
|
36
|
+
}
|
|
37
|
+
// A blocking check is any hard failure, plus a missing UiAutomator2 driver
|
|
38
|
+
// (only a warning for `doctor`, but a hard prerequisite for connecting).
|
|
39
|
+
function isBlockingCheck(check) {
|
|
40
|
+
if (check.status === "fail")
|
|
41
|
+
return true;
|
|
42
|
+
return check.name === "uiautomator2 driver" && check.status === "warn";
|
|
43
|
+
}
|
|
44
|
+
async function assertAndroidPrerequisites() {
|
|
45
|
+
const report = await runMobileDoctor({ platform: "android" });
|
|
46
|
+
const checks = report.ok ? report.data.checks : [];
|
|
47
|
+
const blockers = checks.filter(isBlockingCheck);
|
|
48
|
+
if (blockers.length === 0)
|
|
49
|
+
return;
|
|
50
|
+
const lines = blockers.map((c) => ` - ${c.message}${c.hint ? `\n ${c.hint}` : ""}`);
|
|
51
|
+
throw new Error(`Missing Appium prerequisites:\n${lines.join("\n")}\n` +
|
|
52
|
+
"Run `aximo mobile setup` and/or fix the above, then re-run. " +
|
|
53
|
+
"(`aximo mobile doctor` for details.)");
|
|
54
|
+
}
|
|
55
|
+
export async function connectWithConfig(config) {
|
|
56
|
+
if (config.platform === "android") {
|
|
57
|
+
await assertAndroidPrerequisites();
|
|
58
|
+
}
|
|
59
|
+
const useDrivers = config.platform === "ios" ? ["xcuitest"] : ["uiautomator2"];
|
|
60
|
+
const server = await startAppiumServer({ useDrivers });
|
|
61
|
+
const controller = buildController(config, server.endpoint);
|
|
62
|
+
try {
|
|
63
|
+
await runControlTunnel({
|
|
64
|
+
wsUrl: getTunnelOrigin(),
|
|
65
|
+
deviceToken: config.deviceToken,
|
|
66
|
+
deviceName: config.name,
|
|
67
|
+
controller,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await controller.close();
|
|
72
|
+
await server.stop();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MobileDeviceConfig } from "./paths.js";
|
|
2
|
+
export declare function iosPhysicalWarning(config: MobileDeviceConfig): string | null;
|
|
3
|
+
declare const _default: import("citty").CommandDef<{
|
|
4
|
+
readonly "device-id": {
|
|
5
|
+
readonly type: "string";
|
|
6
|
+
readonly description: "Registered device id (required if multiple)";
|
|
7
|
+
};
|
|
8
|
+
}>;
|
|
9
|
+
export default _default;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { formatError, printResult } from "../../output.js";
|
|
3
|
+
import { connectWithConfig, resolveDeviceConfig } from "./connect-core.js";
|
|
4
|
+
const IOS_PHYSICAL_WARNING = "Driving a physical iPhone goes through Appium's XCUITest driver, which builds and " +
|
|
5
|
+
"installs a WebDriverAgent runner on the device. The device must have Developer Mode " +
|
|
6
|
+
"enabled (Settings → Privacy & Security) and be trusted, and WDA needs an Apple Developer " +
|
|
7
|
+
"signing identity; if control fails, run `aximo mobile doctor` for guidance.";
|
|
8
|
+
export function iosPhysicalWarning(config) {
|
|
9
|
+
return config.platform === "ios" ? IOS_PHYSICAL_WARNING : null;
|
|
10
|
+
}
|
|
11
|
+
export default defineCommand({
|
|
12
|
+
meta: {
|
|
13
|
+
name: "connect",
|
|
14
|
+
description: "Connect a registered phone to Aximo via a CLI-managed Appium server",
|
|
15
|
+
},
|
|
16
|
+
args: {
|
|
17
|
+
"device-id": { type: "string", description: "Registered device id (required if multiple)" },
|
|
18
|
+
},
|
|
19
|
+
async run({ args }) {
|
|
20
|
+
const resolved = resolveDeviceConfig(args["device-id"]);
|
|
21
|
+
if (!resolved.ok) {
|
|
22
|
+
printResult(resolved);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const warning = iosPhysicalWarning(resolved.config);
|
|
26
|
+
if (warning)
|
|
27
|
+
console.error(warning);
|
|
28
|
+
try {
|
|
29
|
+
await connectWithConfig(resolved.config);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
printResult(formatError("api_error", error instanceof Error ? error.message : String(error), true));
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { resolveDeviceConfig } from "./connect-core.js";
|
|
6
|
+
import { iosPhysicalWarning } from "./connect.js";
|
|
7
|
+
import { writeMobileDeviceConfig } from "./paths.js";
|
|
8
|
+
let tempHome;
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
tempHome = mkdtempSync(join(tmpdir(), "aximo-mob-connect-"));
|
|
11
|
+
process.env["AXIMO_HOME"] = tempHome;
|
|
12
|
+
});
|
|
13
|
+
afterAll(() => {
|
|
14
|
+
delete process.env["AXIMO_HOME"];
|
|
15
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
19
|
+
mkdirSync(tempHome, { recursive: true });
|
|
20
|
+
});
|
|
21
|
+
describe("iosPhysicalWarning", () => {
|
|
22
|
+
it("returns an Appium/WDA guidance warning for iOS configs", () => {
|
|
23
|
+
const warning = iosPhysicalWarning({
|
|
24
|
+
deviceId: "d1",
|
|
25
|
+
deviceToken: "t",
|
|
26
|
+
name: "iPhone",
|
|
27
|
+
platform: "ios",
|
|
28
|
+
target: "udid",
|
|
29
|
+
});
|
|
30
|
+
expect(warning).toContain("WebDriverAgent");
|
|
31
|
+
expect(warning).toContain("Developer Mode");
|
|
32
|
+
});
|
|
33
|
+
it("returns null for android configs (no warning)", () => {
|
|
34
|
+
const warning = iosPhysicalWarning({
|
|
35
|
+
deviceId: "d2",
|
|
36
|
+
deviceToken: "t",
|
|
37
|
+
name: "Pixel",
|
|
38
|
+
platform: "android",
|
|
39
|
+
target: "serial",
|
|
40
|
+
});
|
|
41
|
+
expect(warning).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("resolveDeviceConfig", () => {
|
|
45
|
+
it("returns validation_error when none are registered", () => {
|
|
46
|
+
const result = resolveDeviceConfig(undefined);
|
|
47
|
+
expect(result.ok).toBe(false);
|
|
48
|
+
if (!result.ok)
|
|
49
|
+
expect(result.error.code).toBe("validation_error");
|
|
50
|
+
});
|
|
51
|
+
it("auto-selects the only registered device", () => {
|
|
52
|
+
writeMobileDeviceConfig({
|
|
53
|
+
deviceId: "only-1",
|
|
54
|
+
deviceToken: "t",
|
|
55
|
+
name: "Solo",
|
|
56
|
+
platform: "android",
|
|
57
|
+
target: "serial-1",
|
|
58
|
+
});
|
|
59
|
+
const result = resolveDeviceConfig(undefined);
|
|
60
|
+
expect(result.ok).toBe(true);
|
|
61
|
+
if (result.ok)
|
|
62
|
+
expect(result.config.deviceId).toBe("only-1");
|
|
63
|
+
});
|
|
64
|
+
it("requires --device-id when multiple are registered", () => {
|
|
65
|
+
for (const id of ["a", "b"]) {
|
|
66
|
+
writeMobileDeviceConfig({
|
|
67
|
+
deviceId: id,
|
|
68
|
+
deviceToken: "t",
|
|
69
|
+
name: id,
|
|
70
|
+
platform: "android",
|
|
71
|
+
target: `serial-${id}`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const ambiguous = resolveDeviceConfig(undefined);
|
|
75
|
+
expect(ambiguous.ok).toBe(false);
|
|
76
|
+
const picked = resolveDeviceConfig("b");
|
|
77
|
+
expect(picked.ok).toBe(true);
|
|
78
|
+
if (picked.ok)
|
|
79
|
+
expect(picked.config.deviceId).toBe("b");
|
|
80
|
+
});
|
|
81
|
+
it("returns not_found for an unknown device id", () => {
|
|
82
|
+
const result = resolveDeviceConfig("ghost");
|
|
83
|
+
expect(result.ok).toBe(false);
|
|
84
|
+
if (!result.ok)
|
|
85
|
+
expect(result.error.code).toBe("not_found");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Envelope } from "../../types.js";
|
|
2
|
+
export type CheckStatus = "pass" | "warn" | "fail" | "info";
|
|
3
|
+
export interface Check {
|
|
4
|
+
name: string;
|
|
5
|
+
status: CheckStatus;
|
|
6
|
+
message: string;
|
|
7
|
+
hint?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface MobileDoctorReport {
|
|
10
|
+
checks: Check[];
|
|
11
|
+
summary: {
|
|
12
|
+
pass: number;
|
|
13
|
+
warn: number;
|
|
14
|
+
fail: number;
|
|
15
|
+
info: number;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export interface RunMobileDoctorOptions {
|
|
19
|
+
platform?: "ios" | "android" | undefined;
|
|
20
|
+
}
|
|
21
|
+
export declare function runMobileDoctor(options?: RunMobileDoctorOptions): Promise<Envelope<MobileDoctorReport>>;
|
|
22
|
+
declare const _default: import("citty").CommandDef<{
|
|
23
|
+
readonly platform: {
|
|
24
|
+
readonly type: "string";
|
|
25
|
+
readonly description: "Limit checks to ios | android";
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
export default _default;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { platform } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { formatSuccess, printResult } from "../../output.js";
|
|
7
|
+
import { importAppium } from "../../mobile-control/appium-import.js";
|
|
8
|
+
import { getAppiumHome, resolveAndroidSdk } from "../../mobile-control/appium-server.js";
|
|
9
|
+
function which(cmd) {
|
|
10
|
+
const probe = platform() === "win32" ? "where" : "which";
|
|
11
|
+
const result = spawnSync(probe, [cmd], { encoding: "utf-8" });
|
|
12
|
+
return result.status === 0 ? (result.stdout || "").split("\n")[0]?.trim() || cmd : null;
|
|
13
|
+
}
|
|
14
|
+
function driverInstalled(driver) {
|
|
15
|
+
return existsSync(join(getAppiumHome(), "node_modules", `appium-${driver}-driver`));
|
|
16
|
+
}
|
|
17
|
+
function adbInstallHint() {
|
|
18
|
+
switch (platform()) {
|
|
19
|
+
case "darwin":
|
|
20
|
+
return "Install Android platform-tools (brew install --cask android-platform-tools).";
|
|
21
|
+
case "linux":
|
|
22
|
+
return "Install Android platform-tools (apt install android-tools-adb, or your distro's adb package).";
|
|
23
|
+
case "win32":
|
|
24
|
+
return "Install platform-tools via the Android Studio SDK Manager.";
|
|
25
|
+
default:
|
|
26
|
+
return "Install Android platform-tools (adb).";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function checkAppium() {
|
|
30
|
+
try {
|
|
31
|
+
const mod = await importAppium();
|
|
32
|
+
if (typeof mod.main === "function") {
|
|
33
|
+
return { name: "appium", status: "pass", message: "bundled Appium server is importable" };
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
name: "appium",
|
|
37
|
+
status: "fail",
|
|
38
|
+
message: "appium is installed but exposes no programmatic `main`",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
return {
|
|
43
|
+
name: "appium",
|
|
44
|
+
status: "fail",
|
|
45
|
+
message: err instanceof Error ? err.message : String(err),
|
|
46
|
+
hint: "Reinstall the CLI; the bundled Appium dependency appears to be missing.",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function checkAndroid() {
|
|
51
|
+
const checks = [];
|
|
52
|
+
checks.push(driverInstalled("uiautomator2")
|
|
53
|
+
? { name: "uiautomator2 driver", status: "pass", message: "installed" }
|
|
54
|
+
: {
|
|
55
|
+
name: "uiautomator2 driver",
|
|
56
|
+
status: "warn",
|
|
57
|
+
message: "Appium UiAutomator2 driver not installed",
|
|
58
|
+
hint: "Run `aximo mobile setup` to install Appium drivers.",
|
|
59
|
+
});
|
|
60
|
+
const adb = which("adb");
|
|
61
|
+
checks.push(adb
|
|
62
|
+
? { name: "adb", status: "pass", message: adb }
|
|
63
|
+
: {
|
|
64
|
+
name: "adb",
|
|
65
|
+
status: "fail",
|
|
66
|
+
message: "adb not found in PATH",
|
|
67
|
+
hint: `${adbInstallHint()} Appium delegates to adb.`,
|
|
68
|
+
});
|
|
69
|
+
const sdk = resolveAndroidSdk();
|
|
70
|
+
checks.push(sdk
|
|
71
|
+
? { name: "android sdk", status: "pass", message: sdk }
|
|
72
|
+
: {
|
|
73
|
+
name: "android sdk",
|
|
74
|
+
status: "fail",
|
|
75
|
+
message: "Android SDK not found (ANDROID_HOME/ANDROID_SDK_ROOT unset)",
|
|
76
|
+
hint: "Set ANDROID_HOME to your Android SDK — Appium's UiAutomator2 driver requires it even though adb is on PATH — or install it via Android Studio.",
|
|
77
|
+
});
|
|
78
|
+
return checks;
|
|
79
|
+
}
|
|
80
|
+
function checkIos() {
|
|
81
|
+
if (platform() !== "darwin") {
|
|
82
|
+
return [
|
|
83
|
+
{ name: "ios", status: "info", message: "iOS device control is only available on macOS" },
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
const checks = [];
|
|
87
|
+
checks.push(driverInstalled("xcuitest")
|
|
88
|
+
? { name: "xcuitest driver", status: "pass", message: "installed" }
|
|
89
|
+
: {
|
|
90
|
+
name: "xcuitest driver",
|
|
91
|
+
status: "warn",
|
|
92
|
+
message: "Appium XCUITest driver not installed",
|
|
93
|
+
hint: "Run `aximo mobile setup` to install Appium drivers.",
|
|
94
|
+
});
|
|
95
|
+
const xcrun = which("xcrun");
|
|
96
|
+
checks.push(xcrun
|
|
97
|
+
? { name: "xcrun", status: "pass", message: xcrun }
|
|
98
|
+
: {
|
|
99
|
+
name: "xcrun",
|
|
100
|
+
status: "warn",
|
|
101
|
+
message: "xcrun not found",
|
|
102
|
+
hint: "Install Xcode command line tools: xcode-select --install. Appium's XCUITest driver delegates to Xcode.",
|
|
103
|
+
});
|
|
104
|
+
checks.push({
|
|
105
|
+
name: "ios webdriveragent",
|
|
106
|
+
status: "info",
|
|
107
|
+
message: "Physical iPhones are driven via Appium's XCUITest driver, which builds and installs a WebDriverAgent runner on the device.",
|
|
108
|
+
hint: "WDA needs an Apple Developer signing identity; enable Developer Mode (Settings → Privacy & Security) and trust the device.",
|
|
109
|
+
});
|
|
110
|
+
return checks;
|
|
111
|
+
}
|
|
112
|
+
export async function runMobileDoctor(options = {}) {
|
|
113
|
+
const checks = [await checkAppium()];
|
|
114
|
+
if (options.platform !== "ios") {
|
|
115
|
+
checks.push(...checkAndroid());
|
|
116
|
+
}
|
|
117
|
+
if (options.platform !== "android") {
|
|
118
|
+
checks.push(...checkIos());
|
|
119
|
+
}
|
|
120
|
+
const summary = { pass: 0, warn: 0, fail: 0, info: 0 };
|
|
121
|
+
for (const c of checks)
|
|
122
|
+
summary[c.status]++;
|
|
123
|
+
return formatSuccess({ checks, summary });
|
|
124
|
+
}
|
|
125
|
+
export default defineCommand({
|
|
126
|
+
meta: {
|
|
127
|
+
name: "doctor",
|
|
128
|
+
description: "Diagnose Appium prerequisites (bundled server, drivers, adb/Xcode delegates)",
|
|
129
|
+
},
|
|
130
|
+
args: {
|
|
131
|
+
platform: { type: "string", description: "Limit checks to ios | android" },
|
|
132
|
+
},
|
|
133
|
+
async run({ args }) {
|
|
134
|
+
const options = {};
|
|
135
|
+
if (args.platform === "ios" || args.platform === "android")
|
|
136
|
+
options.platform = args.platform;
|
|
137
|
+
const result = await runMobileDoctor(options);
|
|
138
|
+
printResult(result);
|
|
139
|
+
if (result.ok && result.data.summary.fail > 0) {
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
export default defineCommand({
|
|
3
|
+
meta: {
|
|
4
|
+
name: "mobile",
|
|
5
|
+
description: "Register and connect a phone to Aximo via a CLI-managed Appium server",
|
|
6
|
+
},
|
|
7
|
+
subCommands: {
|
|
8
|
+
list: () => import("./list.js").then((m) => m.default),
|
|
9
|
+
register: () => import("./register.js").then((m) => m.default),
|
|
10
|
+
unregister: () => import("./unregister.js").then((m) => m.default),
|
|
11
|
+
connect: () => import("./connect.js").then((m) => m.default),
|
|
12
|
+
setup: () => import("./setup.js").then((m) => m.default),
|
|
13
|
+
doctor: () => import("./doctor.js").then((m) => m.default),
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Envelope } from "../../types.js";
|
|
2
|
+
import { type AndroidDevice, type IosPhysicalDevice } from "../../mobile-control/devices.js";
|
|
3
|
+
import type { ExecFn } from "../../mobile-control/exec.js";
|
|
4
|
+
export interface MobileListResult {
|
|
5
|
+
androidDevices: AndroidDevice[];
|
|
6
|
+
iosDevices: IosPhysicalDevice[];
|
|
7
|
+
}
|
|
8
|
+
export declare function runMobileList(exec?: ExecFn): Promise<Envelope<MobileListResult>>;
|
|
9
|
+
declare const _default: import("citty").CommandDef<{}>;
|
|
10
|
+
export default _default;
|