@autifyhq/aximo-cli 0.4.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 +98 -34
- package/dist/cli.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 +13 -0
- package/dist/commands/device/doctor.js +142 -25
- package/dist/commands/device/doctor.test.js +61 -1
- 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/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/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 +1 -0
- package/package.json +3 -1
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { defineCommand } from "citty";
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
1
|
import { platform, release } from "node:os";
|
|
4
|
-
import {
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import { getBaseUrl, request } from "../../client.js";
|
|
5
4
|
import { handleApiError } from "../../errors.js";
|
|
6
5
|
import { formatError, formatSuccess, printResult } from "../../output.js";
|
|
7
|
-
import {
|
|
6
|
+
import { readDeviceConfig, writeDeviceConfig } from "./device-config.js";
|
|
8
7
|
function detectPlatform() {
|
|
9
8
|
const p = platform();
|
|
10
9
|
if (p === "win32")
|
|
@@ -22,42 +21,59 @@ function detectOs() {
|
|
|
22
21
|
return `macOS ${r}`;
|
|
23
22
|
return `Linux ${r}`;
|
|
24
23
|
}
|
|
24
|
+
/** Whether deviceId is present among the user's devices on the targeted server. */
|
|
25
|
+
async function deviceExistsOnServer(deviceId, deps) {
|
|
26
|
+
const res = await request("/api/devices/user", {}, deps);
|
|
27
|
+
return (res.devices ?? []).some((d) => d.id === deviceId);
|
|
28
|
+
}
|
|
25
29
|
export async function runDeviceRegister(options, deps) {
|
|
26
30
|
try {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
const clientDeps = deps ?? {};
|
|
32
|
+
const baseUrl = clientDeps.baseUrl ?? getBaseUrl();
|
|
33
|
+
let replacedStale = false;
|
|
34
|
+
let previousBaseUrl;
|
|
35
|
+
const existing = readDeviceConfig();
|
|
36
|
+
if (existing && !options.force) {
|
|
37
|
+
// A local registration exists. It only truly conflicts if that device is
|
|
38
|
+
// still present on the server we're targeting. If it's gone — deleted, or
|
|
39
|
+
// registered against a different server (common when rotating ephemeral
|
|
40
|
+
// on-prem test boxes) — the local file is stale and we re-register instead
|
|
41
|
+
// of hard-blocking. When the server can't be reached to check, stay safe
|
|
42
|
+
// and block rather than silently clobber a possibly-valid registration.
|
|
43
|
+
let liveHere;
|
|
44
|
+
try {
|
|
45
|
+
liveHere = await deviceExistsOnServer(existing.deviceId, clientDeps);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
return formatError("validation_error", `A device is already registered on this machine and its status on ${baseUrl} ` +
|
|
49
|
+
`could not be verified (${err instanceof Error ? err.message : String(err)}). ` +
|
|
50
|
+
"Run 'aximo device unregister' first, or pass --force to overwrite.", false);
|
|
51
|
+
}
|
|
52
|
+
if (liveHere) {
|
|
53
|
+
return formatError("validation_error", `A device (${existing.deviceId}) is already registered on this machine and active on ${baseUrl}. ` +
|
|
54
|
+
"Run 'aximo device unregister' first, or pass --force to overwrite (this orphans the previous server-side device).", false);
|
|
55
|
+
}
|
|
56
|
+
replacedStale = true;
|
|
57
|
+
previousBaseUrl = existing.baseUrl;
|
|
30
58
|
}
|
|
31
59
|
const res = await request("/api/devices/register", {
|
|
32
60
|
method: "POST",
|
|
33
|
-
body: {
|
|
34
|
-
|
|
35
|
-
platform: detectPlatform(),
|
|
36
|
-
os: detectOs(),
|
|
37
|
-
},
|
|
38
|
-
}, deps ?? {});
|
|
39
|
-
const aximoDir = getAximoDir();
|
|
40
|
-
if (!existsSync(aximoDir)) {
|
|
41
|
-
mkdirSync(aximoDir, { recursive: true });
|
|
42
|
-
}
|
|
61
|
+
body: { name: options.name, platform: detectPlatform(), os: detectOs() },
|
|
62
|
+
}, clientDeps);
|
|
43
63
|
const registeredAt = new Date().toISOString();
|
|
44
|
-
|
|
64
|
+
writeDeviceConfig({
|
|
45
65
|
deviceId: res.deviceId,
|
|
46
66
|
deviceToken: res.deviceToken,
|
|
47
67
|
name: options.name,
|
|
48
68
|
registeredAt,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
chmodSync(configPath, 0o600);
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
// chmod may fail on Windows, file-level ACLs apply instead
|
|
56
|
-
}
|
|
69
|
+
baseUrl,
|
|
70
|
+
});
|
|
57
71
|
return formatSuccess({
|
|
58
72
|
deviceId: res.deviceId,
|
|
59
73
|
name: options.name,
|
|
60
74
|
registeredAt,
|
|
75
|
+
...(replacedStale ? { replacedStale: true } : {}),
|
|
76
|
+
...(previousBaseUrl ? { previousBaseUrl } : {}),
|
|
61
77
|
});
|
|
62
78
|
}
|
|
63
79
|
catch (err) {
|
|
@@ -67,17 +83,19 @@ export async function runDeviceRegister(options, deps) {
|
|
|
67
83
|
export default defineCommand({
|
|
68
84
|
meta: { name: "register", description: "Register this machine as a BYOD device" },
|
|
69
85
|
args: {
|
|
70
|
-
name: {
|
|
71
|
-
type: "string",
|
|
72
|
-
description: "Device name",
|
|
73
|
-
required: true,
|
|
74
|
-
},
|
|
86
|
+
name: { type: "string", description: "Device name", required: true },
|
|
75
87
|
force: {
|
|
76
88
|
type: "boolean",
|
|
77
89
|
description: "Overwrite an existing local registration (orphans the previous device)",
|
|
78
90
|
},
|
|
79
91
|
},
|
|
80
92
|
async run({ args }) {
|
|
81
|
-
|
|
93
|
+
const result = await runDeviceRegister({ name: args.name, force: Boolean(args.force) });
|
|
94
|
+
printResult(result);
|
|
95
|
+
if (result.ok && result.data.replacedStale) {
|
|
96
|
+
const prev = result.data.previousBaseUrl;
|
|
97
|
+
console.error(`\nNote: replaced a stale local registration${prev ? ` (previously registered against ${prev})` : ""}. ` +
|
|
98
|
+
"If that device still exists on another server it is now orphaned — unregister it there if you no longer need it.");
|
|
99
|
+
}
|
|
82
100
|
},
|
|
83
101
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, 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 { runDeviceRegister } from "./register.js";
|
|
7
|
+
let tempHome;
|
|
8
|
+
beforeAll(() => {
|
|
9
|
+
tempHome = mkdtempSync(join(tmpdir(), "aximo-register-"));
|
|
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
|
+
function readDevice() {
|
|
34
|
+
return JSON.parse(readFileSync(devicePath(), "utf-8"));
|
|
35
|
+
}
|
|
36
|
+
describe("runDeviceRegister", () => {
|
|
37
|
+
it("registers fresh when no local device exists", async () => {
|
|
38
|
+
server.use(http.post(`${TEST_BASE_URL}/api/devices/register`, () => HttpResponse.json({ deviceId: "new-1", deviceToken: "tok" })));
|
|
39
|
+
const result = await runDeviceRegister({ name: "mac" });
|
|
40
|
+
expect(result.ok).toBe(true);
|
|
41
|
+
if (result.ok) {
|
|
42
|
+
expect(result.data.deviceId).toBe("new-1");
|
|
43
|
+
expect(result.data.replacedStale).toBeUndefined();
|
|
44
|
+
}
|
|
45
|
+
expect(readDevice()).toMatchObject({ deviceId: "new-1", name: "mac", baseUrl: TEST_BASE_URL });
|
|
46
|
+
});
|
|
47
|
+
it("blocks when the local device is still live on the targeted server", async () => {
|
|
48
|
+
seedDevice({ deviceId: "dev-live", deviceToken: "t", name: "x", baseUrl: TEST_BASE_URL });
|
|
49
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => HttpResponse.json({ devices: [{ id: "dev-live" }] })));
|
|
50
|
+
const result = await runDeviceRegister({ name: "mac" });
|
|
51
|
+
expect(result.ok).toBe(false);
|
|
52
|
+
if (!result.ok) {
|
|
53
|
+
expect(result.error.code).toBe("validation_error");
|
|
54
|
+
expect(result.error.message).toContain("active on");
|
|
55
|
+
}
|
|
56
|
+
// Local file untouched (no POST happened).
|
|
57
|
+
expect(readDevice()).toMatchObject({ deviceId: "dev-live" });
|
|
58
|
+
});
|
|
59
|
+
it("re-registers when the local device is absent on the targeted server (stale)", async () => {
|
|
60
|
+
seedDevice({ deviceId: "dev-old", deviceToken: "t", name: "x", baseUrl: "https://old.server" });
|
|
61
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => HttpResponse.json({ devices: [] })), http.post(`${TEST_BASE_URL}/api/devices/register`, () => HttpResponse.json({ deviceId: "dev-new", deviceToken: "tok2" })));
|
|
62
|
+
const result = await runDeviceRegister({ name: "mac" });
|
|
63
|
+
expect(result.ok).toBe(true);
|
|
64
|
+
if (result.ok) {
|
|
65
|
+
expect(result.data.deviceId).toBe("dev-new");
|
|
66
|
+
expect(result.data.replacedStale).toBe(true);
|
|
67
|
+
// The replaced registration's server is surfaced, not silently dropped.
|
|
68
|
+
expect(result.data.previousBaseUrl).toBe("https://old.server");
|
|
69
|
+
}
|
|
70
|
+
expect(readDevice()).toMatchObject({ deviceId: "dev-new", baseUrl: TEST_BASE_URL });
|
|
71
|
+
});
|
|
72
|
+
it("blocks conservatively when the server cannot be reached to verify", async () => {
|
|
73
|
+
seedDevice({ deviceId: "dev-x", deviceToken: "t", name: "x" });
|
|
74
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => HttpResponse.json({ error: "boom" }, { status: 500 })));
|
|
75
|
+
const result = await runDeviceRegister({ name: "mac" });
|
|
76
|
+
expect(result.ok).toBe(false);
|
|
77
|
+
if (!result.ok) {
|
|
78
|
+
expect(result.error.code).toBe("validation_error");
|
|
79
|
+
expect(result.error.message).toContain("could not be verified");
|
|
80
|
+
}
|
|
81
|
+
expect(readDevice()).toMatchObject({ deviceId: "dev-x" });
|
|
82
|
+
});
|
|
83
|
+
it("--force overwrites without checking the server", async () => {
|
|
84
|
+
seedDevice({ deviceId: "dev-live", deviceToken: "t", name: "x", baseUrl: TEST_BASE_URL });
|
|
85
|
+
// The device list would 500; if --force consulted it, register would block.
|
|
86
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => HttpResponse.json({ error: "boom" }, { status: 500 })), http.post(`${TEST_BASE_URL}/api/devices/register`, () => HttpResponse.json({ deviceId: "dev-forced", deviceToken: "tok" })));
|
|
87
|
+
const result = await runDeviceRegister({ name: "mac", force: true });
|
|
88
|
+
expect(result.ok).toBe(true);
|
|
89
|
+
if (result.ok) {
|
|
90
|
+
expect(result.data.deviceId).toBe("dev-forced");
|
|
91
|
+
expect(result.data.replacedStale).toBeUndefined();
|
|
92
|
+
}
|
|
93
|
+
expect(readDevice()).toMatchObject({ deviceId: "dev-forced", baseUrl: TEST_BASE_URL });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type ClientDeps } from "../../client.js";
|
|
2
2
|
import type { Envelope } from "../../types.js";
|
|
3
|
+
export interface UnregisterResult {
|
|
4
|
+
success: boolean;
|
|
5
|
+
serverDeleted: boolean;
|
|
6
|
+
}
|
|
3
7
|
export declare function runDeviceUnregister(options: {
|
|
4
8
|
deviceId?: string;
|
|
5
|
-
}, deps?: ClientDeps): Promise<Envelope<
|
|
6
|
-
success: boolean;
|
|
7
|
-
}>>;
|
|
9
|
+
}, deps?: ClientDeps): Promise<Envelope<UnregisterResult>>;
|
|
8
10
|
declare const _default: import("citty").CommandDef<{
|
|
9
11
|
readonly "device-id": {
|
|
10
12
|
readonly type: "string";
|
|
@@ -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
|
+
});
|
|
@@ -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;
|