diff --git a/src/android.ts b/src/android.ts index 0b6d73ed..a6fb1fc5 100644 --- a/src/android.ts +++ b/src/android.ts @@ -4,7 +4,7 @@ import { existsSync } from "node:fs"; import * as xml from "fast-xml-parser"; -import { ActionableError, Button, InstalledApp, Robot, ScreenElement, ScreenElementRect, ScreenSize, SwipeDirection, Orientation } from "./robot"; +import { ActionableError, Button, InstalledApp, Robot, ScreenElement, ScreenElementRect, ScreenSize, SwipeDirection, Orientation, withActionableError } from "./robot"; export interface AndroidDevice { deviceId: string; @@ -100,33 +100,37 @@ export class AndroidRobot implements Robot { } public async getScreenSize(): Promise { - const screenSize = this.adb("shell", "wm", "size") - .toString() - .split(" ") - .pop(); + return withActionableError(() => { + const screenSize = this.adb("shell", "wm", "size") + .toString() + .split(" ") + .pop(); - if (!screenSize) { - throw new Error("Failed to get screen size"); - } + if (!screenSize) { + throw new Error("Could not parse screen size output"); + } - const scale = 1; - const [width, height] = screenSize.split("x").map(Number); - return { width, height, scale }; + const scale = 1; + const [width, height] = screenSize.split("x").map(Number); + return { width, height, scale }; + }, "Failed to get screen size"); } public async listApps(): Promise { - // only apps that have a launcher activity are returned - return this.adb("shell", "cmd", "package", "query-activities", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER") - .toString() - .split("\n") - .map(line => line.trim()) - .filter(line => line.startsWith("packageName=")) - .map(line => line.substring("packageName=".length)) - .filter((value, index, self) => self.indexOf(value) === index) - .map(packageName => ({ - packageName, - appName: packageName, - })); + return withActionableError(() => { + // only apps that have a launcher activity are returned + return this.adb("shell", "cmd", "package", "query-activities", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.LAUNCHER") + .toString() + .split("\n") + .map(line => line.trim()) + .filter(line => line.startsWith("packageName=")) + .map(line => line.substring("packageName=".length)) + .filter((value, index, self) => self.indexOf(value) === index) + .map(packageName => ({ + packageName, + appName: packageName, + })); + }, "Failed to list installed apps"); } private async listPackages(): Promise { @@ -139,11 +143,10 @@ export class AndroidRobot implements Robot { } public async launchApp(packageName: string): Promise { - try { - this.silentAdb("shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1"); - } catch (error) { - throw new ActionableError(`Failed launching app with package name "${packageName}", please make sure it exists`); - } + return withActionableError( + () => { this.silentAdb("shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1"); }, + `Failed to launch app "${packageName}". Please make sure it exists` + ); } public async listRunningProcesses(): Promise { @@ -156,76 +159,80 @@ export class AndroidRobot implements Robot { } public async swipe(direction: SwipeDirection): Promise { - const screenSize = await this.getScreenSize(); - const centerX = screenSize.width >> 1; - - let x0: number, y0: number, x1: number, y1: number; - - switch (direction) { - case "up": - x0 = x1 = centerX; - y0 = Math.floor(screenSize.height * 0.80); - y1 = Math.floor(screenSize.height * 0.20); - break; - case "down": - x0 = x1 = centerX; - y0 = Math.floor(screenSize.height * 0.20); - y1 = Math.floor(screenSize.height * 0.80); - break; - case "left": - x0 = Math.floor(screenSize.width * 0.80); - x1 = Math.floor(screenSize.width * 0.20); - y0 = y1 = Math.floor(screenSize.height * 0.50); - break; - case "right": - x0 = Math.floor(screenSize.width * 0.20); - x1 = Math.floor(screenSize.width * 0.80); - y0 = y1 = Math.floor(screenSize.height * 0.50); - break; - default: - throw new ActionableError(`Swipe direction "${direction}" is not supported`); - } + return withActionableError(async () => { + const screenSize = await this.getScreenSize(); + const centerX = screenSize.width >> 1; + + let x0: number, y0: number, x1: number, y1: number; + + switch (direction) { + case "up": + x0 = x1 = centerX; + y0 = Math.floor(screenSize.height * 0.80); + y1 = Math.floor(screenSize.height * 0.20); + break; + case "down": + x0 = x1 = centerX; + y0 = Math.floor(screenSize.height * 0.20); + y1 = Math.floor(screenSize.height * 0.80); + break; + case "left": + x0 = Math.floor(screenSize.width * 0.80); + x1 = Math.floor(screenSize.width * 0.20); + y0 = y1 = Math.floor(screenSize.height * 0.50); + break; + case "right": + x0 = Math.floor(screenSize.width * 0.20); + x1 = Math.floor(screenSize.width * 0.80); + y0 = y1 = Math.floor(screenSize.height * 0.50); + break; + default: + throw new Error(`Swipe direction "${direction}" is not supported`); + } - this.adb("shell", "input", "swipe", `${x0}`, `${y0}`, `${x1}`, `${y1}`, "1000"); + this.adb("shell", "input", "swipe", `${x0}`, `${y0}`, `${x1}`, `${y1}`, "1000"); + }, `Failed to swipe ${direction}`); } public async swipeFromCoordinate(x: number, y: number, direction: SwipeDirection, distance?: number): Promise { - const screenSize = await this.getScreenSize(); - - let x0: number, y0: number, x1: number, y1: number; - - // Use provided distance or default to 30% of screen dimension - const defaultDistanceY = Math.floor(screenSize.height * 0.3); - const defaultDistanceX = Math.floor(screenSize.width * 0.3); - const swipeDistanceY = distance || defaultDistanceY; - const swipeDistanceX = distance || defaultDistanceX; - - switch (direction) { - case "up": - x0 = x1 = x; - y0 = y; - y1 = Math.max(0, y - swipeDistanceY); - break; - case "down": - x0 = x1 = x; - y0 = y; - y1 = Math.min(screenSize.height, y + swipeDistanceY); - break; - case "left": - x0 = x; - x1 = Math.max(0, x - swipeDistanceX); - y0 = y1 = y; - break; - case "right": - x0 = x; - x1 = Math.min(screenSize.width, x + swipeDistanceX); - y0 = y1 = y; - break; - default: - throw new ActionableError(`Swipe direction "${direction}" is not supported`); - } + return withActionableError(async () => { + const screenSize = await this.getScreenSize(); + + let x0: number, y0: number, x1: number, y1: number; + + // Use provided distance or default to 30% of screen dimension + const defaultDistanceY = Math.floor(screenSize.height * 0.3); + const defaultDistanceX = Math.floor(screenSize.width * 0.3); + const swipeDistanceY = distance || defaultDistanceY; + const swipeDistanceX = distance || defaultDistanceX; + + switch (direction) { + case "up": + x0 = x1 = x; + y0 = y; + y1 = Math.max(0, y - swipeDistanceY); + break; + case "down": + x0 = x1 = x; + y0 = y; + y1 = Math.min(screenSize.height, y + swipeDistanceY); + break; + case "left": + x0 = x; + x1 = Math.max(0, x - swipeDistanceX); + y0 = y1 = y; + break; + case "right": + x0 = x; + x1 = Math.min(screenSize.width, x + swipeDistanceX); + y0 = y1 = y; + break; + default: + throw new Error(`Swipe direction "${direction}" is not supported`); + } - this.adb("shell", "input", "swipe", `${x0}`, `${y0}`, `${x1}`, `${y1}`, "1000"); + this.adb("shell", "input", "swipe", `${x0}`, `${y0}`, `${x1}`, `${y1}`, "1000"); + }, `Failed to swipe ${direction} from coordinates (${x}, ${y})`); } private getDisplayCount(): number { @@ -292,20 +299,22 @@ export class AndroidRobot implements Robot { } public async getScreenshot(): Promise { - if (this.getDisplayCount() <= 1) { - // backward compatibility for android 10 and below, and for single display devices - return this.adb("exec-out", "screencap", "-p"); - } + return withActionableError(() => { + if (this.getDisplayCount() <= 1) { + // backward compatibility for android 10 and below, and for single display devices + return this.adb("exec-out", "screencap", "-p"); + } - // find the first display that is turned on, and capture that one - const displayId = this.getFirstDisplayId(); - if (displayId === null) { - // no idea why, but we have displayCount >= 2, yet we failed to parse - // let's go with screencap's defaults and hope for the best - return this.adb("exec-out", "screencap", "-p"); - } + // find the first display that is turned on, and capture that one + const displayId = this.getFirstDisplayId(); + if (displayId === null) { + // no idea why, but we have displayCount >= 2, yet we failed to parse + // let's go with screencap's defaults and hope for the best + return this.adb("exec-out", "screencap", "-p"); + } - return this.adb("exec-out", "screencap", "-p", "-d", `${displayId}`); + return this.adb("exec-out", "screencap", "-p", "-d", `${displayId}`); + }, "Failed to take screenshot"); } private collectElements(node: UiAutomatorXmlNode): ScreenElement[] { @@ -348,40 +357,40 @@ export class AndroidRobot implements Robot { } public async getElementsOnScreen(): Promise { - const parsedXml = await this.getUiAutomatorXml(); - const hierarchy = parsedXml.hierarchy; - const elements = this.collectElements(hierarchy.node); - return elements; + return withActionableError(async () => { + const parsedXml = await this.getUiAutomatorXml(); + const hierarchy = parsedXml.hierarchy; + const elements = this.collectElements(hierarchy.node); + return elements; + }, "Failed to get elements on screen"); } public async terminateApp(packageName: string): Promise { - this.adb("shell", "am", "force-stop", packageName); + return withActionableError( + () => { this.adb("shell", "am", "force-stop", packageName); }, + `Failed to terminate app "${packageName}"` + ); } public async installApp(path: string): Promise { - try { - this.adb("install", "-r", path); - } catch (error: any) { - const stdout = error.stdout ? error.stdout.toString() : ""; - const stderr = error.stderr ? error.stderr.toString() : ""; - const output = (stdout + stderr).trim(); - throw new ActionableError(output || error.message); - } + return withActionableError( + () => { this.adb("install", "-r", path); }, + `Failed to install app from "${path}"` + ); } public async uninstallApp(bundleId: string): Promise { - try { - this.adb("uninstall", bundleId); - } catch (error: any) { - const stdout = error.stdout ? error.stdout.toString() : ""; - const stderr = error.stderr ? error.stderr.toString() : ""; - const output = (stdout + stderr).trim(); - throw new ActionableError(output || error.message); - } + return withActionableError( + () => { this.adb("uninstall", bundleId); }, + `Failed to uninstall app "${bundleId}"` + ); } public async openUrl(url: string): Promise { - this.adb("shell", "am", "start", "-a", "android.intent.action.VIEW", "-d", url); + return withActionableError( + () => { this.adb("shell", "am", "start", "-a", "android.intent.action.VIEW", "-d", url); }, + `Failed to open URL "${url}"` + ); } private isAscii(text: string): boolean { @@ -435,12 +444,18 @@ export class AndroidRobot implements Robot { } public async tap(x: number, y: number): Promise { - this.adb("shell", "input", "tap", `${x}`, `${y}`); + return withActionableError( + () => { this.adb("shell", "input", "tap", `${x}`, `${y}`); }, + `Failed to tap at coordinates (${x}, ${y})` + ); } public async longPress(x: number, y: number): Promise { - // a long press is a swipe with no movement and a long duration - this.adb("shell", "input", "swipe", `${x}`, `${y}`, `${x}`, `${y}`, "500"); + return withActionableError( + // a long press is a swipe with no movement and a long duration + () => { this.adb("shell", "input", "swipe", `${x}`, `${y}`, `${x}`, `${y}`, "500"); }, + `Failed to long press at coordinates (${x}, ${y})` + ); } public async doubleTap(x: number, y: number): Promise { @@ -450,16 +465,20 @@ export class AndroidRobot implements Robot { } public async setOrientation(orientation: Orientation): Promise { - const value = orientation === "portrait" ? 0 : 1; + return withActionableError(() => { + const value = orientation === "portrait" ? 0 : 1; - // disable auto-rotation prior to setting the orientation - this.adb("shell", "settings", "put", "system", "accelerometer_rotation", "0"); - this.adb("shell", "content", "insert", "--uri", "content://settings/system", "--bind", "name:s:user_rotation", "--bind", `value:i:${value}`); + // disable auto-rotation prior to setting the orientation + this.adb("shell", "settings", "put", "system", "accelerometer_rotation", "0"); + this.adb("shell", "content", "insert", "--uri", "content://settings/system", "--bind", "name:s:user_rotation", "--bind", `value:i:${value}`); + }, `Failed to set orientation to "${orientation}"`); } public async getOrientation(): Promise { - const rotation = this.adb("shell", "settings", "get", "system", "user_rotation").toString().trim(); - return rotation === "0" ? "portrait" : "landscape"; + return withActionableError(() => { + const rotation = this.adb("shell", "settings", "get", "system", "user_rotation").toString().trim(); + return rotation === "0" ? "portrait" : "landscape"; + }, "Failed to get orientation"); } private async getUiAutomatorDump(): Promise { diff --git a/src/ios.ts b/src/ios.ts index 40213675..06d8de41 100644 --- a/src/ios.ts +++ b/src/ios.ts @@ -2,7 +2,7 @@ import { Socket } from "node:net"; import { execFileSync } from "node:child_process"; import { WebDriverAgent } from "./webdriver-agent"; -import { ActionableError, Button, InstalledApp, Robot, ScreenSize, SwipeDirection, ScreenElement, Orientation } from "./robot"; +import { ActionableError, Button, InstalledApp, Robot, ScreenSize, SwipeDirection, ScreenElement, Orientation, withActionableError } from "./robot"; const WDA_PORT = 8100; const IOS_TUNNEL_PORT = 60105; @@ -125,50 +125,50 @@ export class IosRobot implements Robot { public async listApps(): Promise { await this.assertTunnelRunning(); - const output = await this.ios("apps", "--all", "--list"); - return output - .split("\n") - .map(line => { - const [packageName, appName] = line.split(" "); - return { - packageName, - appName, - }; - }); + return withActionableError(async () => { + const output = await this.ios("apps", "--all", "--list"); + return output + .split("\n") + .map(line => { + const [packageName, appName] = line.split(" "); + return { + packageName, + appName, + }; + }); + }, "Failed to list installed apps"); } public async launchApp(packageName: string): Promise { await this.assertTunnelRunning(); - await this.ios("launch", packageName); + await withActionableError( + () => { this.ios("launch", packageName); }, + `Failed to launch app "${packageName}". Please make sure it exists` + ); } public async terminateApp(packageName: string): Promise { await this.assertTunnelRunning(); - await this.ios("kill", packageName); + await withActionableError( + () => { this.ios("kill", packageName); }, + `Failed to terminate app "${packageName}"` + ); } public async installApp(path: string): Promise { await this.assertTunnelRunning(); - try { - await this.ios("install", "--path", path); - } catch (error: any) { - const stdout = error.stdout ? error.stdout.toString() : ""; - const stderr = error.stderr ? error.stderr.toString() : ""; - const output = (stdout + stderr).trim(); - throw new ActionableError(output || error.message); - } + await withActionableError( + () => { this.ios("install", "--path", path); }, + `Failed to install app from "${path}"` + ); } public async uninstallApp(bundleId: string): Promise { await this.assertTunnelRunning(); - try { - await this.ios("uninstall", "--bundleid", bundleId); - } catch (error: any) { - const stdout = error.stdout ? error.stdout.toString() : ""; - const stderr = error.stderr ? error.stderr.toString() : ""; - const output = (stdout + stderr).trim(); - throw new ActionableError(output || error.message); - } + await withActionableError( + () => { this.ios("uninstall", "--bundleid", bundleId); }, + `Failed to uninstall app "${bundleId}"` + ); } public async openUrl(url: string): Promise { diff --git a/src/mobile-device.ts b/src/mobile-device.ts index bd068ab6..75039575 100644 --- a/src/mobile-device.ts +++ b/src/mobile-device.ts @@ -1,5 +1,5 @@ import { Mobilecli } from "./mobilecli"; -import { Button, InstalledApp, Orientation, Robot, ScreenElement, ScreenSize, SwipeDirection } from "./robot"; +import { Button, InstalledApp, Orientation, Robot, ScreenElement, ScreenSize, SwipeDirection, withActionableError } from "./robot"; interface InstalledAppsResponse { status: "ok", @@ -73,11 +73,13 @@ export class MobileDevice implements Robot { } public async getScreenSize(): Promise { - const response = JSON.parse(this.runCommand(["device", "info"])) as DeviceInfoResponse; - if (response.data.device.screenSize) { - return response.data.device.screenSize; - } - return { width: 0, height: 0, scale: 1.0 }; + return withActionableError(() => { + const response = JSON.parse(this.runCommand(["device", "info"])) as DeviceInfoResponse; + if (response.data.device.screenSize) { + return response.data.device.screenSize; + } + return { width: 0, height: 0, scale: 1.0 }; + }, "Failed to get screen size"); } public async swipe(direction: SwipeDirection): Promise { @@ -114,71 +116,101 @@ export class MobileDevice implements Robot { } public async swipeFromCoordinate(x: number, y: number, direction: SwipeDirection, distance?: number): Promise { - const swipeDistance = distance || 400; - let endX = x; - let endY = y; - - switch (direction) { - case "up": - endY = y - swipeDistance; - break; - case "down": - endY = y + swipeDistance; - break; - case "left": - endX = x - swipeDistance; - break; - case "right": - endX = x + swipeDistance; - break; - } - - this.runCommand(["io", "swipe", `${x},${y},${endX},${endY}`]); + return withActionableError(async () => { + const swipeDistance = distance || 400; + let endX = x; + let endY = y; + + switch (direction) { + case "up": + endY = y - swipeDistance; + break; + case "down": + endY = y + swipeDistance; + break; + case "left": + endX = x - swipeDistance; + break; + case "right": + endX = x + swipeDistance; + break; + } + + this.runCommand(["io", "swipe", `${x},${y},${endX},${endY}`]); + }, `Failed to swipe ${direction} from coordinates (${x}, ${y})`); } public async getScreenshot(): Promise { - const fullArgs = ["screenshot", "--device", this.deviceId, "--format", "png", "--output", "-"]; - return this.mobilecli.executeCommandBuffer(fullArgs); + return withActionableError(() => { + const fullArgs = ["screenshot", "--device", this.deviceId, "--format", "png", "--output", "-"]; + return this.mobilecli.executeCommandBuffer(fullArgs); + }, "Failed to take screenshot"); } public async listApps(): Promise { - const response = JSON.parse(this.runCommand(["apps", "list"])) as InstalledAppsResponse; - return response.data.map(app => ({ - appName: app.appName || app.packageName, - packageName: app.packageName, - })) as InstalledApp[]; + return withActionableError(() => { + const response = JSON.parse(this.runCommand(["apps", "list"])) as InstalledAppsResponse; + return response.data.map(app => ({ + appName: app.appName || app.packageName, + packageName: app.packageName, + })) as InstalledApp[]; + }, "Failed to list installed apps"); } public async launchApp(packageName: string): Promise { - this.runCommand(["apps", "launch", packageName]); + return withActionableError( + () => { this.runCommand(["apps", "launch", packageName]); }, + `Failed to launch app "${packageName}". Please make sure it exists` + ); } public async terminateApp(packageName: string): Promise { - this.runCommand(["apps", "terminate", packageName]); + return withActionableError( + () => { this.runCommand(["apps", "terminate", packageName]); }, + `Failed to terminate app "${packageName}"` + ); } public async installApp(path: string): Promise { - this.runCommand(["apps", "install", path]); + return withActionableError( + () => { this.runCommand(["apps", "install", path]); }, + `Failed to install app from "${path}"` + ); } public async uninstallApp(bundleId: string): Promise { - this.runCommand(["apps", "uninstall", bundleId]); + return withActionableError( + () => { this.runCommand(["apps", "uninstall", bundleId]); }, + `Failed to uninstall app "${bundleId}"` + ); } public async openUrl(url: string): Promise { - this.runCommand(["url", url]); + return withActionableError( + () => { this.runCommand(["url", url]); }, + `Failed to open URL "${url}"` + ); } public async sendKeys(text: string): Promise { - this.runCommand(["io", "text", text]); + return withActionableError( + () => { this.runCommand(["io", "text", text]); }, + "Failed to send keys" + ); } public async pressButton(button: Button): Promise { - this.runCommand(["io", "button", button]); + return withActionableError( + () => { this.runCommand(["io", "button", button]); }, + `Failed to press button "${button}"` + ); } public async tap(x: number, y: number): Promise { - this.runCommand(["io", "tap", `${x},${y}`]); + return withActionableError( + () => { this.runCommand(["io", "tap", `${x},${y}`]); }, + `Failed to tap at coordinates (${x}, ${y})` + ); } public async doubleTap(x: number, y: number): Promise { @@ -188,29 +220,39 @@ export class MobileDevice implements Robot { } public async longPress(x: number, y: number): Promise { - this.runCommand(["io", "longpress", `${x},${y}`]); + return withActionableError( + () => { this.runCommand(["io", "longpress", `${x},${y}`]); }, + `Failed to long press at coordinates (${x}, ${y})` + ); } public async getElementsOnScreen(): Promise { - const response = JSON.parse(this.runCommand(["dump", "ui"])) as DumpUIResponse; - return response.data.elements.map(element => ({ - type: element.type, - label: element.label, - text: element.text, - name: element.name, - value: element.value, - identifier: element.identifier, - rect: element.rect, - focused: element.focused, - })); + return withActionableError(() => { + const response = JSON.parse(this.runCommand(["dump", "ui"])) as DumpUIResponse; + return response.data.elements.map(element => ({ + type: element.type, + label: element.label, + text: element.text, + name: element.name, + value: element.value, + identifier: element.identifier, + rect: element.rect, + focused: element.focused, + })); + }, "Failed to get elements on screen"); } public async setOrientation(orientation: Orientation): Promise { - this.runCommand(["device", "orientation", "set", orientation]); + return withActionableError( + () => { this.runCommand(["device", "orientation", "set", orientation]); }, + `Failed to set orientation to "${orientation}"` + ); } public async getOrientation(): Promise { - const response = JSON.parse(this.runCommand(["device", "orientation", "get"])) as OrientationResponse; - return response.data.orientation; + return withActionableError(() => { + const response = JSON.parse(this.runCommand(["device", "orientation", "get"])) as OrientationResponse; + return response.data.orientation; + }, "Failed to get orientation"); } } diff --git a/src/robot.ts b/src/robot.ts index d6fec4fb..fdbe87c0 100644 --- a/src/robot.ts +++ b/src/robot.ts @@ -39,6 +39,34 @@ export interface ScreenElement { export class ActionableError extends Error { constructor(message: string) { super(message); + this.name = "ActionableError"; + } +} + +/** + * Extracts a user-friendly error message from a command execution error. + * Handles both stdout and stderr from child process errors. + */ +export function extractCommandError(error: any): string { + const stdout = error.stdout ? error.stdout.toString().trim() : ""; + const stderr = error.stderr ? error.stderr.toString().trim() : ""; + const combined = [stdout, stderr].filter(Boolean).join(" "); + return combined || error.message || "Unknown error occurred"; +} + +/** + * Wraps a command execution in consistent error handling. + * Converts any error to an ActionableError with a user-friendly message. + */ +export async function withActionableError( + operation: () => T | Promise, + errorContext: string +): Promise { + try { + return await operation(); + } catch (error: any) { + const errorMessage = extractCommandError(error); + throw new ActionableError(`${errorContext}: ${errorMessage}`); } }