├── README.md ├── combine_log.html ├── config.ini ├── constant.py ├── file_lock.py ├── frozen.py ├── log_template.html ├── main.py ├── mywindow.py ├── mywindow.ui ├── mywindowdlg.py ├── qt_test.py ├── report.py ├── requirements.txt ├── runner.py ├── scripts └── test.air │ └── test.py ├── utils.py ├── video.py ├── 安装运行环境.bat ├── 说明.txt └── 运行.bat /README.md: -------------------------------------------------------------------------------- 1 | # airtest_runner 2 | 采用python多进程,多设备并行批量airtest脚本 3 | 4 | 我的运行环境为python3.7,其他依赖包版本在requirements.txt文件里 5 | python2的话需要自己调整部分代码做兼容; 6 | 7 | 懒得安装环境和二次开发的可以直接到release下载exe文件熟肉使用 8 | 9 | 下载点这里:[releases](https://github.com/buffaloleo143/airtest_runner/releases) 10 | 11 | ## 1.1.exe使用步骤 12 | (1).双击打开airtest启动器.exe,进入程序主界面 13 | (2).左边选取安卓设备,右边点击‘选取脚本路径’按钮,选择脚本所在的根目录,选好后可在右边窗口选取要运行的脚本; 14 | (3).选取模式,模式分两种,在【2.config.ini 全局配置】中会有介绍; 15 | (4).点击启动按钮,通过控制台查看运行情况,静静地等待运行结果; 16 | (5).运行结束后会有一个弹窗提示,点击ok按钮查看该次的报告; 17 | (6).历史报告在logs_root文件夹下 18 | 19 | ## 1.2.源码使用步骤 20 | 21 | (1).把自己写的air脚本放置到scripts文件夹下 22 | (2).打开config.ini,根据注释填写运行模式、脚本名称及设备序列号; 23 | (3).运行main.py文件,推荐用pycharm运行,全局环境下也可以直接用 运行.bat 来运行; 24 | (4).等待运行结果,自动生成的报告将在logs_root文件夹;注:报告依赖airtest的静态,这里不建议更改报告的文件结构 25 | 26 | ## 2.config.ini 全局配置 27 | 28 | ```python 29 | [baseconf] 30 | scripts_root = scripts 31 | scripts = 32 | devices = all 33 | mode = 1 34 | platform = Android 35 | 36 | ``` 37 | scripts_root #脚本根目录,默认为工程目录下的scripts文件夹 38 | 39 | scripts # 要运行的脚本名称列表,半角逗号连接,如:SzwyMobile1014-1036.air,hh.air,无内容则按顺序运行scripts目录下所有脚本 40 | 41 | devices = all # 设备id,半角逗号连接,格式为:PBV0216727000183,8TFDU18926001948,为空默认选取电脑上连的第一台设备,all则运行所有设备 42 | 43 | mode = 1 # 1:每台设备各自运行所有脚本,2:所有设备分配运行所有脚本 44 | 45 | platform = Android # 平台为Windows时设备号需填窗口句柄 46 | 47 | 这里提供两种模式: 48 | - mode = 1:每台设备各自运行所有要跑的脚本,即批量并行相同脚本,报告数量=脚本数量x设备数量,适合做兼容测试; 49 | - mode = 2:采用消息队列,将所有要跑的脚本逐一以分配的方式给空闲的设备运行,报告数量=脚本数量,适合做功能的回归测试; 50 | 51 | 52 | ## 3.runner.py 53 | 利用multiprocessing根据设备数量生成进程池,单个进程里再利用unittest生成每一个脚本的测试用例 54 | 55 | ## 4.report.py 56 | 根据模板生成单个airtest脚本测试的报告,重写了airtest源码中若干源码,减少报告中的静态资源的路径依赖 57 | 58 | ## 5.utils.py 59 | 该模块提供了一些通用接口,其中还包括压缩本地报告上传至云平台的代码,上传地址需使用者自己填写 60 | 61 | ```python 62 | def PostZipFile(sZipFile): 63 | sPostUrl = '' # 上传路径 64 | sName = os.path.basename(sZipFile) 65 | file = {sName: open(sZipFile, 'rb')} 66 | headers = { 67 | 'Connection': 'keep-alive', 68 | 'Host': '10.32.17.71:8001', 69 | 'Upgrade-Insecure-Requests': '1', 70 | } 71 | r = requests.post(sPostUrl, files=file, headers=headers) 72 | if r.status_code == 200: 73 | Logging('报告上传成功') 74 | else: 75 | Logging('报告上传失败') 76 | Logging('状态码:%s' % r.status_code) 77 | Logging(r.content) 78 | 79 | 80 | def UnzipFile(sZipFile): 81 | sDir, sZipFileName = os.path.split(sZipFile) 82 | z = zipfile.ZipFile(sZipFile, 'r') 83 | sPath = os.path.join(sDir, sZipFileName.replace('.zip', '')) 84 | if not os.path.exists(sPath): 85 | os.mkdir(sPath) 86 | z.extractall(path=sPath) 87 | z.close() 88 | 89 | 90 | def PostReport2TestWeb(sExportPath): 91 | sZipFile = ZipFile(sExportPath) 92 | PostZipFile(sZipFile) 93 | os.remove(sZipFile) 94 | ``` 95 | PostReport2TestWeb的参数为报告的绝对路径 96 | 97 | ## 5.video.py 98 | 该模块利用OpenCV实现Windows端的录屏功能,弥补了airtest在PC运行时无法录屏的缺点。其中视频的帧率和录制时间间隔可以自己调整至一个合适的数值 99 | 100 | ## 6.file_lock.py 101 | 为了记录单次测试里每一个报告的聚合结果,这里采用将结果写入临时文件的方式。由于存在多条进程同时对一个文件进行读写操作的情况,我只是简单得用了文件锁来处理了一下。 102 | 103 | 经测试在windows端进程较多的情况下仍会出现结果写入异常的情况,条件足够的话建议将结果保存在自己的数据库中。 104 | -------------------------------------------------------------------------------- /combine_log.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{patch_tag}} 结果 6 | 7 | 8 | {% for file in files %} 9 | {{file.name}} 10 |    {{file.result}} 11 |
12 |
13 | 14 | {% endfor %} 15 | 16 | -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [baseconf] 2 | scripts_root = scripts 3 | scripts = 4 | devices = all 5 | mode = 1 6 | platform = Android 7 | 8 | -------------------------------------------------------------------------------- /constant.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Leo Zeng 3 | import os 4 | 5 | BASEPATH = os.path.abspath('.') 6 | LOG_ROOT = os.path.join(BASEPATH, 'logs_root') 7 | CFG_SCRIPTS_ROOT = 'scripts_root' 8 | CFG_SCRIPTS = 'scripts' 9 | CFG_DEVICES = 'devices' 10 | CFG_MODE = 'mode' 11 | CFG_PLATFORM = 'platform' 12 | 13 | -------------------------------------------------------------------------------- /file_lock.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Leo Zeng 3 | ''' 4 | 这部分代码多来自网上,linux部分未验证 5 | ''' 6 | import os 7 | import random 8 | import utils 9 | 10 | 11 | class CFileLock(object): 12 | 13 | def __init__(self, filename): 14 | self.filename = filename 15 | 16 | @utils.RetryFunc(iTimes=3, iSec=random.randint(1, 5)) 17 | def Write(self, msg): 18 | """进程比较多的情况下数据容易出现问题,目前还没想到较好的办法""" 19 | self.handle = open(self.filename, 'a+') 20 | self.acquire() 21 | self.handle.write(msg + "\n") 22 | self.release() 23 | self.handle.close() 24 | 25 | def acquire(self): 26 | # 给文件上锁 27 | if os.name == 'nt': 28 | import win32con 29 | import win32file 30 | import pywintypes 31 | LOCK_EX = win32con.LOCKFILE_EXCLUSIVE_LOCK 32 | overlapped = pywintypes.OVERLAPPED() 33 | hfile = win32file._get_osfhandle(self.handle.fileno()) 34 | win32file.LockFileEx(hfile, LOCK_EX, 0, -0x10000, overlapped) 35 | elif os.name == 'posix': 36 | import fcntl 37 | LOCK_EX = fcntl.LOCK_EX 38 | fcntl.flock(self.handle, LOCK_EX) 39 | 40 | def release(self): 41 | # 文件解锁 42 | if os.name == 'nt': 43 | import win32file 44 | import pywintypes 45 | overlapped = pywintypes.OVERLAPPED() 46 | hfile = win32file._get_osfhandle(self.handle.fileno()) 47 | win32file.UnlockFileEx(hfile, 0, -0x10000, overlapped) 48 | elif os.name == 'posix': 49 | import fcntl 50 | fcntl.flock(self.handle, fcntl.LOCK_UN) 51 | 52 | 53 | def WriteLogfile(sLogFile, sMsg): 54 | CFileLock(sLogFile).Write(sMsg) 55 | -------------------------------------------------------------------------------- /frozen.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Leo Zeng 3 | 4 | import os 5 | import sys 6 | import multiprocessing 7 | 8 | # Module multiprocessing is organized differently in Python 3.4+ 9 | try: 10 | # Python 3.4+ 11 | if sys.platform.startswith('win'): 12 | import multiprocessing.popen_spawn_win32 as forking 13 | else: 14 | import multiprocessing.popen_fork as forking 15 | except ImportError: 16 | import multiprocessing.forking as forking 17 | 18 | if sys.platform.startswith('win'): 19 | # First define a modified version of Popen. 20 | class _Popen(forking.Popen): 21 | def __init__(self, *args, **kw): 22 | if hasattr(sys, 'frozen'): 23 | # We have to set original _MEIPASS2 value from sys._MEIPASS 24 | # to get --onefile mode working. 25 | os.putenv('_MEIPASS2', sys._MEIPASS) 26 | try: 27 | super(_Popen, self).__init__(*args, **kw) 28 | finally: 29 | if hasattr(sys, 'frozen'): 30 | # On some platforms (e.g. AIX) 'os.unsetenv()' is not 31 | # available. In those cases we cannot delete the variable 32 | # but only set it to the empty string. The bootloader 33 | # can handle this case. 34 | if hasattr(os, 'unsetenv'): 35 | os.unsetenv('_MEIPASS2') 36 | else: 37 | os.putenv('_MEIPASS2', '') 38 | 39 | # Second override 'Popen' class with our modified version. 40 | -------------------------------------------------------------------------------- /log_template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{name}} test report 9 | 10 | 11 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 37 | 217 | 218 | 219 | 220 | 221 |
222 |
223 |
224 | 225 | {% if not steps %} 226 |

