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