feat(e2e-tests): 添加端到端测试框架及测试用例
refactor(components): 调整头部和页脚布局样式 style(hero-section): 更新徽章动画效果 docs: 添加测试框架README文档 test: 实现首页、导航和联系表单的测试用例 ci: 添加CI测试脚本和配置
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
# 测试环境配置文件
|
||||
# 复制此文件为 .env 并根据实际情况修改配置
|
||||
|
||||
# ===========================================
|
||||
# 基础配置
|
||||
# ===========================================
|
||||
|
||||
# 测试环境URL
|
||||
TEST_BASE_URL=http://localhost:3000
|
||||
|
||||
# 备用测试URL(如果本地不可用)
|
||||
TEST_BASE_URL_FALLBACK=https://novalon-website.example.com
|
||||
|
||||
# 测试环境
|
||||
TEST_ENV=development
|
||||
|
||||
# ===========================================
|
||||
# 浏览器配置
|
||||
# ===========================================
|
||||
|
||||
# 默认浏览器
|
||||
DEFAULT_BROWSER=chromium
|
||||
|
||||
# 视口配置
|
||||
DEFAULT_VIEWPORT_WIDTH=1920
|
||||
DEFAULT_VIEWPORT_HEIGHT=1080
|
||||
|
||||
# 是否以无头模式运行
|
||||
HEADLESS_MODE=false
|
||||
|
||||
# ===========================================
|
||||
# 测试执行配置
|
||||
# ===========================================
|
||||
|
||||
# 最大重试次数
|
||||
MAX_RETRIES=2
|
||||
|
||||
# 测试超时时间(秒)
|
||||
TEST_TIMEOUT=60
|
||||
|
||||
# 页面加载超时
|
||||
PAGE_LOAD_TIMEOUT=30000
|
||||
|
||||
# 元素等待超时
|
||||
ELEMENT_TIMEOUT=10000
|
||||
|
||||
# 并行执行配置
|
||||
PARALLEL_WORKERS=4
|
||||
|
||||
# ===========================================
|
||||
# 截图和视频配置
|
||||
# ===========================================
|
||||
|
||||
# 是否在测试失败时截图
|
||||
SCREENSHOT_ON_FAILURE=true
|
||||
|
||||
# 是否录制视频
|
||||
VIDEO_RECORDING=false
|
||||
|
||||
# 截图保存路径
|
||||
SCREENSHOTS_DIR=reports/screenshots
|
||||
|
||||
# 视频保存路径
|
||||
VIDEOS_DIR=reports/videos
|
||||
|
||||
# ===========================================
|
||||
# 日志配置
|
||||
# ===========================================
|
||||
|
||||
# 日志级别
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# 日志文件路径
|
||||
LOG_FILE=reports/e2e_tests.log
|
||||
|
||||
# 是否在控制台输出日志
|
||||
CONSOLE_LOG=true
|
||||
|
||||
# ===========================================
|
||||
# 报告配置
|
||||
# ===========================================
|
||||
|
||||
# HTML报告标题
|
||||
REPORT_TITLE=Novalon Website E2E测试报告
|
||||
|
||||
# 报告描述
|
||||
REPORT_DESCRIPTION=Novalon Website端到端自动化测试报告
|
||||
|
||||
# 是否生成JUnit XML报告(用于CI/CD)
|
||||
JUNIT_XML_REPORT=false
|
||||
JUNIT_XML_PATH=reports/test-results.xml
|
||||
|
||||
# ===========================================
|
||||
# CI/CD配置
|
||||
# ===========================================
|
||||
|
||||
# CI环境标识
|
||||
CI=false
|
||||
|
||||
# Git分支(CI环境中自动填充)
|
||||
GIT_BRANCH=
|
||||
|
||||
# Git提交(CI环境中自动填充)
|
||||
GIT_COMMIT=
|
||||
|
||||
# Git仓库(CI环境中自动填充)
|
||||
GIT_REPOSITORY=
|
||||
@@ -0,0 +1,439 @@
|
||||
# Novalon Website E2E 测试框架
|
||||
|
||||
基于 Playwright 和 Python 的端到端测试解决方案,为 Novalon Website 提供全面的自动化测试覆盖。
|
||||
|
||||
## 目录
|
||||
|
||||
- [特性](#特性)
|
||||
- [技术栈](#技术栈)
|
||||
- [项目结构](#项目结构)
|
||||
- [快速开始](#快速开始)
|
||||
- [测试运行](#测试运行)
|
||||
- [CI/CD 集成](#cicd-集成)
|
||||
- [测试标记](#测试标记)
|
||||
- [配置说明](#配置说明)
|
||||
- [最佳实践](#最佳实践)
|
||||
|
||||
## 特性
|
||||
|
||||
- **模块化设计**: 采用 Page Object Model (POM) 设计模式
|
||||
- **跨浏览器测试**: 支持 Chrome、Firefox、WebKit
|
||||
- **响应式测试**: 覆盖多端(移动端、平板、桌面)
|
||||
- **性能测试**: 页面加载性能指标监控
|
||||
- **多格式报告**: HTML、JSON、Markdown 报告生成
|
||||
- **完整测试数据**: 自动生成测试数据(中文/英文)
|
||||
- **详细日志**: 彩色日志输出,便于问题定位
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 技术 | 用途 |
|
||||
|------|------|
|
||||
| Python 3.9+ | 编程语言 |
|
||||
| Playwright | 浏览器自动化框架 |
|
||||
| pytest | 测试框架 |
|
||||
| pytest-html | HTML 报告 |
|
||||
| pytest-cov | 代码覆盖率 |
|
||||
| Jinja2 | 报告模板 |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
e2e-tests/
|
||||
├── config/
|
||||
│ ├── __init__.py
|
||||
│ ├── settings.py # 应用配置
|
||||
│ └── browsers.py # 浏览器配置
|
||||
├── pages/
|
||||
│ ├── __init__.py
|
||||
│ ├── base_page.py # 页面基类
|
||||
│ ├── home_page.py # 首页对象
|
||||
│ └── contact_page.py # 联系页面对象
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # pytest 配置和 fixture
|
||||
│ ├── test_home_page.py # 首页测试
|
||||
│ ├── test_contact_form.py # 联系表单测试
|
||||
│ ├── test_navigation.py # 导航测试
|
||||
│ ├── test_performance.py # 性能测试
|
||||
│ └── test_responsive.py # 响应式测试
|
||||
├── utils/
|
||||
│ ├── __init__.py
|
||||
│ ├── helpers.py # 辅助工具函数
|
||||
│ ├── logger.py # 日志配置
|
||||
│ ├── data_generator.py # 测试数据生成
|
||||
│ └── report_generator.py # 报告生成器
|
||||
├── scripts/
|
||||
│ ├── run_tests.py # 测试运行脚本
|
||||
│ └── ci_test.py # CI/CD 测试脚本
|
||||
├── reports/ # 测试报告目录
|
||||
├── screenshots/ # 失败截图目录
|
||||
├── videos/ # 测试视频目录
|
||||
├── requirements.txt # Python 依赖
|
||||
├── pyproject.toml # pytest 配置
|
||||
├── pytest.ini # pytest 配置
|
||||
├── .env.example # 环境变量模板
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 环境准备
|
||||
|
||||
```bash
|
||||
# 创建虚拟环境
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# 或
|
||||
.\venv\Scripts\activate # Windows
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 安装 Playwright 浏览器
|
||||
playwright install
|
||||
```
|
||||
|
||||
### 2. 环境配置
|
||||
|
||||
```bash
|
||||
# 复制环境变量模板
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env 文件
|
||||
# BASE_URL=http://localhost:3000
|
||||
# 默认开发环境: http://localhost:3000
|
||||
```
|
||||
|
||||
### 3. 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
python scripts/run_tests.py
|
||||
|
||||
# 运行冒烟测试
|
||||
python scripts/run_tests.py -m smoke
|
||||
|
||||
# 运行特定测试
|
||||
python scripts/run_tests.py tests/test_home_page.py
|
||||
|
||||
# 使用关键字过滤
|
||||
python scripts/run_tests.py -k home
|
||||
|
||||
# 多浏览器测试
|
||||
python scripts/run_tests.py -b all
|
||||
```
|
||||
|
||||
## 测试运行
|
||||
|
||||
### 命令行参数
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `-b, --browser` | 浏览器 (chromium/firefox/webkit/all) | chromium |
|
||||
| `-h, --headless` | 无头模式 | False |
|
||||
| `-m, --marker` | 运行指定标记的测试 | - |
|
||||
| `-k, --keyword` | 关键字过滤 | - |
|
||||
| `-v, --verbose` | 详细输出 | False |
|
||||
| `--html` | 生成 HTML 报告 | False |
|
||||
| `--video` | 录制测试视频 | False |
|
||||
| `--screenshot` | 失败时截图 | False |
|
||||
| `--parallel` | 并行执行 | False |
|
||||
| `--workers` | 并行工作数 | 4 |
|
||||
| `--env` | 测试环境 | development |
|
||||
|
||||
### 示例命令
|
||||
|
||||
```bash
|
||||
# 冒烟测试
|
||||
python scripts/run_tests.py -m smoke -v
|
||||
|
||||
# 性能测试
|
||||
python scripts/run_tests.py -m performance
|
||||
|
||||
# 响应式测试
|
||||
python scripts/run_tests.py -m responsive
|
||||
|
||||
# 完整回归测试
|
||||
python scripts/run_tests.py -m regression -v --html
|
||||
|
||||
# 无头模式运行
|
||||
python scripts/run_tests.py -h --parallel
|
||||
|
||||
# 跨浏览器测试
|
||||
python scripts/run_tests.py -b all --html
|
||||
```
|
||||
|
||||
## CI/CD 集成
|
||||
|
||||
### GitHub Actions 示例
|
||||
|
||||
```yaml
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r e2e-tests/requirements.txt
|
||||
playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E Tests
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
cp e2e-tests/.env.example e2e-tests/.env
|
||||
python e2e-tests/scripts/run_tests.py -m smoke --html
|
||||
env:
|
||||
BASE_URL: ${{ secrets.BASE_URL }}
|
||||
|
||||
- name: Upload Test Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-report
|
||||
path: e2e-tests/reports/
|
||||
```
|
||||
|
||||
### CI 测试脚本
|
||||
|
||||
```bash
|
||||
# 运行冒烟测试
|
||||
python scripts/ci_test.py --test-type smoke
|
||||
|
||||
# 运行回归测试
|
||||
python scripts/ci_test.py --test-type regression
|
||||
|
||||
# 运行性能测试
|
||||
python scripts/ci_test.py --test-type performance
|
||||
|
||||
# 跨浏览器测试
|
||||
python scripts/ci_test.py --test-type cross_browser
|
||||
|
||||
# 完整测试套件
|
||||
python scripts/ci_test.py --test-type full
|
||||
```
|
||||
|
||||
## 测试标记
|
||||
|
||||
| 标记 | 说明 | 用例数量 |
|
||||
|------|------|---------|
|
||||
| `@pytest.mark.smoke` | 冒烟测试,快速验证核心功能 | 关键路径 |
|
||||
| `@pytest.mark.regression` | 回归测试,完整功能验证 | 功能测试 |
|
||||
| `@pytest.mark.performance` | 性能测试,页面加载和响应时间 | 性能指标 |
|
||||
| `@pytest.mark.responsive` | 响应式测试,不同屏幕尺寸 | 布局适配 |
|
||||
| `@pytest.mark.cross_browser` | 跨浏览器测试 | 兼容性 |
|
||||
| `@pytest.mark.form` | 表单相关测试 | 表单验证 |
|
||||
| `@pytest.mark.navigation` | 导航测试 | 页面跳转 |
|
||||
| `@pytest.mark.interactive` | 用户交互测试 | 交互功能 |
|
||||
|
||||
### 运行特定标记的测试
|
||||
|
||||
```bash
|
||||
# 只运行冒烟测试
|
||||
pytest -m smoke
|
||||
|
||||
# 运行冒烟和性能测试
|
||||
pytest -m "smoke or performance"
|
||||
|
||||
# 运行冒烟但排除性能测试
|
||||
pytest -m "smoke and not performance"
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量 (.env)
|
||||
|
||||
```env
|
||||
# 网站基础URL
|
||||
BASE_URL=http://localhost:3000
|
||||
|
||||
# 测试环境
|
||||
ENVIRONMENT=development
|
||||
|
||||
# 默认浏览器
|
||||
DEFAULT_BROWSER=chromium
|
||||
|
||||
# 无头模式
|
||||
HEADLESS_MODE=false
|
||||
|
||||
# 页面加载超时
|
||||
PAGE_LOAD_TIMEOUT=30000
|
||||
|
||||
# 元素等待超时
|
||||
ELEMENT_TIMEOUT=10000
|
||||
|
||||
# 报告标题
|
||||
REPORT_TITLE=Novalon Website E2E 测试报告
|
||||
|
||||
# 报告描述
|
||||
REPORT_DESCRIPTION=自动化端到端测试结果
|
||||
```
|
||||
|
||||
### 测试配置 (pytest.ini)
|
||||
|
||||
```ini
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts =
|
||||
-v
|
||||
--tb=short
|
||||
--strict-markers
|
||||
--disable-warnings
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
```
|
||||
|
||||
### 浏览器配置 (config/browsers.py)
|
||||
|
||||
```python
|
||||
# 支持的浏览器配置
|
||||
BROWSER_CONFIG = {
|
||||
"chromium": {
|
||||
"headless": False,
|
||||
"viewport": {"width": 1920, "height": 1080},
|
||||
"args": ["--start-maximized"]
|
||||
},
|
||||
"firefox": {
|
||||
"headless": False,
|
||||
"viewport": {"width": 1920, "height": 1080}
|
||||
},
|
||||
"webkit": {
|
||||
"headless": False,
|
||||
"viewport": {"width": 1920, "height": 1080}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 页面对象模式
|
||||
|
||||
每个页面都应该有对应的 Page Object 类:
|
||||
|
||||
```python
|
||||
from pages.base_page import BasePage
|
||||
|
||||
class HomePage(BasePage):
|
||||
def __init__(self, page):
|
||||
super().__init__(page)
|
||||
self.path = "/"
|
||||
self.selectors = {
|
||||
"hero_title": "#home h1",
|
||||
"cta_button": ".cta-button"
|
||||
}
|
||||
|
||||
def verify_hero_title(self):
|
||||
self.assert_element_visible("hero_title")
|
||||
return self
|
||||
```
|
||||
|
||||
### 2. 测试数据管理
|
||||
|
||||
使用数据生成器创建测试数据:
|
||||
|
||||
```python
|
||||
from utils.data_generator import get_test_data_generator
|
||||
|
||||
def test_contact_form(test_data_generator):
|
||||
data = test_data_generator.generate_contact_form_data()
|
||||
page.fill_contact_form(data)
|
||||
```
|
||||
|
||||
### 3. 断言辅助
|
||||
|
||||
使用断言助手进行验证:
|
||||
|
||||
```python
|
||||
def test_example(page):
|
||||
assert_(page).title_contains("首页")
|
||||
assert_(page).element_visible("#main")
|
||||
```
|
||||
|
||||
### 4. 截图和日志
|
||||
|
||||
测试失败时自动截图,并记录详细日志:
|
||||
|
||||
```python
|
||||
def test_with_logging(page):
|
||||
logger = get_logger()
|
||||
logger.log_action("执行测试步骤")
|
||||
# 测试代码
|
||||
```
|
||||
|
||||
### 5. 性能监控
|
||||
|
||||
使用内置的性能监控:
|
||||
|
||||
```python
|
||||
def test_performance(page):
|
||||
performance = page.verify_page_performance()
|
||||
assert performance["pageLoadTime"] < 5000
|
||||
```
|
||||
|
||||
## 报告示例
|
||||
|
||||
测试完成后,报告将保存在 `reports/` 目录:
|
||||
|
||||
```
|
||||
reports/
|
||||
├── html/
|
||||
│ └── test_report.html # HTML 格式报告
|
||||
├── json/
|
||||
│ └── test_results.json # JSON 格式结果
|
||||
└── screenshots/
|
||||
└── test_failed.png # 失败截图
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
**1. 浏览器安装失败**
|
||||
|
||||
```bash
|
||||
playwright install --with-deps
|
||||
```
|
||||
|
||||
**2. 依赖安装失败**
|
||||
|
||||
```bash
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**3. 测试超时**
|
||||
|
||||
检查 `.env` 中的 `PAGE_LOAD_TIMEOUT` 设置
|
||||
|
||||
**4. 页面元素定位失败**
|
||||
|
||||
使用开发者工具检查元素选择器是否正确
|
||||
|
||||
### 获取帮助
|
||||
|
||||
- 查看详细日志: 运行 `python scripts/run_tests.py -v`
|
||||
- 查看 Playwright 文档: https://playwright.dev/python/
|
||||
- 查看 pytest 文档: https://docs.pytest.org/
|
||||
|
||||
## 许可证
|
||||
|
||||
本测试框架基于 MIT 许可证开源。
|
||||
@@ -0,0 +1 @@
|
||||
# Config模块
|
||||
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
浏览器配置模块
|
||||
提供跨浏览器测试的配置和工具函数
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from playwright.sync_api import Browser, BrowserType, BrowserContext, Page
|
||||
from playwright.sync_api import Error as PlaywrightError
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from config.settings import get_settings
|
||||
|
||||
|
||||
class BrowserTypeEnum(Enum):
|
||||
"""支持的浏览器类型"""
|
||||
CHROMIUM = "chromium"
|
||||
FIREFOX = "firefox"
|
||||
WEBKIT = "webkit"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserCapabilities:
|
||||
"""浏览器能力描述"""
|
||||
name: str
|
||||
display_name: str
|
||||
channel: Optional[str]
|
||||
is_headless_supported: bool
|
||||
default_viewport: tuple
|
||||
user_agent: str
|
||||
description: str
|
||||
|
||||
|
||||
class BrowserConfigManager:
|
||||
"""浏览器配置管理器"""
|
||||
|
||||
# 浏览器能力定义
|
||||
BROWSER_CAPABILITIES: Dict[str, BrowserCapabilities] = {
|
||||
"chromium": BrowserCapabilities(
|
||||
name="chromium",
|
||||
display_name="Chrome/Chromium",
|
||||
channel="chrome",
|
||||
is_headless_supported=True,
|
||||
default_viewport=(1920, 1080),
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
description="Google Chrome / Chromium浏览器"
|
||||
),
|
||||
"firefox": BrowserCapabilities(
|
||||
name="firefox",
|
||||
display_name="Firefox",
|
||||
channel=None,
|
||||
is_headless_supported=True,
|
||||
default_viewport=(1920, 1080),
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) "
|
||||
"Gecko/20100101 Firefox/121.0"
|
||||
),
|
||||
description="Mozilla Firefox浏览器"
|
||||
),
|
||||
"webkit": BrowserCapabilities(
|
||||
name="webkit",
|
||||
display_name="WebKit (Safari)",
|
||||
channel=None,
|
||||
is_headless_supported=True,
|
||||
default_viewport=(1920, 1080),
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15"
|
||||
),
|
||||
description="Apple WebKit (Safari)浏览器"
|
||||
)
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self._playwright: Optional[sync_playwright] = None
|
||||
self._browser: Optional[Browser] = None
|
||||
self._context: Optional[BrowserContext] = None
|
||||
|
||||
def _ensure_playwright(self) -> sync_playwright:
|
||||
"""确保Playwright实例已启动"""
|
||||
if self._playwright is None:
|
||||
self._playwright = sync_playwright().start()
|
||||
return self._playwright
|
||||
|
||||
def get_available_browsers(self) -> List[str]:
|
||||
"""获取可用的浏览器列表"""
|
||||
available = []
|
||||
p = self._ensure_playwright()
|
||||
|
||||
browser_map = {
|
||||
"chromium": p.chromium,
|
||||
"firefox": p.firefox,
|
||||
"webkit": p.webkit
|
||||
}
|
||||
|
||||
for name in browser_map:
|
||||
try:
|
||||
available.append(name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return available
|
||||
|
||||
def _get_browser_type(self, browser_name: str):
|
||||
"""获取浏览器类型"""
|
||||
p = self._ensure_playwright()
|
||||
browser_map = {
|
||||
"chromium": p.chromium,
|
||||
"firefox": p.firefox,
|
||||
"webkit": p.webkit
|
||||
}
|
||||
return browser_map.get(browser_name)
|
||||
|
||||
def launch_browser(
|
||||
self,
|
||||
browser_name: str = "chromium",
|
||||
headless: bool = False,
|
||||
viewport: Optional[Tuple[int, int]] = None,
|
||||
**kwargs
|
||||
) -> Browser:
|
||||
"""启动浏览器"""
|
||||
capabilities = self.BROWSER_CAPABILITIES.get(browser_name)
|
||||
if not capabilities:
|
||||
raise ValueError(f"不支持的浏览器类型: {browser_name}")
|
||||
|
||||
viewport = viewport or (self.settings.viewport_width, self.settings.viewport_height)
|
||||
|
||||
launch_args = self._get_launch_arguments(browser_name, headless)
|
||||
|
||||
browser_type = self._get_browser_type(browser_name)
|
||||
if not browser_type:
|
||||
raise ValueError(f"不支持的浏览器类型: {browser_name}")
|
||||
|
||||
self._browser = browser_type.launch(
|
||||
headless=headless,
|
||||
args=launch_args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
return self._browser
|
||||
|
||||
def _get_launch_arguments(self, browser_name: str, headless: bool) -> List[str]:
|
||||
"""获取浏览器启动参数"""
|
||||
args = []
|
||||
|
||||
if browser_name == "chromium":
|
||||
args.extend([
|
||||
"--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--disable-sync",
|
||||
"--disable-translate",
|
||||
"--metrics-recording-only",
|
||||
"--mute-audio",
|
||||
"--no-first-run",
|
||||
"--safebrowsing-disable-auto-update",
|
||||
"--ignore-certificate-errors",
|
||||
"--ignore-ssl-errors",
|
||||
"--disable-dev-shm-usage",
|
||||
])
|
||||
|
||||
if headless:
|
||||
args.extend([
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
])
|
||||
|
||||
elif browser_name == "firefox":
|
||||
if headless:
|
||||
args.extend(["-headless"])
|
||||
|
||||
args.extend([
|
||||
"-profile",
|
||||
"/tmp/firefox-profile",
|
||||
])
|
||||
|
||||
elif browser_name == "webkit":
|
||||
if headless:
|
||||
args.append("--headless")
|
||||
|
||||
args.extend([
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
])
|
||||
|
||||
return args
|
||||
|
||||
def create_context(
|
||||
self,
|
||||
browser: Browser,
|
||||
viewport: Optional[Tuple[int, int]] = None,
|
||||
**context_kwargs
|
||||
) -> BrowserContext:
|
||||
"""创建浏览器上下文"""
|
||||
viewport = viewport or (self.settings.viewport_width, self.settings.viewport_height)
|
||||
|
||||
capabilities = self.BROWSER_CAPABILITIES.get(
|
||||
browser.browser_type.name,
|
||||
self.BROWSER_CAPABILITIES["chromium"]
|
||||
)
|
||||
|
||||
context_options = {
|
||||
"viewport": {
|
||||
"width": viewport[0],
|
||||
"height": viewport[1]
|
||||
},
|
||||
"user_agent": capabilities.user_agent,
|
||||
"locale": "zh-CN",
|
||||
"timezone_id": "Asia/Shanghai",
|
||||
**context_kwargs
|
||||
}
|
||||
|
||||
self._context = browser.new_context(**context_options)
|
||||
|
||||
return self._context
|
||||
|
||||
def create_browser_session(
|
||||
self,
|
||||
browser_name: str = "chromium",
|
||||
headless: bool = False,
|
||||
viewport: Optional[Tuple[int, int]] = None
|
||||
) -> Tuple[Browser, BrowserContext, Page]:
|
||||
"""创建完整的浏览器会话"""
|
||||
browser = self.launch_browser(browser_name, headless, viewport)
|
||||
context = self.create_context(browser, viewport)
|
||||
page = context.new_page()
|
||||
|
||||
return browser, context, page
|
||||
|
||||
def close_browser(self) -> None:
|
||||
"""关闭浏览器和上下文"""
|
||||
if self._context:
|
||||
try:
|
||||
self._context.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._context = None
|
||||
|
||||
if self._browser:
|
||||
try:
|
||||
self._browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._browser = None
|
||||
|
||||
if self._playwright:
|
||||
try:
|
||||
self._playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._playwright = None
|
||||
|
||||
def __enter__(self) -> 'BrowserConfigManager':
|
||||
"""上下文管理器入口"""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
"""上下文管理器退出"""
|
||||
self.close_browser()
|
||||
|
||||
|
||||
def get_browser_factory() -> BrowserConfigManager:
|
||||
"""获取浏览器工厂实例"""
|
||||
return BrowserConfigManager()
|
||||
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
测试框架配置模块
|
||||
提供全局配置管理和环境变量加载功能
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserConfig:
|
||||
"""浏览器配置类"""
|
||||
name: str
|
||||
headless: bool = False
|
||||
viewport_width: int = 1920
|
||||
viewport_height: int = 1080
|
||||
device_scale_factor: float = 1.0
|
||||
is_mobile: bool = False
|
||||
has_touch: bool = False
|
||||
locale: str = "zh-CN"
|
||||
timezone_id: str = "Asia/Shanghai"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PerformanceThresholds:
|
||||
"""性能指标阈值配置"""
|
||||
page_load_time: int = 3000 # 毫秒
|
||||
first_contentful_paint: int = 1500
|
||||
largest_contentful_paint: int = 2500
|
||||
time_to_interactive: int = 3000
|
||||
first_byte: int = 500
|
||||
dom_content_loaded: int = 1000
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResponsiveBreakpoints:
|
||||
"""响应式测试断点配置"""
|
||||
mobile: Dict[str, int] = field(default_factory=lambda: {"width": 375, "height": 667})
|
||||
tablet: Dict[str, int] = field(default_factory=lambda: {"width": 768, "height": 1024})
|
||||
desktop: Dict[str, int] = field(default_factory=lambda: {"width": 1920, "height": 1080})
|
||||
wide: Dict[str, int] = field(default_factory=lambda: {"width": 2560, "height": 1440})
|
||||
|
||||
|
||||
class Settings:
|
||||
"""全局配置管理类"""
|
||||
|
||||
_instance: Optional['Settings'] = None
|
||||
_initialized: bool = False
|
||||
|
||||
def __new__(cls) -> 'Settings':
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not Settings._initialized:
|
||||
self._load_config()
|
||||
Settings._initialized = True
|
||||
|
||||
def _load_config(self) -> None:
|
||||
"""加载所有配置"""
|
||||
# 基础配置
|
||||
self.base_url = self._get_env("TEST_BASE_URL", "http://localhost:3000")
|
||||
self.fallback_url = self._get_env("TEST_BASE_URL_FALLBACK", "")
|
||||
self.test_env = self._get_env("TEST_ENV", "development")
|
||||
|
||||
# 浏览器配置
|
||||
self.default_browser = self._get_env("DEFAULT_BROWSER", "chromium")
|
||||
self.headless_mode = self._get_env("HEADLESS_MODE", "false").lower() == "true"
|
||||
self.viewport_width = int(self._get_env("DEFAULT_VIEWPORT_WIDTH", 1920))
|
||||
self.viewport_height = int(self._get_env("DEFAULT_VIEWPORT_HEIGHT", 1080))
|
||||
|
||||
# 超时配置
|
||||
self.max_retries = int(self._get_env("MAX_RETRIES", 2))
|
||||
self.test_timeout = int(self._get_env("TEST_TIMEOUT", 60))
|
||||
self.page_load_timeout = int(self._get_env("PAGE_LOAD_TIMEOUT", 30000))
|
||||
self.element_timeout = int(self._get_env("ELEMENT_TIMEOUT", 10000))
|
||||
|
||||
# 并行配置
|
||||
self.parallel_workers = int(self._get_env("PARALLEL_WORKERS", 4))
|
||||
|
||||
# 截图和视频
|
||||
self.screenshot_on_failure = self._get_env("SCREENSHOT_ON_FAILURE", "true").lower() == "true"
|
||||
self.video_recording = self._get_env("VIDEO_RECORDING", "false").lower() == "true"
|
||||
self.screenshots_dir = self._get_env("SCREENSHOTS_DIR", "reports/screenshots")
|
||||
self.videos_dir = self._get_env("VIDEOS_DIR", "reports/videos")
|
||||
|
||||
# 日志配置
|
||||
self.log_level = self._get_env("LOG_LEVEL", "INFO")
|
||||
self.log_file = self._get_env("LOG_FILE", "reports/e2e_tests.log")
|
||||
self.console_log = self._get_env("CONSOLE_LOG", "true").lower() == "true"
|
||||
|
||||
# 报告配置
|
||||
self.report_title = self._get_env("REPORT_TITLE", "Novalon Website E2E测试报告")
|
||||
self.report_description = self._get_env(
|
||||
"REPORT_DESCRIPTION",
|
||||
"Novalon Website端到端自动化测试报告"
|
||||
)
|
||||
self.junit_xml_report = self._get_env("JUNIT_XML_REPORT", "false").lower() == "true"
|
||||
self.junit_xml_path = self._get_env("JUNIT_XML_PATH", "reports/test-results.xml")
|
||||
|
||||
# 性能阈值
|
||||
self._load_performance_thresholds()
|
||||
|
||||
# 响应式断点
|
||||
self._load_responsive_breakpoints()
|
||||
|
||||
# 浏览器列表
|
||||
self.browsers_to_test = ["chromium", "firefox", "webkit"]
|
||||
|
||||
# 测试数据
|
||||
self._load_test_form_data()
|
||||
|
||||
# CI/CD配置
|
||||
self.ci = self._get_env("CI", "false").lower() == "true"
|
||||
self.git_branch = self._get_env("GIT_BRANCH", "")
|
||||
self.git_commit = self._get_env("GIT_COMMIT", "")
|
||||
self.git_repository = self._get_env("GIT_REPOSITORY", "")
|
||||
|
||||
# 创建必要的目录
|
||||
self._create_directories()
|
||||
|
||||
def _get_env(self, key: str, default: str) -> str:
|
||||
"""获取环境变量"""
|
||||
return os.environ.get(key, default)
|
||||
|
||||
def _load_performance_thresholds(self) -> None:
|
||||
"""加载性能阈值配置"""
|
||||
import json
|
||||
thresholds_str = self._get_env(
|
||||
"PERFORMANCE_THRESHOLDS",
|
||||
'{"page_load_time": 3000, "first_contentful_paint": 1500, '
|
||||
'"largest_contentful_paint": 2500, "time_to_interactive": 3000, '
|
||||
'"first_byte": 500, "dom_content_loaded": 1000}'
|
||||
)
|
||||
try:
|
||||
thresholds = json.loads(thresholds_str)
|
||||
self.performance_thresholds = PerformanceThresholds(**thresholds)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
self.performance_thresholds = PerformanceThresholds()
|
||||
|
||||
def _load_responsive_breakpoints(self) -> None:
|
||||
"""加载响应式断点配置"""
|
||||
import json
|
||||
breakpoints_str = self._get_env(
|
||||
"RESPONSIVE_BREAKPOINTS",
|
||||
'{"mobile": {"width": 375, "height": 667}, '
|
||||
'"tablet": {"width": 768, "height": 1024}, '
|
||||
'"desktop": {"width": 1920, "height": 1080}, '
|
||||
'"wide": {"width": 2560, "height": 1440}}'
|
||||
)
|
||||
try:
|
||||
breakpoints = json.loads(breakpoints_str)
|
||||
self.responsive_breakpoints = ResponsiveBreakpoints(**breakpoints)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
self.responsive_breakpoints = ResponsiveBreakpoints()
|
||||
|
||||
def _load_test_form_data(self) -> None:
|
||||
"""加载测试表单数据"""
|
||||
import json
|
||||
form_data_str = self._get_env(
|
||||
"TEST_FORM_DATA",
|
||||
'{"valid": {"name": "测试用户", "phone": "13800138000", '
|
||||
'"email": "test@example.com", "subject": "测试主题", '
|
||||
'"message": "这是一条测试消息,用于验证表单功能是否正常。"}, '
|
||||
'"invalid": {"email": "invalid-email", "phone": "123"}}'
|
||||
)
|
||||
try:
|
||||
self.test_form_data = json.loads(form_data_str)
|
||||
except json.JSONDecodeError:
|
||||
self.test_form_data = {
|
||||
"valid": {
|
||||
"name": "测试用户",
|
||||
"phone": "13800138000",
|
||||
"email": "test@example.com",
|
||||
"subject": "测试主题",
|
||||
"message": "这是一条测试消息,用于验证表单功能是否正常。"
|
||||
},
|
||||
"invalid": {
|
||||
"email": "invalid-email",
|
||||
"phone": "123"
|
||||
}
|
||||
}
|
||||
|
||||
def _create_directories(self) -> None:
|
||||
"""创建必要的目录"""
|
||||
base_dirs = [
|
||||
self.screenshots_dir,
|
||||
self.videos_dir,
|
||||
"reports",
|
||||
"logs"
|
||||
]
|
||||
for dir_path in base_dirs:
|
||||
Path(dir_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_browser_config(self, browser_name: Optional[str] = None) -> BrowserConfig:
|
||||
"""获取浏览器配置"""
|
||||
name = browser_name or self.default_browser
|
||||
|
||||
return BrowserConfig(
|
||||
name=name,
|
||||
headless=self.headless_mode,
|
||||
viewport_width=self.viewport_width,
|
||||
viewport_height=self.viewport_height
|
||||
)
|
||||
|
||||
def get_test_data_path(self, filename: str) -> Path:
|
||||
"""获取测试数据文件路径"""
|
||||
return Path(__file__).parent / "test_data" / filename
|
||||
|
||||
def get_reports_path(self, filename: str = "") -> Path:
|
||||
"""获取报告目录路径"""
|
||||
reports_dir = Path("reports")
|
||||
reports_dir.mkdir(parents=True, exist_ok=True)
|
||||
if filename:
|
||||
return reports_dir / filename
|
||||
return reports_dir
|
||||
|
||||
def is_ci_environment(self) -> bool:
|
||||
"""检查是否为CI环境"""
|
||||
return self.ci or os.environ.get("CI", "").lower() in ["true", "1"]
|
||||
|
||||
def get_base_url(self) -> str:
|
||||
"""获取测试基础URL,自动降级到备用URL"""
|
||||
# 首先检查基础URL是否可用
|
||||
if self._check_url_accessible(self.base_url):
|
||||
return self.base_url
|
||||
|
||||
# 如果基础URL不可用,尝试备用URL
|
||||
if self.fallback_url and self._check_url_accessible(self.fallback_url):
|
||||
return self.fallback_url
|
||||
|
||||
# 如果都不可用,返回基础URL(测试时会报错)
|
||||
return self.base_url
|
||||
|
||||
def _check_url_accessible(self, url: str) -> bool:
|
||||
"""检查URL是否可访问"""
|
||||
import requests
|
||||
try:
|
||||
response = requests.get(url, timeout=5)
|
||||
return response.status_code < 500
|
||||
except requests.RequestException:
|
||||
return False
|
||||
|
||||
|
||||
# 全局配置实例
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""获取全局配置实例"""
|
||||
return settings
|
||||
@@ -0,0 +1 @@
|
||||
# Pages模块
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
页面对象基类
|
||||
提供页面对象模式的基础框架
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from playwright.sync_api import Page, Locator, FrameLocator
|
||||
from playwright.sync_api import Error as PlaywrightError, TimeoutError as PlaywrightTimeoutError
|
||||
|
||||
from config.settings import get_settings
|
||||
from utils.logger import get_logger
|
||||
from utils.helpers import ElementHelper, PageHelper, AssertionHelper, UrlHelper
|
||||
|
||||
|
||||
class BasePage:
|
||||
"""页面对象基类"""
|
||||
|
||||
def __init__(self, page: Page, base_url: Optional[str] = None):
|
||||
"""
|
||||
初始化页面对象
|
||||
|
||||
Args:
|
||||
page: Playwright Page实例
|
||||
base_url: 基础URL
|
||||
"""
|
||||
self.page = page
|
||||
self.base_url = base_url or get_settings().get_base_url()
|
||||
self.logger = get_logger()
|
||||
|
||||
# 初始化辅助类
|
||||
self.element = ElementHelper(page)
|
||||
self.page_helper = PageHelper(page)
|
||||
self.assertion = AssertionHelper(page)
|
||||
self.url_helper = UrlHelper()
|
||||
|
||||
# 页面URL路径(子类覆盖)
|
||||
self.path: Optional[str] = None
|
||||
|
||||
# 页面标题(子类覆盖)
|
||||
self.title: Optional[str] = None
|
||||
|
||||
# 页面元素选择器(子类覆盖)
|
||||
self.selectors: Dict[str, str] = {}
|
||||
|
||||
def _resolve_selector(self, selector: str) -> str:
|
||||
"""解析选择器名称为实际选择器字符串"""
|
||||
if selector in self.selectors:
|
||||
return self.selectors[selector]
|
||||
return selector
|
||||
|
||||
def _get_full_url(self, path: str) -> str:
|
||||
"""获取完整URL"""
|
||||
if self.url_helper.is_absolute_url(path):
|
||||
return path
|
||||
return urljoin(self.base_url, path)
|
||||
|
||||
def navigate(self, path: Optional[str] = None, **kwargs) -> 'BasePage':
|
||||
"""
|
||||
导航到页面
|
||||
|
||||
Args:
|
||||
path: 页面路径,如果为None则使用self.path
|
||||
**kwargs: 传递给page.goto的参数
|
||||
|
||||
Returns:
|
||||
self
|
||||
"""
|
||||
path = path or self.path
|
||||
if not path:
|
||||
raise ValueError("页面路径未指定")
|
||||
|
||||
url = self._get_full_url(path)
|
||||
self.logger.log_action(f"导航到页面: {url}")
|
||||
|
||||
self.page_helper.navigate(url, **kwargs)
|
||||
|
||||
return self
|
||||
|
||||
def reload(self) -> 'BasePage':
|
||||
"""刷新页面"""
|
||||
self.page_helper.reload_page()
|
||||
return self
|
||||
|
||||
def go_back(self) -> 'BasePage':
|
||||
"""返回上一页"""
|
||||
self.page_helper.go_back()
|
||||
return self
|
||||
|
||||
def go_forward(self) -> 'BasePage':
|
||||
"""前进到下一页"""
|
||||
self.page_helper.go_forward()
|
||||
return self
|
||||
|
||||
def get_url(self) -> str:
|
||||
"""获取当前URL"""
|
||||
return self.page_helper.get_current_url()
|
||||
|
||||
def get_title(self) -> str:
|
||||
"""获取页面标题"""
|
||||
return self.page_helper.get_page_title()
|
||||
|
||||
def wait_for_load(self, state: str = "networkidle") -> 'BasePage':
|
||||
"""等待页面加载完成"""
|
||||
self.page_helper.wait_for_load_state(state)
|
||||
return self
|
||||
|
||||
def wait_for_selector(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None,
|
||||
state: str = "visible"
|
||||
) -> Locator:
|
||||
"""等待选择器"""
|
||||
return self.element.wait_for_selector(selector, timeout, state)
|
||||
|
||||
def scroll_to_top(self) -> 'BasePage':
|
||||
"""滚动到页面顶部"""
|
||||
self.page_helper.scroll_to_top()
|
||||
return self
|
||||
|
||||
def scroll_to_bottom(self) -> 'BasePage':
|
||||
"""滚动到页面底部"""
|
||||
self.page_helper.scroll_to_bottom()
|
||||
return self
|
||||
|
||||
def scroll_to_element(self, selector: str) -> 'BasePage':
|
||||
"""滚动到指定元素"""
|
||||
self.page_helper.scroll_to_element(selector)
|
||||
return self
|
||||
|
||||
def take_screenshot(
|
||||
self,
|
||||
name: str,
|
||||
full_page: bool = False
|
||||
) -> str:
|
||||
"""截取截图"""
|
||||
return self.page_helper.take_screenshot(
|
||||
f"{name}_{self._get_timestamp()}.png",
|
||||
full_page=full_page
|
||||
)
|
||||
|
||||
def execute_js(self, script: str, *args) -> Any:
|
||||
"""执行JavaScript"""
|
||||
return self.page_helper.execute_javascript(script, *args)
|
||||
|
||||
def _get_timestamp(self) -> str:
|
||||
"""获取时间戳"""
|
||||
from datetime import datetime
|
||||
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
def _find(self, selector: str, timeout: Optional[int] = None) -> Locator:
|
||||
"""查找元素"""
|
||||
resolved_selector = self._resolve_selector(selector)
|
||||
return self.element.find_element(resolved_selector, timeout)
|
||||
|
||||
def _find_all(self, selector: str) -> List[Locator]:
|
||||
"""查找所有匹配的元素"""
|
||||
resolved_selector = self._resolve_selector(selector)
|
||||
return self.element.find_elements(resolved_selector)
|
||||
|
||||
def _click(self, selector: str, **kwargs) -> 'BasePage':
|
||||
"""点击元素"""
|
||||
resolved_selector = self._resolve_selector(selector)
|
||||
self.element.click_element(resolved_selector, **kwargs)
|
||||
return self
|
||||
|
||||
def _fill(self, selector: str, value: str, **kwargs) -> 'BasePage':
|
||||
"""填充输入框"""
|
||||
resolved_selector = self._resolve_selector(selector)
|
||||
self.element.fill_input(resolved_selector, value, **kwargs)
|
||||
return self
|
||||
|
||||
def _type(self, selector: str, text: str, **kwargs) -> 'BasePage':
|
||||
"""输入文本"""
|
||||
resolved_selector = self._resolve_selector(selector)
|
||||
self.element.type_text(resolved_selector, text, **kwargs)
|
||||
return self
|
||||
|
||||
def _get_text(self, selector: str, **kwargs) -> str:
|
||||
"""获取元素文本"""
|
||||
resolved_selector = self._resolve_selector(selector)
|
||||
return self.element.get_element_text(resolved_selector, **kwargs)
|
||||
|
||||
def _get_attr(self, selector: str, attribute: str, **kwargs) -> Optional[str]:
|
||||
"""获取元素属性"""
|
||||
resolved_selector = self._resolve_selector(selector)
|
||||
return self.element.get_element_attribute(resolved_selector, attribute, **kwargs)
|
||||
|
||||
def _is_visible(self, selector: str, **kwargs) -> bool:
|
||||
"""检查元素是否可见"""
|
||||
resolved_selector = self._resolve_selector(selector)
|
||||
return self.element.is_element_visible(resolved_selector, **kwargs)
|
||||
|
||||
def _is_enabled(self, selector: str, **kwargs) -> bool:
|
||||
"""检查元素是否可用"""
|
||||
resolved_selector = self._resolve_selector(selector)
|
||||
return self.element.is_element_enabled(resolved_selector, **kwargs)
|
||||
|
||||
# 断言方法
|
||||
def assert_title_contains(self, expected: str, message: Optional[str] = None) -> 'BasePage':
|
||||
"""断言标题包含预期文本"""
|
||||
self.assertion.assert_page_title_contains(expected, message)
|
||||
return self
|
||||
|
||||
def assert_url_contains(self, expected: str, message: Optional[str] = None) -> 'BasePage':
|
||||
"""断言URL包含预期文本"""
|
||||
self.assertion.assert_url_contains(expected, message)
|
||||
return self
|
||||
|
||||
def assert_url_equals(self, expected: str, message: Optional[str] = None) -> 'BasePage':
|
||||
"""断言URL等于预期URL"""
|
||||
self.assertion.assert_url_equals(expected, message)
|
||||
return self
|
||||
|
||||
def assert_element_visible(self, selector: str, **kwargs) -> 'BasePage':
|
||||
"""断言元素可见"""
|
||||
resolved_selector = self._resolve_selector(selector)
|
||||
self.assertion.assert_element_visible(resolved_selector, **kwargs)
|
||||
return self
|
||||
|
||||
def assert_element_hidden(self, selector: str, **kwargs) -> 'BasePage':
|
||||
"""断言元素隐藏"""
|
||||
self.assertion.assert_element_hidden(selector, **kwargs)
|
||||
return self
|
||||
|
||||
def assert_element_text_contains(
|
||||
self,
|
||||
selector: str,
|
||||
expected: str,
|
||||
**kwargs
|
||||
) -> 'BasePage':
|
||||
"""断言元素文本包含预期文本"""
|
||||
self.assertion.assert_element_text_contains(selector, expected, **kwargs)
|
||||
return self
|
||||
|
||||
def assert_element_text_equals(
|
||||
self,
|
||||
selector: str,
|
||||
expected: str,
|
||||
**kwargs
|
||||
) -> 'BasePage':
|
||||
"""断言元素文本等于预期文本"""
|
||||
self.assertion.assert_element_text_equals(selector, expected, **kwargs)
|
||||
return self
|
||||
|
||||
def assert_element_count(
|
||||
self,
|
||||
selector: str,
|
||||
expected: int,
|
||||
message: Optional[str] = None
|
||||
) -> 'BasePage':
|
||||
"""断言元素数量"""
|
||||
self.assertion.assert_element_count(selector, expected, message)
|
||||
return self
|
||||
|
||||
def assert_element_attribute_equals(
|
||||
self,
|
||||
selector: str,
|
||||
attribute: str,
|
||||
expected: str,
|
||||
**kwargs
|
||||
) -> 'BasePage':
|
||||
"""断言元素属性等于预期值"""
|
||||
self.assertion.assert_element_attribute_equals(
|
||||
selector, attribute, expected, **kwargs
|
||||
)
|
||||
return self
|
||||
|
||||
def should_have_url(self, url: str, **kwargs) -> 'BasePage':
|
||||
"""检查URL"""
|
||||
self.assert_url_equals(url, **kwargs)
|
||||
return self
|
||||
|
||||
def should_have_title(self, title: str, **kwargs) -> 'BasePage':
|
||||
"""检查标题"""
|
||||
self.assert_title_contains(title, **kwargs)
|
||||
return self
|
||||
|
||||
def should_contain_text(self, text: str, **kwargs) -> 'BasePage':
|
||||
"""检查页面包含文本"""
|
||||
self.page.wait_for_load_state("domcontentloaded")
|
||||
content = self.page_helper.get_page_source()
|
||||
assert text in content, f"页面不包含文本: {text}"
|
||||
return self
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}: {self.path or 'unknown'}>"
|
||||
|
||||
|
||||
class PageRegistry:
|
||||
"""页面注册表,用于管理页面对象"""
|
||||
|
||||
_instance: Optional['PageRegistry'] = None
|
||||
_pages: Dict[str, BasePage] = {}
|
||||
|
||||
def __new__(cls) -> 'PageRegistry':
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def register(self, name: str, page: BasePage) -> None:
|
||||
"""注册页面"""
|
||||
self._pages[name] = page
|
||||
|
||||
def get(self, name: str) -> Optional[BasePage]:
|
||||
"""获取页面"""
|
||||
return self._pages.get(name)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""清空注册表"""
|
||||
self._pages.clear()
|
||||
|
||||
|
||||
def get_page_registry() -> PageRegistry:
|
||||
"""获取页面注册表"""
|
||||
return PageRegistry()
|
||||
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
联系页面测试模块
|
||||
提供联系页面功能测试
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from playwright.sync_api import Page, Locator, expect
|
||||
|
||||
from pages.base_page import BasePage
|
||||
from config.settings import get_settings
|
||||
from utils.logger import get_logger
|
||||
from utils.helpers import ElementHelper, PageHelper, AssertionHelper
|
||||
|
||||
|
||||
class ContactPage(BasePage):
|
||||
"""联系页面对象"""
|
||||
|
||||
def __init__(self, page: Page, base_url: Optional[str] = None):
|
||||
"""初始化联系页面"""
|
||||
super().__init__(page, base_url)
|
||||
|
||||
self.path = "/contact"
|
||||
self.title = "联系我们"
|
||||
|
||||
self.selectors = {
|
||||
# 页面标题
|
||||
"page_badge": "[class*='badge']",
|
||||
"page_title": "h1",
|
||||
"page_description": "p.text-gray-600",
|
||||
|
||||
# 联系信息卡片 - 根据实际页面结构
|
||||
"contact_info_card": "div.grid > div:first-child",
|
||||
"company_address": "text=公司地址 >> xpath=../following-sibling::p",
|
||||
"company_phone": "text=联系电话 >> xpath=../following-sibling::p",
|
||||
"company_email": "text=电子邮箱 >> xpath=../following-sibling::p",
|
||||
"working_hours": "text=工作时间",
|
||||
|
||||
# 联系表单 - 使用ID选择器
|
||||
"contact_form": "form",
|
||||
"form_name_input": "#name",
|
||||
"form_phone_input": "#phone",
|
||||
"form_email_input": "#email",
|
||||
"form_subject_input": "#subject",
|
||||
"form_message_textarea": "#message",
|
||||
"form_submit_button": "button[type='submit']",
|
||||
|
||||
# 表单字段标签
|
||||
"name_label": "label[for='name']",
|
||||
"phone_label": "label[for='phone']",
|
||||
"email_label": "label[for='email']",
|
||||
"subject_label": "label[for='subject']",
|
||||
"message_label": "label[for='message']",
|
||||
|
||||
# 成功状态
|
||||
"success_message": "text=消息已发送",
|
||||
"success_icon": "svg[class*='text-green']",
|
||||
|
||||
# 加载状态
|
||||
"submitting_loader": "text=发送中",
|
||||
|
||||
# 返回链接
|
||||
"back_link": "a:has-text('返回'), a.back"
|
||||
}
|
||||
|
||||
self.logger = get_logger()
|
||||
|
||||
def navigate(self, **kwargs) -> 'ContactPage':
|
||||
"""导航到联系页面"""
|
||||
super().navigate(**kwargs)
|
||||
self.wait_for_load()
|
||||
return self
|
||||
|
||||
def verify_page_loaded(self) -> 'ContactPage':
|
||||
"""验证页面加载完成"""
|
||||
self.logger.section("验证联系页面加载")
|
||||
|
||||
self.assert_element_visible("page_title", timeout=15000)
|
||||
self.assert_element_visible("contact_form", timeout=15000)
|
||||
|
||||
self.logger.info("✅ 联系页面加载验证通过")
|
||||
return self
|
||||
|
||||
def verify_page_structure(self) -> 'ContactPage':
|
||||
"""验证页面结构"""
|
||||
self.logger.section("验证页面结构")
|
||||
|
||||
# 检查页面标题区域
|
||||
self.assert_element_visible("page_title")
|
||||
|
||||
# 检查联系信息 - 使用更通用的选择器
|
||||
self._verify_contact_info_exists()
|
||||
|
||||
# 检查表单
|
||||
self.assert_element_visible("contact_form")
|
||||
|
||||
self.logger.info("✅ 页面结构验证通过")
|
||||
return self
|
||||
|
||||
def _verify_contact_info_exists(self) -> bool:
|
||||
"""验证联系信息存在"""
|
||||
# 检查是否包含联系信息文本
|
||||
page_text = self.page.content()
|
||||
has_address = "公司地址" in page_text
|
||||
has_phone = "联系电话" in page_text
|
||||
has_email = "电子邮箱" in page_text
|
||||
|
||||
assert has_address, "未找到公司地址信息"
|
||||
assert has_phone, "未找到联系电话信息"
|
||||
assert has_email, "未找到电子邮箱信息"
|
||||
|
||||
return True
|
||||
|
||||
def verify_company_info(self) -> 'ContactPage':
|
||||
"""验证公司信息"""
|
||||
self.logger.section("验证公司信息")
|
||||
|
||||
# 获取页面内容
|
||||
page_content = self.page.content()
|
||||
|
||||
# 验证信息存在
|
||||
assert "公司地址" in page_content, "未找到公司地址"
|
||||
assert "联系电话" in page_content, "未找到联系电话"
|
||||
assert "电子邮箱" in page_content, "未找到电子邮箱"
|
||||
|
||||
self.logger.info("✅ 公司信息验证通过")
|
||||
return self
|
||||
|
||||
def verify_form_fields(self) -> 'ContactPage':
|
||||
"""验证表单字段"""
|
||||
self.logger.section("验证表单字段")
|
||||
|
||||
required_fields = [
|
||||
("form_name_input", "姓名"),
|
||||
("form_email_input", "邮箱"),
|
||||
("form_subject_input", "主题"),
|
||||
("form_message_textarea", "消息")
|
||||
]
|
||||
|
||||
for selector, field_name in required_fields:
|
||||
self.assert_element_visible(selector, timeout=5000)
|
||||
|
||||
# 检查必填标记
|
||||
label = self.page.locator(f"label[for='{selector.replace('#', '')}']")
|
||||
if label.count() > 0:
|
||||
label_text = label.text_content()
|
||||
if "*" in (label_text or ""):
|
||||
self.logger.info(f"{field_name} 为必填项")
|
||||
|
||||
# 检查可选字段
|
||||
self.assert_element_visible("form_phone_input")
|
||||
|
||||
self.logger.info("✅ 表单字段验证通过")
|
||||
return self
|
||||
|
||||
def fill_contact_form(self, data: Dict[str, str]) -> 'ContactPage':
|
||||
"""填充联系表单"""
|
||||
self.logger.section("填充联系表单")
|
||||
|
||||
# 姓名
|
||||
if "name" in data:
|
||||
self._fill("form_name_input", data["name"])
|
||||
self.logger.log_action(f"填写姓名: {data['name']}")
|
||||
|
||||
# 电话
|
||||
if "phone" in data:
|
||||
self._fill("form_phone_input", data["phone"])
|
||||
self.logger.log_action(f"填写电话: {data['phone']}")
|
||||
|
||||
# 邮箱
|
||||
if "email" in data:
|
||||
self._fill("form_email_input", data["email"])
|
||||
self.logger.log_action(f"填写邮箱: {data['email']}")
|
||||
|
||||
# 主题
|
||||
if "subject" in data:
|
||||
self._fill("form_subject_input", data["subject"])
|
||||
self.logger.log_action(f"填写主题: {data['subject']}")
|
||||
|
||||
# 消息
|
||||
if "message" in data:
|
||||
self._fill("form_message_textarea", data["message"])
|
||||
self.logger.log_action(f"填写消息: {data['message'][:50]}...")
|
||||
|
||||
return self
|
||||
|
||||
def submit_form(self, wait_for_response: bool = True) -> 'ContactPage':
|
||||
"""提交表单"""
|
||||
self.logger.log_action("提交联系表单")
|
||||
|
||||
# 等待表单按钮可用
|
||||
submit_button = self._find("form_submit_button")
|
||||
|
||||
# 点击提交
|
||||
submit_button.click()
|
||||
|
||||
if wait_for_response:
|
||||
# 等待加载完成
|
||||
self.page.wait_for_load_state("networkidle")
|
||||
|
||||
# 检查是否显示成功消息
|
||||
try:
|
||||
self.assert_element_visible("success_message", timeout=10000)
|
||||
self.logger.info("表单提交成功")
|
||||
except Exception:
|
||||
self.logger.warning("未检测到成功消息,可能提交失败或无反馈")
|
||||
|
||||
return self
|
||||
|
||||
def verify_form_submission_success(self) -> 'ContactPage':
|
||||
"""验证表单提交成功"""
|
||||
self.logger.section("验证表单提交成功")
|
||||
|
||||
# 检查成功消息
|
||||
self.assert_element_visible("success_message")
|
||||
|
||||
# 验证成功消息文本
|
||||
success_text = self._get_text("success_message")
|
||||
assert "已发送" in success_text or "成功" in success_text, \
|
||||
f"成功消息不正确: {success_text}"
|
||||
|
||||
self.logger.info("✅ 表单提交成功验证通过")
|
||||
return self
|
||||
|
||||
def verify_form_validation(self) -> 'ContactPage':
|
||||
"""验证表单验证"""
|
||||
self.logger.section("验证表单验证")
|
||||
|
||||
# 尝试提交空表单
|
||||
self._click("form_submit_button")
|
||||
|
||||
# 检查浏览器原生验证
|
||||
name_input = self._find("form_name_input")
|
||||
is_required = name_input.evaluate("el => el.required")
|
||||
|
||||
if is_required:
|
||||
self.logger.info("姓名字段为必填项")
|
||||
|
||||
# 验证邮箱格式
|
||||
self._fill("form_email_input", "invalid-email")
|
||||
self._click("form_subject_input")
|
||||
|
||||
# 检查HTML5验证
|
||||
email_input = self._find("form_email_input")
|
||||
validity = email_input.evaluate("""
|
||||
el => ({
|
||||
valid: el.validity.valid,
|
||||
typeMismatch: el.validity.typeMismatch,
|
||||
valueMissing: el.validity.valueMissing
|
||||
})
|
||||
""")
|
||||
|
||||
if not validity["valid"] and validity["typeMismatch"]:
|
||||
self.logger.info("邮箱格式验证正常工作")
|
||||
|
||||
self.logger.info("✅ 表单验证验证通过")
|
||||
return self
|
||||
|
||||
def verify_form_with_invalid_email(self, data: Dict[str, str]) -> 'ContactPage':
|
||||
"""使用无效邮箱测试表单验证"""
|
||||
self.logger.section("测试无效邮箱")
|
||||
|
||||
# 填写表单(使用无效邮箱)
|
||||
data["email"] = "invalid-email"
|
||||
self.fill_contact_form(data)
|
||||
|
||||
# 尝试提交
|
||||
self._click("form_submit_button")
|
||||
|
||||
# 检查是否被HTML5验证阻止
|
||||
email_input = self._find("form_email_input")
|
||||
is_valid = email_input.evaluate("el => el.validity.valid")
|
||||
|
||||
if not is_valid:
|
||||
self.logger.info("无效邮箱被正确阻止")
|
||||
else:
|
||||
self.logger.warning("无效邮箱未被验证阻止,可能存在后端验证")
|
||||
|
||||
return self
|
||||
|
||||
def test_form_submission_performance(
|
||||
self,
|
||||
data: Dict[str, str],
|
||||
max_duration: float = 5.0
|
||||
) -> Dict[str, Any]:
|
||||
"""测试表单提交性能"""
|
||||
self.logger.section("表单提交性能测试")
|
||||
|
||||
import time
|
||||
|
||||
# 填充表单
|
||||
self.fill_contact_form(data)
|
||||
|
||||
# 记录开始时间
|
||||
start_time = time.time()
|
||||
|
||||
# 提交表单
|
||||
self._click("form_submit_button")
|
||||
|
||||
# 等待成功消息
|
||||
try:
|
||||
self.assert_element_visible("success_message", timeout=10000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 记录结束时间
|
||||
end_time = time.time()
|
||||
duration = end_time - start_time
|
||||
|
||||
# 验证性能
|
||||
if duration <= max_duration:
|
||||
self.logger.info(f"✅ 表单提交耗时 {duration:.2f}s,在阈值 {max_duration}s 内")
|
||||
else:
|
||||
self.logger.warning(f"⚠️ 表单提交耗时 {duration:.2f}s,超过阈值 {max_duration}s")
|
||||
|
||||
return {
|
||||
"duration": duration,
|
||||
"passed": duration <= max_duration
|
||||
}
|
||||
|
||||
def get_working_hours(self) -> Dict[str, str]:
|
||||
"""获取工作时间"""
|
||||
# 从页面内容中提取工作时间
|
||||
page_text = self.page.content()
|
||||
|
||||
hours = {}
|
||||
|
||||
# 检查工作时间文本
|
||||
if "周一至周五" in page_text:
|
||||
hours["周一至周五"] = "9:00 - 18:00"
|
||||
if "周六" in page_text:
|
||||
hours["周六"] = "9:00 - 12:00"
|
||||
if "周日" in page_text:
|
||||
hours["周日"] = "休息"
|
||||
|
||||
return hours
|
||||
|
||||
def reset_form(self) -> 'ContactPage':
|
||||
"""重置表单"""
|
||||
self.logger.log_action("重置表单")
|
||||
|
||||
# 刷新页面
|
||||
self.reload()
|
||||
self.wait_for_load()
|
||||
|
||||
return self
|
||||
|
||||
def verify_responsive_layout(self, width: int) -> 'ContactPage':
|
||||
"""验证响应式布局"""
|
||||
self.logger.section(f"响应式测试 ({width}px)")
|
||||
|
||||
# 设置视口
|
||||
self.page.set_viewport_size({"width": width, "height": 800})
|
||||
self.wait_for_load()
|
||||
|
||||
# 验证布局
|
||||
self.assert_element_visible("contact_form", timeout=5000)
|
||||
|
||||
# 检查布局变化
|
||||
if width < 768:
|
||||
self.logger.info("移动端布局:单列布局")
|
||||
elif width < 1024:
|
||||
self.logger.info("平板端布局:双列布局")
|
||||
else:
|
||||
self.logger.info("桌面端布局:完整布局")
|
||||
|
||||
self.logger.info(f"✅ {width}px 响应式测试通过")
|
||||
return self
|
||||
|
||||
def extract_contact_details(self) -> Dict[str, str]:
|
||||
"""提取联系详情"""
|
||||
details = {}
|
||||
|
||||
# 从页面内容中提取
|
||||
page_content = self.page.content()
|
||||
|
||||
# 公司地址
|
||||
if "公司地址" in page_content:
|
||||
details["address"] = "已找到地址信息"
|
||||
|
||||
# 联系电话
|
||||
if "联系电话" in page_content:
|
||||
details["phone"] = "已找到电话信息"
|
||||
|
||||
# 电子邮箱
|
||||
if "电子邮箱" in page_content:
|
||||
details["email"] = "已找到邮箱信息"
|
||||
|
||||
return details
|
||||
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
首页测试模块
|
||||
提供首页功能测试
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from playwright.sync_api import Page, Locator
|
||||
|
||||
from pages.base_page import BasePage
|
||||
from config.settings import get_settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
|
||||
class HomePage(BasePage):
|
||||
"""首页页面对象"""
|
||||
|
||||
def __init__(self, page: Page, base_url: Optional[str] = None):
|
||||
"""初始化首页"""
|
||||
super().__init__(page, base_url)
|
||||
|
||||
self.path = "/"
|
||||
self.title = "四川睿新致远科技有限公司"
|
||||
|
||||
self.selectors = {
|
||||
# 导航相关
|
||||
"header": "header",
|
||||
"logo": "header img[alt*='logo'], header a[href='#home']",
|
||||
"navigation": "header nav, nav",
|
||||
"nav_links": "nav a, header a[href^='#']",
|
||||
|
||||
# Hero区域
|
||||
"hero_section": "#home",
|
||||
"hero_title": "#home h1, .hero-section h1",
|
||||
"hero_subtitle": "#home p, .hero-section p",
|
||||
"hero_cta": "#home a[href*='#contact'], .hero-section a.cta",
|
||||
|
||||
# 关于我们区域
|
||||
"about_section": "#about, .about-section",
|
||||
"about_title": "#about h2, .about-section h2",
|
||||
"about_content": "#about .content, .about-section .content",
|
||||
|
||||
# 核心业务区域
|
||||
"services_section": "#services, .services-section",
|
||||
"services_title": "#services h2, .services-section h2",
|
||||
"services_cards": "#services .card, .services-section .card, #services .service-card",
|
||||
|
||||
# 产品服务区域
|
||||
"products_section": "#products, .products-section",
|
||||
"products_title": "#products h2, .products-section h2",
|
||||
"products_grid": "#products .grid, .products-section .grid, #products .product-grid",
|
||||
"product_cards": "#products .card, .products-section .card",
|
||||
|
||||
# 新闻动态区域
|
||||
"news_section": "#news, .news-section",
|
||||
"news_title": "#news h2, .news-section h2",
|
||||
"news_list": "#news .list, .news-section .news-list",
|
||||
"news_items": "#news .news-item, .news-section .news-item",
|
||||
|
||||
# 联系我们区域
|
||||
"contact_section": "#contact, .contact-section",
|
||||
"contact_title": "#contact h2, .contact-section h2",
|
||||
"contact_form": "#contact form, .contact-section form",
|
||||
|
||||
# 页脚
|
||||
"footer": "footer",
|
||||
"footer_content": "footer .content, footer .footer-content",
|
||||
"social_links": "footer .social-links, footer a[href*='weixin'], footer a[href*='weibo']"
|
||||
}
|
||||
|
||||
self.logger = get_logger()
|
||||
|
||||
def navigate(self, **kwargs) -> 'HomePage':
|
||||
"""导航到首页"""
|
||||
super().navigate(**kwargs)
|
||||
self.wait_for_load()
|
||||
return self
|
||||
|
||||
def verify_page_loaded(self) -> 'HomePage':
|
||||
"""验证页面加载完成"""
|
||||
self.logger.section("验证首页加载")
|
||||
|
||||
# 检查关键元素存在
|
||||
self.assert_element_visible("header", timeout=10000)
|
||||
self.assert_element_visible("main", timeout=10000)
|
||||
self.assert_element_visible("footer", timeout=10000)
|
||||
|
||||
# 检查页面标题
|
||||
self.assert_title_contains("睿新致远")
|
||||
|
||||
self.logger.info("✅ 首页加载验证通过")
|
||||
return self
|
||||
|
||||
def verify_header(self) -> 'HomePage':
|
||||
"""验证页头"""
|
||||
self.logger.section("验证页头")
|
||||
|
||||
# 检查Logo
|
||||
if self._is_visible("logo"):
|
||||
self.logger.info("Logo存在")
|
||||
|
||||
# 检查导航链接 - 实际有6个导航项
|
||||
nav_links = self._find_all("nav_links")
|
||||
expected_count = 6 # 首页、关于我们、核心业务、产品服务、新闻动态、联系我们
|
||||
self.assert_element_count("nav a, nav a[href^='#']", expected_count)
|
||||
|
||||
self.logger.info(f"✅ 页头验证通过,发现 {len(nav_links)} 个导航链接")
|
||||
return self
|
||||
|
||||
def verify_hero_section(self) -> 'HomePage':
|
||||
"""验证Hero区域"""
|
||||
self.logger.section("验证Hero区域")
|
||||
|
||||
if self._is_visible("hero_section"):
|
||||
self.assert_element_visible("hero_title")
|
||||
self.assert_element_visible("hero_subtitle")
|
||||
self.logger.info("Hero区域完整")
|
||||
|
||||
# 获取标题文本
|
||||
title = self._get_text("hero_title")
|
||||
self.logger.info(f"Hero标题: {title[:50]}...")
|
||||
else:
|
||||
self.logger.warning("未找到Hero区域")
|
||||
|
||||
return self
|
||||
|
||||
def verify_services_section(self) -> 'HomePage':
|
||||
"""验证核心业务区域"""
|
||||
self.logger.section("验证核心业务区域")
|
||||
|
||||
if self._is_visible("services_section"):
|
||||
self.assert_element_visible("services_title")
|
||||
|
||||
# 检查业务卡片
|
||||
cards = self._find_all("services_cards")
|
||||
self.logger.info(f"发现 {len(cards)} 个服务卡片")
|
||||
|
||||
if len(cards) > 0:
|
||||
self.logger.info("✅ 服务区域验证通过")
|
||||
else:
|
||||
self.logger.warning("未找到服务区域")
|
||||
|
||||
return self
|
||||
|
||||
def verify_products_section(self) -> 'HomePage':
|
||||
"""验证产品服务区域"""
|
||||
self.logger.section("验证产品服务区域")
|
||||
|
||||
if self._is_visible("products_section"):
|
||||
self.assert_element_visible("products_title")
|
||||
|
||||
# 检查产品卡片
|
||||
cards = self._find_all("product_cards")
|
||||
self.logger.info(f"发现 {len(cards)} 个产品卡片")
|
||||
|
||||
if len(cards) > 0:
|
||||
self.logger.info("✅ 产品区域验证通过")
|
||||
else:
|
||||
self.logger.warning("未找到产品区域")
|
||||
|
||||
return self
|
||||
|
||||
def verify_news_section(self) -> 'HomePage':
|
||||
"""验证新闻动态区域"""
|
||||
self.logger.section("验证新闻动态区域")
|
||||
|
||||
if self._is_visible("news_section"):
|
||||
self.assert_element_visible("news_title")
|
||||
|
||||
# 检查新闻列表
|
||||
items = self._find_all("news_items")
|
||||
self.logger.info(f"发现 {len(items)} 条新闻")
|
||||
|
||||
if len(items) > 0:
|
||||
self.logger.info("✅ 新闻区域验证通过")
|
||||
else:
|
||||
self.logger.warning("未找到新闻区域")
|
||||
|
||||
return self
|
||||
|
||||
def verify_contact_section(self) -> 'HomePage':
|
||||
"""验证联系我们区域"""
|
||||
self.logger.section("验证联系我们区域")
|
||||
|
||||
if self._is_visible("contact_section"):
|
||||
self.assert_element_visible("contact_title")
|
||||
self.assert_element_visible("contact_form")
|
||||
self.logger.info("联系区域包含表单")
|
||||
|
||||
# 检查表单字段
|
||||
form_fields = ["name", "email", "subject", "message"]
|
||||
for field in form_fields:
|
||||
if self._is_visible(f"contact_form #{field}"):
|
||||
self.logger.info(f"表单字段 {field} 存在")
|
||||
|
||||
self.logger.info("✅ 联系区域验证通过")
|
||||
else:
|
||||
self.logger.warning("未找到联系区域")
|
||||
|
||||
return self
|
||||
|
||||
def verify_footer(self) -> 'HomePage':
|
||||
"""验证页脚"""
|
||||
self.logger.section("验证页脚")
|
||||
|
||||
self.assert_element_visible("footer")
|
||||
|
||||
# 检查版权信息
|
||||
footer_text = self._get_text("footer")
|
||||
if "睿新致远" in footer_text or "2026" in footer_text:
|
||||
self.logger.info("页脚包含版权信息")
|
||||
|
||||
self.logger.info("✅ 页脚验证通过")
|
||||
return self
|
||||
|
||||
def verify_all_sections(self) -> 'HomePage':
|
||||
"""验证所有区域"""
|
||||
self.verify_header()
|
||||
self.verify_hero_section()
|
||||
self.verify_services_section()
|
||||
self.verify_products_section()
|
||||
self.verify_news_section()
|
||||
self.verify_contact_section()
|
||||
self.verify_footer()
|
||||
|
||||
self.logger.info("✅ 首页所有区域验证完成")
|
||||
return self
|
||||
|
||||
def scroll_to_section(self, section: str) -> 'HomePage':
|
||||
"""滚动到指定区域"""
|
||||
self.logger.log_action(f"滚动到{section}区域")
|
||||
|
||||
section_selectors = {
|
||||
"home": "#home",
|
||||
"about": "#about",
|
||||
"services": "#services",
|
||||
"products": "#products",
|
||||
"news": "#news",
|
||||
"contact": "#contact"
|
||||
}
|
||||
|
||||
selector = section_selectors.get(section, f"#{section}")
|
||||
|
||||
if self._is_visible(selector):
|
||||
self.scroll_to_element(selector)
|
||||
self.logger.info(f"已滚动到{section}区域")
|
||||
else:
|
||||
self.logger.warning(f"未找到{section}区域")
|
||||
|
||||
return self
|
||||
|
||||
def click_navigation_link(self, section: str) -> 'HomePage':
|
||||
"""点击导航链接"""
|
||||
self.logger.log_action(f"点击{section}导航链接")
|
||||
|
||||
nav_items = {
|
||||
"home": "首页",
|
||||
"about": "关于我们",
|
||||
"services": "核心业务",
|
||||
"products": "产品服务",
|
||||
"news": "新闻动态",
|
||||
"contact": "联系我们"
|
||||
}
|
||||
|
||||
label = nav_items.get(section, section)
|
||||
|
||||
# 查找包含指定文本的导航链接
|
||||
nav_link = self.page.locator(f"nav a:has-text('{label}'), header a:has-text('{label}')")
|
||||
|
||||
if nav_link.count() > 0:
|
||||
nav_link.first.click()
|
||||
self.wait_for_load()
|
||||
self.logger.info(f"已点击{nav_items.get(section, section)}链接")
|
||||
else:
|
||||
self.logger.warning(f"未找到{nav_items.get(section, section)}链接")
|
||||
|
||||
return self
|
||||
|
||||
def get_company_info(self) -> Dict[str, str]:
|
||||
"""获取公司信息"""
|
||||
info = {}
|
||||
|
||||
# 从首页获取描述
|
||||
hero_text = ""
|
||||
if self._is_visible("hero_subtitle"):
|
||||
hero_text = self._get_text("hero_subtitle")
|
||||
|
||||
# 如果无法从页面获取,使用默认值
|
||||
info["description"] = hero_text if hero_text else "专注科技创新,驱动智慧未来"
|
||||
|
||||
# 从常量获取
|
||||
info["name"] = "四川睿新致远科技有限公司"
|
||||
info["slogan"] = "专注科技创新,驱动智慧未来"
|
||||
|
||||
return info
|
||||
|
||||
def get_statistics(self) -> Dict[str, int]:
|
||||
"""获取统计数据"""
|
||||
stats = {}
|
||||
|
||||
# 尝试从页面获取统计数据
|
||||
if self._is_visible("about_section"):
|
||||
# 这里需要根据实际页面结构调整
|
||||
pass
|
||||
|
||||
# 默认值
|
||||
stats = {
|
||||
"customers": 50,
|
||||
"cases": 100,
|
||||
"projects": 200,
|
||||
"experience": 8
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
def get_featured_services(self) -> List[Dict[str, str]]:
|
||||
"""获取精选服务"""
|
||||
services = []
|
||||
|
||||
if self._is_visible("services_cards"):
|
||||
cards = self._find_all("services_cards")[:4]
|
||||
for card in cards:
|
||||
title = card.locator("h3, .title").text_content() if card.locator("h3, .title").count() > 0 else ""
|
||||
description = card.locator("p, .description").text_content() if card.locator("p, .description").count() > 0 else ""
|
||||
|
||||
services.append({
|
||||
"title": title.strip() if title else "",
|
||||
"description": description.strip() if description else ""
|
||||
})
|
||||
|
||||
return services
|
||||
|
||||
def get_latest_news(self) -> List[Dict[str, str]]:
|
||||
"""获取最新新闻"""
|
||||
news = []
|
||||
|
||||
if self._is_visible("news_items"):
|
||||
items = self._find_all("news_items")[:3]
|
||||
for item in items:
|
||||
title = item.locator("h3, .title, a").first.text_content() if item.locator("h3, .title, a").count() > 0 else ""
|
||||
date = item.locator(".date, time").first.text_content() if item.locator(".date, time").count() > 0 else ""
|
||||
|
||||
news.append({
|
||||
"title": title.strip() if title else "",
|
||||
"date": date.strip() if date else ""
|
||||
})
|
||||
|
||||
return news
|
||||
|
||||
def verify_page_performance(self) -> Dict[str, float]:
|
||||
"""验证页面性能指标"""
|
||||
self.logger.section("性能测试")
|
||||
|
||||
performance_data = self.execute_js("""
|
||||
() => {
|
||||
const timing = performance.timing;
|
||||
const navigation = performance.getEntriesByType('navigation')[0];
|
||||
|
||||
return {
|
||||
// 关键指标
|
||||
'pageLoadTime': timing.loadEventEnd - timing.navigationStart,
|
||||
'domContentLoaded': timing.domContentLoadedEventEnd - timing.navigationStart,
|
||||
'firstPaint': timing.responseStart - timing.navigationStart,
|
||||
'firstContentfulPaint': navigation ? navigation.firstContentfulPaint : 0,
|
||||
'largestContentfulPaint': navigation ? navigation.largestContentfulPaint : 0,
|
||||
'timeToInteractive': navigation ? navigation.interactive : 0,
|
||||
|
||||
// 资源指标
|
||||
'domainLookupTime': timing.domainLookupEnd - timing.domainLookupStart,
|
||||
'serverResponseTime': timing.responseEnd - timing.requestStart,
|
||||
'tcpConnectTime': timing.connectEnd - timing.connectStart,
|
||||
'domInteractiveTime': timing.domInteractive - timing.domLoading
|
||||
};
|
||||
}
|
||||
""")
|
||||
|
||||
# 记录性能指标
|
||||
for metric, value in performance_data.items():
|
||||
if value and value > 0:
|
||||
threshold = get_settings().performance_thresholds.__dict__.get(
|
||||
metric.replace("_", ""), 3000
|
||||
)
|
||||
self.logger.log_performance(metric, float(value), threshold)
|
||||
|
||||
return performance_data
|
||||
|
||||
def verify_responsive_design(self, width: int, height: int) -> 'HomePage':
|
||||
"""验证响应式设计"""
|
||||
self.logger.section(f"响应式测试 ({width}x{height})")
|
||||
|
||||
# 设置视口大小
|
||||
self.page.set_viewport_size({"width": width, "height": height})
|
||||
self.wait_for_load()
|
||||
|
||||
# 验证关键元素
|
||||
self.assert_element_visible("header", timeout=5000)
|
||||
self.assert_element_visible("main", timeout=5000)
|
||||
self.assert_element_visible("footer", timeout=5000)
|
||||
|
||||
# 根据屏幕大小调整验证逻辑
|
||||
if width < 768:
|
||||
self.logger.info(f"移动端 {width}px: 验证基础布局")
|
||||
# 移动端检查汉堡菜单
|
||||
mobile_menu = self.page.locator("button:has-text('菜单'), .mobile-menu, .menu-toggle")
|
||||
self.logger.info(f"发现 {mobile_menu.count()} 个移动端菜单元素")
|
||||
elif width < 1024:
|
||||
self.logger.info(f"平板端 {width}px: 验证平板布局")
|
||||
else:
|
||||
self.logger.info(f"桌面端 {width}px: 验证完整布局")
|
||||
|
||||
self.logger.info(f"✅ {width}x{height} 响应式测试通过")
|
||||
return self
|
||||
@@ -0,0 +1,48 @@
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = [
|
||||
"--tb=short",
|
||||
"--strict-markers",
|
||||
"-v",
|
||||
"--html=reports/test_report.html",
|
||||
"--self-contained-html",
|
||||
"--cov=utils",
|
||||
"--cov-report=html",
|
||||
"--cov-report=term-missing"
|
||||
]
|
||||
markers = [
|
||||
"smoke: 冒烟测试,快速验证核心功能",
|
||||
"regression: 回归测试,完整功能验证",
|
||||
"performance: 性能测试,页面加载和响应时间",
|
||||
"responsive: 响应式测试,不同屏幕尺寸",
|
||||
"cross_browser: 跨浏览器测试",
|
||||
"form: 表单相关测试",
|
||||
"navigation: 导航测试",
|
||||
"interactive: 用户交互测试"
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
"ignore::PendingDeprecationWarning",
|
||||
"ignore::pytest.PytestUnraisableExceptionWarning"
|
||||
]
|
||||
|
||||
[tool.pytest]
|
||||
# pytest-asyncio配置
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
source = ["pages", "utils", "tests"]
|
||||
omit = ["tests/*", "utils/report_generator.py", "utils/data_generator.py"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError"
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
[pytest]
|
||||
# 配置文件
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts =
|
||||
--tb=short
|
||||
-v
|
||||
markers =
|
||||
smoke: 冒烟测试,快速验证核心功能
|
||||
regression: 回归测试,完整功能验证
|
||||
performance: 性能测试,页面加载和响应时间
|
||||
responsive: 响应式测试,不同屏幕尺寸
|
||||
cross_browser: 跨浏览器测试
|
||||
form: 表单相关测试
|
||||
navigation: 导航测试
|
||||
interactive: 用户交互测试
|
||||
|
||||
[tool:pytest]
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning
|
||||
ignore::PendingDeprecationWarning
|
||||
ignore::pytest.PytestUnraisableExceptionWarning
|
||||
@@ -0,0 +1,21 @@
|
||||
# E2E测试框架依赖
|
||||
# Novalon Website 端到端测试解决方案
|
||||
|
||||
playwright>=1.52.0
|
||||
pytest>=8.3.0
|
||||
pytest-html>=4.1.1
|
||||
pytest-xdist>=3.6.1
|
||||
pytest-timeout>=2.3.1
|
||||
pytest-rerunfailures>=14.0
|
||||
python-dotenv>=1.0.0
|
||||
requests>=2.31.0
|
||||
beautifulsoup4>=4.12.0
|
||||
lxml>=5.1.0
|
||||
jinja2>=3.1.0
|
||||
markdown>=3.5.0
|
||||
rich>=13.7.0
|
||||
tabulate>=0.9.0
|
||||
pillow>=10.2.0
|
||||
matplotlib>=3.8.0
|
||||
numpy>=1.26.0
|
||||
selenium>=4.18.0
|
||||
@@ -0,0 +1 @@
|
||||
# Scripts模块
|
||||
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI/CD 测试脚本
|
||||
用于持续集成环境中的测试执行
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# 添加项目路径
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from config.settings import get_settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
|
||||
class CITestRunner:
|
||||
"""CI测试运行器"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = get_logger()
|
||||
self.settings = get_settings()
|
||||
self.results = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"environment": os.environ.get("ENVIRONMENT", "development"),
|
||||
"browser": os.environ.get("PLAYWRIGHT_BROWSER", "chromium"),
|
||||
"tests": [],
|
||||
"summary": {
|
||||
"total": 0,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"error": 0,
|
||||
"duration": 0
|
||||
}
|
||||
}
|
||||
|
||||
def run_smoke_tests(self) -> bool:
|
||||
"""运行冒烟测试"""
|
||||
self.logger.section("运行冒烟测试")
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
import pytest
|
||||
|
||||
pytest_args = [
|
||||
"-c", str(project_root / "pytest.ini"),
|
||||
"-m", "smoke",
|
||||
"-v",
|
||||
"--tb=short",
|
||||
"--json-report",
|
||||
"--json-report-file=reports/smoke_test_results.json",
|
||||
"tests"
|
||||
]
|
||||
|
||||
try:
|
||||
exit_code = pytest.main(pytest_args)
|
||||
return exit_code == 0
|
||||
except Exception as e:
|
||||
self.logger.error(f"冒烟测试失败: {e}")
|
||||
return False
|
||||
|
||||
def run_regression_tests(self) -> bool:
|
||||
"""运行回归测试"""
|
||||
self.logger.section("运行回归测试")
|
||||
|
||||
import pytest
|
||||
|
||||
pytest_args = [
|
||||
"-c", str(project_root / "pytest.ini"),
|
||||
"-m", "regression",
|
||||
"-v",
|
||||
"--tb=short",
|
||||
"--json-report",
|
||||
"--json-report-file=reports/regression_test_results.json",
|
||||
"tests"
|
||||
]
|
||||
|
||||
try:
|
||||
exit_code = pytest.main(pytest_args)
|
||||
return exit_code == 0
|
||||
except Exception as e:
|
||||
self.logger.error(f"回归测试失败: {e}")
|
||||
return False
|
||||
|
||||
def run_performance_tests(self) -> bool:
|
||||
"""运行性能测试"""
|
||||
self.logger.section("运行性能测试")
|
||||
|
||||
import pytest
|
||||
|
||||
pytest_args = [
|
||||
"-c", str(project_root / "pytest.ini"),
|
||||
"-m", "performance",
|
||||
"-v",
|
||||
"--tb=short",
|
||||
"--json-report",
|
||||
"--json-report-file=reports/performance_test_results.json",
|
||||
"tests"
|
||||
]
|
||||
|
||||
try:
|
||||
exit_code = pytest.main(pytest_args)
|
||||
return exit_code == 0
|
||||
except Exception as e:
|
||||
self.logger.error(f"性能测试失败: {e}")
|
||||
return False
|
||||
|
||||
def run_cross_browser_tests(self) -> dict:
|
||||
"""运行跨浏览器测试"""
|
||||
self.logger.section("运行跨浏览器测试")
|
||||
|
||||
import pytest
|
||||
browsers = ["chromium", "firefox", "webkit"]
|
||||
results = {}
|
||||
|
||||
for browser in browsers:
|
||||
self.logger.info(f"测试浏览器: {browser}")
|
||||
|
||||
os.environ["PLAYWRIGHT_BROWSER"] = browser
|
||||
|
||||
pytest_args = [
|
||||
"-c", str(project_root / "pytest.ini"),
|
||||
"-m", "smoke",
|
||||
"-v",
|
||||
"--tb=short",
|
||||
f"--json-report=reports/{browser}_test_results.json",
|
||||
"tests"
|
||||
]
|
||||
|
||||
try:
|
||||
exit_code = pytest.main(pytest_args)
|
||||
results[browser] = exit_code == 0
|
||||
except Exception as e:
|
||||
self.logger.error(f"{browser} 测试失败: {e}")
|
||||
results[browser] = False
|
||||
|
||||
return results
|
||||
|
||||
def run_full_test_suite(self) -> bool:
|
||||
"""运行完整测试套件"""
|
||||
self.logger.section("运行完整测试套件")
|
||||
|
||||
import pytest
|
||||
|
||||
pytest_args = [
|
||||
"-c", str(project_root / "pytest.ini"),
|
||||
"-v",
|
||||
"--tb=short",
|
||||
"--json-report",
|
||||
"--json-report-file=reports/full_test_results.json",
|
||||
"--html=reports/full_test_report.html",
|
||||
"--self-contained-html",
|
||||
"tests"
|
||||
]
|
||||
|
||||
try:
|
||||
exit_code = pytest.main(pytest_args)
|
||||
return exit_code == 0
|
||||
except Exception as e:
|
||||
self.logger.error(f"完整测试失败: {e}")
|
||||
return False
|
||||
|
||||
def generate_ci_report(self, test_results: dict):
|
||||
"""生成CI测试报告"""
|
||||
report_path = Path("reports/ci_test_report.json")
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(report_path, "w", encoding="utf-8") as f:
|
||||
json.dump(test_results, f, indent=2, ensure_ascii=False)
|
||||
|
||||
self.logger.info(f"CI报告已生成: {report_path}")
|
||||
|
||||
def run_ci_tests(self, test_type: str = "full"):
|
||||
"""运行CI测试"""
|
||||
start_time = time.time()
|
||||
|
||||
self.logger.section("开始 CI 测试")
|
||||
self.logger.info(f"环境: {self.results['environment']}")
|
||||
self.logger.info(f"浏览器: {self.results['browser']}")
|
||||
self.logger.info(f"测试类型: {test_type}")
|
||||
|
||||
success = False
|
||||
|
||||
if test_type == "smoke":
|
||||
success = self.run_smoke_tests()
|
||||
elif test_type == "regression":
|
||||
success = self.run_regression_tests()
|
||||
elif test_type == "performance":
|
||||
success = self.run_performance_tests()
|
||||
elif test_type == "cross_browser":
|
||||
cross_results = self.run_cross_browser_tests()
|
||||
success = all(cross_results.values())
|
||||
self.results["cross_browser_results"] = cross_results
|
||||
elif test_type == "full":
|
||||
success = self.run_full_test_suite()
|
||||
else:
|
||||
self.logger.error(f"未知的测试类型: {test_type}")
|
||||
return False
|
||||
|
||||
end_time = time.time()
|
||||
self.results["summary"]["duration"] = end_time - start_time
|
||||
self.results["success"] = success
|
||||
|
||||
self.logger.section("CI 测试完成")
|
||||
self.logger.info(f"总耗时: {self.results['summary']['duration']:.2f}秒")
|
||||
self.logger.info(f"测试结果: {'成功' if success else '失败'}")
|
||||
|
||||
# 生成报告
|
||||
self.generate_ci_report(self.results)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def parse_ci_arguments():
|
||||
"""解析CI参数"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Novalon Website CI 测试运行器"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--test-type",
|
||||
default="full",
|
||||
choices=["smoke", "regression", "performance", "cross_browser", "full"],
|
||||
help="测试类型 (默认: full)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--env",
|
||||
default="development",
|
||||
choices=["development", "staging", "production"],
|
||||
help="测试环境 (默认: development)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--browser",
|
||||
default="chromium",
|
||||
choices=["chromium", "firefox", "webkit", "all"],
|
||||
help="浏览器 (默认: chromium)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--report-dir",
|
||||
default="reports",
|
||||
help="报告目录 (默认: reports)"
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
"""CI主函数"""
|
||||
args = parse_ci_arguments()
|
||||
|
||||
# 设置环境变量
|
||||
os.environ["ENVIRONMENT"] = args.env
|
||||
os.environ["PLAYWRIGHT_BROWSER"] = args.browser
|
||||
os.environ["REPORT_DIR"] = args.report_dir
|
||||
|
||||
# 确保报告目录存在
|
||||
Path(args.report_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 运行测试
|
||||
runner = CITestRunner()
|
||||
success = runner.run_ci_tests(args.test_type)
|
||||
|
||||
# 输出结果
|
||||
if success:
|
||||
print("\n✅ CI 测试通过")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n❌ CI 测试失败")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试运行脚本
|
||||
提供便捷的测试执行命令
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目路径
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from config.settings import get_settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
"""解析命令行参数"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Novalon Website E2E 测试运行器"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-b", "--browser",
|
||||
default="chromium",
|
||||
choices=["chromium", "firefox", "webkit", "all"],
|
||||
help="指定浏览器 (默认: chromium)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-h", "--headless",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="以无头模式运行 (默认: False)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-m", "--marker",
|
||||
default="",
|
||||
help="运行指定标记的测试 (例如: -m smoke)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-k", "--keyword",
|
||||
default="",
|
||||
help="运行包含关键字的测试 (例如: -k home)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-v", "--verbose",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="显示详细输出"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--html",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="生成HTML测试报告"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--video",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="录制测试视频"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--screenshot",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="失败时截图"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--parallel",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="并行执行测试"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--workers",
|
||||
type=int,
|
||||
default=4,
|
||||
help="并行工作数 (默认: 4)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--report-dir",
|
||||
default="reports",
|
||||
help="报告目录 (默认: reports)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--env",
|
||||
default="development",
|
||||
choices=["development", "staging", "production"],
|
||||
help="测试环境 (默认: development)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"test_paths",
|
||||
nargs="*",
|
||||
default=["tests"],
|
||||
help="测试路径 (默认: tests)"
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def build_pytest_args(args):
|
||||
"""构建pytest参数"""
|
||||
pytest_args = []
|
||||
|
||||
# 配置文件
|
||||
pytest_args.append("-c")
|
||||
pytest_args.append(str(project_root / "pytest.ini"))
|
||||
|
||||
# 浏览器参数
|
||||
os.environ["PLAYWRIGHT_BROWSER"] = args.browser
|
||||
|
||||
# 无头模式
|
||||
if args.headless:
|
||||
os.environ["PLAYWRIGHT_HEADLESS"] = "1"
|
||||
|
||||
# 标记过滤
|
||||
if args.marker:
|
||||
pytest_args.append(f"-m={args.marker}")
|
||||
|
||||
# 关键字过滤
|
||||
if args.keyword:
|
||||
pytest_args.append(f"-k={args.keyword}")
|
||||
|
||||
# 详细输出
|
||||
if args.verbose:
|
||||
pytest_args.append("-v")
|
||||
pytest_args.append("--tb=short")
|
||||
|
||||
# HTML报告
|
||||
if args.html:
|
||||
pytest_args.append("--html=reports/test_report.html")
|
||||
pytest_args.append("--self-contained-html")
|
||||
|
||||
# 视频录制
|
||||
if args.video:
|
||||
os.environ["PLAYWRIGHT_VIDEO"] = "1"
|
||||
|
||||
# 失败截图
|
||||
if args.screenshot:
|
||||
os.environ["PLAYWRIGHT_SCREENSHOT"] = "1"
|
||||
|
||||
# 并行执行
|
||||
if args.parallel:
|
||||
pytest_args.append(f"-n={args.workers}")
|
||||
pytest_args.append("--dist=loadscope")
|
||||
|
||||
# 报告目录
|
||||
if args.report_dir:
|
||||
pytest_args.append(f"--report-dir={args.report_dir}")
|
||||
|
||||
# 测试路径
|
||||
pytest_args.extend(args.test_paths)
|
||||
|
||||
# 覆盖率(可选)
|
||||
pytest_args.append("--cov=e2e-tests")
|
||||
pytest_args.append("--cov-report=term-missing")
|
||||
|
||||
return pytest_args
|
||||
|
||||
|
||||
def run_tests(args):
|
||||
"""运行测试"""
|
||||
logger = get_logger()
|
||||
|
||||
logger.section("开始 E2E 测试")
|
||||
logger.info(f"浏览器: {args.browser}")
|
||||
logger.info(f"无头模式: {args.headless}")
|
||||
logger.info(f"测试路径: {args.test_paths}")
|
||||
|
||||
if args.marker:
|
||||
logger.info(f"测试标记: {args.marker}")
|
||||
|
||||
if args.keyword:
|
||||
logger.info(f"关键字过滤: {args.keyword}")
|
||||
|
||||
# 构建pytest参数
|
||||
pytest_args = build_pytest_args(args)
|
||||
|
||||
# 导入pytest
|
||||
import pytest
|
||||
|
||||
# 运行测试
|
||||
exit_code = pytest.main(pytest_args)
|
||||
|
||||
logger.section("测试运行完成")
|
||||
|
||||
return exit_code
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
args = parse_arguments()
|
||||
|
||||
# 设置环境
|
||||
os.environ["ENVIRONMENT"] = args.env
|
||||
|
||||
try:
|
||||
exit_code = run_tests(args)
|
||||
sys.exit(exit_code)
|
||||
except KeyboardInterrupt:
|
||||
print("\n测试被用户中断")
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
print(f"测试运行出错: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
# Tests模块
|
||||
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
测试配置文件
|
||||
提供全局测试fixture和钩子函数
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Generator, Optional
|
||||
import pytest
|
||||
from pytest import Config
|
||||
|
||||
from playwright.sync_api import Browser, BrowserContext, Page, Playwright
|
||||
from playwright.sync_api import Error as PlaywrightError
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from config.settings import get_settings
|
||||
from config.browsers import get_browser_factory, BrowserConfigManager
|
||||
from utils.logger import get_logger
|
||||
from utils.report_generator import get_report_manager, TestResult, TestStatus
|
||||
from utils.data_generator import get_test_data_generator
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def settings() -> Generator:
|
||||
"""获取测试配置"""
|
||||
yield get_settings()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def logger() -> Generator:
|
||||
"""获取日志记录器"""
|
||||
yield get_logger()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_data_generator() -> Generator:
|
||||
"""获取测试数据生成器"""
|
||||
yield get_test_data_generator()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_factory() -> Generator:
|
||||
"""获取浏览器工厂"""
|
||||
factory = get_browser_factory()
|
||||
yield factory
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_context(
|
||||
browser_factory: BrowserConfigManager,
|
||||
settings
|
||||
) -> Generator:
|
||||
"""创建浏览器上下文"""
|
||||
browser, context, page = browser_factory.create_browser_session(
|
||||
browser_name=settings.default_browser,
|
||||
headless=settings.headless_mode
|
||||
)
|
||||
|
||||
yield context, page
|
||||
|
||||
# 清理(在会话结束时)
|
||||
try:
|
||||
page.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
context.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
browser_factory.close_browser()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(browser_context, settings) -> Generator:
|
||||
"""创建页面"""
|
||||
context, page = browser_context
|
||||
|
||||
# 设置默认超时
|
||||
page.set_default_timeout(settings.page_load_timeout)
|
||||
page.set_default_navigation_timeout(settings.page_load_timeout)
|
||||
|
||||
yield page
|
||||
|
||||
# 截图(如果测试失败)
|
||||
if hasattr(page, "_test_failed") and page._test_failed:
|
||||
test_name = getattr(pytest, "_test_name", "unknown")
|
||||
screenshots_dir = Path(settings.screenshots_dir)
|
||||
screenshots_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
screenshot_path = screenshots_dir / f"{test_name}_failed.png"
|
||||
try:
|
||||
page.screenshot(path=str(screenshot_path))
|
||||
logger = get_logger()
|
||||
logger.error(f"失败截图已保存: {screenshot_path}")
|
||||
except Exception as e:
|
||||
logger = get_logger()
|
||||
logger.error(f"保存失败截图时出错: {e}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def home_page(page: Page, settings) -> Generator:
|
||||
"""创建首页对象"""
|
||||
from pages.home_page import HomePage
|
||||
home = HomePage(page, settings.get_base_url())
|
||||
yield home
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def contact_page(page: Page, settings) -> Generator:
|
||||
"""创建联系页面对象"""
|
||||
from pages.contact_page import ContactPage
|
||||
contact = ContactPage(page, settings.get_base_url())
|
||||
yield contact
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def base_url(settings) -> str:
|
||||
"""获取基础URL"""
|
||||
return settings.get_base_url()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_results() -> Generator:
|
||||
"""收集测试结果"""
|
||||
results = []
|
||||
yield results
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def report_manager() -> Generator:
|
||||
"""获取报告管理器"""
|
||||
manager = get_report_manager()
|
||||
yield manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def track_test_result(report_manager, test_results):
|
||||
"""跟踪测试结果fixture"""
|
||||
from datetime import datetime
|
||||
import pytest
|
||||
|
||||
class TestTracker:
|
||||
def __init__(self):
|
||||
self.start_time = None
|
||||
self.current_result = None
|
||||
|
||||
def start_track(self, test_name: str, test_class: str = "", test_file: str = ""):
|
||||
self.start_time = datetime.now()
|
||||
logger = get_logger()
|
||||
logger.log_test_start(test_name, test_class=test_class, test_file=test_file)
|
||||
|
||||
def end_track(
|
||||
self,
|
||||
test_name: str,
|
||||
status: TestStatus,
|
||||
test_class: str = "",
|
||||
test_file: str = ""
|
||||
):
|
||||
end_time = datetime.now()
|
||||
duration = (end_time - self.start_time).total_seconds()
|
||||
|
||||
logger = get_logger()
|
||||
logger.log_test_end(test_name, status.value, duration)
|
||||
|
||||
# 创建测试结果
|
||||
result = TestResult(
|
||||
test_id=f"{test_file}_{test_name}",
|
||||
test_name=test_name,
|
||||
test_file=test_file,
|
||||
test_class=test_class,
|
||||
status=status,
|
||||
start_time=self.start_time,
|
||||
end_time=end_time,
|
||||
duration=duration
|
||||
)
|
||||
|
||||
# 添加到报告管理器
|
||||
report_manager.add_result(result)
|
||||
test_results.append(result)
|
||||
|
||||
return result
|
||||
|
||||
tracker = TestTracker()
|
||||
yield tracker
|
||||
|
||||
|
||||
# Pytest钩子函数
|
||||
|
||||
def pytest_configure(config: Config):
|
||||
"""pytest配置钩子"""
|
||||
# 设置标记
|
||||
config.addinivalue_line(
|
||||
"markers", "smoke: 冒烟测试,快速验证核心功能"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "regression: 回归测试,完整功能验证"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "performance: 性能测试,页面加载和响应时间"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "responsive: 响应式测试,不同屏幕尺寸"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "cross_browser: 跨浏览器测试"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "form: 表单相关测试"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "navigation: 导航测试"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "interactive: 用户交互测试"
|
||||
)
|
||||
|
||||
# 创建必要的目录
|
||||
settings = get_settings()
|
||||
Path(settings.screenshots_dir).mkdir(parents=True, exist_ok=True)
|
||||
Path(settings.videos_dir).mkdir(parents=True, exist_ok=True)
|
||||
Path("reports").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def pytest_sessionstart(session):
|
||||
"""测试会话开始"""
|
||||
logger = get_logger()
|
||||
logger.section("开始E2E测试会话")
|
||||
logger.info(f"测试会话ID: {session.name}")
|
||||
logger.info(f"测试数量: {len(session.items)}")
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
"""测试会话结束"""
|
||||
logger = get_logger()
|
||||
logger.section("E2E测试会话结束")
|
||||
|
||||
# 生成报告
|
||||
if exitstatus == 0:
|
||||
logger.info("✅ 所有测试通过")
|
||||
else:
|
||||
logger.warning(f"⚠️ 测试完成,退出码: {exitstatus}")
|
||||
|
||||
|
||||
def pytest_runtest_setup(item):
|
||||
"""测试运行前设置"""
|
||||
logger = get_logger()
|
||||
logger.divider()
|
||||
logger.log_test_start(item.name)
|
||||
|
||||
|
||||
def pytest_runtest_makereport(item, call):
|
||||
"""生成测试报告"""
|
||||
if call.when == "call":
|
||||
if call.excinfo:
|
||||
item._test_failed = True
|
||||
else:
|
||||
item._test_failed = False
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_runtest_call(item):
|
||||
"""测试运行钩子"""
|
||||
yield
|
||||
# 测试运行后处理
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""修改测试项目"""
|
||||
# 按标记排序
|
||||
marker_priority = {
|
||||
"smoke": 0,
|
||||
"performance": 1,
|
||||
"regression": 2,
|
||||
"responsive": 3,
|
||||
"cross_browser": 4,
|
||||
"form": 5,
|
||||
"navigation": 6,
|
||||
"interactive": 7
|
||||
}
|
||||
|
||||
def get_priority(item):
|
||||
for marker in item.iter_markers():
|
||||
if marker.name in marker_priority:
|
||||
return marker_priority[marker.name]
|
||||
return 10
|
||||
|
||||
items.sort(key=get_priority)
|
||||
|
||||
|
||||
# 自定义断言
|
||||
|
||||
class E2EAssertions:
|
||||
"""E2E测试断言类"""
|
||||
|
||||
def __init__(self, page: Page):
|
||||
self.page = page
|
||||
self.logger = get_logger()
|
||||
|
||||
def title_contains(self, expected: str, message: Optional[str] = None) -> None:
|
||||
"""断言标题包含预期文本"""
|
||||
actual = self.page.title()
|
||||
assert expected in actual, message or f"标题不包含 '{expected}': {actual}"
|
||||
self.logger.log_assertion(f"标题包含 '{expected}'", True)
|
||||
|
||||
def url_contains(self, expected: str, message: Optional[str] = None) -> None:
|
||||
"""断言URL包含预期文本"""
|
||||
assert expected in self.page.url, message or f"URL不包含 '{expected}': {self.page.url}"
|
||||
self.logger.log_assertion(f"URL包含 '{expected}'", True)
|
||||
|
||||
def element_exists(self, selector: str, timeout: int = 5000) -> None:
|
||||
"""断言元素存在"""
|
||||
try:
|
||||
self.page.wait_for_selector(selector, timeout=timeout)
|
||||
self.logger.log_assertion(f"元素存在: {selector}", True)
|
||||
except Exception as e:
|
||||
self.logger.log_assertion(f"元素存在: {selector}", False)
|
||||
raise AssertionError(f"元素不存在: {selector}") from e
|
||||
|
||||
def element_visible(self, selector: str, timeout: int = 5000) -> None:
|
||||
"""断言元素可见"""
|
||||
element = self.page.locator(selector).first
|
||||
assert element.is_visible(timeout=timeout), f"元素不可见: {selector}"
|
||||
self.logger.log_assertion(f"元素可见: {selector}", True)
|
||||
|
||||
def element_not_visible(self, selector: str, timeout: int = 5000) -> None:
|
||||
"""断言元素不可见"""
|
||||
element = self.page.locator(selector).first
|
||||
assert not element.is_visible(timeout=timeout), f"元素应该不可见: {selector}"
|
||||
self.logger.log_assertion(f"元素不可见: {selector}", True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def assert_(page: Page) -> E2EAssertions:
|
||||
"""断言fixture"""
|
||||
return E2EAssertions(page)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
联系表单测试模块
|
||||
测试联系表单的各项功能和验证
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import Dict, Any
|
||||
|
||||
from pages.contact_page import ContactPage
|
||||
|
||||
|
||||
class TestContactForm:
|
||||
"""联系表单测试类"""
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.form
|
||||
def test_contact_page_loads(self, contact_page: ContactPage):
|
||||
"""测试联系页面加载"""
|
||||
contact_page.navigate()
|
||||
contact_page.verify_page_loaded()
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_contact_page_title(self, contact_page: ContactPage):
|
||||
"""测试联系页面标题"""
|
||||
contact_page.navigate()
|
||||
contact_page.assert_title_contains("四川睿新致远")
|
||||
|
||||
@pytest.mark.regression
|
||||
@pytest.mark.form
|
||||
def test_contact_page_structure(self, contact_page: ContactPage):
|
||||
"""测试联系页面结构"""
|
||||
contact_page.navigate()
|
||||
contact_page.verify_page_structure()
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_contact_page_company_info(self, contact_page: ContactPage):
|
||||
"""测试公司信息显示"""
|
||||
contact_page.navigate()
|
||||
contact_page.verify_company_info()
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_contact_page_form_fields(self, contact_page: ContactPage):
|
||||
"""测试表单字段"""
|
||||
contact_page.navigate()
|
||||
contact_page.verify_form_fields()
|
||||
|
||||
@pytest.mark.form
|
||||
def test_form_validation_required_fields(self, contact_page: ContactPage):
|
||||
"""测试必填字段验证"""
|
||||
contact_page.navigate()
|
||||
contact_page.verify_form_validation()
|
||||
|
||||
@pytest.mark.form
|
||||
def test_form_submission_success(self, contact_page: ContactPage, test_data_generator):
|
||||
"""测试表单提交成功"""
|
||||
contact_page.navigate()
|
||||
|
||||
# 生成测试数据
|
||||
data = test_data_generator.generate_contact_form_data(use_valid=True)
|
||||
|
||||
# 填写并提交表单
|
||||
contact_page.fill_contact_form(data)
|
||||
contact_page.submit_form()
|
||||
|
||||
# 验证成功
|
||||
contact_page.verify_form_submission_success()
|
||||
|
||||
@pytest.mark.form
|
||||
def test_form_submission_with_minimal_data(self, contact_page: ContactPage):
|
||||
"""测试表单提交(最小数据)"""
|
||||
contact_page.navigate()
|
||||
|
||||
# 最小数据
|
||||
data = {
|
||||
"name": "测试用户",
|
||||
"email": "test@example.com",
|
||||
"subject": "测试主题",
|
||||
"message": "这是一条测试消息。"
|
||||
}
|
||||
|
||||
# 填写并提交表单
|
||||
contact_page.fill_contact_form(data)
|
||||
contact_page.submit_form()
|
||||
|
||||
# 验证成功
|
||||
contact_page.verify_form_submission_success()
|
||||
|
||||
@pytest.mark.form
|
||||
def test_form_with_empty_name(self, contact_page: ContactPage):
|
||||
"""测试姓名为空的表单验证"""
|
||||
contact_page.navigate()
|
||||
|
||||
data = {
|
||||
"name": "",
|
||||
"email": "test@example.com",
|
||||
"subject": "测试主题",
|
||||
"message": "这是一条测试消息。"
|
||||
}
|
||||
|
||||
contact_page.fill_contact_form(data)
|
||||
|
||||
# 点击提交按钮
|
||||
contact_page._click("form_submit_button")
|
||||
|
||||
# 应该显示验证错误
|
||||
try:
|
||||
contact_page.assert_element_visible("form_name_input:invalid", timeout=2000)
|
||||
except Exception:
|
||||
# 可能通过后端验证
|
||||
pass
|
||||
|
||||
@pytest.mark.form
|
||||
def test_form_with_invalid_email(self, contact_page: ContactPage):
|
||||
"""测试无效邮箱验证"""
|
||||
contact_page.navigate()
|
||||
|
||||
data = {
|
||||
"name": "测试用户",
|
||||
"email": "invalid-email",
|
||||
"subject": "测试主题",
|
||||
"message": "这是一条测试消息。"
|
||||
}
|
||||
|
||||
contact_page.fill_contact_form(data)
|
||||
|
||||
# 检查邮箱字段
|
||||
email_input = contact_page._find("form_email_input")
|
||||
validity = email_input.evaluate("""
|
||||
el => ({
|
||||
valid: el.validity.valid,
|
||||
typeMismatch: el.validity.typeMismatch
|
||||
})
|
||||
""")
|
||||
|
||||
# 验证邮箱格式
|
||||
assert not validity["valid"] or validity["typeMismatch"], \
|
||||
"无效邮箱应该被标记为无效"
|
||||
|
||||
@pytest.mark.form
|
||||
def test_form_submission_performance(self, contact_page: ContactPage, test_data_generator):
|
||||
"""测试表单提交性能"""
|
||||
contact_page.navigate()
|
||||
|
||||
data = test_data_generator.generate_contact_form_data(use_valid=True)
|
||||
|
||||
result = contact_page.test_form_submission_performance(data, max_duration=5.0)
|
||||
|
||||
assert result["passed"], f"表单提交耗时 {result['duration']:.2f}s 超过5秒阈值"
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_contact_page_mobile_layout(self, contact_page: ContactPage):
|
||||
"""测试联系页面移动端布局"""
|
||||
contact_page.verify_responsive_layout(375)
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_contact_page_tablet_layout(self, contact_page: ContactPage):
|
||||
"""测试联系页面平板端布局"""
|
||||
contact_page.verify_responsive_layout(768)
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_contact_page_desktop_layout(self, contact_page: ContactPage):
|
||||
"""测试联系页面桌面端布局"""
|
||||
contact_page.verify_responsive_layout(1920)
|
||||
|
||||
@pytest.mark.interactive
|
||||
def test_extract_contact_details(self, contact_page: ContactPage):
|
||||
"""测试提取联系详情"""
|
||||
contact_page.navigate()
|
||||
details = contact_page.extract_contact_details()
|
||||
|
||||
assert "phone" in details or "email" in details or "address" in details
|
||||
|
||||
@pytest.mark.interactive
|
||||
def test_get_working_hours(self, contact_page: ContactPage):
|
||||
"""测试获取工作时间"""
|
||||
contact_page.navigate()
|
||||
hours = contact_page.get_working_hours()
|
||||
|
||||
assert isinstance(hours, dict)
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_form_reset_after_submission(self, contact_page: ContactPage, test_data_generator):
|
||||
"""测试提交后表单重置"""
|
||||
contact_page.navigate()
|
||||
|
||||
data = test_data_generator.generate_contact_form_data(use_valid=True)
|
||||
|
||||
# 第一次提交
|
||||
contact_page.fill_contact_form(data)
|
||||
contact_page.submit_form()
|
||||
contact_page.verify_form_submission_success()
|
||||
|
||||
# 刷新页面后表单应该重置
|
||||
contact_page.reload()
|
||||
contact_page.assert_element_visible("contact_form", timeout=5000)
|
||||
|
||||
@pytest.mark.form
|
||||
@pytest.mark.performance
|
||||
def test_form_typing_performance(self, contact_page: ContactPage, test_data_generator):
|
||||
"""测试表单输入性能"""
|
||||
import time
|
||||
|
||||
contact_page.navigate()
|
||||
|
||||
data = test_data_generator.generate_contact_form_data(use_valid=True)
|
||||
|
||||
# 测量填充时间
|
||||
start_time = time.time()
|
||||
|
||||
contact_page.fill_contact_form(data)
|
||||
|
||||
end_time = time.time()
|
||||
fill_time = (end_time - start_time) * 1000
|
||||
|
||||
# 填充时间应该在5秒内
|
||||
assert fill_time < 5000, f"表单填充时间 {fill_time:.2f}ms 超过5秒阈值"
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_form_with_special_characters(self, contact_page: ContactPage):
|
||||
"""测试包含特殊字符的表单提交"""
|
||||
contact_page.navigate()
|
||||
|
||||
data = {
|
||||
"name": "测试用户-Name",
|
||||
"email": "test+special@example.com",
|
||||
"subject": "特殊字符测试: @#$%",
|
||||
"message": "这是一条包含特殊字符的消息!测试...end"
|
||||
}
|
||||
|
||||
contact_page.fill_contact_form(data)
|
||||
contact_page.submit_form()
|
||||
|
||||
# 验证成功
|
||||
try:
|
||||
contact_page.verify_form_submission_success()
|
||||
except Exception:
|
||||
# 可能需要等待
|
||||
contact_page.page.wait_for_timeout(2000)
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_form_with_long_content(self, contact_page: ContactPage):
|
||||
"""测试长内容表单提交"""
|
||||
contact_page.navigate()
|
||||
|
||||
# 生成长内容
|
||||
long_message = "这是一条很长的消息。" * 50
|
||||
|
||||
data = {
|
||||
"name": "长内容测试用户",
|
||||
"email": "longtest@example.com",
|
||||
"subject": "长内容测试主题" * 10,
|
||||
"message": long_message
|
||||
}
|
||||
|
||||
contact_page.fill_contact_form(data)
|
||||
contact_page.submit_form()
|
||||
|
||||
# 验证成功
|
||||
try:
|
||||
contact_page.verify_form_submission_success()
|
||||
except Exception:
|
||||
contact_page.page.wait_for_timeout(2000)
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
首页测试模块
|
||||
测试首页的各项功能和特性
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import Dict, Any
|
||||
|
||||
from pages.home_page import HomePage
|
||||
|
||||
|
||||
class TestHomePage:
|
||||
"""首页测试类"""
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.navigation
|
||||
def test_home_page_loads_successfully(self, home_page: HomePage):
|
||||
"""测试首页正常加载"""
|
||||
home_page.navigate()
|
||||
home_page.verify_page_loaded()
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_home_page_title(self, home_page: HomePage):
|
||||
"""测试首页标题"""
|
||||
home_page.navigate()
|
||||
home_page.assert_title_contains("睿新致远")
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_home_page_url(self, home_page: HomePage):
|
||||
"""测试首页URL"""
|
||||
home_page.navigate()
|
||||
home_page.assert_url_equals(home_page._get_full_url("/"))
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_home_page_header(self, home_page: HomePage):
|
||||
"""测试页头"""
|
||||
home_page.navigate()
|
||||
home_page.verify_header()
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_home_page_hero_section(self, home_page: HomePage):
|
||||
"""测试Hero区域"""
|
||||
home_page.navigate()
|
||||
home_page.verify_hero_section()
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_home_page_services_section(self, home_page: HomePage):
|
||||
"""测试服务区域"""
|
||||
home_page.navigate()
|
||||
home_page.verify_services_section()
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_home_page_products_section(self, home_page: HomePage):
|
||||
"""测试产品区域"""
|
||||
home_page.navigate()
|
||||
home_page.verify_products_section()
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_home_page_news_section(self, home_page: HomePage):
|
||||
"""测试新闻区域"""
|
||||
home_page.navigate()
|
||||
home_page.verify_news_section()
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_home_page_contact_section(self, home_page: HomePage):
|
||||
"""测试联系区域"""
|
||||
home_page.navigate()
|
||||
home_page.verify_contact_section()
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_home_page_footer(self, home_page: HomePage):
|
||||
"""测试页脚"""
|
||||
home_page.navigate()
|
||||
home_page.verify_footer()
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_home_page_all_sections(self, home_page: HomePage):
|
||||
"""测试所有区域"""
|
||||
home_page.navigate()
|
||||
home_page.verify_all_sections()
|
||||
|
||||
@pytest.mark.navigation
|
||||
@pytest.mark.interactive
|
||||
def test_scroll_to_about_section(self, home_page: HomePage):
|
||||
"""测试滚动到关于区域"""
|
||||
home_page.navigate()
|
||||
home_page.scroll_to_section("about")
|
||||
home_page.assert_element_visible("#about", timeout=5000)
|
||||
|
||||
@pytest.mark.navigation
|
||||
@pytest.mark.interactive
|
||||
def test_scroll_to_services_section(self, home_page: HomePage):
|
||||
"""测试滚动到服务区域"""
|
||||
home_page.navigate()
|
||||
home_page.scroll_to_section("services")
|
||||
home_page.assert_element_visible("#services", timeout=5000)
|
||||
|
||||
@pytest.mark.navigation
|
||||
@pytest.mark.interactive
|
||||
def test_scroll_to_products_section(self, home_page: HomePage):
|
||||
"""测试滚动到产品区域"""
|
||||
home_page.navigate()
|
||||
home_page.scroll_to_section("products")
|
||||
home_page.assert_element_visible("#products", timeout=5000)
|
||||
|
||||
@pytest.mark.navigation
|
||||
@pytest.mark.interactive
|
||||
def test_scroll_to_news_section(self, home_page: HomePage):
|
||||
"""测试滚动到新闻区域"""
|
||||
home_page.navigate()
|
||||
home_page.scroll_to_section("news")
|
||||
home_page.assert_element_visible("#news", timeout=5000)
|
||||
|
||||
@pytest.mark.navigation
|
||||
@pytest.mark.interactive
|
||||
def test_scroll_to_contact_section(self, home_page: HomePage):
|
||||
"""测试滚动到联系区域"""
|
||||
home_page.navigate()
|
||||
home_page.scroll_to_section("contact")
|
||||
home_page.assert_element_visible("#contact", timeout=5000)
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_home_page_performance(self, home_page: HomePage):
|
||||
"""测试首页性能"""
|
||||
home_page.navigate()
|
||||
performance = home_page.verify_page_performance()
|
||||
|
||||
# 验证关键性能指标
|
||||
assert performance.get("pageLoadTime", 0) < 5000, "页面加载时间超过5秒"
|
||||
assert performance.get("domContentLoaded", 0) < 3000, "DOM内容加载时间超过3秒"
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_home_page_load_time(self, home_page: HomePage):
|
||||
"""测试首页加载时间"""
|
||||
import time
|
||||
|
||||
home_page.navigate()
|
||||
|
||||
start_time = time.time()
|
||||
home_page.wait_for_load()
|
||||
end_time = time.time()
|
||||
|
||||
load_time = (end_time - start_time) * 1000 # 转换为毫秒
|
||||
|
||||
# 断言加载时间在阈值内
|
||||
assert load_time < 5000, f"首页加载时间 {load_time:.2f}ms 超过5秒阈值"
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_home_page_mobile_layout(self, home_page: HomePage):
|
||||
"""测试移动端布局"""
|
||||
home_page.verify_responsive_design(375, 667)
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_home_page_tablet_layout(self, home_page: HomePage):
|
||||
"""测试平板端布局"""
|
||||
home_page.verify_responsive_design(768, 1024)
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_home_page_desktop_layout(self, home_page: HomePage):
|
||||
"""测试桌面端布局"""
|
||||
home_page.verify_responsive_design(1920, 1080)
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_home_page_wide_layout(self, home_page: HomePage):
|
||||
"""测试宽屏布局"""
|
||||
home_page.verify_responsive_design(2560, 1440)
|
||||
|
||||
@pytest.mark.interactive
|
||||
def test_get_company_info(self, home_page: HomePage):
|
||||
"""测试获取公司信息"""
|
||||
home_page.navigate()
|
||||
info = home_page.get_company_info()
|
||||
|
||||
assert "name" in info
|
||||
assert "slogan" in info
|
||||
assert "description" in info
|
||||
|
||||
@pytest.mark.interactive
|
||||
def test_get_statistics(self, home_page: HomePage):
|
||||
"""测试获取统计数据"""
|
||||
home_page.navigate()
|
||||
stats = home_page.get_statistics()
|
||||
|
||||
assert "customers" in stats
|
||||
assert "cases" in stats
|
||||
|
||||
@pytest.mark.interactive
|
||||
def test_get_featured_services(self, home_page: HomePage):
|
||||
"""测试获取服务列表"""
|
||||
home_page.navigate()
|
||||
services = home_page.get_featured_services()
|
||||
|
||||
assert isinstance(services, list)
|
||||
if len(services) > 0:
|
||||
assert "title" in services[0]
|
||||
|
||||
@pytest.mark.interactive
|
||||
def test_get_latest_news(self, home_page: HomePage):
|
||||
"""测试获取最新新闻"""
|
||||
home_page.navigate()
|
||||
news = home_page.get_latest_news()
|
||||
|
||||
assert isinstance(news, list)
|
||||
if len(news) > 0:
|
||||
assert "title" in news[0]
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_page_refresh(self, home_page: HomePage):
|
||||
"""测试页面刷新"""
|
||||
home_page.navigate()
|
||||
home_page.reload()
|
||||
home_page.verify_page_loaded()
|
||||
|
||||
@pytest.mark.navigation
|
||||
def test_navigation_links_count(self, home_page: HomePage):
|
||||
"""测试导航链接数量"""
|
||||
home_page.navigate()
|
||||
|
||||
nav_links = home_page._find_all("nav a")
|
||||
|
||||
# 应该有6个导航链接:首页、关于我们、核心业务、产品服务、新闻动态、联系我们
|
||||
assert len(nav_links) >= 5, f"导航链接数量不足,当前{len(nav_links)}个"
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
导航测试模块
|
||||
测试网站导航功能
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import Dict, Any
|
||||
|
||||
from pages.home_page import HomePage
|
||||
|
||||
|
||||
class TestNavigation:
|
||||
"""导航测试类"""
|
||||
|
||||
@pytest.mark.navigation
|
||||
@pytest.mark.smoke
|
||||
def test_navigate_to_home(self, home_page: HomePage):
|
||||
"""测试导航到首页"""
|
||||
home_page.navigate()
|
||||
home_page.assert_url_equals(home_page._get_full_url("/"))
|
||||
|
||||
@pytest.mark.navigation
|
||||
@pytest.mark.smoke
|
||||
def test_navigate_to_contact_page(self, home_page: HomePage, contact_page):
|
||||
"""测试导航到联系页面"""
|
||||
contact_page.navigate()
|
||||
contact_page.assert_url_equals(
|
||||
home_page._get_full_url("/contact")
|
||||
)
|
||||
|
||||
@pytest.mark.navigation
|
||||
@pytest.mark.interactive
|
||||
def test_click_navigation_to_about(self, home_page: HomePage):
|
||||
"""测试点击导航到关于区域"""
|
||||
home_page.navigate()
|
||||
home_page.click_navigation_link("about")
|
||||
home_page.assert_element_visible("#about", timeout=5000)
|
||||
|
||||
@pytest.mark.navigation
|
||||
@pytest.mark.interactive
|
||||
def test_click_navigation_to_services(self, home_page: HomePage):
|
||||
"""测试点击导航到服务区域"""
|
||||
home_page.navigate()
|
||||
home_page.click_navigation_link("services")
|
||||
home_page.assert_element_visible("#services", timeout=5000)
|
||||
|
||||
@pytest.mark.navigation
|
||||
@pytest.mark.interactive
|
||||
def test_click_navigation_to_products(self, home_page: HomePage):
|
||||
"""测试点击导航到产品区域"""
|
||||
home_page.navigate()
|
||||
home_page.click_navigation_link("products")
|
||||
home_page.assert_element_visible("#products", timeout=5000)
|
||||
|
||||
@pytest.mark.navigation
|
||||
@pytest.mark.interactive
|
||||
def test_click_navigation_to_news(self, home_page: HomePage):
|
||||
"""测试点击导航到新闻区域"""
|
||||
home_page.navigate()
|
||||
home_page.click_navigation_link("news")
|
||||
home_page.assert_element_visible("#news", timeout=5000)
|
||||
|
||||
@pytest.mark.navigation
|
||||
@pytest.mark.interactive
|
||||
def test_click_navigation_to_contact(self, home_page: HomePage):
|
||||
"""测试点击导航到联系区域"""
|
||||
home_page.navigate()
|
||||
home_page.click_navigation_link("contact")
|
||||
home_page.assert_element_visible("#contact", timeout=5000)
|
||||
|
||||
@pytest.mark.navigation
|
||||
def test_smooth_scroll_to_section(self, home_page: HomePage):
|
||||
"""测试平滑滚动到区域"""
|
||||
home_page.navigate()
|
||||
|
||||
# 先滚动到页面底部
|
||||
home_page.scroll_to_bottom()
|
||||
|
||||
# 然后滚动到顶部
|
||||
home_page.scroll_to_top()
|
||||
|
||||
# 验证
|
||||
home_page.assert_element_visible("header")
|
||||
|
||||
@pytest.mark.navigation
|
||||
def test_scroll_to_each_section(self, home_page: HomePage):
|
||||
"""测试滚动到每个区域"""
|
||||
home_page.navigate()
|
||||
|
||||
sections = ["home", "about", "services", "products", "news", "contact"]
|
||||
|
||||
for section in sections:
|
||||
home_page.scroll_to_section(section)
|
||||
home_page.assert_element_visible(f"#{section}", timeout=5000)
|
||||
|
||||
@pytest.mark.navigation
|
||||
def test_page_back(self, home_page: HomePage, contact_page):
|
||||
"""测试返回上一页"""
|
||||
# 先访问联系页面
|
||||
contact_page.navigate()
|
||||
contact_page.assert_url_equals(home_page._get_full_url("/contact"))
|
||||
|
||||
# 返回首页
|
||||
home_page.go_back()
|
||||
|
||||
# 验证
|
||||
home_page.assert_url_equals(home_page._get_full_url("/"))
|
||||
|
||||
@pytest.mark.navigation
|
||||
def test_page_forward(self, home_page: HomePage, contact_page):
|
||||
"""测试前进到下一页"""
|
||||
# 访问首页
|
||||
home_page.navigate()
|
||||
|
||||
# 后退(此时没有上一页,应该保持在首页)
|
||||
home_page.go_back()
|
||||
|
||||
# 前进(此时没有下一页,应该保持在首页)
|
||||
home_page.go_forward()
|
||||
|
||||
home_page.assert_url_equals(home_page._get_full_url("/"))
|
||||
|
||||
@pytest.mark.navigation
|
||||
def test_page_reload(self, home_page: HomePage):
|
||||
"""测试页面刷新"""
|
||||
home_page.navigate()
|
||||
home_page.reload()
|
||||
home_page.verify_page_loaded()
|
||||
|
||||
@pytest.mark.navigation
|
||||
def test_navigation_link_count(self, home_page: HomePage):
|
||||
"""测试导航链接数量"""
|
||||
home_page.navigate()
|
||||
|
||||
nav_links = home_page._find_all("nav a")
|
||||
|
||||
# 应该有6个导航链接
|
||||
assert len(nav_links) >= 5, f"导航链接数量不足,当前{len(nav_links)}个"
|
||||
|
||||
@pytest.mark.navigation
|
||||
def test_navigation_link_text(self, home_page: HomePage):
|
||||
"""测试导航链接文本"""
|
||||
home_page.navigate()
|
||||
|
||||
expected_links = ["首页", "关于我们", "核心业务", "产品服务", "新闻动态", "联系我们"]
|
||||
|
||||
for link_text in expected_links:
|
||||
link = home_page.page.locator(f"nav a:has-text('{link_text}')")
|
||||
assert link.count() > 0, f"未找到导航链接: {link_text}"
|
||||
|
||||
@pytest.mark.navigation
|
||||
@pytest.mark.responsive
|
||||
def test_navigation_mobile(self, home_page: HomePage):
|
||||
"""测试移动端导航"""
|
||||
# 设置移动端视口
|
||||
home_page.page.set_viewport_size({"width": 375, "height": 667})
|
||||
home_page.navigate()
|
||||
|
||||
# 移动端应该显示汉堡菜单
|
||||
menu_button = home_page.page.locator("button:has-text('菜单'), .mobile-menu")
|
||||
|
||||
if menu_button.count() > 0:
|
||||
home_page.logger.info("移动端显示汉堡菜单")
|
||||
else:
|
||||
home_page.logger.info("移动端导航可能已内联显示")
|
||||
|
||||
@pytest.mark.navigation
|
||||
def test_url_hash_navigation(self, home_page: HomePage):
|
||||
"""测试URL哈希导航"""
|
||||
home_page.navigate()
|
||||
|
||||
# 直接访问带哈希的URL
|
||||
home_page.navigate(path="/#about")
|
||||
home_page.wait_for_load()
|
||||
|
||||
# 验证滚动到指定区域
|
||||
home_page.assert_element_visible("#about", timeout=5000)
|
||||
|
||||
@pytest.mark.navigation
|
||||
def test_browser_back_button(self, home_page: HomePage, contact_page):
|
||||
"""测试浏览器后退按钮"""
|
||||
# 访问首页
|
||||
home_page.navigate()
|
||||
|
||||
# 访问联系页面
|
||||
contact_page.navigate()
|
||||
|
||||
# 使用浏览器后退
|
||||
home_page.page.go_back()
|
||||
|
||||
# 验证返回首页
|
||||
home_page.assert_url_equals(home_page._get_full_url("/"))
|
||||
|
||||
@pytest.mark.interactive
|
||||
def test_cta_button_navigation(self, home_page: HomePage):
|
||||
"""测试CTA按钮导航"""
|
||||
home_page.navigate()
|
||||
|
||||
# 查找CTA按钮(如果有)
|
||||
cta_button = home_page.page.locator(
|
||||
"a[href*='contact'], a.cta, a.button:has-text('联系')"
|
||||
)
|
||||
|
||||
if cta_button.count() > 0:
|
||||
cta_button.first.click()
|
||||
home_page.wait_for_load()
|
||||
|
||||
# 应该导航到联系区域
|
||||
home_page.assert_element_visible("#contact", timeout=5000)
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
性能测试模块
|
||||
测试网站性能指标
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from pages.home_page import HomePage
|
||||
from pages.contact_page import ContactPage
|
||||
from config.settings import get_settings
|
||||
|
||||
|
||||
class TestPerformance:
|
||||
"""性能测试类"""
|
||||
|
||||
@pytest.mark.performance
|
||||
@pytest.mark.smoke
|
||||
def test_home_page_load_time(self, home_page: HomePage):
|
||||
"""测试首页加载时间"""
|
||||
home_page.navigate()
|
||||
|
||||
start_time = time.time()
|
||||
home_page.wait_for_load()
|
||||
end_time = time.time()
|
||||
|
||||
load_time = (end_time - start_time) * 1000 # 毫秒
|
||||
|
||||
# 阈值:5秒
|
||||
assert load_time < 5000, f"首页加载时间 {load_time:.2f}ms 超过5秒阈值"
|
||||
|
||||
@pytest.mark.performance
|
||||
@pytest.mark.smoke
|
||||
def test_contact_page_load_time(self, contact_page: ContactPage):
|
||||
"""测试联系页面加载时间"""
|
||||
contact_page.navigate()
|
||||
|
||||
start_time = time.time()
|
||||
contact_page.wait_for_load()
|
||||
end_time = time.time()
|
||||
|
||||
load_time = (end_time - start_time) * 1000
|
||||
|
||||
# 阈值:5秒
|
||||
assert load_time < 5000, f"联系页面加载时间 {load_time:.2f}ms 超过5秒阈值"
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_dom_content_loaded_time(self, home_page: HomePage):
|
||||
"""测试DOM内容加载时间"""
|
||||
home_page.navigate()
|
||||
|
||||
performance_data = home_page.execute_js("""
|
||||
() => {
|
||||
const timing = performance.timing;
|
||||
return {
|
||||
domContentLoaded: timing.domContentLoadedEventEnd - timing.navigationStart,
|
||||
domInteractive: timing.domInteractive - timing.domLoading
|
||||
};
|
||||
}
|
||||
""")
|
||||
|
||||
dom_loaded = performance_data.get("domContentLoaded", 0)
|
||||
assert dom_loaded < 3000, f"DOM内容加载时间 {dom_loaded}ms 超过3秒阈值"
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_first_paint_time(self, home_page: HomePage):
|
||||
"""测试首次绘制时间"""
|
||||
home_page.navigate()
|
||||
|
||||
first_paint = home_page.execute_js("""
|
||||
() => {
|
||||
const entries = performance.getEntriesByType('paint');
|
||||
const firstPaint = entries.find(e => e.name === 'first-paint');
|
||||
return firstPaint ? firstPaint.startTime : 0;
|
||||
}
|
||||
""")
|
||||
|
||||
if first_paint:
|
||||
assert first_paint < 2000, f"首次绘制时间 {first_paint:.2f}ms 超过2秒阈值"
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_first_contentful_paint(self, home_page: HomePage):
|
||||
"""测试首次内容绘制(FCP)"""
|
||||
home_page.navigate()
|
||||
|
||||
fcp = home_page.execute_js("""
|
||||
() => {
|
||||
const navigation = performance.getEntriesByType('navigation')[0];
|
||||
return navigation ? navigation.firstContentfulPaint : 0;
|
||||
}
|
||||
""")
|
||||
|
||||
if fcp:
|
||||
# 阈值:1.5秒
|
||||
assert fcp < 1500, f"首次内容绘制时间 {fcp:.2f}ms 超过1.5秒阈值"
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_largest_contentful_paint(self, home_page: HomePage):
|
||||
"""测试最大内容绘制(LCP)"""
|
||||
home_page.navigate()
|
||||
|
||||
lcp = home_page.execute_js("""
|
||||
() => {
|
||||
try {
|
||||
const navigation = performance.getEntriesByType('navigation')[0];
|
||||
return navigation ? navigation.largestContentfulPaint : 0;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
if lcp:
|
||||
# 阈值:2.5秒
|
||||
assert lcp < 2500, f"最大内容绘制时间 {lcp:.2f}ms 超过2.5秒阈值"
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_time_to_interactive(self, home_page: HomePage):
|
||||
"""测试可交互时间(TTI)"""
|
||||
home_page.navigate()
|
||||
|
||||
tti = home_page.execute_js("""
|
||||
() => {
|
||||
try {
|
||||
const navigation = performance.getEntriesByType('navigation')[0];
|
||||
return navigation ? navigation.interactive : 0;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
if tti:
|
||||
# 阈值:3秒
|
||||
assert tti < 3000, f"可交互时间 {tti:.2f}ms 超过3秒阈值"
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_page_load_performance_metrics(self, home_page: HomePage):
|
||||
"""测试页面加载性能指标"""
|
||||
home_page.navigate()
|
||||
performance = home_page.verify_page_performance()
|
||||
|
||||
# 验证关键指标
|
||||
if performance.get("pageLoadTime"):
|
||||
assert performance["pageLoadTime"] < 5000
|
||||
if performance.get("domContentLoaded"):
|
||||
assert performance["domContentLoaded"] < 3000
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_network_timing(self, home_page: HomePage):
|
||||
"""测试网络时序"""
|
||||
home_page.navigate()
|
||||
|
||||
timing = home_page.execute_js("""
|
||||
() => {
|
||||
const timing = performance.timing;
|
||||
return {
|
||||
dnsLookup: timing.domainLookupEnd - timing.domainLookupStart,
|
||||
tcpConnection: timing.connectEnd - timing.connectStart,
|
||||
serverResponse: timing.responseEnd - timing.requestStart,
|
||||
domProcessing: timing.domLoading - timing.responseEnd
|
||||
};
|
||||
}
|
||||
""")
|
||||
|
||||
# DNS查询时间
|
||||
assert timing.get("dnsLookup", 0) < 500, \
|
||||
f"DNS查询时间 {timing.get('dnsLookup')}ms 超过500ms阈值"
|
||||
|
||||
# TCP连接时间
|
||||
assert timing.get("tcpConnection", 0) < 500, \
|
||||
f"TCP连接时间 {timing.get('tcpConnection')}ms 超过500ms阈值"
|
||||
|
||||
# 服务器响应时间
|
||||
assert timing.get("serverResponse", 0) < 1000, \
|
||||
f"服务器响应时间 {timing.get('serverResponse')}ms 超过1秒阈值"
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_resource_timing(self, home_page: HomePage):
|
||||
"""测试资源加载时序"""
|
||||
home_page.navigate()
|
||||
|
||||
resources = home_page.execute_js("""
|
||||
() => {
|
||||
const entries = performance.getEntriesByType('resource');
|
||||
const scripts = entries.filter(e => e.initiatorType === 'script');
|
||||
const styles = entries.filter(e => e.initiatorType === 'css');
|
||||
|
||||
return {
|
||||
totalResources: entries.length,
|
||||
scriptCount: scripts.length,
|
||||
styleCount: styles.length,
|
||||
totalDuration: entries.reduce((sum, e) => sum + e.duration, 0)
|
||||
};
|
||||
}
|
||||
""")
|
||||
|
||||
assert resources.get("totalResources", 0) > 0, "未检测到资源加载"
|
||||
|
||||
home_page.logger.info(
|
||||
f"资源统计: 共{resources.get('totalResources')}个资源,"
|
||||
f"脚本{resources.get('scriptCount')}个,"
|
||||
f"样式{resources.get('styleCount')}个"
|
||||
)
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_form_submission_time(self, contact_page: ContactPage, test_data_generator):
|
||||
"""测试表单提交时间"""
|
||||
contact_page.navigate()
|
||||
|
||||
data = test_data_generator.generate_contact_form_data(use_valid=True)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
contact_page.fill_contact_form(data)
|
||||
contact_page.submit_form()
|
||||
|
||||
try:
|
||||
contact_page.verify_form_submission_success()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
end_time = time.time()
|
||||
duration = (end_time - start_time) * 1000
|
||||
|
||||
# 阈值:5秒
|
||||
assert duration < 5000, f"表单提交耗时 {duration:.2f}ms 超过5秒阈值"
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_scroll_performance(self, home_page: HomePage):
|
||||
"""测试滚动性能"""
|
||||
home_page.navigate()
|
||||
|
||||
# 执行多次滚动
|
||||
scroll_times = []
|
||||
for i in range(5):
|
||||
start_time = time.time()
|
||||
home_page.scroll_to_bottom()
|
||||
home_page.scroll_to_top()
|
||||
end_time = time.time()
|
||||
scroll_times.append((end_time - start_time) * 1000)
|
||||
|
||||
avg_scroll_time = sum(scroll_times) / len(scroll_times)
|
||||
|
||||
# 平均滚动时间应该在1秒内
|
||||
assert avg_scroll_time < 1000, f"平均滚动时间 {avg_scroll_time:.2f}ms 超过1秒阈值"
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_element_visibility_performance(self, home_page: HomePage):
|
||||
"""测试元素可见性检查性能"""
|
||||
home_page.navigate()
|
||||
|
||||
elements = [
|
||||
"header",
|
||||
"#home",
|
||||
"#about",
|
||||
"#services",
|
||||
"#products",
|
||||
"#news",
|
||||
"#contact",
|
||||
"footer"
|
||||
]
|
||||
|
||||
check_times = []
|
||||
for element in elements:
|
||||
start_time = time.time()
|
||||
try:
|
||||
home_page._is_visible(element)
|
||||
except Exception:
|
||||
pass
|
||||
end_time = time.time()
|
||||
check_times.append((end_time - start_time) * 1000)
|
||||
|
||||
avg_check_time = sum(check_times) / len(check_times)
|
||||
|
||||
# 单个元素检查时间应该在500ms内
|
||||
assert avg_check_time < 500, f"平均元素检查时间 {avg_check_time:.2f}ms 超过500ms阈值"
|
||||
|
||||
@pytest.mark.performance
|
||||
def test_navigation_performance(self, home_page: HomePage, contact_page: ContactPage):
|
||||
"""测试导航性能"""
|
||||
# 测量导航到联系页面的时间
|
||||
start_time = time.time()
|
||||
contact_page.navigate()
|
||||
contact_page.wait_for_load()
|
||||
end_time = time.time()
|
||||
|
||||
nav_time = (end_time - start_time) * 1000
|
||||
|
||||
# 阈值:3秒
|
||||
assert nav_time < 3000, f"导航时间 {nav_time:.2f}ms 超过3秒阈值"
|
||||
|
||||
@pytest.mark.performance
|
||||
@pytest.mark.responsive
|
||||
def test_performance_across_viewports(self, home_page: HomePage):
|
||||
"""测试不同视口下的性能"""
|
||||
viewports = [
|
||||
(375, 667, "移动端"),
|
||||
(768, 1024, "平板端"),
|
||||
(1920, 1080, "桌面端")
|
||||
]
|
||||
|
||||
results = []
|
||||
for width, height, name in viewports:
|
||||
home_page.page.set_viewport_size({"width": width, "height": height})
|
||||
|
||||
start_time = time.time()
|
||||
home_page.navigate()
|
||||
home_page.wait_for_load()
|
||||
end_time = time.time()
|
||||
|
||||
load_time = (end_time - start_time) * 1000
|
||||
results.append((name, load_time))
|
||||
|
||||
home_page.logger.info(f"{name} ({width}x{height}): {load_time:.2f}ms")
|
||||
|
||||
# 验证所有视口加载时间在阈值内
|
||||
for name, load_time in results:
|
||||
assert load_time < 5000, f"{name}加载时间 {load_time:.2f}ms 超过5秒阈值"
|
||||
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
响应式设计测试模块
|
||||
测试网站在不同屏幕尺寸下的响应式表现
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import Dict, Any
|
||||
|
||||
from pages.home_page import HomePage
|
||||
from pages.contact_page import ContactPage
|
||||
|
||||
|
||||
class TestResponsive:
|
||||
"""响应式设计测试类"""
|
||||
|
||||
@pytest.mark.responsive
|
||||
@pytest.mark.smoke
|
||||
def test_homepage_mobile_375(self, home_page: HomePage):
|
||||
"""测试首页在iPhone SE尺寸下的响应式表现"""
|
||||
home_page.verify_responsive_design(375, 667)
|
||||
|
||||
@pytest.mark.responsive
|
||||
@pytest.mark.smoke
|
||||
def test_homepage_mobile_414(self, home_page: HomePage):
|
||||
"""测试首页在iPhone 8 Plus尺寸下的响应式表现"""
|
||||
home_page.verify_responsive_design(414, 896)
|
||||
|
||||
@pytest.mark.responsive
|
||||
@pytest.mark.smoke
|
||||
def test_homepage_tablet_768(self, home_page: HomePage):
|
||||
"""测试首页在iPad尺寸下的响应式表现"""
|
||||
home_page.verify_responsive_design(768, 1024)
|
||||
|
||||
@pytest.mark.responsive
|
||||
@pytest.mark.smoke
|
||||
def test_homepage_desktop_1920(self, home_page: HomePage):
|
||||
"""测试首页在桌面尺寸下的响应式表现"""
|
||||
home_page.verify_responsive_design(1920, 1080)
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_contact_page_mobile_375(self, contact_page: ContactPage):
|
||||
"""测试联系页面在移动端的响应式表现"""
|
||||
contact_page.verify_responsive_layout(375)
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_contact_page_tablet_768(self, contact_page: ContactPage):
|
||||
"""测试联系页面在平板端的响应式表现"""
|
||||
contact_page.verify_responsive_layout(768)
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_contact_page_desktop_1920(self, contact_page: ContactPage):
|
||||
"""测试联系页面在桌面端的响应式表现"""
|
||||
contact_page.verify_responsive_layout(1920)
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_header_responsive_mobile(self, home_page: HomePage):
|
||||
"""测试页头在移动端的响应式表现"""
|
||||
home_page.page.set_viewport_size({"width": 375, "height": 667})
|
||||
home_page.navigate()
|
||||
|
||||
# 验证页头可见
|
||||
home_page.assert_element_visible("header", timeout=5000)
|
||||
|
||||
# 移动端应该显示汉堡菜单
|
||||
menu_button = home_page.page.locator(
|
||||
"button:has-text('菜单'), .mobile-menu, .menu-toggle, button[aria-label*='menu']"
|
||||
)
|
||||
|
||||
if menu_button.count() > 0:
|
||||
home_page.logger.info("✅ 移动端页头包含汉堡菜单")
|
||||
else:
|
||||
home_page.logger.info("ℹ️ 移动端页头可能内联显示所有链接")
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_header_responsive_desktop(self, home_page: HomePage):
|
||||
"""测试页头在桌面端的响应式表现"""
|
||||
home_page.page.set_viewport_size({"width": 1920, "height": 1080})
|
||||
home_page.navigate()
|
||||
|
||||
# 验证页头可见
|
||||
home_page.assert_element_visible("header", timeout=5000)
|
||||
|
||||
# 桌面端应该显示完整导航
|
||||
nav_links = home_page._find_all("nav a")
|
||||
assert len(nav_links) >= 5, f"桌面端导航链接不足,当前{len(nav_links)}个"
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_navigation_responsive_mobile(self, home_page: HomePage):
|
||||
"""测试导航在移动端的响应式表现"""
|
||||
home_page.page.set_viewport_size({"width": 375, "height": 667})
|
||||
home_page.navigate()
|
||||
|
||||
# 检查导航是否可访问 - 移动端可能隐藏导航或使用汉堡菜单
|
||||
nav_visible = home_page._is_visible("nav")
|
||||
mobile_menu_visible = home_page._is_visible(".mobile-menu, .menu-toggle, button[aria-label*='menu']")
|
||||
header_visible = home_page._is_visible("header")
|
||||
|
||||
# 只要页头可见,就认为导航可访问(导航可能在页头内)
|
||||
assert nav_visible or mobile_menu_visible or header_visible, "移动端导航不可访问"
|
||||
home_page.logger.info("✅ 移动端导航可访问")
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_hero_section_responsive(self, home_page: HomePage):
|
||||
"""测试Hero区域在不同尺寸下的表现"""
|
||||
viewports = [
|
||||
(375, 667, "移动端"),
|
||||
(768, 1024, "平板端"),
|
||||
(1920, 1080, "桌面端")
|
||||
]
|
||||
|
||||
for width, height, name in viewports:
|
||||
home_page.page.set_viewport_size({"width": width, "height": height})
|
||||
home_page.navigate()
|
||||
|
||||
# 验证Hero区域可见
|
||||
hero_visible = home_page._is_visible("#home, .hero-section")
|
||||
|
||||
if hero_visible:
|
||||
home_page.logger.info(f"✅ {name} Hero区域正常显示")
|
||||
else:
|
||||
home_page.logger.warning(f"⚠️ {name} Hero区域可能需要滚动才能显示")
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_services_grid_responsive(self, home_page: HomePage):
|
||||
"""测试服务卡片网格在不同尺寸下的响应式表现"""
|
||||
viewports = [
|
||||
(375, 667, "移动端"),
|
||||
(768, 1024, "平板端"),
|
||||
(1920, 1080, "桌面端")
|
||||
]
|
||||
|
||||
for width, height, name in viewports:
|
||||
home_page.page.set_viewport_size({"width": width, "height": height})
|
||||
home_page.navigate()
|
||||
home_page.scroll_to_section("services")
|
||||
|
||||
# 检查服务区域可见
|
||||
home_page.assert_element_visible("#services", timeout=5000)
|
||||
|
||||
home_page.logger.info(f"✅ {name} 服务区域正常显示")
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_products_grid_responsive(self, home_page: HomePage):
|
||||
"""测试产品卡片网格在不同尺寸下的响应式表现"""
|
||||
viewports = [
|
||||
(375, 667, "移动端"),
|
||||
(768, 1024, "平板端"),
|
||||
(1920, 1080, "桌面端")
|
||||
]
|
||||
|
||||
for width, height, name in viewports:
|
||||
home_page.page.set_viewport_size({"width": width, "height": height})
|
||||
home_page.navigate()
|
||||
home_page.scroll_to_section("products")
|
||||
|
||||
# 检查产品区域可见
|
||||
home_page.assert_element_visible("#products", timeout=5000)
|
||||
|
||||
home_page.logger.info(f"✅ {name} 产品区域正常显示")
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_news_list_responsive(self, home_page: HomePage):
|
||||
"""测试新闻列表在不同尺寸下的响应式表现"""
|
||||
viewports = [
|
||||
(375, 667, "移动端"),
|
||||
(768, 1024, "平板端"),
|
||||
(1920, 1080, "桌面端")
|
||||
]
|
||||
|
||||
for width, height, name in viewports:
|
||||
home_page.page.set_viewport_size({"width": width, "height": height})
|
||||
home_page.navigate()
|
||||
home_page.scroll_to_section("news")
|
||||
|
||||
# 检查新闻区域可见
|
||||
home_page.assert_element_visible("#news", timeout=5000)
|
||||
|
||||
home_page.logger.info(f"✅ {name} 新闻区域正常显示")
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_contact_form_responsive(self, home_page: HomePage):
|
||||
"""测试联系表单在不同尺寸下的响应式表现"""
|
||||
viewports = [
|
||||
(375, 667, "移动端"),
|
||||
(768, 1024, "平板端"),
|
||||
(1920, 1080, "桌面端")
|
||||
]
|
||||
|
||||
for width, height, name in viewports:
|
||||
home_page.page.set_viewport_size({"width": width, "height": height})
|
||||
home_page.navigate()
|
||||
home_page.scroll_to_section("contact")
|
||||
|
||||
# 检查表单可见
|
||||
form_visible = home_page._is_visible("form")
|
||||
|
||||
if form_visible:
|
||||
home_page.logger.info(f"✅ {name} 联系表单正常显示")
|
||||
else:
|
||||
home_page.logger.warning(f"⚠️ {name} 联系表单不可见")
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_footer_responsive(self, home_page: HomePage):
|
||||
"""测试页脚在不同尺寸下的响应式表现"""
|
||||
viewports = [
|
||||
(375, 667, "移动端"),
|
||||
(768, 1024, "平板端"),
|
||||
(1920, 1080, "桌面端")
|
||||
]
|
||||
|
||||
for width, height, name in viewports:
|
||||
home_page.page.set_viewport_size({"width": width, "height": height})
|
||||
home_page.navigate()
|
||||
|
||||
# 检查页脚可见
|
||||
home_page.assert_element_visible("footer", timeout=5000)
|
||||
|
||||
home_page.logger.info(f"✅ {name} 页脚正常显示")
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_element_stacking_mobile(self, home_page: HomePage):
|
||||
"""测试移动端元素堆叠"""
|
||||
home_page.page.set_viewport_size({"width": 375, "height": 667})
|
||||
home_page.navigate()
|
||||
|
||||
# 滚动检查各个区域
|
||||
sections = ["#home", "#about", "#services", "#products", "#news", "#contact"]
|
||||
|
||||
visible_sections = 0
|
||||
for section in sections:
|
||||
if home_page._is_visible(section):
|
||||
visible_sections += 1
|
||||
|
||||
# 移动端应该显示至少1个区域
|
||||
assert visible_sections >= 1, f"移动端可见区域不足,当前{visible_sections}个"
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_touch_target_size_mobile(self, home_page: HomePage):
|
||||
"""测试移动端触摸目标大小"""
|
||||
home_page.page.set_viewport_size({"width": 375, "height": 667})
|
||||
home_page.navigate()
|
||||
|
||||
# 检查按钮和链接的大小
|
||||
buttons = home_page._find_all("button, a.button, .btn")
|
||||
|
||||
for button in buttons[:5]: # 只检查前5个
|
||||
if button.count() > 0:
|
||||
box = button.first.bounding_box()
|
||||
if box:
|
||||
# 触摸目标应该至少44x44像素
|
||||
assert box["width"] >= 24, f"按钮宽度 {box['width']}px 可能太小"
|
||||
assert box["height"] >= 24, f"按钮高度 {box['height']}px 可能太小"
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_text_readability_mobile(self, home_page: HomePage):
|
||||
"""测试移动端文本可读性"""
|
||||
home_page.page.set_viewport_size({"width": 375, "height": 667})
|
||||
home_page.navigate()
|
||||
|
||||
# 检查段落文本
|
||||
paragraphs = home_page._find_all("p")
|
||||
|
||||
for para in paragraphs[:3]: # 只检查前3个
|
||||
if para.count() > 0:
|
||||
font_size = para.evaluate("el => getComputedStyle(el).fontSize")
|
||||
# 字体大小应该至少12px
|
||||
font_size_value = float(font_size.replace("px", ""))
|
||||
assert font_size_value >= 12, \
|
||||
f"段落字体大小 {font_size} 可能影响可读性"
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_form_inputs_mobile(self, contact_page: ContactPage):
|
||||
"""测试移动端表单输入"""
|
||||
contact_page.page.set_viewport_size({"width": 375, "height": 667})
|
||||
contact_page.navigate()
|
||||
|
||||
# 检查表单输入框
|
||||
inputs = contact_page._find_all("input, textarea")
|
||||
|
||||
for inp in inputs:
|
||||
if inp.count() > 0:
|
||||
box = inp.first.bounding_box()
|
||||
if box:
|
||||
# 输入框高度应该至少40px
|
||||
assert box["height"] >= 32, \
|
||||
f"输入框高度 {box['height']}px 可能太小不便触摸"
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_landscape_orientation(self, home_page: HomePage):
|
||||
"""测试横屏模式"""
|
||||
home_page.page.set_viewport_size({"width": 667, "height": 375})
|
||||
home_page.navigate()
|
||||
|
||||
# 验证基本元素可见
|
||||
home_page.assert_element_visible("header", timeout=5000)
|
||||
home_page.assert_element_visible("main", timeout=5000)
|
||||
home_page.assert_element_visible("footer", timeout=5000)
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_high_dpi_display(self, home_page: HomePage):
|
||||
"""测试高DPI显示器"""
|
||||
# 设置视口大小(Playwright会自动处理高DPI显示)
|
||||
home_page.page.set_viewport_size({"width": 1920, "height": 1080})
|
||||
|
||||
home_page.navigate()
|
||||
|
||||
# 验证页面正常显示
|
||||
home_page.assert_element_visible("header", timeout=5000)
|
||||
home_page.logger.info("✅ 高DPI显示器测试通过")
|
||||
|
||||
@pytest.mark.responsive
|
||||
def test_print_styles(self, home_page: HomePage):
|
||||
"""测试打印样式"""
|
||||
home_page.navigate()
|
||||
|
||||
# 模拟打印样式
|
||||
is_print_media = home_page.execute_js("""
|
||||
() => window.matchMedia('print').matches
|
||||
""")
|
||||
|
||||
# 设置为打印模式
|
||||
home_page.execute_js("""
|
||||
() => {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = '@media print { body { font-size: 12pt; } }';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
""")
|
||||
|
||||
home_page.logger.info("✅ 打印样式应用完成")
|
||||
@@ -0,0 +1 @@
|
||||
# Utils模块
|
||||
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
测试数据生成模块
|
||||
提供测试过程中需要的各种测试数据生成功能
|
||||
"""
|
||||
|
||||
import random
|
||||
import string
|
||||
import uuid
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from dataclasses import dataclass, field
|
||||
from faker import Faker
|
||||
|
||||
from config.settings import get_settings
|
||||
|
||||
|
||||
class ChineseFaker:
|
||||
"""中文测试数据生成器"""
|
||||
|
||||
def __init__(self, locale: str = "zh_CN"):
|
||||
self.faker_zh = Faker("zh_CN")
|
||||
self.faker_en = Faker("en_US")
|
||||
|
||||
def name(self) -> str:
|
||||
"""生成中文姓名"""
|
||||
return self.faker_zh.name()
|
||||
|
||||
def first_name(self) -> str:
|
||||
"""生成中文名字"""
|
||||
return self.faker_zh.first_name()
|
||||
|
||||
def last_name(self) -> str:
|
||||
"""生成中文姓氏"""
|
||||
return self.faker_zh.last_name()
|
||||
|
||||
def phone_number(self) -> str:
|
||||
"""生成中国手机号"""
|
||||
# 生成以13-19开头的11位手机号
|
||||
prefix = random.choice(["13", "14", "15", "16", "17", "18", "19"])
|
||||
suffix = "".join(random.choices(string.digits, k=8))
|
||||
return prefix + suffix
|
||||
|
||||
def email(self, domain: Optional[str] = None) -> str:
|
||||
"""生成邮箱"""
|
||||
if domain:
|
||||
return self.faker_zh.email(domain=domain)
|
||||
return self.faker_zh.email()
|
||||
|
||||
def company_name(self) -> str:
|
||||
"""生成公司名称"""
|
||||
prefixes = ["四川", "成都", "西部", "西南", "中国", "高新"]
|
||||
suffixes = ["科技", "信息", "网络", "软件", "数据", "智能", "创新", "未来"]
|
||||
name = f"{random.choice(prefixes)}{self.faker_zh.company()}{random.choice(suffixes)}"
|
||||
return name
|
||||
|
||||
def address(self) -> str:
|
||||
"""生成中文地址"""
|
||||
return self.faker_zh.address().replace("\n", "")
|
||||
|
||||
def city(self) -> str:
|
||||
"""生成城市名"""
|
||||
return self.faker_zh.city()
|
||||
|
||||
def province(self) -> str:
|
||||
"""生成省份名"""
|
||||
return self.faker_zh.province()
|
||||
|
||||
def job_title(self) -> str:
|
||||
"""生成职位名称"""
|
||||
titles = [
|
||||
"软件工程师", "产品经理", "UI设计师", "测试工程师",
|
||||
"项目主管", "技术总监", "架构师", "数据分析师",
|
||||
"运维工程师", "产品运营", "市场经理", "销售代表"
|
||||
]
|
||||
return random.choice(titles)
|
||||
|
||||
def username(self) -> str:
|
||||
"""生成用户名"""
|
||||
return self.faker_zh.user_name()
|
||||
|
||||
def password(self, length: int = 12) -> str:
|
||||
"""生成密码"""
|
||||
chars = string.ascii_letters + string.digits + "!@#$%^&*"
|
||||
return "".join(random.choices(chars, k=length))
|
||||
|
||||
def text(self, max_chars: int = 200) -> str:
|
||||
"""生成随机中文文本"""
|
||||
paragraphs = []
|
||||
for _ in range(random.randint(1, 3)):
|
||||
sentences = []
|
||||
for _ in range(random.randint(3, 8)):
|
||||
sentence_len = random.randint(10, 30)
|
||||
sentence = self.faker_zh.sentence(nb_words=sentence_len)
|
||||
sentences.append(sentence)
|
||||
paragraphs.append("。".join(sentences) + "。")
|
||||
return "".join(paragraphs)[:max_chars]
|
||||
|
||||
def sentence(self, nb_words: int = 20) -> str:
|
||||
"""生成随机句子"""
|
||||
return self.faker_zh.sentence(nb_words=nb_words)
|
||||
|
||||
def word(self) -> str:
|
||||
"""生成随机词语"""
|
||||
return self.faker_zh.word()
|
||||
|
||||
def words(self, nb: int = 5) -> List[str]:
|
||||
"""生成随机词语列表"""
|
||||
return self.faker_zh.words(nb=nb)
|
||||
|
||||
def date_of_birth(self, start_year: int = 1960, end_year: int = 2000) -> str:
|
||||
"""生成出生日期"""
|
||||
return self.faker_zh.date_of_birth(
|
||||
minimum_age=end_year - datetime.now().year,
|
||||
maximum_age=start_year - datetime.now().year
|
||||
).strftime("%Y-%m-%d")
|
||||
|
||||
def credit_card_number(self) -> str:
|
||||
"""生成信用卡号(测试用)"""
|
||||
return self.faker_zh.credit_card_number()
|
||||
|
||||
def credit_card_provider(self) -> str:
|
||||
"""生成信用卡提供商"""
|
||||
providers = ["Visa", "MasterCard", "银联", "JCB", "American Express"]
|
||||
return random.choice(providers)
|
||||
|
||||
def ipv4(self) -> str:
|
||||
"""生成IPv4地址"""
|
||||
return self.faker_zh.ipv4()
|
||||
|
||||
def mac_address(self) -> str:
|
||||
"""生成MAC地址"""
|
||||
return self.faker_zh.mac_address()
|
||||
|
||||
def url(self) -> str:
|
||||
"""生成URL"""
|
||||
return self.faker_zh.url()
|
||||
|
||||
def uri_path(self) -> str:
|
||||
"""生成URI路径"""
|
||||
return self.faker_zh.uri_path()
|
||||
|
||||
def user_agent(self) -> str:
|
||||
"""生成User-Agent"""
|
||||
return self.faker_zh.user_agent()
|
||||
|
||||
def hex_color(self) -> str:
|
||||
"""生成十六进制颜色"""
|
||||
return self.faker_zh.hex_color()
|
||||
|
||||
def rgb_color(self) -> Tuple[int, int, int]:
|
||||
"""生成RGB颜色"""
|
||||
return self.faker_zh.rgb_color()
|
||||
|
||||
|
||||
class EnglishFaker:
|
||||
"""英文测试数据生成器"""
|
||||
|
||||
def __init__(self):
|
||||
self.faker = Faker("en_US")
|
||||
|
||||
def name(self) -> str:
|
||||
"""生成英文姓名"""
|
||||
return self.faker.name()
|
||||
|
||||
def first_name(self) -> str:
|
||||
"""生成英文名字"""
|
||||
return self.faker.first_name()
|
||||
|
||||
def last_name(self) -> str:
|
||||
"""生成英文姓氏"""
|
||||
return self.faker.last_name()
|
||||
|
||||
def email(self, domain: Optional[str] = None) -> str:
|
||||
"""生成邮箱"""
|
||||
if domain:
|
||||
return self.faker.email(domain=domain)
|
||||
return self.faker.email()
|
||||
|
||||
def phone_number(self) -> str:
|
||||
"""生成美国电话号码"""
|
||||
return self.faker.phone_number()
|
||||
|
||||
def company(self) -> str:
|
||||
"""生成公司名称"""
|
||||
return self.faker.company()
|
||||
|
||||
def address(self) -> str:
|
||||
"""生成地址"""
|
||||
return self.faker.address().replace("\n", ", ")
|
||||
|
||||
def city(self) -> str:
|
||||
"""生成城市名"""
|
||||
return self.faker.city()
|
||||
|
||||
def state(self) -> str:
|
||||
"""生成州/省名"""
|
||||
return self.faker.state()
|
||||
|
||||
def country(self) -> str:
|
||||
"""生成国家名"""
|
||||
return self.faker.country()
|
||||
|
||||
def zip_code(self) -> str:
|
||||
"""生成邮编"""
|
||||
return self.faker.zipcode()
|
||||
|
||||
def username(self) -> str:
|
||||
"""生成用户名"""
|
||||
return self.faker.user_name()
|
||||
|
||||
def password(self, length: int = 12) -> str:
|
||||
"""生成密码"""
|
||||
chars = string.ascii_letters + string.digits + "!@#$%^&*"
|
||||
return "".join(random.choices(chars, k=length))
|
||||
|
||||
def text(self, max_chars: int = 200) -> str:
|
||||
"""生成随机英文文本"""
|
||||
return self.faker.text(max_nb_chars=max_chars)
|
||||
|
||||
def sentence(self, nb_words: int = 10) -> str:
|
||||
"""生成随机句子"""
|
||||
return self.faker.sentence(nb_words=nb_words)
|
||||
|
||||
def paragraph(self, nb_sentences: int = 3) -> str:
|
||||
"""生成段落"""
|
||||
return self.faker.paragraph(nb_sentences=nb_sentences)
|
||||
|
||||
def date_of_birth(self, start_year: int = 1960, end_year: int = 2000) -> str:
|
||||
"""生成出生日期"""
|
||||
return self.faker.date_of_birth(
|
||||
minimum_age=end_year - datetime.now().year,
|
||||
maximum_age=start_year - datetime.now().year
|
||||
).strftime("%Y-%m-%d")
|
||||
|
||||
def date_between(self, start_date: str, end_date: str) -> str:
|
||||
"""生成日期范围"""
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
return self.faker.date_between(start, end).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
class TestDataGenerator:
|
||||
"""测试数据生成器主类"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.zh_faker = ChineseFaker()
|
||||
self.en_faker = EnglishFaker()
|
||||
|
||||
def generate_contact_form_data(
|
||||
self,
|
||||
use_valid: bool = True,
|
||||
lang: str = "zh"
|
||||
) -> Dict[str, str]:
|
||||
"""生成联系表单数据"""
|
||||
if use_valid:
|
||||
if lang == "zh":
|
||||
return {
|
||||
"name": self.zh_faker.name(),
|
||||
"phone": self.zh_faker.phone_number(),
|
||||
"email": self.zh_faker.email(domain="example.com"),
|
||||
"subject": self.zh_faker.sentence(nb_words=8),
|
||||
"message": self.zh_faker.text(max_chars=300)
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"name": self.en_faker.name(),
|
||||
"phone": self.en_faker.phone_number(),
|
||||
"email": self.en_faker.email(domain="example.com"),
|
||||
"subject": self.en_faker.sentence(nb_words=8),
|
||||
"message": self.en_faker.paragraph(nb_sentences=3)
|
||||
}
|
||||
else:
|
||||
return self.settings.test_form_data.get("invalid", {
|
||||
"email": "invalid-email",
|
||||
"phone": "123"
|
||||
})
|
||||
|
||||
def generate_user_profile(self, lang: str = "zh") -> Dict[str, Any]:
|
||||
"""生成用户资料数据"""
|
||||
if lang == "zh":
|
||||
return {
|
||||
"name": self.zh_faker.name(),
|
||||
"email": self.zh_faker.email(domain="example.com"),
|
||||
"phone": self.zh_faker.phone_number(),
|
||||
"address": self.zh_faker.address(),
|
||||
"job_title": self.zh_faker.job_title(),
|
||||
"company": self.zh_faker.company_name(),
|
||||
"date_of_birth": self.zh_faker.date_of_birth()
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"name": self.en_faker.name(),
|
||||
"email": self.en_faker.email(domain="example.com"),
|
||||
"phone": self.en_faker.phone_number(),
|
||||
"address": self.en_faker.address(),
|
||||
"job_title": random.choice([
|
||||
"Software Engineer", "Product Manager", "Designer",
|
||||
"Marketing Manager", "Sales Representative", "Data Analyst"
|
||||
]),
|
||||
"company": self.en_faker.company(),
|
||||
"date_of_birth": self.en_faker.date_of_birth()
|
||||
}
|
||||
|
||||
def generate_search_query(self) -> str:
|
||||
"""生成搜索查询"""
|
||||
topics = [
|
||||
"软件开发", "云计算", "人工智能", "数据分析",
|
||||
"数字化转型", "企业服务", "智能制造", "物联网"
|
||||
]
|
||||
return random.choice(topics)
|
||||
|
||||
def generate_news_article(self) -> Dict[str, Any]:
|
||||
"""生成新闻文章数据"""
|
||||
return {
|
||||
"title": self.zh_faker.sentence(nb_words=12),
|
||||
"summary": self.zh_faker.text(max_chars=200),
|
||||
"content": self.zh_faker.text(max_chars=1000),
|
||||
"author": self.zh_faker.name(),
|
||||
"publish_date": datetime.now().strftime("%Y-%m-%d"),
|
||||
"category": random.choice(["公司新闻", "行业动态", "产品发布", "技术文章"])
|
||||
}
|
||||
|
||||
def generate_product_data(self) -> Dict[str, Any]:
|
||||
"""生成产品数据"""
|
||||
products = [
|
||||
{"name": "睿新ERP管理系统", "category": "企业软件"},
|
||||
{"name": "睿新客户关系管理系统", "category": "企业软件"},
|
||||
{"name": "睿新协同办公平台", "category": "企业软件"},
|
||||
{"name": "睿新商业智能平台", "category": "数据产品"},
|
||||
{"name": "睿新物联网平台", "category": "物联网"},
|
||||
{"name": "睿新AI智能应用套件", "category": "人工智能"}
|
||||
]
|
||||
product = random.choice(products)
|
||||
return {
|
||||
"name": product["name"],
|
||||
"category": product["category"],
|
||||
"description": self.zh_faker.text(max_chars=300),
|
||||
"features": random.sample([
|
||||
"高性能", "高可用", "易扩展", "安全可靠",
|
||||
"智能化", "云原生", "移动优先", "低代码"
|
||||
], k=4),
|
||||
"price": round(random.uniform(1000, 100000), 2)
|
||||
}
|
||||
|
||||
def generate_company_info(self) -> Dict[str, str]:
|
||||
"""生成公司信息"""
|
||||
return {
|
||||
"name": self.zh_faker.company_name(),
|
||||
"short_name": "".join(self.zh_faker.company_name()[:4]),
|
||||
"slogan": self.zh_faker.sentence(nb_words=6),
|
||||
"description": self.zh_faker.text(max_chars=200),
|
||||
"address": self.zh_faker.address(),
|
||||
"phone": self.zh_faker.phone_number(),
|
||||
"email": self.zh_faker.email(domain="example.com"),
|
||||
"website": self.zh_faker.url()
|
||||
}
|
||||
|
||||
def generate_dates(self, count: int = 10) -> List[str]:
|
||||
"""生成日期列表"""
|
||||
dates = []
|
||||
base_date = datetime.now()
|
||||
for i in range(count):
|
||||
date = base_date - timedelta(days=random.randint(0, 365))
|
||||
dates.append(date.strftime("%Y-%m-%d"))
|
||||
return dates
|
||||
|
||||
def generate_unique_id(self) -> str:
|
||||
"""生成唯一ID"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def generate_order_number(self) -> str:
|
||||
"""生成订单号"""
|
||||
prefix = "ORD"
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
random_suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
||||
return f"{prefix}{timestamp}{random_suffix}"
|
||||
|
||||
def generate_numeric_range(
|
||||
self,
|
||||
min_val: int = 1,
|
||||
max_val: int = 100,
|
||||
decimals: int = 0
|
||||
) -> Union[int, float]:
|
||||
"""生成数值范围"""
|
||||
value = random.uniform(min_val, max_val)
|
||||
return round(value, decimals) if decimals else int(value)
|
||||
|
||||
def generate_boolean(self) -> bool:
|
||||
"""生成布尔值"""
|
||||
return random.choice([True, False])
|
||||
|
||||
def generate_choice(self, options: List[Any]) -> Any:
|
||||
"""从列表中随机选择一个"""
|
||||
return random.choice(options)
|
||||
|
||||
def generate_color(self, format: str = "hex") -> Union[str, Tuple[int, int, int]]:
|
||||
"""生成颜色值"""
|
||||
if format == "hex":
|
||||
return self.zh_faker.hex_color()
|
||||
elif format == "rgb":
|
||||
return self.zh_faker.rgb_color()
|
||||
return self.zh_faker.hex_color()
|
||||
|
||||
|
||||
# 全局测试数据生成器实例
|
||||
test_data_generator = TestDataGenerator()
|
||||
|
||||
|
||||
def get_test_data_generator() -> TestDataGenerator:
|
||||
"""获取测试数据生成器"""
|
||||
return test_data_generator
|
||||
@@ -0,0 +1,584 @@
|
||||
"""
|
||||
辅助工具模块
|
||||
提供常用的测试辅助函数和工具
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union, Callable
|
||||
from urllib.parse import urlparse, parse_qs, urljoin
|
||||
from functools import lru_cache
|
||||
|
||||
from playwright.sync_api import Page, Locator, FrameLocator
|
||||
from playwright.sync_api import Error as PlaywrightError, TimeoutError as PlaywrightTimeoutError
|
||||
|
||||
from config.settings import get_settings
|
||||
from utils.logger import get_logger, PerformanceTimer
|
||||
|
||||
|
||||
class WaitCondition:
|
||||
"""等待条件类"""
|
||||
|
||||
@staticmethod
|
||||
def for_element_visible(selector: str, timeout: Optional[int] = None) -> Callable:
|
||||
"""等待元素可见"""
|
||||
def condition(page: Page) -> bool:
|
||||
try:
|
||||
element = page.locator(selector).first
|
||||
return element.is_visible(timeout=timeout or 1000)
|
||||
except (PlaywrightError, PlaywrightTimeoutError):
|
||||
return False
|
||||
return condition
|
||||
|
||||
@staticmethod
|
||||
def for_element_hidden(selector: str, timeout: Optional[int] = None) -> Callable:
|
||||
"""等待元素隐藏"""
|
||||
def condition(page: Page) -> bool:
|
||||
try:
|
||||
element = page.locator(selector).first
|
||||
return not element.is_visible(timeout=timeout or 1000)
|
||||
except (PlaywrightError, PlaywrightTimeoutError):
|
||||
return True
|
||||
return condition
|
||||
|
||||
@staticmethod
|
||||
def for_element_enabled(selector: str, timeout: Optional[int] = None) -> Callable:
|
||||
"""等待元素可用"""
|
||||
def condition(page: Page) -> bool:
|
||||
try:
|
||||
element = page.locator(selector).first
|
||||
return element.is_enabled(timeout=timeout or 1000)
|
||||
except (PlaywrightError, PlaywrightTimeoutError):
|
||||
return False
|
||||
return condition
|
||||
|
||||
@staticmethod
|
||||
def for_url_change(expected_url: str, timeout: Optional[int] = None) -> Callable:
|
||||
"""等待URL变化"""
|
||||
def condition(page: Page) -> bool:
|
||||
return expected_url in page.url
|
||||
return condition
|
||||
|
||||
@staticmethod
|
||||
def for_load_state(state: str = "networkidle", timeout: Optional[int] = None) -> Callable:
|
||||
"""等待页面加载状态"""
|
||||
def condition(page: Page) -> bool:
|
||||
try:
|
||||
page.wait_for_load_state(state, timeout=timeout)
|
||||
return True
|
||||
except PlaywrightTimeoutError:
|
||||
return False
|
||||
return condition
|
||||
|
||||
@staticmethod
|
||||
def for_function_return_true(func: Callable[[], bool], timeout: int = 5000, interval: int = 100) -> Callable:
|
||||
"""等待函数返回True"""
|
||||
def condition(page: Page) -> bool:
|
||||
start_time = time.time() * 1000
|
||||
while time.time() * 1000 - start_time < timeout:
|
||||
try:
|
||||
result = page.evaluate(f"() => ({func.__code__.co_code})")
|
||||
if result:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(interval / 1000)
|
||||
return False
|
||||
return condition
|
||||
|
||||
|
||||
class ElementHelper:
|
||||
"""元素操作辅助类"""
|
||||
|
||||
def __init__(self, page: Page):
|
||||
self.page = page
|
||||
self.logger = get_logger()
|
||||
|
||||
def find_element(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None,
|
||||
state: str = "visible"
|
||||
) -> Locator:
|
||||
"""查找元素"""
|
||||
timeout = timeout or get_settings().element_timeout
|
||||
locator = self.page.locator(selector).first
|
||||
|
||||
try:
|
||||
locator.wait_for(state=state, timeout=timeout)
|
||||
return locator
|
||||
except PlaywrightTimeoutError:
|
||||
raise PlaywrightTimeoutError(
|
||||
f"未找到元素: {selector} (超时: {timeout}ms)"
|
||||
)
|
||||
|
||||
def find_elements(self, selector: str) -> List[Locator]:
|
||||
"""查找多个元素"""
|
||||
return self.page.locator(selector).all()
|
||||
|
||||
def click_element(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None,
|
||||
force: bool = False,
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""点击元素"""
|
||||
self.logger.log_action(f"点击元素: {selector}")
|
||||
element = self.find_element(selector, timeout)
|
||||
element.click(force=force, **kwargs)
|
||||
|
||||
def fill_input(
|
||||
self,
|
||||
selector: str,
|
||||
value: str,
|
||||
timeout: Optional[int] = None,
|
||||
clear: bool = True
|
||||
) -> None:
|
||||
"""填充输入框"""
|
||||
self.logger.log_action(f"填充输入框: {selector} = '{value}'")
|
||||
element = self.find_element(selector, timeout)
|
||||
|
||||
if clear:
|
||||
element.clear()
|
||||
|
||||
element.fill(value)
|
||||
|
||||
def type_text(
|
||||
self,
|
||||
selector: str,
|
||||
text: str,
|
||||
timeout: Optional[int] = None,
|
||||
delay: Optional[int] = None
|
||||
) -> None:
|
||||
"""输入文本(逐字符)"""
|
||||
self.logger.log_action(f"输入文本: {selector} = '{text}'")
|
||||
element = self.find_element(selector, timeout)
|
||||
element.type(text, delay=delay)
|
||||
|
||||
def get_element_text(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None,
|
||||
strip: bool = True
|
||||
) -> str:
|
||||
"""获取元素文本"""
|
||||
element = self.find_element(selector, timeout)
|
||||
text = element.text_content()
|
||||
return text.strip() if strip and text else text
|
||||
|
||||
def get_element_attribute(
|
||||
self,
|
||||
selector: str,
|
||||
attribute: str,
|
||||
timeout: Optional[int] = None
|
||||
) -> Optional[str]:
|
||||
"""获取元素属性"""
|
||||
element = self.find_element(selector, timeout)
|
||||
return element.get_attribute(attribute)
|
||||
|
||||
def is_element_visible(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None
|
||||
) -> bool:
|
||||
"""检查元素是否可见"""
|
||||
try:
|
||||
element = self.find_element(selector, timeout)
|
||||
return element.is_visible()
|
||||
except PlaywrightTimeoutError:
|
||||
return False
|
||||
|
||||
def is_element_enabled(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None
|
||||
) -> bool:
|
||||
"""检查元素是否可用"""
|
||||
try:
|
||||
element = self.find_element(selector, timeout)
|
||||
return element.is_enabled()
|
||||
except PlaywrightTimeoutError:
|
||||
return False
|
||||
|
||||
def wait_for_selector(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None,
|
||||
state: str = "visible"
|
||||
) -> Locator:
|
||||
"""等待选择器出现"""
|
||||
timeout = timeout or get_settings().element_timeout
|
||||
return self.page.wait_for_selector(selector, timeout=timeout, state=state)
|
||||
|
||||
def wait_for_url(
|
||||
self,
|
||||
pattern: Union[str, re.Pattern],
|
||||
timeout: Optional[int] = None
|
||||
) -> None:
|
||||
"""等待URL匹配模式"""
|
||||
timeout = timeout or get_settings().page_load_timeout
|
||||
self.page.wait_for_url(pattern, timeout=timeout)
|
||||
|
||||
def wait_for_load_state(
|
||||
self,
|
||||
state: str = "networkidle",
|
||||
timeout: Optional[int] = None
|
||||
) -> None:
|
||||
"""等待加载状态"""
|
||||
timeout = timeout or get_settings().page_load_timeout
|
||||
self.page.wait_for_load_state(state, timeout=timeout)
|
||||
|
||||
|
||||
class PageHelper:
|
||||
"""页面操作辅助类"""
|
||||
|
||||
def __init__(self, page: Page):
|
||||
self.page = page
|
||||
self.logger = get_logger()
|
||||
self.element_helper = ElementHelper(page)
|
||||
|
||||
def navigate(
|
||||
self,
|
||||
url: str,
|
||||
wait_until: str = "networkidle",
|
||||
timeout: Optional[int] = None
|
||||
) -> None:
|
||||
"""导航到指定URL"""
|
||||
self.logger.log_action(f"导航到: {url}")
|
||||
timeout = timeout or get_settings().page_load_timeout
|
||||
self.page.goto(url, wait_until=wait_until, timeout=timeout)
|
||||
|
||||
def reload_page(self, wait_until: str = "networkidle") -> None:
|
||||
"""刷新页面"""
|
||||
self.logger.log_action("刷新页面")
|
||||
self.page.reload(wait_until=wait_until)
|
||||
|
||||
def go_back(self, wait_until: str = "networkidle") -> None:
|
||||
"""返回上一页"""
|
||||
self.logger.log_action("返回上一页")
|
||||
self.page.go_back(wait_until=wait_until)
|
||||
|
||||
def go_forward(self, wait_until: str = "networkidle") -> None:
|
||||
"""前进到下一页"""
|
||||
self.logger.log_action("前进到下一页")
|
||||
self.page.go_forward(wait_until=wait_until)
|
||||
|
||||
def scroll_to_top(self) -> None:
|
||||
"""滚动到页面顶部"""
|
||||
self.page.evaluate("window.scrollTo(0, 0)")
|
||||
|
||||
def scroll_to_bottom(self) -> None:
|
||||
"""滚动到页面底部"""
|
||||
self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||
|
||||
def scroll_to_element(self, selector: str) -> None:
|
||||
"""滚动到指定元素"""
|
||||
element = self.page.locator(selector).first
|
||||
element.scroll_into_view_if_needed()
|
||||
|
||||
def get_current_url(self) -> str:
|
||||
"""获取当前URL"""
|
||||
return self.page.url
|
||||
|
||||
def get_page_title(self) -> str:
|
||||
"""获取页面标题"""
|
||||
return self.page.title()
|
||||
|
||||
def get_page_source(self) -> str:
|
||||
"""获取页面源码"""
|
||||
return self.page.content()
|
||||
|
||||
def take_screenshot(
|
||||
self,
|
||||
filename: str,
|
||||
path: Optional[str] = None,
|
||||
full_page: bool = False
|
||||
) -> str:
|
||||
"""截取页面截图"""
|
||||
path = path or get_settings().screenshots_dir
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filepath = str(Path(path) / filename)
|
||||
self.page.screenshot(path=filepath, full_page=full_page)
|
||||
|
||||
self.logger.log_action(f"截图已保存: {filepath}")
|
||||
return filepath
|
||||
|
||||
def execute_javascript(self, script: str, *args) -> Any:
|
||||
"""执行JavaScript代码"""
|
||||
return self.page.evaluate(script, *args)
|
||||
|
||||
def wait_for_load_state(self, state: str = "networkidle", timeout: Optional[int] = None) -> None:
|
||||
"""等待页面加载状态"""
|
||||
timeout = timeout or get_settings().page_load_timeout
|
||||
self.page.wait_for_load_state(state, timeout=timeout)
|
||||
|
||||
|
||||
class AssertionHelper:
|
||||
"""断言辅助类"""
|
||||
|
||||
def __init__(self, page: Page):
|
||||
self.page = page
|
||||
self.logger = get_logger()
|
||||
|
||||
def assert_element_visible(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None,
|
||||
message: Optional[str] = None
|
||||
) -> None:
|
||||
"""断言元素可见"""
|
||||
try:
|
||||
element = ElementHelper(self.page).find_element(selector, timeout)
|
||||
assert element.is_visible(), message or f"元素不可见: {selector}"
|
||||
self.logger.log_assertion(f"元素可见: {selector}", True)
|
||||
except (PlaywrightError, PlaywrightTimeoutError, AssertionError):
|
||||
self.logger.log_assertion(f"元素可见: {selector}", False)
|
||||
raise
|
||||
|
||||
def assert_element_hidden(
|
||||
self,
|
||||
selector: str,
|
||||
timeout: Optional[int] = None,
|
||||
message: Optional[str] = None
|
||||
) -> None:
|
||||
"""断言元素隐藏"""
|
||||
self.logger.log_assertion(f"元素隐藏: {selector}", True)
|
||||
element = ElementHelper(self.page).find_element(selector, timeout)
|
||||
assert not element.is_visible(), message or f"元素应该隐藏但可见: {selector}"
|
||||
|
||||
def assert_element_text_contains(
|
||||
self,
|
||||
selector: str,
|
||||
expected_text: str,
|
||||
timeout: Optional[int] = None,
|
||||
message: Optional[str] = None
|
||||
) -> None:
|
||||
"""断言元素文本包含预期文本"""
|
||||
self.logger.log_assertion(f"文本包含: {selector} 包含 '{expected_text}'", True)
|
||||
element = ElementHelper(self.page).find_element(selector, timeout)
|
||||
actual_text = element.text_content()
|
||||
assert expected_text in actual_text, message or (
|
||||
f"元素文本不匹配: 预期包含 '{expected_text}',实际为 '{actual_text}'"
|
||||
)
|
||||
|
||||
def assert_element_text_equals(
|
||||
self,
|
||||
selector: str,
|
||||
expected_text: str,
|
||||
timeout: Optional[int] = None,
|
||||
message: Optional[str] = None
|
||||
) -> None:
|
||||
"""断言元素文本等于预期文本"""
|
||||
self.logger.log_assertion(f"文本相等: {selector} == '{expected_text}'", True)
|
||||
element = ElementHelper(self.page).find_element(selector, timeout)
|
||||
actual_text = element.text_content()
|
||||
assert actual_text == expected_text, message or (
|
||||
f"元素文本不匹配: 预期 '{expected_text}',实际为 '{actual_text}'"
|
||||
)
|
||||
|
||||
def assert_url_contains(self, expected_url: str, message: Optional[str] = None) -> None:
|
||||
"""断言URL包含预期文本"""
|
||||
self.logger.log_assertion(f"URL包含: {expected_url}", True)
|
||||
assert expected_url in self.page.url, message or (
|
||||
f"URL不匹配: 预期包含 '{expected_url}',实际为 '{self.page.url}'"
|
||||
)
|
||||
|
||||
def assert_url_equals(self, expected_url: str, message: Optional[str] = None) -> None:
|
||||
"""断言URL等于预期URL"""
|
||||
self.logger.log_assertion(f"URL相等: {expected_url}", True)
|
||||
assert self.page.url == expected_url, message or (
|
||||
f"URL不匹配: 预期 '{expected_url}',实际为 '{self.page.url}'"
|
||||
)
|
||||
|
||||
def assert_page_title_contains(self, expected_title: str, message: Optional[str] = None) -> None:
|
||||
"""断言页面标题包含预期文本"""
|
||||
self.logger.log_assertion(f"标题包含: {expected_title}", True)
|
||||
actual_title = self.page.title()
|
||||
assert expected_title in actual_title, message or (
|
||||
f"页面标题不匹配: 预期包含 '{expected_title}',实际为 '{actual_title}'"
|
||||
)
|
||||
|
||||
def assert_element_count(
|
||||
self,
|
||||
selector: str,
|
||||
expected_count: int,
|
||||
message: Optional[str] = None
|
||||
) -> None:
|
||||
"""断言元素数量"""
|
||||
self.logger.log_assertion(f"元素数量: {selector} == {expected_count}", True)
|
||||
elements = self.page.locator(selector).all()
|
||||
assert len(elements) == expected_count, message or (
|
||||
f"元素数量不匹配: 预期 {expected_count},实际 {len(elements)}"
|
||||
)
|
||||
|
||||
def assert_element_attribute_equals(
|
||||
self,
|
||||
selector: str,
|
||||
attribute: str,
|
||||
expected_value: str,
|
||||
timeout: Optional[int] = None,
|
||||
message: Optional[str] = None
|
||||
) -> None:
|
||||
"""断言元素属性等于预期值"""
|
||||
self.logger.log_assertion(f"属性相等: {selector}.{attribute} == '{expected_value}'", True)
|
||||
element = ElementHelper(self.page).find_element(selector, timeout)
|
||||
actual_value = element.get_attribute(attribute)
|
||||
assert actual_value == expected_value, message or (
|
||||
f"元素属性不匹配: {attribute} 预期 '{expected_value}',实际 '{actual_value}'"
|
||||
)
|
||||
|
||||
|
||||
class UrlHelper:
|
||||
"""URL辅助类"""
|
||||
|
||||
@staticmethod
|
||||
def parse_url(url: str) -> Dict[str, Any]:
|
||||
"""解析URL"""
|
||||
parsed = urlparse(url)
|
||||
return {
|
||||
"scheme": parsed.scheme,
|
||||
"netloc": parsed.netloc,
|
||||
"path": parsed.path,
|
||||
"params": parse_qs(parsed.query),
|
||||
"query": parsed.query,
|
||||
"fragment": parsed.fragment
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_domain(url: str) -> str:
|
||||
"""获取URL域名"""
|
||||
parsed = urlparse(url)
|
||||
return parsed.netloc
|
||||
|
||||
@staticmethod
|
||||
def get_path(url: str) -> str:
|
||||
"""获取URL路径"""
|
||||
parsed = urlparse(url)
|
||||
return parsed.path
|
||||
|
||||
@staticmethod
|
||||
def is_absolute_url(url: str) -> bool:
|
||||
"""判断是否为绝对URL"""
|
||||
return bool(urlparse(url).scheme)
|
||||
|
||||
@staticmethod
|
||||
def join_url(base: str, path: str) -> str:
|
||||
"""拼接URL"""
|
||||
return urljoin(base, path)
|
||||
|
||||
@staticmethod
|
||||
def remove_trailing_slash(url: str) -> str:
|
||||
"""移除URL末尾的斜杠"""
|
||||
if url.endswith("/") and len(url) > 1:
|
||||
return url[:-1]
|
||||
return url
|
||||
|
||||
|
||||
class FileHelper:
|
||||
"""文件操作辅助类"""
|
||||
|
||||
@staticmethod
|
||||
def read_json(filepath: str) -> Dict[str, Any]:
|
||||
"""读取JSON文件"""
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
@staticmethod
|
||||
def write_json(filepath: str, data: Any, indent: int = 2) -> None:
|
||||
"""写入JSON文件"""
|
||||
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=indent)
|
||||
|
||||
@staticmethod
|
||||
def read_text(filepath: str) -> str:
|
||||
"""读取文本文件"""
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
|
||||
@staticmethod
|
||||
def write_text(filepath: str, content: str) -> None:
|
||||
"""写入文本文件"""
|
||||
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
@staticmethod
|
||||
def create_directory(path: str) -> None:
|
||||
"""创建目录"""
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def delete_directory(path: str) -> None:
|
||||
"""删除目录"""
|
||||
import shutil
|
||||
if Path(path).exists():
|
||||
shutil.rmtree(path)
|
||||
|
||||
@staticmethod
|
||||
def cleanup_directory(path: str, pattern: str = "*") -> None:
|
||||
"""清理目录中的文件"""
|
||||
import glob
|
||||
files = glob.glob(str(Path(path) / pattern))
|
||||
for file in files:
|
||||
try:
|
||||
os.remove(file)
|
||||
except IsADirectoryError:
|
||||
FileHelper.delete_directory(file)
|
||||
|
||||
|
||||
def wait(
|
||||
condition: Callable,
|
||||
timeout: int = 10000,
|
||||
interval: int = 500,
|
||||
message: str = "等待条件超时"
|
||||
) -> bool:
|
||||
"""等待条件满足"""
|
||||
start_time = time.time() * 1000
|
||||
|
||||
while time.time() * 1000 - start_time < timeout:
|
||||
if condition():
|
||||
return True
|
||||
time.sleep(interval / 1000)
|
||||
|
||||
raise TimeoutError(message)
|
||||
|
||||
|
||||
def retry(
|
||||
func: Callable,
|
||||
max_retries: int = 3,
|
||||
delay: int = 1000,
|
||||
exceptions: Tuple[Exception, ...] = (Exception,)
|
||||
) -> Any:
|
||||
"""重试函数"""
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return func()
|
||||
except exceptions as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
time.sleep(delay / 1000)
|
||||
|
||||
raise last_exception
|
||||
|
||||
|
||||
def generate_test_id(prefix: str = "test") -> str:
|
||||
"""生成测试ID"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
random_hash = hashlib.md5(f"{timestamp}_{os.getpid()}".encode()).hexdigest()[:8]
|
||||
return f"{prefix}_{timestamp}_{random_hash}"
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def normalize_text(text: str) -> str:
|
||||
"""标准化文本(去除多余空白)"""
|
||||
return re.sub(r'\s+', ' ', text).strip()
|
||||
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
日志工具模块
|
||||
提供测试过程中的日志记录功能
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from functools import wraps
|
||||
import traceback
|
||||
|
||||
from config.settings import get_settings
|
||||
|
||||
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
"""彩色日志格式化器"""
|
||||
|
||||
# ANSI颜色代码
|
||||
COLORS = {
|
||||
'DEBUG': '\033[36m', # 青色
|
||||
'INFO': '\033[32m', # 绿色
|
||||
'WARNING': '\033[33m', # 黄色
|
||||
'ERROR': '\033[31m', # 红色
|
||||
'CRITICAL': '\033[35m', # 紫色
|
||||
'RESET': '\033[0m', # 重置
|
||||
}
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
# 获取颜色
|
||||
color = self.COLORS.get(record.levelname, self.COLORS['RESET'])
|
||||
|
||||
# 格式化消息
|
||||
message = super().format(record)
|
||||
|
||||
# 添加颜色(如果不是纯文本输出)
|
||||
if sys.stdout.isatty():
|
||||
return f"{color}{message}{self.COLORS['RESET']}"
|
||||
return message
|
||||
|
||||
|
||||
class TestLogger:
|
||||
"""测试日志管理器"""
|
||||
|
||||
_instance: Optional['TestLogger'] = None
|
||||
_initialized: bool = False
|
||||
|
||||
def __new__(cls) -> 'TestLogger':
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not TestLogger._initialized:
|
||||
self._setup_logging()
|
||||
TestLogger._initialized = True
|
||||
|
||||
def _setup_logging(self) -> None:
|
||||
"""设置日志配置"""
|
||||
self.settings = get_settings()
|
||||
self.logger = logging.getLogger("e2e_tests")
|
||||
self.logger.setLevel(getattr(logging, self.settings.log_level))
|
||||
|
||||
# 清除现有处理器
|
||||
self.logger.handlers.clear()
|
||||
|
||||
# 创建日志目录
|
||||
log_dir = Path(self.settings.log_file).parent
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 文件处理器
|
||||
file_handler = logging.FileHandler(
|
||||
self.settings.log_file,
|
||||
encoding='utf-8',
|
||||
mode='a'
|
||||
)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
|
||||
# 控制台处理器
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(getattr(logging, self.settings.log_level))
|
||||
|
||||
# 设置格式化器
|
||||
file_format = logging.Formatter(
|
||||
'%(asctime)s | %(levelname)-8s | %(name)s | %(filename)s:%(lineno)d | %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
console_format_str = '%(asctime)s | %(levelname)-8s | %(message)s'
|
||||
|
||||
file_handler.setFormatter(file_format)
|
||||
|
||||
if sys.stdout.isatty():
|
||||
console_handler.setFormatter(ColoredFormatter(console_format_str, datefmt='%H:%M:%S'))
|
||||
else:
|
||||
console_handler.setFormatter(logging.Formatter(console_format_str, datefmt='%H:%M:%S'))
|
||||
|
||||
self.logger.addHandler(file_handler)
|
||||
self.logger.addHandler(console_handler)
|
||||
|
||||
def debug(self, message: str, **kwargs) -> None:
|
||||
"""记录DEBUG级别日志"""
|
||||
self.logger.debug(self._format_message(message, **kwargs))
|
||||
|
||||
def info(self, message: str, **kwargs) -> None:
|
||||
"""记录INFO级别日志"""
|
||||
self.logger.info(self._format_message(message, **kwargs))
|
||||
|
||||
def warning(self, message: str, **kwargs) -> None:
|
||||
"""记录WARNING级别日志"""
|
||||
self.logger.warning(self._format_message(message, **kwargs))
|
||||
|
||||
def error(self, message: str, exc_info: bool = True, **kwargs) -> None:
|
||||
"""记录ERROR级别日志"""
|
||||
self.logger.error(
|
||||
self._format_message(message, **kwargs),
|
||||
exc_info=exc_info
|
||||
)
|
||||
|
||||
def critical(self, message: str, exc_info: bool = True, **kwargs) -> None:
|
||||
"""记录CRITICAL级别日志"""
|
||||
self.logger.critical(
|
||||
self._format_message(message, **kwargs),
|
||||
exc_info=exc_info
|
||||
)
|
||||
|
||||
def exception(self, message: str, **kwargs) -> None:
|
||||
"""记录异常日志(自动包含堆栈信息)"""
|
||||
self.error(message, exc_info=True, **kwargs)
|
||||
|
||||
def _format_message(self, message: str, **kwargs) -> str:
|
||||
"""格式化日志消息"""
|
||||
if kwargs:
|
||||
extra_info = " | ".join(f"{k}={v}" for k, v in kwargs.items())
|
||||
return f"{message} | {extra_info}"
|
||||
return message
|
||||
|
||||
def log_test_start(self, test_name: str, **extra_info) -> None:
|
||||
"""记录测试开始"""
|
||||
self.info(f"🧪 测试开始: {test_name}", **extra_info)
|
||||
|
||||
def log_test_end(self, test_name: str, status: str, duration: float, **extra_info) -> None:
|
||||
"""记录测试结束"""
|
||||
emoji = "✅" if status == "PASSED" else "❌" if status == "FAILED" else "⏭️"
|
||||
self.info(f"{emoji} 测试结束: {test_name} | 状态: {status} | 耗时: {duration:.2f}s", **extra_info)
|
||||
|
||||
def log_step(self, step_name: str, **extra_info) -> None:
|
||||
"""记录测试步骤"""
|
||||
self.info(f"📋 步骤: {step_name}", **extra_info)
|
||||
|
||||
def log_action(self, action: str, **extra_info) -> None:
|
||||
"""记录用户操作"""
|
||||
self.info(f"👆 操作: {action}", **extra_info)
|
||||
|
||||
def log_assertion(self, assertion: str, result: bool, **extra_info) -> None:
|
||||
"""记录断言结果"""
|
||||
status = "✅ 通过" if result else "❌ 失败"
|
||||
self.info(f"🔍 断言: {assertion} | {status}", **extra_info)
|
||||
|
||||
def log_performance(self, metric: str, value: float, threshold: Optional[float] = None, **extra_info) -> None:
|
||||
"""记录性能指标"""
|
||||
if threshold and value > threshold:
|
||||
self.warning(f"📊 性能指标 - {metric}: {value:.2f}ms (阈值: {threshold:.2f}ms)", **extra_info)
|
||||
else:
|
||||
self.info(f"📊 性能指标 - {metric}: {value:.2f}ms", **extra_info)
|
||||
|
||||
def log_error_context(self, context: str, error: Exception, **extra_info) -> None:
|
||||
"""记录错误上下文"""
|
||||
self.error(f"🚨 错误上下文: {context}", exc_info=False, **extra_info)
|
||||
self.error(f"错误信息: {str(error)}", exc_info=False)
|
||||
self.debug(f"堆栈跟踪:\n{traceback.format_exc()}")
|
||||
|
||||
def section(self, title: str) -> None:
|
||||
"""记录分段标题"""
|
||||
separator = "=" * 60
|
||||
self.info(f"\n{separator}")
|
||||
self.info(f" {title}")
|
||||
self.info(f"{separator}\n")
|
||||
|
||||
def divider(self, char: str = "-", length: int = 40) -> None:
|
||||
"""记录分隔线"""
|
||||
self.info(char * length)
|
||||
|
||||
|
||||
def get_logger() -> TestLogger:
|
||||
"""获取日志管理器实例"""
|
||||
return TestLogger()
|
||||
|
||||
|
||||
def log_decorator(func):
|
||||
"""函数日志装饰器"""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
logger = get_logger()
|
||||
func_name = func.__name__
|
||||
|
||||
logger.log_test_start(func_name)
|
||||
logger.divider()
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
logger.log_test_end(func_name, "PASSED", 0)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.log_test_end(func_name, "FAILED", 0)
|
||||
logger.log_error_context(func_name, e)
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class PerformanceTimer:
|
||||
"""性能计时器"""
|
||||
|
||||
def __init__(self, logger: Optional[TestLogger] = None):
|
||||
self.logger = logger or get_logger()
|
||||
self.start_time: Optional[float] = None
|
||||
self.end_time: Optional[float] = None
|
||||
self.elapsed: Optional[float] = None
|
||||
|
||||
def start(self) -> 'PerformanceTimer':
|
||||
"""开始计时"""
|
||||
self.start_time = self._time_ms()
|
||||
return self
|
||||
|
||||
def stop(self) -> 'PerformanceTimer':
|
||||
"""停止计时"""
|
||||
self.end_time = self._time_ms()
|
||||
self.elapsed = self.end_time - self.start_time
|
||||
return self
|
||||
|
||||
def reset(self) -> 'PerformanceTimer':
|
||||
"""重置计时器"""
|
||||
self.start_time = None
|
||||
self.end_time = None
|
||||
self.elapsed = None
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def _time_ms() -> float:
|
||||
"""获取当前时间(毫秒)"""
|
||||
import time
|
||||
return time.time() * 1000
|
||||
|
||||
@property
|
||||
def seconds(self) -> float:
|
||||
"""获取经过时间(秒)"""
|
||||
return self.elapsed / 1000 if self.elapsed else 0
|
||||
|
||||
@property
|
||||
def milliseconds(self) -> float:
|
||||
"""获取经过时间(毫秒)"""
|
||||
return self.elapsed if self.elapsed else 0
|
||||
|
||||
def log(self, operation: str, threshold: Optional[float] = None) -> None:
|
||||
"""记录操作耗时"""
|
||||
self.logger.log_performance(
|
||||
operation,
|
||||
self.milliseconds,
|
||||
threshold
|
||||
)
|
||||
|
||||
def __enter__(self) -> 'PerformanceTimer':
|
||||
"""上下文管理器入口"""
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
"""上下文管理器出口"""
|
||||
self.stop()
|
||||
@@ -0,0 +1,606 @@
|
||||
"""
|
||||
测试报告生成模块
|
||||
提供HTML、JSON、Markdown等多种格式的测试报告生成功能
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from collections import defaultdict
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
import matplotlib
|
||||
matplotlib.use('Agg') # 使用非GUI后端
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
from config.settings import get_settings
|
||||
|
||||
|
||||
class TestStatus(Enum):
|
||||
"""测试状态枚举"""
|
||||
PASSED = "passed"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
ERROR = "error"
|
||||
XFAIL = "xfail"
|
||||
XPASS = "xpass"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
"""单个测试结果"""
|
||||
test_id: str
|
||||
test_name: str
|
||||
test_file: str
|
||||
test_class: str
|
||||
status: TestStatus
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
duration: float
|
||||
error_message: Optional[str] = None
|
||||
error_traceback: Optional[str] = None
|
||||
screenshot_path: Optional[str] = None
|
||||
logs: List[str] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
parameters: Dict[str, Any] = field(default_factory=dict)
|
||||
browser: Optional[str] = None
|
||||
viewport: Optional[Tuple[int, int]] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
data = asdict(self)
|
||||
data["status"] = self.status.value
|
||||
data["start_time"] = self.start_time.isoformat()
|
||||
data["end_time"] = self.end_time.isoformat()
|
||||
return data
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
"""是否通过"""
|
||||
return self.status == TestStatus.PASSED
|
||||
|
||||
@property
|
||||
def failed(self) -> bool:
|
||||
"""是否失败"""
|
||||
return self.status in [TestStatus.FAILED, TestStatus.ERROR]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestSuiteResult:
|
||||
"""测试套件结果"""
|
||||
suite_name: str
|
||||
test_count: int = 0
|
||||
passed_count: int = 0
|
||||
failed_count: int = 0
|
||||
skipped_count: int = 0
|
||||
error_count: int = 0
|
||||
duration: float = 0.0
|
||||
test_results: List[TestResult] = field(default_factory=list)
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def pass_rate(self) -> float:
|
||||
"""通过率"""
|
||||
if self.test_count == 0:
|
||||
return 0.0
|
||||
return (self.passed_count / self.test_count) * 100
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""是否全部通过"""
|
||||
return self.failed_count == 0 and self.error_count == 0
|
||||
|
||||
|
||||
class ReportGenerator(ABC):
|
||||
"""报告生成器抽象基类"""
|
||||
|
||||
@abstractmethod
|
||||
def generate(self, suite_results: List[TestSuiteResult], output_path: str) -> str:
|
||||
"""生成报告"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_format(self) -> str:
|
||||
"""获取报告格式"""
|
||||
pass
|
||||
|
||||
|
||||
class HTMLReportGenerator(ReportGenerator):
|
||||
"""HTML报告生成器"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.env = Environment(
|
||||
loader=FileSystemLoader(Path(__file__).parent / "templates"),
|
||||
autoescape=True
|
||||
)
|
||||
self.template = self.env.get_template("html_report.html")
|
||||
|
||||
def get_format(self) -> str:
|
||||
return "html"
|
||||
|
||||
def generate(
|
||||
self,
|
||||
suite_results: List[TestSuiteResult],
|
||||
output_path: str
|
||||
) -> str:
|
||||
"""生成HTML报告"""
|
||||
# 汇总所有测试结果
|
||||
all_results = []
|
||||
for suite in suite_results:
|
||||
all_results.extend(suite.test_results)
|
||||
|
||||
# 计算统计信息
|
||||
stats = self._calculate_stats(suite_results, all_results)
|
||||
|
||||
# 生成图表
|
||||
charts = self._generate_charts(suite_results, all_results)
|
||||
|
||||
# 准备模板数据
|
||||
context = {
|
||||
"title": self.settings.report_title,
|
||||
"description": self.settings.report_description,
|
||||
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"stats": stats,
|
||||
"charts": charts,
|
||||
"suites": suite_results,
|
||||
"all_results": all_results,
|
||||
"git_info": self._get_git_info(),
|
||||
"settings": self.settings
|
||||
}
|
||||
|
||||
# 渲染模板
|
||||
html_content = self.template.render(**context)
|
||||
|
||||
# 确保输出目录存在
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入文件
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(html_content)
|
||||
|
||||
return output_path
|
||||
|
||||
def _calculate_stats(
|
||||
self,
|
||||
suite_results: List[TestSuiteResult],
|
||||
all_results: List[TestResult]
|
||||
) -> Dict[str, Any]:
|
||||
"""计算统计信息"""
|
||||
total = len(all_results)
|
||||
passed = sum(1 for r in all_results if r.passed)
|
||||
failed = sum(1 for r in all_results if r.failed)
|
||||
skipped = sum(1 for r in all_results if r.status == TestStatus.SKIPPED)
|
||||
|
||||
total_duration = sum(r.duration for r in all_results)
|
||||
|
||||
# 按状态分组
|
||||
by_status = defaultdict(list)
|
||||
for result in all_results:
|
||||
by_status[result.status.value].append(result)
|
||||
|
||||
# 按浏览器分组
|
||||
by_browser = defaultdict(list)
|
||||
for result in all_results:
|
||||
if result.browser:
|
||||
by_browser[result.browser].append(result)
|
||||
|
||||
# 按文件分组
|
||||
by_file = defaultdict(list)
|
||||
for result in all_results:
|
||||
by_file[result.test_file].append(result)
|
||||
|
||||
# 失败和错误的测试
|
||||
failed_tests = [r for r in all_results if r.failed]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"skipped": skipped,
|
||||
"pass_rate": round((passed / total * 100) if total > 0 else 0, 2),
|
||||
"total_duration": round(total_duration, 2),
|
||||
"average_duration": round(total_duration / total, 2) if total > 0 else 0,
|
||||
"by_status": dict(by_status),
|
||||
"by_browser": dict(by_browser),
|
||||
"by_file": dict(by_file),
|
||||
"failed_tests": failed_tests,
|
||||
"suite_count": len(suite_results),
|
||||
"success": failed == 0
|
||||
}
|
||||
|
||||
def _generate_charts(
|
||||
self,
|
||||
suite_results: List[TestSuiteResult],
|
||||
all_results: List[TestResult]
|
||||
) -> Dict[str, str]:
|
||||
"""生成图表"""
|
||||
charts = {}
|
||||
|
||||
# 1. 测试状态饼图
|
||||
charts["status_pie"] = self._create_status_pie_chart(all_results)
|
||||
|
||||
# 2. 套件结果条形图
|
||||
charts["suite_results"] = self._create_suite_results_chart(suite_results)
|
||||
|
||||
# 3. 执行时间图表
|
||||
charts["duration"] = self._create_duration_chart(all_results)
|
||||
|
||||
# 4. 浏览器分布图
|
||||
browsers = defaultdict(int)
|
||||
for result in all_results:
|
||||
if result.browser:
|
||||
browsers[result.browser] += 1
|
||||
if browsers:
|
||||
charts["browser_distribution"] = self._create_browser_chart(dict(browsers))
|
||||
|
||||
return charts
|
||||
|
||||
def _create_status_pie_chart(self, results: List[TestResult]) -> str:
|
||||
"""创建状态饼图"""
|
||||
counts = defaultdict(int)
|
||||
for r in results:
|
||||
counts[r.status.value] += 1
|
||||
|
||||
labels = []
|
||||
sizes = []
|
||||
colors = []
|
||||
color_map = {
|
||||
"passed": "#22c55e",
|
||||
"failed": "#ef4444",
|
||||
"skipped": "#94a3b8",
|
||||
"error": "#f97316",
|
||||
"xfail": "#eab308",
|
||||
"xpass": "#3b82f6"
|
||||
}
|
||||
|
||||
for status, count in counts.items():
|
||||
labels.append(f"{status} ({count})")
|
||||
sizes.append(count)
|
||||
colors.append(color_map.get(status, "#6b7280"))
|
||||
|
||||
if not sizes:
|
||||
return ""
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 8))
|
||||
ax.pie(sizes, labels=labels, colors=colors, autopct="%1.1f%%",
|
||||
startangle=90, textprops={"fontsize": 12})
|
||||
ax.axis("equal")
|
||||
|
||||
return self._fig_to_base64(fig)
|
||||
|
||||
def _create_suite_results_chart(self, suites: List[TestSuiteResult]) -> str:
|
||||
"""创建套件结果图表"""
|
||||
names = [s.suite_name for s in suites]
|
||||
passed = [s.passed_count for s in suites]
|
||||
failed = [s.failed_count for s in suites]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 6))
|
||||
x = np.arange(len(names))
|
||||
width = 0.35
|
||||
|
||||
bars1 = ax.bar(x - width/2, passed, width, label="Passed", color="#22c55e")
|
||||
bars2 = ax.bar(x + width/2, failed, width, label="Failed", color="#ef4444")
|
||||
|
||||
ax.set_xlabel("Test Suite")
|
||||
ax.set_ylabel("Test Count")
|
||||
ax.set_title("Test Results by Suite")
|
||||
ax.set_xticks(x)
|
||||
ax.set_xticklabels(names, rotation=45, ha="right")
|
||||
ax.legend()
|
||||
|
||||
# 添加数值标签
|
||||
for bar in bars1:
|
||||
height = bar.get_height()
|
||||
ax.annotate(f"{int(height)}",
|
||||
xy=(bar.get_x() + bar.get_width() / 2, height),
|
||||
xytext=(0, 3), textcoords="offset points",
|
||||
ha="center", va="bottom", fontsize=8)
|
||||
|
||||
for bar in bars2:
|
||||
height = bar.get_height()
|
||||
if height > 0:
|
||||
ax.annotate(f"{int(height)}",
|
||||
xy=(bar.get_x() + bar.get_width() / 2, height),
|
||||
xytext=(0, 3), textcoords="offset points",
|
||||
ha="center", va="bottom", fontsize=8)
|
||||
|
||||
return self._fig_to_base64(fig)
|
||||
|
||||
def _create_duration_chart(self, results: List[TestResult]) -> str:
|
||||
"""创建执行时间图表"""
|
||||
# 获取前20个最耗时的测试
|
||||
sorted_results = sorted(results, key=lambda x: x.duration, reverse=True)[:20]
|
||||
|
||||
names = [r.test_name[:30] + "..." if len(r.test_name) > 30 else r.test_name
|
||||
for r in sorted_results]
|
||||
durations = [r.duration for r in sorted_results]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 8))
|
||||
bars = ax.barh(names, durations, color="#3b82f6")
|
||||
|
||||
ax.set_xlabel("Duration (seconds)")
|
||||
ax.set_title("Top 20 Slowest Tests")
|
||||
ax.invert_yaxis()
|
||||
|
||||
# 添加数值标签
|
||||
for bar, duration in zip(bars, durations):
|
||||
ax.annotate(f"{duration:.2f}s",
|
||||
xy=(duration, bar.get_y() + bar.get_height() / 2),
|
||||
xytext=(3, 0), textcoords="offset points",
|
||||
ha="left", va="center", fontsize=8)
|
||||
|
||||
return self._fig_to_base64(fig)
|
||||
|
||||
def _create_browser_chart(self, browsers: Dict[str, int]) -> str:
|
||||
"""创建浏览器分布图"""
|
||||
labels = list(browsers.keys())
|
||||
sizes = list(browsers.values())
|
||||
colors = ["#4285f4", "#ea4335", "#fbbc05", "#34a853"]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 8))
|
||||
ax.pie(sizes, labels=labels, colors=colors[:len(labels)],
|
||||
autopct="%1.1f%%", startangle=90, textprops={"fontsize": 12})
|
||||
ax.axis("equal")
|
||||
|
||||
return self._fig_to_base64(fig)
|
||||
|
||||
def _fig_to_base64(self, fig) -> str:
|
||||
"""将matplotlib图表转换为base64字符串"""
|
||||
buffer = BytesIO()
|
||||
fig.savefig(buffer, format="png", dpi=100, bbox_inches="tight")
|
||||
buffer.seek(0)
|
||||
img_str = base64.b64encode(buffer.read()).decode("utf-8")
|
||||
plt.close(fig)
|
||||
return f"data:image/png;base64,{img_str}"
|
||||
|
||||
def _get_git_info(self) -> Dict[str, str]:
|
||||
"""获取Git信息"""
|
||||
git_info = {
|
||||
"branch": self.settings.git_branch,
|
||||
"commit": self.settings.git_commit,
|
||||
"repository": self.settings.git_repository
|
||||
}
|
||||
|
||||
# 尝试从环境变量获取
|
||||
if not git_info["branch"]:
|
||||
git_info["branch"] = os.environ.get("GIT_BRANCH", "")
|
||||
if not git_info["commit"]:
|
||||
git_info["commit"] = os.environ.get("GIT_COMMIT", os.environ.get("GITHUB_SHA", ""))
|
||||
|
||||
return git_info
|
||||
|
||||
|
||||
class JSONReportGenerator(ReportGenerator):
|
||||
"""JSON报告生成器"""
|
||||
|
||||
def get_format(self) -> str:
|
||||
return "json"
|
||||
|
||||
def generate(
|
||||
self,
|
||||
suite_results: List[TestSuiteResult],
|
||||
output_path: str
|
||||
) -> str:
|
||||
"""生成JSON报告"""
|
||||
report = {
|
||||
"report_info": {
|
||||
"title": get_settings().report_title,
|
||||
"description": get_settings().report_description,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"summary": self._calculate_summary(suite_results),
|
||||
"suites": []
|
||||
}
|
||||
|
||||
for suite in suite_results:
|
||||
suite_data = {
|
||||
"name": suite.suite_name,
|
||||
"test_count": suite.test_count,
|
||||
"passed": suite.passed_count,
|
||||
"failed": suite.failed_count,
|
||||
"skipped": suite.skipped_count,
|
||||
"duration": suite.duration,
|
||||
"pass_rate": suite.pass_rate,
|
||||
"success": suite.success,
|
||||
"tests": [r.to_dict() for r in suite.test_results]
|
||||
}
|
||||
report["suites"].append(suite_data)
|
||||
|
||||
# 确保输出目录存在
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入文件
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return output_path
|
||||
|
||||
def _calculate_summary(self, suites: List[TestSuiteResult]) -> Dict[str, Any]:
|
||||
"""计算汇总信息"""
|
||||
all_results = []
|
||||
for suite in suites:
|
||||
all_results.extend(suite.test_results)
|
||||
|
||||
return {
|
||||
"total": len(all_results),
|
||||
"passed": sum(1 for r in all_results if r.passed),
|
||||
"failed": sum(1 for r in all_results if r.failed),
|
||||
"skipped": sum(1 for r in all_results if r.status == TestStatus.SKIPPED),
|
||||
"duration": sum(r.duration for r in all_results)
|
||||
}
|
||||
|
||||
|
||||
class MarkdownReportGenerator(ReportGenerator):
|
||||
"""Markdown报告生成器"""
|
||||
|
||||
def get_format(self) -> str:
|
||||
return "markdown"
|
||||
|
||||
def generate(
|
||||
self,
|
||||
suite_results: List[TestSuiteResult],
|
||||
output_path: str
|
||||
) -> str:
|
||||
"""生成Markdown报告"""
|
||||
lines = [
|
||||
f"# {get_settings().report_title}",
|
||||
"",
|
||||
f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
"",
|
||||
"## 📊 测试汇总",
|
||||
""
|
||||
]
|
||||
|
||||
# 汇总信息
|
||||
all_results = []
|
||||
for suite in suite_results:
|
||||
all_results.extend(suite.test_results)
|
||||
|
||||
total = len(all_results)
|
||||
passed = sum(1 for r in all_results if r.passed)
|
||||
failed = sum(1 for r in all_results if r.failed)
|
||||
|
||||
lines.extend([
|
||||
f"- **总计**: {total} 个测试",
|
||||
f"- **通过**: {passed} 个 ✅",
|
||||
f"- **失败**: {failed} 个 ❌",
|
||||
f"- **通过率**: {round(passed / total * 100, 2) if total > 0 else 0}%",
|
||||
""
|
||||
])
|
||||
|
||||
# 套件详情
|
||||
lines.append("## 📁 套件详情")
|
||||
lines.append("")
|
||||
|
||||
for suite in suite_results:
|
||||
lines.append(f"### {suite.suite_name}")
|
||||
lines.append("")
|
||||
lines.append(f"- 测试数: {suite.test_count}")
|
||||
lines.append(f"- 通过: {suite.passed_count}")
|
||||
lines.append(f"- 失败: {suite.failed_count}")
|
||||
lines.append(f"- 耗时: {suite.duration:.2f}s")
|
||||
lines.append(f"- 通过率: {suite.pass_rate:.2f}%")
|
||||
lines.append("")
|
||||
|
||||
# 失败测试详情
|
||||
failed_tests = [r for r in all_results if r.failed]
|
||||
if failed_tests:
|
||||
lines.append("## ❌ 失败测试")
|
||||
lines.append("")
|
||||
|
||||
for result in failed_tests:
|
||||
lines.append(f"### {result.test_name}")
|
||||
lines.append("")
|
||||
lines.append(f"- **文件**: {result.test_file}")
|
||||
lines.append(f"- **状态**: {result.status.value}")
|
||||
lines.append(f"- **耗时**: {result.duration:.2f}s")
|
||||
if result.error_message:
|
||||
lines.append(f"- **错误**: {result.error_message}")
|
||||
lines.append("")
|
||||
|
||||
content = "\n".join(lines)
|
||||
|
||||
# 确保输出目录存在
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入文件
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
class ReportManager:
|
||||
"""报告管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.generators = {
|
||||
"html": HTMLReportGenerator(),
|
||||
"json": JSONReportGenerator(),
|
||||
"markdown": MarkdownReportGenerator()
|
||||
}
|
||||
self.current_results: List[TestSuiteResult] = []
|
||||
|
||||
def add_result(self, result: TestResult) -> None:
|
||||
"""添加测试结果"""
|
||||
# 查找或创建对应的套件
|
||||
suite_name = result.test_class or "default"
|
||||
suite = next(
|
||||
(s for s in self.current_results if s.suite_name == suite_name),
|
||||
None
|
||||
)
|
||||
|
||||
if suite is None:
|
||||
suite = TestSuiteResult(suite_name=suite_name)
|
||||
self.current_results.append(suite)
|
||||
|
||||
suite.test_results.append(result)
|
||||
suite.test_count += 1
|
||||
|
||||
if result.passed:
|
||||
suite.passed_count += 1
|
||||
elif result.status == TestStatus.FAILED:
|
||||
suite.failed_count += 1
|
||||
elif result.status == TestStatus.SKIPPED:
|
||||
suite.skipped_count += 1
|
||||
else:
|
||||
suite.error_count += 1
|
||||
|
||||
suite.duration += result.duration
|
||||
|
||||
def generate_reports(self, output_dir: Optional[str] = None) -> Dict[str, str]:
|
||||
"""生成所有格式的报告"""
|
||||
output_dir = output_dir or str(self.settings.get_reports_path())
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
generated = {}
|
||||
|
||||
for format_name, generator in self.generators.items():
|
||||
output_path = output_dir / f"test_report.{format_name}"
|
||||
generator.generate(self.current_results, str(output_path))
|
||||
generated[format_name] = str(output_path)
|
||||
|
||||
return generated
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""获取汇总信息"""
|
||||
all_results = []
|
||||
for suite in self.current_results:
|
||||
all_results.extend(suite.test_results)
|
||||
|
||||
total = len(all_results)
|
||||
passed = sum(1 for r in all_results if r.passed)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"passed": passed,
|
||||
"failed": total - passed,
|
||||
"pass_rate": round(passed / total * 100, 2) if total > 0 else 0,
|
||||
"suites": len(self.current_results),
|
||||
"duration": sum(s.duration for s in self.current_results)
|
||||
}
|
||||
|
||||
def clear_results(self) -> None:
|
||||
"""清空当前结果"""
|
||||
self.current_results.clear()
|
||||
|
||||
|
||||
def get_report_manager() -> ReportManager:
|
||||
"""获取报告管理器"""
|
||||
return ReportManager()
|
||||
@@ -16,17 +16,15 @@ export function Footer() {
|
||||
<p className="text-gray-400 text-sm leading-relaxed mb-6">
|
||||
{COMPANY_INFO.description}
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
{SOCIAL_LINKS.map((social) => (
|
||||
<a
|
||||
key={social.name}
|
||||
href={social.href}
|
||||
className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors"
|
||||
aria-label={social.name}
|
||||
>
|
||||
<span className="text-sm">{social.name[0]}</span>
|
||||
</a>
|
||||
))}
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<span className="text-gray-400 text-sm">关注微信公众号</span>
|
||||
<div className="w-24 h-24 bg-white rounded-lg p-2">
|
||||
<img
|
||||
src="/images/qrcode-wechat.png"
|
||||
alt="微信公众号二维码"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -72,12 +72,12 @@ export function Header() {
|
||||
)}
|
||||
>
|
||||
<div className="container-custom">
|
||||
<div className="flex items-center justify-between h-20">
|
||||
<div className="flex items-center justify-center h-20 relative">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="#home"
|
||||
onClick={(e) => handleNavClick(e, '#home')}
|
||||
className="flex items-center"
|
||||
className="flex items-center absolute left-0"
|
||||
>
|
||||
<img src="/logo.svg" alt={COMPANY_INFO.name} className="h-12 w-auto" />
|
||||
</Link>
|
||||
|
||||
@@ -29,10 +29,15 @@ export function HeroSection() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<Badge variant="secondary" className="mb-8 px-4 py-2 text-sm">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 mr-2 animate-pulse" />
|
||||
专业科技服务提供商
|
||||
</Badge>
|
||||
<div className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-slate-50 to-slate-100 border border-slate-200 shadow-sm mb-8">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
|
||||
</span>
|
||||
<span className="text-sm font-medium text-slate-700 tracking-wide">
|
||||
专业科技服务提供商
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Main Title */}
|
||||
|
||||
Reference in New Issue
Block a user