Add UI automation test cases and webapp-testing skill

This commit is contained in:
2026-05-18 17:56:24 +08:00
parent eb053a347f
commit e0e22b895e
25 changed files with 3533 additions and 0 deletions

View File

@@ -0,0 +1,159 @@
import os
import re
from playwright.sync_api import Page, expect
from dulizhan.test_case.Resource.UI.base_page import BasePage
class DownloadAppPage(BasePage):
"""
Download the App 页面对象
已知侦察结果:
- 首页 URL: https://joyhub-website-frontend-test.best-envision.com/
- 页面标题: Joyhub | Explore Sexual Health, Wellness, and Connection
- 导航链接: Download the App
- Cookie 按钮文案: Accepet
"""
DOWNLOAD_APP_LINK_TEXT = re.compile(r"^Download the App$", re.I)
def accept_cookie_if_present(self):
# 页面侦察结果中按钮文本为 Accepet疑似拼写如此按真实页面处理
accept_button = self.page.get_by_role("button", name=re.compile(r"Accepet|Accept", re.I))
self.click_if_visible(accept_button, timeout=3000)
def open_download_app_page(self):
self.accept_cookie_if_present()
download_link = self.page.get_by_role("link", name=self.DOWNLOAD_APP_LINK_TEXT)
expect(download_link).to_be_visible(timeout=10000)
download_link.click()
try:
self.page.wait_for_load_state("networkidle", timeout=5000)
except Exception:
pass
def get_google_play_locator(self):
"""
优先使用稳定定位:
1. href 包含 play.google.com
2. 链接文本包含 Google Play
3. 图片 alt 包含 Google向上找父级 a 标签
如果后续页面提供 data-testid建议替换为
self.page.get_by_test_id("google-play-download")
"""
candidates = [
self.page.locator("a[href*='play.google.com']").first,
self.page.get_by_role("link", name=re.compile(r"Google\s*Play", re.I)).first,
self.page.locator("a").filter(has_text=re.compile(r"Google\s*Play", re.I)).first,
self.page.locator("img[alt*='Google' i]").locator("xpath=ancestor::a[1]").first,
]
for locator in candidates:
try:
if locator.count() > 0:
return locator
except Exception:
continue
return None
def discover_google_play_href(self):
"""
DOM 兜底侦察:
当 Google Play 是图片按钮、无文本链接时,用 JS 从渲染后的 DOM 中提取跳转地址。
只用于兜底发现,不作为首选点击方式。
"""
return self.page.evaluate(
"""
() => {
const keywords = ['google play', 'play.google.com'];
const nodes = Array.from(document.querySelectorAll('a, button, [role="button"], img'));
for (const node of nodes) {
const text = [
node.innerText,
node.textContent,
node.getAttribute('aria-label'),
node.getAttribute('title'),
node.getAttribute('alt'),
node.getAttribute('href'),
node.getAttribute('src')
].filter(Boolean).join(' ').toLowerCase();
if (keywords.some(k => text.includes(k))) {
const link = node.closest('a');
if (link && link.href) {
return link.href;
}
if (node.href) {
return node.href;
}
}
}
return null;
}
"""
)
def click_google_play_and_get_redirect_url(self) -> str:
"""
点击 Google Play 下载入口,并返回跳转地址。
成功判定:
- 新开页面 URL
- 当前页面跳转后的 URL
- 或点击前已获取到 Google Play href
"""
try:
self.page.wait_for_load_state("networkidle", timeout=5000)
except Exception:
pass
google_play_href = self.discover_google_play_href()
google_play_locator = self.get_google_play_locator()
if google_play_locator is None and not google_play_href:
raise AssertionError(
"未找到 Google Play 下载入口。"
"请检查 Download the App 页面是否存在 Google Play 链接,"
"或补充稳定 selector例如 data-testid。"
)
context = self.page.context
before_pages = list(context.pages)
old_url = self.page.url
if google_play_locator is not None:
google_play_locator.scroll_into_view_if_needed()
google_play_locator.click()
else:
# 没有稳定可点击 locator 时,使用已发现 href 直接跳转。
# TODO: 页面增加稳定 selector 后,替换为 locator.click()
self.page.goto(google_play_href, wait_until="domcontentloaded")
self.page.wait_for_timeout(3000)
after_pages = list(context.pages)
new_pages = [p for p in after_pages if p not in before_pages]
if new_pages:
new_page = new_pages[-1]
new_page.wait_for_load_state("domcontentloaded")
try:
new_page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
pass
return new_page.url
if self.page.url != old_url:
return self.page.url
if google_play_href:
return google_play_href
raise AssertionError("点击 Google Play 后未获取到跳转地址")

