diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1ba18d3db..724256b9d 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,6 +1,6 @@ name: SeleniumLibrary CI -on: [push, pull_request] +on: workflow_dispatch jobs: build: @@ -9,10 +9,10 @@ jobs: continue-on-error: true strategy: matrix: - python-version: [3.9.23, 3.13.5, 3.14.0-rc.3] # pypy-3.9 + python-version: [3.13.10, 3.14.1] # 3.9.23, pypy-3.9 # python-version: [{earliest: 3.9}, {latest: 3.13.0}] # pypy-3.9 - rf-version: [6.1.1, 7.3.2] - selenium-version: [4.28.1, 4.29.0, 4.30.0, 4.31.0, 4.32.0, 4.33.0, 4.34.2] + rf-version: [6.1.1, 7.3.2, 7.4.1] + selenium-version: [4.34.2, 4.35.0, 4.36.0, 4.37.0, 4.38.0, 4.39.0] browser: [chrome] # firefox, chrome, headlesschrome, edge steps: @@ -84,7 +84,7 @@ jobs: # xvfb-run --auto-servernum python atest/run.py --zip ${{ matrix.browser }} - name: Run tests with latest python and latest robot framework - if: matrix.python-version == '3.13.0' && matrix.rf-version == '7.2.2' + if: matrix.python-version == '3.14.1' && matrix.rf-version == '7.4.1' run: | xvfb-run --auto-servernum python atest/run.py --zip ${{ matrix.browser }} diff --git a/.github/workflows/Select.yml b/.github/workflows/Select.yml index dcc02d7f4..dcb89c7e4 100644 --- a/.github/workflows/Select.yml +++ b/.github/workflows/Select.yml @@ -1,10 +1,11 @@ -name: Select Configurations +name: Selected Test Configuration Matrix -on: workflow_dispatch +on: [push, pull_request] jobs: test_config: runs-on: ubuntu-latest + continue-on-error: true strategy: matrix: config: @@ -17,7 +18,12 @@ jobs: python-version: 3.12.12 rf-version: 7.3.2 selenium-version: 4.38.0 - browser: firefox + browser: chrome + - description: older_rf_version + python-version: 3.11.14 + rf-version: 6.1.1 + selenium-version: 4.37.0 + browser: chrome steps: - uses: actions/checkout@v4 @@ -32,9 +38,8 @@ jobs: - name: Setup ${{ matrix.config.browser }} browser uses: browser-actions/setup-chrome@v1 with: - chrome-version: 138 + chrome-version: latest install-dependencies: true - install-chromedriver: true id: setup-chrome - run: | echo Installed chromium version: ${{ steps.setup-chrome.outputs.chrome-version }} @@ -61,12 +66,6 @@ jobs: - name: Install RF ${{ matrix.config.rf-version }} run: | pip install -U --pre robotframework==${{ matrix.config.rf-version }} - - name: Install drivers via selenium-manager - run: | - SELENIUM_MANAGER_EXE=$(python -c 'from selenium.webdriver.common.selenium_manager import SeleniumManager; sm=SeleniumManager(); print(f"{str(sm._get_binary())}")') - echo "$SELENIUM_MANAGER_EXE" - echo "WEBDRIVERPATH=$($SELENIUM_MANAGER_EXE --browser chrome --debug | awk '/INFO[[:space:]]Driver path:/ {print $NF;exit}')" >> "$GITHUB_ENV" - echo "$WEBDRIVERPATH" - name: Run tests under specified config run: | diff --git a/atest/acceptance/keywords/content_assertions.robot b/atest/acceptance/keywords/content_assertions.robot index 7e115b0e8..ccd845f93 100644 --- a/atest/acceptance/keywords/content_assertions.robot +++ b/atest/acceptance/keywords/content_assertions.robot @@ -206,14 +206,26 @@ Element Should Not Contain Element Text Should Be Element Text Should Be some_id This text is inside an identified element Element Text Should Be some_id This TEXT IS INSIDE AN IDENTIFIED ELEMENT ignore_case=True + Element Text Should Be some_id This text is inside an identified element${SPACE} strip_spaces=True + Element Text Should Be some_id ${SPACE}This text is inside an identified element strip_spaces=LEADING + Element Text Should Be some_id This text is inside an identified element${SPACE} strip_spaces=TRAILING + Element Text Should Be some_id This${SPACE}${SPACE} text is inside an identified element collapse_spaces=True Run Keyword And Expect Error ... The text of element 'some_id' should have been 'inside' but it was 'This text is inside an identified element'. ... Element Text Should Be some_id inside + Run Keyword And Expect Error + ... The text of element 'some_id' should have been 'This text is inside an identified element ' but it was 'This text is inside an identified element'. + ... Element Text Should Be some_id This text is inside an identified element${SPACE} strip_spaces=False + Run Keyword And Expect Error + ... The text of element 'some_id' should have been 'This${SPACE}${SPACE} text is inside an identified element' but it was 'This text is inside an identified element'. + ... Element Text Should Be some_id This${SPACE}${SPACE} text is inside an identified element collapse_spaces=False Element Text Should Not Be Element Text Should Not Be some_id Foo This text is inside an identified element Element Text Should Not Be some_id This TEXT IS INSIDE AN IDENTIFIED ELEMENT ignore_case=False Element Text Should Not Be some_id FOO This text is inside an identified element ignore_case=True + Element Text Should Not Be some_id This text is inside an identified element${SPACE} strip_spaces=False + Element Text Should Not Be some_id This text${SPACE}${SPACE} is inside an identified element collapse_spaces=False Run Keyword And Expect Error ... The text of element 'some_id' was not supposed to be 'This text is inside an identified element'. ... Element Text Should Not Be some_id This text is inside an identified element @@ -223,6 +235,12 @@ Element Text Should Not Be Run Keyword And Expect Error ... The text of element 'some_id' was not supposed to be 'THIS TEXT is inside an identified element'. ... Element Text Should Not Be some_id THIS TEXT is inside an identified element ignore_case=True + Run Keyword And Expect Error + ... The text of element 'some_id' was not supposed to be 'This text is inside an identified element '. + ... Element Text Should Not Be some_id This text is inside an identified element${SPACE} strip_spaces=True + Run Keyword And Expect Error + ... The text of element 'some_id' was not supposed to be 'This text${SPACE}${SPACE} is inside an identified element'. + ... Element Text Should Not Be some_id This text${SPACE}${SPACE} is inside an identified element collapse_spaces=True Get Text ${str} = Get Text some_id diff --git a/atest/acceptance/keywords/cookies.robot b/atest/acceptance/keywords/cookies.robot index 2349bc68d..04b355e6a 100644 --- a/atest/acceptance/keywords/cookies.robot +++ b/atest/acceptance/keywords/cookies.robot @@ -36,15 +36,21 @@ Add Cookie When Secure Is False Should Be Equal ${cookie.secure} ${False} Add Cookie When Expiry Is Epoch - Add Cookie Cookie1 value1 expiry=1761755100 + # To convert epoch to formatted string + # from time import strftime, localtime + # strftime('%Y-%m-%d %H:%M:%S', localtime(1793247900)) + # To update time each September (as Chrome limits cookies to one year expiry date) use + # import datetime + # print (datetime.datetime.strptime("2027-10-29 12:25:00", "%Y-%m-%d %I:%M:%S").timestamp()) + Add Cookie Cookie1 value1 expiry=1793247900 ${cookie} = Get Cookie Cookie1 - ${expiry} = Convert Date ${1761755100} exclude_millis=True + ${expiry} = Convert Date ${1793247900} exclude_millis=True Should Be Equal As Strings ${cookie.expiry} ${expiry} Add Cookie When Expiry Is Human Readable Data&Time - Add Cookie Cookie12 value12 expiry=2025-10-29 12:25:00 + Add Cookie Cookie12 value12 expiry=2026-10-29 12:25:00 ${cookie} = Get Cookie Cookie12 - Should Be Equal As Strings ${cookie.expiry} 2025-10-29 12:25:00 + Should Be Equal As Strings ${cookie.expiry} 2026-10-29 12:25:00 Delete Cookie [Tags] Known Issue Safari @@ -114,7 +120,7 @@ Test Get Cookie Keyword Logging ... domain=localhost ... secure=False ... httpOnly=False - ... expiry=2025-09-01 *:25:00 + ... expiry=2026-09-01 *:25:00 ... extra={'sameSite': 'Lax'} ${cookie} = Get Cookie far_future @@ -122,7 +128,7 @@ Test Get Cookie Keyword Logging Add Cookies # To update time each September (as Chrome limits cookies to one year expiry date) use # import datetime - # print (datetime.datetime.strptime("2025-09-01 12:25:00", "%Y-%m-%d %I:%M:%S").timestamp()) + # print (datetime.datetime.strptime("2027-09-01 12:25:00", "%Y-%m-%d %I:%M:%S").timestamp()) Delete All Cookies Add Cookie test seleniumlibrary ${now} = Get Current Date @@ -130,4 +136,4 @@ Add Cookies ${tomorrow_thistime_datetime} = Convert Date ${tomorrow_thistime} datetime Set Suite Variable ${tomorrow_thistime_datetime} Add Cookie another value expiry=${tomorrow_thistime} - Add Cookie far_future timemachine expiry=1756700700 # 2025-09-01 12:25:00 + Add Cookie far_future timemachine expiry=1788236700 # 2026-09-01 12:25:00 diff --git a/src/SeleniumLibrary/__init__.pyi b/src/SeleniumLibrary/__init__.pyi index 45998d55a..1ee7c18f5 100644 --- a/src/SeleniumLibrary/__init__.pyi +++ b/src/SeleniumLibrary/__init__.pyi @@ -32,23 +32,90 @@ class SeleniumLibrary: def current_frame_should_not_contain(self, text: str, loglevel: str = 'TRACE'): ... def delete_all_cookies(self): ... def delete_cookie(self, name): ... - def double_click_element(self, locator: Union): ... - def drag_and_drop(self, locator: Union, target: Union): ... - def drag_and_drop_by_offset(self, locator: Union, xoffset: int, yoffset: int): ... - def element_attribute_value_should_be(self, locator: Union, attribute: str, expected: Optional, message: Optional[Optional] = None): ... - def element_should_be_disabled(self, locator: Union): ... - def element_should_be_enabled(self, locator: Union): ... - def element_should_be_focused(self, locator: Union): ... - def element_should_be_visible(self, locator: Union, message: Optional[Optional] = None): ... - def element_should_contain(self, locator: Union, expected: Optional, message: Optional[Optional] = None, ignore_case: bool = False): ... - def element_should_not_be_visible(self, locator: Union, message: Optional[Optional] = None): ... - def element_should_not_contain(self, locator: Union, expected: Optional, message: Optional[Optional] = None, ignore_case: bool = False): ... - def element_text_should_be(self, locator: Union, expected: Optional, message: Optional[Optional] = None, ignore_case: bool = False): ... - def element_text_should_not_be(self, locator: Union, not_expected: Optional, message: Optional[Optional] = None, ignore_case: bool = False): ... - def execute_async_javascript(self, *code: Any): ... - def execute_javascript(self, *code: Any): ... - def frame_should_contain(self, locator: Union, text: str, loglevel: str = 'TRACE'): ... - def get_action_chain_delay(self): ... + def double_click_element( + self, locator: Union[selenium.webdriver.remote.webelement.WebElement, str] + ): ... + def drag_and_drop( + self, + locator: Union[selenium.webdriver.remote.webelement.WebElement, str], + target: Union[selenium.webdriver.remote.webelement.WebElement, str], + ): ... + def drag_and_drop_by_offset( + self, + locator: Union[selenium.webdriver.remote.webelement.WebElement, str], + xoffset: int, + yoffset: int, + ): ... + def element_attribute_value_should_be( + self, + locator: Union[selenium.webdriver.remote.webelement.WebElement, str], + attribute: str, + expected: Union[None, str], + message: Optional[str] = None, + ): ... + def element_should_be_disabled( + self, locator: Union[selenium.webdriver.remote.webelement.WebElement, str] + ): ... + def element_should_be_enabled( + self, locator: Union[selenium.webdriver.remote.webelement.WebElement, str] + ): ... + def element_should_be_focused( + self, locator: Union[selenium.webdriver.remote.webelement.WebElement, str] + ): ... + def element_should_be_visible( + self, + locator: Union[selenium.webdriver.remote.webelement.WebElement, str], + message: Optional[str] = None, + ): ... + def element_should_contain( + self, + locator: Union[selenium.webdriver.remote.webelement.WebElement, str], + expected: Union[None, str], + message: Optional[str] = None, + ignore_case: bool = False, + ): ... + def element_should_not_be_visible( + self, + locator: Union[selenium.webdriver.remote.webelement.WebElement, str], + message: Optional[str] = None, + ): ... + def element_should_not_contain( + self, + locator: Union[selenium.webdriver.remote.webelement.WebElement, str], + expected: Union[None, str], + message: Optional[str] = None, + ignore_case: bool = False, + ): ... + def element_text_should_be( + self, + locator: Union[selenium.webdriver.remote.webelement.WebElement, str], + expected: Union[None, str], + message: Optional[str] = None, + ignore_case: bool = False, + strip_spaces: Union[bool, str] = False, + collapse_spaces: bool = False, + ): ... + def element_text_should_not_be( + self, + locator: Union[selenium.webdriver.remote.webelement.WebElement, str], + not_expected: Union[None, str], + message: Optional[str] = None, + ignore_case: bool = False, + strip_spaces: Union[bool, str] = False, + collapse_spaces: bool = False, + ): ... + def execute_async_javascript( + self, *code: Union[selenium.webdriver.remote.webelement.WebElement, str] + ): ... + def execute_javascript( + self, *code: Union[selenium.webdriver.remote.webelement.WebElement, str] + ): ... + def frame_should_contain( + self, + locator: Union[selenium.webdriver.remote.webelement.WebElement, str], + text: str, + loglevel: str = "TRACE", + ): ... def get_all_links(self): ... def get_browser_aliases(self): ... def get_browser_ids(self): ... diff --git a/src/SeleniumLibrary/keywords/element.py b/src/SeleniumLibrary/keywords/element.py index 831ebfaf2..0e06e011e 100644 --- a/src/SeleniumLibrary/keywords/element.py +++ b/src/SeleniumLibrary/keywords/element.py @@ -13,11 +13,13 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import re + from collections import namedtuple from typing import List, Optional, Tuple, Union from SeleniumLibrary.utils import is_noney -from robot.utils import plural_or_not, is_truthy +from robot.utils import plural_or_not, is_truthy, is_string from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.keys import Keys from selenium.webdriver.remote.webelement import WebElement @@ -334,6 +336,8 @@ def element_text_should_be( expected: Union[None, str], message: Optional[str] = None, ignore_case: bool = False, + strip_spaces: Union[bool, str] = False, + collapse_spaces: bool = False, ): """Verifies that element ``locator`` contains exact the text ``expected``. @@ -346,7 +350,19 @@ def element_text_should_be( The ``ignore_case`` argument can be set to True to compare case insensitive, default is False. + If ``strip_spaces`` is given a true value (see `Boolean arguments`) + and both arguments are strings, the comparison is done without leading + and trailing spaces. If ``strip_spaces`` is given a string value + ``LEADING`` or ``TRAILING`` (case-insensitive), the comparison is done + without leading or trailing spaces, respectively. + + If ``collapse_spaces`` is given a true value (see `Boolean arguments`) and both + arguments are strings, the comparison is done with all white spaces replaced by + a single space character. + ``ignore_case`` argument is new in SeleniumLibrary 3.1. + ``strip_spaces`` is new in SeleniumLibrary 5.x.x and + ``collapse_spaces`` is new in SeleniumLibrary 5.x.x. Use `Element Should Contain` if a substring match is desired. """ @@ -355,6 +371,12 @@ def element_text_should_be( if ignore_case: text = text.lower() expected = expected.lower() + if strip_spaces: + text = self._strip_spaces(text, strip_spaces) + expected = self._strip_spaces(expected, strip_spaces) + if collapse_spaces: + text = self._collapse_spaces(text) + expected = self._collapse_spaces(expected) if text != expected: if message is None: message = ( @@ -370,6 +392,8 @@ def element_text_should_not_be( not_expected: Union[None, str], message: Optional[str] = None, ignore_case: bool = False, + strip_spaces: Union[bool, str] = False, + collapse_spaces: bool = False, ): """Verifies that element ``locator`` does not contain exact the text ``not_expected``. @@ -382,7 +406,19 @@ def element_text_should_not_be( The ``ignore_case`` argument can be set to True to compare case insensitive, default is False. - New in SeleniumLibrary 3.1.1 + If ``strip_spaces`` is given a true value (see `Boolean arguments`) + and both arguments are strings, the comparison is done without leading + and trailing spaces. If ``strip_spaces`` is given a string value + ``LEADING`` or ``TRAILING`` (case-insensitive), the comparison is done + without leading or trailing spaces, respectively. + + If ``collapse_spaces`` is given a true value (see `Boolean arguments`) and both + arguments are strings, the comparison is done with all white spaces replaced by + a single space character. + + ``ignore_case`` is new in SeleniumLibrary 3.1.1 + ``strip_spaces`` is new in SeleniumLibrary 5.x.x and + ``collapse_spaces`` is new in SeleniumLibrary 5.x.x. """ self.info( f"Verifying element '{locator}' does not contain exact text '{not_expected}'." @@ -392,11 +428,31 @@ def element_text_should_not_be( if ignore_case: text = text.lower() not_expected = not_expected.lower() + if strip_spaces: + text = self._strip_spaces(text, strip_spaces) + not_expected = self._strip_spaces(not_expected, strip_spaces) + if collapse_spaces: + text = self._collapse_spaces(text) + not_expected = self._collapse_spaces(not_expected) if text == not_expected: if message is None: message = f"The text of element '{locator}' was not supposed to be '{before_not_expected}'." raise AssertionError(message) + def _strip_spaces(self, value, strip_spaces): + if not is_string(value): + return value + if not is_string(strip_spaces): + return value.strip() if strip_spaces else value + if strip_spaces.upper() == 'LEADING': + return value.lstrip() + if strip_spaces.upper() == 'TRAILING': + return value.rstrip() + return value.strip() if is_truthy(strip_spaces) else value + + def _collapse_spaces(self, value): + return re.sub(r'\s+', ' ', value) if is_string(value) else value + @keyword def get_element_attribute( self, locator: Union[WebElement, str], attribute: str diff --git a/utest/test/keywords/test_keyword_arguments_element.py b/utest/test/keywords/test_keyword_arguments_element.py index c35b402ec..c18a9785a 100644 --- a/utest/test/keywords/test_keyword_arguments_element.py +++ b/utest/test/keywords/test_keyword_arguments_element.py @@ -29,6 +29,27 @@ def test_element_text_should_be(element): element.element_text_should_be(locator, "not text", "foobar") assert "foobar" in str(error.value) + webelement.text = "text " + when(element).find_element(locator).thenReturn(webelement) + with pytest.raises(AssertionError) as error: + element.element_text_should_be(locator, "text", strip_spaces=False) + assert "should have been" in str(error.value) + + with pytest.raises(AssertionError) as error: + element.element_text_should_be(locator, "text", strip_spaces="LEADING") + assert "should have been" in str(error.value) + + webelement.text = " text" + when(element).find_element(locator).thenReturn(webelement) + with pytest.raises(AssertionError) as error: + element.element_text_should_be(locator, "text", strip_spaces="TRAILING") + assert "should have been" in str(error.value) + + webelement.text = "testing is cool" + when(element).find_element(locator).thenReturn(webelement) + with pytest.raises(AssertionError) as error: + element.element_text_should_be(locator, "testing is cool", collapse_spaces=False) + assert "should have been" in str(error.value) def test_action_chain_delay_in_elements(element): @@ -42,6 +63,3 @@ def test_action_chain_delay_in_elements(element): when(chain_mock).move_to_element(matchers.ANY).thenReturn(mock()) when(SUT).ActionChains(matchers.ANY, duration=expected_delay_in_ms).thenReturn(chain_mock) element.scroll_element_into_view(locator) - - -