@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.
Files changed (78) hide show
  1. package/README.md +98 -34
  2. package/dist/cli.js +1 -0
  3. package/dist/commands/device/device-config.d.ts +14 -0
  4. package/dist/commands/device/device-config.js +46 -0
  5. package/dist/commands/device/device-config.test.d.ts +1 -0
  6. package/dist/commands/device/device-config.test.js +56 -0
  7. package/dist/commands/device/doctor.d.ts +13 -0
  8. package/dist/commands/device/doctor.js +142 -25
  9. package/dist/commands/device/doctor.test.js +61 -1
  10. package/dist/commands/device/register.d.ts +2 -0
  11. package/dist/commands/device/register.js +50 -32
  12. package/dist/commands/device/register.test.d.ts +1 -0
  13. package/dist/commands/device/register.test.js +95 -0
  14. package/dist/commands/device/unregister.d.ts +5 -3
  15. package/dist/commands/device/unregister.js +32 -33
  16. package/dist/commands/device/unregister.test.d.ts +1 -0
  17. package/dist/commands/device/unregister.test.js +73 -0
  18. package/dist/commands/mobile/connect-core.d.ts +12 -0
  19. package/dist/commands/mobile/connect-core.js +74 -0
  20. package/dist/commands/mobile/connect.d.ts +9 -0
  21. package/dist/commands/mobile/connect.js +35 -0
  22. package/dist/commands/mobile/connect.test.d.ts +1 -0
  23. package/dist/commands/mobile/connect.test.js +87 -0
  24. package/dist/commands/mobile/doctor.d.ts +28 -0
  25. package/dist/commands/mobile/doctor.js +143 -0
  26. package/dist/commands/mobile/index.d.ts +2 -0
  27. package/dist/commands/mobile/index.js +15 -0
  28. package/dist/commands/mobile/list.d.ts +10 -0
  29. package/dist/commands/mobile/list.js +30 -0
  30. package/dist/commands/mobile/paths.d.ts +14 -0
  31. package/dist/commands/mobile/paths.js +71 -0
  32. package/dist/commands/mobile/register-core.d.ts +19 -0
  33. package/dist/commands/mobile/register-core.js +46 -0
  34. package/dist/commands/mobile/register.d.ts +32 -0
  35. package/dist/commands/mobile/register.js +51 -0
  36. package/dist/commands/mobile/register.test.d.ts +1 -0
  37. package/dist/commands/mobile/register.test.js +60 -0
  38. package/dist/commands/mobile/setup.d.ts +27 -0
  39. package/dist/commands/mobile/setup.js +100 -0
  40. package/dist/commands/mobile/setup.test.d.ts +1 -0
  41. package/dist/commands/mobile/setup.test.js +49 -0
  42. package/dist/commands/mobile/unregister.d.ts +14 -0
  43. package/dist/commands/mobile/unregister.js +37 -0
  44. package/dist/commands/schema.js +22 -0
  45. package/dist/commands/session/create.d.ts +5 -0
  46. package/dist/commands/session/create.js +6 -0
  47. package/dist/index.d.ts +3 -0
  48. package/dist/index.js +3 -0
  49. package/dist/mobile-control/appium-controller.d.ts +39 -0
  50. package/dist/mobile-control/appium-controller.js +367 -0
  51. package/dist/mobile-control/appium-controller.test.d.ts +1 -0
  52. package/dist/mobile-control/appium-controller.test.js +252 -0
  53. package/dist/mobile-control/appium-import.d.ts +7 -0
  54. package/dist/mobile-control/appium-import.js +6 -0
  55. package/dist/mobile-control/appium-import.test.d.ts +1 -0
  56. package/dist/mobile-control/appium-import.test.js +9 -0
  57. package/dist/mobile-control/appium-server.d.ts +30 -0
  58. package/dist/mobile-control/appium-server.js +110 -0
  59. package/dist/mobile-control/appium-server.test.d.ts +1 -0
  60. package/dist/mobile-control/appium-server.test.js +108 -0
  61. package/dist/mobile-control/controller.d.ts +22 -0
  62. package/dist/mobile-control/controller.js +1 -0
  63. package/dist/mobile-control/devices.d.ts +21 -0
  64. package/dist/mobile-control/devices.js +99 -0
  65. package/dist/mobile-control/devices.test.d.ts +1 -0
  66. package/dist/mobile-control/devices.test.js +35 -0
  67. package/dist/mobile-control/exec.d.ts +6 -0
  68. package/dist/mobile-control/exec.js +13 -0
  69. package/dist/mobile-control/tunnel.d.ts +11 -0
  70. package/dist/mobile-control/tunnel.js +152 -0
  71. package/dist/mobile-control/tunnel.test.d.ts +1 -0
  72. package/dist/mobile-control/tunnel.test.js +155 -0
  73. package/dist/mobile-control/webdriver.d.ts +31 -0
  74. package/dist/mobile-control/webdriver.js +137 -0
  75. package/dist/mobile-control/webdriver.test.d.ts +1 -0
  76. package/dist/mobile-control/webdriver.test.js +45 -0
  77. package/dist/types.d.ts +1 -0
  78. package/package.json +3 -1
