Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 44 additions & 7 deletions docs/targets.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
bandophahita marked this conversation as resolved.
with a human-readable string.
The human-readable part
is what gets read out
Expand Down Expand Up @@ -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,
)

Expand All @@ -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
Expand All @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

polish: This won't affect the output, but i've been trying to write these .rst files following the Semantic Linebreak conventions, since it makes changes to these docs much easier to review in PRs. I won't say i've done a great job of it (i think i might have been a bit overzealous in the linebreaks in the past), but it's more than just a character limit.

Can you give these another pass and try to follow that convention?

@bandophahita bandophahita Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am 100% terrible at figuring out where to break up the lines. I thought I was doing that. Better to just let me know where they should break.

----------------

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)
Comment thread
bandophahita marked this conversation as resolved.

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.

46 changes: 40 additions & 6 deletions screenpy_selenium/target.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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
Comment thread
bandophahita marked this conversation as resolved.
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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: This is not the correct typing for a method that returns a new object. Is there a generic "whatever class i am" return type, so we can support Target subclasses?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that technically it is correct even when it's not the same instance. I had the same thought as you. Self feels like an instance of, but iirc it's not limited to that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using mypy and reveal_type shows that inside returns the subclass rather than the base class.

class SubTarget(Target):
    pass

t1 = Target("t1").located((By.ID, "t1"))
st = SubTarget("s1").located((By.ID, "s1")).inside(t1)
reveal_type(st)

Revealed type is "test_target.SubTarget" (232:16)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Man, you're totally right! The docs confirm it: https://mypy.readthedocs.io/en/stable/more_types.html#precise-typing-of-alternative-constructors

I thought Self was very specifically "i return my self" but clearly it's a little more flexible than that. That's some good learnin', thanks for pushing back!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I was going to be very curious about why our mypy check wasn't yelling about it, i suppose that probably should have been my clue that i had some more learning to do.)

"""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__
Expand Down
52 changes: 51 additions & 1 deletion tests/test_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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"
Loading