@kritchoff/agent-browser 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +903 -0
- package/README.sdk.md +77 -0
- package/bin/agent-browser-linux-x64 +0 -0
- package/bin/agent-browser.js +109 -0
- package/dist/actions.d.ts +17 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +1427 -0
- package/dist/actions.js.map +1 -0
- package/dist/browser.d.ts +474 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +1566 -0
- package/dist/browser.js.map +1 -0
- package/dist/cdp-client.d.ts +103 -0
- package/dist/cdp-client.d.ts.map +1 -0
- package/dist/cdp-client.js +223 -0
- package/dist/cdp-client.js.map +1 -0
- package/dist/daemon.d.ts +60 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +401 -0
- package/dist/daemon.js.map +1 -0
- package/dist/dualmode-config.d.ts +37 -0
- package/dist/dualmode-config.d.ts.map +1 -0
- package/dist/dualmode-config.js +44 -0
- package/dist/dualmode-config.js.map +1 -0
- package/dist/dualmode-fetcher.d.ts +60 -0
- package/dist/dualmode-fetcher.d.ts.map +1 -0
- package/dist/dualmode-fetcher.js +449 -0
- package/dist/dualmode-fetcher.js.map +1 -0
- package/dist/dualmode-types.d.ts +183 -0
- package/dist/dualmode-types.d.ts.map +1 -0
- package/dist/dualmode-types.js +8 -0
- package/dist/dualmode-types.js.map +1 -0
- package/dist/ios-actions.d.ts +11 -0
- package/dist/ios-actions.d.ts.map +1 -0
- package/dist/ios-actions.js +228 -0
- package/dist/ios-actions.js.map +1 -0
- package/dist/ios-manager.d.ts +266 -0
- package/dist/ios-manager.d.ts.map +1 -0
- package/dist/ios-manager.js +1073 -0
- package/dist/ios-manager.js.map +1 -0
- package/dist/protocol.d.ts +26 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +832 -0
- package/dist/protocol.js.map +1 -0
- package/dist/snapshot.d.ts +83 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +653 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/stream-server.d.ts +117 -0
- package/dist/stream-server.d.ts.map +1 -0
- package/dist/stream-server.js +305 -0
- package/dist/stream-server.js.map +1 -0
- package/dist/types.d.ts +742 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/docker-compose.sdk.yml +45 -0
- package/package.json +85 -0
- package/scripts/benchmark.sh +80 -0
- package/scripts/build-all-platforms.sh +68 -0
- package/scripts/check-version-sync.js +39 -0
- package/scripts/copy-native.js +36 -0
- package/scripts/fast_reset.sh +108 -0
- package/scripts/postinstall.js +235 -0
- package/scripts/publish_images.sh +55 -0
- package/scripts/snapshot_manager.sh +293 -0
- package/scripts/start-android-agent.sh +49 -0
- package/scripts/sync-version.js +69 -0
- package/scripts/vaccine-run +26 -0
- package/sdk.sh +153 -0
- package/skills/agent-browser/SKILL.md +217 -0
- package/skills/agent-browser/references/authentication.md +202 -0
- package/skills/agent-browser/references/commands.md +259 -0
- package/skills/agent-browser/references/proxy-support.md +188 -0
- package/skills/agent-browser/references/session-management.md +193 -0
- package/skills/agent-browser/references/snapshot-refs.md +194 -0
- package/skills/agent-browser/references/video-recording.md +173 -0
- package/skills/agent-browser/templates/authenticated-session.sh +97 -0
- package/skills/agent-browser/templates/capture-workflow.sh +69 -0
- package/skills/agent-browser/templates/form-automation.sh +62 -0
- package/skills/skill-creator/LICENSE.txt +202 -0
- package/skills/skill-creator/SKILL.md +356 -0
- package/skills/skill-creator/references/output-patterns.md +82 -0
- package/skills/skill-creator/references/workflows.md +28 -0
- package/skills/skill-creator/scripts/init_skill.py +303 -0
- package/skills/skill-creator/scripts/package_skill.py +113 -0
- package/skills/skill-creator/scripts/quick_validate.py +95 -0
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iOS Simulator Manager - Manages iOS Simulator and Safari automation via Appium.
|
|
3
|
+
*
|
|
4
|
+
* This provides 1:1 command parity with BrowserManager for iOS Safari.
|
|
5
|
+
*/
|
|
6
|
+
import { Simctl } from 'node-simctl';
|
|
7
|
+
import { remote } from 'webdriverio';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
/**
|
|
12
|
+
* Manages iOS Simulator and Safari automation via Appium
|
|
13
|
+
*/
|
|
14
|
+
export class IOSManager {
|
|
15
|
+
simctl;
|
|
16
|
+
browser = null;
|
|
17
|
+
appiumProcess = null;
|
|
18
|
+
deviceUdid = null;
|
|
19
|
+
deviceName = null;
|
|
20
|
+
consoleMessages = [];
|
|
21
|
+
refMap = {};
|
|
22
|
+
lastSnapshot = '';
|
|
23
|
+
refCounter = 0;
|
|
24
|
+
// Default Appium port
|
|
25
|
+
static APPIUM_PORT = 4723;
|
|
26
|
+
static APPIUM_HOST = '127.0.0.1';
|
|
27
|
+
constructor() {
|
|
28
|
+
this.simctl = new Simctl();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Check if browser is launched
|
|
32
|
+
*/
|
|
33
|
+
isLaunched() {
|
|
34
|
+
return this.browser !== null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* List connected real iOS devices
|
|
38
|
+
*/
|
|
39
|
+
async listRealDevices() {
|
|
40
|
+
const devices = [];
|
|
41
|
+
try {
|
|
42
|
+
// Use xcrun xctrace to list connected devices
|
|
43
|
+
const { execSync } = await import('node:child_process');
|
|
44
|
+
const output = execSync('xcrun xctrace list devices 2>/dev/null || true', {
|
|
45
|
+
encoding: 'utf-8',
|
|
46
|
+
timeout: 10000,
|
|
47
|
+
});
|
|
48
|
+
// Parse output - format is:
|
|
49
|
+
// == Devices ==
|
|
50
|
+
// Device Name (OS Version) (UDID)
|
|
51
|
+
// Real devices show version as just "26.2", simulators as "iOS 18.0"
|
|
52
|
+
const lines = output.split('\n');
|
|
53
|
+
let inDevicesSection = false;
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
if (line.includes('== Devices ==')) {
|
|
56
|
+
inDevicesSection = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
// Stop at Simulators or Devices Offline section
|
|
60
|
+
if (line.includes('== Simulators ==') || line.includes('== Devices Offline ==')) {
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
if (inDevicesSection && line.trim()) {
|
|
64
|
+
// Match pattern: "Device Name (version) (UDID)"
|
|
65
|
+
const match = line.match(/^(.+?)\s+\(([^)]+)\)\s+\(([A-F0-9-]+)\)$/i);
|
|
66
|
+
if (match) {
|
|
67
|
+
const [, name, version, udid] = match;
|
|
68
|
+
const nameLower = name.toLowerCase();
|
|
69
|
+
// Include iOS devices: either name contains iPhone/iPad, or version looks like iOS
|
|
70
|
+
// (a simple version number like "26.2" or "18.6") and isn't a Mac
|
|
71
|
+
const isIOS = nameLower.includes('iphone') ||
|
|
72
|
+
nameLower.includes('ipad') ||
|
|
73
|
+
version.includes('iOS') ||
|
|
74
|
+
version.includes('iPadOS');
|
|
75
|
+
const isMac = nameLower.includes('mac') ||
|
|
76
|
+
nameLower.includes('macbook') ||
|
|
77
|
+
nameLower.includes('imac');
|
|
78
|
+
if (isIOS || (!isMac && /^\d+\.\d+(\.\d+)?$/.test(version))) {
|
|
79
|
+
devices.push({
|
|
80
|
+
name: name.trim(),
|
|
81
|
+
udid: udid,
|
|
82
|
+
state: 'Connected',
|
|
83
|
+
runtime: `iOS ${version}`,
|
|
84
|
+
isAvailable: true,
|
|
85
|
+
isRealDevice: true,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Ignore errors - real device listing is optional
|
|
94
|
+
}
|
|
95
|
+
return devices;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* List available iOS simulators
|
|
99
|
+
*/
|
|
100
|
+
async listDevices() {
|
|
101
|
+
const devices = [];
|
|
102
|
+
try {
|
|
103
|
+
const rawDevices = await this.simctl.getDevices();
|
|
104
|
+
for (const [runtime, deviceList] of Object.entries(rawDevices)) {
|
|
105
|
+
if (!Array.isArray(deviceList))
|
|
106
|
+
continue;
|
|
107
|
+
for (const device of deviceList) {
|
|
108
|
+
// Only include iPhone and iPad simulators
|
|
109
|
+
if (device.name && (device.name.includes('iPhone') || device.name.includes('iPad'))) {
|
|
110
|
+
devices.push({
|
|
111
|
+
name: device.name,
|
|
112
|
+
udid: device.udid,
|
|
113
|
+
state: device.state,
|
|
114
|
+
runtime: runtime,
|
|
115
|
+
isAvailable: device.isAvailable ?? true,
|
|
116
|
+
isRealDevice: false,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
throw new Error(`Failed to list iOS simulators. Is Xcode installed? Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
124
|
+
}
|
|
125
|
+
return devices;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* List all devices (simulators + real devices)
|
|
129
|
+
*/
|
|
130
|
+
async listAllDevices() {
|
|
131
|
+
const [simulators, realDevices] = await Promise.all([
|
|
132
|
+
this.listDevices(),
|
|
133
|
+
this.listRealDevices(),
|
|
134
|
+
]);
|
|
135
|
+
// Real devices first, then simulators
|
|
136
|
+
return [...realDevices, ...simulators];
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Find the best default device (most recent iPhone)
|
|
140
|
+
*/
|
|
141
|
+
async findDefaultDevice() {
|
|
142
|
+
const devices = await this.listDevices();
|
|
143
|
+
// Filter to available iPhones, prefer Pro models, then by name (which typically indicates recency)
|
|
144
|
+
const iphones = devices
|
|
145
|
+
.filter((d) => d.isAvailable && d.name.includes('iPhone'))
|
|
146
|
+
.sort((a, b) => {
|
|
147
|
+
// Prefer Pro models
|
|
148
|
+
const aIsPro = a.name.includes('Pro') ? 1 : 0;
|
|
149
|
+
const bIsPro = b.name.includes('Pro') ? 1 : 0;
|
|
150
|
+
if (aIsPro !== bIsPro)
|
|
151
|
+
return bIsPro - aIsPro;
|
|
152
|
+
// Then sort by name descending (iPhone 15 > iPhone 14)
|
|
153
|
+
return b.name.localeCompare(a.name);
|
|
154
|
+
});
|
|
155
|
+
return iphones[0] ?? null;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Find device by name or UDID (searches both simulators and real devices)
|
|
159
|
+
*/
|
|
160
|
+
async findDevice(nameOrUdid) {
|
|
161
|
+
const devices = await this.listAllDevices();
|
|
162
|
+
// Try exact UDID match first
|
|
163
|
+
const byUdid = devices.find((d) => d.udid === nameOrUdid);
|
|
164
|
+
if (byUdid)
|
|
165
|
+
return byUdid;
|
|
166
|
+
// Try exact name match
|
|
167
|
+
const byExactName = devices.find((d) => d.name === nameOrUdid);
|
|
168
|
+
if (byExactName)
|
|
169
|
+
return byExactName;
|
|
170
|
+
// Try partial name match
|
|
171
|
+
const byPartialName = devices.find((d) => d.name.toLowerCase().includes(nameOrUdid.toLowerCase()));
|
|
172
|
+
return byPartialName ?? null;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Check if Appium is installed
|
|
176
|
+
*/
|
|
177
|
+
async checkAppiumInstalled() {
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
const proc = spawn('appium', ['--version'], { shell: true });
|
|
180
|
+
proc.on('close', (code) => resolve(code === 0));
|
|
181
|
+
proc.on('error', () => resolve(false));
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Check if Appium server is already running
|
|
186
|
+
*/
|
|
187
|
+
async isAppiumRunning() {
|
|
188
|
+
try {
|
|
189
|
+
const response = await fetch(`http://${IOSManager.APPIUM_HOST}:${IOSManager.APPIUM_PORT}/status`);
|
|
190
|
+
return response.ok;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Start Appium server if not already running
|
|
198
|
+
*/
|
|
199
|
+
async startAppiumServer() {
|
|
200
|
+
if (await this.isAppiumRunning()) {
|
|
201
|
+
return; // Already running
|
|
202
|
+
}
|
|
203
|
+
if (!(await this.checkAppiumInstalled())) {
|
|
204
|
+
throw new Error('Appium not installed. Run: npm install -g appium && appium driver install xcuitest');
|
|
205
|
+
}
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
this.appiumProcess = spawn('appium', ['--port', String(IOSManager.APPIUM_PORT), '--relaxed-security'], {
|
|
208
|
+
shell: true,
|
|
209
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
210
|
+
});
|
|
211
|
+
let started = false;
|
|
212
|
+
const timeout = setTimeout(() => {
|
|
213
|
+
if (!started) {
|
|
214
|
+
reject(new Error('Appium server failed to start within 30 seconds'));
|
|
215
|
+
}
|
|
216
|
+
}, 30000);
|
|
217
|
+
this.appiumProcess.stdout?.on('data', (data) => {
|
|
218
|
+
const output = data.toString();
|
|
219
|
+
if (output.includes('Appium REST http interface listener started')) {
|
|
220
|
+
started = true;
|
|
221
|
+
clearTimeout(timeout);
|
|
222
|
+
resolve();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
this.appiumProcess.stderr?.on('data', (data) => {
|
|
226
|
+
const output = data.toString();
|
|
227
|
+
// Appium logs to stderr for info messages too
|
|
228
|
+
if (output.includes('Appium REST http interface listener started')) {
|
|
229
|
+
started = true;
|
|
230
|
+
clearTimeout(timeout);
|
|
231
|
+
resolve();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
this.appiumProcess.on('error', (err) => {
|
|
235
|
+
clearTimeout(timeout);
|
|
236
|
+
reject(new Error(`Failed to start Appium: ${err.message}`));
|
|
237
|
+
});
|
|
238
|
+
this.appiumProcess.on('close', (code) => {
|
|
239
|
+
if (!started) {
|
|
240
|
+
clearTimeout(timeout);
|
|
241
|
+
reject(new Error(`Appium exited with code ${code}`));
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Boot the iOS simulator
|
|
248
|
+
*/
|
|
249
|
+
async bootSimulator(udid) {
|
|
250
|
+
try {
|
|
251
|
+
const devices = await this.simctl.getDevices();
|
|
252
|
+
let currentState;
|
|
253
|
+
// Find current device state
|
|
254
|
+
for (const deviceList of Object.values(devices)) {
|
|
255
|
+
if (!Array.isArray(deviceList))
|
|
256
|
+
continue;
|
|
257
|
+
const device = deviceList.find((d) => d.udid === udid);
|
|
258
|
+
if (device) {
|
|
259
|
+
currentState = device.state;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (currentState === 'Booted') {
|
|
264
|
+
return; // Already booted
|
|
265
|
+
}
|
|
266
|
+
// node-simctl expects udid to be set on the instance
|
|
267
|
+
this.simctl.udid = udid;
|
|
268
|
+
await this.simctl.bootDevice();
|
|
269
|
+
// Wait for device to be fully booted
|
|
270
|
+
let attempts = 0;
|
|
271
|
+
while (attempts < 60) {
|
|
272
|
+
const updatedDevices = await this.simctl.getDevices();
|
|
273
|
+
for (const deviceList of Object.values(updatedDevices)) {
|
|
274
|
+
if (!Array.isArray(deviceList))
|
|
275
|
+
continue;
|
|
276
|
+
const device = deviceList.find((d) => d.udid === udid);
|
|
277
|
+
if (device?.state === 'Booted') {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
282
|
+
attempts++;
|
|
283
|
+
}
|
|
284
|
+
throw new Error('Simulator failed to boot within 60 seconds');
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
throw new Error(`Failed to boot simulator: ${error instanceof Error ? error.message : String(error)}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Launch iOS Safari via Appium
|
|
292
|
+
*/
|
|
293
|
+
async launch(options = {}) {
|
|
294
|
+
if (this.isLaunched()) {
|
|
295
|
+
return; // Already launched
|
|
296
|
+
}
|
|
297
|
+
// Find device
|
|
298
|
+
let device = null;
|
|
299
|
+
if (options.udid) {
|
|
300
|
+
device = await this.findDevice(options.udid);
|
|
301
|
+
if (!device) {
|
|
302
|
+
throw new Error(`Device with UDID ${options.udid} not found`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
else if (options.device) {
|
|
306
|
+
device = await this.findDevice(options.device);
|
|
307
|
+
if (!device) {
|
|
308
|
+
throw new Error(`Device "${options.device}" not found. Run: agent-browser device list`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
// Check environment variable
|
|
313
|
+
const envDevice = process.env.AGENT_BROWSER_IOS_DEVICE;
|
|
314
|
+
const envUdid = process.env.AGENT_BROWSER_IOS_UDID;
|
|
315
|
+
if (envUdid) {
|
|
316
|
+
device = await this.findDevice(envUdid);
|
|
317
|
+
if (!device) {
|
|
318
|
+
throw new Error(`Device with UDID ${envUdid} not found. Run: agent-browser device list`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else if (envDevice) {
|
|
322
|
+
device = await this.findDevice(envDevice);
|
|
323
|
+
if (!device) {
|
|
324
|
+
throw new Error(`Device "${envDevice}" not found. Run: agent-browser device list`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
device = await this.findDefaultDevice();
|
|
329
|
+
if (!device) {
|
|
330
|
+
throw new Error('No iOS simulators available. Open Xcode and download simulator runtimes.');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
this.deviceUdid = device.udid;
|
|
335
|
+
this.deviceName = device.name;
|
|
336
|
+
// Start Appium server
|
|
337
|
+
await this.startAppiumServer();
|
|
338
|
+
// Boot simulator (skip for real devices - they're already running)
|
|
339
|
+
if (!device.isRealDevice) {
|
|
340
|
+
await this.bootSimulator(device.udid);
|
|
341
|
+
}
|
|
342
|
+
// Connect to Safari via Appium
|
|
343
|
+
try {
|
|
344
|
+
this.browser = await remote({
|
|
345
|
+
hostname: IOSManager.APPIUM_HOST,
|
|
346
|
+
port: IOSManager.APPIUM_PORT,
|
|
347
|
+
path: '/',
|
|
348
|
+
capabilities: {
|
|
349
|
+
platformName: 'iOS',
|
|
350
|
+
'appium:automationName': 'XCUITest',
|
|
351
|
+
'appium:deviceName': device.name,
|
|
352
|
+
'appium:udid': device.udid,
|
|
353
|
+
browserName: 'Safari',
|
|
354
|
+
'appium:noReset': true,
|
|
355
|
+
'appium:newCommandTimeout': 300,
|
|
356
|
+
},
|
|
357
|
+
connectionRetryTimeout: 120000,
|
|
358
|
+
connectionRetryCount: 3,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
throw new Error(`Failed to connect to Safari: ${error instanceof Error ? error.message : String(error)}. ` +
|
|
363
|
+
'Make sure XCUITest driver is installed: appium driver install xcuitest');
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Navigate to URL
|
|
368
|
+
*/
|
|
369
|
+
async navigate(url) {
|
|
370
|
+
if (!this.browser) {
|
|
371
|
+
throw new Error('iOS browser not launched. Call launch first.');
|
|
372
|
+
}
|
|
373
|
+
await this.browser.url(url);
|
|
374
|
+
// Wait for page to load
|
|
375
|
+
await this.browser.waitUntil(async () => {
|
|
376
|
+
const state = (await this.browser.execute('return document.readyState'));
|
|
377
|
+
return state === 'complete';
|
|
378
|
+
}, { timeout: 30000, interval: 500 });
|
|
379
|
+
const title = await this.browser.getTitle();
|
|
380
|
+
const currentUrl = await this.browser.getUrl();
|
|
381
|
+
return { url: currentUrl, title };
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Get current URL
|
|
385
|
+
*/
|
|
386
|
+
async getUrl() {
|
|
387
|
+
if (!this.browser) {
|
|
388
|
+
throw new Error('iOS browser not launched');
|
|
389
|
+
}
|
|
390
|
+
return this.browser.getUrl();
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Get page title
|
|
394
|
+
*/
|
|
395
|
+
async getTitle() {
|
|
396
|
+
if (!this.browser) {
|
|
397
|
+
throw new Error('iOS browser not launched');
|
|
398
|
+
}
|
|
399
|
+
return this.browser.getTitle();
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Click/tap an element
|
|
403
|
+
*/
|
|
404
|
+
async click(selector) {
|
|
405
|
+
if (!this.browser) {
|
|
406
|
+
throw new Error('iOS browser not launched');
|
|
407
|
+
}
|
|
408
|
+
const element = await this.getElement(selector);
|
|
409
|
+
await element.click();
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Alias for click (semantic clarity for touch)
|
|
413
|
+
*/
|
|
414
|
+
async tap(selector) {
|
|
415
|
+
return this.click(selector);
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Type text into an element
|
|
419
|
+
*/
|
|
420
|
+
async type(selector, text, options) {
|
|
421
|
+
if (!this.browser) {
|
|
422
|
+
throw new Error('iOS browser not launched');
|
|
423
|
+
}
|
|
424
|
+
const element = await this.getElement(selector);
|
|
425
|
+
if (options?.clear) {
|
|
426
|
+
await element.clearValue();
|
|
427
|
+
}
|
|
428
|
+
// WebdriverIO doesn't have a delay option, so we simulate it
|
|
429
|
+
if (options?.delay && options.delay > 0) {
|
|
430
|
+
for (const char of text) {
|
|
431
|
+
await element.addValue(char);
|
|
432
|
+
await new Promise((r) => setTimeout(r, options.delay));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
await element.addValue(text);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Fill an element (clear first, then type)
|
|
441
|
+
*/
|
|
442
|
+
async fill(selector, value) {
|
|
443
|
+
if (!this.browser) {
|
|
444
|
+
throw new Error('iOS browser not launched');
|
|
445
|
+
}
|
|
446
|
+
const element = await this.getElement(selector);
|
|
447
|
+
await element.clearValue();
|
|
448
|
+
await element.setValue(value);
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Get element by selector or ref
|
|
452
|
+
*/
|
|
453
|
+
async getElement(selectorOrRef) {
|
|
454
|
+
if (!this.browser) {
|
|
455
|
+
throw new Error('iOS browser not launched');
|
|
456
|
+
}
|
|
457
|
+
// Check if it's a ref
|
|
458
|
+
const refData = this.getRefData(selectorOrRef);
|
|
459
|
+
if (refData) {
|
|
460
|
+
if (refData.xpath) {
|
|
461
|
+
return this.browser.$(refData.xpath);
|
|
462
|
+
}
|
|
463
|
+
return this.browser.$(refData.selector);
|
|
464
|
+
}
|
|
465
|
+
// Regular CSS selector
|
|
466
|
+
return this.browser.$(selectorOrRef);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Get ref data from ref string
|
|
470
|
+
*/
|
|
471
|
+
getRefData(refArg) {
|
|
472
|
+
let ref = null;
|
|
473
|
+
if (refArg.startsWith('@')) {
|
|
474
|
+
ref = refArg.slice(1);
|
|
475
|
+
}
|
|
476
|
+
else if (refArg.startsWith('ref=')) {
|
|
477
|
+
ref = refArg.slice(4);
|
|
478
|
+
}
|
|
479
|
+
else if (/^e\d+$/.test(refArg)) {
|
|
480
|
+
ref = refArg;
|
|
481
|
+
}
|
|
482
|
+
if (ref && this.refMap[ref]) {
|
|
483
|
+
return this.refMap[ref];
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Take a screenshot
|
|
489
|
+
*/
|
|
490
|
+
async screenshot(options) {
|
|
491
|
+
if (!this.browser) {
|
|
492
|
+
throw new Error('iOS browser not launched');
|
|
493
|
+
}
|
|
494
|
+
const base64 = await this.browser.takeScreenshot();
|
|
495
|
+
if (options?.path) {
|
|
496
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
497
|
+
const dir = path.dirname(options.path);
|
|
498
|
+
if (!existsSync(dir)) {
|
|
499
|
+
mkdirSync(dir, { recursive: true });
|
|
500
|
+
}
|
|
501
|
+
writeFileSync(options.path, base64, 'base64');
|
|
502
|
+
return { path: options.path };
|
|
503
|
+
}
|
|
504
|
+
return { base64 };
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Get page snapshot with refs
|
|
508
|
+
*/
|
|
509
|
+
async getSnapshot(options) {
|
|
510
|
+
if (!this.browser) {
|
|
511
|
+
throw new Error('iOS browser not launched');
|
|
512
|
+
}
|
|
513
|
+
this.refCounter = 0;
|
|
514
|
+
this.refMap = {};
|
|
515
|
+
// Get page structure via JavaScript execution
|
|
516
|
+
// Note: The function runs in browser context, so we use 'any' for DOM types
|
|
517
|
+
const snapshot = await this.browser.execute(function (interactiveOnly) {
|
|
518
|
+
const INTERACTIVE_ROLES = new Set([
|
|
519
|
+
'button',
|
|
520
|
+
'link',
|
|
521
|
+
'textbox',
|
|
522
|
+
'checkbox',
|
|
523
|
+
'radio',
|
|
524
|
+
'combobox',
|
|
525
|
+
'listbox',
|
|
526
|
+
'menuitem',
|
|
527
|
+
'option',
|
|
528
|
+
'searchbox',
|
|
529
|
+
'slider',
|
|
530
|
+
'spinbutton',
|
|
531
|
+
'switch',
|
|
532
|
+
'tab',
|
|
533
|
+
'treeitem',
|
|
534
|
+
]);
|
|
535
|
+
const INTERACTIVE_TAGS = new Set([
|
|
536
|
+
'A',
|
|
537
|
+
'BUTTON',
|
|
538
|
+
'INPUT',
|
|
539
|
+
'SELECT',
|
|
540
|
+
'TEXTAREA',
|
|
541
|
+
'DETAILS',
|
|
542
|
+
'SUMMARY',
|
|
543
|
+
]);
|
|
544
|
+
function getXPath(element) {
|
|
545
|
+
if (element.id) {
|
|
546
|
+
return `//*[@id="${element.id}"]`;
|
|
547
|
+
}
|
|
548
|
+
const parts = [];
|
|
549
|
+
let current = element;
|
|
550
|
+
while (current && current.nodeType === 1) {
|
|
551
|
+
// Node.ELEMENT_NODE = 1
|
|
552
|
+
let index = 1;
|
|
553
|
+
let sibling = current.previousElementSibling;
|
|
554
|
+
while (sibling) {
|
|
555
|
+
if (sibling.nodeName === current.nodeName) {
|
|
556
|
+
index++;
|
|
557
|
+
}
|
|
558
|
+
sibling = sibling.previousElementSibling;
|
|
559
|
+
}
|
|
560
|
+
const tagName = current.nodeName.toLowerCase();
|
|
561
|
+
parts.unshift(`${tagName}[${index}]`);
|
|
562
|
+
current = current.parentElement;
|
|
563
|
+
}
|
|
564
|
+
return '/' + parts.join('/');
|
|
565
|
+
}
|
|
566
|
+
function getAccessibleName(element) {
|
|
567
|
+
// aria-label takes precedence
|
|
568
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
569
|
+
if (ariaLabel)
|
|
570
|
+
return ariaLabel;
|
|
571
|
+
// For inputs, check placeholder and label
|
|
572
|
+
const tagName = element.tagName;
|
|
573
|
+
if (tagName === 'INPUT' || tagName === 'TEXTAREA') {
|
|
574
|
+
const id = element.id;
|
|
575
|
+
if (id) {
|
|
576
|
+
const label = document.querySelector(`label[for="${id}"]`);
|
|
577
|
+
if (label)
|
|
578
|
+
return label.textContent?.trim() || '';
|
|
579
|
+
}
|
|
580
|
+
if (element.placeholder)
|
|
581
|
+
return element.placeholder;
|
|
582
|
+
}
|
|
583
|
+
// For buttons and links, use text content
|
|
584
|
+
if (tagName === 'BUTTON' || tagName === 'A') {
|
|
585
|
+
return element.textContent?.trim() || '';
|
|
586
|
+
}
|
|
587
|
+
// aria-labelledby
|
|
588
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
589
|
+
if (labelledBy) {
|
|
590
|
+
const labelElement = document.getElementById(labelledBy);
|
|
591
|
+
if (labelElement)
|
|
592
|
+
return labelElement.textContent?.trim() || '';
|
|
593
|
+
}
|
|
594
|
+
return element.textContent?.trim().slice(0, 50) || '';
|
|
595
|
+
}
|
|
596
|
+
function getRole(element) {
|
|
597
|
+
// Explicit role
|
|
598
|
+
const role = element.getAttribute('role');
|
|
599
|
+
if (role)
|
|
600
|
+
return role;
|
|
601
|
+
// Implicit roles
|
|
602
|
+
const tag = element.tagName;
|
|
603
|
+
if (tag === 'A' && element.hasAttribute('href'))
|
|
604
|
+
return 'link';
|
|
605
|
+
if (tag === 'BUTTON')
|
|
606
|
+
return 'button';
|
|
607
|
+
if (tag === 'INPUT') {
|
|
608
|
+
const type = element.type;
|
|
609
|
+
if (type === 'checkbox')
|
|
610
|
+
return 'checkbox';
|
|
611
|
+
if (type === 'radio')
|
|
612
|
+
return 'radio';
|
|
613
|
+
if (type === 'text' || type === 'email' || type === 'password' || type === 'search')
|
|
614
|
+
return 'textbox';
|
|
615
|
+
if (type === 'submit' || type === 'button')
|
|
616
|
+
return 'button';
|
|
617
|
+
}
|
|
618
|
+
if (tag === 'TEXTAREA')
|
|
619
|
+
return 'textbox';
|
|
620
|
+
if (tag === 'SELECT')
|
|
621
|
+
return 'combobox';
|
|
622
|
+
if (tag === 'H1' ||
|
|
623
|
+
tag === 'H2' ||
|
|
624
|
+
tag === 'H3' ||
|
|
625
|
+
tag === 'H4' ||
|
|
626
|
+
tag === 'H5' ||
|
|
627
|
+
tag === 'H6')
|
|
628
|
+
return 'heading';
|
|
629
|
+
if (tag === 'IMG')
|
|
630
|
+
return 'img';
|
|
631
|
+
if (tag === 'NAV')
|
|
632
|
+
return 'navigation';
|
|
633
|
+
if (tag === 'MAIN')
|
|
634
|
+
return 'main';
|
|
635
|
+
if (tag === 'HEADER')
|
|
636
|
+
return 'banner';
|
|
637
|
+
if (tag === 'FOOTER')
|
|
638
|
+
return 'contentinfo';
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
function traverse(element, depth) {
|
|
642
|
+
if (depth > 10)
|
|
643
|
+
return null; // Limit depth
|
|
644
|
+
const tag = element.tagName;
|
|
645
|
+
const role = getRole(element);
|
|
646
|
+
const name = getAccessibleName(element);
|
|
647
|
+
const isInteractive = INTERACTIVE_TAGS.has(tag) || (role !== null && INTERACTIVE_ROLES.has(role));
|
|
648
|
+
// Skip hidden elements
|
|
649
|
+
const style = window.getComputedStyle(element);
|
|
650
|
+
if (style.display === 'none' || style.visibility === 'hidden') {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
const children = [];
|
|
654
|
+
for (const child of element.children) {
|
|
655
|
+
const childInfo = traverse(child, depth + 1);
|
|
656
|
+
if (childInfo) {
|
|
657
|
+
children.push(childInfo);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// In interactive mode, skip non-interactive elements without interactive children
|
|
661
|
+
if (interactiveOnly && !isInteractive && children.length === 0) {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
tag,
|
|
666
|
+
role,
|
|
667
|
+
name,
|
|
668
|
+
text: element.textContent?.trim().slice(0, 100) || '',
|
|
669
|
+
isInteractive,
|
|
670
|
+
xpath: getXPath(element),
|
|
671
|
+
children,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
const root = traverse(document.body, 0);
|
|
675
|
+
return root;
|
|
676
|
+
}, options?.interactive ?? false);
|
|
677
|
+
// Build tree string and refs
|
|
678
|
+
const lines = [];
|
|
679
|
+
const buildTree = (node, indent) => {
|
|
680
|
+
if (!node)
|
|
681
|
+
return;
|
|
682
|
+
const prefix = ' '.repeat(indent) + '- ';
|
|
683
|
+
const role = node.role || node.tag.toLowerCase();
|
|
684
|
+
const name = node.name;
|
|
685
|
+
let line = `${prefix}${role}`;
|
|
686
|
+
if (name) {
|
|
687
|
+
line += ` "${name}"`;
|
|
688
|
+
}
|
|
689
|
+
// Add ref for interactive elements
|
|
690
|
+
if (node.isInteractive) {
|
|
691
|
+
const ref = `e${++this.refCounter}`;
|
|
692
|
+
line += ` [ref=${ref}]`;
|
|
693
|
+
this.refMap[ref] = {
|
|
694
|
+
selector: node.xpath.startsWith('/') ? node.xpath : `#${node.xpath}`,
|
|
695
|
+
role: node.role,
|
|
696
|
+
name: node.name,
|
|
697
|
+
xpath: node.xpath,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
lines.push(line);
|
|
701
|
+
for (const child of node.children || []) {
|
|
702
|
+
buildTree(child, indent + 1);
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
if (snapshot) {
|
|
706
|
+
buildTree(snapshot, 0);
|
|
707
|
+
}
|
|
708
|
+
const tree = lines.join('\n') || '(empty)';
|
|
709
|
+
this.lastSnapshot = tree;
|
|
710
|
+
return { tree, refs: this.refMap };
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Get cached ref map
|
|
714
|
+
*/
|
|
715
|
+
getRefMap() {
|
|
716
|
+
return this.refMap;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Scroll the page
|
|
720
|
+
*/
|
|
721
|
+
async scroll(options) {
|
|
722
|
+
if (!this.browser) {
|
|
723
|
+
throw new Error('iOS browser not launched');
|
|
724
|
+
}
|
|
725
|
+
const amount = options?.amount ?? 300;
|
|
726
|
+
if (options?.selector) {
|
|
727
|
+
const element = await this.getElement(options.selector);
|
|
728
|
+
await element.scrollIntoView();
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
// Use JavaScript scrolling
|
|
732
|
+
let deltaX = options?.x ?? 0;
|
|
733
|
+
let deltaY = options?.y ?? 0;
|
|
734
|
+
if (options?.direction) {
|
|
735
|
+
switch (options.direction) {
|
|
736
|
+
case 'up':
|
|
737
|
+
deltaY = -amount;
|
|
738
|
+
break;
|
|
739
|
+
case 'down':
|
|
740
|
+
deltaY = amount;
|
|
741
|
+
break;
|
|
742
|
+
case 'left':
|
|
743
|
+
deltaX = -amount;
|
|
744
|
+
break;
|
|
745
|
+
case 'right':
|
|
746
|
+
deltaX = amount;
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
await this.browser.execute(function (x, y) {
|
|
751
|
+
window.scrollBy(x, y);
|
|
752
|
+
}, deltaX, deltaY);
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Swipe gesture (iOS-specific)
|
|
756
|
+
*/
|
|
757
|
+
async swipe(direction, options) {
|
|
758
|
+
if (!this.browser) {
|
|
759
|
+
throw new Error('iOS browser not launched');
|
|
760
|
+
}
|
|
761
|
+
const distance = options?.distance ?? 300;
|
|
762
|
+
// Map direction to scroll (opposite direction)
|
|
763
|
+
const scrollDirection = {
|
|
764
|
+
up: 'down',
|
|
765
|
+
down: 'up',
|
|
766
|
+
left: 'right',
|
|
767
|
+
right: 'left',
|
|
768
|
+
}[direction];
|
|
769
|
+
await this.scroll({ direction: scrollDirection, amount: distance });
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Execute JavaScript
|
|
773
|
+
*/
|
|
774
|
+
async evaluate(script, ...args) {
|
|
775
|
+
if (!this.browser) {
|
|
776
|
+
throw new Error('iOS browser not launched');
|
|
777
|
+
}
|
|
778
|
+
// Execute the script directly - WebdriverIO handles the context
|
|
779
|
+
const result = await this.browser.execute(function (code, evalArgs) {
|
|
780
|
+
// Create a function from the code and execute it
|
|
781
|
+
const fn = new Function(...evalArgs.map((_, i) => `arg${i}`), code);
|
|
782
|
+
return fn(...evalArgs);
|
|
783
|
+
}, script.includes('return') ? script : `return (${script})`, args);
|
|
784
|
+
return result;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Wait for element
|
|
788
|
+
*/
|
|
789
|
+
async wait(options) {
|
|
790
|
+
if (!this.browser) {
|
|
791
|
+
throw new Error('iOS browser not launched');
|
|
792
|
+
}
|
|
793
|
+
const timeout = options.timeout ?? 30000;
|
|
794
|
+
if (options.selector) {
|
|
795
|
+
const element = await this.getElement(options.selector);
|
|
796
|
+
switch (options.state) {
|
|
797
|
+
case 'detached':
|
|
798
|
+
await element.waitForExist({ timeout, reverse: true });
|
|
799
|
+
break;
|
|
800
|
+
case 'hidden':
|
|
801
|
+
await element.waitForDisplayed({ timeout, reverse: true });
|
|
802
|
+
break;
|
|
803
|
+
case 'visible':
|
|
804
|
+
await element.waitForDisplayed({ timeout });
|
|
805
|
+
break;
|
|
806
|
+
case 'attached':
|
|
807
|
+
default:
|
|
808
|
+
await element.waitForExist({ timeout });
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
// Just wait for timeout
|
|
814
|
+
await new Promise((r) => setTimeout(r, timeout));
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Press a key
|
|
819
|
+
*/
|
|
820
|
+
async press(key) {
|
|
821
|
+
if (!this.browser) {
|
|
822
|
+
throw new Error('iOS browser not launched');
|
|
823
|
+
}
|
|
824
|
+
// Map common key names
|
|
825
|
+
const keyMap = {
|
|
826
|
+
Enter: '\uE007',
|
|
827
|
+
Tab: '\uE004',
|
|
828
|
+
Escape: '\uE00C',
|
|
829
|
+
Backspace: '\uE003',
|
|
830
|
+
Delete: '\uE017',
|
|
831
|
+
ArrowUp: '\uE013',
|
|
832
|
+
ArrowDown: '\uE015',
|
|
833
|
+
ArrowLeft: '\uE012',
|
|
834
|
+
ArrowRight: '\uE014',
|
|
835
|
+
};
|
|
836
|
+
const mappedKey = keyMap[key] ?? key;
|
|
837
|
+
await this.browser.keys(mappedKey);
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Hover over element (limited on touch - just scrolls into view)
|
|
841
|
+
*/
|
|
842
|
+
async hover(selector) {
|
|
843
|
+
if (!this.browser) {
|
|
844
|
+
throw new Error('iOS browser not launched');
|
|
845
|
+
}
|
|
846
|
+
const element = await this.getElement(selector);
|
|
847
|
+
await element.scrollIntoView();
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Get page content (HTML)
|
|
851
|
+
*/
|
|
852
|
+
async getContent(selector) {
|
|
853
|
+
if (!this.browser) {
|
|
854
|
+
throw new Error('iOS browser not launched');
|
|
855
|
+
}
|
|
856
|
+
if (selector) {
|
|
857
|
+
const element = await this.getElement(selector);
|
|
858
|
+
return element.getHTML();
|
|
859
|
+
}
|
|
860
|
+
return this.browser.getPageSource();
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Get text content of element
|
|
864
|
+
*/
|
|
865
|
+
async getText(selector) {
|
|
866
|
+
if (!this.browser) {
|
|
867
|
+
throw new Error('iOS browser not launched');
|
|
868
|
+
}
|
|
869
|
+
const element = await this.getElement(selector);
|
|
870
|
+
return element.getText();
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Get attribute value
|
|
874
|
+
*/
|
|
875
|
+
async getAttribute(selector, attribute) {
|
|
876
|
+
if (!this.browser) {
|
|
877
|
+
throw new Error('iOS browser not launched');
|
|
878
|
+
}
|
|
879
|
+
const element = await this.getElement(selector);
|
|
880
|
+
return element.getAttribute(attribute);
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Check if element is visible
|
|
884
|
+
*/
|
|
885
|
+
async isVisible(selector) {
|
|
886
|
+
if (!this.browser) {
|
|
887
|
+
throw new Error('iOS browser not launched');
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
const element = await this.getElement(selector);
|
|
891
|
+
return element.isDisplayed();
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Check if element is enabled
|
|
899
|
+
*/
|
|
900
|
+
async isEnabled(selector) {
|
|
901
|
+
if (!this.browser) {
|
|
902
|
+
throw new Error('iOS browser not launched');
|
|
903
|
+
}
|
|
904
|
+
const element = await this.getElement(selector);
|
|
905
|
+
return element.isEnabled();
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Navigate back
|
|
909
|
+
*/
|
|
910
|
+
async goBack() {
|
|
911
|
+
if (!this.browser) {
|
|
912
|
+
throw new Error('iOS browser not launched');
|
|
913
|
+
}
|
|
914
|
+
await this.browser.back();
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Navigate forward
|
|
918
|
+
*/
|
|
919
|
+
async goForward() {
|
|
920
|
+
if (!this.browser) {
|
|
921
|
+
throw new Error('iOS browser not launched');
|
|
922
|
+
}
|
|
923
|
+
await this.browser.forward();
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Reload page
|
|
927
|
+
*/
|
|
928
|
+
async reload() {
|
|
929
|
+
if (!this.browser) {
|
|
930
|
+
throw new Error('iOS browser not launched');
|
|
931
|
+
}
|
|
932
|
+
await this.browser.refresh();
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Select option(s) from dropdown
|
|
936
|
+
*/
|
|
937
|
+
async select(selector, values) {
|
|
938
|
+
if (!this.browser) {
|
|
939
|
+
throw new Error('iOS browser not launched');
|
|
940
|
+
}
|
|
941
|
+
const element = await this.getElement(selector);
|
|
942
|
+
const valueArray = Array.isArray(values) ? values : [values];
|
|
943
|
+
for (const value of valueArray) {
|
|
944
|
+
await element.selectByAttribute('value', value);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Check a checkbox
|
|
949
|
+
*/
|
|
950
|
+
async check(selector) {
|
|
951
|
+
if (!this.browser) {
|
|
952
|
+
throw new Error('iOS browser not launched');
|
|
953
|
+
}
|
|
954
|
+
const element = await this.getElement(selector);
|
|
955
|
+
const isChecked = await element.isSelected();
|
|
956
|
+
if (!isChecked) {
|
|
957
|
+
await element.click();
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Uncheck a checkbox
|
|
962
|
+
*/
|
|
963
|
+
async uncheck(selector) {
|
|
964
|
+
if (!this.browser) {
|
|
965
|
+
throw new Error('iOS browser not launched');
|
|
966
|
+
}
|
|
967
|
+
const element = await this.getElement(selector);
|
|
968
|
+
const isChecked = await element.isSelected();
|
|
969
|
+
if (isChecked) {
|
|
970
|
+
await element.click();
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Focus an element
|
|
975
|
+
*/
|
|
976
|
+
async focus(selector) {
|
|
977
|
+
if (!this.browser) {
|
|
978
|
+
throw new Error('iOS browser not launched');
|
|
979
|
+
}
|
|
980
|
+
const element = await this.getElement(selector);
|
|
981
|
+
await this.browser.execute(function (el) {
|
|
982
|
+
el.focus();
|
|
983
|
+
}, element);
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Clear input field
|
|
987
|
+
*/
|
|
988
|
+
async clear(selector) {
|
|
989
|
+
if (!this.browser) {
|
|
990
|
+
throw new Error('iOS browser not launched');
|
|
991
|
+
}
|
|
992
|
+
const element = await this.getElement(selector);
|
|
993
|
+
await element.clearValue();
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Get element count
|
|
997
|
+
*/
|
|
998
|
+
async count(selector) {
|
|
999
|
+
if (!this.browser) {
|
|
1000
|
+
throw new Error('iOS browser not launched');
|
|
1001
|
+
}
|
|
1002
|
+
const elements = await this.browser.$$(selector);
|
|
1003
|
+
return elements.length;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Get bounding box
|
|
1007
|
+
*/
|
|
1008
|
+
async getBoundingBox(selector) {
|
|
1009
|
+
if (!this.browser) {
|
|
1010
|
+
throw new Error('iOS browser not launched');
|
|
1011
|
+
}
|
|
1012
|
+
try {
|
|
1013
|
+
const element = await this.getElement(selector);
|
|
1014
|
+
const location = await element.getLocation();
|
|
1015
|
+
const size = await element.getSize();
|
|
1016
|
+
return {
|
|
1017
|
+
x: location.x,
|
|
1018
|
+
y: location.y,
|
|
1019
|
+
width: size.width,
|
|
1020
|
+
height: size.height,
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
catch {
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Get device info
|
|
1029
|
+
*/
|
|
1030
|
+
getDeviceInfo() {
|
|
1031
|
+
if (!this.deviceName || !this.deviceUdid) {
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
return {
|
|
1035
|
+
name: this.deviceName,
|
|
1036
|
+
udid: this.deviceUdid,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Close browser and cleanup
|
|
1041
|
+
*/
|
|
1042
|
+
async close() {
|
|
1043
|
+
if (this.browser) {
|
|
1044
|
+
try {
|
|
1045
|
+
await this.browser.deleteSession();
|
|
1046
|
+
}
|
|
1047
|
+
catch {
|
|
1048
|
+
// Ignore cleanup errors
|
|
1049
|
+
}
|
|
1050
|
+
this.browser = null;
|
|
1051
|
+
}
|
|
1052
|
+
if (this.appiumProcess) {
|
|
1053
|
+
this.appiumProcess.kill();
|
|
1054
|
+
this.appiumProcess = null;
|
|
1055
|
+
}
|
|
1056
|
+
// Optionally shutdown simulator
|
|
1057
|
+
if (this.deviceUdid) {
|
|
1058
|
+
try {
|
|
1059
|
+
this.simctl.udid = this.deviceUdid;
|
|
1060
|
+
await this.simctl.shutdownDevice();
|
|
1061
|
+
}
|
|
1062
|
+
catch {
|
|
1063
|
+
// Ignore - simulator might already be shutdown
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
this.deviceUdid = null;
|
|
1067
|
+
this.deviceName = null;
|
|
1068
|
+
this.refMap = {};
|
|
1069
|
+
this.lastSnapshot = '';
|
|
1070
|
+
this.refCounter = 0;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
//# sourceMappingURL=ios-manager.js.map
|