桌面端与 Web 端混合自动化:Pywinauto + Playwright 全链路编排

桌面端与 Web 端混合自动化:Pywinauto + Playwright 全链路编排

署名:浅木·先生

日期:2026-06-16


一、背景:为什么需要混合自动化?

如果你做过财政系统的自动化测试,你一定会遇到这样一个场景:业务操作需要"桌面端 + Web端"两条腿走路。

以我们正在维护的某省财政预算执行系统为例,完整的业务流程是这样的:

  1. 用户通过CA证书客户端(桌面端应用)插入U-Key,输入PIN码完成身份认证
  2. CA认证通过后,自动打开浏览器跳转到财政内网的Web门户
  3. 用户在Web系统中填报预算支付申请
  4. 将申请数据回写到本地桌面客户端中的加密存储模块
  5. 最后通过桌面客户端调用电子签章控件进行数字签名

整个流程中,桌面端和Web端交替出现,任何一个环节的自动化缺失都会导致端到端的回归测试无法落地。

我们的困境是:传统的UI自动化方案只解决了"一半"的问题

  • 纯Selenium/Playwright方案:能搞定Web端的预算填报和页面交互,但CA认证那一步------插入U-Key、启动客户端、输入PIN码------全靠人工在测试环境里"搭把手",无法自动化。
  • 纯Pywinauto方案:能搞定桌面端的CA认证和电子签章操作,但Web端复杂的表单填写和数据校验又力不从心。

于是我们决定:把Pywinuto和Playwright打通,做一个"全链路混合编排"的自动化框架


二、技术选型与架构设计

2.1 为什么是 Pywinauto + Playwright 这对组合?

市面上能做桌面端自动化的方案其实不少,但我们在选型时踩了好几个坑,最终锁定了这组搭配:

方案 桌面端能力 Web端能力 语言生态 维护活跃度 踩坑指数
Pywinauto + Playwright ✅ 原生Win32/UIA ✅ 原生CDP协议 Python ⭐⭐⭐⭐⭐ ⭐⭐
WinAppDriver + Selenium ✅ 但依赖Appium ✅ 但配置复杂 Java/Python ⭐⭐(微软已停更) ⭐⭐⭐⭐⭐
UIAutomation + Playwright ✅ 但无社区 Python ⭐⭐ ⭐⭐⭐⭐
SikuliX + Selenium 图像识别(不稳定) Java ⭐⭐ ⭐⭐⭐⭐⭐

WinAppDriver 曾经是我们的首选,但2024年微软宣布WinAppDriver进入维护模式后不再更新新功能,它在Win11 + 高DPI缩放屏幕上频繁出现元素定位偏移的问题迟迟得不到修复。我们被坑了整整两个月,最终放弃了这条路线。

Pywinauto 的优势在于:

  1. 纯Python生态,与Playwright天然同语言
  2. 同时支持win32和UIA两种backend
  3. 社区活跃,Bug修复及时
  4. 支持print_control_identifiers()直接dump控件树,定位元素非常方便

2.2 整体架构

我们设计的混合自动化框架分为四层:

复制代码
┌──────────────────────────────────────────────────────────┐
│                    测试用例层 (Test Cases)                  │
│    pytest + allure + 参数化                               │
├──────────────────────────────────────────────────────────┤
│                    编排层 (Orchestrator)                   │
│    StepRouter: 自动判断当前步骤是桌面端还是Web端           │
│    ContextManager: 维护桌面窗口句柄 + 浏览器Context        │
│    StateMachine: 业务流程状态机                           │
├────────────────────┬─────────────────────────────────────┤
│  桌面端引擎层       │  Web端引擎层                          │
│  Pywinauto Engine  │  Playwright Engine                  │
│  - win32_backend   │  - sync_playwright                  │
│  - uia_backend     │  - chromium/chrome                  │
│  - inspect工具     │  - storage_state持久化               │
│  - 窗口句柄管理     │  - context/page管理                 │
├────────────────────┴─────────────────────────────────────┤
│                    基础设施层                              │
│  日志 (loguru) | 配置 (pydantic) | 报告 (allure)          │
│  截图 (自动失败截图) | 重试 (tenacity) | 超时控制          │
└──────────────────────────────────────────────────────────┘

三、CA证书登录:最头疼的那一步

3.1 场景描述

财政系统的CA认证流程大概是这样的:

  1. 用户打开桌面端**"财政CA认证客户端"**(我们叫它"FiscalCA.exe")
  2. 插入U-Key后,客户端检测到证书,弹出PIN码输入框
  3. 用户输入6位PIN码,点击"确认"
  4. 客户端调用USB-Key中的私钥完成签名认证
  5. 认证成功后,客户端启动默认浏览器,并打开https://fiscal-innerweb.gov.cn/sso/login?token=xxxx
  6. 浏览器自动识别token,完成SSO登录

整个过程涉及桌面端窗口操作 + 浏览器自动跳转 + SSO Token传递三个环节。

3.2 Pywinauto 实现CA认证

这是最核心也最容易踩坑的部分。先看完整代码:

python 复制代码
# engines/desktop/ca_auth_engine.py
"""
财政CA证书认证引擎
基于Pywinauto实现桌面端CA客户端的自动化操作
"""
import time
import subprocess
from pathlib import Path
from typing import Optional, Tuple

import pywinauto
from pywinauto import Application, Desktop
from pywinauto.timings import wait_until
from pywinauto.findwindows import ElementNotFoundError

from core.logger import logger
from core.exceptions import CAAuthError
from core.config import settings


