@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,49 @@
1
+ import { mkdtempSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { runMobileSetup } from "./setup.js";
6
+ function fakeDoctor(checks) {
7
+ return async () => ({
8
+ ok: true,
9
+ data: { checks, summary: { pass: 0, warn: 0, fail: 0, info: 0 } },
10
+ });
11
+ }
12
+ describe("runMobileSetup", () => {
13
+ it("installs both drivers and runs the prerequisite check", async () => {
14
+ const appiumHome = mkdtempSync(join(tmpdir(), "aximo-setup-"));
15
+ const installed = [];
16
+ let doctorCalled = false;
17
+ const res = await runMobileSetup({
18
+ appiumHome,
19
+ install: async (driver) => {
20
+ installed.push(driver);
21
+ },
22
+ doctor: async () => {
23
+ doctorCalled = true;
24
+ return fakeDoctor([])();
25
+ },
26
+ });
27
+ expect(installed).toEqual(["uiautomator2", "xcuitest"]);
28
+ expect(doctorCalled).toBe(true);
29
+ expect(res.ok).toBe(true);
30
+ if (res.ok) {
31
+ expect(res.data.drivers.map((d) => d.driver)).toEqual(["uiautomator2", "xcuitest"]);
32
+ // checks are surfaced as guidance, not embedded in setup's JSON result
33
+ expect(res.data).not.toHaveProperty("checks");
34
+ }
35
+ });
36
+ it("limits to one driver with a platform filter", async () => {
37
+ const appiumHome = mkdtempSync(join(tmpdir(), "aximo-setup-"));
38
+ const installed = [];
39
+ await runMobileSetup({
40
+ appiumHome,
41
+ platform: "android",
42
+ install: async (driver) => {
43
+ installed.push(driver);
44
+ },
45
+ doctor: fakeDoctor([]),
46
+ });
47
+ expect(installed).toEqual(["uiautomator2"]);
48
+ });
49
+ });
@@ -0,0 +1,14 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import type { Envelope } from "../../types.js";
3
+ export declare function runMobileUnregister(options: {
4
+ deviceId?: string;
5
+ }, deps?: ClientDeps): Promise<Envelope<{
6
+ success: boolean;
7
+ }>>;
8
+ declare const _default: import("citty").CommandDef<{
9
+ readonly "device-id": {
10
+ readonly type: "string";
11
+ readonly description: "Device ID (defaults to the sole registered local device)";
12
+ };
13
+ }>;
14
+ export default _default;
@@ -0,0 +1,37 @@
1
+ import { defineCommand } from "citty";
2
+ import { existsSync, unlinkSync } from "node:fs";
3
+ import { request } from "../../client.js";
4
+ import { handleApiError } from "../../errors.js";
5
+ import { formatSuccess, printResult } from "../../output.js";
6
+ import { resolveDeviceConfig } from "./connect-core.js";
7
+ import { getMobileDeviceConfigPath } from "./paths.js";
8
+ export async function runMobileUnregister(options, deps) {
9
+ const resolved = resolveDeviceConfig(options.deviceId);
10
+ if (!resolved.ok)
11
+ return resolved;
12
+ const { deviceId } = resolved.config;
13
+ try {
14
+ await request(`/api/devices/user/${deviceId}`, { method: "DELETE" }, deps ?? {});
15
+ const configPath = getMobileDeviceConfigPath(deviceId);
16
+ if (existsSync(configPath)) {
17
+ unlinkSync(configPath);
18
+ }
19
+ return formatSuccess({ success: true });
20
+ }
21
+ catch (err) {
22
+ return handleApiError(err);
23
+ }
24
+ }
25
+ export default defineCommand({
26
+ meta: { name: "unregister", description: "Unregister a local mobile device" },
27
+ args: {
28
+ "device-id": {
29
+ type: "string",
30
+ description: "Device ID (defaults to the sole registered local device)",
31
+ },
32
+ },
33
+ async run({ args }) {
34
+ const deviceId = args["device-id"];
35
+ printResult(await runMobileUnregister(deviceId ? { deviceId } : {}));
36
+ },
37
+ });
@@ -36,6 +36,7 @@ export function getSchema() {
36
36
  arg("upload-type", "string", "ANDROID_APP | IOS_APP"),
37
37
  arg("app-name", "string", ""),
38
38
  arg("bundle-id", "string", ""),
39
+ arg("user-device-id", "string", "Run on a registered local device"),
39
40
  ...MUTATE_ARGS,
40
41
  ]),
