diff --git a/docs/targets.rst b/docs/targets.rst index 574cb51..9cfe214 100644 --- a/docs/targets.rst +++ b/docs/targets.rst @@ -3,12 +3,14 @@ Targets ======= The blocking of the screenplay! -The Target tells the Actors + +The :class:`~screenpy_selenium.Target` tells the +:external+screenpy:class:`~screenpy.Actor` what part of the website they are to interact with. Stripping away the metaphor, -the :ref:`target` combines a locator +the :class:`~screenpy_selenium.Target` combines a locator with a human-readable string. The human-readable part is what gets read out @@ -41,7 +43,7 @@ by passing them to Actions:: from example_test.ui.login_page import ( PASSWORD_FIELD, - SIGN_IN_BUTTON + SIGN_IN_BUTTON, USERNAME_FIELD, ) @@ -60,9 +62,9 @@ The resulting log: | Webster enters "[CENSORED]" into the password field. | Websert clicks on the "Sign In" button. -By default the :ref:`target` will use the locator string as a human-readable -``target_name`` in the absence of providing one. This can be convenient if your -locators are self-describing:: +By default the :class:`~screenpy_selenium.Target` will use the locator string +as a human-readable ``target_name`` in the absence of providing one. +This can be convenient if your locators are self-describing:: from screenpy_selenium import Target from selenium.webdriver.common.by import By @@ -81,5 +83,40 @@ The resulting log: | Webster enters "foo" into the username-field. | Webster enters "[CENSORED]" into the password-field. - | Websert clicks on the sign-in-button. + | Webster clicks on the sign-in-button. + + +Target in Target +---------------- + +A :class:`~screenpy_selenium.Target` (as seen above) +will typically have Selenium do a search +for the locator starting at the root of the DOM:: + + # These are equivalent + + web_element = USERNAME_FIELD.found_by(Webster) + + web_element = driver.find_element(*USERNAME_FIELD) + +Selenium also has the ability +to search for a child WebElement +starting from an already-found parent WebElement. +:class:`~screenpy_selenium.Target` can do the same +by utilizing the method :meth:`~screenpy.target.Target.inside`:: + + # These three are equivalent + + elem1 = USERNAME_FIELD.inside(LOGIN_FORM).found_by(Webster) + + form_elem = driver.find_element(*LOGIN_FORM) + elem2 = form_elem.find_element(*USERNAME_FIELD) + + elem3 = driver.find_element(*LOGIN_FORM).find_element(*USERNAME_FIELD) + + +.. note:: + + :meth:`~screenpy.target.Target.inside` does not mutate :class:`~screenpy_selenium.Target`. + This is done purposefully so the existing `Target` can be reused. diff --git a/screenpy_selenium/target.py b/screenpy_selenium/target.py index 9b374c7..53acc17 100644 --- a/screenpy_selenium/target.py +++ b/screenpy_selenium/target.py @@ -8,7 +8,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import copy +from typing import TYPE_CHECKING, Union from selenium.common.exceptions import WebDriverException from selenium.webdriver.common.by import By @@ -20,9 +21,12 @@ from collections.abc import Iterator from screenpy.actor import Actor - from selenium.webdriver.remote.webdriver import WebElement + from selenium.webdriver.remote.webdriver import WebDriver + from selenium.webdriver.remote.webelement import WebElement from typing_extensions import Self + WebDriverOrWebElement = Union[WebDriver, WebElement] + class Target: """Describe an element with a human-readable string and a locator. @@ -41,6 +45,7 @@ class Target: _description: str | None = None locator: tuple[str, str] | None = None + parent_target: Target | None = None @property def target_name(self) -> str | None: @@ -112,24 +117,53 @@ def get_locator(self) -> tuple[str, str]: def found_by(self, the_actor: Actor) -> WebElement: """Retrieve the |WebElement| as viewed by the Actor.""" - browser = the_actor.ability_to(BrowseTheWeb).browser + driver_or_element: WebDriverOrWebElement + if self.parent_target: + driver_or_element = self.parent_target.found_by(the_actor) + else: + driver_or_element = the_actor.ability_to(BrowseTheWeb).browser + try: - return browser.find_element(*self) + return driver_or_element.find_element(*self) except WebDriverException as e: msg = f"{e} raised while trying to find {self}." raise TargetingError(msg) from e def all_found_by(self, the_actor: Actor) -> list[WebElement]: """Retrieve a list of |WebElement| objects as viewed by the Actor.""" - browser = the_actor.ability_to(BrowseTheWeb).browser + driver_or_element: WebDriverOrWebElement + if self.parent_target: + driver_or_element = self.parent_target.found_by(the_actor) + else: + driver_or_element = the_actor.ability_to(BrowseTheWeb).browser + try: - return browser.find_elements(*self) + return driver_or_element.find_elements(*self) except WebDriverException as e: msg = f"{e} raised while trying to find {self}." raise TargetingError(msg) from e + def inside(self, parent_target: Target) -> Self: + """Set the containing parent element for this Target. + + Creates a new Target instance. + """ + new_target = copy.copy(self) + new_target.parent_target = parent_target + return new_target + + def inside_of(self, parent_target: Target) -> Self: + """Alias for :meth:`~screenpy_selenium.Target.inside`.""" + return self.inside(parent_target) + + def within(self, parent_target: Target) -> Self: + """Alias for :meth:`~screenpy_selenium.Target.inside`.""" + return self.inside(parent_target) + def __repr__(self) -> str: """A Target is represented by its name.""" + if self.parent_target: + return f"{self.target_name} in {self.parent_target}" return f"{self.target_name}" __str__ = __repr__ diff --git a/tests/test_target.py b/tests/test_target.py index 6449dc2..7f1bc4a 100644 --- a/tests/test_target.py +++ b/tests/test_target.py @@ -8,7 +8,7 @@ from screenpy_selenium import Target, TargetingError -from .useful_mocks import get_mocked_browser +from .useful_mocks import get_mocked_browser, get_mocked_target_and_element if TYPE_CHECKING: from screenpy import Actor @@ -103,6 +103,26 @@ def test_locator_tuple_size() -> None: Target("test").located_by([By.ID, "foo"]) # type: ignore[arg-type] +def test_inside() -> None: + t1 = Target.the("one").located((By.ID, "one")) + t2 = Target.the("two").located((By.ID, "two")) + t3 = t1.inside(t2) + t4 = t1.inside_of(t2) + t5 = t1.within(t2) + + assert t1 is not t3 + assert t1 is not t4 + assert t1 is not t5 + assert t2 is not t3 + assert t2 is not t4 + assert t2 is not t5 + assert t1.parent_target is None + assert t2.parent_target is None + assert t3.parent_target is t2 + assert t4.parent_target is t2 + assert t5.parent_target is t2 + + def test_found_by(Tester: Actor) -> None: test_locator = (By.ID, "eggs") Target.the("test").located(test_locator).found_by(Tester) @@ -121,6 +141,15 @@ def test_found_by_raises(Tester: Actor) -> None: assert test_name in str(excinfo.value) +def test_found_by_parent(Tester: Actor) -> None: + parent, mocked_element = get_mocked_target_and_element() + test_locator = (By.ID, "child") + + Target.the("test").located(test_locator).inside(parent).found_by(Tester) + mocked_element.find_element.assert_called_once_with(*test_locator) + parent.found_by.assert_called_once_with(Tester) + + def test_all_found_by(Tester: Actor) -> None: test_locator = (By.ID, "baked beans") Target.the("test").located(test_locator).all_found_by(Tester) @@ -139,6 +168,15 @@ def test_all_found_by_raises(Tester: Actor) -> None: assert test_name in str(excinfo.value) +def test_all_found_by_parent(Tester: Actor) -> None: + parent, mocked_element = get_mocked_target_and_element() + test_locator = (By.ID, "children") + + Target.the("test").located(test_locator).inside_of(parent).all_found_by(Tester) + mocked_element.find_elements.assert_called_once_with(*test_locator) + parent.found_by.assert_called_once_with(Tester) + + def test_iterator() -> None: locator = (By.ID, "eggs") target = Target.the("test").located(locator) @@ -160,14 +198,26 @@ def test_empty_target_iterator() -> None: def test_repr() -> None: t1 = Target() t2 = Target("foo") + t3 = Target("bar").inside(Target("baz")) + t4 = Target("abc").inside(Target("def").inside(Target("ghi"))) + t5 = Target().located((By.ID, "bla")).inside(Target().located((By.XPATH, "//div"))) assert repr(t1) == "None" assert repr(t2) == "foo" + assert repr(t3) == "bar in baz" + assert repr(t4) == "abc in def in ghi" + assert repr(t5) == "bla in //div" def test_str() -> None: t1 = Target() t2 = Target("foo") + t3 = Target("bar").inside(Target("baz")) + t4 = Target("abc").inside(Target("def").inside(Target("ghi"))) + t5 = Target().located((By.ID, "bla")).inside(Target().located((By.XPATH, "//div"))) assert str(t1) == "None" assert str(t2) == "foo" + assert str(t3) == "bar in baz" + assert str(t4) == "abc in def in ghi" + assert str(t5) == "bla in //div"