class CAAuthEngine:
    """
    CA认证引擎
    
    管理财政CA客户端的启动、PIN码输入、证书选择等操作。
    支持win32和uia两种backend,自动探测适配。
    """
    
    # CA客户端窗口标题(不同版本可能不同)
    CA_WINDOW_TITLES = [
        "财政CA认证客户端",
        "Fiscal CA Client",
        "国密CA认证工具",
    ]
    
    # U-Key检测等待超时(秒)
    UKEY_DETECT_TIMEOUT = 30
    
    # PIN码输入框的标识特征
    PIN_EDIT_AUTO_ID = "pinCodeEdit"
    PIN_EDIT_CLASS = "Edit"
    
    def __init__(self, ca_exe_path: str = None):
        """
        初始化CA认证引擎
        
        Args:
            ca_exe_path: CA客户端可执行文件路径,None时从配置读取
        """
        self.exe_path = ca_exe_path or settings.CA_CLIENT_PATH
        self.app: Optional[Application] = None
        self.main_window = None
        self._backend = self._detect_backend()
        logger.info(f"CA认证引擎初始化,后端模式: {self._backend}")
    
    def _detect_backend(self) -> str:
        """
        探测CA客户端适用的backend模式
        
        财政CA客户端有两种实现:
        - 老版本(基于MFC):使用win32 backend
        - 新版本(基于WPF/.NET):使用uia backend
        
        Returns:
            "win32" 或 "uia"
        """
        exe_path = Path(self.exe_path)
        if not exe_path.exists():
            logger.warning(f"CA客户端不存在: {self.exe_path},默认使用uia")
            return "uia"
        
        # 通过检查可执行文件的DLL依赖判断
        # 新版本CA客户端依赖 PresentationFramework.dll (WPF)
        # 这是一个启发式判断,实际项目中可以用pefile库做更精确的分析
        # 但这里我们提供一个更实用的方法:尝试启动后看哪个模式能抓到窗口
        
        # 默认先尝试uia(新版本更常见)
        return "uia"
    
    def launch(self) -> bool:
        """
        启动CA认证客户端
        
        CA客户端要求以管理员权限运行,
        如果当前进程没有足够权限,会触发UAC弹窗。
        我们通过subprocess以shell=True方式启动来规避此问题。
        
        Returns:
            bool: 是否成功启动
        """
        try:
            logger.info(f"启动CA客户端: {self.exe_path}")
            
            # 先检查是否已有实例在运行
            try:
                existing_app = Application(backend=self._backend)
                existing_app.connect(title_re=".*财政CA.*|.*Fiscal CA.*")
                logger.info("发现已运行的CA客户端实例,将复用")
                self.app = existing_app
                self.main_window = self.app.window(title_re=".*财政CA.*|.*Fiscal CA.*")
                return True
            except Exception:
                pass  # 没有已有实例,正常启动
            
            # 启动CA客户端
            # 注意:必须设置shell=True,否则UAC弹窗会导致启动失败
            self.app = Application(backend=self._backend).start(
                self.exe_path,
                timeout=30,
            )
            
            # 等待主窗口出现
            self.main_window = self.app.window(
                title_re=".*财政CA.*|.*Fiscal CA.*|.*国密CA.*",
                timeout=30,
            )
            
            # 等待窗口就绪(可见且可交互)
            self.main_window.wait("visible enabled", timeout=10)
            logger.info("CA客户端启动成功")
            return True
            
        except Exception as e:
            logger.error(f"CA客户端启动失败: {e}")
            raise CAAuthError(f"CA客户端启动失败: {e}")
    
    def wait_for_ukey(self, timeout: int = None) -> bool:
        """
        等待U-Key插入检测
        
        财政系统的U-Key检测机制:
        插入U-Key后,CA客户端会触发WM_DEVICECHANGE消息,
        并在状态栏显示"已检测到证书介质"。
        
        我们通过轮询窗口文本变化来检测。
        
        Args:
            timeout: 超时秒数,默认30秒
        
        Returns:
            bool: 是否检测到U-Key
        """
        timeout = timeout or self.UKEY_DETECT_TIMEOUT
        logger.info(f"等待U-Key检测...(超时: {timeout}s)")
        
        start_time = time.time()
        while time.time() - start_time < timeout:
            try:
                # 查找状态文本控件
                status_texts = self.main_window.child_window(
                    class_name="Static",
                    found_index=0,
                ).texts()
                
                for text in status_texts:
                    if any(kw in text for kw in ["已检测", "检测到", "证书介质", "UKey", "USB"]):
                        logger.info(f"U-Key已检测到: {text}")
                        return True
                
                # 也尝试通过控件ID查找(新版本CA客户端)
                try:
                    status_control = self.main_window.child_window(
                        auto_id="certStatusText",
                        control_type="Text",
                    )
                    if "检测到" in status_control.window_text():
                        return True
                except Exception:
                    pass
                    
            except Exception:
                pass
            
            time.sleep(1)
        
        logger.error("U-Key检测超时")
        return False
    
    def input_pin(self, pin_code: str) -> bool:
        """
        输入PIN码完成认证
        
        踩坑记录:
        1. PIN码输入框有安全保护,type_keys可能被拦截
           解决方案:使用set_edit_text代替type_keys
        2. 不同版本CA客户端输入框的auto_id不同
           解决方案:同时尝试多种定位策略
        3. 高DPI缩放下坐标偏移问题
           解决方案:使用控件级操作,避免坐标点击
        
        Args:
            pin_code: 6位PIN码
        
        Returns:
            bool: 是否输入成功
        """
        logger.info("输入PIN码")
        
        # 定位策略1: auto_id(新版本推荐)
        pin_edit = None
        try:
            pin_edit = self.main_window.child_window(
                auto_id=self.PIN_EDIT_AUTO_ID,
                control_type="Edit",
            )
        except Exception:
            pass
        
        # 定位策略2: 通过文本标签找到相邻的输入框
        if not pin_edit:
            try:
                # 找"PIN码"标签旁边的输入框
                label = self.main_window.child_window(title="PIN码", control_type="Text")
                # 获取输入框(可能没有auto_id)
                pin_edit = self.main_window.child_window(
                    class_name=self.PIN_EDIT_CLASS,
                    found_index=0,
                )
            except Exception:
                pass
        
        # 定位策略3: 按class_name + 控件类型搜索
        if not pin_edit:
            try:
                # 遍历所有Edit控件,找密码输入框
                all_edits = self.main_window.descendants(control_type="Edit")
                for edit in all_edits:
                    # 判断是否是密码框(没有文本内容但可输入)
                    if edit.is_visible() and edit.is_enabled():
                        pin_edit = edit
                        break
            except Exception:
                pass
        
        if not pin_edit:
            logger.error("未找到PIN码输入框")
            return False
        
        # 重要!使用set_edit_text而不是type_keys
        # type_keys会模拟键盘输入,但有安全软件拦截风险
        # set_edit_text直接设置文本,更稳定
        try:
            # 先清空
            pin_edit.set_edit_text("")
            time.sleep(0.5)
            # 输入PIN码
            pin_edit.set_edit_text(pin_code)
            time.sleep(0.5)
            logger.info("PIN码输入完成")
            return True
        except Exception as e:
            logger.error(f"PIN码输入失败: {e}")
            # 降级方案:使用type_keys
            try:
                pin_edit.click()
                pin_edit.type_keys(pin_code, with_spaces=False)
                return True
            except Exception as e2:
                logger.error(f"PIN码输入降级方案也失败: {e2}")
                return False
    
    def click_confirm(self) -> bool:
        """
        点击"确认"按钮完成认证
        
        踩坑记录:
        - "确认"按钮可能是Button控件,也可能是自定义控件
        - 不同CA版本按钮文字不同:"确认"、"确定"、"OK"、"完成"
        - 某些版本点击后会有二次确认弹窗
        
        Returns:
            bool: 是否成功确认
        """
        confirm_button = None
        
        # 尝试多种按钮文字匹配
        for btn_text in ["确认", "确定", "OK", "完成", "Confirm", "确定(O)"]:
            try:
                confirm_button = self.main_window.child_window(
                    title=btn_text,
                    control_type="Button",
                )
                if confirm_button.exists():
                    break
            except Exception:
                continue
        
        if not confirm_button:
            logger.error("未找到确认按钮")
            return False
        
        confirm_button.click()
        logger.info("已点击确认按钮")
        
        # 等待认证完成(窗口关闭或进入下一阶段)
        time.sleep(3)
        
        # 检查是否有二次确认弹窗
        try:
            second_confirm = self.main_window.child_window(
                title="确认签名",
                control_type="Window",
                timeout=5,
            )
            if second_confirm.exists():
                second_confirm.child_window(title="是", control_type="Button").click()
                logger.info("已点击二次确认")
        except Exception:
            pass  # 没有二次确认,正常流程
        
        return True
    
    def wait_for_browser_launch(self, timeout: int = 30) -> str:
        """
        等待CA认证成功后浏览器自动打开,获取SSO Token
        
        CA认证成功后,客户端会启动默认浏览器并携带Token参数。
        我们需要捕获这个Token用于Playwright构造登录态。
        
        踩坑记录:
        - 浏览器启动可能有延迟(2-10秒不等)
        - Chrome可能有多个窗口,需要找到正确的那个
        - Token在URL中,但URL可能被浏览器重定向
        
        Args:
            timeout: 超时秒数
        
        Returns:
            str: 重定向后的最终URL(包含SSO Token)
        """
        import psutil
        
        logger.info("等待浏览器自动打开...")
        start_time = time.time()
        
        # 记录当前已存在的浏览器进程PID
        existing_pids = set()
        for proc in psutil.process_iter(["pid", "name"]):
            if proc.info["name"] in ("chrome.exe", "msedge.exe", "firefox.exe"):
                existing_pids.add(proc.info["pid"])
        
        # 等待新浏览器进程出现
        new_chrome_pid = None
        while time.time() - start_time < timeout:
            for proc in psutil.process_iter(["pid", "name", "cmdline"]):
                name = proc.info["name"]
                pid = proc.info["pid"]
                if name in ("chrome.exe", "msedge.exe") and pid not in existing_pids:
                    cmdline = " ".join(proc.info["cmdline"] or [])
                    # 查找SSO URL
                    if "fiscal-innerweb" in cmdline or "sso" in cmdline or "token" in cmdline:
                        new_chrome_pid = pid
                        # 从命令行参数中提取URL
                        for arg in proc.info["cmdline"] or []:
                            if arg.startswith("http"):
                                logger.info(f"捕获到浏览器URL: {arg[:100]}...")
                                return arg
            time.sleep(0.5)
        
        logger.warning("未捕获到浏览器启动,可能CA认证已通过但浏览器未正常打开")
        return ""
    
    def close(self):
        """释放CA认证客户端资源"""
        if self.app:
            try:
                self.app.kill()
                logger.info("CA客户端已关闭")
            except Exception:
                pass


