Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
90 changes: 85 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -10,7 +10,7 @@

## 📋 项目简介

本项目是一个完整的 **iOS 移动端自动化测试框架**,以**云鹿商城 App** 为测试对象,实现了 **UI 自动化测试**、**接口自动化测试**、**性能测试** 三位一体的测试解决方案。
本项目是一个完整的 **iOS 移动端自动化测试框架**,以**云鹿商城 App** 为测试对象,实现了 **UI 自动化测试**、**接口自动化测试**、**性能测试** 三位一体的测试解决方案,并内置 **Web 控制台**,支持在浏览器中浏览代码、执行测试、查看实时日志和 Allure 报告

### 核心特性

Expand All @@ -22,6 +22,7 @@
| 📈 **Allure 报告** | 可视化测试报告,支持历史趋势 |
| 🔄 **GitHub Actions CI** | 自动化流水线,PR 触发执行 |
| ⚡ **性能测试** | 基于 Locust 的压力测试脚本 |
| 🌐 **Web 控制台** | 本地 Web UI,支持代码浏览、测试执行、AI 问答 |

---

Expand All @@ -36,6 +37,7 @@ graph TD

B & C & D --> E[🛠️ 基础设施层<br/>Config / Logger / Utils]
E --> F[🚀 CI/CD 层<br/>GitHub Actions / Fastlane]
E --> G[🌐 Web 控制台<br/>FastAPI + Alpine.js]
```

---
Expand Down Expand Up @@ -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
Expand All @@ -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 问答 |

---

Expand Down Expand Up @@ -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 模式?

Expand Down
7 changes: 7 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Empty file added tools/__init__.py
Empty file.
19 changes: 19 additions & 0 deletions tools/webui/.env.example
Original file line number Diff line number Diff line change
@@ -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=
Empty file added tools/webui/__init__.py
Empty file.
185 changes: 185 additions & 0 deletions tools/webui/app.py
Original file line number Diff line number Diff line change
@@ -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)

Loading
Loading