refactor: SmartLocator with __getattr__ proxying, integrate into BasePage

Ultraworked with Sisyphus(https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-15 20:26:43 +03:00
parent e62b43b46c
commit a2f73017eb
2 changed files with 839 additions and 477 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,14 @@
"""Page Object Model base classes for UI testing. """Page Object Model base classes for UI testing.
Provides both synchronous (:class:`BasePage`) and asynchronous Provides both synchronous (:class:`BasePage`) and asynchronous
(:class:`AsyncBasePage`) base page objects. (:class:`AsyncBasePage`) base page objects with SmartLocator integration.
""" """
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Self
from pytfm.web.locator import AsyncSmartLocator, SmartLocator
if TYPE_CHECKING: if TYPE_CHECKING:
from playwright.async_api import Page as AsyncPage from playwright.async_api import Page as AsyncPage
@@ -16,80 +18,236 @@ if TYPE_CHECKING:
class BasePage: class BasePage:
"""Synchronous base class for all page objects. """Synchronous base class for all page objects.
This is the **default** implementation. For async tests use Uses :class:`SmartLocator` internally for all element access.
:class:`AsyncBasePage`. The :meth:`locator` method (aliased as :meth:`loc`) provides a shorthand
for creating ``data-testid``-based locators bound to the current page.
Example:
>>> class LoginPage(BasePage):
... path = "/login"
... def login(self, user: str, pwd: str) -> None:
... self.loc("username").fill(user)
... self.loc("password").fill(pwd)
... self.loc("submit").click()
""" """
path: str = "" path: str = ""
def __init__(self, page: "SyncPage", base_url: str) -> None: def __init__(self, page: SyncPage, base_url: str) -> None:
"""Initialize the page object.
Args:
page: Playwright sync ``Page`` instance.
base_url: Base URL of the application.
"""
self.page = page self.page = page
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
@property @property
def url(self) -> str: def url(self) -> str:
"""Full page URL composed from ``base_url`` and ``path``."""
return f"{self.base_url}{self.path}" return f"{self.base_url}{self.path}"
def open(self) -> BasePage: def locator(
self,
testid: str,
timeout: int = 5000,
visible_only: bool = True,
) -> SmartLocator:
"""Create a :class:`SmartLocator` by ``data-testid`` bound to this page.
Args:
testid: The ``data-testid`` value to locate.
timeout: Default wait timeout in milliseconds.
visible_only: Only match visible elements.
Returns:
SmartLocator bound to this page for chaining.
"""
return SmartLocator.by_testid(
testid, timeout=timeout, visible_only=visible_only
).with_page(self.page)
loc = locator
def open(self) -> Self:
"""Navigate to the page URL and return self for chaining.
Returns:
Self for method chaining.
"""
self.page.goto(self.url) self.page.goto(self.url)
return self return self
def wait_for_element(self, selector: str, timeout: int = 5000) -> None: def wait_for_element(
self.page.wait_for_selector(selector, state="visible", timeout=timeout) self,
testid: str,
timeout: int | None = None,
) -> None:
"""Wait for an element to become visible.
def click(self, selector: str) -> None: Args:
self.page.click(selector) testid: The ``data-testid`` of the element.
timeout: Timeout in milliseconds. Defaults to 5000.
"""
self.locator(testid, timeout=timeout or 5000).wait_for(state="visible")
def fill(self, selector: str, value: str) -> None: def click(self, testid: str) -> None:
self.page.fill(selector, value) """Click an element.
def get_text(self, selector: str) -> str: Args:
element = self.page.wait_for_selector(selector) testid: The ``data-testid`` of the element.
if element: """
return element.text_content() or "" self.locator(testid).click()
return ""
def is_visible(self, selector: str) -> bool: def fill(self, testid: str, value: str) -> None:
element = self.page.query_selector(selector) """Fill an element with text.
if element:
return element.is_visible() Args:
return False testid: The ``data-testid`` of the element.
value: Text to fill.
"""
self.locator(testid).fill(value)
def get_text(self, testid: str) -> str:
"""Get the text content of an element.
Args:
testid: The ``data-testid`` of the element.
Returns:
Text content or empty string.
"""
text = self.locator(testid).text_content()
return text if text is not None else ""
def is_visible(self, testid: str) -> bool:
"""Check if an element is visible.
Args:
testid: The ``data-testid`` of the element.
Returns:
``True`` if the element is visible.
"""
return self.locator(testid)._get_locator().is_visible()
class AsyncBasePage: class AsyncBasePage:
"""Asynchronous base class for page objects using ``playwright.async_api``.""" """Asynchronous base class for page objects using ``playwright.async_api``.
Uses :class:`AsyncSmartLocator` internally for all element access.
The :meth:`locator` method (aliased as :meth:`loc`) provides an async-safe
shorthand for creating ``data-testid``-based locators bound to the page.
Example:
>>> class LoginPage(AsyncBasePage):
... path = "/login"
... async def login(self, user: str, pwd: str) -> None:
... await self.loc("username").fill(user)
... await self.loc("password").fill(pwd)
... await self.loc("submit").click()
"""
path: str = "" path: str = ""
def __init__(self, page: "AsyncPage", base_url: str) -> None: def __init__(self, page: AsyncPage, base_url: str) -> None:
"""Initialize the async page object.
Args:
page: Playwright async ``Page`` instance.
base_url: Base URL of the application.
"""
self.page = page self.page = page
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
@property @property
def url(self) -> str: def url(self) -> str:
"""Full page URL composed from ``base_url`` and ``path``."""
return f"{self.base_url}{self.path}" return f"{self.base_url}{self.path}"
async def open(self) -> AsyncBasePage: def locator(
self,
testid: str,
timeout: int = 5000,
visible_only: bool = True,
) -> AsyncSmartLocator:
"""Create an :class:`AsyncSmartLocator` by ``data-testid`` bound to this page.
Args:
testid: The ``data-testid`` value to locate.
timeout: Default wait timeout in milliseconds.
visible_only: Only match visible elements.
Returns:
AsyncSmartLocator bound to this page.
"""
return AsyncSmartLocator.by_testid(
testid, timeout=timeout, visible_only=visible_only
).with_page(self.page)
loc = locator
async def open(self) -> Self:
"""Navigate to the page URL and return self for chaining.
Returns:
Self for method chaining.
"""
await self.page.goto(self.url) await self.page.goto(self.url)
return self return self
async def wait_for_element(self, selector: str, timeout: int = 5000) -> None: async def wait_for_element(
await self.page.wait_for_selector(selector, state="visible", timeout=timeout) self,
testid: str,
timeout: int | None = None,
) -> None:
"""Wait for an element to become visible.
async def click(self, selector: str) -> None: Args:
await self.page.click(selector) testid: The ``data-testid`` of the element.
timeout: Timeout in milliseconds. Defaults to 5000.
"""
await self.locator(
testid, timeout=timeout or 5000
).wait_for(state="visible")
async def fill(self, selector: str, value: str) -> None: async def click(self, testid: str) -> None:
await self.page.fill(selector, value) """Click an element.
async def get_text(self, selector: str) -> str: Args:
element = await self.page.wait_for_selector(selector) testid: The ``data-testid`` of the element.
if element: """
return await element.text_content() or "" await self.locator(testid).click()
return ""
async def is_visible(self, selector: str) -> bool: async def fill(self, testid: str, value: str) -> None:
element = await self.page.query_selector(selector) """Fill an element with text.
if element:
return await element.is_visible() Args:
return False testid: The ``data-testid`` of the element.
value: Text to fill.
"""
await self.locator(testid).fill(value)
async def get_text(self, testid: str) -> str:
"""Get the text content of an element.
Args:
testid: The ``data-testid`` of the element.
Returns:
Text content or empty string.
"""
text = await self.locator(testid).text_content()
return text if text is not None else ""
async def is_visible(self, testid: str) -> bool:
"""Check if an element is visible.
Args:
testid: The ``data-testid`` of the element.
Returns:
``True`` if the element is visible.
"""
return await self.locator(testid)._get_locator().is_visible()