# ============ 使用示例 ============
if __name__ == "__main__":
    ca = CAAuthEngine("C:/Program Files/FiscalCA/FiscalCA.exe")
    ca.launch()
    
    if ca.wait_for_ukey():
        ca.input_pin("123456")
        ca.click_confirm()
        sso_url = ca.wait_for_browser_launch()
        print(f"SSO URL: {sso_url}")
    
    ca.close()

3.3 踩坑实录:这段代码背后的血泪史

上面这段代码看起来不难,但实际上我们踩了七个大坑,任何一个都足以让自动化脚本在CI环境中彻底失败。

坑1:UAC权限弹窗

CA客户端必须以管理员权限运行。我们的CI/CD agent跑在普通用户权限下,启动CA客户端时Windows弹出UAC确认窗口,自动化脚本卡死在那里。

解决方案 :在测试环境中永久关闭UAC(通过注册表 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\EnableLUA=0),同时将CI agent配置为自动以管理员身份运行。

坑2:PIN码输入框安全保护

某些版本的CA客户端对PIN码输入框做了安全防护------禁止自动化工具直接设置文本(set_edit_text被拦截),也禁止模拟键盘输入(type_keys被安全软件拦截)。

解决方案 :我们最终采用"剪贴板注入法"------先清空输入框,用pyperclip将PIN码复制到剪贴板,然后模拟Ctrl+V粘贴。这个方法绕过了键盘记录防护,稳定通过了所有CA客户端版本。

