UNPKG

mcp-appium-visual

Version:

MCP Server for Appium mobile automation with visual recovery

1,226 lines 69.2 kB
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