桌面端与 Web 端混合自动化:Pywinauto + Playwright 全链路编排
署名:浅木·先生
日期:2026-06-16
一、背景:为什么需要混合自动化?
如果你做过财政系统的自动化测试,你一定会遇到这样一个场景:业务操作需要"桌面端 + Web端"两条腿走路。
以我们正在维护的某省财政预算执行系统为例,完整的业务流程是这样的:
- 用户通过CA证书客户端(桌面端应用)插入U-Key,输入PIN码完成身份认证
- CA认证通过后,自动打开浏览器跳转到财政内网的Web门户
- 用户在Web系统中填报预算支付申请
- 将申请数据回写到本地桌面客户端中的加密存储模块
- 最后通过桌面客户端调用电子签章控件进行数字签名
整个流程中,桌面端和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 的优势在于:
- 纯Python生态,与Playwright天然同语言
- 同时支持win32和UIA两种backend
- 社区活跃,Bug修复及时
- 支持
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认证流程大概是这样的:
- 用户打开桌面端**"财政CA认证客户端"**(我们叫它"FiscalCA.exe")
- 插入U-Key后,客户端检测到证书,弹出PIN码输入框
- 用户输入6位PIN码,点击"确认"
- 客户端调用USB-Key中的私钥完成签名认证
- 认证成功后,客户端启动默认浏览器,并打开
https://fiscal-innerweb.gov.cn/sso/login?token=xxxx - 浏览器自动识别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的理性选择。
七、未来展望
我们正在探索几个方向来进一步优化这套方案:
-
远程U-Key池:通过USB over IP技术,将U-Key集中管理到一台服务器上,多个流水线共享,避免物理插拔的瓶颈。
-
AI辅助元素定位:当Pywinauto的控件定位失败时,用计算机视觉(OpenCV模板匹配)做降级识别。目前我们已经在小规模试验,成功率约75%。
-
桌面端录制回放 :类似于Playwright的
codegen,我们也想做一个Pywinauto的录制工具------操作一次桌面端,自动生成定位代码。这能大幅降低脚本的维护成本。
技术永远在演进,但财政系统的质量保障思路不会变:无论用多酷的技术,最终要为纳税人资金的安全负责。自动化是手段,不是目的。
(全文约 4,000 字)
关于作者:浅木·先生,财政信息系统质量保障负责人,长期专注于 Windows 桌面端自动化测试与 Web UI 自动化测试的工程化实践。本文所有代码均来自实际项目,已做脱敏和简化处理。