系列:从 0 到 1 搭建桌面应用 UI 自动化测试框架
上一篇:第 1 篇|先盘点约束,再决定怎么做
下一篇:第 3 篇|跑通业务主链路
关键词:UAC、UIPI、AppSession、窗口反查 PID、Tee
本篇导读
第一道真正的墙是权限,第二道是进程。只要这两件事不稳,后面所有用例都是沙上建塔。本篇讲清楚三件事:
- 为什么必须统一提权,怎么做;
- 为什么 PID 不可信,怎么用窗口反查 PID;
- 提权后日志为什么看不见,怎么解决。
一句话:PID 不可信,窗口才是事实;多层 launcher + UIPI 场景,必须 EnumWindows 反查 + 承载校验。
流程图:一次入口执行的完整链路
1. 统一提权
原则:提权代码只能有一份,所有入口脚本都复用它。
# utils/elevation.py 的调用方式fromutils.elevationimportelevate,is_adminifnotis_admin():returnelevate(sys.argv[1:])三个入口都遵循这个模式:
run.py:主测试入口;run_endurance.py:稳定性测试入口;tools/dump_controls.py:控件探针。
已经在管理员终端里时,用-noelevate跳过重复提权。
为什么必须提权
DesktopApp.exe 自身以 runas 启动。Windows 的 UIPI(用户界面特权隔离)规定:低权限进程不能访问高权限进程的窗口。
真实表现:
ProcessNotFoundError:枚举不到窗口;- connect 后 PID 瞬间消失;
- descendants 访问报 AccessDenied。
这不是“偶发问题”,而是确定性行为。所以它必须成为入口的前置条件,而不是用例里的兜底逻辑。
2. 进程连接不能信 PID
桌面应用的启动链条是:
普通进程 → runas 提权 spawn 中转进程 → 真正承载窗口的进程如果只用“最大 PID”或“第一个 DesktopApp.exe”去 connect,很容易连到:
- launcher 中转进程:连上后没有窗口;
- explorer.exe:打开了应用安装目录,窗口标题含应用名;
- 浏览器进程:Allure 报告页面标题含应用名,class 是
Chrome_WidgetWin_1; - Qt 辅助窗口:
ToolSaveBits、Tooltip、MessageWnd。
正确做法:窗口反查 PID
# 核心思路(简化示意,真实实现见 utils/app_session.py)Application(backend="uia").start(cmd_line=...,work_dir=...)# 候选 PID 不是从进程列表猜,是从 EnumWindows 反查窗口句柄得到forpidincandidate_pids_from_enumwindows():try:app=Application(backend="uia").connect(process=pid)# _has_app_window:校验该 PID 的窗口 class/title/auto_id 是否命中应用特征集if_has_app_window(app):returnappexceptRuntimeError:continue关键细节:
- EnumWindows 反查 PID:从窗口句柄反查进程,而不是从进程列表猜窗口;
- 排除系统窗口:
CabinetWClass、Shell_TrayWnd、Progman、#32770等; - 排除 Qt 辅助窗口:
ToolSaveBits、Tooltip、MessageWnd; - 排除浏览器窗口:
Chrome_WidgetWin_1、Edge_WidgetWin_1; - connect 后立即校验:如果该 PID 不承载桌面应用窗口,抛错换下一个。
这一步的验收标准不是“能连上”,而是“打开资源管理器、打开浏览器、启动应用后,仍然只连到正确进程”。
3. 三种进程模式
| 模式 | 触发方式 | 适用场景 |
|---|---|---|
| 会话共享 | 默认 | 常规场景法,整个会话只启动一次 |
| 每用例重启 | RESTART_PER_TEST=1 | 需要强隔离的用例 |
| 连接外部进程 | APP_PID=xxxx | 外部调试/注入场景,close 不杀进程 |
APP_PID模式有两个硬约束:
close()只断开引用,不杀进程;- 禁止
restart(),否则会杀掉用户手动启动的实例。
这两个约束看起来很小,但它们决定了一个调试工具能不能真正被使用。
4. 提权后的日志可见性
提权后run.py跑在独立控制台,父进程(IDE / Trae)拿不到 stdout。解法是_Tee:
class_Tee:"""同时写控制台和日志文件,每次 write 立即 flush。"""def__init__(self,*streams):self.streams=streamsdefwrite(self,text):forsinself.streams:s.write(text)s.flush()defflush(self):forsinself.streams:s.flush()效果:外部可以轮询logs/YYYYMMDD/run_HHMMSS.log实时看进度。
5. 关键文件
| 职责 | 文件 |
|---|---|
| 提权 | utils/elevation.py |
| 应用生命周期 | utils/app_session.py |
| 主入口 | run.py |
| 稳定性入口 | run_endurance.py |
6. 本步的坑
| 现象 | 根因 | 修正 |
|---|---|---|
| pytest 枚举不到窗口 | 测试进程低权限 | 入口统一提权 |
| connect 到 explorer / 浏览器 / launcher | 只按 PID 或标题猜进程 | 窗口反查 PID + 承载校验 |
| 提权后看不到输出 | stdout 在独立控制台 | Tee 到日志文件 |
| 外部实例被测试杀掉 | close()无条件杀进程 | APP_PID模式只断开,不杀 |
7. 验收清单
python run.py能自动弹 UAC 并继续执行;- 日志文件能看到每一步进度;
- 启动后能稳定找到承载窗口的 PID;
- 故意打开资源管理器/浏览器,不会连错进程;
APP_PID模式下关闭测试不会杀掉外部实例。
8. 可复用要点
- PID 不可信,窗口才是事实:多层 launcher / 提权启动的应用都适用。
- 入口层只做入口的事:提权、清理、跑 pytest、出报告,不掺业务逻辑。
- 提权会改变 I/O 边界:所有依赖 stdout 的设计都要重新验证。
关键代码示例
示例 1:统一提权入口(真实utils/elevation.py节选,12 行接入代码)
# run.py 的 3 行提权逻辑ifnotis_admin():# 把原始命令行参数透传给提权后的新进程sys.exit(elevate(sys.argv[1:]))# elevation.py 里的 elevate() 核心(ShellExecuteW runas)defelevate(args=None,window_state=1):script=Path(sys.argv[0]).resolve()cmd=f'"{script}"{subprocess.list2cmdline(args)}'rc=ctypes.windll.shell32.ShellExecuteW(None,"runas",sys.executable,cmd,str(script.parent),window_state,)return0ifrc>32else1