From 39b38aa69ad8163e0acaa5c5ab0797ac9b5f3534 Mon Sep 17 00:00:00 2001 From: yashaka Date: Thu, 30 Jan 2025 20:14:59 +0200 Subject: [PATCH] NEW: core to web + mobile split - extend web.Element with more web-specific commands - element.shadow_root based on `weblement.shadow_root` - wrapped as _SearchContext class object with only .element and .all methods - collection.shadow_roots based on webelement.shadow_root - element.frame_context --- CHANGELOG.md | 72 ++- docs/faq/iframes-howto.md | 73 +-- docs/faq/shadow-dom-howto.md | 30 +- docs/reference/web/elements.md | 16 + mkdocs.yml | 15 +- selene/core/configuration.py | 2 +- selene/core/match.py | 1 + selene/core/query.py | 405 +------------ selene/support/shared/jquery_style.py | 2 +- selene/web/_context.pyi | 3 +- selene/web/_elements.py | 557 +++++++++++++++++- selene/web/_elements.pyi | 77 +++ ...t__query__frame_context__decorator_test.py | 8 +- ...get__query__frame_context__element_test.py | 7 +- ...ry__frame_context__nested__element_test.py | 37 +- ...query__frame_context__nested__with_test.py | 10 +- ...t__get__query__frame_context__with_test.py | 4 +- ...ery__js__shadow_root__all_elements_test.py | 6 +- ...t__query__js__shadow_root__element_test.py | 6 +- .../elements__shadow_root__element_test.py | 101 ++++ 20 files changed, 920 insertions(+), 512 deletions(-) create mode 100644 docs/reference/web/elements.md create mode 100644 tests/integration/elements__shadow_root__element_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d35c4b2b..f439cfe59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,17 +162,19 @@ check vscode pylance, mypy, jetbrains qodana... ## 2.0.0rc10: «copy&paste, frames, shadow & texts_like» (to be released on DD.05.2024) -### TODO: in addition to browser – _page for pure web and _device for pure mobile? ### DOING: draft Element descriptors POC? +#### TODO: ensure works with frames and shadow roots + #### TODO: make descriptor based PageObjects be used as descriptors on their own #### TODO: implement pom-descriptor-like decorators to name objects returned from methods ... maybe even from properties? (but should work out of the box if @property is applied as last) -### DOING: split into core, web, mobile +### TODO: in addition to browser – _page for pure web and _device for pure mobile? +#### DOING: split into core, web, mobile Done: - copy&paste Browser, Element, Collection into selene.web.* @@ -191,10 +193,17 @@ Done: - `switch_to_previous_tab` - `switch_to_tab` - `switch_to` +- extend web.Element with more web-specific commands + - element.shadow_root based on `weblement.shadow_root` + - wrapped as _SearchContext class object with only .element and .all methods + - collection.shadow_roots based on webelement.shadow_root + - element.frame_context Next: +- extend web.Element with more web-specific commands + - ... - make core.Element a base class for web.Element -- extend web.Element with more web-specific commands (shadow-root, frames, etc.) +- ensure query.* and command.* use proper classes ### Deprecated conditions @@ -208,7 +217,7 @@ Next: ### Added be.hidden_in_dom in addition to be.hidden -Consider `be.hidden` as "hidden somewhere, maybe in DOM with "display:none", or even on frontend/backend, i.e. totally absent from the page". Then `be.hidden_in_dom` is stricter, and means "hidden in DOM, i.e. available in the page DOM, but not visible". +Consider `be.hidden` as hidden somewhere, maybe in DOM with `"display:none"`, or even on frontend/backend, i.e. totally absent from the page. Then `be.hidden_in_dom` is stricter, and means "hidden in DOM, i.e. available in the page DOM, but not visible". ### Added experimental 4-in-1 be._empty over deprecated collection-condition be.empty @@ -393,7 +402,7 @@ browser.all('li').should(have._exact_texts_like( ).where(zero_or_more=...)) ``` -### Text related now supports ignore_case (including regex conditions) +### Text related conditions now supports ignore_case (including regex conditions) ```python from selene import browser, have @@ -439,6 +448,21 @@ browser.all('li').first.with_(_match_ignoring_case=True).should(have.exact_text( ``` +### Shadow DOM support via element.shadow_root or collection.shadow_roots + +As simple as: + +```python +from selene import browser, have + +... + +browser.element('#element-with-shadow-dom').shadow_root.element( + '#shadowed-element' +).click() +browser.all('.item-with-shadow-dom').shadow_roots.should(have.size(3)) +``` + ### Shadow DOM support via query.js.shadow_root(s) As simple as: @@ -456,31 +480,33 @@ browser.all('.item-with-shadow-dom').get(query.js.shadow_roots).should(have.size See one more example at [FAQ: How to work with Shadow DOM in Selene?](https://yashaka.github.io/selene/faq/shadow-dom-howto/) -### A context manager, decorator and search context to work with iFrames (Experimental) +### A context manager, decorator and search context to work with iFrames ```python -from selene import browser, query, have +from selene import browser, have -my_frame_context = browser.element('#my-iframe').get(query._frame_context) +my_frame_context = browser.element('#my-iframe').frame_context # now simply: -my_frame_context._element('#inside-iframe').click() -my_frame_context._all('.items-inside-iframe').should(have.size(3)) +my_frame_context.element('#inside-iframe').click() +my_frame_context.all('.items-inside-iframe').should(have.size(3)) # – here switching to frame and back happens for each command implicitly ... # or with my_frame_context: - # here elements inside frame will be found when searching via browser - browser.element('#inside-iframe').click() - browser.all('.items-inside-iframe').should(have.size(3)) - # this is the most speedy version, - # because switching to frame happens on entering the context - # and switching back to default content happens on exiting the context - ... + # here elements inside frame will be found when searching via browser + browser.element('#inside-iframe').click() + browser.all('.items-inside-iframe').should(have.size(3)) + # this is the most speedy version, + # because switching to frame happens on entering the context + # and switching back to default content happens on exiting the context + ... + + +@my_frame_context.within +def do_something(): + # and here too ;) + ... -@my_frame_context._within -def do_something(self): - # and here too ;) - ... # so now you can simply call it: do_something() @@ -489,11 +515,11 @@ do_something() # Switch to default content happens automatically, nevertheless;) ``` -See a bit more in documented ["FAQ: How to work with iFrames in Selene?"](https://yashaka.github.io/selene/faq/iframes-howto/) and much more in ["Reference: `query.*`](https://yashaka.github.io/selene/reference/query). +See a bit more in documented ["FAQ: How to work with iFrames in Selene?"](https://yashaka.github.io/selene/faq/iframes-howto/) and much more in ["Reference: `Web/Elements`](https://yashaka.github.io/selene/reference/web/elements). ### config._disable_wait_decorator_on_get_query -`True` by default, is needed for cleaner logging implemented via `config._wait_decorator` and more optimal performance for `.get(query._frame_context)` in case of nested frames. +`True` by default, is needed for cleaner logging implemented via `config._wait_decorator` and more optimal performance for `.get(query.frame_context)` in case of nested frames. ### config.selector_to_by_strategy diff --git a/docs/faq/iframes-howto.md b/docs/faq/iframes-howto.md index d5459e101..f7704c44a 100644 --- a/docs/faq/iframes-howto.md +++ b/docs/faq/iframes-howto.md @@ -33,7 +33,7 @@ browser.element('.textarea').should( In addition to that... -## Selene provides an experimental feature – [query._frame_context][selene.core.query._frame_context] that... +## Selene provides a built-into-Element feature – [element.frame_context][selene.web._elements.Element.frame_context] that... ### 1. Either removes a lot of boilerplate but might result in performance drawback @@ -45,14 +45,14 @@ In addition to that... ... # GIVEN - iframe = browser.element('#editor-iframe').get(query._frame_context) + iframe = browser.element('#editor-iframe').frame_context # THEN work with elements as if iframe is a normal parent element - iframe._all('strong').should(have.size(0)) - iframe._element('.textarea').type('Hello, World!').perform(command.select_all) + iframe.all('strong').should(have.size(0)) + iframe.element('.textarea').type('Hello, World!').perform(command.select_all) # AND still dealing with elements outside iframe as usual @@ -60,8 +60,8 @@ In addition to that... # AND ... - iframe._all('strong').should(have.size(1)) - iframe._element('.textarea').should( + iframe.all('strong').should(have.size(1)) + iframe.element('.textarea').should( have.js_property('innerHTML').value( '

