add iOS support (#358)
* ios * tests * docs * real device * better list * fixes
This commit is contained in:
+100
-46
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user