系列:从 0 到 1 搭建桌面应用 UI 自动化测试框架 上一篇:第 1 篇|先盘点约束,再决定怎么做 下一篇:第 3 篇|跑通业务主链路 关键词:UAC、UIPI、AppSession、窗口反查 PID、Tee
本篇导读
第一道真正的墙是权限,第二道是进程。只要这两件事不稳,后面所有用例都是沙上建塔。本篇讲清楚三件事:
一句话:PID 不可信,窗口才是事实;多层 launcher + UIPI 场景,必须 EnumWindows 反查 + 承载校验。
流程图:一次入口执行的完整链路
#mermaid-svg-vqJxQ080uVyDFTHn{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vqJxQ080uVyDFTHn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vqJxQ080uVyDFTHn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vqJxQ080uVyDFTHn .error-icon{fill:#552222;}#mermaid-svg-vqJxQ080uVyDFTHn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vqJxQ080uVyDFTHn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vqJxQ080uVyDFTHn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vqJxQ080uVyDFTHn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vqJxQ080uVyDFTHn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vqJxQ080uVyDFTHn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vqJxQ080uVyDFTHn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vqJxQ080uVyDFTHn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vqJxQ080uVyDFTHn .marker.cross{stroke:#333333;}#mermaid-svg-vqJxQ080uVyDFTHn svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vqJxQ080uVyDFTHn p{margin:0;}#mermaid-svg-vqJxQ080uVyDFTHn .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-vqJxQ080uVyDFTHn .cluster-label text{fill:#333;}#mermaid-svg-vqJxQ080uVyDFTHn .cluster-label span{color:#333;}#mermaid-svg-vqJxQ080uVyDFTHn .cluster-label span p{background-color:transparent;}#mermaid-svg-vqJxQ080uVyDFTHn .label text,#mermaid-svg-vqJxQ080uVyDFTHn span{fill:#333;color:#333;}#mermaid-svg-vqJxQ080uVyDFTHn .node rect,#mermaid-svg-vqJxQ080uVyDFTHn .node circle,#mermaid-svg-vqJxQ080uVyDFTHn .node ellipse,#mermaid-svg-vqJxQ080uVyDFTHn .node polygon,#mermaid-svg-vqJxQ080uVyDFTHn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vqJxQ080uVyDFTHn .rough-node .label text,#mermaid-svg-vqJxQ080uVyDFTHn .node .label text,#mermaid-svg-vqJxQ080uVyDFTHn .image-shape .label,#mermaid-svg-vqJxQ080uVyDFTHn .icon-shape .label{text-anchor:middle;}#mermaid-svg-vqJxQ080uVyDFTHn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vqJxQ080uVyDFTHn .rough-node .label,#mermaid-svg-vqJxQ080uVyDFTHn .node .label,#mermaid-svg-vqJxQ080uVyDFTHn .image-shape .label,#mermaid-svg-vqJxQ080uVyDFTHn .icon-shape .label{text-align:center;}#mermaid-svg-vqJxQ080uVyDFTHn .node.clickable{cursor:pointer;}#mermaid-svg-vqJxQ080uVyDFTHn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vqJxQ080uVyDFTHn .arrowheadPath{fill:#333333;}#mermaid-svg-vqJxQ080uVyDFTHn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vqJxQ080uVyDFTHn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vqJxQ080uVyDFTHn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vqJxQ080uVyDFTHn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vqJxQ080uVyDFTHn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vqJxQ080uVyDFTHn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vqJxQ080uVyDFTHn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vqJxQ080uVyDFTHn .cluster text{fill:#333;}#mermaid-svg-vqJxQ080uVyDFTHn .cluster span{color:#333;}#mermaid-svg-vqJxQ080uVyDFTHn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vqJxQ080uVyDFTHn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vqJxQ080uVyDFTHn rect.text{fill:none;stroke-width:0;}#mermaid-svg-vqJxQ080uVyDFTHn .icon-shape,#mermaid-svg-vqJxQ080uVyDFTHn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vqJxQ080uVyDFTHn .icon-shape p,#mermaid-svg-vqJxQ080uVyDFTHn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vqJxQ080uVyDFTHn .icon-shape .label rect,#mermaid-svg-vqJxQ080uVyDFTHn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vqJxQ080uVyDFTHn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vqJxQ080uVyDFTHn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vqJxQ080uVyDFTHn :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
否
是
不通过
通过
否
是
python run.py
IsUserAnAdmin() == 1?
ShellExecute runas 重新拉起
taskkill 清理残留 DesktopApp.exe
pytest tests/
AppSession.start()
Application.start()
EnumWindows 枚举顶层窗口
从窗口句柄反查候选 PID
排除系统窗口 / Qt 辅助窗口 / 浏览器窗口
connect(process=pid)
该 PID 承载桌面应用窗口?
返回可用 Application
1. 统一提权
原则:提权代码只能有一份,所有入口脚本都复用它。
# utils/elevation.py 的调用方式
from utils.elevation import elevate, is_admin
if not is_admin():
return elevate(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 反查窗口句柄得到
for pid in candidate_pids_from_enumwindows():
try:
app = Application(backend="uia").connect(process=pid)
# _has_app_window:校验该 PID 的窗口 class/title/auto_id 是否命中应用特征集
if _has_app_window(app):
return app
except RuntimeError:
continue
关键细节:
这一步的验收标准不是“能连上”,而是“打开资源管理器、打开浏览器、启动应用后,仍然只连到正确进程”。
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 = streams
def write(self, text):
for s in self.streams:
s.write(text)
s.flush()
def flush(self):
for s in self.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 行提权逻辑
if not is_admin():
# 把原始命令行参数透传给提权后的新进程
sys.exit(elevate(sys.argv[1:]))
# elevation.py 里的 elevate() 核心(ShellExecuteW runas)
def elevate(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,
)
return 0 if rc > 32 else 1
网硕互联帮助中心



评论前必须登录!
注册