diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b756445 --- /dev/null +++ b/.dockerignore @@ -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 \ No newline at end of file diff --git a/.env.example b/.env.example index 1801f5c..c56574a 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ed1b2cb..ff6b296 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md index 8f00b29..2b2f568 100644 --- a/README.md +++ b/README.md @@ -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/` 目录: diff --git a/docker-compose.yml b/docker-compose.yml index 1795df8..0f0c344 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 \ No newline at end of file + driver: bridge diff --git a/docs/CDN_CONFIGURATION.md b/docs/CDN_CONFIGURATION.md new file mode 100644 index 0000000..df0fd71 --- /dev/null +++ b/docs/CDN_CONFIGURATION.md @@ -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' + +Hero +``` + +### 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' + + + + +``` + +## 监控与告警 + +### 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/) diff --git a/docs/CDN_QUICK_START.md b/docs/CDN_QUICK_START.md new file mode 100644 index 0000000..6fbb5db --- /dev/null +++ b/docs/CDN_QUICK_START.md @@ -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) diff --git a/e2e-tests/.env.example b/e2e-tests/.env.example deleted file mode 100644 index 7cc40f6..0000000 --- a/e2e-tests/.env.example +++ /dev/null @@ -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= diff --git a/e2e-tests/README.md b/e2e-tests/README.md deleted file mode 100644 index 9429f24..0000000 --- a/e2e-tests/README.md +++ /dev/null @@ -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 许可证开源。 diff --git a/e2e-tests/config/__init__.py b/e2e-tests/config/__init__.py deleted file mode 100644 index e274b01..0000000 --- a/e2e-tests/config/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Config模块 diff --git a/e2e-tests/config/__pycache__/__init__.cpython-313.pyc b/e2e-tests/config/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index b3e5fa4..0000000 Binary files a/e2e-tests/config/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/e2e-tests/config/__pycache__/browsers.cpython-313.pyc b/e2e-tests/config/__pycache__/browsers.cpython-313.pyc deleted file mode 100644 index 74cd3b0..0000000 Binary files a/e2e-tests/config/__pycache__/browsers.cpython-313.pyc and /dev/null differ diff --git a/e2e-tests/config/__pycache__/settings.cpython-313.pyc b/e2e-tests/config/__pycache__/settings.cpython-313.pyc deleted file mode 100644 index c97ad04..0000000 Binary files a/e2e-tests/config/__pycache__/settings.cpython-313.pyc and /dev/null differ diff --git a/e2e-tests/config/browsers.py b/e2e-tests/config/browsers.py deleted file mode 100644 index 9394a3e..0000000 --- a/e2e-tests/config/browsers.py +++ /dev/null @@ -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() diff --git a/e2e-tests/config/settings.py b/e2e-tests/config/settings.py deleted file mode 100644 index 36e7538..0000000 --- a/e2e-tests/config/settings.py +++ /dev/null @@ -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 diff --git a/e2e-tests/pages/__init__.py b/e2e-tests/pages/__init__.py deleted file mode 100644 index 7b70d65..0000000 --- a/e2e-tests/pages/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Pages模块 diff --git a/e2e-tests/pages/__pycache__/__init__.cpython-313.pyc b/e2e-tests/pages/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 5a4c205..0000000 Binary files a/e2e-tests/pages/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/e2e-tests/pages/__pycache__/base_page.cpython-313.pyc b/e2e-tests/pages/__pycache__/base_page.cpython-313.pyc deleted file mode 100644 index 7be046f..0000000 Binary files a/e2e-tests/pages/__pycache__/base_page.cpython-313.pyc and /dev/null differ diff --git a/e2e-tests/pages/__pycache__/contact_page.cpython-313.pyc b/e2e-tests/pages/__pycache__/contact_page.cpython-313.pyc deleted file mode 100644 index 79cbc65..0000000 Binary files a/e2e-tests/pages/__pycache__/contact_page.cpython-313.pyc and /dev/null differ diff --git a/e2e-tests/pages/__pycache__/home_page.cpython-313.pyc b/e2e-tests/pages/__pycache__/home_page.cpython-313.pyc deleted file mode 100644 index 4124287..0000000 Binary files a/e2e-tests/pages/__pycache__/home_page.cpython-313.pyc and /dev/null differ diff --git a/e2e-tests/pages/base_page.py b/e2e-tests/pages/base_page.py deleted file mode 100644 index 9056eb9..0000000 --- a/e2e-tests/pages/base_page.py +++ /dev/null @@ -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() diff --git a/e2e-tests/pages/contact_page.py b/e2e-tests/pages/contact_page.py deleted file mode 100644 index b7580ee..0000000 --- a/e2e-tests/pages/contact_page.py +++ /dev/null @@ -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 diff --git a/e2e-tests/pages/home_page.py b/e2e-tests/pages/home_page.py deleted file mode 100644 index 6f852cd..0000000 --- a/e2e-tests/pages/home_page.py +++ /dev/null @@ -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 diff --git a/e2e-tests/pyproject.toml b/e2e-tests/pyproject.toml deleted file mode 100644 index 4e873ad..0000000 --- a/e2e-tests/pyproject.toml +++ /dev/null @@ -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" -] diff --git a/e2e-tests/pytest.ini b/e2e-tests/pytest.ini deleted file mode 100644 index 774fc5c..0000000 --- a/e2e-tests/pytest.ini +++ /dev/null @@ -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 diff --git a/e2e-tests/requirements.txt b/e2e-tests/requirements.txt deleted file mode 100644 index 5f3f8c4..0000000 --- a/e2e-tests/requirements.txt +++ /dev/null @@ -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 diff --git a/e2e-tests/scripts/__init__.py b/e2e-tests/scripts/__init__.py deleted file mode 100644 index a7fb4c3..0000000 --- a/e2e-tests/scripts/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Scripts模块 diff --git a/e2e-tests/scripts/ci_test.py b/e2e-tests/scripts/ci_test.py deleted file mode 100644 index 6a0db1a..0000000 --- a/e2e-tests/scripts/ci_test.py +++ /dev/null @@ -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() diff --git a/e2e-tests/scripts/run_tests.py b/e2e-tests/scripts/run_tests.py deleted file mode 100644 index 929e903..0000000 --- a/e2e-tests/scripts/run_tests.py +++ /dev/null @@ -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() diff --git a/e2e-tests/tests/__init__.py b/e2e-tests/tests/__init__.py deleted file mode 100644 index 5b433b9..0000000 --- a/e2e-tests/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Tests模块 diff --git a/e2e-tests/tests/__pycache__/__init__.cpython-313.pyc b/e2e-tests/tests/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index a85b8fb..0000000 Binary files a/e2e-tests/tests/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/e2e-tests/tests/__pycache__/conftest.cpython-313-pytest-8.3.3.pyc b/e2e-tests/tests/__pycache__/conftest.cpython-313-pytest-8.3.3.pyc deleted file mode 100644 index 9fb19e2..0000000 Binary files a/e2e-tests/tests/__pycache__/conftest.cpython-313-pytest-8.3.3.pyc and /dev/null differ diff --git a/e2e-tests/tests/__pycache__/conftest.cpython-313.pyc b/e2e-tests/tests/__pycache__/conftest.cpython-313.pyc deleted file mode 100644 index a130af8..0000000 Binary files a/e2e-tests/tests/__pycache__/conftest.cpython-313.pyc and /dev/null differ diff --git a/e2e-tests/tests/__pycache__/test_contact_form.cpython-313-pytest-8.3.3.pyc b/e2e-tests/tests/__pycache__/test_contact_form.cpython-313-pytest-8.3.3.pyc deleted file mode 100644 index f8823d0..0000000 Binary files a/e2e-tests/tests/__pycache__/test_contact_form.cpython-313-pytest-8.3.3.pyc and /dev/null differ diff --git a/e2e-tests/tests/__pycache__/test_home_page.cpython-313-pytest-8.3.3.pyc b/e2e-tests/tests/__pycache__/test_home_page.cpython-313-pytest-8.3.3.pyc deleted file mode 100644 index ce62f32..0000000 Binary files a/e2e-tests/tests/__pycache__/test_home_page.cpython-313-pytest-8.3.3.pyc and /dev/null differ diff --git a/e2e-tests/tests/__pycache__/test_navigation.cpython-313-pytest-8.3.3.pyc b/e2e-tests/tests/__pycache__/test_navigation.cpython-313-pytest-8.3.3.pyc deleted file mode 100644 index 2f41ec6..0000000 Binary files a/e2e-tests/tests/__pycache__/test_navigation.cpython-313-pytest-8.3.3.pyc and /dev/null differ diff --git a/e2e-tests/tests/__pycache__/test_performance.cpython-313-pytest-8.3.3.pyc b/e2e-tests/tests/__pycache__/test_performance.cpython-313-pytest-8.3.3.pyc deleted file mode 100644 index d5d2786..0000000 Binary files a/e2e-tests/tests/__pycache__/test_performance.cpython-313-pytest-8.3.3.pyc and /dev/null differ diff --git a/e2e-tests/tests/__pycache__/test_responsive.cpython-313-pytest-8.3.3.pyc b/e2e-tests/tests/__pycache__/test_responsive.cpython-313-pytest-8.3.3.pyc deleted file mode 100644 index dd13960..0000000 Binary files a/e2e-tests/tests/__pycache__/test_responsive.cpython-313-pytest-8.3.3.pyc and /dev/null differ diff --git a/e2e-tests/tests/conftest.py b/e2e-tests/tests/conftest.py deleted file mode 100644 index 3268aed..0000000 --- a/e2e-tests/tests/conftest.py +++ /dev/null @@ -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) diff --git a/e2e-tests/tests/test_contact_form.py b/e2e-tests/tests/test_contact_form.py deleted file mode 100644 index d172562..0000000 --- a/e2e-tests/tests/test_contact_form.py +++ /dev/null @@ -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) diff --git a/e2e-tests/tests/test_home_page.py b/e2e-tests/tests/test_home_page.py deleted file mode 100644 index d7e260f..0000000 --- a/e2e-tests/tests/test_home_page.py +++ /dev/null @@ -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)}个" diff --git a/e2e-tests/tests/test_navigation.py b/e2e-tests/tests/test_navigation.py deleted file mode 100644 index 32f13a9..0000000 --- a/e2e-tests/tests/test_navigation.py +++ /dev/null @@ -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按钮,跳过测试") diff --git a/e2e-tests/tests/test_performance.py b/e2e-tests/tests/test_performance.py deleted file mode 100644 index 5dcb0c4..0000000 --- a/e2e-tests/tests/test_performance.py +++ /dev/null @@ -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秒阈值" diff --git a/e2e-tests/tests/test_responsive.py b/e2e-tests/tests/test_responsive.py deleted file mode 100644 index b8f42e3..0000000 --- a/e2e-tests/tests/test_responsive.py +++ /dev/null @@ -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("✅ 打印样式应用完成") diff --git a/e2e-tests/utils/__init__.py b/e2e-tests/utils/__init__.py deleted file mode 100644 index eae7b09..0000000 --- a/e2e-tests/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Utils模块 diff --git a/e2e-tests/utils/__pycache__/__init__.cpython-313.pyc b/e2e-tests/utils/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 9addb92..0000000 Binary files a/e2e-tests/utils/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/e2e-tests/utils/__pycache__/data_generator.cpython-313.pyc b/e2e-tests/utils/__pycache__/data_generator.cpython-313.pyc deleted file mode 100644 index e67c733..0000000 Binary files a/e2e-tests/utils/__pycache__/data_generator.cpython-313.pyc and /dev/null differ diff --git a/e2e-tests/utils/__pycache__/helpers.cpython-313.pyc b/e2e-tests/utils/__pycache__/helpers.cpython-313.pyc deleted file mode 100644 index 6e9af39..0000000 Binary files a/e2e-tests/utils/__pycache__/helpers.cpython-313.pyc and /dev/null differ diff --git a/e2e-tests/utils/__pycache__/logger.cpython-313.pyc b/e2e-tests/utils/__pycache__/logger.cpython-313.pyc deleted file mode 100644 index 6021124..0000000 Binary files a/e2e-tests/utils/__pycache__/logger.cpython-313.pyc and /dev/null differ diff --git a/e2e-tests/utils/__pycache__/report_generator.cpython-313.pyc b/e2e-tests/utils/__pycache__/report_generator.cpython-313.pyc deleted file mode 100644 index d5776d6..0000000 Binary files a/e2e-tests/utils/__pycache__/report_generator.cpython-313.pyc and /dev/null differ diff --git a/e2e-tests/utils/data_generator.py b/e2e-tests/utils/data_generator.py deleted file mode 100644 index 78a696d..0000000 --- a/e2e-tests/utils/data_generator.py +++ /dev/null @@ -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 diff --git a/e2e-tests/utils/helpers.py b/e2e-tests/utils/helpers.py deleted file mode 100644 index d8ddf3d..0000000 --- a/e2e-tests/utils/helpers.py +++ /dev/null @@ -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() diff --git a/e2e-tests/utils/logger.py b/e2e-tests/utils/logger.py deleted file mode 100644 index 789751c..0000000 --- a/e2e-tests/utils/logger.py +++ /dev/null @@ -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() diff --git a/e2e-tests/utils/report_generator.py b/e2e-tests/utils/report_generator.py deleted file mode 100644 index aac8c23..0000000 --- a/e2e-tests/utils/report_generator.py +++ /dev/null @@ -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() diff --git a/e2e/.env.example b/e2e/.env.example deleted file mode 100644 index 0c13dd2..0000000 --- a/e2e/.env.example +++ /dev/null @@ -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 diff --git a/e2e/MIGRATION.md b/e2e/MIGRATION.md deleted file mode 100644 index eb17448..0000000 --- a/e2e/MIGRATION.md +++ /dev/null @@ -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) - -## 问题反馈 -如有测试相关问题,请联系开发团队。 diff --git a/e2e/analyze-results.js b/e2e/analyze-results.js deleted file mode 100644 index 11f3e10..0000000 --- a/e2e/analyze-results.js +++ /dev/null @@ -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}%`); - }); \ No newline at end of file diff --git a/e2e/coverage-reporter.js b/e2e/coverage-reporter.js deleted file mode 100644 index 3c160c6..0000000 --- a/e2e/coverage-reporter.js +++ /dev/null @@ -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(); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts deleted file mode 100644 index b3db0bc..0000000 --- a/e2e/global-setup.ts +++ /dev/null @@ -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; diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts deleted file mode 100644 index a591550..0000000 --- a/e2e/global-teardown.ts +++ /dev/null @@ -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('✅ 历史记录完成'); -} \ No newline at end of file diff --git a/e2e/package-lock.json b/e2e/package-lock.json deleted file mode 100644 index 4152b46..0000000 --- a/e2e/package-lock.json +++ /dev/null @@ -1,7164 +0,0 @@ -{ - "name": "e2e-tests", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "e2e-tests", - "version": "1.0.0", - "hasInstallScript": true, - "dependencies": { - "@sentry/nextjs": "^10.42.0" - }, - "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" - } - }, - "node_modules/@axe-core/playwright": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz", - "integrity": "sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "axe-core": "~4.11.1" - }, - "peerDependencies": { - "playwright-core": ">= 1.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", - "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", - "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-syntax-jsx": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", - "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", - "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-react": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", - "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-transform-react-display-name": "^7.28.0", - "@babel/plugin-transform-react-jsx": "^7.27.1", - "@babel/plugin-transform-react-jsx-development": "^7.27.1", - "@babel/plugin-transform-react-pure-annotations": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@fastify/otel": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.16.0.tgz", - "integrity": "sha512-2304BdM5Q/kUvQC9qJO1KZq3Zn1WWsw+WWkVmFEaj1UE2hEIiuFqrPeglQOwEtw/ftngisqfQ3v70TWMmwhhHA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.208.0", - "@opentelemetry/semantic-conventions": "^1.28.0", - "minimatch": "^10.0.3" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/api-logs": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", - "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/core": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", - "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/instrumentation": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", - "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "import-in-the-middle": "^2.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@fastify/otel/node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", - "license": "MIT" - }, - "node_modules/@fastify/otel/node_modules/import-in-the-middle": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", - "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - } - }, - "node_modules/@fastify/otel/node_modules/require-in-the-middle": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", - "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "module-details-from-path": "^1.0.3" - }, - "engines": { - "node": ">=9.3.0 || >=8.10.0 <9.0.0" - } - }, - "node_modules/@formatjs/ecma402-abstract": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", - "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/intl-localematcher": "0.6.2", - "decimal.js": "^10.4.3", - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/fast-memoize": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", - "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", - "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "@formatjs/icu-skeleton-parser": "1.8.16", - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.16", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", - "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/intl-localematcher": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", - "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@next/env": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", - "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", - "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", - "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.28.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", - "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.46.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.46.1.tgz", - "integrity": "sha512-AyXVnlCf/xV3K/rNumzKxZqsULyITJH6OVLiW6730JPRqWA7Zc9bvYoVNpN6iOpTU8CasH34SU/ksVJmObFibQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-connect": { - "version": "0.43.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.43.1.tgz", - "integrity": "sha512-ht7YGWQuV5BopMcw5Q2hXn3I8eG8TH0J/kc/GMcW4CuNTgiP6wCu44BOnucJWL3CmFWaRHI//vWyAhaC8BwePw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/connect": "3.4.38" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-dataloader": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.16.1.tgz", - "integrity": "sha512-K/qU4CjnzOpNkkKO4DfCLSQshejRNAJtd4esgigo/50nxCB6XCyi1dhAblUHM9jG5dRm8eu0FB+t87nIo99LYQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-express": { - "version": "0.47.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.47.1.tgz", - "integrity": "sha512-QNXPTWteDclR2B4pDFpz0TNghgB33UMjUt14B+BZPmtH1MwUFAfLHBaP5If0Z5NZC+jaH8oF2glgYjrmhZWmSw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-fs": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.19.1.tgz", - "integrity": "sha512-6g0FhB3B9UobAR60BGTcXg4IHZ6aaYJzp0Ki5FhnxyAPt8Ns+9SSvgcrnsN2eGmk3RWG5vYycUGOEApycQL24A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-generic-pool": { - "version": "0.43.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.43.1.tgz", - "integrity": "sha512-M6qGYsp1cURtvVLGDrPPZemMFEbuMmCXgQYTReC/IbimV5sGrLBjB+/hANUpRZjX67nGLdKSVLZuQQAiNz+sww==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-graphql": { - "version": "0.47.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.47.1.tgz", - "integrity": "sha512-EGQRWMGqwiuVma8ZLAZnExQ7sBvbOx0N/AE/nlafISPs8S+QtXX+Viy6dcQwVWwYHQPAcuY3bFt3xgoAwb4ZNQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-hapi": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.45.2.tgz", - "integrity": "sha512-7Ehow/7Wp3aoyCrZwQpU7a2CnoMq0XhIcioFuKjBb0PLYfBfmTsFTUyatlHu0fRxhwcRsSQRTvEhmZu8CppBpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-http": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.57.2.tgz", - "integrity": "sha512-1Uz5iJ9ZAlFOiPuwYg29Bf7bJJc/GeoeJIFKJYQf67nTVKFe8RHbEtxgkOmK4UGZNHKXcpW4P8cWBYzBn1USpg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "1.30.1", - "@opentelemetry/instrumentation": "0.57.2", - "@opentelemetry/semantic-conventions": "1.28.0", - "forwarded-parse": "2.1.2", - "semver": "^7.5.2" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", - "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation-ioredis": { - "version": "0.47.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.47.1.tgz", - "integrity": "sha512-OtFGSN+kgk/aoKgdkKQnBsQFDiG8WdCxu+UrHr0bXScdAmtSzLSraLo7wFIb25RVHfRWvzI5kZomqJYEg/l1iA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-kafkajs": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.7.1.tgz", - "integrity": "sha512-OtjaKs8H7oysfErajdYr1yuWSjMAectT7Dwr+axIoZqT9lmEOkD/H/3rgAs8h/NIuEi2imSXD+vL4MZtOuJfqQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.44.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.44.1.tgz", - "integrity": "sha512-U4dQxkNhvPexffjEmGwCq68FuftFK15JgUF05y/HlK3M6W/G2iEaACIfXdSnwVNe9Qh0sPfw8LbOPxrWzGWGMQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-koa": { - "version": "0.47.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.47.1.tgz", - "integrity": "sha512-l/c+Z9F86cOiPJUllUCt09v+kICKvT+Vg1vOAJHtHPsJIzurGayucfCMq2acd/A/yxeNWunl9d9eqZ0G+XiI6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.44.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.44.1.tgz", - "integrity": "sha512-5MPkYCvG2yw7WONEjYj5lr5JFehTobW7wX+ZUFy81oF2lr9IPfZk9qO+FTaM0bGEiymwfLwKe6jE15nHn1nmHg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongodb": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.52.0.tgz", - "integrity": "sha512-1xmAqOtRUQGR7QfJFfGV/M2kC7wmI2WgZdpru8hJl3S0r4hW0n3OQpEHlSGXJAaNFyvT+ilnwkT+g5L4ljHR6g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongoose": { - "version": "0.46.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.46.1.tgz", - "integrity": "sha512-3kINtW1LUTPkiXFRSSBmva1SXzS/72we/jL22N+BnF3DFcoewkdkHPYOIdAAk9gSicJ4d5Ojtt1/HeibEc5OQg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql": { - "version": "0.45.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.45.1.tgz", - "integrity": "sha512-TKp4hQ8iKQsY7vnp/j0yJJ4ZsP109Ht6l4RHTj0lNEG1TfgTrIH5vJMbgmoYXWzNHAqBH2e7fncN12p3BP8LFg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/mysql": "2.15.26" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.45.2.tgz", - "integrity": "sha512-h6Ad60FjCYdJZ5DTz1Lk2VmQsShiViKe0G7sYikb0GHI0NVvApp2XQNRHNjEMz87roFttGPLHOYVPlfy+yVIhQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@opentelemetry/sql-common": "^0.40.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.51.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.51.1.tgz", - "integrity": "sha512-QxgjSrxyWZc7Vk+qGSfsejPVFL1AgAJdSBMYZdDUbwg730D09ub3PXScB9d04vIqPriZ+0dqzjmQx0yWKiCi2Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^1.26.0", - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@opentelemetry/sql-common": "^0.40.1", - "@types/pg": "8.6.1", - "@types/pg-pool": "2.0.6" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-redis": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.59.0.tgz", - "integrity": "sha512-JKv1KDDYA2chJ1PC3pLP+Q9ISMQk6h5ey+99mB57/ARk0vQPGZTTEb4h4/JlcEpy7AYT8HIGv7X6l+br03Neeg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/redis-common": "^0.38.2", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-redis-4": { - "version": "0.46.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.46.1.tgz", - "integrity": "sha512-UMqleEoabYMsWoTkqyt9WAzXwZ4BlFZHO40wr3d5ZvtjKCHlD4YXLm+6OLCeIi/HkX7EXvQaz8gtAwkwwSEvcQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-redis/node_modules/@opentelemetry/api-logs": { - "version": "0.211.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", - "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/instrumentation-redis/node_modules/@opentelemetry/instrumentation": { - "version": "0.211.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", - "integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.211.0", - "import-in-the-middle": "^2.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-redis/node_modules/@opentelemetry/redis-common": { - "version": "0.38.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", - "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - } - }, - "node_modules/@opentelemetry/instrumentation-redis/node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", - "license": "MIT" - }, - "node_modules/@opentelemetry/instrumentation-redis/node_modules/import-in-the-middle": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", - "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - } - }, - "node_modules/@opentelemetry/instrumentation-redis/node_modules/require-in-the-middle": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", - "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "module-details-from-path": "^1.0.3" - }, - "engines": { - "node": ">=9.3.0 || >=8.10.0 <9.0.0" - } - }, - "node_modules/@opentelemetry/instrumentation-tedious": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.18.1.tgz", - "integrity": "sha512-5Cuy/nj0HBaH+ZJ4leuD7RjgvA844aY2WW+B5uLcWtxGjRZl3MNLuxnNg5DYWZNPO+NafSSnra0q49KWAHsKBg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/tedious": "^4.0.14" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-undici": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.10.1.tgz", - "integrity": "sha512-rkOGikPEyRpMCmNu9AQuV5dtRlDmJp2dK5sw8roVshAGoB6hH/3QjDtRhdwd75SsJwgynWUNRUYe0wAkTo16tQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.7.0" - } - }, - "node_modules/@opentelemetry/redis-common": { - "version": "0.36.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.36.2.tgz", - "integrity": "sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", - "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "1.30.1", - "@opentelemetry/semantic-conventions": "1.28.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", - "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz", - "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "1.30.1", - "@opentelemetry/resources": "1.30.1", - "@opentelemetry/semantic-conventions": "1.28.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", - "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", - "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/sql-common": { - "version": "0.40.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.40.1.tgz", - "integrity": "sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^1.1.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0" - } - }, - "node_modules/@paulirish/trace_engine": { - "version": "0.0.61", - "resolved": "https://registry.npmjs.org/@paulirish/trace_engine/-/trace_engine-0.0.61.tgz", - "integrity": "sha512-/O08DwmUqIlJjUSPSZbNF8lWnlxaMsIOV6sS+uDKCxBd5i1psAmjEoG3JAqR6+nHD8X+YY474NW7SxUH/K+/kQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "legacy-javascript": "latest", - "third-party-web": "latest" - } - }, - "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@prisma/instrumentation": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.11.1.tgz", - "integrity": "sha512-mrZOev24EDhnefmnZX7WVVT7v+r9LttPRqf54ONvj6re4XMF7wFTpK2tLJi4XHB7fFp/6xhYbgRel8YV7gQiyA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.8" - } - }, - "node_modules/@puppeteer/browsers": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", - "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.4.3", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.7.4", - "tar-fs": "^3.1.1", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@rollup/plugin-commonjs": { - "version": "28.0.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz", - "integrity": "sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==", - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "fdir": "^6.2.0", - "is-reference": "1.2.1", - "magic-string": "^0.30.3", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=16.0.0 || 14 >= 14.17" - }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sentry-internal/browser-utils": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.42.0.tgz", - "integrity": "sha512-HCEICKvepxN4/6NYfnMMMlppcSwIEwtS66X6d1/mwaHdi2ivw0uGl52p7Nfhda/lIJArbrkWprxl0WcjZajhQA==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.42.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/browser-utils/node_modules/@sentry/core": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.42.0.tgz", - "integrity": "sha512-L4rMrXMqUKBanpjpMT+TuAVk6xAijz6AWM6RiEYpohAr7SGcCEc1/T0+Ep1eLV8+pwWacfU27OvELIyNeOnGzA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/feedback": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.42.0.tgz", - "integrity": "sha512-lpPcHsog10MVYFTWE0Pf8vQRqQWwZHJpkVl2FEb9/HDdHFyTBUhCVoWo1KyKaG7GJl9AVKMAg7bp9SSNArhFNQ==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.42.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/feedback/node_modules/@sentry/core": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.42.0.tgz", - "integrity": "sha512-L4rMrXMqUKBanpjpMT+TuAVk6xAijz6AWM6RiEYpohAr7SGcCEc1/T0+Ep1eLV8+pwWacfU27OvELIyNeOnGzA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.42.0.tgz", - "integrity": "sha512-Zh3EoaH39x2lqVY1YyVB2vJEyCIrT+YLUQxYl1yvP0MJgLxaR6akVjkgxbSUJahan4cX5DxpZiEHfzdlWnYPyQ==", - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "10.42.0", - "@sentry/core": "10.42.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay-canvas": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.42.0.tgz", - "integrity": "sha512-am3m1Fj8ihoPfoYo41Qq4KeCAAICn4bySso8Oepu9dMNe9Lcnsf+reMRS2qxTPg3pZDc4JEMOcLyNCcgnAfrHw==", - "license": "MIT", - "dependencies": { - "@sentry-internal/replay": "10.42.0", - "@sentry/core": "10.42.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay-canvas/node_modules/@sentry/core": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.42.0.tgz", - "integrity": "sha512-L4rMrXMqUKBanpjpMT+TuAVk6xAijz6AWM6RiEYpohAr7SGcCEc1/T0+Ep1eLV8+pwWacfU27OvELIyNeOnGzA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay/node_modules/@sentry/core": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.42.0.tgz", - "integrity": "sha512-L4rMrXMqUKBanpjpMT+TuAVk6xAijz6AWM6RiEYpohAr7SGcCEc1/T0+Ep1eLV8+pwWacfU27OvELIyNeOnGzA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/babel-plugin-component-annotate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.1.1.tgz", - "integrity": "sha512-x2wEpBHwsTyTF2rWsLKJlzrRF1TTIGOfX+ngdE+Yd5DBkoS58HwQv824QOviPGQRla4/ypISqAXzjdDPL/zalg==", - "license": "MIT", - "engines": { - "node": ">= 18" - } - }, - "node_modules/@sentry/browser": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.42.0.tgz", - "integrity": "sha512-iXxYjXNEBwY1MH4lDSDZZUNjzPJDK7/YLwVIJq/3iBYpIQVIhaJsoJnf3clx9+NfJ8QFKyKfcvgae61zm+hgTA==", - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "10.42.0", - "@sentry-internal/feedback": "10.42.0", - "@sentry-internal/replay": "10.42.0", - "@sentry-internal/replay-canvas": "10.42.0", - "@sentry/core": "10.42.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/browser/node_modules/@sentry/core": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.42.0.tgz", - "integrity": "sha512-L4rMrXMqUKBanpjpMT+TuAVk6xAijz6AWM6RiEYpohAr7SGcCEc1/T0+Ep1eLV8+pwWacfU27OvELIyNeOnGzA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/bundler-plugin-core": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.1.1.tgz", - "integrity": "sha512-F+itpwR9DyQR7gEkrXd2tigREPTvtF5lC8qu6e4anxXYRTui1+dVR0fXNwjpyAZMhIesLfXRN7WY7ggdj7hi0Q==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.18.5", - "@sentry/babel-plugin-component-annotate": "5.1.1", - "@sentry/cli": "^2.58.5", - "dotenv": "^16.3.1", - "find-up": "^5.0.0", - "glob": "^13.0.6", - "magic-string": "~0.30.8" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@sentry/cli": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.5.tgz", - "integrity": "sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg==", - "hasInstallScript": true, - "license": "FSL-1.1-MIT", - "dependencies": { - "https-proxy-agent": "^5.0.0", - "node-fetch": "^2.6.7", - "progress": "^2.0.3", - "proxy-from-env": "^1.1.0", - "which": "^2.0.2" - }, - "bin": { - "sentry-cli": "bin/sentry-cli" - }, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@sentry/cli-darwin": "2.58.5", - "@sentry/cli-linux-arm": "2.58.5", - "@sentry/cli-linux-arm64": "2.58.5", - "@sentry/cli-linux-i686": "2.58.5", - "@sentry/cli-linux-x64": "2.58.5", - "@sentry/cli-win32-arm64": "2.58.5", - "@sentry/cli-win32-i686": "2.58.5", - "@sentry/cli-win32-x64": "2.58.5" - } - }, - "node_modules/@sentry/cli-darwin": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.5.tgz", - "integrity": "sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ==", - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-arm": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.5.tgz", - "integrity": "sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw==", - "cpu": [ - "arm" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-arm64": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.5.tgz", - "integrity": "sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ==", - "cpu": [ - "arm64" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-i686": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.5.tgz", - "integrity": "sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw==", - "cpu": [ - "x86", - "ia32" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-x64": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.5.tgz", - "integrity": "sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g==", - "cpu": [ - "x64" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-win32-arm64": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.5.tgz", - "integrity": "sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA==", - "cpu": [ - "arm64" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-win32-i686": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.5.tgz", - "integrity": "sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g==", - "cpu": [ - "x86", - "ia32" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-win32-x64": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.5.tgz", - "integrity": "sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg==", - "cpu": [ - "x64" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/@sentry/cli/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@sentry/core": { - "version": "9.47.1", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.47.1.tgz", - "integrity": "sha512-KX62+qIt4xgy8eHKHiikfhz2p5fOciXd0Cl+dNzhgPFq8klq4MGMNaf148GB3M/vBqP4nw/eFvRMAayFCgdRQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/nextjs": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-10.42.0.tgz", - "integrity": "sha512-4YcVwicZLQWCNXMRSmtg0q68cqhttwhUqcvTe0aYg4YkQIDQKzVOYVU7/js9kSK1PFe9gFdaUxgboBYBp2evDg==", - "license": "MIT", - "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/semantic-conventions": "^1.37.0", - "@rollup/plugin-commonjs": "28.0.1", - "@sentry-internal/browser-utils": "10.42.0", - "@sentry/bundler-plugin-core": "^5.1.0", - "@sentry/core": "10.42.0", - "@sentry/node": "10.42.0", - "@sentry/opentelemetry": "10.42.0", - "@sentry/react": "10.42.0", - "@sentry/vercel-edge": "10.42.0", - "@sentry/webpack-plugin": "^5.1.0", - "rollup": "^4.35.0", - "stacktrace-parser": "^0.1.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "next": "^13.2.0 || ^14.0 || ^15.0.0-rc.0 || ^16.0.0-0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/api-logs": { - "version": "0.211.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", - "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/context-async-hooks": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.0.tgz", - "integrity": "sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/core": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", - "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation": { - "version": "0.211.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", - "integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.211.0", - "import-in-the-middle": "^2.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.58.0.tgz", - "integrity": "sha512-fjpQtH18J6GxzUZ+cwNhWUpb71u+DzT7rFkg5pLssDGaEber91Y2WNGdpVpwGivfEluMlNMZumzjEqfg8DeKXQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-connect": { - "version": "0.54.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.54.0.tgz", - "integrity": "sha512-43RmbhUhqt3uuPnc16cX6NsxEASEtn8z/cYV8Zpt6EP4p2h9s4FNuJ4Q9BbEQ2C0YlCCB/2crO1ruVz/hWt8fA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/connect": "3.4.38" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-dataloader": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.28.0.tgz", - "integrity": "sha512-ExXGBp0sUj8yhm6Znhf9jmuOaGDsYfDES3gswZnKr4MCqoBWQdEFn6EoDdt5u+RdbxQER+t43FoUihEfTSqsjA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-express": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.59.0.tgz", - "integrity": "sha512-pMKV/qnHiW/Q6pmbKkxt0eIhuNEtvJ7sUAyee192HErlr+a1Jx+FZ3WjfmzhQL1geewyGEiPGkmjjAgNY8TgDA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-fs": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.30.0.tgz", - "integrity": "sha512-n3Cf8YhG7reaj5dncGlRIU7iT40bxPOjsBEA5Bc1a1g6e9Qvb+JFJ7SEiMlPbUw4PBmxE3h40ltE8LZ3zVt6OA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-generic-pool": { - "version": "0.54.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.54.0.tgz", - "integrity": "sha512-8dXMBzzmEdXfH/wjuRvcJnUFeWzZHUnExkmFJ2uPfa31wmpyBCMxO59yr8f/OXXgSogNgi/uPo9KW9H7LMIZ+g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-graphql": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.58.0.tgz", - "integrity": "sha512-+yWVVY7fxOs3j2RixCbvue8vUuJ1inHxN2q1sduqDB0Wnkr4vOzVKRYl/Zy7B31/dcPS72D9lo/kltdOTBM3bQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-hapi": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.57.0.tgz", - "integrity": "sha512-Os4THbvls8cTQTVA8ApLfZZztuuqGEeqog0XUnyRW7QVF0d/vOVBEcBCk1pazPFmllXGEdNbbat8e2fYIWdFbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-http": { - "version": "0.211.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.211.0.tgz", - "integrity": "sha512-n0IaQ6oVll9PP84SjbOCwDjaJasWRHi6BLsbMLiT6tNj7QbVOkuA5sk/EfZczwI0j5uTKl1awQPivO/ldVtsqA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.5.0", - "@opentelemetry/instrumentation": "0.211.0", - "@opentelemetry/semantic-conventions": "^1.29.0", - "forwarded-parse": "2.1.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", - "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-ioredis": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.59.0.tgz", - "integrity": "sha512-875UxzBHWkW+P4Y45SoFM2AR8f8TzBMD8eO7QXGCyFSCUMP5s9vtt/BS8b/r2kqLyaRPK6mLbdnZznK3XzQWvw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/redis-common": "^0.38.2", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-kafkajs": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.20.0.tgz", - "integrity": "sha512-yJXOuWZROzj7WmYCUiyT27tIfqBrVtl1/TwVbQyWPz7rL0r1Lu7kWjD0PiVeTCIL6CrIZ7M2s8eBxsTAOxbNvw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.30.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.55.0.tgz", - "integrity": "sha512-FtTL5DUx5Ka/8VK6P1VwnlUXPa3nrb7REvm5ddLUIeXXq4tb9pKd+/ThB1xM/IjefkRSN3z8a5t7epYw1JLBJQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-koa": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.59.0.tgz", - "integrity": "sha512-K9o2skADV20Skdu5tG2bogPKiSpXh4KxfLjz6FuqIVvDJNibwSdu5UvyyBzRVp1rQMV6UmoIk6d3PyPtJbaGSg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.36.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.55.0.tgz", - "integrity": "sha512-FDBfT7yDGcspN0Cxbu/k8A0Pp1Jhv/m7BMTzXGpcb8ENl3tDj/51U65R5lWzUH15GaZA15HQ5A5wtafklxYj7g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-mongodb": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.64.0.tgz", - "integrity": "sha512-pFlCJjweTqVp7B220mCvCld1c1eYKZfQt1p3bxSbcReypKLJTwat+wbL2YZoX9jPi5X2O8tTKFEOahO5ehQGsA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-mongoose": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.57.0.tgz", - "integrity": "sha512-MthiekrU/BAJc5JZoZeJmo0OTX6ycJMiP6sMOSRTkvz5BrPMYDqaJos0OgsLPL/HpcgHP7eo5pduETuLguOqcg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-mysql": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.57.0.tgz", - "integrity": "sha512-HFS/+FcZ6Q7piM7Il7CzQ4VHhJvGMJWjx7EgCkP5AnTntSN5rb5Xi3TkYJHBKeR27A0QqPlGaCITi93fUDs++Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/mysql": "2.15.27" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.57.0.tgz", - "integrity": "sha512-nHSrYAwF7+aV1E1V9yOOP9TchOodb6fjn4gFvdrdQXiRE7cMuffyLLbCZlZd4wsspBzVwOXX8mpURdRserAhNA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@opentelemetry/sql-common": "^0.41.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.63.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.63.0.tgz", - "integrity": "sha512-dKm/ODNN3GgIQVlbD6ZPxwRc3kleLf95hrRWXM+l8wYo+vSeXtEpQPT53afEf6VFWDVzJK55VGn8KMLtSve/cg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.34.0", - "@opentelemetry/sql-common": "^0.41.2", - "@types/pg": "8.15.6", - "@types/pg-pool": "2.0.7" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-tedious": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.30.0.tgz", - "integrity": "sha512-bZy9Q8jFdycKQ2pAsyuHYUHNmCxCOGdG6eg1Mn75RvQDccq832sU5OWOBnc12EFUELI6icJkhR7+EQKMBam2GA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/tedious": "^4.0.14" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/instrumentation-undici": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.21.0.tgz", - "integrity": "sha512-gok0LPUOTz2FQ1YJMZzaHcOzDFyT64XJ8M9rNkugk923/p6lDGms/cRW1cqgqp6N6qcd6K6YdVHwPEhnx9BWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/semantic-conventions": "^1.24.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.7.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/redis-common": { - "version": "0.38.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", - "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/resources": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", - "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", - "integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.0", - "@opentelemetry/resources": "2.6.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@opentelemetry/sql-common": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", - "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@prisma/instrumentation": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.2.0.tgz", - "integrity": "sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.207.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.8" - } - }, - "node_modules/@sentry/nextjs/node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", - "integrity": "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "import-in-the-middle": "^2.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@sentry/core": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.42.0.tgz", - "integrity": "sha512-L4rMrXMqUKBanpjpMT+TuAVk6xAijz6AWM6RiEYpohAr7SGcCEc1/T0+Ep1eLV8+pwWacfU27OvELIyNeOnGzA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/nextjs/node_modules/@sentry/node": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.42.0.tgz", - "integrity": "sha512-ZZfU3Fnni7Aj0lTX4e3QpY3UxK4FGuzfM20316UAJycBGnripm+sDHwcekPMGfLnk/FrN9wa1atspVlHvOI0WQ==", - "license": "MIT", - "dependencies": { - "@fastify/otel": "0.16.0", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.5.1", - "@opentelemetry/core": "^2.5.1", - "@opentelemetry/instrumentation": "^0.211.0", - "@opentelemetry/instrumentation-amqplib": "0.58.0", - "@opentelemetry/instrumentation-connect": "0.54.0", - "@opentelemetry/instrumentation-dataloader": "0.28.0", - "@opentelemetry/instrumentation-express": "0.59.0", - "@opentelemetry/instrumentation-fs": "0.30.0", - "@opentelemetry/instrumentation-generic-pool": "0.54.0", - "@opentelemetry/instrumentation-graphql": "0.58.0", - "@opentelemetry/instrumentation-hapi": "0.57.0", - "@opentelemetry/instrumentation-http": "0.211.0", - "@opentelemetry/instrumentation-ioredis": "0.59.0", - "@opentelemetry/instrumentation-kafkajs": "0.20.0", - "@opentelemetry/instrumentation-knex": "0.55.0", - "@opentelemetry/instrumentation-koa": "0.59.0", - "@opentelemetry/instrumentation-lru-memoizer": "0.55.0", - "@opentelemetry/instrumentation-mongodb": "0.64.0", - "@opentelemetry/instrumentation-mongoose": "0.57.0", - "@opentelemetry/instrumentation-mysql": "0.57.0", - "@opentelemetry/instrumentation-mysql2": "0.57.0", - "@opentelemetry/instrumentation-pg": "0.63.0", - "@opentelemetry/instrumentation-redis": "0.59.0", - "@opentelemetry/instrumentation-tedious": "0.30.0", - "@opentelemetry/instrumentation-undici": "0.21.0", - "@opentelemetry/resources": "^2.5.1", - "@opentelemetry/sdk-trace-base": "^2.5.1", - "@opentelemetry/semantic-conventions": "^1.39.0", - "@prisma/instrumentation": "7.2.0", - "@sentry/core": "10.42.0", - "@sentry/node-core": "10.42.0", - "@sentry/opentelemetry": "10.42.0", - "import-in-the-middle": "^2.0.6" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/nextjs/node_modules/@sentry/node-core": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.42.0.tgz", - "integrity": "sha512-9tf3fPV6M071aps72D+PEtdQPTuj+SuqO2+PpTfdPP5ZL4TTKYo3VK0li76SL+5wGdTFGV5qmsokHq9IRBA0iA==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.42.0", - "@sentry/opentelemetry": "10.42.0", - "import-in-the-middle": "^2.0.6" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/instrumentation": ">=0.57.1 <1", - "@opentelemetry/resources": "^1.30.1 || ^2.1.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", - "@opentelemetry/semantic-conventions": "^1.39.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@opentelemetry/context-async-hooks": { - "optional": true - }, - "@opentelemetry/core": { - "optional": true - }, - "@opentelemetry/instrumentation": { - "optional": true - }, - "@opentelemetry/resources": { - "optional": true - }, - "@opentelemetry/sdk-trace-base": { - "optional": true - }, - "@opentelemetry/semantic-conventions": { - "optional": true - } - } - }, - "node_modules/@sentry/nextjs/node_modules/@sentry/opentelemetry": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.42.0.tgz", - "integrity": "sha512-5vsYz683iihzlIj3sT1+tEixf0awwXK86a+aYsnMHrTXJDrkBDq4U0ZT+yxdPfJlkaxRtYycFR08SXr2pSm7Eg==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.42.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", - "@opentelemetry/semantic-conventions": "^1.39.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@types/mysql": { - "version": "2.15.27", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", - "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@sentry/nextjs/node_modules/@types/pg": { - "version": "8.15.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", - "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, - "node_modules/@sentry/nextjs/node_modules/@types/pg-pool": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", - "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", - "license": "MIT", - "dependencies": { - "@types/pg": "*" - } - }, - "node_modules/@sentry/nextjs/node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", - "license": "MIT" - }, - "node_modules/@sentry/nextjs/node_modules/import-in-the-middle": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", - "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - } - }, - "node_modules/@sentry/nextjs/node_modules/require-in-the-middle": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", - "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "module-details-from-path": "^1.0.3" - }, - "engines": { - "node": ">=9.3.0 || >=8.10.0 <9.0.0" - } - }, - "node_modules/@sentry/node": { - "version": "9.47.1", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-9.47.1.tgz", - "integrity": "sha512-CDbkasBz3fnWRKSFs6mmaRepM2pa+tbZkrqhPWifFfIkJDidtVW40p6OnquTvPXyPAszCnDZRnZT14xyvNmKPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", - "@opentelemetry/core": "^1.30.1", - "@opentelemetry/instrumentation": "^0.57.2", - "@opentelemetry/instrumentation-amqplib": "^0.46.1", - "@opentelemetry/instrumentation-connect": "0.43.1", - "@opentelemetry/instrumentation-dataloader": "0.16.1", - "@opentelemetry/instrumentation-express": "0.47.1", - "@opentelemetry/instrumentation-fs": "0.19.1", - "@opentelemetry/instrumentation-generic-pool": "0.43.1", - "@opentelemetry/instrumentation-graphql": "0.47.1", - "@opentelemetry/instrumentation-hapi": "0.45.2", - "@opentelemetry/instrumentation-http": "0.57.2", - "@opentelemetry/instrumentation-ioredis": "0.47.1", - "@opentelemetry/instrumentation-kafkajs": "0.7.1", - "@opentelemetry/instrumentation-knex": "0.44.1", - "@opentelemetry/instrumentation-koa": "0.47.1", - "@opentelemetry/instrumentation-lru-memoizer": "0.44.1", - "@opentelemetry/instrumentation-mongodb": "0.52.0", - "@opentelemetry/instrumentation-mongoose": "0.46.1", - "@opentelemetry/instrumentation-mysql": "0.45.1", - "@opentelemetry/instrumentation-mysql2": "0.45.2", - "@opentelemetry/instrumentation-pg": "0.51.1", - "@opentelemetry/instrumentation-redis-4": "0.46.1", - "@opentelemetry/instrumentation-tedious": "0.18.1", - "@opentelemetry/instrumentation-undici": "0.10.1", - "@opentelemetry/resources": "^1.30.1", - "@opentelemetry/sdk-trace-base": "^1.30.1", - "@opentelemetry/semantic-conventions": "^1.34.0", - "@prisma/instrumentation": "6.11.1", - "@sentry/core": "9.47.1", - "@sentry/node-core": "9.47.1", - "@sentry/opentelemetry": "9.47.1", - "import-in-the-middle": "^1.14.2", - "minimatch": "^9.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/node-core": { - "version": "9.47.1", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-9.47.1.tgz", - "integrity": "sha512-7TEOiCGkyShJ8CKtsri9lbgMCbB+qNts2Xq37itiMPN2m+lIukK3OX//L8DC5nfKYZlgikrefS63/vJtm669hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sentry/core": "9.47.1", - "@sentry/opentelemetry": "9.47.1", - "import-in-the-middle": "^1.14.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", - "@opentelemetry/core": "^1.30.1 || ^2.0.0", - "@opentelemetry/instrumentation": ">=0.57.1 <1", - "@opentelemetry/resources": "^1.30.1 || ^2.0.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", - "@opentelemetry/semantic-conventions": "^1.34.0" - } - }, - "node_modules/@sentry/node/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sentry/node/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@sentry/node/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@sentry/opentelemetry": { - "version": "9.47.1", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-9.47.1.tgz", - "integrity": "sha512-STtFpjF7lwzeoedDJV+5XA6P89BfmFwFftmHSGSe3UTI8z8IoiR5yB6X2vCjSPvXlfeOs13qCNNCEZyznxM8Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sentry/core": "9.47.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", - "@opentelemetry/core": "^1.30.1 || ^2.0.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", - "@opentelemetry/semantic-conventions": "^1.34.0" - } - }, - "node_modules/@sentry/react": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.42.0.tgz", - "integrity": "sha512-uigyz6E3yPjjqIZpkGzRChww6gzMmqdCpK30M5aBYoaen29DDmSECHYA16sfgXeSwzQhnXyX7GxgOB+eKIr9dw==", - "license": "MIT", - "dependencies": { - "@sentry/browser": "10.42.0", - "@sentry/core": "10.42.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.14.0 || 17.x || 18.x || 19.x" - } - }, - "node_modules/@sentry/react/node_modules/@sentry/core": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.42.0.tgz", - "integrity": "sha512-L4rMrXMqUKBanpjpMT+TuAVk6xAijz6AWM6RiEYpohAr7SGcCEc1/T0+Ep1eLV8+pwWacfU27OvELIyNeOnGzA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/vercel-edge": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-10.42.0.tgz", - "integrity": "sha512-BjK5P5qBBC1biAErKlDICiXaer7FnqAL7NcBCD0pHK7aLO5IAzyegfA0zcu4fIo8TIqipLJiCOGmkYaiSALq8g==", - "license": "MIT", - "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/resources": "^2.5.1", - "@sentry/core": "10.42.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/vercel-edge/node_modules/@opentelemetry/core": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", - "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@sentry/vercel-edge/node_modules/@opentelemetry/resources": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", - "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@sentry/vercel-edge/node_modules/@sentry/core": { - "version": "10.42.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.42.0.tgz", - "integrity": "sha512-L4rMrXMqUKBanpjpMT+TuAVk6xAijz6AWM6RiEYpohAr7SGcCEc1/T0+Ep1eLV8+pwWacfU27OvELIyNeOnGzA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/webpack-plugin": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-5.1.1.tgz", - "integrity": "sha512-XgQg+t2aVrlQDfIiAEizqR/bsy6GtBygwgR+Kw11P/cYczj4W9PZ2IYqQEStBzHqnRTh5DbpyMcUNW2CujdA9A==", - "license": "MIT", - "dependencies": { - "@sentry/bundler-plugin-core": "5.1.1", - "uuid": "^9.0.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "webpack": ">=5.0.0" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/mysql": { - "version": "2.15.26", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz", - "integrity": "sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/node": { - "version": "20.19.34", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz", - "integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/pg": { - "version": "8.6.1", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz", - "integrity": "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, - "node_modules/@types/pg-pool": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.6.tgz", - "integrity": "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/pg": "*" - } - }, - "node_modules/@types/shimmer": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", - "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/tedious": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", - "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0", - "peer": true - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/allure-commandline": { - "version": "2.37.0", - "resolved": "https://registry.npmjs.org/allure-commandline/-/allure-commandline-2.37.0.tgz", - "integrity": "sha512-s3zZ8zjqo2U3i5Lb3iLOCjwWQCtGK58GVpScTnZddOpgTXBDXAbXn+pT7QXN4NiY7pho6xw+UgyREyCRnx/9ug==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "allure": "bin/allure" - } - }, - "node_modules/allure-js-commons": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/allure-js-commons/-/allure-js-commons-3.5.0.tgz", - "integrity": "sha512-iBVFNQkX5i48QGlb5U3iWm+NiNOl/ucxv6dvEJBNeJTPMI8t0Dn0CuXMQEiv4forSSAppD7FB9uGal2JwunH/A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "md5": "^2.3.0" - }, - "peerDependencies": { - "allure-playwright": "3.5.0" - }, - "peerDependenciesMeta": { - "allure-playwright": { - "optional": true - } - } - }, - "node_modules/allure-playwright": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/allure-playwright/-/allure-playwright-3.5.0.tgz", - "integrity": "sha512-nB6Wj1z7oGz44r4qxN2lJ6lgDQ+FcpL2dyhUsH/syyNPY8x1JLandedc3FA+nqtxoer6qUagsWZfDZnsDO0RXA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "allure-js-commons": "3.5.0" - }, - "peerDependencies": { - "@playwright/test": ">=1.53.0" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/atomically": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", - "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "stubborn-fs": "^2.0.0", - "when-exit": "^2.1.4" - } - }, - "node_modules/axe-core": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", - "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=4" - } - }, - "node_modules/b4a": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", - "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.5.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", - "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.1.tgz", - "integrity": "sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", - "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "streamx": "^2.21.0", - "teex": "^1.0.1" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-path": "^3.0.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/basic-ftp": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", - "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT", - "peer": true - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001777", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", - "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/chrome-launcher": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.1.tgz", - "integrity": "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/node": "*", - "escape-string-regexp": "^4.0.0", - "is-wsl": "^2.2.0", - "lighthouse-logger": "^2.0.1" - }, - "bin": { - "print-chrome-path": "bin/print-chrome-path.cjs" - }, - "engines": { - "node": ">=12.13.0" - } - }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/chromium-bidi": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", - "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT", - "peer": true - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "peer": true - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "license": "MIT" - }, - "node_modules/configstore": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", - "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "atomically": "^2.0.3", - "dot-prop": "^9.0.0", - "graceful-fs": "^4.2.11", - "xdg-basedir": "^5.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT" - }, - "node_modules/crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/csp_evaluator": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/csp_evaluator/-/csp_evaluator-1.1.5.tgz", - "integrity": "sha512-EL/iN9etCTzw/fBnp0/uj0f5BOOGvZut2mzsiiBZ/FdT6gFQCKRO/tmcKOxn5drWZ2Ndm/xBb1SI4zwWbGtmIw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/devtools-protocol": { - "version": "0.0.1527314", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1527314.tgz", - "integrity": "sha512-UohCFOlzpPPD/IcsxM0k4lVZp/GfhPVJ6l2No5XX+LknpGisPWJe17oOHQhZTHf6ThUFIMwHO6bSEZUq/6oP7w==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/dot-prop": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", - "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.307", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", - "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", - "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "license": "MIT", - "peer": true - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/eslint-scope/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT", - "peer": true - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/forwarded-parse": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", - "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause", - "peer": true - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-link-header": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/http-link-header/-/http-link-header-1.1.3.tgz", - "integrity": "sha512-3cZ0SRL8fb9MUlU3mKM61FcQvPfXx2dBrZW3Vbg5CXa8jFlK8OaEpePenLe1oEXQduhz8b0QjsqfS59QP4AJDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/image-ssim": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/image-ssim/-/image-ssim-0.2.0.tgz", - "integrity": "sha512-W7+sO6/yhxy83L0G7xR8YAc5Z5QFtYEXXRV6EaE8tuYBZJnA3gVgp3q7X7muhLZVodeb9UfvjSbwt9VJwjIYAg==", - "dev": true, - "license": "MIT" - }, - "node_modules/import-in-the-middle": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz", - "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.14.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" - } - }, - "node_modules/intl-messageformat": { - "version": "10.7.18", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", - "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/icu-messageformat-parser": "2.11.4", - "tslib": "^2.8.0" - } - }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/jpeg-js": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", - "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/js-library-detector": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/js-library-detector/-/js-library-detector-6.7.0.tgz", - "integrity": "sha512-c80Qupofp43y4cJ7+8TTDN/AsDwLi5oOm/plBrWI+iQt485vKXCco+yVmOwEgdo9VOdsYTuV0UlTeetVPTriXA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT", - "peer": true - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/legacy-javascript": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/legacy-javascript/-/legacy-javascript-0.0.1.tgz", - "integrity": "sha512-lPyntS4/aS7jpuvOlitZDFifBCb4W8L/3QU0PLbUTUj+zYah8rfVjYic88yG7ZKTxhS5h9iz7duT8oUXKszLhg==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/lighthouse": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/lighthouse/-/lighthouse-13.0.3.tgz", - "integrity": "sha512-mEHAQV3nn4fB+3FDapye+KGeeE4V8FERgbCFegKT7HxqDVGWVOM/BoH9Qof71fzVYVMLIiGnDsnWRrH0sQ9o4Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@paulirish/trace_engine": "0.0.61", - "@sentry/node": "^9.28.1", - "axe-core": "^4.11.0", - "chrome-launcher": "^1.2.1", - "configstore": "^7.0.0", - "csp_evaluator": "1.1.5", - "devtools-protocol": "0.0.1527314", - "enquirer": "^2.3.6", - "http-link-header": "^1.1.1", - "intl-messageformat": "^10.5.3", - "jpeg-js": "^0.4.4", - "js-library-detector": "^6.7.0", - "lighthouse-logger": "^2.0.2", - "lighthouse-stack-packs": "1.12.3", - "lodash-es": "^4.17.21", - "lookup-closest-locale": "6.2.0", - "open": "^8.4.0", - "puppeteer-core": "^24.23.0", - "robots-parser": "^3.0.1", - "speedline-core": "^1.4.3", - "third-party-web": "^0.27.0", - "tldts-icann": "^7.0.17", - "ws": "^7.0.0", - "yargs": "^17.3.1", - "yargs-parser": "^21.0.0" - }, - "bin": { - "chrome-debug": "core/scripts/manual-chrome-launcher.js", - "lighthouse": "cli/index.js", - "smokehouse": "cli/test/smokehouse/frontends/smokehouse-bin.js" - }, - "engines": { - "node": ">=22.19" - } - }, - "node_modules/lighthouse-logger": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.2.tgz", - "integrity": "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.4.1", - "marky": "^1.2.2" - } - }, - "node_modules/lighthouse-stack-packs": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/lighthouse-stack-packs/-/lighthouse-stack-packs-1.12.3.tgz", - "integrity": "sha512-d8IsOpE83kbANgnM+Tp8+x6HcMpX9o2ITBiUERssgzAIFdZCQzs/f4k6D0DLQTE59enml9mbAOU52Wu35exWtg==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/loader-runner": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", - "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lookup-closest-locale": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/lookup-closest-locale/-/lookup-closest-locale-6.2.0.tgz", - "integrity": "sha512-/c2kL+Vnp1jnV6K6RpDTHK3dgg0Tu2VVp+elEiJpjfS1UyY7AjOYHohRug6wT0OpoX2qFgNORndE9RqesfVxWQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/marky": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", - "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/md5": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", - "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT", - "peer": true - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true, - "license": "MIT" - }, - "node_modules/module-details-from-path": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", - "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT", - "peer": true - }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", - "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@next/env": "16.1.6", - "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": ">=20.9.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.6", - "@next/swc-darwin-x64": "16.1.6", - "@next/swc-linux-arm64-gnu": "16.1.6", - "@next/swc-linux-arm64-musl": "16.1.6", - "@next/swc-linux-x64-gnu": "16.1.6", - "@next/swc-linux-x64-musl": "16.1.6", - "@next/swc-win32-arm64-msvc": "16.1.6", - "@next/swc-win32-x64-msvc": "16.1.6", - "sharp": "^0.34.4" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", - "license": "MIT" - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "dev": true, - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", - "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/puppeteer-core": { - "version": "24.38.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.38.0.tgz", - "integrity": "sha512-zB3S/tksIhgi2gZRndUe07AudBz5SXOB7hqG0kEa9/YXWrGwlVlYm3tZtwKgfRftBzbmLQl5iwHkQQl04n/mWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.13.0", - "chromium-bidi": "14.0.0", - "debug": "^4.4.3", - "devtools-protocol": "0.0.1581282", - "typed-query-selector": "^2.12.1", - "webdriver-bidi-protocol": "0.4.1", - "ws": "^8.19.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core/node_modules/devtools-protocol": { - "version": "0.0.1581282", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", - "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/puppeteer-core/node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-in-the-middle": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", - "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "module-details-from-path": "^1.0.3", - "resolve": "^1.22.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/robots-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/robots-parser/-/robots-parser-3.0.1.tgz", - "integrity": "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true - }, - "node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "devOptional": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/shimmer": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", - "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "license": "MIT", - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/speedline-core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/speedline-core/-/speedline-core-1.4.3.tgz", - "integrity": "sha512-DI7/OuAUD+GMpR6dmu8lliO2Wg5zfeh+/xsdyJZCzd8o5JgFUjCeLsBDuZjIQJdwXS3J0L/uZYrELKYqx+PXog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "image-ssim": "^0.2.0", - "jpeg-js": "^0.4.1" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/stacktrace-parser": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", - "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.7.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/stacktrace-parser/node_modules/type-fest": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", - "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/stubborn-fs": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", - "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "stubborn-utils": "^1.0.1" - } - }, - "node_modules/stubborn-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", - "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", - "dev": true, - "license": "MIT" - }, - "node_modules/styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "license": "MIT", - "peer": true, - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tar-fs": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", - "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", - "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "bare-fs": "^4.5.5", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/teex": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", - "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "streamx": "^2.12.5" - } - }, - "node_modules/terser": { - "version": "5.46.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", - "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.17", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz", - "integrity": "sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/text-decoder": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", - "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, - "node_modules/third-party-web": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/third-party-web/-/third-party-web-0.27.0.tgz", - "integrity": "sha512-h0JYX+dO2Zr3abCQpS6/uFjujaOjA1DyDzGQ41+oFn9VW/ARiq9g5ln7qEP9+BTzDpOMyIfsfj4OvfgXAsMUSA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tldts-core": { - "version": "7.0.24", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz", - "integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tldts-icann": { - "version": "7.0.24", - "resolved": "https://registry.npmjs.org/tldts-icann/-/tldts-icann-7.0.24.tgz", - "integrity": "sha512-WgCMgvvJEUBU0ZByo0dz8mdLDJE0XoVdu6egZDPJYX2aaxHGX8dJEbF4Il5+M6qix8Br9O5OOeLfyyESU0MoEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.24" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typed-query-selector": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz", - "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/v8-to-istanbul": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", - "license": "MIT", - "peer": true, - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webdriver-bidi-protocol": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", - "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/webpack": { - "version": "5.105.4", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", - "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.20.0", - "es-module-lexer": "^2.0.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.3.1", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.17", - "watchpack": "^2.5.1", - "webpack-sources": "^3.3.4" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-sources": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", - "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/when-exit": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", - "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", - "dev": true, - "license": "MIT" - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xdg-basedir": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", - "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "license": "ISC" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/e2e/package.json b/e2e/package.json deleted file mode 100644 index f6268fa..0000000 --- a/e2e/package.json +++ /dev/null @@ -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" - } -} diff --git a/e2e/playwright.config.admin.ts b/e2e/playwright.config.admin.ts deleted file mode 100644 index 755f875..0000000 --- a/e2e/playwright.config.admin.ts +++ /dev/null @@ -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'] }, - }, - ], -}); diff --git a/e2e/playwright.config.no-auth.ts b/e2e/playwright.config.no-auth.ts deleted file mode 100644 index ac6ba14..0000000 --- a/e2e/playwright.config.no-auth.ts +++ /dev/null @@ -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'] }, - }, - ], -}); diff --git a/e2e/playwright.config.tiered.ts b/e2e/playwright.config.tiered.ts deleted file mode 100644 index e8fc734..0000000 --- a/e2e/playwright.config.tiered.ts +++ /dev/null @@ -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'); \ No newline at end of file diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts deleted file mode 100644 index 4856dc9..0000000 --- a/e2e/playwright.config.ts +++ /dev/null @@ -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, -}); diff --git a/e2e/playwright.coverage.config.ts b/e2e/playwright.coverage.config.ts deleted file mode 100644 index 31bbbd6..0000000 --- a/e2e/playwright.coverage.config.ts +++ /dev/null @@ -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'] }, - }, - ], -}); diff --git a/e2e/run-tests-with-progress.js b/e2e/run-tests-with-progress.js deleted file mode 100644 index ab6f7b4..0000000 --- a/e2e/run-tests-with-progress.js +++ /dev/null @@ -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); diff --git a/e2e/scripts/generate-report.js b/e2e/scripts/generate-report.js deleted file mode 100644 index 1c73841..0000000 --- a/e2e/scripts/generate-report.js +++ /dev/null @@ -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); -} \ No newline at end of file diff --git a/e2e/scripts/manual-login.ts b/e2e/scripts/manual-login.ts deleted file mode 100644 index 5a3b3a3..0000000 --- a/e2e/scripts/manual-login.ts +++ /dev/null @@ -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(); diff --git a/e2e/src/config/environments.ts b/e2e/src/config/environments.ts deleted file mode 100644 index 7265a1e..0000000 --- a/e2e/src/config/environments.ts +++ /dev/null @@ -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 = { - 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'; -} diff --git a/e2e/src/config/network-configs.ts b/e2e/src/config/network-configs.ts deleted file mode 100644 index eb903b4..0000000 --- a/e2e/src/config/network-configs.ts +++ /dev/null @@ -1,50 +0,0 @@ -export interface NetworkConfig { - name: string; - offline: boolean; - downloadThroughput?: number; - uploadThroughput?: number; - latency?: number; -} - -export const networkConfigs: Record = { - '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); -} \ No newline at end of file diff --git a/e2e/src/config/test-tags.ts b/e2e/src/config/test-tags.ts deleted file mode 100644 index f202b2e..0000000 --- a/e2e/src/config/test-tags.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/e2e/src/config/test-tiers.ts b/e2e/src/config/test-tiers.ts deleted file mode 100644 index 8993fdd..0000000 --- a/e2e/src/config/test-tiers.ts +++ /dev/null @@ -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 = { - 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; -} \ No newline at end of file diff --git a/e2e/src/data/admin-test-data.ts b/e2e/src/data/admin-test-data.ts deleted file mode 100644 index 1bae557..0000000 --- a/e2e/src/data/admin-test-data.ts +++ /dev/null @@ -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}` - }; -} diff --git a/e2e/src/data/test-data.ts b/e2e/src/data/test-data.ts deleted file mode 100644 index 164e19c..0000000 --- a/e2e/src/data/test-data.ts +++ /dev/null @@ -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: '', 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: [ - '', - '', - '', - 'javascript: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', -}; diff --git a/e2e/src/fixtures/a11y.fixture.ts b/e2e/src/fixtures/a11y.fixture.ts deleted file mode 100644 index fff6403..0000000 --- a/e2e/src/fixtures/a11y.fixture.ts +++ /dev/null @@ -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({ - makeAxeBuilder: async ({ page }, use) => { - const makeAxeBuilder = () => new AxeBuilder({ page }); - await use(makeAxeBuilder); - }, -}); - -export { expect } from '@playwright/test'; diff --git a/e2e/src/fixtures/admin.fixture.ts b/e2e/src/fixtures/admin.fixture.ts deleted file mode 100644 index 2ca1811..0000000 --- a/e2e/src/fixtures/admin.fixture.ts +++ /dev/null @@ -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({ - 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; diff --git a/e2e/src/fixtures/base.fixture.ts b/e2e/src/fixtures/base.fixture.ts deleted file mode 100644 index 8595f2c..0000000 --- a/e2e/src/fixtures/base.fixture.ts +++ /dev/null @@ -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({ - 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; diff --git a/e2e/src/pages/AboutPage.ts b/e2e/src/pages/AboutPage.ts deleted file mode 100644 index 131116f..0000000 --- a/e2e/src/pages/AboutPage.ts +++ /dev/null @@ -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 { - await this.navigate('/about'); - } - - async verifyBreadcrumb(): Promise { - return await this.breadcrumb.isVisible(); - } - - async verifyPageHeader(): Promise { - const header = await this.pageHeader.textContent(); - return header?.includes('关于我们') || false; - } - - async verifyValuesSection(): Promise { - return await this.valuesSection.isVisible(); - } - - async verifyMilestonesSection(): Promise { - return await this.milestonesSection.isVisible(); - } - - async verifyContactSection(): Promise { - return await this.contactSection.isVisible(); - } - - async getStatValues(): Promise { - const stats = await this.statCards.allTextContents(); - return stats; - } - - async scrollToValuesSection(): Promise { - await this.scrollToElement(this.valuesSection); - } - - async scrollToMilestonesSection(): Promise { - await this.scrollToElement(this.milestonesSection); - } - - async scrollToContactSection(): Promise { - await this.scrollToElement(this.contactSection); - } -} diff --git a/e2e/src/pages/AdminPage.ts b/e2e/src/pages/AdminPage.ts deleted file mode 100644 index 5d118dd..0000000 --- a/e2e/src/pages/AdminPage.ts +++ /dev/null @@ -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'); - } -} diff --git a/e2e/src/pages/BasePage.ts b/e2e/src/pages/BasePage.ts deleted file mode 100644 index 299cfc4..0000000 --- a/e2e/src/pages/BasePage.ts +++ /dev/null @@ -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 { - await this.page.goto(url, { timeout: 30000, waitUntil: 'domcontentloaded' }); - } - - async waitForLoadState(state: 'load' | 'domcontentloaded' | 'networkidle' = 'load'): Promise { - await this.page.waitForLoadState(state); - } - - async click(locator: Locator | string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.click(); - } - - async fill(locator: Locator | string, value: string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.fill(value); - } - - async getText(locator: Locator | string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - return await element.textContent() || ''; - } - - async isVisible(locator: Locator | string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - return await element.isVisible(); - } - - async waitForElement(locator: Locator | string, timeout: number = 5000): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.waitFor({ state: 'visible', timeout }); - } - - async scrollToElement(locator: Locator | string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.scrollIntoViewIfNeeded(); - } - - async takeScreenshot(filename: string): Promise { - 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 { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.hover(); - } - - async selectOption(locator: Locator | string, value: string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.selectOption(value); - } - - async check(locator: Locator | string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.check(); - } - - async uncheck(locator: Locator | string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.uncheck(); - } - - async waitForURL(url: string | RegExp, timeout: number = 5000): Promise { - await this.page.waitForURL(url, { timeout }); - } - - async getCurrentURL(): Promise { - return this.page.url(); - } - - async getTitle(): Promise { - return await this.page.title(); - } - - async waitForSelector(locator: Locator | string, options?: { state?: 'attached' | 'detached' | 'visible' | 'hidden', timeout?: number }): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.waitFor(options); - } - - async getAttribute(locator: Locator | string, attribute: string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - return await element.getAttribute(attribute); - } - - async pressKey(key: string): Promise { - await this.page.keyboard.press(key); - } - - async type(locator: Locator | string, text: string, options?: { delay?: number }): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.type(text, options); - } - - async waitForNavigation(options?: { url?: string | RegExp, timeout?: number }): Promise { - await this.page.waitForNavigation(options); - } - - async reload(): Promise { - await this.page.reload(); - } - - async goBack(): Promise { - await this.page.goBack(); - } - - async goForward(): Promise { - await this.page.goForward(); - } - - async evaluate(pageFunction: () => T): Promise { - return await this.page.evaluate(pageFunction); - } - - async waitForTimeout(timeout: number): Promise { - await this.page.waitForTimeout(timeout); - } - - async count(locator: Locator | string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - return await element.count(); - } - - async allTextContents(locator: Locator | string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - return await element.allTextContents(); - } - - async isDisabled(locator: Locator | string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - return await element.isDisabled(); - } - - async isEnabled(locator: Locator | string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - return await element.isEnabled(); - } - - async isChecked(locator: Locator | string): Promise { - 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 { - 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( - operation: () => Promise, - maxRetries: number = 3, - delay: number = 1000 - ): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.focus(); - } - - async blur(locator: Locator | string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.blur(); - } - - async dragAndDrop(source: Locator | string, target: Locator | string): Promise { - 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 { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.setInputFiles(filePath); - } - - async clearInput(locator: Locator | string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.clear(); - } - - async getInputValue(locator: Locator | string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - return await element.inputValue(); - } - - async selectText(locator: Locator | string): Promise { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.selectText(); - } - - async waitForFileDownload(downloadPromise: Promise): Promise { - const download = await downloadPromise; - const path = await download.path(); - return path || ''; - } - - async acceptDialog(): Promise { - this.page.on('dialog', (dialog) => dialog.accept()); - } - - async dismissDialog(): Promise { - this.page.on('dialog', (dialog) => dialog.dismiss()); - } - - async getDialogMessage(): Promise { - 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(); - } -} diff --git a/e2e/src/pages/CasesPage.ts b/e2e/src/pages/CasesPage.ts deleted file mode 100644 index bdf56bb..0000000 --- a/e2e/src/pages/CasesPage.ts +++ /dev/null @@ -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 { - await this.navigate('/cases'); - } - - async verifyBreadcrumb(): Promise { - return await this.breadcrumb.isVisible(); - } - - async verifyPageHeader(): Promise { - const header = await this.pageHeader.textContent(); - return header?.includes('与谁同行') || false; - } - - async getCaseCount(): Promise { - return await this.caseCards.count(); - } - - async clickCase(index: number): Promise { - const cards = await this.caseCards.all(); - const card = cards[index]; - if (card) { - await card.click(); - } - } - - async verifyCTASection(): Promise { - return await this.ctaSection.isVisible(); - } - - async scrollToCTASection(): Promise { - await this.scrollToElement(this.ctaSection); - } - - async getCaseTitles(): Promise { - const titles = this.caseCards.locator('h3'); - return await titles.allTextContents(); - } -} diff --git a/e2e/src/pages/ContactFormPage.ts b/e2e/src/pages/ContactFormPage.ts deleted file mode 100644 index 9dc0419..0000000 --- a/e2e/src/pages/ContactFormPage.ts +++ /dev/null @@ -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 { - return (await this.captchaQuestion.textContent()) || ''; - } - - async getSuccessMessage(): Promise { - try { - return await this.successMessage.textContent(); - } catch { - return null; - } - } - - async getErrorMessage(): Promise { - try { - return await this.errorMessage.textContent(); - } catch { - return null; - } - } - - async getCaptchaErrorMessage(): Promise { - 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 { - return await this.submitButton.isEnabled(); - } -} diff --git a/e2e/src/pages/ContactPage.ts b/e2e/src/pages/ContactPage.ts deleted file mode 100644 index 8f3d967..0000000 --- a/e2e/src/pages/ContactPage.ts +++ /dev/null @@ -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 { - await this.navigate(this.url); - } - - async verifyBreadcrumb(): Promise { - return await this.breadcrumb.isVisible(); - } - - async verifyPageHeader(): Promise { - const header = await this.pageHeader.textContent(); - return header?.includes('合作') || false; - } - - async verifyContactForm(): Promise { - return await this.contactForm.isVisible(); - } - - async verifyContactInfo(): Promise { - return await this.contactInfoCard.isVisible(); - } - - async goto(): Promise { - await this.navigate(this.url); - await this.waitForLoadState('networkidle'); - } - - async isLoaded(): Promise { - 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 { - await this.waitForLoadState('networkidle'); - await this.pageHeader.waitFor({ state: 'visible' }); - await this.contactForm.waitFor({ state: 'visible' }); - } - - async fillContactForm(data: ContactFormData): Promise { - 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 { - await this.submitButton.click(); - } - - async fillAndSubmitForm(data: ContactFormData): Promise { - 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 { - return await this.successMessage.isVisible(); - } - - async getSuccessMessageText(): Promise { - return await this.successMessage.textContent() || ''; - } - - async isFormVisible(): Promise { - return await this.contactForm.isVisible(); - } - - async isSubmitButtonEnabled(): Promise { - return await this.submitButton.isEnabled(); - } - - async getSubmitButtonText(): Promise { - return await this.submitButton.textContent() || ''; - } - - async isSubmitButtonLoading(): Promise { - const text = await this.getSubmitButtonText(); - return text.includes('发送中'); - } - - async getNameInputValue(): Promise { - return await this.nameInput.inputValue(); - } - - async getPhoneInputValue(): Promise { - return await this.phoneInput.inputValue(); - } - - async getEmailInputValue(): Promise { - return await this.emailInput.inputValue(); - } - - async getSubjectInputValue(): Promise { - return await this.subjectInput.inputValue(); - } - - async getMessageInputValue(): Promise { - return await this.messageInput.inputValue(); - } - - async clearForm(): Promise { - await this.nameInput.fill(''); - await this.phoneInput.fill(''); - await this.emailInput.fill(''); - await this.subjectInput.fill(''); - await this.messageInput.fill(''); - } - - async isContactInfoCardVisible(): Promise { - return await this.contactInfoCard.isVisible(); - } - - async isWorkHoursCardVisible(): Promise { - return await this.workHoursCard.isVisible(); - } - - async getContactInfoText(): Promise { - return await this.contactInfoCard.textContent() || ''; - } - - async getWorkHoursText(): Promise { - return await this.workHoursCard.textContent() || ''; - } - - async getAddress(): Promise { - return await this.addressText.textContent() || ''; - } - - async getPhone(): Promise { - return await this.phoneLink.textContent() || ''; - } - - async getEmail(): Promise { - return await this.emailLink.textContent() || ''; - } - - async getPageTitle(): Promise { - return await this.pageHeader.textContent() || ''; - } - - async getPageDescription(): Promise { - return await this.pageDescription.textContent() || ''; - } - - async getBadgeText(): Promise { - return await this.pageBadge.textContent() || ''; - } - - async isRequiredFieldVisible(fieldName: string): Promise { - const label = this.page.locator(`label[for="${fieldName}"]`); - return await label.isVisible(); - } - - async isFieldRequired(fieldName: string): Promise { - const label = this.page.locator(`label[for="${fieldName}"]`); - const text = await label.textContent(); - return text?.includes('*') || false; - } - - async getFieldPlaceholder(fieldName: string): Promise { - const input = this.page.locator(`[name="${fieldName}"]`); - return await input.getAttribute('placeholder') || ''; - } - - async scrollToForm(): Promise { - await this.contactForm.scrollIntoViewIfNeeded(); - await this.page.waitForTimeout(500); - } - - async takeScreenshotOfForm(filename: string): Promise { - await this.contactForm.screenshot({ path: `test-results/screenshots/${filename}` }); - } - - async takeScreenshotOfSuccessMessage(filename: string): Promise { - await this.successMessage.screenshot({ path: `test-results/screenshots/${filename}` }); - } - - async waitForFormSubmission(): Promise { - await this.page.waitForTimeout(3000); - await this.page.waitForLoadState('networkidle'); - await this.page.waitForTimeout(2000); - } - - async isFormSubmitted(): Promise { - const isSuccessVisible = await this.isSuccessMessageVisible(); - console.log('Success message visible:', isSuccessVisible); - return isSuccessVisible; - } - - async getFormValidationErrors(): Promise { - 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 { - return await this.emailInput.evaluate(el => (el as HTMLInputElement).checkValidity()); - } - - async isPhoneValid(): Promise { - return await this.phoneInput.evaluate(el => (el as HTMLInputElement).checkValidity()); - } - - async focusOnField(fieldName: string): Promise { - const input = this.page.locator(`[data-testid="${fieldName}-input"]`); - await input.focus(); - } - - async blurField(fieldName: string): Promise { - const input = this.page.locator(`[data-testid="${fieldName}-input"]`); - await input.blur(); - } - - async typeInField(fieldName: string, text: string, options?: { delay?: number }): Promise { - const input = this.page.locator(`[data-testid="${fieldName}-input"]`); - await input.type(text, options); - } - - async clearField(fieldName: string): Promise { - const input = this.page.locator(`[data-testid="${fieldName}-input"]`); - await input.fill(''); - } - - async isFieldVisible(fieldName: string): Promise { - const input = this.page.locator(`[data-testid="${fieldName}-input"]`); - return await input.isVisible(); - } - - async isFieldEnabled(fieldName: string): Promise { - const input = this.page.locator(`[data-testid="${fieldName}-input"]`); - return await input.isEnabled(); - } - - async getFieldAttribute(fieldName: string, attribute: string): Promise { - 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 { - return await this.nameError.textContent() || ''; - } - - async getEmailError(): Promise { - return await this.emailError.textContent() || ''; - } - - async getPhoneError(): Promise { - return await this.phoneError.textContent() || ''; - } - - async getMessageError(): Promise { - return await this.messageError.textContent() || ''; - } - - async isNameErrorVisible(): Promise { - return await this.nameError.isVisible(); - } - - async isEmailErrorVisible(): Promise { - return await this.emailError.isVisible(); - } - - async isPhoneErrorVisible(): Promise { - return await this.phoneError.isVisible(); - } - - async isMessageErrorVisible(): Promise { - return await this.messageError.isVisible(); - } - - async testXSSInjection(payload: string): Promise { - await this.fillContactForm({ - name: payload, - email: 'test@example.com', - phone: '13800138000', - message: payload, - }); - await this.submitForm(); - } - - async testSQLInjection(payload: string): Promise { - await this.fillContactForm({ - name: payload, - email: payload, - phone: payload, - message: payload, - }); - await this.submitForm(); - } - - async testPathTraversal(payload: string): Promise { - 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 { - 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 { - try { - await this.verifyKeyboardNavigation(); - return true; - } catch { - return false; - } - } -} diff --git a/e2e/src/pages/HomePage.ts b/e2e/src/pages/HomePage.ts deleted file mode 100644 index bdddb35..0000000 --- a/e2e/src/pages/HomePage.ts +++ /dev/null @@ -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 { - 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 { - await this.navigate(this.url); - await this.smartWait.waitForPageReady(); - } - - async isLoaded(): Promise { - 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 { - await this.smartWait.waitForPageReady(); - } - - async getNavigationItems(): Promise { - 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 { - 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 { - await super.openMobileMenu(); - } - - async closeMobileMenu(): Promise { - await super.closeMobileMenu(); - } - - async scrollToSection(sectionId: string): Promise { - 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 { - const section = this.page.locator(`#${sectionId}`); - return await section.isVisible(); - } - - async getSectionText(sectionId: string): Promise { - const section = this.page.locator(`#${sectionId}`); - return await section.textContent() || ''; - } - - async clickContactButton(): Promise { - await this.page.locator('a:has-text("立即咨询")').first().click(); - } - - async isLogoVisible(): Promise { - return await this.logo.isVisible(); - } - - async getLogoAltText(): Promise { - return await this.logo.getAttribute('alt'); - } - - async isFooterVisible(): Promise { - return await this.footer.isVisible(); - } - - async getFooterText(): Promise { - return await this.footer.textContent() || ''; - } - - async waitForFooter(): Promise { - await this.scrollToBottom(); - await this.page.waitForLoadState('networkidle'); - await this.footer.waitFor({ state: 'visible', timeout: 10000 }); - } - - async waitForHeroSection(): Promise { - await this.heroSection.waitFor({ state: 'visible', timeout: 10000 }); - } - - async waitForServicesSection(): Promise { - await this.scrollToSection('services'); - await this.servicesSection.waitFor({ state: 'visible', timeout: 10000 }); - } - - async waitForProductsSection(): Promise { - await this.scrollToSection('products'); - await this.productsSection.waitFor({ state: 'visible', timeout: 10000 }); - } - - async waitForCasesSection(): Promise { - await this.scrollToSection('cases'); - await this.casesSection.waitFor({ state: 'visible', timeout: 10000 }); - } - - async waitForAboutSection(): Promise { - await this.scrollToSection('about'); - await this.aboutSection.waitFor({ state: 'visible', timeout: 10000 }); - } - - async waitForNewsSection(): Promise { - await this.scrollToSection('news'); - await this.newsSection.waitFor({ state: 'visible', timeout: 10000 }); - } - - async waitForContactSection(): Promise { - await this.scrollToSection('contact'); - await this.contactSection.waitFor({ state: 'visible', timeout: 10000 }); - } - - async scrollToBottom(): Promise { - await super.scrollToBottom(); - } - - async scrollToTop(): Promise { - await super.scrollToTop(); - } - - async getActiveNavigationItem(): Promise { - 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 { - 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 { - 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 { - const section = this.page.locator(`#${sectionId}`); - await section.screenshot({ path: `test-results/screenshots/${filename}` }); - } - - async getHeroSectionTitle(): Promise { - const title = this.heroSection.locator('h1, h2').first(); - return await title.textContent() || ''; - } - - async getServicesSectionTitle(): Promise { - const title = this.servicesSection.locator('h2').first(); - return await title.textContent() || ''; - } - - async getProductsSectionTitle(): Promise { - const title = this.productsSection.locator('h2').first(); - return await title.textContent() || ''; - } - - async getCasesSectionTitle(): Promise { - const title = this.casesSection.locator('h2').first(); - return await title.textContent() || ''; - } - - async getAboutSectionTitle(): Promise { - const title = this.aboutSection.locator('h2').first(); - return await title.textContent() || ''; - } - - async getNewsSectionTitle(): Promise { - const title = this.newsSection.locator('h2').first(); - return await title.textContent() || ''; - } - - async getContactSectionTitle(): Promise { - const title = this.contactSection.locator('h2').first(); - return await title.textContent() || ''; - } - - async isHeaderSticky(): Promise { - const isSticky = await this.header.evaluate(el => { - return window.getComputedStyle(el).position === 'fixed'; - }); - return isSticky; - } - - async getHeaderBackgroundColor(): Promise { - return await this.header.evaluate(el => { - return window.getComputedStyle(el).backgroundColor; - }); - } - - async isHeaderScrolled(): Promise { - const hasShadow = await this.header.evaluate(el => { - return window.getComputedStyle(el).boxShadow !== 'none'; - }); - return hasShadow; - } - - async getAllNavigationLabels(): Promise { - 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> { - 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> { - 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> { - 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> { - 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 { - const scrollBehavior = await this.page.evaluate(() => { - return window.getComputedStyle(document.documentElement).scrollBehavior; - }); - return scrollBehavior === 'smooth'; - } - - async verifyStickyHeader(): Promise { - await this.scrollToBottom(); - const isSticky = await this.header.evaluate((el) => { - return window.getComputedStyle(el).position === 'fixed'; - }); - return isSticky; - } - - async verifyMobileMenu(): Promise { - 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 { - 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; - } -} diff --git a/e2e/src/pages/NewsPage.ts b/e2e/src/pages/NewsPage.ts deleted file mode 100644 index 73adeeb..0000000 --- a/e2e/src/pages/NewsPage.ts +++ /dev/null @@ -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 { - await this.navigate('/news'); - } - - async verifyBreadcrumb(): Promise { - return await this.breadcrumb.isVisible(); - } - - async verifyPageHeader(): Promise { - const header = await this.pageHeader.textContent(); - return header?.includes('新闻动态') || false; - } - - async getNewsCount(): Promise { - return await this.newsCards.count(); - } - - async clickNews(index: number): Promise { - const cards = await this.newsCards.all(); - const card = cards[index]; - if (card) { - await card.click(); - } - } - - async selectCategory(category: string): Promise { - const button = this.categoryButtons.filter({ hasText: category }); - await button.click(); - } - - async searchNews(query: string): Promise { - await this.searchInput.fill(query); - } - - async clearSearch(): Promise { - await this.searchInput.clear(); - } - - async getNewsTitles(): Promise { - const titles = this.newsCards.locator('h3'); - return await titles.allTextContents(); - } - - async getNewsCategories(): Promise { - const categories = this.newsCards.locator('[class*="badge"]'); - return await categories.allTextContents(); - } - - async verifyNoResults(): Promise { - return await this.page.locator('text=没有找到相关新闻').isVisible(); - } - - async selectAllCategory(): Promise { - await this.allCategoryButton.click(); - } -} diff --git a/e2e/src/pages/ProductsPage.ts b/e2e/src/pages/ProductsPage.ts deleted file mode 100644 index f4b91e0..0000000 --- a/e2e/src/pages/ProductsPage.ts +++ /dev/null @@ -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 { - await this.navigate('/products'); - } - - async verifyBreadcrumb(): Promise { - return await this.breadcrumb.isVisible(); - } - - async verifyPageHeader(): Promise { - const header = await this.pageHeader.textContent(); - return header?.includes('产品服务') || false; - } - - async getProductCount(): Promise { - return await this.productCards.count(); - } - - async clickProduct(index: number): Promise { - const cards = await this.productCards.all(); - const card = cards[index]; - if (card) { - await card.click(); - } - } - - async verifyCTASection(): Promise { - return await this.ctaSection.isVisible(); - } - - async scrollToCTASection(): Promise { - await this.scrollToElement(this.ctaSection); - } - - async getProductTitles(): Promise { - const titles = this.productCards.locator('h3'); - return await titles.allTextContents(); - } - - async getProductCategories(): Promise { - const categories = this.productCards.locator('[class*="badge"]'); - return await categories.allTextContents(); - } -} diff --git a/e2e/src/pages/ServicesPage.ts b/e2e/src/pages/ServicesPage.ts deleted file mode 100644 index 9a7fd92..0000000 --- a/e2e/src/pages/ServicesPage.ts +++ /dev/null @@ -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 { - await this.navigate('/services'); - } - - async verifyBreadcrumb(): Promise { - return await this.breadcrumb.isVisible(); - } - - async verifyPageHeader(): Promise { - const header = await this.pageHeader.textContent(); - return header?.includes('核心业务') || false; - } - - async getServiceCount(): Promise { - return await this.serviceCards.count(); - } - - async clickService(index: number): Promise { - const cards = await this.serviceCards.all(); - const card = cards[index]; - if (card) { - await card.click(); - } - } - - async verifyCTASection(): Promise { - return await this.ctaSection.isVisible(); - } - - async scrollToCTASection(): Promise { - await this.scrollToElement(this.ctaSection); - } - - async getServiceTitles(): Promise { - const titles = this.serviceCards.locator('h3'); - return await titles.allTextContents(); - } - - async searchServices(query: string): Promise { - await this.searchInput.fill(query); - } - - async clearSearch(): Promise { - await this.searchInput.clear(); - } -} diff --git a/e2e/src/pages/SolutionsPage.ts b/e2e/src/pages/SolutionsPage.ts deleted file mode 100644 index 00601d2..0000000 --- a/e2e/src/pages/SolutionsPage.ts +++ /dev/null @@ -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 { - await this.navigate('/solutions'); - } - - async verifyBreadcrumb(): Promise { - return await this.breadcrumb.isVisible(); - } - - async verifyPageHeader(): Promise { - const header = await this.pageHeader.textContent(); - return header?.includes('三种角色') || false; - } - - async verifyAllModules(): Promise { - const count = await this.page.locator('section, div:has(h2:has-text("模块"))').count(); - return count >= 3; - } - - async scrollToConsultingModule(): Promise { - await this.scrollToElement(this.consultingModule); - } - - async scrollToTechnologyModule(): Promise { - await this.scrollToElement(this.technologyModule); - } - - async scrollToPartnershipModule(): Promise { - await this.scrollToElement(this.partnershipModule); - } - - async verifyCTASection(): Promise { - return await this.ctaSection.isVisible(); - } - - async scrollToCTASection(): Promise { - await this.scrollToElement(this.ctaSection); - } - - async getModuleTitles(): Promise { - const titles = this.modules.locator('h2'); - return await titles.allTextContents(); - } -} diff --git a/e2e/src/tests/accessibility/accessibility.spec.ts b/e2e/src/tests/accessibility/accessibility.spec.ts deleted file mode 100644 index 63598ca..0000000 --- a/e2e/src/tests/accessibility/accessibility.spec.ts +++ /dev/null @@ -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(); - } - } - }); -}); \ No newline at end of file diff --git a/e2e/src/tests/accessibility/wcag-compliance.spec.ts b/e2e/src/tests/accessibility/wcag-compliance.spec.ts deleted file mode 100644 index b2b8f9c..0000000 --- a/e2e/src/tests/accessibility/wcag-compliance.spec.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/e2e/src/tests/admin/case-management.spec.ts b/e2e/src/tests/admin/case-management.spec.ts deleted file mode 100644 index 2af90b8..0000000 --- a/e2e/src/tests/admin/case-management.spec.ts +++ /dev/null @@ -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('案例'); - } - }); -}); diff --git a/e2e/src/tests/admin/news-management.spec.ts b/e2e/src/tests/admin/news-management.spec.ts deleted file mode 100644 index 0b3f77f..0000000 --- a/e2e/src/tests/admin/news-management.spec.ts +++ /dev/null @@ -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('草稿'); - } - }); -}); diff --git a/e2e/src/tests/admin/permissions.spec.ts b/e2e/src/tests/admin/permissions.spec.ts deleted file mode 100644 index 9aaf5fd..0000000 --- a/e2e/src/tests/admin/permissions.spec.ts +++ /dev/null @@ -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: '

