@autifyhq/aximo-cli 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +129 -35
  2. package/dist/cli.js +1 -0
  3. package/dist/commands/case/case.test.js +34 -0
  4. package/dist/commands/case/delete.d.ts +20 -0
  5. package/dist/commands/case/delete.js +39 -0
  6. package/dist/commands/case/index.js +1 -0
  7. package/dist/commands/device/device-config.d.ts +14 -0
  8. package/dist/commands/device/device-config.js +46 -0
  9. package/dist/commands/device/device-config.test.d.ts +1 -0
  10. package/dist/commands/device/device-config.test.js +56 -0
  11. package/dist/commands/device/doctor.d.ts +14 -0
  12. package/dist/commands/device/doctor.js +155 -29
  13. package/dist/commands/device/doctor.test.js +69 -3
  14. package/dist/commands/device/paths.d.ts +1 -0
  15. package/dist/commands/device/paths.js +3 -0
  16. package/dist/commands/device/register.d.ts +2 -0
  17. package/dist/commands/device/register.js +50 -32
  18. package/dist/commands/device/register.test.d.ts +1 -0
  19. package/dist/commands/device/register.test.js +95 -0
  20. package/dist/commands/device/unregister.d.ts +5 -3
  21. package/dist/commands/device/unregister.js +32 -33
  22. package/dist/commands/device/unregister.test.d.ts +1 -0
  23. package/dist/commands/device/unregister.test.js +73 -0
  24. package/dist/commands/mcp/server.test.js +2 -1
  25. package/dist/commands/mcp/tools.js +11 -0
  26. package/dist/commands/mcp/tools.test.js +12 -0
  27. package/dist/commands/mobile/connect-core.d.ts +12 -0
  28. package/dist/commands/mobile/connect-core.js +74 -0
  29. package/dist/commands/mobile/connect.d.ts +9 -0
  30. package/dist/commands/mobile/connect.js +35 -0
  31. package/dist/commands/mobile/connect.test.d.ts +1 -0
  32. package/dist/commands/mobile/connect.test.js +87 -0
  33. package/dist/commands/mobile/doctor.d.ts +28 -0
  34. package/dist/commands/mobile/doctor.js +143 -0
  35. package/dist/commands/mobile/index.d.ts +2 -0
  36. package/dist/commands/mobile/index.js +15 -0
  37. package/dist/commands/mobile/list.d.ts +10 -0
  38. package/dist/commands/mobile/list.js +30 -0
  39. package/dist/commands/mobile/paths.d.ts +14 -0
  40. package/dist/commands/mobile/paths.js +71 -0
  41. package/dist/commands/mobile/register-core.d.ts +19 -0
  42. package/dist/commands/mobile/register-core.js +46 -0
  43. package/dist/commands/mobile/register.d.ts +32 -0
  44. package/dist/commands/mobile/register.js +51 -0
  45. package/dist/commands/mobile/register.test.d.ts +1 -0
  46. package/dist/commands/mobile/register.test.js +60 -0
  47. package/dist/commands/mobile/setup.d.ts +27 -0
  48. package/dist/commands/mobile/setup.js +100 -0
  49. package/dist/commands/mobile/setup.test.d.ts +1 -0
  50. package/dist/commands/mobile/setup.test.js +49 -0
  51. package/dist/commands/mobile/unregister.d.ts +14 -0
  52. package/dist/commands/mobile/unregister.js +37 -0
  53. package/dist/commands/schema.js +22 -0
  54. package/dist/commands/session/create.d.ts +5 -0
  55. package/dist/commands/session/create.js +6 -0
  56. package/dist/desktop-server/setup-tls.test.d.ts +1 -0
  57. package/dist/desktop-server/setup-tls.test.js +83 -0
  58. package/dist/desktop-server/setup.d.ts +17 -0
  59. package/dist/desktop-server/setup.js +53 -1
  60. package/dist/index.d.ts +3 -0
  61. package/dist/index.js +3 -0
  62. package/dist/mobile-control/appium-controller.d.ts +39 -0
  63. package/dist/mobile-control/appium-controller.js +367 -0
  64. package/dist/mobile-control/appium-controller.test.d.ts +1 -0
  65. package/dist/mobile-control/appium-controller.test.js +252 -0
  66. package/dist/mobile-control/appium-import.d.ts +7 -0
  67. package/dist/mobile-control/appium-import.js +6 -0
  68. package/dist/mobile-control/appium-import.test.d.ts +1 -0
  69. package/dist/mobile-control/appium-import.test.js +9 -0
  70. package/dist/mobile-control/appium-server.d.ts +30 -0
  71. package/dist/mobile-control/appium-server.js +110 -0
  72. package/dist/mobile-control/appium-server.test.d.ts +1 -0
  73. package/dist/mobile-control/appium-server.test.js +108 -0
  74. package/dist/mobile-control/controller.d.ts +22 -0
  75. package/dist/mobile-control/controller.js +1 -0
  76. package/dist/mobile-control/devices.d.ts +21 -0
  77. package/dist/mobile-control/devices.js +99 -0
  78. package/dist/mobile-control/devices.test.d.ts +1 -0
  79. package/dist/mobile-control/devices.test.js +35 -0
  80. package/dist/mobile-control/exec.d.ts +6 -0
  81. package/dist/mobile-control/exec.js +13 -0
  82. package/dist/mobile-control/tunnel.d.ts +11 -0
  83. package/dist/mobile-control/tunnel.js +152 -0
  84. package/dist/mobile-control/tunnel.test.d.ts +1 -0
  85. package/dist/mobile-control/tunnel.test.js +155 -0
  86. package/dist/mobile-control/webdriver.d.ts +31 -0
  87. package/dist/mobile-control/webdriver.js +137 -0
  88. package/dist/mobile-control/webdriver.test.d.ts +1 -0
  89. package/dist/mobile-control/webdriver.test.js +45 -0
  90. package/dist/types.d.ts +4 -0
  91. package/package.json +3 -1
