云计算百科
云计算领域专业知识百科平台

第 2 篇|搭入口层:提权、进程连接与日志

系列:从 0 到 1 搭建桌面应用 UI 自动化测试框架 上一篇:第 1 篇|先盘点约束,再决定怎么做 下一篇:第 3 篇|跑通业务主链路 关键词:UAC、UIPI、AppSession、窗口反查 PID、Tee

本篇导读

第一道真正的墙是权限,第二道是进程。只要这两件事不稳,后面所有用例都是沙上建塔。本篇讲清楚三件事:

  • 为什么必须统一提权,怎么做;
  • 为什么 PID 不可信,怎么用窗口反查 PID;
  • 提权后日志为什么看不见,怎么解决。
  • 一句话: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

    关键细节:

  • 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 = 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

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 第 2 篇|搭入口层:提权、进程连接与日志
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!