View File

@@ -0,0 +1,57 @@
import os
from playwright.sync_api import Locator, Page, TimeoutError as PlaywrightTimeoutError
class BasePage:
"""Playwright 页面基类:封装通用等待、点击、截图等稳定操作。"""
def __init__(self, page: Page):
self.page = page
def goto(self, url: str, timeout: int = 60000) -> None:
self.page.goto(url, wait_until="domcontentloaded", timeout=timeout)
self.wait_for_network_idle()
def wait_for_network_idle(self, timeout: int = 30000) -> None:
try:
self.page.wait_for_load_state("networkidle", timeout=timeout)
except PlaywrightTimeoutError:
self.page.wait_for_load_state("domcontentloaded", timeout=timeout)
def wait_for_visible(self, locator: Locator, timeout: int = 10000) -> Locator:
locator.wait_for(state="visible", timeout=timeout)
return locator
def click_if_visible(self, locator: Locator, timeout: int = 3000) -> bool:
try:
if locator.first.is_visible(timeout=timeout):
locator.first.click()
self.wait_for_network_idle()
return True
except Exception:
return False
return False
def safe_click(self, locator: Locator, timeout: int = 10000) -> None:
self.wait_for_visible(locator, timeout=timeout)
locator.click()
self.wait_for_network_idle()
def screenshot(self, screenshot_dir: str, file_name: str, full_page: bool = True) -> str:
os.makedirs(screenshot_dir, exist_ok=True)
screenshot_path = os.path.join(screenshot_dir, file_name)
self.page.screenshot(path=screenshot_path, full_page=full_page)
return screenshot_path
def current_url(self) -> str:
return self.page.url
def title(self) -> str:
return self.page.title()
def visible_text_contains(self, keyword: str) -> bool:
try:
return self.page.get_by_text(keyword, exact=False).first.is_visible(timeout=3000)
except Exception:
return False

View File

