diff --git a/.run/lint_pylint.sh b/.run/lint_pylint.sh index d56523cf..bf646c63 100755 --- a/.run/lint_pylint.sh +++ b/.run/lint_pylint.sh @@ -1,5 +1,5 @@ #!/bin/bash touch __init__.py -pylint $(pwd) --disable="$(cat .pylint-disabled-rules)" --ignore-patterns=.venv +pylint $(pwd) --disable="$(cat .pylint-disabled-rules)" --ignore-patterns=.venv -r n rm __init__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec80f0c..aa9b0430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,11 +110,13 @@ TODOs: ### TODO: document subclass based custom conditions -## 2.0.0rc10: «copy&paste, frames, shadow & texts_like» (to be released on DD.05.2024) +### TODO: should we help users do not shoot their legs when using browser.all(selector) in for loops? #534 ### TODO: not_ as callable object? -### TODO: should we help users do not shoot their legs when using browser.all(selector) in for loops? #534 +## 2.0.0rc10: «copy&paste, frames, shadow & texts_like» (to be released on DD.05.2024) + +### TODO: finalize pylint update to 3.2.2. (clean all warnings, especially for pyproject.toml) ### TODO: rename all conditions inside match.py so match can be fully used instead be + have #530 @@ -122,6 +124,10 @@ TODOs: ... +#### TODO: finalize error messages tests for present, visible, hidden + +#### TODO: decide on present vs present_in_dom (same for absent) + ### TODO: ENSURE composed conditions work as expected (or/and, etc.) ... diff --git a/selene/common/_typing_functions.py b/selene/common/_typing_functions.py index 74948c66..5ef0edfa 100644 --- a/selene/common/_typing_functions.py +++ b/selene/common/_typing_functions.py @@ -21,11 +21,18 @@ # SOFTWARE. from __future__ import annotations -import functools import inspect import re -from typing_extensions import TypeVar, Callable, Generic, Optional, overload +from typing_extensions import ( + TypeVar, + Callable, + Generic, + Optional, + overload, + Type, + Iterable, +) from selene.common.fp import thread_last @@ -108,26 +115,41 @@ def full_description_for(callable_: Optional[Callable]) -> str | None: @staticmethod @overload - def _inverted(predicate: Predicate[E]) -> Predicate[E]: ... + def _inverted( + predicate: Predicate[E], + _truthy_exceptions: Iterable[Type[Exception]] = (), + ) -> Predicate[E]: ... @staticmethod @overload - def _inverted(predicate: Query[E, bool]) -> Query[E, bool]: ... + def _inverted( + predicate: Query[E, bool], + _truthy_exceptions: Iterable[Type[Exception]] = (), + ) -> Query[E, bool]: ... @staticmethod def _inverted( - predicate: Predicate[E] | Query[E, bool] + predicate: Predicate[E] | Query[E, bool], + _truthy_exceptions: Iterable[Type[Exception]] = (), ) -> Predicate[E] | Query[E, bool]: # TODO: ensure it works correctly:) e.g. unit test it + + def not_predicate(entity: E) -> bool: + try: + return not predicate(entity) + except Exception as reason: + if any( + isinstance(reason, exception) for exception in _truthy_exceptions + ): + return True + raise reason + if isinstance(predicate, Query): return Query( f'not {predicate}', - lambda entity: not predicate(entity), + not_predicate, ) - def not_predicate(entity: E) -> bool: - return not predicate(entity) - not_predicate.__module__ = predicate.__module__ not_predicate.__annotations__ = predicate.__annotations__ diff --git a/selene/core/condition.py b/selene/core/condition.py index 6c591528..8ed51480 100644 --- a/selene/core/condition.py +++ b/selene/core/condition.py @@ -431,10 +431,12 @@ def is_more_than(limit): """ from __future__ import annotations +import functools import sys import typing import warnings +from selenium.common import WebDriverException from typing_extensions import ( List, TypeVar, @@ -726,6 +728,11 @@ def as_not( # TODO: ENSURE ALL USAGES ARE NOW CORRECT ) -> Condition[E]: # TODO: how will it work composed conditions? + # TODO: should we bother? – about "negated inversion via Condition.as_not" + # will "swallow" the reason of failure... + # because we invert the predicate or test itself, ignoring exceptions + # so then when we "recover original exception failure" on negation + # we can just recover it to "false" not to "raise reason error" if description: return ( cls( @@ -736,11 +743,23 @@ def as_not( # TODO: ENSURE ALL USAGES ARE NOW CORRECT # thus, no need to mark condition for further inversion: _inverted=False, ) - if not condition.__by + if condition.__by is None else cls( description, - actual=condition.__actual, - by=Query._inverted(condition.__by), + # # We have to skip the actual here (re-building it into by below), + # # because can't "truthify" its Exceptions when raised on inverted + # # TODO: or can we? + # actual=condition.__actual, + by=Query._inverted( + functools.wraps(condition.__by)( + lambda entity: condition.__by( # type: ignore + condition.__actual(entity) + if condition.__actual + else entity + ) + ), + _truthy_exceptions=(AssertionError, WebDriverException), + ), _inverted=False, ) ) @@ -826,9 +845,15 @@ def __init__( ConditionMismatch._to_raise_if_actual( self.__actual, self.__by, + # TODO: should we DI? – remove this tight coupling to WebDriverException? + # here and elsewhere + _falsy_exceptions=(AssertionError, WebDriverException), ) if self.__actual - else ConditionMismatch._to_raise_if(self.__by) + else ConditionMismatch._to_raise_if( + self.__by, + _falsy_exceptions=(AssertionError, WebDriverException), + ) ) return @@ -854,7 +879,7 @@ def as_inverted(entity: E) -> None: return raise ValueError( - 'either test or by with optional actual should be provided, ' 'not nothing' + 'either test or by with optional actual should be provided, not nothing' ) # TODO: rethink not_ naming... @@ -913,6 +938,8 @@ def __describe(self) -> str: def __describe_inverted(self) -> str: condition_words = self.__describe().split(' ') is_or_have = condition_words[0] + if is_or_have not in ('is', 'has', 'have'): + return f'not ({self.__describe()})' name = ' '.join(condition_words[1:]) no_or_not = 'not' if is_or_have == 'is' else 'no' return f'{is_or_have} {no_or_not} ({name})' @@ -1312,11 +1339,8 @@ def __init__x(self, *args, **kwargs): 'or custom __str__ implementation ' '(like lambda wrapped in Query object)' ) - description = ( - (str(actual_desc) + ' ') - if (actual_desc := Query.full_description_for(actual)) is not None - else '' - ) + str( + actual_desc = Query.full_description_for(actual) + description = ((str(actual_desc) + ' ') if actual_desc else '') + str( by_description ) # noqa super().__init__(description, actual=actual, by=by) @@ -1324,12 +1348,48 @@ def __init__x(self, *args, **kwargs): raise ValueError('invalid arguments to Match initializer') + @overload + def __init__( + self, + description: str | Callable[[], str], + actual: Lambda[E, R], + *, + by: Predicate[R], + _inverted=False, + ): ... + + @overload + def __init__( + self, + description: str | Callable[[], str], + *, + by: Predicate[E], + _inverted=False, + ): ... + + @overload + def __init__( + self, + *, + actual: Lambda[E, R], + by: Predicate[R], + _inverted=False, + ): ... + + @overload + def __init__( + self, + *, + by: Predicate[E], + _inverted=False, + ): ... + def __init__( self, description: str | Callable[[], str] | None = None, actual: Lambda[E, R] | None = None, *, - by: Predicate[R], + by: Predicate[E] | Predicate[R], _inverted=False, ): """ @@ -1345,20 +1405,18 @@ def __init__( """ if not description and not (by_description := Query.full_description_for(by)): raise ValueError( - 'either provide description or ensure that at least by predicate' - 'has __qualname__ (defined as regular named function)' + 'either provide description or ensure that at least by predicate ' + 'has __qualname__ (defined as regular named function) ' 'or custom __str__ implementation ' '(like lambda wrapped in Query object)' ) + actual_desc = Query.full_description_for(actual) description = description or ( - ( - (str(actual_desc) + ' ') - if (actual_desc := Query.full_description_for(actual)) is not None - else '' - ) + ((str(actual_desc) + ' ') if actual_desc else '') + str(by_description) # noqa ) - super().__init__( + # TODO: fix "cannot infer type of argument 1 of __init__" or ignore + super().__init__( # type: ignore description=description, actual=actual, by=by, diff --git a/selene/core/exceptions.py b/selene/core/exceptions.py index 20c15be3..95d06543 100644 --- a/selene/core/exceptions.py +++ b/selene/core/exceptions.py @@ -113,6 +113,7 @@ def _to_raise_if_not( by: Callable[[E], bool], *, _inverted: bool = False, + _falsy_exceptions: Iterable[Type[Exception]] = (AssertionError,), ): ... @classmethod @@ -123,6 +124,7 @@ def _to_raise_if_not( actual: Callable[[E], R] | None = None, *, _inverted: bool = False, + _falsy_exceptions: Iterable[Type[Exception]] = (AssertionError,), ): ... # TODO: should we name test param as predicate? @@ -133,42 +135,18 @@ def _to_raise_if_not( actual: Optional[Callable[[E], E | R]] = None, *, _inverted: Optional[bool] = False, - _falsy_exceptions: Iterable[Type[Exception]] = (), + # TODO: should we rename it to _exceptions_as_truthy_on_inverted? + # or just document this in docstring? + _falsy_exceptions: Iterable[Type[Exception]] = (AssertionError,), ): @functools.wraps(by) def wrapped(entity: E) -> None: - actual_description = ( - f' {name}' if (name := Query.full_description_for(actual)) else '' - ) - # TODO: should we catch errors on actual? - # for e.g. to consider them as False indicator - actual_to_test = None - # error_on_actual = None - try: - actual_to_test = actual(entity) if actual else entity - except Exception as reason: - # error_on_actual = reason - if _inverted: - return - raise reason - - answer = None - error_on_predicate = None - try: - answer = by(actual_to_test) - # TODO: should we move Exception processing out of this helper? - # should it be somewhere in Condition? - # cause now it's not a Mismatch anymore, it's a failure - # – no, we should not, we should keep it here, - # because this is needed for the inverted case - except Exception as reason: - error_on_predicate = reason - # answer is still None - pass - - def describe_not_match(): + def describe_not_match(actual_value): + actual_description = ( + f' {name}' if (name := Query.full_description_for(actual)) else '' + ) return ( - f'actual{actual_description}: {actual_to_test}' + f'actual{actual_description}: {actual_value}' if actual else ( ( @@ -180,6 +158,10 @@ def describe_not_match(): or "condition" ) + ' not matched' + # TODO: should we consider eliminating errors like: + # 'Reason: ConditionMismatch: condition not matched\n' + # to: + # 'Reason: ConditionMismatch\n' ) # TODO: decide on # cls(f'{Query.full_name_for(predicate) or "condition"} not matched') @@ -187,29 +169,59 @@ def describe_not_match(): # else cls('condition not matched') ) - # TODO: should we raise InvalidCompare on _inverted too? - # should we make it configurable? - # if not _inverted and error_on_predicate: - if error_on_predicate and type(error_on_predicate) not in _falsy_exceptions: + def describe_error(error): # TODO: consider making it customizable # remove stacktrace if available: - stacktrace = getattr(error_on_predicate, 'stacktrace', None) - error_on_predicate_str = ( - str(error_on_predicate) + stacktrace = getattr(error, 'stacktrace', None) + return ( + str(error) if not stacktrace - else (''.join(str(error_on_predicate).split(stacktrace))) + else ( + ''.join( + str(error).split('\n'.join(['Stacktrace:', *stacktrace])) + ) + ) ) + + # TODO: should we catch errors on actual? + # for e.g. to consider them as False indicator + actual_to_test = None + try: + actual_to_test = actual(entity) if actual else entity + except Exception as reason: + if _inverted and any( + isinstance(reason, exception) for exception in _falsy_exceptions + ): + return + # TODO: do we even need this prefix? + # raise cls(f'Unable to get actual to match:\n{describe_error(reason)}') + raise cls(describe_error(reason)) from reason + + answer = None + try: + answer = by(actual_to_test) + # TODO: should we move Exception processing out of this helper? + # should it be somewhere in Condition? + # cause now it's not a Mismatch anymore, it's a failure + # – no, we should not, we should keep it here, + # because this is needed for the inverted case + except Exception as reason: + if _inverted and any( + isinstance(reason, exception) for exception in _falsy_exceptions + ): + return + # answer is still None raise cls( - 'InvalidCompareError: ' - f'{error_on_predicate_str}:\n{describe_not_match()}' - ) + f'{describe_error(reason)}:' + f'\n{describe_not_match(actual_to_test)}' + ) from reason if answer if _inverted else not answer: # TODO: should we render expected too? (based on predicate name) # we want need it for our conditions, # cause wait.py logs it in the message # but ... ? - raise cls(describe_not_match()) + raise cls(describe_not_match(actual_to_test)) return wrapped @@ -218,8 +230,11 @@ def _to_raise_if( cls, by: Callable[[E | R], bool], actual: Callable[[E], R] | None = None, + _falsy_exceptions: Iterable[Type[Exception]] = (AssertionError,), ): - return cls._to_raise_if_not(by, actual, _inverted=True) + return cls._to_raise_if_not( + by, actual, _inverted=True, _falsy_exceptions=_falsy_exceptions + ) @classmethod def _to_raise_if_not_actual( @@ -234,8 +249,9 @@ def _to_raise_if_actual( cls, query: Callable[[E], R], by: Callable[[R], bool], + _falsy_exceptions: Iterable[Type[Exception]] = (AssertionError,), ): - return cls._to_raise_if(by, query) + return cls._to_raise_if(by, query, _falsy_exceptions=_falsy_exceptions) class ConditionNotMatchedError(ConditionMismatch): diff --git a/selene/core/match.py b/selene/core/match.py index 531b6c8a..69ea416a 100644 --- a/selene/core/match.py +++ b/selene/core/match.py @@ -55,34 +55,36 @@ ) from selene.core.entity import Collection, Element from selene.core._browser import Browser -from selene.common._typing_functions import Query - -# TODO: consider moving to selene.match.element.is_visible, etc... -element_is_visible: Condition[Element] = ElementCondition.raise_if_not( - 'is visible', lambda element: element().is_displayed() +# TODO: consider renaming to present_in_dom +present: Condition[Element] = Match( + 'is present in DOM', + actual=lambda element: element.locate(), + by=lambda webelement: webelement is not None, ) +# TODO: consider renaming to absent_in_dom +absent: Condition[Element] = Condition.as_not(present, 'is absent in DOM') -element_is_hidden: Condition[Element] = ElementCondition.as_not( - element_is_visible, 'is hidden' + +visible: Condition[Element] = Match( + 'is visible', + actual=lambda element: element.locate(), + by=lambda actual: actual.is_displayed(), ) +hidden: Condition[Element] = Condition.as_not(visible, 'is hidden') + +hidden_in_dom: Condition[Element] = present.and_(visible.not_) + + element_is_enabled: Condition[Element] = ElementCondition.raise_if_not( 'is enabled', lambda element: element().is_enabled() ) element_is_disabled: Condition[Element] = ElementCondition.as_not(element_is_enabled) -element_is_clickable: Condition[Element] = element_is_visible.and_(element_is_enabled) - -present: Condition[Element] = Match( - 'is present in DOM', - actual=lambda element: element.locate(), - by=lambda webelement: webelement is not None, -) - -element_is_absent: Condition[Element] = ElementCondition.as_not(present) +element_is_clickable: Condition[Element] = visible.and_(element_is_enabled) # TODO: how will it work for mobile? element_is_focused: Condition[Element] = ElementCondition.raise_if_not( @@ -166,6 +168,11 @@ def __init__(self, expected: str, _flags=0, _inverted=False): self.__expected = expected self.__flags = _flags self.__inverted = _inverted + # TODO: on invalid pattern error will be: + # 'Reason: ConditionMismatch: nothing to repeat at position 0' + # how to improve it? leaving more hints that this is "regex invalid error" + # probably, we can re-raise re.error inside predicate.matches + # with additional explanation super().__init__( f'has text matching{f" (with flags {_flags}):" if _flags else ""}' diff --git a/selene/support/conditions/be.py b/selene/support/conditions/be.py index ae7fc8dc..f8927b62 100644 --- a/selene/support/conditions/be.py +++ b/selene/support/conditions/be.py @@ -25,15 +25,16 @@ not_ = _not_ -visible = match.element_is_visible -hidden = match.element_is_hidden +visible = match.visible +hidden = match.hidden +hidden_in_dom = match.hidden_in_dom selected = match.element_is_selected present = match.present in_dom = match.present # TODO: do we need both present and in_dom? existing = match.present # TODO: consider deprecating -absent = match.element_is_absent +absent = match.absent enabled = match.element_is_enabled disabled = match.element_is_disabled diff --git a/selene/support/conditions/not_.py b/selene/support/conditions/not_.py index 26e83bd8..ea138d39 100644 --- a/selene/support/conditions/not_.py +++ b/selene/support/conditions/not_.py @@ -35,8 +35,9 @@ # TODO: consider refactoring to class for better extendability # when creating custom conditions -visible: Condition[Element] = _match.element_is_visible.not_ -hidden: Condition[Element] = _match.element_is_hidden.not_ +visible: Condition[Element] = _match.visible.not_ +hidden: Condition[Element] = _match.hidden.not_ +hidden_in_dom: Condition[Element] = _match.hidden_in_dom.not_ present: Condition[Element] = _match.present.not_ in_dom: Condition[Element] = _match.present.not_ @@ -44,7 +45,7 @@ # TODO: consider deprecating existing existing: Condition[Element] = _match.present.not_ -absent: Condition[Element] = _match.element_is_absent.not_ +absent: Condition[Element] = _match.absent.not_ enabled: Condition[Element] = _match.element_is_enabled.not_ disabled: Condition[Element] = _match.element_is_disabled.not_ diff --git a/tests/integration/condition__element__have_text_matching__compared_test.py b/tests/integration/condition__element__have_text_matching__compared_test.py index dafe6382..65009b0d 100644 --- a/tests/integration/condition__element__have_text_matching__compared_test.py +++ b/tests/integration/condition__element__have_text_matching__compared_test.py @@ -318,7 +318,7 @@ def test_text_matching__regex_pattern__error__on_invalid_regex__with_ignorecase( "browser.all(('css selector', 'li'))[0].has text matching (with flags " 're.IGNORECASE): *one*\n' '\n' - 'Reason: ConditionMismatch: InvalidCompareError: nothing to repeat at position ' + 'Reason: ConditionMismatch: nothing to repeat at position ' '0:\n' 'actual text: 1) One!!!\n' 'Screenshot: ' @@ -333,7 +333,7 @@ def test_text_matching__regex_pattern__error__on_invalid_regex__with_ignorecase( "browser.all(('css selector', 'li'))[0].has no (text matching (with flags " 're.IGNORECASE): *one*)\n' '\n' - 'Reason: ConditionMismatch: InvalidCompareError: nothing to repeat at position ' + 'Reason: ConditionMismatch: nothing to repeat at position ' '0:\n' 'actual text: 1) One!!!\n' 'Screenshot: ' @@ -347,7 +347,7 @@ def test_text_matching__regex_pattern__error__on_invalid_regex__with_ignorecase( "browser.all(('css selector', 'li'))[0].has no (text matching (with flags " 're.IGNORECASE): *one*)\n' '\n' - 'Reason: ConditionMismatch: InvalidCompareError: nothing to repeat at position ' + 'Reason: ConditionMismatch: nothing to repeat at position ' '0:\n' 'actual text: 1) One!!!\n' 'Screenshot: ' diff --git a/tests/integration/condition__element__present__via_inline_Match_test.py b/tests/integration/condition__element__present__via_inline_Match_test.py new file mode 100644 index 00000000..3ef1ac21 --- /dev/null +++ b/tests/integration/condition__element__present__via_inline_Match_test.py @@ -0,0 +1,235 @@ +# MIT License +# +# Copyright (c) 2015-2022 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 be, have +from selene.core import match +from selene.core.condition import Match, Condition +from selene.core.exceptions import ConditionMismatch +from tests.integration.helpers.givenpage import GivenPage + + +# TODO: review coverage: consider breaking down into atomic tests +def test_should_be_present__via_inline_Match__passed_and_failed(session_browser): + browser = session_browser.with_(timeout=0.1) + GivenPage(session_browser.driver).opened_with_body( + ''' + + + + ''' + ) + + absent = browser.element("#absent") + hidden = browser.element("#hidden") + # visible = browser.element("#visible") + + # THEN + # - with actual failure as True on inversion + absent.should( + Match( + 'present', + actual=lambda element: element.locate(), + by=lambda actual: actual is not None, + ).not_ + ) + # - with actual failure as True on inversion via Condition.as_not + absent.should( + Condition.as_not( + Match( + 'present', + actual=lambda element: element.locate(), + by=lambda actual: actual is not None, + ), + 'absent', + ) + ) + # - with actual failure + try: + absent.should( + Match( + 'present', + actual=lambda element: element.locate(), + by=lambda actual: actual is not None, + ) + ) + pytest.fail('expected failure') + except AssertionError as error: + assert ( + "browser.element(('css selector', '#absent')).present\n" + '\n' + 'Reason: ConditionMismatch: Message: no such element: Unable to locate ' + 'element: {"method":"css selector","selector":"#absent"}\n' + ' (Session info: chrome=125.0.6422.142); For documentation on this error, ' + 'please visit: ' + 'https://www.selenium.dev/documentation/webdriver/troubleshooting/errors#no-such-element-exception\n' + '\n' + ) in str(error) + # - with actual failure on negated inversion via Condition.as_not + # (with lost reason details) + try: + absent.should( + Condition.as_not( + Match( + 'present', + actual=lambda element: element.locate(), + by=lambda actual: actual is not None, + ), + 'absent', + ).not_ + ) + pytest.fail('expected failure') + except AssertionError as error: + assert ( + "browser.element(('css selector', '#absent')).not (absent)\n" + '\n' + 'Reason: ConditionMismatch: condition not matched\n' + ) in str(error) + # ↪ compared to ↙ + # - with actual failure but wrapped into test on negated inversion via Condition.as_not + # (YET with SAME lost reason details) TODO: should we bother? + try: + absent.should( + Condition.as_not( + Condition( + 'present', + test=ConditionMismatch._to_raise_if_not_actual( + query=lambda element: element.locate(), + by=lambda actual: actual is not None, + ), + ), + 'absent', + ).not_ + ) + pytest.fail('expected failure') + except AssertionError as error: + assert ( + "browser.element(('css selector', '#absent')).not (absent)\n" + '\n' + 'Reason: ConditionMismatch: condition not matched\n' + ) in str(error) + # ↪ compared to ↙ + # - with by failure on negated inversion via Condition.as_not + # (YET with SAME lost reason details) TODO: should we bother? + try: + absent.should( + Condition.as_not( + Match( + 'present', + by=lambda element: element.locate() is not None, + ), + 'absent', + ).not_ + ) + pytest.fail('expected failure') + except AssertionError as error: + assert ( + "browser.element(('css selector', '#absent')).not (absent)\n" + '\n' + 'Reason: ConditionMismatch: condition not matched\n' + ) in str(error) + # - with actual mismatch on inversion (without 'is' prefix in name) + try: + hidden.should( + Match( + 'present', + actual=lambda element: element.locate(), + by=lambda actual: actual is not None, + ).not_ + ) + pytest.fail('expected mismatch') + except AssertionError as error: + assert ( + "browser.element(('css selector', '#hidden')).not (present)\n" + '\n' + 'Reason: ConditionMismatch: actual: ' + '\n' + ) in str(error) + # - with actual mismatch on inversion (without 'is' prefix in name) + try: + hidden.should( + Match( + 'is present', + actual=lambda element: element.locate(), + by=lambda actual: actual is not None, + ).not_ + ) + pytest.fail('expected mismatch') + except AssertionError as error: + assert ( + "browser.element(('css selector', '#hidden')).is not (present)\n" + '\n' + 'Reason: ConditionMismatch: actual: ' + '\n' + ) in str(error) + # - with by failure as True on inversion + absent.should( + Match('present', by=lambda element: element.locate() is not None).not_ + ) + # - with by failure + try: + absent.should(Match('present', by=lambda element: element.locate() is not None)) + pytest.fail('expected failure') + except AssertionError as error: + assert ( + # TODO: one problem with this error... that it tells about Mismatch + # but in fact its a Failure + # (i.e. and actual exception not comparison mismatch) + "browser.element(('css selector', '#absent')).present\n" + '\n' + 'Reason: ConditionMismatch: Message: no such element: Unable to locate ' + 'element: {"method":"css selector","selector":"#absent"}\n' + ' (Session info: chrome=125.0.6422.142); For documentation on this error, ' + 'please visit: ' + 'https://www.selenium.dev/documentation/webdriver/troubleshooting/errors#no-such-element-exception\n' + ':\n' + 'condition not matched\n' # TODO: this ending is not needed... + # but should we bother? + ) in str(error) + # - with by mismatch on inversion (without 'is' prefix in name) + try: + hidden.should( + Match('present', by=lambda element: element.locate() is not None).not_ + ) + pytest.fail('expected mismatch') + except AssertionError as error: + assert ( + "browser.element(('css selector', '#hidden')).not (present)\n" + '\n' + 'Reason: ConditionMismatch: condition not matched\n' + ) in str(error) + # - with by mismatch on inversion (with 'is' prefix in name) + try: + hidden.should( + Match('is present', by=lambda element: element.locate() is not None).not_ + ) + pytest.fail('expected mismatch') + except AssertionError as error: + assert ( + "browser.element(('css selector', '#hidden')).is not (present)\n" + '\n' + 'Reason: ConditionMismatch: condition not matched\n' + ) in str(error) diff --git a/tests/integration/condition__element__hidden_test.py b/tests/integration/condition__element__present_visible__plus_inversions__compared_test.py similarity index 69% rename from tests/integration/condition__element__hidden_test.py rename to tests/integration/condition__element__present_visible__plus_inversions__compared_test.py index 0b1346f2..2edaed6d 100644 --- a/tests/integration/condition__element__hidden_test.py +++ b/tests/integration/condition__element__present_visible__plus_inversions__compared_test.py @@ -22,6 +22,7 @@ import pytest from selene import be, have +from selene.core import match from tests.integration.helpers.givenpage import GivenPage @@ -29,14 +30,47 @@ def test_should_be_hidden__passed_and_failed__compared_to_be_visible(session_bro browser = session_browser.with_(timeout=0.1) GivenPage(session_browser.driver).opened_with_body( ''' + ''' ) + absent = browser.element("#absent") hidden = browser.element("#hidden") visible = browser.element("#visible") + # THEN + + absent.should(match.present.not_) + absent.should(match.present.not_.not_.not_) + absent.should(be.not_.present) + + absent.should(match.absent) + absent.should(match.absent.not_.not_) + absent.should(be.absent) + hidden.should(match.present) + hidden.should(be.present) + hidden.should(be.hidden_in_dom) # same ↙️ + hidden.should(be.present.and_(be.not_.visible)) + hidden.should(be.not_.visible) + hidden.should(be.not_.absent) # TODO: rename to be.not_.absent_in_dom? + + absent.should(match.visible.not_) + absent.should(be.not_.visible) + absent.should(be.hidden) # TODO: should it fail? + absent.should(be.not_.hidden_in_dom) + absent.should(match.hidden_in_dom.not_) + + visible.should(match.visible) + visible.should(be.visible) + visible.should(be.not_.hidden) + visible.should(be.not_.hidden_in_dom) + visible.should(be.present) + visible.should(be.not_.absent) + + # TODO: review and extend/finalize coverage below + # THEN visible.should(be.not_.hidden) try: @@ -69,7 +103,10 @@ def test_should_be_hidden__passed_and_failed__compared_to_be_visible(session_bro assert ( "browser.element(('css selector', '#hidden')).is visible\n" '\n' - 'Reason: ConditionMismatch: condition not matched\n' + 'Reason: ConditionMismatch: actual: ' + '\n' ) in str(error) hidden.should(be.not_.visible) @@ -80,7 +117,10 @@ def test_should_be_hidden__passed_and_failed__compared_to_be_visible(session_bro assert ( "browser.element(('css selector', '#hidden')).is visible\n" '\n' - 'Reason: ConditionMismatch: condition not matched\n' + 'Reason: ConditionMismatch: actual: ' + '\n' ) in str(error) hidden.should(be.not_.hidden.not_) 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 a7877fc5..36f40eb2 100644 --- a/tests/integration/element__get__query__frame_context__element_test.py +++ b/tests/integration/element__get__query__frame_context__element_test.py @@ -91,6 +91,7 @@ def test_actions_on_frame_element_with_logging(session_browser): # WHEN browser.open('https://www.tiny.cloud/docs/tinymce/latest/cloud-quick-start/') + browser.element('#live-demo_tab_run_default').click() # THEN everything inside frame context text_area.element('p').should( 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 a9929ed5..c755e058 100644 --- a/tests/integration/element__get__query__frame_context__with_test.py +++ b/tests/integration/element__get__query__frame_context__with_test.py @@ -35,6 +35,7 @@ def test_actions_within_frame_context(session_browser): # WHEN browser.open('https://www.tiny.cloud/docs/tinymce/latest/cloud-quick-start/') + browser.element('#live-demo_tab_run_default').click() # AND with text_area_frame_context: diff --git a/tests/unit/core/condition_test.py b/tests/unit/core/condition_test.py index 74e490d2..2a5c2c9b 100644 --- a/tests/unit/core/condition_test.py +++ b/tests/unit/core/condition_test.py @@ -291,7 +291,8 @@ def test_as_not_match__of_constructed_via_factory__raise_if_not_actual(): # THEN pytest.fail('on mismatch') except AssertionError as error: - assert 'actual self: 1' == str(error) + # assert 'actual self: 1' == str(error) # TODO: can we achieve this? + assert 'condition not matched' == str(error) # TODO: add InvalidCompareError tests (positive and negative)