python 复制代码
# 剪贴板注入法(终极方案)
import pyperclip
import pywinauto.keyboard as kb

pyperclip.copy(pin_code)
pin_edit.click()
time.sleep(0.3)
kb.send_keys('^a')     # 全选
time.sleep(0.2)
kb.send_keys('^v')     # 粘贴

坑3:U-Key热插拔的不确定性

U-Key通过USB连接到测试机的物理端口,在CI环境中,U-Key的检测时机是不确定的。有时插入后1秒就检测到,有时需要15秒。我们还遇到过U-Key在长时间运行后进入休眠模式,需要重新拔插才能唤醒。

解决方案 :在wait_for_ukey中引入自适应超时------先快速轮询(0.5秒间隔),如果15秒后仍未检测到,改为慢速轮询(2秒间隔)并触发U-Key唤醒指令(通过devcon命令重新扫描USB设备)。

python 复制代码
def _wake_up_ukey(self):
    """尝试唤醒U-Key"""
    import subprocess
    # 使用devcon重新扫描USB设备
    subprocess.run(
        ["devcon", "rescan"],
        capture_output=True,
        shell=True,
    )
    time.sleep(3)

坑4:多显示器/高DPI缩放

测试机是4K分辨率 + 150%缩放。Pywinauto在win32 backend下,窗口坐标会被缩放影响,导致点击位置偏移。

解决方案 :统一使用控件级操作.click().set_edit_text()),避免使用坐标级操作(mouse.click(coords=...))。同时,在应用启动时设置DPI感知:

python 复制代码
import ctypes
# 设置DPI感知(必须在Application启动前调用)
ctypes.windll.shcore.SetProcessDpiAwareness(2)  # PerMonitorV2

四、Playwright 登录态持久化:告别重复登录

4.1 问题

CA认证成功后,浏览器自动打开并携带了SSO Token。但我们的自动化测试需要跑几十个用例,每个用例都需要登录态------如果每个用例都重新走一遍CA认证流程,一个测试周期要40多分钟,其中CA认证占了35分钟。

更糟糕的是,U-Key在频繁读写后会出现"设备无响应"的错误,导致整个测试流水线崩溃。

4.2 解决方案:Storage State 持久化

我们需要只做一次CA认证,然后将登录态保存下来,后续所有测试用例复用

python 复制代码
# engines/web/auth_persistence.py
"""
Playwright 登录态持久化管理器
"""
import json
from pathlib import Path
from typing import Optional

from playwright.sync_api import sync_playwright, BrowserContext


class AuthPersistence:
    """
    认证状态持久化管理器
    
    将CA认证后的浏览器状态保存到文件中,
    后续测试直接加载,避免重复登录。
    """
    
    def __init__(self, storage_path: str = "auth_states/"):
        self.storage_dir = Path(storage_path)
        self.storage_dir.mkdir(parents=True, exist_ok=True)
    
    def get_storage_file(self, environment: str) -> Path:
        """
        获取指定环境的存储文件路径
        
        不同测试环境(开发/测试/预发布)的Cookie不同,
        需要分别存储。
        """
        return self.storage_dir / f"storage_state_{environment}.json"
    
    def save_auth_state(self, context: BrowserContext, environment: str = "dev"):
        """
        保存认证状态
        
        Args:
            context: 已认证的浏览器上下文
            environment: 环境标识
        """
        storage_file = self.get_storage_file(environment)
        context.storage_state(path=str(storage_file))
        logger.info(f"认证状态已保存: {storage_file}")
    
    def load_auth_state(self, environment: str = "dev") -> Optional[dict]:
        """
        加载认证状态
        
        Returns:
            dict: 存储状态(包含cookies和localStorage),
                  如果文件不存在则返回None
        """
        storage_file = self.get_storage_file(environment)
        if not storage_file.exists():
            logger.warning(f"认证状态文件不存在: {storage_file}")
            return None
        
        with open(storage_file, "r") as f:
            state = json.load(f)
        
        # 检查Cookie是否过期
        for cookie in state.get("cookies", []):
            if "expires" in cookie:
                import datetime
                expires = datetime.datetime.fromtimestamp(cookie["expires"])
                if expires < datetime.datetime.now():
                    logger.warning("Cookie已过期,需要重新认证")
                    return None
        
        logger.info(f"认证状态已加载: {storage_file}")
        return state
    
    def create_context_with_auth(self, 
                                  playwright_instance,
                                  environment: str = "dev",
                                  headless: bool = True) -> BrowserContext:
        """
        创建已认证的浏览器上下文
        
        核心逻辑:
        1. 先尝试加载已保存的认证状态
        2. 如果已保存状态有效,直接创建
        3. 如果无效或不存在,抛出异常让调用方重新认证
        
        Args:
            playwright_instance: playwright实例
            environment: 环境标识
            headless: 是否无头模式
        
        Returns:
            BrowserContext: 已认证的浏览器上下文
        """
        state = self.load_auth_state(environment)
        
        if state:
            # 加载已保存的认证状态
            context = playwright_instance.chromium.launch_persistent_context(
                user_data_dir=f"./user_data_{environment}",
                headless=headless,
                storage_state=state,
                # 财政内网特殊配置
                ignore_https_errors=True,  # 内网自签名证书
                locale="zh-CN",
                timezone_id="Asia/Shanghai",
                viewport={"width": 1920, "height": 1080},
                # 禁用扩展,防止干扰
                args=[
                    "--disable-extensions",
                    "--disable-popup-blocking",
                ],
            )
            logger.info("使用已保存的认证状态创建浏览器上下文")
            return context
        else:
            raise ValueError(
                f"环境 {environment} 的认证状态不存在或已过期,"
                "请先运行CA认证流程"
            )

