@autifyhq/aximo-cli 0.4.0 → 0.6.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 (96) hide show
  1. package/README.md +219 -38
  2. package/dist/cli.js +5 -0
  3. package/dist/client.d.ts +2 -0
  4. package/dist/client.js +18 -0
  5. package/dist/commands/device/device-config.d.ts +14 -0
  6. package/dist/commands/device/device-config.js +46 -0
  7. package/dist/commands/device/device-config.test.d.ts +1 -0
  8. package/dist/commands/device/device-config.test.js +56 -0
  9. package/dist/commands/device/doctor.d.ts +13 -0
  10. package/dist/commands/device/doctor.js +142 -25
  11. package/dist/commands/device/doctor.test.js +61 -1
  12. package/dist/commands/device/register.d.ts +2 -0
  13. package/dist/commands/device/register.js +50 -32
  14. package/dist/commands/device/register.test.d.ts +1 -0
  15. package/dist/commands/device/register.test.js +95 -0
  16. package/dist/commands/device/unregister.d.ts +5 -3
  17. package/dist/commands/device/unregister.js +32 -33
  18. package/dist/commands/device/unregister.test.d.ts +1 -0
  19. package/dist/commands/device/unregister.test.js +73 -0
  20. package/dist/commands/mobile/connect-core.d.ts +12 -0
  21. package/dist/commands/mobile/connect-core.js +88 -0
  22. package/dist/commands/mobile/connect.d.ts +9 -0
  23. package/dist/commands/mobile/connect.js +35 -0
  24. package/dist/commands/mobile/connect.test.d.ts +1 -0
  25. package/dist/commands/mobile/connect.test.js +87 -0
  26. package/dist/commands/mobile/doctor.d.ts +28 -0
  27. package/dist/commands/mobile/doctor.js +162 -0
  28. package/dist/commands/mobile/index.d.ts +2 -0
  29. package/dist/commands/mobile/index.js +16 -0
  30. package/dist/commands/mobile/install.d.ts +43 -0
  31. package/dist/commands/mobile/install.js +76 -0
  32. package/dist/commands/mobile/install.test.d.ts +1 -0
  33. package/dist/commands/mobile/install.test.js +104 -0
  34. package/dist/commands/mobile/list.d.ts +10 -0
  35. package/dist/commands/mobile/list.js +30 -0
  36. package/dist/commands/mobile/paths.d.ts +14 -0
  37. package/dist/commands/mobile/paths.js +71 -0
  38. package/dist/commands/mobile/register-core.d.ts +19 -0
  39. package/dist/commands/mobile/register-core.js +46 -0
  40. package/dist/commands/mobile/register.d.ts +37 -0
  41. package/dist/commands/mobile/register.js +57 -0
  42. package/dist/commands/mobile/register.test.d.ts +1 -0
  43. package/dist/commands/mobile/register.test.js +91 -0
  44. package/dist/commands/mobile/setup.d.ts +27 -0
  45. package/dist/commands/mobile/setup.js +100 -0
  46. package/dist/commands/mobile/setup.test.d.ts +1 -0
  47. package/dist/commands/mobile/setup.test.js +49 -0
  48. package/dist/commands/mobile/unregister.d.ts +14 -0
  49. package/dist/commands/mobile/unregister.js +37 -0
  50. package/dist/commands/schema.js +29 -0
  51. package/dist/commands/session/create.d.ts +5 -0
  52. package/dist/commands/session/create.js +6 -0
  53. package/dist/index.d.ts +3 -0
  54. package/dist/index.js +3 -0
  55. package/dist/mobile-control/app-install.d.ts +21 -0
  56. package/dist/mobile-control/app-install.js +28 -0
  57. package/dist/mobile-control/app-install.test.d.ts +1 -0
  58. package/dist/mobile-control/app-install.test.js +78 -0
  59. package/dist/mobile-control/appium-controller.d.ts +42 -0
  60. package/dist/mobile-control/appium-controller.js +372 -0
  61. package/dist/mobile-control/appium-controller.test.d.ts +1 -0
  62. package/dist/mobile-control/appium-controller.test.js +252 -0
  63. package/dist/mobile-control/appium-import.d.ts +7 -0
  64. package/dist/mobile-control/appium-import.js +6 -0
  65. package/dist/mobile-control/appium-import.test.d.ts +1 -0
  66. package/dist/mobile-control/appium-import.test.js +9 -0
  67. package/dist/mobile-control/appium-server.d.ts +30 -0
  68. package/dist/mobile-control/appium-server.js +110 -0
  69. package/dist/mobile-control/appium-server.test.d.ts +1 -0
  70. package/dist/mobile-control/appium-server.test.js +108 -0
  71. package/dist/mobile-control/controller.d.ts +22 -0
  72. package/dist/mobile-control/controller.js +1 -0
  73. package/dist/mobile-control/devices.d.ts +23 -0
  74. package/dist/mobile-control/devices.js +129 -0
  75. package/dist/mobile-control/devices.test.d.ts +1 -0
  76. package/dist/mobile-control/devices.test.js +75 -0
  77. package/dist/mobile-control/exec.d.ts +6 -0
  78. package/dist/mobile-control/exec.js +13 -0
  79. package/dist/mobile-control/ios-signing.d.ts +28 -0
  80. package/dist/mobile-control/ios-signing.js +38 -0
  81. package/dist/mobile-control/ios-signing.test.d.ts +1 -0
  82. package/dist/mobile-control/ios-signing.test.js +73 -0
  83. package/dist/mobile-control/tunnel.d.ts +15 -0
  84. package/dist/mobile-control/tunnel.js +165 -0
  85. package/dist/mobile-control/tunnel.test.d.ts +1 -0
  86. package/dist/mobile-control/tunnel.test.js +155 -0
  87. package/dist/mobile-control/webdriver.d.ts +31 -0
  88. package/dist/mobile-control/webdriver.js +137 -0
  89. package/dist/mobile-control/webdriver.test.d.ts +1 -0
  90. package/dist/mobile-control/webdriver.test.js +45 -0
  91. package/dist/proxy.d.ts +11 -0
  92. package/dist/proxy.js +85 -0
  93. package/dist/proxy.test.d.ts +1 -0
  94. package/dist/proxy.test.js +72 -0
  95. package/dist/types.d.ts +1 -0
  96. package/package.json +5 -2