@@ -0,0 +1,196 @@
import random
from typing import Dict, List
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError, expect
from dulizhan.test_case.Resource.UI.base_page import BasePage
class BlogPage(BasePage):
"""
Joyhub Blog/内容页页面对象。
说明:
- 页面侦察结果未发现明确的 Blog 导航入口;
- 已发现真实导航候选包含 Discover、Following
- 因此进入 blog/内容列表时优先尝试 Blog失败后使用 Discover 作为内容入口兜底。
"""
HOME_URL = "https://joyhub-website-frontend-test.best-envision.com/"
EXPECTED_HOME_TITLE = "Joyhub | Explore Sexual Health, Wellness, and Connection"
NAV_DISCOVER_LINK = "Discover"
def open_home_page(self) -> None:
self.goto(self.HOME_URL)
self.accept_cookie_if_present()
expect(self.page).to_have_title(self.EXPECTED_HOME_TITLE)
def enter_blog_page(self) -> str:
"""
进入 blog/内容页。
优先级:
1. Blog 链接:如果页面后续版本新增 Blog 导航,可直接命中;
2. Discover 链接:侦察结果中存在,作为当前可用内容入口;
3. /blog 直达:仅作为最后兜底,并在无法点击入口时使用。
"""
before_url = self.page.url
blog_locators = [
self.page.get_by_role("link", name="Blog"),
self.page.locator("a", has_text="Blog"),
self.page.get_by_role("link", name=self.NAV_DISCOVER_LINK),
self.page.locator("a", has_text=self.NAV_DISCOVER_LINK),
]
for locator in blog_locators:
try:
if locator.count() > 0 and locator.first.is_visible():
locator.first.click()
self.wait_for_page_ready()
return self.page.url
except Exception:
continue
fallback_blog_url = self.HOME_URL.rstrip("/") + "/blog"
self.goto(fallback_blog_url)
assert self.page.url != before_url or "/blog" in self.page.url.lower(), (
"未能进入 blog/内容页:页面未发现 Blog 入口Discover 入口也不可点击。"
)
return self.page.url
def _get_candidate_blog_links(self) -> List[Dict[str, str]]:
"""
从渲染后的页面中提取可点击的 blog/content 候选链接。
不硬编码未知 selector优先根据 href 语义筛选:
blog/article/post/discover/detail/content。
若无命中,则退化为 main 区域内可见链接,排除导航类链接。
"""
return self.page.evaluate(
"""
() => {
const navTexts = new Set([
'Home',
'Download the App',
'Rewards',
'Support',
'About Us',
'Discover',
'Following',
'Partnerships',
'FAQs',
'Login'
]);
const normalize = value => (value || '').replace(/\\s+/g, ' ').trim();
const links = Array.from(document.querySelectorAll('a[href]'))
.filter(a => {
const rect = a.getBoundingClientRect();
const style = window.getComputedStyle(a);
return rect.width > 0
&& rect.height > 0
&& style.visibility !== 'hidden'
&& style.display !== 'none';
})
.map((a, index) => {
const href = a.href || '';
const text = normalize(a.innerText || a.textContent || a.getAttribute('aria-label') || '');
const imgAlt = normalize(
Array.from(a.querySelectorAll('img'))
.map(img => img.alt)
.filter(Boolean)
.join(' ')
);
return {
index,
href,
text,
imgAlt,
pathname: (() => {
try { return new URL(href).pathname.toLowerCase(); }
catch(e) { return ''; }
})()
};
})
.filter(item => item.href && !item.href.startsWith('javascript:'));
const semanticLinks = links.filter(item =>
/blog|article|post|discover|detail|content|story/i.test(item.href)
&& !navTexts.has(item.text)
);
if (semanticLinks.length > 0) {
return semanticLinks;
}
return links.filter(item =>
!navTexts.has(item.text)
&& item.href !== window.location.href
&& !/#$/.test(item.href)
);
}
"""
)
def click_random_blog(self) -> Dict[str, str]:
"""
随机点击一个 blog/content 候选项。
点击后等待 URL 或页面内容变化。
"""
self.wait_for_page_ready()
candidates = self._get_candidate_blog_links()
assert candidates, (
"未找到可点击的 blog/content 候选链接。"
"页面侦察结果缺少明确 blog 卡片 selector请补充稳定定位如 data-testid='blog-card'"
)
candidate = random.choice(candidates)
before_url = self.page.url
locator = self.page.locator("a[href]").nth(candidate["index"])
locator.scroll_into_view_if_needed(timeout=5000)
try:
with self.page.expect_navigation(wait_until="domcontentloaded", timeout=8000):
locator.click()
except PlaywrightTimeoutError:
locator.click()
self.wait_for_page_ready()
after_url = self.page.url
assert after_url != before_url or self.page.locator("main, article, body").first.is_visible(), (
f"点击 blog/content 候选项后页面未出现有效跳转或内容区域。候选链接:{candidate['href']}"
)
return {
"clicked_href": candidate.get("href", ""),
"clicked_text": candidate.get("text") or candidate.get("imgAlt") or "",
"before_url": before_url,
"after_url": after_url,
}
def assert_blog_content_page_loaded(self) -> None:
"""
断言已进入 blog/content 内容页。
因页面缺少稳定详情页 selector这里采用内容区域可见的稳健断言。
"""
self.wait_for_page_ready()
content_locator = self.page.locator("article, main, [role='main'], body").first
expect(content_locator).to_be_visible(timeout=10000)
body_text = self.page.locator("body").inner_text(timeout=10000).strip()
assert len(body_text) > 0, "blog/content 页面 body 内容为空,疑似跳转失败或页面未渲染完成。"
def capture_blog_content_screenshot(self, screenshot_dir: str, case_key: str) -> str:
return self.screenshot(
screenshot_dir=screenshot_dir,
file_name=f"{case_key}_blog_content.png",
full_page=True,
)

View File