Hello, world!

' ) @@ -76,12 +76,12 @@ In addition to that... ... - iframe = browser.element('#editor-iframe').get(query._frame_context) - iframe._all('strong').should(have.size(0)) - iframe._element('.textarea').type('Hello, World!').perform(command.select_all) + iframe = browser.element('#editor-iframe').frame_context + iframe.all('strong').should(have.size(0)) + iframe.element('.textarea').type('Hello, World!').perform(command.select_all) browser.element('#toolbar').element('#bold').click() - iframe._all('strong').should(have.size(1)) - iframe._element('.textarea').should( + iframe.all('strong').should(have.size(1)) + iframe.element('.textarea').should( have.js_property('innerHTML').value( '

Hello, world!

' ) @@ -96,14 +96,14 @@ In addition to that... ... - iframe = browser.element('#editor-iframe').get(query._frame_context) + iframe = browser.element('#editor-iframe').frame_context - iframe._all('strong').should(have.size(0)) - iframe._element('.textarea').type('Hello, World!').perform(command.select_all) + iframe.all('strong').should(have.size(0)) + iframe.element('.textarea').type('Hello, World!').perform(command.select_all) @@ -111,8 +111,8 @@ In addition to that... - iframe._all('strong').should(have.size(1)) - iframe._element('.textarea').should( + iframe.all('strong').should(have.size(1)) + iframe.element('.textarea').should( have.js_property('innerHTML').value( '

Hello, world!