@@ -0,0 +1,99 @@
1
+ import { execBinary } from "./exec.js";
2
+ /** Parse `adb devices -l` into serial + state (+ model when present). */
3
+ export async function listAndroidDevices(exec = execBinary) {
4
+ const { stdout } = await exec("adb", ["devices", "-l"]);
5
+ const lines = stdout.toString("utf-8").split("\n").slice(1);
6
+ const result = [];
7
+ for (const raw of lines) {
8
+ const line = raw.trim();
9
+ if (!line)
10
+ continue;
11
+ const parts = line.split(/\s+/);
12
+ const serial = parts[0];
13
+ const state = parts[1];
14
+ if (!serial || !state)
15
+ continue;
16
+ const modelToken = parts.find((p) => p.startsWith("model:"));
17
+ const device = { serial, state };
18
+ if (modelToken)
19
+ device.model = modelToken.slice("model:".length);
20
+ result.push(device);
21
+ }
22
+ return result;
23
+ }
24
+ /** Resolve an adb serial: explicit (validated), else the single connected phone. */
25
+ export async function resolveAndroidSerial(preferred, exec = execBinary) {
26
+ const devices = await listAndroidDevices(exec);
27
+ if (preferred) {
28
+ if (preferred.startsWith("emulator-")) {
29
+ throw new Error("Emulators are not supported — register a physical phone connected over USB only.");
30
+ }
31
+ const match = devices.find((d) => d.serial === preferred);
32
+ if (!match) {
33
+ throw new Error(`No Android device with serial '${preferred}' is connected.`);
34
+ }
35
+ if (match.state === "unauthorized") {
36
+ throw new Error("Android device is unauthorized. Accept the RSA prompt on the device, then retry.");
37
+ }
38
+ if (match.state === "offline") {
39
+ throw new Error("Android device is offline — reconnect the USB cable and unlock the phone.");
40
+ }
41
+ if (match.state !== "device") {
42
+ throw new Error(`Android device '${preferred}' is in state '${match.state}' — reconnect and unlock the phone.`);
43
+ }
44
+ return match.serial;
45
+ }
46
+ // Physical phones only: exclude emulator serials (mirrors list.ts).
47
+ const usable = devices.filter((d) => d.state === "device" && !d.serial.startsWith("emulator-"));
48
+ if (usable.length === 1 && usable[0])
49
+ return usable[0].serial;
50
+ if (usable.length === 0) {
51
+ const unauthorized = devices.find((d) => d.state === "unauthorized");
52
+ if (unauthorized) {
53
+ throw new Error("Android device is unauthorized. Accept the RSA prompt on the device, then retry.");
54
+ }
55
+ const offline = devices.find((d) => d.state === "offline");
56
+ if (offline) {
57
+ throw new Error("Android device is offline — reconnect the USB cable and unlock the phone.");
58
+ }
59
+ throw new Error("No connected Android devices found. Connect a phone over USB.");
60
+ }
61
+ throw new Error(`Multiple Android devices found; pass --serial. Available: ${usable
62
+ .map((d) => d.serial)
63
+ .join(", ")}`);
64
+ }
65
+ /**
66
+ * List physically-connected iOS devices via `idb list-targets --json` (one JSON
67
+ * object per line). Only entries whose `type` is `device` are returned. Returns
68
+ * an empty list if idb is unavailable.
69
+ */
70
+ export async function listIosPhysicalDevices(exec = execBinary) {
71
+ let stdout;
72
+ try {
73
+ ({ stdout } = await exec("idb", ["list-targets", "--json"]));
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ const result = [];
79
+ for (const line of stdout.toString("utf-8").split("\n")) {
80
+ const trimmed = line.trim();
81
+ if (!trimmed)
82
+ continue;
83
+ try {
84
+ const entry = JSON.parse(trimmed);
85
+ if (entry.type === "device" &&
86
+ typeof entry.udid === "string" &&
87
+ typeof entry.name === "string") {
88
+ const device = { udid: entry.udid, name: entry.name };
89
+ if (typeof entry.state === "string")
90
+ device.state = entry.state;
91
+ result.push(device);
92
+ }
93
+ }
94
+ catch {
95
+ // ignore non-JSON lines
96
+ }
97
+ }
98
+ return result;
99
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { listAndroidDevices, resolveAndroidSerial } from "./devices.js";
3
+ function fixed(stdout) {
4
+ return async () => ({ stdout: Buffer.from(stdout), stderr: "" });
5
+ }
6
+ describe("listAndroidDevices / resolveAndroidSerial", () => {
7
+ it("parses adb devices -l output", async () => {
8
+ const exec = fixed("List of devices attached\nR1 device model:Pixel_7\nR2 unauthorized\n");
9
+ const devices = await listAndroidDevices(exec);
10
+ expect(devices).toEqual([
11
+ { serial: "R1", state: "device", model: "Pixel_7" },
12
+ { serial: "R2", state: "unauthorized" },
13
+ ]);
14
+ });
15
+ it("auto-selects the only online device", async () => {
16
+ const exec = fixed("List of devices attached\nR1 device\n");
17
+ await expect(resolveAndroidSerial(undefined, exec)).resolves.toBe("R1");
18
+ });
19
+ it("reports an actionable error for an unauthorized-only device", async () => {
20
+ const exec = fixed("List of devices attached\nR1 unauthorized\n");
21
+ await expect(resolveAndroidSerial(undefined, exec)).rejects.toThrow(/unauthorized/i);
22
+ });
23
+ it("rejects an explicitly-passed emulator serial", async () => {
24
+ const exec = fixed("List of devices attached\nemulator-5554 device\n");
25
+ await expect(resolveAndroidSerial("emulator-5554", exec)).rejects.toThrow(/emulator/i);
26
+ });
27
+ it("rejects an explicitly-passed unauthorized device", async () => {
28
+ const exec = fixed("List of devices attached\nR1 unauthorized\n");
29
+ await expect(resolveAndroidSerial("R1", exec)).rejects.toThrow(/unauthorized/i);
30
+ });
31
+ it("rejects an explicitly-passed offline device", async () => {
32
+ const exec = fixed("List of devices attached\nR1 offline\n");
33
+ await expect(resolveAndroidSerial("R1", exec)).rejects.toThrow(/offline/i);
34
+ });
35
+ });
@@ -0,0 +1,6 @@
1
+ export interface ExecResult {
2
+ stdout: Buffer;
3
+ stderr: string;
4
+ }
5
+ export type ExecFn = (file: string, args: string[]) => Promise<ExecResult>;
6
+ export declare const execBinary: ExecFn;
@@ -0,0 +1,13 @@
1
+ import { execFile } from "node:child_process";
2
+ // Explicit argv, no shell — payloads can't inject into the local machine.
3
+ export const execBinary = (file, args) => new Promise((resolve, reject) => {
4
+ execFile(file, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
5
+ const stderrText = stderr.toString("utf-8");
6
+ if (error) {
7
+ const message = stderrText.trim() || error.message;
8
+ reject(new Error(`${file} ${args.join(" ")} failed: ${message}`));
9
+ return;
10
+ }
11
+ resolve({ stdout, stderr: stderrText });
12
+ });
13
+ });
@@ -0,0 +1,11 @@
1
+ import { type Socket } from "socket.io-client";
2
+ import type { MobileController } from "./controller.js";
3
+ export interface RunControlTunnelOptions {
4
+ wsUrl: string;
5
+ deviceToken: string;
6
+ deviceName: string;
7
+ controller: MobileController;
8
+ connect?: (wsUrl: string, deviceToken: string) => Socket;
9
+ }
10
+ export declare function registerHandlers(socket: Socket, controller: MobileController): void;
11
+ export declare function runControlTunnel(options: RunControlTunnelOptions): Promise<void>;
@@ -0,0 +1,152 @@
1
+ import { io } from "socket.io-client";
2
+ const SOCKET_PATH = "/devices/tunnel";
3
+ function ok() {
4
+ return { ok: true };
5
+ }
6
+ function fail(error) {
7
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
8
+ }
9
+ function asActionResult(result) {
10
+ if (result.ok)
11
+ return { ok: true };
12
+ return result.error !== undefined ? { ok: false, error: result.error } : { ok: false };
13
+ }
14
+ function num(value, fallback = 0) {
15
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
16
+ }
17
+ /** Required numeric arg: returns the finite number, or null when missing/non-finite. */
18
+ function reqNum(args, key) {
19
+ const value = args[key];
20
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
21
+ }
22
+ function requireCoords(args, keys) {
23
+ const coords = [];
24
+ for (const key of keys) {
25
+ const value = reqNum(args, key);
26
+ if (value === null)
27
+ return { error: "requires numeric coordinates" };
28
+ coords.push(value);
29
+ }
30
+ return { coords };
31
+ }
32
+ function humanizeDateTime(date) {
33
+ return date.toLocaleString("en-US", {
34
+ weekday: "long",
35
+ year: "numeric",
36
+ month: "long",
37
+ day: "numeric",
38
+ hour: "numeric",
39
+ minute: "2-digit",
40
+ hour12: true,
41
+ });
42
+ }
43
+ function defaultConnect(wsUrl, deviceToken) {
44
+ return io(wsUrl, {
45
+ path: SOCKET_PATH,
46
+ transports: ["websocket"],
47
+ auth: { deviceToken },
48
+ reconnectionAttempts: 10,
49
+ timeout: 10000,
50
+ });
51
+ }
52
+ export function registerHandlers(socket, controller) {
53
+ const on = (event, handler) => {
54
+ socket.on(event, (rawArgs, ack) => {
55
+ const args = (rawArgs ?? {});
56
+ void handler(args)
57
+ .then((result) => ack?.(result))
58
+ .catch((err) => ack?.(fail(err)));
59
+ });
60
+ };
61
+ on("ping", async () => ok());
62
+ on("screenshot", async () => controller.screenshot());
63
+ on("datetime", async () => {
64
+ const fromDevice = await controller.getDeviceTime?.();
65
+ if (fromDevice)
66
+ return fromDevice;
67
+ return {
68
+ datetime: humanizeDateTime(new Date()),
69
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
70
+ };
71
+ });
72
+ on("tap", async (args) => {
73
+ const r = requireCoords(args, ["x", "y"]);
74
+ if ("error" in r)
75
+ return { ok: false, error: `tap ${r.error}` };
76
+ await controller.tap(r.coords[0], r.coords[1]);
77
+ return ok();
78
+ });
79
+ on("double_tap", async (args) => {
80
+ const r = requireCoords(args, ["x", "y"]);
81
+ if ("error" in r)
82
+ return { ok: false, error: `double_tap ${r.error}` };
83
+ await controller.doubleTap(r.coords[0], r.coords[1]);
84
+ return ok();
85
+ });
86
+ on("long_press", async (args) => {
87
+ const r = requireCoords(args, ["x", "y"]);
88
+ if ("error" in r)
89
+ return { ok: false, error: `long_press ${r.error}` };
90
+ await controller.longPress(r.coords[0], r.coords[1], num(args["duration"], 1500));
91
+ return ok();
92
+ });
93
+ on("swipe", async (args) => {
94
+ const r = requireCoords(args, ["x1", "y1", "x2", "y2"]);
95
+ if ("error" in r)
96
+ return { ok: false, error: `swipe ${r.error}` };
97
+ await controller.swipe(r.coords[0], r.coords[1], r.coords[2], r.coords[3], num(args["duration"], 300));
98
+ return ok();
99
+ });
100
+ on("input", async (args) => asActionResult(await controller.typeText(String(args["text"] ?? ""))));
101
+ on("key", async (args) => asActionResult(await controller.pressKey(String(args["key"] ?? ""))));
102
+ on("launch_app", async (args) => asActionResult(await controller.launchApp(String(args["app"] ?? ""))));
103
+ on("find_element", async (args) => asActionResult(await controller.findElement(String(args["text"] ?? ""))));
104
+ on("clear_text", async () => asActionResult(await controller.clearText()));
105
+ on("hide_keyboard", async () => asActionResult(await controller.hideKeyboard()));
106
+ }
107
+ export async function runControlTunnel(options) {
108
+ const connect = options.connect ?? defaultConnect;
109
+ const socket = connect(options.wsUrl, options.deviceToken);
110
+ registerHandlers(socket, options.controller);
111
+ await new Promise((resolve, reject) => {
112
+ let shuttingDown = false;
113
+ const cleanup = () => {
114
+ if (shuttingDown)
115
+ return;
116
+ shuttingDown = true;
117
+ socket.close();
118
+ resolve();
119
+ };
120
+ socket.on("connect", () => {
121
+ console.log(`Connected to Aximo as "${options.deviceName}" (${options.wsUrl}${SOCKET_PATH})`);
122
+ });
123
+ socket.on("connect_error", (err) => {
124
+ // socket.active is false once reconnection is given up; reject so the CLI exits non-zero.
125
+ if (!socket.active) {
126
+ socket.close();
127
+ reject(new Error(`Connection error: ${err.message}`));
128
+ return;
129
+ }
130
+ console.error(`Connection error: ${err.message}`);
131
+ });
132
+ socket.on("disconnect", (reason) => {
133
+ // socket.active is false when the disconnect is terminal (the client won't reconnect).
134
+ if (!socket.active) {
135
+ socket.close();
136
+ reject(new Error(`Disconnected: ${reason}`));
137
+ return;
138
+ }
139
+ console.log(`Disconnected: ${reason}`);
140
+ });
141
+ socket.on("server_shutting_down", () => {
142
+ console.log("Server is shutting down; awaiting reconnect...");
143
+ });
144
+ process.on("SIGINT", () => {
145
+ console.log("\nDisconnecting...");
146
+ cleanup();
147
+ });
148
+ process.on("SIGTERM", () => {
149
+ cleanup();
150
+ });
151
+ });
152
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,155 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { registerHandlers } from "./tunnel.js";
3
+ class FakeSocket {
4
+ handlers = new Map();
5
+ on(event, handler) {
6
+ this.handlers.set(event, handler);
7
+ return this;
8
+ }
9
+ emit(event, args) {
10
+ const handler = this.handlers.get(event);
11
+ if (!handler)
12
+ throw new Error(`no handler for ${event}`);
13
+ return new Promise((resolve) => handler(args, resolve));
14
+ }
15
+ }
16
+ function stubController(overrides = {}) {
17
+ const base = {
18
+ screenshot: vi.fn(async () => Buffer.from([1, 2, 3])),
19
+ tap: vi.fn(async () => { }),
20
+ doubleTap: vi.fn(async () => { }),
21
+ longPress: vi.fn(async () => { }),
22
+ swipe: vi.fn(async () => { }),
23
+ typeText: vi.fn(async () => ({ ok: true })),
24
+ pressKey: vi.fn(async () => ({ ok: true })),
25
+ launchApp: vi.fn(async () => ({ ok: true })),
26
+ findElement: vi.fn(async () => ({ ok: true })),
27
+ clearText: vi.fn(async () => ({ ok: true })),
28
+ hideKeyboard: vi.fn(async () => ({ ok: true })),
29
+ close: vi.fn(async () => { }),
30
+ };
31
+ return { ...base, ...overrides };
32
+ }
33
+ function wire(controller) {
34
+ const socket = new FakeSocket();
35
+ registerHandlers(socket, controller);
36
+ return socket;
37
+ }
38
+ describe("registerHandlers", () => {
39
+ it("ping acks { ok: true }", async () => {
40
+ const socket = wire(stubController());
41
+ await expect(socket.emit("ping", {})).resolves.toEqual({ ok: true });
42
+ });
43
+ it("screenshot acks the raw Buffer", async () => {
44
+ const socket = wire(stubController());
45
+ const result = await socket.emit("screenshot", {});
46
+ expect(Buffer.isBuffer(result)).toBe(true);
47
+ expect(result.equals(Buffer.from([1, 2, 3]))).toBe(true);
48
+ });
49
+ it("tap forwards coordinates and acks ok", async () => {
50
+ const tap = vi.fn(async () => { });
51
+ const socket = wire(stubController({ tap }));
52
+ await expect(socket.emit("tap", { x: 5, y: 9 })).resolves.toEqual({ ok: true });
53
+ expect(tap).toHaveBeenCalledWith(5, 9);
54
+ });
55
+ it("tap rejects missing coordinates without calling the controller", async () => {
56
+ const tap = vi.fn(async () => { });
57
+ const socket = wire(stubController({ tap }));
58
+ await expect(socket.emit("tap", {})).resolves.toEqual({
59
+ ok: false,
60
+ error: "tap requires numeric coordinates",
61
+ });
62
+ expect(tap).not.toHaveBeenCalled();
63
+ });
64
+ it("swipe rejects a non-finite coordinate without calling the controller", async () => {
65
+ const swipe = vi.fn(async () => { });
66
+ const socket = wire(stubController({ swipe }));
67
+ await expect(socket.emit("swipe", { x1: 1, y1: 2, x2: "3", y2: 4 })).resolves.toEqual({
68
+ ok: false,
69
+ error: "swipe requires numeric coordinates",
70
+ });
71
+ expect(swipe).not.toHaveBeenCalled();
72
+ });
73
+ it("long_press forwards duration in ms", async () => {
74
+ const longPress = vi.fn(async () => { });
75
+ const socket = wire(stubController({ longPress }));
76
+ await socket.emit("long_press", { x: 1, y: 2, duration: 1500 });
77
+ expect(longPress).toHaveBeenCalledWith(1, 2, 1500);
78
+ });
79
+ it("swipe forwards all coordinates and duration", async () => {
80
+ const swipe = vi.fn(async () => { });
81
+ const socket = wire(stubController({ swipe }));
82
+ await socket.emit("swipe", { x1: 1, y1: 2, x2: 3, y2: 4, duration: 250 });
83
+ expect(swipe).toHaveBeenCalledWith(1, 2, 3, 4, 250);
84
+ });
85
+ it("input forwards the text", async () => {
86
+ const typeText = vi.fn(async () => ({ ok: true }));
87
+ const socket = wire(stubController({ typeText }));
88
+ await expect(socket.emit("input", { text: "hello" })).resolves.toEqual({ ok: true });
89
+ expect(typeText).toHaveBeenCalledWith("hello");
90
+ });
91
+ it("key acks the structured failure for unsupported keys", async () => {
92
+ const pressKey = vi.fn(async () => ({ ok: false, error: "Unsupported key: f1" }));
93
+ const socket = wire(stubController({ pressKey }));
94
+ await expect(socket.emit("key", { key: "f1" })).resolves.toEqual({
95
+ ok: false,
96
+ error: "Unsupported key: f1",
97
+ });
98
+ });
99
+ it("find_element forwards the text and acks ok", async () => {
100
+ const findElement = vi.fn(async () => ({ ok: true }));
101
+ const socket = wire(stubController({ findElement }));
102
+ await expect(socket.emit("find_element", { text: "Sign in" })).resolves.toEqual({ ok: true });
103
+ expect(findElement).toHaveBeenCalledWith("Sign in");
104
+ });
105
+ it("clear_text acks ok", async () => {
106
+ const clearText = vi.fn(async () => ({ ok: true }));
107
+ const socket = wire(stubController({ clearText }));
108
+ await expect(socket.emit("clear_text", {})).resolves.toEqual({ ok: true });
109
+ expect(clearText).toHaveBeenCalledOnce();
110
+ });
111
+ it("clear_text acks the structured failure", async () => {
112
+ const clearText = vi.fn(async () => ({ ok: false, error: "no focused field" }));
113
+ const socket = wire(stubController({ clearText }));
114
+ await expect(socket.emit("clear_text", {})).resolves.toEqual({
115
+ ok: false,
116
+ error: "no focused field",
117
+ });
118
+ });
119
+ it("hide_keyboard acks ok", async () => {
120
+ const hideKeyboard = vi.fn(async () => ({ ok: true }));
121
+ const socket = wire(stubController({ hideKeyboard }));
122
+ await expect(socket.emit("hide_keyboard", {})).resolves.toEqual({ ok: true });
123
+ expect(hideKeyboard).toHaveBeenCalledOnce();
124
+ });
125
+ it("datetime falls back to a humanized host clock + timezone when the device can't report", async () => {
126
+ const socket = wire(stubController());
127
+ const result = (await socket.emit("datetime", {}));
128
+ expect(typeof result.datetime).toBe("string");
129
+ expect(result.datetime.length).toBeGreaterThan(0);
130
+ expect(result.datetime).not.toContain("T"); // humanized, not raw ISO
131
+ expect(typeof result.timezone).toBe("string");
132
+ expect(result.timezone.length).toBeGreaterThan(0);
133
+ });
134
+ it("datetime prefers the phone's clock when the controller reports it", async () => {
135
+ const getDeviceTime = vi.fn(async () => ({
136
+ datetime: "Friday, June 27, 2026, 10:30 AM",
137
+ timezone: "America/New_York",
138
+ }));
139
+ const socket = wire(stubController({ getDeviceTime }));
140
+ const result = (await socket.emit("datetime", {}));
141
+ expect(getDeviceTime).toHaveBeenCalledOnce();
142
+ expect(result.datetime).toBe("Friday, June 27, 2026, 10:30 AM");
143
+ expect(result.timezone).toBe("America/New_York");
144
+ });
145
+ it("converts a thrown controller error into { ok: false, error }", async () => {
146
+ const tap = vi.fn(async () => {
147
+ throw new Error("device offline");
148
+ });
149
+ const socket = wire(stubController({ tap }));
150
+ await expect(socket.emit("tap", { x: 1, y: 1 })).resolves.toEqual({
151
+ ok: false,
152
+ error: "device offline",
153
+ });
154
+ });
155
+ });
@@ -0,0 +1,31 @@
1
+ export interface WebDriverTransport {
2
+ request(method: "GET" | "POST" | "DELETE", path: string, body?: unknown): Promise<unknown>;
3
+ }
4
+ export declare function fetchTransport(baseUrl: string): WebDriverTransport;
5
+ export interface WindowRect {
6
+ width: number;
7
+ height: number;
8
+ x: number;
9
+ y: number;
10
+ }
11
+ export declare class WebDriverSession {
12
+ private readonly transport;
13
+ private sessionId;
14
+ constructor(transport: WebDriverTransport);
15
+ get id(): string | null;
16
+ createSession(capabilities: Record<string, unknown>): Promise<string>;
17
+ deleteSession(): Promise<void>;
18
+ private requireId;
19
+ screenshot(): Promise<Buffer>;
20
+ windowRect(): Promise<WindowRect>;
21
+ performActions(actions: unknown[]): Promise<void>;
22
+ releaseActions(): Promise<void>;
23
+ getActiveElement(): Promise<string | null>;
24
+ findElement(using: string, value: string): Promise<string | null>;
25
+ elementClick(elementRef: string): Promise<void>;
26
+ elementClear(elementRef: string): Promise<void>;
27
+ elementSendKeys(elementRef: string, text: string): Promise<void>;
28
+ execute(script: string, args?: unknown[]): Promise<unknown>;
29
+ setValueOnActiveElement(text: string): Promise<void>;
30
+ }
31
+ export declare const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
@@ -0,0 +1,137 @@
1
+ function extractError(value) {
2
+ if (value && typeof value === "object") {
3
+ const err = value;
4
+ if (typeof err.error === "string" && err.error.length > 0) {
5
+ return err.message && typeof err.message === "string"
6
+ ? `${err.error}: ${err.message}`
7
+ : err.error;
8
+ }
9
+ }
10
+ return null;
11
+ }
12
+ export function fetchTransport(baseUrl) {
13
+ const root = baseUrl.replace(/\/$/, "");
14
+ return {
15
+ async request(method, path, body) {
16
+ const headers = { Accept: "application/json" };
17
+ const init = { method, headers };
18
+ if (body !== undefined) {
19
+ headers["Content-Type"] = "application/json";
20
+ init.body = JSON.stringify(body);
21
+ }
22
+ const res = await fetch(`${root}${path}`, init);
23
+ const text = await res.text();
24
+ let parsed;
25
+ try {
26
+ parsed = text.length > 0 ? JSON.parse(text) : {};
27
+ }
28
+ catch {
29
+ parsed = { value: text };
30
+ }
31
+ const valueError = extractError(parsed.value);
32
+ if (!res.ok || valueError) {
33
+ const detail = valueError ??
34
+ extractError(parsed) ??
35
+ (text.trim() ? text.slice(0, 500) : `HTTP ${res.status}`);
36
+ throw new Error(`WebDriver ${method} ${path} failed: ${detail}`);
37
+ }
38
+ return parsed.value;
39
+ },
40
+ };
41
+ }
42
+ export class WebDriverSession {
43
+ transport;
44
+ sessionId = null;
45
+ constructor(transport) {
46
+ this.transport = transport;
47
+ }
48
+ get id() {
49
+ return this.sessionId;
50
+ }
51
+ async createSession(capabilities) {
52
+ const value = (await this.transport.request("POST", "/session", {
53
+ capabilities: { alwaysMatch: capabilities, firstMatch: [{}] },
54
+ }));
55
+ const sessionId = value?.sessionId;
56
+ if (!sessionId || typeof sessionId !== "string") {
57
+ throw new Error("WebDriver createSession did not return a sessionId");
58
+ }
59
+ this.sessionId = sessionId;
60
+ return sessionId;
61
+ }
62
+ async deleteSession() {
63
+ if (!this.sessionId)
64
+ return;
65
+ const id = this.sessionId;
66
+ this.sessionId = null;
67
+ await this.transport.request("DELETE", `/session/${id}`);
68
+ }
69
+ requireId() {
70
+ if (!this.sessionId)
71
+ throw new Error("WebDriver session is not open");
72
+ return this.sessionId;
73
+ }
74
+ async screenshot() {
75
+ const value = (await this.transport.request("GET", `/session/${this.requireId()}/screenshot`));
76
+ return Buffer.from(value, "base64");
77
+ }
78
+ async windowRect() {
79
+ const value = (await this.transport.request("GET", `/session/${this.requireId()}/window/rect`));
80
+ return {
81
+ width: Number(value?.width ?? 0),
82
+ height: Number(value?.height ?? 0),
83
+ x: Number(value?.x ?? 0),
84
+ y: Number(value?.y ?? 0),
85
+ };
86
+ }
87
+ async performActions(actions) {
88
+ await this.transport.request("POST", `/session/${this.requireId()}/actions`, { actions });
89
+ }
90
+ async releaseActions() {
91
+ await this.transport.request("DELETE", `/session/${this.requireId()}/actions`);
92
+ }
93
+ async getActiveElement() {
94
+ const value = await this.transport.request("GET", `/session/${this.requireId()}/element/active`);
95
+ return elementId(value);
96
+ }
97
+ async findElement(using, value) {
98
+ const result = await this.transport.request("POST", `/session/${this.requireId()}/element`, {
99
+ using,
100
+ value,
101
+ });
102
+ return elementId(result);
103
+ }
104
+ async elementClick(elementRef) {
105
+ await this.transport.request("POST", `/session/${this.requireId()}/element/${elementRef}/click`, {});
106
+ }
107
+ async elementClear(elementRef) {
108
+ await this.transport.request("POST", `/session/${this.requireId()}/element/${elementRef}/clear`, {});
109
+ }
110
+ async elementSendKeys(elementRef, text) {
111
+ await this.transport.request("POST", `/session/${this.requireId()}/element/${elementRef}/value`, { text, value: [...text] });
112
+ }
113
+ async execute(script, args = []) {
114
+ return this.transport.request("POST", `/session/${this.requireId()}/execute/sync`, {
115
+ script,
116
+ args,
117
+ });
118
+ }
119
+ async setValueOnActiveElement(text) {
120
+ const active = await this.getActiveElement();
121
+ if (!active)
122
+ throw new Error("No active element to receive text");
123
+ await this.elementSendKeys(active, text);
124
+ }
125
+ }
126
+ export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
127
+ function elementId(value) {
128
+ if (value && typeof value === "object") {
129
+ const ref = value[ELEMENT_KEY];
130
+ if (typeof ref === "string")
131
+ return ref;
132
+ const legacy = value["ELEMENT"];
133
+ if (typeof legacy === "string")
134
+ return legacy;
135
+ }
136
+ return null;
137
+ }
@@ -0,0 +1 @@
1
+ export {};