@@ -0,0 +1,252 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { AppiumController } from "./appium-controller.js";
3
+ import { ELEMENT_KEY } from "./webdriver.js";
4
+ function pngWithWidth(width) {
5
+ const buf = Buffer.alloc(24);
6
+ buf.writeUInt32BE(width, 16);
7
+ buf.writeUInt32BE(width, 20);
8
+ return buf.toString("base64");
9
+ }
10
+ function fakeTransport(options = {}) {
11
+ const calls = [];
12
+ const windowWidth = options.windowWidth ?? 400;
13
+ const screenshotWidth = options.screenshotWidth ?? 400;
14
+ const transport = {
15
+ async request(method, path, body) {
16
+ calls.push({ method, path, body });
17
+ if (method === "POST" && path === "/session") {
18
+ return { sessionId: "sess-1" };
19
+ }
20
+ if (path.endsWith("/screenshot")) {
21
+ return pngWithWidth(screenshotWidth);
22
+ }
23
+ if (path.endsWith("/window/rect")) {
24
+ return { width: windowWidth, height: windowWidth * 2, x: 0, y: 0 };
25
+ }
26
+ if (path.endsWith("/element/active")) {
27
+ const ref = options.activeElement === undefined ? "active-el" : options.activeElement;
28
+ return ref ? { [ELEMENT_KEY]: ref } : null;
29
+ }
30
+ if (method === "POST" && path.endsWith("/element")) {
31
+ const ref = options.findElementRef === undefined ? "found-el" : options.findElementRef;
32
+ return ref ? { [ELEMENT_KEY]: ref } : null;
33
+ }
34
+ return {};
35
+ },
36
+ };
37
+ return { transport, calls };
38
+ }
39
+ function makeController(platform, options) {
40
+ const { transport, calls } = fakeTransport(options);
41
+ return {
42
+ controller: new AppiumController({
43
+ endpoint: "http://x",
44
+ platform,
45
+ target: "udid-1",
46
+ transport,
47
+ }),
48
+ calls,
49
+ };
50
+ }
51
+ function bodyOf(calls, predicate) {
52
+ const call = calls.find(predicate);
53
+ if (!call)
54
+ throw new Error("expected a matching recorded call");
55
+ return (call.body ?? {});
56
+ }
57
+ function executeBody(calls) {
58
+ return bodyOf(calls, (c) => c.path.endsWith("/execute/sync"));
59
+ }
60
+ function caps(calls) {
61
+ const body = bodyOf(calls, (c) => c.path === "/session" && c.method === "POST");
62
+ return body["capabilities"].alwaysMatch;
63
+ }
64
+ function pointerActions(calls) {
65
+ const body = bodyOf(calls, (c) => c.path.endsWith("/actions") && c.method === "POST");
66
+ const groups = body["actions"];
67
+ return groups[0]?.actions ?? [];
68
+ }
69
+ describe("AppiumController session capabilities", () => {
70
+ it("sends Android UiAutomator2 caps", async () => {
71
+ const { controller, calls } = makeController("android");
72
+ await controller.screenshot();
73
+ const c = caps(calls);
74
+ expect(c["platformName"]).toBe("Android");
75
+ expect(c["appium:automationName"]).toBe("UiAutomator2");
76
+ expect(c["appium:udid"]).toBe("udid-1");
77
+ expect(c["appium:noReset"]).toBe(true);
78
+ expect(c["appium:autoLaunch"]).toBe(false);
79
+ expect(c["appium:newCommandTimeout"]).toBe(0);
80
+ });
81
+ it("sends iOS XCUITest caps", async () => {
82
+ const { controller, calls } = makeController("ios");
83
+ await controller.screenshot();
84
+ const c = caps(calls);
85
+ expect(c["platformName"]).toBe("iOS");
86
+ expect(c["appium:automationName"]).toBe("XCUITest");
87
+ expect(c["appium:newCommandTimeout"]).toBe(0);
88
+ });
89
+ it("creates the session lazily and only once", async () => {
90
+ const { controller, calls } = makeController("android");
91
+ await controller.screenshot();
92
+ await controller.screenshot();
93
+ expect(calls.filter((c) => c.path === "/session" && c.method === "POST")).toHaveLength(1);
94
+ });
95
+ });
96
+ describe("AppiumController coordinate scaling", () => {
97
+ it("halves pixel coords when screenshot is 800px and window is 400pt (iOS)", async () => {
98
+ const { controller, calls } = makeController("ios", { windowWidth: 400, screenshotWidth: 800 });
99
+ await controller.tap(200, 100);
100
+ const actions = pointerActions(calls);
101
+ const move = actions[0];
102
+ expect(move.type).toBe("pointerMove");
103
+ expect(move.x).toBe(100);
104
+ expect(move.y).toBe(50);
105
+ });
106
+ it("keeps coords ~1:1 for Android when scale is 1", async () => {
107
+ const { controller, calls } = makeController("android", {
108
+ windowWidth: 400,
109
+ screenshotWidth: 400,
110
+ });
111
+ await controller.tap(123, 45);
112
+ const move = pointerActions(calls)[0];
113
+ expect(move.x).toBe(123);
114
+ expect(move.y).toBe(45);
115
+ });
116
+ });
117
+ describe("AppiumController touch actions", () => {
118
+ it("tap builds move/down/pause/up", async () => {
119
+ const { controller, calls } = makeController("android");
120
+ await controller.tap(10, 20);
121
+ const actions = pointerActions(calls).map((a) => a.type);
122
+ expect(actions).toEqual(["pointerMove", "pointerDown", "pause", "pointerUp"]);
123
+ expect(calls.some((c) => c.method === "DELETE" && c.path.endsWith("/actions"))).toBe(true);
124
+ });
125
+ it("long_press holds for the requested duration", async () => {
126
+ const { controller, calls } = makeController("android");
127
+ await controller.longPress(10, 20, 1500);
128
+ const pause = pointerActions(calls).find((a) => a.type === "pause");
129
+ expect(pause.duration).toBe(1500);
130
+ });
131
+ it("swipe moves start->end with the duration on the second move", async () => {
132
+ const { controller, calls } = makeController("android");
133
+ await controller.swipe(10, 20, 30, 40, 250);
134
+ const actions = pointerActions(calls);
135
+ expect(actions[0].x).toBe(10);
136
+ const endMove = actions[2];
137
+ expect(endMove.type).toBe("pointerMove");
138
+ expect(endMove.x).toBe(30);
139
+ expect(endMove.duration).toBe(250);
140
+ });
141
+ it("double_tap performs two down/up cycles", async () => {
142
+ const { controller, calls } = makeController("ios");
143
+ await controller.doubleTap(5, 5);
144
+ const downs = pointerActions(calls).filter((a) => a.type === "pointerDown");
145
+ expect(downs).toHaveLength(2);
146
+ });
147
+ });
148
+ describe("AppiumController text + keys", () => {
149
+ it("typeText on Android uses mobile: type", async () => {
150
+ const { controller, calls } = makeController("android");
151
+ const result = await controller.typeText("hello");
152
+ expect(result.ok).toBe(true);
153
+ const exec = executeBody(calls);
154
+ expect(exec.script).toBe("mobile: type");
155
+ expect(exec.args[0]?.["text"]).toBe("hello");
156
+ });
157
+ it("typeText on iOS sends keys to the active element", async () => {
158
+ const { controller, calls } = makeController("ios");
159
+ await controller.typeText("hi");
160
+ expect(calls.some((c) => c.path.endsWith("/element/active"))).toBe(true);
161
+ const send = bodyOf(calls, (c) => c.path.endsWith("/element/active-el/value"));
162
+ expect(send["text"]).toBe("hi");
163
+ });
164
+ it("clearText clears the active element when present", async () => {
165
+ const { controller, calls } = makeController("android", { activeElement: "el-9" });
166
+ const result = await controller.clearText();
167
+ expect(result.ok).toBe(true);
168
+ expect(calls.some((c) => c.path.endsWith("/element/el-9/clear"))).toBe(true);
169
+ });
170
+ it("clearText falls back to Ctrl+A + delete on Android when no active element", async () => {
171
+ const { controller, calls } = makeController("android", { activeElement: null });
172
+ const result = await controller.clearText();
173
+ expect(result.ok).toBe(true);
174
+ const execs = calls.filter((c) => c.path.endsWith("/execute/sync"));
175
+ expect(execs).toHaveLength(2);
176
+ const first = executeBody(calls);
177
+ expect(first.args[0]?.["keycode"]).toBe(29);
178
+ });
179
+ it("clearText returns a structured error on iOS with no active element", async () => {
180
+ const { controller } = makeController("ios", { activeElement: null });
181
+ const result = await controller.clearText();
182
+ expect(result.ok).toBe(false);
183
+ expect(result.error).toContain("focus");
184
+ });
185
+ it("pressKey maps Android keys to mobile: pressKey keycodes", async () => {
186
+ const { controller, calls } = makeController("android");
187
+ expect((await controller.pressKey("back")).ok).toBe(true);
188
+ expect(executeBody(calls).args[0]?.["keycode"]).toBe(4);
189
+ });
190
+ it("pressKey rejects 'back' on iOS", async () => {
191
+ const { controller } = makeController("ios");
192
+ const result = await controller.pressKey("back");
193
+ expect(result.ok).toBe(false);
194
+ expect(result.error).toContain("Android-only");
195
+ });
196
+ it("pressKey returns an error for unsupported keys", async () => {
197
+ const { controller } = makeController("android");
198
+ const result = await controller.pressKey("f13");
199
+ expect(result.ok).toBe(false);
200
+ });
201
+ });
202
+ describe("AppiumController findElement", () => {
203
+ it("clicks the located element and tries accessibility id first on Android", async () => {
204
+ const { controller, calls } = makeController("android", { findElementRef: "btn-1" });
205
+ const result = await controller.findElement("Sign in");
206
+ expect(result.ok).toBe(true);
207
+ const firstFind = bodyOf(calls, (c) => c.method === "POST" && c.path.endsWith("/element"));
208
+ expect(firstFind["using"]).toBe("accessibility id");
209
+ expect(calls.some((c) => c.path.endsWith("/element/btn-1/click"))).toBe(true);
210
+ });
211
+ it("tries the iOS button predicate first", async () => {
212
+ const { controller, calls } = makeController("ios", { findElementRef: "btn-1" });
213
+ await controller.findElement("OK");
214
+ const firstFind = bodyOf(calls, (c) => c.method === "POST" && c.path.endsWith("/element"));
215
+ expect(firstFind["using"]).toBe("-ios predicate string");
216
+ });
217
+ it("returns not-found when no strategy locates the element", async () => {
218
+ const { controller } = makeController("android", { findElementRef: null });
219
+ const result = await controller.findElement("Nope");
220
+ expect(result.ok).toBe(false);
221
+ expect(result.error).toContain("not found");
222
+ });
223
+ });
224
+ describe("AppiumController launch + lifecycle", () => {
225
+ it("launchApp uses mobile: activateApp with appId on Android", async () => {
226
+ const { controller, calls } = makeController("android");
227
+ await controller.launchApp("com.example.app");
228
+ const exec = executeBody(calls);
229
+ expect(exec.script).toBe("mobile: activateApp");
230
+ expect(exec.args[0]?.["appId"]).toBe("com.example.app");
231
+ });
232
+ it("launchApp uses bundleId on iOS", async () => {
233
+ const { controller, calls } = makeController("ios");
234
+ await controller.launchApp("com.example.app");
235
+ expect(executeBody(calls).args[0]?.["bundleId"]).toBe("com.example.app");
236
+ });
237
+ it("hideKeyboard swallows errors and acks ok", async () => {
238
+ const { controller, calls } = makeController("android");
239
+ await controller.screenshot();
240
+ const result = await controller.hideKeyboard();
241
+ expect(result.ok).toBe(true);
242
+ const exec = calls.find((c) => c.path.endsWith("/execute/sync") &&
243
+ c.body.script === "mobile: hideKeyboard");
244
+ expect(exec).toBeDefined();
245
+ });
246
+ it("close deletes the session", async () => {
247
+ const { controller, calls } = makeController("android");
248
+ await controller.screenshot();
249
+ await controller.close();
250
+ expect(calls.some((c) => c.method === "DELETE" && c.path === "/session/sess-1")).toBe(true);
251
+ });
252
+ });
@@ -0,0 +1,7 @@
1
+ interface AppiumModule {
2
+ main: (args: Record<string, unknown>) => Promise<{
3
+ close(): Promise<void>;
4
+ }>;
5
+ }
6
+ export declare function importAppium(): Promise<AppiumModule>;
7
+ export {};
@@ -0,0 +1,6 @@
1
+ const APPIUM = "appium";
2
+ export async function importAppium() {
3
+ // Appium 3 is CJS, so its `main` is under `.default` when imported from ESM.
4
+ const mod = (await import(APPIUM));
5
+ return mod.default ?? mod;
6
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { importAppium } from "./appium-import.js";
3
+ // Guards the CJS-default unwrap with a real import (Appium 3 exposes `main` under `.default`).
4
+ describe("importAppium", () => {
5
+ it("resolves the appium module with a callable main()", async () => {
6
+ const mod = await importAppium();
7
+ expect(typeof mod.main).toBe("function");
8
+ });
9
+ });
@@ -0,0 +1,30 @@
1
+ export interface AppiumServerHandle {
2
+ endpoint: string;
3
+ stop: () => Promise<void>;
4
+ }
5
+ interface RunningServer {
6
+ close(): Promise<void>;
7
+ }
8
+ export type AppiumLauncher = (args: Record<string, unknown>) => Promise<RunningServer>;
9
+ export interface StartAppiumServerOptions {
10
+ launch?: AppiumLauncher;
11
+ appiumHome?: string;
12
+ useDrivers?: string[];
13
+ readyTimeoutMs?: number;
14
+ }
15
+ export declare function getAppiumHome(): string;
16
+ export interface ResolveAndroidSdkDeps {
17
+ env?: NodeJS.ProcessEnv;
18
+ home?: string;
19
+ platform?: NodeJS.Platform;
20
+ exists?: (path: string) => boolean;
21
+ }
22
+ /**
23
+ * Resolve the Android SDK location. Honours ANDROID_HOME / ANDROID_SDK_ROOT when
24
+ * set and valid, else probes the standard per-OS install location. Appium's
25
+ * UiAutomator2 driver requires this even when `adb` is already on PATH.
26
+ */
27
+ export declare function resolveAndroidSdk(deps?: ResolveAndroidSdkDeps): string | null;
28
+ export declare function pickFreePort(): Promise<number>;
29
+ export declare function startAppiumServer(options?: StartAppiumServerOptions): Promise<AppiumServerHandle>;
30
+ export {};
@@ -0,0 +1,110 @@
1
+ import { existsSync } from "node:fs";
2
+ import { createServer } from "node:net";
3
+ import { homedir, platform } from "node:os";
4
+ import { join } from "node:path";
5
+ import { getAximoDir } from "../commands/device/paths.js";
6
+ import { importAppium } from "./appium-import.js";
7
+ export function getAppiumHome() {
8
+ return join(getAximoDir(), "appium-home");
9
+ }
10
+ function isValidSdk(dir, exists) {
11
+ return exists(dir) && exists(join(dir, "platform-tools"));
12
+ }
13
+ /**
14
+ * Resolve the Android SDK location. Honours ANDROID_HOME / ANDROID_SDK_ROOT when
15
+ * set and valid, else probes the standard per-OS install location. Appium's
16
+ * UiAutomator2 driver requires this even when `adb` is already on PATH.
17
+ */
18
+ export function resolveAndroidSdk(deps = {}) {
19
+ const env = deps.env ?? process.env;
20
+ const home = deps.home ?? homedir();
21
+ const os = deps.platform ?? platform();
22
+ const exists = deps.exists ?? existsSync;
23
+ for (const candidate of [env["ANDROID_HOME"], env["ANDROID_SDK_ROOT"]]) {
24
+ if (candidate && isValidSdk(candidate, exists))
25
+ return candidate;
26
+ }
27
+ const standard = os === "darwin"
28
+ ? join(home, "Library", "Android", "sdk")
29
+ : os === "win32"
30
+ ? join(env["LOCALAPPDATA"] ?? join(home, "AppData", "Local"), "Android", "Sdk")
31
+ : join(home, "Android", "Sdk");
32
+ return isValidSdk(standard, exists) ? standard : null;
33
+ }
34
+ export async function pickFreePort() {
35
+ return new Promise((resolve, reject) => {
36
+ const srv = createServer();
37
+ srv.on("error", reject);
38
+ srv.listen(0, "127.0.0.1", () => {
39
+ const address = srv.address();
40
+ if (address && typeof address === "object") {
41
+ const { port } = address;
42
+ srv.close(() => resolve(port));
43
+ }
44
+ else {
45
+ srv.close(() => reject(new Error("Could not determine a free port")));
46
+ }
47
+ });
48
+ });
49
+ }
50
+ const defaultLaunch = async (args) => {
51
+ const mod = await importAppium();
52
+ return mod.main(args);
53
+ };
54
+ async function pollReady(endpoint, timeoutMs) {
55
+ const deadline = Date.now() + timeoutMs;
56
+ let lastError;
57
+ while (Date.now() < deadline) {
58
+ try {
59
+ const res = await fetch(`${endpoint}/status`);
60
+ if (res.ok)
61
+ return;
62
+ }
63
+ catch (error) {
64
+ lastError = error;
65
+ }
66
+ await new Promise((resolve) => setTimeout(resolve, 250));
67
+ }
68
+ const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
69
+ throw new Error(`Appium server did not become ready within ${timeoutMs}ms${detail}`);
70
+ }
71
+ export async function startAppiumServer(options = {}) {
72
+ const launch = options.launch ?? defaultLaunch;
73
+ const appiumHome = options.appiumHome ?? getAppiumHome();
74
+ const useDrivers = options.useDrivers ?? ["uiautomator2"];
75
+ const readyTimeoutMs = options.readyTimeoutMs ?? 30_000;
76
+ // Appium's UiAutomator2 driver reads ANDROID_HOME/ANDROID_SDK_ROOT from the
77
+ // environment; it runs in-process via main(), so we export a resolved SDK
78
+ // path here when neither is set, otherwise sessions fail to start.
79
+ if (!process.env["ANDROID_HOME"] && !process.env["ANDROID_SDK_ROOT"]) {
80
+ const sdk = resolveAndroidSdk();
81
+ if (sdk) {
82
+ process.env["ANDROID_HOME"] = sdk;
83
+ process.env["ANDROID_SDK_ROOT"] = sdk;
84
+ console.error(`Using auto-detected Android SDK at ${sdk} (set ANDROID_HOME to override).`);
85
+ }
86
+ }
87
+ const port = await pickFreePort();
88
+ const endpoint = `http://127.0.0.1:${port}`;
89
+ const server = await launch({
90
+ port,
91
+ basePath: "/",
92
+ host: "127.0.0.1",
93
+ appiumHome,
94
+ useDrivers,
95
+ loglevel: "warn",
96
+ });
97
+ try {
98
+ await pollReady(endpoint, readyTimeoutMs);
99
+ }
100
+ catch (error) {
101
+ await server.close().catch(() => { });
102
+ throw error;
103
+ }
104
+ return {
105
+ endpoint,
106
+ stop: async () => {
107
+ await server.close();
108
+ },
109
+ };
110
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import { join } from "node:path";
2
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
3
+ import { pickFreePort, resolveAndroidSdk, startAppiumServer, } from "./appium-server.js";
4
+ const realFetch = globalThis.fetch;
5
+ beforeAll(() => {
6
+ process.env["AXIMO_HOME"] = "/tmp/aximo-appium-test";
7
+ });
8
+ afterEach(() => {
9
+ globalThis.fetch = realFetch;
10
+ vi.restoreAllMocks();
11
+ });
12
+ describe("pickFreePort", () => {
13
+ it("returns a usable, positive port", async () => {
14
+ const port = await pickFreePort();
15
+ expect(port).toBeGreaterThan(0);
16
+ expect(port).toBeLessThan(65_536);
17
+ });
18
+ });
19
+ describe("startAppiumServer", () => {
20
+ it("launches on a free port, polls /status, and stops", async () => {
21
+ let statusHits = 0;
22
+ globalThis.fetch = vi.fn(async (input) => {
23
+ const url = String(input);
24
+ expect(url).toContain("/status");
25
+ statusHits++;
26
+ return new Response("{}", { status: statusHits >= 2 ? 200 : 503 });
27
+ });
28
+ const close = vi.fn(async () => { });
29
+ const launchArgs = [];
30
+ const launch = async (args) => {
31
+ launchArgs.push(args);
32
+ return { close };
33
+ };
34
+ const handle = await startAppiumServer({ launch, readyTimeoutMs: 5000 });
35
+ expect(handle.endpoint).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
36
+ const args = launchArgs[0];
37
+ expect(args["host"]).toBe("127.0.0.1");
38
+ expect(args["basePath"]).toBe("/");
39
+ expect(typeof args["port"]).toBe("number");
40
+ expect(args["useDrivers"]).toEqual(["uiautomator2"]);
41
+ expect(statusHits).toBeGreaterThanOrEqual(2);
42
+ await handle.stop();
43
+ expect(close).toHaveBeenCalledOnce();
44
+ });
45
+ it("closes the server and throws when readiness times out", async () => {
46
+ globalThis.fetch = vi.fn(async () => new Response("nope", { status: 503 }));
47
+ const close = vi.fn(async () => { });
48
+ const launch = async () => ({ close });
49
+ await expect(startAppiumServer({ launch, readyTimeoutMs: 300 })).rejects.toThrow(/did not become ready/);
50
+ expect(close).toHaveBeenCalledOnce();
51
+ });
52
+ });
53
+ describe("resolveAndroidSdk", () => {
54
+ const exists = (valid) => (path) => path === valid || path === join(valid, "platform-tools");
55
+ it("honours a valid ANDROID_HOME", () => {
56
+ const result = resolveAndroidSdk({
57
+ env: { ANDROID_HOME: "/opt/sdk" },
58
+ exists: exists("/opt/sdk"),
59
+ });
60
+ expect(result).toBe("/opt/sdk");
61
+ });
62
+ it("falls back to ANDROID_SDK_ROOT when ANDROID_HOME is invalid", () => {
63
+ const result = resolveAndroidSdk({
64
+ env: { ANDROID_HOME: "/bad", ANDROID_SDK_ROOT: "/opt/root" },
65
+ exists: exists("/opt/root"),
66
+ });
67
+ expect(result).toBe("/opt/root");
68
+ });
69
+ it("probes the standard macOS location when env is unset", () => {
70
+ const expected = join("/Users/dev", "Library", "Android", "sdk");
71
+ const result = resolveAndroidSdk({
72
+ env: {},
73
+ home: "/Users/dev",
74
+ platform: "darwin",
75
+ exists: exists(expected),
76
+ });
77
+ expect(result).toBe(expected);
78
+ });
79
+ it("probes the standard Linux location when env is unset", () => {
80
+ const expected = join("/home/dev", "Android", "Sdk");
81
+ const result = resolveAndroidSdk({
82
+ env: {},
83
+ home: "/home/dev",
84
+ platform: "linux",
85
+ exists: exists(expected),
86
+ });
87
+ expect(result).toBe(expected);
88
+ });
89
+ it("probes LOCALAPPDATA on Windows when env is unset", () => {
90
+ const expected = join("C:\\Users\\dev\\AppData\\Local", "Android", "Sdk");
91
+ const result = resolveAndroidSdk({
92
+ env: { LOCALAPPDATA: "C:\\Users\\dev\\AppData\\Local" },
93
+ home: "C:\\Users\\dev",
94
+ platform: "win32",
95
+ exists: exists(expected),
96
+ });
97
+ expect(result).toBe(expected);
98
+ });
99
+ it("returns null when no SDK is found", () => {
100
+ const result = resolveAndroidSdk({
101
+ env: {},
102
+ home: "/home/dev",
103
+ platform: "linux",
104
+ exists: () => false,
105
+ });
106
+ expect(result).toBeNull();
107
+ });
108
+ });
@@ -0,0 +1,22 @@
1
+ export interface ActionResult {
2
+ ok: boolean;
3
+ error?: string;
4
+ }
5
+ export interface MobileController {
6
+ screenshot(): Promise<Buffer>;
7
+ tap(x: number, y: number): Promise<void>;
8
+ doubleTap(x: number, y: number): Promise<void>;
9
+ longPress(x: number, y: number, durationMs: number): Promise<void>;
10
+ swipe(x1: number, y1: number, x2: number, y2: number, durationMs: number): Promise<void>;
11
+ typeText(text: string): Promise<ActionResult>;
12
+ pressKey(key: string): Promise<ActionResult>;
13
+ launchApp(app: string): Promise<ActionResult>;
14
+ findElement(text: string): Promise<ActionResult>;
15
+ clearText(): Promise<ActionResult>;
16
+ hideKeyboard(): Promise<ActionResult>;
17
+ getDeviceTime?(): Promise<{
18
+ datetime: string;
19
+ timezone: string;
20
+ } | null>;
21
+ close(): Promise<void>;
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import { type ExecFn } from "./exec.js";
2
+ export interface AndroidDevice {
3
+ serial: string;
4
+ state: string;
5
+ model?: string;
6
+ }
7
+ /** Parse `adb devices -l` into serial + state (+ model when present). */
8
+ export declare function listAndroidDevices(exec?: ExecFn): Promise<AndroidDevice[]>;
9
+ /** Resolve an adb serial: explicit (validated), else the single connected phone. */
10
+ export declare function resolveAndroidSerial(preferred: string | undefined, exec?: ExecFn): Promise<string>;
11
+ export interface IosPhysicalDevice {
12
+ udid: string;
13
+ name: string;
14
+ state?: string;
15
+ }
16
+ /**
17
+ * List physically-connected iOS devices via `idb list-targets --json` (one JSON
18
+ * object per line). Only entries whose `type` is `device` are returned. Returns
19
+ * an empty list if idb is unavailable.
20
+ */
21
+ export declare function listIosPhysicalDevices(exec?: ExecFn): Promise<IosPhysicalDevice[]>;