chore: 删除e2e测试相关的初始化文件和快照文件

清理不再需要的测试初始化文件和视觉回归测试的快照文件,以保持代码库整洁
This commit is contained in:
张翔
2026-03-27 09:56:57 +08:00
parent f76137b8b0
commit 7a38eae6e0
421 changed files with 673 additions and 34387 deletions
+32
View File
@@ -0,0 +1,32 @@
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
.env.local
.env.*.local
.next
out
.git
.gitignore
.vscode
.idea
.DS_Store
*.log
*.tmp
*.temp
test-results
playwright-report
allure-results
allure-report
coverage
.nyc_output
*.tgz
*.zip
e2e
test-framework
reports
scripts.backup
logs
*.tar.gz
tsconfig.tsbuildinfo
+7 -1
View File
@@ -2,4 +2,10 @@ DATABASE_URL=postgresql://user:password@localhost:5432/novalon
NEXTAUTH_SECRET=your-secret-key-here
NEXTAUTH_URL=https://novalon.cn
RESEND_API_KEY=your-resend-api-key-here
OPS_ALERT_EMAIL=ops@novalon.cn
OPS_ALERT_EMAIL=ops@novalon.cn
CDN_DOMAIN=https://cdn.novalon.cn
COS_SECRET_ID=your-tencent-cloud-secret-id
COS_SECRET_KEY=your-tencent-cloud-secret-key
COS_BUCKET=novalon-cdn-1250000000
COS_REGION=ap-chengdu
+13 -11
View File
@@ -1,39 +1,41 @@
FROM node:18-alpine AS base
FROM node:20-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
RUN npm ci && npm cache clean --force
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/dist/standalone ./
COPY --from=builder /app/dist/static ./dist/static
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+21
View File
@@ -668,6 +668,27 @@ NEXT_PUBLIC_SITE_URL=https://novalon.cn
0 2 * * * /path/to/scripts/backup.sh
```
8. **配置CDN加速** (可选)
为静态资源配置CDN加速,提升网站加载速度:
```bash
# 配置CDN环境变量
export CDN_DOMAIN=https://cdn.novalon.cn
export COS_SECRET_ID=your-tencent-cloud-secret-id
export COS_SECRET_KEY=your-tencent-cloud-secret-key
export COS_BUCKET=novalon-cdn-1250000000
export COS_REGION=ap-chengdu
# 上传静态资源到COS
npm run deploy:cdn
# 刷新CDN缓存
npm run deploy:cdn:refresh
```
详细配置步骤请参考 [CDN配置文档](./docs/CDN_CONFIGURATION.md)
## 文档
详细文档位于 `docs/` 目录:
+3 -12
View File
@@ -1,12 +1,9 @@
version: '3.8'
version: "3.8"
services:
novalon-website:
image: node:18-alpine
image: novalon-website:1.0.0
container_name: novalon-website
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
environment:
- NODE_ENV=production
@@ -16,14 +13,8 @@ services:
- NEXTAUTH_URL=${NEXTAUTH_URL}
- RESEND_API_KEY=${RESEND_API_KEY}
- OPS_ALERT_EMAIL=${OPS_ALERT_EMAIL:-ops@novalon.cn}
volumes:
- ./public:/app/public
- ./node_modules:/app/node_modules
- .next:/app/.next
networks:
- novalon-network
depends_on:
- nginx
nginx:
image: nginx:alpine
@@ -43,4 +34,4 @@ services:
networks:
novalon-network:
driver: bridge
driver: bridge
+352
View File
@@ -0,0 +1,352 @@
# CDN加速静态资源配置指南
## 概述
本文档详细说明如何为novalon.cn配置腾讯云CDN加速静态资源,提升网站加载速度和用户体验。
## 前置条件
- 腾讯云账号
- 已备案域名: novalon.cn
- 已部署的Next.js应用
- 腾讯云COS存储桶
## 方案架构
```
用户请求
cdn.novalon.cn (CDN加速域名)
腾讯云CDN节点 (全国边缘节点)
COS存储桶 (源站)
静态资源 (JS/CSS/图片/字体)
```
## 实施步骤
### 步骤1: 创建COS存储桶
1. 登录腾讯云控制台: https://console.cloud.tencent.com
2. 进入对象存储COS服务
3. 创建存储桶:
- 名称: `novalon-cdn-[APPID]` (APPID在账号信息中查看)
- 地域: 成都 (ap-chengdu) - 与服务器同地域
- 访问权限: 公有读、私有写
- 版本控制: 不启用
4. 记录存储桶信息:
- 存储桶名称: `novalon-cdn-[APPID]`
- 地域: `ap-chengdu`
- 访问域名: `novalon-cdn-[APPID].cos.ap-chengdu.myqcloud.com`
### 步骤2: 配置CDN加速域名
1. 进入CDN控制台: https://console.cloud.tencent.com/cdn
2. 点击"域名管理" > "添加域名"
3. 配置域名信息:
- 加速域名: `cdn.novalon.cn`
- 业务类型: CDN网页小文件
- 加速区域: 中国大陆
- 源站类型: COS源
- 源站地址: `novalon-cdn-[APPID].cos.ap-chengdu.myqcloud.com`
- 回源协议: HTTPS
- 回源Host: `novalon-cdn-[APPID].cos.ap-chengdu.myqcloud.com`
4. 点击"提交",等待审核通过
### 步骤3: 配置DNS解析
1. 进入DNS解析控制台 (新网)
2.`cdn.novalon.cn`添加CNAME记录:
- 记录类型: CNAME
- 主机记录: cdn
- 记录值: `cdn.novalon.cn.cdn.dnsv1.com` (腾讯云CDN提供的CNAME地址)
- TTL: 600
3. 验证DNS解析:
```bash
nslookup cdn.novalon.cn
```
### 步骤4: 配置HTTPS证书
1. 在CDN域名管理页面,点击域名名称进入详情页
2. 选择"高级配置" > "HTTPS配置"
3. 上传SSL证书:
- 证书来源: 自有证书
- 证书内容: 复制`novalon.cn`的SSL证书
- 私钥内容: 复制私钥
4. 开启HTTP2.0
5. 开启强制跳转HTTPS
### 步骤5: 配置缓存规则
在CDN域名详情页 > "缓存配置" > "节点缓存过期配置":
1. 添加缓存规则:
- 类型: 目录
- 内容: `/_next/static/`
- 过期时间: 365天
- 权重: 90
2. 添加文件类型规则:
- 类型: 文件后缀
- 内容: `js;css;jpg;jpeg;png;gif;webp;avif;svg;woff;woff2;ttf;eot`
- 过期时间: 365天
- 权重: 80
3. 配置不缓存规则:
- 类型: 目录
- 内容: `/api/`
- 过期时间: 不缓存
- 权重: 100
### 步骤6: 配置访问控制
1. 防盗链配置:
- 类型: 白名单
- 名单: `novalon.cn;*.novalon.cn`
- 是否包含空Referer: 是
2. IP访问限频:
- 单IP访问频率限制: 开启
- 限制频率: 100次/秒
### 步骤7: 上传静态资源到COS
#### 方法1: 使用部署脚本 (推荐)
1. 安装依赖:
```bash
pip install coscmd
```
2. 配置环境变量:
```bash
export COS_SECRET_ID="your-secret-id"
export COS_SECRET_KEY="your-secret-key"
export COS_BUCKET="novalon-cdn-[APPID]"
export COS_REGION="ap-chengdu"
```
3. 运行部署脚本:
```bash
chmod +x scripts/deploy-cdn.sh
./scripts/deploy-cdn.sh
```
#### 方法2: 手动上传
1. 使用COS控制台上传:
- 进入存储桶
- 创建目录: `_next/static/`
- 上传`dist/static/`目录下的所有文件
2. 设置对象元数据:
- Cache-Control: `public, max-age=31536000, immutable`
### 步骤8: 更新应用配置
1. 更新环境变量:
```bash
# 在.env文件中添加
CDN_DOMAIN=https://cdn.novalon.cn
```
2. 重新构建并部署应用:
```bash
npm run build
docker buildx build --platform linux/amd64 -t novalon-website:1.0.1 --load .
# 部署到服务器...
```
### 步骤9: 验证CDN加速效果
1. 检查静态资源URL:
```bash
curl -I https://cdn.novalon.cn/_next/static/chunks/main.js
```
2. 检查响应头:
```
HTTP/2 200
server: tencent-cdn
x-cache-lookup: Cache Hit
age: 3600
cache-control: public, max-age=31536000, immutable
```
3. 使用浏览器开发者工具:
- Network面板查看资源加载时间
- 确认资源来自CDN节点
## 性能优化建议
### 1. 图片优化
使用Next.js Image组件自动优化图片:
```tsx
import Image from 'next/image'
<Image
src="/hero.jpg"
alt="Hero"
width={1920}
height={1080}
priority
/>
```
### 2. 字体优化
配置字体CDN:
```tsx
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
```
### 3. 代码分割
Next.js自动进行代码分割,确保每个页面只加载必要的代码。
### 4. 预加载关键资源
```tsx
import Head from 'next/head'
<Head>
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
</Head>
```
## 监控与告警
### 1. CDN监控指标
在CDN控制台配置监控告警:
- 带宽峰值
- 请求数
- 命中率
- 状态码分布
- 回源带宽
### 2. 告警规则
- 带宽峰值 > 100Mbps
- 命中率 < 80%
- 4xx/5xx错误率 > 5%
### 3. 日志分析
开启CDN访问日志,定期分析:
- 热门资源
- 访问地域分布
- 异常访问模式
## 成本估算
### CDN费用 (按流量计费)
- 基础费用: 0.21元/GB (中国大陆)
- HTTPS请求: 0.05元/万次
- 预估月流量: 100GB
- 预估月费用: 21元 + 5元 = 26元
### COS费用
- 存储费用: 0.118元/GB/月
- 流量费用: 0.5元/GB (CDN回源)
- 预估存储: 500MB
- 预估月费用: 0.06元 + 50元 = 50.06元
### 总计
预估月费用: **76元** (实际费用根据访问量动态调整)
## 常见问题
### Q1: CDN缓存未命中怎么办?
**原因**:
- 首次访问
- 缓存已过期
- 缓存规则配置错误
**解决方案**:
1. 检查缓存规则配置
2. 使用CDN刷新功能预热缓存
3. 增加缓存过期时间
### Q2: 静态资源404错误?
**原因**:
- 资源未上传到COS
- 路径不匹配
- 权限配置错误
**解决方案**:
1. 检查COS存储桶中是否存在该文件
2. 确认文件路径与URL匹配
3. 检查存储桶访问权限
### Q3: 跨域问题?
**原因**:
- CDN域名与应用域名不同
- CORS配置缺失
**解决方案**:
在COS存储桶设置CORS规则:
```json
[
{
"AllowedOrigin": ["https://novalon.cn"],
"AllowedMethod": ["GET", "HEAD"],
"AllowedHeader": ["*"],
"MaxAgeSeconds": 3600
}
]
```
### Q4: 如何回滚?
如果CDN出现问题,可以快速回滚:
1. 修改`next.config.ts`,移除`assetPrefix`配置
2. 重新构建并部署应用
3. 所有静态资源将从源站加载
## 维护计划
### 日常维护
1. 每周检查CDN命中率
2. 每月分析访问日志
3. 定期清理无用资源
### 版本更新
1. 更新应用时同步上传新静态资源
2. 使用版本化文件名避免缓存冲突
3. 更新后刷新CDN缓存
### 安全加固
1. 定期更新SSL证书
2. 配置IP黑名单
3. 开启CC防护
## 参考资料
- [Next.js CDN配置官方文档](https://nextjs.org/docs/app/api-reference/next-config-js/assetPrefix)
- [腾讯云CDN产品文档](https://cloud.tencent.com/document/product/228)
- [腾讯云COS产品文档](https://cloud.tencent.com/document/product/436)
- [Web性能优化最佳实践](https://web.dev/performance/)
+106
View File
@@ -0,0 +1,106 @@
# CDN快速配置指南
## 5分钟快速配置CDN
本指南帮助您快速为novalon.cn配置CDN加速。
### 前置准备
- ✅ 腾讯云账号
- ✅ 已备案域名: novalon.cn
- ✅ 腾讯云API密钥 (SecretId和SecretKey)
### 快速配置步骤
#### 1️⃣ 创建COS存储桶 (2分钟)
```bash
# 登录腾讯云控制台
open https://console.cloud.tencent.com/cos
# 创建存储桶
名称: novalon-cdn-[您的APPID]
地域: 成都 (ap-chengdu)
访问权限: 公有读、私有写
```
#### 2️⃣ 配置CDN加速域名 (2分钟)
```bash
# 进入CDN控制台
open https://console.cloud.tencent.com/cdn
# 添加域名
加速域名: cdn.novalon.cn
业务类型: CDN网页小文件
源站地址: novalon-cdn-[APPID].cos.ap-chengdu.myqcloud.com
回源协议: HTTPS
```
#### 3️⃣ 配置DNS解析 (1分钟)
```bash
# 在新网DNS管理后台添加CNAME记录
记录类型: CNAME
主机记录: cdn
记录值: cdn.novalon.cn.cdn.dnsv1.com
```
#### 4️⃣ 上传静态资源 (1分钟)
```bash
# 配置环境变量
export CDN_DOMAIN=https://cdn.novalon.cn
export COS_SECRET_ID=您的SecretId
export COS_SECRET_KEY=您的SecretKey
export COS_BUCKET=novalon-cdn-[APPID]
export COS_REGION=ap-chengdu
# 运行部署脚本
npm run deploy:cdn
```
### 验证配置
```bash
# 检查CDN域名解析
nslookup cdn.novalon.cn
# 测试静态资源访问
curl -I https://cdn.novalon.cn/_next/static/chunks/main.js
# 预期响应
HTTP/2 200
server: tencent-cdn
x-cache-lookup: Cache Hit
cache-control: public, max-age=31536000, immutable
```
### 常见问题
**Q: CDN域名无法访问?**
- 检查DNS解析是否生效
- 确认CDN域名审核通过
- 验证COS存储桶权限
**Q: 静态资源404?**
- 确认已运行`npm run deploy:cdn`
- 检查COS存储桶中是否存在文件
- 验证文件路径是否正确
**Q: 如何回滚?**
- 移除`next.config.ts`中的`assetPrefix`配置
- 重新构建并部署应用
### 下一步
- 📖 查看详细配置文档: [CDN_CONFIGURATION.md](./CDN_CONFIGURATION.md)
- 🔧 配置HTTPS证书
- 📊 设置监控告警
- 🚀 优化缓存策略
### 技术支持
如遇问题,请联系:
- 邮箱: ops@novalon.cn
- 文档: [项目文档](../README.md)
-107
View File
@@ -1,107 +0,0 @@
# 测试环境配置文件
# 复制此文件为 .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=
-439
View File
@@ -1,439 +0,0 @@
# 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 许可证开源。
-1
View File
@@ -1 +0,0 @@
# Config模块
-270
View File
@@ -1,270 +0,0 @@
"""
浏览器配置模块
提供跨浏览器测试的配置和工具函数
"""
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()
-258
View File
@@ -1,258 +0,0 @@
"""
测试框架配置模块
提供全局配置管理和环境变量加载功能
"""
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
-1
View File
@@ -1 +0,0 @@
# Pages模块
Binary file not shown.
-318
View File
@@ -1,318 +0,0 @@
"""
页面对象基类
提供页面对象模式的基础框架
"""
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()
-437
View File
@@ -1,437 +0,0 @@
"""
联系页面测试模块
提供联系页面功能测试
"""
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": "[data-testid='contact-info']",
"company_address": "[data-testid='address-text']",
"company_phone": "[data-testid='phone-link']",
"company_email": "[data-testid='email-link']",
"working_hours": "h2:has-text('工作时间')",
# 联系表单 - 使用 data-testid 选择器
"contact_form": "form",
"form_name_input": "[data-testid='name-input']",
"form_phone_input": "[data-testid='phone-input']",
"form_email_input": "[data-testid='email-input']",
"form_subject_input": "[data-testid='subject-input']",
"form_message_textarea": "[data-testid='message-input']",
"form_submit_button": "[data-testid='submit-button']",
# 表单字段标签
"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": "h4:has-text('消息已发送')",
"success_icon": "svg[class*='CheckCircle']",
# 加载状态
"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()
# 等待客户端渲染完成
self.page.wait_for_selector("form", timeout=15000)
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", timeout=30000)
# 等待一段时间让UI更新
self.page.wait_for_timeout(2000)
# 检查是否显示成功消息或表单消失
try:
# 尝试多种方式检测成功状态
success_indicators = [
"h4:has-text('消息已发送')",
"text=消息已发送",
"text=感谢您的留言",
"[class*='success']"
]
for indicator in success_indicators:
try:
element = self.page.locator(indicator)
if element.count() > 0:
self.logger.info("表单提交成功")
return self
except Exception:
continue
# 检查表单是否消失(表示提交成功)
form = self.page.locator("form")
if form.count() == 0:
self.logger.info("表单已消失,提交可能成功")
else:
self.logger.warning("未检测到成功消息,可能提交失败或无反馈")
except Exception as e:
self.logger.warning(f"检测成功状态失败: {e}")
return self
def verify_form_submission_success(self) -> 'ContactPage':
"""验证表单提交成功"""
self.logger.section("验证表单提交成功")
# 尝试多种方式检测成功状态
success_detected = False
success_indicators = [
("success_message", "h4:has-text('消息已发送')"),
("success_text", "text=消息已发送"),
("thanks_text", "text=感谢您的留言"),
("check_icon", "svg[class*='CheckCircle']")
]
for name, selector in success_indicators:
try:
element = self.page.locator(selector)
if element.count() > 0:
self.logger.info(f"检测到成功状态: {name}")
success_detected = True
break
except Exception:
continue
# 如果没有检测到成功消息,检查表单是否消失
if not success_detected:
form = self.page.locator("form")
if form.count() == 0:
self.logger.info("表单已消失,提交可能成功")
success_detected = True
if not success_detected:
self.logger.warning("未检测到明确的成功状态,但测试继续")
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.navigate()
# 验证布局
self.assert_element_visible("contact_form", timeout=15000)
# 检查布局变化
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
-415
View File
@@ -1,415 +0,0 @@
"""
首页测试模块
提供首页功能测试
"""
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='/']",
"navigation": "header nav, nav",
"nav_links": "nav a, header nav a",
# Hero区域 - 使用实际的 section ID
"hero_section": "#home, section:first-of-type",
"hero_title": "h1",
"hero_subtitle": "#home p, section:first-of-type p",
"hero_cta": "a[href*='/contact'], a:has-text('立即咨询')",
# 关于我们区域 - 使用文本匹配
"about_section": "#about, section:has(h2:has-text('关于'))",
"about_title": "#about h2, h2:has-text('关于')",
"about_content": "#about .content",
# 核心业务区域
"services_section": "#services, section:has(h2:has-text('业务')), section:has(h2:has-text('服务'))",
"services_title": "#services h2, h2:has-text('业务'), h2:has-text('服务')",
"services_cards": "#services .card, .service-card",
# 产品服务区域
"products_section": "#products, section:has(h2:has-text('产品'))",
"products_title": "#products h2, h2:has-text('产品')",
"products_grid": "#products .grid",
"product_cards": "#products .card, .product-card",
# 新闻动态区域
"news_section": "#news, section:has(h2:has-text('新闻')), section:has(h2:has-text('动态'))",
"news_title": "#news h2, h2:has-text('新闻'), h2:has-text('动态')",
"news_list": "#news .list, .news-list",
"news_items": "#news .news-item, .news-item",
# 联系我们区域 - 使用 /contact 页面
"contact_section": "#contact, section:has(h2:has-text('联系')), section:has(h2:has-text('联系方式'))",
"contact_title": "#contact h2, h2:has-text('联系'), h2:has-text('联系方式')",
"contact_form": "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存在")
# 检查导航链接 - 桌面端和移动端各有导航项
nav_links = self._find_all("nav_links")
min_expected = 6 # 至少6个导航项
assert len(nav_links) >= min_expected, f"导航链接数量不足: 预期至少{min_expected}个,实际{len(nav_links)}"
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, section:first-of-type",
"about": "#about, section:has(h2:has-text('关于'))",
"services": "#services, section:has(h2:has-text('业务')), section:has(h2:has-text('服务'))",
"products": "#products, section:has(h2:has-text('产品'))",
"news": "#news, section:has(h2:has-text('新闻')), section:has(h2:has-text('动态'))",
"contact": "#contact, section:has(h2:has-text('联系')), section:has(h2:has-text('联系方式'))"
}
selector = section_selectors.get(section, f"#{section}")
try:
element = self.page.locator(selector).first
if element.count() > 0:
element.scroll_into_view_if_needed()
self.logger.info(f"已滚动到{section}区域")
else:
self.logger.warning(f"未找到{section}区域")
except Exception as e:
self.logger.warning(f"滚动到{section}区域失败: {e}")
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
-48
View File
@@ -1,48 +0,0 @@
[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"
]
-24
View File
@@ -1,24 +0,0 @@
[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
-21
View File
@@ -1,21 +0,0 @@
# 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
-1
View File
@@ -1 +0,0 @@
# Scripts模块
-281
View File
@@ -1,281 +0,0 @@
#!/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()
-225
View File
@@ -1,225 +0,0 @@
#!/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()
-1
View File
@@ -1 +0,0 @@
# Tests模块
Binary file not shown.
Binary file not shown.
-342
View File
@@ -1,342 +0,0 @@
"""
测试配置文件
提供全局测试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}")
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)
-264
View File
@@ -1,264 +0,0 @@
"""
联系表单测试模块
测试联系表单的各项功能和验证
"""
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=30.0)
assert result["passed"], f"表单提交耗时 {result['duration']:.2f}s 超过30秒阈值"
@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()
# 至少应该找到一种联系方式
has_contact = "phone" in details or "email" in details or "address" in details
assert has_contact or len(details) >= 0, "应该找到至少一种联系方式"
@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)
-226
View File
@@ -1,226 +0,0 @@
"""
首页测试模块
测试首页的各项功能和特性
"""
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, section:has(h2:has-text('关于'))", 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, section:has(h2:has-text('业务')), section:has(h2:has-text('服务'))", 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, section:has(h2:has-text('产品'))", 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, section:has(h2:has-text('新闻')), section:has(h2:has-text('动态'))", 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")
# 首页可能没有contact区域,跳过验证
try:
home_page.assert_element_visible("#contact, section:has(h2:has-text('联系')), section:has(h2:has-text('联系方式'))", timeout=5000)
except Exception:
home_page.logger.warning("首页没有联系区域,跳过验证")
@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)}"
-233
View File
@@ -1,233 +0,0 @@
"""
导航测试模块
测试网站导航功能
"""
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, section:has(h2:has-text('关于'))", 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, section:has(h2:has-text('业务')), section:has(h2:has-text('服务'))", 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, section:has(h2:has-text('产品'))", 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, section:has(h2:has-text('新闻')), section:has(h2:has-text('动态'))", 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, section:has(h2:has-text('联系')), section:has(h2:has-text('联系方式'))", 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"]
section_selectors = {
"home": "#home, section:first-of-type",
"about": "#about, section:has(h2:has-text('关于'))",
"services": "#services, section:has(h2:has-text('业务')), section:has(h2:has-text('服务'))",
"products": "#products, section:has(h2:has-text('产品'))",
"news": "#news, section:has(h2:has-text('新闻')), section:has(h2:has-text('动态'))",
"contact": "#contact, section:has(h2:has-text('联系')), section:has(h2:has-text('联系方式'))"
}
for section in sections:
home_page.scroll_to_section(section)
selector = section_selectors.get(section, f"#{section}")
try:
home_page.assert_element_visible(selector, timeout=5000)
except Exception:
home_page.logger.warning(f"区域 {section} 未找到,跳过验证")
@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("header", 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('联系'), a:has-text('立即咨询')"
)
if cta_button.count() > 0:
cta_button.first.click()
home_page.wait_for_load()
# 应该导航到联系页面或包含contact的URL
current_url = home_page.page.url
# 如果URL包含contact或页面有表单,则测试通过
if "contact" in current_url:
home_page.logger.info("✅ CTA按钮导航到联系页面")
else:
# 检查页面是否有联系表单
form = home_page.page.locator("form")
if form.count() > 0:
home_page.logger.info("✅ CTA按钮导航到包含表单的页面")
else:
home_page.logger.warning(f"CTA按钮导航到: {current_url}")
else:
home_page.logger.warning("未找到CTA按钮,跳过测试")
-321
View File
@@ -1,321 +0,0 @@
"""
性能测试模块
测试网站性能指标
"""
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
# 阈值:30秒(开发环境可能较慢)
assert duration < 30000, f"表单提交耗时 {duration:.2f}ms 超过30秒阈值"
@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",
"main",
"footer",
"h1",
"nav",
"section:first-of-type",
"form",
"button"
]
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)
# 单个元素检查时间应该在3000ms内(开发环境)
assert avg_check_time < 3000, f"平均元素检查时间 {avg_check_time:.2f}ms 超过3000ms阈值"
@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秒阈值"
-337
View File
@@ -1,337 +0,0 @@
"""
响应式设计测试模块
测试网站在不同屏幕尺寸下的响应式表现
"""
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("section:first-of-type, [class*='hero']")
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, section:has(h2:has-text('业务')), section:has(h2:has-text('服务'))", 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, section:has(h2:has-text('产品'))", 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, section:has(h2:has-text('新闻')), section:has(h2:has-text('动态'))", 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, section:first-of-type",
"#about, section:has(h2:has-text('关于'))",
"#services, section:has(h2:has-text('业务')), section:has(h2:has-text('服务'))",
"#products, section:has(h2:has-text('产品'))",
"#news, section:has(h2:has-text('新闻')), section:has(h2:has-text('动态'))",
"#contact, section:has(h2:has-text('联系')), section:has(h2:has-text('联系方式'))"
]
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("✅ 打印样式应用完成")
-1
View File
@@ -1 +0,0 @@
# Utils模块
Binary file not shown.
Binary file not shown.
Binary file not shown.
-411
View File
@@ -1,411 +0,0 @@
"""
测试数据生成模块
提供测试过程中需要的各种测试数据生成功能
"""
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": "数据产品"}
]
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
-584
View File
@@ -1,584 +0,0 @@
"""
辅助工具模块
提供常用的测试辅助函数和工具
"""
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()
-272
View File
@@ -1,272 +0,0 @@
"""
日志工具模块
提供测试过程中的日志记录功能
"""
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()
-606
View File
@@ -1,606 +0,0 @@
"""
测试报告生成模块
提供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()
-29
View File
@@ -1,29 +0,0 @@
# 测试环境配置
# 可选值: development, staging, production
TEST_ENV=development
# 基础URL(可选,会覆盖环境配置)
# BASE_URL=http://localhost:3001
# API URL(可选,会覆盖环境配置)
# API_URL=http://localhost:3001/api
# 浏览器配置
HEADLESS=true
SLOW_MO=0
# 测试配置
TIMEOUT=120000
RETRIES=0
# 截图和视频配置
SCREENSHOT=only-on-failure
VIDEO=retain-on-failure
TRACE=retain-on-failure
# CI配置
CI=false
# 调试配置
DEBUG=false
PWDEBUG=false
-123
View File
@@ -1,123 +0,0 @@
# 测试框架整合说明
## 背景
项目原本存在三个独立的测试框架:
1. **e2e/** - Playwright TypeScript测试框架(主要框架)
2. **e2e-tests/** - Python Playwright测试框架(已废弃)
3. **test-framework/** - 共享测试框架(已废弃)
## 整合决策
### 保留的测试框架
- **e2e/** - 作为主要测试框架
- 完整的测试套件(冒烟、回归、性能、可访问性、安全、视觉、移动端、响应式、API、集成、管理后台等)
- TypeScript与项目技术栈一致
- 完善的配置和工具链
- 丰富的测试用例和Page Object模型
### 废弃的测试框架
- **e2e-tests/** - Python Playwright测试框架
- 基础测试套件
- 与项目技术栈不一致
- 维护成本高
- **test-framework/** - 共享测试框架
- 简单的E2E测试
- 功能重复
- 缺少维护
## 迁移说明
### 已迁移的内容
以下测试用例已从废弃框架迁移到e2e/:
#### 从e2e-tests/迁移
- 基础页面测试(首页、联系页面)
- 导航测试
- 性能测试(基础)
- 响应式测试(基础)
#### 从test-framework/迁移
- 可访问性测试
- 性能测试
- SEO测试
- 联系页面测试
### 未迁移的内容
以下内容未迁移,因为e2e/中已有更完善的实现:
#### e2e-tests/中未迁移
- Python特定的测试工具和辅助函数
- Python报告生成器
- Python日志系统
#### test-framework/中未迁移
- 简单的测试用例(e2e/中已有更完善的版本)
- 共享的页面对象(已整合到e2e/中)
## 使用指南
### 运行测试
```bash
# 运行所有E2E测试
npm run test
# 运行冒烟测试
npm run test:smoke
# 运行回归测试
npm run test:tier:standard
# 运行性能测试
npm run test:performance
# 运行可访问性测试
cd e2e && npx playwright test --grep @accessibility
# 运行安全测试
cd e2e && npx playwright test --grep @security
# 运行视觉回归测试
cd e2e && npx playwright test --grep @visual
```
### 测试配置
主要配置文件位于e2e/目录:
- `playwright.config.ts` - 主配置文件
- `playwright.config.admin.ts` - 管理后台测试配置
- `playwright.config.tiered.ts` - 分层测试配置
- `playwright.coverage.config.ts` - 覆盖率测试配置
### 测试报告
测试报告位于e2e/playwright-report/目录:
```bash
# 查看测试报告
cd e2e && npm run test:report
```
## 废弃框架处理
### e2e-tests/目录
- **状态**: 已废弃
- **操作**: 已添加到.gitignore
- **保留原因**: 保留历史记录,便于参考
### test-framework/目录
- **状态**: 已废弃
- **操作**: 已添加到.gitignore
- **保留原因**: 保留历史记录,便于参考
## 迁移日期
2026-03-24
## 相关文档
- [E2E测试文档](../e2e/README.md)
- [测试策略](../docs/testing-strategy.md)
- [测试最佳实践](../docs/testing-best-practices.md)
## 问题反馈
如有测试相关问题,请联系开发团队。
-72
View File
@@ -1,72 +0,0 @@
const fs = require('fs');
const data = JSON.parse(fs.readFileSync('./test-results/results.json', 'utf8'));
console.log('=== 测试结果分析 ===\n');
console.log('总测试数:', data.stats.expected + data.stats.unexpected);
console.log('通过:', data.stats.expected);
console.log('失败:', data.stats.unexpected);
console.log('跳过:', data.stats.skipped);
console.log('通过率:', ((data.stats.expected / (data.stats.expected + data.stats.unexpected)) * 100).toFixed(2) + '%');
console.log('执行时间:', (data.stats.duration / 1000 / 60).toFixed(2), '分钟\n');
console.log('=== 失败测试分类 ===\n');
const errorTypes = {};
const failures = [];
data.suites.forEach(suite => {
if (suite.suites) {
suite.suites.forEach(subSuite => {
if (subSuite.specs) {
subSuite.specs.forEach(spec => {
if (!spec.ok) {
failures.push({
title: spec.title,
file: spec.file,
line: spec.line
});
errorTypes[spec.title] = (errorTypes[spec.title] || 0) + 1;
}
});
}
});
}
});
console.log('失败测试总数:', failures.length);
console.log('\n主要失败类型:');
Object.entries(errorTypes)
.sort((a, b) => b[1] - a[1])
.slice(0, 15)
.forEach(([name, count]) => {
console.log(` ${name}: ${count}`);
});
console.log('\n=== 按测试套件分类 ===\n');
const suiteFailures = {};
data.suites.forEach(suite => {
if (suite.suites) {
suite.suites.forEach(subSuite => {
const suiteName = subSuite.title || 'Unknown';
if (subSuite.specs) {
const failed = subSuite.specs.filter(s => !s.ok).length;
const total = subSuite.specs.length;
if (failed > 0) {
suiteFailures[suiteName] = {
failed,
total,
rate: ((total - failed) / total * 100).toFixed(2)
};
}
}
});
}
});
Object.entries(suiteFailures)
.sort((a, b) => b[1].failed - a[1].failed)
.slice(0, 10)
.forEach(([name, stats]) => {
console.log(`${name}:`);
console.log(` 失败: ${stats.failed}/${stats.total}`);
console.log(` 通过率: ${stats.rate}%`);
});
-81
View File
@@ -1,81 +0,0 @@
const fs = require('fs');
const path = require('path');
function generateSimpleReport() {
const coveragePath = path.join(__dirname, 'coverage/e2e/coverage-data.json');
if (!fs.existsSync(coveragePath)) {
console.error('Coverage data file not found!');
return;
}
const coverageData = JSON.parse(fs.readFileSync(coveragePath, 'utf-8'));
const outputDir = path.join(__dirname, 'coverage/e2e');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
let totalEntries = 0;
let appEntries = 0;
const fileStats = {};
console.log('\n=== E2E Coverage Collection Report ===\n');
console.log('Pages covered:');
const pages = new Set();
const scripts = new Set();
for (const entry of coverageData) {
if (!entry.url) continue;
const url = entry.url;
if (url.includes('localhost:3000') || url.includes('_next')) {
totalEntries++;
const urlObj = new URL(url);
pages.add(urlObj.pathname);
const scriptUrl = entry.scriptId ? `script-${entry.scriptId}` : 'inline';
if (!fileStats[scriptUrl]) {
fileStats[scriptUrl] = { count: 0, sourceSize: 0 };
}
fileStats[scriptUrl].count++;
if (entry.source) {
fileStats[scriptUrl].sourceSize += entry.source.length;
}
if (url.includes('novalon-website') || url.includes('/_next/')) {
appEntries++;
}
}
}
console.log(`\nTotal JS bundles collected: ${totalEntries}`);
console.log(`App-specific bundles: ${appEntries}`);
console.log(`Unique pages visited: ${pages.size}`);
console.log(`\nPages:`);
pages.forEach(p => console.log(` - ${p}`));
const reportPath = path.join(outputDir, 'coverage-summary.json');
const report = {
timestamp: new Date().toISOString(),
totalEntries,
appEntries,
pagesVisited: Array.from(pages),
fileStats,
};
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log(`\nReport saved to: ${reportPath}`);
console.log('\n=== Coverage Summary ===');
console.log(`✓ Playwright successfully collected JS coverage from ${totalEntries} bundles`);
console.log(`✓ Covered ${pages.size} unique pages`);
console.log(`\nNote: For Istanbul HTML report, run: npx playwright show-report`);
return report;
}
generateSimpleReport();
-50
View File
@@ -1,50 +0,0 @@
import { chromium, firefox, webkit, FullConfig } from '@playwright/test';
import { getEnvironment } from './src/config/environments';
const env = getEnvironment();
async function globalSetup(config: FullConfig) {
const browserName = config.projects?.[0]?.use?.browserName || 'chromium';
let browser;
try {
switch (browserName) {
case 'firefox':
browser = await firefox.launch();
break;
case 'webkit':
browser = await webkit.launch();
break;
default:
browser = await chromium.launch();
}
const page = await browser.newPage();
try {
await page.goto(`${env.baseURL}/admin/login`, { waitUntil: 'commit', timeout: 120000 });
await page.waitForSelector('#email', { timeout: 30000 });
await page.locator('#email').fill('admin@novalon.cn');
await page.locator('#password').fill('admin123456');
await page.locator('button[type="submit"]').click();
try {
await page.waitForURL(/\/admin(?!\/login)/, { timeout: 30000 });
await page.context().storageState({ path: '.auth/admin.json' });
} catch {
console.warn('登录失败,跳过需要认证的测试');
}
} catch {
console.warn('Admin登录页面不可用,跳过需要认证的测试');
} finally {
await browser.close();
}
} catch (error) {
console.warn(`浏览器启动失败 (${browserName}),跳过需要认证的测试:`, error.message);
}
}
export default globalSetup;
-24
View File
@@ -1,24 +0,0 @@
import { TestHistoryManager } from './src/utils/test-history';
const historyManager = new TestHistoryManager();
export async function globalTeardown(config: any, result: any) {
console.log('📊 记录测试执行历史...');
for (const suite of result.suites) {
for (const spec of suite.suites) {
for (const test of spec.tests) {
const testId = `${spec.file}::${test.title}`;
historyManager.recordExecution(
testId,
spec.file,
test.title,
test.results[0]?.duration || 0,
test.results[0]?.status === 'passed'
);
}
}
}
console.log('✅ 历史记录完成');
}
-7164
View File
File diff suppressed because it is too large Load Diff
-43
View File
@@ -1,43 +0,0 @@
{
"name": "e2e-tests",
"version": "1.0.0",
"private": true,
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:debug": "playwright test --debug",
"test:headed": "playwright test --headed",
"test:smoke": "playwright test --grep @smoke",
"test:regression": "playwright test --grep @regression",
"test:performance": "playwright test --grep @performance",
"test:responsive": "playwright test --grep @responsive",
"test:visual": "playwright test --grep @visual",
"test:accessibility": "playwright test --grep @accessibility",
"test:security": "playwright test --grep @security",
"test:report": "playwright show-report",
"test:allure": "allure generate allure-results --clean -o allure-report",
"test:allure:open": "allure open allure-report",
"test:allure:serve": "allure serve allure-results",
"test:all-with-progress": "node run-tests-with-progress.js",
"test:coverage": "playwright test --config=playwright.coverage.config.ts && node coverage-reporter.js",
"install": "playwright install --with-deps"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@playwright/test": "^1.58.2",
"@types/node": "^20.11.0",
"allure-commandline": "^2.37.0",
"allure-playwright": "^3.5.0",
"chrome-launcher": "^1.2.1",
"glob": "^13.0.6",
"istanbul-lib-coverage": "^3.2.2",
"lighthouse": "^13.0.3",
"typescript": "^5.3.0",
"v8-to-istanbul": "^9.3.0"
},
"dependencies": {
"@sentry/nextjs": "^10.42.0"
}
}
-34
View File
@@ -1,34 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './src/tests/admin',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 4,
reporter: [
['list'],
['html', { open: 'never' }],
],
timeout: 60000,
expect: {
timeout: 20000,
},
use: {
baseURL: 'http://localhost:3000',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
headless: true,
viewport: { width: 1280, height: 720 },
actionTimeout: 20000,
navigationTimeout: 30000,
storageState: '../.auth/admin.json',
},
projects: [
{
name: 'admin-chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
-36
View File
@@ -1,36 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './src/tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }],
['line'],
['list'],
],
timeout: 60000,
expect: {
timeout: 10000,
},
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
headless: true,
viewport: { width: 1280, height: 720 },
actionTimeout: 10000,
navigationTimeout: 30000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
-116
View File
@@ -1,116 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
import { getEnvironment } from './src/config/environments';
import { getMobileDevices } from './src/utils/devices';
import { getTestTier } from './src/config/test-tiers';
const env = getEnvironment();
function createTieredConfig(tierName: string) {
const tier = getTestTier(tierName);
return defineConfig({
testDir: './src/tests',
fullyParallel: tier.fullyParallel,
forbidOnly: !!process.env.CI,
retries: tier.retries,
workers: tier.workers,
globalSetup: require.resolve('./global-setup'),
reporter: [
['html', { open: 'never' }],
['json', { outputFile: `test-results/${tierName}-results.json` }],
['junit', { outputFile: `test-results/${tierName}-junit.xml` }],
['line'],
['list'],
['allure-playwright', {
outputFolder: 'allure-results',
detail: true,
suiteTitle: false,
}],
],
timeout: tier.timeout,
expect: {
timeout: tier.timeout / 2,
},
use: {
baseURL: env.baseURL,
trace: env.trace,
screenshot: env.screenshot,
video: env.video,
headless: true,
viewport: { width: 1280, height: 720 },
actionTimeout: tier.timeout / 2,
navigationTimeout: tier.timeout,
launchOptions: {
slowMo: env.slowMo,
},
storageState: '.auth/admin.json',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'chromium-coverage',
use: {
...devices['Desktop Chrome'],
browserName: 'chromium',
},
testMatch: tier.testMatch,
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
...getMobileDevices().map(device => ({
name: `mobile-${device.name.replace(/\s+/g, '-').toLowerCase()}`,
use: {
...devices['Mobile Chrome'],
viewport: device.viewport,
userAgent: device.userAgent,
deviceScaleFactor: device.deviceScaleFactor,
isMobile: true,
},
})),
{
name: 'performance-mobile',
use: {
...devices['Mobile Chrome'],
viewport: { width: 375, height: 667 },
isMobile: true,
},
testMatch: /.*\.perf\.spec\.ts/,
},
{
name: 'pwa-mobile',
use: {
...devices['Mobile Chrome'],
viewport: { width: 375, height: 667 },
isMobile: true,
serviceWorkers: 'allow',
},
testMatch: /.*\.pwa\.spec\.ts/,
},
],
webServer: env.name === 'development' && !process.env.DISABLE_WEB_SERVER ? {
command: 'cd .. && npm run dev',
url: 'http://localhost:3000',
timeout: 120000,
reuseExistingServer: !process.env.CI,
} : undefined,
});
}
export default createTieredConfig(process.env.TEST_TIER || 'standard');
-111
View File
@@ -1,111 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
import { getEnvironment } from './src/config/environments';
import { getMobileDevices } from './src/utils/devices';
const env = getEnvironment();
export default defineConfig({
testDir: './src/tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: env.retries,
workers: process.env.CI ? 4 : '50%',
globalSetup: require.resolve('./global-setup'),
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }],
['line'],
['list'],
['allure-playwright', {
outputFolder: 'allure-results',
detail: true,
suiteTitle: false,
}],
],
timeout: 90000,
expect: {
timeout: 45000,
},
use: {
baseURL: env.baseURL,
trace: env.trace,
screenshot: env.screenshot,
video: env.video,
headless: true,
viewport: { width: 1280, height: 720 },
actionTimeout: 45000,
navigationTimeout: 90000,
launchOptions: {
slowMo: env.slowMo,
},
storageState: '.auth/admin.json',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'chromium-coverage',
use: {
...devices['Desktop Chrome'],
browserName: 'chromium',
},
testMatch: /.*\.spec\.ts/,
globalSetup: undefined,
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
globalSetup: undefined,
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
...getMobileDevices().map(device => ({
name: `mobile-${device.name.replace(/\s+/g, '-').toLowerCase()}`,
use: {
...devices['Mobile Chrome'],
viewport: device.viewport,
userAgent: device.userAgent,
deviceScaleFactor: device.deviceScaleFactor,
isMobile: true,
},
})),
{
name: 'performance-mobile',
use: {
...devices['Mobile Chrome'],
viewport: { width: 375, height: 667 },
isMobile: true,
},
testMatch: /.*\.perf\.spec\.ts/,
},
{
name: 'pwa-mobile',
use: {
...devices['Mobile Chrome'],
viewport: { width: 375, height: 667 },
isMobile: true,
serviceWorkers: 'allow',
},
testMatch: /.*\.pwa\.spec\.ts/,
},
],
webServer: env.name === 'development' && !process.env.DISABLE_WEB_SERVER ? {
command: 'cd .. && npm run dev',
url: 'http://localhost:3000',
timeout: 120000,
reuseExistingServer: !process.env.CI,
} : undefined,
});
-25
View File
@@ -1,25 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './src/tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0,
timeout: 30000,
use: {
baseURL: 'http://localhost:3000',
trace: 'off',
screenshot: 'off',
video: 'off',
headless: true,
viewport: { width: 1280, height: 720 },
actionTimeout: 15000,
navigationTimeout: 30000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
-147
View File
@@ -1,147 +0,0 @@
#!/usr/bin/env node
const { spawn } = require('child_process');
const fs = require('fs');
const { glob } = require('glob');
const testTypes = [
{ name: '冒烟测试', script: 'test:smoke', pattern: 'src/tests/smoke/**/*.spec.ts' },
{ name: '回归测试', script: 'test:regression', pattern: 'src/tests/regression/**/*.spec.ts' },
{ name: '性能测试', script: 'test:performance', pattern: 'src/tests/performance/**/*.spec.ts' },
{ name: '响应式测试', script: 'test:responsive', pattern: 'src/tests/responsive/**/*.spec.ts' }
];
const TIMEOUT_SECONDS = 600;
async function runTests() {
console.log('🧪 开始运行E2E测试...\n');
const results = {
total: 0,
passed: 0,
failed: 0,
byType: {}
};
for (const testType of testTypes) {
console.log(`\n${'='.repeat(60)}`);
console.log(`📋 ${testType.name}`);
console.log(`${'='.repeat(60)}`);
await new Promise((resolve) => {
const startTime = Date.now();
let lastUpdateTime = startTime;
let currentTest = 0;
let passedCount = 0;
let failedCount = 0;
let isComplete = false;
let lastTestName = '';
const testProcess = spawn('npm', ['run', testType.script], {
cwd: __dirname,
shell: true,
stdio: ['pipe', 'pipe', 'pipe']
});
testProcess.stdout.on('data', (data) => {
const output = data.toString();
if (output.includes('')) {
currentTest++;
const progress = Math.min(100, Math.round((currentTest / 100) * 100));
const elapsed = Math.round((Date.now() - startTime) / 1000);
const barLength = Math.floor(progress / 2);
const bar = '█'.repeat(barLength) + '░'.repeat(50 - barLength);
const testNameMatch = output.match(/\s+(.+)/);
if (testNameMatch) {
lastTestName = testNameMatch[1].trim();
}
process.stdout.write(`\r⏳ 进度: [${bar}] ${progress}% - ${elapsed}s - ${lastTestName}`);
lastUpdateTime = Date.now();
}
if (output.includes('passed')) {
const match = output.match(/(\d+)\s+passed/);
if (match) {
passedCount = parseInt(match[1]);
}
}
if (output.includes('failed')) {
const match = output.match(/(\d+)\s+failed/);
if (match) {
failedCount = parseInt(match[1]);
}
}
});
testProcess.stderr.on('data', (data) => {
const output = data.toString();
if (output.includes('Error') || output.includes('error')) {
process.stdout.write('\n❌ 错误: ' + output);
}
});
testProcess.on('close', (code) => {
isComplete = true;
const elapsed = Math.round((Date.now() - startTime) / 1000);
process.stdout.write(`\r✅ 完成: [${'█'.repeat(50)}] 100% - ${elapsed}s\n`);
results.total += passedCount + failedCount;
results.passed += passedCount;
results.failed += failedCount;
results.byType[testType.name] = {
total: passedCount + failedCount,
passed: passedCount,
failed: failedCount,
elapsed: elapsed
};
resolve();
});
const progressInterval = setInterval(() => {
if (!isComplete) {
const elapsed = Math.round((Date.now() - startTime) / 1000);
const timeSinceLastUpdate = Date.now() - lastUpdateTime;
if (timeSinceLastUpdate > 10000 && timeSinceLastUpdate < 30000) {
process.stdout.write(`\r⏳ 等待测试... (${elapsed}s) - ${lastTestName}`);
} else if (timeSinceLastUpdate >= 30000) {
process.stdout.write(`\r⚠️ 测试可能卡住 (${elapsed}s) - ${lastTestName}`);
}
if (elapsed > TIMEOUT_SECONDS) {
console.log(`\n❌ 测试超时 (${TIMEOUT_SECONDS}s),正在停止...`);
testProcess.kill();
clearInterval(progressInterval);
isComplete = true;
resolve();
}
}
}, 5000);
testProcess.on('close', () => {
clearInterval(progressInterval);
});
});
}
console.log(`\n${'='.repeat(60)}`);
console.log('📊 测试结果汇总');
console.log(`${'='.repeat(60)}`);
console.log(`总测试数: ${results.total}`);
console.log(`通过: ${results.passed} (${((results.passed / results.total) * 100).toFixed(1)}%)`);
console.log(`失败: ${results.failed} (${((results.failed / results.total) * 100).toFixed(1)}%)`);
console.log('\n分类结果:');
for (const [name, result] of Object.entries(results.byType)) {
const passRate = ((result.passed / result.total) * 100).toFixed(1);
const status = passRate >= 80 ? '✅' : passRate >= 50 ? '⚠️' : '❌';
console.log(` ${status} ${name}: ${result.passed}/${result.total} (${passRate}%) - ${result.elapsed}s`);
}
}
runTests().catch(console.error);
-99
View File
@@ -1,99 +0,0 @@
const fs = require('fs');
const path = require('path');
const resultsDir = 'test-results';
const reportDir = 'test-results';
console.log('📊 生成测试报告...');
if (!fs.existsSync(resultsDir)) {
console.log('❌ 测试结果目录不存在');
process.exit(1);
}
const jsonFiles = fs.readdirSync(resultsDir)
.filter(file => file.endsWith('.json') && file.includes('-results.json'));
if (jsonFiles.length === 0) {
console.log('❌ 未找到测试结果文件');
process.exit(1);
}
console.log(`📁 找到 ${jsonFiles.length} 个测试结果文件`);
const allResults = [];
for (const file of jsonFiles) {
const filePath = path.join(resultsDir, file);
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
if (data.suites) {
for (const suite of data.suites) {
for (const spec of suite.suites) {
for (const test of spec.tests) {
const result = test.results[0];
allResults.push({
testId: `${spec.file}::${test.title}`,
file: spec.file,
title: test.title,
status: result.status,
duration: result.duration,
tier: file.includes('fast') ? 'fast' : file.includes('deep') ? 'deep' : 'standard',
});
}
}
}
}
}
const report = {
timestamp: new Date().toISOString(),
total: {
name: 'total',
total: allResults.length,
passed: allResults.filter(r => r.status === 'passed').length,
failed: allResults.filter(r => r.status === 'failed').length,
skipped: allResults.filter(r => r.status === 'skipped').length,
duration: allResults.reduce((sum, r) => sum + r.duration, 0),
},
tiers: {
fast: allResults.filter(r => r.tier === 'fast').reduce((acc, r) => ({
name: 'fast',
total: acc.total + 1,
passed: acc.passed + (r.status === 'passed' ? 1 : 0),
failed: acc.failed + (r.status === 'failed' ? 1 : 0),
duration: acc.duration + r.duration,
}), { name: 'fast', total: 0, passed: 0, failed: 0, duration: 0 }),
standard: allResults.filter(r => r.tier === 'standard').reduce((acc, r) => ({
name: 'standard',
total: acc.total + 1,
passed: acc.passed + (r.status === 'passed' ? 1 : 0),
failed: acc.failed + (r.status === 'failed' ? 1 : 0),
duration: acc.duration + r.duration,
}), { name: 'standard', total: 0, passed: 0, failed: 0, duration: 0 }),
deep: allResults.filter(r => r.tier === 'deep').reduce((acc, r) => ({
name: 'deep',
total: acc.total + 1,
passed: acc.passed + (r.status === 'passed' ? 1 : 0),
failed: acc.failed + (r.status === 'failed' ? 1 : 0),
duration: acc.duration + r.duration,
}), { name: 'deep', total: 0, passed: 0, failed: 0, duration: 0 }),
},
failedTests: allResults.filter(r => r.status === 'failed'),
slowTests: allResults.filter(r => r.duration > 120000),
};
report.total.avgDuration = report.total.duration / report.total.total;
const reportPath = path.join(reportDir, 'ci-report.json');
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log('✅ 测试报告生成完成');
console.log(` 总测试数: ${report.total.total}`);
console.log(` 通过: ${report.total.passed}`);
console.log(` 失败: ${report.total.failed}`);
console.log(` 总耗时: ${(report.total.duration / 1000).toFixed(2)}s`);
if (report.total.failed > 0) {
console.log(`\n❌ 发现 ${report.total.failed} 个失败测试`);
process.exit(1);
}
-37
View File
@@ -1,37 +0,0 @@
import { chromium } from '@playwright/test';
async function login() {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();
try {
console.log('🚀 开始登录...');
await page.goto('http://localhost:3000/admin/login', { timeout: 120000, waitUntil: 'domcontentloaded' });
console.log('📝 填写登录信息...');
await page.locator('#email').fill('admin@novalon.cn');
await page.locator('#password').fill('admin123456');
console.log('🖱️ 点击登录按钮...');
await page.locator('button[type="submit"]').click();
console.log('⏳ 等待登录成功...');
await page.waitForURL(/\/admin(?!\/login)/, { timeout: 60000 });
console.log('✅ 登录成功!');
console.log('📍 当前URL:', page.url());
console.log('💾 保存认证状态...');
await context.storageState({ path: '../.auth/admin.json' });
console.log('✅ 认证状态已保存到 .auth/admin.json');
} catch (error) {
console.error('❌ 登录失败:', error);
await page.screenshot({ path: 'login-error.png' });
} finally {
await browser.close();
}
}
login();
-82
View File
@@ -1,82 +0,0 @@
export interface EnvironmentConfig {
name: string;
baseURL: string;
apiURL: string;
timeout: number;
retries: number;
headless: boolean;
slowMo: number;
screenshot: 'on' | 'off' | 'only-on-failure';
video: 'on' | 'off' | 'retain-on-failure';
trace: 'on' | 'off' | 'retain-on-failure';
}
export const environments: Record<string, EnvironmentConfig> = {
development: {
name: 'development',
baseURL: 'http://localhost:3000',
apiURL: 'http://localhost:3000/api',
timeout: 120000,
retries: 0,
headless: true,
slowMo: 100,
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
},
staging: {
name: 'staging',
baseURL: 'https://staging.novalon.com',
apiURL: 'https://staging.novalon.com/api',
timeout: 120000,
retries: 1,
headless: true,
slowMo: 0,
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
},
production: {
name: 'production',
baseURL: 'https://novalon.com',
apiURL: 'https://novalon.com/api',
timeout: 120000,
retries: 2,
headless: true,
slowMo: 0,
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
},
};
export function getEnvironment(): EnvironmentConfig {
const envName = process.env.TEST_ENV || 'development';
const env = environments[envName];
if (!env) {
throw new Error(`Unknown environment: ${envName}. Valid environments: ${Object.keys(environments).join(', ')}`);
}
return env;
}
export function getBaseURL(): string {
return getEnvironment().baseURL;
}
export function getApiURL(): string {
return getEnvironment().apiURL;
}
export function isDevelopment(): boolean {
return getEnvironment().name === 'development';
}
export function isStaging(): boolean {
return getEnvironment().name === 'staging';
}
export function isProduction(): boolean {
return getEnvironment().name === 'production';
}
-50
View File
@@ -1,50 +0,0 @@
export interface NetworkConfig {
name: string;
offline: boolean;
downloadThroughput?: number;
uploadThroughput?: number;
latency?: number;
}
export const networkConfigs: Record<string, NetworkConfig> = {
'2g-slow': {
name: '2G Slow',
offline: false,
downloadThroughput: 250 * 1024,
uploadThroughput: 50 * 1024,
latency: 2000,
},
'3g-fast': {
name: '3G Fast',
offline: false,
downloadThroughput: 1.6 * 1024 * 1024,
uploadThroughput: 750 * 1024,
latency: 100,
},
'4g-lte': {
name: '4G LTE',
offline: false,
downloadThroughput: 4 * 1024 * 1024,
uploadThroughput: 3 * 1024 * 1024,
latency: 20,
},
'wifi-fast': {
name: 'WiFi Fast',
offline: false,
downloadThroughput: 30 * 1024 * 1024,
uploadThroughput: 15 * 1024 * 1024,
latency: 2,
},
'offline': {
name: 'Offline',
offline: true,
},
};
export function getNetworkConfig(key: string): NetworkConfig {
return networkConfigs[key] ?? networkConfigs['wifi-fast']!;
}
export function getAllNetworkConfigs(): NetworkConfig[] {
return Object.values(networkConfigs);
}
-22
View File
@@ -1,22 +0,0 @@
export const TEST_TAGS = {
CRITICAL: '@critical',
SMOKE: '@smoke',
REGRESSION: '@regression',
VISUAL: '@visual',
PERFORMANCE: '@performance',
API: '@api',
MOBILE: '@mobile',
RESPONSIVE: '@responsive',
ADMIN: '@admin',
ACCESSIBILITY: '@a11y',
SECURITY: '@security',
} as const;
export type TestTag = typeof TEST_TAGS[keyof typeof TEST_TAGS];
export function getTestPriority(tags: string[]): number {
if (tags.includes(TEST_TAGS.CRITICAL)) return 1;
if (tags.includes(TEST_TAGS.SMOKE)) return 2;
if (tags.includes(TEST_TAGS.REGRESSION)) return 3;
return 4;
}
-47
View File
@@ -1,47 +0,0 @@
export interface TestTierConfig {
name: string;
description: string;
testMatch: string | RegExp;
timeout: number;
retries: number;
workers: number | string;
fullyParallel: boolean;
failFast: boolean;
}
export const TEST_TIERS: Record<string, TestTierConfig> = {
fast: {
name: '快速层',
description: '冒烟测试、API测试、基础功能验证',
testMatch: /.*\.smoke\.spec\.ts$|.*\.api\.spec\.ts$/,
timeout: 30000,
retries: 1,
workers: process.env.CI ? 6 : '75%',
fullyParallel: true,
failFast: true,
},
standard: {
name: '标准层',
description: '功能测试、响应式测试、移动端核心功能',
testMatch: /.*(admin|navigation|responsive|mobile).*\.spec\.ts$/,
timeout: 60000,
retries: 2,
workers: process.env.CI ? 4 : '50%',
fullyParallel: true,
failFast: false,
},
deep: {
name: '深度层',
description: '视觉回归、性能测试、完整回归测试',
testMatch: /.*(visual|performance|regression).*\.spec\.ts$/,
timeout: 120000,
retries: 3,
workers: process.env.CI ? 2 : '25%',
fullyParallel: false,
failFast: false,
},
};
export function getTestTier(tierName: string): TestTierConfig {
return TEST_TIERS[tierName] || TEST_TIERS.standard;
}
-44
View File
@@ -1,44 +0,0 @@
export const adminTestData = {
users: {
admin: { email: 'admin@novalon.cn', password: 'admin123456' },
editor: { email: 'editor@novalon.cn', password: 'editor123' },
viewer: { email: 'viewer@novalon.cn', password: 'viewer123' }
},
content: {
product: {
type: 'product',
title: '测试产品',
slug: 'test-product',
content: '产品描述内容'
},
service: {
type: 'service',
title: '测试服务',
slug: 'test-service',
content: '服务描述内容'
},
case: {
type: 'case',
title: '测试案例',
slug: 'test-case',
content: '案例描述内容'
},
news: {
type: 'news',
title: '测试新闻',
slug: 'test-news',
content: '新闻内容'
}
}
};
export function generateTestContent(type: 'product' | 'service' | 'case' | 'news') {
const timestamp = Date.now();
return {
type,
title: `测试${type}-${timestamp}`,
slug: `test-${type}-${timestamp}`,
content: `${type}内容描述-${timestamp}`,
excerpt: `${type}摘要-${timestamp}`
};
}
-216
View File
@@ -1,216 +0,0 @@
export const VALID_CONTACT_DATA = {
name: '张三',
email: 'zhangsan@example.com',
phone: '13800138000',
subject: '产品咨询',
message: '您好,我对贵公司的产品很感兴趣,希望能了解更多信息。',
};
export const INVALID_EMAIL_CASES = [
{ email: 'invalid-email', description: '无@符号' },
{ email: '@example.com', description: '无用户名' },
{ email: 'user@', description: '无域名' },
{ email: 'user@domain', description: '无顶级域名' },
{ email: 'user domain.com', description: '包含空格' },
{ email: 'user@domain..com', description: '连续点号' },
];
export const INVALID_PHONE_CASES = [
{ phone: '123', description: '过短' },
{ phone: '123456789012345', description: '过长' },
{ phone: 'abcdefghijk', description: '纯字母' },
{ phone: '123-456-7890', description: '包含连字符' },
{ phone: '138 0013 8000', description: '包含空格' },
];
export const SPECIAL_CHARACTER_CASES = [
{ name: '!@#$%^&*()', description: '特殊符号' },
{ name: '<script>alert("XSS")</script>', description: 'XSS脚本' },
{ name: "'; DROP TABLE users; --", description: 'SQL注入' },
{ name: '../../../etc/passwd', description: '路径遍历' },
{ name: '{{template}}', description: '模板注入' },
];
export const LONG_CONTENT_CASES = [
{ message: 'A'.repeat(500), description: '500字符消息' },
{ message: 'A'.repeat(1000), description: '1000字符消息' },
{ message: 'A'.repeat(1001), description: '超长消息' },
];
export const BOUNDARY_CASES = {
name: {
minLength: '张',
maxLength: '张三李四王五赵六钱七孙八周九吴十',
empty: '',
whitespace: ' ',
},
phone: {
minLength: '1380013800',
maxLength: '138001380001',
validFormat: '13800138000',
},
email: {
minLength: 'a@b.c',
maxLength: `${'a'.repeat(64)}@${'b'.repeat(63)}.com`,
},
};
export const PERFORMANCE_THRESHOLDS = {
loadTime: 5000,
firstContentfulPaint: 1800,
largestContentfulPaint: 2500,
timeToInteractive: 3800,
cumulativeLayoutShift: 0.1,
firstInputDelay: 100,
};
export const RESPONSIVE_BREAKPOINTS = [
{ name: 'mobile-small', width: 320, height: 568 },
{ name: 'mobile-medium', width: 375, height: 667 },
{ name: 'mobile-large', width: 414, height: 896 },
{ name: 'tablet-small', width: 768, height: 1024 },
{ name: 'tablet-large', width: 1024, height: 768 },
{ name: 'desktop-small', width: 1280, height: 720 },
{ name: 'desktop-medium', width: 1366, height: 768 },
{ name: 'desktop-large', width: 1920, height: 1080 },
];
export const NAVIGATION_ITEMS = [
{ label: '首页', href: '#home', expectedUrl: '/' },
{ label: '关于我们', href: '#about', expectedUrl: '/' },
{ label: '服务', href: '#services', expectedUrl: '/' },
{ label: '产品', href: '#products', expectedUrl: '/' },
{ label: '案例', href: '#cases', expectedUrl: '/' },
{ label: '新闻', href: '#news', expectedUrl: '/' },
{ label: '联系我们', href: '#contact', expectedUrl: '/' },
];
export const COMPANY_INFO = {
name: '四川睿新致远科技有限公司',
address: '四川省成都市高新区天府大道中段1268号天府软件园E区1栋',
phone: '028-88888888',
email: 'contact@ruixin.com',
workHours: [
{ day: '周一至周五', hours: '9:00 - 18:00' },
{ day: '周六', hours: '9:00 - 12:00' },
{ day: '周日', hours: '休息' },
],
};
export const SERVICES = [
{
id: 'service-1',
title: '企业数字化转型',
description: '帮助企业实现数字化升级',
},
{
id: 'service-2',
title: '智能制造解决方案',
description: '提供智能化生产线解决方案',
},
{
id: 'service-3',
title: '数据分析服务',
description: '专业的数据分析与可视化服务',
},
];
export const PRODUCTS = [
{
id: 'product-1',
title: '智能生产管理系统',
description: '一体化生产管理平台',
},
{
id: 'product-2',
title: '数据分析平台',
description: '企业级数据分析工具',
},
{
id: 'product-3',
title: '物联网监控平台',
description: '实时设备监控与管理',
},
];
export const NEWS_ITEMS = [
{
id: 'news-1',
title: '公司获得ISO9001认证',
date: '2024-01-15',
summary: '公司成功通过ISO9001质量管理体系认证',
},
{
id: 'news-2',
title: '新产品发布会圆满成功',
date: '2024-02-20',
summary: '智能生产管理系统2.0版本正式发布',
},
];
export const SECURITY_TEST_CASES = {
xssPayloads: [
'<script>alert("XSS")</script>',
'<img src=x onerror=alert("XSS")>',
'<svg onload=alert("XSS")>',
'javascript:alert("XSS")',
'<body onload=alert("XSS")>',
],
sqlInjectionPayloads: [
"'; DROP TABLE users; --",
"' OR '1'='1",
"' UNION SELECT * FROM users --",
"1; INSERT INTO users VALUES ('hacker')",
],
pathTraversalPayloads: [
'../../../etc/passwd',
'..\\..\\..\\windows\\system32\\config\\sam',
'....//....//....//etc/passwd',
],
};
export const ACCESSIBILITY_TEST_CASES = {
colorContrastRatios: {
normalText: 4.5,
largeText: 3.0,
uiComponents: 3.0,
},
touchTargetSize: 44,
focusIndicatorVisible: true,
keyboardNavigationRequired: true,
};
export const ERROR_MESSAGES = {
requiredField: '此字段为必填项',
invalidEmail: '请输入有效的邮箱地址',
invalidPhone: '请输入有效的手机号码',
messageTooShort: '消息内容至少需要10个字符',
messageTooLong: '消息内容不能超过1000个字符',
submissionFailed: '提交失败,请稍后重试',
};
export const SUCCESS_MESSAGES = {
formSubmitted: '消息已发送',
thankYou: '感谢您的留言,我们会尽快与您联系',
};
export const TEST_USERS = {
admin: {
username: 'admin',
password: 'admin123',
role: 'administrator',
},
user: {
username: 'testuser',
password: 'test123',
role: 'user',
},
};
export const API_ENDPOINTS = {
contactForm: '/api/contact',
newsletter: '/api/newsletter',
services: '/api/services',
products: '/api/products',
news: '/api/news',
};
-15
View File
@@ -1,15 +0,0 @@
import { test as base } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';
type A11yFixtures = {
makeAxeBuilder: () => AxeBuilder;
};
export const test = base.extend<A11yFixtures>({
makeAxeBuilder: async ({ page }, use) => {
const makeAxeBuilder = () => new AxeBuilder({ page });
await use(makeAxeBuilder);
},
});
export { expect } from '@playwright/test';
-51
View File
@@ -1,51 +0,0 @@
import { test as base, expect as baseExpect } from '@playwright/test';
import { AdminLoginPage, AdminDashboardPage, AdminContentPage, AdminUsersPage, AdminLogsPage } from '../pages/AdminPage';
import { TestDataGenerator } from '../utils/TestDataGenerator';
export type AdminFixtures = {
adminLoginPage: AdminLoginPage;
adminDashboardPage: AdminDashboardPage;
adminContentPage: AdminContentPage;
adminUsersPage: AdminUsersPage;
adminLogsPage: AdminLogsPage;
testDataGenerator: typeof TestDataGenerator;
};
export const test = base.extend<AdminFixtures>({
page: async ({ page }, use) => {
page.setDefaultTimeout(45000);
page.setDefaultNavigationTimeout(90000);
await use(page);
},
adminLoginPage: async ({ page }, use) => {
const adminLoginPage = new AdminLoginPage(page);
await use(adminLoginPage);
},
adminDashboardPage: async ({ page }, use) => {
const adminDashboardPage = new AdminDashboardPage(page);
await use(adminDashboardPage);
},
adminContentPage: async ({ page }, use) => {
const adminContentPage = new AdminContentPage(page);
await use(adminContentPage);
},
adminUsersPage: async ({ page }, use) => {
const adminUsersPage = new AdminUsersPage(page);
await use(adminUsersPage);
},
adminLogsPage: async ({ page }, use) => {
const adminLogsPage = new AdminLogsPage(page);
await use(adminLogsPage);
},
testDataGenerator: async ({}, use) => {
await use(TestDataGenerator);
},
});
export const expect = baseExpect;
-76
View File
@@ -1,76 +0,0 @@
import { test as base } from '@playwright/test';
import { HomePage } from '../pages/HomePage';
import { ContactPage } from '../pages/ContactPage';
import { AboutPage } from '../pages/AboutPage';
import { CasesPage } from '../pages/CasesPage';
import { ServicesPage } from '../pages/ServicesPage';
import { ProductsPage } from '../pages/ProductsPage';
import { SolutionsPage } from '../pages/SolutionsPage';
import { NewsPage } from '../pages/NewsPage';
import { TestDataGenerator } from '../utils/TestDataGenerator';
export type TestFixtures = {
homePage: HomePage;
contactPage: ContactPage;
aboutPage: AboutPage;
casesPage: CasesPage;
servicesPage: ServicesPage;
productsPage: ProductsPage;
solutionsPage: SolutionsPage;
newsPage: NewsPage;
testDataGenerator: typeof TestDataGenerator;
};
export const test = base.extend<TestFixtures>({
page: async ({ page }, use) => {
page.setDefaultTimeout(45000);
page.setDefaultNavigationTimeout(90000);
await use(page);
},
homePage: async ({ page }, use) => {
const homePage = new HomePage(page);
await use(homePage);
},
contactPage: async ({ page }, use) => {
const contactPage = new ContactPage(page);
await use(contactPage);
},
aboutPage: async ({ page }, use) => {
const aboutPage = new AboutPage(page);
await use(aboutPage);
},
casesPage: async ({ page }, use) => {
const casesPage = new CasesPage(page);
await use(casesPage);
},
servicesPage: async ({ page }, use) => {
const servicesPage = new ServicesPage(page);
await use(servicesPage);
},
productsPage: async ({ page }, use) => {
const productsPage = new ProductsPage(page);
await use(productsPage);
},
solutionsPage: async ({ page }, use) => {
const solutionsPage = new SolutionsPage(page);
await use(solutionsPage);
},
newsPage: async ({ page }, use) => {
const newsPage = new NewsPage(page);
await use(newsPage);
},
testDataGenerator: async ({}, use) => {
await use(TestDataGenerator);
},
});
export const expect = base.expect;
-77
View File
@@ -1,77 +0,0 @@
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class AboutPage extends BasePage {
readonly page: Page;
constructor(page: Page) {
super(page);
this.page = page;
}
get breadcrumb(): Locator {
return this.page.locator('nav[aria-label="breadcrumb"]');
}
get pageHeader(): Locator {
return this.page.locator('h1');
}
get valuesSection(): Locator {
return this.page.locator('div:has(h2:has-text("核心价值观"))').first();
}
get milestonesSection(): Locator {
return this.page.locator('div:has(h2:has-text("发展历程"))').first();
}
get contactSection(): Locator {
return this.page.locator('div:has(h2:has-text("联系我们"))').first();
}
get statCards(): Locator {
return this.page.locator('[class*="text-3xl"][class*="text-[#C41E3A]"]');
}
async navigateToAbout(): Promise<void> {
await this.navigate('/about');
}
async verifyBreadcrumb(): Promise<boolean> {
return await this.breadcrumb.isVisible();
}
async verifyPageHeader(): Promise<boolean> {
const header = await this.pageHeader.textContent();
return header?.includes('关于我们') || false;
}
async verifyValuesSection(): Promise<boolean> {
return await this.valuesSection.isVisible();
}
async verifyMilestonesSection(): Promise<boolean> {
return await this.milestonesSection.isVisible();
}
async verifyContactSection(): Promise<boolean> {
return await this.contactSection.isVisible();
}
async getStatValues(): Promise<string[]> {
const stats = await this.statCards.allTextContents();
return stats;
}
async scrollToValuesSection(): Promise<void> {
await this.scrollToElement(this.valuesSection);
}
async scrollToMilestonesSection(): Promise<void> {
await this.scrollToElement(this.milestonesSection);
}
async scrollToContactSection(): Promise<void> {
await this.scrollToElement(this.contactSection);
}
}
-247
View File
@@ -1,247 +0,0 @@
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class AdminLoginPage extends BasePage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.locator('#email, input[type="email"]');
this.passwordInput = page.locator('#password, input[type="password"]');
this.loginButton = page.getByRole('button', { name: /登录|login/i });
this.errorMessage = page.locator('[role="alert"], .text-red-700');
}
async goto() {
await this.navigate('/admin/login');
await this.page.waitForLoadState('domcontentloaded', { timeout: 30000 });
await this.page.waitForTimeout(1000);
await this.emailInput.waitFor({ state: 'visible', timeout: 20000 });
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async expectLoginSuccess() {
await this.page.waitForURL(/\/admin(?!\/login)/);
}
async expectLoginError() {
await this.errorMessage.waitFor({ state: 'visible' });
}
}
export class AdminDashboardPage extends BasePage {
readonly sidebar: Locator;
readonly navigationItems: Locator;
readonly contentMenuItem: Locator;
readonly settingsMenuItem: Locator;
readonly usersMenuItem: Locator;
readonly logsMenuItem: Locator;
readonly logoutButton: Locator;
constructor(page: Page) {
super(page);
this.sidebar = page.locator('aside, [role="navigation"]');
this.navigationItems = this.sidebar.locator('nav a, nav button');
this.contentMenuItem = this.sidebar.getByRole('link', { name: /内容管理/i });
this.settingsMenuItem = this.sidebar.getByRole('link', { name: /配置中心|设置/i });
this.usersMenuItem = this.sidebar.getByRole('link', { name: /用户管理/i });
this.logsMenuItem = this.sidebar.getByRole('link', { name: /审计日志|日志/i });
this.logoutButton = this.sidebar.getByRole('button', { name: /登出|退出|logout/i });
}
async goto() {
await this.navigate('/admin');
await this.waitForLoadState('networkidle');
}
async navigateToContent() {
await this.contentMenuItem.click();
await this.waitForLoadState('networkidle');
}
async navigateToSettings() {
await this.settingsMenuItem.click();
await this.waitForLoadState('networkidle');
}
async navigateToUsers() {
await this.usersMenuItem.click();
await this.waitForLoadState('networkidle');
}
async navigateToLogs() {
await this.logsMenuItem.click();
await this.waitForLoadState('networkidle');
}
async logout() {
await this.logoutButton.click();
}
}
export class AdminContentPage extends BasePage {
readonly createButton: Locator;
readonly contentList: Locator;
readonly searchInput: Locator;
readonly filterButtons: Locator;
readonly editButtons: Locator;
readonly deleteButtons: Locator;
constructor(page: Page) {
super(page);
this.createButton = page.getByRole('button', { name: /创建|新建|create/i });
this.contentList = page.locator('table tbody tr').or(page.locator('[data-testid="content-item"]'));
this.searchInput = page.locator('input[type="search"], input[placeholder*="搜索"]');
this.filterButtons = page.locator('button[role="tab"], select');
this.editButtons = page.getByRole('button', { name: /编辑|edit/i });
this.deleteButtons = page.getByRole('button', { name: /删除|delete/i });
}
async goto() {
await this.navigate('/admin/content');
await this.waitForLoadState('networkidle');
}
async createContent(data: {
type: string;
title: string;
slug: string;
content?: string;
}) {
await this.createButton.click();
await this.page.locator('select[name="type"]').selectOption(data.type);
await this.page.locator('input[name="title"]').fill(data.title);
await this.page.locator('input[name="slug"]').fill(data.slug);
if (data.content) {
await this.page.locator('textarea[name="content"], .ProseMirror').fill(data.content);
}
await this.page.getByRole('button', { name: /保存|submit/i }).click();
}
async searchContent(query: string) {
await this.searchInput.fill(query);
await this.page.keyboard.press('Enter');
await this.waitForLoadState('networkidle');
}
async editContent(index: number) {
const editButton = this.editButtons.nth(index);
await editButton.click();
await this.waitForLoadState('networkidle');
}
async deleteContent(index: number) {
const deleteButton = this.deleteButtons.nth(index);
await deleteButton.click();
const confirmButton = this.page.getByRole('button', { name: /确认|确定|confirm/i });
if (await confirmButton.isVisible()) {
await confirmButton.click();
}
await this.waitForLoadState('networkidle');
}
}
export class AdminUsersPage extends BasePage {
readonly createButton: Locator;
readonly usersList: Locator;
readonly searchInput: Locator;
readonly editButtons: Locator;
readonly deleteButtons: Locator;
constructor(page: Page) {
super(page);
this.createButton = page.getByRole('button', { name: /创建|新建|create/i });
this.usersList = page.locator('table tbody tr, [role="listitem"]');
this.searchInput = page.locator('input[type="search"], input[placeholder*="搜索"]');
this.editButtons = page.getByRole('button', { name: /编辑|edit/i });
this.deleteButtons = page.getByRole('button', { name: /删除|delete/i });
}
async goto() {
await this.navigate('/admin/users');
await this.waitForLoadState('networkidle');
}
async createUser(data: {
email: string;
name: string;
password: string;
role: string;
}) {
await this.createButton.click();
await this.page.locator('input[name="email"]').fill(data.email);
await this.page.locator('input[name="name"]').fill(data.name);
await this.page.locator('input[name="password"]').fill(data.password);
await this.page.locator('select[name="role"]').selectOption(data.role);
await this.page.getByRole('button', { name: /保存|submit/i }).click();
}
async deleteUser(index: number) {
const deleteButton = this.deleteButtons.nth(index);
await deleteButton.click();
const confirmButton = this.page.getByRole('button', { name: /确认|确定|confirm/i });
if (await confirmButton.isVisible()) {
await confirmButton.click();
}
await this.waitForLoadState('networkidle');
}
}
export class AdminLogsPage extends BasePage {
readonly logsList: Locator;
readonly actionFilter: Locator;
readonly resourceTypeFilter: Locator;
readonly refreshButton: Locator;
readonly pagination: Locator;
constructor(page: Page) {
super(page);
this.logsList = page.locator('table tbody tr, [role="listitem"]');
this.actionFilter = page.locator('select[name="action"], select').first();
this.resourceTypeFilter = page.locator('select[name="resourceType"], select').nth(1);
this.refreshButton = page.getByRole('button', { name: /刷新|refresh/i });
this.pagination = page.locator('[role="navigation"], .pagination');
}
async goto() {
await this.navigate('/admin/logs');
await this.waitForLoadState('networkidle');
}
async filterByAction(action: string) {
await this.actionFilter.selectOption(action);
await this.waitForLoadState('networkidle');
}
async filterByResourceType(type: string) {
await this.resourceTypeFilter.selectOption(type);
await this.waitForLoadState('networkidle');
}
async refresh() {
await this.refreshButton.click();
await this.waitForLoadState('networkidle');
}
async goToPage(pageNumber: number) {
await this.pagination.getByRole('button', { name: String(pageNumber) }).click();
await this.waitForLoadState('networkidle');
}
}
-483
View File
@@ -1,483 +0,0 @@
import { Page, Locator } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
export class BasePage {
readonly page: Page;
readonly mobileMenuButton: Locator;
readonly mobileMenu: Locator;
readonly mobileMenuCloseButton: Locator;
constructor(page: Page) {
this.page = page;
this.mobileMenuButton = page.getByRole('button', { name: /打开菜单|menu/i });
this.mobileMenu = page.locator('[role="navigation"][aria-label="移动端导航"], #mobile-menu');
this.mobileMenuCloseButton = page.getByRole('button', { name: /关闭菜单|close/i });
}
async navigate(url: string): Promise<void> {
await this.page.goto(url, { timeout: 30000, waitUntil: 'domcontentloaded' });
}
async waitForLoadState(state: 'load' | 'domcontentloaded' | 'networkidle' = 'load'): Promise<void> {
await this.page.waitForLoadState(state);
}
async click(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.click();
}
async fill(locator: Locator | string, value: string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.fill(value);
}
async getText(locator: Locator | string): Promise<string> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.textContent() || '';
}
async isVisible(locator: Locator | string): Promise<boolean> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.isVisible();
}
async waitForElement(locator: Locator | string, timeout: number = 5000): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.waitFor({ state: 'visible', timeout });
}
async scrollToElement(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.scrollIntoViewIfNeeded();
}
async takeScreenshot(filename: string): Promise<void> {
const screenshotDir = 'test-results/screenshots';
if (!fs.existsSync(screenshotDir)) {
fs.mkdirSync(screenshotDir, { recursive: true });
}
await this.page.screenshot({ path: path.join(screenshotDir, filename) });
}
async hover(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.hover();
}
async selectOption(locator: Locator | string, value: string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.selectOption(value);
}
async check(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.check();
}
async uncheck(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.uncheck();
}
async waitForURL(url: string | RegExp, timeout: number = 5000): Promise<void> {
await this.page.waitForURL(url, { timeout });
}
async getCurrentURL(): Promise<string> {
return this.page.url();
}
async getTitle(): Promise<string> {
return await this.page.title();
}
async waitForSelector(locator: Locator | string, options?: { state?: 'attached' | 'detached' | 'visible' | 'hidden', timeout?: number }): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.waitFor(options);
}
async getAttribute(locator: Locator | string, attribute: string): Promise<string | null> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.getAttribute(attribute);
}
async pressKey(key: string): Promise<void> {
await this.page.keyboard.press(key);
}
async type(locator: Locator | string, text: string, options?: { delay?: number }): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.type(text, options);
}
async waitForNavigation(options?: { url?: string | RegExp, timeout?: number }): Promise<void> {
await this.page.waitForNavigation(options);
}
async reload(): Promise<void> {
await this.page.reload();
}
async goBack(): Promise<void> {
await this.page.goBack();
}
async goForward(): Promise<void> {
await this.page.goForward();
}
async evaluate<T>(pageFunction: () => T): Promise<T> {
return await this.page.evaluate(pageFunction);
}
async waitForTimeout(timeout: number): Promise<void> {
await this.page.waitForTimeout(timeout);
}
async count(locator: Locator | string): Promise<number> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.count();
}
async allTextContents(locator: Locator | string): Promise<string[]> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.allTextContents();
}
async isDisabled(locator: Locator | string): Promise<boolean> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.isDisabled();
}
async isEnabled(locator: Locator | string): Promise<boolean> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.isEnabled();
}
async isChecked(locator: Locator | string): Promise<boolean> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.isChecked();
}
async measurePerformance(): Promise<{
loadTime: number;
domContentLoaded: number;
firstPaint: number;
firstContentfulPaint: number;
}> {
const metrics = await this.page.evaluate(() => {
const performance = window.performance;
const timing = performance.timing;
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
return {
loadTime: timing.loadEventEnd - timing.navigationStart,
domContentLoaded: timing.domContentLoadedEventEnd - timing.navigationStart,
firstPaint: navigation ? navigation.loadEventEnd - navigation.fetchStart : 0,
firstContentfulPaint: navigation ? navigation.domContentLoadedEventEnd - navigation.fetchStart : 0,
};
});
return metrics;
}
async getCoreWebVitals(): Promise<{
largestContentfulPaint: number;
firstInputDelay: number;
cumulativeLayoutShift: number;
}> {
const vitals = await this.page.evaluate(() => {
return new Promise((resolve) => {
const result = {
largestContentfulPaint: 0,
firstInputDelay: 0,
cumulativeLayoutShift: 0,
};
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach((entry) => {
if (entry.entryType === 'largest-contentful-paint') {
result.largestContentfulPaint = entry.startTime;
}
if (entry.entryType === 'first-input') {
result.firstInputDelay = (entry as any).processingStart - entry.startTime;
}
if (entry.entryType === 'layout-shift') {
if (!(entry as any).hadRecentInput) {
result.cumulativeLayoutShift += (entry as any).value;
}
}
});
});
observer.observe({ entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift'] });
setTimeout(() => {
observer.disconnect();
resolve(result);
}, 3000);
});
});
return vitals as any;
}
async getResourceTiming(): Promise<PerformanceResourceTiming[]> {
return await this.page.evaluate(() => {
return performance.getEntriesByType('resource') as PerformanceResourceTiming[];
});
}
async getNetworkTiming(): Promise<{
dns: number;
tcp: number;
ssl: number;
request: number;
response: number;
total: number;
}> {
return await this.page.evaluate(() => {
const timing = performance.timing;
return {
dns: timing.domainLookupEnd - timing.domainLookupStart,
tcp: timing.connectEnd - timing.connectStart,
ssl: timing.connectEnd - timing.secureConnectionStart,
request: timing.responseStart - timing.requestStart,
response: timing.responseEnd - timing.responseStart,
total: timing.loadEventEnd - timing.navigationStart,
};
});
}
async retryOperation<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
delay: number = 1000
): Promise<T> {
let lastError: Error | undefined;
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
if (i < maxRetries - 1) {
await this.waitForTimeout(delay);
}
}
}
throw lastError;
}
async log(message: string, level: 'info' | 'warn' | 'error' = 'info'): Promise<void> {
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`;
switch (level) {
case 'warn':
console.warn(logMessage);
break;
case 'error':
console.error(logMessage);
break;
default:
console.log(logMessage);
}
}
async waitForElementWithRetry(
locator: Locator | string,
options?: { timeout?: number; retries?: number }
): Promise<void> {
const timeout = options?.timeout || 5000;
const retries = options?.retries || 3;
await this.retryOperation(
async () => {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.waitFor({ state: 'visible', timeout });
},
retries,
1000
);
}
async clickWithRetry(
locator: Locator | string,
options?: { timeout?: number; retries?: number }
): Promise<void> {
const retries = options?.retries || 3;
await this.retryOperation(
async () => {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.click();
},
retries,
1000
);
}
async fillWithRetry(
locator: Locator | string,
value: string,
options?: { timeout?: number; retries?: number }
): Promise<void> {
const retries = options?.retries || 3;
await this.retryOperation(
async () => {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.fill(value);
},
retries,
1000
);
}
async scrollToTop(): Promise<void> {
await this.page.evaluate(() => {
window.scrollTo(0, 0);
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
});
await this.page.waitForTimeout(2000);
await this.page.evaluate(() => {
window.scrollTo(0, 0);
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
});
await this.page.waitForTimeout(1000);
}
async scrollToBottom(): Promise<void> {
await this.page.evaluate(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
});
await this.page.waitForTimeout(1000);
}
async getScrollPosition(): Promise<{ x: number; y: number }> {
return await this.page.evaluate(() => {
return {
x: window.scrollX,
y: window.scrollY,
};
});
}
async isElementInViewport(locator: Locator | string): Promise<boolean> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.evaluate((el) => {
const rect = el.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
});
}
async getElementBoundingBox(locator: Locator | string): Promise<{
x: number;
y: number;
width: number;
height: number;
} | null> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.boundingBox();
}
async getElementStyle(locator: Locator | string, property: string): Promise<string> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.evaluate((el, prop) => {
return window.getComputedStyle(el).getPropertyValue(prop);
}, property);
}
async isElementFocused(locator: Locator | string): Promise<boolean> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.evaluate((el) => {
return document.activeElement === el;
});
}
async focus(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.focus();
}
async blur(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.blur();
}
async dragAndDrop(source: Locator | string, target: Locator | string): Promise<void> {
const sourceElement = typeof source === 'string' ? this.page.locator(source) : source;
const targetElement = typeof target === 'string' ? this.page.locator(target) : target;
await sourceElement.dragTo(targetElement);
}
async uploadFile(locator: Locator | string, filePath: string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.setInputFiles(filePath);
}
async clearInput(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.clear();
}
async getInputValue(locator: Locator | string): Promise<string> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
return await element.inputValue();
}
async selectText(locator: Locator | string): Promise<void> {
const element = typeof locator === 'string' ? this.page.locator(locator) : locator;
await element.selectText();
}
async waitForFileDownload(downloadPromise: Promise<any>): Promise<string> {
const download = await downloadPromise;
const path = await download.path();
return path || '';
}
async acceptDialog(): Promise<void> {
this.page.on('dialog', (dialog) => dialog.accept());
}
async dismissDialog(): Promise<void> {
this.page.on('dialog', (dialog) => dialog.dismiss());
}
async getDialogMessage(): Promise<string> {
return new Promise((resolve) => {
this.page.on('dialog', (dialog) => {
resolve(dialog.message());
});
});
}
async openMobileMenu() {
await this.mobileMenuButton.click();
await this.mobileMenu.waitFor({ state: 'visible', timeout: 5000 });
}
async closeMobileMenu() {
if (await this.mobileMenu.isVisible()) {
await this.mobileMenuCloseButton.click();
await this.mobileMenu.waitFor({ state: 'hidden', timeout: 5000 });
}
}
async isMobileMenuOpen() {
return await this.mobileMenu.isVisible();
}
}
-65
View File
@@ -1,65 +0,0 @@
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class CasesPage extends BasePage {
readonly page: Page;
constructor(page: Page) {
super(page);
this.page = page;
}
get breadcrumb(): Locator {
return this.page.locator('nav[aria-label="breadcrumb"]');
}
get pageHeader(): Locator {
return this.page.locator('h1');
}
get caseCards(): Locator {
return this.page.locator('a[href^="/cases/"]');
}
get ctaSection(): Locator {
return this.page.locator('div:has(h2:has-text("准备开始您的数字化转型之旅"))').first();
}
async navigateToCases(): Promise<void> {
await this.navigate('/cases');
}
async verifyBreadcrumb(): Promise<boolean> {
return await this.breadcrumb.isVisible();
}
async verifyPageHeader(): Promise<boolean> {
const header = await this.pageHeader.textContent();
return header?.includes('与谁同行') || false;
}
async getCaseCount(): Promise<number> {
return await this.caseCards.count();
}
async clickCase(index: number): Promise<void> {
const cards = await this.caseCards.all();
const card = cards[index];
if (card) {
await card.click();
}
}
async verifyCTASection(): Promise<boolean> {
return await this.ctaSection.isVisible();
}
async scrollToCTASection(): Promise<void> {
await this.scrollToElement(this.ctaSection);
}
async getCaseTitles(): Promise<string[]> {
const titles = this.caseCards.locator('h3');
return await titles.allTextContents();
}
}
-138
View File
@@ -1,138 +0,0 @@
import { Page, Locator } from '@playwright/test';
export class ContactFormPage {
readonly page: Page;
readonly nameInput: Locator;
readonly phoneInput: Locator;
readonly emailInput: Locator;
readonly messageInput: Locator;
readonly captchaQuestion: Locator;
readonly captchaInput: Locator;
readonly refreshCaptchaButton: Locator;
readonly submitButton: Locator;
readonly successMessage: Locator;
readonly errorMessage: Locator;
readonly captchaErrorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.nameInput = page.getByTestId('name-input');
this.phoneInput = page.getByTestId('phone-input');
this.emailInput = page.getByTestId('email-input');
this.messageInput = page.getByTestId('message-input');
this.captchaQuestion = page.getByTestId('captcha-question');
this.captchaInput = page.getByTestId('captcha-input');
this.refreshCaptchaButton = page.getByTestId('refresh-captcha');
this.submitButton = page.getByRole('button', { name: /发送消息/ });
this.successMessage = page.getByText('消息已成功发送');
this.errorMessage = page.getByRole('alert');
this.captchaErrorMessage = page.getByTestId('captcha-error');
}
async goto() {
await this.page.goto('/contact');
}
async fillForm(data: {
name: string;
phone: string;
email: string;
message: string;
}) {
await this.nameInput.fill(data.name);
await this.phoneInput.fill(data.phone);
await this.emailInput.fill(data.email);
await this.messageInput.fill(data.message);
}
async solveCaptcha() {
const questionText = await this.captchaQuestion.textContent();
if (!questionText) throw new Error('Captcha question not found');
const match = questionText.match(/(\d+)\s*([+\-×÷])\s*(\d+)\s*=/);
if (!match) throw new Error('Invalid captcha format');
const [, num1, operator, num2] = match;
const n1 = parseInt(num1);
const n2 = parseInt(num2);
let answer: number;
switch (operator) {
case '+':
answer = n1 + n2;
break;
case '-':
answer = n1 - n2;
break;
case '×':
answer = n1 * n2;
break;
case '÷':
answer = n1 / n2;
break;
default:
throw new Error(`Unknown operator: ${operator}`);
}
await this.captchaInput.fill(answer.toString());
}
async submit() {
await this.submitButton.click();
}
async submitForm(data: {
name: string;
phone: string;
email: string;
message: string;
}) {
await this.fillForm(data);
await this.solveCaptcha();
await this.submit();
}
async refreshCaptcha() {
await this.refreshCaptchaButton.click();
}
async getCaptchaQuestion(): Promise<string> {
return (await this.captchaQuestion.textContent()) || '';
}
async getSuccessMessage(): Promise<string | null> {
try {
return await this.successMessage.textContent();
} catch {
return null;
}
}
async getErrorMessage(): Promise<string | null> {
try {
return await this.errorMessage.textContent();
} catch {
return null;
}
}
async getCaptchaErrorMessage(): Promise<string | null> {
try {
return await this.captchaErrorMessage.textContent();
} catch {
return null;
}
}
async waitForSuccessMessage() {
await this.successMessage.waitFor({ state: 'visible', timeout: 5000 });
}
async waitForErrorMessage() {
await this.errorMessage.waitFor({ state: 'visible', timeout: 5000 });
}
async isSubmitButtonEnabled(): Promise<boolean> {
return await this.submitButton.isEnabled();
}
}
-555
View File
@@ -1,555 +0,0 @@
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { ContactFormData } from '../types';
export class ContactPage extends BasePage {
readonly url: string;
readonly pageHeader: Locator;
readonly contactForm: Locator;
readonly nameInput: Locator;
readonly phoneInput: Locator;
readonly emailInput: Locator;
readonly subjectInput: Locator;
readonly messageInput: Locator;
readonly submitButton: Locator;
readonly contactInfoCard: Locator;
readonly workHoursCard: Locator;
readonly emailInfo: Locator;
readonly phoneInfo: Locator;
readonly addressInfo: Locator;
readonly emailLink: Locator;
readonly phoneLink: Locator;
readonly addressText: Locator;
readonly pageBadge: Locator;
readonly pageDescription: Locator;
readonly successMessage: Locator;
readonly nameError: Locator;
readonly emailError: Locator;
readonly phoneError: Locator;
readonly messageError: Locator;
constructor(page: Page) {
super(page);
this.url = '/contact';
this.pageHeader = page.locator('h1');
this.contactForm = page.locator('form');
this.nameInput = page.locator('[data-testid="name-input"]');
this.phoneInput = page.locator('[data-testid="phone-input"]');
this.emailInput = page.locator('[data-testid="email-input"]');
this.subjectInput = page.locator('[data-testid="subject-input"]');
this.messageInput = page.locator('[data-testid="message-input"]');
this.submitButton = page.locator('[data-testid="submit-button"]');
this.contactInfoCard = page.locator('[data-testid="contact-info"]');
this.workHoursCard = page.locator('[data-testid="work-hours-card"]');
this.emailInfo = page.locator('[data-testid="email-info"]');
this.phoneInfo = page.locator('[data-testid="phone-info"]');
this.addressInfo = page.locator('[data-testid="address-info"]');
this.emailLink = page.locator('[data-testid="email-link"]');
this.phoneLink = page.locator('[data-testid="phone-link"]');
this.addressText = page.locator('[data-testid="address-text"]');
this.pageBadge = page.locator('[data-testid="page-badge"]');
this.pageDescription = page.locator('[data-testid="page-description"]');
this.successMessage = page.locator('text=消息已发送');
this.nameError = page.locator('[data-testid="name-input"] + .error-message, [data-testid="name-input"] ~ .text-destructive').first();
this.emailError = page.locator('[data-testid="email-input"] + .error-message, [data-testid="email-input"] ~ .text-destructive').first();
this.phoneError = page.locator('[data-testid="phone-input"] + .error-message, [data-testid="phone-input"] ~ .text-destructive').first();
this.messageError = page.locator('[data-testid="message-input"] + .error-message, [data-testid="message-input"] ~ .text-destructive').first();
}
get breadcrumb(): Locator {
return this.page.locator('nav[aria-label="breadcrumb"]');
}
async navigateToContact(): Promise<void> {
await this.navigate(this.url);
}
async verifyBreadcrumb(): Promise<boolean> {
return await this.breadcrumb.isVisible();
}
async verifyPageHeader(): Promise<boolean> {
const header = await this.pageHeader.textContent();
return header?.includes('合作') || false;
}
async verifyContactForm(): Promise<boolean> {
return await this.contactForm.isVisible();
}
async verifyContactInfo(): Promise<boolean> {
return await this.contactInfoCard.isVisible();
}
async goto(): Promise<void> {
await this.navigate(this.url);
await this.waitForLoadState('networkidle');
}
async isLoaded(): Promise<boolean> {
try {
await this.pageHeader.waitFor({ state: 'visible', timeout: 5000 });
await this.contactForm.waitFor({ state: 'visible', timeout: 5000 });
return true;
} catch {
return false;
}
}
async waitForPageLoad(): Promise<void> {
await this.waitForLoadState('networkidle');
await this.pageHeader.waitFor({ state: 'visible' });
await this.contactForm.waitFor({ state: 'visible' });
}
async fillContactForm(data: ContactFormData): Promise<void> {
if (data.name) {
await this.nameInput.fill(data.name);
}
if (data.phone) {
await this.phoneInput.fill(data.phone);
}
if (data.email) {
await this.emailInput.fill(data.email);
}
if (data.subject) {
await this.subjectInput.fill(data.subject);
}
if (data.message) {
await this.messageInput.fill(data.message);
}
}
async submitForm(): Promise<void> {
await this.submitButton.click();
}
async fillAndSubmitForm(data: ContactFormData): Promise<void> {
console.log('Filling form with data:', data);
await this.fillContactForm(data);
console.log('Form filled, clicking submit button');
await this.submitForm();
console.log('Submit button clicked');
}
async isSuccessMessageVisible(): Promise<boolean> {
return await this.successMessage.isVisible();
}
async getSuccessMessageText(): Promise<string> {
return await this.successMessage.textContent() || '';
}
async isFormVisible(): Promise<boolean> {
return await this.contactForm.isVisible();
}
async isSubmitButtonEnabled(): Promise<boolean> {
return await this.submitButton.isEnabled();
}
async getSubmitButtonText(): Promise<string> {
return await this.submitButton.textContent() || '';
}
async isSubmitButtonLoading(): Promise<boolean> {
const text = await this.getSubmitButtonText();
return text.includes('发送中');
}
async getNameInputValue(): Promise<string> {
return await this.nameInput.inputValue();
}
async getPhoneInputValue(): Promise<string> {
return await this.phoneInput.inputValue();
}
async getEmailInputValue(): Promise<string> {
return await this.emailInput.inputValue();
}
async getSubjectInputValue(): Promise<string> {
return await this.subjectInput.inputValue();
}
async getMessageInputValue(): Promise<string> {
return await this.messageInput.inputValue();
}
async clearForm(): Promise<void> {
await this.nameInput.fill('');
await this.phoneInput.fill('');
await this.emailInput.fill('');
await this.subjectInput.fill('');
await this.messageInput.fill('');
}
async isContactInfoCardVisible(): Promise<boolean> {
return await this.contactInfoCard.isVisible();
}
async isWorkHoursCardVisible(): Promise<boolean> {
return await this.workHoursCard.isVisible();
}
async getContactInfoText(): Promise<string> {
return await this.contactInfoCard.textContent() || '';
}
async getWorkHoursText(): Promise<string> {
return await this.workHoursCard.textContent() || '';
}
async getAddress(): Promise<string> {
return await this.addressText.textContent() || '';
}
async getPhone(): Promise<string> {
return await this.phoneLink.textContent() || '';
}
async getEmail(): Promise<string> {
return await this.emailLink.textContent() || '';
}
async getPageTitle(): Promise<string> {
return await this.pageHeader.textContent() || '';
}
async getPageDescription(): Promise<string> {
return await this.pageDescription.textContent() || '';
}
async getBadgeText(): Promise<string> {
return await this.pageBadge.textContent() || '';
}
async isRequiredFieldVisible(fieldName: string): Promise<boolean> {
const label = this.page.locator(`label[for="${fieldName}"]`);
return await label.isVisible();
}
async isFieldRequired(fieldName: string): Promise<boolean> {
const label = this.page.locator(`label[for="${fieldName}"]`);
const text = await label.textContent();
return text?.includes('*') || false;
}
async getFieldPlaceholder(fieldName: string): Promise<string> {
const input = this.page.locator(`[name="${fieldName}"]`);
return await input.getAttribute('placeholder') || '';
}
async scrollToForm(): Promise<void> {
await this.contactForm.scrollIntoViewIfNeeded();
await this.page.waitForTimeout(500);
}
async takeScreenshotOfForm(filename: string): Promise<void> {
await this.contactForm.screenshot({ path: `test-results/screenshots/${filename}` });
}
async takeScreenshotOfSuccessMessage(filename: string): Promise<void> {
await this.successMessage.screenshot({ path: `test-results/screenshots/${filename}` });
}
async waitForFormSubmission(): Promise<void> {
await this.page.waitForTimeout(3000);
await this.page.waitForLoadState('networkidle');
await this.page.waitForTimeout(2000);
}
async isFormSubmitted(): Promise<boolean> {
const isSuccessVisible = await this.isSuccessMessageVisible();
console.log('Success message visible:', isSuccessVisible);
return isSuccessVisible;
}
async getFormValidationErrors(): Promise<string[]> {
const errors: string[] = [];
const requiredInputs = this.contactForm.locator('input[required], textarea[required]');
const count = await requiredInputs.count();
for (let i = 0; i < count; i++) {
const input = requiredInputs.nth(i);
const isValid = await input.evaluate(el => (el as HTMLInputElement).checkValidity());
if (!isValid) {
const name = await input.getAttribute('name');
errors.push(`${name} is invalid`);
}
}
return errors;
}
async isEmailValid(): Promise<boolean> {
return await this.emailInput.evaluate(el => (el as HTMLInputElement).checkValidity());
}
async isPhoneValid(): Promise<boolean> {
return await this.phoneInput.evaluate(el => (el as HTMLInputElement).checkValidity());
}
async focusOnField(fieldName: string): Promise<void> {
const input = this.page.locator(`[data-testid="${fieldName}-input"]`);
await input.focus();
}
async blurField(fieldName: string): Promise<void> {
const input = this.page.locator(`[data-testid="${fieldName}-input"]`);
await input.blur();
}
async typeInField(fieldName: string, text: string, options?: { delay?: number }): Promise<void> {
const input = this.page.locator(`[data-testid="${fieldName}-input"]`);
await input.type(text, options);
}
async clearField(fieldName: string): Promise<void> {
const input = this.page.locator(`[data-testid="${fieldName}-input"]`);
await input.fill('');
}
async isFieldVisible(fieldName: string): Promise<boolean> {
const input = this.page.locator(`[data-testid="${fieldName}-input"]`);
return await input.isVisible();
}
async isFieldEnabled(fieldName: string): Promise<boolean> {
const input = this.page.locator(`[data-testid="${fieldName}-input"]`);
return await input.isEnabled();
}
async getFieldAttribute(fieldName: string, attribute: string): Promise<string | null> {
const input = this.page.locator(`[data-testid="${fieldName}-input"]`);
return await input.getAttribute(attribute);
}
async getWorkHours(): Promise<{ day: string; hours: string }[]> {
const workHours: { day: string; hours: string }[] = [];
await this.page.waitForLoadState('networkidle');
const workHoursCard = this.page.locator('[data-testid="work-hours-card"]');
await workHoursCard.waitFor({ state: 'visible', timeout: 10000 });
const rows = workHoursCard.locator('[data-testid="work-hours-row"]');
const count = await rows.count();
for (let i = 0; i < count; i++) {
const row = rows.nth(i);
const day = await row.locator('span').first().textContent();
const hours = await row.locator('span').nth(1).textContent();
if (day && hours) {
workHours.push({ day: day.trim(), hours: hours.trim() });
}
}
return workHours;
}
async getNameError(): Promise<string> {
return await this.nameError.textContent() || '';
}
async getEmailError(): Promise<string> {
return await this.emailError.textContent() || '';
}
async getPhoneError(): Promise<string> {
return await this.phoneError.textContent() || '';
}
async getMessageError(): Promise<string> {
return await this.messageError.textContent() || '';
}
async isNameErrorVisible(): Promise<boolean> {
return await this.nameError.isVisible();
}
async isEmailErrorVisible(): Promise<boolean> {
return await this.emailError.isVisible();
}
async isPhoneErrorVisible(): Promise<boolean> {
return await this.phoneError.isVisible();
}
async isMessageErrorVisible(): Promise<boolean> {
return await this.messageError.isVisible();
}
async testXSSInjection(payload: string): Promise<void> {
await this.fillContactForm({
name: payload,
email: 'test@example.com',
phone: '13800138000',
message: payload,
});
await this.submitForm();
}
async testSQLInjection(payload: string): Promise<void> {
await this.fillContactForm({
name: payload,
email: payload,
phone: payload,
message: payload,
});
await this.submitForm();
}
async testPathTraversal(payload: string): Promise<void> {
await this.fillContactForm({
name: payload,
email: 'test@example.com',
phone: '13800138000',
message: payload,
});
await this.submitForm();
}
async verifyFormResponsiveLayout(viewport: { width: number; height: number }): Promise<{
isFormVisible: boolean;
isSubmitButtonVisible: boolean;
isContactInfoVisible: boolean;
}> {
await this.page.setViewportSize(viewport);
await this.waitForTimeout(500);
return {
isFormVisible: await this.isFormVisible(),
isSubmitButtonVisible: await this.isVisible(this.submitButton),
isContactInfoVisible: await this.isContactInfoCardVisible(),
};
}
async measureFormSubmissionPerformance(): Promise<{
fillTime: number;
submitTime: number;
totalTime: number;
}> {
const startTime = Date.now();
const data = {
name: '测试用户',
email: 'test@example.com',
phone: '13800138000',
message: '这是一条测试消息',
};
const fillStartTime = Date.now();
await this.fillContactForm(data);
const fillTime = Date.now() - fillStartTime;
const submitStartTime = Date.now();
await this.submitForm();
await this.waitForFormSubmission();
const submitTime = Date.now() - submitStartTime;
const totalTime = Date.now() - startTime;
return {
fillTime,
submitTime,
totalTime,
};
}
async getFormAccessibilityAttributes(): Promise<{
nameAriaLabel: string | null;
emailAriaLabel: string | null;
phoneAriaLabel: string | null;
messageAriaLabel: string | null;
submitAriaLabel: string | null;
}> {
return {
nameAriaLabel: await this.nameInput.getAttribute('aria-label'),
emailAriaLabel: await this.emailInput.getAttribute('aria-label'),
phoneAriaLabel: await this.phoneInput.getAttribute('aria-label'),
messageAriaLabel: await this.messageInput.getAttribute('aria-label'),
submitAriaLabel: await this.submitButton.getAttribute('aria-label'),
};
}
async verifyFormLabels(): Promise<{
nameLabel: string | null;
emailLabel: string | null;
phoneLabel: string | null;
messageLabel: string | null;
}> {
return {
nameLabel: await this.page.locator('label[for="name"]').textContent(),
emailLabel: await this.page.locator('label[for="email"]').textContent(),
phoneLabel: await this.page.locator('label[for="phone"]').textContent(),
messageLabel: await this.page.locator('label[for="message"]').textContent(),
};
}
async getFormInputTypes(): Promise<{
nameType: string | null;
emailType: string | null;
phoneType: string | null;
subjectType: string | null;
}> {
return {
nameType: await this.nameInput.getAttribute('type'),
emailType: await this.emailInput.getAttribute('type'),
phoneType: await this.phoneInput.getAttribute('type'),
subjectType: await this.subjectInput.getAttribute('type'),
};
}
async verifyRequiredFields(): Promise<{
nameRequired: boolean;
emailRequired: boolean;
phoneRequired: boolean;
messageRequired: boolean;
}> {
return {
nameRequired: await this.nameInput.getAttribute('required') !== null,
emailRequired: await this.emailInput.getAttribute('required') !== null,
phoneRequired: await this.phoneInput.getAttribute('required') !== null,
messageRequired: await this.messageInput.getAttribute('required') !== null,
};
}
async getFormAutocompleteAttributes(): Promise<{
nameAutocomplete: string | null;
emailAutocomplete: string | null;
phoneAutocomplete: string | null;
}> {
return {
nameAutocomplete: await this.nameInput.getAttribute('autocomplete'),
emailAutocomplete: await this.emailInput.getAttribute('autocomplete'),
phoneAutocomplete: await this.phoneInput.getAttribute('autocomplete'),
};
}
async verifyKeyboardNavigation(): Promise<void> {
await this.nameInput.focus();
await this.pressKey('Tab');
await this.pressKey('Tab');
await this.pressKey('Tab');
await this.pressKey('Tab');
await this.pressKey('Tab');
}
async isFormKeyboardAccessible(): Promise<boolean> {
try {
await this.verifyKeyboardNavigation();
return true;
} catch {
return false;
}
}
}
-531
View File
@@ -1,531 +0,0 @@
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { SmartWait } from '../utils/smart-wait';
export class HomePage extends BasePage {
readonly url: string;
private smartWait: SmartWait;
readonly header: Locator;
readonly logo: Locator;
readonly navigation: Locator;
readonly desktopNavigation: Locator;
readonly mobileNavigation: Locator;
readonly mobileMenuButton: Locator;
readonly consultButton: Locator;
readonly heroSection: Locator;
readonly servicesSection: Locator;
readonly productsSection: Locator;
readonly casesSection: Locator;
readonly aboutSection: Locator;
readonly newsSection: Locator;
readonly contactSection: Locator;
readonly footer: Locator;
constructor(page: Page) {
super(page);
this.url = '/';
this.smartWait = new SmartWait(page);
this.header = page.locator('header');
this.logo = page.locator('header img[alt*="四川睿新致远"]');
this.navigation = page.locator('nav');
this.desktopNavigation = page.locator('[data-testid="desktop-navigation"]');
this.mobileNavigation = page.locator('[data-testid="mobile-menu"]');
this.mobileMenuButton = page.getByRole('button', { name: /打开菜单|menu/i });
this.consultButton = page.locator('[data-testid="consult-button"]');
this.heroSection = page.locator('#home');
this.servicesSection = page.locator('#services');
this.productsSection = page.locator('#products');
this.casesSection = page.locator('#cases');
this.aboutSection = page.locator('#about');
this.newsSection = page.locator('#news');
this.contactSection = page.locator('#contact');
this.footer = page.locator('[data-testid="footer"]');
}
async getNavigationItemCount(): Promise<number> {
const isMobile = await this.mobileMenuButton.isVisible();
if (isMobile) {
await this.mobileMenuButton.click();
await this.mobileNavigation.waitFor({ state: 'visible' });
const count = await this.mobileNavigation.locator('a').count();
await this.mobileMenuButton.click();
return count;
} else {
return await this.desktopNavigation.locator('a').count();
}
}
async goto(): Promise<void> {
await this.navigate(this.url);
await this.smartWait.waitForPageReady();
}
async isLoaded(): Promise<boolean> {
try {
await this.smartWait.waitForElement(this.header, { state: 'visible', timeout: 5000 });
await this.smartWait.waitForElement(this.heroSection, { state: 'visible', timeout: 5000 });
return true;
} catch {
return false;
}
}
async waitForPageLoad(): Promise<void> {
await this.smartWait.waitForPageReady();
}
async getNavigationItems(): Promise<Locator[]> {
const isMobile = await this.mobileMenuButton.isVisible();
if (isMobile) {
await this.openMobileMenu();
return await this.mobileNavigation.locator('a').all();
} else {
return await this.desktopNavigation.locator('a').all();
}
}
async clickNavigationItem(label: string): Promise<void> {
const isMobile = await this.mobileMenuButton.isVisible();
if (isMobile) {
await this.openMobileMenu();
const navItem = this.mobileNavigation.locator('a').filter({ hasText: label }).first();
await navItem.waitFor({ state: 'visible', timeout: 5000 });
await navItem.click();
} else {
const navItem = this.desktopNavigation.locator('a').filter({ hasText: label }).first();
await navItem.waitFor({ state: 'visible', timeout: 5000 });
await navItem.click();
}
}
async openMobileMenu(): Promise<void> {
await super.openMobileMenu();
}
async closeMobileMenu(): Promise<void> {
await super.closeMobileMenu();
}
async scrollToSection(sectionId: string): Promise<void> {
const section = this.page.locator(`#${sectionId}`);
try {
await this.smartWait.waitForElement(section, { state: 'attached', timeout: 5000 });
await section.scrollIntoViewIfNeeded();
await this.smartWait.waitForAnimationFrame(2);
await this.smartWait.waitForElement(section, { state: 'visible', timeout: 5000 });
} catch (error) {
console.log(`区块 ${sectionId} 不存在或不可见,跳过滚动`);
}
}
async isSectionVisible(sectionId: string): Promise<boolean> {
const section = this.page.locator(`#${sectionId}`);
return await section.isVisible();
}
async getSectionText(sectionId: string): Promise<string> {
const section = this.page.locator(`#${sectionId}`);
return await section.textContent() || '';
}
async clickContactButton(): Promise<void> {
await this.page.locator('a:has-text("立即咨询")').first().click();
}
async isLogoVisible(): Promise<boolean> {
return await this.logo.isVisible();
}
async getLogoAltText(): Promise<string | null> {
return await this.logo.getAttribute('alt');
}
async isFooterVisible(): Promise<boolean> {
return await this.footer.isVisible();
}
async getFooterText(): Promise<string> {
return await this.footer.textContent() || '';
}
async waitForFooter(): Promise<void> {
await this.scrollToBottom();
await this.page.waitForLoadState('networkidle');
await this.footer.waitFor({ state: 'visible', timeout: 10000 });
}
async waitForHeroSection(): Promise<void> {
await this.heroSection.waitFor({ state: 'visible', timeout: 10000 });
}
async waitForServicesSection(): Promise<void> {
await this.scrollToSection('services');
await this.servicesSection.waitFor({ state: 'visible', timeout: 10000 });
}
async waitForProductsSection(): Promise<void> {
await this.scrollToSection('products');
await this.productsSection.waitFor({ state: 'visible', timeout: 10000 });
}
async waitForCasesSection(): Promise<void> {
await this.scrollToSection('cases');
await this.casesSection.waitFor({ state: 'visible', timeout: 10000 });
}
async waitForAboutSection(): Promise<void> {
await this.scrollToSection('about');
await this.aboutSection.waitFor({ state: 'visible', timeout: 10000 });
}
async waitForNewsSection(): Promise<void> {
await this.scrollToSection('news');
await this.newsSection.waitFor({ state: 'visible', timeout: 10000 });
}
async waitForContactSection(): Promise<void> {
await this.scrollToSection('contact');
await this.contactSection.waitFor({ state: 'visible', timeout: 10000 });
}
async scrollToBottom(): Promise<void> {
await super.scrollToBottom();
}
async scrollToTop(): Promise<void> {
await super.scrollToTop();
}
async getActiveNavigationItem(): Promise<string | null> {
const isMobile = await this.mobileMenuButton.isVisible();
let activeItem;
if (isMobile) {
await this.openMobileMenu();
activeItem = this.mobileNavigation.locator('a[aria-current="page"]');
} else {
activeItem = this.desktopNavigation.locator('a[aria-current="page"]');
}
if (await activeItem.count() > 0) {
return await activeItem.textContent();
}
return null;
}
async isNavigationItemActive(label: string): Promise<boolean> {
const isMobile = await this.mobileMenuButton.isVisible();
let item;
if (isMobile) {
await this.openMobileMenu();
item = this.mobileNavigation.locator(`a:has-text("${label}")`);
} else {
item = this.desktopNavigation.locator(`a:has-text("${label}")`);
}
const ariaCurrent = await item.getAttribute('aria-current');
return ariaCurrent === 'page';
}
async getAllSectionIds(): Promise<string[]> {
return await this.page.evaluate(() => {
const sections = document.querySelectorAll('section[id]');
return Array.from(sections).map(section => section.id);
});
}
async takeScreenshotOfSection(sectionId: string, filename: string): Promise<void> {
const section = this.page.locator(`#${sectionId}`);
await section.screenshot({ path: `test-results/screenshots/${filename}` });
}
async getHeroSectionTitle(): Promise<string> {
const title = this.heroSection.locator('h1, h2').first();
return await title.textContent() || '';
}
async getServicesSectionTitle(): Promise<string> {
const title = this.servicesSection.locator('h2').first();
return await title.textContent() || '';
}
async getProductsSectionTitle(): Promise<string> {
const title = this.productsSection.locator('h2').first();
return await title.textContent() || '';
}
async getCasesSectionTitle(): Promise<string> {
const title = this.casesSection.locator('h2').first();
return await title.textContent() || '';
}
async getAboutSectionTitle(): Promise<string> {
const title = this.aboutSection.locator('h2').first();
return await title.textContent() || '';
}
async getNewsSectionTitle(): Promise<string> {
const title = this.newsSection.locator('h2').first();
return await title.textContent() || '';
}
async getContactSectionTitle(): Promise<string> {
const title = this.contactSection.locator('h2').first();
return await title.textContent() || '';
}
async isHeaderSticky(): Promise<boolean> {
const isSticky = await this.header.evaluate(el => {
return window.getComputedStyle(el).position === 'fixed';
});
return isSticky;
}
async getHeaderBackgroundColor(): Promise<string> {
return await this.header.evaluate(el => {
return window.getComputedStyle(el).backgroundColor;
});
}
async isHeaderScrolled(): Promise<boolean> {
const hasShadow = await this.header.evaluate(el => {
return window.getComputedStyle(el).boxShadow !== 'none';
});
return hasShadow;
}
async getAllNavigationLabels(): Promise<string[]> {
const isMobile = await this.mobileMenuButton.isVisible().catch(() => false);
let items: Locator[];
if (isMobile) {
await this.openMobileMenu();
items = await this.mobileNavigation.locator('a').all();
} else {
items = await this.desktopNavigation.locator('a').all();
}
const labels: string[] = [];
for (const item of items) {
const text = await item.textContent();
if (text) labels.push(text);
}
if (isMobile) {
await this.closeMobileMenu();
}
return labels;
}
async getCompanyInfo(): Promise<{
name: string;
address: string;
phone: string;
email: string;
}> {
return {
name: '四川睿新致远科技有限公司',
address: '四川省成都市高新区天府大道中段1268号天府软件园E区1栋',
phone: '028-88888888',
email: 'contact@ruixin.com',
};
}
async getStatistics(): Promise<Array<{ label: string; value: string }>> {
const stats = this.page.locator('[class*="text-3xl"][class*="text-[#C41E3A]"]');
const count = await stats.count();
const result: Array<{ label: string; value: string }> = [];
for (let i = 0; i < count; i++) {
const stat = stats.nth(i);
const text = await stat.textContent();
if (text) {
const [label, value] = text.split('\n');
if (label && value) {
result.push({ label: label.trim(), value: value.trim() });
}
}
}
return result;
}
async getServices(): Promise<Array<{ title: string; description: string }>> {
const cards = this.servicesSection.locator('a[href^="/services/"]');
const count = await cards.count();
const result: Array<{ title: string; description: string }> = [];
for (let i = 0; i < count; i++) {
const card = cards.nth(i);
const title = await card.locator('h3').textContent();
const description = await card.locator('p').textContent();
if (title && description) {
result.push({ title: title.trim(), description: description.trim() });
}
}
return result;
}
async getProducts(): Promise<Array<{ title: string; description: string }>> {
const cards = this.productsSection.locator('a[href^="/products/"]');
const count = await cards.count();
const result: Array<{ title: string; description: string }> = [];
for (let i = 0; i < count; i++) {
const card = cards.nth(i);
const title = await card.locator('h3').textContent();
const description = await card.locator('p').textContent();
if (title && description) {
result.push({ title: title.trim(), description: description.trim() });
}
}
return result;
}
async getNews(): Promise<Array<{ title: string; date: string; summary: string }>> {
const cards = this.newsSection.locator('a[href^="/news/"]');
const count = await cards.count();
const result: Array<{ title: string; date: string; summary: string }> = [];
for (let i = 0; i < count; i++) {
const card = cards.nth(i);
const title = await card.locator('h3').textContent();
const date = await card.locator('[class*="text-sm"]').textContent();
const summary = await card.locator('p').textContent();
if (title && date && summary) {
result.push({
title: title.trim(),
date: date.trim(),
summary: summary.trim()
});
}
}
return result;
}
async measurePageLoadPerformance(): Promise<{
loadTime: number;
domContentLoaded: number;
firstPaint: number;
firstContentfulPaint: number;
}> {
return await this.measurePerformance();
}
async verifyResponsiveLayout(viewport: { width: number; height: number }): Promise<{
isHeaderVisible: boolean;
isHeroVisible: boolean;
isNavigationVisible: boolean;
isFooterVisible: boolean;
}> {
await this.page.setViewportSize(viewport);
await this.waitForTimeout(500);
const isMobile = await this.mobileMenuButton.isVisible();
let isNavigationVisible;
if (isMobile) {
isNavigationVisible = await this.mobileMenuButton.isVisible();
} else {
isNavigationVisible = await this.desktopNavigation.isVisible();
}
return {
isHeaderVisible: await this.header.isVisible(),
isHeroVisible: await this.heroSection.isVisible(),
isNavigationVisible,
isFooterVisible: await this.footer.isVisible(),
};
}
async verifyAccessibility(): Promise<{
hasAltText: boolean;
hasAriaLabels: boolean;
hasKeyboardNavigation: boolean;
}> {
const images = this.page.locator('img');
const imageCount = await images.count();
let hasAltText = true;
for (let i = 0; i < imageCount; i++) {
const alt = await images.nth(i).getAttribute('alt');
if (!alt) {
hasAltText = false;
break;
}
}
const interactiveElements = this.page.locator('button, a, input, select, textarea');
const interactiveCount = await interactiveElements.count();
let hasAriaLabels = true;
for (let i = 0; i < interactiveCount; i++) {
const element = interactiveElements.nth(i);
const ariaLabel = await element.getAttribute('aria-label');
const role = await element.getAttribute('role');
if (!ariaLabel && !role) {
hasAriaLabels = false;
break;
}
}
return {
hasAltText,
hasAriaLabels,
hasKeyboardNavigation: true,
};
}
async verifySmoothScroll(): Promise<boolean> {
const scrollBehavior = await this.page.evaluate(() => {
return window.getComputedStyle(document.documentElement).scrollBehavior;
});
return scrollBehavior === 'smooth';
}
async verifyStickyHeader(): Promise<boolean> {
await this.scrollToBottom();
const isSticky = await this.header.evaluate((el) => {
return window.getComputedStyle(el).position === 'fixed';
});
return isSticky;
}
async verifyMobileMenu(): Promise<boolean> {
await this.page.setViewportSize({ width: 375, height: 667 });
await this.waitForTimeout(500);
const isMobileMenuButtonVisible = await this.mobileMenuButton.isVisible();
await this.openMobileMenu();
const isMobileMenuVisible = await this.mobileMenu.isVisible();
return isMobileMenuButtonVisible && isMobileMenuVisible;
}
async verifyColorContrast(): Promise<boolean> {
const textElements = this.page.locator('p, h1, h2, h3, h4, h5, h6');
const count = await textElements.count();
let allValid = true;
for (let i = 0; i < count; i++) {
const element = textElements.nth(i);
const backgroundColor = await element.evaluate((el) => {
return window.getComputedStyle(el).backgroundColor;
});
const color = await element.evaluate((el) => {
return window.getComputedStyle(el).color;
});
if (backgroundColor === 'rgba(0, 0, 0, 0)' || color === 'rgba(0, 0, 0, 0)') {
continue;
}
allValid = true;
}
return allValid;
}
}
-91
View File
@@ -1,91 +0,0 @@
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class NewsPage extends BasePage {
readonly page: Page;
constructor(page: Page) {
super(page);
this.page = page;
}
get breadcrumb(): Locator {
return this.page.locator('nav[aria-label="breadcrumb"]');
}
get pageHeader(): Locator {
return this.page.locator('h1');
}
get newsCards(): Locator {
return this.page.locator('a[href^="/news/"]');
}
get categoryButtons(): Locator {
return this.page.locator('button:has-text("分类筛选")');
}
get searchInput(): Locator {
return this.page.locator('input[placeholder*="搜索"]');
}
get allCategoryButton(): Locator {
return this.categoryButtons.filter({ hasText: '全部' });
}
async navigateToNews(): Promise<void> {
await this.navigate('/news');
}
async verifyBreadcrumb(): Promise<boolean> {
return await this.breadcrumb.isVisible();
}
async verifyPageHeader(): Promise<boolean> {
const header = await this.pageHeader.textContent();
return header?.includes('新闻动态') || false;
}
async getNewsCount(): Promise<number> {
return await this.newsCards.count();
}
async clickNews(index: number): Promise<void> {
const cards = await this.newsCards.all();
const card = cards[index];
if (card) {
await card.click();
}
}
async selectCategory(category: string): Promise<void> {
const button = this.categoryButtons.filter({ hasText: category });
await button.click();
}
async searchNews(query: string): Promise<void> {
await this.searchInput.fill(query);
}
async clearSearch(): Promise<void> {
await this.searchInput.clear();
}
async getNewsTitles(): Promise<string[]> {
const titles = this.newsCards.locator('h3');
return await titles.allTextContents();
}
async getNewsCategories(): Promise<string[]> {
const categories = this.newsCards.locator('[class*="badge"]');
return await categories.allTextContents();
}
async verifyNoResults(): Promise<boolean> {
return await this.page.locator('text=没有找到相关新闻').isVisible();
}
async selectAllCategory(): Promise<void> {
await this.allCategoryButton.click();
}
}
-70
View File
@@ -1,70 +0,0 @@
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class ProductsPage extends BasePage {
readonly page: Page;
constructor(page: Page) {
super(page);
this.page = page;
}
get breadcrumb(): Locator {
return this.page.locator('nav[aria-label="breadcrumb"]');
}
get pageHeader(): Locator {
return this.page.locator('h1');
}
get productCards(): Locator {
return this.page.locator('a[href^="/products/"]');
}
get ctaSection(): Locator {
return this.page.locator('div:has(h2:has-text("需要定制化解决方案"))').first();
}
async navigateToProducts(): Promise<void> {
await this.navigate('/products');
}
async verifyBreadcrumb(): Promise<boolean> {
return await this.breadcrumb.isVisible();
}
async verifyPageHeader(): Promise<boolean> {
const header = await this.pageHeader.textContent();
return header?.includes('产品服务') || false;
}
async getProductCount(): Promise<number> {
return await this.productCards.count();
}
async clickProduct(index: number): Promise<void> {
const cards = await this.productCards.all();
const card = cards[index];
if (card) {
await card.click();
}
}
async verifyCTASection(): Promise<boolean> {
return await this.ctaSection.isVisible();
}
async scrollToCTASection(): Promise<void> {
await this.scrollToElement(this.ctaSection);
}
async getProductTitles(): Promise<string[]> {
const titles = this.productCards.locator('h3');
return await titles.allTextContents();
}
async getProductCategories(): Promise<string[]> {
const categories = this.productCards.locator('[class*="badge"]');
return await categories.allTextContents();
}
}
-81
View File
@@ -1,81 +0,0 @@
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class ServicesPage extends BasePage {
readonly page: Page;
constructor(page: Page) {
super(page);
this.page = page;
}
get breadcrumb(): Locator {
return this.page.locator('nav[aria-label="breadcrumb"]');
}
get pageHeader(): Locator {
return this.page.locator('h1');
}
get serviceCards(): Locator {
return this.page.locator('a[href^="/services/"]');
}
get categoryButtons(): Locator {
return this.page.locator('button:has-text("分类筛选")');
}
get searchInput(): Locator {
return this.page.locator('input[placeholder*="搜索"]');
}
get ctaSection(): Locator {
return this.page.locator('div:has(h2:has-text("准备开始您的数字化转型之旅"))').first();
}
async navigateToServices(): Promise<void> {
await this.navigate('/services');
}
async verifyBreadcrumb(): Promise<boolean> {
return await this.breadcrumb.isVisible();
}
async verifyPageHeader(): Promise<boolean> {
const header = await this.pageHeader.textContent();
return header?.includes('核心业务') || false;
}
async getServiceCount(): Promise<number> {
return await this.serviceCards.count();
}
async clickService(index: number): Promise<void> {
const cards = await this.serviceCards.all();
const card = cards[index];
if (card) {
await card.click();
}
}
async verifyCTASection(): Promise<boolean> {
return await this.ctaSection.isVisible();
}
async scrollToCTASection(): Promise<void> {
await this.scrollToElement(this.ctaSection);
}
async getServiceTitles(): Promise<string[]> {
const titles = this.serviceCards.locator('h3');
return await titles.allTextContents();
}
async searchServices(query: string): Promise<void> {
await this.searchInput.fill(query);
}
async clearSearch(): Promise<void> {
await this.searchInput.clear();
}
}
-82
View File
@@ -1,82 +0,0 @@
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class SolutionsPage extends BasePage {
readonly page: Page;
constructor(page: Page) {
super(page);
this.page = page;
}
get breadcrumb(): Locator {
return this.page.locator('nav[aria-label="breadcrumb"]');
}
get pageHeader(): Locator {
return this.page.locator('h1');
}
get modules(): Locator {
return this.page.locator('div[class*="from-[#FFFBF5]"], div[class*="from-white"]');
}
get consultingModule(): Locator {
return this.page.locator('div:has(h2:has-text("数字化转型咨询"))').first();
}
get technologyModule(): Locator {
return this.page.locator('div:has(h2:has-text("信息技术解决方案"))').first();
}
get partnershipModule(): Locator {
return this.page.locator('div:has(h2:has-text("长期陪跑服务"))').first();
}
get ctaSection(): Locator {
return this.page.locator('div:has(h2:has-text("准备开始您的数字化转型之旅"))').first();
}
async navigateToSolutions(): Promise<void> {
await this.navigate('/solutions');
}
async verifyBreadcrumb(): Promise<boolean> {
return await this.breadcrumb.isVisible();
}
async verifyPageHeader(): Promise<boolean> {
const header = await this.pageHeader.textContent();
return header?.includes('三种角色') || false;
}
async verifyAllModules(): Promise<boolean> {
const count = await this.page.locator('section, div:has(h2:has-text("模块"))').count();
return count >= 3;
}
async scrollToConsultingModule(): Promise<void> {
await this.scrollToElement(this.consultingModule);
}
async scrollToTechnologyModule(): Promise<void> {
await this.scrollToElement(this.technologyModule);
}
async scrollToPartnershipModule(): Promise<void> {
await this.scrollToElement(this.partnershipModule);
}
async verifyCTASection(): Promise<boolean> {
return await this.ctaSection.isVisible();
}
async scrollToCTASection(): Promise<void> {
await this.scrollToElement(this.ctaSection);
}
async getModuleTitles(): Promise<string[]> {
const titles = this.modules.locator('h2');
return await titles.allTextContents();
}
}
@@ -1,343 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('可访问性测试 @accessibility', () => {
test('页面应该有lang属性', async ({ page }) => {
await page.goto('http://localhost:3000');
const html = page.locator('html');
await expect(html).toHaveAttribute('lang', 'zh-CN');
});
test('页面应该有正确的标题层级', async ({ page }) => {
await page.goto('http://localhost:3000');
const headings = page.locator('h1, h2, h3, h4, h5, h6');
const count = await headings.count();
expect(count).toBeGreaterThan(0);
const firstHeading = headings.first();
const firstTag = await firstHeading.evaluate(el => el.tagName.toLowerCase());
expect(firstTag).toBe('h1');
});
test('所有图片应该有alt属性', async ({ page }) => {
await page.goto('http://localhost:3000');
const images = page.locator('img');
const count = await images.count();
for (let i = 0; i < count; i++) {
const img = images.nth(i);
const alt = await img.getAttribute('alt');
expect(alt).toBeTruthy();
expect(alt?.length).toBeGreaterThan(0);
}
});
test('表单输入应该有label', async ({ page }) => {
await page.goto('http://localhost:3000/contact');
await page.waitForLoadState('networkidle');
const inputs = page.locator('input:not([type="hidden"]):not([style*="display: none"]):not([tabindex="-1"]), textarea, select');
const count = await inputs.count();
console.log('找到的input数量:', count);
for (let i = 0; i < count; i++) {
const input = inputs.nth(i);
const inputId = await input.getAttribute('id');
const inputType = await input.getAttribute('type');
const inputDataTestId = await input.getAttribute('data-testid');
console.log(`检查输入 ${i}: id=${inputId}, type=${inputType}, data-testid=${inputDataTestId}`);
const hasLabel = await input.evaluate(el => {
const id = el.getAttribute('id');
const ariaLabel = el.getAttribute('aria-label');
const ariaLabelledBy = el.getAttribute('aria-labelledby');
const hasLabelFor = id && document.querySelector(`label[for="${id}"]`);
const hasParentLabel = el.closest('label');
return !!(ariaLabel || ariaLabelledBy || hasLabelFor || hasParentLabel);
});
console.log(`输入 ${i} hasLabel: ${hasLabel}`);
expect(hasLabel).toBeTruthy();
}
});
test('按钮应该有可访问的名称', async ({ page }) => {
await page.goto('http://localhost:3000');
const buttons = page.locator('button, [role="button"]');
const count = await buttons.count();
for (let i = 0; i < count; i++) {
const button = buttons.nth(i);
const hasAccessibleName = await button.evaluate(el => {
const text = el.textContent?.trim();
const ariaLabel = el.getAttribute('aria-label');
const ariaLabelledBy = el.getAttribute('aria-labelledby');
const title = el.getAttribute('title');
return !!(text || ariaLabel || ariaLabelledBy || title);
});
expect(hasAccessibleName).toBeTruthy();
}
});
test('链接应该有描述性文本', async ({ page }) => {
await page.goto('http://localhost:3000');
const links = page.locator('a[href]').first(10);
const count = await links.count();
for (let i = 0; i < count; i++) {
const link = links.nth(i);
const hasDescriptiveText = await link.evaluate(el => {
const text = el.textContent?.trim();
const ariaLabel = el.getAttribute('aria-label');
const title = el.getAttribute('title');
const hasImg = el.querySelector('img[alt]');
return !!(text || ariaLabel || title || hasImg);
});
expect(hasDescriptiveText).toBeTruthy();
}
});
test('焦点元素应该可见', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.waitForLoadState('networkidle');
const focusableElements = page.locator('a[href]:visible, button:visible, input:visible, textarea:visible, select:visible, [tabindex]:not([tabindex="-1"]):visible');
const count = await focusableElements.count();
for (let i = 0; i < Math.min(count, 10); i++) {
const element = focusableElements.nth(i);
await element.focus();
const isVisible = await element.evaluate(el => {
const style = window.getComputedStyle(el);
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0';
});
expect(isVisible).toBeTruthy();
}
});
test('应该可以通过键盘导航', async ({ page }) => {
await page.goto('http://localhost:3000');
const focusableElements = page.locator('a[href], button, input, textarea, select');
const count = await focusableElements.count();
if (count > 0) {
await page.keyboard.press('Tab');
const firstFocused = await page.evaluate(() => {
return document.activeElement?.tagName;
});
expect(['A', 'BUTTON', 'INPUT', 'TEXTAREA', 'SELECT']).toContain(firstFocused || '');
}
});
test('颜色对比度应该符合WCAG AA标准', async ({ page }) => {
await page.goto('http://localhost:3000');
const textElements = page.locator('p, h1, h2, h3, h4, h5, h6, span, div').first(20);
const count = await textElements.count();
for (let i = 0; i < count; i++) {
const element = textElements.nth(i);
const contrastRatio = await element.evaluate(el => {
const style = window.getComputedStyle(el);
const bgColor = style.backgroundColor;
const textColor = style.color;
if (bgColor === 'rgba(0, 0, 0, 0)' || bgColor === 'transparent') {
return 21;
}
const getLuminance = (color: string) => {
const rgb = color.match(/\d+/g);
if (!rgb || rgb.length < 3) return 0;
const [r, g, b] = rgb.map(Number).map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const l1 = getLuminance(textColor);
const l2 = getLuminance(bgColor);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
});
expect(contrastRatio).toBeGreaterThanOrEqual(4.5);
}
});
test('应该有跳过导航链接', async ({ page }) => {
await page.goto('http://localhost:3000');
const skipLink = page.locator('a[href^="#"][class*="skip"], a[href^="#main"], a[href^="#content"]');
const hasSkipLink = await skipLink.count() > 0;
if (hasSkipLink) {
await skipLink.first().click();
const target = await skipLink.first().getAttribute('href');
const targetElement = page.locator(target || '');
await expect(targetElement.first()).toBeVisible();
}
});
test('移动端菜单应该可以通过键盘关闭', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('http://localhost:3000');
const menuButton = page.locator('button[aria-label*="菜单"], button[aria-label*="打开"]');
await menuButton.click();
await page.waitForTimeout(500);
const mobileMenu = page.locator('[role="navigation"][aria-label*="移动端"]');
await expect(mobileMenu).toBeVisible();
await page.keyboard.press('Escape');
await page.waitForTimeout(200);
await expect(mobileMenu).not.toBeVisible();
await expect(menuButton).toHaveAttribute('aria-expanded', 'false');
});
test('表单错误应该与输入关联', async ({ page }) => {
await page.goto('http://localhost:3000/contact');
const submitButton = page.locator('button[type="submit"]');
await submitButton.click();
const requiredInputs = page.locator('input[required], textarea[required]');
const count = await requiredInputs.count();
for (let i = 0; i < count; i++) {
const input = requiredInputs.nth(i);
const hasError = await input.evaluate(el => {
const id = el.getAttribute('id');
const error = document.querySelector(`[role="alert"][for="${id}"], [aria-describedby*="${id}"]`);
return !!error;
});
if (hasError) {
const ariaDescribedBy = await input.getAttribute('aria-describedby');
expect(ariaDescribedBy).toBeTruthy();
}
}
});
test('ARIA标签应该正确使用', async ({ page }) => {
await page.goto('http://localhost:3000');
const ariaElements = page.locator('[aria-label], [aria-labelledby], [aria-describedby]');
const count = await ariaElements.count();
for (let i = 0; i < count; i++) {
const element = ariaElements.nth(i);
const isValidAria = await element.evaluate(el => {
const ariaLabel = el.getAttribute('aria-label');
const ariaLabelledBy = el.getAttribute('aria-labelledby');
const ariaDescribedBy = el.getAttribute('aria-describedby');
if (ariaLabel) return ariaLabel.trim().length > 0;
if (ariaLabelledBy) {
const referenced = document.getElementById(ariaLabelledBy);
return !!referenced;
}
if (ariaDescribedBy) {
const referenced = document.getElementById(ariaDescribedBy);
return !!referenced;
}
return true;
});
expect(isValidAria).toBeTruthy();
}
});
test('视频/音频应该有字幕', async ({ page }) => {
await page.goto('http://localhost:3000');
const mediaElements = page.locator('video, audio');
const count = await mediaElements.count();
if (count > 0) {
for (let i = 0; i < count; i++) {
const media = mediaElements.nth(i);
const hasCaptions = await media.evaluate(el => {
const tagName = el.tagName.toLowerCase();
if (tagName === 'video') {
return el.hasAttribute('crossorigin') ||
el.querySelector('track[kind="captions"], track[kind="subtitles"]');
}
return true;
});
expect(hasCaptions).toBeTruthy();
}
}
});
test('表格应该有正确的标题', async ({ page }) => {
await page.goto('http://localhost:3000');
const tables = page.locator('table');
const count = await tables.count();
if (count > 0) {
for (let i = 0; i < count; i++) {
const table = tables.nth(i);
const hasCaption = await table.evaluate(el => {
return !!el.querySelector('caption') ||
el.hasAttribute('aria-label') ||
el.hasAttribute('title');
});
expect(hasCaption).toBeTruthy();
}
}
});
test('模态对话框应该有正确的ARIA属性', async ({ page }) => {
await page.goto('http://localhost:3000');
const dialogs = page.locator('[role="dialog"], [role="alertdialog"]');
const count = await dialogs.count();
if (count > 0) {
for (let i = 0; i < count; i++) {
const dialog = dialogs.nth(i);
const hasModalAttributes = await dialog.evaluate(el => {
return el.hasAttribute('aria-modal') ||
el.hasAttribute('aria-labelledby') ||
el.hasAttribute('aria-label');
});
expect(hasModalAttributes).toBeTruthy();
}
}
});
});
@@ -1,280 +0,0 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility Tests (WCAG 2.1 AA)', () => {
test('home page should not have accessibility violations', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('contact page should not have accessibility violations', async ({ page }) => {
await page.goto('/contact');
await page.waitForLoadState('networkidle');
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('all form inputs should have associated labels', async ({ page }) => {
await page.goto('/contact');
await page.waitForLoadState('networkidle');
const inputs = await page.locator('input:not([type="hidden"]), textarea, select').all();
for (const input of inputs) {
const id = await input.getAttribute('id');
const ariaLabel = await input.getAttribute('aria-label');
const ariaLabelledBy = await input.getAttribute('aria-labelledby');
if (id) {
const label = page.locator(`label[for="${id}"]`);
const hasLabel = await label.count() > 0;
expect(hasLabel || ariaLabel || ariaLabelledBy).toBeTruthy();
} else {
expect(ariaLabel || ariaLabelledBy).toBeTruthy();
}
}
});
test('all images should have alt text', async ({ page }) => {
await page.goto('/');
const images = await page.locator('img').all();
for (const img of images) {
const alt = await img.getAttribute('alt');
const role = await img.getAttribute('role');
expect(alt !== null || role === 'presentation').toBeTruthy();
}
});
test('all buttons should have accessible names', async ({ page }) => {
await page.goto('/');
const buttons = await page.locator('button').all();
for (const button of buttons) {
const text = await button.textContent();
const ariaLabel = await button.getAttribute('aria-label');
const ariaLabelledBy = await button.getAttribute('aria-labelledby');
expect(text?.trim() || ariaLabel || ariaLabelledBy).toBeTruthy();
}
});
test('all links should have discernible text', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const links = await page.locator('a:visible').all();
expect(links.length).toBeGreaterThan(0);
let passedCount = 0;
for (const link of links) {
const text = await link.textContent();
const ariaLabel = await link.getAttribute('aria-label');
const title = await link.getAttribute('title');
if ((text && text.trim().length > 0) ||
(ariaLabel && ariaLabel.trim().length > 0) ||
(title && title.trim().length > 0)) {
passedCount++;
}
}
const passRate = links.length > 0 ? passedCount / links.length : 1;
expect(passRate).toBeGreaterThanOrEqual(0.95);
});
test('page should have proper heading hierarchy', async ({ page }) => {
await page.goto('/');
const h1Count = await page.locator('h1').count();
expect(h1Count).toBeGreaterThanOrEqual(1);
expect(h1Count).toBeLessThanOrEqual(2);
const headings = await page.locator('h1, h2, h3, h4, h5, h6').all();
let previousLevel = 0;
for (const heading of headings) {
const tagName = await heading.evaluate(el => el.tagName.toLowerCase());
const currentLevel = parseInt(tagName.replace('h', ''));
expect(currentLevel - previousLevel).toBeLessThanOrEqual(1);
previousLevel = currentLevel;
}
});
test('color contrast should meet WCAG AA standards', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze();
const contrastViolations = accessibilityScanResults.violations.filter(
v => v.id === 'color-contrast'
);
expect(contrastViolations).toEqual([]);
});
test('touch targets should be at least 44x44 pixels', async ({ page }) => {
await page.goto('/');
await page.setViewportSize({ width: 375, height: 667 });
await page.waitForLoadState('networkidle');
const buttons = await page.locator('button:visible, a:visible, input[type="button"]:visible, input[type="submit"]:visible').all();
expect(buttons.length).toBeGreaterThanOrEqual(0);
let passedCount = 0;
let totalCount = 0;
for (const button of buttons) {
try {
const box = await button.boundingBox();
if (box && box.width > 0 && box.height > 0) {
totalCount++;
if (box.width >= 44 && box.height >= 44) {
passedCount++;
}
}
} catch (e) {
continue;
}
}
if (totalCount > 0) {
const passRate = passedCount / totalCount;
expect(passRate).toBeGreaterThanOrEqual(0.7);
} else {
expect(true).toBeTruthy();
}
});
test('page should be fully navigable via keyboard', async ({ page }) => {
await page.goto('/');
const focusableElements = await page.locator(
'a[href], button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
).all();
for (let i = 0; i < Math.min(focusableElements.length, 20); i++) {
await page.keyboard.press('Tab');
const focusedElement = page.locator(':focus');
const isVisible = await focusedElement.isVisible();
expect(isVisible).toBeTruthy();
}
});
test('focus order should be logical', async ({ page }) => {
await page.goto('/');
const focusOrder: string[] = [];
for (let i = 0; i < 20; i++) {
await page.keyboard.press('Tab');
const focusedElement = page.locator(':focus');
const tagName = await focusedElement.evaluate(el => el.tagName.toLowerCase());
const text = await focusedElement.textContent();
focusOrder.push(`${tagName}${text ? `: ${text.substring(0, 20)}` : ''}`);
}
expect(focusOrder.length).toBeGreaterThan(0);
});
test('skip link should be present', async ({ page }) => {
await page.goto('/');
const skipLink = page.locator('a[href="#main"], a[href="#content"], a:has-text("Skip"), a:has-text("跳过")');
const skipLinkCount = await skipLink.count();
expect(skipLinkCount).toBeGreaterThanOrEqual(0);
});
test('form error messages should be associated with inputs', async ({ page }) => {
await page.goto('/contact');
await page.waitForLoadState('networkidle');
await page.fill('input[id="email"]', 'invalid-email');
await page.locator('input[id="email"]').blur();
await page.waitForTimeout(500);
const errorMessages = await page.locator('[role="alert"], .error, .error-message, [data-error], p[id*="error"]').all();
if (errorMessages.length > 0) {
expect(errorMessages.length).toBeGreaterThan(0);
} else {
const emailInput = page.locator('input[id="email"]');
const ariaInvalid = await emailInput.getAttribute('aria-invalid');
expect(['true', 'false', null]).toContain(ariaInvalid);
}
});
test('modals should trap focus', async ({ page }) => {
await page.goto('/');
await page.setViewportSize({ width: 375, height: 667 });
await page.waitForLoadState('networkidle');
const mobileMenuButton = page.locator('button[aria-label*="菜单"], button[aria-label*="menu"], button[aria-label*="打开菜单"]');
const buttonCount = await mobileMenuButton.count();
if (buttonCount > 0) {
const button = mobileMenuButton.first();
const isVisible = await button.isVisible();
if (isVisible) {
await button.click();
await page.waitForTimeout(500);
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab');
}
const focusedElement = page.locator(':focus');
const isInModal = await focusedElement.evaluate(el => {
let parent = el.parentElement;
while (parent) {
if (parent.getAttribute('role') === 'dialog' ||
parent.getAttribute('aria-modal') === 'true') {
return true;
}
parent = parent.parentElement;
}
return false;
});
expect(isInModal || await focusedElement.isVisible()).toBeTruthy();
}
}
});
test('pages should have descriptive titles', async ({ page }) => {
await page.goto('/');
const homeTitle = await page.title();
expect(homeTitle.length).toBeGreaterThan(0);
expect(homeTitle).not.toBe('Untitled');
await page.goto('/contact');
const contactTitle = await page.title();
expect(contactTitle.length).toBeGreaterThan(0);
expect(contactTitle).not.toBe('Untitled');
});
});
@@ -1,89 +0,0 @@
import { test, expect } from '../../fixtures/admin.fixture';
import { generateTestContent } from '../../data/admin-test-data';
test.describe('成功案例管理E2E测试', () => {
test('应该能够创建案例', async ({ page, adminContentPage }) => {
const caseData = generateTestContent('case');
await adminContentPage.goto();
await adminContentPage.createContent(caseData);
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
await adminContentPage.goto();
await adminContentPage.searchContent(caseData.title);
const caseCount = await adminContentPage.contentList.count();
expect(caseCount).toBeGreaterThan(0);
});
test('应该能够编辑案例', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
await adminContentPage.searchContent('测试案例');
const initialCount = await adminContentPage.contentList.count();
expect(initialCount).toBeGreaterThan(0, '测试数据未创建,请运行 npm run db:seed:test');
await adminContentPage.editContent(0);
const updatedTitle = '更新后的案例标题-' + Date.now();
await page.locator('input[name="title"]').fill(updatedTitle);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够删除案例', async ({ page, adminContentPage }) => {
const caseData = generateTestContent('case');
await adminContentPage.goto();
await adminContentPage.createContent(caseData);
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
await adminContentPage.goto();
await adminContentPage.searchContent(caseData.title);
const initialCount = await adminContentPage.contentList.count();
expect(initialCount).toBeGreaterThan(0, '测试数据未创建,请运行 npm run db:seed:test');
await adminContentPage.deleteContent(0);
await expect(adminContentPage.contentList).toHaveCount(initialCount - 1, { timeout: 5000 });
});
test('应该能够设置案例封面图', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
await adminContentPage.createButton.click();
await page.locator('select[name="type"]').selectOption('case');
await page.locator('input[name="title"]').fill('封面图测试案例-' + Date.now());
await page.locator('input[name="slug"]').fill('cover-test-case-' + Date.now());
const fileInput = page.locator('input[type="file"]');
if (await fileInput.count() > 0) {
await fileInput.setInputFiles({
name: 'test-cover.jpg',
mimeType: 'image/jpeg',
buffer: Buffer.from('test image content')
});
}
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够筛选案例类型', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
const typeFilter = page.locator('select').first();
await typeFilter.selectOption('case');
await page.waitForTimeout(1000);
const items = await adminContentPage.contentList.all();
for (const item of items) {
const typeBadge = await item.locator('span').first().textContent();
expect(typeBadge).toContain('案例');
}
});
});
-122
View File
@@ -1,122 +0,0 @@
import { test, expect } from '../../fixtures/admin.fixture';
import { generateTestContent } from '../../data/admin-test-data';
test.describe('新闻动态管理E2E测试', () => {
test('应该能够创建新闻', async ({ page, adminContentPage }) => {
const newsData = generateTestContent('news');
await adminContentPage.goto();
await adminContentPage.createContent(newsData);
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
await adminContentPage.goto();
await adminContentPage.searchContent(newsData.title);
const newsCount = await adminContentPage.contentList.count();
expect(newsCount).toBeGreaterThan(0);
});
test('应该能够发布新闻', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
await adminContentPage.createButton.click();
await page.locator('select[name="type"]').selectOption('news');
const newsTitle = '要发布的新闻-' + Date.now();
await page.locator('input[name="title"]').fill(newsTitle);
await page.locator('input[name="slug"]').fill('published-news-' + Date.now());
await page.getByRole('button', { name: /发布/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
await adminContentPage.goto();
await adminContentPage.searchContent(newsTitle);
const newsItem = adminContentPage.contentList.first();
const statusBadge = await newsItem.locator('span').nth(1).textContent();
expect(statusBadge).toContain('已发布');
});
test('应该能够将新闻设为草稿', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
await adminContentPage.searchContent('要发布的新闻');
const initialCount = await adminContentPage.contentList.count();
expect(initialCount).toBeGreaterThan(0, '测试数据未创建,请运行 npm run db:seed:test');
await adminContentPage.editContent(0);
await page.locator('select[name="status"]').selectOption('draft');
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
await adminContentPage.goto();
const newsItem = adminContentPage.contentList.first();
const statusBadge = await newsItem.locator('span').nth(1).textContent();
expect(statusBadge).toContain('草稿');
});
test('应该能够编辑新闻', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
await adminContentPage.searchContent('测试新闻');
const initialCount = await adminContentPage.contentList.count();
expect(initialCount).toBeGreaterThan(0, '测试数据未创建,请运行 npm run db:seed:test');
await adminContentPage.editContent(0);
const updatedTitle = '更新后的新闻标题-' + Date.now();
await page.locator('input[name="title"]').fill(updatedTitle);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够删除新闻', async ({ page, adminContentPage }) => {
const newsData = generateTestContent('news');
await adminContentPage.goto();
await adminContentPage.createContent(newsData);
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
await adminContentPage.goto();
await adminContentPage.searchContent(newsData.title);
const initialCount = await adminContentPage.contentList.count();
expect(initialCount).toBeGreaterThan(0, '测试数据未创建,请运行 npm run db:seed:test');
await adminContentPage.deleteContent(0);
await expect(adminContentPage.contentList).toHaveCount(initialCount - 1, { timeout: 5000 });
});
test('应该能够筛选新闻类型', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
const typeFilter = page.locator('select').first();
await typeFilter.selectOption('news');
await page.waitForTimeout(1000);
const items = await adminContentPage.contentList.all();
for (const item of items) {
const typeBadge = await item.locator('span').first().textContent();
expect(typeBadge).toContain('新闻');
}
});
test('应该能够按发布状态筛选新闻', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
const statusFilter = page.locator('select').nth(1);
await statusFilter.selectOption('draft');
await page.waitForTimeout(1000);
const items = await adminContentPage.contentList.all();
for (const item of items) {
const statusBadge = await item.locator('span').nth(1).textContent();
expect(statusBadge).toContain('草稿');
}
});
});
-68
View File
@@ -1,68 +0,0 @@
import { test, expect } from '../../fixtures/admin.fixture';
test.describe('权限控制E2E测试', () => {
test('管理员应该能够创建所有类型的内容', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
await expect(adminContentPage.createButton).toBeVisible();
const contentTypes = ['product', 'service', 'case', 'news'];
for (const type of contentTypes) {
await adminContentPage.createButton.click();
await page.locator('select[name="type"]').selectOption(type);
await page.locator('input[name="title"]').fill(`管理员创建的${type}-${Date.now()}`);
await page.locator('input[name="slug"]').fill(`admin-${type}-${Date.now()}`);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
await adminContentPage.goto();
}
});
test('编辑者应该能够创建内容但不能删除', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
await expect(adminContentPage.createButton).toBeVisible();
const contentData = {
type: 'product',
title: '编辑者创建的产品-' + Date.now(),
slug: 'editor-product-' + Date.now(),
excerpt: '编辑者创建的产品',
content: '<p>编辑者创建的产品内容</p>',
category: '软件产品',
tags: ['编辑者测试'],
status: 'draft',
};
await adminContentPage.createContent(contentData);
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
await adminContentPage.goto();
await adminContentPage.searchContent(contentData.title);
const deleteButton = page.locator('button').filter({ hasText: /删除/i });
await expect(deleteButton).toHaveCount(0);
});
test('查看者应该只能查看内容', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
await expect(adminContentPage.createButton).not.toBeVisible();
await expect(adminContentPage.contentList).toBeVisible();
const createButton = page.locator('button').filter({ hasText: /创建/i });
await expect(createButton).toHaveCount(0);
const deleteButtons = page.locator('button').filter({ hasText: /删除/i });
await expect(deleteButtons).toHaveCount(0);
});
test('未登录用户应该被重定向到登录页', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/admin/content');
await expect(page).toHaveURL(/\/admin\/login/);
});
});
@@ -1,97 +0,0 @@
import { test, expect } from '../../fixtures/admin.fixture';
import { generateTestContent } from '../../data/admin-test-data';
test.describe('产品服务管理E2E测试', () => {
test('应该能够创建产品', async ({ page, adminContentPage }) => {
const productData = generateTestContent('product');
await adminContentPage.goto();
await adminContentPage.createContent(productData);
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
await adminContentPage.goto();
await adminContentPage.searchContent(productData.title);
const productCount = await adminContentPage.contentList.count();
expect(productCount).toBeGreaterThan(0);
});
test('应该能够编辑产品', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
await adminContentPage.searchContent('测试产品');
const initialCount = await adminContentPage.contentList.count();
expect(initialCount).toBeGreaterThan(0, '测试数据未创建,请运行 npm run db:seed:test');
await adminContentPage.editContent(0);
const updatedTitle = '更新后的产品标题-' + Date.now();
await page.locator('input[name="title"]').fill(updatedTitle);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
await adminContentPage.goto();
await adminContentPage.searchContent(updatedTitle);
const foundCount = await adminContentPage.contentList.count();
expect(foundCount).toBeGreaterThan(0);
});
test('应该能够删除产品', async ({ page, adminContentPage }) => {
const productData = generateTestContent('product');
await adminContentPage.goto();
await adminContentPage.createContent(productData);
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
await adminContentPage.goto();
await adminContentPage.searchContent(productData.title);
const initialCount = await adminContentPage.contentList.count();
expect(initialCount).toBeGreaterThan(0, '测试数据未创建,请运行 npm run db:seed:test');
await adminContentPage.deleteContent(0);
await expect(adminContentPage.contentList).toHaveCount(initialCount - 1, { timeout: 5000 });
});
test('应该能够筛选产品类型', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
const typeFilter = page.locator('select').first();
await typeFilter.selectOption('product');
await page.waitForTimeout(1000);
const items = await adminContentPage.contentList.all();
for (const item of items) {
const typeBadge = await item.locator('span').first().textContent();
expect(typeBadge).toContain('产品');
}
});
test('应该能够按状态筛选产品', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
const statusFilter = page.locator('select').nth(1);
await statusFilter.selectOption('published');
await page.waitForTimeout(1000);
const items = await adminContentPage.contentList.all();
for (const item of items) {
const statusBadge = await item.locator('span').nth(1).textContent();
expect(statusBadge).toContain('已发布');
}
});
test('应该能够搜索产品', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
await adminContentPage.searchContent('产品');
await page.waitForTimeout(1000);
const itemCount = await adminContentPage.contentList.count();
expect(itemCount).toBeGreaterThanOrEqual(0);
});
});
@@ -1,317 +0,0 @@
import { test, expect } from '../../fixtures/admin.fixture';
test.describe('富文本编辑器E2E测试', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin/content/new');
await page.locator('select[name="type"]').selectOption('news');
});
test('应该能够输入文本内容', async ({ page }) => {
await page.locator('input[name="title"]').fill('富文本测试');
await page.locator('input[name="slug"]').fill('rich-text-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('这是富文本编辑器内容');
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够使用粗体格式', async ({ page }) => {
await page.locator('input[name="title"]').fill('粗体测试');
await page.locator('input[name="slug"]').fill('bold-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('粗体文本');
await page.keyboard.selectText('粗体文本');
await page.getByRole('button', { name: '粗体' }).click();
const boldButton = page.getByRole('button', { name: '粗体' });
await expect(boldButton).toHaveClass(/bg-gray-200/);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够使用斜体格式', async ({ page }) => {
await page.locator('input[name="title"]').fill('斜体测试');
await page.locator('input[name="slug"]').fill('italic-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('斜体文本');
await page.keyboard.selectText('斜体文本');
await page.getByRole('button', { name: '斜体' }).click();
const italicButton = page.getByRole('button', { name: '斜体' });
await expect(italicButton).toHaveClass(/bg-gray-200/);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够使用删除线格式', async ({ page }) => {
await page.locator('input[name="title"]').fill('删除线测试');
await page.locator('input[name="slug"]').fill('strikethrough-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('删除线文本');
await page.keyboard.selectText('删除线文本');
await page.getByRole('button', { name: '删除线' }).click();
const strikeButton = page.getByRole('button', { name: '删除线' });
await expect(strikeButton).toHaveClass(/bg-gray-200/);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够使用代码格式', async ({ page }) => {
await page.locator('input[name="title"]').fill('代码测试');
await page.locator('input[name="slug"]').fill('code-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('console.log("test")');
await page.keyboard.selectText('console.log("test")');
await page.getByRole('button', { name: '代码' }).click();
const codeButton = page.getByRole('button', { name: '代码' });
await expect(codeButton).toHaveClass(/bg-gray-200/);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够使用标题格式', async ({ page }) => {
await page.locator('input[name="title"]').fill('标题测试');
await page.locator('input[name="slug"]').fill('heading-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('标题1');
await page.keyboard.selectText('标题1');
await page.getByRole('button', { name: '标题 1' }).click();
const h1Button = page.getByRole('button', { name: '标题 1' });
await expect(h1Button).toHaveClass(/bg-gray-200/);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够使用标题2格式', async ({ page }) => {
await page.locator('input[name="title"]').fill('标题2测试');
await page.locator('input[name="slug"]').fill('heading2-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('标题2');
await page.keyboard.selectText('标题2');
await page.getByRole('button', { name: '标题 2' }).click();
const h2Button = page.getByRole('button', { name: '标题 2' });
await expect(h2Button).toHaveClass(/bg-gray-200/);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够使用标题3格式', async ({ page }) => {
await page.locator('input[name="title"]').fill('标题3测试');
await page.locator('input[name="slug"]').fill('heading3-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('标题3');
await page.keyboard.selectText('标题3');
await page.getByRole('button', { name: '标题 3' }).click();
const h3Button = page.getByRole('button', { name: '标题 3' });
await expect(h3Button).toHaveClass(/bg-gray-200/);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够使用无序列表', async ({ page }) => {
await page.locator('input[name="title"]').fill('无序列表测试');
await page.locator('input[name="slug"]').fill('bullet-list-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('列表项1');
await page.getByRole('button', { name: '无序列表' }).click();
const listButton = page.getByRole('button', { name: '无序列表' });
await expect(listButton).toHaveClass(/bg-gray-200/);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够使用有序列表', async ({ page }) => {
await page.locator('input[name="title"]').fill('有序列表测试');
await page.locator('input[name="slug"]').fill('ordered-list-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('列表项1');
await page.getByRole('button', { name: '有序列表' }).click();
const listButton = page.getByRole('button', { name: '有序列表' });
await expect(listButton).toHaveClass(/bg-gray-200/);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够使用引用格式', async ({ page }) => {
await page.locator('input[name="title"]').fill('引用测试');
await page.locator('input[name="slug"]').fill('quote-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('这是一段引用文字');
await page.getByRole('button', { name: '引用' }).click();
const quoteButton = page.getByRole('button', { name: '引用' });
await expect(quoteButton).toHaveClass(/bg-gray-200/);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够添加链接', async ({ page }) => {
await page.locator('input[name="title"]').fill('链接测试');
await page.locator('input[name="slug"]').fill('link-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('测试链接');
await page.keyboard.selectText('测试链接');
await page.getByRole('button', { name: '链接' }).click();
const linkInput = page.locator('input[type="url"]');
await expect(linkInput).toBeVisible();
await linkInput.fill('https://example.com');
await page.getByRole('button', { name: '确认' }).click();
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够撤销操作', async ({ page }) => {
await page.locator('input[name="title"]').fill('撤销测试');
await page.locator('input[name="slug"]').fill('undo-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('原始文本');
await page.keyboard.selectText('原始文本');
await page.getByRole('button', { name: '粗体' }).click();
const undoButton = page.getByRole('button', { name: '撤销' });
await expect(undoButton).toBeEnabled();
await undoButton.click();
const boldButton = page.getByRole('button', { name: '粗体' });
await expect(boldButton).not.toHaveClass(/bg-gray-200/);
});
test('应该能够重做操作', async ({ page }) => {
await page.locator('input[name="title"]').fill('重做测试');
await page.locator('input[name="slug"]').fill('redo-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('原始文本');
await page.keyboard.selectText('原始文本');
await page.getByRole('button', { name: '粗体' }).click();
const undoButton = page.getByRole('button', { name: '撤销' });
await undoButton.click();
const redoButton = page.getByRole('button', { name: '重做' });
await expect(redoButton).toBeEnabled();
await redoButton.click();
const boldButton = page.getByRole('button', { name: '粗体' });
await expect(boldButton).toHaveClass(/bg-gray-200/);
});
test('应该能够组合多种格式', async ({ page }) => {
await page.locator('input[name="title"]').fill('组合格式测试');
await page.locator('input[name="slug"]').fill('combined-format-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('粗体斜体文本');
await page.keyboard.selectText('粗体斜体文本');
await page.getByRole('button', { name: '粗体' }).click();
await page.getByRole('button', { name: '斜体' }).click();
const boldButton = page.getByRole('button', { name: '粗体' });
const italicButton = page.getByRole('button', { name: '斜体' });
await expect(boldButton).toHaveClass(/bg-gray-200/);
await expect(italicButton).toHaveClass(/bg-gray-200/);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够切换格式状态', async ({ page }) => {
await page.locator('input[name="title"]').fill('切换格式测试');
await page.locator('input[name="slug"]').fill('toggle-format-test');
const editor = page.locator('.ProseMirror');
await editor.waitFor({ state: 'visible', timeout: 10000 });
await editor.click();
await editor.fill('切换文本');
await page.keyboard.selectText('切换文本');
await page.getByRole('button', { name: '粗体' }).click();
const boldButton = page.getByRole('button', { name: '粗体' });
await expect(boldButton).toHaveClass(/bg-gray-200/);
await page.getByRole('button', { name: '粗体' }).click();
await expect(boldButton).not.toHaveClass(/bg-gray-200/);
});
});
@@ -1,67 +0,0 @@
import { test, expect } from '../../fixtures/admin.fixture';
import { generateTestContent } from '../../data/admin-test-data';
test.describe('服务管理E2E测试', () => {
test('应该能够创建服务', async ({ page, adminContentPage }) => {
const serviceData = generateTestContent('service');
await adminContentPage.goto();
await adminContentPage.createContent(serviceData);
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
await adminContentPage.goto();
await adminContentPage.searchContent(serviceData.title);
const serviceCount = await adminContentPage.contentList.count();
expect(serviceCount).toBeGreaterThan(0);
});
test('应该能够编辑服务', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
await adminContentPage.searchContent('测试服务');
const initialCount = await adminContentPage.contentList.count();
expect(initialCount).toBeGreaterThan(0, '测试数据未创建,请运行 npm run db:seed:test');
await adminContentPage.editContent(0);
const updatedTitle = '更新后的服务标题-' + Date.now();
await page.locator('input[name="title"]').fill(updatedTitle);
await page.getByRole('button', { name: /保存/i }).click();
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
});
test('应该能够删除服务', async ({ page, adminContentPage }) => {
const serviceData = generateTestContent('service');
await adminContentPage.goto();
await adminContentPage.createContent(serviceData);
await expect(page.locator('text=保存成功')).toBeVisible({ timeout: 5000 });
await adminContentPage.goto();
await adminContentPage.searchContent(serviceData.title);
const initialCount = await adminContentPage.contentList.count();
expect(initialCount).toBeGreaterThan(0, '测试数据未创建,请运行 npm run db:seed:test');
await adminContentPage.deleteContent(0);
await expect(adminContentPage.contentList).toHaveCount(initialCount - 1, { timeout: 5000 });
});
test('应该能够筛选服务类型', async ({ page, adminContentPage }) => {
await adminContentPage.goto();
const typeFilter = page.locator('select').first();
await typeFilter.selectOption('service');
await page.waitForTimeout(1000);
const items = await adminContentPage.contentList.all();
for (const item of items) {
const typeBadge = await item.locator('span').first().textContent();
expect(typeBadge).toContain('服务');
}
});
});
-202
View File
@@ -1,202 +0,0 @@
import { test, expect } from '../../fixtures/base.fixture';
test.describe('管理后台API测试 @api @critical', () => {
test.describe('内容管理API', () => {
test('应该能够获取内容列表', async ({ request }) => {
const response = await request.get('/api/admin/content');
expect(response.status()).toBe(200);
const data = await response.json();
expect(data).toHaveProperty('items');
expect(Array.isArray(data.items)).toBe(true);
});
test('应该能够创建新内容', async ({ request }) => {
const response = await request.post('/api/admin/content', {
data: {
type: 'news',
title: '测试新闻',
slug: `test-news-${Date.now()}`,
content: '这是测试内容',
status: 'draft',
},
});
expect([200, 201]).toContain(response.status());
const data = await response.json();
expect(data).toHaveProperty('id');
expect(data.title).toBe('测试新闻');
});
test('应该拒绝重复的slug', async ({ request }) => {
const slug = `duplicate-test-${Date.now()}`;
await request.post('/api/admin/content', {
data: {
type: 'news',
title: '测试1',
slug,
status: 'draft',
},
});
const response = await request.post('/api/admin/content', {
data: {
type: 'news',
title: '测试2',
slug,
status: 'draft',
},
});
expect(response.status()).toBe(400);
});
});
test.describe('用户管理API', () => {
test('应该能够获取用户列表', async ({ request }) => {
const response = await request.get('/api/admin/users');
expect(response.status()).toBe(200);
const data = await response.json();
expect(data).toHaveProperty('users');
expect(Array.isArray(data.users)).toBe(true);
});
test('应该能够创建新用户', async ({ request }) => {
const response = await request.post('/api/admin/users', {
data: {
email: `test-${Date.now()}@example.com`,
name: '测试用户',
password: 'Test123!@#',
role: 'viewer',
},
});
expect([200, 201]).toContain(response.status());
const data = await response.json();
expect(data).toHaveProperty('user');
expect(data.user).toHaveProperty('id');
expect(data.user.email).toContain('@example.com');
});
});
test.describe('审计日志API', () => {
test('应该能够获取审计日志列表', async ({ request }) => {
const response = await request.get('/api/admin/logs');
expect(response.status()).toBe(200);
const data = await response.json();
expect(data).toHaveProperty('logs');
expect(Array.isArray(data.logs)).toBe(true);
});
test('应该支持分页查询', async ({ request }) => {
const response = await request.get('/api/admin/logs?page=1&limit=10');
expect(response.status()).toBe(200);
const data = await response.json();
expect(data).toHaveProperty('page');
expect(data).toHaveProperty('limit');
expect(data).toHaveProperty('total');
expect(data).toHaveProperty('totalPages');
});
test('应该支持按操作类型筛选', async ({ request }) => {
const response = await request.get('/api/admin/logs?action=create');
expect(response.status()).toBe(200);
const data = await response.json();
expect(Array.isArray(data.logs)).toBe(true);
if (data.logs.length > 0) {
data.logs.forEach((log: any) => {
expect(log.action).toBe('create');
});
}
});
});
test.describe('文件上传API', () => {
test('应该能够上传图片', async ({ request }) => {
const response = await request.post('/api/admin/upload', {
multipart: {
file: {
name: 'test.jpg',
mimeType: 'image/jpeg',
buffer: Buffer.from('fake-image-content'),
},
type: 'image',
},
});
if (response.status() === 200) {
const data = await response.json();
expect(data.success).toBe(true);
expect(data.file).toHaveProperty('url');
}
});
test('应该拒绝过大的文件', async ({ request }) => {
const largeBuffer = Buffer.alloc(20 * 1024 * 1024);
const response = await request.post('/api/admin/upload', {
multipart: {
file: {
name: 'large.jpg',
mimeType: 'image/jpeg',
buffer: largeBuffer,
},
type: 'image',
},
});
expect(response.status()).toBe(400);
});
test('应该拒绝不允许的文件类型', async ({ request }) => {
const response = await request.post('/api/admin/upload', {
multipart: {
file: {
name: 'malicious.exe',
mimeType: 'application/octet-stream',
buffer: Buffer.from('malicious-content'),
},
type: 'document',
},
});
expect(response.status()).toBe(400);
});
});
test.describe('配置管理API', () => {
test('应该能够获取配置列表', async ({ request }) => {
const response = await request.get('/api/admin/config');
expect(response.status()).toBe(200);
const data = await response.json();
expect(data).toBeDefined();
});
test('应该能够更新配置', async ({ request }) => {
const response = await request.post('/api/admin/config', {
data: {
key: 'site_name',
value: 'Novalon官网',
category: 'basic',
},
});
expect([200, 201]).toContain(response.status());
});
});
});
@@ -1,106 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('前后台配置并发修改测试', () => {
test('多个管理员同时修改同一配置', async ({ page, context }) => {
const adminPage1 = await context.newPage();
const adminPage2 = await context.newPage();
await Promise.all([
adminPage1.goto('/admin/settings'),
adminPage2.goto('/admin/settings')
]);
await Promise.all([
adminPage1.waitForLoadState('networkidle'),
adminPage2.waitForLoadState('networkidle')
]);
const servicesConfig1 = adminPage1.locator('text=feature_services').locator('..').locator('..');
const servicesConfig2 = adminPage2.locator('text=feature_services').locator('..').locator('..');
await Promise.all([
servicesConfig1.locator('input[type="checkbox"]').first().check(),
servicesConfig2.locator('input[type="checkbox"]').first().uncheck()
]);
await Promise.all([
servicesConfig1.locator('button:has-text("保存")').click(),
servicesConfig2.locator('button:has-text("保存")').click()
]);
await Promise.all([
adminPage1.waitForSelector('text=保存成功', { timeout: 10000 }),
adminPage2.waitForSelector('text=保存成功', { timeout: 10000 })
]);
await page.goto('/');
await page.waitForLoadState('networkidle');
const checkbox = servicesConfig1.locator('input[type="checkbox"]').first();
const isChecked = await checkbox.isChecked();
expect(isChecked).toBe(true);
});
test('快速连续修改配置', async ({ page, context }) => {
const adminPage = await context.newPage();
await adminPage.goto('/admin/settings');
await adminPage.waitForLoadState('networkidle');
const newsConfig = adminPage.locator('text=feature_news').locator('..').locator('..');
const displayCountInput = newsConfig.locator('input[type="number"]');
for (let i = 1; i <= 3; i++) {
await displayCountInput.fill(String(i));
await newsConfig.locator('button:has-text("保存")').click();
await adminPage.waitForSelector('text=保存成功', { timeout: 5000 });
await page.goto('/');
await page.waitForLoadState('networkidle');
const newsCards = page.locator('#news .card');
const count = await newsCards.count();
expect(count).toBe(i);
await adminPage.goto('/admin/settings');
await adminPage.waitForLoadState('networkidle');
}
});
test('同时修改多个不同配置', async ({ page, context }) => {
const adminPage = await context.newPage();
await adminPage.goto('/admin/settings');
await adminPage.waitForLoadState('networkidle');
const servicesConfig = adminPage.locator('text=feature_services').locator('..').locator('..');
const productsConfig = adminPage.locator('text=feature_products').locator('..').locator('..');
const newsConfig = adminPage.locator('text=feature_news').locator('..').locator('..');
await Promise.all([
servicesConfig.locator('input[type="checkbox"]').first().check(),
productsConfig.locator('input[type="checkbox"]').first().uncheck(),
newsConfig.locator('input[type="checkbox"]').first().check()
]);
await Promise.all([
servicesConfig.locator('button:has-text("保存")').click(),
productsConfig.locator('button:has-text("保存")').click(),
newsConfig.locator('button:has-text("保存")').click()
]);
await Promise.all([
servicesConfig.waitForSelector('text=保存成功', { timeout: 10000 }),
productsConfig.waitForSelector('text=保存成功', { timeout: 10000 }),
newsConfig.waitForSelector('text=保存成功', { timeout: 10000 })
]);
await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(page.locator('#services')).toBeVisible();
await expect(page.locator('#products')).not.toBeVisible();
await expect(page.locator('#news')).toBeVisible();
});
});

Some files were not shown because too many files have changed in this diff Show More