本菜鸟记录一下项目的机房判分测试工具,如有不完善的地方,欢迎指正
基于 CDP 协议的 WebView2 自动化测试工具,用于批量验证学生端考试流程。
目录
- 1. 项目概述
- 2. 技术架构
- 3. 配置文件
- 4. 打包部署
- 5. CDP 调试协议
- 6. WebSocket 客户端
- 7. CDP 命令封装
- 8. 进程管理
- 9. 主测试流程
- 10. 异常处理与容错
- 11. 常见问题排查
1. 项目概述
1.1 背景
在智能测评系统的机房部署场景中,需要在100 台客户机上验证学生端(ExamStudent.exe)的完整考试流程是否正常运行。人工逐台操作效率极低,因此开发了本自动化测试工具。
1.2 功能定位
本工具通过CDP(Chrome DevTools Protocol)协议控制基于 WebView2 渲染的学生端桌面应用,模拟真实用户的完整考试操作流程:
启动 → 登录 → 选择任务 → 进入考试 → 交卷 → 输入验证码 → 确认提交
1.3 核心特性
| 特性 | 说明 |
|---|---|
| 零依赖 | 纯 Python 标准库实现,无需 pip install 任何第三方包 |
| 免安装运行 | 通过 PyInstaller 打包为单文件 exe,双击即可运行 |
| 自动发现 | 自动查找 ExamStudent.exe,支持常见路径搜索 + 全盘搜索 + GUI 选择 |
| CDP 自动化 | 手写 WebSocket 客户端,通过 CDP 协议控制 WebView2 页面 |
| 配置持久化 | 首次运行自动生成 config.json,后续直接读取 |
| 测试报告 | 每次运行生成 JSON 格式的测试报告 |
1.4 文件结构
判分测试/
├── exam_test.py # 主脚本(约 1235 行)
├── config.json # 运行配置文件
├── build.bat # PyInstaller 打包脚本
├── 判分测试工具.exe # 打包产物(dist 目录下)
└── test_report_*.json # 测试报告(自动生成)
2. 技术架构
2.1 技术栈
Python 3.9+(纯标准库)
├── socket / struct / base64 → 手写 WebSocket 客户端
├── urllib.request → HTTP 请求(CDP 端口探测)
├── json → 配置读写 / CDP 消息序列化
├── subprocess → 进程管理(启动/杀死 ExamStudent.exe)
├── os / sys / time → 系统交互 / 路径处理 / 流程控制
├── tkinter → 文件选择对话框(GUI 降级方案)
└── PyInstaller → 打包为单文件 exe
2.2 架构图
┌─────────────────────┐
│ 判分测试工具.exe │
│ │
│ ┌───────────────┐ │ env: WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS
│ │ setup_cdp_env │──┼──────────────────────────────────┐
│ └───────────────┘ │ │
│ ┌───────────────┐ │ ▼
│ │ launch_with │──┼──► subprocess.Popen ──► ExamStudent.exe
│ │ _cdp() │ │ │ │ └───────────────┘ │ ▼
│ ┌───────────────┐ │ WebView2 渲染引擎
│ │ wait_cdp_ready│──┼──► HTTP :9222/json/list
│ │ └───────────────┘ │ ▼
│ ┌───────────────┐ │ CDP 端口 9222 开放
│ │ connect_cdp() │──┼──► WebSocket 连接
│ └───────────────┘ │ │
│ ┌───────────────┐ │ ▼
│ │ test_student()│──┼──► DOM / Runtime / Input 命令
│ └───────────────┘ │
│ ┌───────────────┐ │
│ │ main() │──┼──► config.json / test_report.json
│ └───────────────┘ │
└─────────────────────┘
2.3 核心模块关系
exam_test.py ├── find_exe() # 自动查找学生端可执行文件
├── load_config() # 配置加载与持久化
├── WebSocketClient # 纯标准库 WebSocket 客户端
│ ├── _handshake() # HTTP Upgrade 握手
│ ├── send_json() # 发送 JSON 消息(带 WebSocket 帧封装)
│ └── recv_json() # 接收并解析 JSON 消息
├── CDP # Chrome DevTools Protocol 封装
│ ├── send() # 发送 CDP 命令并等待响应
│ ├── enable_domains() # 启用 DOM / Runtime / Page 域
│ ├── query_selector() # CSS 选择器查询
│ ├── set_value() # 设置输入框值(兼容 Vue/React 响应式)
│ ├── eval_js() # 执行 JavaScript 表达式
│ ├── wait_selector() # 等待元素出现
│ └── dump_dom() # 导出 DOM 树(调试用)
├── setup_cdp_env() # 设置 CDP 环境变量(os.environ + setx)
├── launch_with_cdp() # 启动学生端并注入 CDP 环境变量
├── wait_cdp_ready() # 轮询等待 CDP 端口就绪
├── connect_cdp() # 建立 WebSocket 连接
├── test_student() # 单个学生的完整测试流程(19 步)
└── main() # 入口函数
3. 配置文件
3.1 config.json 结构
{"exe_path":"C:/Program Files/智能测评系统/ExamStudent.exe","password":"123456","wait_seconds":5,"cdp_port":9222,"students":["cs001","cs002","cs003"]}3.2 配置字段说明
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
exe_path | string | 自动查找 | 学生端可执行文件的绝对路径 |
password | string | "123456" | 登录密码(所有账号统一密码) |
wait_seconds | int | 5 | 进入考试后的等待秒数(模拟答题时间) |
cdp_port | int | 9222 | WebView2 CDP 调试端口 |
students | array | ["cs002"] | 待测试的学生账号列表 |
3.3 自动查找策略
find_exe()按以下优先级查找 ExamStudent.exe:
- 常见安装路径(7 个预设路径,覆盖 C/D 盘的 Program Files)
- 全盘搜索(限制深度 4 层,避免遍历过深)
- GUI 文件选择(tkinter 文件对话框,作为最终降级方案)
查找结果会自动保存到config.json,下次运行直接读取。
3.4 PyInstaller 路径处理
ifgetattr(sys,'frozen',False):SCRIPT_DIR=os.path.dirname(sys.executable)# 打包后:exe 所在目录else:SCRIPT_DIR=os.path.dirname(os.path.abspath(file))# 开发时:脚本所在目录CONFIG_FILE=os.path.join(SCRIPT_DIR,"config.json")注意:打包后
__file__指向_MEI临时目录,必须用sys.executable获取 exe 所在路径。
4. 打包部署
4.1 打包脚本 build.bat
@echo off chcp 65001 >nul cd /d "%~dp0" set PYTHON_EXE=D:\py\venv\Scripts\python.exe :: 清理旧构建 if exist "build" rd /s /q "build" if exist "dist" rd /s /q "dist" if exist "判分测试工具.spec" del /q "判分测试工具.spec" :: 安装 PyInstaller 并打包 "%PYTHON_EXE%" -m pip install pyinstaller -q "%PYTHON_EXE%" -m PyInstaller --onefile --console --name "判分测试工具" --clean ^ --hidden-import tkinter ^ --hidden-import tkinter.filedialog ^ exam_test.py :: 复制配置文件 copy /Y "config.json" "dist\config.json" >nul 2>&14.2 打包要点
| 要点 | 说明 |
|---|---|
必须cd /d "%~dp0" | 双击 bat 时工作目录可能是 system32,不切换会导致 PyInstaller 报错 |
| 必须清理旧构建 | build/dist/.spec文件不清理可能产生缓存问题 |
--hidden-import tkinter | PyInstaller 无法自动检测 tkinter 的动态导入,需显式声明 |
--onefile --console | 单文件 + 控制台模式,方便查看运行日志 |
| Python 路径 | 当前配置为D:\py\venv\Scripts\python.exe,需根据实际环境修改 |
4.3 部署方式
打包完成后,将dist/目录下的文件复制到目标机器:
dist/
├── 判分测试工具.exe # 主程序
└── config.json # 配置文件
运行方式:直接双击
判分测试工具.exe即可,不需要管理员权限。
5. CDP 调试协议
5.1 原理
ExamStudent.exe 是基于 Tauri 框架打包的桌面应用,内部使用WebView2(Chromium 内核)渲染 UI。WebView2 支持通过环境变量开启 Chrome DevTools Protocol(CDP)调试端口。
5.2 环境变量设置
defsetup_cdp_env(cdp_port):dbg_arg="--remote-debugging-port="+str(cdp_port)os.environ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"]=dbg_arg os.environ["WEBVIEW2_BROWSER_EXECUTABLE_ARGS"]=dbg_arg os.system('setx WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS "'+dbg_arg+'" >nul 2>&1')os.system('setx WEBVIEW2_BROWSER_EXECUTABLE_ARGS "'+dbg_arg+'" >nul 2>&1')| 方法 | 作用范围 | 持久性 | 说明 |
|---|---|---|---|
os.environ | 当前进程及子进程 | 进程结束即失效 | 普通运行时有效 |
setx | 写入用户注册表 | 永久生效 | 确保管理员模式等场景也能读取 |
5.3 启动流程
setup_cdp_env(9222) ← 先写环境变量(os.environ + setx 注册表)
↓
kill_existing() ← 杀掉旧的 ExamStudent.exe
↓
launch_with_cdp(exe_path) ← subprocess.Popen 启动,env 继承环境变量
↓
ExamStudent.exe 启动
↓
WebView2 读取 WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS
↓
开启 --remote-debugging-port=9222
↓
wait_cdp_ready(9222) ← 轮询 HTTP http://127.0.0.1:9222/json/list
↓ connect_cdp(pages) ← 解析 webSocketDebuggerUrl,建立 WebSocket 连接
5.4 重要限制
⚠️ 不要以管理员身份运行!
管理员模式下,WebView2 的子进程(
msedgewebview2.exe)可能以降低的完整性级别运行,导致环境变量传递断裂,CDP 端口无法开启。普通用户模式下,所有进程在同一完整性级别(中等),环境变量正常传递。
6. WebSocket 客户端
6.1 设计说明
由于项目要求零第三方依赖,WebSocket 客户端完全使用 Python 标准库(socket、struct、base64)手写实现。
6.2 核心类 WebSocketClient
classWebSocketClient:definit(self,host,port,path):self.sock=socket.socket(socket.AF_INET,socket.SOCK_STREAM)self.sock.settimeout(30)self.sock.connect((host,port))self._handshake(host,port,path)# RFC 6455 握手self._msg_id=06.3 握手流程
遵循 RFC 6455 规范:
客户端 → 服务端:
GET /devtools/page/xxx HTTP/1.1
Host: 127.0.0.1:9222
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <base64随机16字节>
Sec-WebSocket-Version: 13
服务端 → 客户端: HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: <SHA1(key + magic)>
6.4 帧协议
| 操作 | 实现 |
|---|---|
| 发送 | 构造 WebSocket 帧:0x81(文本帧+FIN)+ `0x80 |
| 接收 | 解析帧头 → 读取长度(7bit / 16bit / 64bit)→ 处理掩码 → 按 opcode 分发 |
| Ping/Pong | 收到opcode=0x9(Ping)自动回复 Pong(0x8A) |
| 关闭 | 发送opcode=0x8(Close)帧 + 关闭 socket |
6.5 消息格式
CDP 消息遵循 JSON-RPC 风格:
// 请求 {"id": 1, "method": "DOM.getDocument"}// 响应 {"id": 1, "result": {"root": {"nodeId": 1, ...}}}// 事件推送 {"method": "Page.loadEventFired", "params": {...}}7. CDP 命令封装
7.1 CDP 类
class CDP: def init(self, ws): self.ws = ws def send(self, method, params=None, timeout=15): """发送 CDP 命令,等待匹配的 id 响应""" msg_id = self.ws.send_json(method, params) while True: msg = self.ws.recv_json(timeout) if msg and msg.get("id") == msg_id: return msg if msg and "method" in msg: # 跳过事件推送 continue7.2 核心方法一览
| 方法 | CDP 命令 | 功能 |
|---|---|---|
enable_domains() | DOM.enable+Runtime.enable+Page.enable | 启用必要的 CDP 域 |
query_selector(sel) | DOM.getDocument+DOM.querySelector | CSS 选择器查询,返回 nodeId |
set_value(sel, val) | DOM.focus+Runtime.evaluate | 设置输入框值,兼容 Vue/React |
eval_js(expr) | Runtime.evaluate | 执行 JS 表达式,返回值 |
click(sel) | Runtime.evaluate | 点击元素 |
wait_selector(sel, t) | 轮询query_selector | 等待元素出现(超时 t 秒) |
dump_dom(depth) | Runtime.evaluate | 导出 DOM 树结构(调试用) |
7.3 Vue/React 输入框兼容
WebView2 页面使用 Vue/Element UI,直接修改input.value不会触发响应式更新。需要使用原生setter:
varnativeSet=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;nativeSet.call(el,'新值');el.dispatchEvent(newEvent('input',{bubbles:true}));el.dispatchEvent(newEvent('change',{bubbles:true}));7.4 鼠标事件双重保障
对于关键操作(如双击任务行),同时使用两种机制确保触发:
- JS 模拟:
element.dispatchEvent(new MouseEvent('dblclick', ...)) - CDP Input:
Input.dispatchMouseEvent(底层渲染级别的鼠标事件)
第一层:JS 模拟 cdp.eval_js("element.dispatchEvent(new MouseEvent('dblclick', ...))")第二层:CDP Input(更底层,绕过 JS 事件系统) cdp.send("Input.dispatchMouseEvent",{"type":"mousePressed","x":x,"y":y,...})cdp.send("Input.dispatchMouseEvent",{"type":"mouseReleased","x":x,"y":y,...})8. 进程管理
8.1 清理旧进程
defkill_existing():os.system('taskkill /F /IM ExamStudent.exe >nul 2>&1')time.sleep(2)使用
>nul 2>&1抑制输出,避免进程不存在时报错。等待 2 秒确保进程完全退出、端口释放。
8.2 启动学生端
deflaunch_with_cdp(exe_path,cdp_port):env=os.environ.copy()proc=subprocess.Popen([exe_path],env=env)returnproc关键点:
subprocess.Popen必须显式传递env=env,否则子进程无法继承环境变量。
8.3 等待 CDP 就绪
def wait_cdp_ready(cdp_port, timeout=30): while time.time() < end: try: resp = urllib.request.urlopen( "http://127.0.0.1:" + str(cdp_port) + "/json/list", timeout=3 ) data = json.loads(resp.read().decode("utf-8")) if data: return data # 返回页面列表 except Exception: pass time.sleep(1) return None每秒轮询一次,最多等待 30 秒。返回的页面列表包含webSocketDebuggerUrl,用于建立 WebSocket 连接。
8.4 连接 CDP
defconnect_cdp(pages):forpageinpages:ifpage.get("type")=="page":ws_url=page.get("webSocketDebuggerUrl","")breakparsed=urlparse(ws_url)ws=WebSocketClient(parsed.hostname,parsed.port,parsed.path)returnCDP(ws)9. 主测试流程
9.1 流程总览
test_student()函数包含19 个步骤,模拟完整的考试操作:
步骤 0: 设置 CDP 环境变量
步骤 1: 清理旧进程
步骤 2: 启动学生端(带 CDP)
步骤 3: 等待 CDP
就绪 步骤 4: 连接 WebSocket
步骤 5: 等待登录页加载
步骤 6: 输入账号密码
步骤 7: 点击登录
步骤 7.5: 处理"未完成考试"弹窗
步骤 8: 双击"练习"图标 步骤
9: 等待任务列表 步骤
10: 双击编号为 1 的任务行
步骤 11: 点击"信息确认"
步骤 12: 点击"开始考试"
步骤 13-14: 等待考试进行
步骤 15: 点击"交卷"
步骤 16: Enter 确认交卷
步骤 17: 读取验证码
步骤 18: 输入验证码
步骤 19: 点击"确定"完成交卷
9.2 登录流程(步骤 5-7)
等待登录页 cdp.wait_selector("input",20)多选择器尝试账号输入框 input_selectors=["input[type='text']","input[placeholder*='账号']","input[placeholder*='用户名']","input[name='username']","input[name='account']","input"]多选择器尝试密码输入框 pwd_selectors=["input[type='password']","input[placeholder*='密码']","input[name='password']"]使用原生 setter 设置值(兼容 Vue 响应式) cdp.set_value(selector,username)cdp.set_value(selector,password)文本匹配点击登录按钮 cdp.eval_js(""" (function() { var btns = document.querySelectorAll('button, .el-button, ...'); for (var i = 0; i < btns.length; i++) { var t = (btns[i].innerText || btns[i].textContent || '').trim(); if (t === '登录' || t === '登 录') { btns[i].click(); return 'clicked'; } } return 'not_found'; })() """)9.3 弹窗处理(步骤 7.5)
登录后可能遇到"未完成考试"弹窗,自动检测并处理:
检测弹窗 unfinished=cdp.eval_js(""" (function() { var all = document.querySelectorAll('*'); for (var i = 0; i < all.length; i++) { var t = (all[i].innerText || '').trim(); if (t.indexOf('未完成') >= 0 || t.indexOf('未完成的考试') >= 0) return 'detected'; } return 'none'; })() """)点击"结束考试"按钮ifstr(unfinished)=='detected':cdp.eval_js("... 查找并点击 '结束考试' 按钮 ...")9.4 任务选择(步骤 8-10)
双击"练习"图标 cdp.eval_js(""" var all = document.querySelectorAll('*'); for (var i = 0; i < all.length; i++) { var t = (el.innerText || '').trim(); if (t === '练习') { el.dispatchEvent(new MouseEvent('dblclick', {bubbles: true})); return 'practice_dblclicked'; } } """)等待表格数据加载(轮询 td 有内容的数量) td_count=cdp.eval_js(""" var cells = document.querySelectorAll('td'); var count = 0; for (var i = 0; i < cells.length; i++) { if ((cells[i].textContent || '').trim().length > 0) count++; } return count; """)双击编号为1的任务行(JS模拟+CDP Input 双重保障)9.5 交卷流程(步骤 15-19)
点击"交卷" → Enter确认 → 读取验证码 → 输入验证码 → 点击"确定"
验证码读取策略(四轮降级):
| 轮次 | 策略 | 说明 |
|---|---|---|
| 第 1 轮 | 红色/橙色背景的 4 位数字 | 匹配backgroundColor中的 red/orange/rgb(2xx…) |
| 第 2 轮 | 白色文字的 4 位数字 | 匹配color中的 white/#fff |
| 第 3 轮 | 内联样式含 background/color | 匹配style属性 |
| 第 4 轮 | 任何叶子节点的 4 位数字 | 兜底:排除 input 和无子元素的节点 |
验证码输入策略(三级降级):
| 级别 | 方法 | 说明 |
|---|---|---|
| 第 1 级 | Input.insertText | CDP 底层直接插入文本,最可靠 |
| 第 2 级 | Input.dispatchKeyEvent | 逐字符模拟键盘输入 |
| 第 3 级 | JSnativeSet赋值 | 直接操作 DOM 的 value 属性 |
10. 异常处理与容错
10.1 异常捕获结构
deftest_student(config,username,index,total):try:# ... 19 步测试流程 ...return{"username":username,"status":"success","msg":"交卷成功","log":log}exceptExceptionase:return{"username":username,"status":"fail","msg":str(e),"log":log}finally:# 关闭 WebSocket 连接ifcdp:cdp.ws.close()# 清理环境变量os.environ.pop("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",None)os.environ.pop("WEBVIEW2_BROWSER_EXECUTABLE_ARGS",None)time.sleep(2)10.2 多选择器降级
对于每个关键 UI 元素,都提供多个 CSS 选择器尝试:
python
账号输入框:6个选择器 input_selectors=["input[type='text']","input[placeholder*='账号']","input[placeholder*='用户名']","input[name='username']","input[name='account']","input"# 最终兜底]任务列表:11个选择器 task_selectors=["table",".el-table",".task-list","[class*='task']",".el-card",".el-table__body","tr",".list-item","[class*='paper']","[class*='exam']",".ant-table"py]10.3 重试机制
| 场景 | 重试策略 |
|---|---|
| 双击"练习"图标 | 最多重试 30 次,每次间隔 3 秒 |
| 等待表格数据 | 最多轮询 20 次,每次间隔 0.5 秒 |
| 读取验证码 | 最多重试 10 次,每次间隔 0.5 秒 |
| 等待 CDP 就绪 | 最多等待 30 秒,每秒轮询 |
| 等待元素出现 | wait_selector超时 20-30 秒 |
10.4 日志记录
每个步骤的操作和结果都记录到log数组,最终输出到控制台和测试报告:
log=[]log.append("清理旧进程...")log.append("启动 ExamStudent.exe (CDP 端口: 9222)...")log.append("CDP 已就绪 (2 个页面)")log.append("已连接 WebSocket")log.append("输入账号: cs002")log.append(" 账号输入框: input[type='text']")...11. 常见问题排查
11.1 CDP 端口未就绪
现象:等待 CDP 端口就绪一直超时,报WinError 10061
排查步骤:
powershell
1.确认进程是否启动 tasklist|findstr"ExamStudent"2.确认端口状态 netstat-ano|findstr"9222"3.确认环境变量setWEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS常见原因:
| 原因 | 解决方案 |
|---|---|
| 以管理员身份运行 | 不要用管理员运行,直接双击即可 |
| 端口被其他进程占用 | netstat -ano | findstr "9222"查看占用进程 |
| WebView2 运行时未安装 | 安装 WebView2 Runtime |
| 环境变量未生效 | 检查setx是否执行成功,重启终端后再试 |
11.2 打包后运行报错
| 错误 | 原因 | 解决 |
|---|---|---|
配置文件不存在: C:\...\_MEIxxx\config.json | 使用了__file__而非sys.executable | 确保sys.frozen判断逻辑正确 |
'pip' is not recognized | Python 不在 PATH 中 | build.bat 中使用完整路径 |
Do not run pyinstaller from system32 | bat 未切换目录 | 确保cd /d "%~dp0" |
| 打包后功能没更新 | 运行的是旧 exe | 重新运行 build.bat,清理 build/dist |
11.3 UI 操作失败
| 现象 | 原因 | 解决 |
|---|---|---|
| 未找到账号输入框 | 页面未加载完成 | 增加wait_selector超时时间 |
| 登录按钮未找到 | 按钮文本不匹配 | 检查是否有空格(如"登 录") |
| 任务行双击无效 | Tauri 不响应 JS 事件 | 已使用 CDP Input 双重保障 |
| 验证码读取失败 | 页面渲染延迟 | 已实现 10 次重试 + 4 轮降级策略 |
| 验证码输入失败 | WebView2 输入框特殊处理 | 已实现 3 级降级(insertText → 键盘 → JS赋值) |
11.4 测试报告
每次运行自动生成test_report_YYYYMMDD_HHMMSS.json:
{"time":"2026-07-15 14:30:00","total":1,"success":1,"fail":0,"results":[{"username":"cs002","status":"success","msg":"交卷成功","log":["清理旧进程...","启动 ExamStudent.exe (CDP 端口: 9222)...","CDP 已就绪 (2 个页面)","已连接 WebSocket","..."]}]}