Skip to content

Commit 4d14854

Browse files
committed
v5.0-B 手机测试API端点(12个)+CLI mobile命令+json导入修复
1 parent 820a527 commit 4d14854

2 files changed

Lines changed: 332 additions & 0 deletions

File tree

cli.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,124 @@ def cmd_init(args: argparse.Namespace) -> int:
238238
return 0
239239

240240

241+
def cmd_mobile(args: argparse.Namespace) -> int:
242+
"""手机测试相关命令。"""
243+
import urllib.request
244+
import urllib.error
245+
246+
base = f"http://127.0.0.1:{args.port}/api/v1"
247+
sub = getattr(args, "mobile_command", None)
248+
249+
if not sub:
250+
print("请指定子命令: devices | appium | connect | screenshot | sessions")
251+
print(" python cli.py mobile devices 列出已连接设备")
252+
print(" python cli.py mobile appium 检查Appium状态")
253+
print(" python cli.py mobile connect 连接设备")
254+
print(" python cli.py mobile sessions 列出活跃会话")
255+
return 0
256+
257+
if sub == "devices":
258+
try:
259+
req = urllib.request.Request(f"{base}/mobile/devices")
260+
with urllib.request.urlopen(req, timeout=10) as resp:
261+
data = json.loads(resp.read().decode())
262+
devices = data.get("devices", [])
263+
if not devices:
264+
print("📱 未检测到已连接的Android设备")
265+
if data.get("error"):
266+
print(f" ⚠️ {data['error']}")
267+
print(" 请确保: USB调试已开启 + 设备已连接 + adb已安装")
268+
else:
269+
print(f"📱 检测到 {len(devices)} 台设备:")
270+
for d in devices:
271+
model = d.get("model", "未知型号")
272+
print(f" • {d['serial']} ({model})")
273+
return 0
274+
except urllib.error.URLError:
275+
print(f"❌ 无法连接引擎,请先运行: python cli.py serve")
276+
return 2
277+
278+
elif sub == "appium":
279+
try:
280+
req = urllib.request.Request(f"{base}/mobile/appium/status")
281+
with urllib.request.urlopen(req, timeout=5) as resp:
282+
data = json.loads(resp.read().decode())
283+
if data.get("running"):
284+
print("✅ Appium Server 运行中")
285+
else:
286+
print("❌ Appium Server 未运行")
287+
print(" 请执行: npm install -g appium && appium")
288+
return 0
289+
except urllib.error.URLError:
290+
print(f"❌ 无法连接引擎,请先运行: python cli.py serve")
291+
return 2
292+
293+
elif sub == "connect":
294+
payload = {
295+
"device_name": args.device,
296+
"app_package": args.package,
297+
"app_activity": args.activity,
298+
"app_path": args.apk,
299+
}
300+
try:
301+
req_data = json.dumps(payload).encode()
302+
req = urllib.request.Request(
303+
f"{base}/mobile/session/create",
304+
data=req_data,
305+
headers={"Content-Type": "application/json"},
306+
method="POST",
307+
)
308+
with urllib.request.urlopen(req, timeout=30) as resp:
309+
data = json.loads(resp.read().decode())
310+
print(f"✅ {data.get('message', '连接成功')}")
311+
print(f" 会话ID: {data.get('session_id')}")
312+
device = data.get("device", {})
313+
print(f" 设备: {device.get('name', '未知')}")
314+
return 0
315+
except urllib.error.HTTPError as e:
316+
body = e.read().decode() if e.fp else ""
317+
print(f"❌ 连接失败: {body}")
318+
return 1
319+
except urllib.error.URLError:
320+
print(f"❌ 无法连接引擎,请先运行: python cli.py serve")
321+
return 2
322+
323+
elif sub == "screenshot":
324+
session_id = args.session
325+
try:
326+
req = urllib.request.Request(f"{base}/mobile/session/{session_id}/screenshot")
327+
with urllib.request.urlopen(req, timeout=15) as resp:
328+
data = json.loads(resp.read().decode())
329+
print(f"📸 截图已保存: {data.get('path', '未知')}")
330+
return 0
331+
except urllib.error.HTTPError as e:
332+
print(f"❌ 截图失败: {e.read().decode() if e.fp else e}")
333+
return 1
334+
except urllib.error.URLError:
335+
print(f"❌ 无法连接引擎")
336+
return 2
337+
338+
elif sub == "sessions":
339+
try:
340+
req = urllib.request.Request(f"{base}/mobile/sessions")
341+
with urllib.request.urlopen(req, timeout=5) as resp:
342+
data = json.loads(resp.read().decode())
343+
sessions = data.get("sessions", [])
344+
if not sessions:
345+
print("📱 没有活跃的手机测试会话")
346+
else:
347+
print(f"📱 {len(sessions)} 个活跃会话:")
348+
for s in sessions:
349+
device = s.get("device", {})
350+
print(f" • {s['session_id']}{device.get('name', '未知')}")
351+
return 0
352+
except urllib.error.URLError:
353+
print(f"❌ 无法连接引擎")
354+
return 2
355+
356+
return 0
357+
358+
241359
def _print_report(report: dict, duration: float) -> None:
242360
"""在终端打印测试报告。"""
243361
pass_rate = report.get("pass_rate", 0)
@@ -430,6 +548,24 @@ def main() -> int:
430548
# health
431549
subparsers.add_parser("health", help="检查引擎健康状态")
432550

