Files
chrome-use/src/ios-manager.ts
T
Chris Tate a1b992411e add iOS support (#358)
* ios

* tests

* docs

* real device

* better list

* fixes
2026-02-03 01:36:19 -06:00

1300 lines
33 KiB
TypeScript

/**
* iOS Simulator Manager - Manages iOS Simulator and Safari automation via Appium.
*
* This provides 1:1 command parity with BrowserManager for iOS Safari.
*/
// Declare browser globals used in execute() callbacks - these run in browser context, not Node
declare const document: any;
declare const window: any;
import { Simctl } from 'node-simctl';
import { remote, type Browser as WDIOBrowser } from 'webdriverio';
import { spawn, type ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
// Ref map for element targeting (mirrors snapshot.ts)
export interface IOSRefMap {
[ref: string]: {
selector: string;
role?: string;
name?: string;
xpath?: string;
};
}
export interface IOSEnhancedSnapshot {
tree: string;
refs: IOSRefMap;
}
interface ConsoleMessage {
type: string;
text: string;
timestamp: number;
}
interface IOSDeviceInfo {
name: string;
udid: string;
state: string;
runtime: string;
isAvailable: boolean;
isRealDevice?: boolean;
}
/**
* Manages iOS Simulator and Safari automation via Appium
*/
export class IOSManager {
private simctl: Simctl;
private browser: WDIOBrowser | null = null;
private appiumProcess: ChildProcess | null = null;
private deviceUdid: string | null = null;
private deviceName: string | null = null;
private consoleMessages: ConsoleMessage[] = [];
private refMap: IOSRefMap = {};
private lastSnapshot: string = '';
private refCounter: number = 0;
// Default Appium port
private static readonly APPIUM_PORT = 4723;
private static readonly APPIUM_HOST = '127.0.0.1';
constructor() {
this.simctl = new Simctl();
}
/**
* Check if browser is launched
*/
isLaunched(): boolean {
return this.browser !== null;
}
/**
* List connected real iOS devices
*/
private async listRealDevices(): Promise<IOSDeviceInfo[]> {
const devices: IOSDeviceInfo[] = [];
try {
// Use xcrun xctrace to list connected devices
const { execSync } = await import('node:child_process');
const output = execSync('xcrun xctrace list devices 2>/dev/null || true', {
encoding: 'utf-8',
timeout: 10000,
});
// Parse output - format is:
// == Devices ==
// Device Name (OS Version) (UDID)
// Real devices show version as just "26.2", simulators as "iOS 18.0"
const lines = output.split('\n');
let inDevicesSection = false;
for (const line of lines) {
if (line.includes('== Devices ==')) {
inDevicesSection = true;
continue;
}
// Stop at Simulators or Devices Offline section
if (line.includes('== Simulators ==') || line.includes('== Devices Offline ==')) {
break;
}
if (inDevicesSection && line.trim()) {
// Match pattern: "Device Name (version) (UDID)"
const match = line.match(/^(.+?)\s+\(([^)]+)\)\s+\(([A-F0-9-]+)\)$/i);
if (match) {
const [, name, version, udid] = match;
const nameLower = name.toLowerCase();
// Include iOS devices: either name contains iPhone/iPad, or version looks like iOS
// (a simple version number like "26.2" or "18.6") and isn't a Mac
const isIOS =
nameLower.includes('iphone') ||
nameLower.includes('ipad') ||
version.includes('iOS') ||
version.includes('iPadOS');
const isMac =
nameLower.includes('mac') ||
nameLower.includes('macbook') ||
nameLower.includes('imac');
if (isIOS || (!isMac && /^\d+\.\d+(\.\d+)?$/.test(version))) {
devices.push({
name: name.trim(),
udid: udid,
state: 'Connected',
runtime: `iOS ${version}`,
isAvailable: true,
isRealDevice: true,
});
}
}
}
}
} catch {
// Ignore errors - real device listing is optional
}
return devices;
}
/**
* List available iOS simulators
*/
async listDevices(): Promise<IOSDeviceInfo[]> {
const devices: IOSDeviceInfo[] = [];
try {
const rawDevices = await this.simctl.getDevices();
for (const [runtime, deviceList] of Object.entries(rawDevices)) {
if (!Array.isArray(deviceList)) continue;
for (const device of deviceList) {
// Only include iPhone and iPad simulators
if (device.name && (device.name.includes('iPhone') || device.name.includes('iPad'))) {
devices.push({
name: device.name,
udid: device.udid,
state: device.state,
runtime: runtime,
isAvailable: device.isAvailable ?? true,
isRealDevice: false,
});
}
}
}
} catch (error) {
throw new Error(
`Failed to list iOS simulators. Is Xcode installed? Error: ${error instanceof Error ? error.message : String(error)}`
);
}
return devices;
}
/**
* List all devices (simulators + real devices)
*/
async listAllDevices(): Promise<IOSDeviceInfo[]> {
const [simulators, realDevices] = await Promise.all([
this.listDevices(),
this.listRealDevices(),
]);
// Real devices first, then simulators
return [...realDevices, ...simulators];
}
/**
* Find the best default device (most recent iPhone)
*/
private async findDefaultDevice(): Promise<IOSDeviceInfo | null> {
const devices = await this.listDevices();
// Filter to available iPhones, prefer Pro models, then by name (which typically indicates recency)
const iphones = devices
.filter((d) => d.isAvailable && d.name.includes('iPhone'))
.sort((a, b) => {
// Prefer Pro models
const aIsPro = a.name.includes('Pro') ? 1 : 0;
const bIsPro = b.name.includes('Pro') ? 1 : 0;
if (aIsPro !== bIsPro) return bIsPro - aIsPro;
// Then sort by name descending (iPhone 15 > iPhone 14)
return b.name.localeCompare(a.name);
});
return iphones[0] ?? null;
}
/**
* Find device by name or UDID (searches both simulators and real devices)
*/
private async findDevice(nameOrUdid: string): Promise<IOSDeviceInfo | null> {
const devices = await this.listAllDevices();
// Try exact UDID match first
const byUdid = devices.find((d) => d.udid === nameOrUdid);
if (byUdid) return byUdid;
// Try exact name match
const byExactName = devices.find((d) => d.name === nameOrUdid);
if (byExactName) return byExactName;
// Try partial name match
const byPartialName = devices.find((d) =>
d.name.toLowerCase().includes(nameOrUdid.toLowerCase())
);
return byPartialName ?? null;
}
/**
* Check if Appium is installed
*/
private async checkAppiumInstalled(): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawn('appium', ['--version'], { shell: true });
proc.on('close', (code) => resolve(code === 0));
proc.on('error', () => resolve(false));
});
}
/**
* Check if Appium server is already running
*/
private async isAppiumRunning(): Promise<boolean> {
try {
const response = await fetch(
`http://${IOSManager.APPIUM_HOST}:${IOSManager.APPIUM_PORT}/status`
);
return response.ok;
} catch {
return false;
}
}
/**
* Start Appium server if not already running
*/
private async startAppiumServer(): Promise<void> {
if (await this.isAppiumRunning()) {
return; // Already running
}
if (!(await this.checkAppiumInstalled())) {
throw new Error(
'Appium not installed. Run: npm install -g appium && appium driver install xcuitest'
);
}
return new Promise((resolve, reject) => {
this.appiumProcess = spawn(
'appium',
['--port', String(IOSManager.APPIUM_PORT), '--relaxed-security'],
{
shell: true,
stdio: ['ignore', 'pipe', 'pipe'],
}
);
let started = false;
const timeout = setTimeout(() => {
if (!started) {
reject(new Error('Appium server failed to start within 30 seconds'));
}
}, 30000);
this.appiumProcess.stdout?.on('data', (data: Buffer) => {
const output = data.toString();
if (output.includes('Appium REST http interface listener started')) {
started = true;
clearTimeout(timeout);
resolve();
}
});
this.appiumProcess.stderr?.on('data', (data: Buffer) => {
const output = data.toString();
// Appium logs to stderr for info messages too
if (output.includes('Appium REST http interface listener started')) {
started = true;
clearTimeout(timeout);
resolve();
}
});
this.appiumProcess.on('error', (err) => {
clearTimeout(timeout);
reject(new Error(`Failed to start Appium: ${err.message}`));
});
this.appiumProcess.on('close', (code) => {
if (!started) {
clearTimeout(timeout);
reject(new Error(`Appium exited with code ${code}`));
}
});
});
}
/**
* Boot the iOS simulator
*/
private async bootSimulator(udid: string): Promise<void> {
try {
const devices = await this.simctl.getDevices();
let currentState: string | undefined;
// Find current device state
for (const deviceList of Object.values(devices)) {
if (!Array.isArray(deviceList)) continue;
const device = (deviceList as any[]).find((d: any) => d.udid === udid);
if (device) {
currentState = device.state;
break;
}
}
if (currentState === 'Booted') {
return; // Already booted
}
// node-simctl expects udid to be set on the instance
this.simctl.udid = udid;
await this.simctl.bootDevice();
// Wait for device to be fully booted
let attempts = 0;
while (attempts < 60) {
const updatedDevices = await this.simctl.getDevices();
for (const deviceList of Object.values(updatedDevices)) {
if (!Array.isArray(deviceList)) continue;
const device = (deviceList as any[]).find((d: any) => d.udid === udid);
if (device?.state === 'Booted') {
return;
}
}
await new Promise((r) => setTimeout(r, 1000));
attempts++;
}
throw new Error('Simulator failed to boot within 60 seconds');
} catch (error) {
throw new Error(
`Failed to boot simulator: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Launch iOS Safari via Appium
*/
async launch(
options: {
device?: string;
udid?: string;
headless?: boolean;
} = {}
): Promise<void> {
if (this.isLaunched()) {
return; // Already launched
}
// Find device
let device: IOSDeviceInfo | null = null;
if (options.udid) {
device = await this.findDevice(options.udid);
if (!device) {
throw new Error(`Device with UDID ${options.udid} not found`);
}
} else if (options.device) {
device = await this.findDevice(options.device);
if (!device) {
throw new Error(`Device "${options.device}" not found. Run: agent-browser device list`);
}
} else {
// Check environment variable
const envDevice = process.env.AGENT_BROWSER_IOS_DEVICE;
const envUdid = process.env.AGENT_BROWSER_IOS_UDID;
if (envUdid) {
device = await this.findDevice(envUdid);
if (!device) {
throw new Error(`Device with UDID ${envUdid} not found. Run: agent-browser device list`);
}
} else if (envDevice) {
device = await this.findDevice(envDevice);
if (!device) {
throw new Error(`Device "${envDevice}" not found. Run: agent-browser device list`);
}
} else {
device = await this.findDefaultDevice();
if (!device) {
throw new Error(
'No iOS simulators available. Open Xcode and download simulator runtimes.'
);
}
}
}
this.deviceUdid = device.udid;
this.deviceName = device.name;
// Start Appium server
await this.startAppiumServer();
// Boot simulator (skip for real devices - they're already running)
if (!device.isRealDevice) {
await this.bootSimulator(device.udid);
}
// Connect to Safari via Appium
try {
this.browser = await remote({
hostname: IOSManager.APPIUM_HOST,
port: IOSManager.APPIUM_PORT,
path: '/',
capabilities: {
platformName: 'iOS',
'appium:automationName': 'XCUITest',
'appium:deviceName': device.name,
'appium:udid': device.udid,
browserName: 'Safari',
'appium:noReset': true,
'appium:newCommandTimeout': 300,
},
connectionRetryTimeout: 120000,
connectionRetryCount: 3,
});
} catch (error) {
throw new Error(
`Failed to connect to Safari: ${error instanceof Error ? error.message : String(error)}. ` +
'Make sure XCUITest driver is installed: appium driver install xcuitest'
);
}
}
/**
* Navigate to URL
*/
async navigate(url: string): Promise<{ url: string; title: string }> {
if (!this.browser) {
throw new Error('iOS browser not launched. Call launch first.');
}
await this.browser.url(url);
// Wait for page to load
await this.browser.waitUntil(
async () => {
const state = (await this.browser!.execute(
'return document.readyState'
)) as unknown as string;
return state === 'complete';
},
{ timeout: 30000, interval: 500 }
);
const title = await this.browser.getTitle();
const currentUrl = await this.browser.getUrl();
return { url: currentUrl, title };
}
/**
* Get current URL
*/
async getUrl(): Promise<string> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
return this.browser.getUrl();
}
/**
* Get page title
*/
async getTitle(): Promise<string> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
return this.browser.getTitle();
}
/**
* Click/tap an element
*/
async click(selector: string): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const element = await this.getElement(selector);
await element.click();
}
/**
* Alias for click (semantic clarity for touch)
*/
async tap(selector: string): Promise<void> {
return this.click(selector);
}
/**
* Type text into an element
*/
async type(
selector: string,
text: string,
options?: { delay?: number; clear?: boolean }
): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const element = await this.getElement(selector);
if (options?.clear) {
await element.clearValue();
}
// WebdriverIO doesn't have a delay option, so we simulate it
if (options?.delay && options.delay > 0) {
for (const char of text) {
await element.addValue(char);
await new Promise((r) => setTimeout(r, options.delay));
}
} else {
await element.addValue(text);
}
}
/**
* Fill an element (clear first, then type)
*/
async fill(selector: string, value: string): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const element = await this.getElement(selector);
await element.clearValue();
await element.setValue(value);
}
/**
* Get element by selector or ref
*/
private async getElement(selectorOrRef: string) {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
// Check if it's a ref
const refData = this.getRefData(selectorOrRef);
if (refData) {
if (refData.xpath) {
return this.browser.$(refData.xpath);
}
return this.browser.$(refData.selector);
}
// Regular CSS selector
return this.browser.$(selectorOrRef);
}
/**
* Get ref data from ref string
*/
private getRefData(refArg: string): IOSRefMap[string] | null {
let ref: string | null = null;
if (refArg.startsWith('@')) {
ref = refArg.slice(1);
} else if (refArg.startsWith('ref=')) {
ref = refArg.slice(4);
} else if (/^e\d+$/.test(refArg)) {
ref = refArg;
}
if (ref && this.refMap[ref]) {
return this.refMap[ref];
}
return null;
}
/**
* Take a screenshot
*/
async screenshot(options?: {
path?: string;
fullPage?: boolean;
}): Promise<{ path?: string; base64?: string }> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const base64 = await this.browser.takeScreenshot();
if (options?.path) {
const { writeFileSync, mkdirSync } = await import('node:fs');
const dir = path.dirname(options.path);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(options.path, base64, 'base64');
return { path: options.path };
}
return { base64 };
}
/**
* Get page snapshot with refs
*/
async getSnapshot(options?: { interactive?: boolean }): Promise<IOSEnhancedSnapshot> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
this.refCounter = 0;
this.refMap = {};
// Get page structure via JavaScript execution
// Note: The function runs in browser context, so we use 'any' for DOM types
const snapshot = await this.browser.execute(function (interactiveOnly: boolean): any {
const INTERACTIVE_ROLES = new Set([
'button',
'link',
'textbox',
'checkbox',
'radio',
'combobox',
'listbox',
'menuitem',
'option',
'searchbox',
'slider',
'spinbutton',
'switch',
'tab',
'treeitem',
]);
const INTERACTIVE_TAGS = new Set([
'A',
'BUTTON',
'INPUT',
'SELECT',
'TEXTAREA',
'DETAILS',
'SUMMARY',
]);
function getXPath(element: any): string {
if (element.id) {
return `//*[@id="${element.id}"]`;
}
const parts: string[] = [];
let current: any = element;
while (current && current.nodeType === 1) {
// Node.ELEMENT_NODE = 1
let index = 1;
let sibling: any = current.previousElementSibling;
while (sibling) {
if (sibling.nodeName === current.nodeName) {
index++;
}
sibling = sibling.previousElementSibling;
}
const tagName = current.nodeName.toLowerCase();
parts.unshift(`${tagName}[${index}]`);
current = current.parentElement;
}
return '/' + parts.join('/');
}
function getAccessibleName(element: any): string {
// aria-label takes precedence
const ariaLabel = element.getAttribute('aria-label');
if (ariaLabel) return ariaLabel;
// For inputs, check placeholder and label
const tagName = element.tagName;
if (tagName === 'INPUT' || tagName === 'TEXTAREA') {
const id = element.id;
if (id) {
const label = (document as any).querySelector(`label[for="${id}"]`);
if (label) return label.textContent?.trim() || '';
}
if (element.placeholder) return element.placeholder;
}
// For buttons and links, use text content
if (tagName === 'BUTTON' || tagName === 'A') {
return element.textContent?.trim() || '';
}
// aria-labelledby
const labelledBy = element.getAttribute('aria-labelledby');
if (labelledBy) {
const labelElement = (document as any).getElementById(labelledBy);
if (labelElement) return labelElement.textContent?.trim() || '';
}
return element.textContent?.trim().slice(0, 50) || '';
}
function getRole(element: any): string | null {
// Explicit role
const role = element.getAttribute('role');
if (role) return role;
// Implicit roles
const tag = element.tagName;
if (tag === 'A' && element.hasAttribute('href')) return 'link';
if (tag === 'BUTTON') return 'button';
if (tag === 'INPUT') {
const type = element.type;
if (type === 'checkbox') return 'checkbox';
if (type === 'radio') return 'radio';
if (type === 'text' || type === 'email' || type === 'password' || type === 'search')
return 'textbox';
if (type === 'submit' || type === 'button') return 'button';
}
if (tag === 'TEXTAREA') return 'textbox';
if (tag === 'SELECT') return 'combobox';
if (
tag === 'H1' ||
tag === 'H2' ||
tag === 'H3' ||
tag === 'H4' ||
tag === 'H5' ||
tag === 'H6'
)
return 'heading';
if (tag === 'IMG') return 'img';
if (tag === 'NAV') return 'navigation';
if (tag === 'MAIN') return 'main';
if (tag === 'HEADER') return 'banner';
if (tag === 'FOOTER') return 'contentinfo';
return null;
}
function traverse(element: any, depth: number): any {
if (depth > 10) return null; // Limit depth
const tag = element.tagName;
const role = getRole(element);
const name = getAccessibleName(element);
const isInteractive =
INTERACTIVE_TAGS.has(tag) || (role !== null && INTERACTIVE_ROLES.has(role));
// Skip hidden elements
const style = (window as any).getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden') {
return null;
}
const children: any[] = [];
for (const child of element.children) {
const childInfo = traverse(child, depth + 1);
if (childInfo) {
children.push(childInfo);
}
}
// In interactive mode, skip non-interactive elements without interactive children
if (interactiveOnly && !isInteractive && children.length === 0) {
return null;
}
return {
tag,
role,
name,
text: element.textContent?.trim().slice(0, 100) || '',
isInteractive,
xpath: getXPath(element),
children,
};
}
const root = traverse((document as any).body, 0);
return root;
}, options?.interactive ?? false);
// Build tree string and refs
const lines: string[] = [];
const buildTree = (node: any, indent: number) => {
if (!node) return;
const prefix = ' '.repeat(indent) + '- ';
const role = node.role || node.tag.toLowerCase();
const name = node.name;
let line = `${prefix}${role}`;
if (name) {
line += ` "${name}"`;
}
// Add ref for interactive elements
if (node.isInteractive) {
const ref = `e${++this.refCounter}`;
line += ` [ref=${ref}]`;
this.refMap[ref] = {
selector: node.xpath.startsWith('/') ? node.xpath : `#${node.xpath}`,
role: node.role,
name: node.name,
xpath: node.xpath,
};
}
lines.push(line);
for (const child of node.children || []) {
buildTree(child, indent + 1);
}
};
if (snapshot) {
buildTree(snapshot, 0);
}
const tree = lines.join('\n') || '(empty)';
this.lastSnapshot = tree;
return { tree, refs: this.refMap };
}
/**
* Get cached ref map
*/
getRefMap(): IOSRefMap {
return this.refMap;
}
/**
* Scroll the page
*/
async scroll(options?: {
selector?: string;
x?: number;
y?: number;
direction?: 'up' | 'down' | 'left' | 'right';
amount?: number;
}): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const amount = options?.amount ?? 300;
if (options?.selector) {
const element = await this.getElement(options.selector);
await element.scrollIntoView();
return;
}
// Use JavaScript scrolling
let deltaX = options?.x ?? 0;
let deltaY = options?.y ?? 0;
if (options?.direction) {
switch (options.direction) {
case 'up':
deltaY = -amount;
break;
case 'down':
deltaY = amount;
break;
case 'left':
deltaX = -amount;
break;
case 'right':
deltaX = amount;
break;
}
}
await this.browser.execute(
function (x: number, y: number) {
(window as any).scrollBy(x, y);
},
deltaX,
deltaY
);
}
/**
* Swipe gesture (iOS-specific)
*/
async swipe(
direction: 'up' | 'down' | 'left' | 'right',
options?: { distance?: number }
): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const distance = options?.distance ?? 300;
// Map direction to scroll (opposite direction)
const scrollDirection = {
up: 'down',
down: 'up',
left: 'right',
right: 'left',
}[direction] as 'up' | 'down' | 'left' | 'right';
await this.scroll({ direction: scrollDirection, amount: distance });
}
/**
* Execute JavaScript
*/
async evaluate<T = unknown>(script: string, ...args: unknown[]): Promise<T> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
// Execute the script directly - WebdriverIO handles the context
const result = await this.browser.execute(
function (code: string, evalArgs: any[]) {
// Create a function from the code and execute it
const fn = new Function(...evalArgs.map((_: any, i: number) => `arg${i}`), code);
return fn(...evalArgs);
},
script.includes('return') ? script : `return (${script})`,
args
);
return result as T;
}
/**
* Wait for element
*/
async wait(options: {
selector?: string;
timeout?: number;
state?: 'attached' | 'detached' | 'visible' | 'hidden';
}): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const timeout = options.timeout ?? 30000;
if (options.selector) {
const element = await this.getElement(options.selector);
switch (options.state) {
case 'detached':
await element.waitForExist({ timeout, reverse: true });
break;
case 'hidden':
await element.waitForDisplayed({ timeout, reverse: true });
break;
case 'visible':
await element.waitForDisplayed({ timeout });
break;
case 'attached':
default:
await element.waitForExist({ timeout });
break;
}
} else {
// Just wait for timeout
await new Promise((r) => setTimeout(r, timeout));
}
}
/**
* Press a key
*/
async press(key: string): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
// Map common key names
const keyMap: Record<string, string> = {
Enter: '\uE007',
Tab: '\uE004',
Escape: '\uE00C',
Backspace: '\uE003',
Delete: '\uE017',
ArrowUp: '\uE013',
ArrowDown: '\uE015',
ArrowLeft: '\uE012',
ArrowRight: '\uE014',
};
const mappedKey = keyMap[key] ?? key;
await this.browser.keys(mappedKey);
}
/**
* Hover over element (limited on touch - just scrolls into view)
*/
async hover(selector: string): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const element = await this.getElement(selector);
await element.scrollIntoView();
}
/**
* Get page content (HTML)
*/
async getContent(selector?: string): Promise<string> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
if (selector) {
const element = await this.getElement(selector);
return element.getHTML();
}
return this.browser.getPageSource();
}
/**
* Get text content of element
*/
async getText(selector: string): Promise<string> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const element = await this.getElement(selector);
return element.getText();
}
/**
* Get attribute value
*/
async getAttribute(selector: string, attribute: string): Promise<string | null> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const element = await this.getElement(selector);
return element.getAttribute(attribute);
}
/**
* Check if element is visible
*/
async isVisible(selector: string): Promise<boolean> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
try {
const element = await this.getElement(selector);
return element.isDisplayed();
} catch {
return false;
}
}
/**
* Check if element is enabled
*/
async isEnabled(selector: string): Promise<boolean> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const element = await this.getElement(selector);
return element.isEnabled();
}
/**
* Navigate back
*/
async goBack(): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
await this.browser.back();
}
/**
* Navigate forward
*/
async goForward(): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
await this.browser.forward();
}
/**
* Reload page
*/
async reload(): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
await this.browser.refresh();
}
/**
* Select option(s) from dropdown
*/
async select(selector: string, values: string | string[]): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const element = await this.getElement(selector);
const valueArray = Array.isArray(values) ? values : [values];
for (const value of valueArray) {
await element.selectByAttribute('value', value);
}
}
/**
* Check a checkbox
*/
async check(selector: string): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const element = await this.getElement(selector);
const isChecked = await element.isSelected();
if (!isChecked) {
await element.click();
}
}
/**
* Uncheck a checkbox
*/
async uncheck(selector: string): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const element = await this.getElement(selector);
const isChecked = await element.isSelected();
if (isChecked) {
await element.click();
}
}
/**
* Focus an element
*/
async focus(selector: string): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const element = await this.getElement(selector);
await this.browser.execute(function (el: any) {
el.focus();
}, element as any);
}
/**
* Clear input field
*/
async clear(selector: string): Promise<void> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const element = await this.getElement(selector);
await element.clearValue();
}
/**
* Get element count
*/
async count(selector: string): Promise<number> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
const elements = await this.browser.$$(selector);
return elements.length;
}
/**
* Get bounding box
*/
async getBoundingBox(
selector: string
): Promise<{ x: number; y: number; width: number; height: number } | null> {
if (!this.browser) {
throw new Error('iOS browser not launched');
}
try {
const element = await this.getElement(selector);
const location = await element.getLocation();
const size = await element.getSize();
return {
x: location.x,
y: location.y,
width: size.width,
height: size.height,
};
} catch {
return null;
}
}
/**
* Get device info
*/
getDeviceInfo(): { name: string; udid: string } | null {
if (!this.deviceName || !this.deviceUdid) {
return null;
}
return {
name: this.deviceName,
udid: this.deviceUdid,
};
}
/**
* Close browser and cleanup
*/
async close(): Promise<void> {
if (this.browser) {
try {
await this.browser.deleteSession();
} catch {
// Ignore cleanup errors
}
this.browser = null;
}
if (this.appiumProcess) {
this.appiumProcess.kill();
this.appiumProcess = null;
}
// Optionally shutdown simulator
if (this.deviceUdid) {
try {
this.simctl.udid = this.deviceUdid;
await this.simctl.shutdownDevice();
} catch {
// Ignore - simulator might already be shutdown
}
}
this.deviceUdid = null;
this.deviceName = null;
this.refMap = {};
this.lastSnapshot = '';
this.refCounter = 0;
}
}