mcp-appium-visual
Version:
MCP Server for Appium mobile automation with visual recovery
1,226 lines • 69.2 kB
JavaScript
import { remote, } from "webdriverio";
import * as fs from "fs/promises";
import * as path from "path";
import { AppiumError } from "./appiumError.js";
/**
* Helper class for Appium operations
*/
export class AppiumHelper {
/**
* Create a new AppiumHelper instance
*
* @param screenshotDir Directory to save screenshots to
*/
constructor(screenshotDir = "./screenshots") {
this.driver = null;
this.maxRetries = 3;
this.retryDelay = 1000;
this.lastCapabilities = null;
this.lastAppiumUrl = null;
this.screenshotDir = screenshotDir;
}
/**
* Initialize the Appium driver with provided capabilities
*
* @param capabilities Appium capabilities
* @param appiumUrl Appium server URL
* @returns Reference to the initialized driver
*/
async initializeDriver(capabilities, appiumUrl = "http://localhost:4723") {
try {
// Source .bash_profile to ensure all environment variables are loaded
try {
// Only run this on Unix-like systems (macOS, Linux)
if (process.platform !== "win32") {
const { execSync } = require("child_process");
execSync("source ~/.bash_profile 2>/dev/null || true", {
shell: "/bin/bash",
});
console.log("Sourced .bash_profile for environment setup");
}
}
catch (envError) {
console.warn("Could not source .bash_profile, continuing anyway:", envError instanceof Error ? envError.message : String(envError));
}
// Store the capabilities and URL for potential session recovery
this.lastCapabilities = { ...capabilities };
this.lastAppiumUrl = appiumUrl;
// Add 'appium:' prefix to all non-standard capabilities
const formattedCapabilities = {};
for (const [key, value] of Object.entries(capabilities)) {
// platformName doesn't need a prefix, everything else does
if (key === "platformName") {
formattedCapabilities[key] = value;
}
else {
formattedCapabilities[`appium:${key}`] = value;
}
}
const options = {
hostname: new URL(appiumUrl).hostname,
port: parseInt(new URL(appiumUrl).port),
path: "/wd/hub",
connectionRetryCount: 3,
logLevel: "error",
capabilities: formattedCapabilities,
};
this.driver = await remote(options);
return this.driver;
}
catch (error) {
throw new AppiumError(`Failed to initialize Appium driver: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Check if the session is still valid and attempt to recover if not
*
* @returns true if session is valid or was successfully recovered
*/
async validateSession() {
if (!this.driver) {
return false;
}
try {
// Simple check to see if session is still valid
await this.driver.getPageSource();
return true;
}
catch (error) {
// Check if the error is a NoSuchDriverError or session terminated error
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes("NoSuchDriverError") ||
errorMessage.includes("terminated") ||
errorMessage.includes("not started")) {
console.log("Appium session terminated, attempting to recover...");
// Try to recover the session if we have the last capabilities
if (this.lastCapabilities && this.lastAppiumUrl) {
try {
// Close the existing driver first to clean up
try {
await this.driver.deleteSession();
}
catch {
// Ignore errors when trying to delete an already terminated session
}
this.driver = null;
// Re-initialize with the stored capabilities
await this.initializeDriver(this.lastCapabilities, this.lastAppiumUrl);
console.log("Session recovery successful");
return true;
}
catch (recoveryError) {
console.error("Session recovery failed:", recoveryError instanceof Error
? recoveryError.message
: String(recoveryError));
return false;
}
}
}
return false;
}
}
/**
* Safely execute an Appium command with session validation
*
* @param operation Function that performs the Appium operation
* @param errorMessage Error message to throw if operation fails
* @returns Result of the operation
*/
async safeExecute(operation, errorMessage) {
try {
return await operation();
}
catch (error) {
// Try to recover the session if it's terminated
if (await this.validateSession()) {
// If session recovery was successful, try the operation again
try {
return await operation();
}
catch (retryError) {
throw new AppiumError(`${errorMessage}: ${retryError instanceof Error
? retryError.message
: String(retryError)}`, retryError instanceof Error ? retryError : undefined);
}
}
throw new AppiumError(`${errorMessage}: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Get the current driver instance
*
* @returns The driver instance or throws if not initialized
*/
getDriver() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
return this.driver;
}
/**
* Close the Appium session
*/
async closeDriver() {
if (this.driver) {
try {
await this.driver.deleteSession();
}
catch (error) {
console.warn("Error while closing Appium session:", error instanceof Error ? error.message : String(error));
// We don't rethrow here as we want to clean up regardless of errors
}
finally {
this.driver = null;
}
}
}
/**
* Take a screenshot and save it to the specified directory
*
* @param name Screenshot name
* @returns Path to the saved screenshot
*/
async takeScreenshot(name) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
try {
await fs.mkdir(this.screenshotDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `${name}_${timestamp}.png`;
const filepath = path.join(this.screenshotDir, filename);
const screenshot = await this.driver.takeScreenshot();
await fs.writeFile(filepath, Buffer.from(screenshot, "base64"));
return filepath;
}
catch (error) {
throw new AppiumError(`Failed to take screenshot: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Check if an element exists
*
* @param selector Element selector
* @param strategy Selection strategy
* @returns true if the element exists
*/
async elementExists(selector, strategy = "xpath") {
try {
await this.findElement(selector, strategy);
return true;
}
catch {
return false;
}
}
/**
* Find an element by its selector with retry mechanism
*
* @param selector Element selector
* @param strategy Selection strategy
* @param timeoutMs Timeout in milliseconds
* @returns WebdriverIO element if found
*/
async findElement(selector, strategy = "xpath", timeoutMs = 10000) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
return this.safeExecute(async () => {
const startTime = Date.now();
let lastError;
while (Date.now() - startTime < timeoutMs) {
try {
let element;
switch (strategy.toLowerCase()) {
case "id":
element = await this.driver.$(`id=${selector}`);
break;
case "xpath":
element = await this.driver.$(`${selector}`);
break;
case "accessibility id":
element = await this.driver.$(`~${selector}`);
break;
case "class name":
element = await this.driver.$(`${selector}`);
break;
default:
element = await this.driver.$(`${selector}`);
}
await element.waitForExist({ timeout: timeoutMs });
return element;
}
catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
await new Promise((resolve) => setTimeout(resolve, this.retryDelay));
}
}
throw new AppiumError(`Failed to find element with selector ${selector} after ${timeoutMs}ms: ${lastError?.message}`, lastError);
}, `Failed to find element with selector ${selector}`);
}
/**
* Find multiple elements by selector
*
* @param selector Element selector
* @param strategy Selection strategy
* @returns Array of WebdriverIO elements
*/
async findElements(selector, strategy = "xpath") {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
try {
let elements;
switch (strategy.toLowerCase()) {
case "id":
elements = await this.driver.$$(`id=${selector}`);
break;
case "xpath":
elements = await this.driver.$$(`${selector}`);
break;
case "accessibility id":
elements = await this.driver.$$(`~${selector}`);
break;
case "class name":
elements = await this.driver.$$(`${selector}`);
break;
default:
elements = await this.driver.$$(`${selector}`);
}
return elements;
}
catch (error) {
throw new AppiumError(`Failed to find elements with selector ${selector}: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Tap on an element with retry mechanism
* Uses W3C Actions API with fallback to TouchAction API for compatibility
*
* @param selector Element selector
* @param strategy Selection strategy
* @returns true if successful
* @throws AppiumError if the operation fails after retries
*/
async tapElement(selector, strategy = "accessibility") {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
return this.safeExecute(async () => {
let lastError;
console.log(`Attempting to tap element with ${strategy}: ${selector}`);
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
let element;
console.log(`Attempt ${attempt}/${this.maxRetries}`);
// Handle different selector strategies
switch (strategy.toLowerCase()) {
case "accessibility id":
console.log(`Using accessibility ID strategy: ~${selector}`);
element = await this.driver.$(`~${selector}`);
break;
case "id":
case "resource id":
console.log(`Using ID strategy: id=${selector}`);
element = await this.driver.$(`id=${selector}`);
break;
case "android uiautomator":
case "uiautomator":
console.log(`Using Android UiAutomator strategy: android=${selector}`);
element = await this.driver.$(`android=${selector}`);
break;
case "xpath":
console.log(`Using XPath strategy: ${selector}`);
element = await this.driver.$(`${selector}`);
break;
default:
console.log(`Using default strategy: ${selector}`);
element = await this.driver.$(`${selector}`);
}
// Make sure element is visible - we avoid waitForClickable since it's not supported in mobile native environments
console.log("Waiting for element to be visible...");
await element.waitForDisplayed({ timeout: 5000 });
try {
// Some elements don't support enabled state, so we'll try but not fail if not supported
await element.waitForEnabled({ timeout: 2000 });
}
catch (enabledError) {
console.log("Note: Element doesn't support enabled state check, continuing anyway");
}
// Add a small pause to ensure element is fully loaded
await new Promise((resolve) => setTimeout(resolve, 300));
// First try using standard element click() method
try {
console.log("Attempting standard click");
await element.click();
}
catch (clickError) {
console.log(`Standard click failed: ${clickError instanceof Error
? clickError.message
: String(clickError)}`);
// Get element location and size for tap coordinates
const location = await element.getLocation();
const size = await element.getSize();
const centerX = location.x + size.width / 2;
const centerY = location.y + size.height / 2;
try {
// Try W3C Actions API first (modern approach)
console.log("Attempting W3C Actions API tap");
const actions = [
{
type: "pointer",
id: "finger1",
parameters: { pointerType: "touch" },
actions: [
// Move to element center
{
type: "pointerMove",
duration: 0,
x: centerX,
y: centerY,
},
// Press down
{ type: "pointerDown", button: 0 },
// Short wait
{ type: "pause", duration: 100 },
// Release
{ type: "pointerUp", button: 0 },
],
},
];
await this.driver.performActions(actions);
}
catch (w3cError) {
// If W3C Actions fail, fall back to TouchAction API
console.log("W3C Actions failed, falling back to TouchAction API");
await this.driver.touchAction([
{
action: "tap",
x: centerX,
y: centerY,
},
]);
}
}
// Small pause after click to let UI update
await new Promise((resolve) => setTimeout(resolve, 500));
console.log("Tap action completed successfully");
return true;
}
catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.log(`Tap attempt ${attempt} failed: ${lastError.message}`);
if (attempt < this.maxRetries) {
const pauseTime = this.retryDelay * attempt; // Gradually increase wait time
console.log(`Will retry in ${pauseTime}ms`);
await new Promise((resolve) => setTimeout(resolve, pauseTime));
}
}
}
throw new AppiumError(`Failed to tap element with selector ${selector} using strategy ${strategy} after ${this.maxRetries} attempts: ${lastError?.message}`, lastError);
}, `Failed to tap element with selector ${selector} using strategy ${strategy}`);
}
/**
* Click on an element - alias for tapElement for better Selenium compatibility
*
* @param selector Element selector
* @param strategy Selection strategy
* @returns true if successful
* @throws AppiumError if the operation fails after retries
*/
async click(selector, strategy = "xpath") {
return this.tapElement(selector, strategy);
}
/**
* Send keys to an element with retry mechanism
*
* @param selector Element selector
* @param text Text to send
* @param strategy Selection strategy
* @returns true if successful
* @throws AppiumError if the operation fails after retries
*/
async sendKeys(selector, text, strategy = "xpath") {
let lastError;
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
const element = await this.findElement(selector, strategy);
await element.waitForEnabled({ timeout: 5000 });
await element.setValue(text);
return true;
}
catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < this.maxRetries) {
await new Promise((resolve) => setTimeout(resolve, this.retryDelay));
}
}
}
throw new AppiumError(`Failed to send keys to element with selector ${selector} after ${this.maxRetries} attempts: ${lastError?.message}`, lastError);
}
/**
* Get the page source (XML representation of the current UI)
*
* @param refreshFirst Whether to try refreshing the UI before getting page source
* @param suppressErrors Whether to suppress specific iOS errors and return empty source
* @returns XML string of the current UI
*/
async getPageSource(refreshFirst = false, suppressErrors = true) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
return this.safeExecute(async () => {
try {
// Refresh the page if requested
if (refreshFirst) {
// For native apps, we can do a small swipe down and back up to refresh the UI
const size = await this.driver.getWindowSize();
const centerX = size.width / 2;
const startY = size.height * 0.3;
const endY = size.height * 0.4;
// Swipe down slightly
await this.swipe(centerX, startY, centerX, endY, 300);
// Small pause
await new Promise((resolve) => setTimeout(resolve, 500));
// Swipe back up
await this.swipe(centerX, endY, centerX, startY, 300);
// Wait for refresh to complete
await new Promise((resolve) => setTimeout(resolve, 1000));
}
// Try getting the page source
return await this.driver.getPageSource();
}
catch (error) {
// Handle iOS-specific XCUITest errors
if (suppressErrors && error instanceof Error) {
const errorMessage = error.message || "";
// Check for known iOS automation issues
if (errorMessage.includes("waitForQuiescenceIncludingAnimationsIdle") ||
errorMessage.includes("unrecognized selector sent to instance") ||
errorMessage.includes("failed to get page source")) {
console.warn("iOS source retrieval warning: Using fallback due to animation or UI state issue.");
// Wait a bit for potential animations to complete
await new Promise((resolve) => setTimeout(resolve, 1500));
try {
// Try again with direct call (might work)
return await this.driver.getPageSource();
}
catch (retryError) {
// Return empty source with warning
return `<AppRoot><Warning>Source unavailable due to iOS animation state issues</Warning></AppRoot>`;
}
}
}
// Rethrow other errors
throw new AppiumError(`Failed to get page source: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}, "Failed to get page source");
}
/**
* Perform a swipe gesture
*
* @param startX Starting X coordinate
* @param startY Starting Y coordinate
* @param endX Ending X coordinate
* @param endY Ending Y coordinate
* @param duration Swipe duration in milliseconds
* @returns true if successful
*/
async swipe(startX, startY, endX, endY, duration = 800) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
try {
await this.driver.touchAction([
{ action: "press", x: startX, y: startY },
{ action: "wait", ms: duration },
{ action: "moveTo", x: endX, y: endY },
"release",
]);
return true;
}
catch (error) {
throw new AppiumError(`Failed to perform swipe: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Wait for an element to be present
*
* @param selector Element selector
* @param strategy Selection strategy
* @param timeoutMs Timeout in milliseconds
* @returns true if the element is found within the timeout
*/
async waitForElement(selector, strategy = "xpath", timeoutMs = 10000) {
try {
await this.findElement(selector, strategy, timeoutMs);
return true;
}
catch {
return false;
}
}
/**
* Long press on an element
*/
async longPress(selector, duration = 1000, strategy = "xpath") {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
const element = await this.findElement(selector, strategy);
const location = await element.getLocation();
await this.driver.touchAction([
{ action: "press", x: location.x, y: location.y },
{ action: "wait", ms: duration },
"release",
]);
return true;
}
catch (error) {
throw new AppiumError(`Failed to long press element: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Scroll to an element
*
* @param selector Element selector to scroll to
* @param direction Direction to scroll ('up', 'down', 'left', 'right')
* @param strategy Selection strategy
* @param maxScrolls Maximum number of scroll attempts
* @returns true if element was found and scrolled to
*/
async scrollToElement(selector, direction = "down", strategy = "xpath", maxScrolls = 10) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
for (let i = 0; i < maxScrolls; i++) {
if (await this.elementExists(selector, strategy)) {
return true;
}
const size = await this.driver.getWindowSize();
const startX = size.width / 2;
const startY = size.height * (direction === "up" ? 0.3 : 0.7);
const endY = size.height * (direction === "up" ? 0.7 : 0.3);
const endX = direction === "left"
? size.width * 0.9
: direction === "right"
? size.width * 0.1
: startX;
// Use W3C Actions API instead of TouchAction API
const actions = [
{
type: "pointer",
id: "finger1",
parameters: { pointerType: "touch" },
actions: [
// Move to start position
{ type: "pointerMove", duration: 0, x: startX, y: startY },
// Press down
{ type: "pointerDown", button: 0 },
// Move to end position over duration milliseconds
{
type: "pointerMove",
duration: 800,
origin: "viewport",
x: endX,
y: endY,
},
// Release
{ type: "pointerUp", button: 0 },
],
},
];
// Execute the W3C Actions
await this.driver.performActions(actions);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
return false;
}
catch (error) {
throw new AppiumError(`Failed to scroll to element: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Get device orientation
*/
async getOrientation() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
const orientation = await this.driver.getOrientation();
return orientation.toUpperCase();
}
catch (error) {
throw new AppiumError(`Failed to get orientation: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Set device orientation
*
* @param orientation Desired orientation ('PORTRAIT' or 'LANDSCAPE')
*/
async setOrientation(orientation) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
await this.driver.setOrientation(orientation);
}
catch (error) {
throw new AppiumError(`Failed to set orientation: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Hide the keyboard if visible
*/
async hideKeyboard() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
const isKeyboardShown = await this.driver.isKeyboardShown();
if (isKeyboardShown) {
await this.driver.hideKeyboard();
}
}
catch (error) {
throw new AppiumError(`Failed to hide keyboard: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Get the current activity (Android) or bundle ID (iOS)
*/
async getCurrentPackage() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
return await this.driver.getCurrentPackage();
}
catch (error) {
throw new AppiumError(`Failed to get current package: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Get the current activity (Android only)
*/
async getCurrentActivity() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
return await this.driver.getCurrentActivity();
}
catch (error) {
throw new AppiumError(`Failed to get current activity: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Launch the app
*/
async launchApp() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
await this.driver.launchApp();
}
catch (error) {
throw new AppiumError(`Failed to launch app: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Close the app
*/
async closeApp() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
await this.driver.closeApp();
}
catch (error) {
throw new AppiumError(`Failed to close app: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Reset the app (clear app data)
*/
async resetApp() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
await this.driver.terminateApp(await this.getCurrentPackage(), {
timeout: 20000,
});
await this.driver.launchApp();
}
catch (error) {
throw new AppiumError(`Failed to reset app: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Get device time
*
* @returns Device time string
*/
async getDeviceTime() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
return await this.driver.getDeviceTime();
}
catch (error) {
throw new AppiumError(`Failed to get device time: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Get battery info (if supported by the device)
* Note: This is a custom implementation as WebdriverIO doesn't directly support this
*/
async getBatteryInfo() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
// Execute mobile command to get battery info
const result = await this.driver.executeScript("mobile: batteryInfo", []);
return {
level: result.level || 0,
state: result.state || 0,
};
}
catch (error) {
throw new AppiumError(`Failed to get battery info: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Lock the device
*
* @param duration Duration in seconds to lock the device
*/
async lockDevice(duration) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
await this.driver.lock(duration);
}
catch (error) {
throw new AppiumError(`Failed to lock device: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Check if device is locked
*/
async isDeviceLocked() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
return await this.driver.isLocked();
}
catch (error) {
throw new AppiumError(`Failed to check if device is locked: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Unlock the device
*/
async unlockDevice() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
await this.driver.unlock();
}
catch (error) {
throw new AppiumError(`Failed to unlock device: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Press a key on the device (Android only)
*
* @param keycode Android keycode
*/
async pressKeyCode(keycode) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
await this.driver.pressKeyCode(keycode);
}
catch (error) {
throw new AppiumError(`Failed to press key code: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Open notifications (Android only)
*/
async openNotifications() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
await this.driver.openNotifications();
}
catch (error) {
throw new AppiumError(`Failed to open notifications: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Get all contexts (NATIVE_APP, WEBVIEW, etc.)
*/
async getContexts() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
const contexts = await this.driver.getContexts();
return contexts.map((context) => context.toString());
}
catch (error) {
throw new AppiumError(`Failed to get contexts: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Switch context (between NATIVE_APP and WEBVIEW)
*
* @param context Context name to switch to
*/
async switchContext(context) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
await this.driver.switchContext(context);
}
catch (error) {
throw new AppiumError(`Failed to switch context: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Get current context
*/
async getCurrentContext() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
const context = await this.driver.getContext();
return context.toString();
}
catch (error) {
throw new AppiumError(`Failed to get current context: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Pull file from device
*
* @param path Path to file on device
* @returns Base64 encoded file content
*/
async pullFile(path) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
return await this.driver.pullFile(path);
}
catch (error) {
throw new AppiumError(`Failed to pull file: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Push file to device
*
* @param path Path on device to write to
* @param data Base64 encoded file content
*/
async pushFile(path, data) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
await this.driver.pushFile(path, data);
}
catch (error) {
throw new AppiumError(`Failed to push file: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Find an iOS predicate string element (iOS only)
*
* @param predicateString iOS predicate string
* @param timeoutMs Timeout in milliseconds
* @returns WebdriverIO element if found
*/
async findByIosPredicate(predicateString, timeoutMs = 10000) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
try {
const element = await this.driver.$(`-ios predicate string:${predicateString}`);
await element.waitForExist({ timeout: timeoutMs });
return element;
}
catch (error) {
throw new AppiumError(`Failed to find element with iOS predicate: ${predicateString}`, error instanceof Error ? error : undefined);
}
}
/**
* Find an iOS class chain element (iOS only)
*
* @param classChain iOS class chain
* @param timeoutMs Timeout in milliseconds
* @returns WebdriverIO element if found
*/
async findByIosClassChain(classChain, timeoutMs = 10000) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
try {
const element = await this.driver.$(`-ios class chain:${classChain}`);
await element.waitForExist({ timeout: timeoutMs });
return element;
}
catch (error) {
throw new AppiumError(`Failed to find element with iOS class chain: ${classChain}`, error instanceof Error ? error : undefined);
}
}
/**
* Get list of available iOS simulators
* Note: This method isn't tied to an Appium session, so it doesn't require an initialized driver
* This uses the executeScript capability of WebdriverIO to run a mobile command
*
* @returns Array of simulator objects
*/
async getIosSimulators() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
try {
const result = await this.driver.executeScript("mobile: listSimulators", []);
return result.devices || [];
}
catch (error) {
throw new AppiumError(`Failed to get iOS simulators list: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Perform iOS-specific touch ID (fingerprint) simulation
*
* @param match Whether the fingerprint should match (true) or not match (false)
* @returns true if successful
*/
async performTouchId(match) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
try {
await this.driver.executeScript("mobile: performTouchId", [{ match }]);
return true;
}
catch (error) {
throw new AppiumError(`Failed to perform Touch ID: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Simulate iOS shake gesture
*
* @returns true if successful
*/
async shakeDevice() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
try {
await this.driver.executeScript("mobile: shake", []);
return true;
}
catch (error) {
throw new AppiumError(`Failed to shake device: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Start recording the screen on iOS or Android device
*
* @param options Recording options
* @returns true if recording started successfully
*/
async startRecording(options) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
try {
const opts = options || {};
await this.driver.startRecordingScreen(opts);
return true;
}
catch (error) {
throw new AppiumError(`Failed to start screen recording: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Stop recording the screen and get the recording content as base64
*
* @returns Base64-encoded recording data
*/
async stopRecording() {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
try {
const recording = await this.driver.stopRecordingScreen();
return recording;
}
catch (error) {
throw new AppiumError(`Failed to stop screen recording: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Execute a custom mobile command
*
* @param command Mobile command to execute
* @param args Arguments for the command
* @returns Command result
*/
async executeMobileCommand(command, args = []) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized. Call initializeDriver first.");
}
try {
return await this.driver.executeScript(`mobile: ${command}`, args);
}
catch (error) {
throw new AppiumError(`Failed to execute mobile command '${command}': ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Get text from an element
*
* @param selector Element selector
* @param strategy Selection strategy
* @returns Text content of the element
* @throws AppiumError if element is not found or has no text
*/
async getText(selector, strategy = "xpath") {
try {
const element = await this.findElement(selector, strategy);
const text = await element.getText();
return text;
}
catch (error) {
throw new AppiumError(`Failed to get text from element with selector ${selector}: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Send keys directly to the device (without focusing on an element)
*
* @param text Text to send
* @returns true if successful
*/
async sendKeysToDevice(text) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
await this.driver.keys(text.split(""));
return true;
}
catch (error) {
throw new AppiumError(`Failed to send keys to device: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Send key events to the device (e.g. HOME button, BACK button)
*
* @param keyEvent Key event name or code
* @returns true if successful
*/
async sendKeyEvent(keyEvent) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
if (typeof keyEvent === "string") {
// For named key events like "home", "back"
await this.driver.keys(keyEvent);
}
else {
// For numeric key codes
await this.driver.pressKeyCode(keyEvent);
}
return true;
}
catch (error) {
throw new AppiumError(`Failed to send key event ${keyEvent}: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Clear text from an input element
*
* @param selector Element selector
* @param strategy Selection strategy
* @returns true if successful
*/
async clearElement(selector, strategy = "xpath") {
try {
const element = await this.findElement(selector, strategy);
await element.clearValue();
return true;
}
catch (error) {
throw new AppiumError(`Failed to clear element with selector ${selector}: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
}
}
/**
* Scroll using predefined directions - scrollDown, scrollUp, scrollLeft, scrollRight
* Implemented using W3C Actions API for better compatibility with modern Appium versions
*
* @param direction Direction to scroll: "down", "up", "left", "right"
* @param distance Optional percentage of screen to scroll (0.0-1.0), defaults to 0.5
* @returns true if successful
*/
async scrollScreen(direction, distance = 0.5) {
if (!this.driver) {
throw new AppiumError("Appium driver not initialized");
}
try {
const size = await this.driver.getWindowSize();
const midX = size.width / 2;
const midY = size.height / 2;
let startX, startY, endX, endY;
// Calculate start and end points based on direction and screen size
switch (direction) {
case "down":
startX = midX;
startY = size.height * 0.7; // Start near bottom
endX = midX;
endY = size.height * (0.7 - distance); // Move up
break;
case "up":
startX = midX;
startY = size.height * 0.3; // Start near top
endX = midX;
endY = size.height * (0.3 + distance); // Move down
break;
case "right":
startX = size.width * 0.7; // Start near right edge
startY = midY;
endX = size.width * (0.7 - distance); // Move left
endY = midY;
break;
case "left":
startX = size.width * 0.3; // Start near left edge
startY = midY;
endX = size.width * (0.3 + distance); // Move right
endY = midY;
break;
}
// Use W3C Actions API - co