diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acf73172..f227ece3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] fail-fast: false steps: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 03506e16..0ed8dd2d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,14 +1,39 @@ Toolium Changelog ================= -v3.1.1 +v3.1.4 ------ *Release date: In development* -- Upgrade Sphinx version from 4.* to 7.* to fix readthedocs theme format +- Add `ignore_empty` optional parameter to POEditor configuration to ignore empty translations - Fix on swipe method. Duration from None to 0. +v3.1.3 +------ + +*Release date: 2024-02-06* + +- Fix `PageElements` class initialization when custom page element classes don't have all optional attributes + +v3.1.2 +------ + +*Release date: 2024-02-05* + +- Upgrade Pillow version to 10.1.* to fix compatibility with Python 3.12 + +v3.1.1 +------ + +*Release date: 2024-02-02* + +- Add support for Python 3.12 +- Upgrade Sphinx version from 4.* to 7.* to fix readthedocs theme format +- Upgrade readthedocs-sphinx-search to 0.3.2 to fix security vulnerability +- Do not log warning messages when toolium system properties are used +- Allow to initialize a `PageElements` class with webview attributes + v3.1.0 ------ diff --git a/VERSION b/VERSION index 701ebec1..fcacf2cb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.1.dev0 +3.1.4.dev0 diff --git a/docs/requirements.txt b/docs/requirements.txt index 77b8ac80..3a560d62 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,3 @@ Sphinx~=7.2 sphinx_rtd_theme~=1.3 -readthedocs-sphinx-search~=0.3 +readthedocs-sphinx-search~=0.3.2 diff --git a/requirements.txt b/requirements.txt index e7c89975..281ce90d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ requests~=2.27 # api tests selenium~=4.0 # web tests Appium-Python-Client~=2.3 # mobile tests -Pillow~=9.4 # visual testing +Pillow~=10.1 # visual testing screeninfo~=0.8 -lxml==4.9.2 +lxml~=5.1 Faker~=18.3 phonenumbers~=8.13 diff --git a/setup.py b/setup.py index 641d6f89..94941cc1 100644 --- a/setup.py +++ b/setup.py @@ -67,6 +67,7 @@ def get_long_description(): 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Software Development :: Quality Assurance', 'Topic :: Software Development :: Testing', diff --git a/toolium/config_parser.py b/toolium/config_parser.py index 3a9c5598..fc64c35c 100644 --- a/toolium/config_parser.py +++ b/toolium/config_parser.py @@ -24,6 +24,11 @@ logger = logging.getLogger(__name__) +SPECIAL_SYSTEM_PROPERTIES = ['TOOLIUM_CONFIG_ENVIRONMENT', 'TOOLIUM_OUTPUT_DIRECTORY', 'TOOLIUM_OUTPUT_LOG_FILENAME', + 'TOOLIUM_CONFIG_DIRECTORY', 'TOOLIUM_CONFIG_LOG_FILENAME', + 'TOOLIUM_CONFIG_PROPERTIES_FILENAMES', 'TOOLIUM_VISUAL_BASELINE_DIRECTORY'] + + class ExtendedConfigParser(ConfigParser): def optionxform(self, optionstr): """Override default optionxform in ConfigParser to allow case sensitive options""" @@ -120,10 +125,10 @@ def update_toolium_system_properties(self, new_properties): if not self.has_section(section): self.add_section(section) self.set(section, option, value) - elif property_name.startswith('TOOLIUM'): + elif property_name.startswith('TOOLIUM') and property_name not in SPECIAL_SYSTEM_PROPERTIES: logger.warning('A toolium system property is configured but its name does not math with section' ' and option in value (use TOOLIUM_[SECTION]_[OPTION]=[Section]_[option]=value):' - ' %s=%s' % (property_name, property_value)) + ' %s=%s', property_name, property_value) def translate_config_variables(self, str_with_variables): """ diff --git a/toolium/pageelements/page_elements.py b/toolium/pageelements/page_elements.py index 13bcb4b2..60b36862 100644 --- a/toolium/pageelements/page_elements.py +++ b/toolium/pageelements/page_elements.py @@ -15,7 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. """ -from typing import List, Any +import inspect +from typing import Any, List from toolium.driver_wrapper import DriverWrappersPool from toolium.pageelements.button_page_element import Button @@ -120,11 +121,13 @@ def page_elements(self) -> List[Any]: self._page_elements = [] for order, web_element in enumerate(self.web_elements): # Create multiple PageElement with original locator and order - page_element =\ - self.page_element_class(self.locator[0], self.locator[1], parent=self.parent, - order=order, webview=self.webview, - webview_context_selection_callback=self.webview_context_selection_callback, - webview_csc_args=self.webview_csc_args) + # Optional parameters are passed only if they are defined in the PageElement constructor + signature = inspect.signature(self.page_element_class.__init__) + opt_names = ['parent', 'webview', 'webview_context_selection_callback', 'webview_csc_args'] + opt_params = {name: getattr(self, name) for name in opt_names if name in signature.parameters} + if 'order' in signature.parameters: + opt_params['order'] = order + page_element = self.page_element_class(self.locator[0], self.locator[1], **opt_params) page_element.reset_object(self.driver_wrapper) page_element._web_element = web_element self._page_elements.append(page_element) diff --git a/toolium/test/pageelements/test_page_elements.py b/toolium/test/pageelements/test_page_elements.py index c53bd575..eab46ad0 100644 --- a/toolium/test/pageelements/test_page_elements.py +++ b/toolium/test/pageelements/test_page_elements.py @@ -36,13 +36,43 @@ def init_page_elements(self): self.links = PageElements(By.XPATH, '//a') self.inputs_with_parent = PageElements(By.XPATH, '//input', parent=(By.ID, 'parent')) self.inputs_with_webview = PageElements(By.XPATH, '//input', webview=True) - self.inputs_with_webview_callback = \ - PageElements(By.XPATH, '//input', webview_context_selection_callback=lambda a, b: (a, b), - webview_csc_args=['WEBVIEW_fake.other', "CDwindow-0123456789"], webview=True) + self.inputs_with_webview_callback = PageElements(By.XPATH, '//input', + webview_context_selection_callback=lambda a, b: (a, b), + webview_csc_args=['WEBVIEW_fake.other', "CDwindow-0123456789"], + webview=True) self.parent_webview = PageElement(By.XPATH, '//parent', webview=True) self.inputs_with_webview_parent = PageElements(By.XPATH, '//input', parent=self.parent_webview, webview=True) +class CustomElementAllAttributes(PageElement): + def __init__(self, by, value, parent=None, order=None, wait=False, shadowroot=None, webview=False, + webview_context_selection_callback=None, webview_csc_args=None): + super(CustomElementAllAttributes, self).__init__(by, value, parent, order, wait, shadowroot, webview, + webview_context_selection_callback, webview_csc_args) + + +class CustomElementSomeAttributes(PageElement): + def __init__(self, by, value, parent=None, order=None, wait=False, shadowroot=None): + super(CustomElementSomeAttributes, self).__init__(by, value, parent, order, wait, shadowroot) + + +class CustomElementMandatoryAttributes(PageElement): + def __init__(self, by, value): + super(CustomElementMandatoryAttributes, self).__init__(by, value) + + +class LoginWithPageElementsPageObject(PageObject): + def init_page_elements(self): + self.all_optional_attrs = PageElements(By.XPATH, '//input', page_element_class=CustomElementAllAttributes, + webview_context_selection_callback=lambda a, b: (a, b), + webview_csc_args=['WEBVIEW_fake.other', "CDwindow-0123456789"], + webview=True) + self.some_optional_attrs = PageElements(By.XPATH, '//input', page_element_class=CustomElementSomeAttributes, + parent=(By.ID, 'parent')) + self.only_mandatory_attrs = PageElements(By.XPATH, '//input', + page_element_class=CustomElementMandatoryAttributes) + + @pytest.fixture def driver_wrapper(): # Reset wrappers pool values @@ -171,13 +201,81 @@ def test_reset_object(driver_wrapper): assert page_element_21._web_element is not None +def test_get_page_elements_custom_element_class_all_optional(driver_wrapper): + driver_wrapper.driver.find_elements.return_value = child_elements + page_elements = LoginWithPageElementsPageObject().all_optional_attrs.page_elements + + # Check that find_elements has been called just one time + driver_wrapper.driver.find_elements.assert_called_once_with(By.XPATH, '//input') + driver_wrapper.driver.find_element.assert_not_called() + + # Check that the response is a list of 2 CustomElementAllFields with the expected web element + assert len(page_elements) == 2 + assert isinstance(page_elements[0], CustomElementAllAttributes) + assert page_elements[0]._web_element == child_elements[0] + assert isinstance(page_elements[1], CustomElementAllAttributes) + assert page_elements[1]._web_element == child_elements[1] + + # Check that the optional attributes are set correctly + assert page_elements[0].order == 0 + assert page_elements[0].webview is True + assert page_elements[0].webview_context_selection_callback + assert page_elements[0].webview_csc_args == ['WEBVIEW_fake.other', "CDwindow-0123456789"] + assert page_elements[1].order == 1 + assert page_elements[1].webview is True + assert page_elements[1].webview_context_selection_callback + assert page_elements[1].webview_csc_args == ['WEBVIEW_fake.other', "CDwindow-0123456789"] + + +def test_get_page_elements_custom_element_class_some_optional(driver_wrapper): + # Create a mock element + mock_element = mock.MagicMock(spec=WebElement) + mock_element.find_elements.return_value = child_elements + + driver_wrapper.driver.find_element.return_value = mock_element + page_elements = LoginWithPageElementsPageObject().some_optional_attrs.page_elements + + # Check that find_elements has been called just one time + driver_wrapper.driver.find_element.assert_called_once_with(By.ID, 'parent') + mock_element.find_elements.assert_called_once_with(By.XPATH, '//input') + + # Check that the response is a list of 2 CustomElementSomeAttributes with the expected web element + assert len(page_elements) == 2 + assert isinstance(page_elements[0], CustomElementSomeAttributes) + assert page_elements[0]._web_element == child_elements[0] + assert isinstance(page_elements[1], CustomElementSomeAttributes) + assert page_elements[1]._web_element == child_elements[1] + + # Check that the optional attributes are set correctly + assert page_elements[0].order == 0 + assert page_elements[0].parent == (By.ID, 'parent') + assert page_elements[1].order == 1 + assert page_elements[0].parent == (By.ID, 'parent') + + +def test_get_page_elements_custom_element_class_only_mandatory(driver_wrapper): + driver_wrapper.driver.find_elements.return_value = child_elements + page_elements = LoginWithPageElementsPageObject().only_mandatory_attrs.page_elements + + # Check that find_elements has been called just one time + driver_wrapper.driver.find_elements.assert_called_once_with(By.XPATH, '//input') + driver_wrapper.driver.find_element.assert_not_called() + + # Check that the response is a list of 2 CustomElementMandatoryAttributes with the expected web element + assert len(page_elements) == 2 + assert isinstance(page_elements[0], CustomElementMandatoryAttributes) + assert page_elements[0]._web_element == child_elements[0] + assert isinstance(page_elements[1], CustomElementMandatoryAttributes) + assert page_elements[1]._web_element == child_elements[1] + + def test_get_page_elements_without_webview(driver_wrapper): driver_wrapper.driver.find_elements.return_value = child_elements page_elements = LoginPageObject().inputs.page_elements # Check webview attribute is set to false by default in child elements - assert not page_elements[0].webview - assert not page_elements[1].webview + assert page_elements[0].webview is False + assert page_elements[1].webview is False def test_get_page_elements_with_webview(driver_wrapper): @@ -186,8 +284,8 @@ def test_get_page_elements_with_webview(driver_wrapper): # Check webview attribute is set to true in child element when a Pagelements element # is created with the webview attribute - assert page_elements[0].webview - assert page_elements[1].webview + assert page_elements[0].webview is True + assert page_elements[1].webview is True def test_get_page_elements_with_context_selection_callback_provided(driver_wrapper): @@ -196,9 +294,9 @@ def test_get_page_elements_with_context_selection_callback_provided(driver_wrapp # Check context selection callback provided is set correctly to pageelements assert page_elements[0].webview_context_selection_callback - assert page_elements[0].webview_csc_args + assert page_elements[0].webview_csc_args == ['WEBVIEW_fake.other', "CDwindow-0123456789"] assert page_elements[1].webview_context_selection_callback - assert page_elements[1].webview_csc_args + assert page_elements[1].webview_csc_args == ['WEBVIEW_fake.other', "CDwindow-0123456789"] def test_mobile_automatic_context_selection_switch_to_new_webview_context_in_pagelements_without_parent(driver_wrapper): diff --git a/toolium/test/test_config_parser.py b/toolium/test/test_config_parser.py index c751d21d..44ca2a32 100644 --- a/toolium/test/test_config_parser.py +++ b/toolium/test/test_config_parser.py @@ -16,8 +16,9 @@ limitations under the License. """ -import mock import os + +import mock import pytest from toolium.config_parser import ExtendedConfigParser @@ -285,11 +286,35 @@ def test_update_toolium_system_properties_wrong_format(config, logger, property_ if property_name.startswith('TOOLIUM'): logger.warning.assert_called_once_with('A toolium system property is configured but its name does not math with' ' section and option in value (use TOOLIUM_[SECTION]_[OPTION]=[Section]_' - '[option]=value): %s=%s' % (property_name, property_value)) + '[option]=value): %s=%s', property_name, property_value) else: logger.warning.assert_not_called() +toolium_system_properties_special = ( + ('TOOLIUM_CONFIG_ENVIRONMENT', 'value1'), + ('TOOLIUM_OUTPUT_DIRECTORY', 'value2'), + ('TOOLIUM_OUTPUT_LOG_FILENAME', 'value3'), + ('TOOLIUM_CONFIG_DIRECTORY', 'value4'), + ('TOOLIUM_CONFIG_LOG_FILENAME', 'value5'), + ('TOOLIUM_CONFIG_PROPERTIES_FILENAMES', 'value6'), + ('TOOLIUM_VISUAL_BASELINE_DIRECTORY', 'value7'), +) + + +@pytest.mark.parametrize("property_name, property_value", toolium_system_properties_special) +def test_update_toolium_system_properties_special(config, logger, property_name, property_value): + # Change system property and update config + environment_properties.append(property_name) + os.environ[property_name] = property_value + previous_config = config.deepcopy() + config.update_toolium_system_properties(os.environ) + + # Check that config has not been updated and error message is not logged + assert previous_config == config, 'Config has been updated' + logger.warning.assert_not_called() + + def test_update_properties_behave(config): section = 'Capabilities' option = 'platformName' diff --git a/toolium/test/utils/test_dataset_map_param.py b/toolium/test/utils/test_dataset_map_param.py index 817f516d..6e9e3d7f 100644 --- a/toolium/test/utils/test_dataset_map_param.py +++ b/toolium/test/utils/test_dataset_map_param.py @@ -212,6 +212,22 @@ def test_a_poe_param_single_result(): assert result == expected +def test_a_poe_param_with_empty_definition_single_result(): + """ + Verification of a POE mapped parameter with empty definition + """ + dataset.poeditor_terms = [ + { + "term": "Poniendo mute", + "definition": "", + "reference": "home:home.tv.mute", + } + ] + result = map_param('[POE:home.tv.mute]') + expected = "" + assert result == expected + + def test_a_poe_param_no_result_assertion(): """ Verification of a POE mapped parameter without result @@ -228,6 +244,39 @@ def test_a_poe_param_no_result_assertion(): assert "No translations found in POEditor for reference home.tv.off" in str(excinfo.value) +def test_a_poe_param_with_no_definition_no_result_assertion_(): + """ + Verification of a POE mapped parameter without definition and without result + """ + dataset.poeditor_terms = [ + { + "term": "Poniendo mute", + "definition": None, + "reference": "home:home.tv.mute", + } + ] + with pytest.raises(Exception) as excinfo: + map_param('[POE:home.tv.mute]') + assert "No translations found in POEditor for reference home.tv.mute" in str(excinfo.value) + + +def test_a_poe_param_with_empty_definition_no_result_assertion(): + """ + Verification of a POE mapped parameter with empty definition and without result (configured ignore_empty) + """ + dataset.project_config = {'poeditor': {'key_field': 'reference', 'search_type': 'contains', 'ignore_empty': True}} + dataset.poeditor_terms = [ + { + "term": "Poniendo mute", + "definition": "", + "reference": "home:home.tv.mute", + } + ] + with pytest.raises(Exception) as excinfo: + map_param('[POE:home.tv.mute]') + assert "No translations found in POEditor for reference home.tv.mute" in str(excinfo.value) + + def test_a_poe_param_prefix_with_no_definition(): """ Verification of a POE mapped parameter with a single result for a reference diff --git a/toolium/test_cases.py b/toolium/test_cases.py index b8a702ee..b20a3487 100644 --- a/toolium/test_cases.py +++ b/toolium/test_cases.py @@ -73,7 +73,7 @@ def tearDown(self): error_message = get_error_message_from_exception(exception) elif not hasattr(self._outcome, 'errors') and hasattr(self._outcome.result, 'failures') \ and len(self._outcome.result.failures) > 0: - # Python 3.11 + # Python >=3.11 traceback = self._outcome.result.failures[0][1] error_message = get_error_message_from_traceback(traceback) diff --git a/toolium/utils/dataset.py b/toolium/utils/dataset.py index 757e63a5..0fc75c37 100644 --- a/toolium/utils/dataset.py +++ b/toolium/utils/dataset.py @@ -690,6 +690,8 @@ def get_translation_by_poeditor_reference(reference, poeditor_terms): poeditor_config = project_config['poeditor'] if project_config and 'poeditor' in project_config else {} key = poeditor_config['key_field'] if 'key_field' in poeditor_config else 'reference' search_type = poeditor_config['search_type'] if 'search_type' in poeditor_config else 'contains' + ignore_empty = poeditor_config['ignore_empty'] if 'ignore_empty' in poeditor_config else False + ignored_definitions = [None, ''] if ignore_empty else [None] # Get POEditor prefixes and add no prefix option poeditor_prefixes = poeditor_config['prefixes'] if 'prefixes' in poeditor_config else [] poeditor_prefixes.append('') @@ -702,10 +704,10 @@ def get_translation_by_poeditor_reference(reference, poeditor_terms): complete_reference = '%s%s' % (prefix, reference) if search_type == 'exact': translation = [term['definition'] for term in poeditor_terms - if complete_reference == term[key] and term['definition'] is not None] + if complete_reference == term[key] and term['definition'] not in ignored_definitions] else: translation = [term['definition'] for term in poeditor_terms - if complete_reference in term[key] and term['definition'] is not None] + if complete_reference in term[key] and term['definition'] not in ignored_definitions] if len(translation) > 0: break assert len(translation) > 0, 'No translations found in POEditor for reference %s' % reference diff --git a/toolium/utils/poeditor.py b/toolium/utils/poeditor.py index 328d0265..b34bb039 100644 --- a/toolium/utils/poeditor.py +++ b/toolium/utils/poeditor.py @@ -46,6 +46,7 @@ "prefixes": [], "key_field": "reference", "search_type": "contains", + "ignore_empty": False, "file_path": "output/poeditor_terms.json", "mode": "online" }