' ) @@ -159,13 +159,15 @@ The performance may decrease because Selene under the hood has to switch to the It may decrease even more if you use such syntax for nested frames in cases more complex (have more commands to execute) than the example below: ```python -from selene import browser, have, query +from selene import browser, have browser.open('https://the-internet.herokuapp.com/nested_frames') -browser.element('[name=frame-top]').get(query._frame_context)._element( +browser.element('[name=frame-top]').frame_context.element( '[name=frame-middle]' -).get(query._frame_context)._element('#content',).should(have.exact_text('MIDDLE')) +).frame_context.element( + '#content' +).should(have.exact_text('MIDDLE')) ``` We recommend to not do premature optimization and start with this feature, and then switch to more optimal ways described below if you face significant performance drawbacks. @@ -184,7 +186,7 @@ We recommend to not do premature optimization and start with this feature, and t # WHEN # THEN - with iframe.get(query._frame_context): + with iframe.frame_context: # AND work with elements inside frame: browser.all('strong').should(have.size(0)) browser.element('.textarea').type('Hello, World!').perform(command.select_all) @@ -193,7 +195,7 @@ We recommend to not do premature optimization and start with this feature, and t # AND deal with elements outside iframe browser.element('#toolbar').element('#bold').click() # AND come back to ... - with iframe.get(query._frame_context): + with iframe.frame_context: # AND ... browser.all('strong').should(have.size(1)) browser.element('.textarea').should( @@ -215,7 +217,7 @@ We recommend to not do premature optimization and start with this feature, and t - with iframe.get(query._frame_context): + with iframe.frame_context: browser.all('strong').should(have.size(0)) browser.element('.textarea').type('Hello, World!').perform(command.select_all) @@ -224,7 +226,7 @@ We recommend to not do premature optimization and start with this feature, and t browser.element('#toolbar').element('#bold').click() - with iframe.get(query._frame_context): + with iframe.frame_context: browser.all('strong').should(have.size(1)) browser.element('.textarea').should( @@ -270,14 +272,15 @@ The performance is kept optimal because via `with` statement we can group action Will also work for nested context: ```python +import selene.web._elements from selene import browser, have, query, be # GIVEN even before opened browser browser.open('https://the-internet.herokuapp.com/nested_frames') # WHEN -with browser.element('[name=frame-top]').get(query._frame_context): - with browser.element('[name=frame-middle]').get(query._frame_context): +with browser.element('[name=frame-top]').frame_context: + with browser.element('[name=frame-middle]').frame_context: browser.element( '#content', # THEN @@ -286,9 +289,9 @@ with browser.element('[name=frame-top]').get(query._frame_context): browser.element('[name=frame-right]').should(be.visible) ``` -### 3. It also has a handy [_within][selene.core.query._frame_context._within] decorator to tune PageObject steps to work with iframes +### 3. It also has a handy [within][selene.web._elements._FrameContext.within] decorator to tune PageObject steps to work with iframes -=== "_within decorator" +=== "within decorator" ```python from selene import browser, command, have, query @@ -296,21 +299,21 @@ with browser.element('[name=frame-top]').get(query._frame_context): class Editor: - area_frame = browser.element('#editor-iframe').get(query._frame_context) + area_frame = browser.element('#editor-iframe').frame_context text_area = browser.element('.textarea') toolbar = browser.element('#toolbar') - @area_frame._within + @area_frame.within def type(self, text): self.text_area.type(text) return self - @area_frame._within + @area_frame.within def should_have_bold_text_parts(self, count): self.text_area.all('strong').should(have.size(count)) return self - @area_frame._within + @area_frame.within def select_all(self): self.text_area.perform(command.select_all) return self @@ -319,7 +322,7 @@ with browser.element('[name=frame-top]').get(query._frame_context): self.toolbar.element('#bold').click() return self - @area_frame._within + @area_frame.within def should_have_content_html(self, text): self.text_area.should( have.js_property('innerHTML').value( @@ -390,7 +393,7 @@ with browser.element('[name=frame-top]').get(query._frame_context): - with iframe.get(query._frame_context): + with iframe.frame_context: browser.all('strong').should(have.size(0)) browser.element('.textarea').type('Hello, World!').perform(command.select_all) @@ -398,7 +401,7 @@ with browser.element('[name=frame-top]').get(query._frame_context): browser.element('#toolbar').element('#bold').click() - with iframe.get(query._frame_context): + with iframe.frame_context: browser.all('strong').should(have.size(1)) browser.element('.textarea').should( @@ -412,4 +415,4 @@ with browser.element('[name=frame-top]').get(query._frame_context): Take into account, that because we break previously groupped actions into separate methods, the performance might decrease same way as it was with a "search context" style, as we have to switch to the frame context and back for each method call. -See a more detailed explanation and examples on [the feature reference][selene.core.query._frame_context]. +See a more detailed explanation and examples on [the feature reference][selene.web._elements._FrameContext]. diff --git a/docs/faq/shadow-dom-howto.md b/docs/faq/shadow-dom-howto.md index d8be03708..9a40b7d5f 100644 --- a/docs/faq/shadow-dom-howto.md +++ b/docs/faq/shadow-dom-howto.md @@ -2,6 +2,34 @@ {% include-markdown 'warn-from-next-release.md' %} +## Via built-in WebDriver-based properties + +– As simply as: + +```python +from selene import browser, have + +# GIVEN +paragraphs = browser.all('my-paragraph') + +# WHEN it's enough to access specific elements +paragraph_2_shadow = paragraphs.second.shadow_root # 💡 +my_shadowed_text_2 = paragraph_2_shadow.element('[name=my-text]') +# OR when you need all shadow roots +my_shadowed_texts = paragraphs.shadow_roots.all('[name=my-text]') # 💡 + +# As you can see these queries are lazy, +# so you were able to store them in vars ↖️ +# even before open ↙️ +browser.open('https://the-internet.herokuapp.com/shadowdom') + +# THEN +my_shadowed_text_2.should(have.exact_text("My default text")) # ⬅️ +my_shadowed_texts.should(have.exact_texts("My default text", "My default text")) # ⬅️ +``` + +## Via JavaScript queries at query.js.* + – By using advanced [query.js.shadow_root][selene.core.query.js.shadow_root] and [query.js.shadow_roots][selene.core.query.js.shadow_roots] queries, as simply as: ```python @@ -14,7 +42,7 @@ paragraphs = browser.all('my-paragraph') paragraph_2_shadow = paragraphs.second.get(query.js.shadow_root) # 💡 my_shadowed_text_2 = paragraph_2_shadow.element('[name=my-text]') # OR when you need all shadow roots -my_shadowed_texts = paragraphs.get(query.js.shadow_roots) # 💡 +my_shadowed_texts = paragraphs.get(query.js.shadow_roots).all('[name=my-text]') # 💡 # As you can see these queries are lazy, # so you were able to store them in vars ↖️ diff --git a/docs/reference/web/elements.md b/docs/reference/web/elements.md new file mode 100644 index 000000000..5a2350e37 --- /dev/null +++ b/docs/reference/web/elements.md @@ -0,0 +1,16 @@ +# + +{% include-markdown 'warn-from-next-release.md' %} + +::: selene.web._elements + options: + show_root_toc_entry: false + show_if_no_docstring: true + members_order: alphabetical + filters: + - "!__.*" + - "!_step" + - "!_steps" + - "!_inner" + - "!_inside" + - "!_content" diff --git a/mkdocs.yml b/mkdocs.yml index 29ccab682..ca72f5ca2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,12 +31,15 @@ nav: # - Use Cases: # - Stub Title 1: use-cases/path-to-some-example.md - Reference: - - Config: reference/configuration.md - - command.*: reference/command.md - - query.*: reference/query.md - - match.* predefined conditions: reference/match.md - - Expected Conditions: reference/condition.md - - Selene Exceptions: reference/exceptions.md + - selene.core: + - Config: reference/configuration.md + - command.*: reference/command.md + - query.*: reference/query.md + - match.* predefined conditions: reference/match.md + - Expected Conditions: reference/condition.md + - Selene Exceptions: reference/exceptions.md + - selene.web: + - Elements: reference/web/elements.md - Contribution: - How to contribute: contribution/to-source-code-guide.md - Code conventions: contribution/code-conventions-guide.md diff --git a/selene/core/configuration.py b/selene/core/configuration.py index 76045e90f..13e016b2c 100644 --- a/selene/core/configuration.py +++ b/selene/core/configuration.py @@ -1347,7 +1347,7 @@ def _selector_or_by_to_by( and speeding up the test execution for some specific cases like working with nested Frames (for more see warning at - [query._frame_context._element][selene.core.query._frame_context._element]). + [_FrameContext.element][selene.web._elements._FrameContext._element]). """ # TODO: why we name it as hook_* why not handle_* ? diff --git a/selene/core/match.py b/selene/core/match.py index 895362fc7..218b34ea8 100644 --- a/selene/core/match.py +++ b/selene/core/match.py @@ -147,6 +147,7 @@ def __init__( self.__actual = actual self.__expected = expected self.__by = by + # TODO: seems like we never use self.__ignore_case... o_O wtf? self.__ignore_case = _ignore_case self.__inverted = _inverted self.__falsy_exceptions = _falsy_exceptions diff --git a/selene/core/query.py b/selene/core/query.py index f4421c3b3..d521a2563 100644 --- a/selene/core/query.py +++ b/selene/core/query.py @@ -186,7 +186,6 @@ def fn(element: Element): """ from __future__ import annotations -import functools import typing import warnings @@ -195,17 +194,16 @@ def fn(element: Element): Dict, Any, Union, - TypeVar, ) from selenium.webdriver.remote.webelement import WebElement -from selene import support -from selene.common._typing_functions import Query, Command +from selene.common._typing_functions import Query from selene.core import entity from selene.core.entity import Element, Collection from selene.core._browser import Browser from selene.core.locator import Locator +from selene.web._elements import _FrameContext # TODO: should not we separate Query type from actual queries implementations? @@ -401,402 +399,12 @@ def js_property( return native_property(name) -# --- Pseudo-queries --- # +# --- Pseudo queries --- # - -class _frame_context: - """A context manager to work with frames (iframes). - Has an additional decorator to adapt context manager to step-methods - when implementing a PageObject pattern. - Partially serves as entity similar to Element - allowing to find element or collection inside frame. - Experimental feature. - - This is a "pseudo-query", i.e. it does not "get something" from entity. - It's implemented as a query to be more readable in usage. - - ## Laziness on query application - - On `get(query._frame_context)` - it actually just wraps an element into context manager and so is lazy, - i.e. you can store result of such query into a variable - even before opening a browser and use it later. - Thus, unlike for other queries, there is no difference - between using the query directly as `query._frame_context(element)` - or via `get` method as `element.get(query._frame_context)`. - - The "lazy result" of the query is also a "lazy search context" - similar to Element entity - – it allows to find elements or collections inside the frame - by using `self._element(selector)` or `self._all(selector)` methods. - This allows the easiest and most implicit way to work with frames in Selene - without bothering about switching to the frame and back: - - ### Example: Using query result as "search context" with fully implicit frame management - - ```python - from selene import browser, command, have, query - ... - iframe = browser.element('#editor-iframe').get(query._frame_context) - iframe._all('strong').should(have.size(0)) - iframe._element('.textarea').type('Hello, World!').perform(command.select_all) - browser.element('#toolbar').element('#bold').click() - iframe._all('strong').should(have.size(1)) - ``` - - !!! warning - - But be aware that such syntax will force to switch to the frame and back - for each command executed on element or collection of elements - inside the frame. This might result in slower tests - if you have a lot of commands to be executed all together inside the frame. - - !!! tip - - We recommend to stay - [YAGNI](https://enterprisecraftsmanship.com/posts/yagni-revisited/) - and use this syntax by default, but when you notice performance drawbacks, - consider choosing an explicit way to work with frame context - as a context manager passed to `with` statement - or as a decorator `_within` applied to step-methods of PageObject - as described below. - - ## Laziness ends on with statement - - On passing the "lazy result" of the query to `with` statement - it actually transforms from "lazy query" into "actual command", - that performs an action on the entity – - the action of switching to the element's frame - with the corresponding implicit waiting. - - On exiting the `with` statement it switches back to the default content, - without any additional implicit waiting. - This behavior might change in the future, and some waiting might be added. - - ## Example: Straightforward usage of the query (in with statement): - - ```python - from selene import browser, query, command, have - - toolbar = browser.element('.tox-toolbar__primary') - text_area_frame = browser.element('.tox-edit-area__iframe') - text_area = browser.element('#tinymce') # ❗️ inside the frame - - browser.open('https://the-internet.herokuapp.com/iframe') - - with text_area_frame.get(query._frame_context): - text_area.perform(command.select_all) - - toolbar.element('[title=Bold]').click() - - with text_area_frame.get(query._frame_context): - text_area.element('p').should( - have.js_property('innerHTML').value( - 'Your content goes here.' - ) - ) - ``` - - ## Example: Usage utilizing the lazy nature of the query (in with statement) - - ```python - from selene import browser, query, command, have - - toolbar = browser.element('.tox-toolbar__primary') - text_area_frame = browser.element('.tox-edit-area__iframe') - text_area_frame_context = text_area_frame.get(query._frame_context) # 💡↙️ - text_area = browser.element('#tinymce') - - browser.open('https://the-internet.herokuapp.com/iframe') - - with text_area_frame_context: # ⬅️ - text_area.perform(command.select_all) - - toolbar.element('[title=Bold]').click() - - with text_area_frame_context: # ⬅️ - text_area.element('p').should( - have.js_property('innerHTML').value( - 'Your content goes here.' - ) - ) - ``` - - ## Example: Usage utilizing the lazy nature of the query without get method: - - Since the query application is fully lazy - (laziness ends only on `with` statement), - you can use it directly, without `get` method: - - ```python - from selene import browser, query, command, have - - toolbar = browser.element('.tox-toolbar__primary') - text_area_frame = browser.element('.tox-edit-area__iframe') - text_area_frame_context = query._frame_context(text_area_frame) # 💡↙️ - text_area = browser.element('#tinymce') - - browser.open('https://the-internet.herokuapp.com/iframe') - - with text_area_frame_context: # ⬅️ - text_area.perform(command.select_all) - - toolbar.element('[title=Bold]').click() - - with text_area_frame_context: # ⬅️ - text_area.element('p').should( - have.js_property('innerHTML').value( - 'Your content goes here.' - ) - ) - ``` - - ## Example: Nested with statements for nested frames - - ```python - from selene import browser, have, query, be - - # GIVEN even before opened browser - browser.open('https://the-internet.herokuapp.com/nested_frames') - - # WHEN - with browser.element('[name=frame-top]').get(query._frame_context): - with browser.element('[name=frame-middle]').get(query._frame_context): - browser.element( - '#content', - # THEN - ).should(have.exact_text('MIDDLE')) - # AND - browser.element('[name=frame-right]').should(be.visible) - ``` - - ## Example: Usage utilizing the [_within][selene.core.query._frame_context._within] decorator for PageObjects: - - See example at [_within][selene.core.query._frame_context._within] section. - """ - - def __init__(self, element: Element): - self._container = element - self.__entered = False - - def decorator(self, func): - """A decorator to mark a function as a step within context manager - - See example of usage at [_within][selene.core.query._frame_context._within] section. - """ - - @functools.wraps(func) - def wrapper(*args, **kwargs): - with self: - return func(*args, **kwargs) - - return wrapper - - # aliases :) TODO: not sure which to keep - _step = decorator - _steps = decorator - _content = decorator - _inside = decorator - _inner = decorator - _within = decorator - """An alias to [`decorator`][selene.core.query._frame_context.decorator] - - Example of usage: - - ```python - from selene import browser, command, have, query - - - def teardown_function(): - browser.quit() - - - class WYSIWYG: - toolbar = browser.element('.tox-toolbar__primary') - text_area_frame = query._frame_context( # 💡⬇️ - browser.element('.tox-edit-area__iframe') - ) - text_area = browser.element('#tinymce') - - def open(self): - browser.open('https://the-internet.herokuapp.com/iframe') - return self - - def set_bold(self): - self.toolbar.element('[title=Bold]').click() - return self - - @text_area_frame._within # ⬅️ - def should_have_text_html(self, text_html): - self.text_area.should(have.js_property('innerHTML').value(text_html)) - return self - - @text_area_frame._within # ⬅️ - def select_all_text(self): - self.text_area.perform(command.select_all) - return self - - @text_area_frame._within # ⬅️ - def reset_to(self, text): - self.text_area.perform(command.select_all).type(text) - return self - - - def test_page_object_steps_within_frame_context(): - wysiwyg = WYSIWYG().open() - - wysiwyg.should_have_text_html( - '

