diff --git a/.flake8 b/.flake8 index 791f075..cf97b2d 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,4 @@ [flake8] +ignore = E203, W503, E701, E704 +exclude = .git,venv,env max-line-length = 119 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 440902b..9233dd3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,14 +20,14 @@ on: - '.github/**' - CHANGELOG.md - README.md - - CONTRIBUTING.rst + - CONTRIBUTING.md env: VERSION_FILE: setup.py VERSION_EXTRACT_PATTERN: >- - __version__\s*=\s*'([^']+) + __version__\s*=\s*"([^"]+) VERSION_REPLACE_PATTERN: >- - __version__ = '\1' + __version__ = "\1" TMP_SUFFIX: _updated CHANGE_LOG_FILE: CHANGELOG.md @@ -38,10 +38,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Generate versions + uses: HardNorth/github-version-generate@v1 + with: + version-source: file + version-file: ${{ env.VERSION_FILE }} + version-file-extraction-pattern: ${{ env.VERSION_EXTRACT_PATTERN }} + - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.8' + python-version: '3.10' - name: Install dependencies run: python -m pip install --upgrade pip setuptools wheel @@ -55,13 +62,6 @@ jobs: user: ${{ secrets.PYPI_USERNAME }} password: ${{ secrets.PYPI_PASSWORD }} - - name: Generate versions - uses: HardNorth/github-version-generate@v1 - with: - version-source: file - version-file: ${{ env.VERSION_FILE }} - version-file-extraction-pattern: ${{ env.VERSION_EXTRACT_PATTERN }} - - name: Setup git credentials uses: oleksiyrudenko/gha-git-credentials@v2-latest with: @@ -119,8 +119,8 @@ jobs: - name: Update version file id: versionFileUpdate run: | - export CURRENT_VERSION_VALUE=`echo '${{ env.CURRENT_VERSION }}' | sed -E "s/(.*)/${{ env.VERSION_REPLACE_PATTERN }}/"` - export NEXT_VERSION_VALUE=`echo '${{ env.NEXT_VERSION }}' | sed -E "s/(.*)/${{ env.VERSION_REPLACE_PATTERN }}/"` + export CURRENT_VERSION_VALUE=`echo '${{ env.CURRENT_VERSION }}' | sed -E 's/(.*)/${{ env.VERSION_REPLACE_PATTERN }}/'` + export NEXT_VERSION_VALUE=`echo '${{ env.NEXT_VERSION }}' | sed -E 's/(.*)/${{ env.VERSION_REPLACE_PATTERN }}/'` sed "s/${CURRENT_VERSION_VALUE}/${NEXT_VERSION_VALUE}/g" ${{ env.VERSION_FILE }} > ${{ env.VERSION_FILE }}${{ env.TMP_SUFFIX }} rm ${{ env.VERSION_FILE }} mv ${{ env.VERSION_FILE }}${{ env.TMP_SUFFIX }} ${{ env.VERSION_FILE }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 11a271d..5130d48 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11', '3.12' ] + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13' ] steps: - name: Checkout repository uses: actions/checkout@v4 @@ -53,7 +53,7 @@ jobs: run: tox - name: Upload coverage to Codecov - if: matrix.python-version == 3.8 && success() + if: matrix.python-version == 3.10 && success() uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 82b645b..071dfb1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,37 +1,31 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: check-added-large-files -- repo: https://github.com/PyCQA/pydocstyle - rev: 6.0.0 + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - repo: https://github.com/PyCQA/pydocstyle + rev: 6.3.0 hooks: - - id: pydocstyle - # Temporary exclude files in which are in charge of offline reporting + - id: pydocstyle exclude: | - (?x)^( - tests/*| - robotframework_reportportal/result_visitor.py| - robotframework_reportportal/post_report.py| - robotframework_reportportal/time_visitor.py - ) -- repo: https://github.com/Lucas-C/pre-commit-hooks-markup - rev: v1.0.1 + (?x)^( + tests/.* + ) + - repo: https://github.com/psf/black + rev: 24.10.0 hooks: - - id: rst-linter -- repo: https://github.com/pycqa/flake8 - rev: 5.0.4 + - id: black + args: [ '--check', 'robotframework_reportportal', 'tests' ] + - repo: https://github.com/pycqa/isort + rev: 5.13.2 hooks: - - id: flake8 - # Temporary exclude files in which are in charge of offline reporting - exclude: | - (?x)^( - robotframework_reportportal/result_visitor.py| - robotframework_reportportal/post_report.py| - robotframework_reportportal/time_visitor.py - )$ + - id: isort + - repo: https://github.com/pycqa/flake8 + rev: 7.1.1 + hooks: + - id: flake8 diff --git a/CHANGELOG.md b/CHANGELOG.md index d153312..1a2adda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ # Changelog ## [Unreleased] +### Changed +- Client version updated on [5.6.0](https://github.com/reportportal/client-Python/releases/tag/5.6.0), by @HardNorth +- Test end message now posts to the Test description, by @HardNorth +- Keywords names now contain Keyword types, by @HardNorth +### Added +- Support for `Python 3.13`, by @HardNorth +- `RP_REMOVE_KEYWORDS` configuration variable, by @HardNorth +- `--remove-keywords` argument support, by @HardNorth +### Removed +- `Python 3.7` support, by @HardNorth + +## [5.5.8] +### Added +- Issue [#191](https://github.com/reportportal/agent-Python-RobotFramework/issues/191): Add seamless screenshot logging for Selenium and Browser libraries, by @HardNorth ## [5.5.7] ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f470bd9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,136 @@ +# Contribution + +Contributions are highly welcomed and appreciated. + +## Contents + +- [Feature requests](#feature-requests) +- [Bug reports](#bug-reports) +- [Bug fixes](#bug-fixes) +- [Implement features](#implement-features) +- [Preparing Pull Requests](#preparing-pull-requests) + +## Feature requests + +We'd also like to hear about your thoughts and suggestions. Feel free to [submit them as issues](https://github.com/reportportal/agent-Python-RobotFramework/issues) and: + +* Explain in detail how they should work. +* Keep the scope as narrow as possible. It will make it easier to implement. + +## Bug reports + +Report bugs for the agent in the [issue tracker](https://github.com/reportportal/agent-Python-RobotFramework/issues). + +If you are reporting a new bug, please include: + +* Your operating system name and version. +* Python interpreter version, installed libraries, reportportal-client, and agent-Python-RobotFramework version. +* Detailed steps to reproduce the bug. + +## Bug fixes + +Look through the [GitHub issues for bugs](https://github.com/reportportal/agent-Python-RobotFramework/labels/bug). + +If you are going to fix any of existing bugs, assign that bug to yourself and specify preliminary milestones. Talk to [contributors](https://github.com/reportportal/agent-Python-RobotFramework/graphs/contributors) in case you need a consultancy regarding implementation. + +## Implement features + +Look through the [GitHub issues for enhancements](https://github.com/reportportal/agent-Python-RobotFramework/labels/enhancement). + +Talk to [contributors](https://github.com/reportportal/agent-Python-RobotFramework/graphs/contributors) in case you need a consultancy regarding implementation. + +## Preparing Pull Requests + +What is a "pull request"? It informs the project's core developers about the changes you want to review and merge. Pull requests are stored on [GitHub servers](https://github.com/reportportal/agent-Python-RobotFramework/pulls). Once you send a pull request, we can discuss its potential modifications and even add more commits to it later on. There's an excellent tutorial on how Pull Requests work in the [GitHub Help Center](https://help.github.com/articles/using-pull-requests/). + +Here is a simple overview below: + +1. Fork the [agent-Python-RobotFramework GitHub repository](https://github.com/reportportal/agent-Python-RobotFramework). + +2. Clone your fork locally using [git](https://git-scm.com/) and create a branch: + + ```sh + $ git clone git@github.com:YOUR_GITHUB_USERNAME/agent-Python-RobotFramework.git + $ cd agent-Python-RobotFramework + # now, create your own branch off the "master": + $ git checkout -b your-bugfix-branch-name + ``` + + If you need some help with Git, follow this quick start guide: https://git.wiki.kernel.org/index.php/QuickStart + +3. Install [pre-commit](https://pre-commit.com) and its hook on the agent-Python-RobotFramework repo: + + **Note: pre-commit must be installed as admin, as it will not function otherwise**: + + ```sh + $ pip install --user pre-commit + $ pre-commit install + ``` + + Afterward `pre-commit` will run whenever you commit. + + [https://pre-commit.com](https://pre-commit.com) is a framework for managing and maintaining multi-language pre-commit hooks to ensure code-style and code formatting is consistent. + +4. Install tox + + Tox is used to run all the tests and will automatically set up virtualenvs to run the tests in. (will implicitly use http://www.virtualenv.org/en/latest/): + + ```sh + $ pip install tox + ``` + +5. Run all the tests + + You need to have Python 3.10 available in your system. Now running tests is as simple as issuing this command: + + ```sh + $ tox -e pep,py310 + ``` + + This command will run tests via the "tox" tool against Python 3.10 and also perform code style checks. + +6. You can now edit your local working copy and run the tests again as necessary. Please follow PEP-8 recommendations. + + You can pass different options to `tox`. For example, to run tests on Python 3.10 and pass options to pytest (e.g. enter pdb on failure) to pytest you can do: + + ```sh + $ tox -e py310 -- --pdb + ``` + + Or to only run tests in a particular test module on Python 3.10: + + ```sh + $ tox -e py310 -- tests/test_service.py + ``` + + When committing, `pre-commit` will re-format the files if necessary. + +7. If instead of using `tox` you prefer to run the tests directly, then we suggest to create a virtual environment and use an editable installation with the `testing` extra: + + ```sh + $ python3 -m venv .venv + $ source .venv/bin/activate # Linux + $ .venv/Scripts/activate.bat # Windows + $ pip install -e ".[testing]" + ``` + + Afterwards, you can edit the files and run pytest normally: + + ```sh + $ pytest tests/test_service.py + ``` + +8. Commit and push once your tests pass and you are happy with your change(s): + + ```sh + $ git commit -m "" + $ git push -u + ``` + +9. Finally, submit a pull request through the GitHub website using this data: + + head-fork: YOUR_GITHUB_USERNAME/agent-Python-RobotFramework + compare: your-branch-name + + base-fork: reportportal/agent-Python-RobotFramework + base: master diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst deleted file mode 100644 index cafcc20..0000000 --- a/CONTRIBUTING.rst +++ /dev/null @@ -1,146 +0,0 @@ -============ -Contribution -============ - -Contributions are highly welcomed and appreciated. - -.. contents:: - :depth: 2 - :backlinks: none - -Feature requests ----------------- - -We'd also like to hear about your thoughts and suggestions. Feel free to -`submit them as issues `_ and: - -* Explain in detail how they should work. -* Keep the scope as narrow as possible. It will make it easier to implement. - -Bug reports ------------ - -Report bugs for the agent in the `issue tracker `_. - -If you are reporting a new bug, please include: - -* Your operating system name and version. -* Python interpreter version, installed libraries, reportportal-client, and agent-Python-RobotFramework - version. -* Detailed steps to reproduce the bug. - -Bug fixes ---------- - -Look through the `GitHub issues for bugs `_. - -If you are gonna fix any of existing bugs, assign that bug to yourself and specify preliminary milestones. -Talk to `contributors `_ in case you need a -consultancy regarding implementation. - -Implement features ------------------- - -Look through the `GitHub issues for enhancements `_. - -Talk to `contributors `_ in case you need a -consultancy regarding implementation. - -Preparing Pull Requests ------------------------ - -What is a "pull request"? It informs the project's core developers about the -changes you want to review and merge. Pull requests are stored on -`GitHub servers `_. -Once you send a pull request, we can discuss its potential modifications and -even add more commits to it later on. There's an excellent tutorial on how Pull -Requests work in the -`GitHub Help Center `_. - -Here is a simple overview below: - -#. Fork the - `agent-Python-RobotFramework GitHub repository `_. - -#. Clone your fork locally using `git `_ and create a branch:: - - $ git clone git@github.com:YOUR_GITHUB_USERNAME/agent-Python-RobotFramework.git - $ cd agent-Python-RobotFramework - # now, create your own branch off the "master": - - $ git checkout -b your-bugfix-branch-name - - If you need some help with Git, follow this quick start - guide: https://git.wiki.kernel.org/index.php/QuickStart - -#. Install `pre-commit `_ and its hook on the agent-Python-RobotFramework repo: - - **Note: pre-commit must be installed as admin, as it will not function otherwise**:: - - - $ pip install --user pre-commit - $ pre-commit install - - Afterwards ``pre-commit`` will run whenever you commit. - - https://pre-commit.com/ is a framework for managing and maintaining multi-language pre-commit hooks - to ensure code-style and code formatting is consistent. - -#. Install tox - - Tox is used to run all the tests and will automatically setup virtualenvs - to run the tests in. - (will implicitly use http://www.virtualenv.org/en/latest/):: - - $ pip install tox - -#. Run all the tests - - You need to have Python 3.6 available in your system. Now - running tests is as simple as issuing this command:: - - $ tox -e pep,py36 - - This command will run tests via the "tox" tool against Python 3.6 - and also perform code style checks. - -#. You can now edit your local working copy and run the tests again as necessary. Please follow PEP-8 recommendations. - - You can pass different options to ``tox``. For example, to run tests on Python 3.6 and pass options to pytest - (e.g. enter pdb on failure) to pytest you can do:: - - $ tox -e py36 -- --pdb - - Or to only run tests in a particular test module on Python 3.6:: - - $ tox -e py36 -- tests/test_service.py - - - When committing, ``pre-commit`` will re-format the files if necessary. - -#. If instead of using ``tox`` you prefer to run the tests directly, then we suggest to create a virtual environment and use - an editable install with the ``testing`` extra:: - - $ python3 -m venv .venv - $ source .venv/bin/activate # Linux - $ .venv/Scripts/activate.bat # Windows - $ pip install -e ".[testing]" - - Afterwards, you can edit the files and run pytest normally:: - - $ pytest tests/test_service.py - - -#. Commit and push once your tests pass and you are happy with your change(s):: - - $ git commit -m "" - $ git push -u - - -#. Finally, submit a pull request through the GitHub website using this data:: - - head-fork: YOUR_GITHUB_USERNAME/agent-Python-RobotFramework - compare: your-branch-name - - base-fork: reportportal/agent-Python-RobotFramework - base: master diff --git a/README.md b/README.md index a534926..2d8f924 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,10 @@ -# ReportPortal RobotFramework agent +# ReportPortal integration for RobotFramework + +A RobotFramework Listener to report test results to ReportPortal + +> **DISCLAIMER**: We use Google Analytics for sending anonymous usage information such as agent's and client's names, +> and their versions after a successful launch start. This information might help us to improve both ReportPortal +> backend and client sides. It is used by the ReportPortal team only and is not supposed for sharing with 3rd parties. [![PyPI](https://img.shields.io/pypi/v/robotframework-reportportal.svg?maxAge=259200)](https://pypi.python.org/pypi/robotframework-reportportal) [![Python versions](https://img.shields.io/pypi/pyversions/robotframework-reportportal.svg)](https://pypi.org/project/robotframework-reportportal) @@ -8,7 +14,6 @@ [![stackoverflow](https://img.shields.io/badge/reportportal-stackoverflow-orange.svg?style=flat)](http://stackoverflow.com/questions/tagged/reportportal) [![Build with Love](https://img.shields.io/badge/build%20with-❤%EF%B8%8F%E2%80%8D-lightgrey.svg)](http://reportportal.io?style=flat) -Listener for RobotFramework to report results to ReportPortal * [Installation](https://github.com/reportportal/agent-Python-RobotFramework#installation) * [Usage](https://github.com/reportportal/agent-Python-RobotFramework#usage) @@ -88,6 +93,8 @@ NOT REQUIRED: --variable RP_VERIFY_SSL:"True" - Default value is "True", disables SSL verification for HTTP requests. Also, you can specify a full path to your certificate as the value. +--variable RP_REMOVE_KEYWORDS:"True" + - Default value is "False", remove keywords from reporting, passed with '--remove-keywords' Robot's argument. ``` ### Logging @@ -123,22 +130,6 @@ ReportPortal. https://github.com/reportportal/client-Python#send-attachement-screenshots -## Integration with GA - -ReportPortal is now supporting integrations with more than 15 test frameworks -simultaneously. In order to define the most popular agents and plan the team -workload accordingly, we are using Google analytics. - -ReportPortal collects information about agent name and its version only. This -information is sent to Google analytics on the launch start. Please help us to -make our work effective. -If you still want to switch Off Google analytics, please change env variable -the way below. - -```bash -export AGENT_NO_ANALYTICS=1 -``` - ## Copyright Notice Licensed under the [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0) diff --git a/examples/binary_file_log_as_image.robot b/examples/binary_file_log_as_image.robot new file mode 100644 index 0000000..01c9195 --- /dev/null +++ b/examples/binary_file_log_as_image.robot @@ -0,0 +1,18 @@ +*** Settings *** +Documentation Example of logging on binary file read +Library library/Log.py +Library OperatingSystem + +*** Variables *** +${PUG_IMAGE} res/pug/lucky.jpg + +*** Keywords *** +Read Binary File + [Tags] binary + [Arguments] ${file} + ${data} Get Binary File ${file} + Binary Log INFO image pug.jpg ${data} + +*** Test Cases *** +Read Pug Image + Read Binary File ${PUG_IMAGE} diff --git a/examples/binary_file_read.robot b/examples/binary_file_log_as_text.robot similarity index 81% rename from examples/binary_file_read.robot rename to examples/binary_file_log_as_text.robot index 29683ca..82c7829 100644 --- a/examples/binary_file_read.robot +++ b/examples/binary_file_log_as_text.robot @@ -7,8 +7,9 @@ ${PUG_IMAGE} res/pug/lucky.jpg *** Keywords *** Read Binary File + [Tags] binary [Arguments] ${file} - ${data} Get Binary File ${file} + ${data} Get Binary File ${file} Log ${data} *** Test Cases *** diff --git a/examples/for_keyword.robot b/examples/for_keyword.robot new file mode 100644 index 0000000..86d4ba3 --- /dev/null +++ b/examples/for_keyword.robot @@ -0,0 +1,11 @@ +*** Settings *** +Documentation Example of 'FOR' keyword reporting + +*** Variables *** +@{fruits} apple banana cherry + +*** Test Cases *** +For test + FOR ${var} IN @{fruits} + Log ${var} + END diff --git a/examples/for_keyword_failed.robot b/examples/for_keyword_failed.robot new file mode 100644 index 0000000..1e6bd24 --- /dev/null +++ b/examples/for_keyword_failed.robot @@ -0,0 +1,14 @@ +*** Settings *** +Documentation Example of failing 'FOR' keyword reporting + +*** Variables *** +@{fruits} apple banana cherry + +*** Test Cases *** +For test + FOR ${var} IN @{fruits} + Log ${var} + IF "${var}" == "banana" + Fail Banana is not a fruit + END + END diff --git a/examples/library/Log.py b/examples/library/Log.py index 30e35cb..606776c 100644 --- a/examples/library/Log.py +++ b/examples/library/Log.py @@ -1,5 +1,3 @@ -"""Logging library for Robot Framework.""" - # Copyright 2024 EPAM Systems # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,6 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Logging library for Robot Framework.""" + +from reportportal_client.helpers import guess_content_type_from_bytes + from robotframework_reportportal import logger @@ -38,3 +40,24 @@ def launch_log(level, message, attachment=None): :param attachment: path to attachment file """ logger.write(message, level, attachment=attachment, launch_log=True) + + +def binary_log(level: str, message: str, file_name: str, attachment: bytes): + """ + Post a log entry with binary attachment. + + :param level: log entry level + :param message: message to post + :param file_name: name of the attachment file + :param attachment: binary data to attach + """ + logger.write( + message, + level, + attachment={ + "name": file_name, + "data": attachment, + "mime": guess_content_type_from_bytes(attachment), + }, + launch_log=False, + ) diff --git a/examples/library/TestCaseId.py b/examples/library/TestCaseId.py index fffaf2d..00cf729 100644 --- a/examples/library/TestCaseId.py +++ b/examples/library/TestCaseId.py @@ -1,4 +1,3 @@ -"""Test Case ID library for Robot Framework.""" # Copyright 2024 EPAM Systems # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -13,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Test Case ID library for Robot Framework.""" + from typing import Optional from robot.libraries.BuiltIn import BuiltIn @@ -27,12 +28,12 @@ def case_id(test_case_id_pattern: Optional[str]) -> None: built_in = BuiltIn() if not test_case_id_pattern: return - suite_metadata = built_in.get_variable_value('${suitemetadata}') + suite_metadata = built_in.get_variable_value("${suitemetadata}") scope = None for key in suite_metadata: - if key.lower() == 'scope': + if key.lower() == "scope": scope = suite_metadata[key] break if not scope: return - built_in.set_tags('test_case_id:' + test_case_id_pattern.format(scope_var=scope)) + built_in.set_tags("test_case_id:" + test_case_id_pattern.format(scope_var=scope)) diff --git a/examples/no_keywords_description.robot b/examples/no_keywords_description.robot new file mode 100644 index 0000000..e750912 --- /dev/null +++ b/examples/no_keywords_description.robot @@ -0,0 +1,3 @@ +*** Test Cases *** +No keyword test case + [Documentation] The test case documentation. diff --git a/examples/rkie_keyword_error.robot b/examples/rkie_keyword_error.robot new file mode 100644 index 0000000..ba673a4 --- /dev/null +++ b/examples/rkie_keyword_error.robot @@ -0,0 +1,19 @@ +*** Settings *** +Documentation Example of 'Run Keyword And Ignore Error' keyword reporting + +*** Variables *** +${countval} 0 + +*** Test Cases *** +Rkie test + Run Keyword And Ignore Error Fail on first try + Fail on first try + +*** Keywords *** +Fail on first try + ${counter} Evaluate ${countval} + 1 + Set Suite Variable ${countval} ${counter} + IF ${countval} < 2 + Log To less executions error ERROR + Fail To less executions + END diff --git a/examples/while_keyword.robot b/examples/while_keyword.robot new file mode 100644 index 0000000..2866197 --- /dev/null +++ b/examples/while_keyword.robot @@ -0,0 +1,13 @@ +*** Settings *** +Documentation Example of 'WHILE' keyword reporting + +*** Variables *** +@{fruits} apple banana cherry + +*** Test Cases *** +While test + ${iter} = Get Length ${fruits} + WHILE ${iter} > 0 + ${iter} Evaluate ${iter} - 1 + Log ${fruits}[${iter}] + END diff --git a/examples/while_keyword_failed.robot b/examples/while_keyword_failed.robot new file mode 100644 index 0000000..211649c --- /dev/null +++ b/examples/while_keyword_failed.robot @@ -0,0 +1,16 @@ +*** Settings *** +Documentation Example of 'WHILE' keyword reporting + +*** Variables *** +@{fruits} apple banana cherry + +*** Test Cases *** +For test + ${iter} = Get Length ${fruits} + WHILE ${iter} > 0 + ${iter} Evaluate ${iter} - 1 + Log ${fruits}[${iter}] + IF "${fruits}[${iter}]" == "banana" + Fail Banana is not a fruit + END + END diff --git a/examples/wuks_keyword.robot b/examples/wuks_keyword.robot index 5a7ea24..e8e8e99 100644 --- a/examples/wuks_keyword.robot +++ b/examples/wuks_keyword.robot @@ -6,12 +6,12 @@ ${countval} 0 *** Test Cases *** Wuks test - Wait Until Keyword Succeeds 2x 200ms Fail on first try + Wait Until Keyword Succeeds 3x 200ms Fail on first try *** Keywords *** Fail on first try ${counter} Evaluate ${countval} + 1 Set Suite Variable ${countval} ${counter} - IF ${countval} < 2 + IF ${countval} < 3 Fail To less executions END diff --git a/examples/wuks_keyword_failed.robot b/examples/wuks_keyword_failed.robot new file mode 100644 index 0000000..d9a8c2f --- /dev/null +++ b/examples/wuks_keyword_failed.robot @@ -0,0 +1,17 @@ +*** Settings *** +Documentation Example of failing 'Wait Until Keyword Succeeds' keyword reporting + +*** Variables *** +${countval} 0 + +*** Test Cases *** +Wuks test + Wait Until Keyword Succeeds 3x 200ms Fail on first try + +*** Keywords *** +Fail on first try + ${counter} Evaluate ${countval} + 1 + Set Suite Variable ${countval} ${counter} + IF ${countval} < 4 + Fail To less executions + END diff --git a/examples/wuks_keyword_warnings.robot b/examples/wuks_keyword_warnings.robot new file mode 100644 index 0000000..4b41473 --- /dev/null +++ b/examples/wuks_keyword_warnings.robot @@ -0,0 +1,18 @@ +*** Settings *** +Documentation Example of 'Wait Until Keyword Succeeds' keyword reporting + +*** Variables *** +${countval} 0 + +*** Test Cases *** +Wuks test + Wait Until Keyword Succeeds 3x 200ms Fail on first try + +*** Keywords *** +Fail on first try + ${counter} Evaluate ${countval} + 1 + Set Suite Variable ${countval} ${counter} + IF ${countval} < 3 + Log To less executions warning WARN + Fail To less executions + END diff --git a/pyproject.toml b/pyproject.toml index bc432f1..c398821 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,3 +6,11 @@ requires = [ "wheel==0.37.1", ] build-backend = "setuptools.build_meta" + +[tool.isort] +line_length = 119 +profile = "black" + +[tool.black] +line-length = 119 +target-version = ["py310"] diff --git a/requirements-dev.txt b/requirements-dev.txt index b2e035f..c866a0f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,4 @@ # Dev dependencies pytest pytest-cov -delayed_assert robotframework-datadriver diff --git a/requirements.txt b/requirements.txt index bca7838..577943a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ # Basic dependencies -python-dateutil~=2.8.1 -reportportal-client~=5.5.8 +python-dateutil~=2.9.0.post0 +reportportal-client~=5.6.0 robotframework diff --git a/robotframework_reportportal/helpers.py b/robotframework_reportportal/helpers.py index d7f696c..628b4db 100644 --- a/robotframework_reportportal/helpers.py +++ b/robotframework_reportportal/helpers.py @@ -14,8 +14,10 @@ """This module contains functions to ease reporting to ReportPortal.""" +import binascii +import fnmatch import re -from typing import Iterable, Tuple +from typing import Iterable, Optional, Tuple def replace_patterns(text: str, patterns: Iterable[Tuple[re.Pattern, str]]) -> str: @@ -26,15 +28,80 @@ def replace_patterns(text: str, patterns: Iterable[Tuple[re.Pattern, str]]) -> s return result -BARE_LINK_PATTERN = re.compile(r'\[\s*([^]|]+)]') -NAMED_LINK_PATTERN = re.compile(r'\[\s*([^]|]+)\|\s*([^]]+)]') +BARE_LINK_PATTERN = re.compile(r"\[\s*([^]|]+)]") +NAMED_LINK_PATTERN = re.compile(r"\[\s*([^]|]+)\|\s*([^]]+)]") ROBOT_MARKUP_REPLACEMENT_PATTERS = [ - (BARE_LINK_PATTERN, r'<\1>'), - (NAMED_LINK_PATTERN, r'[\2](\1)'), + (BARE_LINK_PATTERN, r"<\1>"), + (NAMED_LINK_PATTERN, r"[\2](\1)"), ] +PATTERN_MATCHES_EMPTY_STRING: re.Pattern = re.compile("^$") + def robot_markup_to_markdown(text: str) -> str: """Convert Robot Framework's text markup to Markdown format.""" return replace_patterns(text, ROBOT_MARKUP_REPLACEMENT_PATTERS) + + +def translate_glob_to_regex(pattern: Optional[str]) -> Optional[re.Pattern]: + """Translate glob string pattern to regex Pattern. + + :param pattern: glob pattern + :return: regex pattern + """ + if pattern is None: + return None + if pattern == "": + return PATTERN_MATCHES_EMPTY_STRING + return re.compile(fnmatch.translate(pattern)) + + +def match_pattern(pattern: Optional[re.Pattern], line: Optional[str]) -> bool: + """Check if the line matches given pattern. Handles None values. + + :param pattern: regex pattern + :param line: line to check + :return: True if the line matches the pattern, False otherwise + """ + if pattern is None: + return True + if line is None: + return False + + return pattern.fullmatch(line) is not None + + +def _unescape(binary_string: str, stop_at: int = -1): + result = bytearray() + join_list = list() + join_idx = -3 + skip_next = False + for i, b in enumerate(binary_string): + if skip_next: + skip_next = False + continue + if i < join_idx + 2: + join_list.append(b) + continue + else: + if len(join_list) > 0: + for bb in binascii.unhexlify("".join(join_list)): + result.append(bb) + if stop_at > 0: + if len(result) >= stop_at: + break + join_list = list() + if b == "\\" and binary_string[i + 1] == "x": + skip_next = True + join_idx = i + 2 + continue + for bb in b.encode("utf-8"): + result.append(bb) + if stop_at > 0: + if len(result) >= stop_at: + break + if len(join_list) > 0: + for bb in binascii.unhexlify("".join(join_list)): + result.append(bb) + return result diff --git a/robotframework_reportportal/listener.py b/robotframework_reportportal/listener.py index c65f9db..77ac19b 100644 --- a/robotframework_reportportal/listener.py +++ b/robotframework_reportportal/listener.py @@ -14,65 +14,40 @@ """This module includes Robot Framework listener interfaces.""" -import binascii import logging import os import re +import uuid +from abc import ABC, abstractmethod from functools import wraps from mimetypes import guess_type -from typing import Optional, Dict, Union, Any +from typing import Any, Callable, Dict, List, Optional, Union from warnings import warn -from reportportal_client.helpers import LifoQueue, is_binary, guess_content_type_from_bytes +from reportportal_client.helpers import LifoQueue, guess_content_type_from_bytes, is_binary -from robotframework_reportportal.model import Keyword, Launch, Test, LogMessage, Suite +from robotframework_reportportal.helpers import _unescape, match_pattern, translate_glob_to_regex +from robotframework_reportportal.model import Entity, Keyword, Launch, LogMessage, Suite, Test from robotframework_reportportal.service import RobotService from robotframework_reportportal.static import MAIN_SUITE_ID, PABOT_WITHOUT_LAUNCH_ID_MSG from robotframework_reportportal.variables import Variables logger = logging.getLogger(__name__) -VARIABLE_PATTERN = re.compile(r'^\s*\${[^}]*}\s*=\s*') +VARIABLE_PATTERN = re.compile(r"^\s*\${[^}]*}\s*=\s*") IMAGE_PATTERN = re.compile( r'' - r'') + r'' +) -DEFAULT_BINARY_FILE_TYPE = 'application/octet-stream' +DEFAULT_BINARY_FILE_TYPE = "application/octet-stream" TRUNCATION_SIGN = "...'" - - -def _unescape(binary_string: str, stop_at: int = -1): - result = bytearray() - join_list = list() - join_idx = -3 - skip_next = False - for i, b in enumerate(binary_string): - if skip_next: - skip_next = False - continue - if i < join_idx + 2: - join_list.append(b) - continue - else: - if len(join_list) > 0: - for bb in binascii.unhexlify(''.join(join_list)): - result.append(bb) - if stop_at > 0: - if len(result) >= stop_at: - break - join_list = list() - if b == '\\' and binary_string[i + 1] == 'x': - skip_next = True - join_idx = i + 2 - continue - for bb in b.encode('utf-8'): - result.append(bb) - if stop_at > 0: - if len(result) >= stop_at: - break - if len(join_list) > 0: - for bb in binascii.unhexlify(''.join(join_list)): - result.append(bb) - return result +REMOVED_KEYWORD_CONTENT_LOG = "Content removed using the --remove-keywords option." +REMOVED_WUKS_KEYWORD_LOG = "{number} failing items removed using the --remove-keywords option." +REMOVED_FOR_WHILE_KEYWORD_LOG = "{number} passing items removed using the --remove-keywords option." +WUKS_KEYWORD_NAME = "BuiltIn.Wait Until Keyword Succeeds" +RKIE_KEYWORD_NAME = "BuiltIn.Run Keyword And Ignore Error" +FOR_KEYWORD_TYPE = "FOR" +WHILE_KEYWORD_TYPE = "WHILE" def check_rp_enabled(func): @@ -88,13 +63,75 @@ def wrap(*args, **kwargs): return wrap +class _KeywordMatch(ABC): + @abstractmethod + def match(self, kw: Keyword) -> bool: ... + + +class _KeywordFieldEqual(_KeywordMatch): + expected_value: Optional[str] + extract_func: Callable[[Keyword], str] + + def __init__(self, expected_value: Optional[str], extract_func: Callable[[Keyword], str] = None) -> None: + self.expected_value = expected_value + self.extract_func = extract_func + + def match(self, kw: Keyword) -> bool: + return self.extract_func(kw) == self.expected_value + + +class _KeywordPatternMatch(_KeywordMatch): + pattern: Optional[re.Pattern] + extract_func: Optional[Callable[[Keyword], str]] + + def __init__(self, pattern: Optional[str], extract_func: Callable[[Keyword], str] = None): + self.pattern = translate_glob_to_regex(pattern) + self.extract_func = extract_func + + def match(self, kw: Keyword) -> bool: + return match_pattern(self.pattern, self.extract_func(kw)) + + +class _KeywordNameMatch(_KeywordPatternMatch): + def __init__(self, pattern: Optional[str]) -> None: + super().__init__(pattern, lambda kw: kw.name) + + +class _KeywordTypeEqual(_KeywordFieldEqual): + def __init__(self, expected_value: Optional[str]) -> None: + super().__init__(expected_value, lambda kw: kw.keyword_type) + + +class _KeywordTagMatch(_KeywordMatch): + pattern: Optional[re.Pattern] + + def __init__(self, pattern: Optional[str]) -> None: + self.pattern = translate_glob_to_regex(pattern) + + def match(self, kw: Keyword) -> bool: + return next((True for t in kw.tags if match_pattern(self.pattern, t)), False) + + +class _KeywordStatusEqual(_KeywordFieldEqual): + def __init__(self, status: str) -> None: + super().__init__(status, lambda kw: kw.status) + + +WUKS_KEYWORD_MATCH = _KeywordNameMatch(WUKS_KEYWORD_NAME) +FOR_KEYWORD_MATCH = _KeywordTypeEqual(FOR_KEYWORD_TYPE) +WHILE_KEYWORD_NAME = _KeywordTypeEqual(WHILE_KEYWORD_TYPE) + + # noinspection PyPep8Naming class listener: """Robot Framework listener interface for reporting to ReportPortal.""" - _items: LifoQueue + _items: LifoQueue[Union[Keyword, Launch, Suite, Test]] _service: Optional[RobotService] _variables: Optional[Variables] + _keyword_filters: List[_KeywordMatch] = [] + _remove_all_keyword_content: bool = False + _remove_data_passed_tests: bool = False ROBOT_LISTENER_API_VERSION = 2 def __init__(self) -> None: @@ -108,51 +145,54 @@ def _build_msg_struct(self, message: Dict[str, Any]) -> LogMessage: :param message: Message passed by the Robot Framework """ - if isinstance(message['message'], LogMessage): - msg = message['message'] + if isinstance(message["message"], LogMessage): + msg = message["message"] else: - msg = LogMessage(message['message']) - msg.level = message['level'] - if not msg.launch_log: - msg.item_id = getattr(self.current_item, 'rp_item_id', None) + msg = LogMessage(message["message"]) + msg.level = message["level"] + current_item = self.current_item + if current_item: + msg.item_id = current_item.rp_item_id message_str = msg.message if is_binary(message_str): variable_match = VARIABLE_PATTERN.search(message_str) if variable_match: # Treat as partial binary data - msg_content = message_str[variable_match.end():] + msg_content = message_str[variable_match.end() :] # remove trailing `'"...`, add `...'` - msg.message = (message_str[variable_match.start():variable_match.end()] - + str(msg_content.encode('utf-8'))[:-5] + TRUNCATION_SIGN) + msg.message = ( + message_str[variable_match.start() : variable_match.end()] + + str(msg_content.encode("utf-8"))[:-5] + + TRUNCATION_SIGN + ) else: # Do not log full binary data, since it's usually corrupted content_type = guess_content_type_from_bytes(_unescape(message_str, 128)) - msg.message = (f'Binary data of type "{content_type}" logging skipped, as it was processed as text and' - ' hence corrupted.') - msg.level = 'WARN' - elif message.get('html', 'no') == 'yes': + msg.message = ( + f'Binary data of type "{content_type}" logging skipped, as it was processed as text and' + " hence corrupted." + ) + msg.level = "WARN" + elif message.get("html", "no") == "yes": image_match = IMAGE_PATTERN.match(message_str) if image_match: image_path = image_match.group(1) - msg.message = f'Image attached: {image_path}' + msg.message = f"Image attached: {image_path}" if os.path.exists(image_path): image_type_by_name = guess_type(image_path)[0] - with open(image_path, 'rb') as fh: + with open(image_path, "rb") as fh: image_data = fh.read() image_type_by_data = guess_content_type_from_bytes(image_data) if image_type_by_name and image_type_by_data and image_type_by_name != image_type_by_data: logger.warning( f'Image type mismatch: type by file name "{image_type_by_name}" ' - f'!= type by file content "{image_type_by_data}"') + f'!= type by file content "{image_type_by_data}"' + ) mime_type = DEFAULT_BINARY_FILE_TYPE else: mime_type = image_type_by_name or image_type_by_data or DEFAULT_BINARY_FILE_TYPE - msg.attachment = { - 'name': os.path.basename(image_path), - 'data': image_data, - 'mime': mime_type - } + msg.attachment = {"name": os.path.basename(image_path), "data": image_data, "mime": mime_type} return msg def _add_current_item(self, item: Union[Keyword, Launch, Suite, Test]) -> None: @@ -168,6 +208,85 @@ def current_item(self) -> Optional[Union[Keyword, Launch, Suite, Test]]: """Get the last item from the self._items queue.""" return self._items.last() + def __post_log_message(self, message: LogMessage) -> None: + """Send log message to the Report Portal at skipped Keyword reporting. + + :param message: Internal message object to send + """ + if message.attachment: + logger.debug(f"ReportPortal - Log Message with Attachment: {message}") + else: + logger.debug(f"ReportPortal - Log Message: {message}") + self.service.log(message=message) + + def __post_skipped_keyword(self, kwd: Keyword, clean_data_remove: bool) -> None: + self._do_start_keyword(kwd) + if clean_data_remove: + kwd.remove_data = False + for log_message in kwd.skipped_logs: + self.__post_log_message(log_message) + skipped_keywords = kwd.skipped_keywords + kwd.skipped_keywords = [] + for skipped_kwd in skipped_keywords: + self.__post_skipped_keyword(skipped_kwd, clean_data_remove) + if kwd.status != "NOT SET": + self._do_end_keyword(kwd) + + def _post_skipped_keywords(self, to_post: Optional[Any], clean_data_remove: bool = False) -> None: + if not to_post: + return + if isinstance(to_post, Keyword) and not to_post.posted: + self._do_start_keyword(to_post) + if clean_data_remove: + to_post.remove_data = False + log_messages = to_post.skipped_logs + to_post.skipped_logs = [] + for log_message in log_messages: + self.__post_log_message(log_message) + skipped_keywords = to_post.skipped_keywords + if skipped_keywords: + to_post.skipped_keywords = [] + for skipped_kwd in skipped_keywords: + if skipped_kwd.posted: + log_messages = skipped_kwd.skipped_logs + skipped_kwd.skipped_logs = [] + for log_message in log_messages: + self.__post_log_message(log_message) + skipped_child_keywords = skipped_kwd.skipped_keywords + for skipped_child_kwd in skipped_child_keywords: + if skipped_child_kwd.posted: + continue + self.__post_skipped_keyword(skipped_child_kwd, clean_data_remove) + continue + self.__post_skipped_keyword(skipped_kwd, clean_data_remove) + + def __find_root_keyword_with_removed_data(self, keyword: Entity) -> Entity: + if keyword.parent.remove_data and keyword.parent.type == "KEYWORD": + return self.__find_root_keyword_with_removed_data(keyword.parent) + return keyword + + def _log_message(self, message: LogMessage) -> None: + """Send log message to the Report Portal. + + :param message: Internal message object to send + """ + current_item = self.current_item + if not current_item: + # top-level log message + self.__post_log_message(message) + return + + if not getattr(current_item, "remove_data", False) and getattr(current_item, "posted", True): + self.__post_log_message(message) + else: + if message.level not in {"ERROR", "WARN"}: + self.current_item.skipped_logs.append(message) + else: + if not self._remove_all_keyword_content: + # Post everything skipped by '--removekeywords' option + self._post_skipped_keywords(self.__find_root_keyword_with_removed_data(current_item), True) + self.__post_log_message(message) + @check_rp_enabled def log_message(self, message: Dict) -> None: """Send log message to the Report Portal. @@ -175,8 +294,7 @@ def log_message(self, message: Dict) -> None: :param message: Message passed by the Robot Framework """ msg = self._build_msg_struct(message) - logger.debug(f'ReportPortal - Log Message: {message}') - self.service.log(message=msg) + self._log_message(msg) @check_rp_enabled def log_message_with_image(self, msg: Dict, image: str): @@ -186,19 +304,13 @@ def log_message_with_image(self, msg: Dict, image: str): :param image: Path to image """ mes = self._build_msg_struct(msg) - with open(image, 'rb') as fh: + with open(image, "rb") as fh: mes.attachment = { - 'name': os.path.basename(image), - 'data': fh.read(), - 'mime': guess_type(image)[0] or DEFAULT_BINARY_FILE_TYPE + "name": os.path.basename(image), + "data": fh.read(), + "mime": guess_type(image)[0] or DEFAULT_BINARY_FILE_TYPE, } - logger.debug(f'ReportPortal - Log Message with Image: {mes} {image}') - self.service.log(message=mes) - - @property - def parent_id(self) -> Optional[str]: - """Get rp_item_id attribute of the current item.""" - return getattr(self.current_item, 'rp_item_id', None) + self._log_message(mes) @property def service(self) -> RobotService: @@ -215,24 +327,75 @@ def variables(self) -> Variables: self._variables = Variables() return self._variables - @check_rp_enabled + def _process_keyword_skip(self): + if not self.variables.remove_keywords: + return + + try: + self._keyword_filters = [] + + # noinspection PyUnresolvedReferences + from robot.running.context import EXECUTION_CONTEXTS + + current_context = EXECUTION_CONTEXTS.current + if current_context: + # noinspection PyProtectedMember + for pattern_str in set(current_context.output._settings.remove_keywords): + pattern_str_upper = pattern_str.upper() + if "ALL" == pattern_str_upper: + self._remove_all_keyword_content = True + break + if "PASSED" == pattern_str_upper: + self._remove_data_passed_tests = True + break + if pattern_str_upper in {"FOR", "WHILE", "WUKS"}: + if pattern_str_upper == "WUKS": + self._keyword_filters.append(WUKS_KEYWORD_MATCH) + elif pattern_str_upper == "FOR": + self._keyword_filters.append(FOR_KEYWORD_MATCH) + else: + self._keyword_filters.append(WHILE_KEYWORD_NAME) + continue + if ":" in pattern_str: + pattern_type, pattern = pattern_str.split(":", 1) + pattern_type = pattern_type.strip().upper() + if "NAME" == pattern_type.upper(): + self._keyword_filters.append(_KeywordNameMatch(pattern.strip())) + elif "TAG" == pattern_type.upper(): + self._keyword_filters.append(_KeywordTagMatch(pattern.strip())) + except ImportError: + warn('Unable to locate Robot Framework context. "--remove-keywords" feature will not work.', stacklevel=2) + def start_launch(self, attributes: Dict[str, Any], ts: Optional[Any] = None) -> None: """Start a new launch at the ReportPortal. :param attributes: Dictionary passed by the Robot Framework :param ts: Timestamp(used by the ResultVisitor) """ + self._process_keyword_skip() + launch = Launch(self.variables.launch_name, attributes, self.variables.launch_attributes) launch.doc = self.variables.launch_doc or launch.doc if self.variables.pabot_used and not self._variables.launch_id: warn(PABOT_WITHOUT_LAUNCH_ID_MSG, stacklevel=2) - logger.debug(f'ReportPortal - Start Launch: {launch.robot_attributes}') + logger.debug(f"ReportPortal - Start Launch: {launch.robot_attributes}") self.service.start_launch( launch=launch, mode=self.variables.mode, ts=ts, rerun=self.variables.rerun, - rerun_of=self.variables.rerun_of) + rerun_of=self.variables.rerun_of, + ) + + def finish_launch(self, attributes: Dict[str, Any], ts: Optional[Any] = None) -> None: + """Finish started launch at the ReportPortal. + + :param attributes: Dictionary passed by the Robot Framework + :param ts: Timestamp(used by the ResultVisitor) + """ + launch = Launch(self.variables.launch_name, attributes, None) + logger.debug(f"ReportPortal - End Launch: {launch.robot_attributes}") + self.service.finish_launch(launch=launch, ts=ts) @check_rp_enabled def start_suite(self, name: str, attributes: Dict, ts: Optional[Any] = None) -> None: @@ -242,15 +405,14 @@ def start_suite(self, name: str, attributes: Dict, ts: Optional[Any] = None) -> :param attributes: Dictionary passed by the Robot Framework :param ts: Timestamp(used by the ResultVisitor) """ - if attributes['id'] == MAIN_SUITE_ID: + if attributes["id"] == MAIN_SUITE_ID: self.start_launch(attributes, ts) if self.variables.pabot_used: - name = f'{name}.{self.variables.pabot_pool_id}' - logger.debug(f'ReportPortal - Create global Suite: {attributes}') + name = f"{name}.{self.variables.pabot_pool_id}" + logger.debug(f"ReportPortal - Create global Suite: {attributes}") else: - logger.debug(f'ReportPortal - Start Suite: {attributes}') - suite = Suite(name, attributes) - suite.rp_parent_item_id = self.parent_id + logger.debug(f"ReportPortal - Start Suite: {attributes}") + suite = Suite(name, attributes, self.current_item) suite.rp_item_id = self.service.start_suite(suite=suite, ts=ts) self._add_current_item(suite) @@ -263,12 +425,10 @@ def end_suite(self, _: Optional[str], attributes: Dict, ts: Optional[Any] = None :param ts: Timestamp(used by the ResultVisitor) """ suite = self._remove_current_item().update(attributes) - logger.debug(f'ReportPortal - End Suite: {suite.robot_attributes}') + logger.debug(f"ReportPortal - End Suite: {suite.robot_attributes}") self.service.finish_suite(suite=suite, ts=ts) - if attributes['id'] == MAIN_SUITE_ID: - launch = Launch(self.variables.launch_name, attributes, None) - logger.debug(msg=f'ReportPortal - End Launch: {attributes}') - self.service.finish_launch(launch=launch, ts=ts) + if attributes["id"] == MAIN_SUITE_ID: + self.finish_launch(attributes, ts) @check_rp_enabled def start_test(self, name: str, attributes: Dict, ts: Optional[Any] = None) -> None: @@ -278,13 +438,12 @@ def start_test(self, name: str, attributes: Dict, ts: Optional[Any] = None) -> N :param attributes: Dictionary passed by the Robot Framework :param ts: Timestamp(used by the ResultVisitor) """ - if 'source' not in attributes: + if "source" not in attributes: # no 'source' parameter at this level for Robot versions < 4 attributes = attributes.copy() - attributes['source'] = getattr(self.current_item, 'source', None) - test = Test(name=name, robot_attributes=attributes, test_attributes=self.variables.test_attributes) - logger.debug(f'ReportPortal - Start Test: {attributes}') - test.rp_parent_item_id = self.parent_id + attributes["source"] = getattr(self.current_item, "source", None) + test = Test(name, attributes, self.variables.test_attributes, self.current_item) + logger.debug(f"ReportPortal - Start Test: {attributes}") test.rp_item_id = self.service.start_test(test=test, ts=ts) self._add_current_item(test) @@ -297,14 +456,32 @@ def end_test(self, _: Optional[str], attributes: Dict, ts: Optional[Any] = None) :param ts: Timestamp(used by the ResultVisitor) """ test = self.current_item.update(attributes) - if not test.critical and test.status == 'FAIL': - test.status = 'SKIP' - if test.message: - self.log_message({'message': test.message, 'level': 'DEBUG'}) - logger.debug(f'ReportPortal - End Test: {test.robot_attributes}') + if not test.critical and test.status == "FAIL": + test.status = "SKIP" + if attributes["status"] == "FAIL" and self._remove_data_passed_tests: + self._post_skipped_keywords(test) + elif self._remove_data_passed_tests: + for kwd in test.skipped_keywords: + self._log_keyword_content_removed(kwd.rp_item_id, kwd.start_time) + logger.debug(f"ReportPortal - End Test: {test.robot_attributes}") self._remove_current_item() self.service.finish_test(test=test, ts=ts) + def _log_data_removed(self, item_id: str, timestamp: str, message: str) -> None: + msg = LogMessage(message) + msg.level = "DEBUG" + msg.item_id = item_id + msg.timestamp = timestamp + self.__post_log_message(msg) + + def _log_keyword_content_removed(self, item_id: str, timestamp: str) -> None: + self._log_data_removed(item_id, timestamp, REMOVED_KEYWORD_CONTENT_LOG) + + def _do_start_keyword(self, keyword: Keyword, ts: Optional[str] = None) -> None: + logger.debug(f"ReportPortal - Start Keyword: {keyword.robot_attributes}") + keyword.rp_item_id = self.service.start_keyword(keyword=keyword, ts=ts) + keyword.posted = True + @check_rp_enabled def start_keyword(self, name: str, attributes: Dict, ts: Optional[Any] = None) -> None: """Start a new keyword(test step) at the ReportPortal. @@ -313,12 +490,40 @@ def start_keyword(self, name: str, attributes: Dict, ts: Optional[Any] = None) - :param attributes: Dictionary passed by the Robot Framework :param ts: Timestamp(used by the ResultVisitor) """ - kwd = Keyword(name=name, parent_type=self.current_item.type, robot_attributes=attributes) - kwd.rp_parent_item_id = self.parent_id - logger.debug(f'ReportPortal - Start Keyword: {attributes}') - kwd.rp_item_id = self.service.start_keyword(keyword=kwd, ts=ts) + kwd = Keyword(name, attributes, self.current_item) + parent = self.current_item + skip_kwd = parent.remove_data + skip_data = self._remove_all_keyword_content or self._remove_data_passed_tests + kwd.remove_data = skip_kwd or skip_data + + if kwd.remove_data: + kwd.matched_filter = getattr(parent, "matched_filter", None) + kwd.skip_origin = getattr(parent, "skip_origin", None) + else: + for m in self._keyword_filters: + if m.match(kwd): + kwd.remove_data = True + kwd.matched_filter = m + kwd.skip_origin = kwd + break + + if skip_kwd: + kwd.rp_item_id = str(uuid.uuid4()) + parent.skipped_keywords.append(kwd) + kwd.posted = False + else: + self._do_start_keyword(kwd, ts) + if skip_data: + kwd.skip_origin = kwd + if self._remove_data_passed_tests: + parent.skipped_keywords.append(kwd) + self._add_current_item(kwd) + def _do_end_keyword(self, keyword: Keyword, ts: Optional[str] = None) -> None: + logger.debug(f"ReportPortal - End Keyword: {keyword.robot_attributes}") + self.service.finish_keyword(keyword=keyword, ts=ts) + @check_rp_enabled def end_keyword(self, _: Optional[str], attributes: Dict, ts: Optional[Any] = None) -> None: """Finish started keyword at the ReportPortal. @@ -327,9 +532,50 @@ def end_keyword(self, _: Optional[str], attributes: Dict, ts: Optional[Any] = No :param attributes: Dictionary passed by the Robot Framework :param ts: Timestamp(used by the ResultVisitor) """ - kwd = self._remove_current_item().update(attributes) - logger.debug(f'ReportPortal - End Keyword: {kwd.robot_attributes}') - self.service.finish_keyword(keyword=kwd, ts=ts) + kwd = self.current_item.update(attributes) + + if kwd.matched_filter is WUKS_KEYWORD_MATCH and kwd.skip_origin is kwd: + skipped_keywords = kwd.skipped_keywords + skipped_keywords_num = len(skipped_keywords) + if skipped_keywords_num > 2: + if kwd.status == "FAIL": + message = REMOVED_WUKS_KEYWORD_LOG.format(number=len(kwd.skipped_keywords) - 1) + else: + message = REMOVED_WUKS_KEYWORD_LOG.format(number=len(kwd.skipped_keywords) - 2) + self._log_data_removed(kwd.rp_item_id, kwd.start_time, message) + if skipped_keywords_num > 1 and kwd.status != "FAIL": + first_iteration = kwd.skipped_keywords[0] + self._post_skipped_keywords(first_iteration) + self._do_end_keyword(first_iteration) + if skipped_keywords_num > 0: + last_iteration = kwd.skipped_keywords[-1] + self._post_skipped_keywords(last_iteration) + self._do_end_keyword(last_iteration, ts) + + elif ( + (kwd.matched_filter is FOR_KEYWORD_MATCH) or (kwd.matched_filter is WHILE_KEYWORD_NAME) + ) and kwd.skip_origin is kwd: + skipped_keywords = kwd.skipped_keywords + skipped_keywords_num = len(skipped_keywords) + if skipped_keywords_num > 1: + self._log_data_removed( + kwd.rp_item_id, + kwd.start_time, + REMOVED_FOR_WHILE_KEYWORD_LOG.format(number=skipped_keywords_num - 1), + ) + last_iteration = kwd.skipped_keywords[-1] + self._post_skipped_keywords(last_iteration) + self._do_end_keyword(last_iteration, ts) + elif kwd.posted and kwd.remove_data and kwd.skip_origin is kwd: + if (self._remove_all_keyword_content or not self._remove_data_passed_tests) and ( + kwd.skipped_keywords or kwd.skipped_logs + ): + self._log_keyword_content_removed(kwd.rp_item_id, kwd.start_time) + + self._remove_current_item() + if not kwd.posted: + return + self._do_end_keyword(kwd, ts) def log_file(self, log_path: str) -> None: """Attach HTML log file created by Robot Framework to RP launch. @@ -337,7 +583,7 @@ def log_file(self, log_path: str) -> None: :param log_path: Path to the log file """ if self.variables.attach_log: - message = {'message': 'Execution log', 'level': 'INFO'} + message = {"message": "Execution log", "level": "INFO"} self.log_message_with_image(message, log_path) def report_file(self, report_path: str) -> None: @@ -346,7 +592,7 @@ def report_file(self, report_path: str) -> None: :param report_path: Path to the report file """ if self.variables.attach_report: - message = {'message': 'Execution report', 'level': 'INFO'} + message = {"message": "Execution report", "level": "INFO"} self.log_message_with_image(message, report_path) def xunit_file(self, xunit_path: str) -> None: @@ -355,7 +601,7 @@ def xunit_file(self, xunit_path: str) -> None: :param xunit_path: Path to the XUnit file """ if self.variables.attach_xunit: - message = {'message': 'XUnit result file', 'level': 'INFO'} + message = {"message": "XUnit result file", "level": "INFO"} self.log_message_with_image(message, xunit_path) @check_rp_enabled diff --git a/robotframework_reportportal/logger.py b/robotframework_reportportal/logger.py index e895325..f297899 100644 --- a/robotframework_reportportal/logger.py +++ b/robotframework_reportportal/logger.py @@ -35,15 +35,20 @@ def log_free_memory(self): }, ) """ -from typing import Optional, Dict +from typing import Dict, Optional from robot.api import logger from robotframework_reportportal.model import LogMessage -def write(msg: str, level: str = 'INFO', html: bool = False, attachment: Optional[Dict[str, str]] = None, - launch_log: bool = False) -> None: +def write( + msg: str, + level: str = "INFO", + html: bool = False, + attachment: Optional[Dict[str, str]] = None, + launch_log: bool = False, +) -> None: """Write the message to the log file using the given level. Valid log levels are ``TRACE``, ``DEBUG``, ``INFO`` (default since RF 2.9.1), ``WARN``, @@ -78,8 +83,13 @@ def debug(msg: str, html: bool = False, attachment: Optional[Dict[str, str]] = N write(msg, "DEBUG", html, attachment, launch_log) -def info(msg: str, html: bool = False, also_console: bool = False, attachment: Optional[Dict[str, str]] = None, - launch_log: bool = False): +def info( + msg: str, + html: bool = False, + also_console: bool = False, + attachment: Optional[Dict[str, str]] = None, + launch_log: bool = False, +): """Write the message to the log file using the ``INFO`` level. If ``also_console`` argument is set to ``True``, the message is written both to the log file and to the console. @@ -91,15 +101,15 @@ def info(msg: str, html: bool = False, also_console: bool = False, attachment: O def warn(msg: str, html: bool = False, attachment: Optional[Dict[str, str]] = None, launch_log: bool = False) -> None: """Write the message to the log file using the ``WARN`` level.""" - write(msg, 'WARN', html, attachment, launch_log) + write(msg, "WARN", html, attachment, launch_log) def error(msg: str, html: bool = False, attachment: Optional[Dict[str, str]] = None, launch_log: bool = False) -> None: """Write the message to the log file using the ``ERROR`` level.""" - write(msg, 'ERROR', html, attachment, launch_log) + write(msg, "ERROR", html, attachment, launch_log) -def console(msg: str, newline: bool = True, stream: str = 'stdout') -> None: +def console(msg: str, newline: bool = True, stream: str = "stdout") -> None: """Write the message to the console. If the ``newline`` argument is ``True``, a newline character is automatically added to the message. diff --git a/robotframework_reportportal/model.py b/robotframework_reportportal/model.py index 9fa7cf3..58c3f79 100644 --- a/robotframework_reportportal/model.py +++ b/robotframework_reportportal/model.py @@ -17,13 +17,139 @@ import os from typing import Any, Dict, List, Optional, Union -from robotframework_reportportal.helpers import robot_markup_to_markdown from reportportal_client.helpers import gen_attributes -TEST_CASE_ID_SIGN = 'test_case_id:' +from robotframework_reportportal.helpers import robot_markup_to_markdown + +TEST_CASE_ID_SIGN = "test_case_id:" + + +class Entity: + """Base class for all test items.""" + + type: str + remove_data: bool + rp_item_id: Optional[str] + parent: Optional["Entity"] + + def __init__(self, entity_type: str, parent: Optional["Entity"]): + """Initialize required attributes. + :param entity_type: Type of the entity + :param parent: Parent entity + """ + self.type = entity_type + self.parent = parent + self.rp_item_id = None + self.remove_data = False + + @property + def rp_parent_item_id(self): + """Get parent item ID.""" + return getattr(self.parent, "rp_item_id", None) + + +class LogMessage(str): + """Class represents Robot Framework messages.""" + + attachment: Optional[Dict[str, str]] + launch_log: bool + item_id: Optional[str] + level: str + message: str + timestamp: Optional[str] + + def __init__(self, message: str): + """Initialize required attributes.""" + self.attachment = None + self.item_id = None + self.level = "INFO" + self.launch_log = False + self.message = message + self.timestamp = None + + +class Keyword(Entity): + """Class represents Robot Framework keyword.""" -class Suite: + robot_attributes: Dict[str, Any] + args: List[str] + assign: List[str] + doc: str + end_time: str + keyword_name: str + keyword_type: str + libname: str + name: str + start_time: str + status: str + tags: List[str] + type: str = "KEYWORD" + skipped_logs: List[LogMessage] + skipped_keywords: List["Keyword"] + posted: bool + remove_data: bool + matched_filter: Optional[Any] + skip_origin: Optional[Any] + + def __init__(self, name: str, robot_attributes: Dict[str, Any], parent: Entity): + """Initialize required attributes. + + :param name: Name of the keyword + :param robot_attributes: Attributes passed through the listener + :param parent: Parent entity + """ + super().__init__("KEYWORD", parent) + self.robot_attributes = robot_attributes + self.args = robot_attributes["args"] + self.assign = robot_attributes["assign"] + self.doc = robot_markup_to_markdown(robot_attributes["doc"]) + self.end_time = robot_attributes.get("endtime") + self.keyword_name = robot_attributes["kwname"] + self.keyword_type = robot_attributes["type"] + self.libname = robot_attributes["libname"] + self.name = name + self.start_time = robot_attributes["starttime"] + self.status = robot_attributes.get("status") + self.tags = robot_attributes["tags"] + self.type = "KEYWORD" + self.skipped_keywords = [] + self.skipped_logs = [] + self.posted = True + self.matched_filter = None + self.skip_origin = None + + def get_name(self) -> str: + """Get name of the keyword suitable for ReportPortal.""" + assign = ", ".join(self.assign) + assignment = "{0} = ".format(assign) if self.assign else "" + arguments = ", ".join(self.args) + full_name = f"{self.keyword_type} {assignment}{self.name} ({arguments})" + return full_name[:256] + + def get_type(self) -> str: + """Get keyword type.""" + if self.keyword_type.lower() in ("setup", "teardown"): + if self.parent.type.lower() == "keyword": + return "STEP" + if self.keyword_type.lower() == "setup": + return "BEFORE_{0}".format(self.parent.type.upper()) + if self.keyword_type.lower() == "teardown": + return "AFTER_{0}".format(self.parent.type.upper()) + else: + return "STEP" + + def update(self, attributes: Dict[str, Any]) -> "Keyword": + """Update keyword attributes on keyword finish. + + :param attributes: Suite attributes passed through the listener + """ + self.end_time = attributes.get("endtime", "") + self.status = attributes.get("status") + return self + + +class Suite(Entity): """Class represents Robot Framework test suite.""" robot_attributes: Union[List[str], Dict[str, Any]] @@ -34,62 +160,58 @@ class Suite: metadata: Dict[str, str] name: str robot_id: str - rp_item_id: Optional[str] - rp_parent_item_id: Optional[str] start_time: Optional[str] statistics: str status: str suites: List[str] tests: List[str] total_tests: int - type: str = 'SUITE' - def __init__(self, name: str, robot_attributes: Dict[str, Any]): + def __init__(self, name: str, robot_attributes: Dict[str, Any], parent: Optional[Entity] = None): """Initialize required attributes. :param name: Suite name :param robot_attributes: Suite attributes passed through the listener + :param parent: Parent entity """ + super().__init__("SUITE", parent) self.robot_attributes = robot_attributes - self.doc = robot_markup_to_markdown(robot_attributes['doc']) - self.end_time = robot_attributes.get('endtime', '') - self.longname = robot_attributes['longname'] - self.message = robot_attributes.get('message') - self.metadata = robot_attributes['metadata'] + self.doc = robot_markup_to_markdown(robot_attributes["doc"]) + self.end_time = robot_attributes.get("endtime", "") + self.longname = robot_attributes["longname"] + self.message = robot_attributes.get("message") + self.metadata = robot_attributes["metadata"] self.name = name - self.robot_id = robot_attributes['id'] - self.rp_item_id = None - self.rp_parent_item_id = None - self.start_time = robot_attributes.get('starttime') - self.statistics = robot_attributes.get('statistics') - self.status = robot_attributes.get('status') - self.suites = robot_attributes['suites'] - self.tests = robot_attributes['tests'] - self.total_tests = robot_attributes['totaltests'] - self.type = 'SUITE' + self.robot_id = robot_attributes["id"] + self.start_time = robot_attributes.get("starttime") + self.statistics = robot_attributes.get("statistics") + self.status = robot_attributes.get("status") + self.suites = robot_attributes["suites"] + self.tests = robot_attributes["tests"] + self.total_tests = robot_attributes["totaltests"] @property def attributes(self) -> Optional[List[Dict[str, str]]]: """Get Suite attributes.""" if self.metadata is None or not self.metadata: return None - return [{'key': key, 'value': value} for key, value in self.metadata.items()] + return [{"key": key, "value": value} for key, value in self.metadata.items()] @property def source(self) -> str: """Return the test case source file path.""" - if self.robot_attributes.get('source') is not None: - return os.path.relpath(self.robot_attributes['source'], os.getcwd()) + if self.robot_attributes.get("source") is not None: + return os.path.relpath(self.robot_attributes["source"], os.getcwd()) - def update(self, attributes: Dict[str, Any]) -> 'Suite': + def update(self, attributes: Dict[str, Any]) -> "Suite": """Update suite attributes on suite finish. :param attributes: Suite attributes passed through the listener """ - self.end_time = attributes.get('endtime', '') - self.message = attributes.get('message') - self.status = attributes.get('status') - self.statistics = attributes.get('statistics') + self.end_time = attributes.get("endtime", "") + self.message = attributes.get("message") + self.status = attributes.get("status") + self.statistics = attributes.get("statistics") return self @@ -97,7 +219,7 @@ class Launch(Suite): """Class represents Robot Framework test suite.""" launch_attributes: Optional[List[Dict[str, str]]] - type: str = 'LAUNCH' + type: str = "LAUNCH" def __init__(self, name: str, robot_attributes: Dict[str, Any], launch_attributes: Optional[List[str]]): """Initialize required attributes. @@ -108,7 +230,7 @@ def __init__(self, name: str, robot_attributes: Dict[str, Any], launch_attribute """ super().__init__(name, robot_attributes) self.launch_attributes = gen_attributes(launch_attributes or []) - self.type = 'LAUNCH' + self.type = "LAUNCH" @property def attributes(self) -> Optional[List[Dict[str, str]]]: @@ -116,7 +238,7 @@ def attributes(self) -> Optional[List[Dict[str, str]]]: return self.launch_attributes -class Test: +class Test(Entity): """Class represents Robot Framework test case.""" _critical: str @@ -129,42 +251,39 @@ class Test: message: str name: str robot_id: str - rp_item_id: Optional[str] - rp_parent_item_id: Optional[str] start_time: str status: str template: str - type: str = 'TEST' + skipped_keywords: List[Keyword] - def __init__(self, name: str, robot_attributes: Dict[str, Any], test_attributes: List[str]): + def __init__(self, name: str, robot_attributes: Dict[str, Any], test_attributes: List[str], parent: Entity): """Initialize required attributes. :param name: Name of the test :param robot_attributes: Attributes passed through the listener """ + super().__init__("TEST", parent) # for backward compatibility with Robot < 4.0 mark every test case # as critical if not set - self._critical = robot_attributes.get('critical', 'yes') - self._tags = robot_attributes['tags'] + self._critical = robot_attributes.get("critical", "yes") + self._tags = robot_attributes["tags"] self.test_attributes = gen_attributes(test_attributes) self.robot_attributes = robot_attributes - self.doc = robot_markup_to_markdown(robot_attributes['doc']) - self.end_time = robot_attributes.get('endtime', '') - self.longname = robot_attributes['longname'] - self.message = robot_attributes.get('message') + self.doc = robot_markup_to_markdown(robot_attributes["doc"]) + self.end_time = robot_attributes.get("endtime", "") + self.longname = robot_attributes["longname"] + self.message = robot_attributes.get("message") self.name = name - self.robot_id = robot_attributes['id'] - self.rp_item_id = None - self.rp_parent_item_id = None - self.start_time = robot_attributes['starttime'] - self.status = robot_attributes.get('status') - self.template = robot_attributes['template'] - self.type = 'TEST' + self.robot_id = robot_attributes["id"] + self.start_time = robot_attributes["starttime"] + self.status = robot_attributes.get("status") + self.template = robot_attributes["template"] + self.skipped_keywords = [] @property def critical(self) -> bool: """Form unique value for RF 4.0+ and older versions.""" - return self._critical in ('yes', True) + return self._critical in ("yes", True) @property def tags(self) -> List[str]: @@ -179,8 +298,8 @@ def attributes(self) -> Optional[List[Dict[str, str]]]: @property def source(self) -> str: """Return the test case source file path.""" - if self.robot_attributes['source'] is not None: - return os.path.relpath(self.robot_attributes['source'], os.getcwd()) + if self.robot_attributes["source"] is not None: + return os.path.relpath(self.robot_attributes["source"], os.getcwd()) @property def code_ref(self) -> str: @@ -190,8 +309,8 @@ def code_ref(self) -> str: """ line_number = self.robot_attributes.get("lineno") if line_number is not None: - return '{0}:{1}'.format(self.source, line_number) - return '{0}:{1}'.format(self.source, self.name) + return "{0}:{1}".format(self.source, line_number) + return "{0}:{1}".format(self.source, self.name) @property def test_case_id(self) -> Optional[str]: @@ -199,109 +318,17 @@ def test_case_id(self) -> Optional[str]: # use test case id from tags if specified for tag in self._tags: if tag.startswith(TEST_CASE_ID_SIGN): - return tag.split(':')[1] + return tag.split(":")[1] # generate it if not - return '{0}:{1}'.format(self.source, self.name) + return "{0}:{1}".format(self.source, self.name) - def update(self, attributes: Dict[str, Any]) -> 'Test': + def update(self, attributes: Dict[str, Any]) -> "Test": """Update test attributes on test finish. :param attributes: Suite attributes passed through the listener """ - self._tags = attributes.get('tags', self._tags) - self.end_time = attributes.get('endtime', '') - self.message = attributes.get('message') - self.status = attributes.get('status') - return self - - -class Keyword: - """Class represents Robot Framework keyword.""" - - robot_attributes: Dict[str, Any] - args: List[str] - assign: List[str] - doc: str - end_time: str - keyword_name: str - keyword_type: str - libname: str - name: str - rp_item_id: Optional[str] - rp_parent_item_id: Optional[str] - parent_type: str - start_time: str - status: str - tags: List[str] - type: str = 'KEYWORD' - - def __init__(self, name: str, robot_attributes: Dict[str, Any], parent_type: Optional[str] = None): - """Initialize required attributes. - - :param name: Name of the keyword - :param robot_attributes: Attributes passed through the listener - :param parent_type: Type of the parent test item - """ - self.robot_attributes = robot_attributes - self.args = robot_attributes['args'] - self.assign = robot_attributes['assign'] - self.doc = robot_markup_to_markdown(robot_attributes['doc']) - self.end_time = robot_attributes.get('endtime') - self.keyword_name = robot_attributes['kwname'] - self.keyword_type = robot_attributes['type'] - self.libname = robot_attributes['libname'] - self.name = name - self.rp_item_id = None - self.rp_parent_item_id = None - self.parent_type = parent_type - self.start_time = robot_attributes['starttime'] - self.status = robot_attributes.get('status') - self.tags = robot_attributes['tags'] - self.type = 'KEYWORD' - - def get_name(self) -> str: - """Get name of the keyword suitable for ReportPortal.""" - assign = ', '.join(self.assign) - assignment = '{0} = '.format(assign) if self.assign else '' - arguments = ', '.join(self.args) - full_name = f'{assignment}{self.name} ({arguments})' - return full_name[:256] - - def get_type(self) -> str: - """Get keyword type.""" - if self.keyword_type.lower() in ('setup', 'teardown'): - if self.parent_type.lower() == 'keyword': - return 'STEP' - if self.keyword_type.lower() == 'setup': - return 'BEFORE_{0}'.format(self.parent_type.upper()) - if self.keyword_type.lower() == 'teardown': - return 'AFTER_{0}'.format(self.parent_type.upper()) - else: - return 'STEP' - - def update(self, attributes: Dict[str, Any]) -> 'Keyword': - """Update keyword attributes on keyword finish. - - :param attributes: Suite attributes passed through the listener - """ - self.end_time = attributes.get('endtime', '') - self.status = attributes.get('status') + self._tags = attributes.get("tags", self._tags) + self.end_time = attributes.get("endtime", "") + self.message = attributes.get("message") + self.status = attributes.get("status") return self - - -class LogMessage(str): - """Class represents Robot Framework messages.""" - - attachment: Optional[Dict[str, str]] - launch_log: bool - item_id: Optional[str] - level: str - message: str - - def __init__(self, message: str): - """Initialize required attributes.""" - self.attachment = None - self.item_id = None - self.level = 'INFO' - self.launch_log = False - self.message = message diff --git a/robotframework_reportportal/post_report.py b/robotframework_reportportal/post_report.py index 6176bb3..85ac5e9 100644 --- a/robotframework_reportportal/post_report.py +++ b/robotframework_reportportal/post_report.py @@ -50,24 +50,29 @@ from robotframework_reportportal.result_visitor import RobotResultsVisitor from robotframework_reportportal.time_visitor import TimeVisitor, corrections + # noinspection PyUnresolvedReferences from robotframework_reportportal.variables import _variables def process(infile="output.xml"): + """Process the given file.""" test_run = ExecutionResult(infile) test_run.visit(TimeVisitor()) if corrections: - logging.warning("{0} is missing some of its starttime/endtime. " - "This might cause inconsistencies with your " - "duration report.".format(infile)) + logging.warning( + "{0} is missing some of its starttime/endtime. " + "This might cause inconsistencies with your " + "duration report.".format(infile) + ) test_run.visit(RobotResultsVisitor()) def main(): + """Start the script.""" argument_list = sys.argv[1:] short_options = "hv:" - long_options = ["help", "variable=", "loglevel=", 'timezone='] + long_options = ["help", "variable=", "loglevel=", "timezone="] try: arguments, values = getopt.getopt(argument_list, short_options, long_options) except getopt.error: @@ -84,7 +89,7 @@ def main(): numeric_level = getattr(logging, current_value.upper(), None) logging.basicConfig(level=numeric_level) elif current_argument == "--timezone": - _variables['RP_TIME_ZONE_OFFSET'] = current_value + _variables["RP_TIME_ZONE_OFFSET"] = current_value try: process(*values) diff --git a/robotframework_reportportal/result_visitor.py b/robotframework_reportportal/result_visitor.py index 6dd010f..a7db6d8 100644 --- a/robotframework_reportportal/result_visitor.py +++ b/robotframework_reportportal/result_visitor.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Robot Framework test report sender to ReportPortal.""" + import re import string import sys @@ -19,13 +21,15 @@ if sys.version_info >= (3, 9): from zoneinfo import available_timezones, ZoneInfo -from typing import List, Pattern, Optional + +from typing import Optional, Pattern, Tuple from urllib.parse import unquote -from robot.result import ResultVisitor, Result, TestSuite, TestCase, Keyword, Message +from robot.result import Keyword, Message, Result, ResultVisitor, TestCase, TestSuite from robotframework_reportportal import listener from robotframework_reportportal.time_visitor import corrections + # noinspection PyUnresolvedReferences from robotframework_reportportal.variables import _variables @@ -37,27 +41,31 @@ def to_timestamp(time_str: str) -> Optional[str]: + """Convert time string to timestamp with given timezone offset.""" if not time_str: return None - timezone_offset_str: Optional[str] = _variables.get('RP_TIME_ZONE_OFFSET', None) - dt = datetime.strptime(time_str, '%Y%m%d %H:%M:%S.%f') + timezone_offset_str: Optional[str] = _variables.get("RP_TIME_ZONE_OFFSET", None) + dt = datetime.strptime(time_str, "%Y%m%d %H:%M:%S.%f") if timezone_offset_str: if timezone_offset_str in AVAILABLE_TIMEZONES: tz = ZoneInfo(timezone_offset_str) dt = dt.replace(tzinfo=tz) else: - hours, minutes = map(int, timezone_offset_str.split(':')) + hours, minutes = map(int, timezone_offset_str.split(":")) offset = timedelta(hours=hours, minutes=minutes) dt = dt.replace(tzinfo=timezone(offset)) return str(int(dt.timestamp() * 1000)) class RobotResultsVisitor(ResultVisitor): - _link_pattern: Pattern = re.compile("src=[\"\']([^\"\']+)[\"\']") + """Visitor for Robot Framework result XML report.""" + + _link_pattern: Pattern = re.compile("src=[\"']([^\"']+)[\"']") def start_result(self, result: Result) -> bool: + """Start result.""" if "RP_LAUNCH" not in _variables: _variables["RP_LAUNCH"] = result.suite.name if "RP_LAUNCH_DOC" not in _variables: @@ -65,119 +73,126 @@ def start_result(self, result: Result) -> bool: return True def start_suite(self, suite: TestSuite) -> bool: + """Start suite.""" ts = to_timestamp(suite.starttime if suite.id not in corrections else corrections[suite.id][0]) attrs = { - 'id': suite.id, - 'longname': suite.longname, - 'doc': suite.doc, - 'metadata': suite.metadata, - 'source': suite.source, - 'suites': suite.suites, - 'tests': suite.tests, - 'totaltests': getattr(suite.statistics, 'all', suite.statistics).total, - 'starttime': ts + "id": suite.id, + "longname": suite.longname, + "doc": suite.doc, + "metadata": suite.metadata, + "source": suite.source, + "suites": suite.suites, + "tests": suite.tests, + "totaltests": getattr(suite.statistics, "all", suite.statistics).total, + "starttime": ts, } listener.start_suite(suite.name, attrs, ts) return True def end_suite(self, suite: TestSuite) -> None: + """End suite.""" ts = to_timestamp(suite.endtime if suite.id not in corrections else corrections[suite.id][1]) attrs = { - 'id': suite.id, - 'longname': suite.longname, - 'doc': suite.doc, - 'metadata': suite.metadata, - 'source': suite.source, - 'suites': suite.suites, - 'tests': suite.tests, - 'totaltests': getattr(suite.statistics, 'all', suite.statistics).total, - 'endtime': ts, - 'elapsedtime': suite.elapsedtime, - 'status': suite.status, - 'statistics': suite.statistics, - 'message': suite.message, + "id": suite.id, + "longname": suite.longname, + "doc": suite.doc, + "metadata": suite.metadata, + "source": suite.source, + "suites": suite.suites, + "tests": suite.tests, + "totaltests": getattr(suite.statistics, "all", suite.statistics).total, + "endtime": ts, + "elapsedtime": suite.elapsedtime, + "status": suite.status, + "statistics": suite.statistics, + "message": suite.message, } listener.end_suite(None, attrs, ts) def start_test(self, test: TestCase) -> bool: + """Start test.""" ts = to_timestamp(test.starttime if test.id not in corrections else corrections[test.id][0]) attrs = { - 'id': test.id, - 'longname': test.longname, + "id": test.id, + "longname": test.longname, # 'originalname': test.originalname, - 'doc': test.doc, - 'tags': list(test.tags), + "doc": test.doc, + "tags": list(test.tags), # for backward compatibility with Robot < 4.0 mark every test case # as critical if not set - 'critical': getattr(test, 'critical', 'yes'), - 'source': test.source, - 'template': '', + "critical": getattr(test, "critical", "yes"), + "source": test.source, + "template": "", # 'lineno': test.lineno, - 'starttime': ts, + "starttime": ts, } listener.start_test(test.name, attrs, ts) return True def end_test(self, test: TestCase) -> None: + """End test.""" ts = to_timestamp(test.endtime if test.id not in corrections else corrections[test.id][1]) attrs = { - 'id': test.id, - 'longname': test.longname, + "id": test.id, + "longname": test.longname, # 'originalname': test.originalname, - 'doc': test.doc, - 'tags': list(test.tags), + "doc": test.doc, + "tags": list(test.tags), # for backward compatibility with Robot < 4.0 mark every test case # as critical if not set - 'critical': getattr(test, 'critical', 'yes'), - 'template': '', + "critical": getattr(test, "critical", "yes"), + "template": "", # 'lineno': test.lineno, - 'endtime': ts, - 'elapsedtime': test.elapsedtime, - 'source': test.source, - 'status': test.status, - 'message': test.message, + "endtime": ts, + "elapsedtime": test.elapsedtime, + "source": test.source, + "status": test.status, + "message": test.message, } listener.end_test(test.name, attrs, ts) def start_keyword(self, kw: Keyword) -> bool: + """Start keyword.""" ts = to_timestamp(kw.starttime if kw.id not in corrections else corrections[kw.id][0]) attrs = { - 'type': string.capwords(kw.type), - 'kwname': kw.kwname, - 'libname': kw.libname, - 'doc': kw.doc, - 'args': kw.args, - 'assign': kw.assign, - 'tags': kw.tags, - 'starttime': ts, + "type": string.capwords(kw.type), + "kwname": kw.kwname, + "libname": kw.libname, + "doc": kw.doc, + "args": kw.args, + "assign": kw.assign, + "tags": kw.tags, + "starttime": ts, } listener.start_keyword(kw.name, attrs, ts) return True def end_keyword(self, kw: Keyword) -> None: + """End keyword.""" ts = to_timestamp(kw.endtime if kw.id not in corrections else corrections[kw.id][1]) attrs = { - 'type': string.capwords(kw.type), - 'kwname': kw.kwname, - 'libname': kw.libname, - 'doc': kw.doc, - 'args': kw.args, - 'assign': kw.assign, - 'tags': kw.tags, - 'endtime': ts, - 'elapsedtime': kw.elapsedtime, - 'status': 'PASS' if kw.assign else kw.status, + "type": string.capwords(kw.type), + "kwname": kw.kwname, + "libname": kw.libname, + "doc": kw.doc, + "args": kw.args, + "assign": kw.assign, + "tags": kw.tags, + "endtime": ts, + "elapsedtime": kw.elapsedtime, + "status": "PASS" if kw.assign else kw.status, } listener.end_keyword(kw.name, attrs, ts) def start_message(self, msg: Message) -> bool: + """Start message.""" if msg.message: message = { - 'message': msg.message, - 'level': msg.level, + "message": msg.message, + "level": msg.level, } try: - m = self.parse_message(message['message']) + m = self.split_message_and_image(message["message"]) message["message"] = m[0] listener.log_message_with_image(message, m[1]) except (AttributeError, IOError): @@ -188,6 +203,7 @@ def start_message(self, msg: Message) -> bool: return False return True - def parse_message(self, msg: str) -> List[str]: + def split_message_and_image(self, msg: str) -> Tuple[str, str]: + """Split message and image.""" m = self._link_pattern.search(msg) - return [m.group(), unquote(m.group(1))] + return m.group(), unquote(m.group(1)) diff --git a/robotframework_reportportal/service.py b/robotframework_reportportal/service.py index e0e2ffc..b0763ac 100644 --- a/robotframework_reportportal/service.py +++ b/robotframework_reportportal/service.py @@ -19,20 +19,15 @@ from dateutil.parser import parse from reportportal_client import RP, create_client -from reportportal_client.helpers import ( - dict_to_payload, - get_launch_sys_attrs, - get_package_version, - timestamp -) - -from robotframework_reportportal.model import Launch, Suite, Test, Keyword, LogMessage +from reportportal_client.helpers import dict_to_payload, get_launch_sys_attrs, get_package_version, timestamp + +from robotframework_reportportal.model import Keyword, Launch, LogMessage, Suite, Test from robotframework_reportportal.static import LOG_LEVEL_MAPPING, STATUS_MAPPING from robotframework_reportportal.variables import Variables logger = logging.getLogger(__name__) -TOP_LEVEL_ITEMS = {'BEFORE_SUITE', 'AFTER_SUITE'} +TOP_LEVEL_ITEMS = {"BEFORE_SUITE", "AFTER_SUITE"} def to_epoch(date: Optional[str]) -> Optional[str]: @@ -43,10 +38,10 @@ def to_epoch(date: Optional[str]) -> Optional[str]: parsed_date = parse(date) except ValueError: return None - if hasattr(parsed_date, 'timestamp'): + if hasattr(parsed_date, "timestamp"): epoch_time = parsed_date.timestamp() else: - epoch_time = float(parsed_date.strftime('%s')) + parsed_date.microsecond / 1e6 + epoch_time = float(parsed_date.strftime("%s")) + parsed_date.microsecond / 1e6 return str(int(epoch_time * 1000)) @@ -59,7 +54,7 @@ class RobotService: def __init__(self) -> None: """Initialize service attributes.""" - self.agent_name = 'robotframework-reportportal' + self.agent_name = "robotframework-reportportal" self.agent_version = get_package_version(self.agent_name) self.rp = None @@ -70,8 +65,7 @@ def _get_launch_attributes(self, cmd_attrs: list) -> list: """ attributes = cmd_attrs or [] system_attributes = get_launch_sys_attrs() - system_attributes['agent'] = ( - '{}|{}'.format(self.agent_name, self.agent_version)) + system_attributes["agent"] = "{}|{}".format(self.agent_name, self.agent_version) return attributes + dict_to_payload(system_attributes) def init_service(self, variables: Variables) -> None: @@ -80,8 +74,10 @@ def init_service(self, variables: Variables) -> None: :param variables: ReportPortal variables """ if self.rp is None: - logger.debug(f'ReportPortal - Init service: endpoint={variables.endpoint}, ' - f'project={variables.project}, api_key={variables.api_key}') + logger.debug( + f"ReportPortal - Init service: endpoint={variables.endpoint}, " + f"project={variables.project}, api_key={variables.api_key}" + ) self.rp = create_client( client_type=variables.client_type, @@ -97,7 +93,7 @@ def init_service(self, variables: Variables) -> None: launch_uuid=variables.launch_id, launch_uuid_print=variables.launch_uuid_print, print_output=variables.launch_uuid_print_output, - http_timeout=variables.http_timeout + http_timeout=variables.http_timeout, ) def terminate_service(self) -> None: @@ -105,9 +101,14 @@ def terminate_service(self) -> None: if self.rp: self.rp.close() - def start_launch(self, launch: Launch, mode: Optional[str] = None, rerun: bool = False, - rerun_of: Optional[str] = None, - ts: Optional[str] = None) -> Optional[str]: + def start_launch( + self, + launch: Launch, + mode: Optional[str] = None, + rerun: bool = False, + rerun_of: Optional[str] = None, + ts: Optional[str] = None, + ) -> Optional[str]: """Call start_launch method of the common client. :param launch: Instance of the Launch class @@ -119,15 +120,15 @@ def start_launch(self, launch: Launch, mode: Optional[str] = None, rerun: bool = :return: launch UUID """ sl_pt = { - 'attributes': self._get_launch_attributes(launch.attributes), - 'description': launch.doc, - 'name': launch.name, - 'mode': mode, - 'rerun': rerun, - 'rerun_of': rerun_of, - 'start_time': ts or to_epoch(launch.start_time) or timestamp() + "attributes": self._get_launch_attributes(launch.attributes), + "description": launch.doc, + "name": launch.name, + "mode": mode, + "rerun": rerun, + "rerun_of": rerun_of, + "start_time": ts or to_epoch(launch.start_time) or timestamp(), } - logger.debug('ReportPortal - Start launch: request_body={0}'.format(sl_pt)) + logger.debug("ReportPortal - Start launch: request_body={0}".format(sl_pt)) return self.rp.start_launch(**sl_pt) def finish_launch(self, launch: Launch, ts: Optional[str] = None) -> None: @@ -136,11 +137,8 @@ def finish_launch(self, launch: Launch, ts: Optional[str] = None) -> None: :param launch: Launch name :param ts: End time """ - fl_rq = { - 'end_time': ts or to_epoch(launch.end_time) or timestamp(), - 'status': STATUS_MAPPING[launch.status] - } - logger.debug('ReportPortal - Finish launch: request_body={0}'.format(fl_rq)) + fl_rq = {"end_time": ts or to_epoch(launch.end_time) or timestamp(), "status": STATUS_MAPPING[launch.status]} + logger.debug("ReportPortal - Finish launch: request_body={0}".format(fl_rq)) self.rp.finish_launch(**fl_rq) def start_suite(self, suite: Suite, ts: Optional[str] = None) -> Optional[str]: @@ -151,18 +149,17 @@ def start_suite(self, suite: Suite, ts: Optional[str] = None) -> Optional[str]: :return: Suite UUID """ start_rq = { - 'attributes': suite.attributes, - 'description': suite.doc, - 'item_type': suite.type, - 'name': suite.name, - 'parent_item_id': suite.rp_parent_item_id, - 'start_time': ts or to_epoch(suite.start_time) or timestamp() + "attributes": suite.attributes, + "description": suite.doc, + "item_type": suite.type, + "name": suite.name, + "parent_item_id": suite.rp_parent_item_id, + "start_time": ts or to_epoch(suite.start_time) or timestamp(), } - logger.debug('ReportPortal - Start suite: request_body={0}'.format(start_rq)) + logger.debug("ReportPortal - Start suite: request_body={0}".format(start_rq)) return self.rp.start_test_item(**start_rq) - def finish_suite(self, suite: Suite, issue: Optional[str] = None, - ts: Optional[str] = None) -> None: + def finish_suite(self, suite: Suite, issue: Optional[str] = None, ts: Optional[str] = None) -> None: """Finish started suite. :param suite: Instance of the started suite item @@ -170,12 +167,12 @@ def finish_suite(self, suite: Suite, issue: Optional[str] = None, :param ts: End time """ fta_rq = { - 'end_time': ts or to_epoch(suite.end_time) or timestamp(), - 'issue': issue, - 'item_id': suite.rp_item_id, - 'status': STATUS_MAPPING[suite.status] + "end_time": ts or to_epoch(suite.end_time) or timestamp(), + "issue": issue, + "item_id": suite.rp_item_id, + "status": STATUS_MAPPING[suite.status], } - logger.debug('ReportPortal - Finish suite: request_body={0}'.format(fta_rq)) + logger.debug("ReportPortal - Finish suite: request_body={0}".format(fta_rq)) self.rp.finish_test_item(**fta_rq) def start_test(self, test: Test, ts: Optional[str] = None): @@ -188,16 +185,16 @@ def start_test(self, test: Test, ts: Optional[str] = None): # Details at: # https://github.com/reportportal/agent-Python-RobotFramework/issues/56 start_rq = { - 'attributes': test.attributes, - 'code_ref': test.code_ref, - 'description': test.doc, - 'item_type': 'STEP', - 'name': test.name, - 'parent_item_id': test.rp_parent_item_id, - 'start_time': ts or to_epoch(test.start_time) or timestamp(), - 'test_case_id': test.test_case_id + "attributes": test.attributes, + "code_ref": test.code_ref, + "description": test.doc, + "item_type": "STEP", + "name": test.name, + "parent_item_id": test.rp_parent_item_id, + "start_time": ts or to_epoch(test.start_time) or timestamp(), + "test_case_id": test.test_case_id, } - logger.debug('ReportPortal - Start test: request_body={0}'.format(start_rq)) + logger.debug("ReportPortal - Start test: request_body={0}".format(start_rq)) return self.rp.start_test_item(**start_rq) def finish_test(self, test: Test, issue: Optional[str] = None, ts: Optional[str] = None): @@ -207,15 +204,26 @@ def finish_test(self, test: Test, issue: Optional[str] = None, ts: Optional[str] :param issue: Corresponding issue if it exists :param ts: End time """ + description = None + if test.doc: + description = test.doc + if test.message: + message = f"Message:\n\n{test.message}" + if description: + description += f"\n\n---\n\n{message}" + else: + description = message fta_rq = { - 'attributes': test.attributes, - 'end_time': ts or to_epoch(test.end_time) or timestamp(), - 'issue': issue, - 'item_id': test.rp_item_id, - 'status': STATUS_MAPPING[test.status], - 'test_case_id': test.test_case_id + "attributes": test.attributes, + "end_time": ts or to_epoch(test.end_time) or timestamp(), + "issue": issue, + "item_id": test.rp_item_id, + "status": STATUS_MAPPING[test.status], + "test_case_id": test.test_case_id, } - logger.debug('ReportPortal - Finish test: request_body={0}'.format(fta_rq)) + if description: + fta_rq["description"] = description + logger.debug("ReportPortal - Finish test: request_body={0}".format(fta_rq)) self.rp.finish_test_item(**fta_rq) def start_keyword(self, keyword: Keyword, ts: Optional[str] = None): @@ -225,14 +233,16 @@ def start_keyword(self, keyword: Keyword, ts: Optional[str] = None): :param ts: Start time """ start_rq = { - 'description': keyword.doc, - 'has_stats': keyword.get_type() in TOP_LEVEL_ITEMS, - 'item_type': keyword.get_type(), - 'name': keyword.get_name(), - 'parent_item_id': keyword.rp_parent_item_id, - 'start_time': ts or to_epoch(keyword.start_time) or timestamp() + "description": keyword.doc, + "has_stats": keyword.get_type() in TOP_LEVEL_ITEMS, + "item_type": keyword.get_type(), + "name": keyword.get_name(), + "parent_item_id": keyword.rp_parent_item_id, + "start_time": ts or to_epoch(keyword.start_time) or timestamp(), } - logger.debug('ReportPortal - Start keyword: request_body={0}'.format(start_rq)) + if keyword.rp_item_id: + start_rq["uuid"] = keyword.rp_item_id + logger.debug("ReportPortal - Start keyword: request_body={0}".format(start_rq)) return self.rp.start_test_item(**start_rq) def finish_keyword(self, keyword: Keyword, issue: Optional[str] = None, ts: Optional[str] = None): @@ -243,12 +253,12 @@ def finish_keyword(self, keyword: Keyword, issue: Optional[str] = None, ts: Opti :param ts: End time """ fta_rq = { - 'end_time': ts or to_epoch(keyword.end_time) or timestamp(), - 'issue': issue, - 'item_id': keyword.rp_item_id, - 'status': STATUS_MAPPING[keyword.status] + "end_time": ts or to_epoch(keyword.end_time) or timestamp(), + "issue": issue, + "item_id": keyword.rp_item_id, + "status": STATUS_MAPPING[keyword.status], } - logger.debug('ReportPortal - Finish keyword: request_body={0}'.format(fta_rq)) + logger.debug("ReportPortal - Finish keyword: request_body={0}".format(fta_rq)) self.rp.finish_test_item(**fta_rq) def log(self, message: LogMessage, ts: Optional[str] = None): @@ -258,10 +268,10 @@ def log(self, message: LogMessage, ts: Optional[str] = None): :param ts: Timestamp """ sl_rq = { - 'attachment': message.attachment, - 'item_id': message.item_id, - 'level': LOG_LEVEL_MAPPING.get(message.level, 'INFO'), - 'message': message.message, - 'time': ts or timestamp() + "attachment": message.attachment, + "item_id": None if message.launch_log else message.item_id, + "level": LOG_LEVEL_MAPPING.get(message.level, "INFO"), + "message": message.message, + "time": ts or to_epoch(message.timestamp) or timestamp(), } self.rp.log(**sl_rq) diff --git a/robotframework_reportportal/static.py b/robotframework_reportportal/static.py index 05b578d..2f3aec6 100644 --- a/robotframework_reportportal/static.py +++ b/robotframework_reportportal/static.py @@ -17,21 +17,23 @@ from typing import Dict LOG_LEVEL_MAPPING: Dict[str, str] = { - 'INFO': 'INFO', - 'FAIL': 'ERROR', - 'TRACE': 'TRACE', - 'DEBUG': 'DEBUG', - 'HTML': 'INFO', - 'WARN': 'WARN', - 'ERROR': 'ERROR', - 'SKIP': 'INFO' + "INFO": "INFO", + "FAIL": "ERROR", + "TRACE": "TRACE", + "DEBUG": "DEBUG", + "HTML": "INFO", + "WARN": "WARN", + "ERROR": "ERROR", + "SKIP": "INFO", } -MAIN_SUITE_ID: str = 's1' -PABOT_WITHOUT_LAUNCH_ID_MSG: str = ('Pabot library is used but RP_LAUNCH_UUID was not provided. Please, ' - 'initialize listener with the RP_LAUNCH_UUID argument.') +MAIN_SUITE_ID: str = "s1" +PABOT_WITHOUT_LAUNCH_ID_MSG: str = ( + "Pabot library is used but RP_LAUNCH_UUID was not provided. Please, " + "initialize listener with the RP_LAUNCH_UUID argument." +) STATUS_MAPPING: Dict[str, str] = { - 'PASS': 'PASSED', - 'FAIL': 'FAILED', - 'NOT RUN': 'SKIPPED', - 'SKIP': 'SKIPPED' + "PASS": "PASSED", + "FAIL": "FAILED", + "NOT RUN": "SKIPPED", + "SKIP": "SKIPPED", } diff --git a/robotframework_reportportal/time_visitor.py b/robotframework_reportportal/time_visitor.py index 15c9ab2..fcf3b89 100644 --- a/robotframework_reportportal/time_visitor.py +++ b/robotframework_reportportal/time_visitor.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""This module provides a visitor for the robot framework results to correct the start and end times of the nodes.""" + import logging from robot.api import ResultVisitor @@ -21,24 +23,26 @@ class TimeVisitor(ResultVisitor): + """Visitor for the robot framework results to correct the start and end times of the nodes.""" @staticmethod def _correct_starts(o, node_class): """ - starttime wants to be the oldest start time of its children. - only correcting null starttime. + Correct items start time. + + Start time of a parent item should to be the oldest start time of its children so correct it if it's null or + newer than on any child item. """ if o.starttime: corrected = False for parent_id in _stack: - if corrections[parent_id][0] is None or \ - corrections[parent_id][0] > o.starttime: + if corrections[parent_id][0] is None or corrections[parent_id][0] > o.starttime: corrections[parent_id][0] = o.starttime corrected = True if corrected: logging.debug( - "Correcting parents' starttime to {0} based on {2}={1}" - .format(o.starttime, o.id, node_class)) + "Correcting parents' starttime to {0} based on {2}={1}".format(o.starttime, o.id, node_class) + ) else: _stack.append(o.id) corrections[o.id] = [None, None] @@ -46,37 +50,44 @@ def _correct_starts(o, node_class): @staticmethod def _correct_ends(o, node_class): """ - endtime wants to be the newest end time of its children. - only correcting null endtime. + Correct items end time. + + End time of a parent item should to be the newest end time of its children so correct it if it's null or + older than on any child item. """ if o.endtime: corrected = False for parent_id in _stack: - if corrections[parent_id][1] is None or \ - corrections[parent_id][1] < o.endtime: + if corrections[parent_id][1] is None or corrections[parent_id][1] < o.endtime: corrections[parent_id][1] = o.endtime corrected = True if corrected: logging.debug( - "Correcting parents' endtime to {0} based on {2}={1}" - .format(o.endtime, o.id, node_class)) + "Correcting parents' endtime to {0} based on {2}={1}".format(o.endtime, o.id, node_class) + ) if _stack and o.id == _stack[-1]: _stack.pop() def start_suite(self, suite): + """Correct start time of the suite.""" self._correct_starts(suite, "suite") def end_suite(self, suite): + """Correct end time of the suite.""" self._correct_ends(suite, "suite") def start_test(self, test): + """Correct start time of the test.""" self._correct_starts(test, "test") def end_test(self, test): + """Correct end time of the test.""" self._correct_ends(test, "test") def start_keyword(self, keyword): + """Correct start time of the keyword.""" self._correct_starts(keyword, "kw") def end_keyword(self, keyword): + """Correct end time of the keyword.""" self._correct_ends(keyword, "kw") diff --git a/robotframework_reportportal/variables.py b/robotframework_reportportal/variables.py index 488c101..c76d6e8 100644 --- a/robotframework_reportportal/variables.py +++ b/robotframework_reportportal/variables.py @@ -14,10 +14,10 @@ """This module contains model that stores Robot Framework variables.""" from os import path -from typing import Optional, Union, Dict, Tuple, Any, List +from typing import Any, Dict, List, Optional, Tuple, Union from warnings import warn -from reportportal_client import OutputType, ClientType +from reportportal_client import ClientType, OutputType from reportportal_client.helpers import to_bool from reportportal_client.logs import MAX_LOG_BATCH_PAYLOAD_SIZE from robot.libraries.BuiltIn import BuiltIn, RobotNotRunningError @@ -67,40 +67,41 @@ class Variables: launch_uuid_print_output: Optional[OutputType] client_type: ClientType http_timeout: Optional[Union[Tuple[float, float], float]] + remove_keywords: bool def __init__(self) -> None: """Initialize instance attributes.""" - self.endpoint = get_variable('RP_ENDPOINT') - self.launch_name = get_variable('RP_LAUNCH') - self.project = get_variable('RP_PROJECT') + self.endpoint = get_variable("RP_ENDPOINT") + self.launch_name = get_variable("RP_LAUNCH") + self.project = get_variable("RP_PROJECT") self._pabot_pool_id = None self._pabot_used = None - self.attach_log = to_bool(get_variable('RP_ATTACH_LOG', default='False')) - self.attach_report = to_bool(get_variable('RP_ATTACH_REPORT', default='False')) - self.attach_xunit = to_bool(get_variable('RP_ATTACH_XUNIT', default='False')) - self.launch_attributes = get_variable('RP_LAUNCH_ATTRIBUTES', default='').split() - self.launch_id = get_variable('RP_LAUNCH_UUID') - self.launch_doc = get_variable('RP_LAUNCH_DOC') - self.log_batch_size = int(get_variable( - 'RP_LOG_BATCH_SIZE', default='20')) - self.mode = get_variable('RP_MODE') - self.pool_size = int(get_variable('RP_MAX_POOL_SIZE', default='50')) - self.rerun = to_bool(get_variable('RP_RERUN', default='False')) - self.rerun_of = get_variable('RP_RERUN_OF', default=None) - self.skipped_issue = to_bool(get_variable('RP_SKIPPED_ISSUE', default='True')) - self.test_attributes = get_variable('RP_TEST_ATTRIBUTES', default='').split() - self.log_batch_payload_size = int(get_variable('RP_LOG_BATCH_PAYLOAD_SIZE', - default=str(MAX_LOG_BATCH_PAYLOAD_SIZE))) - self.launch_uuid_print = to_bool(get_variable('RP_LAUNCH_UUID_PRINT', default='False')) - output_type = get_variable('RP_LAUNCH_UUID_PRINT_OUTPUT') + self.attach_log = to_bool(get_variable("RP_ATTACH_LOG", default="False")) + self.attach_report = to_bool(get_variable("RP_ATTACH_REPORT", default="False")) + self.attach_xunit = to_bool(get_variable("RP_ATTACH_XUNIT", default="False")) + self.launch_attributes = get_variable("RP_LAUNCH_ATTRIBUTES", default="").split() + self.launch_id = get_variable("RP_LAUNCH_UUID") + self.launch_doc = get_variable("RP_LAUNCH_DOC") + self.log_batch_size = int(get_variable("RP_LOG_BATCH_SIZE", default="20")) + self.mode = get_variable("RP_MODE") + self.pool_size = int(get_variable("RP_MAX_POOL_SIZE", default="50")) + self.rerun = to_bool(get_variable("RP_RERUN", default="False")) + self.rerun_of = get_variable("RP_RERUN_OF", default=None) + self.skipped_issue = to_bool(get_variable("RP_SKIPPED_ISSUE", default="True")) + self.test_attributes = get_variable("RP_TEST_ATTRIBUTES", default="").split() + self.log_batch_payload_size = int( + get_variable("RP_LOG_BATCH_PAYLOAD_SIZE", default=str(MAX_LOG_BATCH_PAYLOAD_SIZE)) + ) + self.launch_uuid_print = to_bool(get_variable("RP_LAUNCH_UUID_PRINT", default="False")) + output_type = get_variable("RP_LAUNCH_UUID_PRINT_OUTPUT") self.launch_uuid_print_output = OutputType[output_type.upper()] if output_type else None - client_type = get_variable('RP_CLIENT_TYPE') + client_type = get_variable("RP_CLIENT_TYPE") self.client_type = ClientType[client_type.upper()] if client_type else ClientType.SYNC - connect_timeout = get_variable('RP_CONNECT_TIMEOUT') + connect_timeout = get_variable("RP_CONNECT_TIMEOUT") connect_timeout = float(connect_timeout) if connect_timeout else None - read_timeout = get_variable('RP_READ_TIMEOUT') + read_timeout = get_variable("RP_READ_TIMEOUT") read_timeout = float(read_timeout) if read_timeout else None if connect_timeout is None and read_timeout is None: @@ -110,34 +111,36 @@ def __init__(self) -> None: else: self.http_timeout = connect_timeout or read_timeout - self.api_key = get_variable('RP_API_KEY') + self.remove_keywords = to_bool(get_variable("RP_REMOVE_KEYWORDS", default="False")) + + self.api_key = get_variable("RP_API_KEY") if not self.api_key: - token = get_variable('RP_UUID') + token = get_variable("RP_UUID") if token: warn( message="Argument `RP_UUID` is deprecated since version 5.3.3 and will be subject for " - "removing in the next major version. Use `RP_API_KEY` argument instead.", + "removing in the next major version. Use `RP_API_KEY` argument instead.", category=DeprecationWarning, - stacklevel=2 + stacklevel=2, ) self.api_key = token else: warn( message="Argument `RP_API_KEY` is `None` or empty string, that's not supposed to happen " - "because ReportPortal is usually requires an authorization key. Please check your" - " configuration.", + "because ReportPortal is usually requires an authorization key. Please check your" + " configuration.", category=RuntimeWarning, - stacklevel=2 + stacklevel=2, ) cond = (self.endpoint, self.launch_name, self.project, self.api_key) self.enabled = all(cond) if not self.enabled: warn( - 'One or required parameter is missing, ReportPortal listener will be disabled. ' - 'Please check agent documentation.', + "One or required parameter is missing, ReportPortal listener will be disabled. " + "Please check agent documentation.", RuntimeWarning, - 2 + 2, ) @property @@ -147,7 +150,7 @@ def pabot_pool_id(self) -> int: :return: Pool id for the current Robot Framework executor """ if not self._pabot_pool_id: - self._pabot_pool_id = get_variable(name='PABOTEXECUTIONPOOLID') + self._pabot_pool_id = get_variable(name="PABOTEXECUTIONPOOLID") return self._pabot_pool_id @property @@ -157,13 +160,13 @@ def pabot_used(self) -> Optional[str]: :return: Cached value of the Pabotlib URI """ if not self._pabot_used: - self._pabot_used = get_variable(name='PABOTLIBURI') + self._pabot_used = get_variable(name="PABOTLIBURI") return self._pabot_used @property def verify_ssl(self) -> Union[bool, str]: """Get value of the verify_ssl parameter for the client.""" - verify_ssl = get_variable('RP_VERIFY_SSL', default='True') + verify_ssl = get_variable("RP_VERIFY_SSL", default="True") if path.exists(verify_ssl): return verify_ssl return to_bool(verify_ssl) diff --git a/setup.py b/setup.py index a42b845..3ac0479 100644 --- a/setup.py +++ b/setup.py @@ -15,10 +15,10 @@ """Setup instructions for the package.""" import os -from setuptools import setup +from setuptools import setup -__version__ = '5.5.8' +__version__ = "5.6.0" def read_file(fname): @@ -32,34 +32,29 @@ def read_file(fname): setup( - name='robotframework-reportportal', - packages=['robotframework_reportportal'], - package_data={'robotframework_reportportal': ['*.pyi']}, + name="robotframework-reportportal", + packages=["robotframework_reportportal"], + package_data={"robotframework_reportportal": ["*.pyi"]}, version=__version__, - description='Agent for reporting RobotFramework test results to ReportPortal', - long_description=read_file('README.md'), - long_description_content_type='text/markdown', - author='ReportPortal Team', - author_email='support@reportportal.io', - url='https://github.com/reportportal/agent-Python-RobotFramework', + description="Agent for reporting RobotFramework test results to ReportPortal", + long_description=read_file("README.md"), + long_description_content_type="text/markdown", + author="ReportPortal Team", + author_email="support@reportportal.io", + url="https://github.com/reportportal/agent-Python-RobotFramework", download_url=( - 'https://github.com/reportportal/agent-Python-RobotFramework/' - 'tarball/{version}'.format(version=__version__)), - keywords=['testing', 'reporting', 'robot framework', 'reportportal', - 'agent'], + "https://github.com/reportportal/agent-Python-RobotFramework/" "tarball/{version}".format(version=__version__) + ), + keywords=["testing", "reporting", "robot framework", "reportportal", "agent"], classifiers=[ - 'Framework :: Robot Framework', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - ], - install_requires=read_file('requirements.txt').splitlines(), - entry_points={ - 'console_scripts': [ - 'post_report=robotframework_reportportal.post_report:main' - ] - }, + "Framework :: Robot Framework", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + ], + install_requires=read_file("requirements.txt").splitlines(), + entry_points={"console_scripts": ["post_report=robotframework_reportportal.post_report:main"]}, ) diff --git a/tests/__init__.py b/tests/__init__.py index 37c9923..ea257eb 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -14,5 +14,5 @@ limitations under the License """ -REPORT_PORTAL_SERVICE = 'reportportal_client.RPClient' -REQUESTS_SERVICE = 'reportportal_client.client.requests.Session' +REPORT_PORTAL_SERVICE = "reportportal_client.RPClient" +REQUESTS_SERVICE = "reportportal_client.client.requests.Session" diff --git a/tests/helpers/utils.py b/tests/helpers/utils.py index 8fdfe21..7ac2d20 100644 --- a/tests/helpers/utils.py +++ b/tests/helpers/utils.py @@ -16,24 +16,26 @@ import random import time -from typing import List, Optional, Dict, Any, Tuple +from typing import Any, Dict, List, Optional, Tuple from robot.run import RobotFramework DEFAULT_VARIABLES: Dict[str, Any] = { - 'RP_LAUNCH': 'Robot Framework', - 'RP_ENDPOINT': 'http://localhost:8080', - 'RP_PROJECT': 'default_personal', - 'RP_API_KEY': 'test_api_key', - 'RP_ATTACH_REPORT': False + "RP_LAUNCH": "Robot Framework", + "RP_ENDPOINT": "http://localhost:8080", + "RP_PROJECT": "default_personal", + "RP_API_KEY": "test_api_key", + "RP_ATTACH_REPORT": False, } -def run_robot_tests(tests: List[str], - listener: str = 'robotframework_reportportal.listener', - variables: Optional[Dict[str, Any]] = None, - arguments: Optional[Dict[str, Any]] = None) -> int: - cmd_arguments = ['--listener', listener] +def run_robot_tests( + tests: List[str], + listener: str = "robotframework_reportportal.listener", + variables: Optional[Dict[str, Any]] = None, + arguments: Optional[Dict[str, Any]] = None, +) -> int: + cmd_arguments = ["--listener", listener] if arguments: for k, v in arguments.items(): cmd_arguments.append(k) @@ -43,10 +45,10 @@ def run_robot_tests(tests: List[str], variables = DEFAULT_VARIABLES for k, v in variables.items(): - cmd_arguments.append('--variable') + cmd_arguments.append("--variable") if type(v) is not str: v = str(v) - cmd_arguments.append(k + ':' + v) + cmd_arguments.append(k + ":" + v) for t in tests: cmd_arguments.append(t) @@ -55,14 +57,12 @@ def run_robot_tests(tests: List[str], def get_launch_log_calls(mock) -> List[Tuple[List[Any], Dict[str, Any]]]: - return [e for e in mock.log.call_args_list - if 'item_id' in e[1] and e[1]['item_id'] is None] + return [e for e in mock.log.call_args_list if "item_id" in e[1] and e[1]["item_id"] is None] def get_log_calls(mock) -> List[Tuple[List[Any], Dict[str, Any]]]: - return [e for e in mock.log.call_args_list if 'item_id' in e[1] and e[1]['item_id']] + return [e for e in mock.log.call_args_list if "item_id" in e[1] and e[1]["item_id"]] def item_id_gen(**kwargs) -> str: - return "{}-{}-{}".format(kwargs['name'], str(round(time.time() * 1000)), - random.randint(0, 9999)) + return "{}-{}-{}".format(kwargs["name"], str(round(time.time() * 1000)), random.randint(0, 9999)) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 4fc1b08..6a4b018 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -14,41 +14,40 @@ """This module contains common Pytest fixtures and hooks for unit tests.""" -import sys +KEYWORDS_EXPECTED_TEST_NAMES = ["Invalid Password"] +KEYWORDS_EXPECTED_CODE_REF_SUFFIXES = ["6"] * 6 -KEYWORDS_EXPECTED_TEST_NAMES = ['Invalid Password'] -KEYWORDS_EXPECTED_CODE_REF_SUFFIXES = ['6'] * 6 - -SETTINGS_EXPECTED_TEST_NAMES = ['Invalid User Name', 'Invalid Password', - 'Invalid User Name and Password', - 'Empty User Name', - 'Empty Password', - 'Empty User Name and Password'] +SETTINGS_EXPECTED_TEST_NAMES = [ + "Invalid User Name", + "Invalid Password", + "Invalid User Name and Password", + "Empty User Name", + "Empty Password", + "Empty User Name and Password", +] SETTINGS_EXPECTED_CODE_REF_SUFFIXES = [str(x) for x in range(7, 14)] -DATADRIVER_EXPECTED_TEST_NAMES = \ - ['Login with user \'invalid\' and password \'Password\'', - 'Login with user \'User\' and password \'invalid\'', - 'Login with user \'invalid\' and password \'invalid\'', - 'Login with user \'\' and password \'Password\'', - 'Login with user \'User\' and password \'\'', - 'Login with user \'\' and password \'\''] -DATADRIVER_EXPECTED_CODE_REF_SUFFIXES = ['8'] * 6 +DATADRIVER_EXPECTED_TEST_NAMES = [ + "Login with user 'invalid' and password 'Password'", + "Login with user 'User' and password 'invalid'", + "Login with user 'invalid' and password 'invalid'", + "Login with user '' and password 'Password'", + "Login with user 'User' and password ''", + "Login with user '' and password ''", +] +DATADRIVER_EXPECTED_CODE_REF_SUFFIXES = ["8"] * 6 def pytest_generate_tests(metafunc): - if metafunc.function.__name__ == 'test_code_reference_template': - func_options = 'test,test_names,code_ref_suffixes' + if metafunc.function.__name__ == "test_code_reference_template": + func_options = "test,test_names,code_ref_suffixes" option_args = [ - ('examples/templates/keyword.robot', KEYWORDS_EXPECTED_TEST_NAMES, - KEYWORDS_EXPECTED_CODE_REF_SUFFIXES), - ('examples/templates/settings.robot', SETTINGS_EXPECTED_TEST_NAMES, - SETTINGS_EXPECTED_CODE_REF_SUFFIXES)] - if sys.version_info >= (3, 6): - pass - # TODO: Uncomment as soon as DataDriver fix its compatibility with - # Robot 6.1 - # option_args.append(('examples/templates/datadriver.robot', - # DATADRIVER_EXPECTED_TEST_NAMES, - # DATADRIVER_EXPECTED_CODE_REF_SUFFIXES)) + ("examples/templates/keyword.robot", KEYWORDS_EXPECTED_TEST_NAMES, KEYWORDS_EXPECTED_CODE_REF_SUFFIXES), + ("examples/templates/settings.robot", SETTINGS_EXPECTED_TEST_NAMES, SETTINGS_EXPECTED_CODE_REF_SUFFIXES), + ( + "examples/templates/datadriver.robot", + DATADRIVER_EXPECTED_TEST_NAMES, + DATADRIVER_EXPECTED_CODE_REF_SUFFIXES, + ), + ] metafunc.parametrize(func_options, option_args) diff --git a/tests/integration/test_attachments.py b/tests/integration/test_attachments.py index bdbf04a..1a72bee 100644 --- a/tests/integration/test_attachments.py +++ b/tests/integration/test_attachments.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from delayed_assert import assert_expectations, expect from unittest import mock from tests import REPORT_PORTAL_SERVICE @@ -27,52 +26,42 @@ def verify_attachment(mock_client_init, result, message, name, content_type): assert len(calls) == 1 call_params = calls[0][1] - expect('attachment' in call_params.keys(), - 'log entry does not contain attachment') - expect('level' in call_params.keys(), - 'log entry does not contain level') - expect('message' in call_params.keys(), - 'log entry does not contain message') + assert "attachment" in call_params.keys(), "log entry does not contain attachment" + assert "level" in call_params.keys(), "log entry does not contain level" + assert "message" in call_params.keys(), "log entry does not contain message" - assert_expectations() - expect(call_params['level'] == 'INFO') - expect(call_params['message'] == message) - expect(call_params['attachment']['name'] == name) - expect(call_params['attachment']['mime'] == content_type) - expect(len(call_params['attachment']['data']) > 0) - assert_expectations() + assert call_params["level"] == "INFO" + assert call_params["message"] == message + assert call_params["attachment"]["name"] == name + assert call_params["attachment"]["mime"] == content_type + assert len(call_params["attachment"]["data"]) > 0 @mock.patch(REPORT_PORTAL_SERVICE) def test_agent_attaches_report(mock_client_init): variables = utils.DEFAULT_VARIABLES.copy() - variables['RP_ATTACH_REPORT'] = True - result = utils.run_robot_tests(['examples/templates/settings.robot'], - variables=variables) - verify_attachment(mock_client_init, result, 'Execution report', - 'report.html', 'text/html') + variables["RP_ATTACH_REPORT"] = True + result = utils.run_robot_tests(["examples/templates/settings.robot"], variables=variables) + verify_attachment(mock_client_init, result, "Execution report", "report.html", "text/html") @mock.patch(REPORT_PORTAL_SERVICE) def test_agent_attaches_log(mock_client_init): variables = utils.DEFAULT_VARIABLES.copy() - variables['RP_ATTACH_LOG'] = True - result = utils.run_robot_tests(['examples/templates/settings.robot'], - variables=variables) - verify_attachment(mock_client_init, result, 'Execution log', - 'log.html', 'text/html') + variables["RP_ATTACH_LOG"] = True + result = utils.run_robot_tests(["examples/templates/settings.robot"], variables=variables) + verify_attachment(mock_client_init, result, "Execution log", "log.html", "text/html") assert result == 0 # the test successfully passed -XUNIT_FILE_NAME = 'xunit.xml' +XUNIT_FILE_NAME = "xunit.xml" @mock.patch(REPORT_PORTAL_SERVICE) def test_agent_attaches_xunit(mock_client_init): variables = utils.DEFAULT_VARIABLES.copy() - variables['RP_ATTACH_XUNIT'] = True - result = utils.run_robot_tests(['examples/templates/settings.robot'], - variables=variables, - arguments={'-x': XUNIT_FILE_NAME}) - verify_attachment(mock_client_init, result, 'XUnit result file', - XUNIT_FILE_NAME, 'application/xml') + variables["RP_ATTACH_XUNIT"] = True + result = utils.run_robot_tests( + ["examples/templates/settings.robot"], variables=variables, arguments={"-x": XUNIT_FILE_NAME} + ) + verify_attachment(mock_client_init, result, "XUnit result file", XUNIT_FILE_NAME, "application/xml") diff --git a/tests/integration/test_before_after.py b/tests/integration/test_before_after.py index c6dcfc1..142aa12 100644 --- a/tests/integration/test_before_after.py +++ b/tests/integration/test_before_after.py @@ -12,23 +12,28 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pytest from unittest import mock +import pytest + from tests import REPORT_PORTAL_SERVICE from tests.helpers import utils @pytest.mark.parametrize( - 'test, idx_to_check, step_name, suite_name', [ - ('examples/before_after/before_suite_with_steps.robot', 1, - 'Log suite setup', 'Before Suite With Steps'), - ('examples/before_after/after_suite_with_steps.robot', 3, - 'Log suite tear down', 'After Suite With Steps') - ]) + "test, idx_to_check, step_name, suite_name", + [ + ("examples/before_after/before_suite_with_steps.robot", 1, "SETUP Log suite setup", "Before Suite With Steps"), + ( + "examples/before_after/after_suite_with_steps.robot", + 3, + "TEARDOWN Log suite tear down", + "After Suite With Steps", + ), + ], +) @mock.patch(REPORT_PORTAL_SERVICE) -def test_before_after_suite_with_steps(mock_client_init, test, idx_to_check, - step_name, suite_name): +def test_before_after_suite_with_steps(mock_client_init, test, idx_to_check, step_name, suite_name): mock_client = mock_client_init.return_value mock_client.start_test_item.side_effect = utils.item_id_gen @@ -43,10 +48,10 @@ def test_before_after_suite_with_steps(mock_client_init, test, idx_to_check, item_finish_calls = mock_client.finish_test_item.call_args_list assert len(item_start_calls) == len(item_finish_calls) == 5 - statuses = [finish[1]['status'] for finish in item_finish_calls] - assert statuses == ['PASSED'] * 5 + statuses = [finish[1]["status"] for finish in item_finish_calls] + assert statuses == ["PASSED"] * 5 before_suite_start = item_start_calls[idx_to_check][1] - assert before_suite_start['name'].startswith(step_name) - assert before_suite_start['has_stats'] - assert before_suite_start['parent_item_id'].startswith(suite_name) + assert before_suite_start["name"].startswith(step_name) + assert before_suite_start["has_stats"] + assert before_suite_start["parent_item_id"].startswith(suite_name) diff --git a/tests/integration/test_case_id.py b/tests/integration/test_case_id.py index 160fe98..8318966 100644 --- a/tests/integration/test_case_id.py +++ b/tests/integration/test_case_id.py @@ -17,7 +17,7 @@ from tests import REPORT_PORTAL_SERVICE from tests.helpers import utils -SIMPLE_TEST = 'examples/simple.robot' +SIMPLE_TEST = "examples/simple.robot" @mock.patch(REPORT_PORTAL_SERVICE) @@ -28,10 +28,10 @@ def test_case_id_simple(mock_client_init): mock_client = mock_client_init.return_value item_start_calls = mock_client.start_test_item.call_args_list test_item = item_start_calls[-2] - assert test_item[1]['test_case_id'] == SIMPLE_TEST + ':Simple test' + assert test_item[1]["test_case_id"] == SIMPLE_TEST + ":Simple test" -CUSTOM_TEST_CASE_ID = 'examples/custom_test_case_id.robot' +CUSTOM_TEST_CASE_ID = "examples/custom_test_case_id.robot" @mock.patch(REPORT_PORTAL_SERVICE) @@ -49,4 +49,4 @@ def test_case_id_custom_definition(mock_client_init): assert len(item_start_calls) == len(item_finish_calls) == 3 test_item = item_start_calls[-2] - assert test_item[1]['test_case_id'] == 'custom' + assert test_item[1]["test_case_id"] == "custom" diff --git a/tests/integration/test_code_reference.py b/tests/integration/test_code_reference.py index b03df8b..61a2f75 100644 --- a/tests/integration/test_code_reference.py +++ b/tests/integration/test_code_reference.py @@ -12,13 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from tests.helpers import utils from unittest import mock from tests import REPORT_PORTAL_SERVICE +from tests.helpers import utils - -SIMPLE_TEST = 'examples/simple.robot' +SIMPLE_TEST = "examples/simple.robot" @mock.patch(REPORT_PORTAL_SERVICE) @@ -36,4 +35,4 @@ def test_code_reference_simple(mock_client_init): assert len(item_start_calls) == len(item_finish_calls) == 3 test_item = item_start_calls[-2] - assert test_item[1]['code_ref'] == SIMPLE_TEST + ':3' + assert test_item[1]["code_ref"] == SIMPLE_TEST + ":3" diff --git a/tests/integration/test_dynamic_tags.py b/tests/integration/test_dynamic_tags.py index b116877..88d58a0 100644 --- a/tests/integration/test_dynamic_tags.py +++ b/tests/integration/test_dynamic_tags.py @@ -13,19 +13,17 @@ # limitations under the License. import uuid - -from delayed_assert import assert_expectations, expect from unittest import mock from tests import REPORT_PORTAL_SERVICE from tests.helpers import utils -TEST_CASES = ['Test tag set', 'Test no tag', 'Test set multiple tags'] +TEST_CASES = ["Test tag set", "Test no tag", "Test set multiple tags"] TEST_CASE_UUIDS = [str(uuid.uuid5(uuid.NAMESPACE_OID, i)) for i in TEST_CASES] def generate_id(*args, **kwargs): - return str(uuid.uuid5(uuid.NAMESPACE_OID, str(kwargs['name']))) + return str(uuid.uuid5(uuid.NAMESPACE_OID, str(kwargs["name"]))) @mock.patch(REPORT_PORTAL_SERVICE) @@ -33,21 +31,21 @@ def test_launch_log(mock_client_init): mock_client = mock_client_init.return_value mock_client.start_test_item.side_effect = generate_id - result = utils.run_robot_tests(['examples/dynamic_tags.robot']) + result = utils.run_robot_tests(["examples/dynamic_tags.robot"]) assert result == 0 # the test successfully passed start_tests = [ - call for call in mock_client.start_test_item.call_args_list if - call[1]['item_type'] == 'STEP' and call[1].get('has_stats', True)] - finish_tests = [call for call in - mock_client.finish_test_item.call_args_list if - call[1]['item_id'] in TEST_CASE_UUIDS] + call + for call in mock_client.start_test_item.call_args_list + if call[1]["item_type"] == "STEP" and call[1].get("has_stats", True) + ] + finish_tests = [ + call for call in mock_client.finish_test_item.call_args_list if call[1]["item_id"] in TEST_CASE_UUIDS + ] for start in start_tests: - expect(len(start[1]['attributes']) == 0) + assert len(start[1]["attributes"]) == 0 - expect(finish_tests[0][1]['attributes'] == [{'value': 'dynamic_tag'}]) - expect(finish_tests[1][1]['attributes'] == []) - expect(finish_tests[2][1]['attributes'] == - [{'value': 'multiple_tags_one'}, {'value': 'multiple_tags_two'}]) - assert_expectations() + assert finish_tests[0][1]["attributes"] == [{"value": "dynamic_tag"}] + assert finish_tests[1][1]["attributes"] == [] + assert finish_tests[2][1]["attributes"] == [{"value": "multiple_tags_one"}, {"value": "multiple_tags_two"}] diff --git a/tests/integration/test_dynamic_test_case_id.py b/tests/integration/test_dynamic_test_case_id.py index 9fb8196..7730802 100644 --- a/tests/integration/test_dynamic_test_case_id.py +++ b/tests/integration/test_dynamic_test_case_id.py @@ -17,12 +17,12 @@ from tests import REPORT_PORTAL_SERVICE from tests.helpers import utils -EXAMPLE_TEST = 'examples/dynamic_test_case_id.robot' +EXAMPLE_TEST = "examples/dynamic_test_case_id.robot" @mock.patch(REPORT_PORTAL_SERVICE) def test_case_id_simple(mock_client_init): - result = utils.run_robot_tests([EXAMPLE_TEST], arguments={'--metadata': 'Scope:Smoke'}) + result = utils.run_robot_tests([EXAMPLE_TEST], arguments={"--metadata": "Scope:Smoke"}) assert result == 0 # the test successfully passed mock_client = mock_client_init.return_value @@ -35,7 +35,7 @@ def test_case_id_simple(mock_client_init): assert len(item_start_calls) == len(item_finish_calls) == 3 test_item_start = item_start_calls[-2] - assert test_item_start[1]['test_case_id'] == f'{EXAMPLE_TEST}:Test set dynamic Test Case ID' + assert test_item_start[1]["test_case_id"] == f"{EXAMPLE_TEST}:Test set dynamic Test Case ID" test_item_finish = item_finish_calls[-2] - assert test_item_finish[1]['test_case_id'] == 'dynamic_tags.robot[Smoke]' + assert test_item_finish[1]["test_case_id"] == "dynamic_tags.robot[Smoke]" diff --git a/tests/integration/test_logger.py b/tests/integration/test_logger.py index c6d69a1..4b4b379 100644 --- a/tests/integration/test_logger.py +++ b/tests/integration/test_logger.py @@ -12,34 +12,34 @@ # See the License for the specific language governing permissions and # limitations under the License. -from tests.helpers import utils from unittest import mock from tests import REPORT_PORTAL_SERVICE +from tests.helpers import utils @mock.patch(REPORT_PORTAL_SERVICE) def test_launch_log(mock_client_init): - result = utils.run_robot_tests(['examples/launch_log.robot']) + result = utils.run_robot_tests(["examples/launch_log.robot"]) assert result == 0 # the test successfully passed mock_client = mock_client_init.return_value calls = utils.get_launch_log_calls(mock_client) assert len(calls) == 3 - messages = set(map(lambda x: x[1]['message'], calls)) - assert messages == {'Hello, world!', 'Goodbye, world!', 'Enjoy my pug!'} + messages = set(map(lambda x: x[1]["message"], calls)) + assert messages == {"Hello, world!", "Goodbye, world!", "Enjoy my pug!"} @mock.patch(REPORT_PORTAL_SERVICE) def test_binary_file_log(mock_client_init): - result = utils.run_robot_tests(['examples/binary_file_read.robot']) + result = utils.run_robot_tests(["examples/binary_file_log_as_text.robot"]) assert result == 0 # the test successfully passed mock_client = mock_client_init.return_value calls = utils.get_log_calls(mock_client) assert len(calls) == 3 - messages = set(map(lambda x: x[1]['message'], calls)) + messages = set(map(lambda x: x[1]["message"], calls)) error_msg = 'Binary data of type "image/jpeg" logging skipped, as it was processed as text and hence corrupted.' assert error_msg in messages diff --git a/tests/integration/test_no_keyword_message.py b/tests/integration/test_no_keyword_message.py deleted file mode 100644 index 32751ea..0000000 --- a/tests/integration/test_no_keyword_message.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright 2023 EPAM Systems -# -# 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 -# -# https://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.s - -import re -from tests.helpers import utils -from unittest import mock - -from tests import REPORT_PORTAL_SERVICE - - -NO_KEYWORDS_MESSAGE_PATTERN = \ - re.compile(r'Test(?: case)? (?:contains no keywords|cannot be empty)\.') - - -@mock.patch(REPORT_PORTAL_SERVICE) -def test_no_keyword_message(mock_client_init): - mock_client = mock_client_init.return_value - mock_client.start_test_item.side_effect = utils.item_id_gen - - result = utils.run_robot_tests(['examples/no_keywords.robot']) - assert result == 1 - - log_calls = mock_client.log.call_args_list - assert len(log_calls) == 1 - - log_call = log_calls[0][1] - assert NO_KEYWORDS_MESSAGE_PATTERN.match(log_call['message']) - assert log_call['item_id'].startswith('No keyword test case') - - item_start_calls = mock_client.start_test_item.call_args_list - item_finish_calls = mock_client.finish_test_item.call_args_list - assert len(item_start_calls) == len(item_finish_calls) == 2 - - statuses = [finish[1]['status'] for finish in item_finish_calls] - assert statuses == ['FAILED', 'FAILED'] diff --git a/tests/integration/test_remove_keywords.py b/tests/integration/test_remove_keywords.py new file mode 100644 index 0000000..6191da2 --- /dev/null +++ b/tests/integration/test_remove_keywords.py @@ -0,0 +1,333 @@ +# Copyright 2024 EPAM Systems +# +# 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 +# +# https://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 unittest import mock + +import pytest + +from tests import REPORT_PORTAL_SERVICE +from tests.helpers import utils +from tests.helpers.utils import DEFAULT_VARIABLES + + +@mock.patch(REPORT_PORTAL_SERVICE) +def test_remove_keyword_not_provided(mock_client_init): + mock_client = mock_client_init.return_value + mock_client.start_test_item.side_effect = utils.item_id_gen + + result = utils.run_robot_tests( + ["examples/for_keyword.robot"], variables=DEFAULT_VARIABLES, arguments={"--remove-keywords": "FOR"} + ) + assert result == 0 + + launch_start = mock_client.start_launch.call_args_list + launch_finish = mock_client.finish_launch.call_args_list + assert len(launch_start) == len(launch_finish) == 1 + + item_start_calls = mock_client.start_test_item.call_args_list + item_finish_calls = mock_client.finish_test_item.call_args_list + assert len(item_start_calls) == len(item_finish_calls) + assert len(item_finish_calls) == 9 + + statuses = [finish[1]["status"] for finish in item_finish_calls] + assert statuses == ["PASSED"] * 9 + + +@pytest.mark.parametrize( + "file, keyword_to_remove, exit_code, expected_statuses, log_number, skip_idx, skip_message", + [ + ( + "examples/for_keyword.robot", + "FOR", + 0, + ["PASSED"] * 5, + 2, + 0, + "2 passing items removed using the --remove-keywords option.", + ), + ( + "examples/while_keyword.robot", + "WHILE", + 0, + ["PASSED"] * 7, + 5, + 2, + "2 passing items removed using the --remove-keywords option.", + ), + ( + "examples/for_keyword_failed.robot", + "FOR", + 1, + ["PASSED"] + ["FAILED"] * 6, + 3, + 0, + "1 passing items removed using the --remove-keywords option.", + ), + ( + "examples/while_keyword_failed.robot", + "WHILE", + 1, + ["PASSED"] * 3 + ["FAILED"] * 6, + 6, + 2, + "1 passing items removed using the --remove-keywords option.", + ), + ( + "examples/wuks_keyword.robot", + "WUKS", + 0, + ["PASSED"] * 2 + ["FAILED"] * 3 + ["PASSED"] * 2 + ["SKIPPED"] * 2 + ["PASSED"] * 4, + 6, + 0, + "1 failing items removed using the --remove-keywords option.", + ), + ( + "examples/wuks_keyword_failed.robot", + "WUKS", + 1, + ["PASSED"] * 2 + ["FAILED"] * 6, + 4, + 0, + "2 failing items removed using the --remove-keywords option.", + ), + ( + "examples/wuks_keyword_warnings.robot", + "WUKS", + 0, + ["PASSED"] * 3 + + ["FAILED"] * 3 + + ["PASSED"] * 3 + + ["FAILED"] * 3 + + ["PASSED"] * 2 + + ["SKIPPED"] * 3 + + ["PASSED"] * 4, + 10, + 6, + "To less executions warning", + ), + ( + "examples/rkie_keyword.robot", + "ALL", + 0, + ["PASSED"] * 4, + 2, + 0, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/wuks_keyword.robot", + "ALL", + 0, + ["PASSED"] * 3, + 1, + 0, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/wuks_keyword_failed.robot", + "ALL", + 1, + ["FAILED"] * 3, + 1, + 0, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/for_keyword.robot", + "ALL", + 0, + ["PASSED"] * 3, + 1, + 0, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/while_keyword.robot", + "ALL", + 0, + ["PASSED"] * 4, + 2, + 1, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/for_keyword_failed.robot", + "ALL", + 1, + ["FAILED"] * 3, + 1, + 0, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/while_keyword_failed.robot", + "ALL", + 1, + ["PASSED"] + ["FAILED"] * 3, + 2, + 1, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/rkie_keyword.robot", + "PASSED", + 0, + ["PASSED"] * 4, + 2, + 0, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/wuks_keyword.robot", + "PASSED", + 0, + ["PASSED"] * 3, + 1, + 0, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/for_keyword.robot", + "PASSED", + 0, + ["PASSED"] * 3, + 1, + 0, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/while_keyword.robot", + "PASSED", + 0, + ["PASSED"] * 4, + 2, + 1, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/for_keyword_failed.robot", + "PASSED", + 1, + ["FAILED"] + ["PASSED"] + ["SKIPPED"] * 2 + ["PASSED"] * 2 + ["FAILED"] * 5, + 3, + 0, + "apple", + ), + ( + "examples/while_keyword_failed.robot", + "PASSED", + 1, + ["PASSED"] + ["FAILED"] + ["PASSED"] * 2 + ["SKIPPED"] * 2 + ["PASSED"] * 3 + ["FAILED"] * 5, + 7, + 0, + "Length is 3.", + ), + ( + "examples/wuks_keyword_failed.robot", + "PASSED", + 1, + ["FAILED"] + + ["PASSED"] * 2 + + ["FAILED"] * 3 + + ["PASSED"] * 2 + + ["FAILED"] * 3 + + ["PASSED"] * 2 + + ["FAILED"] * 5, + 10, + 0, + "Keyword 'Fail on first try' failed after retrying 3 times. The last error was: To less executions", + ), + ( + "examples/binary_file_log_as_image.robot", + "tag:binary", + 0, + ["PASSED"] * 3, + 1, + 0, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/binary_file_log_as_image.robot", + "tag:bin*", + 0, + ["PASSED"] * 3, + 1, + 0, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/binary_file_log_as_text.robot", + "tag:binary", + 0, + ["PASSED"] * 5, + 3, + 2, + 'Binary data of type "image/jpeg" logging skipped, as it was processed as text and hence corrupted.', + ), + ( + "examples/rkie_keyword.robot", + "name: BuiltIn.Run Keyword And Ignore Error", + 0, + ["PASSED"] * 3 + ["SKIPPED"] * 2 + ["PASSED"] * 3, + 3, + 0, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/rkie_keyword.robot", + "name: *.Run Keyword And Ignore Error", + 0, + ["PASSED"] * 3 + ["SKIPPED"] * 2 + ["PASSED"] * 3, + 3, + 0, + "Content removed using the --remove-keywords option.", + ), + ( + "examples/rkie_keyword_error.robot", + "name: BuiltIn.Run Keyword And Ignore Error", + 0, + ["PASSED"] * 3 + ["FAILED"] * 3 + ["PASSED"] * 3 + ["SKIPPED"] * 3 + ["PASSED"] * 3, + 6, + 2, + "To less executions error", + ), + ], +) +@mock.patch(REPORT_PORTAL_SERVICE) +def test_keyword_remove( + mock_client_init, file, keyword_to_remove, exit_code, expected_statuses, log_number, skip_idx, skip_message +): + mock_client = mock_client_init.return_value + mock_client.start_test_item.side_effect = utils.item_id_gen + + variables = DEFAULT_VARIABLES.copy() + variables["RP_REMOVE_KEYWORDS"] = True + result = utils.run_robot_tests([file], variables=variables, arguments={"--remove-keywords": keyword_to_remove}) + assert result == exit_code + + launch_start = mock_client.start_launch.call_args_list + launch_finish = mock_client.finish_launch.call_args_list + assert len(launch_start) == len(launch_finish) == 1 + + item_start_calls = mock_client.start_test_item.call_args_list + item_finish_calls = mock_client.finish_test_item.call_args_list + assert len(item_start_calls) == len(item_finish_calls) + assert len(item_finish_calls) == len(expected_statuses) + + statuses = [finish[1]["status"] for finish in item_finish_calls] + assert statuses == expected_statuses + + log_calls = utils.get_log_calls(mock_client) + assert len(log_calls) == log_number + assert sorted(log_calls, key=lambda x: x[1]["time"])[skip_idx][1]["message"] == skip_message diff --git a/tests/integration/test_rkie_keyword.py b/tests/integration/test_rkie_keyword.py index a39dd73..4c5cb9c 100644 --- a/tests/integration/test_rkie_keyword.py +++ b/tests/integration/test_rkie_keyword.py @@ -23,7 +23,7 @@ def test_before_after_suite_with_steps(mock_client_init): mock_client = mock_client_init.return_value mock_client.start_test_item.side_effect = utils.item_id_gen - result = utils.run_robot_tests(['examples/rkie_keyword.robot']) + result = utils.run_robot_tests(["examples/rkie_keyword.robot"]) assert result == 0 launch_start = mock_client.start_launch.call_args_list @@ -34,6 +34,5 @@ def test_before_after_suite_with_steps(mock_client_init): item_finish_calls = mock_client.finish_test_item.call_args_list assert len(item_start_calls) == len(item_finish_calls) == 13 - statuses = [finish[1]['status'] for finish in item_finish_calls] - assert statuses == ['PASSED'] * 2 + ['FAILED'] * 3 + ['PASSED'] * 3 + [ - 'SKIPPED'] * 2 + ['PASSED'] * 3 + statuses = [finish[1]["status"] for finish in item_finish_calls] + assert statuses == ["PASSED"] * 2 + ["FAILED"] * 3 + ["PASSED"] * 3 + ["SKIPPED"] * 2 + ["PASSED"] * 3 diff --git a/tests/integration/test_screenshot.py b/tests/integration/test_screenshot.py index 1b2b5b4..de4402c 100644 --- a/tests/integration/test_screenshot.py +++ b/tests/integration/test_screenshot.py @@ -12,14 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -from tests.helpers import utils from unittest import mock from tests import REPORT_PORTAL_SERVICE +from tests.helpers import utils -EXAMPLE_TEST = 'examples/screenshot.robot' -SELENIUM_SCREENSHOT = 'examples/res/selenium-screenshot-1.png' -PLAYWRIGHT_SCREENSHOT = 'examples/res/Screenshot_test_FAILURE_SCREENSHOT_1.png' +EXAMPLE_TEST = "examples/screenshot.robot" +SELENIUM_SCREENSHOT = "examples/res/selenium-screenshot-1.png" +PLAYWRIGHT_SCREENSHOT = "examples/res/Screenshot_test_FAILURE_SCREENSHOT_1.png" SCREENSHOTS = [SELENIUM_SCREENSHOT, PLAYWRIGHT_SCREENSHOT] @@ -33,12 +33,12 @@ def test_screenshot_log(mock_client_init): assert len(calls) == 2 for i, call in enumerate(calls): - message = call[1]['message'] - assert message == f'Image attached: {SCREENSHOTS[i]}' + message = call[1]["message"] + assert message == f"Image attached: {SCREENSHOTS[i]}" - attachment = call[1]['attachment'] + attachment = call[1]["attachment"] - assert attachment['name'] == SCREENSHOTS[i].split('/')[-1] - assert attachment['mime'] == 'image/png' - with open(SCREENSHOTS[i], 'rb') as file: - assert attachment['data'] == file.read() + assert attachment["name"] == SCREENSHOTS[i].split("/")[-1] + assert attachment["mime"] == "image/png" + with open(SCREENSHOTS[i], "rb") as file: + assert attachment["data"] == file.read() diff --git a/tests/integration/test_suite_doc_with_urls.py b/tests/integration/test_suite_doc_with_urls.py index c09dd85..a4a797a 100644 --- a/tests/integration/test_suite_doc_with_urls.py +++ b/tests/integration/test_suite_doc_with_urls.py @@ -17,7 +17,7 @@ from tests import REPORT_PORTAL_SERVICE from tests.helpers import utils -SIMPLE_TEST = 'examples/suite_doc_with_urls.robot' +SIMPLE_TEST = "examples/suite_doc_with_urls.robot" @mock.patch(REPORT_PORTAL_SERVICE) @@ -35,5 +35,6 @@ def test_suite_doc_with_urls(mock_client_init): assert len(item_start_calls) == len(item_finish_calls) == 3 test_suite = item_start_calls[0] - assert test_suite[1]['description'] == ('This is a test suite with URLs: [Google](https://www.google.com ) and ' - '') + assert test_suite[1]["description"] == ( + "This is a test suite with URLs: [Google](https://www.google.com ) and " "" + ) diff --git a/tests/integration/test_suite_metadata.py b/tests/integration/test_suite_metadata.py index 2eaa7e7..e1ed714 100644 --- a/tests/integration/test_suite_metadata.py +++ b/tests/integration/test_suite_metadata.py @@ -12,13 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from tests.helpers import utils from unittest import mock from tests import REPORT_PORTAL_SERVICE +from tests.helpers import utils - -SIMPLE_TEST = 'examples/suite_metadata.robot' +SIMPLE_TEST = "examples/suite_metadata.robot" @mock.patch(REPORT_PORTAL_SERVICE) @@ -36,12 +35,12 @@ def test_suite_metadata_simple(mock_client_init): assert len(item_start_calls) == len(item_finish_calls) == 3 test_suite = item_start_calls[0] - assert test_suite[1]['attributes'] == [{'key': 'Author', 'value': 'John Doe'}] + assert test_suite[1]["attributes"] == [{"key": "Author", "value": "John Doe"}] @mock.patch(REPORT_PORTAL_SERVICE) def test_suite_metadata_command_line_simple(mock_client_init): - result = utils.run_robot_tests([SIMPLE_TEST], arguments={'--metadata': 'Scope:Smoke'}) + result = utils.run_robot_tests([SIMPLE_TEST], arguments={"--metadata": "Scope:Smoke"}) assert result == 0 # the test successfully passed mock_client = mock_client_init.return_value @@ -54,7 +53,7 @@ def test_suite_metadata_command_line_simple(mock_client_init): assert len(item_start_calls) == len(item_finish_calls) == 3 test_suite = item_start_calls[0] - attributes = test_suite[1]['attributes'] + attributes = test_suite[1]["attributes"] assert len(attributes) == 2 - assert {'value': 'Smoke', 'key': 'Scope'} in attributes - assert {'key': 'Author', 'value': 'John Doe'} in attributes + assert {"value": "Smoke", "key": "Scope"} in attributes + assert {"key": "Author", "value": "John Doe"} in attributes diff --git a/tests/integration/test_templates_test.py b/tests/integration/test_templates_test.py index 75e6f5c..e88f834 100644 --- a/tests/integration/test_templates_test.py +++ b/tests/integration/test_templates_test.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License.s -from delayed_assert import assert_expectations, expect from unittest import mock from tests import REPORT_PORTAL_SERVICE @@ -20,22 +19,20 @@ @mock.patch(REPORT_PORTAL_SERVICE) -def test_code_reference_template(mock_client_init, test, test_names, - code_ref_suffixes): +def test_code_reference_template(mock_client_init, test, test_names, code_ref_suffixes): result = utils.run_robot_tests([test]) assert result == 0 # the test successfully passed mock_client = mock_client_init.return_value - calls = [call for call in mock_client.start_test_item.call_args_list if - call[1]['item_type'] == 'STEP' and call[1].get('has_stats', True) - is True] + calls = [ + call + for call in mock_client.start_test_item.call_args_list + if call[1]["item_type"] == "STEP" and call[1].get("has_stats", True) is True + ] assert len(calls) == len(test_names) - for call, test_name, code_ref_suff in zip(calls, test_names, - code_ref_suffixes): - code_ref = call[1]['code_ref'] - test_case_id = call[1]['test_case_id'] - expect(test_case_id == test + ':' + test_name) - expect(code_ref == test + ':' + code_ref_suff) - - assert_expectations() + for call, test_name, code_ref_suff in zip(calls, test_names, code_ref_suffixes): + code_ref = call[1]["code_ref"] + test_case_id = call[1]["test_case_id"] + assert test_case_id == test + ":" + test_name + assert code_ref == test + ":" + code_ref_suff diff --git a/tests/integration/test_test_message.py b/tests/integration/test_test_message.py new file mode 100644 index 0000000..3a52257 --- /dev/null +++ b/tests/integration/test_test_message.py @@ -0,0 +1,64 @@ +# Copyright 2023 EPAM Systems +# +# 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 +# +# https://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.s + +import re +from unittest import mock + +import pytest + +from tests import REPORT_PORTAL_SERVICE +from tests.helpers import utils + +NO_KEYWORDS_MESSAGE_PATTERN = re.compile(r"Message:\s+Test(?: case)? (?:contains no keywords|cannot be empty)\.") +NO_KEYWORDS_DESCRIPTION_MESSAGE_PATTERN = re.compile( + r"The test case documentation.\n\n---\n\nMessage:\n\nTest(?: case)? (?:contains no keywords|cannot be empty)\." +) + + +@pytest.mark.parametrize( + "file, expected_pattern", + [ + ( + "examples/no_keywords.robot", + NO_KEYWORDS_MESSAGE_PATTERN, + ), + ( + "examples/no_keywords_description.robot", + NO_KEYWORDS_DESCRIPTION_MESSAGE_PATTERN, + ), + ], +) +@mock.patch(REPORT_PORTAL_SERVICE) +def test_no_keyword_message(mock_client_init, file, expected_pattern): + mock_client = mock_client_init.return_value + mock_client.start_test_item.side_effect = utils.item_id_gen + + result = utils.run_robot_tests([file]) + assert result == 1 + + launch_start = mock_client.start_launch.call_args_list + launch_finish = mock_client.finish_launch.call_args_list + assert len(launch_start) == len(launch_finish) == 1 + + item_start_calls = mock_client.start_test_item.call_args_list + item_finish_calls = mock_client.finish_test_item.call_args_list + assert len(item_start_calls) == len(item_finish_calls) + assert len(item_finish_calls) == 2 + + statuses = [finish[1]["status"] for finish in item_finish_calls] + assert statuses == ["FAILED", "FAILED"] + + test_finish_call = item_finish_calls[0][1] + assert "description" in test_finish_call + assert expected_pattern.match(test_finish_call["description"]) diff --git a/tests/integration/test_variables.py b/tests/integration/test_variables.py index 124f79d..1bb83e6 100644 --- a/tests/integration/test_variables.py +++ b/tests/integration/test_variables.py @@ -17,7 +17,7 @@ # noinspection PyPackageRequirements import pytest -from reportportal_client import OutputType, RPClient, ThreadedRPClient, BatchedRPClient +from reportportal_client import BatchedRPClient, OutputType, RPClient, ThreadedRPClient from tests import REPORT_PORTAL_SERVICE, REQUESTS_SERVICE from tests.helpers import utils @@ -27,44 +27,40 @@ def test_agent_pass_batch_payload_size_variable(mock_client_init): variables = utils.DEFAULT_VARIABLES.copy() payload_size = 100 - variables['RP_LOG_BATCH_PAYLOAD_SIZE'] = payload_size - result = utils.run_robot_tests(['examples/simple.robot'], - variables=variables) + variables["RP_LOG_BATCH_PAYLOAD_SIZE"] = payload_size + result = utils.run_robot_tests(["examples/simple.robot"], variables=variables) assert result == 0 # the test successfully passed - payload_variable = 'log_batch_payload_size' + payload_variable = "log_batch_payload_size" assert payload_variable in mock_client_init.call_args_list[0][1] - assert mock_client_init.call_args_list[0][1][ - payload_variable] == payload_size + assert mock_client_init.call_args_list[0][1][payload_variable] == payload_size @mock.patch(REQUESTS_SERVICE) def test_agent_pass_launch_uuid_variable(mock_requests_init): variables = utils.DEFAULT_VARIABLES.copy() - test_launch_id = 'my_test_launch' - variables['RP_LAUNCH_UUID'] = test_launch_id - result = utils.run_robot_tests(['examples/simple.robot'], - variables=variables) + test_launch_id = "my_test_launch" + variables["RP_LAUNCH_UUID"] = test_launch_id + result = utils.run_robot_tests(["examples/simple.robot"], variables=variables) assert result == 0 # the test successfully passed mock_requests = mock_requests_init.return_value assert mock_requests.post.call_count == 3 suite_start = mock_requests.post.call_args_list[0] - assert suite_start[0][0].endswith('/item') - assert suite_start[1]['json']['launchUuid'] == test_launch_id + assert suite_start[0][0].endswith("/item") + assert suite_start[1]["json"]["launchUuid"] == test_launch_id -@pytest.mark.parametrize('variable, warn_num', - [('RP_PROJECT', 1), ('RP_API_KEY', 2), - ('RP_ENDPOINT', 1), ('RP_LAUNCH', 1)]) +@pytest.mark.parametrize( + "variable, warn_num", [("RP_PROJECT", 1), ("RP_API_KEY", 2), ("RP_ENDPOINT", 1), ("RP_LAUNCH", 1)] +) @mock.patch(REPORT_PORTAL_SERVICE) def test_no_required_variable_warning(mock_client_init, variable, warn_num): variables = utils.DEFAULT_VARIABLES.copy() del variables[variable] with warnings.catch_warnings(record=True) as w: - result = utils.run_robot_tests(['examples/simple.robot'], - variables=variables) + result = utils.run_robot_tests(["examples/simple.robot"], variables=variables) assert result == 0 # the test successfully passed assert len(w) == warn_num @@ -78,87 +74,77 @@ def test_no_required_variable_warning(mock_client_init, variable, warn_num): def filter_agent_call(warn): - category = getattr(warn, 'category', None) + category = getattr(warn, "category", None) if category: - return category.__name__ == 'DeprecationWarning' \ - or category.__name__ == 'RuntimeWarning' + return category.__name__ == "DeprecationWarning" or category.__name__ == "RuntimeWarning" return False def filter_agent_calls(warning_list): - return list( - filter( - lambda call: filter_agent_call(call), - warning_list - ) - ) + return list(filter(lambda call: filter_agent_call(call), warning_list)) @mock.patch(REPORT_PORTAL_SERVICE) def test_rp_api_key(mock_client_init): - api_key = 'rp_api_key' + api_key = "rp_api_key" variables = dict(utils.DEFAULT_VARIABLES) - variables.update({'RP_API_KEY': api_key}.items()) + variables.update({"RP_API_KEY": api_key}.items()) with warnings.catch_warnings(record=True) as w: - result = utils.run_robot_tests(['examples/simple.robot'], - variables=variables) - assert int(result) == 0, 'Exit code should be 0 (no errors)' + result = utils.run_robot_tests(["examples/simple.robot"], variables=variables) + assert int(result) == 0, "Exit code should be 0 (no errors)" assert mock_client_init.call_count == 1 constructor_args = mock_client_init.call_args_list[0][1] - assert constructor_args['api_key'] == api_key + assert constructor_args["api_key"] == api_key assert len(filter_agent_calls(w)) == 0 @mock.patch(REPORT_PORTAL_SERVICE) def test_rp_uuid(mock_client_init): - api_key = 'rp_api_key' + api_key = "rp_api_key" variables = dict(utils.DEFAULT_VARIABLES) - del variables['RP_API_KEY'] - variables.update({'RP_UUID': api_key}.items()) + del variables["RP_API_KEY"] + variables.update({"RP_UUID": api_key}.items()) with warnings.catch_warnings(record=True) as w: - result = utils.run_robot_tests(['examples/simple.robot'], - variables=variables) - assert int(result) == 0, 'Exit code should be 0 (no errors)' + result = utils.run_robot_tests(["examples/simple.robot"], variables=variables) + assert int(result) == 0, "Exit code should be 0 (no errors)" assert mock_client_init.call_count == 1 constructor_args = mock_client_init.call_args_list[0][1] - assert constructor_args['api_key'] == api_key + assert constructor_args["api_key"] == api_key assert len(filter_agent_calls(w)) == 1 @mock.patch(REPORT_PORTAL_SERVICE) def test_rp_api_key_priority(mock_client_init): - api_key = 'rp_api_key' + api_key = "rp_api_key" variables = dict(utils.DEFAULT_VARIABLES) - variables.update({'RP_API_KEY': api_key, 'RP_UUID': 'rp_uuid'}.items()) + variables.update({"RP_API_KEY": api_key, "RP_UUID": "rp_uuid"}.items()) with warnings.catch_warnings(record=True) as w: - result = utils.run_robot_tests(['examples/simple.robot'], - variables=variables) - assert int(result) == 0, 'Exit code should be 0 (no errors)' + result = utils.run_robot_tests(["examples/simple.robot"], variables=variables) + assert int(result) == 0, "Exit code should be 0 (no errors)" assert mock_client_init.call_count == 1 constructor_args = mock_client_init.call_args_list[0][1] - assert constructor_args['api_key'] == api_key + assert constructor_args["api_key"] == api_key assert len(filter_agent_calls(w)) == 0 @mock.patch(REPORT_PORTAL_SERVICE) def test_rp_api_key_empty(mock_client_init): - api_key = '' + api_key = "" variables = dict(utils.DEFAULT_VARIABLES) - variables.update({'RP_API_KEY': api_key}.items()) + variables.update({"RP_API_KEY": api_key}.items()) with warnings.catch_warnings(record=True) as w: - result = utils.run_robot_tests(['examples/simple.robot'], - variables=variables) - assert int(result) == 0, 'Exit code should be 0 (no errors)' + result = utils.run_robot_tests(["examples/simple.robot"], variables=variables) + assert int(result) == 0, "Exit code should be 0 (no errors)" assert mock_client_init.call_count == 0 assert len(filter_agent_calls(w)) == 2 @@ -168,43 +154,39 @@ def test_rp_api_key_empty(mock_client_init): def test_launch_uuid_print(mock_client_init): print_uuid = True variables = utils.DEFAULT_VARIABLES.copy() - variables.update({'RP_LAUNCH_UUID_PRINT': str(print_uuid)}.items()) + variables.update({"RP_LAUNCH_UUID_PRINT": str(print_uuid)}.items()) - result = utils.run_robot_tests(['examples/simple.robot'], - variables=variables) + result = utils.run_robot_tests(["examples/simple.robot"], variables=variables) - assert int(result) == 0, 'Exit code should be 0 (no errors)' + assert int(result) == 0, "Exit code should be 0 (no errors)" assert mock_client_init.call_count == 1 - assert mock_client_init.call_args_list[0][1]['launch_uuid_print'] == print_uuid - assert mock_client_init.call_args_list[0][1]['print_output'] is None + assert mock_client_init.call_args_list[0][1]["launch_uuid_print"] == print_uuid + assert mock_client_init.call_args_list[0][1]["print_output"] is None @mock.patch(REPORT_PORTAL_SERVICE) def test_launch_uuid_print_stderr(mock_client_init): print_uuid = True variables = utils.DEFAULT_VARIABLES.copy() - variables.update( - {'RP_LAUNCH_UUID_PRINT': str(print_uuid), 'RP_LAUNCH_UUID_PRINT_OUTPUT': 'stderr'}.items()) + variables.update({"RP_LAUNCH_UUID_PRINT": str(print_uuid), "RP_LAUNCH_UUID_PRINT_OUTPUT": "stderr"}.items()) - result = utils.run_robot_tests(['examples/simple.robot'], variables=variables) + result = utils.run_robot_tests(["examples/simple.robot"], variables=variables) - assert int(result) == 0, 'Exit code should be 0 (no errors)' + assert int(result) == 0, "Exit code should be 0 (no errors)" assert mock_client_init.call_count == 1 - assert mock_client_init.call_args_list[0][1]['launch_uuid_print'] == print_uuid - assert mock_client_init.call_args_list[0][1]['print_output'] is OutputType.STDERR + assert mock_client_init.call_args_list[0][1]["launch_uuid_print"] == print_uuid + assert mock_client_init.call_args_list[0][1]["print_output"] is OutputType.STDERR @mock.patch(REPORT_PORTAL_SERVICE) def test_launch_uuid_print_invalid_output(mock_client_init): print_uuid = True variables = utils.DEFAULT_VARIABLES.copy() - variables.update({'RP_LAUNCH_UUID_PRINT': str(print_uuid), - 'RP_LAUNCH_UUID_PRINT_OUTPUT': 'something'}.items()) + variables.update({"RP_LAUNCH_UUID_PRINT": str(print_uuid), "RP_LAUNCH_UUID_PRINT_OUTPUT": "something"}.items()) - result = utils.run_robot_tests(['examples/simple.robot'], - variables=variables) + result = utils.run_robot_tests(["examples/simple.robot"], variables=variables) - assert int(result) == 0, 'Exit code should be 0 (no errors)' + assert int(result) == 0, "Exit code should be 0 (no errors)" assert mock_client_init.call_count == 0 @@ -212,29 +194,28 @@ def test_launch_uuid_print_invalid_output(mock_client_init): def test_no_launch_uuid_print(mock_client_init): variables = utils.DEFAULT_VARIABLES.copy() - result = utils.run_robot_tests(['examples/simple.robot'], variables=variables) + result = utils.run_robot_tests(["examples/simple.robot"], variables=variables) - assert int(result) == 0, 'Exit code should be 0 (no errors)' + assert int(result) == 0, "Exit code should be 0 (no errors)" assert mock_client_init.call_count == 1 - assert mock_client_init.call_args_list[0][1]['launch_uuid_print'] is False - assert mock_client_init.call_args_list[0][1]['print_output'] is None + assert mock_client_init.call_args_list[0][1]["launch_uuid_print"] is False + assert mock_client_init.call_args_list[0][1]["print_output"] is None @pytest.mark.parametrize( - 'variable_value, expected_type', - [('SYNC', RPClient), ('ASYNC_THREAD', ThreadedRPClient), - ('ASYNC_BATCHED', BatchedRPClient), (None, RPClient)] + "variable_value, expected_type", + [("SYNC", RPClient), ("ASYNC_THREAD", ThreadedRPClient), ("ASYNC_BATCHED", BatchedRPClient), (None, RPClient)], ) -@mock.patch('reportportal_client.aio.client.Client') +@mock.patch("reportportal_client.aio.client.Client") @mock.patch(REPORT_PORTAL_SERVICE) def test_client_types(mock_client_init, mock_async_client_init, variable_value, expected_type): variables = utils.DEFAULT_VARIABLES.copy() if variable_value: - variables['RP_CLIENT_TYPE'] = variable_value + variables["RP_CLIENT_TYPE"] = variable_value - result = utils.run_robot_tests(['examples/simple.robot'], variables=variables) + result = utils.run_robot_tests(["examples/simple.robot"], variables=variables) - assert int(result) == 0, 'Exit code should be 0 (no errors)' + assert int(result) == 0, "Exit code should be 0 (no errors)" if expected_type is RPClient: assert mock_async_client_init.call_count == 0 assert mock_client_init.call_count == 1 @@ -244,25 +225,19 @@ def test_client_types(mock_client_init, mock_async_client_init, variable_value, @pytest.mark.parametrize( - 'connect_value, read_value, expected_result', - [ - ('5', '15', (5.0, 15.0)), - ('5.5', '15.5', (5.5, 15.5)), - (None, None, None), - (None, '5', 5), - ('5', None, 5) - ] + "connect_value, read_value, expected_result", + [("5", "15", (5.0, 15.0)), ("5.5", "15.5", (5.5, 15.5)), (None, None, None), (None, "5", 5), ("5", None, 5)], ) @mock.patch(REPORT_PORTAL_SERVICE) def test_client_timeouts(mock_client_init, connect_value, read_value, expected_result): variables = utils.DEFAULT_VARIABLES.copy() if connect_value: - variables['RP_CONNECT_TIMEOUT'] = connect_value + variables["RP_CONNECT_TIMEOUT"] = connect_value if read_value: - variables['RP_READ_TIMEOUT'] = read_value + variables["RP_READ_TIMEOUT"] = read_value - result = utils.run_robot_tests(['examples/simple.robot'], variables=variables) + result = utils.run_robot_tests(["examples/simple.robot"], variables=variables) - assert int(result) == 0, 'Exit code should be 0 (no errors)' + assert int(result) == 0, "Exit code should be 0 (no errors)" assert mock_client_init.call_count == 1 - assert mock_client_init.call_args_list[0][1]['http_timeout'] == expected_result + assert mock_client_init.call_args_list[0][1]["http_timeout"] == expected_result diff --git a/tests/integration/test_wuks_keyword.py b/tests/integration/test_wuks_keyword.py index 014e39f..0eb41bd 100644 --- a/tests/integration/test_wuks_keyword.py +++ b/tests/integration/test_wuks_keyword.py @@ -19,11 +19,11 @@ @mock.patch(REPORT_PORTAL_SERVICE) -def test_before_after_suite_with_steps(mock_client_init): +def test_wuks_keyword(mock_client_init): mock_client = mock_client_init.return_value mock_client.start_test_item.side_effect = utils.item_id_gen - result = utils.run_robot_tests(['examples/wuks_keyword.robot']) + result = utils.run_robot_tests(["examples/wuks_keyword.robot"]) assert result == 0 launch_start = mock_client.start_launch.call_args_list @@ -32,8 +32,17 @@ def test_before_after_suite_with_steps(mock_client_init): item_start_calls = mock_client.start_test_item.call_args_list item_finish_calls = mock_client.finish_test_item.call_args_list - assert len(item_start_calls) == len(item_finish_calls) == 13 - - statuses = [finish[1]['status'] for finish in item_finish_calls] - assert statuses == ['PASSED'] * 2 + ['FAILED'] * 3 + ['PASSED'] * 2 + [ - 'SKIPPED'] * 2 + ['PASSED'] * 4 + assert len(item_start_calls) == len(item_finish_calls) + assert len(item_finish_calls) == 18 + + statuses = [finish[1]["status"] for finish in item_finish_calls] + assert ( + statuses + == ["PASSED"] * 2 + + ["FAILED"] * 3 + + ["PASSED"] * 2 + + ["FAILED"] * 3 + + ["PASSED"] * 2 + + ["SKIPPED"] * 2 + + ["PASSED"] * 4 + ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 54d3146..8b7b852 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -13,31 +13,32 @@ See the License for the specific language governing permissions and limitations under the License """ -import os -from pytest import fixture +import os from unittest import mock +import pytest + from robotframework_reportportal.listener import listener -from robotframework_reportportal.variables import Variables from robotframework_reportportal.result_visitor import RobotResultsVisitor +from robotframework_reportportal.variables import Variables -@fixture() +@pytest.fixture def visitor(): return RobotResultsVisitor() -@mock.patch('robotframework_reportportal.variables.strtobool', mock.Mock()) -@mock.patch('robotframework_reportportal.variables.get_variable', mock.Mock()) -@fixture() +@mock.patch("robotframework_reportportal.variables.strtobool", mock.Mock()) +@mock.patch("robotframework_reportportal.variables.get_variable", mock.Mock()) +@pytest.fixture def mock_variables(): mock_variables = Variables() - mock_variables.endpoint = 'http://localhost:8080' - mock_variables.launch_name = 'Robot' - mock_variables.project = 'default_personal' - mock_variables.api_key = 'test_api_key' - mock_variables.launch_attributes = '' + mock_variables.endpoint = "http://localhost:8080" + mock_variables.launch_name = "Robot" + mock_variables.project = "default_personal" + mock_variables.api_key = "test_api_key" + mock_variables.launch_attributes = "" mock_variables.launch_id = None mock_variables.launch_doc = None mock_variables.log_batch_size = 1 @@ -52,51 +53,51 @@ def mock_variables(): return mock_variables -@fixture() +@pytest.fixture def mock_listener(mock_variables): mock_listener = listener() mock_listener._variables = mock_variables return mock_listener -@fixture() +@pytest.fixture def kwd_attributes(): """Keyword attributes.""" return { - 'args': ('Kw Body Start',), - 'assign': (), - 'doc': 'Logs the given message with the given level.', - 'kwname': 'Log', - 'libname': 'BuiltIn', - 'starttime': '1621947055434', - 'tags': [], - 'type': 'Keyword' + "args": ("Kw Body Start",), + "assign": (), + "doc": "Logs the given message with the given level.", + "kwname": "Log", + "libname": "BuiltIn", + "starttime": "1621947055434", + "tags": [], + "type": "Keyword", } -@fixture() +@pytest.fixture def suite_attributes(): return { - 'id': 's1', - 'doc': '', - 'longname': 'Suite', - 'metadata': {}, - 'source': os.getcwd() + '/robot/test.robot', - 'suites': [], - 'tests': ['Test'], - 'starttime': '20210407 12:24:27.116', - 'totaltests': 1 + "id": "s1", + "doc": "", + "longname": "Suite", + "metadata": {}, + "source": os.getcwd() + "/robot/test.robot", + "suites": [], + "tests": ["Test"], + "starttime": "20210407 12:24:27.116", + "totaltests": 1, } -@fixture() +@pytest.fixture def test_attributes(): return { - 'id': 's1-t1', - 'doc': '', - 'longname': 'Suite.Test', - 'tags': [], - 'source': os.getcwd() + '/robot/test.robot', - 'template': '', - 'starttime': '20210407 12:24:27.116' + "id": "s1-t1", + "doc": "", + "longname": "Suite.Test", + "tags": [], + "source": os.getcwd() + "/robot/test.robot", + "template": "", + "starttime": "20210407 12:24:27.116", } diff --git a/tests/unit/test_listener.py b/tests/unit/test_listener.py index 1a4184e..77bb691 100644 --- a/tests/unit/test_listener.py +++ b/tests/unit/test_listener.py @@ -13,9 +13,10 @@ limitations under the License """ -import pytest from unittest import mock +import pytest + from robotframework_reportportal.listener import listener from tests import REPORT_PORTAL_SERVICE @@ -23,144 +24,121 @@ class TestListener: @mock.patch(REPORT_PORTAL_SERVICE) - def test_code_ref(self, mock_client_init, mock_listener, - test_attributes): - mock_listener.start_test('Test', test_attributes) + def test_code_ref(self, mock_client_init, mock_listener, test_attributes): + mock_listener.start_test("Test", test_attributes) mock_client = mock_client_init.return_value assert mock_client.start_test_item.call_count == 1 args, kwargs = mock_client.start_test_item.call_args - assert (kwargs['code_ref'] == - '{0}:{1}'.format('robot/test.robot', 'Test')) + assert kwargs["code_ref"] == "{0}:{1}".format("robot/test.robot", "Test") # Robot Framework of versions < 4 does not bypass 'source' attribute on # 'start_test' method call @mock.patch(REPORT_PORTAL_SERVICE) - def test_code_ref_robot_3_2_2(self, mock_client_init, mock_listener, - suite_attributes, test_attributes): + def test_code_ref_robot_3_2_2(self, mock_client_init, mock_listener, suite_attributes, test_attributes): test_attributes = test_attributes.copy() - del test_attributes['source'] - mock_listener.start_suite('Suite', suite_attributes) - mock_listener.start_test('Test', test_attributes) + del test_attributes["source"] + mock_listener.start_suite("Suite", suite_attributes) + mock_listener.start_test("Test", test_attributes) mock_client = mock_client_init.return_value assert mock_client.start_test_item.call_count == 2 args, kwargs = mock_client.start_test_item.call_args - assert (kwargs['code_ref'] == - '{0}:{1}'.format('robot/test.robot', 'Test')) + assert kwargs["code_ref"] == "{0}:{1}".format("robot/test.robot", "Test") @mock.patch(REPORT_PORTAL_SERVICE) - def test_code_ref_robot_3_2_2_no_source_in_parent(self, mock_client_init, - mock_listener, - test_attributes): + def test_code_ref_robot_3_2_2_no_source_in_parent(self, mock_client_init, mock_listener, test_attributes): test_attributes = test_attributes.copy() - del test_attributes['source'] - mock_listener.start_test('Test', test_attributes) + del test_attributes["source"] + mock_listener.start_test("Test", test_attributes) mock_client = mock_client_init.return_value assert mock_client.start_test_item.call_count == 1 args, kwargs = mock_client.start_test_item.call_args - assert (kwargs['code_ref'] == '{0}:{1}'.format(None, 'Test')) + assert kwargs["code_ref"] == "{0}:{1}".format(None, "Test") @mock.patch(REPORT_PORTAL_SERVICE) - def test_suite_no_source_attribute(self, mock_client_init, mock_listener, - suite_attributes, test_attributes): + def test_suite_no_source_attribute(self, mock_client_init, mock_listener, suite_attributes, test_attributes): suite_attributes = suite_attributes.copy() - del suite_attributes['source'] - del test_attributes['source'] - mock_listener.start_suite('Suite', suite_attributes) - mock_listener.start_test('Test', test_attributes) + del suite_attributes["source"] + del test_attributes["source"] + mock_listener.start_suite("Suite", suite_attributes) + mock_listener.start_test("Test", test_attributes) mock_client = mock_client_init.return_value assert mock_client.start_test_item.call_count == 2 args, kwargs = mock_client.start_test_item.call_args - assert (kwargs['code_ref'] == '{0}:{1}'.format(None, 'Test')) + assert kwargs["code_ref"] == "{0}:{1}".format(None, "Test") @mock.patch(REPORT_PORTAL_SERVICE) - def test_critical_test_failure(self, mock_client_init, mock_listener, - test_attributes): - mock_listener.start_test('Test', test_attributes) - test_attributes['status'] = 'FAIL' - mock_listener.end_test('Test', test_attributes) + def test_critical_test_failure(self, mock_client_init, mock_listener, test_attributes): + mock_listener.start_test("Test", test_attributes) + test_attributes["status"] = "FAIL" + mock_listener.end_test("Test", test_attributes) mock_client = mock_client_init.return_value assert mock_client.finish_test_item.call_count == 1 args, kwargs = mock_client.finish_test_item.call_args - assert kwargs['status'] == 'FAILED' + assert kwargs["status"] == "FAILED" @mock.patch(REPORT_PORTAL_SERVICE) - def test_dynamic_attributes(self, mock_client_init, mock_listener, - test_attributes): - test_attributes['tags'] = ['simple'] - mock_listener.start_test('Test', test_attributes) - test_attributes['tags'] = ['simple', 'SLID:12345'] - test_attributes['status'] = 'PASS' - mock_listener.end_test('Test', test_attributes) + def test_dynamic_attributes(self, mock_client_init, mock_listener, test_attributes): + test_attributes["tags"] = ["simple"] + mock_listener.start_test("Test", test_attributes) + test_attributes["tags"] = ["simple", "SLID:12345"] + test_attributes["status"] = "PASS" + mock_listener.end_test("Test", test_attributes) mock_client = mock_client_init.return_value assert mock_client.start_test_item.call_count == 1 assert mock_client.finish_test_item.call_count == 1 args, kwargs = mock_client.start_test_item.call_args - assert kwargs['attributes'] == [{'value': 'simple'}] + assert kwargs["attributes"] == [{"value": "simple"}] args, kwargs = mock_client.finish_test_item.call_args - assert kwargs['attributes'] == [{'value': 'simple'}, - {'key': 'SLID', 'value': '12345'}] + assert kwargs["attributes"] == [{"value": "simple"}, {"key": "SLID", "value": "12345"}] @mock.patch(REPORT_PORTAL_SERVICE) - @pytest.mark.parametrize('critical, expected_status', [ - (True, 'FAILED'), ('yes', 'FAILED'), ('no', 'SKIPPED')]) - def test_non_critical_test_skip( - self, mock_client_init, mock_listener, - test_attributes, critical, expected_status): - test_attributes['critical'] = critical - mock_listener.start_test('Test', test_attributes) - test_attributes['status'] = 'FAIL' - mock_listener.end_test('Test', test_attributes) + @pytest.mark.parametrize("critical, expected_status", [(True, "FAILED"), ("yes", "FAILED"), ("no", "SKIPPED")]) + def test_non_critical_test_skip(self, mock_client_init, mock_listener, test_attributes, critical, expected_status): + test_attributes["critical"] = critical + mock_listener.start_test("Test", test_attributes) + test_attributes["status"] = "FAIL" + mock_listener.end_test("Test", test_attributes) mock_client = mock_client_init.return_value assert mock_client.finish_test_item.call_count == 1 args, kwargs = mock_client.finish_test_item.call_args - assert kwargs['status'] == expected_status + assert kwargs["status"] == expected_status @mock.patch(REPORT_PORTAL_SERVICE) - @pytest.mark.parametrize('skipped_issue_value', [True, False]) - def test_skipped_issue_variable_bypass(self, mock_client_init, - mock_variables, - skipped_issue_value): + @pytest.mark.parametrize("skipped_issue_value", [True, False]) + def test_skipped_issue_variable_bypass(self, mock_client_init, mock_variables, skipped_issue_value): mock_variables.skipped_issue = skipped_issue_value mock_listener = listener() mock_listener._variables = mock_variables _ = mock_listener.service assert mock_client_init.call_count == 1 args, kwargs = mock_client_init.call_args - assert kwargs['is_skipped_an_issue'] == skipped_issue_value + assert kwargs["is_skipped_an_issue"] == skipped_issue_value - @mock.patch('robotframework_reportportal.variables.get_variable') + @mock.patch("robotframework_reportportal.variables.get_variable") @mock.patch(REPORT_PORTAL_SERVICE) @pytest.mark.parametrize( - 'get_var_value, verify_ssl_value, path_exists_value', [ - ('True', True, False), - ('False', False, False), - ('/path/to/cert', '/path/to/cert', True) - ]) - def test_verify_ssl_variable_bypass(self, - mock_client_init, - get_var_mock, - mock_variables, - get_var_value, - verify_ssl_value, - path_exists_value): + "get_var_value, verify_ssl_value, path_exists_value", + [("True", True, False), ("False", False, False), ("/path/to/cert", "/path/to/cert", True)], + ) + def test_verify_ssl_variable_bypass( + self, mock_client_init, get_var_mock, mock_variables, get_var_value, verify_ssl_value, path_exists_value + ): """Test case for the RP_VERIFY_SSL bypass.""" get_var_mock.return_value = get_var_value - with mock.patch('robotframework_reportportal.variables.path.exists', - return_value=path_exists_value): + with mock.patch("robotframework_reportportal.variables.path.exists", return_value=path_exists_value): mock_listener = listener() mock_listener._variables = mock_variables _ = mock_listener.service assert mock_client_init.call_count == 1 args, kwargs = mock_client_init.call_args - assert kwargs['verify_ssl'] == verify_ssl_value + assert kwargs["verify_ssl"] == verify_ssl_value @mock.patch(REPORT_PORTAL_SERVICE) - def test_test_case_id(self, mock_client_init, mock_listener, - test_attributes): - test_attributes['tags'] = ['simple', 'test_case_id:12345'] - mock_listener.start_test('Test', test_attributes) + def test_test_case_id(self, mock_client_init, mock_listener, test_attributes): + test_attributes["tags"] = ["simple", "test_case_id:12345"] + mock_listener.start_test("Test", test_attributes) mock_client = mock_client_init.return_value assert mock_client.start_test_item.call_count == 1 args, kwargs = mock_client.start_test_item.call_args - assert kwargs['test_case_id'] == '12345' - assert kwargs['attributes'] == [{'value': 'simple'}] + assert kwargs["test_case_id"] == "12345" + assert kwargs["attributes"] == [{"value": "simple"}] diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py index 11271b5..648b4de 100644 --- a/tests/unit/test_logger.py +++ b/tests/unit/test_logger.py @@ -13,67 +13,64 @@ limitations under the License """ -import pytest -from delayed_assert import assert_expectations, expect from unittest import mock +import pytest + from robotframework_reportportal import logger -ATTACHMENT = {'name': 'test_screenshot.png', 'data': b'x0x0', - 'mime': 'image/png'} +ATTACHMENT = {"name": "test_screenshot.png", "data": b"x0x0", "mime": "image/png"} TEST_DATA_METHODS = [ - ('trace', ['Test', False, None, False]), - ('trace', [None, True, None, False]), - ('trace', [None, False, ATTACHMENT, False]), - ('trace', [None, False, None, True]), - ('debug', ['Test', False, None, False]), - ('debug', [None, True, None, False]), - ('debug', [None, False, ATTACHMENT, False]), - ('debug', [None, False, None, True]), - ('warn', ['Test', False, None, False]), - ('warn', [None, True, None, False]), - ('warn', [None, False, ATTACHMENT, False]), - ('warn', [None, False, None, True]), - ('error', ['Test', False, None, False]), - ('error', [None, True, None, False]), - ('error', [None, False, ATTACHMENT, False]), - ('error', [None, False, None, True]), - ('info', ['Test', False, False, None, False]), - ('info', [None, True, False, None, False]), - ('info', [None, False, True, None, False]), - ('info', [None, False, False, ATTACHMENT, False]), - ('info', [None, False, False, None, True]), + ("trace", ["Test", False, None, False]), + ("trace", [None, True, None, False]), + ("trace", [None, False, ATTACHMENT, False]), + ("trace", [None, False, None, True]), + ("debug", ["Test", False, None, False]), + ("debug", [None, True, None, False]), + ("debug", [None, False, ATTACHMENT, False]), + ("debug", [None, False, None, True]), + ("warn", ["Test", False, None, False]), + ("warn", [None, True, None, False]), + ("warn", [None, False, ATTACHMENT, False]), + ("warn", [None, False, None, True]), + ("error", ["Test", False, None, False]), + ("error", [None, True, None, False]), + ("error", [None, False, ATTACHMENT, False]), + ("error", [None, False, None, True]), + ("info", ["Test", False, False, None, False]), + ("info", [None, True, False, None, False]), + ("info", [None, False, True, None, False]), + ("info", [None, False, False, ATTACHMENT, False]), + ("info", [None, False, False, None, True]), ] -@pytest.mark.parametrize('method, params', TEST_DATA_METHODS) -@mock.patch('robotframework_reportportal.logger.logger') +@pytest.mark.parametrize("method, params", TEST_DATA_METHODS) +@mock.patch("robotframework_reportportal.logger.logger") def test_logger_params_bypass(mock_logger, method, params): getattr(logger, method)(*params) assert mock_logger.write.call_count == 1 - if method == 'info': + if method == "info": attachment = params[3] launch_log = params[4] else: attachment = params[2] launch_log = params[3] - if method == 'info' and params[2]: - expect(mock_logger.console.call_count == 1) - expect(mock_logger.console.call_args[0][0] == params[0]) - expect(mock_logger.console.call_args[0][1] is True) - expect(mock_logger.console.call_args[0][2] == 'stdout') + if method == "info" and params[2]: + assert mock_logger.console.call_count == 1 + assert mock_logger.console.call_args[0][0] == params[0] + assert mock_logger.console.call_args[0][1] is True + assert mock_logger.console.call_args[0][2] == "stdout" else: - expect(mock_logger.console.call_count == 0) + assert mock_logger.console.call_count == 0 message = mock_logger.write.call_args[0][0] - expect(message.level == method.upper()) - expect(message.message == params[0]) - expect(message.attachment == attachment) - expect(message.launch_log == launch_log) - expect(mock_logger.write.call_args[0][1] == method.upper()) - expect(mock_logger.write.call_args[0][2] == params[1]) - - assert_expectations() + assert message.level == method.upper() + assert message.message == params[0] + assert message.attachment == attachment + assert message.launch_log == launch_log + assert mock_logger.write.call_args[0][1] == method.upper() + assert mock_logger.write.call_args[0][2] == params[1] diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py index 12a8cc7..680b413 100644 --- a/tests/unit/test_model.py +++ b/tests/unit/test_model.py @@ -12,20 +12,26 @@ limitations under the License. """ +from unittest import mock + import pytest from robotframework_reportportal.model import Keyword -@pytest.mark.parametrize('self_type, parent_type, expected', [ - ('SETUP', 'KEYWORD', 'STEP'), - ('SETUP', 'TEST', 'BEFORE_TEST'), - ('TEARDOWN', 'SUITE', 'AFTER_SUITE'), - ('TEST', 'SUITE', 'STEP') -]) +@pytest.mark.parametrize( + "self_type, parent_type, expected", + [ + ("SETUP", "KEYWORD", "STEP"), + ("SETUP", "TEST", "BEFORE_TEST"), + ("TEARDOWN", "SUITE", "AFTER_SUITE"), + ("TEST", "SUITE", "STEP"), + ], +) def test_keyword_get_type(kwd_attributes, self_type, parent_type, expected): """Test for the get_type() method of the Keyword model.""" - kwd = Keyword(name='Test keyword', robot_attributes=kwd_attributes, - parent_type=parent_type) + parent = mock.Mock() + parent.type = parent_type + kwd = Keyword(name="Test keyword", robot_attributes=kwd_attributes, parent=parent) kwd.keyword_type = self_type assert kwd.get_type() == expected diff --git a/tests/unit/test_result_visitor.py b/tests/unit/test_result_visitor.py index 3ebaef2..623c170 100644 --- a/tests/unit/test_result_visitor.py +++ b/tests/unit/test_result_visitor.py @@ -14,6 +14,7 @@ """ import sys + import pytest from robotframework_reportportal.result_visitor import to_timestamp @@ -22,38 +23,38 @@ def test_parse_message_no_img_tag(visitor): with pytest.raises(AttributeError): - visitor.parse_message('usual test comment without image') + visitor.split_message_and_image("usual test comment without image") def test_parse_message_bad_img_tag(visitor): with pytest.raises(AttributeError): - visitor.parse_message('') + visitor.split_message_and_image("') + assert ('src="any.png"', "any.png") == visitor.split_message_and_image('') def test_parse_message_contains_image_with_space(visitor): - assert ['src="any%20image.png"', 'any image.png'] == \ - visitor.parse_message('') + assert ('src="any%20image.png"', "any image.png") == visitor.split_message_and_image( + '' + ) TIMESTAMP_TEST_CASES = [ - ('20240920 00:00:00.000', '+3:00', '1726779600000'), - ('20240919 18:00:00.000', '-3:00', '1726779600000') + ("20240920 00:00:00.000", "+3:00", "1726779600000"), + ("20240919 18:00:00.000", "-3:00", "1726779600000"), ] if sys.version_info >= (3, 9): TIMESTAMP_TEST_CASES += [ - ('20240919 23:00:00.000', 'Europe/Warsaw', '1726779600000'), - ('20240920 00:00:00.000', 'UTC', '1726790400000'), - ('20240919 19:00:00.000', 'EST', '1726790400000') + ("20240919 23:00:00.000", "Europe/Warsaw", "1726779600000"), + ("20240920 00:00:00.000", "UTC", "1726790400000"), + ("20240919 19:00:00.000", "EST", "1726790400000"), ] -@pytest.mark.parametrize('time_str, time_shift, expected', TIMESTAMP_TEST_CASES) +@pytest.mark.parametrize("time_str, time_shift, expected", TIMESTAMP_TEST_CASES) def test_time_stamp_conversion(time_str, time_shift, expected): - _variables['RP_TIME_ZONE_OFFSET'] = time_shift + _variables["RP_TIME_ZONE_OFFSET"] = time_shift assert to_timestamp(time_str) == expected diff --git a/tox.ini b/tox.ini index 08b1aa5..cbf67a8 100644 --- a/tox.ini +++ b/tox.ini @@ -2,12 +2,12 @@ isolated_build = True envlist = pep - py37 py38 py39 py310 py311 - py311 + py312 + py313 [testenv] deps = @@ -26,9 +26,9 @@ commands = pre-commit run --all-files --show-diff-on-failure [gh-actions] python = - 3.7: py37 - 3.8: pep, py38 + 3.8: py38 3.9: py39 - 3.10: py310 + 3.10: pep, py310 3.11: py311 3.12: py312 + 3.13: py313