551+
# mobile (手机测试 v5.0)
552+
p_mobile = subparsers.add_parser("mobile", help="手机测试相关命令")
553+
mobile_sub = p_mobile.add_subparsers(dest="mobile_command", help="手机测试子命令")
554+
555+
mobile_sub.add_parser("devices", help="列出已连接的手机设备")
556+
mobile_sub.add_parser("appium", help="检查 Appium Server 状态")
557+
558+
p_mobile_connect = mobile_sub.add_parser("connect", help="连接手机设备并创建测试会话")
559+
p_mobile_connect.add_argument("--device", default="", help="设备名称")
560+
p_mobile_connect.add_argument("--package", default="", help="Android 包名")
561+
p_mobile_connect.add_argument("--activity", default="", help="启动 Activity")
562+
p_mobile_connect.add_argument("--apk", default="", help="APK 文件路径")
563+
564+
p_mobile_screenshot = mobile_sub.add_parser("screenshot", help="截取手机屏幕")
565+
p_mobile_screenshot.add_argument("--session", required=True, help="会话ID")
566+
567+
mobile_sub.add_parser("sessions", help="列出活跃的手机测试会话")
568+
433569
args = parser.parse_args()
434570

435571
if not args.command:
@@ -442,6 +578,7 @@ def main() -> int:
442578
"explore": cmd_explore,
443579
"init": cmd_init,
444580
"health": cmd_health,
581+
"mobile": cmd_mobile,
445582
}
446583

447584
handler = commands.get(args.command)

