diff --git a/Joyhub_ui_auto_test/({tag b/Joyhub_ui_auto_test/({tag new file mode 100644 index 0000000..e69de29 diff --git a/Joyhub_ui_auto_test/.env.example b/Joyhub_ui_auto_test/.env.example new file mode 100644 index 0000000..b718db9 --- /dev/null +++ b/Joyhub_ui_auto_test/.env.example @@ -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 diff --git a/Joyhub_ui_auto_test/.gitignore b/Joyhub_ui_auto_test/.gitignore new file mode 100644 index 0000000..e44281c --- /dev/null +++ b/Joyhub_ui_auto_test/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.env +.venv/ +venv/ +reports/ +test-results/ +playwright-report/ +allure-results/ +.idea/ diff --git a/Joyhub_ui_auto_test/0 b/Joyhub_ui_auto_test/0 new file mode 100644 index 0000000..e69de29 diff --git a/Joyhub_ui_auto_test/README.md b/Joyhub_ui_auto_test/README.md new file mode 100644 index 0000000..e87ee27 --- /dev/null +++ b/Joyhub_ui_auto_test/README.md @@ -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/ 测试报告输出 +``` diff --git a/Joyhub_ui_auto_test/config/__init__.py b/Joyhub_ui_auto_test/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Joyhub_ui_auto_test/config/settings.py b/Joyhub_ui_auto_test/config/settings.py new file mode 100644 index 0000000..d71dee8 --- /dev/null +++ b/Joyhub_ui_auto_test/config/settings.py @@ -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() diff --git a/Joyhub_ui_auto_test/pages/__init__.py b/Joyhub_ui_auto_test/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Joyhub_ui_auto_test/pages/base_page.py b/Joyhub_ui_auto_test/pages/base_page.py new file mode 100644 index 0000000..75a1a4b --- /dev/null +++ b/Joyhub_ui_auto_test/pages/base_page.py @@ -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))) diff --git a/Joyhub_ui_auto_test/pages/home_page.py b/Joyhub_ui_auto_test/pages/home_page.py new file mode 100644 index 0000000..0491d09 --- /dev/null +++ b/Joyhub_ui_auto_test/pages/home_page.py @@ -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() diff --git a/Joyhub_ui_auto_test/pytest.ini b/Joyhub_ui_auto_test/pytest.ini new file mode 100644 index 0000000..9947b6c --- /dev/null +++ b/Joyhub_ui_auto_test/pytest.ini @@ -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 diff --git a/Joyhub_ui_auto_test/requirements.txt b/Joyhub_ui_auto_test/requirements.txt new file mode 100644 index 0000000..a8e7b1f --- /dev/null +++ b/Joyhub_ui_auto_test/requirements.txt @@ -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 diff --git a/Joyhub_ui_auto_test/tests/__init__.py b/Joyhub_ui_auto_test/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Joyhub_ui_auto_test/tests/conftest.py b/Joyhub_ui_auto_test/tests/conftest.py new file mode 100644 index 0000000..0431352 --- /dev/null +++ b/Joyhub_ui_auto_test/tests/conftest.py @@ -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, + } diff --git a/Joyhub_ui_auto_test/tests/run.tests.py b/Joyhub_ui_auto_test/tests/run.tests.py new file mode 100644 index 0000000..1eadef0 --- /dev/null +++ b/Joyhub_ui_auto_test/tests/run.tests.py @@ -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()) diff --git a/Joyhub_ui_auto_test/tests/test_app_page.py b/Joyhub_ui_auto_test/tests/test_app_page.py new file mode 100644 index 0000000..9f956bc --- /dev/null +++ b/Joyhub_ui_auto_test/tests/test_app_page.py @@ -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__))])) diff --git a/Joyhub_ui_auto_test/tests/test_download_app.py b/Joyhub_ui_auto_test/tests/test_download_app.py new file mode 100644 index 0000000..7608d84 --- /dev/null +++ b/Joyhub_ui_auto_test/tests/test_download_app.py @@ -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__))])) diff --git a/Joyhub_ui_auto_test/tests/test_faq_page.py b/Joyhub_ui_auto_test/tests/test_faq_page.py new file mode 100644 index 0000000..a516867 --- /dev/null +++ b/Joyhub_ui_auto_test/tests/test_faq_page.py @@ -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__))])) diff --git a/Joyhub_ui_auto_test/tests/test_home_page.py b/Joyhub_ui_auto_test/tests/test_home_page.py new file mode 100644 index 0000000..a226c3c --- /dev/null +++ b/Joyhub_ui_auto_test/tests/test_home_page.py @@ -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() diff --git a/Joyhub_ui_auto_test/tests/test_home_posts_load_more.py b/Joyhub_ui_auto_test/tests/test_home_posts_load_more.py new file mode 100644 index 0000000..884ec56 --- /dev/null +++ b/Joyhub_ui_auto_test/tests/test_home_posts_load_more.py @@ -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) diff --git a/Joyhub_ui_auto_test/tests/test_login_logout.py b/Joyhub_ui_auto_test/tests/test_login_logout.py new file mode 100644 index 0000000..aaeebc9 --- /dev/null +++ b/Joyhub_ui_auto_test/tests/test_login_logout.py @@ -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__))])) diff --git a/Joyhub_ui_auto_test/tests/test_my_order.py b/Joyhub_ui_auto_test/tests/test_my_order.py new file mode 100644 index 0000000..a76caa7 --- /dev/null +++ b/Joyhub_ui_auto_test/tests/test_my_order.py @@ -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__))])) diff --git a/Joyhub_ui_auto_test/tests/test_navigation_pages.py b/Joyhub_ui_auto_test/tests/test_navigation_pages.py new file mode 100644 index 0000000..51157ba --- /dev/null +++ b/Joyhub_ui_auto_test/tests/test_navigation_pages.py @@ -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__))])) diff --git a/Joyhub_ui_auto_test/tests/test_open_chrome_browser.py b/Joyhub_ui_auto_test/tests/test_open_chrome_browser.py new file mode 100644 index 0000000..7049330 --- /dev/null +++ b/Joyhub_ui_auto_test/tests/test_open_chrome_browser.py @@ -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__))])) diff --git a/Joyhub_ui_auto_test/tests/test_partnerships_page.py b/Joyhub_ui_auto_test/tests/test_partnerships_page.py new file mode 100644 index 0000000..68beecb --- /dev/null +++ b/Joyhub_ui_auto_test/tests/test_partnerships_page.py @@ -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__))])) diff --git a/Joyhub_ui_auto_test/tests/test_points_redemption_payment.py b/Joyhub_ui_auto_test/tests/test_points_redemption_payment.py new file mode 100644 index 0000000..e2b5e2b --- /dev/null +++ b/Joyhub_ui_auto_test/tests/test_points_redemption_payment.py @@ -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__))])) diff --git a/Joyhub_ui_auto_test/tests/test_rewards_shopping_cart_login_logout.py b/Joyhub_ui_auto_test/tests/test_rewards_shopping_cart_login_logout.py new file mode 100644 index 0000000..b0614ce --- /dev/null +++ b/Joyhub_ui_auto_test/tests/test_rewards_shopping_cart_login_logout.py @@ -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__))])) diff --git a/Joyhub_ui_auto_test/tests/test_rewards_shopping_payment.py b/Joyhub_ui_auto_test/tests/test_rewards_shopping_payment.py new file mode 100644 index 0000000..6ad3726 --- /dev/null +++ b/Joyhub_ui_auto_test/tests/test_rewards_shopping_payment.py @@ -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__))])) diff --git a/Joyhub_ui_auto_test/tests/test_shopping_language_country_random_cart.py b/Joyhub_ui_auto_test/tests/test_shopping_language_country_random_cart.py new file mode 100644 index 0000000..ccd26ed --- /dev/null +++ b/Joyhub_ui_auto_test/tests/test_shopping_language_country_random_cart.py @@ -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__))])) diff --git a/Joyhub_ui_auto_test/tmp_probe_points.py b/Joyhub_ui_auto_test/tmp_probe_points.py new file mode 100644 index 0000000..7e15822 --- /dev/null +++ b/Joyhub_ui_auto_test/tmp_probe_points.py @@ -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() diff --git a/Joyhub_ui_auto_test/utils/__init__.py b/Joyhub_ui_auto_test/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Joyhub_ui_auto_test/utils/logger.py b/Joyhub_ui_auto_test/utils/logger.py new file mode 100644 index 0000000..5cca250 --- /dev/null +++ b/Joyhub_ui_auto_test/utils/logger.py @@ -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 diff --git a/Joyhub_ui_auto_test/webapp-testing/LICENSE.txt b/Joyhub_ui_auto_test/webapp-testing/LICENSE.txt new file mode 100644 index 0000000..4f881c5 --- /dev/null +++ b/Joyhub_ui_auto_test/webapp-testing/LICENSE.txt @@ -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. \ No newline at end of file diff --git a/Joyhub_ui_auto_test/webapp-testing/SKILL.md b/Joyhub_ui_auto_test/webapp-testing/SKILL.md new file mode 100644 index 0000000..4726215 --- /dev/null +++ b/Joyhub_ui_auto_test/webapp-testing/SKILL.md @@ -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 \ No newline at end of file diff --git a/Joyhub_ui_auto_test/webapp-testing/examples/console_logging.py b/Joyhub_ui_auto_test/webapp-testing/examples/console_logging.py new file mode 100644 index 0000000..9329b5e --- /dev/null +++ b/Joyhub_ui_auto_test/webapp-testing/examples/console_logging.py @@ -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") \ No newline at end of file diff --git a/Joyhub_ui_auto_test/webapp-testing/examples/element_discovery.py b/Joyhub_ui_auto_test/webapp-testing/examples/element_discovery.py new file mode 100644 index 0000000..917ba72 --- /dev/null +++ b/Joyhub_ui_auto_test/webapp-testing/examples/element_discovery.py @@ -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() \ No newline at end of file diff --git a/Joyhub_ui_auto_test/webapp-testing/examples/static_html_automation.py b/Joyhub_ui_auto_test/webapp-testing/examples/static_html_automation.py new file mode 100644 index 0000000..90bbedc --- /dev/null +++ b/Joyhub_ui_auto_test/webapp-testing/examples/static_html_automation.py @@ -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!") \ No newline at end of file diff --git a/Joyhub_ui_auto_test/webapp-testing/scripts/with_server.py b/Joyhub_ui_auto_test/webapp-testing/scripts/with_server.py new file mode 100644 index 0000000..431f2eb --- /dev/null +++ b/Joyhub_ui_auto_test/webapp-testing/scripts/with_server.py @@ -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() \ No newline at end of file