feat(e2e-tests): 添加端到端测试框架及测试用例
refactor(components): 调整头部和页脚布局样式 style(hero-section): 更新徽章动画效果 docs: 添加测试框架README文档 test: 实现首页、导航和联系表单的测试用例 ci: 添加CI测试脚本和配置
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Config模块
|
||||
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
浏览器配置模块
|
||||
提供跨浏览器测试的配置和工具函数
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from playwright.sync_api import Browser, BrowserType, BrowserContext, Page
|
||||
from playwright.sync_api import Error as PlaywrightError
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from config.settings import get_settings
|
||||
|
||||
|
||||
class BrowserTypeEnum(Enum):
|
||||
"""支持的浏览器类型"""
|
||||
CHROMIUM = "chromium"
|
||||
FIREFOX = "firefox"
|
||||
WEBKIT = "webkit"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserCapabilities:
|
||||
"""浏览器能力描述"""
|
||||
name: str
|
||||
display_name: str
|
||||
channel: Optional[str]
|
||||
is_headless_supported: bool
|
||||
default_viewport: tuple
|
||||
user_agent: str
|
||||
description: str
|
||||
|
||||
|
||||
class BrowserConfigManager:
|
||||
"""浏览器配置管理器"""
|
||||
|
||||
# 浏览器能力定义
|
||||
BROWSER_CAPABILITIES: Dict[str, BrowserCapabilities] = {
|
||||
"chromium": BrowserCapabilities(
|
||||
name="chromium",
|
||||
display_name="Chrome/Chromium",
|
||||
channel="chrome",
|
||||
is_headless_supported=True,
|
||||
default_viewport=(1920, 1080),
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
description="Google Chrome / Chromium浏览器"
|
||||
),
|
||||
"firefox": BrowserCapabilities(
|
||||
name="firefox",
|
||||
display_name="Firefox",
|
||||
channel=None,
|
||||
is_headless_supported=True,
|
||||
default_viewport=(1920, 1080),
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) "
|
||||
"Gecko/20100101 Firefox/121.0"
|
||||
),
|
||||
description="Mozilla Firefox浏览器"
|
||||
),
|
||||
"webkit": BrowserCapabilities(
|
||||
name="webkit",
|
||||
display_name="WebKit (Safari)",
|
||||
channel=None,
|
||||
is_headless_supported=True,
|
||||
default_viewport=(1920, 1080),
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15"
|
||||
),
|
||||
description="Apple WebKit (Safari)浏览器"
|
||||
)
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self._playwright: Optional[sync_playwright] = None
|
||||
self._browser: Optional[Browser] = None
|
||||
self._context: Optional[BrowserContext] = None
|
||||
|
||||
def _ensure_playwright(self) -> sync_playwright:
|
||||
"""确保Playwright实例已启动"""
|
||||
if self._playwright is None:
|
||||
self._playwright = sync_playwright().start()
|
||||
return self._playwright
|
||||
|
||||
def get_available_browsers(self) -> List[str]:
|
||||
"""获取可用的浏览器列表"""
|
||||
available = []
|
||||
p = self._ensure_playwright()
|
||||
|
||||
browser_map = {
|
||||
"chromium": p.chromium,
|
||||
"firefox": p.firefox,
|
||||
"webkit": p.webkit
|
||||
}
|
||||
|
||||
for name in browser_map:
|
||||
try:
|
||||
available.append(name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return available
|
||||
|
||||
def _get_browser_type(self, browser_name: str):
|
||||
"""获取浏览器类型"""
|
||||
p = self._ensure_playwright()
|
||||
browser_map = {
|
||||
"chromium": p.chromium,
|
||||
"firefox": p.firefox,
|
||||
"webkit": p.webkit
|
||||
}
|
||||
return browser_map.get(browser_name)
|
||||
|
||||
def launch_browser(
|
||||
self,
|
||||
browser_name: str = "chromium",
|
||||
headless: bool = False,
|
||||
viewport: Optional[Tuple[int, int]] = None,
|
||||
**kwargs
|
||||
) -> Browser:
|
||||
"""启动浏览器"""
|
||||
capabilities = self.BROWSER_CAPABILITIES.get(browser_name)
|
||||
if not capabilities:
|
||||
raise ValueError(f"不支持的浏览器类型: {browser_name}")
|
||||
|
||||
viewport = viewport or (self.settings.viewport_width, self.settings.viewport_height)
|
||||
|
||||
launch_args = self._get_launch_arguments(browser_name, headless)
|
||||
|
||||
browser_type = self._get_browser_type(browser_name)
|
||||
if not browser_type:
|
||||
raise ValueError(f"不支持的浏览器类型: {browser_name}")
|
||||
|
||||
self._browser = browser_type.launch(
|
||||
headless=headless,
|
||||
args=launch_args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
return self._browser
|
||||
|
||||
def _get_launch_arguments(self, browser_name: str, headless: bool) -> List[str]:
|
||||
"""获取浏览器启动参数"""
|
||||
args = []
|
||||
|
||||
if browser_name == "chromium":
|
||||
args.extend([
|
||||
"--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--disable-sync",
|
||||
"--disable-translate",
|
||||
"--metrics-recording-only",
|
||||
"--mute-audio",
|
||||
"--no-first-run",
|
||||
"--safebrowsing-disable-auto-update",
|
||||
"--ignore-certificate-errors",
|
||||
"--ignore-ssl-errors",
|
||||
"--disable-dev-shm-usage",
|
||||
])
|
||||
|
||||
if headless:
|
||||
args.extend([
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
])
|
||||
|
||||
elif browser_name == "firefox":
|
||||
if headless:
|
||||
args.extend(["-headless"])
|
||||
|
||||
args.extend([
|
||||
"-profile",
|
||||
"/tmp/firefox-profile",
|
||||
])
|
||||
|
||||
elif browser_name == "webkit":
|
||||
if headless:
|
||||
args.append("--headless")
|
||||
|
||||
args.extend([
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
])
|
||||
|
||||
return args
|
||||
|
||||
def create_context(
|
||||
self,
|
||||
browser: Browser,
|
||||
viewport: Optional[Tuple[int, int]] = None,
|
||||
**context_kwargs
|
||||
) -> BrowserContext:
|
||||
"""创建浏览器上下文"""
|
||||
viewport = viewport or (self.settings.viewport_width, self.settings.viewport_height)
|
||||
|
||||
capabilities = self.BROWSER_CAPABILITIES.get(
|
||||
browser.browser_type.name,
|
||||
self.BROWSER_CAPABILITIES["chromium"]
|
||||
)
|
||||
|
||||
context_options = {
|
||||
"viewport": {
|
||||
"width": viewport[0],
|
||||
"height": viewport[1]
|
||||
},
|
||||
"user_agent": capabilities.user_agent,
|
||||
"locale": "zh-CN",
|
||||
"timezone_id": "Asia/Shanghai",
|
||||
**context_kwargs
|
||||
}
|
||||
|
||||
self._context = browser.new_context(**context_options)
|
||||
|
||||
return self._context
|
||||
|
||||
def create_browser_session(
|
||||
self,
|
||||
browser_name: str = "chromium",
|
||||
headless: bool = False,
|
||||
viewport: Optional[Tuple[int, int]] = None
|
||||
) -> Tuple[Browser, BrowserContext, Page]:
|
||||
"""创建完整的浏览器会话"""
|
||||
browser = self.launch_browser(browser_name, headless, viewport)
|
||||
context = self.create_context(browser, viewport)
|
||||
page = context.new_page()
|
||||
|
||||
return browser, context, page
|
||||
|
||||
def close_browser(self) -> None:
|
||||
"""关闭浏览器和上下文"""
|
||||
if self._context:
|
||||
try:
|
||||
self._context.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._context = None
|
||||
|
||||
if self._browser:
|
||||
try:
|
||||
self._browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._browser = None
|
||||
|
||||
if self._playwright:
|
||||
try:
|
||||
self._playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._playwright = None
|
||||
|
||||
def __enter__(self) -> 'BrowserConfigManager':
|
||||
"""上下文管理器入口"""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
"""上下文管理器退出"""
|
||||
self.close_browser()
|
||||
|
||||
|
||||
def get_browser_factory() -> BrowserConfigManager:
|
||||
"""获取浏览器工厂实例"""
|
||||
return BrowserConfigManager()
|
||||
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
测试框架配置模块
|
||||
提供全局配置管理和环境变量加载功能
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserConfig:
|
||||
"""浏览器配置类"""
|
||||
name: str
|
||||
headless: bool = False
|
||||
viewport_width: int = 1920
|
||||
viewport_height: int = 1080
|
||||
device_scale_factor: float = 1.0
|
||||
is_mobile: bool = False
|
||||
has_touch: bool = False
|
||||
locale: str = "zh-CN"
|
||||
timezone_id: str = "Asia/Shanghai"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PerformanceThresholds:
|
||||
"""性能指标阈值配置"""
|
||||
page_load_time: int = 3000 # 毫秒
|
||||
first_contentful_paint: int = 1500
|
||||
largest_contentful_paint: int = 2500
|
||||
time_to_interactive: int = 3000
|
||||
first_byte: int = 500
|
||||
dom_content_loaded: int = 1000
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResponsiveBreakpoints:
|
||||
"""响应式测试断点配置"""
|
||||
mobile: Dict[str, int] = field(default_factory=lambda: {"width": 375, "height": 667})
|
||||
tablet: Dict[str, int] = field(default_factory=lambda: {"width": 768, "height": 1024})
|
||||
desktop: Dict[str, int] = field(default_factory=lambda: {"width": 1920, "height": 1080})
|
||||
wide: Dict[str, int] = field(default_factory=lambda: {"width": 2560, "height": 1440})
|
||||
|
||||
|
||||
class Settings:
|
||||
"""全局配置管理类"""
|
||||
|
||||
_instance: Optional['Settings'] = None
|
||||
_initialized: bool = False
|
||||
|
||||
def __new__(cls) -> 'Settings':
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not Settings._initialized:
|
||||
self._load_config()
|
||||
Settings._initialized = True
|
||||
|
||||
def _load_config(self) -> None:
|
||||
"""加载所有配置"""
|
||||
# 基础配置
|
||||
self.base_url = self._get_env("TEST_BASE_URL", "http://localhost:3000")
|
||||
self.fallback_url = self._get_env("TEST_BASE_URL_FALLBACK", "")
|
||||
self.test_env = self._get_env("TEST_ENV", "development")
|
||||
|
||||
# 浏览器配置
|
||||
self.default_browser = self._get_env("DEFAULT_BROWSER", "chromium")
|
||||
self.headless_mode = self._get_env("HEADLESS_MODE", "false").lower() == "true"
|
||||
self.viewport_width = int(self._get_env("DEFAULT_VIEWPORT_WIDTH", 1920))
|
||||
self.viewport_height = int(self._get_env("DEFAULT_VIEWPORT_HEIGHT", 1080))
|
||||
|
||||
# 超时配置
|
||||
self.max_retries = int(self._get_env("MAX_RETRIES", 2))
|
||||
self.test_timeout = int(self._get_env("TEST_TIMEOUT", 60))
|
||||
self.page_load_timeout = int(self._get_env("PAGE_LOAD_TIMEOUT", 30000))
|
||||
self.element_timeout = int(self._get_env("ELEMENT_TIMEOUT", 10000))
|
||||
|
||||
# 并行配置
|
||||
self.parallel_workers = int(self._get_env("PARALLEL_WORKERS", 4))
|
||||
|
||||
# 截图和视频
|
||||
self.screenshot_on_failure = self._get_env("SCREENSHOT_ON_FAILURE", "true").lower() == "true"
|
||||
self.video_recording = self._get_env("VIDEO_RECORDING", "false").lower() == "true"
|
||||
self.screenshots_dir = self._get_env("SCREENSHOTS_DIR", "reports/screenshots")
|
||||
self.videos_dir = self._get_env("VIDEOS_DIR", "reports/videos")
|
||||
|
||||
# 日志配置
|
||||
self.log_level = self._get_env("LOG_LEVEL", "INFO")
|
||||
self.log_file = self._get_env("LOG_FILE", "reports/e2e_tests.log")
|
||||
self.console_log = self._get_env("CONSOLE_LOG", "true").lower() == "true"
|
||||
|
||||
# 报告配置
|
||||
self.report_title = self._get_env("REPORT_TITLE", "Novalon Website E2E测试报告")
|
||||
self.report_description = self._get_env(
|
||||
"REPORT_DESCRIPTION",
|
||||
"Novalon Website端到端自动化测试报告"
|
||||
)
|
||||
self.junit_xml_report = self._get_env("JUNIT_XML_REPORT", "false").lower() == "true"
|
||||
self.junit_xml_path = self._get_env("JUNIT_XML_PATH", "reports/test-results.xml")
|
||||
|
||||
# 性能阈值
|
||||
self._load_performance_thresholds()
|
||||
|
||||
# 响应式断点
|
||||
self._load_responsive_breakpoints()
|
||||
|
||||
# 浏览器列表
|
||||
self.browsers_to_test = ["chromium", "firefox", "webkit"]
|
||||
|
||||
# 测试数据
|
||||
self._load_test_form_data()
|
||||
|
||||
# CI/CD配置
|
||||
self.ci = self._get_env("CI", "false").lower() == "true"
|
||||
self.git_branch = self._get_env("GIT_BRANCH", "")
|
||||
self.git_commit = self._get_env("GIT_COMMIT", "")
|
||||
self.git_repository = self._get_env("GIT_REPOSITORY", "")
|
||||
|
||||
# 创建必要的目录
|
||||
self._create_directories()
|
||||
|
||||
def _get_env(self, key: str, default: str) -> str:
|
||||
"""获取环境变量"""
|
||||
return os.environ.get(key, default)
|
||||
|
||||
def _load_performance_thresholds(self) -> None:
|
||||
"""加载性能阈值配置"""
|
||||
import json
|
||||
thresholds_str = self._get_env(
|
||||
"PERFORMANCE_THRESHOLDS",
|
||||
'{"page_load_time": 3000, "first_contentful_paint": 1500, '
|
||||
'"largest_contentful_paint": 2500, "time_to_interactive": 3000, '
|
||||
'"first_byte": 500, "dom_content_loaded": 1000}'
|
||||
)
|
||||
try:
|
||||
thresholds = json.loads(thresholds_str)
|
||||
self.performance_thresholds = PerformanceThresholds(**thresholds)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
self.performance_thresholds = PerformanceThresholds()
|
||||
|
||||
def _load_responsive_breakpoints(self) -> None:
|
||||
"""加载响应式断点配置"""
|
||||
import json
|
||||
breakpoints_str = self._get_env(
|
||||
"RESPONSIVE_BREAKPOINTS",
|
||||
'{"mobile": {"width": 375, "height": 667}, '
|
||||
'"tablet": {"width": 768, "height": 1024}, '
|
||||
'"desktop": {"width": 1920, "height": 1080}, '
|
||||
'"wide": {"width": 2560, "height": 1440}}'
|
||||
)
|
||||
try:
|
||||
breakpoints = json.loads(breakpoints_str)
|
||||
self.responsive_breakpoints = ResponsiveBreakpoints(**breakpoints)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
self.responsive_breakpoints = ResponsiveBreakpoints()
|
||||
|
||||
def _load_test_form_data(self) -> None:
|
||||
"""加载测试表单数据"""
|
||||
import json
|
||||
form_data_str = self._get_env(
|
||||
"TEST_FORM_DATA",
|
||||
'{"valid": {"name": "测试用户", "phone": "13800138000", '
|
||||
'"email": "test@example.com", "subject": "测试主题", '
|
||||
'"message": "这是一条测试消息,用于验证表单功能是否正常。"}, '
|
||||
'"invalid": {"email": "invalid-email", "phone": "123"}}'
|
||||
)
|
||||
try:
|
||||
self.test_form_data = json.loads(form_data_str)
|
||||
except json.JSONDecodeError:
|
||||
self.test_form_data = {
|
||||
"valid": {
|
||||
"name": "测试用户",
|
||||
"phone": "13800138000",
|
||||
"email": "test@example.com",
|
||||
"subject": "测试主题",
|
||||
"message": "这是一条测试消息,用于验证表单功能是否正常。"
|
||||
},
|
||||
"invalid": {
|
||||
"email": "invalid-email",
|
||||
"phone": "123"
|
||||
}
|
||||
}
|
||||
|
||||
def _create_directories(self) -> None:
|
||||
"""创建必要的目录"""
|
||||
base_dirs = [
|
||||
self.screenshots_dir,
|
||||
self.videos_dir,
|
||||
"reports",
|
||||
"logs"
|
||||
]
|
||||
for dir_path in base_dirs:
|
||||
Path(dir_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_browser_config(self, browser_name: Optional[str] = None) -> BrowserConfig:
|
||||
"""获取浏览器配置"""
|
||||
name = browser_name or self.default_browser
|
||||
|
||||
return BrowserConfig(
|
||||
name=name,
|
||||
headless=self.headless_mode,
|
||||
viewport_width=self.viewport_width,
|
||||
viewport_height=self.viewport_height
|
||||
)
|
||||
|
||||
def get_test_data_path(self, filename: str) -> Path:
|
||||
"""获取测试数据文件路径"""
|
||||
return Path(__file__).parent / "test_data" / filename
|
||||
|
||||
def get_reports_path(self, filename: str = "") -> Path:
|
||||
"""获取报告目录路径"""
|
||||
reports_dir = Path("reports")
|
||||
reports_dir.mkdir(parents=True, exist_ok=True)
|
||||
if filename:
|
||||
return reports_dir / filename
|
||||
return reports_dir
|
||||
|
||||
def is_ci_environment(self) -> bool:
|
||||
"""检查是否为CI环境"""
|
||||
return self.ci or os.environ.get("CI", "").lower() in ["true", "1"]
|
||||
|
||||
def get_base_url(self) -> str:
|
||||
"""获取测试基础URL,自动降级到备用URL"""
|
||||
# 首先检查基础URL是否可用
|
||||
if self._check_url_accessible(self.base_url):
|
||||
return self.base_url
|
||||
|
||||
# 如果基础URL不可用,尝试备用URL
|
||||
if self.fallback_url and self._check_url_accessible(self.fallback_url):
|
||||
return self.fallback_url
|
||||
|
||||
# 如果都不可用,返回基础URL(测试时会报错)
|
||||
return self.base_url
|
||||
|
||||
def _check_url_accessible(self, url: str) -> bool:
|
||||
"""检查URL是否可访问"""
|
||||
import requests
|
||||
try:
|
||||
response = requests.get(url, timeout=5)
|
||||
return response.status_code < 500
|
||||
except requests.RequestException:
|
||||
return False
|
||||
|
||||
|
||||
# 全局配置实例
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""获取全局配置实例"""
|
||||
return settings
|
||||
Reference in New Issue
Block a user