Your content goes here.

', - ).select_all_text().set_bold().should_have_text_html( - '

Your content goes here.

', - ) - - wysiwyg.reset_to('New content').should_have_text_html( - '

New content

', - ) - ``` - """ - - def __enter__(self): - if not self.__entered: - self._container.wait.with_( - # resetting wait decorator to default - # in order to avoid automatic exit applied to each command - # including switching to the frame - # that (automatic exit) was added after self._element - # (this fixes breaking exiting from the frame in nested frame context) - decorator=None, - ).for_( - Command( - 'switch to frame', - lambda entity: entity.config.driver.switch_to.frame( - entity.locate() - ), - ) - ) - self.__entered = True - - def __exit__(self, exc_type, exc_val, exc_tb): - if self.__entered: - driver = self._container.config.driver - - # we intentionally use parent_frame() over default_content() - # to make it work for nested frames - # (in case of "root frames" parent_frame() should work as default_content()) - driver.switch_to.parent_frame() - self.__entered = False - - @property - def __as_wait_decorator(self): - if self._container.config._wait_decorator is None: - return support._wait.with_(context=self) - - def composed_wait_decorator(wait): - def decorator(for_): - original_wait_decorator = self._container.config._wait_decorator - context_wait_decorator = support._wait.with_(context=self) - - for_decorator_after_context = context_wait_decorator(wait) - for_decorator_after_original = original_wait_decorator(wait) - - # by applying context decorator first (i.e. closer to the function call) - # we actually make it second in the chain - for_after_context = for_decorator_after_context(for_) - - # – because lastly applied decorator will contain the first code - # to be executed before the decorated function - for_after_context_then_original = for_decorator_after_original( - for_after_context - ) - - # – so, given original decorator is a logging decorator - # first we log the command, - # and then we actually switch to context before running the command - # ! This is very important because switching context for us - # ! is a low level command, that's why it should be "logged as second" - # ! that in reports like allure will also be "nested" on a deeper level - return for_after_context_then_original - - return decorator - - return composed_wait_decorator - - def _element(self, selector: str | typing.Tuple[str, str]) -> Element: - """Allows to search for a first element by selector inside the frame context - with implicit switching to the frame and back for each method execution. - - Is lazy, i.e. does not switch to the frame immediately on calling this method, - and so can be stored in a variable and used later. - - Args: - selector: css or xpath as string or classic selenium tuple-like locator, - e.g. `('css selector', '.some-class')` - or `(By.CSS_SELECTOR, '.some-class')` - - !!! warning - By adding implicit switching to the frame and back - for each command executed on entity, it makes the usage of such entity - slower in case of a lot of commands to be executed - all together inside the frame. - - It becomes especially important in case of nested frames. - In such cases, if you use - `entity.get(query._frame_context)` over `query._frame_context(entity)` - then try to keep turned on the option: - [config._disable_wait_decorator_on_get_query][selene.core.configuration.Config._disable_wait_decorator_on_get_query] - That will help to avoid re-switching at least on `get` calls. - - If you notice performance drawbacks, consider choosing an explicit way - to work with frame context as a context manager passed to `with` statement. - """ - by = self._container.config._selector_or_by_to_by(selector) - - return Element( - Locator( - f'{self._container}: element({by})', - # f'{self._container} {{ element({by}) }}', # TODO: maybe this? - lambda: self._container.config.driver.find_element(*by), - ), - self._container.config.with_(_wait_decorator=self.__as_wait_decorator), - ) - - def _all(self, selector: str | typing.Tuple[str, str]) -> Collection: - """Allows to search for all elements by selector inside the frame context - with implicit switching to the frame and back for each method execution. - - Is lazy, i.e. does not switch to the frame immediately on calling this method, - and so can be stored in a variable and used later. - - Args: - selector: css or xpath as string or classic selenium tuple-like locator, - e.g. `('css selector', '.some-class')` - or `(By.CSS_SELECTOR, '.some-class')` - - !!! warning - Same "potential performance drawbacks" warning is applied here - as for [_element][selene.core.query._frame_context._element] method. - """ - by = self._container.config._selector_or_by_to_by(selector) - - return Collection( - Locator( - f'{self._container}: all({by})', - lambda: self._container.config.driver.find_elements(*by), - ), - self._container.config.with_(_wait_decorator=self.__as_wait_decorator), - ) - - -# The following is not needed once we now have switch_to.parent_frame() -# in _frame_context itself -# class _nested_frame_context(_frame_context): -# def __exit__(self, exc_type, exc_val, exc_tb): -# driver = self._container.config.driver -# driver.switch_to.parent_frame() +frame_context = _FrameContext # --- Browser queries --- # - url: Query[Browser, str] = Query('url', lambda browser: browser.driver.current_url) title: Query[Browser, str] = Query('title', lambda browser: browser.driver.title) @@ -890,9 +498,10 @@ def page_source_saved( class js: shadow_root: Query[Element, Element] = Query( 'shadow root', + # TODO: use _ElementsContext instead lambda element: Element( Locator( - f'{element}: shadow root', + f'{element}.shadow root', lambda: element.config.driver.execute_script( 'return arguments[0].shadowRoot', element.locate() ), @@ -908,7 +517,7 @@ class js: 'shadow roots', lambda collection: Collection( Locator( - f'{collection}: shadow roots', + f'{collection}.shadow roots', lambda: collection.config.driver.execute_script( 'return [...arguments[0]].map(arg => arg.shadowRoot)', collection.locate(), diff --git a/selene/support/shared/jquery_style.py b/selene/support/shared/jquery_style.py index b86cbdf7e..3171b734d 100644 --- a/selene/support/shared/jquery_style.py +++ b/selene/support/shared/jquery_style.py @@ -21,7 +21,7 @@ # SOFTWARE. from typing import Union, Tuple -from selene.core.entity import Element, Collection +from selene.web._elements import Element, Collection from selene import browser diff --git a/selene/web/_context.pyi b/selene/web/_context.pyi index acee22bb0..e9a240f37 100644 --- a/selene/web/_context.pyi +++ b/selene/web/_context.pyi @@ -45,7 +45,8 @@ from selenium.webdriver.remote.switch_to import SwitchTo as SwitchTo from selenium.webdriver.remote.webdriver import WebDriver as WebDriver from typing import Callable, Optional, Tuple, Union, Any -from selene.core.entity import WaitingEntity, Element, Collection, E +from selene.core.entity import WaitingEntity, E +from selene.web._elements import Element, Collection class Browser(WaitingEntity['Browser']): def __init__(self, config: Optional[Config] = ...) -> None: ... diff --git a/selene/web/_elements.py b/selene/web/_elements.py index f793e4d58..333434ef4 100644 --- a/selene/web/_elements.py +++ b/selene/web/_elements.py @@ -22,16 +22,19 @@ # type: ignore # TODO: remove finally from __future__ import annotations +import functools + from typing_extensions import Union, Callable, Tuple, Iterable, Optional, Self import typing_extensions as typing import warnings +from selene import support from selene.common.fp import pipe from selene.common.helpers import flatten from selene.common._typing_functions import Command from selene.core.condition import Condition from selene.core.configuration import Config -from selene.core.entity import WaitingEntity +from selene.core.entity import WaitingEntity, E from selene.core.locator import Locator from selene.core.wait import Wait @@ -43,6 +46,109 @@ from selenium.webdriver.common.keys import Keys +@typing.runtime_checkable +class _SearchContext(typing.Protocol): + def find_element(self, by: str, value: str | None = None) -> WebElement: ... + + def find_elements( + self, by: str, value: str | None = None + ) -> typing.List[WebElement]: ... + + +class _ElementsContext(WaitingEntity['_ElementsContext']): + """An Element-like class that serves as pure context for search elements inside + via `element(selector_or_by)` or `all(selector_or_by)` methods""" + + def __init__(self, locator: Locator[_SearchContext], config: Config): + self._locator = locator + super().__init__(config) + + # --- Configured --- # + + def with_( + self, config: Optional[Config] = None, **config_as_kwargs + ) -> _ElementsContext: + return _ElementsContext( + self._locator, + config if config else self.config.with_(**config_as_kwargs), + ) + + # --- Located --- # + + def __str__(self): + return str(self._locator) + + def locate(self) -> _SearchContext: + return self._locator() + + @property + def __raw__(self) -> _SearchContext: + return self.locate() + + def __call__(self) -> _SearchContext: + return self.locate() + + # --- WaitingEntity --- # + + @property + def wait(self) -> Wait[_ElementsContext]: + # TODO: will not it break code like browser.with_(timeout=...)? + # TODO: fix that will disable/break shared hooks (snapshots) + # return Wait(self, # TODO: isn't it slower to create it each time from scratch? move to __init__? + # at_most=self.config.timeout, + # or_fail_with=pipe( + # Element._log_webelement_outer_html_for(self), + # self.config.hook_wait_failure)) + if self.config.log_outer_html_on_failure: + # TODO: remove this part completely from core.entity logic + # move it to support.shared.config + return super().wait.or_fail_with( + pipe( + # TODO: decide on ... + # Element._log_webelement_outer_html_for(self), + super().wait.hook_failure, + ) + ) + else: + return super().wait + + @property + def cached(self) -> _ElementsContext: + # TODO: do we need caching ? with lazy save of webelement to cache + + cache = None + error = None + try: + cache = self.locate() + except Exception as e: + error = e + + def get_cache(): + if cache: + return cache + raise error + + return _ElementsContext(Locator(f'{self}.cached', get_cache), self.config) + + # --- Relative location --- # + + def element(self, selector_or_by: Union[str, Tuple[str, str]], /) -> Element: + by = self.config._selector_or_by_to_by(selector_or_by) + + return Element( + Locator(f'{self}.element({by})', lambda: self().find_element(*by)), + self.config, + ) + + def all(self, selector_or_by: Union[str, Tuple[str, str]], /) -> Collection: + by = self.config._selector_or_by_to_by(selector_or_by) + + return Collection( + Locator(f'{self}.all({by})', lambda: self().find_elements(*by)), + self.config, + ) + + class Element(WaitingEntity['Element']): @staticmethod def _log_webelement_outer_html_for( @@ -152,6 +258,35 @@ def all(self, css_or_xpath_or_by: Union[str, Tuple[str, str]]) -> Collection: self.config, ) + @property + def shadow_root(self) -> _ElementsContext: + return _ElementsContext( + Locator(f'{self}.shadow root', lambda: self.locate().shadow_root), + self.config, + ) + + @property + def frame_context(self) -> _FrameContext: + """A context manager to work with frames (iframes). + Has an additional decorator to adapt context manager to step-methods + when implementing a PageObject pattern. + Partially serves as entity similar to Element + allowing to find element or collection inside frame. + + Technically it's a shortcut to `_FrameContext(element)` + and also is pretty similar to `element.get(_FrameContext)` query. + Find more details in the [_FrameContext][selene.web._elements._FrameContext] docs. + """ + + return _FrameContext(self) + + # @property + # def shadow_root(self) -> Element: + # from selene.core import query + # self.locate().shadow_root + # + # return self.get(query.js.shadow_root) + # --- Commands --- # def execute_script(self, script_on_self: str, *arguments): @@ -1013,7 +1148,427 @@ def all_first(self, selector: Union[str, Tuple[str, str]]) -> Collection: self.config, ) + # --- Unique for Web --- # + + @property + def shadow_roots(self) -> Collection: + + # TODO: should not we return Collection of _SearchContexts instead of Collection of WebElements? + return Collection( + Locator( + f'{self}.shadow roots', + lambda: [webelement.shadow_root for webelement in self.locate()], + ), + self.config, + ) + AllElements = Collection All = Collection + + +# TODO: should we rename it to FrameContextManager +class _FrameContext: + """A context manager to work with frames (iframes). + Has an additional decorator to adapt context manager to step-methods + when implementing a PageObject pattern. + + Partially serves as entity similar to Element + allowing to find element or collection inside frame + and work with them with implicit automatic "switch into context" + before any action and "switch out" after it. + But this ability may reduce performance in case of "too much of actions" + inside a frame. In such cases, it's better to use explicit context manager. + + !!! note + There is a `query.frame_context` alias to this class, because it can + be used as "pseudo-query": `element.get(query.frame_context)`. + + This context manager is already built into `selene.web.Element` entity, + That's why in the majority of examples below + you will see `element.frame_context` instead of `_FrameContext(element)` + or `element.get(_FrameContext)`. + + ## Laziness on query application + + On `element.get(query.frame_context)` (or `element.frame_context`) + it actually just wraps an element into context manager and so is lazy, + i.e. you can store result of such query into a variable + even before opening a browser and use it later. + Thus, unlike for other queries, there is no difference + between using the query directly as `query.frame_context(element)` + or via `get` method as `element.get(query.frame_context)`. + + The "lazy result" of the query is also a "lazy search context" + similar to Element entity + – it allows to find elements or collections inside the frame + by using `self.element(selector)` or `self.all(selector)` methods. + This allows the easiest and most implicit way to work with frames in Selene + without bothering about switching to the frame and back: + + ### Example: Using as "search context" with fully implicit frame management + + ```python + from selene import browser, command, have, query + ... + # iframe = _FrameContext(browser.element('#editor-iframe')) + # OR: + # iframe = query.frame_context(browser.element('#editor-iframe')) + # OR: + # iframe = browser.element('#editor-iframe').get(_FrameContext) + # OR: + # iframe = browser.element('#editor-iframe').get(query.frame_context) + # OR: + iframe = browser.element('#editor-iframe').frame_context + iframe.all('strong').should(have.size(0)) + iframe.element('.textarea').type('Hello, World!').perform(command.select_all) + browser.element('#toolbar').element('#bold').click() + iframe.all('strong').should(have.size(1)) + ``` + + !!! warning + + But be aware that such syntax will force to switch to the frame and back + for each command executed on element or collection of elements + inside the frame. This might result in slower tests + if you have a lot of commands to be executed all together inside the frame. + + !!! tip + + We recommend to stay + [YAGNI](https://enterprisecraftsmanship.com/posts/yagni-revisited/) + and use this "frame like an element context" syntax by default, + but when you notice performance drawbacks, + consider choosing an explicit way to work with frame context + as a context manager passed to `with` statement + or as a decorator `within` applied to step-methods of PageObject + as described below. + + ## Laziness ends on with statement + + On passing the "lazy result" of the query to `with` statement + it actually transforms from "lazy query" into "actual command", + that performs an action on the entity – + the action of switching to the element's frame + with the corresponding implicit waiting. + + On exiting the `with` statement it switches back to the default content, + without any additional implicit waiting. + This behavior might change in the future, and some waiting might be added. + + ## Example: Straightforward usage of the frame context (in with statement): + + ```python + from selene import browser, query, command, have + + toolbar = browser.element('.tox-toolbar__primary') + text_area_frame = browser.element('.tox-edit-area__iframe') + # the following var will only work if used after the switch to the frame ↙️ + text_area = browser.element('#tinymce') # ❗️ inside the frame + + browser.open('https://the-internet.herokuapp.com/iframe') + + with text_area_frame.frame_context: + text_area.perform(command.select_all) + + toolbar.element('[title=Bold]').click() + + with text_area_frame.frame_context: + text_area.element('p').should( + have.js_property('innerHTML').value( + 'Your content goes here.' + ) + ) + ``` + + ## Example: Usage utilizing the lazy nature of the frame context (in with statement) + + ```python + from selene import browser, query, command, have + + toolbar = browser.element('.tox-toolbar__primary') + text_area_frame = browser.element('.tox-edit-area__iframe') + text_area_frame_context = text_area_frame.frame_context # 💡↙️ + # the following var will only work if used after the switch to the frame + text_area = browser.element('#tinymce') # ❗️ inside the frame + + browser.open('https://the-internet.herokuapp.com/iframe') + + with text_area_frame_context: # ⬅️ + text_area.perform(command.select_all) + + toolbar.element('[title=Bold]').click() + + with text_area_frame_context: # ⬅️ + text_area.element('p').should( + have.js_property('innerHTML').value( + 'Your content goes here.' + ) + ) + ``` + + ## Example: Usage utilizing the lazy nature of the query without get method: + + Since the query application is fully lazy + (laziness ends only on `with` statement), + you can use it directly, without `get` method: + + ```python + from selene import browser, query, command, have + + toolbar = browser.element('.tox-toolbar__primary') + text_area_frame = browser.element('.tox-edit-area__iframe') + # text_area_frame_context = _FrameContext(text_area_frame) + # OR: + text_area_frame_context = query.frame_context(text_area_frame) # 💡↙️ + # the following var will only work if used after the switch to the frame + text_area = browser.element('#tinymce') + + browser.open('https://the-internet.herokuapp.com/iframe') + + with text_area_frame_context: # ⬅️ + text_area.perform(command.select_all) + + toolbar.element('[title=Bold]').click() + + with text_area_frame_context: # ⬅️ + text_area.element('p').should( + have.js_property('innerHTML').value( + 'Your content goes here.' + ) + ) + ``` + + ## Example: Nested with statements for nested frames + + ```python + from selene import browser, have, query, be + + # GIVEN opened browser + browser.open('https://the-internet.herokuapp.com/nested_frames') + + # WHEN + with browser.element('[name=frame-top]').frame_context: + with browser.element('[name=frame-middle]').frame_context: + browser.element( + '#content', + # THEN + ).should(have.exact_text('MIDDLE')) + # AND + browser.element('[name=frame-right]').should(be.visible) + ``` + + ## Example: Usage utilizing the [within][selene.web._elements._FrameContext.within] decorator for PageObjects: + + See example at [within][selene.web._elements._FrameContext.within] section. + """ + + def __init__(self, element: Element): + self._container = element + self.__entered = False + + def decorator(self, func): + """A decorator to mark a function as a step within context manager + + See example of usage at [within][selene.web._elements._FrameContext.within] section. + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + with self: + return func(*args, **kwargs) + + return wrapper + + # aliases :) TODO: not sure which to keep + _step = decorator + _steps = decorator + _content = decorator + _inside = decorator + _inner = decorator + within = decorator + """An alias to [`decorator`][selene.web._elements._FrameContext.decorator] + + Example of usage: + + ```python + from selene import browser, command, have, query + + + def teardown_function(): + browser.quit() + + + class WYSIWYG: + toolbar = browser.element('.tox-toolbar__primary') + text_area_frame = browser.element('.tox-edit-area__iframe').frame_context # 💡⬇️ + text_area = browser.element('#tinymce') + + def open(self): + browser.open('https://the-internet.herokuapp.com/iframe') + return self + + def set_bold(self): + self.toolbar.element('[title=Bold]').click() + return self + + @text_area_frame.within # ⬅️ + def should_have_text_html(self, text_html): + self.text_area.should(have.js_property('innerHTML').value(text_html)) + return self + + @text_area_frame.within # ⬅️ + def select_all_text(self): + self.text_area.perform(command.select_all) + return self + + @text_area_frame.within # ⬅️ + def reset_to(self, text): + self.text_area.perform(command.select_all).type(text) + return self + + + def test_page_object_steps_within_frame_context(): + wysiwyg = WYSIWYG().open() + + wysiwyg.should_have_text_html( + '

