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:
2026-05-13 16:01:25 +08:00
parent 37a040c3e5
commit a94eb5dbbe
38 changed files with 2567 additions and 0 deletions

View File

View 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
View 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
View File

View 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/ 测试报告输出
```

View File

View 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()

View File

View 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)))

View 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()

View 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

View 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

View File

View 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,
}

View 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())

View 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__))]))

View 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__))]))

View 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__))]))

View 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()

View 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)

View 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__))]))

View 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__))]))

View 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__))]))

View 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__))]))

View 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__))]))

View 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__))]))

View 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__))]))

View 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__))]))

View 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__))]))

View 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 divoire',
'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()

View File

View 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

View 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.

View 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

View File

@@ -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")

View File

@@ -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()

View File

@@ -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!")

View 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()