I am sorry, this log file is empty!

227 | {% endif %} 228 | 229 |
230 |

Test Case {{info.name}} 231 | {% if test_result %} 232 | 233 | {% else %} 234 | 235 | {% endif %} 236 |

237 | {% if info.author %} 238 |

Author: {{info.author}}

239 | {% else %} 240 |

Author: Anonymous

241 | {% endif %} 242 | {% if info.title %} 243 |

Title: {{info.title}}

244 | {% endif %} 245 |

{{run_start}} -- {{run_end}}

246 | 247 |

Assert: Test Steps Summary

248 |
249 |
250 | {% for step in steps %} 251 | {% if step.assert %} 252 |
253 | Test step 254 | {% if step.traceback %} 255 | {{loop.index}} 256 | {% else %} 257 | {{loop.index}} 258 | {% endif %} 259 | Expected result:{{step.assert}} 260 |
261 | {% endif %} 262 | {% endfor %} 263 |
264 |
265 | 266 | 267 |
268 |
269 | {% if records %} 270 | {% for r in records %} 271 |
272 | 275 |
276 | {% endfor %} 277 | {% else %} 278 |

No record information found.

279 | {% endif %} 280 |
281 |
282 | 283 | 284 | {% for step in steps %} 285 |
286 |
287 |

Step {{loop.index}} {{step.title}} [{{step.time}}]

289 |
290 |
291 | 292 | 293 | 294 | {% if step.code.name %} 295 |
296 |
297 |
298 | 299 |
300 | 301 |
302 |
303 | {% for arg in step.code.args %} 304 | {% if arg.image %} 305 | 307 | 308 |

resolution: {{arg.value.resolution}}

309 | {% else %} 310 | 311 |

{{arg.key}}: {{arg.value}}

312 | {% endif %} 313 | {% endfor %} 314 |
315 |
316 | {% endif %} 317 | 318 | 319 | {% if step.screen.src %} 320 |
321 |
322 |
323 | 324 |
325 | 326 |
327 | 328 |
329 |
330 | 332 | {% if step.screen.pos %} 333 | {% for pos in step.screen.pos %} 334 | 337 | {% endfor %} 338 | {% endif %} 339 | 340 | {% if step.screen.vector %} 341 | {% for vector in step.screen.vector %} 342 | {% set pos=step.screen.pos[loop.index-1]%} 343 |
344 |
345 |
346 |
347 |
348 | {% endfor %} 349 | {% endif %} 350 | 351 | 352 | {% if step.screen.rect %} 353 | {% for rect in step.screen.rect %} 354 |
355 | {% endfor %} 356 | {% endif %} 357 |
358 |
359 |
360 |
361 |
362 | 363 | {% if step.screen.confidence %} 364 |

Matching confidence: 365 | {{step.screen.confidence}}

366 | {% endif %} 367 | {% endif %} 368 | 369 | 370 | {% if step.desc %} 371 |

{{step.desc}}

