From 9c3eefa3d9d244ea561fe3ffd4b31bd4394b2b2f Mon Sep 17 00:00:00 2001 From: robertomier Date: Wed, 29 May 2024 10:02:13 +0200 Subject: [PATCH 01/11] NOV-244340: expression for selecting in an array for a context storage (#388) * chore: base code for selecting in an array using expresions like a.[key='value'].b * fix: previous unitary tests with the new code * test: add positive unitary tests for the new feature * test: add negative unitary tests * doc: updated methods docstring * doc: updated changelog * fix: flake8 format for unitary testing * fix: flake8 format for dataset module * chore: adapt code to codeclimate bot stupidity * test: add unitary test for invalid list structure --- CHANGELOG.rst | 1 + .../utils/test_dataset_map_param_context.py | 313 +++++++++++++++++- toolium/utils/dataset.py | 51 ++- 3 files changed, 357 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2a0c43be..17879c6c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,7 @@ 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 v3.1.4 ------ 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" From 63188db01a3196bc3b00a9fe1e7fddea10b2cf54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Gonz=C3=A1lez=20Alonso?= Date: Wed, 26 Jun 2024 12:42:39 +0200 Subject: [PATCH 02/11] fix: upgrade Faker version to 25.9 (#394) * fix: upgrade Faker version to 25.9 * fix Firefox unittests --- CHANGELOG.rst | 3 ++- requirements.txt | 2 +- toolium/test/test_config_driver_firefox.py | 17 ++++++++++------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 17879c6c..417ad7e1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,7 +7,8 @@ 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 +- Add `key=value` expressions for selecting elements in the context storage +- Upgrade Faker version to 25.9.* v3.1.4 ------ diff --git a/requirements.txt b/requirements.txt index 281ce90d..7edfa80b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,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/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' From 3f08314508125603653a912f78b85e4b75df659f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Garc=C3=ADa=20Fern=C3=A1ndez?= Date: Thu, 4 Jul 2024 08:55:24 +0200 Subject: [PATCH 03/11] bug(QAWTO-212): fix result for action before the feature error with background (#397) * bug(QAWTO-212): fix action before the feature with background output with error * fix(QAWTO-212): fix flake8 linter messages * fix(QAWTO-212): move behave import to selected function * docs(QAWTO-212): update changelog --- CHANGELOG.rst | 1 + toolium/behave/env_utils.py | 19 +++++++++++++------ toolium/test/behave/test_env_utils.py | 10 ++++++---- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 417ad7e1..0eef00f4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,7 @@ v3.1.5 - 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/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/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) From e8fa864a44068a59904b07677e45163258447149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Gonz=C3=A1lez=20Alonso?= Date: Thu, 16 May 2024 18:08:55 +0200 Subject: [PATCH 04/11] feat: add initial integration of playwright (#387) * feat: add initial integration of playwright * avoid error in after_scenario --- VERSION | 2 +- requirements.txt | 1 + toolium/behave/environment.py | 31 ++++++++++++++++-- toolium/pageobjects/playwright_page_object.py | 32 +++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 toolium/pageobjects/playwright_page_object.py diff --git a/VERSION b/VERSION index e94017a5..745e2a57 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.5.dev0 +3.2.0.dev0 diff --git a/requirements.txt b/requirements.txt index 7edfa80b..4f749883 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ requests~=2.27 # api tests selenium~=4.0 # web tests +playwright~=1.43 # web tests Appium-Python-Client~=2.3 # mobile tests Pillow~=10.1 # visual testing screeninfo~=0.8 diff --git a/toolium/behave/environment.py b/toolium/behave/environment.py index b6f66f09..8236aa3f 100644 --- a/toolium/behave/environment.py +++ b/toolium/behave/environment.py @@ -20,6 +20,9 @@ import os import re +from behave.api.async_step import use_or_create_async_context +from playwright.async_api import async_playwright + from toolium.utils import dataset from toolium.config_files import ConfigFiles from toolium.driver_wrapper import DriverWrappersPool @@ -225,6 +228,12 @@ def after_scenario(context, scenario): DriverWrappersPool.close_drivers(scope='function', test_name=scenario.name, test_passed=scenario.status in ['passed', 'skipped'], context=context) + # Stop playwright + if context.toolium_config.get_optional('Driver', 'web_library') == 'playwright' and hasattr(context, 'playwright'): + # TODO: reuse driver like in close_drivers + loop = context.async_context.loop + loop.run_until_complete(context.playwright.stop()) + # Save test status to be updated later if jira_test_status: add_jira_status(get_jira_key_from_scenario(scenario), jira_test_status, jira_test_comment) @@ -281,6 +290,22 @@ def start_driver(context, no_driver): :param context: behave context :param no_driver: True if this is an api test and driver should not be started """ - create_and_configure_wrapper(context) - if not no_driver: - connect_wrapper(context) + if context.toolium_config.get_optional('Driver', 'web_library') == 'playwright': + start_playwright(context) + else: + create_and_configure_wrapper(context) + if not no_driver: + connect_wrapper(context) + + +def start_playwright(context): + """Start playwright with configured values + + :param context: behave context + """ + use_or_create_async_context(context) + loop = context.async_context.loop + context.playwright = loop.run_until_complete(async_playwright().start()) + # TODO: select browser from config + context.browser = loop.run_until_complete(context.playwright.chromium.launch(headless=False)) + context.page = loop.run_until_complete(context.browser.new_page()) diff --git a/toolium/pageobjects/playwright_page_object.py b/toolium/pageobjects/playwright_page_object.py new file mode 100644 index 00000000..d4e33ea6 --- /dev/null +++ b/toolium/pageobjects/playwright_page_object.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +""" +Copyright 2024 Telefónica Innovación Digital, S.L. +This file is part of Toolium. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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. +""" + +from playwright.async_api import Page +from toolium.pageobjects.page_object import PageObject + + +class PlaywrightPageObject(PageObject): + """Class to represent a playwright web page""" + + def __init__(self, page: Page): + """Initialize page object properties + + :param page: playwright page instance + """ + self.page = page + super(PlaywrightPageObject, self).__init__() From be3193ae39b4ed7aab7f90e05c53a9d41917c5c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Gonz=C3=A1lez=20Alonso?= Date: Fri, 31 May 2024 09:39:54 +0200 Subject: [PATCH 05/11] feat: reuse toolium driver and pageobjects for playwright tests (#389) --- toolium/behave/environment.py | 25 ++----- toolium/driver_wrapper.py | 21 +++++- toolium/pageelements/playwright/__init__.py | 23 +++++++ .../playwright/button_page_element.py | 36 ++++++++++ .../playwright/input_text_page_element.py | 69 +++++++++++++++++++ .../pageelements/playwright/page_element.py | 65 +++++++++++++++++ .../playwright/text_page_element.py} | 16 ++--- 7 files changed, 225 insertions(+), 30 deletions(-) create mode 100644 toolium/pageelements/playwright/__init__.py create mode 100644 toolium/pageelements/playwright/button_page_element.py create mode 100644 toolium/pageelements/playwright/input_text_page_element.py create mode 100644 toolium/pageelements/playwright/page_element.py rename toolium/{pageobjects/playwright_page_object.py => pageelements/playwright/text_page_element.py} (62%) diff --git a/toolium/behave/environment.py b/toolium/behave/environment.py index 8236aa3f..8a35028f 100644 --- a/toolium/behave/environment.py +++ b/toolium/behave/environment.py @@ -21,7 +21,6 @@ import re from behave.api.async_step import use_or_create_async_context -from playwright.async_api import async_playwright from toolium.utils import dataset from toolium.config_files import ConfigFiles @@ -291,21 +290,9 @@ def start_driver(context, no_driver): :param no_driver: True if this is an api test and driver should not be started """ if context.toolium_config.get_optional('Driver', 'web_library') == 'playwright': - start_playwright(context) - else: - create_and_configure_wrapper(context) - if not no_driver: - connect_wrapper(context) - - -def start_playwright(context): - """Start playwright with configured values - - :param context: behave context - """ - use_or_create_async_context(context) - loop = context.async_context.loop - context.playwright = loop.run_until_complete(async_playwright().start()) - # TODO: select browser from config - context.browser = loop.run_until_complete(context.playwright.chromium.launch(headless=False)) - context.page = loop.run_until_complete(context.browser.new_page()) + # Activate behave async context to execute playwright + use_or_create_async_context(context) + context.driver_wrapper.async_loop = context.async_context.loop + create_and_configure_wrapper(context) + if not no_driver: + connect_wrapper(context) diff --git a/toolium/driver_wrapper.py b/toolium/driver_wrapper.py index ffa9a940..560b592a 100644 --- a/toolium/driver_wrapper.py +++ b/toolium/driver_wrapper.py @@ -20,6 +20,7 @@ import os import screeninfo +from playwright.async_api import async_playwright from toolium.config_driver import ConfigDriver from toolium.config_parser import ExtendedConfigParser @@ -54,6 +55,7 @@ class DriverWrapper(object): remote_node = None #: remote grid node remote_node_video_enabled = False #: True if the remote grid node has the video recorder enabled logger = None #: logger instance + async_loop = None #: async loop for playwright tests # Configuration and output files config_properties_filenames = None #: configuration filenames separated by commas @@ -204,11 +206,16 @@ def configure(self, tc_config_files, is_selenium_test=True, behave_properties=No def connect(self): """Set up the selenium driver and connect to the server - :returns: selenium driver + :returns: selenium or playwright driver """ if not self.config.get('Driver', 'type') or self.config.get('Driver', 'type') in ['api', 'no_driver']: return None + if self.async_loop: + # Connect playwright driver + self.driver = self.connect_playwright(self.async_loop) + return self.driver + self.driver = ConfigDriver(self.config, self.utils).create_driver() # Save session id and remote node to download video after the test execution @@ -239,6 +246,18 @@ def connect(self): return self.driver + def connect_playwright(self, async_loop): + """Set up the playwright page + + :returns: playwright page + """ + # TODO: should playwright and browser be saved in driver_wrapper? + playwright = async_loop.run_until_complete(async_playwright().start()) + # TODO: select browser from config + browser = async_loop.run_until_complete(playwright.chromium.launch(headless=False)) + page = async_loop.run_until_complete(browser.new_page()) + return page + def resize_window(self): """Resize and move browser window""" if self.is_maximizable(): diff --git a/toolium/pageelements/playwright/__init__.py b/toolium/pageelements/playwright/__init__.py new file mode 100644 index 00000000..e323b03c --- /dev/null +++ b/toolium/pageelements/playwright/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +""" +Copyright 2024 Telefónica Innovación Digital, S.L. +This file is part of Toolium. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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. +""" + +from toolium.pageelements.playwright.button_page_element import Button +from toolium.pageelements.playwright.input_text_page_element import InputText +from toolium.pageelements.playwright.text_page_element import Text + +__all__ = ['Text', 'InputText', 'Button'] diff --git a/toolium/pageelements/playwright/button_page_element.py b/toolium/pageelements/playwright/button_page_element.py new file mode 100644 index 00000000..e73f3161 --- /dev/null +++ b/toolium/pageelements/playwright/button_page_element.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +""" +Copyright 2024 Telefónica Innovación Digital, S.L. +This file is part of Toolium. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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. +""" + +from toolium.pageelements.playwright.page_element import PageElement + + +class Button(PageElement): + async def get_text(self): + """Get the element text value + + :returns: element text value + """ + return await (await self.web_element).get_text() + + async def click(self): + """Click the element + + :returns: page element instance + """ + await (await self.web_element).click() + return self diff --git a/toolium/pageelements/playwright/input_text_page_element.py b/toolium/pageelements/playwright/input_text_page_element.py new file mode 100644 index 00000000..7f7f540c --- /dev/null +++ b/toolium/pageelements/playwright/input_text_page_element.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +""" +Copyright 2024 Telefónica Innovación Digital, S.L. +This file is part of Toolium. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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. +""" + +from toolium.pageelements.playwright.page_element import PageElement + + +class InputText(PageElement): + # TODO: convert to async get_text + @property + def text(self): + """Get the element text value + + :returns: element text value + """ + if self.driver_wrapper.is_web_test() or self.webview: + return self.web_element.get_attribute("value") + elif self.driver_wrapper.is_ios_test(): + return self.web_element.get_attribute("label") + elif self.driver_wrapper.is_android_test(): + return self.web_element.get_attribute("text") + + async def fill(self, value): + """Set value on the element + + :param value: value to be set + """ + await (await self.web_element).fill(value) + + # TODO: convert to async method + def clear(self): + """Clear the element value + + :returns: page element instance + """ + self.web_element.clear() + return self + + async def click(self): + """Click the element + + :returns: page element instance + """ + await (await self.web_element).click() + return self + + # TODO: convert to async method + def set_focus(self): + """ + Set the focus over the element and click on the InputField + + :returns: page element instance + """ + self.utils.focus_element(self.web_element, click=True) + return self diff --git a/toolium/pageelements/playwright/page_element.py b/toolium/pageelements/playwright/page_element.py new file mode 100644 index 00000000..c7e653b2 --- /dev/null +++ b/toolium/pageelements/playwright/page_element.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +""" +Copyright 2024 Telefónica Innovación Digital, S.L. +This file is part of Toolium. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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. +""" + +from selenium.common.exceptions import NoSuchElementException +from selenium.webdriver.common.by import By + +from toolium.pageelements import PageElement as BasePageElement + + +class PageElement(BasePageElement): + @property + async def web_element(self): + """Find WebElement using element locator + + :returns: web element object + :rtype: selenium.webdriver.remote.webelement.WebElement or appium.webdriver.webelement.WebElement + """ + try: + await self._find_web_element() + except NoSuchElementException as exception: + parent_msg = f" and parent locator {self.parent_locator_str()}" if self.parent else '' + msg = "Page element of type '%s' with locator %s%s not found" + self.logger.error(msg, type(self).__name__, self.locator, parent_msg) + exception.msg += "\n {}".format(msg % (type(self).__name__, self.locator, parent_msg)) + raise exception + return self._web_element + + async def _find_web_element(self): + """Find WebElement using element locator and save it in _web_element attribute""" + if not self._web_element or not self.driver_wrapper.config.getboolean_optional('Driver', 'save_web_element'): + # Element will be searched from parent element or from driver + # TODO: search from parent element + # base = self.utils.get_web_element(self.parent) if self.parent else self.driver + self._web_element = self.driver.locator(self.playwright_locator) + + @property + def playwright_locator(self): + """Return playwright locator converted from toolium/selenium locator + + :returns: playwright locator + """ + # TODO: Implement playwright locator conversion + if self.locator[0] == By.ID: + prefix = '#' + elif self.locator[0] == By.XPATH: + prefix = 'xpath=' + else: + raise ValueError(f'Locator type not supported to be converted to playwright: {self.locator[0]}') + playwright_locator = f'{prefix}{self.locator[1]}' + return playwright_locator diff --git a/toolium/pageobjects/playwright_page_object.py b/toolium/pageelements/playwright/text_page_element.py similarity index 62% rename from toolium/pageobjects/playwright_page_object.py rename to toolium/pageelements/playwright/text_page_element.py index d4e33ea6..e1fea263 100644 --- a/toolium/pageobjects/playwright_page_object.py +++ b/toolium/pageelements/playwright/text_page_element.py @@ -16,17 +16,13 @@ limitations under the License. """ -from playwright.async_api import Page -from toolium.pageobjects.page_object import PageObject +from toolium.pageelements.playwright.page_element import PageElement -class PlaywrightPageObject(PageObject): - """Class to represent a playwright web page""" +class Text(PageElement): + async def get_text(self): + """Get the text of the element - def __init__(self, page: Page): - """Initialize page object properties - - :param page: playwright page instance + :returns: the text of the element """ - self.page = page - super(PlaywrightPageObject, self).__init__() + return await (await self.web_element).text_content() From eaaf151d1215c34d09132be3b9c8029523035e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Garc=C3=ADa=20Fern=C3=A1ndez?= Date: Thu, 6 Jun 2024 11:24:14 +0200 Subject: [PATCH 06/11] chore: include playwright module on setup (#391) * chore: include playwright module on setup --- setup.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 94941cc1..f96063d0 100644 --- a/setup.py +++ b/setup.py @@ -43,8 +43,21 @@ def get_long_description(): setup( name='toolium', version=read_file('VERSION').strip(), - packages=['toolium', 'toolium.pageobjects', 'toolium.pageelements', 'toolium.behave', 'toolium.utils'], - package_data={'': ['resources/VisualTestsTemplate.html', 'resources/VisualTests.js', 'resources/VisualTests.css']}, + packages=[ + 'toolium', + 'toolium.pageobjects', + 'toolium.pageelements', + 'toolium.pageelements.playwright', + 'toolium.behave', + 'toolium.utils', + ], + package_data={ + '': [ + 'resources/VisualTestsTemplate.html', + 'resources/VisualTests.js', + 'resources/VisualTests.css', + ] + }, install_requires=read_file('requirements.txt').splitlines(), setup_requires=['pytest-runner'], tests_require=read_file('requirements_dev.txt').splitlines(), From 78346d20e9efacc3a4e244f1ecb993ae6a1d85c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Garc=C3=ADa=20Fern=C3=A1ndez?= Date: Thu, 6 Jun 2024 12:06:19 +0200 Subject: [PATCH 07/11] feat: playwright enable headless config (#392) --- toolium/driver_wrapper.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/toolium/driver_wrapper.py b/toolium/driver_wrapper.py index 560b592a..b3ec879b 100644 --- a/toolium/driver_wrapper.py +++ b/toolium/driver_wrapper.py @@ -254,7 +254,8 @@ def connect_playwright(self, async_loop): # TODO: should playwright and browser be saved in driver_wrapper? playwright = async_loop.run_until_complete(async_playwright().start()) # TODO: select browser from config - browser = async_loop.run_until_complete(playwright.chromium.launch(headless=False)) + headless_mode = self.config.getboolean_optional('Driver', 'headless') + browser = async_loop.run_until_complete(playwright.chromium.launch(headless=headless_mode)) page = async_loop.run_until_complete(browser.new_page()) return page From e467649114b7a0e4f7c90e8c44d8012aaa440181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Gonz=C3=A1lez=20Alonso?= Date: Tue, 11 Jun 2024 15:22:34 +0200 Subject: [PATCH 08/11] feat: stop playwright when driver is closed (#393) --- toolium/behave/environment.py | 16 ++++------ toolium/driver_wrapper.py | 53 +++++++++++++++++++++++++-------- toolium/driver_wrappers_pool.py | 2 +- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/toolium/behave/environment.py b/toolium/behave/environment.py index 8a35028f..11509469 100644 --- a/toolium/behave/environment.py +++ b/toolium/behave/environment.py @@ -156,6 +156,12 @@ def create_and_configure_wrapper(context): # Configure wrapper context.driver_wrapper.configure(context.config_files, behave_properties=behave_properties) + # Activate behave async context to execute playwright + if (context.driver_wrapper.config.get_optional('Driver', 'web_library') == 'playwright' + and context.driver_wrapper.async_loop is None): + use_or_create_async_context(context) + context.driver_wrapper.async_loop = context.async_context.loop + # Copy config object context.toolium_config = context.driver_wrapper.config @@ -227,12 +233,6 @@ def after_scenario(context, scenario): DriverWrappersPool.close_drivers(scope='function', test_name=scenario.name, test_passed=scenario.status in ['passed', 'skipped'], context=context) - # Stop playwright - if context.toolium_config.get_optional('Driver', 'web_library') == 'playwright' and hasattr(context, 'playwright'): - # TODO: reuse driver like in close_drivers - loop = context.async_context.loop - loop.run_until_complete(context.playwright.stop()) - # Save test status to be updated later if jira_test_status: add_jira_status(get_jira_key_from_scenario(scenario), jira_test_status, jira_test_comment) @@ -289,10 +289,6 @@ def start_driver(context, no_driver): :param context: behave context :param no_driver: True if this is an api test and driver should not be started """ - if context.toolium_config.get_optional('Driver', 'web_library') == 'playwright': - # Activate behave async context to execute playwright - use_or_create_async_context(context) - context.driver_wrapper.async_loop = context.async_context.loop create_and_configure_wrapper(context) if not no_driver: connect_wrapper(context) diff --git a/toolium/driver_wrapper.py b/toolium/driver_wrapper.py index b3ec879b..0d9e2470 100644 --- a/toolium/driver_wrapper.py +++ b/toolium/driver_wrapper.py @@ -56,6 +56,8 @@ class DriverWrapper(object): remote_node_video_enabled = False #: True if the remote grid node has the video recorder enabled logger = None #: logger instance async_loop = None #: async loop for playwright tests + playwright = None #: playwright instance + playwright_browser = None #: playwright browser instance # Configuration and output files config_properties_filenames = None #: configuration filenames separated by commas @@ -70,6 +72,9 @@ def __init__(self): default_wrapper = DriverWrappersPool.get_default_wrapper() self.config = default_wrapper.config.deepcopy() self.logger = default_wrapper.logger + self.async_loop = default_wrapper.async_loop + self.playwright = default_wrapper.playwright + self.playwright_browser = default_wrapper.playwright_browser 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 @@ -204,7 +209,7 @@ def configure(self, tc_config_files, is_selenium_test=True, behave_properties=No self.configure_visual_baseline() def connect(self): - """Set up the selenium driver and connect to the server + """Set up the driver and connect to the server :returns: selenium or playwright driver """ @@ -212,10 +217,17 @@ def connect(self): return None if self.async_loop: - # Connect playwright driver - self.driver = self.connect_playwright(self.async_loop) - return self.driver + self.connect_playwright() + else: + self.connect_selenium() + return self.driver + + def connect_selenium(self): + """Set up selenium driver + + :returns: selenium driver + """ self.driver = ConfigDriver(self.config, self.utils).create_driver() # Save session id and remote node to download video after the test execution @@ -244,20 +256,37 @@ def connect(self): # Set implicitly wait timeout self.utils.set_implicitly_wait() - return self.driver - - def connect_playwright(self, async_loop): + def connect_playwright(self): """Set up the playwright page + It is a sync method because it is called from sync behave initialization method :returns: playwright page """ - # TODO: should playwright and browser be saved in driver_wrapper? - playwright = async_loop.run_until_complete(async_playwright().start()) + 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') - browser = async_loop.run_until_complete(playwright.chromium.launch(headless=headless_mode)) - page = async_loop.run_until_complete(browser.new_page()) - return page + 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()) + + async def connect_playwright_new_page(self): + """Set up and additional playwright driver creating a new context and page in current browser 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() + 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()) + else: + # Stop selenium driver + self.driver.quit() def resize_window(self): """Resize and move browser window""" diff --git a/toolium/driver_wrappers_pool.py b/toolium/driver_wrappers_pool.py index 7f2298b6..4aea05d7 100644 --- a/toolium/driver_wrappers_pool.py +++ b/toolium/driver_wrappers_pool.py @@ -167,7 +167,7 @@ def stop_drivers(cls, maintain_default=False): if not driver_wrapper.driver: continue try: - driver_wrapper.driver.quit() + driver_wrapper.stop() except Exception as e: driver_wrapper.logger.warning( "Capture exceptions to avoid errors in teardown method due to session timeouts: \n %s" % e) From b1fcb091a54abe594ade4308c979eb4c7f67a246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ruiz=20Garc=C3=ADa?= Date: Mon, 8 Jul 2024 14:04:43 +0200 Subject: [PATCH 09/11] playwright start by browser --- toolium/config_driver.py | 267 +++++++++++++++++++++++++++++++++++++- toolium/driver_wrapper.py | 24 ++-- 2 files changed, 280 insertions(+), 11 deletions(-) diff --git a/toolium/config_driver.py b/toolium/config_driver.py index 9b4bb730..0adea55b 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 @@ -91,6 +92,36 @@ def create_driver(self): raise 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 @@ -163,6 +194,77 @@ def _create_local_driver(self): driver = driver_setup_method() 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 +397,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 @@ -361,6 +526,80 @@ def _get_chrome_options(self, capabilities={}): self._update_dict(options.capabilities, 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 +666,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: @@ -458,6 +697,30 @@ def _get_safari_options(self, capabilities={}): self._add_capabilities_from_properties(capabilities, '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..5613289c 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,30 @@ 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) + if user_data_dir: + self.playwright_context = async_loop.run_until_complete(ConfigDriver(self.config, self.utils, self.playwright).create_playwright_persistent_browser_context()) + else: + self.playwright_browser = async_loop.run_until_complete(ConfigDriver(self.config, self.utils, self.playwright).create_playwright_browser()) + self.playwright_context = async_loop.run_until_complete(self.playwright_browser.new_context(**ConfigDriver(self.config, self.utils, self.playwright).get_playwright_context_options())) + self.driver = async_loop.run_until_complete(self.playwright_context.new_page(**ConfigDriver(self.config, self.utils, self.playwright).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() From 54faef21350264e7a63684865986b24b7341087b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ruiz=20Garc=C3=ADa?= Date: Fri, 12 Jul 2024 12:06:54 +0200 Subject: [PATCH 10/11] avoid duplications for ConfigDriver instance --- toolium/driver_wrapper.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/toolium/driver_wrapper.py b/toolium/driver_wrapper.py index 5613289c..9a350b78 100644 --- a/toolium/driver_wrapper.py +++ b/toolium/driver_wrapper.py @@ -269,12 +269,13 @@ def connect_playwright(self): # 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(ConfigDriver(self.config, self.utils, self.playwright).create_playwright_persistent_browser_context()) + self.playwright_context = async_loop.run_until_complete(config_driver.create_playwright_persistent_browser_context()) else: - self.playwright_browser = async_loop.run_until_complete(ConfigDriver(self.config, self.utils, self.playwright).create_playwright_browser()) - self.playwright_context = async_loop.run_until_complete(self.playwright_browser.new_context(**ConfigDriver(self.config, self.utils, self.playwright).get_playwright_context_options())) - self.driver = async_loop.run_until_complete(self.playwright_context.new_page(**ConfigDriver(self.config, self.utils, self.playwright).get_playwright_page_options())) + 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 page in current browser and context instance From f1351a926b955053e6dfbefac1294d6904767122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ruiz=20Garc=C3=ADa?= Date: Fri, 12 Jul 2024 14:47:30 +0200 Subject: [PATCH 11/11] fix lint --- toolium/config_driver.py | 43 +++++++++++++++++++-------------------- toolium/driver_wrapper.py | 27 ++++++++++++++++-------- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/toolium/config_driver.py b/toolium/config_driver.py index 0adea55b..2b3f38a4 100644 --- a/toolium/config_driver.py +++ b/toolium/config_driver.py @@ -92,7 +92,7 @@ def create_driver(self): raise return driver - + def create_playwright_browser(self): """ Create a playwright browser using specified config properties @@ -194,16 +194,16 @@ def _create_local_driver(self): driver = driver_setup_method() 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: + else: if driver_name in ['chrome', 'chromium']: browser = self._setup_playwright_chrome() elif driver_name == 'firefox': @@ -213,16 +213,16 @@ def _create_playwright_browser(self): 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: + else: if driver_name in ['chrome', 'chromium']: browser_context = self._setup_playwright_persistent_chrome() elif driver_name == 'firefox': @@ -250,7 +250,7 @@ def get_playwright_context_options(self): 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 @@ -265,7 +265,6 @@ def get_playwright_page_options(self): pass return page_options - def _get_capabilities_from_driver_type(self): """Extract browserVersion and platformName from driver type and add them to capabilities @@ -415,7 +414,7 @@ def _setup_playwright_firefox(self): headless=headless_mode, **browser_options ) - + def _setup_playwright_persistent_firefox(self): """Setup Playwright Firefox persistent browser context @@ -430,11 +429,11 @@ def _setup_playwright_persistent_firefox(self): 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( + 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 @@ -526,7 +525,7 @@ def _get_chrome_options(self, capabilities={}): self._update_dict(options.capabilities, capabilities) return options - + def _get_playwright_browser_options(self): """ Get Playwright browser options from properties file @@ -541,7 +540,7 @@ def _get_playwright_browser_options(self): except NoSectionError: pass return browser_options - + def _setup_playwright_chrome(self): """ Setup Playwright Chrome browser @@ -558,7 +557,7 @@ def _setup_playwright_chrome(self): headless=headless_mode, **browser_options ) - + def _setup_playwright_persistent_chrome(self): """ Setup Playwright Chrome persistent browser context @@ -571,7 +570,7 @@ def _setup_playwright_persistent_chrome(self): 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( + return self.playwright.chromium.launch_persistent_context( headless=headless_mode, **context_options ) @@ -587,7 +586,7 @@ def _add_playwright_chrome_arguments(self, arguments): self.logger.debug("Added Chrome argument: %s%s", pref, pref_value) arguments.append('--{}{}'.format(pref, self._convert_property_type(pref_value))) except NoSectionError: - pass + pass def _add_playwright_chrome_extensions(self, arguments): """Add Chrome extensions from properties file @@ -599,7 +598,7 @@ def _add_playwright_chrome_extensions(self, arguments): self.logger.debug("Added Chrome extension: %s = %s", pref, pref_value) arguments.append('--load-extension={}'.format(pref_value)) except NoSectionError: - pass + pass def _add_chrome_options(self, options, option_name): """Add Chrome options from properties file @@ -697,7 +696,7 @@ def _get_safari_options(self, capabilities={}): self._add_capabilities_from_properties(capabilities, 'Capabilities') self._update_dict(options.capabilities, capabilities) return options - + def _setup_playwright_webkit(self): """Setup Playwright Webkit browser @@ -709,10 +708,10 @@ def _setup_playwright_webkit(self): 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') @@ -720,7 +719,7 @@ def _setup_playwright_persistent_webkit(self): 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 9a350b78..72e96799 100644 --- a/toolium/driver_wrapper.py +++ b/toolium/driver_wrapper.py @@ -267,23 +267,34 @@ def connect_playwright(self): async_loop = self.async_loop self.playwright = async_loop.run_until_complete(async_playwright().start()) - # In case of using a persistent context this property must be set and a BrowserContext is returned instead of a Browser + # 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())) + 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 page in current browser and context instance It is an async method to be called from async steps or page objects :returns: playwright driver - """ - self.driver = await self.playwright_context.new_page(**ConfigDriver(self.config, self.utils).get_playwright_page_options()) + """ + self.driver = await self.playwright_context.new_page( + **ConfigDriver(self.config, self.utils).get_playwright_page_options() + ) return self.driver def stop(self):