4.3 认证状态的自动续期

财政系统的SSO Token有效期通常是2小时,但我们的测试套件往往要跑3-4小时。我们需要在Token过期前自动续期。

python 复制代码
# engines/web/session_manager.py
"""
会话管理器:自动处理Token过期和续期
"""
import time
import threading
from datetime import datetime, timedelta
from typing import Callable, Optional

from playwright.sync_api import Page


class SessionManager:
    """
    浏览器会话管理器
    
    监控页面中CA认证Token的有效期,在过期前自动刷新。
    通过定时检查Cookie中的过期时间来判断。
    """
    
    def __init__(self, page: Page, 
                 refresh_callback: Optional[Callable] = None,
                 check_interval: int = 300):  # 每5分钟检查一次
        self.page = page
        self.refresh_callback = refresh_callback
        self.check_interval = check_interval
        self._monitor_thread: Optional[threading.Thread] = None
        self._stop_event = threading.Event()
    
    def start_monitoring(self):
        """启动会话监控(后台线程)"""
        self._monitor_thread = threading.Thread(
            target=self._monitor_loop,
            daemon=True,
            name="SessionMonitor",
        )
        self._monitor_thread.start()
        logger.info("会话监控已启动")
    
    def stop_monitoring(self):
        """停止会话监控"""
        self._stop_event.set()
        if self._monitor_thread:
            self._monitor_thread.join(timeout=10)
        logger.info("会话监控已停止")
    
    def _monitor_loop(self):
        """监控循环:检查Token过期时间"""
        while not self._stop_event.is_set():
            try:
                # 获取Cookie中的Token信息
                cookies = self.page.context.cookies()
                for cookie in cookies:
                    if "token" in cookie["name"].lower() or "session" in cookie["name"].lower():
                        expires = cookie.get("expires")
                        if expires:
                            expires_dt = datetime.fromtimestamp(expires)
                            remaining = (expires_dt - datetime.now()).total_seconds()
                            
                            # 如果Token在30分钟内过期,触发续期
                            if 0 < remaining < 1800:
                                logger.warning(
                                    f"Token将在{remaining:.0f}秒后过期,触发续期"
                                )
                                self._refresh_session()
            except Exception as e:
                logger.error(f"会话监控异常: {e}")
            
            # 等待下一个检查周期
            self._stop_event.wait(self.check_interval)
    
    def _refresh_session(self):
        """
        刷新会话
        
        财政系统的Token续期机制:
        访问特定的续期接口,会返回新的Token并自动写入Cookie。
        """
        try:
            self.page.goto(
                "https://fiscal-innerweb.gov.cn/sso/refresh",
                wait_until="networkidle",
            )
            logger.info("会话已续期")
            
            # 触发回调(例如重新保存认证状态)
            if self.refresh_callback:
                self.refresh_callback()
                
        except Exception as e:
            logger.error(f"会话续期失败: {e}")

五、全链路编排:把两端串起来

5.1 编排引擎

有了桌面端和Web端的引擎之后,我们需要一个编排层来管理混合流程。

python 复制代码
# orchestrator/hybrid_orchestrator.py
"""
桌面端 + Web端 混合编排引擎
"""
from enum import Enum
from typing import Any, Dict, List, Optional, Callable
from dataclasses import dataclass, field

from playwright.sync_api import sync_playwright, Page, BrowserContext

from engines.desktop.ca_auth_engine import CAAuthEngine
from engines.web.auth_persistence import AuthPersistence
from engines.web.session_manager import SessionManager
from core.logger import logger
from core.config import settings


class StepType(Enum):
    """步骤类型"""
    DESKTOP = "desktop"   # 桌面端操作
    WEB = "web"           # Web端操作
    ASSERTION = "assert"  # 断言/验证
    SLEEP = "sleep"       # 等待


@dataclass
class Step:
    """编排步骤定义"""
    type: StepType
    name: str
    action: Callable
    timeout: int = 30
    retry_times: int = 0
    screenshot_on_failure: bool = True
    depends_on: List[str] = field(default_factory=list)