@@ -0,0 +1,200 @@
import random
import re
from typing import Dict, List
from playwright.sync_api import Page, TimeoutError as PlaywrightTimeoutError
from dulizhan.test_case.Resource.UI.base_page import BasePage
class NewsPage(BasePage):
"""Joyhub News 相关页面对象。"""
COOKIE_ACCEPT_TEXT_PATTERN = re.compile(r"^(Accepet|Accept|I Accept|Agree|Got it)$", re.I)
NEWS_LINK_TEXT_PATTERN = re.compile(r"^News$", re.I)
NEWS_ROUTE = "/news"
def __init__(self, page: Page, base_url: str):
super().__init__(page)
self.base_url = base_url.rstrip("/")
def open_home(self) -> None:
self.goto(self.base_url)
def accept_cookie_if_present(self) -> None:
accept_button = self.page.get_by_role("button", name=self.COOKIE_ACCEPT_TEXT_PATTERN)
self.click_if_visible(accept_button, timeout=3000)
def enter_news_page(self) -> None:
news_link = self.page.get_by_role("link", name=self.NEWS_LINK_TEXT_PATTERN)
try:
if news_link.first.is_visible(timeout=5000):
news_link.first.click()
self.wait_for_network_idle()
return
except Exception:
pass
self.goto(f"{self.base_url}{self.NEWS_ROUTE}")
def is_news_context(self) -> bool:
url = self.page.url.lower()
title = self.page.title().lower()
if "news" in url:
return True
if "news" in title:
return True
if self.visible_text_contains("News"):
return True
return False
def _collect_clickable_news_candidates(self) -> List[Dict[str, str]]:
script = """
() => {
const navTexts = new Set([
'home',
'download the app',
'rewards',
'support',
'about us',
'discover',
'following',
'partnerships',
'faqs',
'login'
]);
const currentUrl = new URL(window.location.href);
const anchors = Array.from(document.querySelectorAll('a[href]'));
function isVisible(el) {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style &&
style.visibility !== 'hidden' &&
style.display !== 'none' &&
rect.width > 0 &&
rect.height > 0;
}
function hasContentContainer(el) {
return !!el.closest(
'article, [class*="article" i], [class*="card" i], [class*="news" i], [class*="post" i], [class*="blog" i], [class*="item" i]'
);
}
function isBadProtocol(url) {
return ['javascript:', 'mailto:', 'tel:'].includes(url.protocol);
}
function isLikelySocial(url) {
const host = url.hostname.toLowerCase();
return [
'facebook.com',
'instagram.com',
'twitter.com',
'x.com',
'youtube.com',
'tiktok.com',
'linkedin.com'
].some(domain => host.includes(domain));
}
const candidates = [];
for (const a of anchors) {
if (!isVisible(a)) continue;
let url;
try {
url = new URL(a.href, window.location.origin);
} catch (e) {
continue;
}
if (isBadProtocol(url)) continue;
if (isLikelySocial(url)) continue;
if (url.href === currentUrl.href) continue;
const text = (a.innerText || a.textContent || '').trim().replace(/\\s+/g, ' ');
const textLower = text.toLowerCase();
const pathLower = url.pathname.toLowerCase();
if (navTexts.has(textLower)) continue;
if (url.pathname === '/' || url.pathname === '') continue;
const hrefLooksLikeContent =
/news|article|blog|post|detail|story/i.test(pathLower) &&
pathLower !== '/news';
const containerLooksLikeContent = hasContentContainer(a);
if (hrefLooksLikeContent || containerLooksLikeContent) {
candidates.push({
href: url.href,
text: text,
path: url.pathname,
reason: hrefLooksLikeContent ? 'href_content_pattern' : 'content_container'
});
}
}
const seen = new Set();
return candidates.filter(item => {
if (seen.has(item.href)) return false;
seen.add(item.href);
return true;
});
}
"""
return self.page.evaluate(script)
def click_random_news_item(self) -> Dict[str, str]:
self.wait_for_network_idle()
candidates = self._collect_clickable_news_candidates()
if not candidates:
raise AssertionError(
"未发现可点击的 news 内容候选。"
"请检查 News 页面是否加载成功,或为 news 卡片补充稳定 selector/data-testid。"
)
selected = random.choice(candidates)
href = selected["href"]
old_url = self.page.url
click_script = """
(targetHref) => {
const anchors = Array.from(document.querySelectorAll('a[href]'));
const target = anchors.find(a => {
try {
return new URL(a.href, window.location.origin).href === targetHref;
} catch (e) {
return false;
}
});
if (!target) {
throw new Error('Target news link not found: ' + targetHref);
}
target.scrollIntoView({block: 'center', inline: 'center'});
target.click();
}
"""
self.page.evaluate(click_script, href)
try:
self.page.wait_for_url(lambda url: str(url) != old_url, timeout=15000)
except PlaywrightTimeoutError:
pass
self.wait_for_network_idle()
return selected
def screenshot_news_content(self, screenshot_dir: str, file_name: str) -> str:
return self.screenshot(screenshot_dir=screenshot_dir, file_name=file_name, full_page=True)

