From 57a7ea9468caaa6e00080906f53c2b15273b753b Mon Sep 17 00:00:00 2001 From: ConnorQi01 Date: Wed, 29 Apr 2026 11:01:05 +0800 Subject: [PATCH] feat: add WebUI dashboard for test execution and monitoring - FastAPI backend with SSE real-time log streaming - Alpine.js single-page dashboard (dark theme, 3-column layout) - File browser, code viewer, AI Q&A, test execution panel - Run history persistence via metadata.json - Log sanitization for sensitive data - Allure report integration - .env-based AI provider configuration (mock/claude) --- .gitignore | 6 + README.md | 90 +++- requirements.txt | 7 + tools/__init__.py | 0 tools/webui/.env.example | 19 + tools/webui/__init__.py | 0 tools/webui/app.py | 185 +++++++++ tools/webui/config.py | 45 ++ tools/webui/models.py | 115 ++++++ tools/webui/services/__init__.py | 0 tools/webui/services/ai_service.py | 99 +++++ tools/webui/services/allure_service.py | 24 ++ tools/webui/services/env_service.py | 59 +++ tools/webui/services/file_service.py | 62 +++ tools/webui/services/run_service.py | 138 +++++++ tools/webui/services/sanitize.py | 16 + tools/webui/static/index.html | 545 +++++++++++++++++++++++++ 17 files changed, 1405 insertions(+), 5 deletions(-) create mode 100644 tools/__init__.py create mode 100644 tools/webui/.env.example create mode 100644 tools/webui/__init__.py create mode 100644 tools/webui/app.py create mode 100644 tools/webui/config.py create mode 100644 tools/webui/models.py create mode 100644 tools/webui/services/__init__.py create mode 100644 tools/webui/services/ai_service.py create mode 100644 tools/webui/services/allure_service.py create mode 100644 tools/webui/services/env_service.py create mode 100644 tools/webui/services/file_service.py create mode 100644 tools/webui/services/run_service.py create mode 100644 tools/webui/services/sanitize.py create mode 100644 tools/webui/static/index.html diff --git a/.gitignore b/.gitignore index c5d27ca..fbf1cef 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,9 @@ allure-results-history/ # 本地配置(含敏感信息,不提交) config/local.yml + +# ==================== Web UI 运行产物 ==================== +Reports/webui-runs/*/logs.txt +Reports/webui-runs/*/allure-results/ +Reports/webui-runs/*/allure-report/ +tools/webui/.env diff --git a/README.md b/README.md index c364047..d26ec4c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🧪 iOS-Automation-Framework -> **基于云鹿商城的移动端自动化测试框架** | Appium + pytest + Allure + GitHub Actions +> **基于云鹿商城的移动端自动化测试框架** | Appium + pytest + Allure + GitHub Actions + Web UI ![Python](https://img.shields.io/badge/Python-3.9+-blue.svg) ![Framework](https://img.shields.io/badge/Framework-Appium+pytest-green.svg) @@ -10,7 +10,7 @@ ## 📋 项目简介 -本项目是一个完整的 **iOS 移动端自动化测试框架**,以**云鹿商城 App** 为测试对象,实现了 **UI 自动化测试**、**接口自动化测试**、**性能测试** 三位一体的测试解决方案。 +本项目是一个完整的 **iOS 移动端自动化测试框架**,以**云鹿商城 App** 为测试对象,实现了 **UI 自动化测试**、**接口自动化测试**、**性能测试** 三位一体的测试解决方案,并内置 **Web 控制台**,支持在浏览器中浏览代码、执行测试、查看实时日志和 Allure 报告。 ### 核心特性 @@ -22,6 +22,7 @@ | 📈 **Allure 报告** | 可视化测试报告,支持历史趋势 | | 🔄 **GitHub Actions CI** | 自动化流水线,PR 触发执行 | | ⚡ **性能测试** | 基于 Locust 的压力测试脚本 | +| 🌐 **Web 控制台** | 本地 Web UI,支持代码浏览、测试执行、AI 问答 | --- @@ -36,6 +37,7 @@ graph TD B & C & D --> E[🛠️ 基础设施层
Config / Logger / Utils] E --> F[🚀 CI/CD 层
GitHub Actions / Fastlane] + E --> G[🌐 Web 控制台
FastAPI + Alpine.js] ``` --- @@ -113,6 +115,27 @@ iOS-Automation-Framework/ │ └── Fastfile # Fastlane config │ ├── Reports/ # Test report output (git-ignored) +│ └── webui-runs/ # Web UI 每次运行的独立报告目录 +│ └── {run_id}/ +│ ├── logs.txt # pytest 输出(已脱敏) +│ ├── allure-results/ # Allure 原始数据 +│ └── allure-report/ # Allure HTML 报告 +│ +├── tools/ # 工具目录 +│ └── webui/ # Web 控制台 +│ ├── app.py # FastAPI 主入口 +│ ├── config.py # 配置(读取环境变量) +│ ├── models.py # Run 任务模型 +│ ├── .env.example # 配置模板 +│ ├── services/ +│ │ ├── ai_service.py # AI 问答(mock / Claude) +│ │ ├── allure_service.py # Allure 报告生成 +│ │ ├── env_service.py # 环境检查 +│ │ ├── file_service.py # 文件树/内容(白名单) +│ │ ├── run_service.py # 测试执行(subprocess) +│ │ └── sanitize.py # 日志脱敏 +│ └── static/ +│ └── index.html # Alpine.js 单页前端 │ └── docs/ # Documentation └── design.md # Design notes @@ -125,8 +148,9 @@ iOS-Automation-Framework/ | `UI_Automation/` | Appium UI 自动化,Page Object 模式 | | `API_Automation/` | 接口自动化,数据与用例分离 | | `Performance/` | Locust 性能压测脚本 | -| `CI/` | Jenkins Pipeline + Fastlane 配置 | -| `Reports/` | Allure 报告输出目录 | +| `CI/` | GitHub Actions / Fastlane 配置 | +| `Reports/` | Allure 报告输出目录,webui-runs/ 存放 Web UI 运行记录 | +| `tools/webui/` | Web 控制台:代码浏览、测试执行、AI 问答 | --- @@ -190,7 +214,63 @@ locust -f locustfile.py --host=https://api-dev.yunlu.com --- -## 🎨 设计思路 +## 🌐 Web 控制台 + +内置本地 Web UI,无需命令行即可浏览代码、执行测试、查看实时日志和 Allure 报告。 + +### 启动 + +```bash +python -m uvicorn tools.webui.app:app --host 127.0.0.1 --port 8000 +# 访问 http://127.0.0.1:8000 +``` + +### 配置 + +复制配置模板并按需修改: + +```bash +cp tools/webui/.env.example tools/webui/.env +``` + +关键配置项: + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `AI_PROVIDER` | `mock` | `mock` 或 `claude`(需配合 `AI_API_KEY`) | +| `AI_MODEL` | `claude-sonnet-4-6` | AI 模型 ID,可替换为其他模型 | +| `AI_API_KEY` | 空 | Claude API Key,mock 模式下不需要 | +| `ALLURE_BIN` | `allure` | allure 命令路径 | +| `MAX_CONCURRENT_RUNS` | `1` | 最大并发任务数 | + +### 功能说明 + +| 功能 | 说明 | +|------|------| +| 文件树浏览 | 只展示白名单文件,敏感文件不可访问 | +| 代码查看 | 只读,单文件最大 200KB | +| AI 问答 | 基于项目文件检索回答,mock 模式无需 API Key | +| 测试执行 | 固定模块白名单,不支持任意命令输入 | +| 实时日志 | SSE 推送,日志自动脱敏(token、密码等) | +| Allure 报告 | 每次运行独立目录,需安装 allure 命令 | +| 环境检查 | 自动检测 Python/pytest/Allure/Appium/Xcode 状态 | + +### 运行记录 + +每次执行测试会在 `Reports/webui-runs/{run_id}/` 生成: + +``` +{run_id}/ +├── logs.txt # pytest 完整输出(已脱敏) +├── allure-results/ # Allure 原始数据 +└── allure-report/ # Allure HTML 报告(需 allure 命令) +``` + +### 注意事项 + +- iOS UI 测试需要 **macOS + Xcode + Appium**,Windows/Linux 下 UI 模块自动禁用 +- Web UI 仅供本地单用户 Demo 使用,不适合生产部署 +- 接入真实 AI 需设置 `AI_PROVIDER=claude` 和 `AI_API_KEY` ### 为什么选择 Page Object 模式? diff --git a/requirements.txt b/requirements.txt index 04e8581..ea57d73 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,3 +43,10 @@ python-dotenv~=1.1.0 # Screenshot & Image Pillow~=11.2.1 + +# Web UI (tools/webui) +fastapi~=0.115.0 +uvicorn[standard]~=0.32.0 +sse-starlette~=2.1.0 +# Optional: AI integration (set AI_PROVIDER=claude in .env) +# anthropic~=0.40.0 diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/webui/.env.example b/tools/webui/.env.example new file mode 100644 index 0000000..28fb863 --- /dev/null +++ b/tools/webui/.env.example @@ -0,0 +1,19 @@ +WEBUI_HOST=127.0.0.1 +WEBUI_PORT=8000 +PROJECT_ROOT=../.. + +AI_PROVIDER=mock +AI_MODEL=claude-sonnet-4-6 +AI_API_KEY= + +MAX_CONCURRENT_RUNS=1 +DEFAULT_TIMEOUT_SECONDS=1800 +REPORTS_ROOT=Reports/webui-runs +ALLURE_BIN=allure + +TEST_ENV=dev +APPIUM_URL=http://127.0.0.1:4723 +APP_PATH= +IOS_DEVICE_NAME=iPhone 15 Pro +IOS_PLATFORM_VERSION=17.2 +IOS_UDID= diff --git a/tools/webui/__init__.py b/tools/webui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/webui/app.py b/tools/webui/app.py new file mode 100644 index 0000000..e609f57 --- /dev/null +++ b/tools/webui/app.py @@ -0,0 +1,185 @@ +import sys +import os +import asyncio +from pathlib import Path + +# 确保项目根目录在 Python 路径中 +_project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(_project_root)) + +from fastapi import FastAPI, HTTPException, BackgroundTasks +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from pydantic import BaseModel +from sse_starlette.sse import EventSourceResponse +from tools.webui.config import PROJECT_ROOT, WEBUI_HOST, WEBUI_PORT + +app = FastAPI(title="iOS-Automation-Framework WebUI") + +@app.on_event("startup") +async def startup(): + from tools.webui.models import load_history + load_history() + +# ==================== Health ==================== + +@app.get("/api/health") +def health(): + return {"status": "ok"} + +# ==================== Env ==================== + +@app.get("/api/env") +def env(): + from tools.webui.services.env_service import check_env + return check_env() + +# ==================== Files ==================== + +@app.get("/api/files") +def files(): + from tools.webui.services.file_service import get_file_tree + return get_file_tree() + +@app.get("/api/files/content") +def file_content(path: str): + from tools.webui.services.file_service import get_file_content + return get_file_content(path) + +# ==================== Test Modules ==================== + +@app.get("/api/test-modules") +def test_modules(): + from tools.webui.services.env_service import check_env + from tools.webui.services.run_service import get_modules + return get_modules(check_env()) + +# ==================== Runs ==================== + +class RunRequest(BaseModel): + module_id: str + +@app.post("/api/runs", status_code=201) +async def create_run(req: RunRequest, background_tasks: BackgroundTasks): + from tools.webui.services.run_service import TEST_MODULES, execute_run + from tools.webui.models import create_run as _create + from tools.webui.services.env_service import check_env + + if req.module_id not in TEST_MODULES: + raise HTTPException(status_code=400, detail="Unknown module_id") + + m = TEST_MODULES[req.module_id] + if m["type"] == "ui" and not check_env().get("ios_ui_runnable"): + raise HTTPException(status_code=400, detail="iOS UI 自动化需要 macOS + Xcode + Appium") + + run = _create(req.module_id, m["pytest_args"]) + background_tasks.add_task(execute_run, run) + return run.to_dict() + +@app.get("/api/runs") +def list_runs(): + from tools.webui.models import list_runs as _list + return [r.to_dict() for r in _list()] + +@app.get("/api/runs/{run_id}") +def get_run(run_id: str): + from tools.webui.models import get_run as _get + run = _get(run_id) + if not run: + raise HTTPException(status_code=404, detail="Run not found") + return run.to_dict() + +@app.get("/api/runs/{run_id}/events") +async def run_events(run_id: str): + from tools.webui.models import get_run as _get + run = _get(run_id) + if not run: + raise HTTPException(status_code=404, detail="Run not found") + + async def generator(): + # 先发送已落盘的日志 + if run.log_file.exists(): + for line in run.log_file.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True): + yield {"data": line.rstrip()} + + # 再实时推送新日志 + from tools.webui.models import RunStatus + while run.status in (RunStatus.queued, RunStatus.running): + try: + line = await asyncio.wait_for(run._log_queue.get(), timeout=1.0) + if line is None: + break + yield {"data": line.rstrip()} + except asyncio.TimeoutError: + yield {"data": ""} # keepalive + + yield {"event": "done", "data": run.status} + + return EventSourceResponse(generator()) + +@app.post("/api/runs/{run_id}/cancel") +async def cancel_run(run_id: str): + from tools.webui.models import get_run as _get, RunStatus + run = _get(run_id) + if not run: + raise HTTPException(status_code=404, detail="Run not found") + if run.status not in (RunStatus.queued, RunStatus.running): + raise HTTPException(status_code=400, detail="Run is not active") + if run.process: + try: + run.process.kill() + except Exception: + pass + run.status = RunStatus.cancelled + return {"status": "cancelled"} + +@app.get("/api/runs/{run_id}/report") +def run_report(run_id: str): + from tools.webui.models import get_run as _get + run = _get(run_id) + if not run: + raise HTTPException(status_code=404, detail="Run not found") + if run.report_path: + return {"available": True, "path": run.report_path} + return {"available": False, "path": None} + +# ==================== AI Chat ==================== + +class ChatRequest(BaseModel): + question: str + +@app.post("/api/chat") +async def chat(req: ChatRequest): + from tools.webui.services.ai_service import answer + return await answer(req.question) + +# ==================== Allure report static files ==================== + +from tools.webui.config import REPORTS_ROOT + +@app.get("/reports/{run_id}/{rest_path:path}") +def serve_report(run_id: str, rest_path: str): + target = (REPORTS_ROOT / run_id / "allure-report" / rest_path).resolve() + try: + target.relative_to(REPORTS_ROOT) + except ValueError: + raise HTTPException(status_code=403) + if not target.exists(): + raise HTTPException(status_code=404) + return FileResponse(str(target)) + +# ==================== Static ==================== + +_static = Path(__file__).parent / "static" +if _static.exists(): + app.mount("/static", StaticFiles(directory=str(_static)), name="static") + + @app.get("/") + def index(): + return FileResponse(str(_static / "index.html")) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("tools.webui.app:app", host=WEBUI_HOST, port=WEBUI_PORT, reload=True) + diff --git a/tools/webui/config.py b/tools/webui/config.py new file mode 100644 index 0000000..c533436 --- /dev/null +++ b/tools/webui/config.py @@ -0,0 +1,45 @@ +import os +from pathlib import Path + +PROJECT_ROOT = Path(os.getenv("PROJECT_ROOT", Path(__file__).parent.parent.parent)).resolve() + +WEBUI_HOST = os.getenv("WEBUI_HOST", "127.0.0.1") +WEBUI_PORT = int(os.getenv("WEBUI_PORT", "8000")) + +AI_PROVIDER = os.getenv("AI_PROVIDER", "mock") +AI_MODEL = os.getenv("AI_MODEL", "claude-sonnet-4-6") +AI_API_KEY = os.getenv("AI_API_KEY", "") + +MAX_CONCURRENT_RUNS = int(os.getenv("MAX_CONCURRENT_RUNS", "1")) +DEFAULT_TIMEOUT_SECONDS = int(os.getenv("DEFAULT_TIMEOUT_SECONDS", "1800")) +REPORTS_ROOT = PROJECT_ROOT / os.getenv("REPORTS_ROOT", "Reports/webui-runs") +ALLURE_BIN = os.getenv("ALLURE_BIN", "allure") + +TEST_ENV = os.getenv("TEST_ENV", "dev") +APPIUM_URL = os.getenv("APPIUM_URL", "http://127.0.0.1:4723") +APP_PATH = os.getenv("APP_PATH", "") +IOS_DEVICE_NAME = os.getenv("IOS_DEVICE_NAME", "iPhone 15 Pro") +IOS_PLATFORM_VERSION = os.getenv("IOS_PLATFORM_VERSION", "17.2") +IOS_UDID = os.getenv("IOS_UDID", "") + +WHITELIST_PATHS = [ + "README.md", "pytest.ini", "requirements.txt", "conftest.py", + "run_api.bat", "run_api.sh", "run_ui.bat", "run_ui.sh", + "API_Automation", "UI_Automation", "Performance/locust_scripts", + "config/environments.yaml", "config/local.yml.example", + "docs", "utils", +] + +IGNORE_DIRS = { + ".git", ".github", ".venv", "venv", "__pycache__", + ".pytest_cache", "Reports", "allure-results", "node_modules", + "tools", +} + +SENSITIVE_FILES = { + ".env", "config/local.yml", "local.yml", +} + +SENSITIVE_EXTENSIONS = {".key", ".pem", ".p12", ".mobileprovision"} + +MAX_FILE_SIZE = 200 * 1024 # 200KB diff --git a/tools/webui/models.py b/tools/webui/models.py new file mode 100644 index 0000000..89633df --- /dev/null +++ b/tools/webui/models.py @@ -0,0 +1,115 @@ +import asyncio +import json +import sys +from datetime import datetime +from enum import Enum +from typing import Optional +from uuid import uuid4 + +from tools.webui.config import PROJECT_ROOT, REPORTS_ROOT, DEFAULT_TIMEOUT_SECONDS + + +class RunStatus(str, Enum): + queued = "queued" + running = "running" + succeeded = "succeeded" + failed = "failed" + cancelled = "cancelled" + timeout = "timeout" + + +class Run: + def __init__(self, module_id: str, pytest_args: list[str]): + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + self.run_id = f"{ts}-{module_id}-{uuid4().hex[:6]}" + self.module_id = module_id + self.pytest_args = pytest_args + self.status = RunStatus.queued + self.started_at: Optional[datetime] = None + self.finished_at: Optional[datetime] = None + self.exit_code: Optional[int] = None + self.report_path: Optional[str] = None + self.run_dir = REPORTS_ROOT / self.run_id + self.log_file = self.run_dir / "logs.txt" + self.allure_results = self.run_dir / "allure-results" + self.allure_report = self.run_dir / "allure-report" + self.process: Optional[asyncio.subprocess.Process] = None + self._log_queue: asyncio.Queue = asyncio.Queue() + + def to_dict(self) -> dict: + return { + "run_id": self.run_id, + "module_id": self.module_id, + "status": self.status, + "started_at": self.started_at.isoformat() if self.started_at else None, + "finished_at": self.finished_at.isoformat() if self.finished_at else None, + "exit_code": self.exit_code, + "report_path": self.report_path, + } + + def save_metadata(self): + """写入 metadata.json,供重启后恢复""" + meta = self.to_dict() + meta["pytest_args"] = self.pytest_args + self.run_dir.mkdir(parents=True, exist_ok=True) + (self.run_dir / "metadata.json").write_text( + json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + @classmethod + def from_metadata(cls, meta: dict) -> "Run": + """从 metadata.json 恢复 Run 对象(只读,不可重新执行)""" + run = cls.__new__(cls) + run.run_id = meta["run_id"] + run.module_id = meta["module_id"] + run.pytest_args = meta.get("pytest_args", []) + run.status = RunStatus(meta["status"]) + run.started_at = datetime.fromisoformat(meta["started_at"]) if meta.get("started_at") else None + run.finished_at = datetime.fromisoformat(meta["finished_at"]) if meta.get("finished_at") else None + run.exit_code = meta.get("exit_code") + run.report_path = meta.get("report_path") + run.run_dir = REPORTS_ROOT / run.run_id + run.log_file = run.run_dir / "logs.txt" + run.allure_results = run.run_dir / "allure-results" + run.allure_report = run.run_dir / "allure-report" + run.process = None + run._log_queue = asyncio.Queue() + return run + + +# In-memory store (single-user demo) +_runs: dict[str, Run] = {} + + +def get_run(run_id: str) -> Optional[Run]: + return _runs.get(run_id) + + +def list_runs() -> list[Run]: + return sorted(_runs.values(), key=lambda r: r.run_id, reverse=True)[:20] + + +def create_run(module_id: str, pytest_args: list[str]) -> Run: + run = Run(module_id, pytest_args) + _runs[run.run_id] = run + return run + + +def load_history(): + """启动时从 Reports/webui-runs/ 恢复历史 run(最近20条)""" + if not REPORTS_ROOT.exists(): + return + dirs = sorted(REPORTS_ROOT.iterdir(), reverse=True)[:20] + for d in dirs: + meta_file = d / "metadata.json" + if meta_file.exists(): + try: + meta = json.loads(meta_file.read_text(encoding="utf-8")) + run = Run.from_metadata(meta) + # 重启后 running/queued 状态视为 failed + if run.status in (RunStatus.running, RunStatus.queued): + run.status = RunStatus.failed + _runs[run.run_id] = run + except Exception: + pass + diff --git a/tools/webui/services/__init__.py b/tools/webui/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/webui/services/ai_service.py b/tools/webui/services/ai_service.py new file mode 100644 index 0000000..d4554ff --- /dev/null +++ b/tools/webui/services/ai_service.py @@ -0,0 +1,99 @@ +import re +from pathlib import Path +from tools.webui.config import PROJECT_ROOT, AI_PROVIDER, AI_API_KEY, AI_MODEL +from tools.webui.services.file_service import get_file_content + +# 关键词 -> 相关文件映射 +_KEYWORD_FILES = { + "登录": ["UI_Automation/Pages/login_page.py", "API_Automation/cases/test_user.py"], + "login": ["UI_Automation/Pages/login_page.py", "API_Automation/cases/test_user.py"], + "购物车": ["UI_Automation/Pages/cart_page.py", "API_Automation/cases/test_cart.py"], + "cart": ["UI_Automation/Pages/cart_page.py", "API_Automation/cases/test_cart.py"], + "订单": ["API_Automation/api/order_api.py", "API_Automation/cases/test_order.py"], + "order": ["API_Automation/api/order_api.py", "API_Automation/cases/test_order.py"], + "page object": ["UI_Automation/Pages/base_page.py"], + "配置": ["config/environments.yaml", "pytest.ini"], + "config": ["config/environments.yaml", "pytest.ini"], + "allure": ["conftest.py", "pytest.ini"], + "并发": ["pytest.ini"], + "appium": ["UI_Automation/Pages/base_page.py"], +} + +_DEFAULT_FILES = ["README.md", "pytest.ini", "conftest.py"] + + +def _retrieve_context(question: str) -> list[dict]: + """根据问题关键词检索相关文件片段""" + q = question.lower() + files = list(_DEFAULT_FILES) + for kw, paths in _KEYWORD_FILES.items(): + if kw in q: + files.extend(paths) + + # 去重,最多取 6 个 + seen = set() + unique = [] + for f in files: + if f not in seen: + seen.add(f) + unique.append(f) + unique = unique[:6] + + snippets = [] + for path in unique: + result = get_file_content(path) + if "content" in result: + content = result["content"] + # 截取前 150 行 + lines = content.splitlines()[:150] + snippets.append({"path": path, "content": "\n".join(lines)}) + return snippets + + +def _mock_answer(question: str, snippets: list[dict]) -> str: + refs = "\n".join(f"- `{s['path']}`" for s in snippets) + return ( + f"(Mock 模式)基于以下文件检索到相关内容:\n{refs}\n\n" + f"请配置 `AI_PROVIDER=claude` 和 `AI_API_KEY` 以获得真实 AI 回答。\n\n" + f"你的问题:{question}" + ) + + +async def _claude_answer(question: str, snippets: list[dict]) -> str: + try: + import anthropic + except ImportError: + return "请先安装 anthropic 包:pip install anthropic" + + context = "\n\n".join( + f"# {s['path']}\n```\n{s['content']}\n```" for s in snippets + ) + system = ( + "你是 iOS-Automation-Framework 的项目助手。" + "只能基于提供的项目上下文回答。" + "如果上下文不足,说明不确定并建议用户查看哪些文件。" + "回答涉及命令时,只推荐项目中已有的安全命令。" + "不要输出密钥、token、证书、密码。" + "如果用户询问 UI 自动化运行环境,说明 iOS UI 自动化依赖 macOS + Xcode + Appium。" + ) + + client = anthropic.Anthropic(api_key=AI_API_KEY) + message = client.messages.create( + model=AI_MODEL, + max_tokens=1024, + system=system, + messages=[{"role": "user", "content": f"项目上下文:\n{context}\n\n问题:{question}"}], + ) + return message.content[0].text + + +async def answer(question: str) -> dict: + snippets = _retrieve_context(question) + if AI_PROVIDER == "claude" and AI_API_KEY: + text = await _claude_answer(question, snippets) + else: + text = _mock_answer(question, snippets) + return { + "answer": text, + "references": [s["path"] for s in snippets], + } diff --git a/tools/webui/services/allure_service.py b/tools/webui/services/allure_service.py new file mode 100644 index 0000000..3211165 --- /dev/null +++ b/tools/webui/services/allure_service.py @@ -0,0 +1,24 @@ +import asyncio +from pathlib import Path +from tools.webui.config import ALLURE_BIN, PROJECT_ROOT + + +async def generate_report(allure_results: Path, allure_report: Path) -> tuple[bool, str]: + try: + proc = await asyncio.create_subprocess_exec( + ALLURE_BIN, "generate", str(allure_results), + "-o", str(allure_report), "--clean", + cwd=str(PROJECT_ROOT), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await asyncio.wait_for(proc.communicate(), timeout=60) + if proc.returncode == 0: + return True, str(allure_report / "index.html") + return False, stderr.decode(errors="replace").strip() + except asyncio.TimeoutError: + return False, "Allure report generation timed out" + except FileNotFoundError: + return False, f"'{ALLURE_BIN}' command not found" + except Exception as e: + return False, str(e) diff --git a/tools/webui/services/env_service.py b/tools/webui/services/env_service.py new file mode 100644 index 0000000..34edd59 --- /dev/null +++ b/tools/webui/services/env_service.py @@ -0,0 +1,59 @@ +import os +import sys +import platform +import shutil +import subprocess +from tools.webui.config import APPIUM_URL, APP_PATH, ALLURE_BIN + + +def _run(cmd: list[str]) -> tuple[bool, str]: + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=5) + return r.returncode == 0, (r.stdout + r.stderr).strip() + except Exception as e: + return False, str(e) + + +def check_env() -> dict: + plat = platform.system() # Windows / Darwin / Linux + is_mac = plat == "Darwin" + + # Python + py_ver = sys.version.split()[0] + + # pytest + ok_pytest, pytest_out = _run(["pytest", "--version"]) + + # allure + ok_allure, allure_out = _run([ALLURE_BIN, "--version"]) + + # appium (ping server) + ok_appium = False + appium_msg = "Appium server unavailable" + try: + import urllib.request + urllib.request.urlopen(APPIUM_URL + "/status", timeout=3) + ok_appium = True + appium_msg = APPIUM_URL + except Exception: + pass + + # xcode (macOS only) + ok_xcode = False + xcode_msg = "Xcode requires macOS" + if is_mac: + ok_xcode, xcode_msg = _run(["xcodebuild", "-version"]) + + ios_ui_runnable = is_mac and ok_appium and ok_xcode and bool(APP_PATH) + api_runnable = ok_pytest + + return { + "platform": plat, + "python": {"ok": True, "version": py_ver}, + "pytest": {"ok": ok_pytest, "version": pytest_out if ok_pytest else None, "message": None if ok_pytest else pytest_out}, + "allure": {"ok": ok_allure, "version": allure_out if ok_allure else None, "message": None if ok_allure else allure_out}, + "appium": {"ok": ok_appium, "message": appium_msg}, + "xcode": {"ok": ok_xcode, "message": xcode_msg}, + "ios_ui_runnable": ios_ui_runnable, + "api_runnable": api_runnable, + } diff --git a/tools/webui/services/file_service.py b/tools/webui/services/file_service.py new file mode 100644 index 0000000..69ef3c3 --- /dev/null +++ b/tools/webui/services/file_service.py @@ -0,0 +1,62 @@ +import os +from pathlib import Path +from tools.webui.config import ( + PROJECT_ROOT, WHITELIST_PATHS, IGNORE_DIRS, + SENSITIVE_FILES, SENSITIVE_EXTENSIONS, MAX_FILE_SIZE, +) + + +def _is_sensitive(rel: str) -> bool: + p = Path(rel) + if rel in SENSITIVE_FILES or p.name in SENSITIVE_FILES: + return True + if p.suffix in SENSITIVE_EXTENSIONS: + return True + return False + + +def _is_allowed(rel: str) -> bool: + for w in WHITELIST_PATHS: + if rel == w or rel.startswith(w + "/") or rel.startswith(w + "\\"): + return True + return False + + +def get_file_tree() -> list[dict]: + result = [] + for root, dirs, files in os.walk(PROJECT_ROOT): + dirs[:] = [d for d in dirs if d not in IGNORE_DIRS] + rel_root = Path(root).relative_to(PROJECT_ROOT).as_posix() + for f in sorted(files): + rel = (rel_root + "/" + f).lstrip("./") + if rel_root == ".": + rel = f + if _is_allowed(rel) and not _is_sensitive(rel): + result.append({"path": rel, "type": "file"}) + return result + + +def get_file_content(rel_path: str) -> dict: + # Prevent path traversal + try: + target = (PROJECT_ROOT / rel_path).resolve() + target.relative_to(PROJECT_ROOT) + except ValueError: + return {"error": "Access denied"} + + if _is_sensitive(rel_path): + return {"error": "Sensitive file, access denied"} + + if not _is_allowed(rel_path): + return {"error": "File not in whitelist"} + + if not target.exists() or not target.is_file(): + return {"error": "File not found"} + + if target.stat().st_size > MAX_FILE_SIZE: + return {"error": f"File exceeds 200KB limit"} + + try: + return {"content": target.read_text(encoding="utf-8", errors="replace")} + except Exception as e: + return {"error": str(e)} diff --git a/tools/webui/services/run_service.py b/tools/webui/services/run_service.py new file mode 100644 index 0000000..292fc4e --- /dev/null +++ b/tools/webui/services/run_service.py @@ -0,0 +1,138 @@ +import sys +import asyncio +from datetime import datetime +from tools.webui.config import PROJECT_ROOT, DEFAULT_TIMEOUT_SECONDS, MAX_CONCURRENT_RUNS +from tools.webui.services.sanitize import sanitize + +TEST_MODULES = { + "api_all": { + "name": "API 全量测试", + "type": "api", + "pytest_args": [sys.executable, "-m", "pytest", "API_Automation/cases", "-v"], + "requires": ["pytest"], + }, + "api_smoke": { + "name": "API 冒烟测试", + "type": "api", + "pytest_args": [sys.executable, "-m", "pytest", "API_Automation/cases", "-v", "-m", "smoke"], + "requires": ["pytest"], + }, + "api_regression": { + "name": "API 回归测试", + "type": "api", + "pytest_args": [sys.executable, "-m", "pytest", "API_Automation/cases", "-v", "-m", "regression"], + "requires": ["pytest"], + }, + "ui_all": { + "name": "UI 全量测试", + "type": "ui", + "pytest_args": [sys.executable, "-m", "pytest", "UI_Automation/Tests", "-v", "-n", "0"], + "requires": ["pytest", "appium", "xcode", "app_path"], + }, + "ui_smoke": { + "name": "UI 冒烟测试", + "type": "ui", + "pytest_args": [sys.executable, "-m", "pytest", "UI_Automation/Tests", "-v", "-n", "0", "-m", "smoke"], + "requires": ["pytest", "appium", "xcode", "app_path"], + }, +} + + +def get_modules(env: dict) -> list[dict]: + result = [] + for mid, m in TEST_MODULES.items(): + runnable = True + reason = None + if m["type"] == "ui" and not env.get("ios_ui_runnable"): + runnable = False + reason = "iOS UI 自动化需要 macOS + Xcode + Appium" + result.append({ + "id": mid, + "name": m["name"], + "type": m["type"], + "runnable": runnable, + "disabled_reason": reason, + }) + return result + + +# 并发控制 +_semaphore: asyncio.Semaphore | None = None + + +def _get_semaphore() -> asyncio.Semaphore: + global _semaphore + if _semaphore is None: + _semaphore = asyncio.Semaphore(MAX_CONCURRENT_RUNS) + return _semaphore + + +async def execute_run(run) -> None: + """执行测试任务,管理生命周期""" + from tools.webui.models import RunStatus + from tools.webui.services.allure_service import generate_report + + sem = _get_semaphore() + async with sem: + run.status = RunStatus.running + run.started_at = datetime.now() + run.run_dir.mkdir(parents=True, exist_ok=True) + run.allure_results.mkdir(parents=True, exist_ok=True) + + args = run.pytest_args + ["--alluredir", str(run.allure_results)] + + try: + proc = await asyncio.create_subprocess_exec( + *args, + cwd=str(PROJECT_ROOT), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + run.process = proc + + with open(run.log_file, "w", encoding="utf-8") as f: + async def _read(): + while True: + line = await proc.stdout.readline() + if not line: + break + text = sanitize(line.decode(errors="replace")) + f.write(text) + f.flush() + await run._log_queue.put(text) + + await asyncio.wait_for( + asyncio.gather(_read(), proc.wait()), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + run.exit_code = proc.returncode + run.status = RunStatus.succeeded if proc.returncode == 0 else RunStatus.failed + + except asyncio.TimeoutError: + run.status = RunStatus.timeout + run.exit_code = -1 + if run.process: + try: + run.process.kill() + except Exception: + pass + + except asyncio.CancelledError: + run.status = RunStatus.cancelled + run.exit_code = -1 + if run.process: + try: + run.process.kill() + except Exception: + pass + + finally: + run.finished_at = datetime.now() + await run._log_queue.put(None) # sentinel + run.save_metadata() + + # 生成 Allure 报告(无论成功失败都尝试) + ok, path = await generate_report(run.allure_results, run.allure_report) + if ok: + run.report_path = path diff --git a/tools/webui/services/sanitize.py b/tools/webui/services/sanitize.py new file mode 100644 index 0000000..a42ef3f --- /dev/null +++ b/tools/webui/services/sanitize.py @@ -0,0 +1,16 @@ +import re + +_PATTERNS = [ + (re.compile(r'(token\s*=\s*)\S+', re.IGNORECASE), r'\1***'), + (re.compile(r'(Authorization:\s*Bearer\s+)\S+', re.IGNORECASE), r'\1***'), + (re.compile(r'(password\s*[=:]\s*)\S+', re.IGNORECASE), r'\1***'), + (re.compile(r'(AI_API_KEY\s*=\s*)\S+', re.IGNORECASE), r'\1***'), + (re.compile(r'(api_key\s*[=:]\s*)\S+', re.IGNORECASE), r'\1***'), + (re.compile(r'(secret\s*[=:]\s*)\S+', re.IGNORECASE), r'\1***'), +] + + +def sanitize(line: str) -> str: + for pattern, repl in _PATTERNS: + line = pattern.sub(repl, line) + return line diff --git a/tools/webui/static/index.html b/tools/webui/static/index.html new file mode 100644 index 0000000..0100c21 --- /dev/null +++ b/tools/webui/static/index.html @@ -0,0 +1,545 @@ + + + + + +iOS-Automation-Framework + + + + + + + +
+ +
+ + + + + + + + + +
+
+ + +
+ + + + + +
+
+
+ 📄 代码查看 +
+
+ 🤖 AI 问答 + +
+
+ + + + + + +
+ + +
+ +
+
⚡ 环境状态
+
+
+
Python
+
+
+
+
pytest
+
+
+
+
Allure
+
+
+
+
Appium
+
+
+
+
+ + +
+
🚀 测试模块
+ +
+ + +
+
+ 📋 执行日志 + + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
+ + + +