add iOS support (#358)

* ios

* tests

* docs

* real device

* better list

* fixes
This commit is contained in:
Chris Tate
2026-02-03 01:36:19 -06:00
committed by GitHub
parent 3c6ae7df9d
commit a1b992411e
18 changed files with 4197 additions and 56 deletions
+100 -46
View File
@@ -3,10 +3,15 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { BrowserManager } from './browser.js';
import { IOSManager } from './ios-manager.js';
import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
import { executeCommand } from './actions.js';
import { executeIOSCommand } from './ios-actions.js';
import { StreamServer } from './stream-server.js';
// Manager type - either desktop browser or iOS
type Manager = BrowserManager | IOSManager;
// Platform detection
const isWindows = process.platform === 'win32';
@@ -167,8 +172,12 @@ export function getStreamPortFile(session?: string): string {
/**
* Start the daemon server
* @param options.streamPort Port for WebSocket stream server (0 to disable)
* @param options.provider Provider type ('ios' for iOS Simulator, undefined for desktop)
*/
export async function startDaemon(options?: { streamPort?: number }): Promise<void> {
export async function startDaemon(options?: {
streamPort?: number;
provider?: string;
}): Promise<void> {
// Ensure socket directory exists
const socketDir = getSocketDir();
if (!fs.existsSync(socketDir)) {
@@ -178,18 +187,24 @@ export async function startDaemon(options?: { streamPort?: number }): Promise<vo
// Clean up any stale socket
cleanupSocket();
const browser = new BrowserManager();
// Determine provider from options or environment
const provider = options?.provider ?? process.env.AGENT_BROWSER_PROVIDER;
const isIOS = provider === 'ios';
// Create appropriate manager
const manager: Manager = isIOS ? new IOSManager() : new BrowserManager();
let shuttingDown = false;
// Start stream server if port is specified (or use default if env var is set)
// Note: Stream server only works with BrowserManager (desktop), not iOS
const streamPort =
options?.streamPort ??
(process.env.AGENT_BROWSER_STREAM_PORT
? parseInt(process.env.AGENT_BROWSER_STREAM_PORT, 10)
: 0);
if (streamPort > 0) {
streamServer = new StreamServer(browser, streamPort);
if (streamPort > 0 && !isIOS && manager instanceof BrowserManager) {
streamServer = new StreamServer(manager, streamPort);
await streamServer.start();
// Write stream port to file for clients to discover
@@ -233,56 +248,91 @@ export async function startDaemon(options?: { streamPort?: number }): Promise<vo
continue;
}
// Auto-launch browser if not already launched and this isn't a launch command
// Handle device_list specially - it works without a session and always uses IOSManager
if (parseResult.command.action === 'device_list') {
const iosManager = new IOSManager();
try {
const devices = await iosManager.listAllDevices();
const response = {
id: parseResult.command.id,
success: true as const,
data: { devices },
};
socket.write(serializeResponse(response) + '\n');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
socket.write(
serializeResponse(errorResponse(parseResult.command.id, message)) + '\n'
);
}
continue;
}
// Auto-launch if not already launched and this isn't a launch/close command
if (
!browser.isLaunched() &&
!manager.isLaunched() &&
parseResult.command.action !== 'launch' &&
parseResult.command.action !== 'close'
) {
const extensions = process.env.AGENT_BROWSER_EXTENSIONS
? process.env.AGENT_BROWSER_EXTENSIONS.split(',')
.map((p) => p.trim())
.filter(Boolean)
: undefined;
if (isIOS && manager instanceof IOSManager) {
// Auto-launch iOS Safari
// Check for device in command first (for reused daemons), then fall back to env vars
const cmd = parseResult.command as { iosDevice?: string };
const iosDevice = cmd.iosDevice || process.env.AGENT_BROWSER_IOS_DEVICE;
await manager.launch({
device: iosDevice,
udid: process.env.AGENT_BROWSER_IOS_UDID,
});
} else if (manager instanceof BrowserManager) {
// Auto-launch desktop browser
const extensions = process.env.AGENT_BROWSER_EXTENSIONS
? process.env.AGENT_BROWSER_EXTENSIONS.split(',')
.map((p) => p.trim())
.filter(Boolean)
: undefined;
// Parse args from env (comma or newline separated)
const argsEnv = process.env.AGENT_BROWSER_ARGS;
const args = argsEnv
? argsEnv
.split(/[,\n]/)
.map((a) => a.trim())
.filter((a) => a.length > 0)
: undefined;
// Parse args from env (comma or newline separated)
const argsEnv = process.env.AGENT_BROWSER_ARGS;
const args = argsEnv
? argsEnv
.split(/[,\n]/)
.map((a) => a.trim())
.filter((a) => a.length > 0)
: undefined;
// Parse proxy from env
const proxyServer = process.env.AGENT_BROWSER_PROXY;
const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS;
const proxy = proxyServer
? {
server: proxyServer,
...(proxyBypass && { bypass: proxyBypass }),
}
: undefined;
// Parse proxy from env
const proxyServer = process.env.AGENT_BROWSER_PROXY;
const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS;
const proxy = proxyServer
? {
server: proxyServer,
...(proxyBypass && { bypass: proxyBypass }),
}
: undefined;
const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1';
await browser.launch({
id: 'auto',
action: 'launch' as const,
headless: process.env.AGENT_BROWSER_HEADED !== '1',
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
profile: process.env.AGENT_BROWSER_PROFILE,
storageState: process.env.AGENT_BROWSER_STATE,
args,
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
proxy,
ignoreHTTPSErrors: ignoreHTTPSErrors,
});
const ignoreHTTPSErrors = process.env.AGENT_BROWSER_IGNORE_HTTPS_ERRORS === '1';
await manager.launch({
id: 'auto',
action: 'launch' as const,
headless: process.env.AGENT_BROWSER_HEADED !== '1',
executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH,
extensions: extensions,
profile: process.env.AGENT_BROWSER_PROFILE,
storageState: process.env.AGENT_BROWSER_STATE,
args,
userAgent: process.env.AGENT_BROWSER_USER_AGENT,
proxy,
ignoreHTTPSErrors: ignoreHTTPSErrors,
});
}
}
// Handle close command specially
// Handle close command specially - shuts down daemon
if (parseResult.command.action === 'close') {
const response = await executeCommand(parseResult.command, browser);
const response =
isIOS && manager instanceof IOSManager
? await executeIOSCommand(parseResult.command, manager)
: await executeCommand(parseResult.command, manager as BrowserManager);
socket.write(serializeResponse(response) + '\n');
if (!shuttingDown) {
@@ -296,7 +346,11 @@ export async function startDaemon(options?: { streamPort?: number }): Promise<vo
return;
}
const response = await executeCommand(parseResult.command, browser);
// Execute command with appropriate handler
const response =
isIOS && manager instanceof IOSManager
? await executeIOSCommand(parseResult.command, manager)
: await executeCommand(parseResult.command, manager as BrowserManager);
socket.write(serializeResponse(response) + '\n');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -355,7 +409,7 @@ export async function startDaemon(options?: { streamPort?: number }): Promise<vo
}
}
await browser.close();
await manager.close();
server.close();
cleanupSocket();
process.exit(0);
+273
View File
@@ -0,0 +1,273 @@
/**
* iOS command execution - mirrors actions.ts but for iOS Safari via Appium.
* Provides 1:1 command parity where possible.
*/
import type { IOSManager } from './ios-manager.js';
import type { Command, Response } from './types.js';
function successResponse<T>(id: string, data: T): Response<T> {
return { id, success: true, data };
}
function errorResponse(id: string, error: string): Response {
return { id, success: false, error };
}
/**
* Execute a command on the iOS manager
*/
export async function executeIOSCommand(command: Command, manager: IOSManager): Promise<Response> {
const { id, action } = command;
try {
switch (action) {
case 'launch': {
const cmd = command as any;
await manager.launch({
device: cmd.device,
udid: cmd.udid,
});
const info = manager.getDeviceInfo();
return successResponse(id, {
launched: true,
device: info?.name ?? 'iOS Simulator',
udid: info?.udid,
});
}
case 'navigate': {
const cmd = command as any;
const result = await manager.navigate(cmd.url);
return successResponse(id, result);
}
case 'click': {
const cmd = command as any;
await manager.click(cmd.selector);
return successResponse(id, { clicked: true });
}
case 'tap': {
const cmd = command as any;
await manager.tap(cmd.selector);
return successResponse(id, { tapped: true });
}
case 'type': {
const cmd = command as any;
await manager.type(cmd.selector, cmd.text, {
delay: cmd.delay,
clear: cmd.clear,
});
return successResponse(id, { typed: true });
}
case 'fill': {
const cmd = command as any;
await manager.fill(cmd.selector, cmd.value);
return successResponse(id, { filled: true });
}
case 'screenshot': {
const cmd = command as any;
const result = await manager.screenshot({
path: cmd.path,
fullPage: cmd.fullPage,
});
return successResponse(id, result);
}
case 'snapshot': {
const cmd = command as any;
const result = await manager.getSnapshot({
interactive: cmd.interactive,
});
return successResponse(id, { snapshot: result.tree, refs: result.refs });
}
case 'scroll': {
const cmd = command as any;
await manager.scroll({
selector: cmd.selector,
x: cmd.x,
y: cmd.y,
direction: cmd.direction,
amount: cmd.amount,
});
return successResponse(id, { scrolled: true });
}
case 'swipe': {
const cmd = command as any;
await manager.swipe(cmd.direction, { distance: cmd.distance });
return successResponse(id, { swiped: true });
}
case 'evaluate': {
const cmd = command as any;
const result = await manager.evaluate(cmd.script, ...(cmd.args ?? []));
return successResponse(id, { result });
}
case 'wait': {
const cmd = command as any;
await manager.wait({
selector: cmd.selector,
timeout: cmd.timeout,
state: cmd.state,
});
return successResponse(id, { waited: true });
}
case 'press': {
const cmd = command as any;
await manager.press(cmd.key);
return successResponse(id, { pressed: true });
}
case 'hover': {
const cmd = command as any;
await manager.hover(cmd.selector);
return successResponse(id, { hovered: true });
}
case 'content': {
const cmd = command as any;
const html = await manager.getContent(cmd.selector);
return successResponse(id, { html });
}
case 'gettext': {
const cmd = command as any;
const text = await manager.getText(cmd.selector);
return successResponse(id, { text });
}
case 'getattribute': {
const cmd = command as any;
const value = await manager.getAttribute(cmd.selector, cmd.attribute);
return successResponse(id, { value });
}
case 'isvisible': {
const cmd = command as any;
const visible = await manager.isVisible(cmd.selector);
return successResponse(id, { visible });
}
case 'isenabled': {
const cmd = command as any;
const enabled = await manager.isEnabled(cmd.selector);
return successResponse(id, { enabled });
}
case 'url': {
const url = await manager.getUrl();
return successResponse(id, { url });
}
case 'title': {
const title = await manager.getTitle();
return successResponse(id, { title });
}
case 'back': {
await manager.goBack();
return successResponse(id, { navigated: 'back' });
}
case 'forward': {
await manager.goForward();
return successResponse(id, { navigated: 'forward' });
}
case 'reload': {
await manager.reload();
return successResponse(id, { reloaded: true });
}
case 'select': {
const cmd = command as any;
await manager.select(cmd.selector, cmd.values);
return successResponse(id, { selected: true });
}
case 'check': {
const cmd = command as any;
await manager.check(cmd.selector);
return successResponse(id, { checked: true });
}
case 'uncheck': {
const cmd = command as any;
await manager.uncheck(cmd.selector);
return successResponse(id, { unchecked: true });
}
case 'focus': {
const cmd = command as any;
await manager.focus(cmd.selector);
return successResponse(id, { focused: true });
}
case 'clear': {
const cmd = command as any;
await manager.clear(cmd.selector);
return successResponse(id, { cleared: true });
}
case 'count': {
const cmd = command as any;
const count = await manager.count(cmd.selector);
return successResponse(id, { count });
}
case 'boundingbox': {
const cmd = command as any;
const box = await manager.getBoundingBox(cmd.selector);
return successResponse(id, { box });
}
case 'close': {
await manager.close();
return successResponse(id, { closed: true });
}
// iOS-specific: device list
case 'device_list': {
const devices = await manager.listDevices();
return successResponse(id, { devices });
}
// Commands that don't apply to iOS Safari
case 'tab_new':
case 'tab_list':
case 'tab_switch':
case 'tab_close':
case 'window_new':
return errorResponse(
id,
`Command '${action}' is not supported on iOS Safari. Mobile Safari does not support programmatic tab management.`
);
case 'pdf':
return errorResponse(id, 'PDF generation is not supported on iOS Safari.');
case 'screencast_start':
case 'screencast_stop':
return errorResponse(id, 'Screencast is not supported on iOS (requires CDP).');
case 'recording_start':
case 'recording_stop':
case 'recording_restart':
return errorResponse(id, 'Video recording is not yet supported on iOS.');
default:
return errorResponse(id, `Unknown or unsupported iOS command: ${action}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return errorResponse(id, message);
}
}
+157
View File
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { IOSManager } from './ios-manager.js';
// Mock node-simctl
vi.mock('node-simctl', () => {
return {
Simctl: class MockSimctl {
async getDevices() {
return {
'iOS 18.0': [
{
name: 'iPhone 16 Pro',
udid: 'TEST-UDID-1234',
state: 'Shutdown',
isAvailable: true,
},
{
name: 'iPhone 16',
udid: 'TEST-UDID-5678',
state: 'Booted',
isAvailable: true,
},
{
name: 'iPad Pro',
udid: 'TEST-UDID-IPAD',
state: 'Shutdown',
isAvailable: true,
},
],
};
}
},
};
});
describe('IOSManager', () => {
let manager: IOSManager;
beforeEach(() => {
manager = new IOSManager();
});
describe('listDevices', () => {
it('should list available iOS simulators', async () => {
const devices = await manager.listDevices();
expect(devices).toHaveLength(3);
expect(devices[0]).toEqual({
name: 'iPhone 16 Pro',
udid: 'TEST-UDID-1234',
state: 'Shutdown',
runtime: 'iOS 18.0',
isAvailable: true,
isRealDevice: false,
});
});
it('should include runtime version for each device', async () => {
const devices = await manager.listDevices();
devices.forEach((device) => {
expect(device.runtime).toBe('iOS 18.0');
});
});
});
describe('isLaunched', () => {
it('should return false when browser is not launched', () => {
expect(manager.isLaunched()).toBe(false);
});
});
describe('getRefData', () => {
it('should return null for unknown refs', () => {
// Access private method via bracket notation for testing
const result = (manager as any).getRefData('@e99');
expect(result).toBeNull();
});
it('should handle @-prefixed refs', () => {
// Set up a ref in the refMap
(manager as any).refMap = {
e1: { selector: 'button', role: 'button', name: 'Submit' },
};
const result = (manager as any).getRefData('@e1');
expect(result).toEqual({ selector: 'button', role: 'button', name: 'Submit' });
});
it('should handle ref= prefixed refs', () => {
(manager as any).refMap = {
e2: { selector: 'a', role: 'link', name: 'Learn more' },
};
const result = (manager as any).getRefData('ref=e2');
expect(result).toEqual({ selector: 'a', role: 'link', name: 'Learn more' });
});
it('should handle bare ref names', () => {
(manager as any).refMap = {
e3: { selector: 'input', role: 'textbox', name: 'Email' },
};
const result = (manager as any).getRefData('e3');
expect(result).toEqual({ selector: 'input', role: 'textbox', name: 'Email' });
});
});
});
describe('IOSManager integration', () => {
// These tests require Appium and iOS Simulator to be available
// They are skipped by default and can be run manually
describe.skip('with real simulator', () => {
let manager: IOSManager;
beforeEach(() => {
// Use real implementation for integration tests
vi.resetModules();
manager = new IOSManager();
});
it('should launch Safari and navigate', async () => {
await manager.launch({ device: 'iPhone 16 Pro' });
expect(manager.isLaunched()).toBe(true);
const result = await manager.navigate('https://example.com');
expect(result.url).toContain('example.com');
expect(result.title).toBe('Example Domain');
await manager.close();
}, 120000);
it('should take screenshots', async () => {
await manager.launch({ device: 'iPhone 16 Pro' });
await manager.navigate('https://example.com');
const result = await manager.screenshot();
expect(result.base64).toBeDefined();
expect(result.base64?.length).toBeGreaterThan(1000);
await manager.close();
}, 120000);
it('should generate snapshots with refs', async () => {
await manager.launch({ device: 'iPhone 16 Pro' });
await manager.navigate('https://example.com');
const snapshot = await manager.getSnapshot();
expect(snapshot.tree).toContain('link');
expect(snapshot.tree).toContain('[ref=e1]');
expect(snapshot.refs.e1).toBeDefined();
expect(snapshot.refs.e1.role).toBe('link');
await manager.close();
}, 120000);
});
});
+1299
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -686,6 +686,17 @@ const inputTouchSchema = baseCommandSchema.extend({
modifiers: z.number().optional(),
});
// iOS-specific schemas
const swipeSchema = baseCommandSchema.extend({
action: z.literal('swipe'),
direction: z.enum(['up', 'down', 'left', 'right']),
distance: z.number().positive().optional(),
});
const deviceListSchema = baseCommandSchema.extend({
action: z.literal('device_list'),
});
const pressSchema = baseCommandSchema.extend({
action: z.literal('press'),
key: z.string().min(1),
@@ -906,6 +917,8 @@ const commandSchema = z.discriminatedUnion('action', [
inputMouseSchema,
inputKeyboardSchema,
inputTouchSchema,
swipeSchema,
deviceListSchema,
]);
// Parse result type
+14 -1
View File
@@ -521,6 +521,17 @@ export interface InputTouchCommand extends BaseCommand {
modifiers?: number;
}
// iOS-specific commands
export interface SwipeCommand extends BaseCommand {
action: 'swipe';
direction: 'up' | 'down' | 'left' | 'right';
distance?: number;
}
export interface DeviceListCommand extends BaseCommand {
action: 'device_list';
}
// Video recording (Playwright native - requires launch-time setup)
export interface VideoStartCommand extends BaseCommand {
action: 'video_start';
@@ -931,7 +942,9 @@ export type Command =
| ScreencastStopCommand
| InputMouseCommand
| InputKeyboardCommand
| InputTouchCommand;
| InputTouchCommand
| SwipeCommand
| DeviceListCommand;
// Response types
export interface SuccessResponse<T = unknown> {