41
42
  cmd("get", "GET /api/sessions/{sessionId}", [
@@ -119,6 +120,27 @@ export function getSchema() {
119
120
  arg("available-only", "boolean", ""),
120
121
  ]),
121
122
  ]),
123
+ cmd("mobile", "Register and connect a physically-connected Android phone (via adb)", [], [
124
+ cmd("list", "List physically-connected phones (adb)"),
125
+ cmd("register", "Register a physically-connected Android phone as an Aximo device", [
126
+ arg("name", "string", "Device name", true),
127
+ arg("platform", "string", "Device platform (only `android` is supported today)"),
128
+ arg("serial", "string", "Android adb serial (defaults to the connected one)"),
129
+ arg("os", "string", "Optional OS version label"),
130
+ ]),
131
+ cmd("unregister", "Unregister a local mobile device", [
132
+ arg("device-id", "string", "Device ID (defaults to the sole registered local device)"),
133
+ ]),
134
+ cmd("connect", "Connect a registered Android phone via a CLI-managed Appium server", [
135
+ arg("device-id", "string", "Registered device id (required if multiple)"),
136
+ ]),
137
+ cmd("setup", "Install the Appium drivers used to drive registered phones", [
138
+ arg("platform", "string", "Limit checks to android"),
139
+ ]),
140
+ cmd("doctor", "Diagnose physical-device tooling (adb authorization, Appium setup)", [
141
+ arg("platform", "string", "Limit checks to android"),
142
+ ]),
143
+ ]),
122
144
  cmd("upload", "Upload commands", [], [
123
145
  cmd("create", "POST /api/uploads", [
124
146
  arg("file-name", "string", "File name"),
@@ -16,6 +16,7 @@ export interface CreateSessionOptions {
16
16
  uploadType?: unknown;
17
17
  appName?: unknown;
18
18
  bundleId?: unknown;
19
+ userDeviceId?: unknown;
19
20
  json?: unknown;
20
21
  dryRun?: unknown;
21
22
  }
@@ -66,6 +67,10 @@ declare const _default: import("citty").CommandDef<{
66
67
  readonly "bundle-id": {
67
68
  readonly type: "string";
68
69
  };
70
+ readonly "user-device-id": {
71
+ readonly type: "string";
72
+ readonly description: "Run on a registered local device (BYOD desktop or local mobile)";
73
+ };
69
74
  readonly json: {
70
75
  readonly type: "string";
71
76
  readonly description: "Raw JSON request body (replaces individual flags)";
@@ -29,6 +29,7 @@ export async function runSessionCreate(options, deps) {
29
29
  uploadType: parseFileType(options.uploadType),
30
30
  appName: optionalString("appName", options.appName),
31
31
  bundleId: optionalString("bundleId", options.bundleId),
32
+ userDeviceId: optionalString("userDeviceId", options.userDeviceId),
32
33
  };
33
34
  }
34
35
  catch (err) {
@@ -63,6 +64,10 @@ export default defineCommand({
63
64
  "upload-type": { type: "string", description: "ANDROID_APP | IOS_APP" },
64
65
  "app-name": { type: "string" },
65
66
  "bundle-id": { type: "string" },
67
+ "user-device-id": {
68
+ type: "string",
69
+ description: "Run on a registered local device (BYOD desktop or local mobile)",
70
+ },
66
71
  json: { type: "string", description: "Raw JSON request body (replaces individual flags)" },
67
72
  "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
68
73
  },
@@ -82,6 +87,7 @@ export default defineCommand({
82
87
  uploadType: args["upload-type"],
83
88
  appName: args["app-name"],
84
89
  bundleId: args["bundle-id"],
90
+ userDeviceId: args["user-device-id"],
85
91
  json: args.json,
86
92
  dryRun: args["dry-run"],
87
93
  }));
package/dist/index.d.ts CHANGED
@@ -3,6 +3,9 @@ export { getApiKey, getBaseUrl, request } from "./client.js";
3
3
  export { runCaseCreate } from "./commands/case/create.js";
4
4
  export { runDeviceList } from "./commands/device/list.js";
5
5
  export { runHealthCheck } from "./commands/health/check.js";
6
+ export { runMobileList } from "./commands/mobile/list.js";
7
+ export { runMobileRegister } from "./commands/mobile/register.js";
8
+ export { runMobileDoctor } from "./commands/mobile/doctor.js";
6
9
  export { runPlanAddCase } from "./commands/plan/add-case.js";
7
10
  export { runPlanCreate } from "./commands/plan/create.js";
8
11
  export { runPlanRemoveCase } from "./commands/plan/remove-case.js";
package/dist/index.js CHANGED
@@ -2,6 +2,9 @@ export { getApiKey, getBaseUrl, request } from "./client.js";
2
2
  export { runCaseCreate } from "./commands/case/create.js";
3
3
  export { runDeviceList } from "./commands/device/list.js";
4
4
  export { runHealthCheck } from "./commands/health/check.js";
5
+ export { runMobileList } from "./commands/mobile/list.js";
6
+ export { runMobileRegister } from "./commands/mobile/register.js";
7
+ export { runMobileDoctor } from "./commands/mobile/doctor.js";
5
8
  export { runPlanAddCase } from "./commands/plan/add-case.js";
6
9
  export { runPlanCreate } from "./commands/plan/create.js";
7
10
  export { runPlanRemoveCase } from "./commands/plan/remove-case.js";
@@ -0,0 +1,39 @@
1
+ import type { ActionResult, MobileController } from "./controller.js";
2
+ import { type WebDriverTransport } from "./webdriver.js";
3
+ export interface AppiumControllerOptions {
4
+ endpoint: string;
5
+ platform: "ios" | "android";
6
+ target: string;
7
+ transport?: WebDriverTransport;
8
+ }
9
+ export declare class AppiumController implements MobileController {
10
+ private readonly session;
11
+ private readonly platform;
12
+ private readonly target;
13
+ private sessionReady;
14
+ private scaling;
15
+ constructor(opts: AppiumControllerOptions);
16
+ private capabilities;
17
+ private ensureSession;
18
+ private scale;
19
+ private toPoint;
20
+ private pointerAction;
21
+ screenshot(): Promise<Buffer>;
22
+ tap(x: number, y: number): Promise<void>;
23
+ doubleTap(x: number, y: number): Promise<void>;
24
+ longPress(x: number, y: number, durationMs: number): Promise<void>;
25
+ swipe(x1: number, y1: number, x2: number, y2: number, durationMs: number): Promise<void>;
26
+ private performPointer;
27
+ typeText(text: string): Promise<ActionResult>;
28
+ clearText(): Promise<ActionResult>;
29
+ pressKey(key: string): Promise<ActionResult>;
30
+ launchApp(app: string): Promise<ActionResult>;
31
+ findElement(text: string): Promise<ActionResult>;
32
+ private locate;
33
+ hideKeyboard(): Promise<ActionResult>;
34
+ getDeviceTime(): Promise<{
35
+ datetime: string;
36
+ timezone: string;
37
+ } | null>;
38
+ close(): Promise<void>;
39
+ }
@@ -0,0 +1,367 @@
1
+ import { fetchTransport, WebDriverSession } from "./webdriver.js";
2
+ const ANDROID_KEY_CODES = {
3
+ enter: 66,
4
+ delete: 67,
5
+ tab: 61,
6
+ space: 62,
7
+ escape: 111,
8
+ back: 4,
9
+ home: 3,
10
+ };
11
+ const ANDROID_KEYCODE_A = 29;
12
+ const ANDROID_META_CTRL_ON = 4096;
13
+ const IOS_BUTTONS = {
14
+ home: "home",
15
+ };
16
+ const W3C_KEYS = {
17
+ enter: "\uE007",
18
+ delete: "\uE003",
19
+ tab: "\uE004",
20
+ escape: "\uE00C",
21
+ space: " ",
22
+ };
23
+ export class AppiumController {
24
+ session;
25
+ platform;
26
+ target;
27
+ sessionReady = null;
28
+ scaling = null;
29
+ constructor(opts) {
30
+ this.platform = opts.platform;
31
+ this.target = opts.target;
32
+ const transport = opts.transport ?? fetchTransport(opts.endpoint);
33
+ this.session = new WebDriverSession(transport);
34
+ }
35
+ capabilities() {
36
+ if (this.platform === "android") {
37
+ return {
38
+ platformName: "Android",
39
+ "appium:automationName": "UiAutomator2",
40
+ "appium:udid": this.target,
41
+ "appium:noReset": true,
42
+ "appium:autoLaunch": false,
43
+ "appium:newCommandTimeout": 0,
44
+ };
45
+ }
46
+ return {
47
+ platformName: "iOS",
48
+ "appium:automationName": "XCUITest",
49
+ "appium:udid": this.target,
50
+ "appium:newCommandTimeout": 0,
51
+ };
52
+ }
53
+ ensureSession() {
54
+ if (!this.sessionReady) {
55
+ this.sessionReady = this.session
56
+ .createSession(this.capabilities())
57
+ .then(() => undefined)
58
+ .catch((err) => {
59
+ this.sessionReady = null;
60
+ throw err;
61
+ });
62
+ }
63
+ return this.sessionReady;
64
+ }
65
+ // Incoming pixel coords scale to logical points: iOS divides by DPR, Android ~= 1.
66
+ async scale() {
67
+ if (this.scaling)
68
+ return this.scaling.scale;
69
+ const [rect, shot] = await Promise.all([this.session.windowRect(), this.session.screenshot()]);
70
+ const pixelWidth = readPngWidth(shot);
71
+ const scale = rect.width > 0 && pixelWidth > 0 ? pixelWidth / rect.width : 1;
72
+ this.scaling = { scale: scale > 0 ? scale : 1 };
73
+ return this.scaling.scale;
74
+ }
75
+ async toPoint(x, y) {
76
+ const s = await this.scale();
77
+ return [Math.round(x / s), Math.round(y / s)];
78
+ }
79
+ pointerAction(actions) {
80
+ return [
81
+ {
82
+ type: "pointer",
83
+ id: "finger1",
84
+ parameters: { pointerType: "touch" },
85
+ actions,
86
+ },
87
+ ];
88
+ }
89
+ async screenshot() {
90
+ await this.ensureSession();
91
+ return this.session.screenshot();
92
+ }
93
+ async tap(x, y) {
94
+ await this.ensureSession();
95
+ const [px, py] = await this.toPoint(x, y);
96
+ await this.performPointer([
97
+ { type: "pointerMove", duration: 0, x: px, y: py },
98
+ { type: "pointerDown", button: 0 },
99
+ { type: "pause", duration: 50 },
100
+ { type: "pointerUp", button: 0 },
101
+ ]);
102
+ }
103
+ async doubleTap(x, y) {
104
+ await this.ensureSession();
105
+ const [px, py] = await this.toPoint(x, y);
106
+ await this.performPointer([
107
+ { type: "pointerMove", duration: 0, x: px, y: py },
108
+ { type: "pointerDown", button: 0 },
109
+ { type: "pause", duration: 50 },
110
+ { type: "pointerUp", button: 0 },
111
+ { type: "pause", duration: 50 },
112
+ { type: "pointerDown", button: 0 },
113
+ { type: "pause", duration: 50 },
114
+ { type: "pointerUp", button: 0 },
115
+ ]);
116
+ }
117
+ async longPress(x, y, durationMs) {
118
+ await this.ensureSession();
119
+ const [px, py] = await this.toPoint(x, y);
120
+ await this.performPointer([
121
+ { type: "pointerMove", duration: 0, x: px, y: py },
122
+ { type: "pointerDown", button: 0 },
123
+ { type: "pause", duration: Math.max(0, Math.round(durationMs)) },
124
+ { type: "pointerUp", button: 0 },
125
+ ]);
126
+ }
127
+ async swipe(x1, y1, x2, y2, durationMs) {
128
+ await this.ensureSession();
129
+ const [px1, py1] = await this.toPoint(x1, y1);
130
+ const [px2, py2] = await this.toPoint(x2, y2);
131
+ await this.performPointer([
132
+ { type: "pointerMove", duration: 0, x: px1, y: py1 },
133
+ { type: "pointerDown", button: 0 },
134
+ { type: "pointerMove", duration: Math.max(0, Math.round(durationMs)), x: px2, y: py2 },
135
+ { type: "pointerUp", button: 0 },
136
+ ]);
137
+ }
138
+ async performPointer(actions) {
139
+ await this.session.performActions(this.pointerAction(actions));
140
+ await this.session.releaseActions();
141
+ }
142
+ async typeText(text) {
143
+ await this.ensureSession();
144
+ try {
145
+ if (this.platform === "android") {
146
+ await this.session.execute("mobile: type", [{ text }]);
147
+ return { ok: true };
148
+ }
149
+ await this.session.setValueOnActiveElement(text);
150
+ return { ok: true };
151
+ }
152
+ catch (error) {
153
+ return fail(error);
154
+ }
155
+ }
156
+ async clearText() {
157
+ await this.ensureSession();
158
+ try {
159
+ const active = await this.session.getActiveElement();
160
+ if (active) {
161
+ await this.session.elementClear(active);
162
+ return { ok: true };
163
+ }
164
+ }
165
+ catch { }
166
+ if (this.platform === "android") {
167
+ try {
168
+ await this.session.execute("mobile: pressKey", [
169
+ { keycode: ANDROID_KEYCODE_A, metastate: ANDROID_META_CTRL_ON },
170
+ ]);
171
+ await this.session.execute("mobile: pressKey", [{ keycode: ANDROID_KEY_CODES["delete"] }]);
172
+ return { ok: true };
173
+ }
174
+ catch (error) {
175
+ return fail(error);
176
+ }
177
+ }
178
+ return {
179
+ ok: false,
180
+ error: "Could not clear the field: no focused input element was found. " +
181
+ "Tap the field to focus it before calling clear_text.",
182
+ };
183
+ }
184
+ async pressKey(key) {
185
+ await this.ensureSession();
186
+ const normalized = key.toLowerCase().trim();
187
+ try {
188
+ if (this.platform === "android") {
189
+ const code = ANDROID_KEY_CODES[normalized];
190
+ if (code === undefined)
191
+ return { ok: false, error: `Unsupported key: ${key}` };
192
+ await this.session.execute("mobile: pressKey", [{ keycode: code }]);
193
+ return { ok: true };
194
+ }
195
+ if (normalized === "back") {
196
+ return {
197
+ ok: false,
198
+ error: '"back" key is Android-only. On iOS, tap the back button in the UI instead.',
199
+ };
200
+ }
201
+ const button = IOS_BUTTONS[normalized];
202
+ if (button) {
203
+ await this.session.execute("mobile: pressButton", [{ name: button }]);
204
+ return { ok: true };
205
+ }
206
+ const w3c = W3C_KEYS[normalized];
207
+ if (w3c) {
208
+ await this.session.performActions([
209
+ {
210
+ type: "key",
211
+ id: "keyboard",
212
+ actions: [
213
+ { type: "keyDown", value: w3c },
214
+ { type: "keyUp", value: w3c },
215
+ ],
216
+ },
217
+ ]);
218
+ await this.session.releaseActions();
219
+ return { ok: true };
220
+ }
221
+ return { ok: false, error: `Unsupported key: ${key}` };
222
+ }
223
+ catch (error) {
224
+ return fail(error);
225
+ }
226
+ }
227
+ async launchApp(app) {
228
+ if (!app)
229
+ return { ok: false, error: "launch_app requires an app id" };
230
+ await this.ensureSession();
231
+ try {
232
+ const param = this.platform === "android" ? { appId: app } : { bundleId: app };
233
+ await this.session.execute("mobile: activateApp", [param]);
234
+ return { ok: true };
235
+ }
236
+ catch (error) {
237
+ return fail(error);
238
+ }
239
+ }
240
+ async findElement(text) {
241
+ if (!text)
242
+ return { ok: false, error: "find_element requires text" };
243
+ await this.ensureSession();
244
+ try {
245
+ const ref = await this.locate(text);
246
+ if (!ref)
247
+ return { ok: false, error: `Element with text "${text}" not found` };
248
+ await this.session.elementClick(ref);
249
+ return { ok: true };
250
+ }
251
+ catch (error) {
252
+ return fail(error);
253
+ }
254
+ }
255
+ async locate(text) {
256
+ const strategies = this.platform === "android" ? androidStrategies(text) : iosStrategies(text);
257
+ for (const [using, value] of strategies) {
258
+ try {
259
+ const ref = await this.session.findElement(using, value);
260
+ if (ref)
261
+ return ref;
262
+ }
263
+ catch { }
264
+ }
265
+ return null;
266
+ }
267
+ async hideKeyboard() {
268
+ await this.ensureSession();
269
+ try {
270
+ await this.session.execute("mobile: hideKeyboard", []);
271
+ return { ok: true };
272
+ }
273
+ catch {
274
+ return { ok: true };
275
+ }
276
+ }
277
+ async getDeviceTime() {
278
+ await this.ensureSession();
279
+ try {
280
+ const raw = await this.session.execute("mobile: getDeviceTime", []);
281
+ if (typeof raw !== "string" || raw.trim().length === 0)
282
+ return null;
283
+ const parsed = new Date(raw);
284
+ if (Number.isNaN(parsed.getTime()))
285
+ return null;
286
+ return {
287
+ datetime: humanizeDateTime(parsed),
288
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
289
+ };
290
+ }
291
+ catch {
292
+ return null;
293
+ }
294
+ }
295
+ async close() {
296
+ try {
297
+ await this.session.deleteSession();
298
+ }
299
+ catch { }
300
+ }
301
+ }
302
+ // Builds an XPath string literal that is safe even when `s` contains single quotes.
303
+ function xpathLiteral(s) {
304
+ if (!s.includes("'"))
305
+ return `'${s}'`;
306
+ const parts = s.split("'").map((part) => `'${part}'`);
307
+ return `concat(${parts.join(`, "'", `)})`;
308
+ }
309
+ // Escapes backslashes and double quotes for a Java UiSelector string argument.
310
+ function uiSelectorLiteral(s) {
311
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
312
+ }
313
+ // Escapes single quotes for an iOS NSPredicate string literal.
314
+ function iosPredicateLiteral(s) {
315
+ return s.replace(/'/g, "\\'");
316
+ }
317
+ function androidStrategies(text) {
318
+ const lower = text.toLowerCase();
319
+ return [
320
+ ["accessibility id", text],
321
+ ["id", text],
322
+ ["xpath", `//*[@text=${xpathLiteral(text)}]`],
323
+ [
324
+ "xpath",
325
+ `//*[contains(translate(@text, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), ${xpathLiteral(lower)})]`,
326
+ ],
327
+ ["xpath", `//*[@content-desc=${xpathLiteral(text)}]`],
328
+ ["xpath", `//*[contains(@content-desc, ${xpathLiteral(text)})]`],
329
+ ["-android uiautomator", `new UiSelector().textContains("${uiSelectorLiteral(text)}")`],
330
+ ];
331
+ }
332
+ function iosStrategies(text) {
333
+ const v = iosPredicateLiteral(text);
334
+ return [
335
+ ["-ios predicate string", `type == 'XCUIElementTypeButton' AND label == '${v}'`],
336
+ ["-ios predicate string", `type == 'XCUIElementTypeButton' AND name == '${v}'`],
337
+ ["accessibility id", text],
338
+ ["name", text],
339
+ ["-ios predicate string", `label == '${v}'`],
340
+ ["-ios predicate string", `name == '${v}'`],
341
+ ["-ios predicate string", `label CONTAINS '${v}'`],
342
+ ];
343
+ }
344
+ function humanizeDateTime(date) {
345
+ return date.toLocaleString("en-US", {
346
+ weekday: "long",
347
+ year: "numeric",
348
+ month: "long",
349
+ day: "numeric",
350
+ hour: "numeric",
351
+ minute: "2-digit",
352
+ hour12: true,
353
+ });
354
+ }
355
+ function fail(error) {
356
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
357
+ }
358
+ function readPngWidth(buffer) {
359
+ if (buffer.length < 24)
360
+ return 0;
361
+ try {
362
+ return buffer.readUInt32BE(16);
363
+ }
364
+ catch {
365
+ return 0;
366
+ }
367
+ }
@@ -0,0 +1 @@
1
+ export {};