import type {
WdaResponse,
WdaErrorResponse,
WdaStatus,
WdaSession,
WdaElement,
WdaFindStrategy,
WdaAlertAction,
} from '../types/wda.js';
import { WdaConnectionError, WdaRequestError } from '../types/wda.js';
export interface WdaClientOptions {
baseUrl?: string;
timeout?: number;
port?: number;
}
// Options for wdaFetch
interface WdaFetchOptions extends WdaClientOptions {
method?: 'GET' | 'POST' | 'DELETE';
body?: unknown;
retries?: number;
noRetryOnTimeout?: boolean; // Don't retry on timeout (for actions that shouldn't be repeated)
}
const DEFAULT_PORT = 8100;
const DEFAULT_TIMEOUT = 30000;
const MAX_RETRIES = 3;
const RETRY_DELAY_BASE = 100;
function getBaseUrl(options?: WdaClientOptions): string {
const port = options?.port ?? DEFAULT_PORT;
return options?.baseUrl ?? `http://localhost:${port}`;
}
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function wdaFetch<T>(
path: string,
options: WdaFetchOptions = {}
): Promise<WdaResponse<T>> {
const baseUrl = getBaseUrl(options);
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
const maxRetries = options.retries ?? MAX_RETRIES;
const url = `${baseUrl}${path}`;
let lastError: Error | undefined;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
method: options.method ?? 'GET',
headers: {
'Content-Type': 'application/json',
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
const json = (await response.json()) as WdaResponse<T> | WdaErrorResponse;
// Check for WebDriver error in response body (WDA returns 200 with error in value)
if (
json.value &&
typeof json.value === 'object' &&
'error' in json.value
) {
const errorResponse = json as unknown as WdaErrorResponse;
throw new WdaRequestError(
errorResponse.value?.message ?? 'WDA request failed',
response.status,
errorResponse.value?.error
);
}
if (!response.ok) {
const errorResponse = json as WdaErrorResponse;
throw new WdaRequestError(
errorResponse.value?.message ??
`WDA request failed with status ${response.status}`,
response.status,
errorResponse.value?.error
);
}
return json as WdaResponse<T>;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof WdaRequestError) {
// Don't retry client errors (4xx) or known WebDriver errors
if (error.statusCode >= 400 && error.statusCode < 500) {
throw error;
}
// Don't retry "no such element" errors
if (error.wdaError === 'no such element') {
throw error;
}
}
if (error instanceof Error) {
// Connection refused or network error
if (
error.message.includes('ECONNREFUSED') ||
error.message.includes('fetch failed') ||
error.cause?.toString().includes('ECONNREFUSED')
) {
throw new WdaConnectionError(
`Cannot connect to WDA server at ${baseUrl}. ` +
'Ensure WebDriverAgent is running on the simulator. ' +
'Start it with: xcodebuild test -project WebDriverAgent.xcodeproj ' +
'-scheme WebDriverAgentRunner -destination "platform=iOS Simulator,name=<device>"'
);
}
if (error.name === 'AbortError') {
// Don't retry timeout for action commands
if (options.noRetryOnTimeout) {
throw new Error(`WDA request timed out after ${timeout}ms`);
}
lastError = new Error(`WDA request timed out after ${timeout}ms`);
} else {
lastError = error;
}
}
// Wait before retrying with exponential backoff
if (attempt < maxRetries - 1) {
await sleep(RETRY_DELAY_BASE * Math.pow(2, attempt));
}
}
}
throw lastError ?? new Error('WDA request failed after retries');
}
// Public API functions
export async function getStatus(options?: WdaClientOptions): Promise<WdaStatus> {
const response = await wdaFetch<WdaStatus>('/status', {
...options,
retries: 1, // Don't retry status checks
});
return response.value;
}
export async function createSession(
bundleId?: string,
options?: WdaClientOptions
): Promise<WdaSession> {
const capabilities = bundleId
? { capabilities: { alwaysMatch: { 'appium:bundleId': bundleId } } }
: { capabilities: {} };
const response = await wdaFetch<WdaSession>('/session', {
method: 'POST',
body: capabilities,
...options,
});
return response.value;
}
export async function getSession(
sessionId: string,
options?: WdaClientOptions
): Promise<boolean> {
const sid = encodeURIComponent(sessionId);
try {
await wdaFetch<unknown>(`/session/${sid}`, {
retries: 1,
...options,
});
return true;
} catch {
return false;
}
}
export async function deleteSession(
sessionId: string,
options?: WdaClientOptions
): Promise<void> {
const sid = encodeURIComponent(sessionId);
await wdaFetch<null>(`/session/${sid}`, {
method: 'DELETE',
...options,
});
}
export async function tap(
sessionId: string,
x: number,
y: number,
options?: WdaClientOptions
): Promise<void> {
const sid = encodeURIComponent(sessionId);
await wdaFetch<null>(`/session/${sid}/wda/tap/0`, {
method: 'POST',
body: { x, y },
noRetryOnTimeout: true, // Don't retry UI actions
...options,
});
}
export async function tapElement(
sessionId: string,
elementId: string,
options?: WdaClientOptions
): Promise<void> {
const sid = encodeURIComponent(sessionId);
const eid = encodeURIComponent(elementId);
await wdaFetch<null>(`/session/${sid}/element/${eid}/click`, {
method: 'POST',
body: {},
noRetryOnTimeout: true, // Don't retry UI actions
...options,
});
}
export async function swipe(
sessionId: string,
fromX: number,
fromY: number,
toX: number,
toY: number,
duration: number = 500,
options?: WdaClientOptions
): Promise<void> {
const sid = encodeURIComponent(sessionId);
await wdaFetch<null>(`/session/${sid}/wda/dragfromtoforduration`, {
method: 'POST',
body: {
fromX,
fromY,
toX,
toY,
duration: duration / 1000, // WDA expects seconds
},
noRetryOnTimeout: true, // Don't retry UI actions
...options,
});
}
export async function typeText(
sessionId: string,
text: string,
options?: WdaClientOptions
): Promise<void> {
const sid = encodeURIComponent(sessionId);
await wdaFetch<null>(`/session/${sid}/wda/keys`, {
method: 'POST',
body: { value: text.split('') }, // WDA expects array of characters
noRetryOnTimeout: true, // Don't retry UI actions
...options,
});
}
export async function findElement(
sessionId: string,
using: WdaFindStrategy,
value: string,
options?: WdaClientOptions
): Promise<WdaElement | null> {
const sid = encodeURIComponent(sessionId);
try {
const response = await wdaFetch<WdaElement>(`/session/${sid}/element`, {
method: 'POST',
body: { using, value },
...options,
});
return response.value;
} catch (error) {
// WDA returns "no such element" error when element not found
if (error instanceof WdaRequestError && error.wdaError === 'no such element') {
return null;
}
throw error;
}
}
export async function findElements(
sessionId: string,
using: WdaFindStrategy,
value: string,
options?: WdaClientOptions
): Promise<WdaElement[]> {
const sid = encodeURIComponent(sessionId);
const response = await wdaFetch<WdaElement[]>(`/session/${sid}/elements`, {
method: 'POST',
body: { using, value },
...options,
});
return response.value ?? [];
}
export async function getSource(
sessionId: string,
options?: WdaClientOptions
): Promise<string> {
const sid = encodeURIComponent(sessionId);
const response = await wdaFetch<string>(`/session/${sid}/source`, options);
return response.value;
}
export async function goHome(options?: WdaClientOptions): Promise<void> {
await wdaFetch<null>('/wda/homescreen', {
method: 'POST',
body: {},
noRetryOnTimeout: true,
...options,
});
}
export async function handleAlert(
sessionId: string,
action: WdaAlertAction,
options?: WdaClientOptions
): Promise<void> {
const sid = encodeURIComponent(sessionId);
await wdaFetch<null>(`/session/${sid}/alert/${action}`, {
method: 'POST',
body: {},
noRetryOnTimeout: true,
...options,
});
}
export async function getAlertText(
sessionId: string,
options?: WdaClientOptions
): Promise<string | null> {
const sid = encodeURIComponent(sessionId);
try {
const response = await wdaFetch<string>(`/session/${sid}/alert/text`, options);
return response.value;
} catch (error) {
// WDA returns "no such alert" when no alert present
if (error instanceof WdaRequestError && error.wdaError === 'no such alert') {
return null;
}
throw error;
}
}
export async function getElementAttribute(
sessionId: string,
elementId: string,
attribute: string,
options?: WdaClientOptions
): Promise<string | null> {
const sid = encodeURIComponent(sessionId);
const eid = encodeURIComponent(elementId);
const attr = encodeURIComponent(attribute);
const response = await wdaFetch<string | null>(
`/session/${sid}/element/${eid}/attribute/${attr}`,
options
);
return response.value;
}
// Helper to extract element ID from WDA element response
export function getElementId(element: WdaElement): string {
return element.ELEMENT || element['element-6066-11e4-a52e-4f735466cecf'] || '';
}