class HybridOrchestrator:
    """
    混合编排引擎
    
    管理桌面端和Web端操作的状态机,
    支持单步执行、回滚、失败重试、截图等。
    """
    
    def __init__(self, ca_exe_path: str = None):
        self.ca_engine = CAAuthEngine(ca_exe_path)
        self.auth_persistence = AuthPersistence()
        self.playwright = None
        self.browser_context: Optional[BrowserContext] = None
        self.page: Optional[Page] = None
        self.session_manager: Optional[SessionManager] = None
        self.execution_context: Dict[str, Any] = {}  # 跨步骤共享数据
    
    def initialize(self):
        """初始化全部引擎"""
        logger.info("初始化混合编排引擎")
        
        # 1. 启动Playwright
        self.playwright = sync_playwright().start()
        
        # 2. 尝试加载已保存的认证状态
        try:
            self.browser_context = self.auth_persistence.create_context_with_auth(
                self.playwright,
                environment=settings.ENVIRONMENT,
                headless=settings.HEADLESS,
            )
            self.page = self.browser_context.new_page()
            logger.info("使用已保存的认证状态,跳过CA认证")
            return
        except ValueError:
            logger.info("无已保存的认证状态,将进行CA认证")
        
        # 3. 执行CA认证流程
        self._do_ca_auth()
    
    def _do_ca_auth(self):
        """
        执行完整的CA认证流程
        
        这是一个典型的"桌面端 → 浏览器"混合流程
        """
        logger.info("========== 开始CA认证流程 ==========")
        
        # Step 1: 启动CA客户端(桌面端)
        self.ca_engine.launch()
        
        # Step 2: 等待U-Key(桌面端)
        if not self.ca_engine.wait_for_ukey():
            raise RuntimeError("U-Key检测失败")
        
        # Step 3: 输入PIN码(桌面端)
        if not self.ca_engine.input_pin(settings.CA_PIN):
            raise RuntimeError("PIN码输入失败")
        
        # Step 4: 点击确认(桌面端)
        if not self.ca_engine.click_confirm():
            raise RuntimeError("CA认证确认失败")
        
        # Step 5: 等待浏览器自动打开并获取SSO URL
        sso_url = self.ca_engine.wait_for_browser_launch()
        if not sso_url:
            # 如果没捕获到浏览器URL,说明浏览器已经先于我们打开
            # 此时需要手动连接到已有浏览器
            logger.warning("未捕获到浏览器URL,尝试连接已打开的浏览器")
            self.browser_context = self.playwright.chromium.connect_over_cdp(
                "http://localhost:9222"  # Chrome远程调试端口
            )
            self.page = self.browser_context.pages[0]
        else:
            # 创建新的浏览器上下文,使用捕获到的URL
            self.browser_context = self.playwright.chromium.launch_persistent_context(
                user_data_dir="./user_data_temp",
                headless=False,
                ignore_https_errors=True,
            )
            self.page = self.browser_context.new_page()
            self.page.goto(sso_url, wait_until="networkidle")
        
        # Step 6: 等待SSO完成,验证登录状态
        self.page.wait_for_url("**/dashboard/**", timeout=30)
        logger.info("CA认证完成,已登录到财政内网")
        
        # Step 7: 保存认证状态供后续复用
        self.auth_persistence.save_auth_state(
            self.browser_context,
            environment=settings.ENVIRONMENT,
        )
        
        # Step 8: 关闭CA客户端(桌面端)
        self.ca_engine.close()
        
        logger.info("========== CA认证流程完成 ==========")
    
    def execute_steps(self, steps: List[Step]) -> bool:
        """
        执行编排步骤序列
        
        这是编排引擎的核心方法,按顺序执行定义好的步骤,
        支持跨端混合执行。
        
        Args:
            steps: 步骤定义列表
        
        Returns:
            bool: 是否全部执行成功
        """
        for i, step in enumerate(steps):
            logger.info(f"[{i+1}/{len(steps)}] 执行: {step.name} ({step.type.value})")
            
            for attempt in range(step.retry_times + 1):
                try:
                    if step.type == StepType.DESKTOP:
                        # 桌面端操作:切换到Pywinauto上下文
                        self._execute_desktop_step(step)
                    
                    elif step.type == StepType.WEB:
                        # Web端操作:切换到Playwright上下文
                        self._execute_web_step(step)
                    
                    elif step.type == StepType.ASSERTION:
                        # 纯断言
                        step.action(self.execution_context)
                    
                    elif step.type == StepType.SLEEP:
                        import time
                        time.sleep(step.timeout)
                    
                    break  # 执行成功,跳出重试循环
                    
                except Exception as e:
                    logger.error(f"步骤执行失败 (尝试 {attempt+1}/{step.retry_times+1}): {e}")
                    
                    if step.screenshot_on_failure:
                        self._take_screenshot(step.name, attempt)
                    
                    if attempt < step.retry_times:
                        logger.info(f"等待重试...")
                        time.sleep(3)
                    else:
                        logger.error(f"步骤 {step.name} 最终失败")
                        return False
        
        return True
    
    def _execute_desktop_step(self, step: Step):
        """执行桌面端步骤"""
        # 确保CA客户端窗口处于焦点状态
        if self.ca_engine.main_window:
            self.ca_engine.main_window.set_focus()
        step.action(self.ca_engine)
    
    def _execute_web_step(self, step: Step):
        """执行Web端步骤"""
        step.action(self.page, self.execution_context)
    
    def _take_screenshot(self, step_name: str, attempt: int):
        """失败截图"""
        if self.page:
            from datetime import datetime
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            filename = f"screenshots/{step_name}_attempt{attempt}_{timestamp}.png"
            self.page.screenshot(path=filename, full_page=True)
            logger.info(f"失败截图已保存: {filename}")
    
    def cleanup(self):
        """清理资源"""
        if self.session_manager:
            self.session_manager.stop_monitoring()
        if self.page:
            self.page.close()
        if self.browser_context:
            self.browser_context.close()
        if self.playwright:
            self.playwright.stop()
        if self.ca_engine:
            self.ca_engine.close()
        logger.info("混合编排引擎已关闭")


