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


@@ -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 @@
+
+
+
从左侧选择文件查看内容
+