Your content goes here.

', + ).select_all_text().set_bold().should_have_text_html( + '

Your content goes here.

', + ) + + wysiwyg.reset_to('New content').should_have_text_html( + '

New content

', + ) + ``` + """ + + def __enter__(self): + if not self.__entered: + self._container.wait.with_( + # resetting wait decorator to default + # in order to avoid automatic exit applied to each command + # including switching to the frame + # that (automatic exit) was added after self.element + # (this fixes breaking exiting from the frame in nested frame context) + decorator=None, + ).for_( + Command( + 'switch to frame', + lambda entity: entity.config.driver.switch_to.frame( + entity.locate() + ), + ) + ) + self.__entered = True + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.__entered: + driver = self._container.config.driver + + # we intentionally use parent_frame() over default_content() + # to make it work for nested frames + # (in case of "root frames" parent_frame() should work as default_content()) + driver.switch_to.parent_frame() + self.__entered = False + + @property + def __as_wait_decorator(self): + if self._container.config._wait_decorator is None: + return support._wait.with_(context=self) + + def composed_wait_decorator(wait): + def decorator(for_): + original_wait_decorator = self._container.config._wait_decorator + context_wait_decorator = support._wait.with_(context=self) + + for_decorator_after_context = context_wait_decorator(wait) + for_decorator_after_original = original_wait_decorator(wait) + + # by applying context decorator first (i.e. closer to the function call) + # we actually make it second in the chain + for_after_context = for_decorator_after_context(for_) + + # – because lastly applied decorator will contain the first code + # to be executed before the decorated function + for_after_context_then_original = for_decorator_after_original( + for_after_context + ) + + # – so, given original decorator is a logging decorator + # first we log the command, + # and then we actually switch to context before running the command + # ! This is very important because switching context for us + # ! is a low level command, that's why it should be "logged as second" + # ! that in reports like allure will also be "nested" on a deeper level + return for_after_context_then_original + + return decorator + + return composed_wait_decorator + + def element(self, selector: str | typing.Tuple[str, str]) -> Element: + """Allows to search for a first element by selector inside the frame context + with implicit switching to the frame and back for each method execution. + + Is lazy, i.e. does not switch to the frame immediately on calling this method, + and so can be stored in a variable and used later. + + Args: + selector: css or xpath as string or classic selenium tuple-like locator, + e.g. `('css selector', '.some-class')` + or `(By.CSS_SELECTOR, '.some-class')` + + !!! warning + By adding implicit switching to the frame and back + for each command executed on entity, it makes the usage of such entity + slower in case of a lot of commands to be executed + all together inside the frame. + + It becomes especially important in case of nested frames. + In such cases, if you use + `entity.get(query.frame_context)` over `query.frame_context(entity)` + or `entity.frame_context` then try to keep turned on the option: + [config._disable_wait_decorator_on_get_query][selene.core.configuration.Config._disable_wait_decorator_on_get_query] + That will help to avoid re-switching at least on `get` calls. + + If you notice performance drawbacks, consider choosing an explicit way + to work with frame context as a context manager passed to `with` statement. + """ + by = self._container.config._selector_or_by_to_by(selector) + + return Element( + Locator( + f'{self._container}: element({by})', + # f'{self._container} {{ element({by}) }}', # TODO: maybe this? + lambda: self._container.config.driver.find_element(*by), + ), + self._container.config.with_(_wait_decorator=self.__as_wait_decorator), + ) + + def all(self, selector: str | typing.Tuple[str, str]) -> Collection: + """Allows to search for all elements by selector inside the frame context + with implicit switching to the frame and back for each method execution. + + Is lazy, i.e. does not switch to the frame immediately on calling this method, + and so can be stored in a variable and used later. + + Args: + selector: css or xpath as string or classic selenium tuple-like locator, + e.g. `('css selector', '.some-class')` + or `(By.CSS_SELECTOR, '.some-class')` + + !!! warning + Same "potential performance drawbacks" warning is applied here + as for [element][selene.web._elements._FrameContext.element] method. + """ + by = self._container.config._selector_or_by_to_by(selector) + + return Collection( + Locator( + f'{self._container}: all({by})', + lambda: self._container.config.driver.find_elements(*by), + ), + self._container.config.with_(_wait_decorator=self.__as_wait_decorator), + ) diff --git a/selene/web/_elements.pyi b/selene/web/_elements.pyi index e8f31bdc8..0f08f96fe 100644 --- a/selene/web/_elements.pyi +++ b/selene/web/_elements.pyi @@ -26,6 +26,58 @@ from selene.core.wait import Wait E = TypeVar('E', bound='Assertable') R = TypeVar('R') +@typing.runtime_checkable +class _SearchContext(typing.Protocol): + def find_element(self, by: str, value: str | None = None) -> WebElement: ... + def find_elements( + self, by: str, value: str | None = None + ) -> typing.List[WebElement]: ... + +class _ElementsContext(WaitingEntity['_ElementsContext']): + """An Element-like class that serves as pure context for search elements inside + via `element(selector_or_by)` or `all(selector_or_by)` methods""" + + def __init__(self, locator: Locator[_SearchContext], config: Config): ... + + # --- Configured --- # + + def with_( + self, config: Optional[Config] = None, **config_as_kwargs + ) -> _ElementsContext: ... + + # --- Located --- # + + def __str__(self): ... + def locate(self) -> _SearchContext: ... + @property + def __raw__(self) -> _SearchContext: ... + def __call__(self) -> _SearchContext: ... + + # --- WaitingEntity --- # + + @property + def wait(self) -> Wait[_ElementsContext]: ... + @property + def cached(self) -> _ElementsContext: ... + + # --- Relative location --- # + + def element(self, selector_or_by: Union[str, Tuple[str, str]], /) -> Element: + by = self.config._selector_or_by_to_by(selector_or_by) + + return Element( + Locator(f'{self}.element({by})', lambda: self().find_element(*by)), + self.config, + ) + + def all(self, selector_or_by: Union[str, Tuple[str, str]], /) -> Collection: + by = self.config._selector_or_by_to_by(selector_or_by) + + return Collection( + Locator(f'{self}.all({by})', lambda: self().find_elements(*by)), + self.config, + ) + class Element(WaitingEntity['Element']): def __init__(self, locator: Locator[WebElement], config: Config) -> None: ... def with_( @@ -77,6 +129,10 @@ class Element(WaitingEntity['Element']): def cached(self) -> Element: ... def element(self, css_or_xpath_or_by: Union[str, Tuple[str, str]]) -> Element: ... def all(self, css_or_xpath_or_by: Union[str, Tuple[str, str]]) -> Collection: ... + @property + def shadow_root(self) -> _ElementsContext: ... + @property + def frame_context(self) -> _FrameContext: ... def execute_script(self, script_on_self: str, *arguments): ... def set_value(self, value: Union[str, int]) -> Element: ... def set(self, value: Union[str, int]) -> Element: ... @@ -187,6 +243,27 @@ class Collection(WaitingEntity['Collection'], Iterable[Element]): ) -> Collection: ... def all(self, selector: Union[str, Tuple[str, str]]) -> Collection: ... def all_first(self, selector: Union[str, Tuple[str, str]]) -> Collection: ... + # --- Unique for Web --- # + @property + def shadow_roots(self) -> Collection: ... AllElements = Collection All = AllElements + +class _FrameContext: + def __init__(self, element: Element): ... + def decorator(self, func): ... + + # aliases :) TODO: not sure which to keep + def _step(self, func): ... + def _steps(self, func): ... + def _content(self, func): ... + def _inside(self, func): ... + def _inner(self, func): ... + def within(self, func): ... + def __enter__(self): ... + def __exit__(self, exc_type, exc_val, exc_tb): ... + @property + def __as_wait_decorator(self): ... + def element(self, selector: str | typing.Tuple[str, str]) -> Element: ... + def all(self, selector: str | typing.Tuple[str, str]) -> Collection: ... diff --git a/tests/integration/element__get__query__frame_context__decorator_test.py b/tests/integration/element__get__query__frame_context__decorator_test.py index 703d9f665..355e07d1c 100644 --- a/tests/integration/element__get__query__frame_context__decorator_test.py +++ b/tests/integration/element__get__query__frame_context__decorator_test.py @@ -30,7 +30,7 @@ def teardown_function(): class WYSIWYG: toolbar = browser.element('.tox-toolbar__primary') - text_area_frame = query._frame_context(browser.element('.tox-edit-area__iframe')) + text_area_frame = browser.element('.tox-edit-area__iframe').frame_context text_area = browser.element('#tinymce') def open(self): @@ -41,17 +41,17 @@ def set_bold(self): self.toolbar.element('[aria-label=Bold]').click() return self - @text_area_frame._within + @text_area_frame.within def should_have_text_html(self, text_html): self.text_area.should(have.property_('innerHTML').value(text_html)) return self - @text_area_frame._within + @text_area_frame.within def select_all_text(self): self.text_area.perform(command.select_all) return self - @text_area_frame._within + @text_area_frame.within def reset_to(self, text): self.text_area.perform(command.select_all).type(text) return self diff --git a/tests/integration/element__get__query__frame_context__element_test.py b/tests/integration/element__get__query__frame_context__element_test.py index 25be4e441..404c49dce 100644 --- a/tests/integration/element__get__query__frame_context__element_test.py +++ b/tests/integration/element__get__query__frame_context__element_test.py @@ -23,6 +23,7 @@ import pytest +import selene.web._elements from selene import command, have, query, support from tests import const @@ -75,10 +76,8 @@ def test_actions_on_frame_element_with_logging(session_browser): # GIVEN even before opened browser toolbar = browser.element('.tox-toolbar__primary') - text_area_frame = browser.element('.tox-edit-area__iframe').get( - query._frame_context - ) - text_area = text_area_frame._element('#tinymce') + text_area_frame = browser.element('.tox-edit-area__iframe').frame_context + text_area = text_area_frame.element('#tinymce') ''' # TODO: consider Option B: text_area = browser._frame('.tox-edit-area__iframe').element('#tinymce') diff --git a/tests/integration/element__get__query__frame_context__nested__element_test.py b/tests/integration/element__get__query__frame_context__nested__element_test.py index c23138c9d..d4aaec2c6 100644 --- a/tests/integration/element__get__query__frame_context__nested__element_test.py +++ b/tests/integration/element__get__query__frame_context__nested__element_test.py @@ -21,6 +21,7 @@ # SOFTWARE. import pytest +import selene.web._elements from selene import command, have, query, be @@ -33,9 +34,9 @@ def test_actions_on_nested_frames_element_via_search_context_via_get( browser.open('https://the-internet.herokuapp.com/nested_frames') # WHEN - browser.element('[name=frame-top]').get(query._frame_context)._element( + browser.element('[name=frame-top]').frame_context.element( '[name=frame-middle]' - ).get(query._frame_context)._element( + ).frame_context.element( '#content', # THEN ).should( @@ -44,13 +45,9 @@ def test_actions_on_nested_frames_element_via_search_context_via_get( # WHEN failed try: - browser.element('[name=frame-top]').get(query._frame_context)._element( + browser.element('[name=frame-top]').frame_context.element( '[name=frame-middle]' - ).get(query._frame_context)._element( - '#content', - ).should( - have.exact_text('LEFT') - ) + ).frame_context.element('#content',).should(have.exact_text('LEFT')) pytest.fail('should have failed on text mismatch') except AssertionError as error: # THEN @@ -75,32 +72,28 @@ def test_actions_on_nested_frames_element_via_search_context__via_direct_applica browser.open('https://the-internet.herokuapp.com/nested_frames') # WHEN - query._frame_context( - query._frame_context( + query.frame_context( + query.frame_context( browser.with_(timeout=0.0).element('[name=frame-top]') - )._element('[name=frame-middle]') - )._element( + ).element('[name=frame-middle]') + ).element( '#content', # THEN ).should( have.exact_text('MIDDLE') ) - # query._frame_context( - # query._frame_context( + # query.frame_context( + # query.frame_context( # browser.with_(timeout=0.0).element('[name=frame-top]') - # )._element('[name=frame-middle]') - # )._element('#content').should(have.exact_text('MIDDLE')) + # ).element('[name=frame-middle]') + # ).element('#content').should(have.exact_text('MIDDLE')) # WHEN failed try: - browser.element('[name=frame-top]').get(query._frame_context)._element( + browser.element('[name=frame-top]').frame_context.element( '[name=frame-middle]' - ).get(query._frame_context)._element( - '#content', - ).should( - have.exact_text('LEFT') - ) + ).frame_context.element('#content',).should(have.exact_text('LEFT')) pytest.fail('should have failed on text mismatch') except AssertionError as error: # THEN diff --git a/tests/integration/element__get__query__frame_context__nested__with_test.py b/tests/integration/element__get__query__frame_context__nested__with_test.py index 855b847b8..27d2036ed 100644 --- a/tests/integration/element__get__query__frame_context__nested__with_test.py +++ b/tests/integration/element__get__query__frame_context__nested__with_test.py @@ -21,7 +21,7 @@ # SOFTWARE. import pytest -from selene import command, have, query, be +from selene import have, be # TODO: break down into 2 tests @@ -32,8 +32,8 @@ def test_actions_on_nested_frames_element_via_with_statement(session_browser): browser.open('https://the-internet.herokuapp.com/nested_frames') # WHEN - with browser.element('[name=frame-top]').get(query._frame_context): - with browser.element('[name=frame-middle]').get(query._frame_context): + with browser.element('[name=frame-top]').frame_context: + with browser.element('[name=frame-middle]').frame_context: browser.element( '#content', # THEN @@ -43,8 +43,8 @@ def test_actions_on_nested_frames_element_via_with_statement(session_browser): # WHEN failed try: - with browser.element('[name=frame-top]').get(query._frame_context): - with browser.element('[name=frame-middle]').get(query._frame_context): + with browser.element('[name=frame-top]').frame_context: + with browser.element('[name=frame-middle]').frame_context: browser.element( '#content', ).should(have.exact_text('LEFT')) diff --git a/tests/integration/element__get__query__frame_context__with_test.py b/tests/integration/element__get__query__frame_context__with_test.py index 7f5031373..bd75e7b11 100644 --- a/tests/integration/element__get__query__frame_context__with_test.py +++ b/tests/integration/element__get__query__frame_context__with_test.py @@ -19,7 +19,7 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. - +import selene.web._elements from selene import command, have, query from tests import const @@ -31,7 +31,7 @@ def test_actions_within_frame_context(session_browser): toolbar = browser.element('.tox-toolbar__primary') text_area_frame = browser.element('.tox-edit-area__iframe') - text_area_frame_context = query._frame_context(text_area_frame) # THEN lazy;) + text_area_frame_context = text_area_frame.frame_context # THEN lazy;) text_area = browser.element('#tinymce') # WHEN diff --git a/tests/integration/element__get__query__js__shadow_root__all_elements_test.py b/tests/integration/element__get__query__js__shadow_root__all_elements_test.py index df7a8761e..f19215b73 100644 --- a/tests/integration/element__get__query__js__shadow_root__all_elements_test.py +++ b/tests/integration/element__get__query__js__shadow_root__all_elements_test.py @@ -19,11 +19,9 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import logging - import pytest -from selene import command, have, query, support +from selene import have, query def test_actions_on_shadow_roots_of_all_elements(session_browser): @@ -56,7 +54,7 @@ def test_actions_on_shadow_roots_of_all_elements(session_browser): 'Message: \n' '\n' 'Timed out after 0.5s, while waiting for:\n' - "browser.all(('css selector', 'my-paragraph')): shadow roots.all(('css " + "browser.all(('css selector', 'my-paragraph')).shadow roots.all(('css " "selector', '[name=my-text]')).have exact texts ['My WRONG text', 'My WRONG " "text']\n" '\n' diff --git a/tests/integration/element__get__query__js__shadow_root__element_test.py b/tests/integration/element__get__query__js__shadow_root__element_test.py index 4ee9f7dd3..1ddf28e85 100644 --- a/tests/integration/element__get__query__js__shadow_root__element_test.py +++ b/tests/integration/element__get__query__js__shadow_root__element_test.py @@ -19,11 +19,9 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import logging - import pytest -from selene import command, have, query, support +from selene import have, query def test_actions_on_shadow_root_element(session_browser): @@ -57,7 +55,7 @@ def test_actions_on_shadow_root_element(session_browser): 'Message: \n' '\n' 'Timed out after 0.5s, while waiting for:\n' - "browser.all(('css selector', 'my-paragraph'))[0]: shadow root.element(('css " + "browser.all(('css selector', 'my-paragraph'))[0].shadow root.element(('css " "selector', '[name=my-text]')).has exact text 'My WRONG text'\n" '\n' 'Reason: ConditionMismatch: actual text: My default text\n' diff --git a/tests/integration/elements__shadow_root__element_test.py b/tests/integration/elements__shadow_root__element_test.py new file mode 100644 index 000000000..cb120dd9f --- /dev/null +++ b/tests/integration/elements__shadow_root__element_test.py @@ -0,0 +1,101 @@ +# MIT License +# +# Copyright (c) 2024 Iakiv Kramarenko +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import pytest + +from selene import have, query + + +def test_actions_on_shadow_root_element(session_browser): + # GIVEN + browser = session_browser.with_(timeout=0.5) + paragraphs = browser.all('my-paragraph') + + # WHEN even before opened browser + paragraph_1_shadow = paragraphs.first.shadow_root + paragraph_2_shadow = paragraphs.second.shadow_root + my_shadowed_text_1 = paragraph_1_shadow.element('[name=my-text]') + my_shadowed_text_2 = paragraph_2_shadow.element('[name=my-text]') + # AND + browser.open('https://the-internet.herokuapp.com/shadowdom') + + # THEN + paragraphs.first.should(have.exact_text("Let's have some different text!")) + my_shadowed_text_1.should(have.exact_text("My default text")) + paragraphs.second.should( + have.exact_text("Let's have some different text!\nIn a list!") + ) + my_shadowed_text_2.should(have.exact_text("My default text")) + + # WHEN failed + try: + my_shadowed_text_1.should(have.exact_text("My WRONG text")) + pytest.fail('should have failed on size mismatch') + except AssertionError as error: + # THEN + assert ( + 'Message: \n' + '\n' + 'Timed out after 0.5s, while waiting for:\n' + "browser.all(('css selector', 'my-paragraph'))[0].shadow root.element(('css " + "selector', '[name=my-text]')).has exact text 'My WRONG text'\n" + '\n' + 'Reason: ConditionMismatch: actual text: My default text\n' + ) in str(error) + + +def test_actions_on_shadow_roots_elements(session_browser): + # GIVEN + browser = session_browser.with_(timeout=0.5) + paragraphs = browser.all('my-paragraph') + + # WHEN even before opened browser + paragraph_1_shadow = paragraphs.shadow_roots.first + paragraph_2_shadow = paragraphs.shadow_roots.second + my_shadowed_text_1 = paragraph_1_shadow.element('[name=my-text]') + my_shadowed_text_2 = paragraph_2_shadow.element('[name=my-text]') + # AND + browser.open('https://the-internet.herokuapp.com/shadowdom') + + # THEN + paragraphs.shadow_roots.should(have.size(2)) + paragraphs.first.should(have.exact_text("Let's have some different text!")) + my_shadowed_text_1.should(have.exact_text("My default text")) + paragraphs.second.should( + have.exact_text("Let's have some different text!\nIn a list!") + ) + my_shadowed_text_2.should(have.exact_text("My default text")) + + # WHEN failed + try: + my_shadowed_text_1.should(have.exact_text("My WRONG text")) + pytest.fail('should have failed on size mismatch') + except AssertionError as error: + # THEN + assert ( + 'Message: \n' + '\n' + 'Timed out after 0.5s, while waiting for:\n' + "browser.all(('css selector', 'my-paragraph')).shadow roots[0].element(('css " + "selector', '[name=my-text]')).has exact text 'My WRONG text'\n" + '\n' + 'Reason: ConditionMismatch: actual text: My default text\n' + ) in str(error)