|
7 | 7 | - /browser/*: 浏览器操作(启动/导航/点击/输入/截图/关闭) |
8 | 8 | """ |
9 | 9 |
|
| 10 | +import json |
| 11 | + |
10 | 12 | from fastapi import APIRouter, HTTPException |
11 | 13 | from loguru import logger |
12 | 14 |
|
@@ -607,4 +609,197 @@ async def send_webhook(req: dict) -> dict: |
607 | 609 | success = handler(webhook_url, report) |
608 | 610 | return {"success": success, "type": notify_type} |
609 | 611 |
|
| 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 | + |
610 | 805 | return router |
0 commit comments