diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2a0c43be..0eef00f4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,9 @@ v3.1.5 *Release date: In development* - Fix `export_poeditor_project` method allowing empty export response +- Add `key=value` expressions for selecting elements in the context storage +- Upgrade Faker version to 25.9.* +- Fix result for action before the feature with error and background to fail scenarios v3.1.4 ------ diff --git a/requirements.txt b/requirements.txt index 32034f00..4f749883 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,5 @@ Appium-Python-Client~=2.3 # mobile tests Pillow~=10.1 # visual testing screeninfo~=0.8 lxml~=5.1 -Faker~=18.3 +Faker~=25.9 phonenumbers~=8.13 diff --git a/toolium/behave/env_utils.py b/toolium/behave/env_utils.py index a7b3d77b..363de05a 100644 --- a/toolium/behave/env_utils.py +++ b/toolium/behave/env_utils.py @@ -279,7 +279,7 @@ def execute_after_scenario_steps(self, context): self.scenario_error = False self.before_error_message = None context.scenario.reset() - self.fail_first_step_precondition_exception(context.scenario) + self.fail_first_step_precondition_exception(context.scenario, error_message) raise Exception(f'Before scenario steps have failed: {error_message}') def execute_after_feature_steps(self, context): @@ -298,18 +298,25 @@ def execute_after_feature_steps(self, context): context.feature.reset() for scenario in context.feature.walk_scenarios(): if scenario.should_run(context.config): - self.fail_first_step_precondition_exception(scenario) + self.fail_first_step_precondition_exception(scenario, error_message) + if len(scenario.background_steps) > 0: + context.logger.warn('Background from scenario status udpated to fail') raise Exception(f'Before feature steps have failed: {error_message}') - def fail_first_step_precondition_exception(self, scenario): + def fail_first_step_precondition_exception(self, scenario, error_message): """ Fail first step in the given Scenario and add exception message for the output. This is needed because xUnit exporter in Behave fails if there are not failed steps. :param scenario: Behave's Scenario + :param error_message: Exception message """ + # Behave is an optional dependency in toolium, so it is imported here + from behave.model_core import Status if len(scenario.steps) > 0: - # Behave is an optional dependency in toolium, so it is imported here - from behave.model_core import Status scenario.steps[0].status = Status.failed scenario.steps[0].exception = Exception('Preconditions failed') - scenario.steps[0].error_message = str(self.before_error_message) + scenario.steps[0].error_message = str(error_message) + if len(scenario.background_steps) > 0: + scenario.background_steps[0].status = Status.failed + scenario.background_steps[0].exception = Exception('Preconditions failed') + scenario.background_steps[0].error_message = str(error_message) diff --git a/toolium/config_driver.py b/toolium/config_driver.py index 9b4bb730..2b3f38a4 100644 --- a/toolium/config_driver.py +++ b/toolium/config_driver.py @@ -66,10 +66,11 @@ def get_error_message_from_traceback(traceback): class ConfigDriver(object): - def __init__(self, config, utils=None): + def __init__(self, config, utils=None, playwright=None): self.logger = logging.getLogger(__name__) self.config = config self.utils = utils + self.playwright = playwright def create_driver(self): """Create a selenium driver using specified config properties @@ -92,6 +93,36 @@ def create_driver(self): return driver + def create_playwright_browser(self): + """ + Create a playwright browser using specified config properties + + :returns: a new playwright browser o persistent browser context + """ + driver_type = self.config.get('Driver', 'type') + try: + self.logger.info("Creating playwright driver (type = %s)", driver_type) + return self._create_playwright_browser() + except Exception as exc: + error_message = get_error_message_from_exception(exc) + self.logger.error("%s driver can not be launched: %s", driver_type.capitalize(), error_message) + raise + + def create_playwright_persistent_browser_context(self): + """ + Create a playwright persistent browser context using specified config properties + + :returns: a new playwright persistent browser context + """ + driver_type = self.config.get('Driver', 'type') + try: + self.logger.info("Creating playwright persistent context (type = %s)", driver_type) + return self._create_playwright_persistent_browser_context() + except Exception as exc: + error_message = get_error_message_from_exception(exc) + self.logger.error("%s driver can not be launched: %s", driver_type.capitalize(), error_message) + raise + def _create_remote_driver(self): """Create a driver in a remote server View valid capabilities in https://www.selenium.dev/documentation/webdriver/drivers/options/ @@ -164,6 +195,76 @@ def _create_local_driver(self): return driver + def _create_playwright_browser(self): + """Create a browser in local machine using Playwright + + :returns: a new browser Playwright + """ + driver_name = self.utils.get_driver_name() + if driver_name in ('android', 'ios', 'iphone'): + raise Exception('Playwright does not support mobile devices') + else: + if driver_name in ['chrome', 'chromium']: + browser = self._setup_playwright_chrome() + elif driver_name == 'firefox': + browser = self._setup_playwright_firefox() + elif driver_name in ['safari', 'webkit']: + browser = self._setup_playwright_webkit() + else: + raise Exception(f'Playwright does not support {driver_name} driver') + return browser + + def _create_playwright_persistent_browser_context(self): + """Create a browser in local machine using Playwright + + :returns: a new persistent browser context Playwright + """ + driver_name = self.utils.get_driver_name() + if driver_name in ('android', 'ios', 'iphone'): + raise Exception('Playwright does not support mobile devices') + else: + if driver_name in ['chrome', 'chromium']: + browser_context = self._setup_playwright_persistent_chrome() + elif driver_name == 'firefox': + browser_context = self._setup_playwright_persistent_firefox() + elif driver_name in ['safari', 'webkit']: + browser_context = self._setup_playwright_persistent_webkit() + else: + raise Exception(f'Playwright does not support {driver_name} driver') + return browser_context + + def get_playwright_context_options(self): + """Get Playwright context options from properties file + + :returns: Playwright context options + """ + context_options = {} + try: + for key, value in dict(self.config.items('PlaywrightContextOptions')).items(): + self.logger.debug("Added Playwright context option: %s = %s", key, value) + context_options[key] = self._convert_property_type(value) + except NoSectionError: + pass + window_width = self.config.get_optional('Driver', 'window_width') + window_height = self.config.get_optional('Driver', 'window_height') + if window_width and window_height: + context_options['viewport'] = {'width': int(window_width), 'height': int(window_height)} + return context_options + + def get_playwright_page_options(self): + """Get Playwright page options from properties file + + :returns: Playwright page options + """ + page_options = {} + try: + for key, value in dict(self.config.items('PlaywrightPageOptions')).items(): + self.logger.debug("Added Playwright page option: %s = %s", key, value) + page_options[key] = self._convert_property_type(value) + except NoSectionError: + pass + return page_options + def _get_capabilities_from_driver_type(self): """Extract browserVersion and platformName from driver type and add them to capabilities @@ -295,6 +396,69 @@ def _add_firefox_extensions(self, driver): except NoSectionError: pass + def _setup_playwright_firefox(self): + """Setup Playwright Firefox browser + + :returns: a new Playwright Firefox browser + """ + headless_mode = self.config.getboolean_optional('Driver', 'headless') + arguments = [] + preferences = {} + self._add_playwright_firefox_arguments(arguments) + # Note: Playwright does not support Firefox extensions + self._add_playwright_firefox_preferences(preferences) + browser_options = self._get_playwright_browser_options() + browser_options = self._update_dict(browser_options, {'args': arguments}) + browser_options = self._update_dict(browser_options, {'firefox_user_prefs': preferences}) + return self.playwright.firefox.launch( + headless=headless_mode, + **browser_options + ) + + def _setup_playwright_persistent_firefox(self): + """Setup Playwright Firefox persistent browser context + + :returns: a new Playwright Firefox persistent browser context + """ + headless_mode = self.config.getboolean_optional('Driver', 'headless') + arguments = [] + preferences = {} + self._add_playwright_firefox_arguments(arguments) + # Note: Playwright does not support Firefox extensions + self._add_playwright_firefox_preferences(preferences) + context_options = self.get_playwright_context_options() + context_options = self._update_dict(context_options, {'args': arguments}) + context_options = self._update_dict(context_options, {'firefox_user_prefs': preferences}) + return self.playwright.firefox.launch_persistent_context( + headless=headless_mode, + **context_options + ) + + def _add_playwright_firefox_arguments(self, arguments): + """Add Firefox arguments from properties file prepared for Playwright + + :param arguments: Firefox arguments object + """ + try: + for pref, pref_value in dict(self.config.items('FirefoxArguments')).items(): + pref_value = '={}'.format(pref_value) if pref_value else '' + self.logger.debug("Added Firefox argument: %s%s", pref, pref_value) + arguments.append('--{}{}'.format(pref, self._convert_property_type(pref_value))) + except NoSectionError: + pass + + def _add_playwright_firefox_preferences(self, preferences): + """Add Firefox preferences from properties file prepared for Playwright + + :param preferences: Firefox preferences object + """ + try: + for pref, pref_value in dict(self.config.items('FirefoxPreferences')).items(): + self.logger.debug("Added Firefox preference: %s = %s", pref, pref_value) + preferences[pref] = self._convert_property_type(pref_value) + except NoSectionError: + pass + @staticmethod def _convert_property_type(value): """Converts the string value in a boolean, integer or string @@ -362,6 +526,80 @@ def _get_chrome_options(self, capabilities={}): return options + def _get_playwright_browser_options(self): + """ + Get Playwright browser options from properties file + + :returns: Playwright browser options + """ + browser_options = {} + try: + for key, value in dict(self.config.items('PlaywrightBrowserOptions')).items(): + self.logger.debug("Added Playwright Browser option: %s = %s", key, value) + browser_options[key] = self._convert_property_type(value) + except NoSectionError: + pass + return browser_options + + def _setup_playwright_chrome(self): + """ + Setup Playwright Chrome browser + + :returns: a new Playwright Chrome browser + """ + headless_mode = self.config.getboolean_optional('Driver', 'headless') + arguments = [] + self._add_playwright_chrome_arguments(arguments) + self._add_playwright_chrome_extensions(arguments) + browser_options = self._get_playwright_browser_options() + browser_options = self._update_dict(browser_options, {'args': arguments}) + return self.playwright.chromium.launch( + headless=headless_mode, + **browser_options + ) + + def _setup_playwright_persistent_chrome(self): + """ + Setup Playwright Chrome persistent browser context + + :returns: a new Playwright Chrome persistent browser context + """ + headless_mode = self.config.getboolean_optional('Driver', 'headless') + arguments = [] + self._add_playwright_chrome_arguments(arguments) + self._add_playwright_chrome_extensions(arguments) + context_options = self.get_playwright_context_options() + context_options = self._update_dict(context_options, {'args': arguments}) + return self.playwright.chromium.launch_persistent_context( + headless=headless_mode, + **context_options + ) + + def _add_playwright_chrome_arguments(self, arguments): + """Add Chrome arguments from properties file prepared for Playwright + + :param arguments: Chrome arguments object + """ + try: + for pref, pref_value in dict(self.config.items('ChromeArguments')).items(): + pref_value = '={}'.format(pref_value) if pref_value else '' + self.logger.debug("Added Chrome argument: %s%s", pref, pref_value) + arguments.append('--{}{}'.format(pref, self._convert_property_type(pref_value))) + except NoSectionError: + pass + + def _add_playwright_chrome_extensions(self, arguments): + """Add Chrome extensions from properties file + + :param arguments: Chrome options object + """ + try: + for pref, pref_value in dict(self.config.items('ChromeExtensions')).items(): + self.logger.debug("Added Chrome extension: %s = %s", pref, pref_value) + arguments.append('--load-extension={}'.format(pref_value)) + except NoSectionError: + pass + def _add_chrome_options(self, options, option_name): """Add Chrome options from properties file @@ -427,7 +665,7 @@ def _update_dict(self, initial, update, initial_key=None): :param initial: initial dict to be updated :param update: new dict :param initial_key: update only one key in initial dicts - :return: merged dict + :returns: merged dict """ for key, value in update.items(): if initial_key is None or key == initial_key: @@ -459,6 +697,30 @@ def _get_safari_options(self, capabilities={}): self._update_dict(options.capabilities, capabilities) return options + def _setup_playwright_webkit(self): + """Setup Playwright Webkit browser + + :returns: a new Playwright Webkit browser + """ + headless_mode = self.config.getboolean_optional('Driver', 'headless') + browser_options = self._get_playwright_browser_options() + return self.playwright.webkit.launch( + headless=headless_mode, + **browser_options + ) + + def _setup_playwright_persistent_webkit(self): + """Setup Playwright Webkit persistent browser context + + :returns: a new Playwright Webkit persistent browser context + """ + headless_mode = self.config.getboolean_optional('Driver', 'headless') + context_options = self.get_playwright_context_options() + return self.playwright.webkit.launch_persistent_context( + headless=headless_mode, + **context_options + ) + def _setup_explorer(self): """Setup Internet Explorer webdriver diff --git a/toolium/driver_wrapper.py b/toolium/driver_wrapper.py index 0d9e2470..7844a2c9 100644 --- a/toolium/driver_wrapper.py +++ b/toolium/driver_wrapper.py @@ -58,6 +58,7 @@ class DriverWrapper(object): async_loop = None #: async loop for playwright tests playwright = None #: playwright instance playwright_browser = None #: playwright browser instance + playwright_context = None #: playwright context instance # Configuration and output files config_properties_filenames = None #: configuration filenames separated by commas @@ -75,6 +76,7 @@ def __init__(self): self.async_loop = default_wrapper.async_loop self.playwright = default_wrapper.playwright self.playwright_browser = default_wrapper.playwright_browser + self.playwright_context = default_wrapper.playwright_context self.config_properties_filenames = default_wrapper.config_properties_filenames self.config_log_filename = default_wrapper.config_log_filename self.output_log_filename = default_wrapper.output_log_filename @@ -264,26 +266,43 @@ def connect_playwright(self): """ async_loop = self.async_loop self.playwright = async_loop.run_until_complete(async_playwright().start()) - # TODO: select browser from config - headless_mode = self.config.getboolean_optional('Driver', 'headless') - self.playwright_browser = async_loop.run_until_complete(self.playwright.chromium.launch(headless=headless_mode)) - self.driver = async_loop.run_until_complete(self.playwright_browser.new_page()) + + # In case of using a persistent context this property must be set and + # a BrowserContext is returned instead of a Browser + user_data_dir = self.config.get_optional('PlaywrightContextOptions', 'user_data_dir', None) + config_driver = ConfigDriver(self.config, self.utils, self.playwright) + if user_data_dir: + self.playwright_context = async_loop.run_until_complete( + config_driver.create_playwright_persistent_browser_context() + ) + else: + self.playwright_browser = async_loop.run_until_complete( + config_driver.create_playwright_browser() + ) + self.playwright_context = async_loop.run_until_complete( + self.playwright_browser.new_context(**config_driver.get_playwright_context_options()) + ) + self.driver = async_loop.run_until_complete( + self.playwright_context.new_page(**config_driver.get_playwright_page_options()) + ) async def connect_playwright_new_page(self): - """Set up and additional playwright driver creating a new context and page in current browser instance + """Set up and additional playwright driver creating a new page in current browser and context instance It is an async method to be called from async steps or page objects :returns: playwright driver """ - context = await self.playwright_browser.new_context() - self.driver = await context.new_page() + + self.driver = await self.playwright_context.new_page( + **ConfigDriver(self.config, self.utils).get_playwright_page_options() + ) return self.driver def stop(self): """Stop selenium or playwright driver""" if self.async_loop: # Stop playwright driver - self.async_loop.run_until_complete(self.driver.close()) + self.async_loop.run_until_complete(self.playwright_context.close()) else: # Stop selenium driver self.driver.quit() diff --git a/toolium/test/behave/test_env_utils.py b/toolium/test/behave/test_env_utils.py index 994d3401..9a322850 100644 --- a/toolium/test/behave/test_env_utils.py +++ b/toolium/test/behave/test_env_utils.py @@ -222,7 +222,8 @@ def test_execute_after_feature_steps_failed_before_feature(context, dyn_env): assert dyn_env.before_error_message is None context.execute_steps.assert_called_with('Given after feature step') context.feature.reset.assert_called_once_with() - dyn_env.fail_first_step_precondition_exception.assert_called_once_with(context.scenario) + dyn_env.fail_first_step_precondition_exception.assert_called_once_with( + context.scenario, 'Exception in before feature step') def test_execute_after_feature_steps_failed_actions_failed_before_feature(context, dyn_env): @@ -241,7 +242,8 @@ def test_execute_after_feature_steps_failed_actions_failed_before_feature(contex assert dyn_env.before_error_message is None context.execute_steps.assert_called_with('Given after feature step') context.feature.reset.assert_called_once_with() - dyn_env.fail_first_step_precondition_exception.assert_called_once_with(context.scenario) + dyn_env.fail_first_step_precondition_exception.assert_called_once_with( + context.scenario, 'Exception in before feature step') def test_fail_first_step_precondition_exception(dyn_env): @@ -251,7 +253,7 @@ def test_fail_first_step_precondition_exception(dyn_env): scenario.steps = [step1, step2] dyn_env.before_error_message = 'Exception in before feature step' - dyn_env.fail_first_step_precondition_exception(scenario) + dyn_env.fail_first_step_precondition_exception(scenario, dyn_env.before_error_message) assert step1.status == 'failed' assert str(step1.exception) == 'Preconditions failed' assert step1.error_message == 'Exception in before feature step' @@ -262,4 +264,4 @@ def test_fail_first_step_precondition_exception_without_steps(dyn_env): scenario.steps = [] dyn_env.before_error_message = 'Exception in before feature step' - dyn_env.fail_first_step_precondition_exception(scenario) + dyn_env.fail_first_step_precondition_exception(scenario, dyn_env.before_error_message) diff --git a/toolium/test/test_config_driver_firefox.py b/toolium/test/test_config_driver_firefox.py index 9482f4ef..767a8420 100644 --- a/toolium/test/test_config_driver_firefox.py +++ b/toolium/test/test_config_driver_firefox.py @@ -27,6 +27,7 @@ DEFAULT_CAPABILITIES = {'acceptInsecureCerts': True, 'browserName': 'firefox', 'moz:debuggerAddress': True, 'pageLoadStrategy': 'normal'} +DEFAULT_PREFERENCES = {'remote.active-protocols': 3} @pytest.fixture @@ -130,7 +131,7 @@ def test_create_local_driver_firefox_extension(webdriver_mock, config, utils): def test_get_firefox_options(config, utils): config_driver = ConfigDriver(config, utils) expected_arguments = [] - expected_preferences = {} + expected_preferences = DEFAULT_PREFERENCES expected_capabilities = DEFAULT_CAPABILITIES options = config_driver._get_firefox_options() @@ -145,7 +146,7 @@ def test_get_firefox_options_arguments(config, utils): config.set('FirefoxArguments', '-private', '') config_driver = ConfigDriver(config, utils) expected_arguments = ['-private'] - expected_preferences = {} + expected_preferences = DEFAULT_PREFERENCES expected_capabilities = DEFAULT_CAPABILITIES options = config_driver._get_firefox_options() @@ -160,7 +161,8 @@ def test_get_firefox_options_preferences(config, utils): config.set('FirefoxPreferences', 'browser.download.folderList', '2') config_driver = ConfigDriver(config, utils) expected_arguments = [] - expected_preferences = {'browser.download.folderList': 2} + expected_preferences = DEFAULT_PREFERENCES.copy() + expected_preferences['browser.download.folderList'] = 2 expected_capabilities = DEFAULT_CAPABILITIES options = config_driver._get_firefox_options() @@ -175,7 +177,8 @@ def test_get_firefox_options_profile(config, utils): config.set('Firefox', 'profile', '/tmp') config_driver = ConfigDriver(config, utils) expected_arguments = [] - expected_preferences = {'profile': '/tmp'} + expected_preferences = DEFAULT_PREFERENCES.copy() + expected_preferences['profile'] = '/tmp' expected_capabilities = DEFAULT_CAPABILITIES options = config_driver._get_firefox_options() @@ -190,7 +193,7 @@ def test_get_firefox_options_capabilities_update(config, utils): config.set('Capabilities', 'pageLoadStrategy', 'eager') config_driver = ConfigDriver(config, utils) expected_arguments = [] - expected_preferences = {} + expected_preferences = DEFAULT_PREFERENCES expected_capabilities = DEFAULT_CAPABILITIES.copy() expected_capabilities['pageLoadStrategy'] = 'eager' @@ -208,7 +211,7 @@ def test_get_firefox_options_capabilities_new(config, utils): config.set('Capabilities', 'pageLoadStrategy', 'normal') config_driver = ConfigDriver(config, utils) expected_arguments = [] - expected_preferences = {} + expected_preferences = DEFAULT_PREFERENCES expected_capabilities = DEFAULT_CAPABILITIES.copy() expected_capabilities['browserVersion'] = '50' @@ -222,7 +225,7 @@ def test_get_firefox_options_capabilities_new(config, utils): def test_get_firefox_options_with_previous_capabilities(config, utils): config_driver = ConfigDriver(config, utils) expected_arguments = [] - expected_preferences = {} + expected_preferences = DEFAULT_PREFERENCES expected_capabilities = DEFAULT_CAPABILITIES.copy() expected_capabilities['browserVersion'] = '100' diff --git a/toolium/test/utils/test_dataset_map_param_context.py b/toolium/test/utils/test_dataset_map_param_context.py index 2fc1a517..39873535 100644 --- a/toolium/test/utils/test_dataset_map_param_context.py +++ b/toolium/test/utils/test_dataset_map_param_context.py @@ -329,7 +329,7 @@ class Context(object): with pytest.raises(Exception) as excinfo: map_param("[CONTEXT:list.cmsScrollableActions.text]") - assert "the index 'text' must be a numeric index" == str(excinfo.value) + assert "the expression 'text' was not able to select an element in the list" == str(excinfo.value) def test_a_context_param_list_correct_index(): @@ -411,11 +411,11 @@ class Context(object): with pytest.raises(Exception) as excinfo: map_param("[CONTEXT:list.cmsScrollableActions.prueba.id]") - assert "the index 'prueba' must be a numeric index" == str(excinfo.value) + assert "the expression 'prueba' was not able to select an element in the list" == str(excinfo.value) with pytest.raises(Exception) as excinfo: map_param("[CONTEXT:list.cmsScrollableActions.'36'.id]") - assert "the index ''36'' must be a numeric index" == str(excinfo.value) + assert "the expression ''36'' was not able to select an element in the list" == str(excinfo.value) def test_a_context_param_class_no_numeric_index(): @@ -441,8 +441,311 @@ def __init__(self): print(context) with pytest.raises(Exception) as excinfo: map_param("[CONTEXT:list.cmsScrollableActions.prueba.id]") - assert "the index 'prueba' must be a numeric index" == str(excinfo.value) + assert "the expression 'prueba' was not able to select an element in the list" == str(excinfo.value) with pytest.raises(Exception) as excinfo: map_param("[CONTEXT:list.cmsScrollableActions.'36'.id]") - assert "the index ''36'' must be a numeric index" == str(excinfo.value) + assert "the expression ''36'' was not able to select an element in the list" == str(excinfo.value) + + +def test_a_context_param_list_correct_select_expression(): + """ + Verification of a list with a correct select expression as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert ( + map_param("[CONTEXT:list.cmsScrollableActions.id=ask-for-qa.text]") + == "QA no duplica" + ) + + +def test_a_context_param_list_correct_select_expression_with_value_single_quotes(): + """ + Verification of a list with a correct select expression having single quotes for value as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert ( + map_param("[CONTEXT:list.cmsScrollableActions.id='ask-for-qa'.text]") + == "QA no duplica" + ) + + +def test_a_context_param_list_correct_select_expression_with_value_double_quotes(): + """ + Verification of a list with a correct select expression having double quotes for value as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert ( + map_param('[CONTEXT:list.cmsScrollableActions.id="ask-for-qa".text]') + == "QA no duplica" + ) + + +def test_a_context_param_list_correct_select_expression_with_key_single_quotes(): + """ + Verification of a list with a correct select expression having single quotes for the value as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert ( + map_param("[CONTEXT:list.cmsScrollableActions.'id'=ask-for-qa.text]") + == "QA no duplica" + ) + + +def test_a_context_param_list_correct_select_expression_with_key_double_quotes(): + """ + Verification of a list with a correct select expression having double quotes for the key as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert ( + map_param('[CONTEXT:list.cmsScrollableActions."id"=ask-for-qa.text]') + == "QA no duplica" + ) + + +def test_a_context_param_list_correct_select_expression_with_key_and_value_with_quotes(): + """ + Verification of a list with a correct select expression having quotes for both key and value as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert ( + map_param('[CONTEXT:list.cmsScrollableActions."id"="ask-for-qa".text]') + == "QA no duplica" + ) + + +def test_a_context_param_list_correct_select_expression_with_blanks(): + """ + Verification of a list with a correct select expression with blanks in the text to search for as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert ( + map_param('[CONTEXT:list.cmsScrollableActions.text="QA no duplica".id]') + == "ask-for-qa" + ) + + +def test_a_context_param_list_correct_select_expression_finds_nothing(): + """ + Verification of a list with a correct select expression which obtains no result as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + with pytest.raises(Exception) as excinfo: + map_param("[CONTEXT:list.cmsScrollableActions.id='not-existing-id'.text]") + assert ( + "the expression 'id='not-existing-id'' was not able to select an element in the list" + == str(excinfo.value) + ) + + +def test_a_context_param_list_correct_select_expression_with_empty_value_finds_nothing(): + """ + Verification of a list with a correct select expression with empty value which obtains no result as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + with pytest.raises(Exception) as excinfo: + map_param("[CONTEXT:list.cmsScrollableActions.id=''.text]") + assert ( + "the expression 'id=''' was not able to select an element in the list" + == str(excinfo.value) + ) + + +def test_a_context_param_list_correct_select_expression_with_empty_value_hits_value(): + """ + Verification of a list with a correct select expression with empty value which obtains no result as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + assert map_param("[CONTEXT:list.cmsScrollableActions.id=''.text]") == "QA duplica" + + +def test_a_context_param_list_invalid_select_expression(): + """ + Verification of a list with an invalid select expression as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + with pytest.raises(Exception) as excinfo: + map_param("[CONTEXT:list.cmsScrollableActions.invalidexpression.text]") + assert ( + "the expression 'invalidexpression' was not able to select an element in the list" + == str(excinfo.value) + ) + + +def test_a_context_param_list_invalid_select_expression_having_empty_key(): + """ + Verification of a list with a invalid select expression having empty key as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": [ + {"id": "ask-for-duplicate", "text": "QA duplica"}, + {"id": "ask-for-qa", "text": "QA no duplica"}, + ] + } + dataset.behave_context = context + with pytest.raises(Exception) as excinfo: + map_param("[CONTEXT:list.cmsScrollableActions.='not-existing-id'.text]") + assert ( + "the expression '='not-existing-id'' was not able to select an element in the list" + == str(excinfo.value) + ) + + +def test_a_context_param_list_invalid_structure_for_valid_select(): + """ + Verification of a list with a invalid structure for a valid select as CONTEXT + """ + + class Context(object): + pass + + context = Context() + + context.list = { + "cmsScrollableActions": {"id": "ask-for-duplicate", "text": "QA duplica"}, + } + dataset.behave_context = context + with pytest.raises(Exception) as excinfo: + map_param("[CONTEXT:list.cmsScrollableActions.id=ask-for-duplicate.text]") + assert ( + "'id=ask-for-duplicate' key not found in {'id': 'ask-for-duplicate', 'text': 'QA duplica'} value in context" + == str(excinfo.value) + ) diff --git a/toolium/utils/dataset.py b/toolium/utils/dataset.py index fa09df11..2750f81c 100644 --- a/toolium/utils/dataset.py +++ b/toolium/utils/dataset.py @@ -578,8 +578,19 @@ def get_value_from_context(param, context): storages or the context object itself. In a dotted case, "last_request.result" is searched as a "last_request" key in the context storages or as a property of the context object whose name is last_request. In both cases, when found, "result" is considered (and resolved) as a property or a key into the returned value. + If the resolved element at one of the tokens is a list, then the next token (if present) is used as the index - to select one of its elements, e.g. "list.1" returns the second element of the list "list". + to select one of its elements in case it is a number, e.g. "list.1" returns the second element of the list "list". + + If the resolved element at one of the tokens is a list and the next token is a key=value expression, then the + element in the list that matches the key=value expression is selected, e.g. "list.key=value" returns the element + in the list "list" that has the value for key attribute. So, for example, if the list is: + [ + {"key": "value1", "attr": "attr1"}, + {"key": "value2", "attr": "attr2"} + ] + then "list.key=value2" returns the second element in the list. Also does "list.'key'='value2'", + "list.'key'=\"value2\"", "list.\"key\"='value2'" or "list.\"key\"=\"value2\"". There is not limit in the nested levels of dotted tokens, so a key like a.b.c.d will be tried to be resolved as: @@ -596,19 +607,53 @@ def get_value_from_context(param, context): msg = None for part in parts[1:]: + # the regular case is having a key in a dict if isinstance(value, dict) and part in value: value = value[part] + # evaluate if in an array, access is requested by index elif isinstance(value, list) and part.isdigit() and int(part) < len(value): value = value[int(part)] + # or by a key=value expression + elif isinstance(value, list) and (element := _select_element_in_list(value, part)): + value = element + # look for an attribute in an object elif hasattr(value, part): value = getattr(value, part) else: + # raise an exception if not possible to resolve the current part against the current value msg = _get_value_context_error_msg(value, part) logger.error(msg) - raise Exception(msg) + raise ValueError(msg) return value +def _select_element_in_list(the_list, expression): + """ + Select an element in the list that matches the key=value expression. + + :param the_list: list of dictionaries + :param expression: key=value expression + :return: the element in the list that matches the key=value expression + """ + if not expression: + return None + tokens = expression.split('=') + if len(tokens) != 2 or len(tokens[0]) == 0: + return None + + def _trim_quotes(value): + if len(value) >= 2 and value[0] == value[-1] and value[0] in ['"', "'"]: + return value[1:-1] + return value + + key = _trim_quotes(tokens[0]) + value = _trim_quotes(tokens[1]) + for idx, item in enumerate(the_list): + if key in item and item[key] == value: + return the_list[idx] + return None + + def _get_value_context_error_msg(value, part): """ Returns an appropriate error message when an error occurs resolving a CONTEXT reference. @@ -623,7 +668,7 @@ def _get_value_context_error_msg(value, part): if part.isdigit(): return f"Invalid index '{part}', list size is '{len(value)}'. {part} >= {len(value)}." else: - return f"the index '{part}' must be a numeric index" + return f"the expression '{part}' was not able to select an element in the list" else: return f"'{part}' attribute not found in {type(value).__name__} class in context"