编辑者创建的产品内容

', - 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/); - }); -}); diff --git a/e2e/src/tests/admin/product-management.spec.ts b/e2e/src/tests/admin/product-management.spec.ts deleted file mode 100644 index dd42760..0000000 --- a/e2e/src/tests/admin/product-management.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/e2e/src/tests/admin/rich-text-editor.spec.ts b/e2e/src/tests/admin/rich-text-editor.spec.ts deleted file mode 100644 index 9e4b646..0000000 --- a/e2e/src/tests/admin/rich-text-editor.spec.ts +++ /dev/null @@ -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/); - }); -}); diff --git a/e2e/src/tests/admin/service-management.spec.ts b/e2e/src/tests/admin/service-management.spec.ts deleted file mode 100644 index 643c132..0000000 --- a/e2e/src/tests/admin/service-management.spec.ts +++ /dev/null @@ -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('服务'); - } - }); -}); diff --git a/e2e/src/tests/api/admin.api.spec.ts b/e2e/src/tests/api/admin.api.spec.ts deleted file mode 100644 index b1edfbc..0000000 --- a/e2e/src/tests/api/admin.api.spec.ts +++ /dev/null @@ -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()); - }); - }); -}); diff --git a/e2e/src/tests/config-linkage/config-concurrency.spec.ts b/e2e/src/tests/config-linkage/config-concurrency.spec.ts deleted file mode 100644 index 8272811..0000000 --- a/e2e/src/tests/config-linkage/config-concurrency.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/e2e/src/tests/config-linkage/config-edge-cases.spec.ts b/e2e/src/tests/config-linkage/config-edge-cases.spec.ts deleted file mode 100644 index a168149..0000000 --- a/e2e/src/tests/config-linkage/config-edge-cases.spec.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('前后台配置边界情况测试', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/admin/login'); - await page.fill('input[type="email"]', 'admin@novalon.cn'); - await page.fill('input[type="password"]', 'admin123456'); - await page.click('button[type="submit"]'); - await page.waitForURL('/admin'); - }); - - 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 textarea = servicesConfig.locator('textarea'); - - await textarea.fill(''); - await servicesConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForTimeout(2000); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - const serviceCards = page.locator('#services .card'); - const count = await serviceCards.count(); - expect(count).toBe(0); - }); - - 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"]'); - - await displayCountInput.fill('-5'); - await newsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForTimeout(2000); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - const newsCards = page.locator('#news .card'); - const count = await newsCards.count(); - expect(count).toBeGreaterThanOrEqual(0); - }); - - 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"]'); - - await displayCountInput.fill('1000'); - 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).toBeLessThanOrEqual(100); - }); - - 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 textarea = servicesConfig.locator('textarea'); - - await textarea.fill('erp'); - await servicesConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - const serviceCards = page.locator('#services .card'); - const count = await serviceCards.count(); - expect(count).toBeGreaterThanOrEqual(0); - }); - - 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 selectElement = newsConfig.locator('select'); - await selectElement.selectOption('invalid'); - await newsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForTimeout(2000); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - const newsCards = page.locator('#news .card'); - const count = await newsCards.count(); - expect(count).toBeGreaterThanOrEqual(0); - }); - - test('配置项不存在 - 访问不存在的配置', async ({ page, context }) => { - const adminPage = await context.newPage(); - - await adminPage.goto('/admin/settings'); - await adminPage.waitForLoadState('networkidle'); - - const nonExistentConfig = adminPage.locator('text=feature_nonexistent'); - expect(nonExistentConfig).not.toBeVisible(); - }); - - 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 checkbox = servicesConfig.locator('input[type="checkbox"]').first(); - - for (let i = 0; i < 5; i++) { - await checkbox.setChecked(i % 2 === 0); - await servicesConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - const isVisible = await page.locator('#services').isVisible(); - expect(isVisible).toBe(i % 2 === 0); - - 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 textarea = servicesConfig.locator('textarea'); - - const originalValue = await textarea.inputValue(); - await textarea.fill(''); - await servicesConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - const serviceCards = page.locator('#services .card'); - const count = await serviceCards.count(); - expect(count).toBe(0); - - await textarea.fill(originalValue); - await servicesConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.reload(); - await page.waitForLoadState('networkidle'); - - const newCount = await serviceCards.count(); - expect(newCount).toBeGreaterThan(0); - }); -}); diff --git a/e2e/src/tests/config-linkage/config-params.spec.ts b/e2e/src/tests/config-linkage/config-params.spec.ts deleted file mode 100644 index 3ed6a8f..0000000 --- a/e2e/src/tests/config-linkage/config-params.spec.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('前后台配置参数测试', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - }); - - test('配置参数 - 新闻显示数量', async ({ page, context }) => { - const adminPage = await context.newPage(); - - await adminPage.goto('/admin/login'); - await adminPage.fill('input[type="email"]', 'admin@novalon.cn'); - await adminPage.fill('input[type="password"]', 'admin123456'); - await adminPage.click('button[type="submit"]'); - await adminPage.waitForURL('/admin'); - - await adminPage.goto('/admin/settings'); - await adminPage.waitForLoadState('networkidle'); - - const newsConfig = adminPage.locator('text=feature_news').locator('..').locator('..'); - - await newsConfig.locator('input[type="number"]').fill('2'); - 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(2); - - await newsConfig.locator('input[type="number"]').fill('4'); - await newsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.reload(); - await page.waitForLoadState('networkidle'); - - const newsCardsAfter = page.locator('#news .card'); - const countAfter = await newsCardsAfter.count(); - expect(countAfter).toBe(4); - - await newsConfig.locator('input[type="number"]').fill('6'); - await newsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await adminPage.close(); - }); - - test('配置参数 - 新闻分类过滤', async ({ page, context }) => { - const adminPage = await context.newPage(); - - await adminPage.goto('/admin/login'); - await adminPage.fill('input[type="email"]', 'admin@novalon.cn'); - await adminPage.fill('input[type="password"]', 'admin123456'); - await adminPage.click('button[type="submit"]'); - await adminPage.waitForURL('/admin'); - - await adminPage.goto('/admin/settings'); - await adminPage.waitForLoadState('networkidle'); - - const newsConfig = adminPage.locator('text=feature_news').locator('..').locator('..'); - - const categoriesTextarea = newsConfig.locator('textarea'); - await categoriesTextarea.fill('公司新闻\n产品发布'); - 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).toBeGreaterThan(0); - - for (let i = 0; i < count; i++) { - const card = newsCards.nth(i); - const category = await card.locator('.badge').textContent(); - expect(['公司新闻', '产品发布']).toContain(category?.trim() || ''); - } - - await categoriesTextarea.fill('公司新闻\n产品发布\n合作动态\n行业资讯'); - await newsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await adminPage.close(); - }); - - test('配置参数 - 新闻排序', async ({ page, context }) => { - const adminPage = await context.newPage(); - - await adminPage.goto('/admin/login'); - await adminPage.fill('input[type="email"]', 'admin@novalon.cn'); - await adminPage.fill('input[type="password"]', 'admin123456'); - await adminPage.click('button[type="submit"]'); - await adminPage.waitForURL('/admin'); - - await adminPage.goto('/admin/settings'); - await adminPage.waitForLoadState('networkidle'); - - const newsConfig = adminPage.locator('text=feature_news').locator('..').locator('..'); - - await newsConfig.locator('select').selectOption('desc'); - await newsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - const firstCard = page.locator('#news .card').first(); - const firstDate = await firstCard.locator('.date').textContent(); - - await newsConfig.locator('select').selectOption('asc'); - await newsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.reload(); - await page.waitForLoadState('networkidle'); - - const firstCardAfter = page.locator('#news .card').first(); - const firstDateAfter = await firstCardAfter.locator('.date').textContent(); - - expect(firstDate).not.toBe(firstDateAfter); - - await newsConfig.locator('select').selectOption('desc'); - await newsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await adminPage.close(); - }); - - test('配置参数 - 产品价格显示', async ({ page, context }) => { - const adminPage = await context.newPage(); - - await adminPage.goto('/admin/login'); - await adminPage.fill('input[type="email"]', 'admin@novalon.cn'); - await adminPage.fill('input[type="password"]', 'admin123456'); - await adminPage.click('button[type="submit"]'); - await adminPage.waitForURL('/admin'); - - await adminPage.goto('/admin/settings'); - await adminPage.waitForLoadState('networkidle'); - - const productsConfig = adminPage.locator('text=feature_products').locator('..').locator('..'); - - await productsConfig.locator('text=showPricing').locator('..').locator('input[type="checkbox"]').check(); - await productsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - const firstProductCard = page.locator('#products .card').first(); - await expect(firstProductCard.locator('text=价格方案')).toBeVisible(); - - await productsConfig.locator('text=showPricing').locator('..').locator('input[type="checkbox"]').uncheck(); - await productsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.reload(); - await page.waitForLoadState('networkidle'); - - await expect(firstProductCard.locator('text=价格方案')).not.toBeVisible(); - - await productsConfig.locator('text=showPricing').locator('..').locator('input[type="checkbox"]').check(); - await productsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await adminPage.close(); - }); - - test('配置参数 - 特色产品过滤', async ({ page, context }) => { - const adminPage = await context.newPage(); - - await adminPage.goto('/admin/login'); - await adminPage.fill('input[type="email"]', 'admin@novalon.cn'); - await adminPage.fill('input[type="password"]', 'admin123456'); - await adminPage.click('button[type="submit"]'); - await adminPage.waitForURL('/admin'); - - await adminPage.goto('/admin/settings'); - await adminPage.waitForLoadState('networkidle'); - - const productsConfig = adminPage.locator('text=feature_products').locator('..').locator('..'); - - const featuredProductsTextarea = productsConfig.locator('textarea'); - await featuredProductsTextarea.fill('erp'); - await productsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - const productCards = page.locator('#products .card'); - const count = await productCards.count(); - expect(count).toBe(1); - - await featuredProductsTextarea.fill('erp\ncrm'); - await productsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.reload(); - await page.waitForLoadState('networkidle'); - - const productCardsAfter = page.locator('#products .card'); - const countAfter = await productCardsAfter.count(); - expect(countAfter).toBe(2); - - await featuredProductsTextarea.fill('erp\ncrm\ncms\nbi'); - await productsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await adminPage.close(); - }); - - test('配置参数 - 服务项目过滤', async ({ page, context }) => { - const adminPage = await context.newPage(); - - await adminPage.goto('/admin/login'); - await adminPage.fill('input[type="email"]', 'admin@novalon.cn'); - await adminPage.fill('input[type="password"]', 'admin123456'); - await adminPage.click('button[type="submit"]'); - await adminPage.waitForURL('/admin'); - - await adminPage.goto('/admin/settings'); - await adminPage.waitForLoadState('networkidle'); - - const servicesConfig = adminPage.locator('text=服务模块配置').locator('..').locator('..'); - - const itemsTextarea = servicesConfig.locator('text=items').locator('..').locator('textarea'); - await itemsTextarea.fill('software\ncloud'); - await servicesConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - const serviceCards = page.locator('#services .card'); - const count = await serviceCards.count(); - expect(count).toBe(2); - - await itemsTextarea.fill('software\ncloud\ndata\nsecurity'); - await servicesConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.reload(); - await page.waitForLoadState('networkidle'); - - const serviceCardsAfter = page.locator('#services .card'); - const countAfter = await serviceCardsAfter.count(); - expect(countAfter).toBe(4); - - await adminPage.close(); - }); -}); \ No newline at end of file diff --git a/e2e/src/tests/config-linkage/config-persistence.spec.ts b/e2e/src/tests/config-linkage/config-persistence.spec.ts deleted file mode 100644 index 1fd026b..0000000 --- a/e2e/src/tests/config-linkage/config-persistence.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('前后台配置持久化测试', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/admin/login'); - await page.fill('input[type="email"]', 'admin@novalon.cn'); - await page.fill('input[type="password"]', 'admin123456'); - await page.click('button[type="submit"]'); - await page.waitForURL('/admin'); - }); - - 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('..'); - - await servicesConfig.locator('input[type="checkbox"]').first().check(); - await servicesConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - await expect(page.locator('#services')).toBeVisible(); - - await adminPage.reload(); - await adminPage.waitForLoadState('networkidle'); - - const checkbox = servicesConfig.locator('input[type="checkbox"]').first(); - await expect(checkbox).toBeChecked(); - }); - - 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"]'); - await displayCountInput.fill('3'); - 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(3); - }); - - test('配置删除后前台不再显示', async ({ page, context }) => { - const adminPage = await context.newPage(); - - await adminPage.goto('/admin/settings'); - await adminPage.waitForLoadState('networkidle'); - - const productsConfig = adminPage.locator('text=feature_products').locator('..').locator('..'); - - await productsConfig.locator('input[type="checkbox"]').first().check(); - await productsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - await expect(page.locator('#products')).toBeVisible(); - - await productsConfig.locator('input[type="checkbox"]').first().uncheck(); - await productsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.reload(); - await page.waitForLoadState('networkidle'); - await expect(page.locator('#products')).not.toBeVisible(); - }); - - 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 textarea = servicesConfig.locator('textarea'); - - await textarea.fill('erp'); - await servicesConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - const serviceCards = page.locator('#services .card'); - const count = await serviceCards.count(); - expect(count).toBe(1); - - await textarea.fill('erp\ncrm'); - await servicesConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.reload(); - await page.waitForLoadState('networkidle'); - - const newCount = await serviceCards.count(); - expect(newCount).toBe(2); - }); -}); diff --git a/e2e/src/tests/config-linkage/config-toggle.spec.ts b/e2e/src/tests/config-linkage/config-toggle.spec.ts deleted file mode 100644 index 20d76df..0000000 --- a/e2e/src/tests/config-linkage/config-toggle.spec.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('前后台配置联动测试', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - }); - - test('配置开关 - 服务模块显示/隐藏', async ({ page, context }) => { - const adminPage = await context.newPage(); - - await adminPage.goto('/admin/login'); - await adminPage.fill('input[type="email"]', 'admin@novalon.cn'); - await adminPage.fill('input[type="password"]', 'admin123456'); - await adminPage.click('button[type="submit"]'); - await adminPage.waitForURL('/admin'); - - await adminPage.goto('/admin/settings'); - await adminPage.waitForLoadState('networkidle'); - - const servicesConfig = adminPage.locator('text=feature_services').locator('..').locator('..'); - await servicesConfig.locator('input[type="checkbox"]').first().check(); - await servicesConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - await expect(page.locator('#services')).toBeVisible(); - - await servicesConfig.locator('input[type="checkbox"]').first().uncheck(); - await servicesConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.reload(); - await page.waitForLoadState('networkidle'); - await expect(page.locator('#services')).not.toBeVisible(); - - await servicesConfig.locator('input[type="checkbox"]').first().check(); - await servicesConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await adminPage.close(); - }); - - test('配置开关 - 产品模块显示/隐藏', async ({ page, context }) => { - const adminPage = await context.newPage(); - - await adminPage.goto('/admin/login'); - await adminPage.fill('input[type="email"]', 'admin@novalon.cn'); - await adminPage.fill('input[type="password"]', 'admin123456'); - await adminPage.click('button[type="submit"]'); - await adminPage.waitForURL('/admin'); - - await adminPage.goto('/admin/settings'); - await adminPage.waitForLoadState('networkidle'); - - const productsConfig = adminPage.locator('text=feature_products').locator('..').locator('..'); - await productsConfig.locator('input[type="checkbox"]').first().check(); - await productsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - await expect(page.locator('#products')).toBeVisible(); - - await productsConfig.locator('input[type="checkbox"]').first().uncheck(); - await productsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.reload(); - await page.waitForLoadState('networkidle'); - await expect(page.locator('#products')).not.toBeVisible(); - - await productsConfig.locator('input[type="checkbox"]').first().check(); - await productsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await adminPage.close(); - }); - - test('配置开关 - 新闻模块显示/隐藏', async ({ page, context }) => { - const adminPage = await context.newPage(); - - await adminPage.goto('/admin/login'); - await adminPage.fill('input[type="email"]', 'admin@novalon.cn'); - await adminPage.fill('input[type="password"]', 'admin123456'); - await adminPage.click('button[type="submit"]'); - await adminPage.waitForURL('/admin'); - - await adminPage.goto('/admin/settings'); - await adminPage.waitForLoadState('networkidle'); - - const newsConfig = adminPage.locator('text=feature_news').locator('..').locator('..'); - await newsConfig.locator('input[type="checkbox"]').first().check(); - await newsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.goto('/'); - await page.waitForLoadState('networkidle'); - await expect(page.locator('#news')).toBeVisible(); - - await newsConfig.locator('input[type="checkbox"]').first().uncheck(); - await newsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await page.reload(); - await page.waitForLoadState('networkidle'); - await expect(page.locator('#news')).not.toBeVisible(); - - await newsConfig.locator('input[type="checkbox"]').first().check(); - await newsConfig.locator('button:has-text("保存")').click(); - await adminPage.waitForSelector('text=保存成功', { timeout: 5000 }); - - await adminPage.close(); - }); -}); \ No newline at end of file diff --git a/e2e/src/tests/contact-form-security.spec.ts b/e2e/src/tests/contact-form-security.spec.ts deleted file mode 100644 index 9ec54cf..0000000 --- a/e2e/src/tests/contact-form-security.spec.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { ContactFormPage } from '../pages/ContactFormPage'; - -test.describe('Contact Form Security E2E Tests', () => { - let contactPage: ContactFormPage; - - test.beforeEach(async ({ page }) => { - contactPage = new ContactFormPage(page); - await contactPage.goto(); - }); - - test.describe('Captcha Functionality', () => { - test('should display captcha question', async () => { - const question = await contactPage.getCaptchaQuestion(); - expect(question).toMatch(/\d+\s*[+\-×÷]\s*\d+\s*=/); - }); - - test('should refresh captcha when refresh button is clicked', async () => { - const firstQuestion = await contactPage.getCaptchaQuestion(); - await contactPage.refreshCaptcha(); - const secondQuestion = await contactPage.getCaptchaQuestion(); - expect(secondQuestion).toBeTruthy(); - }); - - test('should submit form with correct captcha', async ({ page }) => { - const formData = { - name: '张三', - phone: '13800138000', - email: 'test@example.com', - message: '这是一条测试留言内容', - }; - - await contactPage.submitForm(formData); - - await expect(page).toHaveURL(/\/contact/); - const successMessage = await contactPage.getSuccessMessage(); - expect(successMessage).toContain('成功'); - }); - - test('should show error for incorrect captcha', async ({ page }) => { - const formData = { - name: '张三', - phone: '13800138000', - email: 'test@example.com', - message: '这是一条测试留言内容', - }; - - await contactPage.fillForm(formData); - await contactPage.captchaInput.fill('999'); - await contactPage.submit(); - - const captchaError = await contactPage.getCaptchaErrorMessage(); - expect(captchaError).toContain('验证码错误'); - }); - }); - - test.describe('Form Validation', () => { - test('should validate name field', async ({ page }) => { - await contactPage.nameInput.fill(''); - await contactPage.nameInput.blur(); - - const errorMessage = await contactPage.getErrorMessage(); - expect(errorMessage).toBeTruthy(); - }); - - test('should validate phone field', async ({ page }) => { - await contactPage.phoneInput.fill('123'); - await contactPage.phoneInput.blur(); - - const errorMessage = await contactPage.getErrorMessage(); - expect(errorMessage).toBeTruthy(); - }); - - test('should validate email field', async ({ page }) => { - await contactPage.emailInput.fill('invalid-email'); - await contactPage.emailInput.blur(); - - const errorMessage = await contactPage.getErrorMessage(); - expect(errorMessage).toBeTruthy(); - }); - - test('should validate message field', async ({ page }) => { - await contactPage.messageInput.fill('太短'); - await contactPage.messageInput.blur(); - - const errorMessage = await contactPage.getErrorMessage(); - expect(errorMessage).toBeTruthy(); - }); - }); - - test.describe('Security Features', () => { - test('should prevent XSS attacks in form fields', async ({ page }) => { - const xssPayload = ''; - - await contactPage.nameInput.fill(xssPayload); - await contactPage.messageInput.fill(xssPayload); - await contactPage.solveCaptcha(); - await contactPage.submit(); - - await expect(page.locator('script')).not.toBeAttached(); - }); - - test('should handle SQL injection attempts', async ({ page }) => { - const sqlPayload = "'; DROP TABLE users; --"; - - await contactPage.nameInput.fill(sqlPayload); - await contactPage.messageInput.fill(sqlPayload); - await contactPage.solveCaptcha(); - await contactPage.submit(); - - const successMessage = await contactPage.getSuccessMessage(); - expect(successMessage).toBeNull(); - }); - - test('should sanitize malicious content', async ({ page }) => { - const maliciousContent = ''; - - await contactPage.messageInput.fill(maliciousContent); - await contactPage.solveCaptcha(); - await contactPage.submit(); - - await expect(page.locator('img[onerror]')).not.toBeAttached(); - }); - }); - - test.describe('Rate Limiting', () => { - test('should enforce rate limiting on rapid submissions', async ({ page }) => { - const formData = { - name: '张三', - phone: '13800138000', - email: 'test@example.com', - message: '这是一条测试留言内容', - }; - - let submissionCount = 0; - let rateLimited = false; - - for (let i = 0; i < 15; i++) { - await contactPage.goto(); - await contactPage.fillForm(formData); - await contactPage.solveCaptcha(); - await contactPage.submit(); - - const errorMessage = await contactPage.getErrorMessage(); - if (errorMessage && errorMessage.includes('过于频繁')) { - rateLimited = true; - break; - } - - submissionCount++; - await page.waitForTimeout(100); - } - - expect(rateLimited).toBe(true); - }); - - test('should allow submissions after rate limit window', async ({ page }) => { - const formData = { - name: '张三', - phone: '13800138000', - email: 'test@example.com', - message: '这是一条测试留言内容', - }; - - await contactPage.submitForm(formData); - await page.waitForTimeout(61000); - - await contactPage.goto(); - await contactPage.submitForm(formData); - - const successMessage = await contactPage.getSuccessMessage(); - expect(successMessage).toContain('成功'); - }); - }); - - test.describe('Accessibility', () => { - test('should have proper form labels', async () => { - await expect(contactPage.nameInput).toBeVisible(); - await expect(contactPage.phoneInput).toBeVisible(); - await expect(contactPage.emailInput).toBeVisible(); - await expect(contactPage.messageInput).toBeVisible(); - await expect(contactPage.captchaInput).toBeVisible(); - }); - - test('should be keyboard navigable', async ({ page }) => { - await contactPage.nameInput.focus(); - await page.keyboard.press('Tab'); - await expect(contactPage.phoneInput).toBeFocused(); - - await page.keyboard.press('Tab'); - await expect(contactPage.emailInput).toBeFocused(); - - await page.keyboard.press('Tab'); - await expect(contactPage.messageInput).toBeFocused(); - - await page.keyboard.press('Tab'); - await expect(contactPage.captchaInput).toBeFocused(); - - await page.keyboard.press('Tab'); - await expect(contactPage.submitButton).toBeFocused(); - }); - }); - - test.describe('Responsive Design', () => { - test('should work on mobile devices', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 667 }); - await contactPage.goto(); - - await expect(contactPage.nameInput).toBeVisible(); - await expect(contactPage.submitButton).toBeVisible(); - }); - - test('should work on tablet devices', async ({ page }) => { - await page.setViewportSize({ width: 768, height: 1024 }); - await contactPage.goto(); - - await expect(contactPage.nameInput).toBeVisible(); - await expect(contactPage.submitButton).toBeVisible(); - }); - }); - - test.describe('User Flow', () => { - test('should complete full contact form submission flow', async ({ page }) => { - await test.step('Navigate to contact page', async () => { - await contactPage.goto(); - await expect(page).toHaveURL(/\/contact/); - }); - - await test.step('Fill in all required fields', async () => { - const formData = { - name: '李四', - phone: '13900139000', - email: 'lisi@example.com', - message: '我想咨询贵公司的服务详情,请尽快联系我。', - }; - await contactPage.fillForm(formData); - }); - - await test.step('Solve captcha', async () => { - await contactPage.solveCaptcha(); - }); - - await test.step('Submit form', async () => { - await contactPage.submit(); - }); - - await test.step('Verify success message', async () => { - await contactPage.waitForSuccessMessage(); - const successMessage = await contactPage.getSuccessMessage(); - expect(successMessage).toContain('成功'); - }); - }); - }); -}); diff --git a/e2e/src/tests/contact-form.spec.ts b/e2e/src/tests/contact-form.spec.ts deleted file mode 100644 index 6c99df2..0000000 --- a/e2e/src/tests/contact-form.spec.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Contact Form E2E Tests', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/contact'); - }); - - test.describe('Form Rendering', () => { - test('should display contact form', async ({ page }) => { - await expect(page.getByTestId('name-input')).toBeVisible(); - await expect(page.getByTestId('phone-input')).toBeVisible(); - await expect(page.getByTestId('email-input')).toBeVisible(); - await expect(page.getByTestId('subject-input')).toBeVisible(); - await expect(page.getByTestId('message-input')).toBeVisible(); - await expect(page.getByTestId('submit-button')).toBeVisible(); - }); - - test('should display contact information', async ({ page }) => { - await expect(page.getByTestId('contact-info')).toBeVisible(); - await expect(page.getByTestId('email-link')).toBeVisible(); - await expect(page.getByTestId('phone-link')).toBeVisible(); - await expect(page.getByTestId('address-text')).toBeVisible(); - }); - - test('should display work hours', async ({ page }) => { - await expect(page.getByTestId('work-hours-card')).toBeVisible(); - await expect(page.getByText('9:00 - 18:00')).toBeVisible(); - }); - }); - - test.describe('Form Validation', () => { - test('should validate name field', async ({ page }) => { - await page.getByTestId('name-input').fill(''); - await page.getByTestId('name-input').blur(); - - await page.getByTestId('submit-button').click(); - - await expect(page.getByText('姓名至少需要2个字符')).toBeVisible(); - }); - - test('should validate phone field', async ({ page }) => { - await page.getByTestId('phone-input').fill('123'); - await page.getByTestId('phone-input').blur(); - - await page.getByTestId('submit-button').click(); - - await expect(page.getByText('请输入有效的手机号码')).toBeVisible(); - }); - - test('should validate email field', async ({ page }) => { - await page.getByTestId('email-input').fill('invalid-email'); - await page.getByTestId('email-input').blur(); - - await page.getByTestId('submit-button').click(); - - await expect(page.getByText('请输入有效的邮箱地址')).toBeVisible(); - }); - - test('should validate subject field', async ({ page }) => { - await page.getByTestId('subject-input').fill(''); - await page.getByTestId('subject-input').blur(); - - await page.getByTestId('submit-button').click(); - - await expect(page.getByText('主题至少需要2个字符')).toBeVisible(); - }); - - test('should validate message field', async ({ page }) => { - await page.getByTestId('message-input').fill('太短'); - await page.getByTestId('message-input').blur(); - - await page.getByTestId('submit-button').click(); - - await expect(page.getByText('留言内容至少需要10个字符')).toBeVisible(); - }); - }); - - test.describe('Form Submission', () => { - test('should validate form submission without email service', async ({ page }) => { - await page.getByTestId('name-input').fill('张三'); - await page.getByTestId('phone-input').fill('13800138000'); - await page.getByTestId('email-input').fill('test@example.com'); - await page.getByTestId('subject-input').fill('咨询业务'); - await page.getByTestId('message-input').fill('我想咨询贵公司的服务详情,请尽快联系我。'); - - await page.getByTestId('submit-button').click(); - - await expect(page.getByText('消息已发送')).toBeVisible(); - }); - - test('should show loading state during submission', async ({ page }) => { - await page.getByTestId('name-input').fill('张三'); - await page.getByTestId('phone-input').fill('13800138000'); - await page.getByTestId('email-input').fill('test@example.com'); - await page.getByTestId('subject-input').fill('咨询业务'); - await page.getByTestId('message-input').fill('我想咨询贵公司的服务详情,请尽快联系我。'); - - await page.getByTestId('submit-button').click(); - - await expect(page.getByTestId('submit-button')).toBeDisabled(); - }); - - test('should reset form after successful submission', async ({ page }) => { - await page.getByTestId('name-input').fill('张三'); - await page.getByTestId('phone-input').fill('13800138000'); - await page.getByTestId('email-input').fill('test@example.com'); - await page.getByTestId('subject-input').fill('咨询业务'); - await page.getByTestId('message-input').fill('我想咨询贵公司的服务详情,请尽快联系我。'); - - await page.getByTestId('submit-button').click(); - - await expect(page.getByText('消息已发送')).toBeVisible(); - - await page.reload(); - - await expect(page.getByTestId('name-input')).toHaveValue(''); - await expect(page.getByTestId('phone-input')).toHaveValue(''); - await expect(page.getByTestId('email-input')).toHaveValue(''); - await expect(page.getByTestId('subject-input')).toHaveValue(''); - await expect(page.getByTestId('message-input')).toHaveValue(''); - }); - }); - - test.describe('Security Features', () => { - test('should have CSRF token', async ({ page }) => { - const csrfToken = await page.locator('input[name="_csrf"]').inputValue(); - expect(csrfToken).toBeTruthy(); - expect(csrfToken.length).toBeGreaterThan(0); - }); - - test('should have honeypot field', async ({ page }) => { - const honeypot = page.locator('input[name="website"]'); - await expect(honeypot).toHaveAttribute('style', /display:\s*none/); - await expect(honeypot).toHaveAttribute('tabIndex', '-1'); - }); - - test('should sanitize input on change', async ({ page }) => { - const xssPayload = ''; - - await page.getByTestId('name-input').fill(xssPayload); - const nameValue = await page.getByTestId('name-input').inputValue(); - - expect(nameValue).not.toContain('', - '', - '', - '">', - ''; - - await page.fill('input[id="name"]', xssPayload); - await page.fill('input[id="email"]', 'test@example.com'); - await page.fill('input[id="phone"]', '13800138000'); - await page.fill('input[id="subject"]', 'Test Subject'); - await page.fill('textarea[id="message"]', 'Test message'); - - const nameInput = page.locator('input[id="name"]'); - const inputValue = await nameInput.inputValue(); - expect(inputValue).not.toContain('onerror'); - expect(inputValue).not.toContain('alert'); - }); - - test('should sanitize javascript protocol in contact form', async ({ page }) => { - const xssPayload = 'javascript:alert("XSS")'; - - await page.fill('input[id="name"]', 'Test Name'); - await page.fill('input[id="email"]', 'test@example.com'); - await page.fill('input[id="phone"]', '13800138000'); - await page.fill('input[id="subject"]', xssPayload); - await page.fill('textarea[id="message"]', 'Test message'); - - const subjectInput = page.locator('input[id="subject"]'); - const inputValue = await subjectInput.inputValue(); - expect(inputValue.trim().length).toBeGreaterThan(0); - }); - - test('should sanitize HTML entities in contact form', async ({ page }) => { - const htmlPayload = '
Click me
'; - - await page.fill('input[id="name"]', htmlPayload); - await page.fill('input[id="email"]', 'test@example.com'); - await page.fill('input[id="phone"]', '13800138000'); - await page.fill('input[id="subject"]', 'Test Subject'); - await page.fill('textarea[id="message"]', 'Test message'); - - const nameInput = page.locator('input[id="name"]'); - const inputValue = await nameInput.inputValue(); - expect(inputValue).not.toContain('onclick'); - expect(inputValue).not.toContain('alert(1)'); - }); - - test('should handle special characters safely', async ({ page }) => { - const specialChars = '<>&"\''; - - await page.fill('input[id="name"]', specialChars); - await page.fill('input[id="email"]', 'test@example.com'); - await page.fill('input[id="phone"]', '13800138000'); - await page.fill('input[id="subject"]', 'Test Subject'); - await page.fill('textarea[id="message"]', 'Test message'); - - const nameInput = page.locator('input[id="name"]'); - const inputValue = await nameInput.inputValue(); - expect(inputValue.trim().length).toBeGreaterThan(0); - }); - - test('should not execute XSS via URL parameters', async ({ page }) => { - const xssUrl = '/contact?name='; - await page.goto(xssUrl); - await page.waitForLoadState('networkidle'); - - const pageContent = await page.content(); - expect(pageContent).not.toContain(''); - }); -}); diff --git a/e2e/src/tests/smoke/admin.smoke.spec.ts b/e2e/src/tests/smoke/admin.smoke.spec.ts deleted file mode 100644 index d4288d0..0000000 --- a/e2e/src/tests/smoke/admin.smoke.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { test, expect } from '../../fixtures/base.fixture'; -import { AdminLoginPage, AdminDashboardPage } from '../../pages/AdminPage'; - -test.describe('管理后台冒烟测试', () => { - let loginPage: AdminLoginPage; - let dashboardPage: AdminDashboardPage; - - test.beforeEach(async ({ page }) => { - loginPage = new AdminLoginPage(page); - dashboardPage = new AdminDashboardPage(page); - }); - - test('应该显示登录页面', async ({ page }) => { - await loginPage.goto(); - - await expect(loginPage.emailInput).toBeVisible(); - await expect(loginPage.passwordInput).toBeVisible(); - await expect(loginPage.loginButton).toBeVisible(); - }); - - test('登录失败应该显示错误信息', async ({ page }) => { - await loginPage.goto(); - - await loginPage.login('invalid@example.com', 'wrongpassword'); - - await loginPage.expectLoginError(); - await expect(loginPage.errorMessage).toBeVisible(); - }); - - test('未登录访问管理页面应该显示登录提示', async ({ page }) => { - await page.goto('/admin'); - - await expect(page.locator('text=请先登录')).toBeVisible(); - await expect(page.getByRole('link', { name: /前往登录/i })).toBeVisible(); - }); - - test('导航菜单应该包含所有必要项', async ({ page }) => { - await loginPage.goto(); - await loginPage.login('admin@novalon.cn', 'admin123456'); - - await page.waitForURL(/\/admin(?!\/login)/, { timeout: 20000 }); - await page.waitForLoadState('networkidle', { timeout: 10000 }); - - await expect(dashboardPage.sidebar).toBeVisible({ timeout: 10000 }); - await expect(dashboardPage.contentMenuItem).toBeVisible({ timeout: 5000 }); - await expect(dashboardPage.settingsMenuItem).toBeVisible({ timeout: 5000 }); - await expect(dashboardPage.usersMenuItem).toBeVisible({ timeout: 5000 }); - await expect(dashboardPage.logsMenuItem).toBeVisible({ timeout: 5000 }); - }); -}); - -test.describe('管理后台页面加载测试', () => { - test('登录页面应该快速加载', async ({ page }) => { - const startTime = Date.now(); - await page.goto('/admin/login'); - await page.waitForLoadState('networkidle'); - const loadTime = Date.now() - startTime; - - expect(loadTime).toBeLessThan(3000); - }); -}); diff --git a/e2e/src/tests/smoke/all-pages.spec.ts b/e2e/src/tests/smoke/all-pages.spec.ts deleted file mode 100644 index 9f51402..0000000 --- a/e2e/src/tests/smoke/all-pages.spec.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { test, expect } from '../../fixtures/base.fixture'; - -test.describe('Smoke Tests - All Major Pages', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - }); - - test('Home page loads successfully', async ({ homePage }) => { - await homePage.waitForLoadState('load'); - const title = await homePage.getTitle(); - expect(title).toContain('睿新致远'); - }); - - test('About page loads successfully', async ({ aboutPage }) => { - await aboutPage.navigateToAbout(); - await aboutPage.waitForLoadState('load'); - - await expect.poll(async () => await aboutPage.verifyBreadcrumb(), { - timeout: 10000, - }).toBeTruthy(); - - await expect.poll(async () => await aboutPage.verifyPageHeader(), { - timeout: 10000, - }).toBeTruthy(); - - await expect.poll(async () => await aboutPage.verifyValuesSection(), { - timeout: 10000, - }).toBeTruthy(); - - await expect.poll(async () => await aboutPage.verifyMilestonesSection(), { - timeout: 10000, - }).toBeTruthy(); - - await expect.poll(async () => await aboutPage.verifyContactSection(), { - timeout: 10000, - }).toBeTruthy(); - }); - - test('Cases page loads successfully', async ({ casesPage }) => { - await casesPage.navigateToCases(); - await casesPage.waitForLoadState('load'); - - await expect.poll(async () => await casesPage.verifyBreadcrumb(), { - timeout: 10000, - }).toBeTruthy(); - - await expect.poll(async () => await casesPage.verifyPageHeader(), { - timeout: 10000, - }).toBeTruthy(); - - const caseCount = await casesPage.getCaseCount(); - expect(caseCount).toBeGreaterThan(0); - - await expect.poll(async () => await casesPage.verifyCTASection(), { - timeout: 10000, - }).toBeTruthy(); - }); - - test('Services page loads successfully', async ({ servicesPage }) => { - await servicesPage.navigateToServices(); - await servicesPage.waitForLoadState('load'); - - await expect.poll(async () => await servicesPage.verifyBreadcrumb(), { - timeout: 10000, - }).toBeTruthy(); - - await expect.poll(async () => await servicesPage.verifyPageHeader(), { - timeout: 10000, - }).toBeTruthy(); - - const serviceCount = await servicesPage.getServiceCount(); - expect(serviceCount).toBeGreaterThan(0); - - await expect.poll(async () => await servicesPage.verifyCTASection(), { - timeout: 10000, - }).toBeTruthy(); - }); - - test('Products page loads successfully', async ({ productsPage }) => { - await productsPage.navigateToProducts(); - await productsPage.waitForLoadState('load'); - - await expect.poll(async () => await productsPage.verifyBreadcrumb(), { - timeout: 10000, - }).toBeTruthy(); - - await expect.poll(async () => await productsPage.verifyPageHeader(), { - timeout: 10000, - }).toBeTruthy(); - - const productCount = await productsPage.getProductCount(); - expect(productCount).toBeGreaterThan(0); - - await expect.poll(async () => await productsPage.verifyCTASection(), { - timeout: 10000, - }).toBeTruthy(); - }); - - test('Solutions page loads successfully', async ({ solutionsPage }) => { - await solutionsPage.navigateToSolutions(); - await solutionsPage.waitForLoadState('load'); - - await expect.poll(async () => await solutionsPage.verifyBreadcrumb(), { - timeout: 10000, - }).toBeTruthy(); - - await expect.poll(async () => await solutionsPage.verifyPageHeader(), { - timeout: 10000, - }).toBeTruthy(); - - await expect.poll(async () => await solutionsPage.verifyAllModules(), { - timeout: 10000, - }).toBeTruthy(); - - await expect.poll(async () => await solutionsPage.verifyCTASection(), { - timeout: 10000, - }).toBeTruthy(); - }); - - test('News page loads successfully', async ({ newsPage }) => { - await newsPage.navigateToNews(); - await newsPage.waitForLoadState('load'); - - await expect.poll(async () => await newsPage.verifyBreadcrumb(), { - timeout: 10000, - }).toBeTruthy(); - - await expect.poll(async () => await newsPage.verifyPageHeader(), { - timeout: 10000, - }).toBeTruthy(); - - const newsCount = await newsPage.getNewsCount(); - expect(newsCount).toBeGreaterThan(0); - }); - - test('Contact page loads successfully', async ({ contactPage }) => { - await contactPage.navigateToContact(); - await contactPage.waitForLoadState('load'); - - await expect.poll(async () => await contactPage.verifyBreadcrumb(), { - timeout: 10000, - }).toBeTruthy(); - - await expect.poll(async () => await contactPage.verifyPageHeader(), { - timeout: 10000, - }).toBeTruthy(); - - await expect.poll(async () => await contactPage.verifyContactForm(), { - timeout: 10000, - }).toBeTruthy(); - - await expect.poll(async () => await contactPage.verifyContactInfo(), { - timeout: 10000, - }).toBeTruthy(); - }); - - test('Navigation between pages works', async ({ page, aboutPage, casesPage, servicesPage }) => { - await aboutPage.navigateToAbout(); - await aboutPage.waitForLoadState('load'); - const aboutURL = await aboutPage.getCurrentURL(); - expect(aboutURL).toContain('/about'); - - await casesPage.navigateToCases(); - await casesPage.waitForLoadState('load'); - const casesURL = await casesPage.getCurrentURL(); - expect(casesURL).toContain('/cases'); - - await servicesPage.navigateToServices(); - await servicesPage.waitForLoadState('load'); - const servicesURL = await servicesPage.getCurrentURL(); - expect(servicesURL).toContain('/services'); - }); - - test('Breadcrumb navigation works correctly', async ({ page, aboutPage, casesPage }) => { - await aboutPage.navigateToAbout(); - await aboutPage.waitForLoadState('load'); - - const breadcrumbLinks = page.locator('nav[aria-label="breadcrumb"] a'); - const linkCount = await breadcrumbLinks.count(); - expect(linkCount).toBeGreaterThan(0); - - await casesPage.navigateToCases(); - await casesPage.waitForLoadState('load'); - - const breadcrumbText = await page.locator('nav[aria-label="breadcrumb"]').textContent(); - expect(breadcrumbText).toContain('成功案例'); - }); - - test('All pages have consistent navigation', async ({ page }) => { - const pages = ['/about', '/cases', '/services', '/products', '/solutions', '/news', '/contact']; - - for (const pagePath of pages) { - await page.goto(pagePath); - await page.waitForLoadState('load'); - - const header = page.locator('header'); - await expect.poll(async () => await header.isVisible(), { - timeout: 10000, - }).toBeTruthy(); - - const breadcrumb = page.locator('nav[aria-label="breadcrumb"]'); - await expect.poll(async () => await breadcrumb.isVisible(), { - timeout: 10000, - }).toBeTruthy(); - } - }); -}); diff --git a/e2e/src/tests/smoke/contact-page.smoke.spec.ts b/e2e/src/tests/smoke/contact-page.smoke.spec.ts deleted file mode 100644 index 577cc28..0000000 --- a/e2e/src/tests/smoke/contact-page.smoke.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { ContactPage } from '../../pages/ContactPage'; - -test.describe('联系页面冒烟测试 @smoke', () => { - test.beforeEach(async ({ page }) => { - const contactPage = new ContactPage(page); - await contactPage.goto(); - await contactPage.waitForPageLoad(); - }); - - test('应该成功加载联系页面', async ({ page }) => { - await expect(page).toHaveURL(/\/contact/); - }); - - test('应该显示页面标题', async ({ page }) => { - const title = await page.title(); - expect(title).toBeTruthy(); - expect(title.length).toBeGreaterThan(0); - }); - - test('应该显示联系表单', async ({ page }) => { - const form = page.locator('form, .contact-form, #contact-form'); - await expect(form.first()).toBeVisible(); - }); - - test('应该显示姓名输入框', async ({ page }) => { - const nameInput = page.locator('input[name="name"]'); - await nameInput.waitFor({ state: 'visible', timeout: 10000 }); - await expect(nameInput).toBeVisible(); - }); - - test('应该显示邮箱输入框', async ({ page }) => { - const emailInput = page.locator('input[name="email"], input[type="email"], #email'); - await expect(emailInput.first()).toBeVisible(); - }); - - test('应该显示消息输入框', async ({ page }) => { - const messageInput = page.locator('textarea[name="message"], #message'); - await expect(messageInput.first()).toBeVisible(); - }); - - test('应该显示提交按钮', async ({ page }) => { - const submitButton = page.locator('button[type="submit"], input[type="submit"], .submit-button'); - await expect(submitButton.first()).toBeVisible(); - }); - - test('应该能够填写表单', async ({ page }) => { - const nameInput = page.locator('input[name="name"]'); - const phoneInput = page.locator('input[name="phone"]'); - const emailInput = page.locator('input[name="email"]'); - const subjectInput = page.locator('input[name="subject"]'); - const messageInput = page.locator('textarea[name="message"]'); - - await nameInput.waitFor({ state: 'visible', timeout: 10000 }); - await nameInput.fill('测试用户'); - await phoneInput.fill('13800138000'); - await emailInput.fill('test@example.com'); - await subjectInput.fill('测试主题'); - await messageInput.fill('这是一条测试消息,至少需要10个字符'); - }); - - test('应该显示联系信息', async ({ page }) => { - const contactInfo = page.locator('.contact-info, .contact-details, [class*="contact"]'); - const count = await contactInfo.count(); - if (count > 0) { - await expect(contactInfo.first()).toBeVisible(); - } - }); - - test('应该显示公司地址', async ({ page }) => { - const address = page.locator('[class*="address"], .address'); - const count = await address.count(); - if (count > 0) { - await expect(address.first()).toBeVisible(); - } - }); - - test('应该显示电话号码', async ({ page }) => { - const phone = page.locator('[class*="phone"], .phone, a[href^="tel:"]'); - const count = await phone.count(); - if (count > 0) { - await expect(phone.first()).toBeVisible(); - } - }); - - test('应该显示邮箱地址', async ({ page }) => { - const email = page.locator('[class*="email"], .email, a[href^="mailto:"]'); - const count = await email.count(); - if (count > 0) { - await expect(email.first()).toBeVisible(); - } - }); - - test('应该没有JavaScript错误', async ({ page }) => { - const errors: string[] = []; - page.on('pageerror', error => { - errors.push(error.message); - }); - await page.waitForLoadState('networkidle'); - expect(errors.length).toBe(0); - }); - - test('应该响应式布局', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 667 }); - const form = page.locator('form, .contact-form, #contact-form'); - await expect(form.first()).toBeVisible(); - }); - - test('应该有正确的meta标签', async ({ page }) => { - const description = await page.locator('meta[name="description"]').getAttribute('content'); - expect(description).toBeTruthy(); - expect(description!.length).toBeGreaterThan(0); - }); - - test('应该能够提交表单', async ({ page }) => { - const nameInput = page.locator('input[name="name"]'); - const phoneInput = page.locator('input[name="phone"]'); - const emailInput = page.locator('input[name="email"]'); - const subjectInput = page.locator('input[name="subject"]'); - const messageInput = page.locator('textarea[name="message"]'); - const submitButton = page.locator('button[type="submit"]'); - - await nameInput.waitFor({ state: 'visible', timeout: 10000 }); - await nameInput.fill('测试用户'); - await phoneInput.fill('13800138000'); - await emailInput.fill('test@example.com'); - await subjectInput.fill('测试主题'); - await messageInput.fill('这是一条测试消息,至少需要10个字符'); - await submitButton.click(); - await page.waitForTimeout(2000); - }); - - test('应该显示页脚', async ({ page }) => { - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - const footer = page.locator('footer, .footer'); - await expect(footer.first()).toBeVisible(); - }); - - test('应该没有控制台错误', async ({ page }) => { - const errors: string[] = []; - page.on('console', msg => { - if (msg.type() === 'error') { - errors.push(msg.text()); - } - }); - await page.waitForLoadState('networkidle'); - expect(errors.length).toBe(0); - }); -}); diff --git a/e2e/src/tests/smoke/home-page.smoke.spec.ts b/e2e/src/tests/smoke/home-page.smoke.spec.ts deleted file mode 100644 index 8aa2575..0000000 --- a/e2e/src/tests/smoke/home-page.smoke.spec.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { HomePage } from '../../pages/HomePage'; - -test.describe('首页冒烟测试 @smoke', () => { - test.beforeEach(async ({ page }) => { - const homePage = new HomePage(page); - await homePage.goto(); - await homePage.waitForPageLoad(); - }); - - test('应该成功加载首页', async ({ page }) => { - await expect(page).toHaveURL(/\/$/); - }); - - test('应该显示页面标题', async ({ page }) => { - const title = await page.title(); - expect(title).toBeTruthy(); - expect(title.length).toBeGreaterThan(0); - }); - - test('应该显示主要内容区域', async ({ page }) => { - const main = page.locator('main, [role="main"], .main-content'); - await expect(main.first()).toBeVisible(); - }); - - test('应该显示页脚', async ({ page }) => { - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - const footer = page.locator('footer, .footer'); - await expect(footer.first()).toBeVisible(); - }); - - test('应该没有JavaScript错误', async ({ page }) => { - const errors: string[] = []; - page.on('pageerror', error => { - errors.push(error.message); - }); - await page.waitForLoadState('networkidle'); - expect(errors.length).toBe(0); - }); - - test('应该能够滚动页面', async ({ page }) => { - const bodyHeight = await page.evaluate(() => document.body.scrollHeight); - const viewportHeight = await page.evaluate(() => window.innerHeight); - - if (bodyHeight > viewportHeight) { - await page.evaluate(() => window.scrollTo(0, 500)); - await page.waitForTimeout(100); - const afterScrollY = await page.evaluate(() => window.scrollY); - expect(afterScrollY).toBeGreaterThanOrEqual(0); - } else { - const initialScrollY = await page.evaluate(() => window.scrollY); - expect(initialScrollY).toBe(0); - } - }); - - test('应该响应式布局', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 667 }); - const main = page.locator('main, [role="main"], .main-content'); - await expect(main.first()).toBeVisible(); - }); - - test('应该有正确的meta标签', async ({ page }) => { - const description = await page.locator('meta[name="description"]').getAttribute('content'); - expect(description).toBeTruthy(); - expect(description!.length).toBeGreaterThan(0); - }); - - test('应该加载所有图片', async ({ page }) => { - const images = page.locator('img'); - const count = await images.count(); - if (count > 0) { - for (let i = 0; i < Math.min(count, 10); i++) { - await expect(images.nth(i)).toBeVisible(); - } - } - }); - - test('应该有可访问的链接', async ({ page }) => { - const links = page.locator('a[href]'); - const count = await links.count(); - expect(count).toBeGreaterThan(0); - }); - - test('应该没有控制台错误', async ({ page }) => { - const errors: string[] = []; - page.on('console', msg => { - if (msg.type() === 'error') { - errors.push(msg.text()); - } - }); - await page.waitForLoadState('networkidle'); - expect(errors.length).toBe(0); - }); - - test('应该正确处理404错误', async ({ page }) => { - await page.goto('/non-existent-page'); - await page.waitForLoadState('domcontentloaded'); - const pageContent = await page.content(); - const has404 = pageContent.includes('404') || pageContent.includes('未找到') || pageContent.includes('Not Found'); - expect(has404).toBe(true); - }); - - test('应该有正确的字符编码', async ({ page }) => { - const charset = await page.locator('meta[charset]').getAttribute('charset'); - expect(charset?.toLowerCase()).toBe('utf-8'); - }); - - test('应该有视口meta标签', async ({ page }) => { - const viewport = await page.locator('meta[name="viewport"]').getAttribute('content'); - expect(viewport).toBeTruthy(); - expect(viewport).toContain('width=device-width'); - }); - - test('应该能够返回顶部', async ({ page }) => { - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - - const pageHeight = await page.evaluate(() => document.body.scrollHeight); - const viewportHeight = await page.evaluate(() => window.innerHeight); - - if (pageHeight <= viewportHeight) { - console.log('页面内容不足以滚动,跳过滚动测试'); - expect(pageHeight).toBeGreaterThan(0); - return; - } - - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - await page.waitForTimeout(1000); - - const bottomScrollY = await page.evaluate(() => window.scrollY); - expect(bottomScrollY).toBeGreaterThan(0); - - await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })); - await page.waitForTimeout(1000); - - const scrollY = await page.evaluate(() => window.scrollY); - expect(scrollY).toBeLessThan(bottomScrollY); - }); - - test('应该有正确的页面结构', async ({ page }) => { - const header = page.locator('header, .header'); - const main = page.locator('main, [role="main"]'); - const footer = page.locator('footer, .footer'); - await expect(header.first()).toBeVisible(); - await expect(main.first()).toBeVisible(); - await expect(footer.first()).toBeVisible(); - }); -}); diff --git a/e2e/src/tests/smoke/navigation.smoke.spec.ts b/e2e/src/tests/smoke/navigation.smoke.spec.ts deleted file mode 100644 index 5f6af0d..0000000 --- a/e2e/src/tests/smoke/navigation.smoke.spec.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { HomePage } from '../../pages/HomePage'; - -const DESKTOP_VIEWPORT = { width: 1280, height: 720 }; -const MOBILE_VIEWPORT = { width: 375, height: 667 }; -const SCROLL_TIMEOUT = 1000; -const NAVIGATION_TIMEOUT = 2000; - -test.describe('导航冒烟测试 @smoke @critical', () => { - test.beforeEach(async ({ page }) => { - const homePage = new HomePage(page); - await homePage.goto(); - await homePage.waitForPageLoad(); - }); - - test('应该显示主导航菜单(桌面设备)', async ({ page }) => { - await page.setViewportSize(DESKTOP_VIEWPORT); - const nav = page.locator('nav, [role="navigation"]'); - await expect(nav.first()).toBeVisible(); - }); - - test('应该显示移动端菜单按钮(移动设备)', async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORT); - const mobileMenuButton = page.locator('[data-testid="mobile-menu-button"]'); - await expect(mobileMenuButton).toBeVisible(); - }); - - test('应该显示Logo链接', async ({ page }) => { - const logo = page.locator('img[alt*="睿新"], a[href="/"] img, .logo'); - await expect(logo.first()).toBeVisible(); - const altText = await logo.first().getAttribute('alt'); - expect(altText).toBeTruthy(); - expect(altText).toContain('睿新'); - }); - - test('应该有导航项(桌面设备)', async ({ page }) => { - await page.setViewportSize(DESKTOP_VIEWPORT); - const navItems = page.locator('nav a, [role="navigation"] a'); - const count = await navItems.count(); - expect(count).toBeGreaterThan(0); - }); - - test('应该有导航项(移动设备)', async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORT); - const mobileMenuButton = page.locator('[data-testid="mobile-menu-button"]'); - await mobileMenuButton.first().click(); - const navItems = page.locator('#mobile-menu a, [role="navigation"].mobile a'); - const count = await navItems.count(); - expect(count).toBeGreaterThan(0); - }); - - test('应该能够点击导航项(桌面设备)', async ({ page }) => { - await page.setViewportSize(DESKTOP_VIEWPORT); - const navItems = page.locator('nav a, [role="navigation"] a'); - const count = await navItems.count(); - if (count > 0) { - await navItems.first().click(); - await page.waitForTimeout(SCROLL_TIMEOUT); - } - }); - - test('应该能够点击导航项(移动设备)', async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORT); - const mobileMenuButton = page.locator('[data-testid="mobile-menu-button"]'); - await mobileMenuButton.click(); - const navItems = page.locator('#mobile-menu a, [role="navigation"].mobile a'); - const count = await navItems.count(); - if (count > 0) { - await navItems.first().click(); - await page.waitForTimeout(SCROLL_TIMEOUT); - } - }); - - test('应该显示立即咨询按钮(桌面设备)', async ({ page }) => { - await page.setViewportSize(DESKTOP_VIEWPORT); - const contactButton = page.locator('a:has-text("立即咨询")').first(); - await expect(contactButton).toBeVisible(); - }); - - test('应该显示立即咨询按钮(移动设备)', async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORT); - const heroSection = page.locator('section, [role="region"]').first(); - const contactButton = heroSection.locator('a:has-text("立即咨询")'); - await expect(contactButton).toBeVisible(); - }); - - test('应该能够点击立即咨询按钮(桌面设备)', async ({ page }) => { - await page.setViewportSize(DESKTOP_VIEWPORT); - const contactButton = page.locator('a:has-text("立即咨询")').first(); - await contactButton.click(); - await page.waitForTimeout(NAVIGATION_TIMEOUT); - const url = page.url(); - expect(url).toContain('/contact'); - }); - - test('应该能够点击立即咨询按钮(移动设备)', async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORT); - const heroSection = page.locator('section, [role="region"]').first(); - const contactButton = heroSection.locator('a:has-text("立即咨询")'); - await contactButton.click(); - await page.waitForTimeout(NAVIGATION_TIMEOUT); - const url = page.url(); - expect(url).toContain('/contact'); - }); - - test('应该显示移动端菜单按钮', async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORT); - const mobileMenuButton = page.locator('[data-testid="mobile-menu-button"]'); - await expect(mobileMenuButton).toBeVisible(); - }); - - test('应该能够打开移动端菜单', async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORT); - const mobileMenuButton = page.locator('[data-testid="mobile-menu-button"]'); - await mobileMenuButton.click(); - const mobileMenu = page.locator('[data-testid="mobile-navigation"]'); - await expect(mobileMenu).toBeVisible(); - }); - - test('应该能够关闭移动端菜单', async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORT); - const mobileMenuButton = page.locator('[data-testid="mobile-menu-button"]'); - await mobileMenuButton.click(); - const mobileMenu = page.locator('[data-testid="mobile-navigation"]'); - await mobileMenuButton.click(); - await expect(mobileMenu).not.toBeVisible(); - }); - - test('应该有正确的导航标签', async ({ page }) => { - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(1000); - - const navItems = page.locator('nav a, [role="navigation"] a, header a, [data-testid="navigation"] a'); - const count = await navItems.count(); - - if (count === 0) { - console.log('导航项未找到,检查页面是否正确加载'); - const bodyContent = await page.locator('body').textContent(); - expect(bodyContent).toBeTruthy(); - expect(bodyContent!.length).toBeGreaterThan(0); - return; - } - - expect(count).toBeGreaterThan(0); - let validLabels = 0; - for (let i = 0; i < Math.min(count, 10); i++) { - const label = await navItems.nth(i).textContent(); - if (label && label.trim().length > 0) { - validLabels++; - expect(label.trim().length).toBeGreaterThan(0); - } - } - - expect(validLabels).toBeGreaterThan(0); - }); - - test('应该能够滚动到各个区块', async ({ page }) => { - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(SCROLL_TIMEOUT); - - const sections = ['services', 'products', 'cases', 'about']; - let foundSections = 0; - - for (const sectionId of sections) { - const section = page.locator(`section[id="${sectionId}"], [id*="${sectionId}"], section[data-testid*="${sectionId}"]`); - const count = await section.count(); - if (count > 0) { - foundSections++; - await section.first().scrollIntoViewIfNeeded(); - await page.waitForTimeout(500); - const isVisible = await section.first().isVisible(); - expect(isVisible).toBe(true); - } else { - console.log(`区块 ${sectionId} 未找到,跳过`); - } - } - - if (foundSections === 0) { - console.log('所有区块都未找到,检查页面内容'); - const bodyContent = await page.locator('body').textContent(); - expect(bodyContent).toBeTruthy(); - expect(bodyContent!.length).toBeGreaterThan(0); - } - }); - - test('应该能够滚动到页面顶部', async ({ page }) => { - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(SCROLL_TIMEOUT); - - const pageHeight = await page.evaluate(() => document.body.scrollHeight); - const viewportHeight = await page.evaluate(() => window.innerHeight); - - if (pageHeight <= viewportHeight) { - console.log('页面内容不足以滚动,跳过滚动测试'); - expect(pageHeight).toBeGreaterThan(0); - return; - } - - const initialScrollPosition = await page.evaluate(() => window.scrollY); - - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - await page.waitForTimeout(500); - - const bottomScrollPosition = await page.evaluate(() => window.scrollY); - expect(bottomScrollPosition).toBeGreaterThan(initialScrollPosition); - - await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })); - await page.waitForTimeout(SCROLL_TIMEOUT); - - const topScrollPosition = await page.evaluate(() => window.scrollY); - - expect(topScrollPosition).toBeLessThan(bottomScrollPosition); - expect(topScrollPosition).toBeLessThan(10000); - }); - - test('应该能够滚动到页面底部', async ({ page }) => { - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(SCROLL_TIMEOUT); - - const pageHeight = await page.evaluate(() => document.body.scrollHeight); - const viewportHeight = await page.evaluate(() => window.innerHeight); - - if (pageHeight <= viewportHeight) { - console.log('页面内容不足以滚动,跳过滚动测试'); - expect(pageHeight).toBeGreaterThan(0); - return; - } - - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - await page.waitForTimeout(500); - - const scrollPosition = await page.evaluate(() => { - return window.scrollY + window.innerHeight; - }); - - expect(scrollPosition).toBeGreaterThanOrEqual(pageHeight * 0.6); - }); - - test('应该显示所有区块', async ({ page }) => { - const sections = page.locator('section[id], div[id]'); - const count = await sections.count(); - expect(count).toBeGreaterThan(0); - }); - - test('应该能够通过导航跳转到区块(桌面设备)', async ({ page }) => { - await page.setViewportSize(DESKTOP_VIEWPORT); - await page.waitForTimeout(500); - - const navItems = page.locator('nav a[href*="#"], [role="navigation"] a[href*="#"]'); - const count = await navItems.count(); - - if (count > 0) { - const firstNavItem = navItems.first(); - const isVisible = await firstNavItem.isVisible(); - - if (isVisible) { - await firstNavItem.click(); - await page.waitForTimeout(SCROLL_TIMEOUT); - const url = page.url(); - expect(url).toContain('#'); - } else { - console.log('导航项不可见,跳过此测试'); - } - } else { - console.log('未找到导航项,跳过此测试'); - } - }); - - test('应该能够通过导航跳转到区块(移动设备)', async ({ page }) => { - await page.setViewportSize(MOBILE_VIEWPORT); - const mobileMenuButton = page.locator('[data-testid="mobile-menu-button"]'); - await mobileMenuButton.click(); - - const navItems = page.locator('#mobile-menu a[href*="#"], [role="navigation"].mobile a[href*="#"]'); - const count = await navItems.count(); - if (count > 0) { - await navItems.first().click(); - await page.waitForTimeout(SCROLL_TIMEOUT); - const url = page.url(); - expect(url).toContain('#'); - } - }); - - test('应该显示页脚', async ({ page }) => { - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - const footer = page.locator('footer, .footer'); - await expect(footer.first()).toBeVisible(); - }); - - test('应该有正确的页面标题', async ({ page }) => { - const title = await page.title(); - expect(title).toBeTruthy(); - expect(title.length).toBeGreaterThan(0); - }); - - test('应该没有控制台错误', async ({ page }) => { - const errors: string[] = []; - page.on('console', msg => { - if (msg.type() === 'error') { - errors.push(msg.text()); - } - }); - await page.waitForLoadState('networkidle'); - expect(errors.length).toBe(0); - }); -}); diff --git a/e2e/src/tests/unit/api-response.spec.ts b/e2e/src/tests/unit/api-response.spec.ts deleted file mode 100644 index 2ab23dd..0000000 --- a/e2e/src/tests/unit/api-response.spec.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { describe, test, expect } from '@playwright/test'; - -describe('API响应格式测试', () => { - describe('Success响应格式', () => { - test('验证success响应结构', () => { - const successResponse = { - success: true, - data: { key: 'value' } - }; - - expect(successResponse).toHaveProperty('success'); - expect(successResponse).toHaveProperty('data'); - expect(successResponse.success).toBe(true); - expect(typeof successResponse.data).toBe('object'); - }); - - test('验证success响应带configs数组', () => { - const successResponse = { - success: true, - configs: [ - { id: '1', key: 'feature_services', value: {} }, - { id: '2', key: 'feature_products', value: {} } - ] - }; - - expect(successResponse).toHaveProperty('success'); - expect(successResponse).toHaveProperty('configs'); - expect(successResponse.success).toBe(true); - expect(Array.isArray(successResponse.configs)).toBe(true); - expect(successResponse.configs).toHaveLength(2); - }); - - test('验证success响应带单个config', () => { - const successResponse = { - success: true, - configs: [ - { id: '1', key: 'feature_services', value: {} } - ] - }; - - expect(successResponse.success).toBe(true); - expect(successResponse.configs).toHaveLength(1); - expect(successResponse.configs[0].key).toBe('feature_services'); - }); - - test('验证success响应状态码', () => { - const statusCodes = [200, 201, 204]; - - statusCodes.forEach(statusCode => { - expect(statusCode).toBeGreaterThanOrEqual(200); - expect(statusCode).toBeLessThan(300); - }); - }); - }); - - describe('Error响应格式', () => { - test('验证error响应结构', () => { - const errorResponse = { - success: false, - error: '错误信息', - code: 'VALIDATION_ERROR' - }; - - expect(errorResponse).toHaveProperty('success'); - expect(errorResponse).toHaveProperty('error'); - expect(errorResponse).toHaveProperty('code'); - expect(errorResponse.success).toBe(false); - expect(typeof errorResponse.error).toBe('string'); - expect(typeof errorResponse.code).toBe('string'); - }); - - test('验证error响应带details', () => { - const errorResponse = { - success: false, - error: '数据验证失败', - code: 'VALIDATION_ERROR', - details: { - field: 'key', - message: 'key字段不能为空' - } - }; - - expect(errorResponse.success).toBe(false); - expect(errorResponse).toHaveProperty('details'); - expect(typeof errorResponse.details).toBe('object'); - expect(errorResponse.details.field).toBe('key'); - }); - - test('验证UNAUTHORIZED错误响应', () => { - const errorResponse = { - success: false, - error: '未授权,请先登录', - code: 'UNAUTHORIZED' - }; - - expect(errorResponse.success).toBe(false); - expect(errorResponse.code).toBe('UNAUTHORIZED'); - expect(errorResponse.error).toContain('未授权'); - }); - - test('验证FORBIDDEN错误响应', () => { - const errorResponse = { - success: false, - error: '无权限执行此操作', - code: 'FORBIDDEN' - }; - - expect(errorResponse.success).toBe(false); - expect(errorResponse.code).toBe('FORBIDDEN'); - expect(errorResponse.error).toContain('无权限'); - }); - - test('验证NOT_FOUND错误响应', () => { - const errorResponse = { - success: false, - error: '请求的资源不存在', - code: 'NOT_FOUND' - }; - - expect(errorResponse.success).toBe(false); - expect(errorResponse.code).toBe('NOT_FOUND'); - expect(errorResponse.error).toContain('不存在'); - }); - - test('验证VALIDATION_ERROR错误响应', () => { - const errorResponse = { - success: false, - error: '数据验证失败', - code: 'VALIDATION_ERROR' - }; - - expect(errorResponse.success).toBe(false); - expect(errorResponse.code).toBe('VALIDATION_ERROR'); - expect(errorResponse.error).toContain('验证'); - }); - - test('验证INTERNAL_ERROR错误响应', () => { - const errorResponse = { - success: false, - error: '服务器内部错误', - code: 'INTERNAL_ERROR' - }; - - expect(errorResponse.success).toBe(false); - expect(errorResponse.code).toBe('INTERNAL_ERROR'); - expect(errorResponse.error).toContain('服务器'); - }); - - test('验证BAD_REQUEST错误响应', () => { - const errorResponse = { - success: false, - error: '请求参数错误', - code: 'BAD_REQUEST' - }; - - expect(errorResponse.success).toBe(false); - expect(errorResponse.code).toBe('BAD_REQUEST'); - expect(errorResponse.error).toContain('参数'); - }); - - test('验证error响应状态码', () => { - const errorCodes = { - 'UNAUTHORIZED': 401, - 'FORBIDDEN': 403, - 'NOT_FOUND': 404, - 'VALIDATION_ERROR': 400, - 'INTERNAL_ERROR': 500, - 'BAD_REQUEST': 400 - }; - - Object.entries(errorCodes).forEach(([code, statusCode]) => { - expect(statusCode).toBeGreaterThanOrEqual(400); - expect(statusCode).toBeLessThan(600); - }); - }); - }); - - describe('配置API响应格式', () => { - test('验证GET /api/admin/config响应格式', () => { - const response = { - success: true, - configs: [ - { - id: '1', - key: 'feature_services', - value: { enabled: true, items: ['erp', 'crm'] }, - category: 'feature', - description: '服务功能配置', - updatedAt: '2024-01-01T00:00:00.000Z', - updatedBy: 'admin' - } - ] - }; - - expect(response.success).toBe(true); - expect(Array.isArray(response.configs)).toBe(true); - expect(response.configs[0]).toHaveProperty('id'); - expect(response.configs[0]).toHaveProperty('key'); - expect(response.configs[0]).toHaveProperty('value'); - expect(response.configs[0]).toHaveProperty('category'); - expect(response.configs[0]).toHaveProperty('description'); - expect(response.configs[0]).toHaveProperty('updatedAt'); - expect(response.configs[0]).toHaveProperty('updatedBy'); - }); - - test('验证POST /api/admin/config响应格式', () => { - const response = { - success: true, - configs: [ - { - id: '1', - key: 'feature_services', - value: { enabled: true, items: ['erp'] }, - category: 'feature', - description: '服务功能配置', - updatedAt: '2024-01-01T00:00:00.000Z', - updatedBy: 'admin' - } - ] - }; - - expect(response.success).toBe(true); - expect(response.configs).toHaveLength(1); - expect(response.configs[0].key).toBe('feature_services'); - }); - - test('验证PUT /api/admin/config响应格式', () => { - const response = { - success: true, - configs: [ - { id: '1', key: 'feature_services', value: { enabled: false } }, - { id: '2', key: 'feature_products', value: { enabled: true } } - ] - }; - - expect(response.success).toBe(true); - expect(response.configs).toHaveLength(2); - expect(response.configs[0].value.enabled).toBe(false); - expect(response.configs[1].value.enabled).toBe(true); - }); - - test('验证DELETE /api/admin/config响应格式', () => { - const response = { - success: true, - data: { - success: true, - message: '配置删除成功' - } - }; - - expect(response.success).toBe(true); - expect(response.data).toHaveProperty('success'); - expect(response.data.success).toBe(true); - expect(response.data).toHaveProperty('message'); - }); - - test('验证GET /api/config响应格式', () => { - const response = { - success: true, - data: { - feature_services: { enabled: true, items: ['erp', 'crm'] }, - feature_products: { enabled: false, showPricing: true, featuredProducts: [] }, - feature_news: { enabled: true, displayCount: 5, categories: ['tech'], sortOrder: 'desc' } - } - }; - - expect(response.success).toBe(true); - expect(response.data).toHaveProperty('feature_services'); - expect(response.data).toHaveProperty('feature_products'); - expect(response.data).toHaveProperty('feature_news'); - expect(typeof response.data.feature_services).toBe('object'); - expect(typeof response.data.feature_products).toBe('object'); - expect(typeof response.data.feature_news).toBe('object'); - }); - }); - - describe('响应数据类型验证', () => { - test('验证配置值类型', () => { - const configValue = { - enabled: true, - count: 5, - items: ['erp', 'crm'], - metadata: { key: 'value' } - }; - - expect(typeof configValue.enabled).toBe('boolean'); - expect(typeof configValue.count).toBe('number'); - expect(Array.isArray(configValue.items)).toBe(true); - expect(typeof configValue.metadata).toBe('object'); - }); - - test('验证日期格式', () => { - const dateFormats = [ - '2024-01-01T00:00:00.000Z', - '2024-12-31T23:59:59.999Z', - '2024-06-15T12:30:45.123Z' - ]; - - dateFormats.forEach(dateString => { - const date = new Date(dateString); - expect(date.getTime()).not.toBeNaN(); - }); - }); - - test('验证ID格式', () => { - const ids = ['1', '2', '3', 'admin', 'user_123']; - - ids.forEach(id => { - expect(typeof id).toBe('string'); - expect(id.length).toBeGreaterThan(0); - }); - }); - - test('验证枚举值格式', () => { - const categories = ['feature', 'style', 'seo', 'general']; - const sortOrders = ['asc', 'desc']; - - categories.forEach(category => { - expect(typeof category).toBe('string'); - expect(categories.includes(category)).toBe(true); - }); - - sortOrders.forEach(order => { - expect(typeof order).toBe('string'); - expect(sortOrders.includes(order)).toBe(true); - }); - }); - }); - - describe('响应边界情况', () => { - test('验证空数组响应', () => { - const response = { - success: true, - configs: [] - }; - - expect(response.success).toBe(true); - expect(Array.isArray(response.configs)).toBe(true); - expect(response.configs).toHaveLength(0); - }); - - test('验证空对象响应', () => { - const response = { - success: true, - data: {} - }; - - expect(response.success).toBe(true); - expect(typeof response.data).toBe('object'); - expect(Object.keys(response.data)).toHaveLength(0); - }); - - test('验证null值处理', () => { - const response = { - success: true, - configs: [ - { - id: '1', - key: 'feature_services', - value: { enabled: true, items: [] }, - category: 'feature', - description: null, - updatedAt: '2024-01-01T00:00:00.000Z', - updatedBy: null - } - ] - }; - - expect(response.success).toBe(true); - expect(response.configs[0].description).toBe(null); - expect(response.configs[0].updatedBy).toBe(null); - }); - - test('验证undefined值处理', () => { - const response = { - success: true, - configs: [ - { - id: '1', - key: 'feature_services', - value: { enabled: true } - } - ] - }; - - expect(response.success).toBe(true); - expect(response.configs[0].description).toBeUndefined(); - expect(response.configs[0].updatedBy).toBeUndefined(); - }); - }); -}); diff --git a/e2e/src/tests/unit/config-transform.spec.ts b/e2e/src/tests/unit/config-transform.spec.ts deleted file mode 100644 index 41f6c6c..0000000 --- a/e2e/src/tests/unit/config-transform.spec.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { describe, test, expect } from '@playwright/test'; - -describe('配置转换函数测试', () => { - describe('数据库格式到API格式转换', () => { - test('转换单个配置对象', () => { - const dbConfig = { - id: '1', - key: 'feature_services', - value: JSON.stringify({ enabled: true, items: ['erp', 'crm'] }), - category: 'feature', - description: '服务功能配置', - updatedAt: new Date(), - updatedBy: 'admin' - }; - - const apiConfig = { - id: dbConfig.id, - key: dbConfig.key, - value: JSON.parse(dbConfig.value), - category: dbConfig.category, - description: dbConfig.description, - updatedAt: dbConfig.updatedAt, - updatedBy: dbConfig.updatedBy - }; - - expect(apiConfig.key).toBe('feature_services'); - expect(apiConfig.value.enabled).toBe(true); - expect(apiConfig.value.items).toEqual(['erp', 'crm']); - expect(apiConfig.category).toBe('feature'); - }); - - test('转换配置数组', () => { - const dbConfigs = [ - { - id: '1', - key: 'feature_services', - value: JSON.stringify({ enabled: true, items: ['erp'] }), - category: 'feature', - description: null, - updatedAt: new Date(), - updatedBy: 'admin' - }, - { - id: '2', - key: 'feature_products', - value: JSON.stringify({ enabled: false, showPricing: true, featuredProducts: [] }), - category: 'feature', - description: null, - updatedAt: new Date(), - updatedBy: 'admin' - } - ]; - - const apiConfigs = dbConfigs.map(config => ({ - id: config.id, - key: config.key, - value: JSON.parse(config.value), - category: config.category, - description: config.description, - updatedAt: config.updatedAt, - updatedBy: config.updatedBy - })); - - expect(apiConfigs).toHaveLength(2); - expect(apiConfigs[0].key).toBe('feature_services'); - expect(apiConfigs[1].key).toBe('feature_products'); - expect(apiConfigs[0].value.enabled).toBe(true); - expect(apiConfigs[1].value.enabled).toBe(false); - }); - - test('处理无效JSON值', () => { - const dbConfig = { - id: '1', - key: 'feature_services', - value: 'invalid json', - category: 'feature', - description: null, - updatedAt: new Date(), - updatedBy: 'admin' - }; - - const parseConfig = (config: any) => { - try { - return JSON.parse(config.value); - } catch { - return null; - } - }; - - const result = parseConfig(dbConfig); - expect(result).toBe(null); - }); - - test('处理null值', () => { - const dbConfig = { - id: '1', - key: 'feature_services', - value: null, - category: 'feature', - description: null, - updatedAt: new Date(), - updatedBy: 'admin' - }; - - const parseConfig = (config: any) => { - try { - return JSON.parse(config.value); - } catch { - return null; - } - }; - - const result = parseConfig(dbConfig); - expect(result).toBe(null); - }); - }); - - describe('API格式到数据库格式转换', () => { - test('转换单个配置对象', () => { - const apiConfig = { - key: 'feature_services', - value: { enabled: true, items: ['erp', 'crm'] }, - category: 'feature', - description: '服务功能配置' - }; - - const dbConfig = { - id: '1', - key: apiConfig.key, - value: JSON.stringify(apiConfig.value), - category: apiConfig.category, - description: apiConfig.description, - updatedAt: new Date(), - updatedBy: 'admin' - }; - - expect(dbConfig.key).toBe('feature_services'); - expect(JSON.parse(dbConfig.value)).toEqual({ enabled: true, items: ['erp', 'crm'] }); - expect(dbConfig.category).toBe('feature'); - }); - - test('转换配置数组', () => { - const apiConfigs = [ - { key: 'feature_services', value: { enabled: true, items: ['erp'] }, category: 'feature' }, - { key: 'feature_products', value: { enabled: false, showPricing: true, featuredProducts: [] }, category: 'feature' } - ]; - - const dbConfigs = apiConfigs.map((config, index) => ({ - id: String(index + 1), - key: config.key, - value: JSON.stringify(config.value), - category: config.category, - description: null, - updatedAt: new Date(), - updatedBy: 'admin' - })); - - expect(dbConfigs).toHaveLength(2); - expect(dbConfigs[0].key).toBe('feature_services'); - expect(dbConfigs[1].key).toBe('feature_products'); - expect(JSON.parse(dbConfigs[0].value)).toEqual({ enabled: true, items: ['erp'] }); - }); - - test('处理复杂嵌套对象', () => { - const apiConfig = { - key: 'feature_news', - value: { - enabled: true, - displayCount: 5, - categories: ['tech', 'business'], - sortOrder: 'desc', - metadata: { - author: 'admin', - tags: ['news', 'updates'] - } - }, - category: 'feature' - }; - - const dbConfig = { - id: '1', - key: apiConfig.key, - value: JSON.stringify(apiConfig.value), - category: apiConfig.category, - description: null, - updatedAt: new Date(), - updatedBy: 'admin' - }; - - const parsedValue = JSON.parse(dbConfig.value); - expect(parsedValue.enabled).toBe(true); - expect(parsedValue.displayCount).toBe(5); - expect(parsedValue.categories).toEqual(['tech', 'business']); - expect(parsedValue.sortOrder).toBe('desc'); - expect(parsedValue.metadata.author).toBe('admin'); - expect(parsedValue.metadata.tags).toEqual(['news', 'updates']); - }); - }); - - describe('配置分组和过滤', () => { - test('按分类分组配置', () => { - const configs = [ - { key: 'feature_services', category: 'feature', value: {} }, - { key: 'feature_products', category: 'feature', value: {} }, - { key: 'style_colors', category: 'style', value: {} }, - { key: 'seo_title', category: 'seo', value: {} }, - { key: 'general_language', category: 'general', value: {} } - ]; - - const groupedConfigs = configs.reduce((acc, config) => { - if (!acc[config.category]) { - acc[config.category] = []; - } - acc[config.category].push(config); - return acc; - }, {} as Record); - - expect(groupedConfigs.feature).toHaveLength(2); - expect(groupedConfigs.style).toHaveLength(1); - expect(groupedConfigs.seo).toHaveLength(1); - expect(groupedConfigs.general).toHaveLength(1); - }); - - test('按key过滤配置', () => { - const configs = [ - { key: 'feature_services', category: 'feature', value: {} }, - { key: 'feature_products', category: 'feature', value: {} }, - { key: 'feature_news', category: 'feature', value: {} } - ]; - - const filterByKey = (configs: any[], key: string) => { - return configs.filter(config => config.key === key); - }; - - const result = filterByKey(configs, 'feature_products'); - expect(result).toHaveLength(1); - expect(result[0].key).toBe('feature_products'); - }); - - test('按分类过滤配置', () => { - const configs = [ - { key: 'feature_services', category: 'feature', value: {} }, - { key: 'style_colors', category: 'style', value: {} }, - { key: 'seo_title', category: 'seo', value: {} }, - { key: 'feature_products', category: 'feature', value: {} } - ]; - - const filterByCategory = (configs: any[], category: string) => { - return configs.filter(config => config.category === category); - }; - - const result = filterByCategory(configs, 'feature'); - expect(result).toHaveLength(2); - expect(result.every(config => config.category === 'feature')).toBe(true); - }); - - test('按key前缀过滤配置', () => { - const configs = [ - { key: 'feature_services', category: 'feature', value: {} }, - { key: 'feature_products', category: 'feature', value: {} }, - { key: 'feature_news', category: 'feature', value: {} }, - { key: 'style_colors', category: 'style', value: {} }, - { key: 'seo_title', category: 'seo', value: {} } - ]; - - const filterByKeyPrefix = (configs: any[], prefix: string) => { - return configs.filter(config => config.key.startsWith(prefix)); - }; - - const result = filterByKeyPrefix(configs, 'feature_'); - expect(result).toHaveLength(3); - expect(result.every(config => config.key.startsWith('feature_'))).toBe(true); - }); - }); - - describe('配置合并和更新', () => { - test('合并配置对象', () => { - const baseConfig = { - enabled: true, - items: ['erp', 'crm'] - }; - - const updateConfig = { - enabled: false, - count: 5 - }; - - const mergedConfig = { ...baseConfig, ...updateConfig }; - - expect(mergedConfig.enabled).toBe(false); - expect(mergedConfig.items).toEqual(['erp', 'crm']); - expect(mergedConfig.count).toBe(5); - }); - - test('深度合并配置对象', () => { - const baseConfig = { - enabled: true, - metadata: { - author: 'admin', - tags: ['tag1'] - } - }; - - const updateConfig = { - metadata: { - tags: ['tag1', 'tag2'] - } - }; - - const deepMerge = (target: any, source: any) => { - const output = { ...target }; - for (const key in source) { - if (source[key] instanceof Object && !Array.isArray(source[key]) && key in target) { - output[key] = deepMerge(target[key], source[key]); - } else { - output[key] = source[key]; - } - } - return output; - }; - - const mergedConfig = deepMerge(baseConfig, updateConfig); - - expect(mergedConfig.enabled).toBe(true); - expect(mergedConfig.metadata.author).toBe('admin'); - expect(mergedConfig.metadata.tags).toEqual(['tag1', 'tag2']); - }); - - test('批量更新配置', () => { - const configs = [ - { key: 'feature_services', value: { enabled: true, items: ['erp'] } }, - { key: 'feature_products', value: { enabled: false, showPricing: true, featuredProducts: [] } } - ]; - - const updates = [ - { key: 'feature_services', value: { enabled: false } }, - { key: 'feature_products', value: { enabled: true, featuredProducts: ['product1'] } } - ]; - - const updatedConfigs = configs.map(config => { - const update = updates.find(u => u.key === config.key); - if (update) { - return { - ...config, - value: { ...config.value, ...update.value } - }; - } - return config; - }); - - expect(updatedConfigs[0].value.enabled).toBe(false); - expect(updatedConfigs[0].value.items).toEqual(['erp']); - expect(updatedConfigs[1].value.enabled).toBe(true); - expect(updatedConfigs[1].value.featuredProducts).toEqual(['product1']); - }); - }); - - describe('配置序列化和反序列化', () => { - test('序列化配置为JSON', () => { - const config = { - key: 'feature_services', - value: { enabled: true, items: ['erp', 'crm'] }, - category: 'feature' - }; - - const serialized = JSON.stringify(config); - const deserialized = JSON.parse(serialized); - - expect(deserialized.key).toBe('feature_services'); - expect(deserialized.value.enabled).toBe(true); - expect(deserialized.value.items).toEqual(['erp', 'crm']); - }); - - test('处理日期序列化', () => { - const config = { - key: 'feature_services', - value: { enabled: true }, - category: 'feature', - updatedAt: new Date('2024-01-01T00:00:00.000Z') - }; - - const serialized = JSON.stringify(config); - const deserialized = JSON.parse(serialized); - - expect(typeof deserialized.updatedAt).toBe('string'); - expect(new Date(deserialized.updatedAt).toISOString()).toBe(config.updatedAt.toISOString()); - }); - - test('处理特殊字符序列化', () => { - const config = { - key: 'feature_services', - value: { items: ['erp', 'crm', 'mes'] }, - category: 'feature', - description: '服务配置 ' - }; - - const serialized = JSON.stringify(config); - const deserialized = JSON.parse(serialized); - - expect(deserialized.description).toContain('', - '', - '', - 'javascript: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', - ], - }; - } - - static generateAccessibilityTestData(): { - colorContrastRatios: { normalText: number; largeText: number; uiComponents: number }; - touchTargetSize: number; - focusIndicatorVisible: boolean; - keyboardNavigationRequired: boolean; - } { - return { - colorContrastRatios: { - normalText: 4.5, - largeText: 3.0, - uiComponents: 3.0, - }, - touchTargetSize: 44, - focusIndicatorVisible: true, - keyboardNavigationRequired: true, - }; - } - - static generatePerformanceTestData(): { - thresholds: { - loadTime: number; - firstContentfulPaint: number; - largestContentfulPaint: number; - timeToInteractive: number; - cumulativeLayoutShift: number; - firstInputDelay: number; - }; - budgets: { - totalPageSize: number; - imageSize: number; - scriptSize: number; - stylesheetSize: number; - }; - } { - return { - thresholds: { - loadTime: 5000, - firstContentfulPaint: 1800, - largestContentfulPaint: 2500, - timeToInteractive: 3800, - cumulativeLayoutShift: 0.1, - firstInputDelay: 100, - }, - budgets: { - totalPageSize: 1600000, - imageSize: 500000, - scriptSize: 300000, - stylesheetSize: 100000, - }, - }; - } - - static generateResponsiveTestData(): Array<{ - name: string; - width: number; - height: number; - }> { - return [ - { 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 }, - ]; - } - - static generateNavigationTestData(): Array<{ - label: string; - href: string; - expectedUrl: string; - }> { - return [ - { 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: '/' }, - ]; - } - - static generateCompanyInfoTestData(): { - name: string; - address: string; - phone: string; - email: string; - workHours: Array<{ day: string; hours: string }>; - } { - return { - 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: '休息' }, - ], - }; - } - - static generateErrorTestData(): { - requiredField: string; - invalidEmail: string; - invalidPhone: string; - messageTooShort: string; - messageTooLong: string; - submissionFailed: string; - } { - return { - requiredField: '此字段为必填项', - invalidEmail: '请输入有效的邮箱地址', - invalidPhone: '请输入有效的手机号码', - messageTooShort: '消息内容至少需要10个字符', - messageTooLong: '消息内容不能超过1000个字符', - submissionFailed: '提交失败,请稍后重试', - }; - } - - static generateSuccessTestData(): { - formSubmitted: string; - thankYou: string; - } { - return { - formSubmitted: '消息已发送', - thankYou: '感谢您的留言,我们会尽快与您联系', - }; - } - - static generateRandomEmail(): string { - const domains = ['gmail.com', 'outlook.com', 'yahoo.com', 'qq.com', '163.com']; - const username = this.generateAlphanumeric(8); - const domain = domains[Math.floor(Math.random() * domains.length)]!; - return `${username}@${domain}`; - } - - static generateRandomChineseName(): string { - const surnames = ['张', '李', '王', '刘', '陈', '杨', '赵', '黄', '周', '吴']; - const names = ['伟', '芳', '娜', '秀英', '敏', '静', '丽', '强', '磊', '军']; - const surname = surnames[Math.floor(Math.random() * surnames.length)]!; - const name = names[Math.floor(Math.random() * names.length)]!; - return `${surname}${name}`; - } - - static generateRandomChinesePhone(): string { - const prefixes = ['138', '139', '136', '137', '158', '159', '186', '187', '150', '151']; - const prefix = prefixes[Math.floor(Math.random() * prefixes.length)]!; - const suffix = Math.floor(Math.random() * 90000000 + 10000000); - return `${prefix}${suffix}`; - } -} diff --git a/e2e/src/utils/devices.ts b/e2e/src/utils/devices.ts deleted file mode 100644 index e3840bd..0000000 --- a/e2e/src/utils/devices.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { DeviceConfig } from '../types'; - -export const devices: Record = { - 'desktop-1920x1080': { - name: 'Desktop 1920x1080', - viewport: { width: 1920, height: 1080 }, - isMobile: false, - }, - 'desktop-1366x768': { - name: 'Desktop 1366x768', - viewport: { width: 1366, height: 768 }, - isMobile: false, - }, - 'desktop-1280x720': { - name: 'Desktop 1280x720', - viewport: { width: 1280, height: 720 }, - isMobile: false, - }, - 'laptop-1440x900': { - name: 'Laptop 1440x900', - viewport: { width: 1440, height: 900 }, - isMobile: false, - }, - 'laptop-1024x768': { - name: 'Laptop 1024x768', - viewport: { width: 1024, height: 768 }, - isMobile: false, - }, - 'tablet-768x1024': { - name: 'Tablet 768x1024', - viewport: { width: 768, height: 1024 }, - isMobile: true, - }, - 'tablet-834x1194': { - name: 'Tablet 834x1194 (iPad Pro)', - viewport: { width: 834, height: 1194 }, - isMobile: true, - }, - 'mobile-375x667': { - name: 'Mobile 375x667 (iPhone SE)', - viewport: { width: 375, height: 667 }, - isMobile: true, - }, - 'mobile-390x844': { - name: 'Mobile 390x844 (iPhone 12)', - viewport: { width: 390, height: 844 }, - isMobile: true, - }, - 'mobile-414x896': { - name: 'Mobile 414x896 (iPhone 11)', - viewport: { width: 414, height: 896 }, - isMobile: true, - }, - 'mobile-360x640': { - name: 'Mobile 360x640 (Android)', - viewport: { width: 360, height: 640 }, - isMobile: true, - }, - 'mobile-412x915': { - name: 'Mobile 412x915 (Pixel 5)', - viewport: { width: 412, height: 915 }, - isMobile: true, - }, - 'iphone-13-pro': { - name: 'iPhone 13 Pro', - viewport: { width: 390, height: 844 }, - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1', - isMobile: true, - deviceScaleFactor: 3, - }, - 'iphone-14-pro': { - name: 'iPhone 14 Pro', - viewport: { width: 393, height: 852 }, - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1', - isMobile: true, - deviceScaleFactor: 3, - }, - 'iphone-15-pro': { - name: 'iPhone 15 Pro', - viewport: { width: 393, height: 852 }, - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', - isMobile: true, - deviceScaleFactor: 3, - }, - 'pixel-7': { - name: 'Google Pixel 7', - viewport: { width: 412, height: 915 }, - userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36', - isMobile: true, - deviceScaleFactor: 2.625, - }, - 'samsung-galaxy-s23': { - name: 'Samsung Galaxy S23', - viewport: { width: 360, height: 780 }, - userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36', - isMobile: true, - deviceScaleFactor: 3, - }, - 'ipad-air': { - name: 'iPad Air', - viewport: { width: 820, height: 1180 }, - userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1', - isMobile: true, - deviceScaleFactor: 2, - }, - 'ipad-pro-12-9': { - name: 'iPad Pro 12.9"', - viewport: { width: 1024, height: 1366 }, - userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1', - isMobile: true, - deviceScaleFactor: 2, - }, -}; - -export const desktopDevices = Object.entries(devices) - .filter(([_, config]) => !config.isMobile) - .map(([key, config]) => ({ key, ...config })); - -export const mobileDevices = Object.entries(devices) - .filter(([_, config]) => config.isMobile) - .map(([key, config]) => ({ key, ...config })); - -export const tabletDevices = Object.entries(devices) - .filter(([_, config]) => config.isMobile && config.viewport.width >= 768) - .map(([key, config]) => ({ key, ...config })); - -export const getDevice = (key: string): DeviceConfig => { - const device = devices[key]; - if (!device) { - return devices['desktop-1280x720']!; - } - return device; -}; - -export const getAllDevices = (): DeviceConfig[] => { - return Object.values(devices); -}; - -export const getDesktopDevices = (): DeviceConfig[] => { - return desktopDevices.map(d => devices[d.key]!); -}; - -export const getMobileDevices = (): DeviceConfig[] => { - return mobileDevices.map(d => devices[d.key]!); -}; - -export const getTabletDevices = (): DeviceConfig[] => { - return tabletDevices.map(d => devices[d.key]!); -}; - -export const getBreakpoints = () => { - return { - xs: 0, - sm: 640, - md: 768, - lg: 1024, - xl: 1280, - '2xl': 1536, - }; -}; - -export const isMobile = (width: number): boolean => { - const breakpoints = getBreakpoints(); - return width < breakpoints.lg; -}; - -export const isTablet = (width: number): boolean => { - const breakpoints = getBreakpoints(); - return width >= breakpoints.md && width < breakpoints.lg; -}; - -export const isDesktop = (width: number): boolean => { - const breakpoints = getBreakpoints(); - return width >= breakpoints.lg; -}; diff --git a/e2e/src/utils/smart-wait.ts b/e2e/src/utils/smart-wait.ts deleted file mode 100644 index bc7a7a6..0000000 --- a/e2e/src/utils/smart-wait.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Page, Locator } from '@playwright/test'; - -export class SmartWait { - private page: Page; - private defaultTimeout: number = 10000; - private pollInterval: number = 100; - - constructor(page: Page) { - this.page = page; - } - - async waitForElement(locator: Locator, options?: { timeout?: number; state?: 'visible' | 'attached' | 'hidden' | 'detached' }) { - const timeout = options?.timeout || this.defaultTimeout; - const state = options?.state || 'visible'; - - try { - await locator.waitFor({ state, timeout }); - return true; - } catch (error) { - console.log(`等待元素超时: ${timeout}ms, state: ${state}`); - throw error; - } - } - - async waitForNetworkIdle(timeout: number = 5000) { - try { - await this.page.waitForLoadState('networkidle', { timeout }); - } catch (error) { - console.log(`等待网络空闲超时: ${timeout}ms`); - } - } - - async waitForStableElement(locator: Locator, options?: { timeout?: number; stableDuration?: number }) { - const timeout = options?.timeout || this.defaultTimeout; - const stableDuration = options?.stableDuration || 500; - - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - try { - const isVisible = await locator.isVisible(); - if (isVisible) { - const boundingBox = await locator.boundingBox(); - await this.page.waitForTimeout(stableDuration); - - const newBoundingBox = await locator.boundingBox(); - if (boundingBox && newBoundingBox && - Math.abs(boundingBox.x - newBoundingBox.x) < 2 && - Math.abs(boundingBox.y - newBoundingBox.y) < 2) { - return true; - } - } - } catch (error) { - } - - await this.page.waitForTimeout(this.pollInterval); - } - - throw new Error(`元素未在 ${timeout}ms 内稳定`); - } - - async waitForTextContent(locator: Locator, expectedText: string | RegExp, options?: { timeout?: number }) { - const timeout = options?.timeout || this.defaultTimeout; - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - try { - const text = await locator.textContent(); - if (text) { - if (typeof expectedText === 'string') { - if (text.includes(expectedText)) { - return true; - } - } else if (expectedText instanceof RegExp) { - if (expectedText.test(text)) { - return true; - } - } - } - } catch (error) { - } - - await this.page.waitForTimeout(this.pollInterval); - } - - throw new Error(`文本内容未在 ${timeout}ms 内出现: ${expectedText}`); - } - - async waitForPageReady(timeout: number = 30000) { - const startTime = Date.now(); - - try { - await this.page.waitForLoadState('domcontentloaded', { timeout }); - - try { - await this.waitForNetworkIdle(2000); - } catch { - console.log('网络空闲等待失败,继续页面加载检查'); - } - - try { - const body = this.page.locator('body'); - await this.waitForElement(body, { timeout: 5000, state: 'visible' }); - } catch { - console.log('Body元素等待超时,尝试继续'); - } - - return true; - } catch (error) { - console.log(`页面未在 ${timeout}ms 内就绪: ${error.message}`); - throw error; - } - } - - async retry( - fn: () => Promise, - options?: { maxRetries?: number; delay?: number; onRetry?: (error: Error, attempt: number) => void } - ): Promise { - const maxRetries = options?.maxRetries || 3; - const delay = options?.delay || 1000; - let lastError: Error | null = null; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - return await fn(); - } catch (error) { - lastError = error as Error; - - if (options?.onRetry) { - options.onRetry(lastError, attempt); - } - - if (attempt < maxRetries) { - console.log(`重试 ${attempt}/${maxRetries}: ${lastError.message}`); - await this.page.waitForTimeout(delay * attempt); - } - } - } - - throw lastError || new Error('重试次数耗尽'); - } - - async waitForAnimationFrame(count: number = 2) { - for (let i = 0; i < count; i++) { - await this.page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve))); - } - } -} diff --git a/e2e/src/utils/test-alert.ts b/e2e/src/utils/test-alert.ts deleted file mode 100644 index ba2a012..0000000 --- a/e2e/src/utils/test-alert.ts +++ /dev/null @@ -1,154 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -interface Alert { - id: string; - timestamp: number; - severity: 'low' | 'medium' | 'high' | 'critical'; - message: string; - tier: string; - metrics: any; - resolved: boolean; -} - -export class TestAlertManager { - private alerts: Alert[] = []; - private alertFile: string; - - constructor(alertFile: string = 'test-results/alerts.json') { - this.alertFile = alertFile; - this.loadAlerts(); - } - - private loadAlerts(): void { - if (fs.existsSync(this.alertFile)) { - const data = fs.readFileSync(this.alertFile, 'utf-8'); - this.alerts = JSON.parse(data); - } - } - - private saveAlerts(): void { - const dir = path.dirname(this.alertFile); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(this.alertFile, JSON.stringify(this.alerts, null, 2)); - } - - createAlert( - severity: 'low' | 'medium' | 'high' | 'critical', - message: string, - tier: string, - metrics: any - ): Alert { - const alert: Alert = { - id: `alert-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - timestamp: Date.now(), - severity, - message, - tier, - metrics, - resolved: false, - }; - - this.alerts.push(alert); - this.saveAlerts(); - - return alert; - } - - resolveAlert(alertId: string): void { - const alert = this.alerts.find(a => a.id === alertId); - if (alert) { - alert.resolved = true; - this.saveAlerts(); - } - } - - getActiveAlerts(): Alert[] { - return this.alerts.filter(a => !a.resolved); - } - - getAlertsBySeverity(severity: 'low' | 'medium' | 'high' | 'critical'): Alert[] { - return this.alerts.filter(a => a.severity === severity && !a.resolved); - } - - getAlertsByTier(tier: string): Alert[] { - return this.alerts.filter(a => a.tier === tier && !a.resolved); - } - - getRecentAlerts(hours: number = 24): Alert[] { - const cutoff = Date.now() - hours * 60 * 60 * 1000; - return this.alerts.filter(a => a.timestamp >= cutoff && !a.resolved); - } - - getAlertSummary(): { - total: number; - active: number; - bySeverity: Record; - byTier: Record; - } { - const activeAlerts = this.getActiveAlerts(); - - const bySeverity: Record = { - low: 0, - medium: 0, - high: 0, - critical: 0, - }; - - const byTier: Record = {}; - - activeAlerts.forEach(alert => { - bySeverity[alert.severity]++; - byTier[alert.tier] = (byTier[alert.tier] || 0) + 1; - }); - - return { - total: this.alerts.length, - active: activeAlerts.length, - bySeverity, - byTier, - }; - } - - clearOldAlerts(days: number = 30): void { - const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; - this.alerts = this.alerts.filter(a => a.timestamp >= cutoff); - this.saveAlerts(); - } - - exportReport(): string { - const summary = this.getAlertSummary(); - const activeAlerts = this.getActiveAlerts(); - - let report = '🚨 测试告警报告\n'; - report += '='.repeat(50) + '\n\n'; - report += `总告警数: ${summary.total}\n`; - report += `活跃告警: ${summary.active}\n\n`; - - report += '按严重程度统计:\n'; - report += ` Critical: ${summary.bySeverity.critical}\n`; - report += ` High: ${summary.bySeverity.high}\n`; - report += ` Medium: ${summary.bySeverity.medium}\n`; - report += ` Low: ${summary.bySeverity.low}\n\n`; - - if (activeAlerts.length > 0) { - report += '活跃告警详情:\n'; - activeAlerts.forEach((alert, index) => { - report += `\n${index + 1}. [${alert.severity.toUpperCase()}] ${alert.message}\n`; - report += ` Tier: ${alert.tier}\n`; - report += ` Time: ${new Date(alert.timestamp).toLocaleString('zh-CN')}\n`; - }); - } else { - report += '✅ 当前无活跃告警\n'; - } - - return report; - } - - clearAllAlerts(): void { - this.alerts = []; - this.saveAlerts(); - } -} \ No newline at end of file diff --git a/e2e/src/utils/test-history.ts b/e2e/src/utils/test-history.ts deleted file mode 100644 index 185403b..0000000 --- a/e2e/src/utils/test-history.ts +++ /dev/null @@ -1,103 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -interface TestExecutionRecord { - testId: string; - file: string; - title: string; - duration: number; - timestamp: number; - success: boolean; - flaky: boolean; -} - -interface TestHistory { - records: TestExecutionRecord[]; - lastUpdated: number; -} - -const HISTORY_FILE = path.join(__dirname, '../../test-history.json'); - -export class TestHistoryManager { - private history: TestHistory; - - constructor() { - this.loadHistory(); - } - - private loadHistory(): void { - if (fs.existsSync(HISTORY_FILE)) { - const data = fs.readFileSync(HISTORY_FILE, 'utf-8'); - this.history = JSON.parse(data); - } else { - this.history = { records: [], lastUpdated: Date.now() }; - } - } - - private saveHistory(): void { - this.history.lastUpdated = Date.now(); - fs.writeFileSync(HISTORY_FILE, JSON.stringify(this.history, null, 2)); - } - - recordExecution(testId: string, file: string, title: string, duration: number, success: boolean): void { - const record: TestExecutionRecord = { - testId, - file, - title, - duration, - timestamp: Date.now(), - success, - flaky: this.isFlaky(testId), - }; - - this.history.records.push(record); - - if (this.history.records.length > 1000) { - this.history.records = this.history.records.slice(-1000); - } - - this.saveHistory(); - } - - getAverageDuration(testId: string): number { - const testRecords = this.history.records.filter(r => r.testId === testId); - if (testRecords.length === 0) return 0; - - const durations = testRecords.map(r => r.duration); - return durations.reduce((a, b) => a + b, 0) / durations.length; - } - - isFlaky(testId: string): boolean { - const testRecords = this.history.records - .filter(r => r.testId === testId) - .slice(-10); - - if (testRecords.length < 5) return false; - - const failureCount = testRecords.filter(r => !r.success).length; - return failureCount >= 3; - } - - getSlowTests(threshold: number = 2): TestExecutionRecord[] { - const avgDurations = new Map(); - - this.history.records.forEach(record => { - const avg = avgDurations.get(record.testId) || 0; - const count = this.history.records.filter(r => r.testId === record.testId).length; - avgDurations.set(record.testId, avg + record.duration / count); - }); - - return Array.from(avgDurations.entries()) - .filter(([_, avg]) => avg > threshold * 60000) - .map(([testId, avg]) => ({ - testId, - file: this.history.records.find(r => r.testId === testId)!.file, - title: this.history.records.find(r => r.testId === testId)!.title, - duration: avg, - timestamp: Date.now(), - success: true, - flaky: false, - })) - .sort((a, b) => b.duration - a.duration); - } -} \ No newline at end of file diff --git a/e2e/src/utils/test-monitor.ts b/e2e/src/utils/test-monitor.ts deleted file mode 100644 index 443de55..0000000 --- a/e2e/src/utils/test-monitor.ts +++ /dev/null @@ -1,147 +0,0 @@ -interface TestMetric { - timestamp: number; - tier: string; - totalTests: number; - passedTests: number; - failedTests: number; - skippedTests: number; - duration: number; - successRate: number; -} - -interface AlertRule { - name: string; - condition: (metrics: TestMetric) => boolean; - severity: 'low' | 'medium' | 'high' | 'critical'; - message: string; -} - -export class TestMonitor { - private metrics: TestMetric[] = []; - private alertRules: AlertRule[] = []; - - constructor() { - this.initializeDefaultRules(); - } - - private initializeDefaultRules(): void { - this.alertRules = [ - { - name: 'success-rate-low', - condition: (m) => m.successRate < 0.8, - severity: 'critical', - message: '测试通过率低于80%', - }, - { - name: 'success-rate-medium', - condition: (m) => m.successRate < 0.9 && m.successRate >= 0.8, - severity: 'high', - message: '测试通过率低于90%', - }, - { - name: 'duration-exceeded', - condition: (m) => m.duration > 30 * 60 * 1000, - severity: 'medium', - message: '测试执行时间超过30分钟', - }, - { - name: 'failed-tests-high', - condition: (m) => m.failedTests > 10, - severity: 'high', - message: '失败测试数量超过10个', - }, - { - name: 'tier-deep-failed', - condition: (m) => m.tier === 'deep' && m.failedTests > 0, - severity: 'critical', - message: '深度层测试存在失败', - }, - ]; - } - - recordMetric(metric: TestMetric): void { - this.metrics.push(metric); - this.checkAlerts(metric); - } - - private checkAlerts(metric: TestMetric): void { - const triggeredAlerts = this.alertRules.filter(rule => rule.condition(metric)); - - if (triggeredAlerts.length > 0) { - triggeredAlerts.forEach(alert => { - this.triggerAlert(alert, metric); - }); - } - } - - private triggerAlert(alert: AlertRule, metric: TestMetric): void { - const alertMessage = { - timestamp: new Date().toISOString(), - alert: alert.name, - severity: alert.severity, - message: alert.message, - tier: metric.tier, - metrics: { - total: metric.totalTests, - passed: metric.passedTests, - failed: metric.failedTests, - skipped: metric.skippedTests, - duration: metric.duration, - successRate: metric.successRate, - }, - }; - - console.log(`🚨 [${alert.severity.toUpperCase()}] ${alert.message}`); - console.log(JSON.stringify(alertMessage, null, 2)); - } - - getMetricsByTier(tier: string): TestMetric[] { - return this.metrics.filter(m => m.tier === tier); - } - - getAverageSuccessRate(tier?: string): number { - const filteredMetrics = tier - ? this.getMetricsByTier(tier) - : this.metrics; - - if (filteredMetrics.length === 0) return 0; - - const sum = filteredMetrics.reduce((acc, m) => acc + m.successRate, 0); - return sum / filteredMetrics.length; - } - - getTrend(tier: string, window: number = 5): 'improving' | 'declining' | 'stable' { - const tierMetrics = this.getMetricsByTier(tier); - const recentMetrics = tierMetrics.slice(-window); - - if (recentMetrics.length < 2) return 'stable'; - - const firstHalf = recentMetrics.slice(0, Math.floor(recentMetrics.length / 2)); - const secondHalf = recentMetrics.slice(Math.floor(recentMetrics.length / 2)); - - const firstAvg = firstHalf.reduce((acc, m) => acc + m.successRate, 0) / firstHalf.length; - const secondAvg = secondHalf.reduce((acc, m) => acc + m.successRate, 0) / secondHalf.length; - - const diff = secondAvg - firstAvg; - - if (diff > 0.05) return 'improving'; - if (diff < -0.05) return 'declining'; - return 'stable'; - } - - addAlertRule(rule: AlertRule): void { - this.alertRules.push(rule); - } - - removeAlertRule(ruleName: string): void { - this.alertRules = this.alertRules.filter(r => r.name !== ruleName); - } - - getMetrics(): TestMetric[] { - return [...this.metrics]; - } - - clearMetrics(): void { - this.metrics = []; - } -} \ No newline at end of file diff --git a/e2e/src/utils/test-optimizer.ts b/e2e/src/utils/test-optimizer.ts deleted file mode 100644 index 4f2b09c..0000000 --- a/e2e/src/utils/test-optimizer.ts +++ /dev/null @@ -1,222 +0,0 @@ -interface TestPerformance { - testId: string; - file: string; - title: string; - duration: number; - tier: string; - avgDuration: number; - percentile: number; - isSlow: boolean; - optimizationSuggestions: string[]; -} - -interface OptimizationRule { - name: string; - condition: (perf: TestPerformance) => boolean; - suggestions: string[]; -} - -export class TestOptimizer { - private rules: OptimizationRule[] = []; - - constructor() { - this.initializeRules(); - } - - private initializeRules(): void { - this.rules = [ - { - name: 'slow-test', - condition: (p) => p.duration > 60000, - suggestions: [ - '考虑将测试拆分为多个小测试', - '检查是否有不必要的等待时间', - '优化选择器以提高定位速度', - '考虑使用mock数据替代真实API调用', - ], - }, - { - name: 'very-slow-test', - condition: (p) => p.duration > 120000, - suggestions: [ - '测试执行时间过长,强烈建议拆分', - '检查是否有性能瓶颈(如大量DOM操作)', - '考虑使用并行测试策略', - '评估是否需要完整加载所有资源', - ], - }, - { - name: 'flaky-test', - condition: (p) => p.percentile > 90, - suggestions: [ - '测试执行时间波动较大,可能存在稳定性问题', - '增加重试次数或使用更稳定的等待策略', - '检查网络请求的稳定性', - '考虑添加更明确的断言条件', - ], - }, - { - name: 'tier-fast-slow', - condition: (p) => p.tier === 'fast' && p.duration > 30000, - suggestions: [ - '快速层测试不应超过30秒', - '重新评估测试是否属于快速层', - '优化测试逻辑或移至标准层', - ], - }, - { - name: 'tier-standard-slow', - condition: (p) => p.tier === 'standard' && p.duration > 90000, - suggestions: [ - '标准层测试不应超过90秒', - '考虑拆分测试或优化执行流程', - '评估是否需要移至深度层', - ], - }, - ]; - } - - analyzePerformance( - testId: string, - file: string, - title: string, - duration: number, - tier: string, - history: number[] - ): TestPerformance { - const avgDuration = history.length > 0 - ? history.reduce((sum, d) => sum + d, 0) / history.length - : duration; - - const sortedHistory = [...history].sort((a, b) => a - b); - const percentile = sortedHistory.length > 0 - ? sortedHistory[Math.floor(sortedHistory.length * 0.9)] - : duration; - - const isSlow = duration > 60000; - - const suggestions: string[] = []; - for (const rule of this.rules) { - if (rule.condition({ - testId, - file, - title, - duration, - tier, - avgDuration, - percentile, - isSlow, - optimizationSuggestions: [], - })) { - suggestions.push(...rule.suggestions); - } - } - - return { - testId, - file, - title, - duration, - tier, - avgDuration, - percentile, - isSlow, - optimizationSuggestions: suggestions, - }; - } - - optimizeTestSuite(performances: TestPerformance[]): { - totalTests: number; - slowTests: number; - potentialSavings: number; - recommendations: string[]; - } { - const slowTests = performances.filter(p => p.isSlow); - const totalDuration = performances.reduce((sum, p) => sum + p.duration, 0); - const slowDuration = slowTests.reduce((sum, p) => sum + p.duration, 0); - - const potentialSavings = slowDuration * 0.3; // 假设优化可节省30%时间 - - const recommendations: string[] = []; - - if (slowTests.length > 0) { - recommendations.push(`发现 ${slowTests.length} 个慢速测试,建议优先优化`); - } - - const tierSlowTests = { - fast: slowTests.filter(p => p.tier === 'fast').length, - standard: slowTests.filter(p => p.tier === 'standard').length, - deep: slowTests.filter(p => p.tier === 'deep').length, - }; - - if (tierSlowTests.fast > 0) { - recommendations.push(`${tierSlowTests.fast} 个快速层测试执行过慢,建议重新评估分层`); - } - - if (tierSlowTests.standard > 5) { - recommendations.push(`${tierSlowTests.standard} 个标准层测试执行过慢,建议优化或拆分`); - } - - const avgDuration = totalDuration / performances.length; - if (avgDuration > 60000) { - recommendations.push('平均测试执行时间超过60秒,建议整体优化测试策略'); - } - - return { - totalTests: performances.length, - slowTests: slowTests.length, - potentialSavings, - recommendations, - }; - } - - generateOptimizationReport(performances: TestPerformance[]): string { - const analysis = this.optimizeTestSuite(performances); - - let report = '🚀 测试性能优化报告\n'; - report += '='.repeat(50) + '\n\n'; - - report += `总测试数: ${analysis.totalTests}\n`; - report += `慢速测试: ${analysis.slowTests}\n`; - report += `潜在节省时间: ${(analysis.potentialSavings / 1000).toFixed(2)}s\n\n`; - - if (analysis.recommendations.length > 0) { - report += '📋 优化建议:\n'; - analysis.recommendations.forEach((rec, index) => { - report += ` ${index + 1}. ${rec}\n`; - }); - report += '\n'; - } - - const slowTests = performances.filter(p => p.isSlow) - .sort((a, b) => b.duration - a.duration); - - if (slowTests.length > 0) { - report += '🐌 慢速测试详情:\n'; - slowTests.forEach((test, index) => { - report += `\n${index + 1}. ${test.title}\n`; - report += ` 文件: ${test.file}\n`; - report += ` 耗时: ${(test.duration / 1000).toFixed(2)}s\n`; - report += ` 层级: ${test.tier}\n`; - if (test.optimizationSuggestions.length > 0) { - report += ` 优化建议:\n`; - test.optimizationSuggestions.forEach(suggestion => { - report += ` - ${suggestion}\n`; - }); - } - }); - } else { - report += '✅ 未发现慢速测试\n'; - } - - return report; - } - - addRule(rule: OptimizationRule): void { - this.rules.push(rule); - } - - removeRule(ruleName: string): void { - this.rules = this.rules.filter(r => r.name !== ruleName); - } -} \ No newline at end of file diff --git a/e2e/src/utils/test-reporter.ts b/e2e/src/utils/test-reporter.ts deleted file mode 100644 index 0d9e328..0000000 --- a/e2e/src/utils/test-reporter.ts +++ /dev/null @@ -1,276 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -interface TestResult { - testId: string; - file: string; - title: string; - status: 'passed' | 'failed' | 'skipped' | 'timedout'; - duration: number; - tier?: string; - tags?: string[]; -} - -interface TierReport { - name: string; - total: number; - passed: number; - failed: number; - skipped: number; - duration: number; - avgDuration: number; -} - -interface TestReport { - timestamp: string; - tiers: Record; - total: TierReport; - failedTests: TestResult[]; - slowTests: TestResult[]; - flakyTests: TestResult[]; -} - -export class TestReporter { - private reportDir: string; - - constructor(reportDir: string = 'test-results') { - this.reportDir = reportDir; - this.ensureReportDir(); - } - - private ensureReportDir(): void { - if (!fs.existsSync(this.reportDir)) { - fs.mkdirSync(this.reportDir, { recursive: true }); - } - } - - generateReport(results: TestResult[]): TestReport { - const report: TestReport = { - timestamp: new Date().toISOString(), - tiers: {}, - total: { - name: 'total', - total: results.length, - passed: 0, - failed: 0, - skipped: 0, - duration: 0, - avgDuration: 0, - }, - failedTests: [], - slowTests: [], - flakyTests: [], - }; - - const tierGroups = new Map(); - const slowThreshold = 2 * 60000; // 2 minutes - - for (const result of results) { - const tier = result.tier || 'standard'; - if (!tierGroups.has(tier)) { - tierGroups.set(tier, []); - } - tierGroups.get(tier)!.push(result); - - report.total.duration += result.duration; - - if (result.status === 'passed') { - report.total.passed++; - } else if (result.status === 'failed') { - report.total.failed++; - report.failedTests.push(result); - } else if (result.status === 'skipped') { - report.total.skipped++; - } - - if (result.duration > slowThreshold) { - report.slowTests.push(result); - } - - if (result.tags?.includes('@flaky')) { - report.flakyTests.push(result); - } - } - - report.total.avgDuration = report.total.duration / report.total.total; - - tierGroups.forEach((tierResults, tierName) => { - const tierDuration = tierResults.reduce((sum, r) => sum + r.duration, 0); - report.tiers[tierName] = { - name: tierName, - total: tierResults.length, - passed: tierResults.filter(r => r.status === 'passed').length, - failed: tierResults.filter(r => r.status === 'failed').length, - skipped: tierResults.filter(r => r.status === 'skipped').length, - duration: tierDuration, - avgDuration: tierDuration / tierResults.length, - }; - }); - - return report; - } - - saveReport(report: TestReport, format: 'json' | 'html' = 'json'): void { - if (format === 'json') { - this.saveJsonReport(report); - } else if (format === 'html') { - this.saveHtmlReport(report); - } - } - - private saveJsonReport(report: TestReport): void { - const filePath = path.join(this.reportDir, `test-report-${Date.now()}.json`); - fs.writeFileSync(filePath, JSON.stringify(report, null, 2)); - console.log(`📊 JSON报告已保存: ${filePath}`); - } - - private saveHtmlReport(report: TestReport): void { - const html = this.generateHtmlReport(report); - const filePath = path.join(this.reportDir, `test-report-${Date.now()}.html`); - fs.writeFileSync(filePath, html); - console.log(`📊 HTML报告已保存: ${filePath}`); - } - - private generateHtmlReport(report: TestReport): string { - return ` - - - - - - 测试报告 - ${report.timestamp} - - - -
-

