## 如何搭建并定制 WebDriverAgent Server
Appium 的 iOS 版本的后端用的是[Facebook's WebDriverAgent](https://github.com/facebook/WebDriverAgent)。该后端是基于苹果公司的 XCTest 框架,所以也有所有 XCTest 框架已知的问题。其中有些问题我们正在设法解决,有一些在现阶段可能无法解决,如 https://github.com/facebookarchive/WebDriverAgent/issues/507 。本文中描述的方法已经能够使您完全掌握在设备上如何构建、管理和运行WDA。通过这种方式,您可以在CI环境中对您的自动化测试进行微调,并使其在长期运行的情况下更加稳定。
重点:
* 如果使用了Appium的默认设置,则不需要如下的步骤。服务器将为您搞定一切,当然你也不能对WDA做太多控制。
* 对连接的被测设备必须有SSH或物理访问权限。
### 安装WDA
Appium 会自动下载 WebDriverAgent 源码。如果使用 npm 命令(`npm install -g appium`)安装Appium的话,通常情况下会保存在/usr/local/lib/node_modules/appium/node_modules/appium-xcuitest-driver/WebDriverAgent目录下。
如果是首次安装的话,还需要下载一些第三方依赖("carthage"工具就是为此准备的:`brew install carthage`):
```bash
cd /usr/local/lib/node_modules/appium/node_modules/appium-xcuitest-driver/WebDriverAgent
./Scripts/bootstrap.sh -d
```
并且,可能需要为 WDA 源码创建一个空文件夹:
```bash
mkdir -p /usr/local/lib/node_modules/appium/node_modules/appium-webdriveragent/Resources/WebDriverAgent.bundle
```
如果是在iOS模拟器上执行自动化测试,是不需要进一步配置的。
但如果是在真机上进行测试的话,则需要进一步配置。参考[real device configuration documentation](https://github.com/appium/appium-xcuitest-driver/blob/master/docs/real-device-config.md)设置代码签名。
为了确保 WDA 源代码配置正确,请执行以下操作:
* 用Xcode打开 `/usr/local/lib/node_modules/appium/node_modules/appium-xcuitest-driver/WebDriverAgent/WebDriverAgent.xcodeproj`
* 选择工程*WebDriverAgentRunner*
* 选择要运行自动化测试的真机/模拟器作为构建目标机
* 在主菜单中选择 Product -> Test
Xcode 会成功构建项目并安装到真机/模拟器上,所以您将在苹果系统的桌面上看到 WebDriverAgentRunner 应用程序的图标。
### 启动WDA
WebDriverAgent 作为一个 REST 服务,监听外部 API 请求,传递给原生 XCTest 调用待测应用。如果是在模拟器上进行测试,REST 服务的地址是 localhost,如果是在真机上运行,REST 服务的地址将是实际的 ip 地址。使用 appium-ios-device(https://github.com/appium/appium-ios-device) 将网络请求路由到通过 USB 连接的真机上,这意味着可以使用这个工具将模拟器和真实设备上的 WDA 网络地址统一。
可以使用 appium-ios-device(https://github.com/appium/appium-ios-device) 连接远程设备,JavaScript 模块代码需要和 Appium 一致。
此外,也可以使用*iproxy*在 Appium 之外转发 WebDriverAgent。安装命令如下`node install -g iproxy`。
这个用Java编写的助手类说明了主要的实现细节:
```java
public class WDAServer {
private static final Logger log = ZLogger.getLog(WDAServer.class.getSimpleName());
private static final int MAX_REAL_DEVICE_RESTART_RETRIES = 1;
private static final Timedelta REAL_DEVICE_RUNNING_TIMEOUT = Timedelta.ofMinutes(4);
private static final Timedelta RESTART_TIMEOUT = Timedelta.ofMinutes(1);
// These settings are needed to properly sign WDA for real device tests
// See https://github.com/appium/appium-xcuitest-driver for more details
private static final File KEYCHAIN = new File(String.format("%s/%s",
System.getProperty("user.home"), "/Library/Keychains/MyKeychain.keychain"));
private static final String KEYCHAIN_PASSWORD = "******";
private static final File IPROXY_EXECUTABLE = new File("/usr/local/bin/iproxy");
private static final File XCODEBUILD_EXECUTABLE = new File("/usr/bin/xcodebuild");
private static final File WDA_PROJECT =
new File("/usr/local/lib/node_modules/appium/node_modules/appium-xcuitest-driver/" +
"WebDriverAgent/WebDriverAgent.xcodeproj");
private static final String WDA_SCHEME = "WebDriverAgentRunner";
private static final String WDA_CONFIGURATION = "Debug";
private static final File XCODEBUILD_LOG = new File("/usr/local/var/log/appium/build.log");
private static final File IPROXY_LOG = new File("/usr/local/var/log/appium/iproxy.log");
private static final int PORT = 8100;
public static final String SERVER_URL = String.format("http://127.0.0.1:%d", PORT);
private static final String[] IPROXY_CMDLINE = new String[]{
IPROXY_EXECUTABLE.getAbsolutePath(),
Integer.toString(PORT),
Integer.toString(PORT),
String.format("> %s 2>&1 &", IPROXY_LOG.getAbsolutePath())
};
private static WDAServer instance = null;
private final boolean isRealDevice;
private final String deviceId;
private final String platformVersion;
private int failedRestartRetriesCount = 0;
private WDAServer() {
try {
this.isRealDevice = !getIsSimulatorFromConfig(getClass());
final String udid;
if (isRealDevice) {
udid = IOSRealDeviceHelpers.getUDID();
} else {
udid = IOSSimulatorHelpers.getId();
}
this.deviceId = udid;
this.platformVersion = getPlatformVersionFromConfig(getClass());
} catch (Exception e) {
throw new RuntimeException(e);
}
ensureToolsExistence();
ensureParentDirExistence();
}
public synchronized static WDAServer getInstance() {
if (instance == null) {
instance = new WDAServer();
}
return instance;
}
private boolean waitUntilIsRunning(Timedelta timeout) throws Exception {
final URL status = new URL(SERVER_URL + "/status");
try {
if (timeout.asSeconds() > 5) {
log.debug(String.format("Waiting max %s until WDA server starts responding...", timeout));
}
new UrlChecker().waitUntilAvailable(timeout.asMillis(), TimeUnit.MILLISECONDS, status);
return true;
} catch (UrlChecker.TimeoutException e) {
return false;
}
}
private static void ensureParentDirExistence() {
if (!XCODEBUILD_LOG.getParentFile().exists()) {
if (!XCODEBUILD_LOG.getParentFile().mkdirs()) {
throw new IllegalStateException(String.format(
"The script has failed to create '%s' folder for Appium logs. " +
"Please make sure your account has correct access permissions on the parent folder(s)",
XCODEBUILD_LOG.getParentFile().getAbsolutePath()));
}
}
}
private void ensureToolsExistence() {
if (isRealDevice && !IPROXY_EXECUTABLE.exists()) {
throw new IllegalStateException(String.format("%s tool is expected to be installed (`npm install -g iproxy`)",
IPROXY_EXECUTABLE.getAbsolutePath()));
}
if (!XCODEBUILD_EXECUTABLE.exists()) {
throw new IllegalStateException(String.format("xcodebuild tool is not detected on the current system at %s",
XCODEBUILD_EXECUTABLE.getAbsolutePath()));
}
if (!WDA_PROJECT.exists()) {
throw new IllegalStateException(String.format("WDA project is expected to exist at %s",
WDA_PROJECT.getAbsolutePath()));
}
}
private List<String> generateXcodebuildCmdline() {
final List<String> result = new ArrayList<>();
result.add(XCODEBUILD_EXECUTABLE.getAbsolutePath());
result.add("clean build test");
result.add(String.format("-project %s", WDA_PROJECT.getAbsolutePath()));
result.add(String.format("-scheme %s", WDA_SCHEME));
result.add(String.format("-destination id=%s", deviceId));
result.add(String.format("-configuration %s", WDA_CONFIGURATION));
result.add(String.format("IPHONEOS_DEPLOYMENT_TARGET=%s", platformVersion));
result.add(String.format("> %s 2>&1 &", XCODEBUILD_LOG.getAbsolutePath()));
return result;
}
private static List<String> generateKeychainUnlockCmdlines() throws Exception {
final List<String> result = new ArrayList<>();
result.add(String.format("/usr/bin/security -v list-keychains -s %s", KEYCHAIN.getAbsolutePath()));
result.add(String.format("/usr/bin/security -v unlock-keychain -p %s %s",
KEYCHAIN_PASSWORD, KEYCHAIN.getAbsolutePath()));
result.add(String.format("/usr/bin/security set-keychain-settings -t 3600 %s", KEYCHAIN.getAbsolutePath()));
return result;
}
public synchronized void restart() throws Exception {
if (isRealDevice && failedRestartRetriesCount >= MAX_REAL_DEVICE_RESTART_RETRIES) {
throw new IllegalStateException(String.format(
"WDA server cannot start on the connected device with udid %s after %s retries. " +
"Reboot the device manually and try again", deviceId, MAX_REAL_DEVICE_RESTART_RETRIES));
}
final String hostname = InetAddress.getLocalHost().getHostName();
log.info(String.format("Trying to (re)start WDA server on %s:%s...", hostname, PORT));
UnixProcessHelpers.killProcessesGracefully(IPROXY_EXECUTABLE.getName(), XCODEBUILD_EXECUTABLE.getName());
final File scriptFile = File.createTempFile("script", ".sh");
try {
final List<String> scriptContent = new ArrayList<>();
scriptContent.add("#!/bin/bash");
if (isRealDevice && isRunningInJenkinsNetwork()) {
scriptContent.add(String.join("\n", generateKeychainUnlockCmdlines()));
}
if (isRealDevice) {
scriptContent.add(String.join(" ", IPROXY_CMDLINE));
}
final String wdaBuildCmdline = String.join(" ", generateXcodebuildCmdline());
log.debug(String.format("Building WDA with command line:\n%s\n", wdaBuildCmdline));
scriptContent.add(wdaBuildCmdline);
try (Writer output = new BufferedWriter(new FileWriter(scriptFile))) {
output.write(String.join("\n", scriptContent));
}
new ProcessBuilder("/bin/chmod", "u+x", scriptFile.getCanonicalPath())
.redirectErrorStream(true).start().waitFor(5, TimeUnit.SECONDS);
final ProcessBuilder pb = new ProcessBuilder("/bin/bash", scriptFile.getCanonicalPath());
final Map<String, String> env = pb.environment();
// This is needed for Jenkins
env.put("BUILD_ID", "dontKillMe");
log.info(String.format("Waiting max %s for WDA to be (re)started on %s:%s...", RESTART_TIMEOUT.toString(),
hostname, PORT));
final Timedelta started = Timedelta.now();
pb.redirectErrorStream(true).start().waitFor(RESTART_TIMEOUT.asMillis(), TimeUnit.MILLISECONDS);
if (!waitUntilIsRunning(RESTART_TIMEOUT)) {
++failedRestartRetriesCount;
throw new IllegalStateException(
String.format("WDA server has failed to start after %s timeout on server '%s'.\n"
+ "Please make sure that iDevice is properly connected and you can build "
+ "WDA manually from XCode.\n"
+ "Xcodebuild logs:\n\n%s\n\n\niproxy logs:\n\n%s\n\n\n",
RESTART_TIMEOUT, hostname,
getLog(XCODEBUILD_LOG).orElse("EMPTY"), getLog(IPROXY_LOG).orElse("EMPTY"))
);
}
log.info(String.format("WDA server has been successfully (re)started after %s " +
"and now is listening on %s:%s", Timedelta.now().diff(started).toString(), hostname, PORT));
} finally {
scriptFile.delete();
}
}
public boolean isRunning() throws Exception {
if (!isProcessRunning(XCODEBUILD_EXECUTABLE.getName())
|| (isRealDevice && !isProcessRunning(IPROXY_EXECUTABLE.getName()))) {
return false;
}
return waitUntilIsRunning(isRealDevice ? REAL_DEVICE_RUNNING_TIMEOUT : Timedelta.ofSeconds(3));
}
public Optional<String> getLog(File logFile) {
if (logFile.exists()) {
try {
return Optional.of(new String(Files.readAllBytes(logFile.toPath()), Charset.forName("UTF-8")));
} catch (IOException e) {
e.printStackTrace();
}
}
return Optional.empty();
}
public void resetLogs() {
for (File logFile : new File[]{XCODEBUILD_LOG, IPROXY_LOG}) {
if (logFile.exists()) {
try {
final PrintWriter writer = new PrintWriter(logFile);
writer.print("");
writer.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
}
}
}
}
}
```
之前应该调用这段代码来启动 Appium iOS 驱动,例如,在 setUp 方法中:
```java
if (!WDAServer.getInstance().isRunning()) {
WDAServer.getInstance().restart();
}
```
为 Appium 驱动程序设置 webDriverAgentUrl 非常重要,让它知道我们的 WDA 驱动程序可以使用:
```java
capabilities.setCapability("webDriverAgentUrl", WDAServer.SERVER_URL);
```
### 重要注释
* 如果是 jenkins agent 执行的,该进程不能直接访问钥匙串(Keychain),所以我们在真机上编译 WDA 前需要提前准备好钥匙串,否则验签会失败。
* 就算 xcodebuild 和 iproxy 进程已经被冻结,我们在重新启动之前杀死这些进程,也可以确保编译成功
* 我们准备一个单独的 bash 脚本并独立于 iproxy / xcodebuild 进程,所以即使在实际的代码执行完成后,它们也可以在后台继续运行。如果在自动化 lab 中的同一机器/节点上执行多个测试/套件,最少的人工干预是非常重要的。
* 更改*BUILD_ID*环境变量的值以避免在作业完成后,后台进程被 Jenkins agent 程序杀死。
* 通过验证实际的网络终端,来检查*isRunning*
* 守护进程的输出会存入日志,因此可以跟踪错误和意外的故障。如果服务器无法启动/重启,日志文件的内容会自动添加到实际的错误消息中。
* 真机设备ID可以从 `system_profiler SPUSBDataType` 输出中解析
* 模拟器ID可以从 `xcrun simctl list` 输出中解析
* *UrlChecker*类是从 org.openqa.selenium.net 包导入的
本文由 [simple](https://testerhome.com/simple) 翻译,由 [lihuazhang](https://github.com/lihuazhang) 校验。
- 关于TesterHome和MTSC
- 关于Appium
- 简介
- Appium 客户端
- 入门指南
- 已支持的平台
- API 文档
- Appium驱动
- XCUITest (iOS)
- XCUITest Real Devices (iOS)
- UIAutomation (iOS)
- UIAutomation Safari Launcher (iOS)
- UIAutomator (Android)
- UIAutomator2 (Android)
- Espresso (Android)
- Windows
- Mac
- Appium命令
- Status
- Execute Mobile Command
- Session
- Create
- End
- Get Session Capabilities
- Go Back
- Screenshot
- Source
- Timeouts
- Timeouts
- Implicit Wait
- Async Script
- Orientation
- Get Orientation
- Set Orientation
- Geolocation
- Get Geolocation
- Set Geolocation
- Logs
- Get Log Types
- Get Logs
- Events
- Log event
- Get events
- Settings
- Update Settings
- Get Device Settings
- Settings
- Update Settings
- Get Device Settings
- Execute Driver Script
- Device
- Activity
- Start Activity
- Current Activity
- Current Package
- App
- Install App
- Is App Installed
- Launch App
- Background App
- Close App
- Reset App
- Remove App
- Activate App
- Terminate App
- Get App State
- Get App Strings
- End Test Coverage
- Clipboard
- Get Clipboard
- Set Clipboard
- Emulator
- Power AC
- Power Capacity
- Files
- Push File
- Pull File
- Pull Folder
- Interactions
- Shake
- Lock
- Unlock
- Is Locked
- Rotate
- Keys
- Press keycode
- Long press keycode
- Hide Keyboard
- Is Keyboard Shown
- Network
- Toggle Airplane Mode
- Toggle Data
- Toggle WiFi
- Toggle Location Services
- Send SMS
- GSM Call
- GSM Signal
- GSM Voice
- Network Speed
- Performance Data
- Get Performance Data
- Performance Data Types
- Screen Recording
- Start Screen Recording
- Stop Screen Recording
- Simulator
- Perform Touch ID
- Toggle Touch ID Enrollment
- System
- Open Notifications
- System Bars
- System Time
- Display density
- Authentication
- Finger Print
- Element
- Find Element
- Find Elements
- Actions
- Click
- Send Keys
- Clear
- Attributes
- Text
- Name
- Attribute
- Selected
- Enabled
- Displayed
- Location
- Size
- Rect
- CSS Property
- Location in View
- Other
- Submit
- Active Element
- Equals Element
- Context
- Get Context
- Get All Contexts
- Set Context
- Interactions
- Mouse
- Move To
- Click
- Double Click
- Button Down
- Button Up
- Touch
- Single Tap
- Double Tap
- Move
- Touch Down
- Touch Up
- Long Press
- Scroll
- Flick
- Multi Touch Perform
- Touch Perform
- W3C Actions
- Web
- Window
- Set Window
- Close Window
- Get Handle
- Get Handles
- Get Title
- Get Window Size
- Set Window Size
- Get Window Position
- Set Window Position
- Maximize Window
- Navigation
- Go to URL
- Get URL
- Back
- Forward
- Refresh
- Storage
- Get All Cookies
- Set Cookie
- Delete Cookie
- Delete All Cookies
- Frame
- Switch to Frame
- Switch to Parent Frame
- Execute Async
- Execute
- 编写 & 运行Appium脚本
- Running Tests
- Desired Capabilities
- The --default-capabilities flag
- Finding Elements
- Touch Actions
- CLI Arguments
- Server Security
- Web/Web Views
- Mobile Web Testing
- Automating Hybrid Apps
- Using ios-webkit-debug-proxy
- Using Chromedriver
- Image Comparison
- iOS
- Low-Level Insights on iOS Input Events
- XCUITest Mobile Gestures
- XCUITest Mobile App Management
- iOS Pasteboard Guide
- iOS Predicate Guide
- iOS Touch ID Guide
- iOS Install Certificate
- tvOS support
- Pushing/Pulling files
- Audio Capture
- Android
- Low-Level Insights on Android Input Events
- UiSelector Guide
- Espresso Datamatcher Guide
- Android Code Coverage Guide
- Activities Startup Troubleshooting Guide
- How To Execute Shell Commands On The Remote Device
- Android Device Screen Streaming
- How To Emulate IME Actions Generation
- How To Test Android App Bundle
- Other
- Reset Strategies
- Network Connection Guide
- Using Unicode with Appium
- Troubleshooting
- Tutorial
- Swipe Tutorial
- Screen
- Element
- Partial screen
- Simple
- Multiple scroll views
- Add scroll layout
- Tricks and Tips
- Screen
- Element
- Element search
- Fast
- Slow
- Guide
- 进阶概念
- 定位图像中的元素
- 使用定位元素的插件
- 迁移到 XCUITest
- 在 Appium 中使用 Selenium Grid
- Appium Logs Filtering
- 跨域 iframes
- 使用自定义 WDA 服务器
- 使用不同版本的 Xcode 运行
- The Event Timings API
- 并行测试的设置
- The Settings API
- Memory Collection
- 向Appium项目做贡献
- 从源代码运行 Appium
- 开发者概述
- 标准开发命令
- Appium 风格指南
- 如何编写文档
- Appium 包结构
- 鸣谢