View File

View File

@@ -0,0 +1,91 @@
from dulizhan.test_case.Resource.UI.base_page import BasePage
from dulizhan.test_case.Resource.UI.news_page import NewsPage
import os
import allure
import pytest
from playwright.sync_api import sync_playwright
from dulizhan.test_case.Resource.UI.news_page import NewsPage
CASE_INFO = {
"projectId": 1001,
"caseId": 2001,
"automationType": "ui",
"caseKey": "TC_dulizhan_ui_api_verify_001",
"moduleName": "news",
"productName": "",
"projectName": "dulizhan",
"caseName": "进入news页面随机点击news跳转到news内容后截图就认为用例执行成功",
"pageUrl": "https://joyhub-website-frontend-test.best-envision.com/",
"screenshotDir": r"C:\Users\a\smart-management-auto-test\dulizhan\screenshots",
}
@pytest.fixture(scope="session")
def browser():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
yield browser
browser.close()
@pytest.fixture()
def page(browser):
context = browser.new_context(
viewport={"width": 1440, "height": 900},
ignore_https_errors=True,
)
page = context.new_page()
yield page
context.close()
@allure.feature(CASE_INFO["projectName"])
@allure.story(CASE_INFO["moduleName"])
@allure.title(CASE_INFO["caseName"])
def test_random_click_news_and_capture_content(page):
screenshot_dir = CASE_INFO["screenshotDir"]
os.makedirs(screenshot_dir, exist_ok=True)
news_page = NewsPage(page)
with allure.step("打开被测页面"):
news_page.open_home_page()
news_page.accept_cookie_if_present()
allure.attach(
news_page.current_url(),
name="首页 URL",
attachment_type=allure.attachment_type.TEXT,
)
with allure.step("进入 news 页面"):
news_page.enter_news_page()
allure.attach(
news_page.current_url(),
name="News 页面 URL",
attachment_type=allure.attachment_type.TEXT,
)
with allure.step("随机点击 news"):
selected_news = news_page.click_random_news()
allure.attach(
str(selected_news),
name="随机点击的 news 候选信息",
attachment_type=allure.attachment_type.TEXT,
)
with allure.step("截图跳转的 news 内容"):
news_page.assert_news_content_loaded()
screenshot_path = news_page.capture_news_content_screenshot(
screenshot_dir=screenshot_dir,
case_key=CASE_INFO["caseKey"],
)
allure.attach.file(
screenshot_path,
name="跳转后的 news 内容截图",
attachment_type=allure.attachment_type.PNG,
)
assert os.path.exists(screenshot_path), f"news 内容截图未生成: {screenshot_path}"

View File