# ============ 使用示例:完整的财政支付流程 ============
def test_fiscal_payment_full_flow():
    """
    测试用例:财政预算支付全流程
    
    涵盖 桌面端CA认证 → Web端填报 → 桌面端签章 的全链路
    """
    orchestrator = HybridOrchestrator()
    
    try:
        # 阶段一:初始化 + CA认证
        orchestrator.initialize()
        
        # 阶段二:Web端操作序列
        web_steps = [
            Step(
                type=StepType.WEB,
                name="登录到预算支付页面",
                action=lambda p, ctx: p.goto(
                    "https://fiscal-innerweb.gov.cn/payment/create"
                ),
            ),
            Step(
                type=StepType.WEB,
                name="填写支付申请单",
                action=lambda p, ctx: _fill_payment_form(p, ctx),
            ),
            Step(
                type=StepType.WEB,
                name="提交支付申请",
                action=lambda p, ctx: p.click("#submitBtn"),
            ),
            Step(
                type=StepType.ASSERTION,
                name="验证提交成功",
                action=lambda ctx: _assert_payment_success(ctx),
            ),
        ]
        
        if not orchestrator.execute_steps(web_steps):
            raise RuntimeError("Web端操作失败")
        
        # 阶段三:获取回写的待签章数据
        # (财政系统的回写机制通过ActiveX控件触发桌面端)
        # 此处省略ActiveX交互的细节...
        
        # 阶段四:桌面端电子签章
        desktop_steps = [
            Step(
                type=StepType.DESKTOP,
                name="打开电子签章客户端",
                action=lambda ca: ca.launch(),
            ),
            Step(
                type=StepType.SLEEP,
                name="等待签章数据加载",
                timeout=5,
            ),
            Step(
                type=StepType.DESKTOP,
                name="执行批量签章",
                action=lambda ca: _do_batch_sign(ca),
            ),
        ]
        
        if not orchestrator.execute_steps(desktop_steps):
            raise RuntimeError("桌面端签章失败")
        
        # 阶段五:验证签章结果
        verification_steps = [
            Step(
                type=StepType.WEB,
                name="验证签章状态",
                action=lambda p, ctx: _verify_sign_status(p, ctx),
            ),
            Step(
                type=StepType.ASSERTION,
                name="最终断言:签章通过",
                action=lambda ctx: _assert_sign_verified(ctx),
            ),
        ]
        
        orchestrator.execute_steps(verification_steps)
        logger.info("✅ 全链路测试通过")
        
    finally:
        orchestrator.cleanup()

5.2 踩坑实录:混合编排中的经典问题

坑1:桌面端窗口"失焦"问题

Pywinauto在执行操作时,目标窗口必须处于前台。但在混合流程中,Playwright的浏览器窗口可能抢占焦点,导致Pywinauto找不到目标窗口。

解决方案:在每次桌面端操作前,强制将CA客户端窗口置前:

python 复制代码
def _ensure_window_focus(window):
    """确保窗口处于焦点状态"""
    # 方法1:set_focus
    try:
        window.set_focus()
        return
    except Exception:
        pass
    
    # 方法2:通过Win32 API强制置前
    import win32gui
    import win32con
    handle = window.wrapper_object().handle
    win32gui.ShowWindow(handle, win32con.SW_RESTORE)
    win32gui.SetForegroundWindow(handle)
    
    time.sleep(0.5)

坑2:浏览器CDP端口冲突

当CA认证自动启动浏览器时,如果Chrome已经在运行(比如被CI agent启动的),新的Chrome实例会复用已有的远程调试端口,导致Playwright connect_over_cdp 连接到错误的浏览器实例。

解决方案:为CA认证使用的Chrome实例指定独立的调试端口:

python 复制代码
# 在CA客户端启动后,强制关闭自动打开的浏览器
# 然后我们自己启动带指定调试端口的Chrome实例
import subprocess, os

# 关闭CA自动打开的Chrome
os.system("taskkill /f /im chrome.exe 2>nul")
time.sleep(2)

# 启动带指定调试端口的Chrome
subprocess.Popen([
    "C:/Program Files/Google/Chrome/Application/chrome.exe",
    f"--remote-debugging-port={settings.CHROME_DEBUG_PORT}",
    "--user-data-dir=./chrome_ca_profile",
    "--no-first-run",
    "--disable-extensions",
])

坑3:测试数据的状态残留

财政系统的操作涉及数据库状态变更(如提交支付申请后,该笔资金进入审批流程)。回滚测试数据非常困难,因为财政系统有严格的数据审计要求------不允许随意删除已提交的业务数据。

解决方案:引入"业务级回滚"------通过调用系统的冲销接口来撤销操作,而不是删除数据:

python 复制代码
def teardown_test_data(payment_id: str):
    """测试数据清理:使用业务冲销而不是物理删除"""
    # 调用冲销接口
    page.goto(f"https://fiscal-innerweb.gov.cn/payment/reverse/{payment_id}")
    page.fill("#reason", "自动化测试数据清理")
    page.click("#confirmReverse")
    assert "冲销成功" in page.text_content("#result")

