opendx是一套完整的UI自动化解决方案,项目地址:opendx,目前已停止维护,但是新的项目还没有发布,所以先学习一下opendx来入门UI自动化框架和解决方案设计。
opendx包含了server服务、agent代理服务和一个web后台项目,关系如图所示:
查看opendx的详细介绍请移步官方文档:介绍 | opendx
本文章主要写的是
agent
服务实现设备连接和运行自动化脚本相关的功能模块(以Android设备模块为例),目前是草稿状态,后续会继续补充
检测设备
当agent服务启动时,在启动加载类AgentStartRunner
会重启本地ADB服务,并向ADB添加一个监听器androidDeviceChangeListener,通过监听器的回调,agent服务可以监听设备的状态变更。
androidDeviceChangeListener实现了AndroidDebugBridge.IDeviceChangeListener接口,该接口提供了3个回调方法,分别对应设备连接
、设备断开连接
和设备状态变更
3种场景的监听回调:
void deviceConnected(IDeivce var1);
void deviceDisconnected(IDevice var1);
void deviceChanged(IDevice var1, int var2);
当有Android设备接入时,androidDeviceChangeListener的设备连接回调方法会进行以下处理;
1、启动一个子线程,后续步骤都在子线程中处理
2、等待设备(IDevice)上线,等待期间每秒判断一次设备是否上线,超时时间5分钟
3、如果设备在agent有记录,就更新agent的设备对象属性,然后通知server设备已上线
3、如果是首次连接agent,启动本地appium服务,向server查询设备是否已入库
4、如果设备已入库,server返回一个Mobile对象,通过设备、Mobile和appiumServer实例化一个AndroidDevice对象
4、如果未入库,则:
(1)实例化Mobile对象,将设备信息赋值给对象对应的属性字段
(2)通过设备实例、Mobile实例和appiumServer实例化一个AndroidDevice
(3)通过androidDevice给设备安装minicap、minitouch和一个测试安装包
(4)初始化appium,设备截图并上传server
(5)给AndroidDevice实例设置Minicap、Minitouch、Scrcpy、AdbKit属性
5、将Android实例存入agent的DeviceHolder,然后通知server设备已上线,同时请求server新增/更新设备信息
6、一些其余的agent初始化操作
IDevice对象来自于ADB的监听回调,因此IDevice代表真实的设备对象状态和属性,AndroidDevice通过这个IDevice实例来构造对象。AndroidDevice获取到IDevice中的设备数据后,通过Mobile对象来维护具体的设备参数信息,而AndroidDevice对象本身则维护一些系统定义的设备状态和设备驱动信息等。
设备连接回调的部分代码:
protected void mobileConnected(IDevice iDevice) {
String mobileId = iDevice.getSerialNumber();
Device device = DeviceHolder.get(mobileId);
if (device == null) {
log.info("[{}]首次接入agent", mobileId);
Mobile mobile = ServerClient.getInstance().getMobileById(mobileId);
log.info("[{}]启动appium server...", mobileId);
AppiumServer appiumServer = new AppiumServer();
appiumServer.start();
log.info("[{}]启动appium server完成, url: {}", mobileId, appiumServer.getUrl());
if (mobile == null) {
try {
log.info("[{}]首次接入server,开始初始化...", mobileId);
device = initMobile(iDevice, appiumServer);
} catch (Exception e) {
log.info("[{}]停止appium server", mobileId);
appiumServer.stop();
throw new RuntimeException(String.format("[%s]初始化失败", mobileId), e);
}
} else {
log.info("[{}]已接入过server", mobileId);
device = newMobile(iDevice, mobile, appiumServer);
}
beforePutDeviceToHolder(device);
DeviceHolder.put(mobileId, device);
} else {
log.info("[{}]重新接入agent", mobileId);
reconnectToAgent(device, iDevice);
}
device.onlineToServer();
log.info("[{}]MobileConnected处理完成", mobileId);
}
连接设备
前端先通过server查询设备是否可用,然后请求agent的/scrcpy/android/{mobileId}/user/{username}/project/{projectId}
接口[1]来建立前端agent之间的websocket连接。
当连接建立时,会进行以下处理:
- 从DeviceHolder查找指定deviceId的空闲设备,如果查找为空则发送错误信息并抛出异常
- 从ws连接池(WebSocketSessionPool)池查找指定deviceId的会话,如有会话则发送错误信息并抛出异常
- 设备空闲且未被连接,将当前会话加入ws连接池,并告知server设备已占用
- 通过Scrcpy将设备屏幕内容持续发送给前端(子线程)
- 刷新设备的appium驱动(RemoteWebDriver,来自selenium)
- 将设备remoteWebDriver的sessionId发送给前端
@OnOpen
public void onOpen(@PathParam("mobileId") String mobileId, @PathParam("username") String username,
@PathParam("projectId") Integer projectId, Session session) throws Exception {
onWebsocketOpenStart(mobileId, username, session);
scrcpy = ((AndroidDevice) device).getScrcpy();
sender.sendText("启动scrcpy...");
scrcpy.start(imgData -> {
try {
sender.sendBinary(imgData);
} catch (IOException e) {
log.error("[{}]发送scrcpy数据异常", mobileId, e);
}
});
freshDriver(projectId);
onWebsocketOpenFinish();
}
在Scrcpy的start方法中,先将scrcpy的jar包推到设备缓存目录,然后异步启动设备的scrcpy服务:
if (isRunning) {
return;
}
pushScrcpyToDevice();
CountDownLatch countDownLatch = new CountDownLatch(1);
new Thread(() -> {
try {
String startCmd = String.format(拼接启动scrcpy的cmd字符串...);
log.info("[{}]start scrcpy: {}", mobileId, startCmd);
iDevice.executeShellCommand(startCmd, new MultiLineReceiver() {...}, 0, TimeUnit.SECONDS);
log.info("[{}]scrcpy已停止运行", mobileId);
isRunning = false;
} catch (Exception e) {
throw new RuntimeException(String.format("[%s]启动scrcpy失败", mobileId), e);
}
}).start();
int scrcpyStartTimeoutInSeconds = 30;
boolean scrcpyStartSuccess = countDownLatch.await(scrcpyStartTimeoutInSeconds, TimeUnit.SECONDS);
if (!scrcpyStartSuccess) {
throw new RuntimeException(String.format("[%s]启动scrcpy失败,超时时间:%d秒", mobileId, scrcpyStartTimeoutInSeconds));
}
log.info("[{}]scrcpy启动完成", mobileId);
isRunning = true;
然后和设备的scrcpy服务建立socket连接,将设备消息转发到agent本地端口,获取本地端口的输入输出流,输入流用于获取屏幕内容,输入流用于操控设备:
// 获取可用的本地端口
int localPort = PortProvider.getScrcpyAvailablePort();
log.info("[{}]adb forward: {} -> remote scrcpy", mobileId, localPort);
iDevice.createForward(localPort, "scrcpy", IDevice.DeviceUnixSocketNamespace.ABSTRACT);
new Thread(() -> {
Socket controlSocket = null;
try (Socket screenSocket = new Socket("127.0.0.1", localPort);
InputStream screenStream = screenSocket.getInputStream()) {
检测screenStream是否可用...
log.info("[{}]connect scrcpy success", mobileId);
// 初始化controlSocket和controlOutputStream
controlSocket = new Socket("127.0.0.1", localPort);
controlOutputStream = controlSocket.getOutputStream();
获取屏幕宽高...
log.info("[{}]scrcpy width: {} height: {}", mobileId, width, height);
byte[] packet = new byte[1024 * 1024];
int packetSize;
while (isRunning) {
通过screenStream获取设备屏幕图像,存到packet中...
consumer.accept(ByteBuffer.wrap(packet, 0, packetSize));
}
} catch (IndexOutOfBoundsException ign) {
} catch (Exception e) {
log.warn("[{}]处理scrcpy数据失败", mobileId, e);
} finally {
关闭controlsocket和controlOutputStream...
}
log.info("[{}]已停止消费scrcpy图片数据", mobileId);
// 移除adb forward
try {
log.info("[{}]移除adb forward: {} -> remote scrcpy", mobileId, localPort);
iDevice.removeForward(localPort, "scrcpy", IDevice.DeviceUnixSocketNamespace.ABSTRACT);
} catch (Exception e) {
log.error("[{}]移除adb forward出错", mobileId, e);
}
}).start();
自动化操作
新建测试计划后,server将生成的测试任务插入数据库device_test_task
表。
初始化任务执行器
在agent中,Device
基类初始化时会同时实例化DeviceTestTaskExecutor
,该实例初始化时启动一个子线程来持续获测试任务。
// 设备基类Devcie.java
public Device(DeviceServer deviceServer) {
this.deviceServer = deviceServer;
//在对象初始化的同时实例化一个DeviceTestTaskExecutor
deviceTestTaskExecutor = new DeviceTestTaskExecutor(this);
...
}
// 设备测试任务执行器类DeviceTestTaskExecutor.java
public DeviceTestTaskExecutor(Device device) {
this.device = device;
// 用于持续获取测试任务的子线程
executeTestTaskThread = new Thread(() -> {
DeviceTestTask deviceTestTask;
while (true) {
try {
deviceTestTask = testTaskQueue.take(); // 没有测试任务,线程阻塞在此
} catch (InterruptedException e) { ... }
try {
// 执行测试任务
executeTestTask(deviceTestTask);
} catch (Throwable e) { ... }
}
});
executeTestTaskThread.start();
}
获取测试任务
executeTestTaskThread线程从taskTestQueue中获取任务,taskTestQueue的任务数据来源于定时任务ScheduledTaskExecutor.commitDeviceTestTask()
,该任务10秒检测一次,将未开始的任务添加到所有空闲设备的任务队列中。
执行测试任务
在executeTestTask
方法中,会进行以下操作:
1、将设备变为使用中
2、将测试任务数据转换为自动化测试脚本
3、编译脚本
4、重置设备驱动(继承自selenium的RemoteWebDriver)
5、运行编译后的自动化脚本,开始自动化测试
6、关闭设备驱动,将设备变为空闲
如果中途编译脚本或运行脚本出错,会中止自动化测试,并将错误信息更新到device_test_ask表
转换自动化脚本
在executeTestTask方法中,这两行代码完成了由DeviceTestTask对象数据到TestNG代码文本的转换:
String className = "Test_" + UUIDUtil.getUUID();
String code = TestNGCodeConverterFactory.create(deviceTestTask.getPlatform()).convert(deviceTestTask, className);
首先创建一个随机的类名,然后通过TestNGCodeConverterFactory
工厂类创建一个对应设备系统的TestNGCodeConverter
对象,最后调用TestNGCodeConverter.convert
方法将DeviceTestTask对象转换为testng代码文本。
构建代码内容对象
在convert
方法中,首先创建了一个名为dataModel
的键值对,然后:
- 添加testcases
testcases的值是一个JSONObject列表,其字段如下:
List<Testcase> testcases = deviceTestTask.getTestcases();
dataModel.put("testcases", testcases.stream().map(testcase -> {
JSONObject tc = new JSONObject();
tc.put("invoke", getInvokeMethodStringWithDefaultParamValue(testcase));
tc.put("description", getTestcaseDesc(deviceTestTask, testcase));
tc.put("dependsOnMethods", getTestcaseDependsOnMethods(testcase.getDepends()));
tc.put("id", testcase.getId());
return tc;
}).collect(Collectors.toList()));
其中,getInvokeMethodStringWithDefaultParamValue
方法将测试任务中的Testcase(继承自Action类)转换为action_xxx(p1, p2, ...);
格式的字符串,p1
,p2
等是根据参数类型(Action.params[n].type)生成的默认值;
getTestcaseDesc
方法返回由设备id,测试任务id,测试用例id是否开启视频录制,失败重试次数5个字段组成的字符串;
getTestcaseDependsOnMethods
方法返回被依赖Action组成的字符串,格式如:{"action_2","action_1"}
,action_x的x是对应action的id。
- 添加前置/后置处理类和方法
使用前一个步骤创建的testcase列表初始化一个Action列表:List<Action> actions = new ArrayList<>(testcases);
如果测试任务配置了BeforeClass/AfterClass、BeforeMethod/AfterMethod,将其添加到actions中;随后调用getInvokeMethodStringWithDefaultParamValue方法为生成带默认参数的字符串,将其添加到dataModel中,如:
Action beforeClass = deviceTestTask.getBeforeClass();
if (beforeClass != null) {
actions.add(beforeClass);
String invokeBeforeClass = getInvokeMethodStringWithDefaultParamValue(beforeClass);
dataModel.put("beforeClass", invokeBeforeClass);
}
- 添加actions
将不重复的Action(包含Action中的importAction)添加到列表cachedActions(TestNGCodeConverter类的属性)中;
然后将cachedAction列表中执行Java代码的Action移除;
随后解析cachedAction列表中每个Action的局部变量、setup参数、步骤参数、tearDown参数,将${val}
形式的参数转换为实际参数值;
最后将cachedAction的值添加到dataModel:
// 将上个步骤的actions内的Action和其importAction去重添加到列表cachedActions
parseActions(actions);
// 移除执行Java代码的Action
cachedActions.remove(BaseAction.EXECUTE_JAVA_CODE_ID); // ExecuteJavaCode无需调用
// 解析cachedActions中的局部变量和占位符参数
handleActionValue();
dataModel.put("actions", cachedActions.values());
- 添加其它属性字段
dataModel.put("className", className); // Test_xxxxxxxxx
dataModel.put("actionPrefix", ACTION_PREFIX); // action_
dataModel.put("testcasePrefix", TESTCASE_PREFIX); // testcase_
dataModel.put("executeJavaCodeActionId", BaseAction.EXECUTE_JAVA_CODE_ID); // 1
dataModel.put("driverClassSimpleName", getDriverClass().getSimpleName());
dataModel.put("actionClassSimpleName", getActionClass().getSimpleName());
dataModel.put("deviceClassSimpleName", getDeviceClass().getSimpleName());
// 引入自动化脚本所需的所有依赖,放入javaImports集合中
handleJavaImports();
dataModel.put("javaImports", javaImports);
// 将全局变量的`{val}`形参值转换为实际值
handleGlobalVarValue(deviceTestTask.getGlobalVars());
dataModel.put("deviceTestTask", deviceTestTask);
代码对象写入文件
- 将dataModel转换为代码字符串
String code = FreemarkerUtil.process(FTL_BASE_PACKAGE_PATH, FTL_FILE_NAME, dataModel);
通过FreeMaker[2]将dataModel转换为代码字符串,FTL_BASE_PACKAGE_PATH是包路径,FTL_FILE_NAME是文件名 - 创建Document对象并实例化
通过org.eclipse.jdt.core.ToolFactory创建CodeFormatter并执行格式化操作
IDocument doc = new Document(code);
ToolFactory.createCodeFormatter(null)
.format(CodeFormatter.K_COMPILATION_UNIT, code, 0, code.length(), 0, null)
.apply(doc);
返回代码字符串
最终convert方法返回上个步骤格式化后的doc的字符串:return doc.get();
-
Android 5.0以下设备请求
/stf/android/{mobileId}/user/{username}/project/{projectId}
接口 ↩