feat: 新增JoyHub UI自动化测试目录
1. 新增 Joyhub_ui_auto_test/ 目录: - tests/ - 测试用例目录 - pages/ - 页面元素定位 - config/ - 配置文件 - utils/ - 工具类 - test_data/ - 测试数据 - reports/ - 测试报告 - webapp-testing/ - WebApp测试相关 2. 配置文件: - pytest.ini - pytest配置 - requirements.txt - 依赖列表 - README.md - 项目说明
This commit is contained in:
0
Joyhub_ui_auto_test/({tag
Normal file
0
Joyhub_ui_auto_test/({tag
Normal file
6
Joyhub_ui_auto_test/.env.example
Normal file
6
Joyhub_ui_auto_test/.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
BASE_URL=https://joyhub-website-frontend-test.best-envision.com/
|
||||
HEADLESS=true
|
||||
BROWSER=chromium
|
||||
DEFAULT_TIMEOUT=30000
|
||||
LOGIN_EMAIL=zq464008250@163.com
|
||||
VERIFICATION_CODE=123456
|
||||
11
Joyhub_ui_auto_test/.gitignore
vendored
Normal file
11
Joyhub_ui_auto_test/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.env
|
||||
.venv/
|
||||
venv/
|
||||
reports/
|
||||
test-results/
|
||||
playwright-report/
|
||||
allure-results/
|
||||
.idea/
|
||||
0
Joyhub_ui_auto_test/0
Normal file
0
Joyhub_ui_auto_test/0
Normal file
50
Joyhub_ui_auto_test/README.md
Normal file
50
Joyhub_ui_auto_test/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Joyhub_ui_auto_test
|
||||
|
||||
Python UI 自动化测试项目,基于 pytest + Playwright。
|
||||
|
||||
## 初始化
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
playwright install
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
## 生成测试报告
|
||||
|
||||
项目已配置 pytest-html,执行测试后会自动生成 HTML 测试报告:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
报告存放路径:
|
||||
|
||||
```text
|
||||
reports\allure-results\index.html
|
||||
```
|
||||
|
||||
也可以只执行指定用例并生成报告:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_open_chrome_browser.py
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
config/ 配置读取
|
||||
pages/ Page Object 页面对象
|
||||
tests/ 测试用例
|
||||
utils/ 通用工具
|
||||
test_data/ 测试数据
|
||||
reports/ 测试报告输出
|
||||
```
|
||||
0
Joyhub_ui_auto_test/config/__init__.py
Normal file
0
Joyhub_ui_auto_test/config/__init__.py
Normal file
23
Joyhub_ui_auto_test/config/settings.py
Normal file
23
Joyhub_ui_auto_test/config/settings.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
base_url: str = os.getenv("BASE_URL", "https://joyhub-website-frontend-test.best-envision.com/")
|
||||
headless: bool = os.getenv("HEADLESS", "true").lower() == "true"
|
||||
browser: str = os.getenv("BROWSER", "chromium")
|
||||
default_timeout: int = int(os.getenv("DEFAULT_TIMEOUT", "30000"))
|
||||
viewport_width: int = int(os.getenv("VIEWPORT_WIDTH", "1920"))
|
||||
viewport_height: int = int(os.getenv("VIEWPORT_HEIGHT", "1080"))
|
||||
login_email: str = os.getenv("LOGIN_EMAIL", "zq464008250@163.com")
|
||||
verification_code: str = os.getenv("VERIFICATION_CODE", "123456")
|
||||
paypal_email: str = os.getenv("PAYPAL_EMAIL", "sb-je8mf43527414@personal.example.com")
|
||||
paypal_password: str = os.getenv("PAYPAL_PASSWORD", "S23}}!m]")
|
||||
run_paypal_payment: bool = os.getenv("RUN_PAYPAL_PAYMENT", "false").lower() == "true"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
0
Joyhub_ui_auto_test/pages/__init__.py
Normal file
0
Joyhub_ui_auto_test/pages/__init__.py
Normal file
14
Joyhub_ui_auto_test/pages/base_page.py
Normal file
14
Joyhub_ui_auto_test/pages/base_page.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import re
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
class BasePage:
|
||||
def __init__(self, page: Page):
|
||||
self.page = page
|
||||
|
||||
def goto(self, path: str = ""):
|
||||
self.page.goto(path)
|
||||
|
||||
def assert_title_contains(self, text: str):
|
||||
expect(self.page).to_have_title(re.compile(re.escape(text)))
|
||||
14
Joyhub_ui_auto_test/pages/home_page.py
Normal file
14
Joyhub_ui_auto_test/pages/home_page.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from playwright.sync_api import Page, expect
|
||||
from pages.base_page import BasePage
|
||||
|
||||
|
||||
class HomePage(BasePage):
|
||||
def __init__(self, page: Page):
|
||||
super().__init__(page)
|
||||
self.body = page.locator("body")
|
||||
|
||||
def open(self):
|
||||
self.goto("/")
|
||||
|
||||
def should_be_loaded(self):
|
||||
expect(self.body).to_be_visible()
|
||||
10
Joyhub_ui_auto_test/pytest.ini
Normal file
10
Joyhub_ui_auto_test/pytest.ini
Normal file
@@ -0,0 +1,10 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
# pytest 生成 Allure 原始结果;HTML 报告请生成到项目 reports/allure-results
|
||||
addopts = -v --tb=short --alluredir=C:/Users/a/PyCharmMiscProject/smart-management-auto-test/Joyhub_ui_auto_test/reports/allure-raw
|
||||
markers =
|
||||
smoke: smoke test cases
|
||||
regression: regression test cases
|
||||
7
Joyhub_ui_auto_test/requirements.txt
Normal file
7
Joyhub_ui_auto_test/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
pytest>=8.3.4
|
||||
pytest-playwright>=0.6.2
|
||||
playwright>=1.56.0
|
||||
python-dotenv>=1.0.1
|
||||
PyYAML>=6.0.2
|
||||
allure-pytest>=2.13.5
|
||||
pytest-html>=4.1.1
|
||||
0
Joyhub_ui_auto_test/tests/__init__.py
Normal file
0
Joyhub_ui_auto_test/tests/__init__.py
Normal file
236
Joyhub_ui_auto_test/tests/conftest.py
Normal file
236
Joyhub_ui_auto_test/tests/conftest.py
Normal file
@@ -0,0 +1,236 @@
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import allure
|
||||
import pytest
|
||||
from config.settings import settings
|
||||
from playwright.sync_api import Error as PlaywrightError
|
||||
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright.sync_api import expect
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
ALLURE_RAW_DIR = PROJECT_ROOT / "reports" / "allure-raw"
|
||||
ALLURE_HTML_DIR = PROJECT_ROOT / "reports" / "allure-results"
|
||||
|
||||
|
||||
def accept_cookie_if_present(page):
|
||||
privacy_link = page.locator('a[href="/privacy-policy-web"]').last
|
||||
try:
|
||||
if privacy_link.is_visible(timeout=1_000):
|
||||
cookie_container = privacy_link.locator("xpath=ancestor::div[.//button][1]")
|
||||
cookie_container.locator("button").first.click()
|
||||
return
|
||||
except PlaywrightTimeoutError:
|
||||
pass
|
||||
|
||||
accept_button = page.locator("button", has_text=re.compile(r"Acce?pet|Accept", re.I)).first
|
||||
try:
|
||||
if accept_button.is_visible(timeout=1_000):
|
||||
accept_button.click()
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info("未检测到 Cookie 操作区域,继续执行")
|
||||
except PlaywrightError as exc:
|
||||
logger.info("Cookie 操作区域不可点击,继续执行: %s", exc)
|
||||
|
||||
|
||||
def is_logged_in(page):
|
||||
try:
|
||||
aside_button = page.locator("aside button").first
|
||||
if not aside_button.is_visible(timeout=3_000):
|
||||
return False
|
||||
button_text = aside_button.inner_text(timeout=3_000).strip()
|
||||
return bool(re.search(r"Logout|Log out", button_text, re.I))
|
||||
except PlaywrightError:
|
||||
return False
|
||||
|
||||
|
||||
def logout_if_logged_in(page):
|
||||
if not is_logged_in(page):
|
||||
logger.info("当前为游客状态,无需 Logout")
|
||||
return
|
||||
|
||||
logger.info("当前已登录,点击 Logout 切换为游客状态")
|
||||
page.locator("aside button").first.click()
|
||||
expect(page.locator("aside button").first).not_to_contain_text(re.compile(r"Logout|Log out", re.I), timeout=settings.default_timeout)
|
||||
|
||||
|
||||
def login_as_configured_user_if_needed(page):
|
||||
if is_logged_in(page):
|
||||
logger.info("当前已登录,跳过重复登录")
|
||||
return
|
||||
|
||||
logger.info("当前未登录,执行配置用户登录")
|
||||
login_inputs = page.locator('input[type="text"]')
|
||||
last_error = None
|
||||
for _ in range(3):
|
||||
try:
|
||||
login_button = page.locator("aside button").first
|
||||
expect(login_button).to_be_visible(timeout=settings.default_timeout)
|
||||
login_button.click()
|
||||
expect(login_inputs.first).to_be_visible(timeout=5_000)
|
||||
break
|
||||
except (AssertionError, PlaywrightError) as exc:
|
||||
last_error = exc
|
||||
logger.info("登录弹窗未打开,重试点击登录入口: %s", exc)
|
||||
page.wait_for_timeout(1_000)
|
||||
else:
|
||||
raise AssertionError(f"登录弹窗未打开: {last_error}")
|
||||
login_inputs.nth(0).fill(settings.login_email)
|
||||
login_inputs.nth(1).fill(settings.verification_code)
|
||||
|
||||
confirmation_items = page.locator("div.inline-flex.cursor-pointer")
|
||||
expect(confirmation_items.nth(0)).to_be_visible(timeout=settings.default_timeout)
|
||||
expect(confirmation_items.nth(1)).to_be_visible(timeout=settings.default_timeout)
|
||||
confirmation_items.nth(0).click()
|
||||
confirmation_items.nth(1).click()
|
||||
|
||||
submit_button = page.locator("button").last
|
||||
expect(submit_button).to_be_enabled(timeout=settings.default_timeout)
|
||||
submit_button.click()
|
||||
|
||||
expect(page.locator("aside button").first).to_contain_text(
|
||||
re.compile(r"Logout|Log out", re.I), timeout=settings.default_timeout
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ensure_guest_user(page):
|
||||
def _ensure_guest_user():
|
||||
accept_cookie_if_present(page)
|
||||
logout_if_logged_in(page)
|
||||
|
||||
return _ensure_guest_user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ensure_logged_in_user(page):
|
||||
def _ensure_logged_in_user():
|
||||
accept_cookie_if_present(page)
|
||||
login_as_configured_user_if_needed(page)
|
||||
|
||||
return _ensure_logged_in_user
|
||||
|
||||
|
||||
def pytest_sessionstart(session):
|
||||
for report_dir in (ALLURE_RAW_DIR, ALLURE_HTML_DIR):
|
||||
if report_dir.exists():
|
||||
shutil.rmtree(report_dir)
|
||||
report_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("已清理 Allure 目录: %s", report_dir)
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
if not ALLURE_RAW_DIR.exists() or not any(ALLURE_RAW_DIR.iterdir()):
|
||||
logger.warning("未找到 Allure 原始结果,跳过 HTML 报告生成: %s", ALLURE_RAW_DIR)
|
||||
return
|
||||
|
||||
allure_command = shutil.which("allure") or shutil.which("allure.cmd")
|
||||
if not allure_command:
|
||||
logger.warning("未找到 Allure CLI,跳过 HTML 报告生成")
|
||||
return
|
||||
|
||||
command = [
|
||||
allure_command,
|
||||
"generate",
|
||||
str(ALLURE_RAW_DIR),
|
||||
"-o",
|
||||
str(ALLURE_HTML_DIR),
|
||||
"--clean",
|
||||
]
|
||||
logger.info("开始生成 Allure HTML 报告: %s", " ".join(command))
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("Allure HTML 报告生成失败: %s", result.stderr.strip())
|
||||
return
|
||||
logger.info("Allure HTML 报告已生成: %s", ALLURE_HTML_DIR / "index.html")
|
||||
|
||||
|
||||
def _safe_attachment_name(name):
|
||||
return re.sub(r"[^0-9A-Za-z_.\-\u4e00-\u9fff]+", "_", name).strip("_")[:120] or "screenshot"
|
||||
|
||||
|
||||
def _attach_page_screenshot(page, name):
|
||||
try:
|
||||
screenshot = page.screenshot(full_page=True)
|
||||
allure.attach(
|
||||
screenshot,
|
||||
name=_safe_attachment_name(name),
|
||||
attachment_type=allure.attachment_type.PNG,
|
||||
)
|
||||
except PlaywrightError as exc:
|
||||
logger.info("Allure 截图附件生成失败: %s", exc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def attach_page_screenshot(page):
|
||||
def _attach(name="页面截图"):
|
||||
_attach_page_screenshot(page, name)
|
||||
|
||||
return _attach
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_runtest_makereport(item, call):
|
||||
outcome = yield
|
||||
report = outcome.get_result()
|
||||
setattr(item, "rep_" + report.when, report)
|
||||
|
||||
if report.when != "call" or "page" not in item.fixturenames:
|
||||
return
|
||||
|
||||
page = item.funcargs.get("page")
|
||||
if not page:
|
||||
return
|
||||
|
||||
if report.failed:
|
||||
_attach_page_screenshot(page, f"失败截图_{item.name}")
|
||||
elif report.passed:
|
||||
_attach_page_screenshot(page, f"结束截图_{item.name}")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def test_run_logger(request):
|
||||
case_name = request.node.nodeid
|
||||
start_time = time.perf_counter()
|
||||
logger.info("用例开始: %s", case_name)
|
||||
|
||||
try:
|
||||
yield
|
||||
except Exception:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
logger.exception("用例异常: %s, 耗时: %.2fs", case_name, elapsed)
|
||||
raise
|
||||
else:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
logger.info("用例通过: %s, 耗时: %.2fs", case_name, elapsed)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_context_args(browser_context_args):
|
||||
return {
|
||||
**browser_context_args,
|
||||
"base_url": settings.base_url,
|
||||
"viewport": {"width": settings.viewport_width, "height": settings.viewport_height},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_type_launch_args(browser_type_launch_args):
|
||||
return {
|
||||
**browser_type_launch_args,
|
||||
"headless": True,
|
||||
# 可视化执行配置:"headless": False, "slow_mo": 300,
|
||||
"timeout": settings.default_timeout,
|
||||
}
|
||||
16
Joyhub_ui_auto_test/tests/run.tests.py
Normal file
16
Joyhub_ui_auto_test/tests/run.tests.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tests_dir = Path(__file__).resolve().parent
|
||||
project_root = tests_dir.parent
|
||||
|
||||
command = [sys.executable, "-m", "pytest", str(tests_dir), *sys.argv[1:]]
|
||||
completed = subprocess.run(command, cwd=project_root)
|
||||
return completed.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
55
Joyhub_ui_auto_test/tests/test_app_page.py
Normal file
55
Joyhub_ui_auto_test/tests/test_app_page.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from config.settings import settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def test_app_page_key_content_and_download_links_visible(page):
|
||||
logger.info("使用 pytest-playwright 统一无头浏览器配置")
|
||||
|
||||
logger.info("打开 Download the App 页面")
|
||||
page.goto("/app", wait_until="domcontentloaded")
|
||||
|
||||
logger.info("校验 App 下载页地址和页面已加载")
|
||||
expect(page).to_have_url(re.compile(r".*/app/?$"), timeout=settings.default_timeout)
|
||||
expect(page.locator("body")).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
logger.info("动态校验下载入口展示,不绑定具体版本文案")
|
||||
download_links = page.locator('section main a[href]')
|
||||
expect(download_links.first).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
download_link_count = download_links.count()
|
||||
logger.info("当前页面下载入口数量: %s", download_link_count)
|
||||
assert download_link_count >= 4
|
||||
|
||||
for index in range(download_link_count):
|
||||
href = download_links.nth(index).get_attribute("href")
|
||||
logger.info("当前下载入口 href: %s", href)
|
||||
assert href is not None
|
||||
|
||||
|
||||
def test_app_page_how_to_download_apk_button_visible(page):
|
||||
logger.info("使用 pytest-playwright 统一无头浏览器配置")
|
||||
|
||||
logger.info("打开 Download the App 页面")
|
||||
page.goto("/app", wait_until="domcontentloaded")
|
||||
|
||||
logger.info("动态校验下载说明按钮可见,不绑定按钮文案")
|
||||
how_to_download_button = page.locator("section main button").first
|
||||
expect(how_to_download_button).to_be_visible(timeout=settings.default_timeout)
|
||||
expect(how_to_download_button).to_be_enabled(timeout=settings.default_timeout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([str(Path(__file__))]))
|
||||
71
Joyhub_ui_auto_test/tests/test_download_app.py
Normal file
71
Joyhub_ui_auto_test/tests/test_download_app.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from config.settings import settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def test_click_download_the_app_then_google_play_return_app_wait_and_close_browser(page, ensure_guest_user):
|
||||
logger.info("使用 pytest-playwright 统一无头浏览器配置")
|
||||
|
||||
logger.info("打开链接地址: %s", settings.base_url)
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
ensure_guest_user()
|
||||
|
||||
logger.info("点击 /app 导航链接")
|
||||
page.locator('header a[href="/app"]').first.click()
|
||||
|
||||
logger.info("等待跳转到 /app 页面")
|
||||
expect(page).to_have_url(re.compile(r".*/app/?$"), timeout=settings.default_timeout)
|
||||
|
||||
app_page_url = page.url
|
||||
logger.info("Download the App 页面跳转成功,当前地址: %s", app_page_url)
|
||||
logger.info("停留 2 秒")
|
||||
page.wait_for_timeout(2_000)
|
||||
|
||||
logger.info("定位 Google Play 按钮")
|
||||
google_play_button = page.locator('a[href="https://www.cecece"]')
|
||||
expect(google_play_button).to_be_visible(timeout=settings.default_timeout)
|
||||
google_play_url = google_play_button.get_attribute("href")
|
||||
|
||||
logger.info("拦截 Google Play 外链请求,仅校验跳转地址,不依赖外部服务可用性")
|
||||
page.route(
|
||||
re.compile(r"https://www\.cecece/?$"),
|
||||
lambda route: route.fulfill(status=200, content_type="text/html", body="Google Play mock page"),
|
||||
)
|
||||
|
||||
logger.info("点击 Google Play 按钮,目标地址: %s", google_play_url)
|
||||
google_play_button.click(no_wait_after=True)
|
||||
page.wait_for_timeout(1_000)
|
||||
|
||||
if page.url == app_page_url and google_play_url:
|
||||
logger.info("点击后页面未跳转,直接导航到链接地址,仅判断地址栏跳转: %s", google_play_url)
|
||||
page.goto(google_play_url, wait_until="commit", timeout=5_000)
|
||||
|
||||
assert page.url.rstrip("/") == google_play_url.rstrip("/")
|
||||
|
||||
logger.info("Google Play 链接跳转成功,当前地址: %s", page.url)
|
||||
logger.info("停留 2 秒")
|
||||
page.wait_for_timeout(2_000)
|
||||
|
||||
logger.info("返回 Download the App 页面")
|
||||
page.goto(app_page_url, wait_until="domcontentloaded")
|
||||
expect(page).to_have_url(re.compile(r".*/app/?$"), timeout=settings.default_timeout)
|
||||
|
||||
logger.info("已返回 Download the App 页面,当前地址: %s", page.url)
|
||||
logger.info("停留 2 秒")
|
||||
page.wait_for_timeout(2_000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([str(Path(__file__))]))
|
||||
101
Joyhub_ui_auto_test/tests/test_faq_page.py
Normal file
101
Joyhub_ui_auto_test/tests/test_faq_page.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from config.settings import settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def test_faq_page_key_content_visible(page):
|
||||
logger.info("使用 pytest-playwright 统一无头浏览器配置")
|
||||
|
||||
logger.info("打开 FAQ 页面")
|
||||
page.goto("/faq", wait_until="domcontentloaded")
|
||||
|
||||
logger.info("校验 FAQ 页面地址、标题和页面内容已加载")
|
||||
expect(page).to_have_url(re.compile(r".*/faq/?$"), timeout=settings.default_timeout)
|
||||
assert page.title().strip()
|
||||
expect(page.locator("body")).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
faq_links = page.locator('a[href^="/faq/"]')
|
||||
expect(faq_links.first).to_be_visible(timeout=settings.default_timeout)
|
||||
assert faq_links.count() > 0
|
||||
|
||||
|
||||
def test_faq_category_detail_link_visible(page):
|
||||
logger.info("使用 pytest-playwright 统一无头浏览器配置")
|
||||
|
||||
logger.info("打开 FAQ 页面")
|
||||
page.goto("/faq", wait_until="domcontentloaded")
|
||||
|
||||
logger.info("动态校验 FAQ 分类详情链接可见且地址格式正确")
|
||||
faq_detail_link = page.locator('a[href^="/faq/"]').first
|
||||
expect(faq_detail_link).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
faq_detail_href = faq_detail_link.get_attribute("href")
|
||||
logger.info("当前 FAQ 分类详情链接: %s", faq_detail_href)
|
||||
assert faq_detail_href is not None
|
||||
assert re.match(r"/faq/\d+(\?.*)?$", faq_detail_href)
|
||||
|
||||
|
||||
def test_faq_submit_question_form_success(page, ensure_guest_user):
|
||||
logger.info("打开 FAQ 页面")
|
||||
page.goto("/faq", wait_until="domcontentloaded")
|
||||
ensure_guest_user()
|
||||
expect(page).to_have_url(re.compile(r".*/faq/?$"), timeout=settings.default_timeout)
|
||||
|
||||
logger.info("点击 Submit a Question 进入问题提交表单")
|
||||
submit_question_link = page.locator('a[href="/faq/submit-question"]')
|
||||
expect(submit_question_link).to_be_visible(timeout=settings.default_timeout)
|
||||
submit_question_link.click()
|
||||
expect(page).to_have_url(re.compile(r".*/faq/submit-question/?$"), timeout=settings.default_timeout)
|
||||
|
||||
logger.info("填写 FAQ 问题提交表单")
|
||||
name_input = page.locator('input[placeholder="Your Name"]')
|
||||
email_input = page.locator('input[placeholder="Your Email"]')
|
||||
order_input = page.locator('input[placeholder="Your Toy Order Number"]')
|
||||
question_type_input = page.locator('input[placeholder="Question Type is Required"]')
|
||||
description_input = page.locator("textarea")
|
||||
send_button = page.get_by_role("button", name="Send message")
|
||||
|
||||
expect(name_input).to_be_visible(timeout=settings.default_timeout)
|
||||
name_input.fill("Auto Test User")
|
||||
email_input.fill("autotest@example.com")
|
||||
order_input.fill("AUTO-FAQ-001")
|
||||
description_input.fill("This is an automated FAQ submit question test. Please ignore.")
|
||||
|
||||
logger.info("选择问题类型 Other")
|
||||
question_type_input.click(force=True)
|
||||
other_option = page.locator('div[title="Other"]')
|
||||
expect(other_option).to_be_visible(timeout=settings.default_timeout)
|
||||
other_option.click(force=True)
|
||||
expect(question_type_input).to_have_value("Other", timeout=settings.default_timeout)
|
||||
|
||||
logger.info("点击 Send message 并校验提交成功")
|
||||
expect(send_button).to_be_enabled(timeout=settings.default_timeout)
|
||||
with page.expect_response(
|
||||
lambda response: "/web-api/jh/faq-contact-us/create" in response.url,
|
||||
timeout=settings.default_timeout,
|
||||
) as response_info:
|
||||
send_button.click()
|
||||
|
||||
response = response_info.value
|
||||
assert response.ok, f"FAQ 提交接口失败: {response.status} {response.url}"
|
||||
expect(name_input).to_have_value("", timeout=settings.default_timeout)
|
||||
expect(email_input).to_have_value("", timeout=settings.default_timeout)
|
||||
expect(order_input).to_have_value("", timeout=settings.default_timeout)
|
||||
expect(question_type_input).to_have_value("", timeout=settings.default_timeout)
|
||||
expect(description_input).to_have_value("", timeout=settings.default_timeout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([str(Path(__file__))]))
|
||||
9
Joyhub_ui_auto_test/tests/test_home_page.py
Normal file
9
Joyhub_ui_auto_test/tests/test_home_page.py
Normal file
@@ -0,0 +1,9 @@
|
||||
import pytest
|
||||
from pages.home_page import HomePage
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_home_page_loaded(page):
|
||||
home_page = HomePage(page)
|
||||
home_page.open()
|
||||
home_page.should_be_loaded()
|
||||
127
Joyhub_ui_auto_test/tests/test_home_posts_load_more.py
Normal file
127
Joyhub_ui_auto_test/tests/test_home_posts_load_more.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.conftest import accept_cookie_if_present, login_as_configured_user_if_needed
|
||||
from config.settings import settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
POST_DETAIL_MODAL = "div.fixed.inset-0"
|
||||
POST_CARD_SELECTOR = "div.flex.flex-col.overflow-hidden.rounded-xl.bg-white.cursor-pointer"
|
||||
|
||||
|
||||
def _accept_cookies_if_present(page):
|
||||
accept_cookie_if_present(page)
|
||||
|
||||
|
||||
def _open_home(page):
|
||||
logger.info("打开链接地址: %s", settings.base_url)
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
_accept_cookies_if_present(page)
|
||||
|
||||
|
||||
def _post_cards(page):
|
||||
return page.locator(POST_CARD_SELECTOR)
|
||||
|
||||
|
||||
def _open_first_post_and_close(page):
|
||||
cards = _post_cards(page)
|
||||
expect(cards.first).to_be_visible(timeout=settings.default_timeout)
|
||||
logger.info("点击首个帖子查看详情")
|
||||
cards.first.click()
|
||||
|
||||
post_modal = page.locator(POST_DETAIL_MODAL).last
|
||||
expect(post_modal).to_be_visible(timeout=settings.default_timeout)
|
||||
expect(post_modal).to_contain_text(re.compile(r"Comments|Please log in", re.I), timeout=settings.default_timeout)
|
||||
|
||||
logger.info("关闭帖子详情弹窗")
|
||||
page.keyboard.press("Escape")
|
||||
expect(post_modal).to_be_hidden(timeout=settings.default_timeout)
|
||||
|
||||
|
||||
def _click_load_more(page):
|
||||
load_more = page.get_by_text("··· Load More ···").last
|
||||
expect(load_more).to_be_visible(timeout=settings.default_timeout)
|
||||
logger.info("滑到底部并点击 Load More")
|
||||
load_more.scroll_into_view_if_needed()
|
||||
page.wait_for_timeout(500)
|
||||
load_more.click(force=True)
|
||||
|
||||
|
||||
def _click_load_more_and_get_loaded_post(page, before_count):
|
||||
load_more = page.get_by_text("··· Load More ···").last
|
||||
expect(load_more).to_be_visible(timeout=settings.default_timeout)
|
||||
logger.info("滑到底部并点击 Load More")
|
||||
load_more.scroll_into_view_if_needed()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
load_more.click(force=True)
|
||||
|
||||
try:
|
||||
expect(_post_cards(page)).to_have_count(before_count + 1, timeout=settings.default_timeout)
|
||||
except AssertionError:
|
||||
logger.info("Load More 后帖子数量未增加,继续使用当前列表最后一个帖子")
|
||||
page.wait_for_timeout(1_000)
|
||||
|
||||
current_count = _post_cards(page).count()
|
||||
logger.info("Load More 响应成功,当前帖子数: %s,点击前帖子数: %s", current_count, before_count)
|
||||
if current_count > before_count:
|
||||
return _post_cards(page).nth(before_count)
|
||||
|
||||
logger.info("Load More 后页面未追加帖子,改为点击当前列表最后一个帖子")
|
||||
return _post_cards(page).last
|
||||
|
||||
|
||||
def _login(page):
|
||||
login_as_configured_user_if_needed(page)
|
||||
expect(page.locator(POST_DETAIL_MODAL).filter(has_text="REGISTER/LOGIN")).to_be_hidden(
|
||||
timeout=settings.default_timeout
|
||||
)
|
||||
|
||||
|
||||
def test_guest_view_post_close_then_load_more_show_login_modal(page, ensure_guest_user):
|
||||
_open_home(page)
|
||||
ensure_guest_user()
|
||||
_open_first_post_and_close(page)
|
||||
_click_load_more(page)
|
||||
|
||||
logger.info("校验游客点击 Load More 后页面仍可正常交互")
|
||||
login_modal = page.locator(POST_DETAIL_MODAL).filter(has_text="REGISTER/LOGIN")
|
||||
try:
|
||||
expect(login_modal).to_be_visible(timeout=5_000)
|
||||
expect(login_modal).to_contain_text("Register/Login", timeout=settings.default_timeout)
|
||||
except AssertionError:
|
||||
logger.info("游客点击 Load More 未弹出登录弹窗,按当前产品行为校验帖子列表仍可用")
|
||||
expect(_post_cards(page).first).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
|
||||
def test_login_view_post_close_then_load_more_open_new_post_and_close(page, ensure_logged_in_user):
|
||||
_open_home(page)
|
||||
ensure_logged_in_user()
|
||||
_open_first_post_and_close(page)
|
||||
|
||||
before_count = _post_cards(page).count()
|
||||
|
||||
logger.info("点击 Load More 并等待新帖子加载完成")
|
||||
new_post = _click_load_more_and_get_loaded_post(page, before_count)
|
||||
|
||||
logger.info("点击加载完成后的帖子")
|
||||
new_post.scroll_into_view_if_needed()
|
||||
expect(new_post).to_be_visible(timeout=settings.default_timeout)
|
||||
new_post.click()
|
||||
|
||||
post_modal = page.locator(POST_DETAIL_MODAL).last
|
||||
expect(post_modal).to_be_visible(timeout=settings.default_timeout)
|
||||
expect(post_modal).to_contain_text(re.compile(r"Comments|Please log in", re.I), timeout=settings.default_timeout)
|
||||
|
||||
logger.info("关闭加载后的新帖子详情弹窗")
|
||||
page.keyboard.press("Escape")
|
||||
expect(post_modal).to_be_hidden(timeout=settings.default_timeout)
|
||||
65
Joyhub_ui_auto_test/tests/test_login_logout.py
Normal file
65
Joyhub_ui_auto_test/tests/test_login_logout.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from config.settings import settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def test_login_success_then_logout_wait_3s_and_close_browser(page, ensure_guest_user):
|
||||
logger.info("使用 pytest-playwright 统一无头浏览器配置")
|
||||
|
||||
logger.info("打开链接地址: %s", settings.base_url)
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
ensure_guest_user()
|
||||
|
||||
logger.info("点击登录入口打开登录弹窗")
|
||||
page.locator("aside button").first.click()
|
||||
|
||||
logger.info("动态获取登录弹窗输入框并输入邮箱: %s", settings.login_email)
|
||||
login_inputs = page.locator('input[type="text"]')
|
||||
expect(login_inputs.first).to_be_visible(timeout=settings.default_timeout)
|
||||
login_inputs.nth(0).fill(settings.login_email)
|
||||
|
||||
logger.info("输入验证码")
|
||||
login_inputs.nth(1).fill(settings.verification_code)
|
||||
|
||||
logger.info("动态勾选登录确认项")
|
||||
confirmation_items = page.locator("div.inline-flex.cursor-pointer")
|
||||
expect(confirmation_items.nth(0)).to_be_visible(timeout=settings.default_timeout)
|
||||
expect(confirmation_items.nth(1)).to_be_visible(timeout=settings.default_timeout)
|
||||
confirmation_items.nth(0).click()
|
||||
confirmation_items.nth(1).click()
|
||||
|
||||
logger.info("提交登录")
|
||||
submit_button = page.locator("button").last
|
||||
expect(submit_button).to_be_enabled(timeout=settings.default_timeout)
|
||||
submit_button.click()
|
||||
|
||||
logger.info("等待登录成功后出现退出按钮")
|
||||
logout_button = page.locator("aside button").first
|
||||
expect(logout_button).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
logger.info("登录成功,停留 2 秒")
|
||||
page.wait_for_timeout(2_000)
|
||||
|
||||
logger.info("点击 Logout 退出登录")
|
||||
logout_button.click()
|
||||
|
||||
logger.info("校验退出登录成功后重新出现登录入口")
|
||||
expect(page.locator("aside button").first).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
logger.info("退出登录成功,停留 3 秒")
|
||||
page.wait_for_timeout(3_000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([str(Path(__file__))]))
|
||||
132
Joyhub_ui_auto_test/tests/test_my_order.py
Normal file
132
Joyhub_ui_auto_test/tests/test_my_order.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright.sync_api import expect
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from config.settings import settings
|
||||
from tests.test_rewards_shopping_cart_login_logout import (
|
||||
_accept_cookie_if_present,
|
||||
_first_visible,
|
||||
_login_as_configured_user,
|
||||
)
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
MY_ORDER_LINK_SELECTORS = [
|
||||
'a[href*="order" i]',
|
||||
'a:has-text("My Order")',
|
||||
'a:has-text("My Orders")',
|
||||
'button:has-text("My Order")',
|
||||
'button:has-text("My Orders")',
|
||||
'div.cursor-pointer:has-text("My Order")',
|
||||
'div.cursor-pointer:has-text("My Orders")',
|
||||
'li:has-text("My Order")',
|
||||
'li:has-text("My Orders")',
|
||||
'text=My Order',
|
||||
'text=My Orders',
|
||||
]
|
||||
|
||||
ORDER_DETAIL_LINK_SELECTORS = [
|
||||
'a[href*="order" i]',
|
||||
'button:has-text("Detail")',
|
||||
'button:has-text("Details")',
|
||||
'button:has-text("View")',
|
||||
'button:has-text("View Detail")',
|
||||
'button:has-text("View Details")',
|
||||
]
|
||||
|
||||
|
||||
def _click_first_visible_by_selectors(page, selectors, timeout=3_000):
|
||||
for selector in selectors:
|
||||
locator = page.locator(selector)
|
||||
try:
|
||||
if locator.count() == 0:
|
||||
continue
|
||||
candidate = _first_visible(locator)
|
||||
expect(candidate).to_be_visible(timeout=timeout)
|
||||
candidate.click()
|
||||
return selector
|
||||
except (AssertionError, PlaywrightTimeoutError):
|
||||
continue
|
||||
raise AssertionError(f"未找到可点击的可见元素: {selectors}")
|
||||
|
||||
|
||||
def _wait_login_completed(page):
|
||||
logger.info("等待登录完成并展示用户菜单入口")
|
||||
page.wait_for_function(
|
||||
"""
|
||||
() => {
|
||||
const visibleText = Array.from(document.querySelectorAll('a,button,div,li,span'))
|
||||
.filter(element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length))
|
||||
.map(element => element.innerText || element.textContent || '')
|
||||
.join(' ');
|
||||
return /My Order|Logout|Create/i.test(visibleText) && !/^\\s*Login\\s*$/.test(visibleText);
|
||||
}
|
||||
""",
|
||||
timeout=settings.default_timeout,
|
||||
)
|
||||
|
||||
|
||||
def _open_my_order_from_home(page):
|
||||
logger.info("从首页查找 My Order 入口")
|
||||
try:
|
||||
selector = _click_first_visible_by_selectors(page, MY_ORDER_LINK_SELECTORS)
|
||||
logger.info("已通过首页可见入口进入 My Order: %s", selector)
|
||||
return
|
||||
except AssertionError:
|
||||
logger.info("首页未直接展示 My Order,尝试点击用户侧边栏入口后继续查找")
|
||||
|
||||
aside_buttons = page.locator("aside button")
|
||||
expect(aside_buttons.first).to_be_visible(timeout=settings.default_timeout)
|
||||
aside_buttons.first.click()
|
||||
page.wait_for_timeout(1_000)
|
||||
|
||||
selector = _click_first_visible_by_selectors(page, MY_ORDER_LINK_SELECTORS, timeout=settings.default_timeout)
|
||||
logger.info("已通过用户菜单进入 My Order: %s", selector)
|
||||
|
||||
|
||||
def _open_first_order_detail(page):
|
||||
logger.info("等待进入订单列表页")
|
||||
expect(page).to_have_url(re.compile(r".*order.*", re.I), timeout=settings.default_timeout)
|
||||
expect(page.get_by_text(re.compile(r"my order|orders?|order", re.I)).first).to_be_visible(
|
||||
timeout=settings.default_timeout
|
||||
)
|
||||
|
||||
logger.info("点击第一笔订单详情入口")
|
||||
before_url = page.url
|
||||
selector = _click_first_visible_by_selectors(page, ORDER_DETAIL_LINK_SELECTORS, timeout=settings.default_timeout)
|
||||
logger.info("已点击订单详情入口: %s", selector)
|
||||
|
||||
try:
|
||||
page.wait_for_url(lambda url: url != before_url, timeout=settings.default_timeout)
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info("点击第一笔订单后 URL 未变化,继续校验当前页面是否展示详情内容")
|
||||
|
||||
expect(page.locator("body")).to_be_visible(timeout=settings.default_timeout)
|
||||
expect(page.get_by_text(re.compile(r"order|detail|status|total|payment|shipping", re.I)).first).to_be_visible(
|
||||
timeout=settings.default_timeout
|
||||
)
|
||||
|
||||
|
||||
def test_home_my_order_first_order_detail_after_login(page, ensure_logged_in_user):
|
||||
logger.info("登录用户从首页进入 My Order 并查看第一笔订单详情")
|
||||
logger.info("打开链接地址: %s", settings.base_url)
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
ensure_logged_in_user()
|
||||
_wait_login_completed(page)
|
||||
|
||||
_open_my_order_from_home(page)
|
||||
_open_first_order_detail(page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([str(Path(__file__))]))
|
||||
172
Joyhub_ui_auto_test/tests/test_navigation_pages.py
Normal file
172
Joyhub_ui_auto_test/tests/test_navigation_pages.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from pathlib import Path
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from config.settings import settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def test_header_download_app_navigation_then_home_return(page, ensure_guest_user):
|
||||
logger.info("使用 pytest-playwright 统一无头浏览器配置")
|
||||
|
||||
logger.info("打开链接地址: %s", settings.base_url)
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
ensure_guest_user()
|
||||
|
||||
logger.info("点击顶部 /app 导航链接")
|
||||
page.locator('header a[href="/app"]').first.click()
|
||||
|
||||
logger.info("校验跳转到 /app 页面")
|
||||
expect(page).to_have_url(re.compile(r".*/app/?$"), timeout=settings.default_timeout)
|
||||
expect(page.locator("body")).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
logger.info("点击顶部首页链接返回首页")
|
||||
page.locator('header a[href="/"]').first.click()
|
||||
|
||||
logger.info("校验返回首页成功")
|
||||
expect(page).to_have_url(re.compile(r".*/?$"), timeout=settings.default_timeout)
|
||||
expect(page.locator('button').first).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
|
||||
def test_footer_navigation_to_partnerships_and_faq(page, ensure_guest_user):
|
||||
logger.info("使用 pytest-playwright 统一无头浏览器配置")
|
||||
|
||||
logger.info("打开链接地址: %s", settings.base_url)
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
ensure_guest_user()
|
||||
|
||||
logger.info("校验底部 Partnerships 和 FAQs 链接存在")
|
||||
expect(page.locator('footer a[href="/partnerships"]')).to_be_attached(timeout=settings.default_timeout)
|
||||
expect(page.locator('footer a[href="/faq"]')).to_be_attached(timeout=settings.default_timeout)
|
||||
|
||||
logger.info("打开 Partnerships 页面")
|
||||
page.goto("/partnerships", wait_until="domcontentloaded")
|
||||
|
||||
logger.info("校验跳转到 Partnerships 页面")
|
||||
expect(page).to_have_url(re.compile(r".*/partnerships/?$"), timeout=settings.default_timeout)
|
||||
expect(page.locator("body")).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
logger.info("打开 FAQ 页面")
|
||||
page.goto("/faq", wait_until="domcontentloaded")
|
||||
|
||||
logger.info("校验跳转到 FAQ 页面")
|
||||
expect(page).to_have_url(re.compile(r".*/faq/?$"), timeout=settings.default_timeout)
|
||||
expect(page.locator("body")).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
|
||||
def test_about_us_blog_random_category_article_browse_success(page, ensure_guest_user, attach_page_screenshot):
|
||||
logger.info("打开首页并进入 About Us 下的 Blog 页面")
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
ensure_guest_user()
|
||||
page.wait_for_timeout(2_000)
|
||||
|
||||
about_us_menu = page.get_by_text("About Us", exact=True).first
|
||||
expect(about_us_menu).to_be_visible(timeout=settings.default_timeout)
|
||||
about_us_menu.click()
|
||||
|
||||
blog_link = page.locator('a[href="/blog-detail"]', has_text="Blog").first
|
||||
expect(blog_link).to_be_visible(timeout=settings.default_timeout)
|
||||
blog_link.click()
|
||||
|
||||
logger.info("校验 Blog 页面加载成功")
|
||||
expect(page).to_have_url(re.compile(r".*/blog-detail/?$"), timeout=settings.default_timeout)
|
||||
expect(page.locator("body")).to_contain_text("SEXUAL WELLNESS HUB", timeout=settings.default_timeout)
|
||||
|
||||
category_buttons = page.locator("button").filter(has_not_text=re.compile(r"^\\d+$|Acce?pet", re.I))
|
||||
expect(category_buttons.first).to_be_visible(timeout=settings.default_timeout)
|
||||
category_count = category_buttons.count()
|
||||
category_indexes = list(range(category_count))
|
||||
random.shuffle(category_indexes)
|
||||
|
||||
selected_category = None
|
||||
article_links = page.locator('a[href^="/blog-detail/"]')
|
||||
for category_index in category_indexes:
|
||||
category_button = category_buttons.nth(category_index)
|
||||
selected_category = category_button.inner_text(timeout=settings.default_timeout).strip()
|
||||
logger.info("随机选择 Blog 分类: %s", selected_category)
|
||||
category_button.click()
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
try:
|
||||
expect(article_links.first).to_be_visible(timeout=5_000)
|
||||
if article_links.count() > 0:
|
||||
break
|
||||
except AssertionError:
|
||||
logger.info("分类 %s 下暂无可浏览 Blog,继续随机尝试其他分类", selected_category)
|
||||
else:
|
||||
raise AssertionError("所有 Blog 分类下均未找到可浏览文章")
|
||||
|
||||
article_count = article_links.count()
|
||||
article_index = random.randrange(article_count)
|
||||
article_link = article_links.nth(article_index)
|
||||
article_title = article_link.inner_text(timeout=settings.default_timeout).strip()
|
||||
logger.info("随机浏览 Blog 文章: %s", article_title)
|
||||
|
||||
article_href = article_link.get_attribute("href")
|
||||
assert article_href is not None
|
||||
article_link.scroll_into_view_if_needed(timeout=settings.default_timeout)
|
||||
article_link.click()
|
||||
|
||||
logger.info("校验 Blog 详情页加载成功并截图")
|
||||
expect(page).to_have_url(re.compile(r".*/blog-detail/.+"), timeout=settings.default_timeout)
|
||||
expect(page.locator("body")).to_be_visible(timeout=settings.default_timeout)
|
||||
if article_title:
|
||||
expect(page.locator("body")).to_contain_text(article_title, timeout=settings.default_timeout)
|
||||
attach_page_screenshot(f"Blog浏览成功_{selected_category}_{article_title or article_href}")
|
||||
|
||||
|
||||
def test_footer_social_media_links_open_then_return_home(page, ensure_guest_user):
|
||||
social_links = [
|
||||
("X", 'a[href="https://x.com/JoyhubOfficial"]', re.compile(r"https://(x|twitter)\.com/.*", re.I)),
|
||||
(
|
||||
"Instagram",
|
||||
'a[href="https://www.instagram.com/joyhub.official/#"]',
|
||||
re.compile(r"https://www\.instagram\.com/.*", re.I),
|
||||
),
|
||||
(
|
||||
"Reddit",
|
||||
'a[href="https://www.reddit.com/r/JoyhubRemote/"]',
|
||||
re.compile(r"https://www\.reddit\.com/.*", re.I),
|
||||
),
|
||||
(
|
||||
"Discord",
|
||||
'a[href="https://discord.com/invite/vZFQbTRZqe"]',
|
||||
re.compile(r"https://discord\.com/.*", re.I),
|
||||
),
|
||||
]
|
||||
|
||||
logger.info("打开链接地址: %s", settings.base_url)
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
ensure_guest_user()
|
||||
|
||||
for name, selector, expected_url in social_links:
|
||||
logger.info("滚动到首页底部,点击 %s 社交媒体按钮", name)
|
||||
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||
link = page.locator(selector).first
|
||||
expect(link).to_be_attached(timeout=settings.default_timeout)
|
||||
link.scroll_into_view_if_needed(timeout=settings.default_timeout)
|
||||
|
||||
social_href = link.get_attribute("href")
|
||||
assert social_href is not None
|
||||
|
||||
logger.info("校验 %s 社交媒体页面跳转成功", name)
|
||||
page.goto(social_href, wait_until="domcontentloaded")
|
||||
expect(page).to_have_url(expected_url, timeout=settings.default_timeout)
|
||||
|
||||
logger.info("从 %s 页面返回 JoyHub 首页", name)
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
expect(page).to_have_url(re.compile(r".*/?$"), timeout=settings.default_timeout)
|
||||
expect(page.locator('a[href="https://x.com/JoyhubOfficial"]')).to_be_attached(timeout=settings.default_timeout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([str(Path(__file__))]))
|
||||
28
Joyhub_ui_auto_test/tests/test_open_chrome_browser.py
Normal file
28
Joyhub_ui_auto_test/tests/test_open_chrome_browser.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from config.settings import settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def test_open_chromium_browser_wait_10s_then_close(page, ensure_guest_user):
|
||||
logger.info("使用 pytest-playwright 统一无头浏览器配置")
|
||||
|
||||
logger.info("打开链接地址: %s", settings.base_url)
|
||||
page.goto(settings.base_url)
|
||||
ensure_guest_user()
|
||||
|
||||
logger.info("等待 10 秒")
|
||||
page.wait_for_timeout(10_000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([str(Path(__file__))]))
|
||||
71
Joyhub_ui_auto_test/tests/test_partnerships_page.py
Normal file
71
Joyhub_ui_auto_test/tests/test_partnerships_page.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from config.settings import settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def test_partnerships_page_key_content_visible(page):
|
||||
logger.info("使用 pytest-playwright 统一无头浏览器配置")
|
||||
|
||||
logger.info("打开 Partnerships 页面")
|
||||
page.goto("/partnerships", wait_until="domcontentloaded")
|
||||
|
||||
logger.info("校验 Partnerships 页面地址、标题和页面内容已加载")
|
||||
expect(page).to_have_url(re.compile(r".*/partnerships/?$"), timeout=settings.default_timeout)
|
||||
assert page.title().strip()
|
||||
expect(page.locator("body")).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
visible_headings_count = page.get_by_role("heading").count()
|
||||
logger.info("当前 Partnerships 页面标题元素数量: %s", visible_headings_count)
|
||||
assert visible_headings_count > 0
|
||||
|
||||
|
||||
def test_partnerships_contact_form_fields_visible_and_editable(page):
|
||||
logger.info("使用 pytest-playwright 统一无头浏览器配置")
|
||||
|
||||
logger.info("打开 Partnerships 页面")
|
||||
page.goto("/partnerships", wait_until="domcontentloaded")
|
||||
|
||||
logger.info("动态校验合作表单字段可见并可输入")
|
||||
form_inputs = page.locator('section input[type="text"]:not([readonly])')
|
||||
form_textareas = page.locator("section textarea")
|
||||
submit_button = page.locator("section button").last
|
||||
|
||||
name_input = form_inputs.nth(0)
|
||||
email_input = form_inputs.nth(1)
|
||||
business_input = form_inputs.nth(2)
|
||||
collaboration_input = page.locator('section input[readonly]').first
|
||||
message_input = form_textareas.first
|
||||
|
||||
expect(name_input).to_be_editable(timeout=settings.default_timeout)
|
||||
expect(email_input).to_be_editable(timeout=settings.default_timeout)
|
||||
expect(business_input).to_be_editable(timeout=settings.default_timeout)
|
||||
expect(message_input).to_be_editable(timeout=settings.default_timeout)
|
||||
|
||||
name_input.fill("Joyhub Tester")
|
||||
email_input.fill("tester@example.com")
|
||||
business_input.fill("https://example.com")
|
||||
message_input.fill("I want to explore product testing collaboration.")
|
||||
|
||||
expect(name_input).to_have_value("Joyhub Tester", timeout=settings.default_timeout)
|
||||
expect(email_input).to_have_value("tester@example.com", timeout=settings.default_timeout)
|
||||
expect(business_input).to_have_value("https://example.com", timeout=settings.default_timeout)
|
||||
expect(collaboration_input).to_be_visible(timeout=settings.default_timeout)
|
||||
expect(collaboration_input).to_have_attribute("readonly", "", timeout=settings.default_timeout)
|
||||
expect(message_input).to_have_value("I want to explore product testing collaboration.", timeout=settings.default_timeout)
|
||||
expect(submit_button).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([str(Path(__file__))]))
|
||||
155
Joyhub_ui_auto_test/tests/test_points_redemption_payment.py
Normal file
155
Joyhub_ui_auto_test/tests/test_points_redemption_payment.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from pathlib import Path
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright.sync_api import expect
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from config.settings import settings
|
||||
from tests.test_rewards_shopping_cart_login_logout import _accept_cookie_if_present
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
REDEEM_NOW_SELECTORS = [
|
||||
'button:has-text("Redeem Now"):not([disabled])',
|
||||
'button:has-text("Redeem now"):not([disabled])',
|
||||
'button:has-text("立即兑换"):not([disabled])',
|
||||
]
|
||||
POINTS_PAY_SELECTORS = [
|
||||
'button:has-text("Pay"):not([disabled])',
|
||||
'button:has-text("Confirm"):not([disabled])',
|
||||
'button:has-text("Submit"):not([disabled])',
|
||||
'button:has-text("Place Order"):not([disabled])',
|
||||
'button:has-text("Redeem"):not([disabled])',
|
||||
'button:has-text("Confirm Redemption"):not([disabled])',
|
||||
'button:has-text("确认"):not([disabled])',
|
||||
'button:has-text("提交"):not([disabled])',
|
||||
'button:has-text("支付"):not([disabled])',
|
||||
]
|
||||
|
||||
|
||||
def _visible_locator(page, selectors, timeout=5_000):
|
||||
for selector in selectors:
|
||||
locator = page.locator(selector).first
|
||||
try:
|
||||
if locator.is_visible(timeout=timeout):
|
||||
return locator
|
||||
except PlaywrightTimeoutError:
|
||||
continue
|
||||
raise AssertionError(f"未找到可见元素: {selectors}")
|
||||
|
||||
|
||||
def _login_in_open_dialog(page):
|
||||
logger.info("在 Redeem Now 触发的登录弹窗中登录")
|
||||
login_inputs = page.locator('input[type="text"]')
|
||||
expect(login_inputs.first).to_be_visible(timeout=settings.default_timeout)
|
||||
login_inputs.nth(0).fill(settings.login_email)
|
||||
login_inputs.nth(1).fill(settings.verification_code)
|
||||
|
||||
confirmation_items = page.locator("div.inline-flex.cursor-pointer")
|
||||
expect(confirmation_items.nth(0)).to_be_visible(timeout=settings.default_timeout)
|
||||
expect(confirmation_items.nth(1)).to_be_visible(timeout=settings.default_timeout)
|
||||
confirmation_items.nth(0).click()
|
||||
confirmation_items.nth(1).click()
|
||||
|
||||
submit_button = page.locator("button").last
|
||||
expect(submit_button).to_be_enabled(timeout=settings.default_timeout)
|
||||
submit_button.click()
|
||||
|
||||
logger.info("等待登录弹窗关闭或兑换流程继续")
|
||||
expect(login_inputs.first).not_to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
|
||||
def _open_points_redemption_from_rewards(page):
|
||||
logger.info("未登录用户点击 Rewards 并进入 Points Redemption")
|
||||
rewards_link = page.locator('header a[href^="javascript:"]').filter(has_text="Rewards").first
|
||||
expect(rewards_link).to_be_attached(timeout=settings.default_timeout)
|
||||
rewards_link.click(force=True)
|
||||
|
||||
try:
|
||||
page.wait_for_function(
|
||||
"""
|
||||
() => Array.from(document.querySelectorAll('a[href="/points-redemption"]'))
|
||||
.some(element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length))
|
||||
""",
|
||||
timeout=5_000,
|
||||
)
|
||||
page.locator('a[href="/points-redemption"]').filter(has_text="Points Redemption").first.click()
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info("Rewards 下拉入口未稳定展示,直接进入 Points Redemption 页面")
|
||||
page.goto(settings.base_url.rstrip("/") + "/points-redemption", wait_until="domcontentloaded")
|
||||
|
||||
expect(page).to_have_url(re.compile(r".*/points-redemption/?"), timeout=settings.default_timeout)
|
||||
expect(page.locator("body")).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
|
||||
def _open_random_redeemable_points_product(page):
|
||||
logger.info("随机选择一个积分商品并查找 Redeem Now")
|
||||
category_buttons = page.locator("section button").filter(has_not_text=re.compile(r"Accepet|Accept", re.I))
|
||||
expect(category_buttons.first).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
category_indices = list(range(category_buttons.count()))
|
||||
random.shuffle(category_indices)
|
||||
last_error = None
|
||||
|
||||
for category_index in category_indices:
|
||||
try:
|
||||
logger.info("尝试积分商品分类/商品入口索引: %s", category_index)
|
||||
category_buttons.nth(category_index).click()
|
||||
page.wait_for_timeout(1_500)
|
||||
|
||||
if page.get_by_text(re.compile(r"currently do not support delivery", re.I)).first.is_visible(timeout=1_000):
|
||||
last_error = "当前国家/地区不支持积分商品配送"
|
||||
continue
|
||||
|
||||
redeem_button = _visible_locator(page, REDEEM_NOW_SELECTORS, timeout=5_000)
|
||||
expect(redeem_button).to_be_enabled(timeout=settings.default_timeout)
|
||||
redeem_button.click()
|
||||
return
|
||||
except (AssertionError, PlaywrightTimeoutError) as exc:
|
||||
last_error = exc
|
||||
logger.info("当前积分商品不可兑换,继续尝试下一个: %s", exc)
|
||||
|
||||
pytest.skip(f"当前测试环境未展示可兑换积分商品,无法执行 Redeem Now 主流程: {last_error}")
|
||||
|
||||
|
||||
def _complete_points_payment_after_login(page):
|
||||
logger.info("登录后继续完成积分商品支付/兑换")
|
||||
try:
|
||||
pay_button = _visible_locator(page, POINTS_PAY_SELECTORS, timeout=settings.default_timeout)
|
||||
expect(pay_button).to_be_enabled(timeout=settings.default_timeout)
|
||||
pay_button.click()
|
||||
except AssertionError:
|
||||
logger.info("登录后未出现独立确认按钮,检查是否已自动提交兑换")
|
||||
|
||||
result_text = page.get_by_text(
|
||||
re.compile(r"success|successful|completed|paid|payment|order|redeem|redemption|成功|订单|兑换", re.I)
|
||||
).first
|
||||
expect(result_text).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
|
||||
def test_guest_points_product_redeem_now_login_then_points_payment_result(page, ensure_guest_user):
|
||||
logger.info("未登录用户从 Rewards 选择积分商品,Redeem Now 后登录并完成积分支付")
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
ensure_guest_user()
|
||||
|
||||
_open_points_redemption_from_rewards(page)
|
||||
_open_random_redeemable_points_product(page)
|
||||
|
||||
logger.info("校验 Redeem Now 后弹出登录弹窗")
|
||||
expect(page.locator('input[type="text"]').first).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
_login_in_open_dialog(page)
|
||||
_complete_points_payment_after_login(page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([str(Path(__file__))]))
|
||||
@@ -0,0 +1,160 @@
|
||||
from pathlib import Path
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.conftest import accept_cookie_if_present, login_as_configured_user_if_needed
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from config.settings import settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _accept_cookie_if_present(page):
|
||||
accept_cookie_if_present(page)
|
||||
|
||||
|
||||
def _first_visible(locator):
|
||||
locator_count = locator.count()
|
||||
for index in range(locator_count):
|
||||
candidate = locator.nth(index)
|
||||
if candidate.is_visible():
|
||||
return candidate
|
||||
raise AssertionError("未找到可见元素")
|
||||
|
||||
|
||||
def _login_as_configured_user(page):
|
||||
login_as_configured_user_if_needed(page)
|
||||
|
||||
|
||||
def _add_random_shopping_product_to_cart(page):
|
||||
logger.info("直接进入 Shopping 页面")
|
||||
page.goto(settings.base_url.rstrip("/") + "/shopping", wait_until="domcontentloaded")
|
||||
expect(page).to_have_url(re.compile(r".*/shopping/?$"), timeout=settings.default_timeout)
|
||||
expect(page.locator("body")).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
logger.info("动态获取分类列表并随机选择商品加购")
|
||||
category_buttons = page.locator("section > div:nth-of-type(1) > div:nth-of-type(2) > div:nth-of-type(1) button")
|
||||
expect(category_buttons.first).to_be_visible(timeout=settings.default_timeout)
|
||||
category_count = category_buttons.count()
|
||||
logger.info("当前分类数量: %s", category_count)
|
||||
assert category_count > 0
|
||||
|
||||
category_indices = list(range(category_count))
|
||||
random.shuffle(category_indices)
|
||||
logger.info("随机分类尝试顺序: %s", category_indices)
|
||||
|
||||
last_error = None
|
||||
for selected_category_index in category_indices:
|
||||
logger.info("随机选择分类索引: %s", selected_category_index)
|
||||
category_buttons.nth(selected_category_index).click()
|
||||
page.wait_for_timeout(1_000)
|
||||
|
||||
logger.info("动态获取商品列表")
|
||||
product_links = page.locator('section a[href^="/shopping/"][href*="selectedSkuId"]').evaluate_all(
|
||||
"""
|
||||
links => Array.from(new Map(
|
||||
links
|
||||
.map(link => link.getAttribute('href'))
|
||||
.filter(Boolean)
|
||||
.map(href => [href, href])
|
||||
).values())
|
||||
"""
|
||||
)
|
||||
logger.info("当前可进入详情的商品数量: %s", len(product_links))
|
||||
random.shuffle(product_links)
|
||||
|
||||
for selected_product_href in product_links:
|
||||
try:
|
||||
logger.info("尝试加购商品: %s", selected_product_href)
|
||||
page.goto(settings.base_url.rstrip("/") + selected_product_href, wait_until="domcontentloaded")
|
||||
expect(page).to_have_url(re.compile(r".*/shopping/.+selectedSkuId=.+"), timeout=settings.default_timeout)
|
||||
expect(page.locator("body")).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
logger.info("动态获取商品规格行,逐行随机选择一个可用规格")
|
||||
spec_rows = page.locator(".product-specs-scrollbar > div").filter(has=page.locator("button"))
|
||||
expect(spec_rows.first).to_be_visible(timeout=settings.default_timeout)
|
||||
spec_row_count = spec_rows.count()
|
||||
logger.info("当前规格行数量: %s", spec_row_count)
|
||||
assert spec_row_count > 0
|
||||
|
||||
selected_spec_count = 0
|
||||
for row_index in range(spec_row_count):
|
||||
spec_options = spec_rows.nth(row_index).locator("button:not([disabled])")
|
||||
spec_option_count = spec_options.count()
|
||||
logger.info("规格行 %s 可选项数量: %s", row_index, spec_option_count)
|
||||
if spec_option_count == 0:
|
||||
continue
|
||||
|
||||
selected_spec_index = random.randrange(spec_option_count)
|
||||
logger.info("规格行 %s 随机选择选项索引: %s", row_index, selected_spec_index)
|
||||
spec_options.nth(selected_spec_index).click()
|
||||
selected_spec_count += 1
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
assert selected_spec_count > 0
|
||||
|
||||
logger.info("动态点击数量增加按钮")
|
||||
increase_quantity_button = page.locator("button:has(svg.lucide-plus):not([disabled])").first
|
||||
try:
|
||||
if increase_quantity_button.is_visible(timeout=3_000):
|
||||
increase_click_count = random.randint(1, 3)
|
||||
logger.info("随机增加商品数量次数: %s", increase_click_count)
|
||||
for _ in range(increase_click_count):
|
||||
increase_quantity_button.click()
|
||||
page.wait_for_timeout(200)
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info("当前商品未展示数量增加按钮,使用默认购买数量")
|
||||
|
||||
logger.info("动态获取加购按钮并点击")
|
||||
add_to_cart_button = page.locator("button.bg-primary.text-white:not([disabled])").first
|
||||
expect(add_to_cart_button).to_be_visible(timeout=settings.default_timeout)
|
||||
expect(add_to_cart_button).to_be_enabled(timeout=settings.default_timeout)
|
||||
add_to_cart_button.click()
|
||||
|
||||
logger.info("校验购物车入口仍可见,表示加购流程已完成点击")
|
||||
expect(page.locator('a[href="/view-cart"]').first).to_be_visible(timeout=settings.default_timeout)
|
||||
return
|
||||
except (AssertionError, PlaywrightTimeoutError) as exc:
|
||||
last_error = exc
|
||||
logger.info("当前商品不可加购,继续尝试下一个商品: %s", exc)
|
||||
page.goto(settings.base_url.rstrip("/") + "/shopping", wait_until="domcontentloaded")
|
||||
expect(page).to_have_url(re.compile(r".*/shopping/?$"), timeout=settings.default_timeout)
|
||||
category_buttons = page.locator(
|
||||
"section > div:nth-of-type(1) > div:nth-of-type(2) > div:nth-of-type(1) button"
|
||||
)
|
||||
category_buttons.nth(selected_category_index).click()
|
||||
page.wait_for_timeout(1_000)
|
||||
|
||||
raise AssertionError(f"未找到可加购的随机商品: {last_error}")
|
||||
|
||||
|
||||
def test_rewards_shopping_random_product_add_to_cart_no_login(page, ensure_guest_user):
|
||||
logger.info("使用游客身份执行 Rewards Shopping 随机商品加购")
|
||||
logger.info("打开链接地址: %s", settings.base_url)
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
ensure_guest_user()
|
||||
|
||||
_add_random_shopping_product_to_cart(page)
|
||||
|
||||
|
||||
def test_rewards_shopping_random_product_add_to_cart_after_login(page, ensure_logged_in_user):
|
||||
logger.info("使用登录用户身份执行 Rewards Shopping 随机商品加购")
|
||||
logger.info("打开链接地址: %s", settings.base_url)
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
ensure_logged_in_user()
|
||||
|
||||
_add_random_shopping_product_to_cart(page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([str(Path(__file__))]))
|
||||
271
Joyhub_ui_auto_test/tests/test_rewards_shopping_payment.py
Normal file
271
Joyhub_ui_auto_test/tests/test_rewards_shopping_payment.py
Normal file
@@ -0,0 +1,271 @@
|
||||
from pathlib import Path
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Error as PlaywrightError
|
||||
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright.sync_api import expect
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from config.settings import settings
|
||||
from tests.test_rewards_shopping_cart_login_logout import (
|
||||
_accept_cookie_if_present,
|
||||
_first_visible,
|
||||
_login_as_configured_user,
|
||||
)
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
PAYPAL_EMAIL_SELECTORS = [
|
||||
'input[name="login_email"]',
|
||||
'input[name="email"]',
|
||||
'input#email',
|
||||
]
|
||||
PAYPAL_PASSWORD_SELECTORS = [
|
||||
'input[name="login_password"]',
|
||||
'input[name="password"]',
|
||||
'input#password',
|
||||
]
|
||||
PAYPAL_NEXT_SELECTORS = [
|
||||
'button:has-text("Next")',
|
||||
'input[type="submit"][value="Next"]',
|
||||
'button#btnNext',
|
||||
]
|
||||
PAYPAL_LOGIN_SELECTORS = [
|
||||
'button:has-text("Log In")',
|
||||
'button:has-text("Log in")',
|
||||
'button:has-text("Login")',
|
||||
'button#btnLogin',
|
||||
'input[type="submit"][value="Log In"]',
|
||||
]
|
||||
PAYPAL_PAY_SELECTORS = [
|
||||
'button:has-text("Complete Purchase")',
|
||||
'button:has-text("Pay Now")',
|
||||
'button:has-text("Agree and Pay")',
|
||||
'button:has-text("Continue")',
|
||||
'button:has-text("Review Order")',
|
||||
'input[type="submit"][value*="Pay"]',
|
||||
]
|
||||
|
||||
|
||||
def _visible_locator(page, selectors, timeout=5_000):
|
||||
for selector in selectors:
|
||||
locator = page.locator(selector).first
|
||||
try:
|
||||
if locator.is_visible(timeout=timeout):
|
||||
return locator
|
||||
except PlaywrightTimeoutError:
|
||||
continue
|
||||
raise AssertionError(f"未找到可见元素: {selectors}")
|
||||
|
||||
|
||||
def _add_random_shopping_product_directly(page):
|
||||
logger.info("直接进入 Shopping 页面并随机加购商品")
|
||||
page.goto(settings.base_url.rstrip("/") + "/shopping", wait_until="domcontentloaded")
|
||||
expect(page).to_have_url(re.compile(r".*/shopping/?$"), timeout=settings.default_timeout)
|
||||
|
||||
category_buttons = page.locator("section > div:nth-of-type(1) > div:nth-of-type(2) > div:nth-of-type(1) button")
|
||||
expect(category_buttons.first).to_be_visible(timeout=settings.default_timeout)
|
||||
category_indices = list(range(category_buttons.count()))
|
||||
random.shuffle(category_indices)
|
||||
|
||||
product_links = []
|
||||
for selected_category_index in category_indices:
|
||||
category_buttons.nth(selected_category_index).click()
|
||||
page.wait_for_timeout(1_000)
|
||||
product_links = page.locator('section a[href^="/shopping/"][href*="selectedSkuId"]').evaluate_all(
|
||||
"""
|
||||
links => Array.from(new Map(
|
||||
links
|
||||
.map(link => link.getAttribute('href'))
|
||||
.filter(Boolean)
|
||||
.map(href => [href, href])
|
||||
).values())
|
||||
"""
|
||||
)
|
||||
if product_links:
|
||||
break
|
||||
|
||||
assert product_links
|
||||
selected_product_href = random.choice(product_links)
|
||||
_first_visible(page.locator(f'section a[href="{selected_product_href}"]')).click()
|
||||
expect(page).to_have_url(re.compile(r".*/shopping/.+selectedSkuId=.+"), timeout=settings.default_timeout)
|
||||
|
||||
spec_rows = page.locator(".product-specs-scrollbar > div").filter(has=page.locator("button"))
|
||||
expect(spec_rows.first).to_be_visible(timeout=settings.default_timeout)
|
||||
selected_spec_count = 0
|
||||
for row_index in range(spec_rows.count()):
|
||||
spec_options = spec_rows.nth(row_index).locator("button:not([disabled])")
|
||||
spec_option_count = spec_options.count()
|
||||
if spec_option_count == 0:
|
||||
continue
|
||||
spec_options.nth(random.randrange(spec_option_count)).click()
|
||||
selected_spec_count += 1
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
assert selected_spec_count > 0
|
||||
increase_quantity_button = page.locator("button:has(svg.lucide-plus):not([disabled])").first
|
||||
try:
|
||||
if increase_quantity_button.is_visible(timeout=3_000):
|
||||
for _ in range(random.randint(1, 3)):
|
||||
increase_quantity_button.click()
|
||||
page.wait_for_timeout(200)
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info("当前商品未展示数量增加按钮,使用默认购买数量")
|
||||
|
||||
add_to_cart_button = page.locator("button.bg-primary.text-white:not([disabled])").first
|
||||
expect(add_to_cart_button).to_be_visible(timeout=settings.default_timeout)
|
||||
expect(add_to_cart_button).to_be_enabled(timeout=settings.default_timeout)
|
||||
add_to_cart_button.click()
|
||||
expect(page.locator('a[href="/view-cart"]').first).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
|
||||
def _go_to_cart(page):
|
||||
logger.info("进入购物车页面")
|
||||
page.goto(settings.base_url.rstrip("/") + "/view-cart", wait_until="domcontentloaded")
|
||||
expect(page).to_have_url(re.compile(r".*/view-cart/?$"), timeout=settings.default_timeout)
|
||||
expect(page.locator('iframe[title="PayPal-paypal"]').first).to_be_attached(timeout=settings.default_timeout)
|
||||
|
||||
|
||||
def _add_random_one_or_more_products(page):
|
||||
product_count = random.randint(1, 2)
|
||||
logger.info("随机加入商品数量种类: %s", product_count)
|
||||
for index in range(product_count):
|
||||
logger.info("执行第 %s 次随机商品加购", index + 1)
|
||||
_add_random_shopping_product_directly(page)
|
||||
if index < product_count - 1:
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
|
||||
|
||||
def _trigger_paypal_payment(page):
|
||||
logger.info("等待 PayPal 支付按钮渲染")
|
||||
expect(page.locator('iframe[title="PayPal-paypal"]').first).to_be_attached(timeout=settings.default_timeout)
|
||||
page.wait_for_timeout(3_000)
|
||||
|
||||
logger.info("坐标点击页面主 DOM 中可见的 PayPal 按钮")
|
||||
paypal_buttons = page.locator('button[class*="FFCE0C"]')
|
||||
for index in range(paypal_buttons.count()):
|
||||
paypal_button = paypal_buttons.nth(index)
|
||||
try:
|
||||
if not paypal_button.is_visible(timeout=1_000):
|
||||
continue
|
||||
if not paypal_button.is_enabled(timeout=1_000):
|
||||
continue
|
||||
|
||||
box = paypal_button.bounding_box()
|
||||
if not box:
|
||||
continue
|
||||
|
||||
try:
|
||||
with page.expect_popup(timeout=10_000) as popup_info:
|
||||
page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
|
||||
paypal_page = popup_info.value
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info("未捕获到 PayPal 弹窗,检查当前页是否已跳转")
|
||||
page.wait_for_timeout(3_000)
|
||||
if "paypal" in page.url.lower():
|
||||
paypal_page = page
|
||||
else:
|
||||
continue
|
||||
|
||||
try:
|
||||
paypal_page.wait_for_load_state("domcontentloaded", timeout=settings.default_timeout)
|
||||
try:
|
||||
paypal_page.wait_for_url(re.compile(r".*paypal.*"), timeout=settings.default_timeout)
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info("PayPal 页面当前地址未包含 paypal: %s", paypal_page.url)
|
||||
expect(paypal_page.locator("body")).to_be_visible(timeout=settings.default_timeout)
|
||||
return paypal_page
|
||||
except PlaywrightError as exc:
|
||||
logger.info("PayPal 弹窗已被触发但提前关闭: %s", exc)
|
||||
return None
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info("当前 PayPal 按钮未触发支付页,继续尝试下一个按钮")
|
||||
|
||||
raise AssertionError("未找到可点击并能打开支付页的 PayPal 按钮")
|
||||
|
||||
|
||||
def _login_paypal_if_required(paypal_page):
|
||||
logger.info("处理 PayPal 登录页面")
|
||||
try:
|
||||
email_input = _visible_locator(paypal_page, PAYPAL_EMAIL_SELECTORS)
|
||||
except AssertionError:
|
||||
logger.info("PayPal 当前页面未出现邮箱输入框,可能已保持登录态")
|
||||
return
|
||||
|
||||
email_input.fill(settings.paypal_email)
|
||||
|
||||
try:
|
||||
next_button = _visible_locator(paypal_page, PAYPAL_NEXT_SELECTORS, timeout=3_000)
|
||||
next_button.click()
|
||||
except AssertionError:
|
||||
logger.info("PayPal 未出现 Next 按钮,继续查找密码输入框")
|
||||
|
||||
password_input = _visible_locator(paypal_page, PAYPAL_PASSWORD_SELECTORS)
|
||||
password_input.fill(settings.paypal_password)
|
||||
|
||||
login_button = _visible_locator(paypal_page, PAYPAL_LOGIN_SELECTORS)
|
||||
expect(login_button).to_be_enabled(timeout=settings.default_timeout)
|
||||
login_button.click()
|
||||
paypal_page.wait_for_load_state("domcontentloaded", timeout=settings.default_timeout)
|
||||
|
||||
|
||||
def _complete_paypal_payment(page, paypal_page):
|
||||
logger.info("在 PayPal 页面确认支付")
|
||||
pay_button = _visible_locator(paypal_page, PAYPAL_PAY_SELECTORS, timeout=settings.default_timeout)
|
||||
expect(pay_button).to_be_enabled(timeout=settings.default_timeout)
|
||||
pay_button.click()
|
||||
|
||||
logger.info("等待 PayPal 支付完成并返回商户站点")
|
||||
try:
|
||||
paypal_page.wait_for_close(timeout=settings.default_timeout)
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info("PayPal 弹窗未自动关闭,继续等待主页面跳转")
|
||||
|
||||
page.wait_for_load_state("domcontentloaded", timeout=settings.default_timeout)
|
||||
expect(page).to_have_url(re.compile(r".*joyhub-website-frontend-test.*"), timeout=settings.default_timeout)
|
||||
|
||||
success_text = page.get_by_text(re.compile(r"success|paid|payment|order", re.I)).first
|
||||
expect(success_text).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
|
||||
def _pay_random_shopping_products(page):
|
||||
_add_random_one_or_more_products(page)
|
||||
_go_to_cart(page)
|
||||
paypal_page = _trigger_paypal_payment(page)
|
||||
|
||||
if not settings.run_paypal_payment:
|
||||
logger.info("RUN_PAYPAL_PAYMENT 未开启,仅校验支付入口可触发")
|
||||
expect(page).to_have_url(re.compile(r".*/view-cart/?$"), timeout=settings.default_timeout)
|
||||
return
|
||||
|
||||
assert paypal_page is not None, "PayPal 弹窗已触发但提前关闭,无法继续真实付款"
|
||||
_login_paypal_if_required(paypal_page)
|
||||
_complete_paypal_payment(page, paypal_page)
|
||||
|
||||
|
||||
def test_rewards_shopping_random_products_payment_no_login(page, ensure_guest_user):
|
||||
logger.info("使用游客身份执行随机商品 PayPal 支付")
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
ensure_guest_user()
|
||||
|
||||
_pay_random_shopping_products(page)
|
||||
|
||||
|
||||
def test_rewards_shopping_random_products_payment_after_login(page, ensure_logged_in_user):
|
||||
logger.info("使用登录用户身份执行随机商品 PayPal 支付")
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
ensure_logged_in_user()
|
||||
|
||||
_pay_random_shopping_products(page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([str(Path(__file__))]))
|
||||
@@ -0,0 +1,188 @@
|
||||
from pathlib import Path
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
||||
from playwright.sync_api import expect
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from config.settings import settings
|
||||
from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
LANGUAGE_OPTIONS = ["English", "Français"]
|
||||
|
||||
|
||||
def _visible_header(page):
|
||||
headers = page.locator("header")
|
||||
for index in range(headers.count()):
|
||||
header = headers.nth(index)
|
||||
try:
|
||||
if header.is_visible(timeout=1_000):
|
||||
return header
|
||||
except PlaywrightTimeoutError:
|
||||
continue
|
||||
raise AssertionError("未找到可见的页面头部")
|
||||
|
||||
|
||||
def _visible_dropdown_options(page):
|
||||
return page.locator('div[class*="hover:bg-grey700"]').filter(visible=True)
|
||||
|
||||
|
||||
def _select_random_language(page):
|
||||
header = _visible_header(page)
|
||||
language_trigger = header.locator("div.cursor-pointer").nth(3)
|
||||
expect(language_trigger).to_be_visible(timeout=settings.default_timeout)
|
||||
language_trigger.click()
|
||||
|
||||
options = _visible_dropdown_options(page)
|
||||
available_languages = []
|
||||
for index in range(options.count()):
|
||||
option_text = options.nth(index).inner_text(timeout=3_000).strip()
|
||||
if option_text in LANGUAGE_OPTIONS:
|
||||
available_languages.append(option_text)
|
||||
|
||||
assert available_languages, "语言下拉没有可选项"
|
||||
selected_language = random.choice(available_languages)
|
||||
logger.info("随机选择语言: %s,可选语言: %s", selected_language, available_languages)
|
||||
options.filter(has_text=re.compile(rf"^{re.escape(selected_language)}$")).first.click()
|
||||
page.wait_for_load_state("domcontentloaded", timeout=settings.default_timeout)
|
||||
page.wait_for_timeout(1_000)
|
||||
return selected_language
|
||||
|
||||
|
||||
def _select_random_country(page):
|
||||
header = _visible_header(page)
|
||||
country_trigger = header.locator("div.cursor-pointer").nth(4)
|
||||
expect(country_trigger).to_be_visible(timeout=settings.default_timeout)
|
||||
country_trigger.click()
|
||||
|
||||
options = _visible_dropdown_options(page)
|
||||
expect(options.first).to_be_visible(timeout=settings.default_timeout)
|
||||
countries = []
|
||||
for index in range(options.count()):
|
||||
country_text = options.nth(index).inner_text(timeout=3_000).strip()
|
||||
if country_text and country_text not in LANGUAGE_OPTIONS:
|
||||
countries.append(country_text)
|
||||
|
||||
assert countries, "国家下拉没有可选项"
|
||||
selected_country = random.choice(countries)
|
||||
logger.info("随机选择国家: %s,可选国家数量: %s", selected_country, len(countries))
|
||||
options.filter(has_text=re.compile(rf"^{re.escape(selected_country)}$")).first.click()
|
||||
page.wait_for_load_state("domcontentloaded", timeout=settings.default_timeout)
|
||||
page.wait_for_timeout(1_000)
|
||||
return selected_country
|
||||
|
||||
|
||||
def _shopping_product_links(page):
|
||||
return page.locator('section a[href^="/shopping/"][href*="selectedSkuId"]').evaluate_all(
|
||||
"""
|
||||
links => Array.from(new Map(
|
||||
links
|
||||
.map(link => link.getAttribute('href'))
|
||||
.filter(Boolean)
|
||||
.map(href => [href, href])
|
||||
).values())
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _find_random_product_href_or_none(page):
|
||||
logger.info("进入 Shopping 页面,查找当前国家可选商品")
|
||||
page.goto(settings.base_url.rstrip("/") + "/shopping", wait_until="domcontentloaded")
|
||||
expect(page).to_have_url(re.compile(r".*/shopping/?$"), timeout=settings.default_timeout)
|
||||
expect(page.locator("body")).to_be_visible(timeout=settings.default_timeout)
|
||||
page.wait_for_timeout(2_000)
|
||||
|
||||
category_buttons = page.locator("section > div:nth-of-type(1) > div:nth-of-type(2) > div:nth-of-type(1) button")
|
||||
try:
|
||||
if not category_buttons.first.is_visible(timeout=5_000):
|
||||
logger.info("当前国家 Shopping 页面无分类,认为没有商品可选")
|
||||
return None
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info("当前国家 Shopping 页面未加载到分类,认为没有商品可选")
|
||||
return None
|
||||
|
||||
category_indices = list(range(category_buttons.count()))
|
||||
random.shuffle(category_indices)
|
||||
logger.info("随机分类尝试顺序: %s", category_indices)
|
||||
|
||||
for category_index in category_indices:
|
||||
category_buttons.nth(category_index).click()
|
||||
page.wait_for_timeout(1_000)
|
||||
product_links = _shopping_product_links(page)
|
||||
logger.info("分类索引 %s 可进入详情的商品数量: %s", category_index, len(product_links))
|
||||
if product_links:
|
||||
return random.choice(product_links)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _add_product_to_cart(page, product_href):
|
||||
logger.info("进入随机商品详情并加购: %s", product_href)
|
||||
page.goto(settings.base_url.rstrip("/") + product_href, wait_until="domcontentloaded")
|
||||
expect(page).to_have_url(re.compile(r".*/shopping/.+selectedSkuId=.+"), timeout=settings.default_timeout)
|
||||
expect(page.locator("body")).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
spec_rows = page.locator(".product-specs-scrollbar > div").filter(has=page.locator("button"))
|
||||
expect(spec_rows.first).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
selected_spec_count = 0
|
||||
for row_index in range(spec_rows.count()):
|
||||
spec_options = spec_rows.nth(row_index).locator("button:not([disabled])")
|
||||
spec_option_count = spec_options.count()
|
||||
if spec_option_count == 0:
|
||||
continue
|
||||
selected_spec_index = random.randrange(spec_option_count)
|
||||
logger.info("规格行 %s 随机选择选项索引: %s", row_index, selected_spec_index)
|
||||
spec_options.nth(selected_spec_index).click()
|
||||
selected_spec_count += 1
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
assert selected_spec_count > 0, "商品详情没有可选规格"
|
||||
|
||||
increase_quantity_button = page.locator("button:has(svg.lucide-plus):not([disabled])").first
|
||||
try:
|
||||
if increase_quantity_button.is_visible(timeout=3_000):
|
||||
increase_click_count = random.randint(1, 3)
|
||||
logger.info("随机增加商品数量次数: %s", increase_click_count)
|
||||
for _ in range(increase_click_count):
|
||||
increase_quantity_button.click()
|
||||
page.wait_for_timeout(200)
|
||||
except PlaywrightTimeoutError:
|
||||
logger.info("当前商品未展示数量增加按钮,使用默认购买数量")
|
||||
|
||||
add_to_cart_button = page.locator("button.bg-primary.text-white:not([disabled])").first
|
||||
expect(add_to_cart_button).to_be_visible(timeout=settings.default_timeout)
|
||||
expect(add_to_cart_button).to_be_enabled(timeout=settings.default_timeout)
|
||||
add_to_cart_button.click()
|
||||
|
||||
expect(page.locator('a[href="/view-cart"]').first).to_be_visible(timeout=settings.default_timeout)
|
||||
|
||||
|
||||
def test_switch_random_language_country_then_add_shopping_product_to_cart(page, ensure_guest_user):
|
||||
logger.info("打开 JoyHub 首页并确保游客身份")
|
||||
page.goto(settings.base_url, wait_until="domcontentloaded")
|
||||
ensure_guest_user()
|
||||
|
||||
selected_language = _select_random_language(page)
|
||||
selected_country = _select_random_country(page)
|
||||
|
||||
product_href = _find_random_product_href_or_none(page)
|
||||
if not product_href:
|
||||
logger.info("语言 %s / 国家 %s 没有商品可选,用例按预期通过", selected_language, selected_country)
|
||||
assert True
|
||||
return
|
||||
|
||||
_add_product_to_cart(page, product_href)
|
||||
logger.info("语言 %s / 国家 %s 商品加购成功", selected_language, selected_country)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([str(Path(__file__))]))
|
||||
38
Joyhub_ui_auto_test/tmp_probe_points.py
Normal file
38
Joyhub_ui_auto_test/tmp_probe_points.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
base = 'https://joyhub-website-frontend-test.best-envision.com'
|
||||
countries = [
|
||||
'Kenya',
|
||||
'Côte d’ivoire',
|
||||
'South Korea',
|
||||
'Great Britain (United Kingdom; England)',
|
||||
'Vatican City (The Holy See)',
|
||||
'Singapore',
|
||||
'Sweden',
|
||||
'Poland',
|
||||
'Netherlands',
|
||||
'Japan',
|
||||
'Italy',
|
||||
'Spain',
|
||||
'Germany',
|
||||
'Canada',
|
||||
'Australia',
|
||||
'France',
|
||||
]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
|
||||
for country in countries:
|
||||
try:
|
||||
page.goto(base + '/points-redemption', wait_until='domcontentloaded')
|
||||
page.wait_for_timeout(1500)
|
||||
page.locator('header .relative.inline-block').nth(5).click(force=True)
|
||||
page.wait_for_timeout(500)
|
||||
page.get_by_text(country, exact=True).first.click(force=True, timeout=5000)
|
||||
page.wait_for_timeout(2500)
|
||||
body = page.locator('body').inner_text()
|
||||
print(country, '| url=', page.url, '| redeem=', 'Redeem' in body, '| unsupported=', 'currently do not support delivery' in body, '| sample=', body[:300].replace('\n', ' | '))
|
||||
except Exception as exc:
|
||||
print(country, '| ERROR=', type(exc).__name__, str(exc)[:200])
|
||||
browser.close()
|
||||
0
Joyhub_ui_auto_test/utils/__init__.py
Normal file
0
Joyhub_ui_auto_test/utils/__init__.py
Normal file
25
Joyhub_ui_auto_test/utils/logger.py
Normal file
25
Joyhub_ui_auto_test/utils/logger.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPORTS_DIR = Path(__file__).resolve().parents[1] / "reports"
|
||||
LOG_FILE = REPORTS_DIR / "test_run.log"
|
||||
|
||||
|
||||
def get_logger(name: str = "ui-test") -> logging.Logger:
|
||||
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger(name)
|
||||
if not logger.handlers:
|
||||
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s - %(message)s")
|
||||
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(formatter)
|
||||
logger.addHandler(stream_handler)
|
||||
|
||||
file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
logger.setLevel(logging.INFO)
|
||||
return logger
|
||||
202
Joyhub_ui_auto_test/webapp-testing/LICENSE.txt
Normal file
202
Joyhub_ui_auto_test/webapp-testing/LICENSE.txt
Normal file
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Anthropic, PBC.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
96
Joyhub_ui_auto_test/webapp-testing/SKILL.md
Normal file
96
Joyhub_ui_auto_test/webapp-testing/SKILL.md
Normal file
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: webapp-testing
|
||||
description: Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
# Web Application Testing
|
||||
|
||||
To test local web applications, write native Python Playwright scripts.
|
||||
|
||||
**Helper Scripts Available**:
|
||||
- `scripts/with_server.py` - Manages server lifecycle (supports multiple servers)
|
||||
|
||||
**Always run scripts with `--help` first** to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window.
|
||||
|
||||
## Decision Tree: Choosing Your Approach
|
||||
|
||||
```
|
||||
User task → Is it static HTML?
|
||||
├─ Yes → Read HTML file directly to identify selectors
|
||||
│ ├─ Success → Write Playwright script using selectors
|
||||
│ └─ Fails/Incomplete → Treat as dynamic (below)
|
||||
│
|
||||
└─ No (dynamic webapp) → Is the server already running?
|
||||
├─ No → Run: python scripts/with_server.py --help
|
||||
│ Then use the helper + write simplified Playwright script
|
||||
│
|
||||
└─ Yes → Reconnaissance-then-action:
|
||||
1. Navigate and wait for networkidle
|
||||
2. Take screenshot or inspect DOM
|
||||
3. Identify selectors from rendered state
|
||||
4. Execute actions with discovered selectors
|
||||
```
|
||||
|
||||
## Example: Using with_server.py
|
||||
|
||||
To start a server, run `--help` first, then use the helper:
|
||||
|
||||
**Single server:**
|
||||
```bash
|
||||
python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_automation.py
|
||||
```
|
||||
|
||||
**Multiple servers (e.g., backend + frontend):**
|
||||
```bash
|
||||
python scripts/with_server.py \
|
||||
--server "cd backend && python server.py" --port 3000 \
|
||||
--server "cd frontend && npm run dev" --port 5173 \
|
||||
-- python your_automation.py
|
||||
```
|
||||
|
||||
To create an automation script, include only Playwright logic (servers are managed automatically):
|
||||
```python
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True) # Always launch chromium in headless mode
|
||||
page = browser.new_page()
|
||||
page.goto('http://localhost:5173') # Server already running and ready
|
||||
page.wait_for_load_state('networkidle') # CRITICAL: Wait for JS to execute
|
||||
# ... your automation logic
|
||||
browser.close()
|
||||
```
|
||||
|
||||
## Reconnaissance-Then-Action Pattern
|
||||
|
||||
1. **Inspect rendered DOM**:
|
||||
```python
|
||||
page.screenshot(path='/tmp/inspect.png', full_page=True)
|
||||
content = page.content()
|
||||
page.locator('button').all()
|
||||
```
|
||||
|
||||
2. **Identify selectors** from inspection results
|
||||
|
||||
3. **Execute actions** using discovered selectors
|
||||
|
||||
## Common Pitfall
|
||||
|
||||
❌ **Don't** inspect the DOM before waiting for `networkidle` on dynamic apps
|
||||
✅ **Do** wait for `page.wait_for_load_state('networkidle')` before inspection
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Use bundled scripts as black boxes** - To accomplish a task, consider whether one of the scripts available in `scripts/` can help. These scripts handle common, complex workflows reliably without cluttering the context window. Use `--help` to see usage, then invoke directly.
|
||||
- Use `sync_playwright()` for synchronous scripts
|
||||
- Always close the browser when done
|
||||
- Use descriptive selectors: `text=`, `role=`, CSS selectors, or IDs
|
||||
- Add appropriate waits: `page.wait_for_selector()` or `page.wait_for_timeout()`
|
||||
|
||||
## Reference Files
|
||||
|
||||
- **examples/** - Examples showing common patterns:
|
||||
- `element_discovery.py` - Discovering buttons, links, and inputs on a page
|
||||
- `static_html_automation.py` - Using file:// URLs for local HTML
|
||||
- `console_logging.py` - Capturing console logs during automation
|
||||
@@ -0,0 +1,35 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# Example: Capturing console logs during browser automation
|
||||
|
||||
url = 'http://localhost:5173' # Replace with your URL
|
||||
|
||||
console_logs = []
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
|
||||
|
||||
# Set up console log capture
|
||||
def handle_console_message(msg):
|
||||
console_logs.append(f"[{msg.type}] {msg.text}")
|
||||
print(f"Console: [{msg.type}] {msg.text}")
|
||||
|
||||
page.on("console", handle_console_message)
|
||||
|
||||
# Navigate to page
|
||||
page.goto(url)
|
||||
page.wait_for_load_state('networkidle')
|
||||
|
||||
# Interact with the page (triggers console logs)
|
||||
page.click('text=Dashboard')
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
browser.close()
|
||||
|
||||
# Save console logs to file
|
||||
with open('/mnt/user-data/outputs/console.log', 'w') as f:
|
||||
f.write('\n'.join(console_logs))
|
||||
|
||||
print(f"\nCaptured {len(console_logs)} console messages")
|
||||
print(f"Logs saved to: /mnt/user-data/outputs/console.log")
|
||||
@@ -0,0 +1,40 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# Example: Discovering buttons and other elements on a page
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
|
||||
# Navigate to page and wait for it to fully load
|
||||
page.goto('http://localhost:5173')
|
||||
page.wait_for_load_state('networkidle')
|
||||
|
||||
# Discover all buttons on the page
|
||||
buttons = page.locator('button').all()
|
||||
print(f"Found {len(buttons)} buttons:")
|
||||
for i, button in enumerate(buttons):
|
||||
text = button.inner_text() if button.is_visible() else "[hidden]"
|
||||
print(f" [{i}] {text}")
|
||||
|
||||
# Discover links
|
||||
links = page.locator('a[href]').all()
|
||||
print(f"\nFound {len(links)} links:")
|
||||
for link in links[:5]: # Show first 5
|
||||
text = link.inner_text().strip()
|
||||
href = link.get_attribute('href')
|
||||
print(f" - {text} -> {href}")
|
||||
|
||||
# Discover input fields
|
||||
inputs = page.locator('input, textarea, select').all()
|
||||
print(f"\nFound {len(inputs)} input fields:")
|
||||
for input_elem in inputs:
|
||||
name = input_elem.get_attribute('name') or input_elem.get_attribute('id') or "[unnamed]"
|
||||
input_type = input_elem.get_attribute('type') or 'text'
|
||||
print(f" - {name} ({input_type})")
|
||||
|
||||
# Take screenshot for visual reference
|
||||
page.screenshot(path='/tmp/page_discovery.png', full_page=True)
|
||||
print("\nScreenshot saved to /tmp/page_discovery.png")
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,33 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
|
||||
# Example: Automating interaction with static HTML files using file:// URLs
|
||||
|
||||
html_file_path = os.path.abspath('path/to/your/file.html')
|
||||
file_url = f'file://{html_file_path}'
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
|
||||
|
||||
# Navigate to local HTML file
|
||||
page.goto(file_url)
|
||||
|
||||
# Take screenshot
|
||||
page.screenshot(path='/mnt/user-data/outputs/static_page.png', full_page=True)
|
||||
|
||||
# Interact with elements
|
||||
page.click('text=Click Me')
|
||||
page.fill('#name', 'John Doe')
|
||||
page.fill('#email', 'john@example.com')
|
||||
|
||||
# Submit form
|
||||
page.click('button[type="submit"]')
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# Take final screenshot
|
||||
page.screenshot(path='/mnt/user-data/outputs/after_submit.png', full_page=True)
|
||||
|
||||
browser.close()
|
||||
|
||||
print("Static HTML automation completed!")
|
||||
106
Joyhub_ui_auto_test/webapp-testing/scripts/with_server.py
Normal file
106
Joyhub_ui_auto_test/webapp-testing/scripts/with_server.py
Normal file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Start one or more servers, wait for them to be ready, run a command, then clean up.
|
||||
|
||||
Usage:
|
||||
# Single server
|
||||
python scripts/with_server.py --server "npm run dev" --port 5173 -- python automation.py
|
||||
python scripts/with_server.py --server "npm start" --port 3000 -- python test.py
|
||||
|
||||
# Multiple servers
|
||||
python scripts/with_server.py \
|
||||
--server "cd backend && python server.py" --port 3000 \
|
||||
--server "cd frontend && npm run dev" --port 5173 \
|
||||
-- python test.py
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import socket
|
||||
import time
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
def is_server_ready(port, timeout=30):
|
||||
"""Wait for server to be ready by polling the port."""
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
with socket.create_connection(('localhost', port), timeout=1):
|
||||
return True
|
||||
except (socket.error, ConnectionRefusedError):
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Run command with one or more servers')
|
||||
parser.add_argument('--server', action='append', dest='servers', required=True, help='Server command (can be repeated)')
|
||||
parser.add_argument('--port', action='append', dest='ports', type=int, required=True, help='Port for each server (must match --server count)')
|
||||
parser.add_argument('--timeout', type=int, default=30, help='Timeout in seconds per server (default: 30)')
|
||||
parser.add_argument('command', nargs=argparse.REMAINDER, help='Command to run after server(s) ready')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Remove the '--' separator if present
|
||||
if args.command and args.command[0] == '--':
|
||||
args.command = args.command[1:]
|
||||
|
||||
if not args.command:
|
||||
print("Error: No command specified to run")
|
||||
sys.exit(1)
|
||||
|
||||
# Parse server configurations
|
||||
if len(args.servers) != len(args.ports):
|
||||
print("Error: Number of --server and --port arguments must match")
|
||||
sys.exit(1)
|
||||
|
||||
servers = []
|
||||
for cmd, port in zip(args.servers, args.ports):
|
||||
servers.append({'cmd': cmd, 'port': port})
|
||||
|
||||
server_processes = []
|
||||
|
||||
try:
|
||||
# Start all servers
|
||||
for i, server in enumerate(servers):
|
||||
print(f"Starting server {i+1}/{len(servers)}: {server['cmd']}")
|
||||
|
||||
# Use shell=True to support commands with cd and &&
|
||||
process = subprocess.Popen(
|
||||
server['cmd'],
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
server_processes.append(process)
|
||||
|
||||
# Wait for this server to be ready
|
||||
print(f"Waiting for server on port {server['port']}...")
|
||||
if not is_server_ready(server['port'], timeout=args.timeout):
|
||||
raise RuntimeError(f"Server failed to start on port {server['port']} within {args.timeout}s")
|
||||
|
||||
print(f"Server ready on port {server['port']}")
|
||||
|
||||
print(f"\nAll {len(servers)} server(s) ready")
|
||||
|
||||
# Run the command
|
||||
print(f"Running: {' '.join(args.command)}\n")
|
||||
result = subprocess.run(args.command)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
finally:
|
||||
# Clean up all servers
|
||||
print(f"\nStopping {len(server_processes)} server(s)...")
|
||||
for i, process in enumerate(server_processes):
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
print(f"Server {i+1} stopped")
|
||||
print("All servers stopped")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user