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:
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user