@@ -0,0 +1,80 @@
from dulizhan.test_case.Resource.UI.base_page import BasePage
from dulizhan.test_case.Resource.UI.blog_page import BlogPage
from types import SimpleNamespace
import allure
import pytest
from playwright.sync_api import sync_playwright
from dulizhan.test_case.Resource.UI.blog_page import BlogPage
case_info = SimpleNamespace(
projectId=1001,
caseId=2001,
automationType="ui",
caseKey="TC_dulizhan_ui_api_verify_001",
moduleName="blog",
productName="",
projectName="dulizhan",
caseName="进入blog页面随机点击blog跳转到blog内容后截图就认为用例执行成功",
screenshotDir=r"C:\Users\a\smart-management-auto-test\dulizhan\screenshots",
pageUrl="https://joyhub-website-frontend-test.best-envision.com/",
)
@pytest.fixture(scope="function")
def page():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={"width": 1440, "height": 900},
ignore_https_errors=True,
)
page = context.new_page()
yield page
context.close()
browser.close()
@allure.feature(case_info.projectName)
@allure.story(case_info.moduleName)
@allure.title(case_info.caseName)
def test_enter_blog_random_click_and_capture_content(page):
blog_page = BlogPage(page)
with allure.step("打开被测页面"):
blog_page.open_home_page()
assert page.url.startswith(case_info.pageUrl)
with allure.step("进入blog页面"):
entered_url = blog_page.enter_blog_page()
allure.attach(
entered_url,
name="进入blog页面后的URL",
attachment_type=allure.attachment_type.TEXT,
)
with allure.step("随机点击blog"):
click_result = blog_page.click_random_blog()
allure.attach(
str(click_result),
name="随机点击blog结果",
attachment_type=allure.attachment_type.TEXT,
)
with allure.step("截图跳转的blog内容"):
blog_page.assert_blog_content_page_loaded()
screenshot_path = blog_page.capture_blog_content_screenshot(
screenshot_dir=case_info.screenshotDir,
case_key=case_info.caseKey,
)
with open(screenshot_path, "rb") as image_file:
allure.attach(
image_file.read(),
name="blog内容页截图",
attachment_type=allure.attachment_type.PNG,
)
assert screenshot_path, "blog 内容页截图路径为空"

View File

@@ -0,0 +1,90 @@
from dulizhan.test_case.Resource.UI.base_page import BasePage
from dulizhan.test_case.Resource.UI.news_page import NewsPage
from types import SimpleNamespace
import allure
import pytest
from playwright.sync_api import sync_playwright
from dulizhan.test_case.Resource.UI.news_page import NewsPage
CASE_INFO = SimpleNamespace(
projectId=1001,
caseId=2001,
automationType="ui",
caseKey="TC_dulizhan_ui_api_verify_001",
moduleName="news",
productName="",
projectName="dulizhan",
caseName="进入news页面随机点击news跳转到news内容后截图就认为用例执行成功",
pageUrl="https://joyhub-website-frontend-test.best-envision.com/",
screenshotDir=r"C:\Users\a\smart-management-auto-test\dulizhan\screenshots",
)
@pytest.fixture(scope="session")
def browser():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
yield browser
browser.close()
@pytest.fixture()
def page(browser):
page = browser.new_page(
viewport={"width": 1440, "height": 1200},
ignore_https_errors=True,
)
yield page
page.close()
@pytest.fixture()
def case_info():
return CASE_INFO
@allure.feature(CASE_INFO.projectName)
@allure.story(CASE_INFO.moduleName)
@allure.title(CASE_INFO.caseName)
@pytest.mark.ui
def test_enter_news_random_click_and_screenshot(page, case_info):
news_page = NewsPage(page, case_info.pageUrl)
with allure.step("打开被测页面"):
news_page.open_home()
news_page.accept_cookie_if_present()
assert "Joyhub" in news_page.title(), f"首页标题不符合预期,当前标题:{news_page.title()}"
with allure.step("进入news页面"):
news_page.enter_news_page()
assert news_page.is_news_context(), (
f"未确认进入 news 页面上下文当前URL{page.url},当前标题:{page.title()}"
"侦察结果未提供明确 News 导航 selector如实际路由不是 /news请调整 NewsPage.NEWS_ROUTE。"
)
with allure.step("随机点击news"):
before_click_url = page.url
selected_news = news_page.click_random_news_item()
allure.attach(
str(selected_news),
name="随机点击的 news 候选",
attachment_type=allure.attachment_type.TEXT,
)
assert page.url != before_click_url or selected_news.get("href"), (
f"点击 news 后页面未发生有效跳转点击前URL{before_click_url}点击后URL{page.url}"
)
with allure.step("截图跳转的news内容"):
screenshot_path = news_page.screenshot_news_content(
screenshot_dir=case_info.screenshotDir,
file_name=f"{case_info.caseKey}_news_content.png",
)
allure.attach.file(
screenshot_path,
name="跳转后的news内容截图",
attachment_type=allure.attachment_type.PNG,
)
assert screenshot_path, "news 内容截图保存失败"