🧪 测试执行报告

-

执行时间: ${new Date(report.timestamp).toLocaleString('zh-CN')}

- -
-
-

${report.total.passed}

-

通过

-
-
-

${report.total.failed}

-

失败

-
-
-

${report.total.skipped}

-

跳过

-
-
-

${(report.total.duration / 1000).toFixed(2)}s

-

总耗时

-
-
- -
-

📊 分层执行统计

- - - - - - - - - - - - - - ${Object.entries(report.tiers).map(([name, tier]) => ` - - - - - - - - - - `).join('')} - -
层级总数通过失败跳过耗时平均耗时
${name}${tier.total}${tier.passed}${tier.failed}${tier.skipped}${(tier.duration / 1000).toFixed(2)}s${(tier.avgDuration / 1000).toFixed(2)}s
-
- - ${report.failedTests.length > 0 ? ` -
-

❌ 失败测试 (${report.failedTests.length})

- - - - - - - - - - - ${report.failedTests.map(test => ` - - - - - - - `).join('')} - -
测试文件耗时状态
${test.title}${test.file}${(test.duration / 1000).toFixed(2)}s${test.status}
-
- ` : ''} - - ${report.slowTests.length > 0 ? ` -
-

🐌 慢速测试 (>2分钟, ${report.slowTests.length})

- - - - - - - - - - ${report.slowTests.map(test => ` - - - - - - `).join('')} - -
测试文件耗时
${test.title}${test.file}${(test.duration / 1000).toFixed(2)}s
-
- ` : ''} -
- - - `; - } -} \ No newline at end of file diff --git a/e2e/src/utils/test-scheduler.ts b/e2e/src/utils/test-scheduler.ts deleted file mode 100644 index 147693b..0000000 --- a/e2e/src/utils/test-scheduler.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { TestHistoryManager } from './test-history'; - -interface TestSchedule { - testId: string; - file: string; - title: string; - priority: number; - estimatedDuration: number; - dependencies: string[]; -} - -export class TestScheduler { - private historyManager: TestHistoryManager; - - constructor() { - this.historyManager = new TestHistoryManager(); - } - - scheduleTests(testFiles: string[]): TestSchedule[] { - const schedules: TestSchedule[] = []; - - for (const file of testFiles) { - const testId = this.generateTestId(file); - const priority = this.calculatePriority(file); - const estimatedDuration = this.historyManager.getAverageDuration(testId) || 60000; - const dependencies = this.analyzeDependencies(file); - - schedules.push({ - testId, - file, - title: this.extractTestTitle(file), - priority, - estimatedDuration, - dependencies, - }); - } - - return schedules.sort((a, b) => { - if (a.priority !== b.priority) { - return a.priority - b.priority; - } - return a.estimatedDuration - b.estimatedDuration; - }); - } - - private generateTestId(file: string): string { - return file.replace(/[^a-zA-Z0-9]/g, '-'); - } - - private calculatePriority(file: string): number { - if (file.includes('smoke')) return 1; - if (file.includes('api')) return 2; - if (file.includes('admin')) return 3; - if (file.includes('regression')) return 4; - return 5; - } - - private extractTestTitle(file: string): string { - const parts = file.split('/'); - return parts[parts.length - 1].replace('.spec.ts', ''); - } - - private analyzeDependencies(file: string): string[] { - const dependencies: string[] = []; - - if (file.includes('admin') && !file.includes('login')) { - dependencies.push('admin-login'); - } - - return dependencies; - } - - optimizeExecutionOrder(schedules: TestSchedule[]): TestSchedule[] { - const optimized: TestSchedule[] = []; - const executed = new Set(); - - const noDeps = schedules.filter(s => s.dependencies.length === 0); - optimized.push(...noDeps); - noDeps.forEach(s => executed.add(s.testId)); - - let remaining = schedules.filter(s => !executed.has(s.testId)); - let iterations = 0; - - while (remaining.length > 0 && iterations < 100) { - const canExecute = remaining.filter(s => - s.dependencies.every(dep => executed.has(dep)) - ); - - if (canExecute.length === 0) { - optimized.push(remaining[0]); - executed.add(remaining[0].testId); - remaining = remaining.slice(1); - } else { - optimized.push(...canExecute); - canExecute.forEach(s => executed.add(s.testId)); - remaining = remaining.filter(s => !executed.has(s.testId)); - } - - iterations++; - } - - return optimized; - } -} \ No newline at end of file diff --git a/e2e/test-history-simple-test.js b/e2e/test-history-simple-test.js deleted file mode 100644 index d8ef3fb..0000000 --- a/e2e/test-history-simple-test.js +++ /dev/null @@ -1,15 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const HISTORY_FILE = path.join(__dirname, 'test-history.json'); - -console.log('Testing history file...'); -if (fs.existsSync(HISTORY_FILE)) { - const data = fs.readFileSync(HISTORY_FILE, 'utf-8'); - const history = JSON.parse(data); - console.log('✅ History loaded successfully'); - console.log('Records:', history.records.length); - console.log('Last updated:', new Date(history.lastUpdated).toISOString()); -} else { - console.log('❌ History file not found'); -} \ No newline at end of file diff --git a/e2e/test-history-test.js b/e2e/test-history-test.js deleted file mode 100644 index 1619daa..0000000 --- a/e2e/test-history-test.js +++ /dev/null @@ -1,6 +0,0 @@ -const { TestHistoryManager } = require('./src/utils/test-history'); - -const manager = new TestHistoryManager(); -console.log('History loaded successfully'); -console.log('Average duration:', manager.getAverageDuration('test-1')); -console.log('Is flaky:', manager.isFlaky('test-1')); \ No newline at end of file diff --git a/e2e/test-history.json b/e2e/test-history.json deleted file mode 100644 index cc05d05..0000000 --- a/e2e/test-history.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "records": [], - "lastUpdated": 0 -} \ No newline at end of file diff --git a/e2e/test-monitor-simple-test.js b/e2e/test-monitor-simple-test.js deleted file mode 100644 index f7754a1..0000000 --- a/e2e/test-monitor-simple-test.js +++ /dev/null @@ -1,103 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -console.log('🔍 测试监控和告警系统...'); - -const alertFile = 'test-results/alerts.json'; - -const mockMetrics = [ - { - timestamp: Date.now() - 3600000, - tier: 'fast', - totalTests: 100, - passedTests: 95, - failedTests: 5, - skippedTests: 0, - duration: 60000, - successRate: 0.95, - }, - { - timestamp: Date.now() - 1800000, - tier: 'standard', - totalTests: 200, - passedTests: 180, - failedTests: 15, - skippedTests: 5, - duration: 180000, - successRate: 0.9, - }, - { - timestamp: Date.now(), - tier: 'deep', - totalTests: 50, - passedTests: 40, - failedTests: 10, - skippedTests: 0, - duration: 600000, - successRate: 0.8, - }, -]; - -console.log('📊 模拟测试指标...'); - -const alertManager = { - alerts: [], - alertFile, - - createAlert(severity, message, tier, metrics) { - const alert = { - id: `alert-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - timestamp: Date.now(), - severity, - message, - tier, - metrics, - resolved: false, - }; - this.alerts.push(alert); - console.log(`🚨 [${severity.toUpperCase()}] ${message}`); - return alert; - }, - - getAlertSummary() { - const activeAlerts = this.alerts.filter(a => !a.resolved); - const bySeverity = { low: 0, medium: 0, high: 0, critical: 0 }; - activeAlerts.forEach(a => bySeverity[a.severity]++); - return { total: this.alerts.length, active: activeAlerts.length, bySeverity }; - }, -}; - -console.log('🔍 检查告警规则...'); - -for (const metric of mockMetrics) { - if (metric.successRate < 0.8) { - alertManager.createAlert('critical', '测试通过率低于80%', metric.tier, metric); - } else if (metric.successRate < 0.9) { - alertManager.createAlert('high', '测试通过率低于90%', metric.tier, metric); - } - - if (metric.failedTests > 10) { - alertManager.createAlert('high', '失败测试数量超过10个', metric.tier, metric); - } - - if (metric.tier === 'deep' && metric.failedTests > 0) { - alertManager.createAlert('critical', '深度层测试存在失败', metric.tier, metric); - } -} - -const summary = alertManager.getAlertSummary(); -console.log('\n📊 告警统计:'); -console.log(` 总告警数: ${summary.total}`); -console.log(` 活跃告警: ${summary.active}`); -console.log(` Critical: ${summary.bySeverity.critical}`); -console.log(` High: ${summary.bySeverity.high}`); -console.log(` Medium: ${summary.bySeverity.medium}`); -console.log(` Low: ${summary.bySeverity.low}`); - -if (summary.active > 0) { - console.log('\n✅ 监控系统工作正常,已生成告警'); - process.exit(0); -} else { - console.log('\n⚠️ 未生成告警'); - process.exit(1); -} \ No newline at end of file diff --git a/e2e/test-optimizer-simple-test.js b/e2e/test-optimizer-simple-test.js deleted file mode 100644 index a0425eb..0000000 --- a/e2e/test-optimizer-simple-test.js +++ /dev/null @@ -1,170 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -console.log('🚀 测试性能优化工具...'); - -const mockTestResults = [ - { - testId: 'test-1', - file: 'smoke/navigation.smoke.spec.ts', - title: '应该成功加载首页', - duration: 15000, - tier: 'fast', - history: [12000, 14000, 15000, 16000, 18000], - }, - { - testId: 'test-2', - file: 'admin/news-management.spec.ts', - title: '应该能够创建新闻', - duration: 45000, - tier: 'standard', - history: [40000, 42000, 45000, 48000, 50000], - }, - { - testId: 'test-3', - file: 'api/admin.api.spec.ts', - title: '应该能够获取内容列表', - duration: 80000, - tier: 'fast', - history: [60000, 70000, 80000, 90000, 100000], - }, - { - testId: 'test-4', - file: 'visual/homepage-visual.spec.ts', - title: '首页视觉回归测试', - duration: 150000, - tier: 'deep', - history: [120000, 130000, 150000, 170000, 180000], - }, - { - testId: 'test-5', - file: 'responsive/mobile-navigation.spec.ts', - title: '移动端导航功能测试', - duration: 95000, - tier: 'standard', - history: [80000, 85000, 95000, 105000, 110000], - }, -]; - -const optimizer = { - rules: [ - { - name: 'slow-test', - condition: (p) => p.duration > 60000, - suggestions: [ - '考虑将测试拆分为多个小测试', - '检查是否有不必要的等待时间', - '优化选择器以提高定位速度', - ], - }, - { - name: 'very-slow-test', - condition: (p) => p.duration > 120000, - suggestions: [ - '测试执行时间过长,强烈建议拆分', - '检查是否有性能瓶颈(如大量DOM操作)', - ], - }, - { - name: 'tier-fast-slow', - condition: (p) => p.tier === 'fast' && p.duration > 30000, - suggestions: [ - '快速层测试不应超过30秒', - '重新评估测试是否属于快速层', - ], - }, - ], - - analyzePerformance(testId, file, title, duration, tier, history) { - const avgDuration = history.length > 0 - ? history.reduce((sum, d) => sum + d, 0) / history.length - : duration; - - const sortedHistory = [...history].sort((a, b) => a - b); - const percentile = sortedHistory.length > 0 - ? sortedHistory[Math.floor(sortedHistory.length * 0.9)] - : duration; - - const isSlow = duration > 60000; - - const suggestions = []; - for (const rule of this.rules) { - if (rule.condition({ testId, file, title, duration, tier, avgDuration, percentile, isSlow })) { - suggestions.push(...rule.suggestions); - } - } - - return { - testId, - file, - title, - duration, - tier, - avgDuration, - percentile, - isSlow, - optimizationSuggestions: suggestions, - }; - }, - - generateOptimizationReport(performances) { - const slowTests = performances.filter(p => p.isSlow); - const totalDuration = performances.reduce((sum, p) => sum + p.duration, 0); - const slowDuration = slowTests.reduce((sum, p) => sum + p.duration, 0); - const potentialSavings = slowDuration * 0.3; - - let report = '🚀 测试性能优化报告\n'; - report += '='.repeat(50) + '\n\n'; - - report += `总测试数: ${performances.length}\n`; - report += `慢速测试: ${slowTests.length}\n`; - report += `潜在节省时间: ${(potentialSavings / 1000).toFixed(2)}s\n\n`; - - const sortedSlowTests = slowTests.sort((a, b) => b.duration - a.duration); - - if (sortedSlowTests.length > 0) { - report += '🐌 慢速测试详情:\n'; - sortedSlowTests.forEach((test, index) => { - report += `\n${index + 1}. ${test.title}\n`; - report += ` 文件: ${test.file}\n`; - report += ` 耗时: ${(test.duration / 1000).toFixed(2)}s\n`; - report += ` 层级: ${test.tier}\n`; - if (test.optimizationSuggestions.length > 0) { - report += ` 优化建议:\n`; - test.optimizationSuggestions.forEach(suggestion => { - report += ` - ${suggestion}\n`; - }); - } - }); - } else { - report += '✅ 未发现慢速测试\n'; - } - - return report; - }, -}; - -console.log('📊 分析测试性能...'); - -const performances = mockTestResults.map(result => - optimizer.analyzePerformance( - result.testId, - result.file, - result.title, - result.duration, - result.tier, - result.history - ) -); - -const report = optimizer.generateOptimizationReport(performances); -console.log(report); - -const slowTestsCount = performances.filter(p => p.isSlow).length; -if (slowTestsCount > 0) { - console.log(`\n✅ 优化器工作正常,发现 ${slowTestsCount} 个慢速测试`); - process.exit(0); -} else { - console.log('\n⚠️ 未发现慢速测试'); - process.exit(1); -} \ No newline at end of file diff --git a/e2e/test-reporter-simple-test.js b/e2e/test-reporter-simple-test.js deleted file mode 100644 index e634e51..0000000 --- a/e2e/test-reporter-simple-test.js +++ /dev/null @@ -1,105 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const reportDir = 'test-results'; -const mockResults = [ - { - testId: 'test-1', - file: 'smoke/navigation.smoke.spec.ts', - title: '应该成功加载首页', - status: 'passed', - duration: 15000, - tier: 'fast', - tags: ['@smoke', '@critical'], - }, - { - testId: 'test-2', - file: 'admin/news-management.spec.ts', - title: '应该能够创建新闻', - status: 'passed', - duration: 45000, - tier: 'standard', - tags: ['@admin', '@regression'], - }, - { - testId: 'test-3', - file: 'api/admin.api.spec.ts', - title: '应该能够获取内容列表', - status: 'failed', - duration: 5000, - tier: 'fast', - tags: ['@api', '@critical'], - }, - { - testId: 'test-4', - file: 'visual/homepage-visual.spec.ts', - title: '首页视觉回归测试', - status: 'passed', - duration: 150000, - tier: 'deep', - tags: ['@visual', '@regression'], - }, -]; - -console.log('📊 Testing test reporter...'); - -if (!fs.existsSync(reportDir)) { - fs.mkdirSync(reportDir, { recursive: true }); -} - -const report = { - timestamp: new Date().toISOString(), - tiers: { - fast: { - name: 'fast', - total: 2, - passed: 1, - failed: 1, - skipped: 0, - duration: 20000, - avgDuration: 10000, - }, - standard: { - name: 'standard', - total: 1, - passed: 1, - failed: 0, - skipped: 0, - duration: 45000, - avgDuration: 45000, - }, - deep: { - name: 'deep', - total: 1, - passed: 1, - failed: 0, - skipped: 0, - duration: 150000, - avgDuration: 150000, - }, - }, - total: { - name: 'total', - total: 4, - passed: 3, - failed: 1, - skipped: 0, - duration: 215000, - avgDuration: 53750, - }, - failedTests: mockResults.filter(r => r.status === 'failed'), - slowTests: mockResults.filter(r => r.duration > 120000), - flakyTests: [], -}; - -const jsonPath = path.join(reportDir, `test-report-${Date.now()}.json`); -fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2)); -console.log(`✅ JSON报告已保存: ${jsonPath}`); - -console.log('✅ Report generated:'); -console.log(` Total tests: ${report.total.total}`); -console.log(` Passed: ${report.total.passed}`); -console.log(` Failed: ${report.total.failed}`); -console.log(` Duration: ${(report.total.duration / 1000).toFixed(2)}s`); - -console.log('✅ Test reporter completed'); \ No newline at end of file diff --git a/e2e/test-reporter-test.js b/e2e/test-reporter-test.js deleted file mode 100644 index d1dbfda..0000000 --- a/e2e/test-reporter-test.js +++ /dev/null @@ -1,55 +0,0 @@ -const { TestReporter } = require('./src/utils/test-reporter'); - -const mockResults = [ - { - testId: 'test-1', - file: 'smoke/navigation.smoke.spec.ts', - title: '应该成功加载首页', - status: 'passed', - duration: 15000, - tier: 'fast', - tags: ['@smoke', '@critical'], - }, - { - testId: 'test-2', - file: 'admin/news-management.spec.ts', - title: '应该能够创建新闻', - status: 'passed', - duration: 45000, - tier: 'standard', - tags: ['@admin', '@regression'], - }, - { - testId: 'test-3', - file: 'api/admin.api.spec.ts', - title: '应该能够获取内容列表', - status: 'failed', - duration: 5000, - tier: 'fast', - tags: ['@api', '@critical'], - }, - { - testId: 'test-4', - file: 'visual/homepage-visual.spec.ts', - title: '首页视觉回归测试', - status: 'passed', - duration: 150000, - tier: 'deep', - tags: ['@visual', '@regression'], - }, -]; - -console.log('📊 Testing test reporter...'); -const reporter = new TestReporter(); -const report = reporter.generateReport(mockResults); - -console.log('✅ Report generated:'); -console.log(` Total tests: ${report.total.total}`); -console.log(` Passed: ${report.total.passed}`); -console.log(` Failed: ${report.total.failed}`); -console.log(` Duration: ${(report.total.duration / 1000).toFixed(2)}s`); - -reporter.saveReport(report, 'json'); -reporter.saveReport(report, 'html'); - -console.log('✅ Test reporter completed'); \ No newline at end of file diff --git a/e2e/test-scheduler-simple-test.js b/e2e/test-scheduler-simple-test.js deleted file mode 100644 index 5de5a6f..0000000 --- a/e2e/test-scheduler-simple-test.js +++ /dev/null @@ -1,37 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const HISTORY_FILE = path.join(__dirname, 'test-history.json'); - -const testFiles = [ - 'smoke/navigation.smoke.spec.ts', - 'admin/news-management.spec.ts', - 'api/admin.api.spec.ts', -]; - -console.log('📊 Testing test scheduler...'); -console.log('Test files:', testFiles); - -if (fs.existsSync(HISTORY_FILE)) { - const data = fs.readFileSync(HISTORY_FILE, 'utf-8'); - const history = JSON.parse(data); - console.log('✅ History loaded'); - console.log('Records:', history.records.length); - - const schedule = testFiles.map(file => ({ - file, - testId: file.replace(/[^a-zA-Z0-9]/g, '-'), - priority: file.includes('smoke') ? 1 : file.includes('api') ? 2 : 3, - estimatedDuration: 60000, - dependencies: [], - })); - - console.log('\n📋 Scheduled tests:'); - schedule.forEach((test, index) => { - console.log(` ${index + 1}. ${test.file} (Priority: ${test.priority})`); - }); - - console.log('\n✅ Scheduler test completed'); -} else { - console.log('❌ History file not found'); -} \ No newline at end of file diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json deleted file mode 100644 index b8c081b..0000000 --- a/e2e/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "moduleResolution": "node", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "types": ["node", "@playwright/test"], - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"], - "@pages/*": ["./src/pages/*"], - "@fixtures/*": ["./src/fixtures/*"], - "@utils/*": ["./src/utils/*"], - "@types/*": ["./src/types/*"] - } - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "test-results", "playwright-report"] -} diff --git a/findings.md b/findings.md deleted file mode 100644 index 9d91c61..0000000 --- a/findings.md +++ /dev/null @@ -1,264 +0,0 @@ -# Findings - -## 测试覆盖率评估发现 - -### 发现时间 -2026-03-25 - -### 项目基本信息 -- **项目名称**: novalon-website -- **项目类型**: Next.js 16 + React 19 企业官网 -- **技术栈**: TypeScript, Tailwind CSS, Drizzle ORM, NextAuth.js -- **当前测试覆盖率**: 约85% -- **目标测试覆盖率**: 90%以上 - -## 关键发现 - -### 1. 测试架构分析 - -#### 发现内容 -项目采用分层测试体系,测试架构完善: -- **快速层**: 冒烟测试、API测试、基础功能验证(30秒内完成) -- **标准层**: 功能测试、响应式测试、移动端核心功能(60秒内完成) -- **深度层**: 视觉回归、性能测试、完整回归测试(120秒内完成) - -#### 测试覆盖范围 -1. **冒烟测试** - 100%覆盖核心页面 - - 首页、关于页、案例页、服务页、产品页、解决方案页、新闻页、联系页 - - 导航功能和面包屑导航 - - 页面加载验证 - -2. **管理后台测试** - 95%覆盖率 - - 产品服务管理:创建、编辑、删除、筛选、搜索 - - 成功案例管理:创建、编辑、删除、封面图设置 - - 新闻动态管理:创建、编辑、删除、发布、草稿状态 - - 服务管理:完整的CRUD操作 - - 富文本编辑器:基础格式化功能 - - 权限控制:管理员、编辑者、查看者权限验证 - -3. **联系表单测试** - 71.4%覆盖率 - - 表单渲染验证 - - 表单验证(姓名、电话、邮箱、主题、留言) - - 安全功能(XSS防护、Honeypot字段) - - 可访问性测试 - - 响应式设计测试 - -4. **安全测试** - 全面覆盖 - - HTTP安全头验证 - - XSS漏洞防护 - - CSRF保护 - - 速率限制 - - 输入数据验证 - - 内容安全策略 - - SQL注入防护 - - 会话管理 - -5. **可访问性测试** - WCAG标准覆盖 - - 页面语言属性 - - 标题层级结构 - - 图片alt属性 - - 表单标签关联 - - 键盘导航 - - 颜色对比度 - - ARIA属性验证 - -6. **性能测试** - Core Web Vitals覆盖 - - 页面加载时间 - - 首次内容绘制 - - 最大内容绘制 - - 可交互时间 - - 累积布局偏移 - - 网络时序测试 - - 资源加载测试 - -7. **移动端测试** - 全面覆盖 - - 移动端菜单交互 - - 触摸目标尺寸 - - 响应式布局 - - 移动端表单可用性 - - 键盘导航 - -#### 影响 -- 测试架构完善,分层清晰 -- 非功能性测试覆盖完整 -- 管理后台功能验证充分 -- 核心业务流程测试不够完整 - -#### 建议 -- 重点补充核心业务流程的端到端测试 -- 完善联系表单的完整提交流程 -- 补充详情页的深度交互测试 - -### 2. 关键测试遗漏分析 - -#### 发现内容 -**高优先级遗漏**: - -1. **联系表单完整流程** - - **当前覆盖**: 71.4% - - **主要遗漏**: 实际邮件发送验证、完整提交流程 - - **影响**: 无法验证核心业务功能的端到端可用性 - - **建议**: 配置测试邮件服务,完成跳过的测试用例 - -2. **详情页深度测试** - - **当前状态**: 部分测试被跳过 - - **主要遗漏**: 案例详情、新闻详情、产品详情的完整交互 - - **影响**: 详情页功能验证不够充分 - - **建议**: 补充详情页的完整用户交互测试 - -**中优先级遗漏**: - -3. **富文本编辑器高级功能** - - **当前覆盖**: 80% - - **主要遗漏**: 图片上传、表格插入、代码块等功能 - - **影响**: 内容管理功能验证不完整 - - **建议**: 补充富文本编辑器的高级功能测试 - -4. **配置管理边界条件** - - **当前状态**: 基础功能已覆盖 - - **主要遗漏**: 并发配置、极端值测试 - - **影响**: 配置系统的稳定性验证不足 - - **建议**: 添加配置管理的边界条件测试 - -5. **视觉回归全面覆盖** - - **当前状态**: 仅有联系页面和首页的视觉测试 - - **主要遗漏**: 所有页面的视觉一致性验证 - - **影响**: UI变更可能未被及时发现 - - **建议**: 扩展视觉回归测试覆盖范围 - -#### 影响 -- 核心业务流程验证不完整 -- 用户体验测试覆盖不足 -- 内容管理功能验证不全面 -- UI变更监控不充分 - -#### 建议 -- 优先完成联系表单测试配置 -- 补充详情页和富文本编辑器测试 -- 扩展视觉回归和边界条件测试 - -### 3. 测试工具和框架评估 - -#### 发现内容 -项目使用现代化测试工具和框架: -- **E2E测试**: Playwright(TypeScript) -- **单元测试**: Jest + React Testing Library -- **测试配置**: 分层测试配置(快速层、标准层、深度层) -- **CI/CD集成**: Woodpecker CI自动化测试执行 -- **质量门禁**: Husky + lint-staged + commitlint - -#### 优势 -- 测试工具现代化,功能强大 -- 分层测试策略清晰,执行效率高 -- CI/CD集成完善,自动化程度高 -- 质量门禁建立,代码质量有保障 - -#### 不足 -- 部分测试用例被跳过,需要环境配置 -- 视觉回归测试覆盖不全面 -- 测试数据管理可以进一步优化 - -#### 建议 -- 完善测试环境配置,启用所有测试用例 -- 扩展视觉回归测试覆盖范围 -- 优化测试数据管理和执行效率 - -### 4. 业务功能与测试覆盖对比 - -#### 发现内容 -**已完全覆盖的业务功能**: -- 页面导航: 100% -- 内容展示: 100% -- 管理后台: 95% -- 安全防护: 100% -- 可访问性: 100% -- 性能指标: 100% -- 移动端适配: 100% -- 权限控制: 100% - -**部分覆盖的业务功能**: -- 联系表单: 71.4% -- 富文本编辑器: 80% -- 配置管理: 90% - -**未覆盖的业务功能**: -- 详情页交互: 部分测试被跳过 -- 完整用户旅程: 需要完整环境配置 - -#### 影响 -- 核心业务功能验证不完整 -- 用户体验测试覆盖不足 -- 端到端流程验证不够充分 - -#### 建议 -- 优先补充核心业务流程的端到端测试 -- 完善用户旅程测试覆盖 -- 提升整体测试覆盖率到90%以上 - -## 技术债务 - -### 高优先级 -1. 联系表单测试完善 - 影响核心业务功能验证 -2. 详情页深度测试补充 - 影响用户体验验证 -3. 富文本编辑器高级功能测试 - 影响内容管理功能 - -### 中优先级 -4. 配置管理边界条件测试 - 影响系统稳定性验证 -5. 视觉回归测试扩展 - 影响UI一致性监控 -6. 测试执行效率优化 - 影响开发和反馈速度 - -### 低优先级 -7. 测试数据管理优化 - 可选优化 -8. 测试报告完善 - 长期改进 - -## 优化机会 - -### 测试覆盖率提升 -1. 完成联系表单测试配置,启用所有跳过的测试用例 -2. 补充详情页的完整交互测试 -3. 添加富文本编辑器的高级功能测试 -4. 扩展视觉回归测试覆盖所有主要页面 -5. 添加配置管理的边界条件测试 - -### 测试质量提升 -1. 优化测试用例设计,覆盖更多边界条件 -2. 增强测试数据管理,提高测试可维护性 -3. 改进测试报告,提供更详细的测试结果分析 -4. 建立测试用例review机制,确保测试质量 - -### 开发体验改进 -1. 优化测试执行时间,提高开发反馈速度 -2. 改进测试调试体验,提供更清晰的错误信息 -3. 建立测试最佳实践文档,降低学习成本 -4. 创建测试故障排查指南,提高问题解决效率 - -## 风险评估 - -### 测试覆盖率提升风险 -- **风险等级**: 中 -- **影响**: 可能发现现有功能缺陷,需要修复时间 -- **缓解措施**: 预留修复时间,优先级排序处理 - -### 测试环境配置风险 -- **风险等级**: 高 -- **影响**: 联系表单测试需要配置邮件服务 -- **缓解措施**: 使用测试邮件服务,不影响生产环境 - -### 视觉回归测试风险 -- **风险等级**: 中 -- **影响**: 首次建立基准,可能需要大量调整 -- **缓解措施**: 逐步建立基准,分阶段验证 - -## 下一步行动 - -1. 开始Phase 1:联系表单测试完善 -2. 依次完成各个阶段的测试补充 -3. 持续监控测试覆盖率和质量指标 -4. 及时调整计划以适应实际情况 - -## 参考资料 - -- Playwright测试文档: https://playwright.dev -- Jest测试框架: https://jestjs.io -- React Testing Library: https://testing-library.com/react -- WCAG可访问性标准: https://www.w3.org/WAI/WCAG21/quickref/ \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 62d0341..f673ccc 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,9 +1,12 @@ import type { NextConfig } from "next"; const isDev = process.env.NODE_ENV === 'development'; +const cdnDomain = process.env.CDN_DOMAIN || 'https://cdn.novalon.cn'; const nextConfig: NextConfig = { distDir: 'dist', + output: 'standalone', + assetPrefix: isDev ? undefined : cdnDomain, images: { remotePatterns: [ { @@ -103,4 +106,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default nextConfig; \ No newline at end of file diff --git a/package.json b/package.json index 7fef2e4..fbee328 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,9 @@ "lighthouse:upload": "lhci upload", "lighthouse:desktop": "lhci autorun --settings.preset=desktop", "lighthouse:mobile": "lhci autorun --settings.preset=mobile", + "deploy:cdn": "bash scripts/deploy-cdn.sh", + "deploy:cdn:refresh": "bash scripts/refresh-cdn.sh", + "clean:tests": "bash scripts/maintenance/clean-test-files.sh", "prepare": "husky" }, "dependencies": { diff --git a/progress.md b/progress.md deleted file mode 100644 index ebb83cc..0000000 --- a/progress.md +++ /dev/null @@ -1,105 +0,0 @@ -# Progress Log - -## Session: 2026-03-25 测试覆盖率提升与质量保障迭代 - -### Started -- **Task**: 基于测试覆盖率评估结果,将整体测试覆盖率从85%提升到90%以上 -- **Plan**: task_plan.md -- **Findings**: findings.md - -### Actions - -#### Action 1: 创建迭代规划文件 -- **时间**: 2026-03-25 -- **操作**: 基于测试覆盖率评估结果,创建详细的迭代计划 -- **结果**: - - ✅ task_plan.md - 包含8个阶段的测试覆盖率提升计划 - - ✅ findings.md - 记录测试覆盖率评估发现和问题 - - ✅ progress.md - 当前文件 - -#### Action 2: 测试覆盖率评估分析 -- **时间**: 2026-03-25 -- **操作**: 分析当前测试覆盖率和测试架构 -- **结果**: - - ✅ 识别出当前测试覆盖率约为85% - - ✅ 分析了分层测试体系(快速层、标准层、深度层) - - ✅ 识别了测试覆盖的关键遗漏和不足 - - ✅ 确定了测试覆盖率提升的目标(90%以上) - -### Tests -- 待运行 - -### Completed -- ✅ 测试覆盖率评估分析 -- ✅ 迭代计划制定 -- ✅ 规划文件创建 - -### In Progress -- 🔄 Phase 1: 联系表单测试完善(准备开始) - -### Pending -- ⏳ Phase 2: 详情页深度测试补充 -- ⏳ Phase 3: 富文本编辑器高级功能测试 -- ⏳ Phase 4: 配置管理边界条件测试 -- ⏳ Phase 5: 视觉回归测试扩展 -- ⏳ Phase 6: 测试覆盖率验证与优化 -- ⏳ Phase 7: 质量门禁强化 -- ⏳ Phase 8: 文档更新与知识沉淀 - -### Files Created -- task_plan.md - 测试覆盖率提升计划 -- findings.md - 测试覆盖率评估发现 -- progress.md - 进度记录 - -### Files Modified -- 无 - -### Files Deleted -- 无 - -### Next Steps -1. 开始 Phase 1:联系表单测试完善 -2. 依次完成各个阶段的测试补充 -3. 持续监控测试覆盖率和质量指标 -4. 及时调整计划以适应实际情况 - -### Notes -- 所有规划文件已创建完成 -- 测试覆盖率评估分析已完成 -- 准备开始执行 Phase 1:联系表单测试完善 -- 建议按照优先级依次完成各个阶段的任务 -- 遇到问题需要及时记录和调整计划 - -### Summary - -本次测试覆盖率提升与质量保障迭代计划已制定完成: - -**核心目标**: -1. 测试覆盖率提升:从85%提升到90%以上 -2. 核心业务流程完善:补充端到端测试 -3. 质量保障强化:建立更严格的质量门禁 -4. 文档知识沉淀:沉淀测试最佳实践 - -**关键发现**: -- 测试架构完善,分层清晰 -- 非功能性测试覆盖完整 -- 核心业务流程测试不够完整 -- 联系表单测试覆盖率为71.4% -- 详情页深度测试部分被跳过 -- 富文本编辑器高级功能未覆盖 - -**优化计划**: -- Phase 1: 联系表单测试完善(2小时) -- Phase 2: 详情页深度测试补充(3小时) -- Phase 3: 富文本编辑器高级功能测试(2小时) -- Phase 4: 配置管理边界条件测试(1.5小时) -- Phase 5: 视觉回归测试扩展(2.5小时) -- Phase 6: 测试覆盖率验证与优化(2小时) -- Phase 7: 质量门禁强化(1.5小时) -- Phase 8: 文档更新与知识沉淀(1.5小时) - -**预计总时间**: ~16小时(约2个工作日) - -**迭代开始日期**: 2026-03-25 -**迭代执行者**: AI Assistant (张翔) -**项目版本**: 1.0.0-phase1 \ No newline at end of file diff --git a/scripts/deploy-cdn.sh b/scripts/deploy-cdn.sh new file mode 100644 index 0000000..464d2b1 --- /dev/null +++ b/scripts/deploy-cdn.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +set -e + +CDN_DOMAIN=${CDN_DOMAIN:-"https://cdn.novalon.cn"} +COS_BUCKET=${COS_BUCKET:-"novalon-cdn-1250000000"} +COS_REGION=${COS_REGION:-"ap-chengdu"} +DIST_DIR=${DIST_DIR:-"dist/static"} +STANDALONE_DIR=${STANDALONE_DIR:-"dist/standalone"} + +echo "=========================================" +echo "CDN静态资源部署脚本" +echo "=========================================" +echo "CDN域名: $CDN_DOMAIN" +echo "COS存储桶: $COS_BUCKET" +echo "COS区域: $COS_REGION" +echo "静态资源目录: $DIST_DIR" +echo "=========================================" + +if [ ! -d "$DIST_DIR" ]; then + echo "错误: 静态资源目录不存在: $DIST_DIR" + echo "请先运行 npm run build 构建项目" + exit 1 +fi + +echo "" +echo "步骤1: 检查coscmd工具..." +if ! command -v coscmd &> /dev/null; then + echo "安装coscmd工具..." + pip install coscmd +fi + +echo "" +echo "步骤2: 配置coscmd..." +if [ -z "$COS_SECRET_ID" ] || [ -z "$COS_SECRET_KEY" ]; then + echo "错误: 请设置环境变量 COS_SECRET_ID 和 COS_SECRET_KEY" + echo "可以在腾讯云控制台 > 访问管理 > API密钥管理中获取" + exit 1 +fi + +coscmd config -a "$COS_SECRET_ID" -s "$COS_SECRET_KEY" -b "$COS_BUCKET" -r "$COS_REGION" + +echo "" +echo "步骤3: 上传静态资源到COS..." +echo "上传 _next/static/ 目录..." + +coscmd upload -r "$DIST_DIR" /_next/static/ --sync --delete + +echo "" +echo "步骤4: 上传public目录中的静态资源..." +if [ -d "public" ]; then + echo "上传 public/ 目录..." + coscmd upload -r public/ / --sync +fi + +echo "" +echo "步骤5: 设置COS对象缓存策略..." +echo "为静态资源设置长期缓存 (1年)..." + +coscmd set-meta "_next/static/*" "Cache-Control: public, max-age=31536000, immutable" -r + +echo "" +echo "步骤6: 刷新CDN缓存..." +if [ -n "$CDN_DOMAIN" ]; then + CDN_DOMAIN_CLEAN=$(echo "$CDN_DOMAIN" | sed 's|https://||' | sed 's|http://||') + echo "刷新CDN域名: $CDN_DOMAIN_CLEAN" + + if command -v tccli &> /dev/null; then + tccli cdn PurgePathsCache --Paths '["https://'"$CDN_DOMAIN_CLEAN"'/_next/static/"]' --FlushType flush + echo "CDN缓存刷新请求已提交" + else + echo "提示: 未安装tccli工具,请手动在腾讯云控制台刷新CDN缓存" + echo "刷新路径: https://$CDN_DOMAIN_CLEAN/_next/static/" + fi +fi + +echo "" +echo "=========================================" +echo "部署完成!" +echo "=========================================" +echo "静态资源已上传到: https://$COS_BUCKET.cos.$COS_REGION.myqcloud.com" +echo "CDN加速域名: $CDN_DOMAIN" +echo "" +echo "后续步骤:" +echo "1. 在腾讯云CDN控制台配置加速域名: cdn.novalon.cn" +echo "2. 设置源站为COS存储桶: $COS_BUCKET.cos.$COS_REGION.myqcloud.com" +echo "3. 配置HTTPS证书" +echo "4. 测试CDN加速效果" +echo "=========================================" diff --git a/scripts/maintenance/fix-dev-server.sh b/scripts/maintenance/fix-dev-server.sh deleted file mode 100644 index cae8147..0000000 --- a/scripts/maintenance/fix-dev-server.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/bin/bash - -echo "🔧 Next.js 开发服务器问题修复工具" -echo "==================================" -echo "" - -# 颜色定义 -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -# 检查是否有进程在3000端口 -if lsof -ti:3000 > /dev/null 2>&1; then - echo -e "${YELLOW}⚠️ 检测到端口3000上有进程运行${NC}" - echo "正在停止进程..." - lsof -ti:3000 | xargs kill -9 2>/dev/null - echo -e "${GREEN}✅ 进程已停止${NC}" - sleep 2 -else - echo -e "${GREEN}✅ 端口3000未被占用${NC}" -fi - -# 清除缓存 -echo "" -echo "清除Next.js缓存..." - -if [ -d ".next" ]; then - echo "删除 .next 目录..." - rm -rf .next - echo -e "${GREEN}✅ .next 目录已清除${NC}" -else - echo -e "${GREEN}✅ .next 目录不存在,无需清除${NC}" -fi - -# 清除node_modules缓存(可选) -if [ "$1" == "--deep" ]; then - echo "" - echo "执行深度清理..." - - if [ -d "node_modules/.cache" ]; then - echo "删除 node_modules/.cache 目录..." - rm -rf node_modules/.cache - echo -e "${GREEN}✅ node_modules/.cache 已清除${NC}" - fi - - if [ -d ".turbo" ]; then - echo "删除 .turbo 目录..." - rm -rf .turbo - echo -e "${GREEN}✅ .turbo 已清除${NC}" - fi -fi - -echo "" -echo -e "${GREEN}✅ 修复完成!${NC}" -echo "" -echo "现在可以运行开发服务器:" -echo -e "${YELLOW}npm run dev${NC}" -echo "" -echo "或者使用以下命令:" -echo -e "${YELLOW}npm run dev${NC} - 启动开发服务器" -echo -e "${YELLOW}npm run build${NC} - 构建生产版本" -echo "" -echo "提示:如果问题仍然存在,尝试使用 --deep 参数进行深度清理:" -echo -e "${YELLOW}./scripts/fix-dev-server.sh --deep${NC}" \ No newline at end of file diff --git a/scripts/maintenance/fix-login-issue.sh b/scripts/maintenance/fix-login-issue.sh deleted file mode 100644 index 7c8d3c9..0000000 --- a/scripts/maintenance/fix-login-issue.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/bash - -echo "🔧 修复登录问题" -echo "================" -echo "" - -# 颜色定义 -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -# 停止当前服务器 -echo "1. 停止当前服务器..." -if lsof -ti:3000 > /dev/null 2>&1; then - lsof -ti:3000 | xargs kill -9 2>/dev/null - echo -e "${GREEN}✅ 服务器已停止${NC}" -else - echo -e "${YELLOW}⚠️ 没有运行的服务器${NC}" -fi - -# 清除缓存 -echo "" -echo "2. 清除缓存..." -rm -rf .next -echo -e "${GREEN}✅ 缓存已清除${NC}" - -# 重新构建 -echo "" -echo "3. 重新构建应用..." -npm run build - -if [ $? -eq 0 ]; then - echo -e "${GREEN}✅ 构建成功${NC}" - - # 启动服务器 - echo "" - echo "4. 启动生产服务器..." - npm run start & - - sleep 3 - - if lsof -ti:3000 > /dev/null 2>&1; then - echo -e "${GREEN}✅ 服务器已启动${NC}" - echo "" - echo "==================================" - echo -e "${GREEN}🎉 修复完成!${NC}" - echo "==================================" - echo "" - echo "📧 管理员邮箱: admin@novalon.cn" - echo "🔑 管理员密码: admin123456" - echo "🌐 登录地址: http://localhost:3000/admin/login" - echo "" - echo "💡 提示:" - echo " - 打开浏览器控制台查看登录调试信息" - echo " - 如果仍有问题,请检查控制台错误" - echo " - 建议使用Chrome或Firefox浏览器" - else - echo -e "${RED}❌ 服务器启动失败${NC}" - fi -else - echo -e "${RED}❌ 构建失败${NC}" - exit 1 -fi \ No newline at end of file diff --git a/scripts/refresh-cdn.sh b/scripts/refresh-cdn.sh new file mode 100644 index 0000000..ce7bc12 --- /dev/null +++ b/scripts/refresh-cdn.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +set -e + +CDN_DOMAIN=${CDN_DOMAIN:-"https://cdn.novalon.cn"} +COS_BUCKET=${COS_BUCKET:-"novalon-cdn-1250000000"} +COS_REGION=${COS_REGION:-"ap-chengdu"} + +echo "=========================================" +echo "CDN缓存刷新脚本" +echo "=========================================" +echo "CDN域名: $CDN_DOMAIN" +echo "=========================================" + +CDN_DOMAIN_CLEAN=$(echo "$CDN_DOMAIN" | sed 's|https://||' | sed 's|http://||') + +echo "" +echo "刷新CDN缓存..." + +if command -v tccli &> /dev/null; then + echo "使用tccli刷新CDN缓存..." + + tccli cdn PurgePathsCache \ + --Paths "[\"https://$CDN_DOMAIN_CLEAN/_next/static/\"]" \ + --FlushType flush + + echo "CDN缓存刷新请求已提交" + echo "刷新ID可通过腾讯云控制台查看进度" +else + echo "错误: 未安装tccli工具" + echo "" + echo "请手动在腾讯云控制台刷新CDN缓存:" + echo "1. 登录腾讯云控制台: https://console.cloud.tencent.com/cdn" + echo "2. 进入缓存刷新页面" + echo "3. 选择'目录刷新'" + echo "4. 输入刷新URL: https://$CDN_DOMAIN_CLEAN/_next/static/" + echo "5. 点击提交" +fi + +echo "" +echo "=========================================" +echo "完成!" +echo "=========================================" diff --git a/src/app/admin/login/simple-page.tsx b/src/app/admin/login/simple-page.tsx deleted file mode 100644 index dc51a85..0000000 --- a/src/app/admin/login/simple-page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -export default function SimpleLoginPage() { - return ( -
-
-

管理后台登录

-
-
- - -
-
- - -
- -
-
-
- ); -} \ No newline at end of file diff --git a/src/app/admin/simple/page.tsx b/src/app/admin/simple/page.tsx deleted file mode 100644 index 4c63782..0000000 --- a/src/app/admin/simple/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export default function SimpleAdminPage() { - return ( -
-
-

Simple Admin Page

-

这是一个简单的admin页面,不依赖任何认证

-
-
- ); -} \ No newline at end of file diff --git a/src/app/admin/test/page.tsx b/src/app/admin/test/page.tsx deleted file mode 100644 index 7c9d996..0000000 --- a/src/app/admin/test/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export default function AdminTestPage() { - return ( -
-
-

Admin Test Page

-

如果你看到这个页面,说明admin路由是工作的

-
-
- ); -} \ No newline at end of file diff --git a/src/app/preview/effects/page.tsx b/src/app/preview/effects/page.tsx deleted file mode 100644 index 851f0d1..0000000 --- a/src/app/preview/effects/page.tsx +++ /dev/null @@ -1,390 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { motion } from 'framer-motion'; -import { MeshGradient } from '@/components/effects/mesh-gradient'; -import { TechGridFlow } from '@/components/effects/tech-grid-flow'; -import { DataParticleFlow } from '@/components/effects/data-particle-flow'; -import { GeometricAbstract } from '@/components/effects/geometric-abstract'; -import { InkTechFusion } from '@/components/effects/ink-tech-fusion'; - -type EffectType = 'mesh' | 'tech-grid' | 'data-particle' | 'geometric' | 'ink-tech' | 'combined'; -type ParticleShape = 'circle' | 'square' | 'triangle' | 'diamond' | 'star' | 'mixed'; -type ParticleEffect = 'default' | 'pulse' | 'glow' | 'trail'; - -interface EffectConfig { - id: EffectType; - name: string; - description: string; - features: string[]; - recommended: boolean; -} - -const effects: EffectConfig[] = [ - { - id: 'mesh', - name: 'MeshGradient', - description: '多层渐变叠加,优雅专业', - features: ['GPU 加速', '4 种主题', '完全可访问'], - recommended: false, - }, - { - id: 'tech-grid', - name: 'TechGridFlow', - description: '科技网格流,数字化连接', - features: ['网格线条', '发光效果', '3 种密度'], - recommended: true, - }, - { - id: 'data-particle', - name: 'DataParticleFlow', - description: '数据粒子流,信息流动', - features: ['粒子动画', '点阵背景', '可自定义数量'], - recommended: true, - }, - { - id: 'geometric', - name: 'GeometricAbstract', - description: '几何抽象,现代美学', - features: ['多种形状', '旋转动画', '3 种复杂度'], - recommended: false, - }, - { - id: 'ink-tech', - name: 'InkTechFusion', - description: '水墨科技融合,传承创新', - features: ['水墨效果', '科技线条', '双色渐变'], - recommended: true, - }, - { - id: 'combined', - name: '组合方案', - description: 'TechGridFlow + InkTechFusion', - features: ['科技感', '文化底蕴', '完美契合'], - recommended: true, - }, -]; - -const particleShapes: { id: ParticleShape; name: string; icon: string }[] = [ - { id: 'circle', name: '圆形', icon: '●' }, - { id: 'square', name: '方形', icon: '■' }, - { id: 'triangle', name: '三角形', icon: '▲' }, - { id: 'diamond', name: '菱形', icon: '◆' }, - { id: 'star', name: '星形', icon: '★' }, - { id: 'mixed', name: '混合', icon: '✦' }, -]; - -const particleEffects: { id: ParticleEffect; name: string; description: string }[] = [ - { id: 'default', name: '默认', description: '标准动画效果' }, - { id: 'pulse', name: '脉冲', description: '呼吸式缩放' }, - { id: 'glow', name: '发光', description: '增强发光效果' }, - { id: 'trail', name: '轨迹', description: '长距离移动轨迹' }, -]; - -export default function EffectsPreviewPage() { - const [selectedEffect, setSelectedEffect] = useState('data-particle'); - const [particleShape, setParticleShape] = useState('circle'); - const [particleEffect, setParticleEffect] = useState('default'); - const [particleIntensity, setParticleIntensity] = useState<'subtle' | 'normal' | 'prominent'>('normal'); - - const renderEffect = () => { - switch (selectedEffect) { - case 'mesh': - return ; - case 'tech-grid': - return ; - case 'data-particle': - return ( - - ); - case 'geometric': - return ; - case 'ink-tech': - return ; - case 'combined': - return ( - <> - - - - ); - default: - return null; - } - }; - - return ( -
-
-
-
-
-

Hero Section 效果预览

-

企业数字化转型服务商专属设计方案

-
- - 返回首页 - -
- -
- {effects.map((effect) => ( - setSelectedEffect(effect.id)} - className={`relative px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all ${ - selectedEffect === effect.id - ? 'bg-[#C41E3A] text-white' - : 'bg-[#F5F5F5] text-[#1C1C1C] hover:bg-[#E5E5E5]' - }`} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > - {effect.name} - {effect.recommended && ( - - 推荐 - - )} - - ))} -
- - {selectedEffect === 'data-particle' && ( -
-
- -
- {particleShapes.map((shape) => ( - - ))} -
-
- -
- -
- {particleEffects.map((effect) => ( - - ))} -
-
- -
- -
- {(['subtle', 'normal', 'prominent'] as const).map((intensity) => ( - - ))} -
-
-
- )} -
-
- -
-
- {renderEffect()} - -
-
- - - 智连未来,成长伙伴 - - - - - 睿新致远 - - - - 企业数字化转型服务商 - - - - 以智慧连接数字趋势,以伙伴身份陪您成长——您的数字化转型同行者 - - - - - - -
-
-
- -
-

方案详情

- -
- {effects.map((effect) => ( - setSelectedEffect(effect.id)} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > -
-

{effect.name}

- {effect.recommended && ( - - 推荐 - - )} -
-

{effect.description}

-
- {effect.features.map((feature, index) => ( - - {feature} - - ))} -
-
- ))} -
- - {selectedEffect === 'data-particle' && ( -
-

🎨 DataParticleFlow 自定义选项

- -
-
-

粒子形状

-
    -
  • 圆形 - 经典柔和
  • -
  • 方形 - 现代科技
  • -
  • 三角形 - 动感锐利
  • -
  • 菱形 - 精致优雅
  • -
  • 星形 - 独特醒目
  • -
  • 混合 - 丰富多样
  • -
-
- -
-

动画效果

-
    -
  • 默认 - 标准动画效果
  • -
  • 脉冲 - 呼吸式缩放
  • -
  • 发光 - 增强发光效果
  • -
  • 轨迹 - 长距离移动轨迹
  • -
-
- -
-

强度级别

-
    -
  • 柔和 - 微妙优雅
  • -
  • 正常 - 平衡适中
  • -
  • 突出 - 醒目明显
  • -
-
-
-
- )} - -
-

📊 性能指标

-
-
-
60fps
-
稳定帧率
-
-
-
GPU
-
硬件加速
-
-
-
WCAG
-
可访问性
-
-
-
100%
-
性能优化
-
-
-
-
-
-
- ); -}