src/api/routes.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
- /browser/*: 浏览器操作(启动/导航/点击/输入/截图/关闭)
88
"""
99

10+
import json
11+
1012
from fastapi import APIRouter, HTTPException
1113
from loguru import logger
1214

@@ -607,4 +609,197 @@ async def send_webhook(req: dict) -> dict:
607609
success = handler(webhook_url, report)
608610
return {"success": success, "type": notify_type}
609611

612+
# ── 手机测试(v5.0)─────────────────────────────
613+
614+
# 设备会话管理(内存中缓存)
615+
_mobile_sessions: dict[str, "AndroidController"] = {}
616+
617+
@router.get("/mobile/devices", tags=["手机测试"])
618+
async def list_mobile_devices() -> dict:
619+
"""列出已连接的 Android 设备(通过 adb devices)。"""
620+
import subprocess
621+
try:
622+
result = subprocess.run(
623+
["adb", "devices", "-l"],
624+
capture_output=True, text=True, timeout=5,
625+
)
626+
lines = result.stdout.strip().split("\n")[1:] # 跳过header
627+
devices = []
628+
for line in lines:
629+
parts = line.split()
630+
if len(parts) >= 2 and parts[1] == "device":
631+
info = {"serial": parts[0], "status": "connected"}
632+
# 解析 model/device 等额外信息
633+
for part in parts[2:]:
634+
if ":" in part:
635+
k, v = part.split(":", 1)
636+
info[k] = v
637+
devices.append(info)
638+
return {"devices": devices, "count": len(devices)}
639+
except FileNotFoundError:
640+
return {"devices": [], "count": 0, "error": "adb 未安装或不在PATH中"}
641+
except Exception as e:
642+
return {"devices": [], "count": 0, "error": str(e)}
643+
644+
@router.get("/mobile/appium/status", tags=["手机测试"])
645+
async def appium_status() -> dict:
646+
"""检查 Appium Server 是否运行。"""
647+
import urllib.request
648+
import urllib.error
649+
650+
appium_url = "http://127.0.0.1:4723/status"
651+
try:
652+
req = urllib.request.Request(appium_url)
653+
with urllib.request.urlopen(req, timeout=3) as resp:
654+
data = json.loads(resp.read().decode())
655+
return {"running": True, "data": data}
656+
except (urllib.error.URLError, Exception):
657+
return {"running": False, "message": "Appium Server 未运行。请执行: appium"}
658+
659+
@router.post("/mobile/session/create", tags=["手机测试"])
660+
async def create_mobile_session(req: dict) -> dict:
661+
"""创建手机测试会话(连接设备)。
662+
663+
Body:
664+
device_name: 设备名称(可选)
665+
app_package: 应用包名(可选)
666+
app_activity: 启动Activity(可选)
667+
app_path: APK路径(可选,自动安装)
668+
"""
669+
from src.controller.android import AndroidController, MobileConfig
670+
671+
config = MobileConfig(
672+
device_name=req.get("device_name", ""),
673+
app_package=req.get("app_package", ""),
674+
app_activity=req.get("app_activity", ""),
675+
app_path=req.get("app_path", ""),
676+
)
677+
678+
controller = AndroidController(config)
679+
try:
680+
await controller.launch()
681+
session_id = f"mobile_{len(_mobile_sessions) + 1}"
682+
_mobile_sessions[session_id] = controller
683+
return {
684+
"session_id": session_id,
685+
"device": controller.device_info.model_dump(),
686+
"message": "手机会话创建成功",
687+
}
688+
except Exception as e:
689+
raise HTTPException(status_code=500, detail=f"连接设备失败: {e}")
690+
691+
@router.post("/mobile/session/{session_id}/tap", tags=["手机测试"])
692+
async def mobile_tap(session_id: str, req: dict) -> dict:
693+
"""点击手机元素。"""
694+
ctrl = _mobile_sessions.get(session_id)
695+
if not ctrl:
696+
raise HTTPException(status_code=404, detail="会话不存在")
697+
try:
698+
await ctrl.tap(req.get("selector", ""))
699+
return {"success": True}
700+
except Exception as e:
701+
raise HTTPException(status_code=400, detail=str(e))
702+
703+
@router.post("/mobile/session/{session_id}/input", tags=["手机测试"])
704+
async def mobile_input(session_id: str, req: dict) -> dict:
705+
"""在手机输入框中输入文本。"""
706+
ctrl = _mobile_sessions.get(session_id)
707+
if not ctrl:
708+
raise HTTPException(status_code=404, detail="会话不存在")
709+
try:
710+
await ctrl.input_text(req.get("selector", ""), req.get("text", ""))
711+
return {"success": True}
712+
except Exception as e:
713+
raise HTTPException(status_code=400, detail=str(e))
714+
715+
@router.post("/mobile/session/{session_id}/swipe", tags=["手机测试"])
716+
async def mobile_swipe(session_id: str, req: dict) -> dict:
717+
"""手机滑动操作。"""
718+
ctrl = _mobile_sessions.get(session_id)
719+
if not ctrl:
720+
raise HTTPException(status_code=404, detail="会话不存在")
721+
try:
722+
await ctrl.swipe(
723+
req.get("start_x", 0), req.get("start_y", 0),
724+
req.get("end_x", 0), req.get("end_y", 0),
725+
req.get("duration_ms", 300),
726+
)
727+
return {"success": True}
728+
except Exception as e:
729+
raise HTTPException(status_code=400, detail=str(e))
730+
731+
@router.get("/mobile/session/{session_id}/screenshot", tags=["手机测试"])
732+
async def mobile_screenshot(session_id: str, name: str = "") -> dict:
733+
"""截取手机屏幕。"""
734+
ctrl = _mobile_sessions.get(session_id)
735+
if not ctrl:
736+
raise HTTPException(status_code=404, detail="会话不存在")
737+
try:
738+
path = await ctrl.screenshot(name or "mobile_capture")
739+
# 返回base64用于前端显示
740+
import base64
741+
b64 = base64.b64encode(path.read_bytes()).decode()
742+
return {"path": str(path), "base64": b64}
743+
except Exception as e:
744+
raise HTTPException(status_code=500, detail=str(e))
745+
746+
@router.get("/mobile/session/{session_id}/source", tags=["手机测试"])
747+
async def mobile_page_source(session_id: str) -> dict:
748+
"""获取手机UI层级XML。"""
749+
ctrl = _mobile_sessions.get(session_id)
750+
if not ctrl:
751+
raise HTTPException(status_code=404, detail="会话不存在")
752+
try:
753+
source = await ctrl.get_page_source()
754+
return {"source": source}
755+
except Exception as e:
756+
raise HTTPException(status_code=500, detail=str(e))
757+
758+
@router.post("/mobile/session/{session_id}/navigate", tags=["手机测试"])
759+
async def mobile_navigate(session_id: str, req: dict) -> dict:
760+
"""打开URL或Activity。"""
761+
ctrl = _mobile_sessions.get(session_id)
762+
if not ctrl:
763+
raise HTTPException(status_code=404, detail="会话不存在")
764+
try:
765+
await ctrl.navigate(req.get("target", ""))
766+
return {"success": True}
767+
except Exception as e:
768+
raise HTTPException(status_code=400, detail=str(e))
769+
770+
@router.post("/mobile/session/{session_id}/back", tags=["手机测试"])
771+
async def mobile_back(session_id: str) -> dict:
772+
"""按返回键。"""
773+
ctrl = _mobile_sessions.get(session_id)
774+
if not ctrl:
775+
raise HTTPException(status_code=404, detail="会话不存在")
776+
try:
777+
await ctrl.back()
778+
return {"success": True}
779+
except Exception as e:
780+
raise HTTPException(status_code=400, detail=str(e))
781+
782+
@router.delete("/mobile/session/{session_id}", tags=["手机测试"])
783+
async def close_mobile_session(session_id: str) -> dict:
784+
"""关闭手机测试会话。"""
785+
ctrl = _mobile_sessions.pop(session_id, None)
786+
if not ctrl:
787+
raise HTTPException(status_code=404, detail="会话不存在")
788+
try:
789+
await ctrl.close()
790+
return {"message": "会话已关闭"}
791+
except Exception as e:
792+
return {"message": f"关闭时出错: {e}"}
793+
794+
@router.get("/mobile/sessions", tags=["手机测试"])
795+
async def list_mobile_sessions() -> dict:
796+
"""列出所有活跃的手机测试会话。"""
797+
sessions = []
798+
for sid, ctrl in _mobile_sessions.items():
799+
sessions.append({
800+
"session_id": sid,
801+
"device": ctrl.device_info.model_dump(),
802+
})
803+
return {"sessions": sessions, "count": len(sessions)}
804+
610805
return router

0 commit comments

Comments
 (0)