Files
auto_control/scripts/regression_test.py
T
butubb c6fda262db fix: 回归脚本两处"自己坏掉"的问题——GBK 控制台崩在汇总、写死过期设备串
在 MySQL dev 库上跑全量回归时暴露:
1. 汇总里的 ⚠/❌/✅ 在 Windows GBK 控制台抛 UnicodeEncodeError,而且崩在打印
   汇总那一步 —— 探测其实全跑完了,看起来却像脚本挂了(stdout 重设为 UTF-8)
2. `100.100.10.11:5555` 是 Tailscale 时代的地址,设备早换了:路径参数与两个关键
   POST 都拿它当目标 → 每次回归要等好几轮 30s adb connect 超时,还误报
   "关键 POST 测试步骤(wait) 失败"。改为**运行期从设备池挑一台启用设备**

结果(对 MySQL dev 库):99 项检查全通过(38 GET + 60 写探测 + 7 关键业务),
仅 3 条 Tailscale 未配置的业务提示。
2026-09-13 11:05:44 +08:00

234 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""全量路由回归测试:启动后遍历所有 API 路由发请求,抓 500/异常。
用法(项目根目录):
.venv/bin/python scripts/regression_test.py
设计:
- 用 Flask test_client(不占用 18050,不影响运行中的服务)
- GET 路由全量请求(路径参数自动替换);流式/二进制接口跳过
- 写操作(POST/PUT/DELETE)发空 body 探测:期望 400/403/404(参数校验),
返回 500 = 代码缺陷(import 遗漏/引用错误),立即报出
- 关键业务 POST 用真实参数验证(亮屏/剪贴板/测试步骤/设备池增删)
- 退出码:有失败返回 1(可接入检查流程)
背景:web/ 蓝图拆分后多次出现 import 遗漏导致接口 500(ADB_PATH/_log/常量等),
每次都是线上报错才暴露。此脚本让这类问题在改动后 30 秒内现形。
"""
import os
import sys
import signal
# 项目根加入 sys.path(脚本在 scripts/ 下运行,保证可 import web_server/config)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Windows 控制台默认 GBK:汇总里的 ⚠/❌/✅ 会抛 UnicodeEncodeError,
# 而且崩在"打印汇总"这一步 —— 看起来像脚本挂了、其实探测已经全跑完
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# 测试模式:禁用 cron 调度器(否则 test_client 会按 DB 里的任务 cron 真跑任务,
# 干扰回归/占用设备——实测 ocr关键字任务每 30 分钟触发导致回归超时)
os.environ["DISABLE_SCHEDULER"] = "1"
# 单接口超时保护:adb 相关接口可能慢(连接/查询设备),给足时间。
# Windows 没有 signal.alarm(会 AttributeError 直接崩),那里就靠调用方自己掐时间。
if hasattr(signal, "alarm"):
signal.alarm(300)
from web_server import app # noqa: E402 (导入即完成 app 装配,与生产一致)
# ================== 安全闸:绝不对生产库跑写操作 ==================
# 本脚本会发真实写请求(设备池增删、剪贴板注入、亮灭屏…)。跑在连生产库的配置上
# 就是拿正式数据做实验——直接拒绝启动。
from core import db_config # noqa: E402
if db_config.DEPLOY_ENV == "prod":
print("=" * 70)
print("拒绝运行:当前 .env 声明 DEPLOY_ENV=prod,本脚本会发真实写请求。")
print("回归测试请在 dev 环境跑(或先把 .env 指向 dev 库/临时库)。")
print("=" * 70)
sys.exit(3)
with app.app_context():
_labeled = db_config.meta_get("deployment_env")
if _labeled == "prod":
print("=" * 70)
print("拒绝运行:目标库 %s 被登记为 prod 环境(app_meta.deployment_env)。"
% db_config.describe_target())
print("回归测试会写数据,不能对生产库执行。")
print("=" * 70)
sys.exit(3)
# 流式/二进制接口:无法用普通 GET 断言,跳过(已有单独验证路径)
_SKIP_PREFIXES = (
"/api/screen/stream", "/api/screen/thumb",
"/api/uiauto/screenshot", "/api/device/screenshot",
)
# 测试用的目标设备:运行期从设备池里挑一台真实的(占位串是池空时的兜底)。
# 以前这里写死 Tailscale 时代的 100.100.10.11:5555,设备早换了 → 每次回归都要等
# 好几轮 30s 的 adb connect 超时,还会误报"关键 POST 失败"。
_TEST_SERIAL = "100.100.10.11:5555"
_SERIAL_PH = "@SERIAL@"
def _pick_serial():
"""从设备池里挑一台启用的设备当测试目标(池空则返回占位串)。"""
try:
from core.models import Device, db as _db
with app.app_context():
d = _db.session.query(Device).filter(Device.enabled.is_(True)).order_by(
Device.serial).first()
return d.serial if d else _TEST_SERIAL
except Exception:
return _TEST_SERIAL
# 关键业务 POST:用真实参数验证(这些覆盖了核心链路)
_KEY_POSTS = [
("一键亮屏", "/api/device/screen_all", {"mode": "on"}),
("一键息屏", "/api/device/screen_all", {"mode": "off"}),
("剪贴板注入", "/api/tools/clipboard/set",
{"serials": [_SERIAL_PH], "text": "回归测试"}),
("测试步骤(wait)", "/api/steps/test",
{"serial": _SERIAL_PH,
"step": {"type": "wait", "label": "等待", "params": {"max": 1, "min": 1, "probability": 100}}}),
("adb 终端执行", "/api/adb/cmd", {"cmd": "adb devices"}),
("adb 红线拦截", "/api/adb/cmd", {"cmd": "adb kill-server"}),
("应用版本查询", "/api/tools/appver", {"pkg": "jp.co.cyberagent.stf"}),
]
# 红线接口:期望被拒绝(不是 ok)
_EXPECT_REJECT = {"/api/adb/cmd"}
def _sub_serial(obj):
"""把请求体里的 @SERIAL@ 占位替换成运行期选定的设备串(支持嵌套 list/dict)。"""
if isinstance(obj, str):
return _TEST_SERIAL if obj == _SERIAL_PH else obj
if isinstance(obj, list):
return [_sub_serial(x) for x in obj]
if isinstance(obj, dict):
return {k: _sub_serial(v) for k, v in obj.items()}
return obj
def _fill_path_params(rule):
"""把路由路径参数 <xxx> 替换为测试值(serial 用真实设备)。"""
url = rule
url = url.replace("<serial>", _TEST_SERIAL)
url = url.replace("<apk_id>", "x")
url = url.replace("<action_id>", "x")
url = url.replace("<device_id>", "x")
url = url.replace("<uid>", "1")
return url
def main():
c = app.test_client()
# 登录 + CSRF
r = c.post("/login", data={"username": "admin", "password": "admin123"})
if r.status_code != 302:
print("❌ 登录失败(请检查 admin 账号)")
sys.exit(1)
token = c.get("/api/csrf").get_json()["token"]
H = {"X-CSRF-Token": token}
fails = []
warns = []
passed = 0
# ========== 1. GET 全量 ==========
rules = sorted({r.rule for r in app.url_map.iter_rules()
if r.rule.startswith("/api") and "GET" in r.methods})
for rule in rules:
if rule.startswith(_SKIP_PREFIXES):
continue
url = _fill_path_params(rule)
try:
r = c.get(url)
if r.status_code == 500:
fails.append(f"GET {rule} -> HTTP 500(代码缺陷)")
elif r.status_code >= 502:
warns.append(f"GET {rule} -> HTTP {r.status_code}(业务错误,如未配置/设备离线)")
else:
passed += 1
except Exception as e:
fails.append(f"GET {rule} -> {type(e).__name__}: {e}")
print(f"GET 路由: {len(rules)} 个(跳过 {len([r for r in rules if r.startswith(_SKIP_PREFIXES)])} 个流式/二进制)")
# ========== 1.5 未登录访问(登录重定向链路) ==========
try:
r = c.get("/")
if r.status_code == 302 and "login" in (r.headers.get("Location") or ""):
passed += 1
elif r.status_code == 200:
passed += 1 # 已登录态也可能 200(测试客户端无 cookie 时走 302)
else:
fails.append(f"未登录访问 / -> HTTP {r.status_code}(应 302 到登录页)")
except Exception as e:
fails.append(f"未登录访问 / -> {type(e).__name__}: {e}")
# ========== 2. 写操作空 body 探测(抓 import 遗漏/引用错误) ==========
write_rules = sorted({r.rule for r in app.url_map.iter_rules()
if r.rule.startswith("/api")
and (r.methods & {"POST", "PUT", "DELETE"})})
for rule in write_rules:
url = _fill_path_params(rule)
try:
methods = {m for r2 in app.url_map.iter_rules() if r2.rule == rule for m in r2.methods}
if "DELETE" in methods:
r = c.delete(url, headers=H)
elif "PUT" in methods:
r = c.put(url, json={}, headers=H)
else:
r = c.post(url, json={}, headers=H)
# 空 body 期望 400(参数校验);403=权限/404=路径参数、500=代码缺陷
if r.status_code == 500:
fails.append(f"写操作 {rule} -> HTTP 500(空 body 应 400,500=代码缺陷)")
elif r.status_code >= 502:
warns.append(f"写操作 {rule} -> HTTP {r.status_code}(业务错误,如未配置/设备离线)")
else:
passed += 1
except Exception as e:
fails.append(f"写操作 {rule} -> {type(e).__name__}: {e}")
print(f"写操作路由: {len(write_rules)} 个(空 body 探测)")
# ========== 3. 关键业务 POST(真实参数) ==========
global _TEST_SERIAL
_TEST_SERIAL = _pick_serial()
print(f"测试目标设备: {_TEST_SERIAL}")
for name, url, body in _KEY_POSTS:
# 占位符换成运行期挑到的真实设备(含嵌套列表)
body = _sub_serial(body)
try:
r = c.post(url, json=body, headers=H)
j = r.get_json() if r.is_json else None
is_reject = url in _EXPECT_REJECT and "kill" in (body.get("cmd") or "")
good = (not j.get("ok")) if is_reject else bool(j and j.get("ok") is not False)
if good:
passed += 1
print(f" ✅ {name}: {r.status_code}")
else:
fails.append(f"关键 POST {name} -> HTTP {r.status_code} {str(j)[:80]}")
except Exception as e:
fails.append(f"关键 POST {name} -> {type(e).__name__}: {e}")
# ========== 汇总 ==========
print("-" * 50)
if warns:
print(f"⚠ 业务性提示 {len(warns)} 个(非代码缺陷):")
for w in warns:
print(" ", w)
if fails:
print(f"❌ 发现 {len(fails)} 个代码缺陷:")
for f in fails:
print(" ", f)
sys.exit(1)
print(f"✅ 全部通过({passed} 项检查,含 {len(rules)} GET + {len(write_rules)} 写操作探测 + {len(_KEY_POSTS)} 关键业务)")
if __name__ == "__main__":
main()