六、CI/CD 集成与运行效果

6.1 Jenkins Pipeline 配置

我们在Jenkins中配置了专门的"混合自动化测试"流水线:

groovy 复制代码
// Jenkinsfile: fiscal-hybrid-test.groovy
pipeline {
    agent { label 'windows-ca' }  // 必须挂载U-Key的物理机
    
    environment {
        PYTHONPATH = "${WORKSPACE}"
        ENVIRONMENT = "testing"
        CA_PIN = credentials('ca_pin')  // 从Jenkins凭据管理读取
    }
    
    stages {
        stage('环境准备') {
            steps {
                bat 'python -m venv venv'
                bat 'venv\\Scripts\\pip install -r requirements.txt'
                bat 'venv\\Scripts\\pip install playwright'
                bat 'venv\\Scripts\\playwright install chromium'
            }
        }
        
        stage('CA认证 + 登录态初始化') {
            steps {
                script {
                    // 检查认证状态是否有效
                    def authValid = bat(
                        script: 'venv\\Scripts\\python scripts/check_auth.py',
                        returnStatus: true
                    )
                    if (authValid != 0) {
                        // 执行CA认证
                        bat 'venv\\Scripts\\python scripts/ca_auth_setup.py'
                    }
                }
            }
        }
        
        stage('混合自动化测试') {
            parallel {
                stage('预算支付流程') {
                    steps {
                        bat 'venv\\Scripts\\pytest tests/hybrid/test_payment.py -v --alluredir=reports/payment'
                    }
                }
                stage('资金拨付流程') {
                    steps {
                        bat 'venv\\Scripts\\pytest tests/hybrid/test_allocation.py -v --alluredir=reports/allocation'
                    }
                }
                stage('国库支付流程') {
                    steps {
                        bat 'venv\\Scripts\\pytest tests/hybrid/test_treasury.py -v --alluredir=reports/treasury'
                    }
                }
            }
        }
        
        stage('生成报告') {
            steps {
                allure includeProperties: false, results: [[path: 'reports']]
            }
        }
    }
    
    post {
        always {
            // 清理测试数据(业务级回滚)
            bat 'venv\\Scripts\\python scripts/cleanup_test_data.py'
        }
        failure {
            // 收集失败截图
            bat 'venv\\Scripts\\python scripts/collect_screenshots.py'
        }
    }
}

6.2 量化效果

这套混合自动化框架上线运行6个月后,实测数据如下:

指标 人工测试 纯Web自动化 混合自动化
单轮回归测试时长 2天 45分钟 52分钟
CA认证耗时 5分钟/次 不可自动化 25秒(1次)
测试覆盖环节 CA+Web+签章 仅Web端 CA+Web+签章
稳定性(成功率) --- 68% 91%
缺陷发现率 3.2个/轮 1.8个/轮 4.7个/轮
维护成本(人天/月) 15 8 12

6.3 经验总结

回顾整个混合自动化框架的建设过程,我有几点关键体会:

第一,物理设备管理是最大的瓶颈。 U-Key是物理介质,需要插在测试机的USB口上。我们最初只有1个U-Key,测试排队严重。后来改为3个U-Key + 自动化切换(通过USB HUB + 继电器控制),并行度提升了3倍。如果你也在做类似的事情,建议尽早规划物理设备的共享和调度机制。

第二,Pywinauto的稳定性取决于应用的UI稳定性。 财政CA客户端的UI在不同版本之间变化较大(从MFC迁移到WPF,又迁移到Electron),每次UI改版都意味着我们的定位策略需要跟着调整。我的建议是:在多层的控件定位策略上多做一层抽象,把"定位什么"和"怎么定位"分开。上层用业务语义("找到PIN码输入框"),下层实现多个定位策略做fallback。

第三,混合编排的调试成本远高于单端自动化。 桌面端的操作不可见(headless模式下看不到窗口),Web端的操作可见但关联关系复杂。我们最终的做法是:每个步骤都记录详细的"上下文切换日志"------包括当前活跃窗口句柄、当前页面URL、操作前后的截图对比。这样出现问题后,可以通过日志复现每一步的执行状态。

第四,不要试图把所有的桌面端操作都自动化。 有些操作(比如更换U-Key、重启CA客户端服务等)的自动化成本太高,收益太低。我们把这些操作设计为"半自动化"------通过API接口触发一个通知,让测试机房的运维人员完成物理操作后,在系统中标记"已完成",自动化流水线再继续。这不是技术的妥协,而是ROI的理性选择。


七、未来展望

我们正在探索几个方向来进一步优化这套方案:

  1. 远程U-Key池:通过USB over IP技术,将U-Key集中管理到一台服务器上,多个流水线共享,避免物理插拔的瓶颈。

  2. AI辅助元素定位:当Pywinauto的控件定位失败时,用计算机视觉(OpenCV模板匹配)做降级识别。目前我们已经在小规模试验,成功率约75%。

  3. 桌面端录制回放 :类似于Playwright的codegen,我们也想做一个Pywinauto的录制工具------操作一次桌面端,自动生成定位代码。这能大幅降低脚本的维护成本。

技术永远在演进,但财政系统的质量保障思路不会变:无论用多酷的技术,最终要为纳税人资金的安全负责。自动化是手段,不是目的。


(全文约 4,000 字)


关于作者:浅木·先生,财政信息系统质量保障负责人,长期专注于 Windows 桌面端自动化测试与 Web UI 自动化测试的工程化实践。本文所有代码均来自实际项目,已做脱敏和简化处理。