@@ -0,0 +1,372 @@
1
+ import { iosSigningCapabilities } from "./ios-signing.js";
2
+ import { fetchTransport, WebDriverSession } from "./webdriver.js";
3
+ const ANDROID_KEY_CODES = {
4
+ enter: 66,
5
+ delete: 67,
6
+ tab: 61,
7
+ space: 62,
8
+ escape: 111,
9
+ back: 4,
10
+ home: 3,
11
+ };
12
+ const ANDROID_KEYCODE_A = 29;
13
+ const ANDROID_META_CTRL_ON = 4096;
14
+ const IOS_BUTTONS = {
15
+ home: "home",
16
+ };
17
+ const W3C_KEYS = {
18
+ enter: "\uE007",
19
+ delete: "\uE003",
20
+ tab: "\uE004",
21
+ escape: "\uE00C",
22
+ space: " ",
23
+ };
24
+ export class AppiumController {
25
+ session;
26
+ platform;
27
+ target;
28
+ iosSigning;
29
+ sessionReady = null;
30
+ scaling = null;
31
+ constructor(opts) {
32
+ this.platform = opts.platform;
33
+ this.target = opts.target;
34
+ this.iosSigning = opts.iosSigning;
35
+ const transport = opts.transport ?? fetchTransport(opts.endpoint);
36
+ this.session = new WebDriverSession(transport);
37
+ }
38
+ capabilities() {
39
+ if (this.platform === "android") {
40
+ return {
41
+ platformName: "Android",
42
+ "appium:automationName": "UiAutomator2",
43
+ "appium:udid": this.target,
44
+ "appium:noReset": true,
45
+ "appium:autoLaunch": false,
46
+ "appium:newCommandTimeout": 0,
47
+ };
48
+ }
49
+ return {
50
+ platformName: "iOS",
51
+ "appium:automationName": "XCUITest",
52
+ "appium:udid": this.target,
53
+ "appium:newCommandTimeout": 0,
54
+ // WDA code-signing caps for physical iPhones (empty unless a team id is set).
55
+ ...(this.iosSigning ? iosSigningCapabilities(this.iosSigning) : {}),
56
+ };
57
+ }
58
+ ensureSession() {
59
+ if (!this.sessionReady) {
60
+ this.sessionReady = this.session
61
+ .createSession(this.capabilities())
62
+ .then(() => undefined)
63
+ .catch((err) => {
64
+ this.sessionReady = null;
65
+ throw err;
66
+ });
67
+ }
68
+ return this.sessionReady;
69
+ }
70
+ // Incoming pixel coords scale to logical points: iOS divides by DPR, Android ~= 1.
71
+ async scale() {
72
+ if (this.scaling)
73
+ return this.scaling.scale;
74
+ const [rect, shot] = await Promise.all([this.session.windowRect(), this.session.screenshot()]);
75
+ const pixelWidth = readPngWidth(shot);
76
+ const scale = rect.width > 0 && pixelWidth > 0 ? pixelWidth / rect.width : 1;
77
+ this.scaling = { scale: scale > 0 ? scale : 1 };
78
+ return this.scaling.scale;
79
+ }
80
+ async toPoint(x, y) {
81
+ const s = await this.scale();
82
+ return [Math.round(x / s), Math.round(y / s)];
83
+ }
84
+ pointerAction(actions) {
85
+ return [
86
+ {
87
+ type: "pointer",
88
+ id: "finger1",
89
+ parameters: { pointerType: "touch" },
90
+ actions,
91
+ },
92
+ ];
93
+ }
94
+ async screenshot() {
95
+ await this.ensureSession();
96
+ return this.session.screenshot();
97
+ }
98
+ async tap(x, y) {
99
+ await this.ensureSession();
100
+ const [px, py] = await this.toPoint(x, y);
101
+ await this.performPointer([
102
+ { type: "pointerMove", duration: 0, x: px, y: py },
103
+ { type: "pointerDown", button: 0 },
104
+ { type: "pause", duration: 50 },
105
+ { type: "pointerUp", button: 0 },
106
+ ]);
107
+ }
108
+ async doubleTap(x, y) {
109
+ await this.ensureSession();
110
+ const [px, py] = await this.toPoint(x, y);
111
+ await this.performPointer([
112
+ { type: "pointerMove", duration: 0, x: px, y: py },
113
+ { type: "pointerDown", button: 0 },
114
+ { type: "pause", duration: 50 },
115
+ { type: "pointerUp", button: 0 },
116
+ { type: "pause", duration: 50 },
117
+ { type: "pointerDown", button: 0 },
118
+ { type: "pause", duration: 50 },
119
+ { type: "pointerUp", button: 0 },
120
+ ]);
121
+ }
122
+ async longPress(x, y, durationMs) {
123
+ await this.ensureSession();
124
+ const [px, py] = await this.toPoint(x, y);
125
+ await this.performPointer([
126
+ { type: "pointerMove", duration: 0, x: px, y: py },
127
+ { type: "pointerDown", button: 0 },
128
+ { type: "pause", duration: Math.max(0, Math.round(durationMs)) },
129
+ { type: "pointerUp", button: 0 },
130
+ ]);
131
+ }
132
+ async swipe(x1, y1, x2, y2, durationMs) {
133
+ await this.ensureSession();
134
+ const [px1, py1] = await this.toPoint(x1, y1);
135
+ const [px2, py2] = await this.toPoint(x2, y2);
136
+ await this.performPointer([
137
+ { type: "pointerMove", duration: 0, x: px1, y: py1 },
138
+ { type: "pointerDown", button: 0 },
139
+ { type: "pointerMove", duration: Math.max(0, Math.round(durationMs)), x: px2, y: py2 },
140
+ { type: "pointerUp", button: 0 },
141
+ ]);
142
+ }
143
+ async performPointer(actions) {
144
+ await this.session.performActions(this.pointerAction(actions));
145
+ await this.session.releaseActions();
146
+ }
147
+ async typeText(text) {
148
+ await this.ensureSession();
149
+ try {
150
+ if (this.platform === "android") {
151
+ await this.session.execute("mobile: type", [{ text }]);
152
+ return { ok: true };
153
+ }
154
+ await this.session.setValueOnActiveElement(text);
155
+ return { ok: true };
156
+ }
157
+ catch (error) {
158
+ return fail(error);
159
+ }
160
+ }
161
+ async clearText() {
162
+ await this.ensureSession();
163
+ try {
164
+ const active = await this.session.getActiveElement();
165
+ if (active) {
166
+ await this.session.elementClear(active);
167
+ return { ok: true };
168
+ }
169
+ }
170
+ catch { }
171
+ if (this.platform === "android") {
172
+ try {
173
+ await this.session.execute("mobile: pressKey", [
174
+ { keycode: ANDROID_KEYCODE_A, metastate: ANDROID_META_CTRL_ON },
175
+ ]);
176
+ await this.session.execute("mobile: pressKey", [{ keycode: ANDROID_KEY_CODES["delete"] }]);
177
+ return { ok: true };
178
+ }
179
+ catch (error) {
180
+ return fail(error);
181
+ }
182
+ }
183
+ return {
184
+ ok: false,
185
+ error: "Could not clear the field: no focused input element was found. " +
186
+ "Tap the field to focus it before calling clear_text.",
187
+ };
188
+ }
189
+ async pressKey(key) {
190
+ await this.ensureSession();
191
+ const normalized = key.toLowerCase().trim();
192
+ try {
193
+ if (this.platform === "android") {
194
+ const code = ANDROID_KEY_CODES[normalized];
195
+ if (code === undefined)
196
+ return { ok: false, error: `Unsupported key: ${key}` };
197
+ await this.session.execute("mobile: pressKey", [{ keycode: code }]);
198
+ return { ok: true };
199
+ }
200
+ if (normalized === "back") {
201
+ return {
202
+ ok: false,
203
+ error: '"back" key is Android-only. On iOS, tap the back button in the UI instead.',
204
+ };
205
+ }
206
+ const button = IOS_BUTTONS[normalized];
207
+ if (button) {
208
+ await this.session.execute("mobile: pressButton", [{ name: button }]);
209
+ return { ok: true };
210
+ }
211
+ const w3c = W3C_KEYS[normalized];
212
+ if (w3c) {
213
+ await this.session.performActions([
214
+ {
215
+ type: "key",
216
+ id: "keyboard",
217
+ actions: [
218
+ { type: "keyDown", value: w3c },
219
+ { type: "keyUp", value: w3c },
220
+ ],
221
+ },
222
+ ]);
223
+ await this.session.releaseActions();
224
+ return { ok: true };
225
+ }
226
+ return { ok: false, error: `Unsupported key: ${key}` };
227
+ }
228
+ catch (error) {
229
+ return fail(error);
230
+ }
231
+ }
232
+ async launchApp(app) {
233
+ if (!app)
234
+ return { ok: false, error: "launch_app requires an app id" };
235
+ await this.ensureSession();
236
+ try {
237
+ const param = this.platform === "android" ? { appId: app } : { bundleId: app };
238
+ await this.session.execute("mobile: activateApp", [param]);
239
+ return { ok: true };
240
+ }
241
+ catch (error) {
242
+ return fail(error);
243
+ }
244
+ }
245
+ async findElement(text) {
246
+ if (!text)
247
+ return { ok: false, error: "find_element requires text" };
248
+ await this.ensureSession();
249
+ try {
250
+ const ref = await this.locate(text);
251
+ if (!ref)
252
+ return { ok: false, error: `Element with text "${text}" not found` };
253
+ await this.session.elementClick(ref);
254
+ return { ok: true };
255
+ }
256
+ catch (error) {
257
+ return fail(error);
258
+ }
259
+ }
260
+ async locate(text) {
261
+ const strategies = this.platform === "android" ? androidStrategies(text) : iosStrategies(text);
262
+ for (const [using, value] of strategies) {
263
+ try {
264
+ const ref = await this.session.findElement(using, value);
265
+ if (ref)
266
+ return ref;
267
+ }
268
+ catch { }
269
+ }
270
+ return null;
271
+ }
272
+ async hideKeyboard() {
273
+ await this.ensureSession();
274
+ try {
275
+ await this.session.execute("mobile: hideKeyboard", []);
276
+ return { ok: true };
277
+ }
278
+ catch {
279
+ return { ok: true };
280
+ }
281
+ }
282
+ async getDeviceTime() {
283
+ await this.ensureSession();
284
+ try {
285
+ const raw = await this.session.execute("mobile: getDeviceTime", []);
286
+ if (typeof raw !== "string" || raw.trim().length === 0)
287
+ return null;
288
+ const parsed = new Date(raw);
289
+ if (Number.isNaN(parsed.getTime()))
290
+ return null;
291
+ return {
292
+ datetime: humanizeDateTime(parsed),
293
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
294
+ };
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ }
300
+ async close() {
301
+ try {
302
+ await this.session.deleteSession();
303
+ }
304
+ catch { }
305
+ }
306
+ }
307
+ // Builds an XPath string literal that is safe even when `s` contains single quotes.
308
+ function xpathLiteral(s) {
309
+ if (!s.includes("'"))
310
+ return `'${s}'`;
311
+ const parts = s.split("'").map((part) => `'${part}'`);
312
+ return `concat(${parts.join(`, "'", `)})`;
313
+ }
314
+ // Escapes backslashes and double quotes for a Java UiSelector string argument.
315
+ function uiSelectorLiteral(s) {
316
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
317
+ }
318
+ // Escapes single quotes for an iOS NSPredicate string literal.
319
+ function iosPredicateLiteral(s) {
320
+ return s.replace(/'/g, "\\'");
321
+ }
322
+ function androidStrategies(text) {
323
+ const lower = text.toLowerCase();
324
+ return [
325
+ ["accessibility id", text],
326
+ ["id", text],
327
+ ["xpath", `//*[@text=${xpathLiteral(text)}]`],
328
+ [
329
+ "xpath",
330
+ `//*[contains(translate(@text, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), ${xpathLiteral(lower)})]`,
331
+ ],
332
+ ["xpath", `//*[@content-desc=${xpathLiteral(text)}]`],
333
+ ["xpath", `//*[contains(@content-desc, ${xpathLiteral(text)})]`],
334
+ ["-android uiautomator", `new UiSelector().textContains("${uiSelectorLiteral(text)}")`],
335
+ ];
336
+ }
337
+ function iosStrategies(text) {
338
+ const v = iosPredicateLiteral(text);
339
+ return [
340
+ ["-ios predicate string", `type == 'XCUIElementTypeButton' AND label == '${v}'`],
341
+ ["-ios predicate string", `type == 'XCUIElementTypeButton' AND name == '${v}'`],
342
+ ["accessibility id", text],
343
+ ["name", text],
344
+ ["-ios predicate string", `label == '${v}'`],
345
+ ["-ios predicate string", `name == '${v}'`],
346
+ ["-ios predicate string", `label CONTAINS '${v}'`],
347
+ ];
348
+ }
349
+ function humanizeDateTime(date) {
350
+ return date.toLocaleString("en-US", {
351
+ weekday: "long",
352
+ year: "numeric",
353
+ month: "long",
354
+ day: "numeric",
355
+ hour: "numeric",
356
+ minute: "2-digit",
357
+ hour12: true,
358
+ });
359
+ }
360
+ function fail(error) {
361
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
362
+ }
363
+ function readPngWidth(buffer) {
364
+ if (buffer.length < 24)
365
+ return 0;
366
+ try {
367
+ return buffer.readUInt32BE(16);
368
+ }
369
+ catch {
370
+ return 0;
371
+ }
372
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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 {};