372 | {% endif %} 373 | 374 | 375 | {% if not step.traceback %} 376 |
377 | 378 | Execution succeeded 379 |
380 | {% else %} 381 |
382 | 383 | Execution failed 384 |
385 |
386 |
{{step.traceback}}
387 |
388 | {% endif %} 389 | 390 |
391 |
392 | {% endfor %} 393 | 394 | 395 |
396 | 402 | 412 |
413 | 414 | 415 |
416 |
417 |
418 | 419 | {% block footer %} 420 | 423 | {% endblock %} 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 633 | 634 | 637 | 638 | 639 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Leo Zeng 3 | import os 4 | import utils 5 | 6 | 7 | def Run(lAirScripts, lDevices, sPatchTag=None): 8 | import runner 9 | runner.Init() 10 | return runner.CreatePools(lAirScripts, lDevices, sPatchTag) 11 | 12 | 13 | def main(): 14 | sPath = utils.GetCfgData('scripts_root') or os.path.abspath('scripts') 15 | lScripts = utils.GetCfgData('scripts').split(',') 16 | lAirScripts = [os.path.join(sPath, sAir) for sAir in lScripts] or [sPath] 17 | lDevices = utils.GetDeviceNum() 18 | if not lDevices: 19 | sError = u'无设备,请查看设备是否连接,设备权限是否开启' 20 | utils.Logging(sError) 21 | return 22 | return Run(lAirScripts, lDevices) 23 | 24 | 25 | if __name__ == '__main__': 26 | main() 27 | -------------------------------------------------------------------------------- /mywindow.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'mywindow.ui' 4 | # 5 | # Created by: PyQt5 UI code generator 5.13.0 6 | # 7 | # WARNING! All changes made in this file will be lost! 8 | 9 | 10 | from PyQt5 import QtCore, QtGui, QtWidgets 11 | 12 | 13 | class Ui_MainWindow(object): 14 | def setupUi(self, MainWindow): 15 | MainWindow.setObjectName("MainWindow") 16 | MainWindow.resize(793, 600) 17 | self.centralwidget = QtWidgets.QWidget(MainWindow) 18 | self.centralwidget.setObjectName("centralwidget") 19 | self.groupBox = QtWidgets.QGroupBox(self.centralwidget) 20 | self.groupBox.setGeometry(QtCore.QRect(20, 40, 351, 281)) 21 | font = QtGui.QFont() 22 | font.setPointSize(15) 23 | self.groupBox.setFont(font) 24 | self.groupBox.setObjectName("groupBox") 25 | self.listWidget = QtWidgets.QListWidget(self.groupBox) 26 | self.listWidget.setGeometry(QtCore.QRect(20, 60, 321, 211)) 27 | self.listWidget.setObjectName("listWidget") 28 | self.checkBox = QtWidgets.QCheckBox(self.groupBox) 29 | self.checkBox.setGeometry(QtCore.QRect(20, 30, 71, 16)) 30 | self.checkBox.setObjectName("checkBox") 31 | self.RefreshBtn = QtWidgets.QPushButton(self.groupBox) 32 | self.RefreshBtn.setGeometry(QtCore.QRect(260, 30, 75, 23)) 33 | font = QtGui.QFont() 34 | font.setPointSize(13) 35 | self.RefreshBtn.setFont(font) 36 | self.RefreshBtn.setObjectName("RefreshBtn") 37 | self.widget = QtWidgets.QWidget(self.centralwidget) 38 | self.widget.setGeometry(QtCore.QRect(360, 360, 120, 80)) 39 | self.widget.setObjectName("widget") 40 | self.groupBox_2 = QtWidgets.QGroupBox(self.centralwidget) 41 | self.groupBox_2.setGeometry(QtCore.QRect(400, 40, 351, 281)) 42 | font = QtGui.QFont() 43 | font.setPointSize(15) 44 | self.groupBox_2.setFont(font) 45 | self.groupBox_2.setObjectName("groupBox_2") 46 | self.LWScripts = QtWidgets.QListWidget(self.groupBox_2) 47 | self.LWScripts.setGeometry(QtCore.QRect(10, 60, 321, 211)) 48 | self.LWScripts.setObjectName("LWScripts") 49 | self.CBScripts = QtWidgets.QCheckBox(self.groupBox_2) 50 | self.CBScripts.setGeometry(QtCore.QRect(20, 30, 71, 16)) 51 | self.CBScripts.setObjectName("CBScripts") 52 | self.LScriptsRoot = QtWidgets.QLabel(self.groupBox_2) 53 | self.LScriptsRoot.setGeometry(QtCore.QRect(90, 30, 251, 21)) 54 | font = QtGui.QFont() 55 | font.setPointSize(9) 56 | self.LScriptsRoot.setFont(font) 57 | self.LScriptsRoot.setText("") 58 | self.LScriptsRoot.setObjectName("LScriptsRoot") 59 | self.BtnSelectScripts = QtWidgets.QPushButton(self.groupBox_2) 60 | self.BtnSelectScripts.setGeometry(QtCore.QRect(210, 0, 121, 23)) 61 | font = QtGui.QFont() 62 | font.setPointSize(13) 63 | self.BtnSelectScripts.setFont(font) 64 | self.BtnSelectScripts.setObjectName("BtnSelectScripts") 65 | self.BtnLaunch = QtWidgets.QPushButton(self.centralwidget) 66 | self.BtnLaunch.setGeometry(QtCore.QRect(480, 400, 261, 121)) 67 | font = QtGui.QFont() 68 | font.setPointSize(20) 69 | self.BtnLaunch.setFont(font) 70 | self.BtnLaunch.setObjectName("BtnLaunch") 71 | self.CBMode = QtWidgets.QComboBox(self.centralwidget) 72 | self.CBMode.setGeometry(QtCore.QRect(50, 460, 91, 21)) 73 | font = QtGui.QFont() 74 | font.setPointSize(12) 75 | self.CBMode.setFont(font) 76 | self.CBMode.setObjectName("CBMode") 77 | self.CBMode.addItem("") 78 | self.CBMode.addItem("") 79 | self.label = QtWidgets.QLabel(self.centralwidget) 80 | self.label.setGeometry(QtCore.QRect(40, 340, 151, 16)) 81 | font = QtGui.QFont() 82 | font.setPointSize(12) 83 | self.label.setFont(font) 84 | self.label.setObjectName("label") 85 | self.BtnConnect = QtWidgets.QPushButton(self.centralwidget) 86 | self.BtnConnect.setGeometry(QtCore.QRect(240, 340, 41, 23)) 87 | self.BtnConnect.setObjectName("BtnConnect") 88 | self.TextAddr = QtWidgets.QLineEdit(self.centralwidget) 89 | self.TextAddr.setGeometry(QtCore.QRect(40, 370, 281, 31)) 90 | font = QtGui.QFont() 91 | font.setPointSize(12) 92 | self.TextAddr.setFont(font) 93 | self.TextAddr.setObjectName("TextAddr") 94 | MainWindow.setCentralWidget(self.centralwidget) 95 | self.menubar = QtWidgets.QMenuBar(MainWindow) 96 | self.menubar.setGeometry(QtCore.QRect(0, 0, 793, 23)) 97 | self.menubar.setObjectName("menubar") 98 | MainWindow.setMenuBar(self.menubar) 99 | self.statusbar = QtWidgets.QStatusBar(MainWindow) 100 | self.statusbar.setObjectName("statusbar") 101 | MainWindow.setStatusBar(self.statusbar) 102 | 103 | self.retranslateUi(MainWindow) 104 | QtCore.QMetaObject.connectSlotsByName(MainWindow) 105 | 106 | def retranslateUi(self, MainWindow): 107 | _translate = QtCore.QCoreApplication.translate 108 | MainWindow.setWindowTitle(_translate("MainWindow", "你曾哥的启动器")) 109 | self.groupBox.setTitle(_translate("MainWindow", "List of devices attached")) 110 | self.checkBox.setText(_translate("MainWindow", "全选")) 111 | self.RefreshBtn.setText(_translate("MainWindow", "刷新adb")) 112 | self.groupBox_2.setTitle(_translate("MainWindow", "请选择脚本:")) 113 | self.CBScripts.setText(_translate("MainWindow", "全选")) 114 | self.BtnSelectScripts.setText(_translate("MainWindow", "选取脚本路径")) 115 | self.BtnLaunch.setText(_translate("MainWindow", "启动")) 116 | self.CBMode.setItemText(0, _translate("MainWindow", "模式1")) 117 | self.CBMode.setItemText(1, _translate("MainWindow", "模式2")) 118 | self.label.setText(_translate("MainWindow", "远程设备连接")) 119 | self.BtnConnect.setText(_translate("MainWindow", "连接")) 120 | self.TextAddr.setText(_translate("MainWindow", "adb connect 127.0.0.1:7555")) 121 | -------------------------------------------------------------------------------- /mywindow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 793 10 | 600 11 | 12 | 13 | 14 | 你曾哥的启动器 15 | 16 | 17 | 18 | 19 | 20 | 20 21 | 40 22 | 351 23 | 281 24 | 25 | 26 | 27 | 28 | 15 29 | 30 | 31 | 32 | List of devices attached 33 | 34 | 35 | 36 | 37 | 20 38 | 60 39 | 321 40 | 211 41 | 42 | 43 | 44 | 45 | 46 | 47 | 20 48 | 30 49 | 71 50 | 16 51 | 52 | 53 | 54 | 全选 55 | 56 | 57 | 58 | 59 | 60 | 260 61 | 30 62 | 75 63 | 23 64 | 65 | 66 | 67 | 68 | 13 69 | 70 | 71 | 72 | 刷新adb 73 | 74 | 75 | 76 | 77 | 78 | 79 | 360 80 | 360 81 | 120 82 | 80 83 | 84 | 85 | 86 | 87 | 88 | 89 | 400 90 | 40 91 | 351 92 | 281 93 | 94 | 95 | 96 | 97 | 15 98 | 99 | 100 | 101 | 请选择脚本: 102 | 103 | 104 | 105 | 106 | 10 107 | 60 108 | 321 109 | 211 110 | 111 | 112 | 113 | 114 | 115 | 116 | 20 117 | 30 118 | 71 119 | 16 120 | 121 | 122 | 123 | 全选 124 | 125 | 126 | 127 | 128 | 129 | 90 130 | 30 131 | 251 132 | 21 133 | 134 | 135 | 136 | 137 | 9 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 210 148 | 0 149 | 121 150 | 23 151 | 152 | 153 | 154 | 155 | 13 156 | 157 | 158 | 159 | 选取脚本路径 160 | 161 | 162 | 163 | 164 | 165 | 166 | 480 167 | 400 168 | 261 169 | 121 170 | 171 | 172 | 173 | 174 | 20 175 | 176 | 177 | 178 | 启动 179 | 180 | 181 | 182 | 183 | 184 | 50 185 | 460 186 | 91 187 | 21 188 | 189 | 190 | 191 | 192 | 12 193 | 194 | 195 | 196 | 197 | 模式1 198 | 199 | 200 | 201 | 202 | 模式2 203 | 204 | 205 | 206 | 207 | 208 | 209 | 40 210 | 340 211 | 151 212 | 16 213 | 214 | 215 | 216 | 217 | 12 218 | 219 | 220 | 221 | 远程设备连接 222 | 223 | 224 | 225 | 226 | 227 | 240 228 | 340 229 | 41 230 | 23 231 | 232 | 233 | 234 | 连接 235 | 236 | 237 | 238 | 239 | 240 | 40 241 | 370 242 | 281 243 | 31 244 | 245 | 246 | 247 | 248 | 12 249 | 250 | 251 | 252 | adb connect 127.0.0.1:7555 253 | 254 | 255 | 256 | 257 | 258 | 259 | 0 260 | 0 261 | 793 262 | 23 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | -------------------------------------------------------------------------------- /mywindowdlg.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Leo Zeng 3 | import os 4 | import sys 5 | import utils 6 | import frozen 7 | 8 | if hasattr(sys, 'frozen'): 9 | os.environ['PATH'] = sys._MEIPASS + ";" + os.environ['PATH'] 10 | 11 | from PyQt5 import QtCore, QtGui, QtWidgets 12 | from airtest.core.android.adb import ADB 13 | from mywindow import * 14 | from constant import * 15 | 16 | DEFAULT_SCRIPTS_ROOT = 'scripts' 17 | 18 | 19 | def InitWindow(): 20 | oMyWindow = MyWindow.GetInstance() 21 | oMyWindow.show() 22 | return oMyWindow 23 | 24 | 25 | class SingleInst(object): 26 | oMgrObj = None 27 | 28 | @classmethod 29 | def GetInstance(cls): 30 | if not isinstance(SingleInst.oMgrObj, cls): 31 | cls.ReleaseInstance() 32 | SingleInst.oMgrObj = cls() 33 | return SingleInst.oMgrObj 34 | 35 | @classmethod 36 | def ReleaseInstance(cls): 37 | SingleInst.oMgrObj = None 38 | 39 | def __init__(self): 40 | assert (SingleInst.oMgrObj is None) 41 | 42 | 43 | class MyWindow(QtWidgets.QMainWindow, Ui_MainWindow, SingleInst): 44 | 45 | def __init__(self, parent=None): 46 | super(MyWindow, self).__init__(parent) 47 | self.setupUi(self) 48 | self.InitListWidget() 49 | self.InitSignal() 50 | self.m_ScriptRoot = utils.GetCfgData('scripts_root') 51 | self.m_ADB = ADB() 52 | self.RefreshScripts() 53 | self.RefreshADB() 54 | self.m_Running = False 55 | 56 | def InitListWidget(self): 57 | self.m_DeviceListWidget = CMyListWidget(self.listWidget, self.checkBox) 58 | self.m_ScriptListWidget = CMyListWidget(self.LWScripts, self.CBScripts) 59 | 60 | def InitSignal(self): 61 | self.RefreshBtn.clicked.connect(self.RefreshADB) 62 | # self.BtnRefreshScripts.clicked.connect(self.RefreshScripts) 63 | self.BtnLaunch.clicked.connect(self.Lauch) 64 | self.BtnSelectScripts.clicked.connect(self.SelectScriptRoot) 65 | self.checkBox.stateChanged.connect(self.m_DeviceListWidget.SelcetAll) 66 | self.CBScripts.stateChanged.connect(self.m_ScriptListWidget.SelcetAll) 67 | self.BtnConnect.clicked.connect(self.ConnectRemoteADB) 68 | 69 | def RefreshADB(self): 70 | lDevices = [(tDevice, tDevice[1] == 'device') for tDevice in self.m_ADB.devices()] 71 | self.m_DeviceListWidget.Refresh(lDevices) 72 | 73 | def RefreshScripts(self): 74 | if not self.m_ScriptRoot: 75 | utils.SetCfgData('scripts_root', DEFAULT_SCRIPTS_ROOT) 76 | self.m_ScriptRoot = DEFAULT_SCRIPTS_ROOT 77 | if not os.path.exists(self.m_ScriptRoot): 78 | os.mkdir(self.m_ScriptRoot) 79 | self.LScriptsRoot.setText(self.m_ScriptRoot) 80 | lScripts = [(sScript, True) for sScript in os.listdir(self.m_ScriptRoot) if sScript.endswith('.air')] 81 | self.m_ScriptListWidget.Refresh(lScripts) 82 | 83 | def SelectScriptRoot(self): 84 | sDirChoose = QtWidgets.QFileDialog.getExistingDirectory(self, "选取文件夹", self.m_ScriptRoot) 85 | if sDirChoose == "": 86 | return 87 | self.m_ScriptRoot = sDirChoose 88 | self.RefreshScripts() 89 | 90 | def ShowReport(self, sCombineLog): 91 | self.m_Running = False 92 | import webbrowser 93 | reply = QtWidgets.QMessageBox.question(self, "Question", 94 | self.tr("测试结束,点击查看报告"), 95 | QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel, 96 | QtWidgets.QMessageBox.Ok) 97 | if reply == QtWidgets.QMessageBox.Ok: 98 | webbrowser.open(sCombineLog, new=0, autoraise=True) 99 | elif reply == QtWidgets.QMessageBox.Cancel: 100 | print('点击了取消,报告路径:%s' % sCombineLog) 101 | else: 102 | return 103 | 104 | def Lauch(self): 105 | if self.m_Running: 106 | QtWidgets.QMessageBox.information(self, "提示", self.tr("正在运行中!")) 107 | return 108 | 109 | lDevices = self.m_DeviceListWidget.GetSelectedList() 110 | lScripts = self.m_ScriptListWidget.GetSelectedList() 111 | if not lDevices: 112 | QtWidgets.QMessageBox.warning(self, "提示", self.tr("请选择设备!")) 113 | return 114 | if not lScripts: 115 | QtWidgets.QMessageBox.warning(self, "提示", self.tr("请选择脚本!")) 116 | return 117 | sMode = self.CBMode.currentText()[-1] 118 | utils.SetCfgData(CFG_SCRIPTS, ','.join(lScripts)) 119 | utils.SetCfgData(CFG_DEVICES, ','.join(lDevices)) 120 | utils.SetCfgData(CFG_MODE, sMode) 121 | utils.SetCfgData(CFG_SCRIPTS_ROOT, self.m_ScriptRoot) 122 | utils.SetCfgData(CFG_PLATFORM, 'Android') 123 | self.m_Running = True 124 | self.m_RunThread = CRunthread() 125 | self.m_RunThread._signal.connect(self.ShowReport) 126 | self.m_RunThread.start() 127 | 128 | def ConnectRemoteADB(self): 129 | sText = self.TextAddr.text() 130 | sCmd = sText.split()[-2] 131 | sAddr = sText.split()[-1] 132 | if sCmd == 'connect': 133 | ADB(serialno=sAddr) 134 | elif sCmd == 'disconnect': 135 | ADB(serialno=sAddr).disconnect() 136 | else: 137 | QtWidgets.QMessageBox.information(self, "提示", self.tr("请输入正确指令!(connect or disconnect)")) 138 | self.RefreshADB() 139 | 140 | 141 | class CMyListWidget(object): 142 | def __init__(self, oListWidget, oCheckBox): 143 | self.m_ListWidget = oListWidget 144 | self.m_CheckBox = oCheckBox 145 | 146 | def Refresh(self, lData): 147 | self.m_ListWidget.clear() 148 | self.m_CheckBox.setCheckState(False) 149 | for oText, bCheckable in lData: 150 | sText = '\t'.join(oText) if isinstance(oText, tuple) else oText 151 | self.AddItem(sText, bCheckable) 152 | 153 | def AddItem(self, sText, bCheckable=True): 154 | oItem = QtWidgets.QListWidgetItem() 155 | if bCheckable: 156 | oItem.setFlags(QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled) 157 | oItem.setCheckState(QtCore.Qt.Unchecked) 158 | else: 159 | oItem.setFlags(QtCore.Qt.ItemIsEnabled) 160 | oItem.setText(sText) 161 | font = QtGui.QFont() 162 | font.setPointSize(12) 163 | oItem.setFont(font) 164 | self.m_ListWidget.addItem(oItem) 165 | 166 | def SetCheckState(self, iState): 167 | for i in range(self.m_ListWidget.count()): 168 | oItem = self.m_ListWidget.item(i) 169 | oFlags = oItem.flags() 170 | if int(oFlags) & QtCore.Qt.ItemIsUserCheckable: 171 | oItem.setCheckState(iState) 172 | 173 | def SelcetAll(self): 174 | iState = self.m_CheckBox.checkState() 175 | self.SetCheckState(iState) 176 | 177 | def GetSelectedList(self): 178 | lText = [] 179 | for i in range(self.m_ListWidget.count()): 180 | oItem = self.m_ListWidget.item(i) 181 | iState = oItem.checkState() 182 | if iState: 183 | lText.append(oItem.text().split(' ')[0]) 184 | return lText 185 | 186 | 187 | class CRunthread(QtCore.QThread): 188 | # 通过类成员对象定义信号对象 189 | _signal = QtCore.pyqtSignal(str) 190 | 191 | def __init__(self): 192 | super(CRunthread, self).__init__() 193 | 194 | def __del__(self): 195 | self.wait() 196 | 197 | def run(self): 198 | import main 199 | sCombineLog = main.main() 200 | self._signal.emit(sCombineLog) 201 | 202 | 203 | if __name__ == '__main__': 204 | import multiprocessing 205 | 206 | multiprocessing.freeze_support() 207 | app = QtWidgets.QApplication(sys.argv) 208 | InitWindow() 209 | sys.exit(app.exec_()) 210 | -------------------------------------------------------------------------------- /qt_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Leo Zeng 3 | import os 4 | import sys 5 | import utils 6 | import frozen 7 | 8 | if hasattr(sys, 'frozen'): 9 | os.environ['PATH'] = sys._MEIPASS + ";" + os.environ['PATH'] 10 | 11 | from PyQt5 import QtCore, QtGui, QtWidgets 12 | from airtest.core.android.adb import ADB 13 | from test import * 14 | from constant import * 15 | 16 | DEFAULT_SCRIPTS_ROOT = 'scripts' 17 | 18 | 19 | def InitWindow(): 20 | oMyWindow = MyWindow.GetInstance() 21 | oMyWindow.show() 22 | return oMyWindow 23 | 24 | class SingleInst(object): 25 | oMgrObj = None 26 | 27 | @classmethod 28 | def GetInstance(cls): 29 | if not isinstance(SingleInst.oMgrObj, cls): 30 | cls.ReleaseInstance() 31 | SingleInst.oMgrObj = cls() 32 | return SingleInst.oMgrObj 33 | 34 | @classmethod 35 | def ReleaseInstance(cls): 36 | SingleInst.oMgrObj = None 37 | 38 | def __init__(self): 39 | assert (SingleInst.oMgrObj is None) 40 | 41 | 42 | class MyWindow(QtWidgets.QMainWindow, Ui_MainWindow, SingleInst): 43 | 44 | def __init__(self, parent=None): 45 | super(MyWindow, self).__init__(parent) 46 | self.setupUi(self) 47 | self.InitListWidget() 48 | self.InitSignal() 49 | self.m_ScriptRoot = utils.GetCfgData('scripts_root') 50 | self.RefreshScripts() 51 | self.RefreshADB() 52 | self.m_Running = False 53 | 54 | def InitListWidget(self): 55 | self.m_DeviceListWidget = CMyListWidget(self.listWidget, self.checkBox) 56 | self.m_ScriptListWidget = CMyListWidget(self.LWScripts, self.CBScripts) 57 | 58 | def InitSignal(self): 59 | self.RefreshBtn.clicked.connect(self.RefreshADB) 60 | # self.BtnRefreshScripts.clicked.connect(self.RefreshScripts) 61 | self.BtnLaunch.clicked.connect(self.Lauch) 62 | self.BtnSelectScripts.clicked.connect(self.SelectScriptRoot) 63 | self.checkBox.stateChanged.connect(self.m_DeviceListWidget.SelcetAll) 64 | self.CBScripts.stateChanged.connect(self.m_ScriptListWidget.SelcetAll) 65 | 66 | def RefreshADB(self): 67 | lDevices = [(tDevice, tDevice[1] == 'device') for tDevice in ADB().devices()] 68 | self.m_DeviceListWidget.Refresh(lDevices) 69 | 70 | def RefreshScripts(self): 71 | if not self.m_ScriptRoot: 72 | utils.SetCfgData('scripts_root', DEFAULT_SCRIPTS_ROOT) 73 | self.m_ScriptRoot = DEFAULT_SCRIPTS_ROOT 74 | if not os.path.exists(self.m_ScriptRoot): 75 | os.mkdir(self.m_ScriptRoot) 76 | self.LScriptsRoot.setText(self.m_ScriptRoot) 77 | lScripts = [(sScript, True) for sScript in os.listdir(self.m_ScriptRoot) if sScript.endswith('.air')] 78 | self.m_ScriptListWidget.Refresh(lScripts) 79 | 80 | def SelectScriptRoot(self): 81 | sDirChoose = QtWidgets.QFileDialog.getExistingDirectory(self, "选取文件夹", self.m_ScriptRoot) 82 | if sDirChoose == "": 83 | return 84 | self.m_ScriptRoot = sDirChoose 85 | self.RefreshScripts() 86 | 87 | def ShowReport(self, sCombineLog): 88 | self.m_Running = False 89 | import webbrowser 90 | reply = QtWidgets.QMessageBox.question(self, "Question", 91 | self.tr("测试结束,点击查看报告"), 92 | QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel, 93 | QtWidgets.QMessageBox.Ok) 94 | if reply == QtWidgets.QMessageBox.Ok: 95 | webbrowser.open(sCombineLog, new=0, autoraise=True) 96 | elif reply == QtWidgets.QMessageBox.Cancel: 97 | print('点击了取消,报告路径:%s' % sCombineLog) 98 | else: 99 | return 100 | 101 | def Lauch(self): 102 | if self.m_Running: 103 | QtWidgets.QMessageBox.information(self, "提示", self.tr("正在运行中!")) 104 | return 105 | 106 | lDevices = self.m_DeviceListWidget.GetSelectedList() 107 | lScripts = self.m_ScriptListWidget.GetSelectedList() 108 | if not lDevices: 109 | QtWidgets.QMessageBox.warning(self, "提示", self.tr("请选择设备!")) 110 | return 111 | if not lScripts: 112 | QtWidgets.QMessageBox.warning(self, "提示", self.tr("请选择脚本!")) 113 | return 114 | sMode = self.CBMode.currentText()[-1] 115 | utils.SetCfgData(CFG_SCRIPTS, ','.join(lScripts)) 116 | utils.SetCfgData(CFG_DEVICES, ','.join(lDevices)) 117 | utils.SetCfgData(CFG_MODE, sMode) 118 | utils.SetCfgData(CFG_SCRIPTS_ROOT, self.m_ScriptRoot) 119 | utils.SetCfgData(CFG_PLATFORM, 'Android') 120 | self.m_Running = True 121 | self.m_RunThread = CRunthread() 122 | self.m_RunThread._signal.connect(self.ShowReport) 123 | self.m_RunThread.start() 124 | 125 | 126 | class CMyListWidget(object): 127 | def __init__(self, oListWidget, oCheckBox): 128 | self.m_ListWidget = oListWidget 129 | self.m_CheckBox = oCheckBox 130 | 131 | def Refresh(self, lData): 132 | self.m_ListWidget.clear() 133 | self.m_CheckBox.setCheckState(False) 134 | for oText, bCheckable in lData: 135 | sText = '\t'.join(oText) if isinstance(oText, tuple) else oText 136 | self.AddItem(sText, bCheckable) 137 | 138 | def AddItem(self, sText, bCheckable=True): 139 | oItem = QtWidgets.QListWidgetItem() 140 | if bCheckable: 141 | oItem.setFlags(QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled) 142 | oItem.setCheckState(QtCore.Qt.Unchecked) 143 | else: 144 | oItem.setFlags(QtCore.Qt.ItemIsEnabled) 145 | oItem.setText(sText) 146 | font = QtGui.QFont() 147 | font.setPointSize(12) 148 | oItem.setFont(font) 149 | self.m_ListWidget.addItem(oItem) 150 | 151 | def SetCheckState(self, iState): 152 | for i in range(self.m_ListWidget.count()): 153 | oItem = self.m_ListWidget.item(i) 154 | oFlags = oItem.flags() 155 | if int(oFlags) & QtCore.Qt.ItemIsUserCheckable: 156 | oItem.setCheckState(iState) 157 | 158 | def SelcetAll(self): 159 | iState = self.m_CheckBox.checkState() 160 | self.SetCheckState(iState) 161 | 162 | def GetSelectedList(self): 163 | lText = [] 164 | for i in range(self.m_ListWidget.count()): 165 | oItem = self.m_ListWidget.item(i) 166 | iState = oItem.checkState() 167 | if iState: 168 | lText.append(oItem.text().split(' ')[0]) 169 | return lText 170 | 171 | 172 | class CRunthread(QtCore.QThread): 173 | # 通过类成员对象定义信号对象 174 | _signal = QtCore.pyqtSignal(str) 175 | 176 | def __init__(self): 177 | super(CRunthread, self).__init__() 178 | 179 | def __del__(self): 180 | self.wait() 181 | 182 | def run(self): 183 | import main 184 | sCombineLog = main.main() 185 | self._signal.emit(sCombineLog) 186 | 187 | 188 | 189 | 190 | if __name__ == '__main__': 191 | import multiprocessing 192 | multiprocessing.freeze_support() 193 | app = QtWidgets.QApplication(sys.argv) 194 | InitWindow() 195 | sys.exit(app.exec_()) 196 | -------------------------------------------------------------------------------- /report.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Leo Zeng 3 | import os 4 | import types 5 | import shutil 6 | import json 7 | import airtest.report.report as R 8 | 9 | from airtest.utils.compat import decode_path 10 | from constant import BASEPATH 11 | 12 | TXT_FILE = "log.txt" 13 | HTML_FILE = "log.html" 14 | HTML_TPL = "log_template.html" 15 | MY_STATIC_DIR = 'report_static' 16 | STATIC_DIR = os.path.dirname(R.__file__) 17 | 18 | 19 | def get_script_info(script_path): 20 | script_name = os.path.basename(script_path) 21 | result_json = {"name": script_name, "author": 'Leo', "title": script_name, "desc": None} 22 | return json.dumps(result_json) 23 | 24 | 25 | def _make_export_dir(self): 26 | dirpath = BASEPATH 27 | logpath = self.script_root 28 | # copy static files 29 | for subdir in ["css", "fonts", "image", "js"]: 30 | dist = os.path.join(dirpath, MY_STATIC_DIR, subdir) 31 | if os.path.exists(dist) and os.path.isdir(dist): 32 | continue 33 | shutil.rmtree(dist, ignore_errors=True) 34 | self.copy_tree(os.path.join(STATIC_DIR, subdir), dist) 35 | 36 | return dirpath, logpath 37 | 38 | 39 | def render(template_name, output_file=None, **template_vars): 40 | import io 41 | import jinja2 42 | """ 用jinja2渲染html""" 43 | env = jinja2.Environment( 44 | loader=jinja2.FileSystemLoader(BASEPATH), 45 | extensions=(), 46 | autoescape=True 47 | ) 48 | template = env.get_template(template_name) 49 | html = template.render(**template_vars) 50 | 51 | if output_file: 52 | with io.open(output_file, 'w', encoding="utf-8") as f: 53 | f.write(html) 54 | print(output_file) 55 | 56 | return html 57 | 58 | 59 | def _translate_screen(self, step, code): 60 | import six 61 | if step['tag'] != "function": 62 | return None 63 | screen = { 64 | "src": None, 65 | "rect": [], 66 | "pos": [], 67 | "vector": [], 68 | "confidence": None, 69 | } 70 | 71 | for item in step["__children__"]: 72 | if item["data"]["name"] == "try_log_screen" and isinstance(item["data"].get("ret", None), six.text_type): 73 | src = item["data"]['ret'] 74 | if self.export_dir: # all relative path 75 | # src = os.path.join(LOGDIR, src) # Leo 2019-6-20 76 | screen['_filepath'] = src 77 | else: 78 | # screen['_filepath'] = os.path.abspath(os.path.join(self.log_root, src)) # Leo 2019-6-20 79 | screen['_filepath'] = src 80 | screen['src'] = screen['_filepath'] 81 | break 82 | 83 | display_pos = None 84 | 85 | for item in step["__children__"]: 86 | if item["data"]["name"] == "_cv_match" and isinstance(item["data"].get("ret"), dict): 87 | cv_result = item["data"]["ret"] 88 | pos = cv_result['result'] 89 | if self.is_pos(pos): 90 | display_pos = [round(pos[0]), round(pos[1])] 91 | rect = self.div_rect(cv_result['rectangle']) 92 | screen['rect'].append(rect) 93 | screen['confidence'] = cv_result['confidence'] 94 | break 95 | 96 | if step["data"]["name"] in ["touch", "assert_exists", "wait", "exists"]: 97 | # 将图像匹配得到的pos修正为最终pos 98 | if self.is_pos(step["data"].get("ret")): 99 | display_pos = step["data"]["ret"] 100 | elif self.is_pos(step["data"]["call_args"].get("v")): 101 | display_pos = step["data"]["call_args"]["v"] 102 | 103 | elif step["data"]["name"] == "swipe": 104 | if "ret" in step["data"]: 105 | screen["pos"].append(step["data"]["ret"][0]) 106 | target_pos = step["data"]["ret"][1] 107 | origin_pos = step["data"]["ret"][0] 108 | screen["vector"].append([target_pos[0] - origin_pos[0], target_pos[1] - origin_pos[1]]) 109 | 110 | if display_pos: 111 | screen["pos"].append(display_pos) 112 | return screen 113 | 114 | 115 | def report(self, template_name, output_file=None, record_list=None): 116 | """替换LogToHtml中的report方法""" 117 | self._load() 118 | steps = self._analyse() 119 | # 修改info获取方式 120 | info = json.loads(get_script_info(self.script_root)) 121 | if self.export_dir: 122 | _, self.log_root = self._make_export_dir() 123 | # output_file = os.path.join(self.script_root, HTML_FILE) 124 | # self.static_root = "static/" 125 | if not record_list: 126 | record_list = [f for f in os.listdir(self.log_root) if f.endswith(".mp4")] 127 | records = [f if self.export_dir else os.path.basename(f) for f in record_list] 128 | 129 | if not self.static_root.endswith(os.path.sep): 130 | self.static_root = self.static_root.replace("\\", "/") 131 | self.static_root += "/" 132 | data = {} 133 | data['steps'] = steps 134 | data['name'] = os.path.basename(self.script_root) 135 | data['scale'] = self.scale 136 | data['test_result'] = self.test_result 137 | data['run_end'] = self.run_end 138 | data['run_start'] = self.run_start 139 | data['static_root'] = self.static_root 140 | data['lang'] = self.lang 141 | data['records'] = records 142 | data['info'] = info 143 | return render(template_name, output_file, **data) 144 | 145 | 146 | def get_result(self): 147 | return self.test_result 148 | 149 | 150 | def main(args): 151 | # script filepath 152 | path = decode_path(args.script) 153 | record_list = args.record or [] 154 | log_root = decode_path(args.log_root) or path 155 | static_root = args.static_root or STATIC_DIR 156 | static_root = decode_path(static_root) 157 | export = args.export 158 | lang = args.lang if args.lang in ['zh', 'en'] else 'zh' 159 | plugins = args.plugins 160 | # gen html report 161 | rpt = R.LogToHtml(path, log_root, static_root, export_dir=export, lang=lang, plugins=plugins) 162 | # override methods 163 | rpt._make_export_dir = types.MethodType(_make_export_dir, rpt) 164 | rpt.report = types.MethodType(report, rpt) 165 | rpt.get_result = types.MethodType(get_result, rpt) 166 | rpt._translate_screen = types.MethodType(_translate_screen, rpt) 167 | rpt.report(HTML_TPL, output_file=args.outfile, record_list=record_list) 168 | return rpt.get_result() 169 | 170 | 171 | def ReportHtml(subdir): 172 | import argparse 173 | oArgs = argparse.Namespace(script=None, device=None, outfile=None, static_root='../../../%s' % MY_STATIC_DIR, 174 | log_root=None, record=None, export=True, lang=None, plugins=None) 175 | oArgs.script = subdir 176 | oArgs.outfile = os.path.join(subdir, HTML_FILE) 177 | result = main(oArgs) 178 | sRet = 'PASS' if result else 'FAIL' 179 | return sRet 180 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | airtest==1.0.27 2 | altgraph==0.16.1 3 | certifi==2019.6.16 4 | chardet==3.0.4 5 | Click==7.0 6 | comtypes==1.1.7 7 | configparser==3.8.1 8 | decorator==4.4.0 9 | facebook-wda==0.3.6 10 | func-timeout==4.3.5 11 | future==0.17.1 12 | hrpc==1.0.8 13 | idna==2.8 14 | Jinja2==2.10.1 15 | MarkupSafe==1.1.1 16 | mss==4.0.3 17 | numpy==1.15.1 18 | opencv-contrib-python==3.4.2.17 19 | pefile==2019.4.18 20 | Pillow==6.1.0 21 | pocoui==1.0.76 22 | py==1.8.0 23 | PyInstaller==3.5 24 | pypiwin32==223 25 | PyQt5==5.13.0 26 | PyQt5-sip==4.19.18 27 | pyqt5-tools==5.13.0.1.5 28 | python-dotenv==0.10.3 29 | pywin32==224 30 | pywin32-ctypes==0.2.0 31 | pywinauto==0.6.3 32 | requests==2.22.0 33 | retry==0.9.2 34 | six==1.12.0 35 | urllib3==1.25.3 36 | websocket-client==0.56.0 37 | -------------------------------------------------------------------------------- /runner.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Leo Zeng 3 | import datetime 4 | import unittest 5 | import os 6 | import sys 7 | import six 8 | import traceback 9 | import report 10 | import video 11 | import time 12 | import json 13 | import multiprocessing 14 | 15 | from utils import CatchErr, RetryFunc, GetCfgData 16 | from io import open 17 | from airtest.core.api import auto_setup, log, connect_device 18 | from airtest.core.helper import device_platform 19 | from copy import copy 20 | from constant import LOG_ROOT 21 | 22 | 23 | 24 | class MyAirtestCase(unittest.TestCase): 25 | 26 | def __init__(self, sDevice, oQueue=None): 27 | super(MyAirtestCase, self).__init__() 28 | self.sDevice = sDevice 29 | self.queue = oQueue 30 | 31 | def Init(self, sPath, sPyFileName): 32 | self.m_LogRoot = sPath 33 | sConn = GetCfgData('platform') + ':///' + self.sDevice 34 | self.m_oDev = connect_device(sConn) 35 | sRunTime = datetime.datetime.now().strftime("%H%M%S") 36 | self.m_sLogDir = sRunTime + '_' + self.sDevice.replace(':', '') + '_' + sPyFileName 37 | self.logdir = os.path.join(sPath, self.m_sLogDir) 38 | 39 | @classmethod 40 | def setUpClass(cls): 41 | cls.scope = copy(globals()) 42 | 43 | def setUp(self): 44 | auto_setup(logdir=self._logdir) 45 | self.RecordScreen() 46 | 47 | def tearDown(self): 48 | try: 49 | output = os.path.join(self.logdir, "recording_0.mp4") 50 | print(output) 51 | self.m_oDev.stop_recording(output) 52 | except: 53 | traceback.print_exc() 54 | self.Report() 55 | if self.queue: 56 | RunScript(self.sDevice, self.m_LogRoot, oScripts=self.queue) 57 | 58 | def runTest(self): 59 | try: 60 | exec(self._code["code"], self._code["ns"]) 61 | except Exception as err: 62 | tb = traceback.format_exc() 63 | log("Final Error", tb) 64 | six.reraise(*sys.exc_info()) 65 | 66 | def StartRecording(self): 67 | if device_platform(self.m_oDev) == 'Windows': 68 | output = os.path.join(self.logdir, 'recording_0.mp4') 69 | video.InitVideoRecorder(self.m_oDev) 70 | self.m_oDev.start_recording(output) 71 | else: 72 | self.m_oDev.start_recording() 73 | 74 | @RetryFunc() 75 | def RecordScreen(self): # 开始录屏 76 | try: 77 | return self.StartRecording() 78 | except Exception as e: 79 | try: 80 | self.m_oDev.stop_recording(is_interrupted=True) 81 | except: 82 | pass 83 | raise e 84 | 85 | def Report(self): 86 | import file_lock 87 | sRet = report.ReportHtml(self.logdir) 88 | sCombineTxt = os.path.join(self.m_LogRoot, 'log.txt') 89 | if not os.path.exists(sCombineTxt): 90 | file = open(sCombineTxt, 'w') 91 | file.close() 92 | sMsg = json.dumps({'name': self.m_sLogDir, 'result': sRet}) 93 | file_lock.WriteLogfile(sCombineTxt, sMsg) 94 | 95 | @property 96 | def logdir(self): 97 | return self._logdir 98 | 99 | @logdir.setter 100 | def logdir(self, value): 101 | self._logdir = value 102 | 103 | @property 104 | def code(self): 105 | return self._code 106 | 107 | @code.setter 108 | def code(self, value): 109 | self._code = value 110 | 111 | 112 | def Init(): 113 | if not os.path.exists(LOG_ROOT): 114 | os.mkdir(LOG_ROOT) 115 | 116 | 117 | def Finish(sLogDir): 118 | print('test finish') 119 | sCombineLog = os.path.join(sLogDir, 'log.html') 120 | sCombineTxt = os.path.join(sLogDir, 'log.txt') 121 | with open(sCombineTxt, 'r') as f: 122 | lMsg = f.readlines() 123 | template_vars = { 124 | 'patch_tag': os.path.basename(sLogDir), 125 | 'files': [json.loads(line) for line in lMsg] 126 | } 127 | report.render('combine_log.html', sCombineLog, **template_vars) 128 | return sCombineLog 129 | 130 | def NewCase(fPy, sLogDir, sDeviceNum, oQueue=None): 131 | """实例化MyAirtestCase并绑定runCase方法""" 132 | if not os.path.exists(sLogDir): 133 | os.mkdir(sLogDir) 134 | with open(fPy, 'r', encoding="utf8") as f: 135 | code = f.read() 136 | obj = compile(code.encode("utf-8"), fPy, "exec") 137 | ns = {} 138 | ns["__file__"] = fPy 139 | oCase = MyAirtestCase(sDeviceNum, oQueue) 140 | sPyFileName = os.path.basename(fPy).replace(".py", "") 141 | oCase.code = {"code": obj, "ns": ns} 142 | oCase.Init(sLogDir, sPyFileName) 143 | return oCase 144 | 145 | 146 | def InitSuite(lScripts): 147 | lSuite = [] 148 | for sAirDir in lScripts: 149 | if sAirDir.endswith('air') and os.path.isdir(sAirDir): 150 | sPyName = os.path.basename(sAirDir).replace('air', 'py') 151 | lSuite.append(os.path.join(sAirDir, sPyName)) 152 | else: 153 | for sAirDirSecond in os.listdir(sAirDir): 154 | sAirDirSecond = os.path.join(sAirDir, sAirDirSecond) 155 | if sAirDirSecond.endswith('air') and os.path.isdir(sAirDirSecond): 156 | sPyName = os.path.basename(sAirDirSecond).replace('air', 'py') 157 | lSuite.append(os.path.join(sAirDirSecond, sPyName)) 158 | return lSuite 159 | 160 | 161 | @CatchErr 162 | def RunScript(sDeviceNum, sLogDir, oScripts): 163 | oTestSuite = unittest.TestSuite() 164 | if isinstance(oScripts, list): 165 | for fPy in oScripts: 166 | oCase = NewCase(fPy, sLogDir, sDeviceNum) 167 | oTestSuite.addTest(oCase) 168 | else: 169 | if oScripts.empty(): 170 | return 171 | fPy = oScripts.get() 172 | oCase = NewCase(fPy, sLogDir, sDeviceNum, oScripts) 173 | oTestSuite.addTest(oCase) 174 | unittest.TextTestRunner(verbosity=0).run(oTestSuite) # 运行脚本 175 | 176 | 177 | def CreatePools(lAirScripts, lDevices, sPatchTag=None): 178 | lSuite = InitSuite(lAirScripts) 179 | if GetCfgData('mode') == '2': 180 | oAirQueue = multiprocessing.Queue() 181 | for sAirScript in lSuite: 182 | oAirQueue.put(sAirScript) 183 | oScripts = oAirQueue 184 | else: 185 | oScripts = lSuite 186 | pool = [] 187 | sRunTime = datetime.datetime.now().strftime("%Y%m%d%H%M%S") 188 | sPatchTag = sPatchTag or sRunTime 189 | sLogDir = os.path.join(LOG_ROOT, sPatchTag) 190 | for sDeviceNum in lDevices: 191 | p = multiprocessing.Process(target=RunScript, args=(sDeviceNum, sLogDir, oScripts)) 192 | p.start() 193 | time.sleep(3) 194 | pool.append(p) 195 | for p in pool: 196 | p.join() 197 | return Finish(sLogDir) 198 | 199 | -------------------------------------------------------------------------------- /scripts/test.air/test.py: -------------------------------------------------------------------------------- 1 | # -*- encoding=utf8 -*- 2 | __author__ = "Leo Zeng" 3 | 4 | from airtest.core.api import * 5 | 6 | auto_setup(__file__) 7 | 8 | snapshot(msg="请填写测试点.") 9 | sleep(5) 10 | snapshot(msg="请填写测试点.") 11 | sleep(5) 12 | snapshot(msg="请填写测试点.") 13 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Leo Zeng 3 | import os 4 | import time 5 | import logging 6 | import zipfile 7 | import traceback 8 | import requests 9 | import configparser 10 | 11 | from airtest.core.android.adb import ADB 12 | from functools import wraps 13 | 14 | CFG_FILE = 'config.ini' 15 | 16 | def GetCfgData(sKey): 17 | config = configparser.ConfigParser() 18 | config.read(CFG_FILE) 19 | if sKey in config.options('baseconf'): 20 | sValue = config.get('baseconf', sKey) 21 | return sValue 22 | else: 23 | return '' 24 | 25 | def SetCfgData(sKey,sValue): 26 | config = configparser.ConfigParser() 27 | config.read(CFG_FILE) 28 | config.set('baseconf', sKey, sValue) 29 | config.write(open(CFG_FILE, "w")) 30 | 31 | 32 | def CreateCfgFile(): 33 | if not os.path.exists(CFG_FILE): 34 | file = open(CFG_FILE, 'w') 35 | file.write('[baseconf]\n') 36 | file.close() 37 | 38 | 39 | CreateCfgFile() 40 | 41 | 42 | def GetValidDevices(): 43 | """获取本地连接的设备号列表""" 44 | lData = ADB().devices('device') 45 | lPositiveDevices = [item[0] for item in lData] 46 | return lPositiveDevices 47 | 48 | 49 | def GetDeviceNum(): 50 | sDevices = GetCfgData('devices') 51 | lDevice = GetValidDevices() 52 | if not sDevices and lDevice: 53 | return [lDevice[0]] 54 | elif 'all' in sDevices: 55 | return lDevice 56 | else: 57 | return sDevices.split(',') 58 | 59 | 60 | def ZipFile(sExportPath): 61 | """压缩报告""" 62 | # sPatchTag = os.path.basename(sExportPath) 63 | sZipFile = sExportPath + '.zip' # 压缩后文件夹的名字 64 | z = zipfile.ZipFile(sZipFile, 'w', zipfile.ZIP_DEFLATED) # 参数一:文件夹名 65 | for dirpath, dirnames, filenames in os.walk(sExportPath): 66 | fpath = dirpath.replace(sExportPath, '') # 这一句很重要,不replace的话,就从根目录开始复制 67 | fpath = fpath and fpath + os.sep or '' 68 | for filename in filenames: 69 | z.write(os.path.join(dirpath, filename), fpath + filename) 70 | z.close() 71 | return sZipFile 72 | 73 | 74 | def PostZipFile(sZipFile): 75 | sPostUrl = '' # 上传路径 76 | sName = os.path.basename(sZipFile) 77 | file = {sName: open(sZipFile, 'rb')} 78 | headers = { 79 | 'Connection': 'keep-alive', 80 | 'Host': '10.32.17.71:8001', 81 | 'Upgrade-Insecure-Requests': '1', 82 | } 83 | r = requests.post(sPostUrl, files=file, headers=headers) 84 | if r.status_code == 200: 85 | Logging('报告上传成功') 86 | else: 87 | Logging('报告上传失败') 88 | Logging('状态码:%s' % r.status_code) 89 | Logging(r.content) 90 | 91 | 92 | def UnzipFile(sZipFile): 93 | sDir, sZipFileName = os.path.split(sZipFile) 94 | z = zipfile.ZipFile(sZipFile, 'r') 95 | sPath = os.path.join(sDir, sZipFileName.replace('.zip', '')) 96 | if not os.path.exists(sPath): 97 | os.mkdir(sPath) 98 | z.extractall(path=sPath) 99 | z.close() 100 | 101 | 102 | def PostReport2TestWeb(sExportPath): 103 | sZipFile = ZipFile(sExportPath) 104 | PostZipFile(sZipFile) 105 | os.remove(sZipFile) 106 | 107 | 108 | def Logging(sMsg): 109 | logging.error(sMsg) 110 | print(sMsg) 111 | 112 | 113 | def CatchErr(func): 114 | @wraps(func) 115 | def MyWrapper(*args,**kwargs): 116 | try: 117 | return func(*args,**kwargs) 118 | except Exception as e: 119 | traceback.print_exc() 120 | Logging(e) 121 | 122 | return MyWrapper 123 | 124 | 125 | def RetryFunc(iTimes=3, iSec=2): 126 | def CatchErr(func): 127 | @wraps(func) 128 | def MyWrapper(*args, **kwargs): 129 | for _ in range(iTimes): 130 | try: 131 | return func(*args, **kwargs) 132 | except Exception as e: 133 | Logging(e) 134 | time.sleep(iSec) 135 | continue 136 | 137 | return MyWrapper 138 | 139 | return CatchErr 140 | -------------------------------------------------------------------------------- /video.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Leo Zeng 3 | ''' 4 | pc端录屏 5 | ''' 6 | import types 7 | import numpy 8 | import cv2 9 | import threading 10 | 11 | from airtest import aircv 12 | 13 | 14 | INTERVAL = 0.05 15 | FPS = 10 16 | 17 | 18 | def start_recording(self, output): 19 | rect = self.get_rect() # 获得分辨率 20 | bbox = (ToEvenNum(rect.left), ToEvenNum(rect.top), ToEvenNum(rect.right), ToEvenNum(rect.bottom)) # 虚拟机中需要是偶数 21 | # bbox = (rect.left, rect.top, rect.right, rect.bottom) 22 | size = (bbox[2] - bbox[0], bbox[3] - bbox[1]) 23 | width, heigh = size 24 | self.video = cv2.VideoWriter(output, cv2.VideoWriter_fourcc(*'X264'), FPS, (width, heigh)) 25 | self.m_Lock = threading.Lock() 26 | self.m_bRecording = True 27 | self.LoopTimer = threading.Timer(INTERVAL, self.Record, [bbox]) 28 | self.LoopTimer.start() 29 | 30 | 31 | def stop_recording(self, output): 32 | self.m_Lock.acquire() 33 | print('Record end') 34 | self.m_bRecording = False 35 | if self.LoopTimer: 36 | self.LoopTimer.cancel() 37 | self.LoopTimer = None 38 | self.video.release() 39 | self.m_Lock.release() 40 | 41 | 42 | def Record(self, bbox): 43 | import time 44 | import airtest.core.win.screen as screen 45 | threading.current_thread.name = 'recording' 46 | while True: 47 | if not self.m_bRecording: 48 | break 49 | self.m_Lock.acquire() 50 | print('Recording...', (bbox[2] - bbox[0], bbox[3] - bbox[1])) 51 | try: 52 | im = aircv.crop_image(screen.screenshot(None), bbox) 53 | self.video.write(numpy.array(im)) # 将img convert ndarray 54 | except: 55 | pass 56 | self.m_Lock.release() 57 | time.sleep(INTERVAL) 58 | 59 | 60 | def InitVideoRecorder(oDevices): 61 | if oDevices.__class__.__name__ == "Windows": 62 | oDevices.m_Lock = threading.Lock() 63 | if not hasattr(oDevices, 'start_recording'): 64 | oDevices.start_recording = types.MethodType(start_recording, oDevices) 65 | if not hasattr(oDevices, 'stop_recording'): 66 | oDevices.stop_recording = types.MethodType(stop_recording, oDevices) 67 | if not hasattr(oDevices, 'Record'): 68 | oDevices.Record = types.MethodType(Record, oDevices) 69 | 70 | 71 | def ToEvenNum(iNum): 72 | if iNum % 2 == 0: 73 | return iNum 74 | else: 75 | return iNum + 1 76 | -------------------------------------------------------------------------------- /安装运行环境.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | start cmd /K "pip install -r requirements.txt" 3 | -------------------------------------------------------------------------------- /说明.txt: -------------------------------------------------------------------------------- 1 | 1. 运行环境为python3 2 | 3 | 2. 点击文件夹下的 《安装运行环境.bat》 文件,等待安装好运行环境; 4 | 5 | 3.把自己写的air脚本放置到scripts文件夹下 6 | 7 | 4.打开config.ini,根据注释填写运行模式、脚本名称及设备序列号; 8 | 9 | 6.点击《运行.bat》,等待运行完成; 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /运行.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | del std.log 3 | start cmd /K "python main.py>>std.log" --------------------------------------------------------------------------------