From a2f73017ebb606c42a6fc2a19679f423d46be2f3 Mon Sep 17 00:00:00 2001 From: Sergey Vanyushkin Date: Fri, 15 May 2026 20:26:43 +0300 Subject: [PATCH] refactor: SmartLocator with __getattr__ proxying, integrate into BasePage Ultraworked with Sisyphus(https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/pytfm/web/locator.py | 1076 +++++++++++++++++++++++--------------- src/pytfm/web/pom.py | 240 +++++++-- 2 files changed, 839 insertions(+), 477 deletions(-) diff --git a/src/pytfm/web/locator.py b/src/pytfm/web/locator.py index eac622a..0df48a2 100644 --- a/src/pytfm/web/locator.py +++ b/src/pytfm/web/locator.py @@ -1,13 +1,14 @@ """Smart locator module for Playwright-based UI testing. Provides both synchronous (:class:`SmartLocator`) and asynchronous -(:class:`AsyncSmartLocator`) variants for maximum flexibility. +(:class:`AsyncSmartLocator`) variants with ``__getattr__`` proxying to +Playwright's ``Locator``, eliminating manual method wrappers. """ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal, Self +from typing import TYPE_CHECKING, Any, Literal, Self if TYPE_CHECKING: from playwright.async_api import Locator as AsyncLocator @@ -18,7 +19,15 @@ if TYPE_CHECKING: @dataclass(frozen=True) class LocatorConfig: - """Configuration for locator search strategy.""" + """Configuration for locator search strategy. + + Attributes: + selector: The selector string to locate elements. + strategy: How to interpret the selector: + ``css``, ``xpath``, ``text``, ``testid``, or ``role``. + timeout: Default wait timeout in milliseconds. + visible_only: Whether to only match visible elements. + """ selector: str strategy: Literal["css", "xpath", "text", "testid", "role"] = "testid" @@ -26,9 +35,18 @@ class LocatorConfig: visible_only: bool = True def to_playwright_selector(self) -> str: - """Convert to Playwright-compatible selector string.""" + """Convert to a Playwright-compatible selector string. + + Returns: + Selector usable with ``page.locator()``. + + Example: + >>> LocatorConfig("submit", strategy="testid").to_playwright_selector() + '[data-testid="submit"]' + """ if self.strategy == "testid": - return f'[data-testid="{self.selector}"]' + safe = self.selector.replace('"', '\\"') + return f'[data-testid="{safe}"]' if self.strategy == "css": return self.selector if self.strategy == "xpath": @@ -45,28 +63,110 @@ class LocatorConfig: return self.selector -class SmartLocator: - """Synchronous enhanced locator with waiting and assertion capabilities. +def _escape(value: str) -> str: + """Escape special characters for CSS attribute selectors. - This is the **default** sync implementation. For async tests use - :class:`AsyncSmartLocator`. + Args: + value: Raw string value. + + Returns: + String safe inside ``[data-testid="..."]``. + """ + return value.replace("\\", "\\\\").replace('"', '\\"') + + +class SmartLocator: + """Synchronous locator that proxies to a Playwright ``Locator``. + + Factory methods create instances with a given :class:`LocatorConfig`. + Any method not defined on ``SmartLocator`` is proxied to the + underlying Playwright ``Locator`` via :meth:`__getattr__`. + + The optional ``page`` kwarg can be passed to any proxied or compound + method to override the page for that single call. + + Example: + >>> loc = SmartLocator.by_testid("btn-submit") + >>> loc.click(page=page) # proxied to PW Locator + >>> loc.fill("hello", page=page) # proxied + >>> loc.wait_for(state="visible") # requires page set via with_page() """ - def __init__(self, config: LocatorConfig, page: "SyncPage" | None = None) -> None: + def __init__( + self, + config: LocatorConfig, + page: SyncPage | None = None, + ) -> None: + """Initialize the locator. + + Args: + config: Search strategy configuration. + page: Optional Playwright sync ``Page`` to associate with. + """ self._config = config self._page = page - self._selector = config.to_playwright_selector() @property def selector(self) -> str: - return self._selector + """Playwright-compatible selector string from :class:`LocatorConfig`.""" + return self._config.to_playwright_selector() def __str__(self) -> str: - return self._selector + return self.selector def __repr__(self) -> str: return f"SmartLocator({self._config.strategy}='{self._config.selector}')" + def _get_locator(self, page: SyncPage | None = None) -> SyncLocator: + """Resolve a Playwright ``Locator``, optionally with an override page. + + Args: + page: Override page. Falls back to ``self._page``. + + Returns: + Playwright ``Locator``. + + Raises: + RuntimeError: If no page is available. + """ + target = page or self._page + if not target: + raise RuntimeError( + "No page attached. Pass a page= argument or use with_page()." + ) + return target.locator(self.selector) + + def __getattr__(self, name: str) -> Any: + """Proxy attribute access to the underlying Playwright ``Locator``. + + Returns a callable wrapper that resolves the locator lazily, + allowing a ``page`` kwarg to be passed per-call. + + Args: + name: Attribute name. + + Returns: + Callable proxy to the Playwright ``Locator`` method. + + Raises: + AttributeError: If the attribute is private or not found on + the Playwright ``Locator``. + """ + if name.startswith("_"): + raise AttributeError(name) + + def _proxy(*args: Any, **kwargs: Any) -> Any: + page = kwargs.pop("page", None) + locator = self._get_locator(page) + attr = getattr(locator, name, None) + if attr is None: + raise AttributeError( + f"'{type(self).__name__}' has no attribute '{name}'" + ) + return attr(*args, **kwargs) + + return _proxy + # ============ Factory Methods ============ @classmethod @@ -76,10 +176,12 @@ class SmartLocator: timeout: int = 5000, visible_only: bool = True, ) -> Self: - config = LocatorConfig( - selector=testid, strategy="testid", timeout=timeout, visible_only=visible_only + """Target ``[data-testid=...]``.""" + return cls( + LocatorConfig( + selector=testid, strategy="testid", timeout=timeout, visible_only=visible_only + ) ) - return cls(config) @classmethod def by_css( @@ -88,10 +190,12 @@ class SmartLocator: timeout: int = 5000, visible_only: bool = True, ) -> Self: - config = LocatorConfig( - selector=selector, strategy="css", timeout=timeout, visible_only=visible_only + """Target with a CSS selector.""" + return cls( + LocatorConfig( + selector=selector, strategy="css", timeout=timeout, visible_only=visible_only + ) ) - return cls(config) @classmethod def by_xpath( @@ -100,10 +204,12 @@ class SmartLocator: timeout: int = 5000, visible_only: bool = True, ) -> Self: - config = LocatorConfig( - selector=xpath, strategy="xpath", timeout=timeout, visible_only=visible_only + """Target with an XPath expression.""" + return cls( + LocatorConfig( + selector=xpath, strategy="xpath", timeout=timeout, visible_only=visible_only + ) ) - return cls(config) @classmethod def by_text( @@ -112,10 +218,12 @@ class SmartLocator: timeout: int = 5000, visible_only: bool = True, ) -> Self: - config = LocatorConfig( - selector=text, strategy="text", timeout=timeout, visible_only=visible_only + """Target by text content.""" + return cls( + LocatorConfig( + selector=text, strategy="text", timeout=timeout, visible_only=visible_only + ) ) - return cls(config) @classmethod def by_role( @@ -125,297 +233,422 @@ class SmartLocator: timeout: int = 5000, visible_only: bool = True, ) -> Self: + """Target by ARIA role with optional accessible name.""" selector = f"{role}:{name}" if name else role - config = LocatorConfig( - selector=selector, strategy="role", timeout=timeout, visible_only=visible_only + return cls( + LocatorConfig( + selector=selector, strategy="role", timeout=timeout, visible_only=visible_only + ) ) - return cls(config) # ============ Page Association ============ - def with_page(self, page: "SyncPage") -> Self: + def with_page(self, page: SyncPage) -> Self: + """Bind this locator to a specific page. + + Args: + page: Playwright sync ``Page``. + + Returns: + New ``SmartLocator`` with the page attached. + """ return self.__class__(self._config, page) - def _get_locator(self, page: "SyncPage" | None = None) -> "SyncLocator": - target_page = page or self._page - if not target_page: - raise RuntimeError("Page not attached to locator") - return target_page.locator(self._selector) - - def _get_page(self, page: "SyncPage" | None = None) -> "SyncPage": - target_page = page or self._page - if not target_page: - raise RuntimeError("Page not attached to locator") - return target_page - - # ============ Wait Methods ============ - - def wait_for_visible(self, page: "SyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - loc.wait_for(state="visible", timeout=timeout or self._config.timeout) - - def wait_for_hidden(self, page: "SyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - loc.wait_for(state="hidden", timeout=timeout or self._config.timeout) - - def wait_for_enabled(self, page: "SyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - loc.wait_for(state="visible", timeout=timeout or self._config.timeout) - target_page = page or self._page - if target_page: - target_page.wait_for_function( - f"!document.querySelector('{self._selector}').disabled", - timeout=timeout or self._config.timeout, - ) - - def wait_for_disabled(self, page: "SyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - loc.wait_for(state="visible", timeout=timeout or self._config.timeout) - - # ============ Action Methods ============ - - def click(self, page: "SyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - loc.click(timeout=timeout or self._config.timeout) + # ============ Compound Action Methods ============ def click_and_wait( self, - page: "SyncPage" | None = None, + page: SyncPage | None = None, target_testid: str | None = None, navigation: bool = False, timeout: int | None = None, ) -> None: - target_page = self._get_page(page) + """Click and wait for navigation or a target element. + + Args: + page: Override page. Falls back to ``self._page``. + target_testid: Wait for this ``data-testid`` after clicking. + navigation: When ``True``, wrap the click in + :meth:`Page.expect_navigation`. + timeout: Timeout in milliseconds. Defaults to config timeout. + + Raises: + playwright.sync_api.TimeoutError: On navigation or element + wait timeout. + """ + locator = self._get_locator(page) timeout_val = timeout or self._config.timeout if navigation: - try: - with target_page.expect_navigation( - timeout=timeout_val * 2, - wait_until="networkidle", - ): - target_page.click(self._selector) - except TimeoutError as e: - raise TimeoutError( - f"Navigation timeout after clicking '{self._selector}'. " - f"Current URL: {target_page.url}" - ) from e + page_obj = page or self._page + if page_obj is None: + raise RuntimeError("No page available for navigation") + with page_obj.expect_navigation( + timeout=timeout_val * 2, + wait_until="load", + ): + locator.click(timeout=timeout_val) else: - target_page.click(self._selector) + locator.click(timeout=timeout_val) if target_testid: - target_loc = SmartLocator.by_testid(target_testid) - try: - target_loc.wait_for_visible(target_page, timeout=timeout_val) - except TimeoutError as e: - raise TimeoutError( - f"Element with testid '{target_testid}' not visible after " - f"clicking '{self._selector}'. Current URL: {target_page.url}" - ) from e - - def fill( - self, page: "SyncPage" | None = None, value: str = "", timeout: int | None = None - ) -> None: - loc = self._get_locator(page) - loc.fill(value, timeout=timeout or self._config.timeout) + target_loc = self.__class__.by_testid(target_testid) + target_loc.wait_for(state="visible", page=page, timeout=timeout_val) def fill_and_submit( self, - page: "SyncPage" | None = None, + page: SyncPage | None = None, value: str = "", submit_testid: str | None = None, timeout: int | None = None, ) -> None: - target_page = self._get_page(page) - timeout_val = timeout or self._config.timeout + """Fill a field and submit the form. - self.fill(target_page, value, timeout_val) + Args: + page: Override page. Falls back to ``self._page``. + value: Text to fill. + submit_testid: If set, click this submit button instead of + pressing Enter. + timeout: Timeout in milliseconds. Defaults to config timeout. + """ + locator = self._get_locator(page) + timeout_val = timeout or self._config.timeout + locator.fill(value, timeout=timeout_val) if submit_testid: - submit_btn = SmartLocator.by_testid(submit_testid) - submit_btn.click(target_page, timeout_val) + submit_loc = self.__class__.by_testid(submit_testid) + submit_loc.click(page=page, timeout=timeout_val) else: - target_page.press(self._selector, "Enter") + locator.press("Enter") - def clear(self, page: "SyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - loc.clear(timeout=timeout or self._config.timeout) + # ============ Wait Helpers ============ - def select_option( - self, page: "SyncPage" | None = None, value: str = "", timeout: int | None = None + def wait_for_enabled( + self, + page: SyncPage | None = None, + timeout: int | None = None, ) -> None: - loc = self._get_locator(page) - loc.select_option(value, timeout=timeout or self._config.timeout) + """Wait for the element to become visible and enabled. - def check(self, page: "SyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - loc.check(timeout=timeout or self._config.timeout) + Uses Playwright's ``expect(locator).to_be_enabled()`` internally, + which works with **all** selector strategies (not just CSS). - def uncheck(self, page: "SyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - loc.uncheck(timeout=timeout or self._config.timeout) + Args: + page: Override page. Falls back to ``self._page``. + timeout: Timeout in milliseconds. Defaults to config timeout. + """ + from playwright.sync_api import expect - def hover(self, page: "SyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - loc.hover(timeout=timeout or self._config.timeout) + locator = self._get_locator(page) + timeout_val = timeout or self._config.timeout + expect(locator).to_be_enabled(timeout=timeout_val) - def scroll_into_view(self, page: "SyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - loc.scroll_into_view_if_needed(timeout=timeout or self._config.timeout) + def wait_for_disabled( + self, + page: SyncPage | None = None, + timeout: int | None = None, + ) -> None: + """Wait for the element to become visible and disabled. - # ============ Query Methods ============ + Uses Playwright's ``expect(locator).to_be_disabled()`` internally, + which works with **all** selector strategies (not just CSS). - def get_text(self, page: "SyncPage" | None = None) -> str: - loc = self._get_locator(page) - text = loc.text_content() - return text or "" + Args: + page: Override page. Falls back to ``self._page``. + timeout: Timeout in milliseconds. Defaults to config timeout. + """ + from playwright.sync_api import expect - def get_input_value(self, page: "SyncPage" | None = None) -> str: - loc = self._get_locator(page) - val = loc.input_value() - return val or "" + locator = self._get_locator(page) + timeout_val = timeout or self._config.timeout + expect(locator).to_be_disabled(timeout=timeout_val) - def get_attribute(self, name: str, page: "SyncPage" | None = None) -> str | None: - loc = self._get_locator(page) - return loc.get_attribute(name) + # ============ Selector Composition ============ - def count(self, page: "SyncPage" | None = None) -> int: - loc = self._get_locator(page) - return loc.count() + def nth(self, index: int) -> Self: + """Target the N-th matching element (zero-based). - def is_visible(self, page: "SyncPage" | None = None) -> bool: - loc = self._get_locator(page) - return loc.is_visible() + Uses Playwright ``>> nth=`` chaining, compatible with all + selector strategies. - def is_enabled(self, page: "SyncPage" | None = None) -> bool: - loc = self._get_locator(page) - return loc.is_enabled() + Args: + index: Zero-based index. - def is_checked(self, page: "SyncPage" | None = None) -> bool: - loc = self._get_locator(page) - return loc.is_checked() + Returns: + New ``SmartLocator`` for the indexed element. + """ + new_selector = f"{self.selector} >> nth={index}" + return self.__class__( + LocatorConfig( + selector=new_selector, + strategy="css", + timeout=self._config.timeout, + visible_only=self._config.visible_only, + ), + self._page, + ) - # ============ Assertion Methods ============ + def first(self) -> Self: + """Target the first matching element.""" + return self.nth(0) - def assert_visible(self, page: "SyncPage" | None = None, visible: bool = True) -> None: - is_vis = self.is_visible(page) - assert is_vis == visible, f"Expected visible={visible}, got {is_vis}" + def last(self) -> Self: + """Target the last matching element.""" + return self.nth(-1) + + def filter(self, filter_testid: str) -> Self: + """Narrow to elements that contain a descendant with the given testid. + + Uses Playwright ``>> css=:has()`` chaining, compatible with all + selector strategies. + + Args: + filter_testid: The ``data-testid`` a descendant must have. + + Returns: + New ``SmartLocator`` filtered by the descendant. + """ + safe = _escape(filter_testid) + new_selector = f'{self.selector} >> css=:has([data-testid="{safe}"])' + return self.__class__( + LocatorConfig( + selector=new_selector, + strategy="css", + timeout=self._config.timeout, + visible_only=self._config.visible_only, + ), + self._page, + ) + + def child(self, child_testid: str) -> Self: + """Target a descendant element by ``data-testid``. + + Uses Playwright ``>>`` chaining, compatible with all selector + strategies. + + Args: + child_testid: The ``data-testid`` of the descendant. + + Returns: + New ``SmartLocator`` for the descendant. + """ + safe = _escape(child_testid) + new_selector = f'{self.selector} >> [data-testid="{safe}"]' + return self.__class__( + LocatorConfig( + selector=new_selector, + strategy="css", + timeout=self._config.timeout, + visible_only=self._config.visible_only, + ), + self._page, + ) + + def parent(self) -> Self: + """Target the parent element using Playwright ``>> ..`` chaining. + + Returns: + New ``SmartLocator`` for the parent. + """ + new_selector = f"{self.selector} >> .." + return self.__class__( + LocatorConfig( + selector=new_selector, + strategy="css", + timeout=self._config.timeout, + visible_only=self._config.visible_only, + ), + self._page, + ) + + # ============ Assertion Helpers ============ + + def assert_visible( + self, + page: SyncPage | None = None, + visible: bool = True, + ) -> None: + """Assert the element's visibility state. + + Args: + page: Override page. Falls back to ``self._page``. + visible: ``True`` = must be visible, ``False`` = must be hidden. + + Raises: + AssertionError: If the visibility does not match. + """ + locator = self._get_locator(page) + is_vis = locator.is_visible() + if is_vis != visible: + raise AssertionError( + f"Expected visible={visible}, got visible={is_vis}" + ) def assert_text( self, expected: str, - page: "SyncPage" | None = None, + page: SyncPage | None = None, case_sensitive: bool = True, ) -> None: - actual = self.get_text(page) + """Assert the element's exact text content. + + Args: + expected: Expected text. + page: Override page. Falls back to ``self._page``. + case_sensitive: Compare case-sensitively. + + Raises: + AssertionError: If the text does not match. + """ + locator = self._get_locator(page) + actual = locator.text_content() or "" if not case_sensitive: actual = actual.lower() expected = expected.lower() - assert actual == expected, f"Expected '{expected}', got '{actual}'" + if actual != expected: + raise AssertionError(f"Expected text '{expected}', got '{actual}'") def assert_contains_text( self, expected: str, - page: "SyncPage" | None = None, + page: SyncPage | None = None, case_sensitive: bool = True, ) -> None: - actual = self.get_text(page) + """Assert the element's text contains a substring. + + Args: + expected: Expected substring. + page: Override page. Falls back to ``self._page``. + case_sensitive: Compare case-sensitively. + + Raises: + AssertionError: If the substring is not found. + """ + locator = self._get_locator(page) + actual = locator.text_content() or "" if not case_sensitive: actual = actual.lower() expected = expected.lower() - assert expected in actual, f"Expected '{expected}' in '{actual}'" + if expected not in actual: + raise AssertionError( + f"Expected substring '{expected}' not found in '{actual}'" + ) - def assert_count(self, expected: int, page: "SyncPage" | None = None) -> None: - actual = self.count(page) - assert actual == expected, f"Expected {expected} elements, got {actual}" + def assert_count(self, expected: int, page: SyncPage | None = None) -> None: + """Assert the number of matching elements. + + Args: + expected: Expected count. + page: Override page. Falls back to ``self._page``. + + Raises: + AssertionError: If the count does not match. + """ + locator = self._get_locator(page) + actual = locator.count() + if actual != expected: + raise AssertionError(f"Expected {expected} elements, got {actual}") def assert_attribute( self, name: str, expected: str, - page: "SyncPage" | None = None, + page: SyncPage | None = None, ) -> None: - actual = self.get_attribute(name, page) - assert actual == expected, f"Expected attr '{name}'='{expected}', got '{actual}'" + """Assert an attribute value on the element. - # ============ Navigation Methods ============ + Args: + name: Attribute name. + expected: Expected value. + page: Override page. Falls back to ``self._page``. - def nth(self, index: int) -> Self: - new_selector = f"{self._selector} >> nth={index}" - new_config = LocatorConfig( - selector=new_selector, - strategy="css", - timeout=self._config.timeout, - visible_only=self._config.visible_only, - ) - return self.__class__(new_config, self._page) - - def first(self) -> Self: - return self.nth(0) - - def last(self) -> Self: - new_selector = f"{self._selector} >> nth=-1" - new_config = LocatorConfig( - selector=new_selector, - strategy="css", - timeout=self._config.timeout, - visible_only=self._config.visible_only, - ) - return self.__class__(new_config, self._page) - - def filter(self, filter_testid: str) -> Self: - new_selector = f"{self._selector}:has([data-testid='{filter_testid}'])" - new_config = LocatorConfig( - selector=new_selector, - strategy="css", - timeout=self._config.timeout, - visible_only=self._config.visible_only, - ) - return self.__class__(new_config, self._page) - - def child(self, child_testid: str) -> Self: - new_selector = f"{self._selector} [data-testid='{child_testid}']" - new_config = LocatorConfig( - selector=new_selector, - strategy="css", - timeout=self._config.timeout, - visible_only=self._config.visible_only, - ) - return self.__class__(new_config, self._page) - - def parent(self) -> Self: - new_selector = f"{self._selector} >> .." - new_config = LocatorConfig( - selector=new_selector, - strategy="css", - timeout=self._config.timeout, - visible_only=self._config.visible_only, - ) - return self.__class__(new_config, self._page) + Raises: + AssertionError: If the attribute value does not match. + """ + locator = self._get_locator(page) + actual = locator.get_attribute(name) + if actual != expected: + raise AssertionError( + f"Expected attribute '{name}'='{expected}', got '{actual}'" + ) class AsyncSmartLocator: - """Asynchronous variant of SmartLocator for async Playwright usage. + """Asynchronous locator proxying Playwright's async ``Locator``. - Use this when your test suite runs with ``async def`` test functions - and ``playwright.async_api``. + Mirror of :class:`SmartLocator` for ``playwright.async_api``. + + Example: + >>> loc = AsyncSmartLocator.by_testid("btn-submit") + >>> await loc.click(page=page) + >>> await loc.wait_for(state="visible", page=page) """ - def __init__(self, config: LocatorConfig, page: "AsyncPage" | None = None) -> None: + def __init__( + self, + config: LocatorConfig, + page: AsyncPage | None = None, + ) -> None: + """Initialize the async locator. + + Args: + config: Search strategy configuration. + page: Optional Playwright async ``Page`` to associate with. + """ self._config = config self._page = page - self._selector = config.to_playwright_selector() @property def selector(self) -> str: - return self._selector + """Playwright-compatible selector string.""" + return self._config.to_playwright_selector() def __str__(self) -> str: - return self._selector + return self.selector def __repr__(self) -> str: return f"AsyncSmartLocator({self._config.strategy}='{self._config.selector}')" + def _get_locator(self, page: AsyncPage | None = None) -> AsyncLocator: + target = page or self._page + if not target: + raise RuntimeError( + "No page attached. Pass a page= argument or use with_page()." + ) + return target.locator(self.selector) + + def __getattr__(self, name: str) -> Any: + """Proxy attribute access to the underlying Playwright ``Locator``. + + Returns an async wrapper that ``await`` s the corresponding + Playwright ``Locator`` method and accepts a ``page`` kwarg. + + Args: + name: Attribute name. + + Returns: + Async wrapper around the Playwright ``Locator`` method. + + Raises: + AttributeError: If the attribute does not exist. + """ + if name.startswith("_"): + raise AttributeError(name) + + async def _proxy(*args: Any, **kwargs: Any) -> Any: + page = kwargs.pop("page", None) + locator = self._get_locator(page) + attr = getattr(locator, name, None) + if attr is None: + raise AttributeError( + f"'{type(self).__name__}' has no attribute '{name}'" + ) + return await attr(*args, **kwargs) + + return _proxy + + # ============ Factory Methods ============ + @classmethod def by_testid( cls, @@ -423,10 +656,11 @@ class AsyncSmartLocator: timeout: int = 5000, visible_only: bool = True, ) -> Self: - config = LocatorConfig( - selector=testid, strategy="testid", timeout=timeout, visible_only=visible_only + return cls( + LocatorConfig( + selector=testid, strategy="testid", timeout=timeout, visible_only=visible_only + ) ) - return cls(config) @classmethod def by_css( @@ -435,10 +669,11 @@ class AsyncSmartLocator: timeout: int = 5000, visible_only: bool = True, ) -> Self: - config = LocatorConfig( - selector=selector, strategy="css", timeout=timeout, visible_only=visible_only + return cls( + LocatorConfig( + selector=selector, strategy="css", timeout=timeout, visible_only=visible_only + ) ) - return cls(config) @classmethod def by_xpath( @@ -447,10 +682,11 @@ class AsyncSmartLocator: timeout: int = 5000, visible_only: bool = True, ) -> Self: - config = LocatorConfig( - selector=xpath, strategy="xpath", timeout=timeout, visible_only=visible_only + return cls( + LocatorConfig( + selector=xpath, strategy="xpath", timeout=timeout, visible_only=visible_only + ) ) - return cls(config) @classmethod def by_text( @@ -459,10 +695,11 @@ class AsyncSmartLocator: timeout: int = 5000, visible_only: bool = True, ) -> Self: - config = LocatorConfig( - selector=text, strategy="text", timeout=timeout, visible_only=visible_only + return cls( + LocatorConfig( + selector=text, strategy="text", timeout=timeout, visible_only=visible_only + ) ) - return cls(config) @classmethod def by_role( @@ -473,257 +710,224 @@ class AsyncSmartLocator: visible_only: bool = True, ) -> Self: selector = f"{role}:{name}" if name else role - config = LocatorConfig( - selector=selector, strategy="role", timeout=timeout, visible_only=visible_only + return cls( + LocatorConfig( + selector=selector, strategy="role", timeout=timeout, visible_only=visible_only + ) ) - return cls(config) - def with_page(self, page: "AsyncPage") -> Self: + # ============ Page Association ============ + + def with_page(self, page: AsyncPage) -> Self: return self.__class__(self._config, page) - def _get_locator(self, page: "AsyncPage" | None = None) -> "AsyncLocator": - target_page = page or self._page - if not target_page: - raise RuntimeError("Page not attached to locator") - return target_page.locator(self._selector) - - def _get_page(self, page: "AsyncPage" | None = None) -> "AsyncPage": - target_page = page or self._page - if not target_page: - raise RuntimeError("Page not attached to locator") - return target_page - - async def wait_for_visible(self, page: "AsyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - await loc.wait_for(state="visible", timeout=timeout or self._config.timeout) - - async def wait_for_hidden(self, page: "AsyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - await loc.wait_for(state="hidden", timeout=timeout or self._config.timeout) - - async def wait_for_enabled(self, page: "AsyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - await loc.wait_for(state="visible", timeout=timeout or self._config.timeout) - target_page = page or self._page - if target_page: - await target_page.wait_for_function( - f"!document.querySelector('{self._selector}').disabled", - timeout=timeout or self._config.timeout, - ) - - async def wait_for_disabled(self, page: "AsyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - await loc.wait_for(state="visible", timeout=timeout or self._config.timeout) - - async def click(self, page: "AsyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - await loc.click(timeout=timeout or self._config.timeout) + # ============ Compound Action Methods ============ async def click_and_wait( self, - page: "AsyncPage" | None = None, + page: AsyncPage | None = None, target_testid: str | None = None, navigation: bool = False, timeout: int | None = None, ) -> None: - target_page = self._get_page(page) + """Click and wait for navigation or a target element. + + Args: + page: Override async page. Falls back to ``self._page``. + target_testid: Wait for this ``data-testid`` after clicking. + navigation: Wrap click in :meth:`Page.expect_navigation`. + timeout: Timeout in milliseconds. Defaults to config timeout. + """ + locator = self._get_locator(page) timeout_val = timeout or self._config.timeout if navigation: - try: - async with target_page.expect_navigation( - timeout=timeout_val * 2, - wait_until="networkidle", - ): - await target_page.click(self._selector) - except TimeoutError as e: - raise TimeoutError( - f"Navigation timeout after clicking '{self._selector}'. " - f"Current URL: {target_page.url}" - ) from e + page_obj = page or self._page + if page_obj is None: + raise RuntimeError("No page available for navigation") + async with page_obj.expect_navigation( + timeout=timeout_val * 2, + wait_until="load", + ): + await locator.click(timeout=timeout_val) else: - await target_page.click(self._selector) + await locator.click(timeout=timeout_val) if target_testid: - target_loc = AsyncSmartLocator.by_testid(target_testid) - try: - await target_loc.wait_for_visible(target_page, timeout=timeout_val) - except TimeoutError as e: - raise TimeoutError( - f"Element with testid '{target_testid}' not visible after " - f"clicking '{self._selector}'. Current URL: {target_page.url}" - ) from e - - async def fill( - self, page: "AsyncPage" | None = None, value: str = "", timeout: int | None = None - ) -> None: - loc = self._get_locator(page) - await loc.fill(value, timeout=timeout or self._config.timeout) + target_loc = self.__class__.by_testid(target_testid) + await target_loc.wait_for(state="visible", page=page, timeout=timeout_val) async def fill_and_submit( self, - page: "AsyncPage" | None = None, + page: AsyncPage | None = None, value: str = "", submit_testid: str | None = None, timeout: int | None = None, ) -> None: - target_page = self._get_page(page) + """Fill a field and submit the form.""" + locator = self._get_locator(page) timeout_val = timeout or self._config.timeout - - await self.fill(target_page, value, timeout_val) + await locator.fill(value, timeout=timeout_val) if submit_testid: - submit_btn = AsyncSmartLocator.by_testid(submit_testid) - await submit_btn.click(target_page, timeout_val) + submit_loc = self.__class__.by_testid(submit_testid) + await submit_loc.click(page=page, timeout=timeout_val) else: - await target_page.press(self._selector, "Enter") + await locator.press("Enter") - async def clear(self, page: "AsyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - await loc.clear(timeout=timeout or self._config.timeout) + # ============ Wait Helpers ============ - async def select_option( - self, page: "AsyncPage" | None = None, value: str = "", timeout: int | None = None - ) -> None: - loc = self._get_locator(page) - await loc.select_option(value, timeout=timeout or self._config.timeout) - - async def check(self, page: "AsyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - await loc.check(timeout=timeout or self._config.timeout) - - async def uncheck(self, page: "AsyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - await loc.uncheck(timeout=timeout or self._config.timeout) - - async def hover(self, page: "AsyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - await loc.hover(timeout=timeout or self._config.timeout) - - async def scroll_into_view(self, page: "AsyncPage" | None = None, timeout: int | None = None) -> None: - loc = self._get_locator(page) - await loc.scroll_into_view_if_needed(timeout=timeout or self._config.timeout) - - async def get_text(self, page: "AsyncPage" | None = None) -> str: - loc = self._get_locator(page) - text = await loc.text_content() - return text or "" - - async def get_input_value(self, page: "AsyncPage" | None = None) -> str: - loc = self._get_locator(page) - val = await loc.input_value() - return val or "" - - async def get_attribute(self, name: str, page: "AsyncPage" | None = None) -> str | None: - loc = self._get_locator(page) - return await loc.get_attribute(name) - - async def count(self, page: "AsyncPage" | None = None) -> int: - loc = self._get_locator(page) - return await loc.count() - - async def is_visible(self, page: "AsyncPage" | None = None) -> bool: - loc = self._get_locator(page) - return await loc.is_visible() - - async def is_enabled(self, page: "AsyncPage" | None = None) -> bool: - loc = self._get_locator(page) - return await loc.is_enabled() - - async def is_checked(self, page: "AsyncPage" | None = None) -> bool: - loc = self._get_locator(page) - return await loc.is_checked() - - async def assert_visible(self, page: "AsyncPage" | None = None, visible: bool = True) -> None: - is_vis = await self.is_visible(page) - assert is_vis == visible, f"Expected visible={visible}, got {is_vis}" - - async def assert_text( + async def wait_for_enabled( self, - expected: str, - page: "AsyncPage" | None = None, - case_sensitive: bool = True, + page: AsyncPage | None = None, + timeout: int | None = None, ) -> None: - actual = await self.get_text(page) - if not case_sensitive: - actual = actual.lower() - expected = expected.lower() - assert actual == expected, f"Expected '{expected}', got '{actual}'" + """Wait for the element to become visible and enabled. - async def assert_contains_text( + Uses Playwright's ``expect(locator).to_be_enabled()``. + """ + from playwright.async_api import expect + + locator = self._get_locator(page) + timeout_val = timeout or self._config.timeout + await expect(locator).to_be_enabled(timeout=timeout_val) + + async def wait_for_disabled( self, - expected: str, - page: "AsyncPage" | None = None, - case_sensitive: bool = True, + page: AsyncPage | None = None, + timeout: int | None = None, ) -> None: - actual = await self.get_text(page) - if not case_sensitive: - actual = actual.lower() - expected = expected.lower() - assert expected in actual, f"Expected '{expected}' in '{actual}'" + """Wait for the element to become visible and disabled. - async def assert_count(self, expected: int, page: "AsyncPage" | None = None) -> None: - actual = await self.count(page) - assert actual == expected, f"Expected {expected} elements, got {actual}" + Uses Playwright's ``expect(locator).to_be_disabled()``. + """ + from playwright.async_api import expect - async def assert_attribute( - self, - name: str, - expected: str, - page: "AsyncPage" | None = None, - ) -> None: - actual = await self.get_attribute(name, page) - assert actual == expected, f"Expected attr '{name}'='{expected}', got '{actual}'" + locator = self._get_locator(page) + timeout_val = timeout or self._config.timeout + await expect(locator).to_be_disabled(timeout=timeout_val) + + # ============ Selector Composition ============ def nth(self, index: int) -> Self: - new_selector = f"{self._selector} >> nth={index}" - new_config = LocatorConfig( - selector=new_selector, - strategy="css", - timeout=self._config.timeout, - visible_only=self._config.visible_only, + new_selector = f"{self.selector} >> nth={index}" + return self.__class__( + LocatorConfig( + selector=new_selector, + strategy="css", + timeout=self._config.timeout, + visible_only=self._config.visible_only, + ), + self._page, ) - return self.__class__(new_config, self._page) def first(self) -> Self: return self.nth(0) def last(self) -> Self: - new_selector = f"{self._selector} >> nth=-1" - new_config = LocatorConfig( - selector=new_selector, - strategy="css", - timeout=self._config.timeout, - visible_only=self._config.visible_only, - ) - return self.__class__(new_config, self._page) + return self.nth(-1) def filter(self, filter_testid: str) -> Self: - new_selector = f"{self._selector}:has([data-testid='{filter_testid}'])" - new_config = LocatorConfig( - selector=new_selector, - strategy="css", - timeout=self._config.timeout, - visible_only=self._config.visible_only, + safe = _escape(filter_testid) + new_selector = f'{self.selector} >> css=:has([data-testid="{safe}"])' + return self.__class__( + LocatorConfig( + selector=new_selector, + strategy="css", + timeout=self._config.timeout, + visible_only=self._config.visible_only, + ), + self._page, ) - return self.__class__(new_config, self._page) def child(self, child_testid: str) -> Self: - new_selector = f"{self._selector} [data-testid='{child_testid}']" - new_config = LocatorConfig( - selector=new_selector, - strategy="css", - timeout=self._config.timeout, - visible_only=self._config.visible_only, + safe = _escape(child_testid) + new_selector = f'{self.selector} >> [data-testid="{safe}"]' + return self.__class__( + LocatorConfig( + selector=new_selector, + strategy="css", + timeout=self._config.timeout, + visible_only=self._config.visible_only, + ), + self._page, ) - return self.__class__(new_config, self._page) def parent(self) -> Self: - new_selector = f"{self._selector} >> .." - new_config = LocatorConfig( - selector=new_selector, - strategy="css", - timeout=self._config.timeout, - visible_only=self._config.visible_only, + new_selector = f"{self.selector} >> .." + return self.__class__( + LocatorConfig( + selector=new_selector, + strategy="css", + timeout=self._config.timeout, + visible_only=self._config.visible_only, + ), + self._page, ) - return self.__class__(new_config, self._page) + + # ============ Assertion Helpers ============ + + async def assert_visible( + self, + page: AsyncPage | None = None, + visible: bool = True, + ) -> None: + locator = self._get_locator(page) + is_vis = await locator.is_visible() + if is_vis != visible: + raise AssertionError( + f"Expected visible={visible}, got visible={is_vis}" + ) + + async def assert_text( + self, + expected: str, + page: AsyncPage | None = None, + case_sensitive: bool = True, + ) -> None: + locator = self._get_locator(page) + actual = await locator.text_content() or "" + if not case_sensitive: + actual = actual.lower() + expected = expected.lower() + if actual != expected: + raise AssertionError(f"Expected text '{expected}', got '{actual}'") + + async def assert_contains_text( + self, + expected: str, + page: AsyncPage | None = None, + case_sensitive: bool = True, + ) -> None: + locator = self._get_locator(page) + actual = await locator.text_content() or "" + if not case_sensitive: + actual = actual.lower() + expected = expected.lower() + if expected not in actual: + raise AssertionError( + f"Expected substring '{expected}' not found in '{actual}'" + ) + + async def assert_count( + self, + expected: int, + page: AsyncPage | None = None, + ) -> None: + locator = self._get_locator(page) + actual = await locator.count() + if actual != expected: + raise AssertionError(f"Expected {expected} elements, got {actual}") + + async def assert_attribute( + self, + name: str, + expected: str, + page: AsyncPage | None = None, + ) -> None: + locator = self._get_locator(page) + actual = await locator.get_attribute(name) + if actual != expected: + raise AssertionError( + f"Expected attribute '{name}'='{expected}', got '{actual}'" + ) diff --git a/src/pytfm/web/pom.py b/src/pytfm/web/pom.py index fe63772..cbe4e35 100644 --- a/src/pytfm/web/pom.py +++ b/src/pytfm/web/pom.py @@ -1,12 +1,14 @@ """Page Object Model base classes for UI testing. 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 typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Self + +from pytfm.web.locator import AsyncSmartLocator, SmartLocator if TYPE_CHECKING: from playwright.async_api import Page as AsyncPage @@ -16,80 +18,236 @@ if TYPE_CHECKING: class BasePage: """Synchronous base class for all page objects. - This is the **default** implementation. For async tests use - :class:`AsyncBasePage`. + Uses :class:`SmartLocator` internally for all element access. + 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 = "" - 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.base_url = base_url.rstrip("/") @property def url(self) -> str: + """Full page URL composed from ``base_url`` and ``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) return self - def wait_for_element(self, selector: str, timeout: int = 5000) -> None: - self.page.wait_for_selector(selector, state="visible", timeout=timeout) + def wait_for_element( + self, + testid: str, + timeout: int | None = None, + ) -> None: + """Wait for an element to become visible. - def click(self, selector: str) -> None: - self.page.click(selector) + Args: + 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: - self.page.fill(selector, value) + def click(self, testid: str) -> None: + """Click an element. - def get_text(self, selector: str) -> str: - element = self.page.wait_for_selector(selector) - if element: - return element.text_content() or "" - return "" + Args: + testid: The ``data-testid`` of the element. + """ + self.locator(testid).click() - def is_visible(self, selector: str) -> bool: - element = self.page.query_selector(selector) - if element: - return element.is_visible() - return False + def fill(self, testid: str, value: str) -> None: + """Fill an element with text. + + Args: + 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: - """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 = "" - 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.base_url = base_url.rstrip("/") @property def url(self) -> str: + """Full page URL composed from ``base_url`` and ``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) return self - async def wait_for_element(self, selector: str, timeout: int = 5000) -> None: - await self.page.wait_for_selector(selector, state="visible", timeout=timeout) + async def wait_for_element( + self, + testid: str, + timeout: int | None = None, + ) -> None: + """Wait for an element to become visible. - async def click(self, selector: str) -> None: - await self.page.click(selector) + Args: + 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: - await self.page.fill(selector, value) + async def click(self, testid: str) -> None: + """Click an element. - async def get_text(self, selector: str) -> str: - element = await self.page.wait_for_selector(selector) - if element: - return await element.text_content() or "" - return "" + Args: + testid: The ``data-testid`` of the element. + """ + await self.locator(testid).click() - async def is_visible(self, selector: str) -> bool: - element = await self.page.query_selector(selector) - if element: - return await element.is_visible() - return False + async def fill(self, testid: str, value: str) -> None: + """Fill an element with text. + + Args: + 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()