├── .github
└── workflows
│ ├── run-tests
│ └── action.yml
│ └── test.yml
├── .gitignore
├── DEVGUIDE.md
├── LICENSE
├── README.md
├── docs
├── OxygenLibrary-0.1.html
├── OxygenLibrary-0.2.html
├── OxygenLibrary-0.3.html
└── index.html
├── example
├── __init__.py
├── my_parser.py
└── my_parser_tests.robot
├── handler_result_specification.md
├── requirements.txt
├── setup.py
├── src
└── oxygen
│ ├── __init__.py
│ ├── __main__.py
│ ├── base_handler.py
│ ├── config.py
│ ├── config.yml
│ ├── config_original.yml
│ ├── errors.py
│ ├── gatling.py
│ ├── junit.py
│ ├── oxygen.py
│ ├── oxygen_handler_result.py
│ ├── robot3_interface.py
│ ├── robot4_interface.py
│ ├── robot_interface.py
│ ├── utils.py
│ ├── version.py
│ └── zap.py
├── tasks.py
├── tests
├── __init__.py
├── atest
│ ├── metadata.robot
│ ├── oxygen_junit_tests.robot
│ ├── test.robot
│ ├── test_with_import.robot
│ └── test_without_import.robot
├── resources
│ ├── big.xml
│ ├── example_robot_output.xml
│ ├── gatling-example-simulation.log
│ ├── green-junit-example.xml
│ ├── green-junit-expected-robot-output.xml
│ ├── junit-single-testsuite.xml
│ ├── junit.xml
│ ├── my_dummy_handlers
│ │ ├── __init__.py
│ │ ├── dummy_handler_default_params.py
│ │ ├── dummy_handler_metadata.py
│ │ ├── dummy_handler_multiple_args.py
│ │ ├── dummy_handler_multiple_args_too_few.py
│ │ └── dummy_handler_single_arg.py
│ └── zap
│ │ ├── zap.json
│ │ ├── zap.md
│ │ ├── zap.xml
│ │ ├── zap.xml.lol
│ │ └── zap_pp.json
└── utest
│ ├── __init__.py
│ ├── base_handler
│ ├── __init__.py
│ ├── test_errors.py
│ ├── test_inject_suite_report.py
│ ├── test_normalize_keyword_name.py
│ └── test_set_suite_tags.py
│ ├── gatling
│ ├── __init__.py
│ └── test_basic_functionality.py
│ ├── helpers.py
│ ├── junit
│ ├── __init__.py
│ └── test_basic_functionality.py
│ ├── my_dummy_handler
│ ├── __init__.py
│ └── test_basic_functionality.py
│ ├── oxygen
│ ├── __init__.py
│ ├── test_oxygen_cli.py
│ ├── test_oxygen_config_file.py
│ ├── test_oxygen_core.py
│ ├── test_oxygen_listener.py
│ ├── test_oxygen_visitor.py
│ └── test_oxygenlibrary.py
│ ├── oxygen_handler_result
│ ├── __init__.py
│ ├── shared_tests.py
│ ├── test_OxygenKeywordDict.py
│ ├── test_OxygenSuiteDict.py
│ ├── test_OxygenTestCaseDict.py
│ └── test_deprecation_warning.py
│ ├── robot_interface
│ ├── __init__.py
│ ├── test_robot_interface_basic_usage.py
│ └── test_time_conversions.py
│ └── zap
│ ├── __init__.py
│ ├── test_basic_functionality.py
│ ├── test_parse_zap_alert_dict.py
│ ├── test_parse_zap_dict.py
│ ├── test_parse_zap_instance.py
│ ├── test_parse_zap_site_dict.py
│ ├── test_xml_to_dict.py
│ └── test_zap_cli.py
└── tox.ini
/.github/workflows/run-tests/action.yml:
--------------------------------------------------------------------------------
1 | name: 'Run tests'
2 | inputs:
3 | python-version:
4 | required: true
5 | rf-version:
6 | required: true
7 | terminal:
8 | required: true
9 | runs:
10 | using: "composite"
11 | steps:
12 | - name: Install Python
13 | uses: actions/setup-python@v4
14 | with:
15 | python-version: ${{ inputs.python-version }}
16 | - name: Install dependencies
17 | shell: ${{ inputs.terminal }}
18 | run: |
19 | pip install -r requirements.txt
20 | pip install robotframework==${{ inputs.rf-version }}
21 | - name: Run tests
22 | shell: ${{ inputs.terminal }}
23 | run: |
24 | invoke test
25 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on:
4 | push:
5 |
6 | # Allows you to run this workflow manually from the Actions tab
7 | workflow_dispatch:
8 |
9 | jobs:
10 |
11 | generate-matrix:
12 | runs-on: ubuntu-latest
13 | outputs:
14 | python-versions: ${{ steps.generate-matrix.outputs.PYTHONS }}
15 | rf-versions: ${{ steps.generate-matrix.outputs.RF_VERSIONS }}
16 | steps:
17 | - name: "Generate Matrix"
18 | id: generate-matrix
19 | run: |
20 | echo 'PYTHONS=["3.10.11", "3.11.6", "3.12.0"]' >> $GITHUB_OUTPUT
21 | echo 'RF_VERSIONS=["3.2.2", "4.1.3", "5.0.1", "6.1.1"]' >> $GITHUB_OUTPUT
22 |
23 |
24 | windows:
25 | runs-on: windows-latest
26 | needs:
27 | - generate-matrix
28 | strategy:
29 | fail-fast: false
30 | matrix:
31 | python: ${{ fromJSON(needs.generate-matrix.outputs.python-versions) }}
32 | rf-version: ${{ fromJSON(needs.generate-matrix.outputs.rf-versions) }}
33 | name: Windows (python-${{ matrix.python }}, robotframework-${{ matrix.rf-version }})
34 | steps:
35 | - name: Checkout the repository
36 | uses: actions/checkout@v4
37 | - name: Run tests
38 | uses: ./.github/workflows/run-tests
39 | with:
40 | python-version: ${{ matrix.python }}
41 | rf-version: ${{ matrix.rf-version }}
42 | terminal: "pwsh"
43 |
44 | linux:
45 | runs-on: ubuntu-latest
46 | needs:
47 | - generate-matrix
48 | strategy:
49 | fail-fast: false
50 | matrix:
51 | python: ${{ fromJSON(needs.generate-matrix.outputs.python-versions) }}
52 | rf-version: ${{ fromJSON(needs.generate-matrix.outputs.rf-versions) }}
53 | name: Linux (python-${{ matrix.python }}, robotframework-${{ matrix.rf-version }})
54 | steps:
55 | - name: Checkout the repository
56 | uses: actions/checkout@v4
57 | - name: Run tests
58 | uses: ./.github/workflows/run-tests
59 | with:
60 | python-version: ${{ matrix.python }}
61 | rf-version: ${{ matrix.rf-version }}
62 | terminal: "bash"
63 | macos:
64 | runs-on: macos-latest
65 | needs:
66 | - generate-matrix
67 | strategy:
68 | fail-fast: false
69 | matrix:
70 | python: ${{ fromJSON(needs.generate-matrix.outputs.python-versions) }}
71 | rf-version: ${{ fromJSON(needs.generate-matrix.outputs.rf-versions) }}
72 | name: MacOS (python-${{ matrix.python }}, robotframework-${{ matrix.rf-version }})
73 | steps:
74 | - name: Checkout the repository
75 | uses: actions/checkout@v4
76 | - name: Run tests
77 | uses: ./.github/workflows/run-tests
78 | with:
79 | python-version: ${{ matrix.python }}
80 | rf-version: ${{ matrix.rf-version }}
81 | terminal: "bash"
82 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | *.bak
3 | report.html
4 | log.html
5 | output.xml
6 | *.pyc
7 | .python-version
8 | .tox
9 | *.egg-info
10 | build
11 | dist
12 | .idea
13 | venv
14 | .coverage
15 | htmlcov
16 | green-junit.xml
17 | tests/resources/green-junit-example_robot_output.xml
18 | example/results_robot_output.xml
19 | example/results.json
20 | .vscode
21 | /result
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Eficode Oy
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Oxygen
2 |
3 | Oxygen is a [Robot Framework](https://robotframework.org/) tool that empowers the user to convert the results of any testing tool or framework to [Robot Framework's reporting](https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#screenshots). This consolidates all test reporting together regardless of tools used.
4 |
5 | Oxygen has built-in support for three testing frameworks: [JUnit](https://junit.org/junit5/), [Gatling](https://gatling.io/), and [Zed Attack Proxy (ZAP)](https://www.zaproxy.org/).
6 |
7 | Oxygen is designed to be extensible. Users can create their own *handlers* for other testing framework or tools to transform their reporting into the Robot Framework's `log.html` and `report.html`.
8 |
9 | # Table of Contents
10 | 1. [Installation](#installation)
11 | 1. [Keyword documentation](#keyword-documentation)
12 | 1. [Usage](#usage)
13 | 1. [Developing Oxygen](#developing-oxygen)
14 | 1. [License](#license)
15 | 1. [Acknowledgements](#acknowledgments)
16 |
17 | # Installation
18 |
19 | To install Oxygen, run the following:
20 | ```
21 | $ pip install robotframework-oxygen
22 | ```
23 |
24 | ## Pre-requisites
25 |
26 | - Oxygen is supported on Windows, Linux and MacOS
27 | - [Python 3.10](http://python.org) or above
28 | - [pip](https://pypi.python.org/pypi/pip) for easy installation
29 | - [Robot Framework](http://robotframework.org)
30 | - [additional dependencies](requirements.txt)
31 |
32 | To check the Python version on the command line, run:
33 | ```
34 | $ python --version
35 | ```
36 |
37 | # Keyword documentation
38 |
39 | [Keyword Documentation](http://eficode.github.io/robotframework-oxygen/)
40 |
41 | # Usage
42 |
43 | ## Example: Robot Framework running other test tools
44 |
45 | Main usage scenario for Oxygen is the ability to write acceptance test cases that run your tests in other test tools and integrate the resulting test report as part of Robot Framework's. This means you are able to run all of your testing from Robot Framework and thus having all test reporting consolidated together.
46 |
47 | After installing Oxygen, it can be used in the Robot Framework suite to write test cases. For example, to build acceptance tests that run different sets of JUnit tests:
48 |
49 | ``` RobotFramework
50 | *** Settings ***
51 | Library oxygen.OxygenLibrary
52 |
53 | *** Test cases ***
54 |
55 | JUnit unit tests should pass
56 | [Tags] testset-1
57 | Run JUnit path/to/mydir/results.xml java -jar junit.jar --reports-dir=path/to/mydir
58 |
59 | JUnit integration tests should pass
60 | [Tags] testset-2
61 | Run JUnit path/to/anotherdir/results.xml java -jar junit.jar --reports-dir=path/to/anotherdir
62 | ```
63 |
64 | Then, run the suite by providing Oxygen as [a listener](http://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#listener-interface):
65 |
66 | ```
67 | $ robot --listener oxygen.listener my_tests.robot
68 | ```
69 |
70 | Opening the Robot Framework `log.html` and `report.html`, you should see that test case `JUnit unt tests should pass` has been replaced by Oxygen with test cases matching with what is in the `path/to/mydir/results.xml` JUnit report file. Similarly, test case `JUnit integration tests should pass` has been replaced with results from `path/to/anotherdir/results.xml`; each JUnit test case with its relevant information has a counterpart in the `log.html`. Each JUnit test case is also tagged with the tags from the original Robot Framework test case.
71 |
72 | The example above, for the brevity, shows incomplete commands to run JUnit tool from command line. Please refer to [keyword documentation](#keyword-documentation) for more detailed documentation about keyword's arguments, as well as documentation for [Gatling](https://gatling.io/) and [ZAP](https://www.zaproxy.org/) related keywords. And, of course, refer to the particular tool documentation as well.
73 |
74 | ## Using from command line
75 |
76 | In case where you want to run your other testing tools separately, but yet combine results into unified Robot Framework `log.html` and `report.html`, you can use Oxygen's command line interface to convert single result file to single corresponding Robot Framework `output.xml`:
77 |
78 | ```
79 | $ python -m oxygen oxygen.junit my_junit_results.xml
80 | ```
81 |
82 | As a convention, the resulting Robot Framework xml file will be named by adding a suffix to the end. In the example above, the resulting Robot Framework xml file would be named `my_junit_results_robot_output.xml`.
83 |
84 | **Note** that resulting xml file will also be created at the same location as the original result file. Therefore, when original result files are in another directory:
85 |
86 | ```
87 | $ python -m oxygen oxygen.gatling path/to/results.log
88 | ```
89 |
90 | Then `results_robot_output.xml` will be created under `path/to/`.
91 |
92 | ## Extending Oxygen: writing your own handler
93 |
94 | ### [Read the developer guide on how to write your own handler](DEVGUIDE.md)
95 |
96 | You might also want to look at [specification for handler results](handler_result_specification.md)
97 |
98 | ### Configuring your handler to Oxygen
99 |
100 | Oxygen knows about different handlers based on the [`config.yml`](https://github.com/eficode/robotframework-oxygen/blob/master/config.yml) file. This configuration file can be interacted with through Oxygen's command line.
101 |
102 | The configuration has the following parts:
103 | ```yml
104 | oxygen.junit: # Python module. Oxygen will use this key to try to import the handler
105 | handler: JUnitHandler # Class that Oxygen will initiate after the handler is imported
106 | keyword: run_junit # Keyword that should be used to run the other test tool
107 | tags: # List of tags that by default should be added to the test cases converted with this handler
108 | - oxygen-junit
109 | oxygen.zap:
110 | handler: ZAProxyHandler
111 | keyword: run_zap
112 | tags: oxygen-zap
113 | accepted_risk_level: 2 # Handlers can have their own command line arguments
114 | required_confidence_level: 1 # See https://github.com/eficode/robotframework-oxygen/blob/master/DEVGUIDE.md for more information
115 | ```
116 |
117 | #### `--add-config`
118 |
119 | This argument is used to add new handler configuration to Oxygen:
120 |
121 | ```bash
122 | $ python -m oxygen --add-config path/to/your_handler_config.yml
123 | ```
124 |
125 | This file is read and appended to the Oxygen's `config.yml`. Based on the key, Oxygen will try to import you handler.
126 |
127 | ### `--reset-config`
128 |
129 | This argument is used to return Oxygen's `config.yml` back to the state it was when the tool was installed:
130 |
131 | ```bash
132 | $ python -m oxygen --reset-config
133 | ```
134 |
135 | The command **does not** verify the operation from the user, so be careful.
136 |
137 | ### `--print-config`
138 |
139 | This argument prints the current configuration of Oxygen:
140 | ```bash
141 | $ python -m oxygen --print-config
142 | Using config file: /path/to/oxygen/src/oxygen/config.yml
143 | oxygen.gatling:
144 | handler: GatlingHandler
145 | keyword: run_gatling
146 | tags: oxygen-gatling
147 | oxygen.junit:
148 | handler: JUnitHandler
149 | keyword: run_junit
150 | tags:
151 | - oxygen-junit
152 | oxygen.zap:
153 | accepted_risk_level: 2
154 | handler: ZAProxyHandler
155 | keyword: run_zap
156 | required_confidence_level: 1
157 | tags: oxygen-zap
158 |
159 | $
160 | ```
161 | Because you can add the configuration to the same handler multiple times, note that only the last entry is in effect.
162 |
163 | ## `utils` module
164 |
165 | In [utils module](https://github.com/eficode/robotframework-oxygen/blob/master/src/oxygen/utils.py), you will find assortment of functionalities that you might want to leverage when writing your own handler.
166 |
167 | ### `run_command_line()`
168 |
169 | Most of the time, handlers want to run the other test tool through command line. For this, `utils` provides `run_command_line()` that wraps Python's [`subprocess`](https://docs.python.org/3/library/subprocess.html) module for more easier to use when writing your handler.
170 |
171 | `run_command_line()` takes following arguments:
172 | - `cmd`: the command to be executed in a subprocess
173 | - `check_return_code`: if set to `True`, will raise an exception if the `cmd` fails in the subprocess. **Note** that this fails the keyword and, thus, the execution of the test case is stopped. If you want to enable test case to continue even after `run_command_line()` has failed, you should disable it by setting `False`. It is often a good idea to allow user using your handler's keyword to decide how they want the command line execution to affect the test case
174 | - `env`: a dictionary of environment variables that should be passed to the subprocess. By default, `run_command_line()` inherits the environment from the current Python process as well as from modifications done by the Robot Framework command line arguments (ie. `--pythonpath`)
175 |
176 | # Developing Oxygen
177 |
178 | Clone the Oxygen repository to the environment where you want to the run the tool.
179 |
180 | Oxygen requires a set of dependencies to be installed. Dependencies are listed in the `requirements.txt` file:
181 | ```
182 | $ pip install -r requirements.txt
183 | ```
184 |
185 | Oxygen uses task runner tool [`invoke`](http://www.pyinvoke.org/) to run tests, build the project, etc.
186 |
187 | Please refer to the available tasks for the project:
188 | ```
189 | $ invoke --list
190 | ```
191 |
192 | and the task file [`tasks.py`](https://github.com/eficode/robotframework-oxygen/blob/master/tasks.py).
193 |
194 |
195 | # License
196 |
197 | Details of project licensing can be found in the [LICENSE](LICENSE) file in the project repository.
198 |
199 | # Acknowledgments
200 |
201 | Oxygen tool was developed by Eficode Oy as part of [Testomat project](https://www.testomatproject.eu/) with funding by [Business Finland](https://www.businessfinland.fi/).
202 |
--------------------------------------------------------------------------------
/example/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eficode/robotframework-oxygen/93bc908a3d38006086d7b9832a63c54549a8d1f8/example/__init__.py
--------------------------------------------------------------------------------
/example/my_parser.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from oxygen import BaseHandler
4 |
5 |
6 | class MyParser(BaseHandler):
7 | '''
8 | MyParser is an example how to extend Oxygen by writing your own handler
9 | '''
10 | RESULTS = {'tests': [
11 | {'name': 'My test 1', 'passed': True, 'msg': ''},
12 | {'name': 'My Test 2', 'passed': False, 'msg': 'Error text D:'}]
13 | }
14 |
15 | def run_my_tests(self, result_file):
16 | with open(result_file, 'w') as f:
17 | json.dump(self.RESULTS, f)
18 | return result_file
19 |
20 | def parse_results(self, result_file):
21 | with open(result_file, 'r') as f:
22 | results = json.load(f)
23 | return {
24 | 'name': result_file,
25 | 'tags': [],
26 | 'setup': None,
27 | 'teardown': None,
28 | 'suites': [],
29 | 'tests': [{
30 | 'name': test['name'],
31 | 'tags': [],
32 | 'setup': None,
33 | 'teardown': None,
34 | 'keywords': [{
35 | 'name': test['name'] + ' result',
36 | 'pass': test['passed'],
37 | 'elapsed': 0.0,
38 | 'tags': [],
39 | 'messages': [test['msg']],
40 | 'teardown': None,
41 | 'keywords': []
42 | }]
43 | } for test in results['tests']]
44 | }
45 |
--------------------------------------------------------------------------------
/example/my_parser_tests.robot:
--------------------------------------------------------------------------------
1 | *** Settings ***
2 | Library oxygen.OxygenLibrary
3 |
4 | *** Test Cases ***
5 | My parser's tests should succeed
6 | Run my tests ${CURDIR}/results.json
7 |
--------------------------------------------------------------------------------
/handler_result_specification.md:
--------------------------------------------------------------------------------
1 | # Oxygen handler result specification
2 | ```
3 | {
4 | "$defs": {
5 | "OxygenKeywordDict": {
6 | "properties": {
7 | "pass": {
8 | "title": "Pass",
9 | "type": "boolean"
10 | },
11 | "name": {
12 | "title": "Name",
13 | "type": "string"
14 | },
15 | "elapsed": {
16 | "title": "Elapsed",
17 | "type": "number"
18 | },
19 | "tags": {
20 | "items": {
21 | "type": "string"
22 | },
23 | "title": "Tags",
24 | "type": "array"
25 | },
26 | "messages": {
27 | "items": {
28 | "type": "string"
29 | },
30 | "title": "Messages",
31 | "type": "array"
32 | },
33 | "teardown": {
34 | "$ref": "#/$defs/OxygenKeywordDict"
35 | },
36 | "keywords": {
37 | "items": {
38 | "$ref": "#/$defs/OxygenKeywordDict"
39 | },
40 | "title": "Keywords",
41 | "type": "array"
42 | }
43 | },
44 | "required": [
45 | "pass",
46 | "name"
47 | ],
48 | "title": "OxygenKeywordDict",
49 | "type": "object"
50 | },
51 | "OxygenSuiteDict": {
52 | "properties": {
53 | "name": {
54 | "title": "Name",
55 | "type": "string"
56 | },
57 | "tags": {
58 | "items": {
59 | "type": "string"
60 | },
61 | "title": "Tags",
62 | "type": "array"
63 | },
64 | "setup": {
65 | "$ref": "#/$defs/OxygenKeywordDict"
66 | },
67 | "teardown": {
68 | "$ref": "#/$defs/OxygenKeywordDict"
69 | },
70 | "metadata": {
71 | "additionalProperties": {
72 | "type": "string"
73 | },
74 | "title": "Metadata",
75 | "type": "object"
76 | },
77 | "suites": {
78 | "items": {
79 | "$ref": "#/$defs/OxygenSuiteDict"
80 | },
81 | "title": "Suites",
82 | "type": "array"
83 | },
84 | "tests": {
85 | "items": {
86 | "$ref": "#/$defs/OxygenTestCaseDict"
87 | },
88 | "title": "Tests",
89 | "type": "array"
90 | }
91 | },
92 | "required": [
93 | "name"
94 | ],
95 | "title": "OxygenSuiteDict",
96 | "type": "object"
97 | },
98 | "OxygenTestCaseDict": {
99 | "properties": {
100 | "name": {
101 | "title": "Name",
102 | "type": "string"
103 | },
104 | "keywords": {
105 | "items": {
106 | "$ref": "#/$defs/OxygenKeywordDict"
107 | },
108 | "title": "Keywords",
109 | "type": "array"
110 | },
111 | "tags": {
112 | "items": {
113 | "type": "string"
114 | },
115 | "title": "Tags",
116 | "type": "array"
117 | },
118 | "setup": {
119 | "$ref": "#/$defs/OxygenKeywordDict"
120 | },
121 | "teardown": {
122 | "$ref": "#/$defs/OxygenKeywordDict"
123 | }
124 | },
125 | "required": [
126 | "name",
127 | "keywords"
128 | ],
129 | "title": "OxygenTestCaseDict",
130 | "type": "object"
131 | }
132 | },
133 | "allOf": [
134 | {
135 | "$ref": "#/$defs/OxygenSuiteDict"
136 | }
137 | ]
138 | }
139 | ```
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | robotframework>=3.0.4
2 | junitparser==2.0
3 | PyYAML>=3.13
4 | pydantic>=2.4.2
5 |
6 | ### Dev
7 | mock>=2.0.0
8 | invoke>=1.1.1
9 | coverage>=5.1
10 | testfixtures>=6.14.1 # needed for large dict comparisons to make sense of them
11 | pytest>=7.4.2
12 | pytest-cov>=4.1.0
13 | docutils>=0.16 # needed to generate library documentation with libdoc
14 | Pygments>=2.6.1 # this one too
15 | twine>=3.1.1 # needed for releasing to pypi
16 | build>=0.6.0 # needed for building the distribution
17 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from os.path import abspath, dirname, join as path_join
3 | from setuptools import find_packages, setup
4 |
5 | CURDIR = abspath(dirname(__file__))
6 | SRC = path_join(CURDIR, 'src')
7 |
8 | with open(path_join(SRC, 'oxygen', 'version.py')) as f:
9 | exec(f.read())
10 |
11 | KEYWORDS = ('robotframework testing testautomation acceptancetesting atdd bdd'
12 | 'reporting testreporting')
13 |
14 | SHORT_DESC = ('Oxygen is an extensible tool for Robot Framework that '
15 | 'enables you to integrate running other testing tools and their '
16 | 'reports as part of Robot Framework\'s reporting.')
17 |
18 | with open(path_join(CURDIR, 'README.md'), 'r') as readme:
19 | LONG_DESCRIPTION = readme.read()
20 |
21 | CLASSIFIERS = '''
22 | Development Status :: 5 - Production/Stable
23 | Programming Language :: Python :: 3 :: Only
24 | Operating System :: OS Independent
25 | Topic :: Software Development :: Testing
26 | License :: OSI Approved :: MIT License
27 | '''.strip().splitlines()
28 |
29 | setup(name='robotframework-oxygen',
30 | author='Eficode Oy',
31 | author_email='info@eficode.com',
32 | url='',
33 | license='MIT',
34 | install_requires=[
35 | 'robotframework>=3.0.4',
36 | 'junitparser==2.0',
37 | 'PyYAML>=3.13',
38 | 'pydantic>=2.4.2'
39 | ],
40 | packages=find_packages(SRC),
41 | package_dir={'': 'src'},
42 | package_data={'oxygen': ['*.yml']},
43 | keywords=KEYWORDS,
44 | classifiers=CLASSIFIERS,
45 | version=VERSION,
46 | description=SHORT_DESC,
47 | long_description=LONG_DESCRIPTION,
48 | long_description_content_type="text/markdown")
49 |
--------------------------------------------------------------------------------
/src/oxygen/__init__.py:
--------------------------------------------------------------------------------
1 | from .version import VERSION
2 | from .base_handler import BaseHandler
3 | from .oxygen import listener, OxygenLibrary
4 |
5 | __all__ = ['BaseHandler', 'listener', 'OxygenLibrary']
6 | __version__ = VERSION
7 |
--------------------------------------------------------------------------------
/src/oxygen/__main__.py:
--------------------------------------------------------------------------------
1 | from .oxygen import OxygenCLI, main
2 |
3 | if __name__ == '__main__':
4 | main()
5 |
--------------------------------------------------------------------------------
/src/oxygen/base_handler.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from inspect import signature, Parameter
4 |
5 | from .errors import MismatchArgumentException
6 | from .robot_interface import (RobotInterface, get_keywords_from,
7 | set_special_keyword)
8 | from .utils import validate_with_deprecation_warning
9 |
10 | class BaseHandler(object):
11 | DEFAULT_CLI = {tuple(['result_file']): {}}
12 |
13 | def __init__(self, config):
14 | '''
15 | Set up the handler with the given configuration
16 |
17 | config: A dict including the 'keyword' node
18 | '''
19 | self._interface = RobotInterface()
20 | self._config = config
21 |
22 | tags = self._config.get('tags', [])
23 | if not isinstance(tags, list):
24 | tags = [tags]
25 | self._tags = tags
26 | self.keyword = self._normalize_keyword_name(self._config['keyword'])
27 | self.run_time_data = None
28 |
29 | def cli(self):
30 | '''
31 | augment in subclasses
32 |
33 | def cli(self):
34 | cli_interface = self.DEFAULT_CLI.copy()
35 | cli_interface[('-e', '--example')] = {'help': 'use this like that'}
36 | cli_interface[('-f', '--flag')] = {'action': 'store_true'}
37 | return cli_interface
38 | '''
39 | return self.DEFAULT_CLI
40 |
41 | def parse_results(self, kw_args):
42 | raise NotImplementedError('Actual handler implementation should override '
43 | 'this with proper implementation!')
44 |
45 | def check_for_keyword(self, test, data):
46 | '''Check if any of the keywords directly under this test trigger test
47 | execution
48 |
49 | test: A Robot test
50 | '''
51 | test_keywords = get_keywords_from(test)
52 |
53 | for curr, keyword in enumerate(test_keywords):
54 | keyword_name = self._normalize_keyword_name(keyword.name)
55 | if not (keyword_name == self.keyword):
56 | continue
57 |
58 | self.run_time_data = data[test.longname]
59 | # ALL keywords, setup or not, preceding the trigger will be treated
60 | # as setup keywords later. Same goes for keywords succeeding the
61 | # trigger; they will become teardown keywords.
62 | setup_keywords = test_keywords[:curr]
63 | teardown_keywords = test_keywords[(curr+1):]
64 |
65 | self._report_oxygen_run(keyword, setup_keywords, teardown_keywords)
66 |
67 |
68 | def _report_oxygen_run(self, keyword, setup_keywords, teardown_keywords):
69 | '''
70 | keyword: The trigger keyword for this handler
71 | setup_keywords: The keywords preceding the trigger
72 | teardown_keywords: The keywords succeeding the trigger
73 | '''
74 | # Wrap setup- and teardown keywords as a single keyword
75 | setup_keyword = None
76 | teardown_keyword = None
77 |
78 | if setup_keywords:
79 | setup_start = setup_keywords[0].starttime
80 | setup_end = setup_keywords[-1].endtime
81 | setup_keyword = self._interface.result.create_wrapper_keyword(
82 | 'Oxygen Setup',
83 | setup_start,
84 | setup_end,
85 | True,
86 | *setup_keywords)
87 |
88 | if teardown_keywords:
89 | teardown_start = teardown_keywords[0].starttime
90 | teardown_end = teardown_keywords[-1].endtime
91 | teardown_keyword = self._interface.result.create_wrapper_keyword(
92 | 'Oxygen Teardown',
93 | teardown_start,
94 | teardown_end,
95 | False,
96 | *teardown_keywords)
97 |
98 | self._build_results(keyword, setup_keyword, teardown_keyword)
99 |
100 | def _build_results(self, keyword, setup_keyword, teardown_keyword):
101 | '''
102 | keyword: The trigger keyword
103 | setup_keyword: The special oxygen setup wrapper
104 | teardown_keyword: The special oxygen teardown wrapper
105 | '''
106 | accepted_params = signature(self.parse_results).parameters
107 | accepted_params_max = len(accepted_params)
108 | accepted_params_min = len([
109 | n for n, v in accepted_params.items()
110 | if v.default == Parameter.empty])
111 | is_multiple_inputs = isinstance(self.run_time_data, tuple)
112 |
113 | # there are multiple inputs and in the range of accepted min and max
114 | if is_multiple_inputs and (accepted_params_min <= len(
115 | self.run_time_data) <= accepted_params_max):
116 | test_results = self.parse_results(*self.run_time_data)
117 |
118 | # there is single input and one required, also can be more non-required
119 | elif not is_multiple_inputs and accepted_params_min == 1:
120 | test_results = self.parse_results(self.run_time_data)
121 |
122 | # else if there are multiple inputs and not in the range of accepted
123 | elif is_multiple_inputs:
124 | raise MismatchArgumentException(
125 | f'parse_results expects at least {accepted_params_min} and'
126 | f' at most {accepted_params_max} arguments '
127 | f'but got {len(self.run_time_data)}')
128 |
129 | # at this point there could be only multiple required and single input
130 | else:
131 | raise MismatchArgumentException(
132 | f'parse_results expects at least {accepted_params_min} '
133 | 'arguments but got 1')
134 |
135 | self._validate(test_results)
136 |
137 | _, result_suite = self._interface.result.build_suite(
138 | 100000, test_results)
139 |
140 | if not result_suite:
141 | return
142 |
143 | test = keyword.parent
144 | self._set_suite_tags(result_suite, *(self._tags + list(test.tags)))
145 |
146 | if setup_keyword:
147 | set_special_keyword(result_suite, 'setup', setup_keyword)
148 |
149 | if teardown_keyword:
150 | set_special_keyword(result_suite, 'teardown', teardown_keyword)
151 |
152 | self._inject_suite_report(test, result_suite)
153 |
154 | def _validate(self, oxygen_result_dict):
155 | validate_with_deprecation_warning(oxygen_result_dict, self)
156 |
157 | def _inject_suite_report(self, test, result_suite):
158 | '''Add the given suite to the parent suite of the test case.
159 |
160 | This also filters out the test case from the parent suite test cases.
161 |
162 | test: Any Robot object part of the current test execution
163 | result_suite: Robot suite to report on
164 | '''
165 | suite = test.parent
166 | new_tests = [t for t in suite.tests if t is not test]
167 | suite.suites.append(result_suite)
168 | suite.tests = new_tests
169 |
170 | def _normalize_keyword_name(self, keyword_name):
171 | '''
172 | keyword_name: The raw keyword name (suite.subsuite.test.My Keyword)
173 | '''
174 | short_name = str(keyword_name).split('.')[-1].strip()
175 | underscored = re.sub(r' +', '_', short_name)
176 | return underscored.lower()
177 |
178 | def _set_suite_tags(self, suite, *tags):
179 | suite.set_tags(tags)
180 | return suite
181 |
182 |
--------------------------------------------------------------------------------
/src/oxygen/config.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | CONFIG_FILE = Path(__file__).resolve().parent / 'config.yml'
4 | ORIGINAL_CONFIG_FILE = Path(__file__).resolve().parent / 'config_original.yml'
5 |
--------------------------------------------------------------------------------
/src/oxygen/config.yml:
--------------------------------------------------------------------------------
1 | oxygen.junit:
2 | handler: JUnitHandler
3 | keyword: run_junit
4 | tags:
5 | - oxygen-junit
6 | oxygen.gatling:
7 | handler: GatlingHandler
8 | keyword: run_gatling
9 | tags: oxygen-gatling
10 | oxygen.zap:
11 | handler: ZAProxyHandler
12 | keyword: run_zap
13 | tags: oxygen-zap
14 | accepted_risk_level: 2
15 | required_confidence_level: 1
16 |
17 |
--------------------------------------------------------------------------------
/src/oxygen/config_original.yml:
--------------------------------------------------------------------------------
1 | oxygen.junit:
2 | handler: JUnitHandler
3 | keyword: run_junit
4 | tags:
5 | - oxygen-junit
6 | oxygen.gatling:
7 | handler: GatlingHandler
8 | keyword: run_gatling
9 | tags: oxygen-gatling
10 | oxygen.zap:
11 | handler: ZAProxyHandler
12 | keyword: run_zap
13 | tags: oxygen-zap
14 | accepted_risk_level: 2
15 | required_confidence_level: 1
16 |
17 |
--------------------------------------------------------------------------------
/src/oxygen/errors.py:
--------------------------------------------------------------------------------
1 | class GatlingHandlerException(Exception):
2 | pass
3 |
4 |
5 | class JUnitHandlerException(Exception):
6 | pass
7 |
8 |
9 | class OxygenException(Exception):
10 | pass
11 |
12 |
13 | class SubprocessException(Exception):
14 | pass
15 |
16 |
17 | class ZAProxyHandlerException(Exception):
18 | pass
19 |
20 |
21 | class ResultFileNotFoundException(Exception):
22 | pass
23 |
24 |
25 | class ResultFileIsNotAFileException(Exception):
26 | pass
27 |
28 |
29 | class MismatchArgumentException(Exception):
30 | pass
31 |
32 |
33 | class InvalidConfigurationException(Exception):
34 | pass
35 |
36 |
37 | class InvalidOxygenResultException(Exception):
38 | pass
39 |
--------------------------------------------------------------------------------
/src/oxygen/gatling.py:
--------------------------------------------------------------------------------
1 | from robot.api import logger
2 |
3 | from .base_handler import BaseHandler
4 | from .errors import GatlingHandlerException, SubprocessException
5 | from .utils import run_command_line, validate_path
6 |
7 | class GatlingHandler(BaseHandler):
8 |
9 | def run_gatling(self, result_file, command, check_return_code=False, **env):
10 | '''Run Gatling performance testing tool specified with ``command``.
11 |
12 | ``result_file`` is path to the file ``oxygen`` uses to parse the results.
13 | It is important you craft your `command` to produce the file
14 | `result_file` argument expects — otherwise Oxygen will not be able to
15 | parse the results later on.
16 |
17 | ``command`` is used to run the test tool. It is a single string which is
18 | run in a terminal subshell.
19 |
20 | ``check_return_code`` checks that ``command`` returns with exit code zero
21 | (0). By default, return code is not checked as failing test execution
22 | generally is reported with non-zero return code. However, it is useful
23 | to set ``check_return_code`` to ``True`` temporarily when you want to
24 | debug why the test tool is failing for other reasons than failing test
25 | execution.
26 |
27 | ``env`` is used to pass environment variables that are set in the subshell
28 | the ``command`` is run in.
29 | '''
30 | try:
31 | output = run_command_line(command, check_return_code, **env)
32 | except SubprocessException as e:
33 | raise GatlingHandlerException(e)
34 | logger.info(output)
35 | logger.info('Result file: {}'.format(result_file))
36 | return result_file
37 |
38 | def parse_results(self, result_file):
39 | return self._transform_tests(validate_path(result_file).resolve())
40 |
41 | def _transform_tests(self, result_file):
42 | '''Given the result_file path, open the test results and get a suite
43 | dict.
44 |
45 | The whole Gatling format is jank 3000.
46 | Here be dragons.
47 |
48 | result_file: The path to the Gatling results
49 | '''
50 | test_cases = []
51 | with open(result_file) as results:
52 | result_contents = results.readlines()
53 | for line in result_contents:
54 | columns = line.strip().split('\t')
55 | if len(columns) < 8:
56 | continue
57 | step_name = columns[4]
58 | status = columns[7]
59 | if status not in ['OK', 'KO']:
60 | continue
61 | message = ''
62 | if len(columns) > 8:
63 | message = columns[8]
64 |
65 | keyword = {
66 | 'name': ' | '.join(columns),
67 | 'pass': True,
68 | 'messages': [],
69 | }
70 |
71 | if status == 'KO':
72 | keyword['pass'] = False
73 | keyword['messages'].append(message)
74 |
75 | test_case = {
76 | 'name': step_name,
77 | 'keywords': [keyword]
78 | }
79 |
80 | test_cases.append(test_case)
81 |
82 | test_suite = {
83 | 'name': 'Gatling Scenario',
84 | 'tags': self._tags,
85 | 'tests': test_cases,
86 | }
87 |
88 | return test_suite
89 |
--------------------------------------------------------------------------------
/src/oxygen/junit.py:
--------------------------------------------------------------------------------
1 | from robot.api import logger
2 | from junitparser import Error, Failure, JUnitXml
3 | from junitparser.junitparser import TestSuite as JUnitXmlTestSuite
4 |
5 | from .base_handler import BaseHandler
6 | from .errors import JUnitHandlerException, SubprocessException
7 | from .utils import run_command_line, validate_path
8 |
9 |
10 | class JUnitHandler(BaseHandler):
11 |
12 | def run_junit(self, result_file, command, check_return_code=False, **env):
13 | '''Run JUnit unit testing tool specified with ``command``.
14 |
15 | See documentation for other arguments in \`Run Gatling\`.
16 | '''
17 | logger.debug(f'Command: {command}')
18 | try:
19 | output = run_command_line(command, check_return_code, **env)
20 | except SubprocessException as e:
21 | raise JUnitHandlerException(e)
22 | logger.info(output)
23 | logger.info('Result file: {}'.format(result_file))
24 | return result_file
25 |
26 | def parse_results(self, result_file):
27 | result_file = validate_path(result_file)
28 | xml = JUnitXml.fromfile(result_file)
29 | return self._transform_tests(xml)
30 |
31 | def _transform_tests(self, node):
32 | '''Convert the given xml object into a test suite dict
33 |
34 | node: An xml object from JUnitXml.fromfile()
35 |
36 | Return: The test suite dict
37 | '''
38 | suite_dict = {
39 | 'name': 'JUnit Execution',
40 | 'tags': self._tags,
41 | 'suites': [],
42 | }
43 |
44 | if isinstance(node, JUnitXmlTestSuite):
45 | node = [node]
46 |
47 | for xunit_suite in node:
48 | suite = self._transform_test_suite(xunit_suite)
49 | suite_dict['suites'].append(suite)
50 |
51 | return suite_dict
52 |
53 | def _transform_test_suite(self, test_suite):
54 | '''Convert the given suite xml object into a suite dict
55 |
56 | test_suite: A JUnit suite xml object
57 |
58 | Return: A suite dict
59 | '''
60 | suite_dict = {
61 | 'name': test_suite.name,
62 | 'tags': [],
63 | 'suites': [],
64 | 'tests': [],
65 | }
66 |
67 | # For child suites
68 | for xunit_suite in test_suite.testsuites():
69 | suite = self._transform_test_suite(xunit_suite)
70 | suite_dict['suites'].append(suite)
71 |
72 | # For test cases
73 | for xunit_case in test_suite:
74 | case = self._transform_test_case(xunit_case)
75 | suite_dict['tests'].append(case)
76 |
77 | return suite_dict
78 |
79 | def _transform_test_case(self, test_case):
80 | '''Convert the given test case xml object into a test case dict
81 |
82 | test_case: A JUnit case xml object
83 |
84 | Return: A test case dict
85 | '''
86 | test_dict = {
87 | 'name': '{} (Execution)'.format(test_case.name),
88 | 'pass': True,
89 | 'messages': [],
90 | 'keywords': [],
91 | }
92 |
93 | errors = test_case.iterchildren(Error)
94 | failures = test_case.iterchildren(Failure)
95 |
96 | if errors:
97 | for error in errors:
98 | error_name = 'ERROR: {} ({})'.format(
99 | error.message,
100 | error.type,
101 | )
102 | test_dict['messages'].append(error_name)
103 |
104 | if failures:
105 | for failure in failures:
106 | failure_name = 'FAIL: {} ({})'.format(
107 | failure.message,
108 | failure.type,
109 | )
110 | test_dict['messages'].append(failure_name)
111 |
112 | # If we had errors/failures, it's not a PASS
113 | if test_dict['messages']:
114 | test_dict['pass'] = False
115 |
116 | execution_time = (test_case.time or 0.0) * 1000
117 | test_dict['elapsed'] = execution_time
118 |
119 | test_case_dict = {
120 | 'name': test_case.name,
121 | 'tags': [],
122 | 'keywords': [
123 | test_dict,
124 | ],
125 | }
126 |
127 | # This is really unreliable but at least we can find the trouble spots
128 | if not test_case.time:
129 | test_case_dict['tags'].append(
130 | 'oxygen-junit-unknown-execution-time')
131 |
132 | return test_case_dict
133 |
--------------------------------------------------------------------------------
/src/oxygen/oxygen_handler_result.py:
--------------------------------------------------------------------------------
1 | ''' IMPORTANT
2 |
3 | OxygenKeywordDict is defined like this since key `pass` is reserved
4 | word in Python, and thus raises SyntaxError if defined like a class.
5 | However, in the functional style you cannot refer to the TypedDict itself
6 | recursively, like you can with with class style. Oh bother.
7 |
8 | See more:
9 | - https://docs.python.org/3/library/typing.html?highlight=typeddict#typing.TypedDict
10 | - https://stackoverflow.com/a/72460065
11 | '''
12 |
13 | import functools
14 |
15 | from typing import List, Dict
16 | # TODO FIXME: Python 3.10 requires these to be imported from here
17 | # Python 3.10 EOL is in 2026
18 | from typing_extensions import TypedDict, Required
19 |
20 | from pydantic import TypeAdapter, ValidationError
21 |
22 | from .errors import InvalidOxygenResultException
23 |
24 |
25 | _Pass = TypedDict('_Pass', { 'pass': Required[bool], 'name': Required[str] })
26 | # define required fields in this one above
27 | class OxygenKeywordDict(_Pass, total=False):
28 | elapsed: float # milliseconds
29 | tags: List[str]
30 | messages: List[str]
31 | teardown: 'OxygenKeywordDict' # in RF, keywords do not have setup kw; just put it as first kw in `keywords`
32 | keywords: List['OxygenKeywordDict']
33 |
34 |
35 | class OxygenTestCaseDict(TypedDict, total=False):
36 | name: Required[str]
37 | keywords: Required[List[OxygenKeywordDict]]
38 | tags: List[str]
39 | setup: OxygenKeywordDict
40 | teardown: OxygenKeywordDict
41 |
42 |
43 | class OxygenSuiteDict(TypedDict, total=False):
44 | name: Required[str]
45 | tags: List[str]
46 | setup: OxygenKeywordDict
47 | teardown: OxygenKeywordDict
48 | metadata: Dict[str, str]
49 | suites: List['OxygenSuiteDict']
50 | tests: List[OxygenTestCaseDict]
51 |
52 |
53 | def _change_validationerror_to_oxygenexception(func):
54 | @functools.wraps(func)
55 | def wrapper(*args, **kwargs):
56 | try:
57 | return func(*args, **kwargs)
58 | except ValidationError as e:
59 | raise InvalidOxygenResultException(e)
60 | return wrapper
61 |
62 | @_change_validationerror_to_oxygenexception
63 | def validate_oxygen_suite(oxygen_result_dict):
64 | return TypeAdapter(OxygenSuiteDict).validate_python(oxygen_result_dict)
65 |
66 | @_change_validationerror_to_oxygenexception
67 | def validate_oxygen_test_case(oxygen_test_case_dict):
68 | return TypeAdapter(OxygenTestCaseDict).validate_python(oxygen_test_case_dict)
69 |
70 | @_change_validationerror_to_oxygenexception
71 | def validate_oxygen_keyword(oxygen_kw_dict):
72 | return TypeAdapter(OxygenKeywordDict).validate_python(oxygen_kw_dict)
73 |
--------------------------------------------------------------------------------
/src/oxygen/robot_interface.py:
--------------------------------------------------------------------------------
1 | from robot.version import get_version as robot_version
2 |
3 |
4 | class RobotInterface(object):
5 | def __init__(self):
6 | major_version = int(robot_version().split('.')[0])
7 |
8 | if major_version > 3:
9 | from .robot4_interface import (RobotResultInterface,
10 | RobotRunningInterface)
11 | else:
12 | from .robot3_interface import (RobotResultInterface,
13 | RobotRunningInterface)
14 |
15 | self.result = RobotResultInterface()
16 | self.running = RobotRunningInterface()
17 |
18 |
19 | def get_keywords_from(test):
20 | if hasattr(test, 'body'):
21 | return test.body.filter(keywords=True)
22 | return test.keywords
23 |
24 |
25 | def set_special_keyword(suite, keyword_type, keyword):
26 | if hasattr(suite, keyword_type):
27 | if keyword_type == 'setup':
28 | suite.setup = keyword
29 | elif keyword_type == 'teardown':
30 | suite.teardown = keyword
31 | else:
32 | suite.keywords.append(keyword)
33 |
--------------------------------------------------------------------------------
/src/oxygen/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 | import subprocess
3 | import sys
4 |
5 | from pathlib import Path
6 |
7 | from .errors import (InvalidOxygenResultException,
8 | ResultFileIsNotAFileException,
9 | ResultFileNotFoundException,
10 | SubprocessException)
11 | from .oxygen_handler_result import validate_oxygen_suite
12 |
13 | def run_command_line(command, check_return_code=True, **env):
14 | new_env = os.environ.copy()
15 | # When user uses 'robot --pythonpath' we need to update PYTHONPATH in the
16 | # subprocess
17 | updated_pythonpath = {'PYTHONPATH': os.pathsep.join([new_env.get(
18 | 'PYTHONPATH', '')] + sys.path)}
19 | new_env.update(updated_pythonpath)
20 | new_env.update(env)
21 |
22 | try:
23 | proc = subprocess.run(command, capture_output=True, env=new_env, shell=True)
24 | except IndexError:
25 | raise SubprocessException('Command "{}" was empty'.format(command))
26 | if check_return_code and proc.returncode != 0:
27 | raise SubprocessException(f'Command "{command}" failed with return '
28 | f'code {proc.returncode}:\n"{proc.stdout.decode("utf-8")}"')
29 | return proc.stdout
30 |
31 | def validate_path(filepath):
32 | try:
33 | path = Path(filepath)
34 | except TypeError as e:
35 | raise ResultFileIsNotAFileException(f'File "{filepath}" is not a file')
36 | if not path.exists():
37 | raise ResultFileNotFoundException(f'File "{path}" does not exits')
38 | if path.is_dir():
39 | raise ResultFileIsNotAFileException(f'File "{path}" is not a file, '
40 | 'but a directory')
41 | return path
42 |
43 | def validate_with_deprecation_warning(oxygen_result_dict, handler):
44 | try:
45 | validate_oxygen_suite(oxygen_result_dict)
46 | except InvalidOxygenResultException as e:
47 | import warnings
48 | # this is not done with triple quotes intentionally
49 | # to get sensible formatting to output
50 | msg = (f'\n{handler.__module__} is producing invalid results:\n'
51 | f'{e}\n\n'
52 | 'In Oxygen 1.0, handlers will need to produce valid '
53 | 'results.\nSee: '
54 | 'https://github.com/eficode/robotframework-oxygen/blob/master/parser_specification.md')
55 | warnings.warn(msg)
56 |
--------------------------------------------------------------------------------
/src/oxygen/version.py:
--------------------------------------------------------------------------------
1 | VERSION = '0.4.dev'
2 |
--------------------------------------------------------------------------------
/src/oxygen/zap.py:
--------------------------------------------------------------------------------
1 | import json
2 | import xml.etree.ElementTree as ETree
3 |
4 | from collections import defaultdict
5 |
6 | from robot.api import logger
7 |
8 | from .base_handler import BaseHandler
9 | from .errors import SubprocessException, ZAProxyHandlerException
10 | from .utils import run_command_line, validate_path
11 |
12 |
13 | class ZAProxyHandler(BaseHandler):
14 | def run_zap(self, result_file, command, check_return_code=False, **env):
15 | '''Run Zed Attack Proxy security testing tool specified with
16 | ``command``.
17 |
18 | See documentation for other arguments in \`Run Gatling\`.
19 | '''
20 | try:
21 | output = run_command_line(command, check_return_code, **env)
22 | except SubprocessException as e:
23 | raise ZAProxyHandlerException(e)
24 | logger.info(output)
25 | logger.info('Result file: {}'.format(result_file))
26 | return result_file
27 |
28 | def cli(self):
29 | cli_interface = self.DEFAULT_CLI.copy()
30 | cli_interface[('--accepted-risk-level',)] = {
31 | 'help': 'Set accepted risk level',
32 | 'type': int
33 | }
34 | cli_interface[('--required-confidence-level',)] = {
35 | 'help': 'Set required confidence level',
36 | 'type': int
37 | }
38 | return cli_interface
39 |
40 | def parse_results(self, result_file, accepted_risk_level=None,
41 | required_confidence_level=None):
42 | if accepted_risk_level is not None:
43 | self._config['accepted_risk_level'] = accepted_risk_level
44 | if required_confidence_level is not None:
45 | self._config['required_confidence_level'] = \
46 | required_confidence_level
47 | zap_dict = self._read_results(validate_path(result_file).resolve())
48 | return self._parse_zap_dict(zap_dict)
49 |
50 | def _read_results(self, file_name):
51 | with open(file_name) as test_file:
52 | result_contents = test_file.read()
53 |
54 | try:
55 | json_dict = self._xml_to_dict(ETree.XML(result_contents))
56 | if 'OWASPZAPReport' in json_dict.keys():
57 | json_dict = json_dict['OWASPZAPReport']
58 | except:
59 | print('Oxygen: Loading {} as XML failed, falling '
60 | 'back to JSON.'.format(file_name))
61 | json_dict = json.loads(result_contents)
62 | return json_dict
63 |
64 | def _xml_to_dict(self, xml):
65 | d = {xml.tag: {} if xml.attrib else None}
66 | children = list(xml)
67 | if children:
68 | dd = defaultdict(list)
69 | for dc in map(self._xml_to_dict, children):
70 | for k, v in dc.items():
71 | dd[k].append(v)
72 | d = {xml.tag: {k:v[0] if len(v) == 1 else v for k, v in dd.items()}}
73 | if xml.attrib:
74 | d[xml.tag].update(('@' + k, v) for k, v in xml.attrib.items())
75 | if xml.text:
76 | text = xml.text.strip()
77 | if children or xml.attrib:
78 | if text:
79 | d[xml.tag]['#text'] = text
80 | else:
81 | d[xml.tag] = text
82 | return d
83 |
84 |
85 | def _parse_zap_dict(self, zap_dict):
86 | zap_version = self._get_parameter(zap_dict, 'version', 'Unknown ZAProxy Version')
87 | zap_run_date = self._get_parameter(zap_dict, 'generated', 'Unknown ZAProxy Run Time')
88 |
89 | return_dict = {}
90 | return_dict['name'] = 'Oxygen ZAProxy Report ({}, {})'.format(
91 | zap_version,
92 | zap_run_date,
93 | )
94 |
95 | zap_sites = zap_dict.get('site', [])
96 |
97 | return_dict['tags'] = self._tags
98 | return_dict['suites'] = []
99 |
100 | for zap_site in zap_sites:
101 | parsed_site = self._parse_zap_site_dict(zap_site)
102 | return_dict['suites'].append(parsed_site)
103 |
104 | return return_dict
105 |
106 |
107 | def _parse_zap_site_dict(self, zap_site_dict):
108 | site_name = self._get_parameter(zap_site_dict, 'name', 'Unknown Site Name')
109 |
110 | return_dict = {}
111 | return_dict['name'] = 'Site: {}'.format(site_name)
112 | return_dict['tests'] = []
113 |
114 | zap_alerts = zap_site_dict.get('alerts', [])
115 | zap_alerts = zap_alerts or []
116 |
117 | if isinstance(zap_alerts, dict):
118 | zap_alerts = zap_alerts.get('alertitem', [])
119 | if not isinstance(zap_alerts, list):
120 | zap_alerts = [zap_alerts]
121 | for zap_alert in zap_alerts:
122 | parsed_alert = self._parse_zap_alert_dict(zap_alert)
123 | return_dict['tests'].append(parsed_alert)
124 |
125 | return return_dict
126 |
127 |
128 | def _parse_zap_alert_dict(self, zap_alert_dict):
129 | plugin_id = zap_alert_dict.get('pluginid', '[Unknown Plugin ID]')
130 | alert_name = zap_alert_dict.get('name', 'Unknown Alert Name')
131 | zap_instances = zap_alert_dict.get('instances', [])
132 | tags = []
133 |
134 | return_dict = {}
135 | return_dict['name'] = '{} {}'.format(plugin_id, alert_name)
136 | return_dict['tags'] = tags
137 | return_dict['keywords'] = []
138 |
139 | considered_risk = self._is_considered_risk(zap_alert_dict)
140 | is_confident = self._is_considered_confident(zap_alert_dict)
141 |
142 | if isinstance(zap_instances, dict):
143 | zap_instances = zap_instances.get('instance', None)
144 | if not isinstance(zap_instances, list):
145 | zap_instances = filter(None, [zap_instances])
146 |
147 | for zap_instance in zap_instances:
148 | parsed_instance = self._parse_zap_instance(zap_instance,
149 | considered_risk,
150 | is_confident,
151 | )
152 | return_dict['keywords'].append(parsed_instance)
153 |
154 | return return_dict
155 |
156 |
157 | def _is_considered_risk(self, zap_alert_dict):
158 | risk_level = zap_alert_dict.get('riskcode', -1)
159 | risk_level = int(risk_level)
160 |
161 | if risk_level < 0:
162 | risk_level = 3
163 |
164 | acceptable_risk = self._get_treshold_risk_level()
165 | considered_risk = (risk_level >= acceptable_risk)
166 |
167 | return considered_risk
168 |
169 |
170 | def _is_considered_confident(self, zap_alert_dict):
171 | confidence_level = zap_alert_dict.get('confidence', -1)
172 | confidence_level = int(confidence_level)
173 |
174 | if confidence_level < 0:
175 | confidence_level = 3
176 |
177 | required_confidence = self._get_required_confidence_level()
178 | is_confident = (confidence_level >= required_confidence)
179 |
180 | return is_confident
181 |
182 |
183 | def _parse_zap_instance(self, zap_instance_dict, risk, confident):
184 | zap_uri = zap_instance_dict.get('uri', '[Unknown Target URI]')
185 | zap_method = zap_instance_dict.get('method', '[Unknown HTTP Method]')
186 | zap_param = zap_instance_dict.get('param', '[Unknown Target Parameter]')
187 | zap_evidence = zap_instance_dict.get('evidence', '[No Evidence Provided]')
188 |
189 | return_dict = {}
190 | return_dict['name'] = '{} {}: {}'.format(zap_method, zap_uri, zap_param)
191 | return_dict['pass'] = not (risk and confident)
192 | return_dict['elapsed'] = 0.0
193 | return_dict['messages'] = []
194 | return_dict['messages'].append('Evidence: {}'.format(zap_evidence))
195 |
196 | return return_dict
197 |
198 |
199 | def _get_parameter(self, zap_dict, name, default_text):
200 | special_name = '@' + name
201 |
202 | return_value = zap_dict.get(name, None)
203 | if not return_value:
204 | return_value = zap_dict.get(special_name, default_text)
205 |
206 | return return_value
207 |
208 |
209 | def _get_treshold_risk_level(self):
210 | risk_level = self._config.get('accepted_risk_level', None)
211 |
212 | if risk_level is None:
213 | print('No acceptable risk level configured, defaulting to 0')
214 | return 0
215 |
216 | risk_level = int(risk_level)
217 |
218 | if risk_level > 3:
219 | print('Risk level is configured too high, maximizing at 3')
220 | return 3
221 |
222 | return risk_level
223 |
224 |
225 | def _get_required_confidence_level(self):
226 | confidence_level = self._config.get('required_confidence_level', None)
227 |
228 | if confidence_level is None:
229 | print('No required confidence level configured, defaulting to 0')
230 | return 0
231 |
232 | confidence_level = int(confidence_level)
233 |
234 | if confidence_level > 3:
235 | print('Confidence level is configured too high, maximizing at 3')
236 | return 3
237 |
238 | return confidence_level
239 |
--------------------------------------------------------------------------------
/tasks.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from pathlib import Path
4 | from platform import system
5 | from tempfile import mkstemp
6 | from textwrap import dedent
7 |
8 | from invoke import run, task
9 |
10 |
11 | CURDIR = Path.cwd()
12 | SRCPATH = CURDIR / 'src'
13 | UNIT_TESTS = CURDIR / 'tests'
14 | DUMMYHANDLERS = UNIT_TESTS / 'resources' / 'my_dummy_handlers'
15 |
16 | # If you want colored output for the tasks, use `run()` with `pty=True`
17 | # Not on Windows, though -- it'll fail if you have `pty=True`
18 |
19 | @task
20 | def clean(context):
21 | for path in (f'{SRCPATH / "robotframework_oxygen.egg-info"}',
22 | f'{CURDIR / "build"}',
23 | f'{CURDIR / "dist"}',
24 | f'{CURDIR / ".tox"}',
25 | f'{CURDIR / "htmlcov"}',
26 | f'{CURDIR / "log.html"}',
27 | f'{CURDIR / "report.html"}',
28 | f'{CURDIR / "output.xml"}',
29 | f'{CURDIR / "green-junit.xml"}'):
30 | run(f'rm -rf {path}')
31 | run(f'python {CURDIR / "setup.py"} clean')
32 |
33 | @task(pre=[clean])
34 | def install(context, package=None):
35 | run(f'pip install -r {CURDIR / "requirements.txt"}')
36 | if package:
37 | run(f'pip install {package}')
38 |
39 | @task(iterable=['test'],
40 | help={
41 | 'test': 'Limit unit test execution to specific tests. Must be given '
42 | 'multiple times to select several targets.'
43 | })
44 | def utest(context, test=None):
45 | run(f'pytest {" -k".join(test) if test else UNIT_TESTS} -q --disable-warnings',
46 | env={'PYTHONPATH': str(SRCPATH)},
47 | pty=(not system() == 'Windows'))
48 |
49 | @task
50 | def coverage(context):
51 | run(f'pytest --cov {UNIT_TESTS}',
52 | env={'PYTHONPATH': str(SRCPATH)},
53 | pty=(not system() == 'Windows'))
54 | run('coverage html')
55 |
56 | def _setup_atest():
57 | _, tempconf = mkstemp()
58 | with open(tempconf, 'w') as f:
59 | f.write('''dummy_handler_metadata:
60 | handler: MyDummyMetadataHandler
61 | keyword: run_metadata_dummy_handler
62 | tags: oxygen-metadata''')
63 | return (tempconf,
64 | os.pathsep.join([str(SRCPATH),
65 | str(DUMMYHANDLERS)]))
66 |
67 | @task(help={
68 | 'rf': 'Additional command-line arguments for Robot Framework as '
69 | 'single string. E.g: invoke atest --rf "--name my_suite"'
70 | })
71 | def atest(context, rf=''):
72 | tempconf, pythonpath = _setup_atest()
73 | run(f'python -m oxygen --add-config {tempconf}',
74 | env={'PYTHONPATH': pythonpath})
75 | try:
76 | run(f'robot '
77 | f'--pythonpath {str(SRCPATH)} '
78 | f'--pythonpath {str(DUMMYHANDLERS)} '
79 | f'--dotted '
80 | f'{rf} '
81 | f'--listener oxygen.listener '
82 | f'{str(CURDIR / "tests" / "atest")}',
83 | pty=(not system() == 'Windows'))
84 | finally:
85 | run('python -m oxygen --reset-config', env={'PYTHONPATH': pythonpath})
86 |
87 | @task
88 | def test(context):
89 | utest(context)
90 | atest(context)
91 |
92 | @task
93 | def update_oxygen_schema(context):
94 | import sys
95 | import json
96 | from pydantic import TypeAdapter
97 |
98 | sys.path.insert(0, str(SRCPATH))
99 | from oxygen.oxygen_handler_result import OxygenSuiteDict
100 |
101 | schema = TypeAdapter(OxygenSuiteDict).json_schema()
102 | out = json.dumps(schema, indent=2)
103 | with open(CURDIR / 'handler_result_specification.md', 'r+') as f:
104 | header = f.readline()
105 | f.seek(0)
106 | f.write(header)
107 | f.write(f'```\n{out}\n```')
108 | f.truncate()
109 | print('Updated schema')
110 |
111 | @task(pre=[update_oxygen_schema])
112 | def doc(context):
113 | doc_path = CURDIR / 'docs'
114 | if not doc_path.exists():
115 | run(f'mkdir {doc_path}')
116 | version = run('python -c "import oxygen; print(oxygen.__version__)"',
117 | env={'PYTHONPATH': str(SRCPATH)})
118 | version = version.stdout.strip()
119 | target = doc_path / f'OxygenLibrary-{version}.html'
120 | run(f'python -m robot.libdoc oxygen.OxygenLibrary {target}',
121 | env={'PYTHONPATH': str(SRCPATH)})
122 | run(f'cp {target} {doc_path / "index.html"}')
123 |
124 | @task(pre=[clean])
125 | def build(context):
126 | run(f'python -m build --wheel')
127 |
128 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eficode/robotframework-oxygen/93bc908a3d38006086d7b9832a63c54549a8d1f8/tests/__init__.py
--------------------------------------------------------------------------------
/tests/atest/metadata.robot:
--------------------------------------------------------------------------------
1 | *** Settings ***
2 | Library oxygen.OxygenLibrary
3 | Metadata RF suite This metadata comes from the suite file itself
4 |
5 | *** Test Cases ***
6 | Metadata returned by handler should be visible
7 | [Documentation] This test is replaced with fix results that have metadata in them
8 | Run Metadata Dummy Handler doesentmatter
9 |
10 |
--------------------------------------------------------------------------------
/tests/atest/oxygen_junit_tests.robot:
--------------------------------------------------------------------------------
1 | *** Settings ***
2 | Library oxygen.OxygenLibrary
3 | Library OperatingSystem
4 |
5 | *** Variables ***
6 | ${JUNIT XML FILE}= ${EXECDIR}${/}green-junit.xml
7 |
8 | *** Test Cases ***
9 | Oxygen's unit tests should pass
10 | [Documentation] Tests in test_oxygen_cli that run things in subprocesses
11 | ... *SHOULD* fail; Since `Run JUnit` runs things in
12 | ... subprocess itself, this is essentially running
13 | ... subprocesses within subprocesses all the way down.
14 | ...
15 | ... We can also inspect errors are reported correctly.
16 | [Tags] oxygen-own-junit
17 | Remove file ${JUNIT XML FILE}
18 | File should not exist ${JUNIT XML FILE}
19 | ${pytest}= Get command pytest
20 | Run JUnit ${JUNIT XML FILE}
21 | ... ${pytest} --junit-xml\=${JUNIT XML FILE} ${EXECDIR}
22 | File should exist ${JUNIT XML FILE}
23 |
24 | *** Keywords ***
25 | Get command
26 | [Arguments] ${program}
27 | ${locate}= Set variable if '${:}' == ';' where which
28 | Run keyword and return Run ${locate} ${program}
29 |
--------------------------------------------------------------------------------
/tests/atest/test.robot:
--------------------------------------------------------------------------------
1 | *** Settings ***
2 | Library oxygen.OxygenLibrary
3 |
4 | *** Variables ***
5 | ${RESOURCES} ${CURDIR}/../resources
6 |
7 | *** Test Cases ***
8 | My First Test
9 | [Tags] JUNIT_ROBOT_TAG
10 | Log Junit Setup Here 1
11 | Log Junit Setup Here 2
12 | run_junit
13 | ... ${RESOURCES}/junit.xml
14 | ... echo JUNIT_TEST_STRING
15 | Log Junit Teardown Here 1
16 | Log Junit Teardown Here 2
17 |
18 | My First Test Director's Cut
19 | [Tags] JUNIT_ROBOT_TAG
20 | Sleep 2
21 | Log Junit Setup Here 2
22 | run junit
23 | ... ${RESOURCES}/big.xml
24 | ... echo JUNIT_TEST_STRING_BIG
25 | Log Junit Teardown Here 1
26 | Log Junit Teardown Here 2
27 |
28 | My Second Test
29 | [Tags] GATLING_ROBOT_TAG
30 | Log Gatling Setup Here 1
31 | Log Gatling Setup Here 2
32 | run_gatling
33 | ... ${RESOURCES}/gatling-example-simulation.log
34 | ... echo GATLING TEST STRING
35 | Log Gatling Teardown Here 2
36 |
37 | My Third Test
38 | [Tags] ZAP_ROBOT_TAG
39 | Log ZAP Setup Here 1
40 | Log ZAP Setup Here 2
41 | run_zap
42 | ... ${RESOURCES}/zap/zap.xml.lol
43 | ... echo ZAP TEST STRING 2
44 | Log ZAP Teardown Here 1
45 | Log ZAP Teardown Here 2
46 |
47 | My Three Point Fifth Test
48 | [Tags] ZAP_ROBOT_TAG
49 | Log ZAP Setup Here 1
50 | Log ZAP Setup Here 2
51 | run_zap
52 | ... ${RESOURCES}/zap/zap_pp.json
53 | ... echo ZAP TEST STRING 3
54 | Log ZAP Teardown Here 1
55 | Log ZAP Teardown Here 2
56 |
57 | My Fourth Test
58 | [Tags] NO_OXYGEN_HERE
59 | Log Just a normal test
60 |
--------------------------------------------------------------------------------
/tests/atest/test_with_import.robot:
--------------------------------------------------------------------------------
1 | *** Settings ***
2 | Library oxygen.OxygenLibrary
3 | Library OperatingSystem
4 |
5 | *** Variables ***
6 | ${JUNIT XML FILE}= ${CURDIR}${/}..${/}resources${/}junit-single-testsuite.xml
7 |
8 | *** Test Cases ***
9 | Oxygen's unit test with dynamic import
10 | Import Library oxygen.OxygenLibrary
11 | Run JUnit ${JUNIT XML FILE}
12 | ... echo Run JUnit Dynamically Imported
13 |
14 | Oxygen's unit test with global import
15 | Run JUnit ${JUNIT XML FILE}
16 | ... echo Run JUnit Globally Imported
17 |
--------------------------------------------------------------------------------
/tests/atest/test_without_import.robot:
--------------------------------------------------------------------------------
1 | *** Test Cases ***
2 | Oxygen's unit test without import
3 | Log Some text
4 |
--------------------------------------------------------------------------------
/tests/resources/example_robot_output.xml:
--------------------------------------------------------------------------------
1 |
2 |
The AUTOCOMPLETE attribute is not disabled on an HTML FORM/INPUT element containing password type input. Passwords may be stored in browsers and retrieved.<\/p>","instances":[{"uri":"http://localhost:7272/","method":"GET","param":"password_field","evidence":""}],"count":"1","solution":"
Turn off the AUTOCOMPLETE attribute in forms or individual input elements containing password inputs by using AUTOCOMPLETE='OFF'.<\/p>","reference":"
http://www.w3schools.com/tags/att_input_autocomplete.asp<\/p>
https://msdn.microsoft.com/en-us/library/ms533486%28v=vs.85%29.aspx<\/p>","cweid":"525","wascid":"15","sourceid":"3"},{"pluginid":"10020","alert":"X-Frame-Options Header Not Set","name":"X-Frame-Options Header Not Set","riskcode":"2","confidence":"2","riskdesc":"Medium (Medium)","desc":"
X-Frame-Options header is not included in the HTTP response to protect against 'ClickJacking' attacks.<\/p>","instances":[{"uri":"http://localhost:7272/","method":"GET","param":"X-Frame-Options"}],"count":"1","solution":"
Most modern Web browsers support the X-Frame-Options HTTP header. Ensure it's set on all web pages returned by your site (if you expect the page to be framed only by pages on your server (e.g. it's part of a FRAMESET) then you'll want to use SAMEORIGIN, otherwise if you never expect the page to be framed, you should use DENY. ALLOW-FROM allows specific websites to frame the web page in supported web browsers).<\/p>","reference":"
http://blogs.msdn.com/b/ieinternals/archive/2010/03/30/combating-clickjacking-with-x-frame-options.aspx<\/p>","cweid":"16","wascid":"15","sourceid":"3"},{"pluginid":"10016","alert":"Web Browser XSS Protection Not Enabled","name":"Web Browser XSS Protection Not Enabled","riskcode":"1","confidence":"2","riskdesc":"Low (Medium)","desc":"
Web Browser XSS Protection is not enabled, or is disabled by the configuration of the 'X-XSS-Protection' HTTP response header on the web server<\/p>","instances":[{"uri":"http://localhost:7272/","method":"GET","param":"X-XSS-Protection"}],"count":"1","solution":"
Ensure that the web browser's XSS filter is enabled, by setting the X-XSS-Protection HTTP response header to '1'.<\/p>","otherinfo":"
The X-XSS-Protection HTTP response header allows the web server to enable or disable the web browser's XSS protection mechanism. The following values would attempt to enable it: <\/p>
X-XSS-Protection: 1; mode=block<\/p>
X-XSS-Protection: 1; report=http://www.example.com/xss<\/p>
The following values would disable it:<\/p>
X-XSS-Protection: 0<\/p>
The X-XSS-Protection HTTP response header is currently supported on Internet Explorer, Chrome and Safari (WebKit).<\/p>
Note that this alert is only raised if the response body could potentially contain an XSS payload (with a text-based content type, with a non-zero length).<\/p>","reference":"
https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet<\/p>
https://blog.veracode.com/2014/03/guidelines-for-setting-security-headers/<\/p>","cweid":"933","wascid":"14","sourceid":"3"},{"pluginid":"10021","alert":"X-Content-Type-Options Header Missing","name":"X-Content-Type-Options Header Missing","riskcode":"1","confidence":"2","riskdesc":"Low (Medium)","desc":"
The Anti-MIME-Sniffing header X-Content-Type-Options was not set to 'nosniff'. This allows older versions of Internet Explorer and Chrome to perform MIME-sniffing on the response body, potentially causing the response body to be interpreted and displayed as a content type other than the declared content type. Current (early 2014) and legacy versions of Firefox will use the declared content type (if one is set), rather than performing MIME-sniffing.<\/p>","instances":[{"uri":"http://localhost:7272/","method":"GET","param":"X-Content-Type-Options"},{"uri":"http://localhost:7272/demo.css","method":"GET","param":"X-Content-Type-Options"}],"count":"2","solution":"
Ensure that the application/web server sets the Content-Type header appropriately, and that it sets the X-Content-Type-Options header to 'nosniff' for all web pages.<\/p>
If possible, ensure that the end user uses a standards-compliant and modern web browser that does not perform MIME-sniffing at all, or that can be directed by the web application/web server to not perform MIME-sniffing.<\/p>","otherinfo":"
This issue still applies to error type pages (401, 403, 500, etc) as those pages are often still affected by injection issues, in which case there is still concern for browsers sniffing pages away from their actual content type.<\/p>
At \"High\" threshold this scanner will not alert on client or server error responses.<\/p>","reference":"
http://msdn.microsoft.com/en-us/library/ie/gg622941%28v=vs.85%29.aspx<\/p>
https://www.owasp.org/index.php/List_of_useful_HTTP_headers<\/p>","cweid":"16","wascid":"15","sourceid":"3"}]},{"@name":"http://127.0.0.1:7272","@host":"127.0.0.1","@port":"7272","@ssl":"false","alerts":[{"pluginid":"10021","alert":"X-Content-Type-Options Header Missing","name":"X-Content-Type-Options Header Missing","riskcode":"1","confidence":"2","riskdesc":"Low (Medium)","desc":"
The Anti-MIME-Sniffing header X-Content-Type-Options was not set to 'nosniff'. This allows older versions of Internet Explorer and Chrome to perform MIME-sniffing on the response body, potentially causing the response body to be interpreted and displayed as a content type other than the declared content type. Current (early 2014) and legacy versions of Firefox will use the declared content type (if one is set), rather than performing MIME-sniffing.<\/p>","instances":[{"uri":"http://127.0.0.1:7272/demo.css","method":"GET","param":"X-Content-Type-Options"},{"uri":"http://127.0.0.1:7272/welcome.html","method":"GET","param":"X-Content-Type-Options"},{"uri":"http://127.0.0.1:7272/","method":"GET","param":"X-Content-Type-Options"},{"uri":"http://127.0.0.1:7272/error.html","method":"GET","param":"X-Content-Type-Options"},{"uri":"http://127.0.0.1:7272","method":"GET","param":"X-Content-Type-Options"}],"count":"5","solution":"
Ensure that the application/web server sets the Content-Type header appropriately, and that it sets the X-Content-Type-Options header to 'nosniff' for all web pages.<\/p>
If possible, ensure that the end user uses a standards-compliant and modern web browser that does not perform MIME-sniffing at all, or that can be directed by the web application/web server to not perform MIME-sniffing.<\/p>","otherinfo":"
This issue still applies to error type pages (401, 403, 500, etc) as those pages are often still affected by injection issues, in which case there is still concern for browsers sniffing pages away from their actual content type.<\/p>
At \"High\" threshold this scanner will not alert on client or server error responses.<\/p>","reference":"
http://msdn.microsoft.com/en-us/library/ie/gg622941%28v=vs.85%29.aspx<\/p>
https://www.owasp.org/index.php/List_of_useful_HTTP_headers<\/p>","cweid":"16","wascid":"15","sourceid":"3"},{"pluginid":"10016","alert":"Web Browser XSS Protection Not Enabled","name":"Web Browser XSS Protection Not Enabled","riskcode":"1","confidence":"2","riskdesc":"Low (Medium)","desc":"
Web Browser XSS Protection is not enabled, or is disabled by the configuration of the 'X-XSS-Protection' HTTP response header on the web server<\/p>","instances":[{"uri":"http://127.0.0.1:7272/favicon.ico","method":"GET","param":"X-XSS-Protection"},{"uri":"http://127.0.0.1:7272/","method":"GET","param":"X-XSS-Protection"},{"uri":"http://127.0.0.1:7272","method":"GET","param":"X-XSS-Protection"},{"uri":"http://127.0.0.1:7272/sitemap.xml","method":"GET","param":"X-XSS-Protection"},{"uri":"http://127.0.0.1:7272/welcome.html","method":"GET","param":"X-XSS-Protection"},{"uri":"http://127.0.0.1:7272/error.html","method":"GET","param":"X-XSS-Protection"},{"uri":"http://127.0.0.1:7272/robots.txt","method":"GET","param":"X-XSS-Protection"}],"count":"7","solution":"
Ensure that the web browser's XSS filter is enabled, by setting the X-XSS-Protection HTTP response header to '1'.<\/p>","otherinfo":"
The X-XSS-Protection HTTP response header allows the web server to enable or disable the web browser's XSS protection mechanism. The following values would attempt to enable it: <\/p>
X-XSS-Protection: 1; mode=block<\/p>
X-XSS-Protection: 1; report=http://www.example.com/xss<\/p>
The following values would disable it:<\/p>
X-XSS-Protection: 0<\/p>
The X-XSS-Protection HTTP response header is currently supported on Internet Explorer, Chrome and Safari (WebKit).<\/p>
Note that this alert is only raised if the response body could potentially contain an XSS payload (with a text-based content type, with a non-zero length).<\/p>","reference":"
https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet<\/p>
https://blog.veracode.com/2014/03/guidelines-for-setting-security-headers/<\/p>","cweid":"933","wascid":"14","sourceid":"3"},{"pluginid":"10020","alert":"X-Frame-Options Header Not Set","name":"X-Frame-Options Header Not Set","riskcode":"2","confidence":"2","riskdesc":"Medium (Medium)","desc":"
X-Frame-Options header is not included in the HTTP response to protect against 'ClickJacking' attacks.<\/p>","instances":[{"uri":"http://127.0.0.1:7272/","method":"GET","param":"X-Frame-Options"},{"uri":"http://127.0.0.1:7272","method":"GET","param":"X-Frame-Options"},{"uri":"http://127.0.0.1:7272/welcome.html","method":"GET","param":"X-Frame-Options"},{"uri":"http://127.0.0.1:7272/error.html","method":"GET","param":"X-Frame-Options"}],"count":"4","solution":"
Most modern Web browsers support the X-Frame-Options HTTP header. Ensure it's set on all web pages returned by your site (if you expect the page to be framed only by pages on your server (e.g. it's part of a FRAMESET) then you'll want to use SAMEORIGIN, otherwise if you never expect the page to be framed, you should use DENY. ALLOW-FROM allows specific websites to frame the web page in supported web browsers).<\/p>","reference":"
http://blogs.msdn.com/b/ieinternals/archive/2010/03/30/combating-clickjacking-with-x-frame-options.aspx<\/p>","cweid":"16","wascid":"15","sourceid":"3"},{"pluginid":"10012","alert":"Password Autocomplete in Browser","name":"Password Autocomplete in Browser","riskcode":"1","confidence":"2","riskdesc":"Low (Medium)","desc":"
The AUTOCOMPLETE attribute is not disabled on an HTML FORM/INPUT element containing password type input. Passwords may be stored in browsers and retrieved.<\/p>","instances":[{"uri":"http://127.0.0.1:7272","method":"GET","param":"password_field","evidence":""},{"uri":"http://127.0.0.1:7272/","method":"GET","param":"password_field","evidence":""}],"count":"2","solution":"
Turn off the AUTOCOMPLETE attribute in forms or individual input elements containing password inputs by using AUTOCOMPLETE='OFF'.<\/p>","reference":"
http://www.w3schools.com/tags/att_input_autocomplete.asp<\/p>
https://msdn.microsoft.com/en-us/library/ms533486%28v=vs.85%29.aspx<\/p>","cweid":"525","wascid":"15","sourceid":"3"}]},{"@name":"http://detectportal.firefox.com","@host":"detectportal.firefox.com","@port":"80","@ssl":"false","alerts":[{"pluginid":"10021","alert":"X-Content-Type-Options Header Missing","name":"X-Content-Type-Options Header Missing","riskcode":"1","confidence":"2","riskdesc":"Low (Medium)","desc":"
The Anti-MIME-Sniffing header X-Content-Type-Options was not set to 'nosniff'. This allows older versions of Internet Explorer and Chrome to perform MIME-sniffing on the response body, potentially causing the response body to be interpreted and displayed as a content type other than the declared content type. Current (early 2014) and legacy versions of Firefox will use the declared content type (if one is set), rather than performing MIME-sniffing.<\/p>","instances":[{"uri":"http://detectportal.firefox.com/success.txt","method":"GET","param":"X-Content-Type-Options"}],"count":"1","solution":"
Ensure that the application/web server sets the Content-Type header appropriately, and that it sets the X-Content-Type-Options header to 'nosniff' for all web pages.<\/p>
If possible, ensure that the end user uses a standards-compliant and modern web browser that does not perform MIME-sniffing at all, or that can be directed by the web application/web server to not perform MIME-sniffing.<\/p>","otherinfo":"
This issue still applies to error type pages (401, 403, 500, etc) as those pages are often still affected by injection issues, in which case there is still concern for browsers sniffing pages away from their actual content type.<\/p>
At \"High\" threshold this scanner will not alert on client or server error responses.<\/p>","reference":"
http://msdn.microsoft.com/en-us/library/ie/gg622941%28v=vs.85%29.aspx<\/p>
https://www.owasp.org/index.php/List_of_useful_HTTP_headers<\/p>","cweid":"16","wascid":"15","sourceid":"3"}]}]} -------------------------------------------------------------------------------- /tests/utest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-oxygen/93bc908a3d38006086d7b9832a63c54549a8d1f8/tests/utest/__init__.py -------------------------------------------------------------------------------- /tests/utest/base_handler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-oxygen/93bc908a3d38006086d7b9832a63c54549a8d1f8/tests/utest/base_handler/__init__.py -------------------------------------------------------------------------------- /tests/utest/base_handler/test_errors.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from oxygen.base_handler import BaseHandler 4 | from ..helpers import get_config 5 | 6 | class TestBaseHandlerErrors(TestCase): 7 | def test_parse_results_raises_an_error(self): 8 | '''It is intentional that parse_results() raises an error. 9 | 10 | It should never be directly invoked from BaseHandler, as it should 11 | always be specific to implementing plugin. 12 | ''' 13 | with self.assertRaises(NotImplementedError): 14 | BaseHandler(get_config()['oxygen.junit']).parse_results('whatever') 15 | -------------------------------------------------------------------------------- /tests/utest/base_handler/test_inject_suite_report.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock 3 | 4 | from oxygen.base_handler import BaseHandler 5 | from ..helpers import get_config 6 | 7 | 8 | class TestInjectSuiteReport(TestCase): 9 | def setUp(self): 10 | self.handler = BaseHandler(get_config()['oxygen.junit']) 11 | 12 | self.test = MagicMock() 13 | self.parent = MagicMock() 14 | self.traveller = MagicMock() 15 | self.suites = MagicMock() 16 | self.suites.append = MagicMock() 17 | self.suite = MagicMock() 18 | 19 | self.test.parent = self.parent 20 | self.parent.tests = [1, 2, self.test, 3] 21 | self.parent.parent = self.traveller 22 | self.traveller.parent = None 23 | self.traveller.suites = self.suites 24 | 25 | def test_finds_and_appends(self): 26 | self.handler._inject_suite_report(self.test, self.suite) 27 | self.test.parent.suites.append.assert_called_once_with(self.suite) 28 | 29 | def test_finds_and_filters(self): 30 | self.handler._inject_suite_report(self.test, self.suite) 31 | self.assertEqual(self.parent.tests, [1, 2, 3]) 32 | -------------------------------------------------------------------------------- /tests/utest/base_handler/test_normalize_keyword_name.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from oxygen.base_handler import BaseHandler 4 | from ..helpers import get_config 5 | 6 | 7 | class TestNormalizeKeywordName(TestCase): 8 | 9 | def test_normalize(self): 10 | self.bh = BaseHandler(get_config()['oxygen.gatling']) 11 | unnormalized = 'Suite 1 . Suite 2 . My KeyWord NAME ' 12 | self.assertEqual(self.bh._normalize_keyword_name(unnormalized), 13 | 'my_keyword_name') 14 | -------------------------------------------------------------------------------- /tests/utest/base_handler/test_set_suite_tags.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock 3 | 4 | from oxygen.base_handler import BaseHandler 5 | from ..helpers import get_config 6 | 7 | 8 | class TestSetSuiteTags(TestCase): 9 | def setUp(self): 10 | self.object = BaseHandler(get_config()['oxygen.junit']) 11 | 12 | self.suite = MagicMock() 13 | self.suite.set_tags = MagicMock() 14 | self.tags = (MagicMock(), MagicMock()) 15 | 16 | def test_tags_are_set(self): 17 | self.object._set_suite_tags(self.suite, *self.tags) 18 | self.suite.set_tags.assert_called_once_with(self.tags) 19 | -------------------------------------------------------------------------------- /tests/utest/gatling/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-oxygen/93bc908a3d38006086d7b9832a63c54549a8d1f8/tests/utest/gatling/__init__.py -------------------------------------------------------------------------------- /tests/utest/gatling/test_basic_functionality.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest import skip, TestCase 3 | from unittest.mock import ANY, create_autospec, Mock, patch 4 | 5 | from testfixtures import compare 6 | 7 | from oxygen.base_handler import BaseHandler 8 | from oxygen.gatling import GatlingHandler 9 | from oxygen.errors import GatlingHandlerException 10 | from oxygen.oxygen_handler_result import validate_oxygen_suite 11 | from ..helpers import (example_robot_output, 12 | GATLING_EXPECTED_OUTPUT, 13 | get_config, 14 | RESOURCES_PATH) 15 | 16 | class GatlingBasicTests(TestCase): 17 | 18 | def setUp(self): 19 | self.handler = GatlingHandler(get_config()['oxygen.gatling']) 20 | 21 | def test_initialization(self): 22 | self.assertEqual(self.handler.keyword, 'run_gatling') 23 | self.assertEqual(self.handler._tags, ['GATLING']) 24 | 25 | @patch('oxygen.gatling.GatlingHandler._transform_tests') 26 | @patch('oxygen.gatling.validate_path') 27 | def test_parsing(self, mock_validate_path, mock_transform): 28 | m = create_autospec(Path) 29 | mock_validate_path.return_value = m 30 | self.handler.parse_results('some/file/path.ext') 31 | mock_validate_path.assert_called_once_with('some/file/path.ext') 32 | mock_transform.assert_called_once_with(m.resolve()) 33 | 34 | @patch('oxygen.utils.subprocess') 35 | def test_running(self, mock_subprocess): 36 | mock_subprocess.run.return_value = Mock(returncode=0) 37 | self.handler.run_gatling('somefile', 'some command') 38 | mock_subprocess.run.assert_called_once_with('some command', 39 | capture_output=True, 40 | shell=True, 41 | env=ANY) 42 | 43 | def subdict_in_parent_dict(self, parent_dict, subdict): 44 | return all( 45 | subitem in parent_dict.items() for subitem in subdict.items()) 46 | 47 | 48 | @patch('oxygen.utils.subprocess') 49 | def test_running_with_passing_environment_variables(self, mock_subprocess): 50 | mock_subprocess.run.return_value = Mock(returncode=0) 51 | self.handler.run_gatling('somefile', 'some command', ENV_VAR='hello') 52 | mock_subprocess.run.assert_called_once_with('some command', 53 | capture_output=True, 54 | shell=True, 55 | env=ANY) 56 | passed_full_env = mock_subprocess.run.call_args[-1]['env'] 57 | self.assertTrue(self.subdict_in_parent_dict(passed_full_env, 58 | {'ENV_VAR': 'hello'})) 59 | 60 | @patch('oxygen.utils.subprocess') 61 | def test_running_fails_correctly(self, mock_subprocess): 62 | mock_subprocess.run.return_value = Mock(returncode=255) 63 | with self.assertRaises(GatlingHandlerException): 64 | self.handler.run_gatling('somefile', 65 | 'some command', 66 | check_return_code=True) 67 | 68 | @patch('oxygen.utils.subprocess') 69 | def test_running_does_not_fail_by_default(self, mock_subprocess): 70 | mock_subprocess.run.return_value = Mock(returncode=255) 71 | retval = self.handler.run_gatling('somefile', 'some command') 72 | self.assertEqual(retval, 'somefile') 73 | 74 | def test_cli(self): 75 | self.assertEqual(self.handler.cli(), BaseHandler.DEFAULT_CLI) 76 | 77 | @patch('oxygen.gatling.GatlingHandler._report_oxygen_run') 78 | def test_check_for_keyword(self, mock_report): 79 | fake_test = example_robot_output().suite.suites[0].tests[2] 80 | expected_data = {'Atest.Test.My Second Test': 'somefile.lol'} 81 | 82 | self.handler.check_for_keyword(fake_test, expected_data) 83 | 84 | self.assertEqual(mock_report.call_args[0][0].name, 85 | 'oxygen.OxygenLibrary.Run Gatling') 86 | self.assertEqual(self.handler.run_time_data, 'somefile.lol') 87 | 88 | def test_gatling_parsing(self): 89 | example_file = RESOURCES_PATH / 'gatling-example-simulation.log' 90 | retval = self.handler._transform_tests(example_file) 91 | compare(retval, GATLING_EXPECTED_OUTPUT) 92 | self.assertTrue(validate_oxygen_suite(retval)) 93 | -------------------------------------------------------------------------------- /tests/utest/junit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-oxygen/93bc908a3d38006086d7b9832a63c54549a8d1f8/tests/utest/junit/__init__.py -------------------------------------------------------------------------------- /tests/utest/junit/test_basic_functionality.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest import skip, TestCase 3 | from unittest.mock import ANY, create_autospec, Mock, patch 4 | 5 | from junitparser import JUnitXml 6 | from testfixtures import compare 7 | 8 | from oxygen.base_handler import BaseHandler 9 | from oxygen.errors import JUnitHandlerException, ResultFileIsNotAFileException 10 | from oxygen.junit import JUnitHandler 11 | from oxygen.oxygen_handler_result import validate_oxygen_suite 12 | from ..helpers import example_robot_output, get_config, RESOURCES_PATH 13 | 14 | class JUnitBasicTests(TestCase): 15 | 16 | def setUp(self): 17 | self.handler = JUnitHandler(get_config()['oxygen.junit']) 18 | 19 | def test_initialization(self): 20 | self.assertEqual(self.handler.keyword, 'run_junit') 21 | self.assertEqual(self.handler._tags, ['JUNIT', 'EXTRA_JUNIT_CASE']) 22 | 23 | @patch('oxygen.junit.JUnitXml') 24 | @patch('oxygen.junit.JUnitHandler._transform_tests') 25 | @patch('oxygen.junit.validate_path') 26 | def test_parsing(self, mock_validate_path, mock_transform, mock_junitxml): 27 | m = create_autospec(Path) 28 | mock_validate_path.return_value = m 29 | mock_junitxml.fromfile.return_value = 'some junit' 30 | 31 | self.handler.parse_results('some/file/path.ext') 32 | 33 | mock_validate_path.assert_called_once_with('some/file/path.ext') 34 | mock_junitxml.fromfile.assert_called_once_with(m) 35 | mock_transform.assert_called_once_with('some junit') 36 | 37 | def test_result_file_is_not_a_string(self): 38 | with self.assertRaises(ResultFileIsNotAFileException) as ex: 39 | self.handler.parse_results(None) 40 | 41 | ex = str(ex.exception) 42 | self.assertIn('File "None" is not a file', ex) 43 | 44 | @patch('oxygen.utils.subprocess') 45 | def test_running(self, mock_subprocess): 46 | mock_subprocess.run.return_value = Mock(returncode=0) 47 | self.handler.run_junit('somefile', 'some command') 48 | mock_subprocess.run.assert_called_once_with('some command', 49 | capture_output=True, 50 | shell=True, 51 | env=ANY) 52 | 53 | def subdict_in_parent_dict(self, parent_dict, subdict): 54 | return all( 55 | subitem in parent_dict.items() for subitem in subdict.items()) 56 | 57 | @patch('oxygen.utils.subprocess') 58 | def test_running_with_passing_environment_variables(self, mock_subprocess): 59 | mock_subprocess.run.return_value = Mock(returncode=0) 60 | self.handler.run_junit('somefile', 'some command', env_var='value') 61 | mock_subprocess.run.assert_called_once_with('some command', 62 | capture_output=True, 63 | shell=True, 64 | env=ANY) 65 | passed_full_env = mock_subprocess.run.call_args[-1]['env'] 66 | self.assertTrue(self.subdict_in_parent_dict(passed_full_env, 67 | {'env_var': 'value'})) 68 | 69 | @patch('oxygen.utils.subprocess') 70 | def test_running_fails_correctly(self, mock_subprocess): 71 | mock_subprocess.run.return_value = Mock(returncode=255) 72 | with self.assertRaises(JUnitHandlerException): 73 | self.handler.run_junit('somefile', 74 | 'some command', 75 | check_return_code=True) 76 | 77 | @patch('oxygen.utils.subprocess') 78 | def test_running_does_not_fail_by_default(self, mock_subprocess): 79 | mock_subprocess.run.return_value = Mock(returncode=255) 80 | retval = self.handler.run_junit('somefile', 'some command') 81 | self.assertEqual(retval, 'somefile') 82 | 83 | def test_cli(self): 84 | self.assertEqual(self.handler.cli(), BaseHandler.DEFAULT_CLI) 85 | 86 | @patch('oxygen.junit.JUnitHandler._report_oxygen_run') 87 | def test_check_for_keyword(self, mock_report): 88 | fake_test = example_robot_output().suite.suites[0].tests[0] 89 | expected_data = {'Atest.Test.My First Test': '/some/path/to.ext'} 90 | 91 | self.handler.check_for_keyword(fake_test, expected_data) 92 | 93 | self.assertEqual(mock_report.call_args[0][0].name, 94 | 'oxygen.OxygenLibrary.Run Junit') 95 | self.assertEqual(self.handler.run_time_data, '/some/path/to.ext') 96 | 97 | def test_transform_tests_with_single_test_suite(self): 98 | expected_output = { 99 | 'name': 'JUnit Execution', 100 | 'suites': [{'name': 'com.example.demo.DemoApplicationTests', 101 | 'suites': [], 102 | 'tags': [], 103 | 'tests': [{'keywords': [{'elapsed': 454.0, 104 | 'keywords': [], 105 | 'messages': [], 106 | 'name': 'contextLoads() (Execution)', 107 | 'pass': True}], 108 | 'name': 'contextLoads()', 109 | 'tags': []}]}], 110 | 'tags': ['JUNIT', 'EXTRA_JUNIT_CASE'], 111 | } 112 | xml = JUnitXml.fromfile(str(RESOURCES_PATH / 'junit-single-testsuite.xml')) 113 | retval = self.handler._transform_tests(xml) 114 | compare(retval, expected_output) 115 | 116 | def test_transform_tests_with_multiple_suites(self): 117 | expected_output = { 118 | 'name': 'JUnit Execution', 119 | 'tags': ['JUNIT', 'EXTRA_JUNIT_CASE'], 120 | 'suites': [{ 121 | 'name': 'suite1', 122 | 'tags': [], 123 | 'suites': [{ 124 | 'name': 'suite2', 125 | 'tags': [], 126 | 'suites': [], 127 | 'tests': [{ 128 | 'name': 'casea', 129 | 'tags': ['oxygen-junit-unknown-execution-time'], 130 | 'keywords': [{'name': 'casea (Execution)', 131 | 'pass': True, 132 | 'messages': [], 133 | 'keywords': [], 134 | 'elapsed': 0.0}] 135 | }, { 136 | 'name': 'caseb', 137 | 'tags': ['oxygen-junit-unknown-execution-time'], 138 | 'keywords': [{ 139 | 'name': 'caseb (Execution)', 140 | 'pass': True, 141 | 'messages': [], 142 | 'elapsed': 0.0, 143 | 'keywords': [] 144 | }] 145 | }] 146 | }], 147 | 'tests': [{ 148 | 'name': 'case1', 149 | 'tags': ['oxygen-junit-unknown-execution-time'], 150 | 'keywords': [{ 151 | 'name': 'case1 (Execution)', 152 | 'pass': True, 153 | 'messages': [], 154 | 'keywords': [], 155 | 'elapsed': 0.0 156 | }] 157 | }, { 158 | 'name': 'case2', 159 | 'tags': ['oxygen-junit-unknown-execution-time'], 160 | 'keywords': [{ 161 | 'name': 'case2 (Execution)', 162 | 'pass': False, 163 | 'messages': [ 164 | 'ERROR: Example error message (the_error_type)' 165 | ], 166 | 'keywords': [], 167 | 'elapsed': 0.0 168 | }] 169 | }, { 170 | 'name': 'case3', 171 | 'tags': ['oxygen-junit-unknown-execution-time'], 172 | 'keywords': [{ 173 | 'name': 'case3 (Execution)', 174 | 'pass': False, 175 | 'messages': [ 176 | 'FAIL: Example failure message (the_failure_type)' 177 | ], 178 | 'keywords': [], 179 | 'elapsed': 0.0 180 | }] 181 | }] 182 | }], 183 | } 184 | xml = JUnitXml.fromfile(RESOURCES_PATH / 'junit.xml') 185 | retval = self.handler._transform_tests(xml) 186 | compare(retval, expected_output) 187 | self.assertTrue(validate_oxygen_suite(retval)) 188 | -------------------------------------------------------------------------------- /tests/utest/my_dummy_handler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-oxygen/93bc908a3d38006086d7b9832a63c54549a8d1f8/tests/utest/my_dummy_handler/__init__.py -------------------------------------------------------------------------------- /tests/utest/my_dummy_handler/test_basic_functionality.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest import TestCase 3 | from oxygen.errors import MismatchArgumentException 4 | from ..helpers import RESOURCES_PATH, get_config, example_robot_output 5 | 6 | sys.path.append(str(RESOURCES_PATH / 'my_dummy_handlers')) 7 | 8 | from dummy_handler_single_arg import ( 9 | MyDummyHandler as DummyHandlerSingleArg, 10 | ) 11 | from dummy_handler_multiple_args import ( 12 | MyDummyHandler as DummyHandlerMultipleArgs, 13 | ) 14 | from dummy_handler_multiple_args_too_few import ( 15 | MyDummyHandler as DummyHandlerMultipleArgsTooFew, 16 | ) 17 | from dummy_handler_default_params import ( 18 | MyDummyHandler as DummyHandlerDefaultParams, 19 | ) 20 | 21 | 22 | class DummyHandlerSingleArgTests(TestCase): 23 | ''' 24 | A test for passing tuple if parse_results accepts one parameter 25 | ''' 26 | 27 | def setUp(self): 28 | self.handler = DummyHandlerSingleArg( 29 | get_config()['oxygen.my_dummy_handler'] 30 | ) 31 | 32 | def test_run_my_dummy_handler(self): 33 | return_value = self.handler.run_my_dummy_handler('/some/path/to.ext') 34 | self.assertTupleEqual(return_value, ('/some/path/to.ext', 'foo')) 35 | 36 | def test_parse_results(self): 37 | fake_test = example_robot_output().suite.suites[0].tests[6] 38 | expected_data = {'Atest.Test.My Fifth Test': '/some/path/to.ext'} 39 | 40 | self.handler.check_for_keyword(fake_test, expected_data) 41 | 42 | self.assertEqual(self.handler.run_time_data, '/some/path/to.ext') 43 | 44 | 45 | class DummyHandlerMultipleArgsTests(TestCase): 46 | ''' 47 | A test for unfolding parse_results arguments 48 | if it has multiple parameters 49 | ''' 50 | 51 | def setUp(self): 52 | self.handler = DummyHandlerMultipleArgs( 53 | get_config()['oxygen.my_dummy_handler'] 54 | ) 55 | 56 | def test_parse_results(self): 57 | fake_test = example_robot_output().suite.suites[0].tests[6] 58 | expected_data = { 59 | 'Atest.Test.My Fifth Test': ('/some/path/to.ext', 'foo') 60 | } 61 | 62 | self.handler.check_for_keyword(fake_test, expected_data) 63 | 64 | self.assertEqual( 65 | self.handler.run_time_data, ('/some/path/to.ext', 'foo') 66 | ) 67 | 68 | 69 | class DummyHandlerMultipleArgsTooFewTests(TestCase): 70 | ''' 71 | A test for testing if it throws mismatch argument exception because 72 | parse_results expects too many arguments 73 | ''' 74 | 75 | def setUp(self): 76 | self.handler = DummyHandlerMultipleArgsTooFew( 77 | get_config()['oxygen.my_dummy_handler'] 78 | ) 79 | 80 | def test_parse_results(self): 81 | fake_test = example_robot_output().suite.suites[0].tests[6] 82 | expected_data = { 83 | 'Atest.Test.My Fifth Test': ('/some/path/to.ext', 'foo') 84 | } 85 | 86 | self.assertRaises( 87 | MismatchArgumentException, 88 | self.handler.check_for_keyword, 89 | fake_test, 90 | expected_data, 91 | ) 92 | 93 | 94 | class DummyHandlerMultipleArgsSingleTests(TestCase): 95 | ''' 96 | A test for testing if it throws mismatch argument exception because 97 | parse_results expects multiple arguments but we do not pass multiple 98 | ''' 99 | 100 | def setUp(self): 101 | self.handler = DummyHandlerMultipleArgsTooFew( 102 | get_config()['oxygen.my_dummy_handler'] 103 | ) 104 | 105 | def test_parse_results(self): 106 | fake_test = example_robot_output().suite.suites[0].tests[6] 107 | expected_data = {'Atest.Test.My Fifth Test': 'some/path/to.ext'} 108 | 109 | self.assertRaises( 110 | MismatchArgumentException, 111 | self.handler.check_for_keyword, 112 | fake_test, 113 | expected_data, 114 | ) 115 | 116 | 117 | class DummyHandlerDefaultParamsTests(TestCase): 118 | ''' 119 | A test for testing arguments with defaults 120 | ''' 121 | 122 | def setUp(self): 123 | self.handler = DummyHandlerDefaultParams( 124 | get_config()['oxygen.my_dummy_handler'] 125 | ) 126 | 127 | def test_parse_results_with_one(self): 128 | fake_test = example_robot_output().suite.suites[0].tests[6] 129 | expected_data = {'Atest.Test.My Fifth Test': 'some/path/to.ext'} 130 | self.handler.check_for_keyword(fake_test, expected_data) 131 | self.assertEqual(self.handler.run_time_data, 'some/path/to.ext') 132 | 133 | def test_parse_results_with_multiple(self): 134 | fake_test = example_robot_output().suite.suites[0].tests[6] 135 | expected_data = { 136 | 'Atest.Test.My Fifth Test': ('some/path/to.ext', 'foo')} 137 | self.handler.check_for_keyword(fake_test, expected_data) 138 | self.assertTupleEqual( 139 | self.handler.run_time_data, ('some/path/to.ext', 'foo')) 140 | 141 | def test_parse_results_with_too_many(self): 142 | fake_test = example_robot_output().suite.suites[0].tests[6] 143 | expected_data = { 144 | 'Atest.Test.My Fifth Test': ('some/path/to.ext', 'foo', 'bar')} 145 | self.assertRaises( 146 | MismatchArgumentException, 147 | self.handler.check_for_keyword, 148 | fake_test, 149 | expected_data, 150 | ) 151 | -------------------------------------------------------------------------------- /tests/utest/oxygen/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-oxygen/93bc908a3d38006086d7b9832a63c54549a8d1f8/tests/utest/oxygen/__init__.py -------------------------------------------------------------------------------- /tests/utest/oxygen/test_oxygen_cli.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser, Namespace 2 | from pathlib import Path 3 | from subprocess import check_output, run, STDOUT, CalledProcessError 4 | from tempfile import mkstemp 5 | from unittest import TestCase 6 | from unittest.mock import ANY, create_autospec, patch, Mock 7 | from xml.etree import ElementTree 8 | 9 | from robot.running.model import TestSuite 10 | 11 | from oxygen.oxygen import OxygenCLI, OxygenCore 12 | from oxygen.config import CONFIG_FILE, ORIGINAL_CONFIG_FILE 13 | 14 | from ..helpers import RESOURCES_PATH 15 | 16 | 17 | class TestOxygenCLIEntryPoints(TestCase): 18 | '''Coverage does not measure coverage correctly for these tests. 19 | 20 | We have tests for __main__ entrypoint below, but Coverage is unable to 21 | do its measurements as they are running in subprocesses. They are run in 22 | subprocesses to simulate real command line usage. 23 | 24 | Setting up Coverage to see subprocesses as well seems a lot of work and 25 | quite a hack: https://coverage.readthedocs.io/en/latest/subprocess.html 26 | ''' 27 | 28 | @classmethod 29 | def tearDownClass(cls): 30 | with open(ORIGINAL_CONFIG_FILE, 'r') as og: 31 | with open(CONFIG_FILE, 'w') as config: 32 | config.write(og.read()) 33 | 34 | def tearDown(self): 35 | self.tearDownClass() 36 | 37 | def test_main_level_entrypoint(self): 38 | self.verify_cli_help_text('python -m oxygen --help') 39 | self.verify_cli_help_text('python -m oxygen -h') 40 | 41 | def test_direct_module_entrypoint(self): 42 | self.verify_cli_help_text('python -m oxygen.oxygen --help') 43 | self.verify_cli_help_text('python -m oxygen.oxygen -h') 44 | 45 | def test_cli_with_no_args(self): 46 | proc = run('python -m oxygen', 47 | shell=True, 48 | text=True, 49 | capture_output=True) 50 | self.assertEqual(proc.returncode, 2) 51 | self.assertIn('usage: oxygen', proc.stderr) 52 | 53 | def _run(self, cmd): 54 | try: 55 | return check_output(cmd, text=True, shell=True, stderr=STDOUT) 56 | except CalledProcessError as e: 57 | print(e.output) # with this, you can actually see the command 58 | raise # output, ie. why it failed 59 | 60 | def verify_cli_help_text(self, cmd): 61 | out = self._run(cmd) 62 | self.assertIn('usage: oxygen', out) 63 | self.assertIn('-h, --help', out) 64 | 65 | def test_junit_works_on_cli(self): 66 | target = RESOURCES_PATH / 'green-junit-example.xml' 67 | example = target.with_name('green-junit-expected-robot-output.xml') 68 | actual = target.with_name('green-junit-example_robot_output.xml') 69 | if actual.exists(): 70 | actual.unlink() # delete file if exists 71 | 72 | self._run(f'python -m oxygen oxygen.junit {target}') 73 | 74 | example_xml = ElementTree.parse(example).getroot() 75 | actual_xml = ElementTree.parse(actual).getroot() 76 | 77 | # RF<4 has multiple `stat` elements therefore we use the double slash 78 | # and a filter to single out the `stat` element with text "All Tests" 79 | all_tests_stat_block = './statistics/total//stat[.="All Tests"]' 80 | 81 | example_stat = example_xml.find(all_tests_stat_block) 82 | actual_stat = actual_xml.find(all_tests_stat_block) 83 | 84 | self.assertEqual(example_stat.get('pass'), actual_stat.get('pass')) 85 | self.assertEqual(example_stat.get('fail'), actual_stat.get('fail')) 86 | 87 | def _validate_handler_names(self, text): 88 | for handler in ('JUnitHandler', 'GatlingHandler', 'ZAProxyHandler'): 89 | self.assertIn(handler, text) 90 | 91 | def test_reset_config(self): 92 | with open(CONFIG_FILE, 'w') as f: 93 | f.write('complete: gibberish') 94 | 95 | self._run(f'python -m oxygen --reset-config') 96 | 97 | with open(CONFIG_FILE, 'r') as f: 98 | config_content = f.read() 99 | self.assertNotIn('complete: gibberish', config_content) 100 | self._validate_handler_names(config_content) 101 | 102 | def test_print_config(self): 103 | out = self._run('python -m oxygen --print-config') 104 | 105 | self.assertIn('Using config file', out) 106 | self._validate_handler_names(out) 107 | 108 | def _make_test_config(self): 109 | _, filepath = mkstemp() 110 | with open(filepath, 'w') as f: 111 | f.write('complete: gibberish') 112 | return filepath 113 | 114 | def test_add_config(self): 115 | filepath = self._make_test_config() 116 | 117 | self._run(f'python -m oxygen --add-config {filepath}') 118 | 119 | with open(CONFIG_FILE, 'r') as f: 120 | config_content = f.read() 121 | self._validate_handler_names(config_content) 122 | self.assertIn('complete: gibberish', config_content) 123 | 124 | def _is_file_content(self, filepath, text): 125 | with open(filepath, 'r') as f: 126 | return bool(text in f.read()) 127 | 128 | def test_main_level_args_override_handler_args(self): 129 | filepath = self._make_test_config() 130 | 131 | cmd = ('python -m oxygen {main_level_arg} ' 132 | f'oxygen.junit {RESOURCES_PATH / "green-junit-example.xml"}') 133 | 134 | self._run(cmd.format(main_level_arg=f'--add-config {filepath}')) 135 | self.assertTrue(self._is_file_content(CONFIG_FILE, 'complete: gibberish')) 136 | 137 | self._run(cmd.format(main_level_arg='--reset-config')) 138 | self.assertFalse(self._is_file_content(CONFIG_FILE, 139 | 'complete: gibberish')) 140 | 141 | 142 | out = self._run(cmd.format(main_level_arg='--print-config')) 143 | self._validate_handler_names(out) 144 | self.assertNotIn('gibberish', out) 145 | 146 | 147 | class TestOxygenCLI(TestCase): 148 | 149 | def setUp(self): 150 | self.cli = OxygenCLI() 151 | 152 | @patch('oxygen.oxygen.RobotInterface') 153 | @patch('oxygen.oxygen.OxygenCLI.parse_args') 154 | def test_run(self, mock_parse_args, mock_robot_iface): 155 | mock_parse_args.return_value = { 156 | 'result_file': 'path/to/file.xml', 157 | 'func': lambda *_, **__: {'some': 'results'}} 158 | expected_suite = create_autospec(TestSuite) 159 | mock = Mock() 160 | mock.running.build_suite = Mock(return_value=expected_suite) 161 | mock_robot_iface.return_value = mock 162 | 163 | self.cli.run() 164 | 165 | mock.running.build_suite.assert_called_once_with({'some': 'results'}) 166 | expected_suite.run.assert_called_once_with( 167 | output=str(Path('path/to/file_robot_output.xml')), 168 | log=None, 169 | report=None, 170 | stdout=ANY 171 | ) 172 | 173 | def test_parse_args(self): 174 | '''verifies that `parse_args()` returns a dictionary''' 175 | p = create_autospec(ArgumentParser) 176 | p.parse_args.return_value = create_autospec(Namespace) 177 | 178 | retval = self.cli.parse_args(p) 179 | 180 | self.assertIsInstance(retval, dict) 181 | 182 | def test_add_arguments(self): 183 | mock_parser = create_autospec(ArgumentParser) 184 | m = Mock() 185 | mock_parser.add_subparsers.return_value = m 186 | 187 | self.cli.add_arguments(mock_parser) 188 | 189 | # verify all main-level cli arguments were added 190 | self.assertEqual(len(mock_parser.add_argument.call_args_list), 4) 191 | # verify all built-in handlers were added 192 | self.assertEqual(len(m.add_parser.call_args_list), 3) 193 | 194 | def _actual(self, path): 195 | return self.cli.get_output_filename(path) 196 | 197 | def _expected(self, path): 198 | return str(Path(path)) 199 | 200 | def test_get_output_filename(self): 201 | for act, exp in ((self._actual('/absolute/path/to.file'), 202 | self._expected('/absolute/path/to_robot_output.xml')), 203 | 204 | (self._actual('path/to/file.xml'), 205 | self._expected('path/to/file_robot_output.xml')), 206 | 207 | (self._actual('file.extension'), 208 | self._expected('file_robot_output.xml'))): 209 | self.assertEqual(act, exp) 210 | 211 | 212 | 213 | -------------------------------------------------------------------------------- /tests/utest/oxygen/test_oxygen_config_file.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from oxygen.config import CONFIG_FILE, ORIGINAL_CONFIG_FILE 4 | 5 | class TestOxygenCLIEntryPoints(TestCase): 6 | def test_config_and_config_original_match(self): 7 | with open(CONFIG_FILE, 'r') as config: 8 | with open(ORIGINAL_CONFIG_FILE, 'r') as original_config: 9 | self.assertEqual(config.read(), original_config.read()) 10 | -------------------------------------------------------------------------------- /tests/utest/oxygen/test_oxygen_core.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from oxygen.oxygen import OxygenCore 3 | 4 | 5 | class TestOxygenInitialization(TestCase): 6 | def test_oxygen_core_initializes_without_loading_config(self): 7 | ''' 8 | OxygenCore and all it's subclasses lazy-load the configuration and, 9 | consequently, the handlers. This test makes sure that is not 10 | accidentally changed at some point 11 | ''' 12 | core = OxygenCore() 13 | self.assertEqual(core._config, None) 14 | self.assertEqual(core._handlers, None) 15 | 16 | -------------------------------------------------------------------------------- /tests/utest/oxygen/test_oxygen_listener.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import Mock, patch 3 | 4 | from oxygen import listener 5 | 6 | class OxygenListenerBasicTests(TestCase): 7 | def setUp(self): 8 | self.listener = listener() 9 | 10 | def test_listener_api_version_is_not_changed_accidentally(self): 11 | self.assertEqual(self.listener.ROBOT_LISTENER_API_VERSION, 2) 12 | 13 | def mock_lib_instance(self, mock_builtin, return_value): 14 | m_builtin = Mock() 15 | m_get_context = Mock() 16 | m_builtin._get_context.return_value = m_get_context 17 | m_get_context.namespace.get_library_instance.return_value = \ 18 | return_value 19 | mock_builtin.return_value = m_builtin 20 | return m_builtin 21 | 22 | @patch('oxygen.oxygen.BuiltIn') 23 | def test_end_test_when_library_was_not_used(self, mock_builtin): 24 | m = self.mock_lib_instance(mock_builtin, None) 25 | 26 | self.listener.end_test('foo', {}) 27 | 28 | m._get_context().namespace.get_library_instance.assert_called_once_with('oxygen.OxygenLibrary') 29 | self.assertEqual(self.listener.run_time_data, {}) 30 | 31 | @patch('oxygen.oxygen.BuiltIn') 32 | def test_end_test_when_library_was_used(self, mock_builtin): 33 | o = lambda: None 34 | o.data = 'I do not have a solution, but I do admire the problem' 35 | m = self.mock_lib_instance(mock_builtin, o) 36 | 37 | self.listener.end_test('oxygen.OxygenLibrary', {'longname': 'hello'}) 38 | 39 | m._get_context().namespace.get_library_instance.assert_called_once_with('oxygen.OxygenLibrary') 40 | self.assertEqual(self.listener.run_time_data, 41 | {'hello': ('I do not have a solution, but I do ' 42 | 'admire the problem')}) 43 | -------------------------------------------------------------------------------- /tests/utest/oxygen/test_oxygen_visitor.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import Mock 3 | 4 | from oxygen.oxygen import OxygenVisitor 5 | from oxygen import errors as oxygen_errors 6 | 7 | 8 | class CustomUserException(Exception): 9 | pass 10 | 11 | 12 | class TestOxygen(TestCase): 13 | def test_multiple_errors_are_reported(self): 14 | m = Mock() 15 | m.check_for_keyword.side_effect = [ 16 | TypeError('different'), 17 | oxygen_errors.JUnitHandlerException('kinds of'), 18 | oxygen_errors.OxygenException('exceptions'), 19 | CustomUserException('for fun and profit') 20 | ] 21 | oxy = OxygenVisitor('fakedata') 22 | oxy._handlers = { 23 | 'fake_handler': m, 24 | 'another_fake': m, 25 | 'third': m, 26 | 'and fourth': m 27 | } 28 | 29 | with self.assertRaises(oxygen_errors.OxygenException) as ex: 30 | oxy.visit_test(Mock()) 31 | 32 | exception_message = str(ex.exception) 33 | for expected in ('different', 34 | 'kinds of', 35 | 'exceptions', 36 | 'for fun and profit'): 37 | self.assertIn(expected, exception_message) 38 | 39 | def test_single_exception_raised_directly(self): 40 | m = Mock() 41 | m.check_for_keyword.side_effect = [CustomUserException('single')] 42 | oxy = OxygenVisitor('fakedata') 43 | oxy._handlers = {'fake_handler': m} 44 | 45 | with self.assertRaises(CustomUserException) as ex: 46 | oxy.visit_test(Mock()) 47 | 48 | self.assertIn('single', str(ex.exception)) 49 | self.assertEqual(oxy.data, 'fakedata') 50 | -------------------------------------------------------------------------------- /tests/utest/oxygen/test_oxygenlibrary.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import patch, Mock 3 | 4 | from oxygen import BaseHandler, OxygenLibrary 5 | from oxygen.errors import OxygenException 6 | 7 | from ..helpers import get_config_as_file 8 | 9 | class TestOxygenLibrary(TestCase): 10 | EXPECTED_KEYWORDS = ('run_junit', 'run_gatling', 'run_zap') 11 | 12 | def setUp(self): 13 | self.lib = OxygenLibrary() 14 | 15 | 16 | def test_initialization(self): 17 | for kw in self.EXPECTED_KEYWORDS: 18 | self.assertIn(kw, self.lib.get_keyword_names()) 19 | 20 | @patch('oxygen.config.CONFIG_FILE') 21 | def test_config_is_correct(self, mock_config): 22 | mock_config.return_value = get_config_as_file() 23 | 24 | self.assertGreater(len(self.lib.handlers), 1) 25 | for handler in self.lib.handlers.values(): 26 | self.assertTrue(isinstance(handler, BaseHandler)) 27 | self.assertTrue(any(hasattr(handler, kw) 28 | for kw in self.EXPECTED_KEYWORDS), 29 | 'handler "{}" did not have any of the ' 30 | 'following {}'.format(handler.__class__, 31 | self.EXPECTED_KEYWORDS)) 32 | 33 | def test_documentation(self): 34 | for kw in self.EXPECTED_KEYWORDS: 35 | self.assertNotNoneOrEmpty(self.lib.get_keyword_documentation(kw)) 36 | # Libdoc specific values 37 | self.assertNotNoneOrEmpty(self.lib.get_keyword_documentation('__intro__')) 38 | 39 | def assertNotNoneOrEmpty(self, value): 40 | assert value != None or value == '', \ 41 | 'Value "{}" is None or empty'.format(value) 42 | 43 | def test_arguments(self): 44 | for kw in self.EXPECTED_KEYWORDS: 45 | args = self.lib.get_keyword_arguments(kw) 46 | self.assertIsInstance(args, list) 47 | self.assertGreater(len(args), 0) 48 | 49 | @patch('oxygen.OxygenLibrary._fetch_handler') 50 | def test_run_keyword(self, mock_fetch_handler): 51 | expected_data = {f'{attr}.return_value': 'somefile.ext' 52 | for attr in self.EXPECTED_KEYWORDS} 53 | m = Mock() 54 | m.configure_mock(**expected_data) 55 | mock_fetch_handler.side_effect = [m]*3 56 | 57 | for kw in self.EXPECTED_KEYWORDS: 58 | ret = self.lib.run_keyword(kw, ['somefile.ext'], {}) 59 | self.assertEqual(ret, 'somefile.ext') 60 | self.assertNotNoneOrEmpty(self.lib.data) 61 | 62 | def test_run_keyword_should_fail_if_nonexistent_kw_is_called(self): 63 | with self.assertRaises(OxygenException) as ex: 64 | self.lib.run_keyword('nonexistent', [], {}) 65 | 66 | self.assertIn('No handler for keyword', str(ex.exception)) 67 | -------------------------------------------------------------------------------- /tests/utest/oxygen_handler_result/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-oxygen/93bc908a3d38006086d7b9832a63c54549a8d1f8/tests/utest/oxygen_handler_result/__init__.py -------------------------------------------------------------------------------- /tests/utest/oxygen_handler_result/shared_tests.py: -------------------------------------------------------------------------------- 1 | from ..helpers import (MINIMAL_KEYWORD_DICT, 2 | _ListSubclass, 3 | _StrSubclass, 4 | _KwSubclass) 5 | 6 | class SharedTestsForName(object): 7 | def shared_test_for_name(self): 8 | valid_inherited = _StrSubclass('someKeyword') 9 | this_is_not_None = _StrSubclass(None) 10 | 11 | self.valid_inputs_for('name', 12 | '', 13 | 'someKeyword', 14 | b'someKeyword', 15 | valid_inherited, 16 | this_is_not_None) 17 | 18 | self.invalid_inputs_for('name', None) 19 | 20 | 21 | class SharedTestsForTags(object): 22 | def shared_test_for_tags(self): 23 | self.valid_inputs_for('tags', 24 | [], 25 | ['some-tag', 'another-tag'], 26 | _ListSubclass()) 27 | 28 | invalid_inherited = _ListSubclass() 29 | invalid_inherited.append(123) 30 | 31 | self.invalid_inputs_for('tags', [123], None, {'foo': 'bar'}, object()) 32 | 33 | 34 | class SharedTestsForKeywordField(object): 35 | def shared_test_for_keyword_field(self, attribute): 36 | valid_inherited = _KwSubclass(**MINIMAL_KEYWORD_DICT) 37 | 38 | self.valid_inputs_for(attribute, 39 | MINIMAL_KEYWORD_DICT, 40 | valid_inherited, 41 | {**MINIMAL_KEYWORD_DICT, 42 | 'something_random': 'will-be-ignored'}) 43 | 44 | self.invalid_inputs_for(attribute, None, {}) 45 | -------------------------------------------------------------------------------- /tests/utest/oxygen_handler_result/test_OxygenKeywordDict.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from oxygen.errors import InvalidOxygenResultException 4 | from oxygen.oxygen_handler_result import (validate_oxygen_keyword, 5 | OxygenKeywordDict) 6 | 7 | from ..helpers import (MINIMAL_KEYWORD_DICT, 8 | _ListSubclass, 9 | _KwSubclass) 10 | from .shared_tests import (SharedTestsForKeywordField, 11 | SharedTestsForName, 12 | SharedTestsForTags) 13 | 14 | 15 | class TestOxygenKeywordDict(TestCase, 16 | SharedTestsForName, 17 | SharedTestsForTags, 18 | SharedTestsForKeywordField): 19 | def setUp(self): 20 | self.minimal = MINIMAL_KEYWORD_DICT 21 | 22 | def test_validate_oxygen_keyword_validates_correctly(self): 23 | with self.assertRaises(InvalidOxygenResultException): 24 | validate_oxygen_keyword({}) 25 | 26 | def test_validate_oxygen_keyword_with_minimal_valid(self): 27 | minimal1 = { 'name': 'somename', 'pass': True } 28 | minimal2 = { 'name': 'somename', 'pass': False } 29 | 30 | self.assertEqual(validate_oxygen_keyword(minimal1), minimal1) 31 | self.assertEqual(validate_oxygen_keyword(minimal2), minimal2) 32 | 33 | def valid_inputs_for(self, attribute, *valid_inputs): 34 | for valid_input in valid_inputs: 35 | self.assertTrue(validate_oxygen_keyword({**self.minimal, 36 | attribute: valid_input})) 37 | 38 | def invalid_inputs_for(self, attribute, *invalid_inputs): 39 | for invalid_input in invalid_inputs: 40 | with self.assertRaises(InvalidOxygenResultException): 41 | validate_oxygen_keyword({**self.minimal, 42 | attribute: invalid_input}) 43 | 44 | def test_validate_oxygen_keyword_validates_name(self): 45 | self.shared_test_for_name() 46 | 47 | def test_validate_oxygen_keyword_validates_pass(self): 48 | ''' 49 | Due note that boolean cannot be subclassed in Python: 50 | https://mail.python.org/pipermail/python-dev/2002-March/020822.html 51 | ''' 52 | self.valid_inputs_for('pass', True, False, 0, 1, 0.0, 1.0) 53 | self.invalid_inputs_for('pass', [], {}, None, object(), -999, -99.9) 54 | 55 | def test_validate_oxygen_keyword_validates_tags(self): 56 | self.shared_test_for_tags() 57 | 58 | def test_validate_oxygen_keyword_validates_elapsed(self): 59 | class FloatSubclass(float): 60 | pass 61 | 62 | self.valid_inputs_for('elapsed', 63 | 123.4, 64 | -123.0, 65 | '123.4', 66 | '-999.999', 67 | 123, 68 | FloatSubclass()) 69 | 70 | self.invalid_inputs_for('elapsed', '', None, object()) 71 | 72 | def test_validate_oxygen_keyword_validates_messages(self): 73 | valid_inherited = _ListSubclass() 74 | valid_inherited.append('message') 75 | 76 | self.valid_inputs_for('messages', 77 | [], 78 | ['message'], 79 | _ListSubclass(), 80 | valid_inherited) 81 | 82 | invalid_inherited = _ListSubclass() 83 | invalid_inherited.append('message') 84 | invalid_inherited.append(123) 85 | 86 | self.invalid_inputs_for('messages', 87 | 'some,messages', 88 | None, 89 | invalid_inherited) 90 | 91 | def test_validate_oxygen_keyword_validates_teardown(self): 92 | self.shared_test_for_keyword_field('teardown') 93 | 94 | def test_validate_oxygen_keyword_validates_keywords(self): 95 | valid_inherited = _ListSubclass() 96 | valid_inherited.append(_KwSubclass(**self.minimal)) 97 | 98 | self.valid_inputs_for('keywords', 99 | [], 100 | [self.minimal, {**self.minimal, 101 | 'something_random': 'will-be-ignored'}], 102 | _ListSubclass(), # empty inherited list 103 | valid_inherited) 104 | 105 | invalid_inherited = _ListSubclass() 106 | invalid_inherited.append(_KwSubclass(**self.minimal)) 107 | invalid_inherited.append(123) 108 | self.invalid_inputs_for('keywords', None, invalid_inherited) 109 | 110 | def test_validate_oxygen_keyword_with_maximal_valid(self): 111 | expected = { 112 | 'name': 'keyword', 113 | 'pass': True, 114 | 'tags': ['some-tag'], 115 | 'messages': ['some message'], 116 | 'teardown': { 117 | 'name': 'teardownKeyword', 118 | 'pass': True, 119 | 'tags': ['teardown-kw'], 120 | 'messages': ['Teardown passed'], 121 | 'keywords': [] 122 | }, 123 | 'keywords': [{ 124 | 'name': 'subKeyword', 125 | 'pass': False, 126 | # tags missing intentionally 127 | 'messages': ['This particular kw failed'], 128 | 'teardown': { 129 | 'name': 'anotherTeardownKw', 130 | 'pass': True, 131 | 'tags': ['teardown-kw'], 132 | 'messages': ['message from anotherTeardownKw'], 133 | # teardown missing intentionally 134 | 'keywords': [] 135 | }, 136 | 'keywords': [{ 137 | 'name': 'subsubKeyword', 138 | 'pass': True, 139 | }] 140 | },{ 141 | 'name': 'anotherSubKeyword', 142 | 'pass': True, 143 | 'tags': [], 144 | 'messages': [], 145 | 'keywords': [] 146 | }] 147 | } 148 | 149 | self.assertEqual(validate_oxygen_keyword(expected), expected) 150 | -------------------------------------------------------------------------------- /tests/utest/oxygen_handler_result/test_OxygenSuiteDict.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from oxygen.errors import InvalidOxygenResultException 4 | from oxygen.oxygen_handler_result import OxygenSuiteDict, validate_oxygen_suite 5 | 6 | from ..helpers import (MINIMAL_TC_DICT, 7 | MINIMAL_SUITE_DICT, 8 | _ListSubclass, 9 | _StrSubclass, 10 | _TCSubclass) 11 | from .shared_tests import (SharedTestsForName, 12 | SharedTestsForKeywordField, 13 | SharedTestsForTags) 14 | 15 | class TestOxygenSuiteDict(TestCase, 16 | SharedTestsForName, 17 | SharedTestsForKeywordField, 18 | SharedTestsForTags): 19 | def setUp(self): 20 | self.minimal = MINIMAL_SUITE_DICT 21 | 22 | def test_validate_oxygen_suite_validates_correctly(self): 23 | with self.assertRaises(InvalidOxygenResultException): 24 | validate_oxygen_suite({}) 25 | 26 | def test_validate_oxygen_suite_with_minimal_valid(self): 27 | expected = { 28 | 'name': 'My Suite' 29 | } 30 | 31 | self.assertEqual(validate_oxygen_suite(expected), expected) 32 | self.assertEqual(validate_oxygen_suite(self.minimal), self.minimal) 33 | 34 | def valid_inputs_for(self, attribute, *valid_inputs): 35 | for valid_input in valid_inputs: 36 | self.assertTrue(validate_oxygen_suite({**self.minimal, 37 | attribute: valid_input})) 38 | 39 | def invalid_inputs_for(self, attribute, *invalid_inputs): 40 | for invalid_input in invalid_inputs: 41 | with self.assertRaises(InvalidOxygenResultException): 42 | validate_oxygen_suite({**self.minimal, attribute: invalid_input}) 43 | 44 | def test_validate_oxygen_suite_validates_name(self): 45 | self.shared_test_for_name() 46 | 47 | def test_validate_oxygen_suite_validates_tags(self): 48 | self.shared_test_for_tags() 49 | 50 | def test_validate_oxygen_suite_validates_setup(self): 51 | self.shared_test_for_keyword_field('setup') 52 | 53 | def test_validate_oxygen_suite_validates_teardown(self): 54 | self.shared_test_for_keyword_field('teardown') 55 | 56 | def test_validate_oxygen_suite_validates_suites(self): 57 | class OxygenSuiteDictSubclass(OxygenSuiteDict): 58 | pass 59 | valid_inherited = _ListSubclass() 60 | valid_inherited.append(OxygenSuiteDictSubclass(**self.minimal)) 61 | 62 | self.valid_inputs_for('suites', 63 | [], 64 | [self.minimal], 65 | valid_inherited, 66 | [ OxygenSuiteDictSubclass(**self.minimal) ]) 67 | 68 | self.invalid_inputs_for('suites', None, [ {} ]) 69 | 70 | def test_validate_oxygen_suite_validates_tests(self): 71 | valid_inherited = _ListSubclass() 72 | valid_inherited.append(_TCSubclass(**MINIMAL_TC_DICT)) 73 | valid_inherited.append(MINIMAL_TC_DICT) 74 | 75 | self.valid_inputs_for('tests', 76 | [], 77 | [ MINIMAL_TC_DICT ], 78 | valid_inherited, 79 | [ _TCSubclass(**MINIMAL_TC_DICT) ]) 80 | 81 | 82 | self.invalid_inputs_for('tests', None, [ {} ]) 83 | 84 | def test_validate_oxygen_suite_validates_metadata(self): 85 | class DictSubclass(dict): 86 | pass 87 | inherited_key = _StrSubclass('key') 88 | 89 | this_is_not_None = _StrSubclass(None) 90 | 91 | self.valid_inputs_for('metadata', 92 | {}, 93 | {'': ''}, 94 | {'key': 'value'}, 95 | {_StrSubclass('key'): _StrSubclass('value')}, 96 | DictSubclass(inherited_key=_StrSubclass('value')), 97 | {this_is_not_None: 'value'}) 98 | 99 | self.invalid_inputs_for('metadata', 100 | {'key': None}, 101 | {'key': 1234},) 102 | -------------------------------------------------------------------------------- /tests/utest/oxygen_handler_result/test_OxygenTestCaseDict.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from oxygen.errors import InvalidOxygenResultException 4 | from oxygen.oxygen_handler_result import validate_oxygen_test_case 5 | 6 | from ..helpers import _ListSubclass, MINIMAL_KEYWORD_DICT, MINIMAL_TC_DICT 7 | from .shared_tests import (SharedTestsForKeywordField, 8 | SharedTestsForName, 9 | SharedTestsForTags) 10 | 11 | class TestOxygenTestCaseDict(TestCase, 12 | SharedTestsForName, 13 | SharedTestsForTags, 14 | SharedTestsForKeywordField): 15 | def setUp(self): 16 | self.minimal = MINIMAL_TC_DICT 17 | 18 | def test_validate_oxygen_tc_validates_correctly(self): 19 | with self.assertRaises(InvalidOxygenResultException): 20 | validate_oxygen_test_case({}) 21 | 22 | def test_validate_oxygen_tc_with_minimal_valid(self): 23 | expected = { 24 | 'name': 'My TC', 25 | 'keywords': [] 26 | } 27 | self.assertEqual(validate_oxygen_test_case(expected), expected) 28 | self.assertEqual(validate_oxygen_test_case(self.minimal), self. minimal) 29 | 30 | def valid_inputs_for(self, attribute, *valid_inputs): 31 | for valid_input in valid_inputs: 32 | self.assertTrue(validate_oxygen_test_case({**self.minimal, 33 | attribute: valid_input})) 34 | 35 | def invalid_inputs_for(self, attribute, *invalid_inputs): 36 | for invalid_input in invalid_inputs: 37 | with self.assertRaises(InvalidOxygenResultException): 38 | validate_oxygen_test_case({**self.minimal, 39 | attribute: invalid_input}) 40 | 41 | def test_validate_oxygen_tc_validates_name(self): 42 | self.shared_test_for_name() 43 | 44 | def test_validate_oxygen_tc_validates_keywords(self): 45 | valid_inherited = _ListSubclass() 46 | valid_inherited.append(MINIMAL_KEYWORD_DICT) 47 | 48 | self.valid_inputs_for('keywords', 49 | [], 50 | [ MINIMAL_KEYWORD_DICT ], 51 | _ListSubclass(), 52 | valid_inherited) 53 | 54 | invalid_inherited = _ListSubclass() 55 | invalid_inherited.append( {} ) 56 | 57 | self.invalid_inputs_for('keywords', None, invalid_inherited) 58 | 59 | def test_validate_oxygen_tc_validates_tags(self): 60 | self.shared_test_for_tags() 61 | 62 | def test_validate_oxygen_tc_validates_setup(self): 63 | self.shared_test_for_keyword_field('setup') 64 | 65 | def test_validate_oxygen_tc_validates_teardown(self): 66 | self.shared_test_for_keyword_field('teardown') 67 | -------------------------------------------------------------------------------- /tests/utest/oxygen_handler_result/test_deprecation_warning.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Currently, the introduction of pydantic[1] to validate result dictionaries that 3 | handlers return is planned to just raise a deprecation warning. 4 | 5 | After 1.0 -- ie. a backwards-incompatible release -- we should turn deprecation 6 | warning to actually start failing. See issue [2]. 7 | 8 | [1| https://github.com/eficode/robotframework-oxygen/issues/43 9 | [2] https://github.com/eficode/robotframework-oxygen/issues/45 10 | ''' 11 | from unittest import TestCase 12 | from unittest.mock import patch 13 | from oxygen.base_handler import BaseHandler 14 | from oxygen.oxygen import OxygenCLI 15 | 16 | from ..helpers import get_config, MINIMAL_SUITE_DICT 17 | 18 | class TestDeprecationWarningWhenValidating(TestCase): 19 | def setUp(self): 20 | self.cli = OxygenCLI() 21 | 22 | def _validate_warning_msg(self, warning, module_name): 23 | warning_message = str(warning.warning) 24 | for expected in (module_name, 25 | 'validation error for typed-dict', 26 | 'In Oxygen 1.0, handlers will need to produce valid results.'): 27 | self.assertIn(expected, warning_message) 28 | 29 | def test_warning_about_invalid_result(self): 30 | handler = BaseHandler(get_config()['oxygen.junit']) 31 | 32 | with self.assertWarns(UserWarning) as warning: 33 | handler._validate({}) 34 | 35 | self._validate_warning_msg(warning, 'oxygen.base_handler') 36 | 37 | @patch('oxygen.oxygen.RobotInterface') 38 | def test_warning_about_invalid_result_in_CLI(self, mock_iface): 39 | with self.assertWarns(UserWarning) as warning: 40 | self.cli.convert_to_robot_result({ 41 | 'result_file': 'doesentmatter', 42 | 'func': lambda **_: {**MINIMAL_SUITE_DICT, 'setup': []} 43 | }) 44 | 45 | mock_iface.assert_any_call() 46 | # this one has weird name because we fake `func` with lambda 47 | self._validate_warning_msg(warning, 'test_deprecation_warning') 48 | 49 | def test_deprecation_was_removed(self): 50 | '''Remove this test once deprecation warning has been removed''' 51 | if self.cli.__version__.startswith('1'): 52 | self.fail('Deprecation warning should have been removed in 1.0') 53 | -------------------------------------------------------------------------------- /tests/utest/robot_interface/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-oxygen/93bc908a3d38006086d7b9832a63c54549a8d1f8/tests/utest/robot_interface/__init__.py -------------------------------------------------------------------------------- /tests/utest/robot_interface/test_robot_interface_basic_usage.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | from unittest import TestCase 3 | 4 | from robot.result.model import (Keyword as RobotKeyword, 5 | Message as RobotMessage, 6 | TestCase as RobotTest, 7 | TestSuite as RobotSuite) 8 | 9 | from robot.running.model import TestSuite as RobotRunningSuite 10 | 11 | from oxygen.robot_interface import RobotInterface, get_keywords_from 12 | 13 | EXAMPLE_SUITES = [{ 14 | 'name': 'suite1', 15 | 'setup': [], 16 | 'metadata': {'metadata-key': 'metadata-value'}, 17 | 'suites': [{'name': 'suite2', 18 | 'setup': {'elapsed': 0.0, 19 | 'keywords': [], 20 | 'messages': [], 21 | 'name': 'Suite Setup keyword', 22 | 'pass': True, 23 | 'tags': [], 24 | 'teardown': []}, 25 | 'suites': [], 26 | 'tags': [], 27 | 'teardown': {'elapsed': 0.0, 28 | 'keywords': [], 29 | 'messages': [], 30 | 'name': 'Suite Teardown keyword', 31 | 'pass': True, 32 | 'tags': [], 33 | 'teardown': []}, 34 | 'tests': [{'keywords': [{'elapsed': 0.0, 35 | 'keywords': [], 36 | 'messages': [], 37 | 'name': 'casea (Execution)', 38 | 'pass': True, 39 | 'tags': [], 40 | 'teardown': []}], 41 | 'name': 'casea', 42 | 'setup': {'elapsed': 0.0, 43 | 'keywords': [], 44 | 'messages': [], 45 | 'name': 'Test Setup keyword', 46 | 'pass': True, 47 | 'tags': [], 48 | 'teardown': []}, 49 | 'tags': ['OXYGEN_JUNIT_UNKNOWN_EXECUTION_TIME'], 50 | 'teardown': {'elapsed': 0.0, 51 | 'keywords': [], 52 | 'messages': [], 53 | 'name': 'Suite Teardown keyword', 54 | 'pass': True, 55 | 'tags': [], 56 | 'teardown': []}}, 57 | {'keywords': [{'elapsed': 0.0, 58 | 'keywords': [], 59 | 'messages': [], 60 | 'name': 'caseb (Execution)', 61 | 'pass': True, 62 | 'tags': [], 63 | 'teardown': []}], 64 | 'name': 'caseb', 65 | 'setup': [], 66 | 'tags': ['OXYGEN_JUNIT_UNKNOWN_EXECUTION_TIME'], 67 | 'teardown': []}]}], 68 | 'tags': [], 69 | 'teardown': [], 70 | 'tests': [{'keywords': [{'elapsed': 0.0, 71 | 'keywords': [], 72 | 'messages': [], 73 | 'name': 'case1 (Execution)', 74 | 'pass': True, 75 | 'tags': [], 76 | 'teardown': []}], 77 | 'name': 'case1', 78 | 'setup': [], 79 | 'tags': ['OXYGEN_JUNIT_UNKNOWN_EXECUTION_TIME'], 80 | 'teardown': []}, 81 | {'keywords': [{'elapsed': 0.0, 82 | 'keywords': [], 83 | 'messages': ['ERROR: Example error message ' 84 | '(the_error_type)'], 85 | 'name': 'case2 (Execution)', 86 | 'pass': False, 87 | 'tags': [], 88 | 'teardown': []}], 89 | 'name': 'case2', 90 | 'setup': [], 91 | 'tags': ['OXYGEN_JUNIT_UNKNOWN_EXECUTION_TIME'], 92 | 'teardown': []}, 93 | {'keywords': [{'elapsed': 0.0, 94 | 'keywords': [], 95 | 'messages': ['FAIL: Example failure message ' 96 | '(the_failure_type)'], 97 | 'name': 'case3 (Execution)', 98 | 'pass': False, 99 | 'tags': [], 100 | 'teardown': []}], 101 | 'name': 'case3', 102 | 'setup': [], 103 | 'tags': ['OXYGEN_JUNIT_UNKNOWN_EXECUTION_TIME'], 104 | 'teardown': []}, 105 | {'keywords': [{'elapsed': 0.0, 106 | 'keywords': [], 107 | 'messages': ['*HTML* Robot Framework'], 108 | 'name': 'case3 (Execution)', 109 | 'pass': False, 110 | 'tags': [], 111 | 'teardown': []}], 112 | 'name': 'case3', 113 | 'setup': [], 114 | 'tags': ['OXYGEN_JUNIT_UNKNOWN_EXECUTION_TIME'], 115 | 'teardown': []}] 116 | }, { 117 | 'name': 'suite2', 118 | 'setup': [], 119 | 'suites': [], 120 | 'tags': [], 121 | 'teardown': [], 122 | 'tests': [{'keywords': [{'elapsed': 0.0, 123 | 'keywords': [], 124 | 'messages': [], 125 | 'name': 'casea (Execution)', 126 | 'pass': True, 127 | 'tags': [], 128 | 'teardown': []}], 129 | 'name': 'casea', 130 | 'setup': [], 131 | 'tags': ['OXYGEN_JUNIT_UNKNOWN_EXECUTION_TIME'], 132 | 'teardown': []}, 133 | {'keywords': [{'elapsed': 0.0, 134 | 'keywords': [], 135 | 'messages': [], 136 | 'name': 'caseb (Execution)', 137 | 'pass': False, 138 | 'tags': [], 139 | 'teardown': []}], 140 | 'name': 'caseb', 141 | 'setup': [], 142 | 'tags': ['OXYGEN_JUNIT_UNKNOWN_EXECUTION_TIME'], 143 | 'teardown': []}] 144 | }] 145 | 146 | 147 | class RobotInterfaceBasicTests(TestCase): 148 | ''' 149 | This tests only two methods of RobotInterface, but since they internally use 150 | all the other functions, pretty much everything is covered. 151 | ''' 152 | 153 | def setUp(self): 154 | self.iface = RobotInterface() 155 | 156 | def now(self): 157 | return int(round(time() * 1000)) 158 | 159 | def test_result_build_suites(self): 160 | _, converted = self.iface.result.build_suites(self.now(), 161 | *EXAMPLE_SUITES) 162 | 163 | self.assertIsInstance(converted, list) 164 | self.assertEqual(len(converted), 2) 165 | 166 | for converted_suite in converted: 167 | self.assertIsInstance(converted_suite, RobotSuite) 168 | 169 | for subsuite in converted[0].suites: 170 | self.assertIsInstance(subsuite, RobotSuite) 171 | 172 | for test in converted[1].tests: 173 | self.assertIsInstance(test, RobotTest) 174 | 175 | for kw in get_keywords_from(converted[0].tests[1]): 176 | self.assertIsInstance(kw, RobotKeyword) 177 | 178 | for message in get_keywords_from(converted[0].tests[1])[0].messages: 179 | self.assertIsInstance(message, RobotMessage) 180 | self.assertEqual(get_keywords_from(converted[0].tests[3])[0].messages[0].html, True) 181 | self.assertEqual(get_keywords_from(converted[0].tests[3])[0].messages[0].message, ' Robot Framework') 182 | self.assertEqual(get_keywords_from(converted[0].tests[2])[0].messages[0].message,'FAIL: Example failure message ' 183 | '(the_failure_type)') 184 | self.assertEqual(get_keywords_from(converted[0].tests[2])[0].messages[0].html, False) 185 | 186 | def test_result_create_wrapper_keyword_for_setup(self): 187 | ret = self.iface.result.create_wrapper_keyword('My Wrapper', 188 | '20200507 13:42:50.001', 189 | '20200507 14:59:01.999', 190 | True, 191 | RobotKeyword(), 192 | RobotKeyword()) 193 | 194 | self.assertIsInstance(ret, RobotKeyword) 195 | self.assertEqual(ret.name, 'My Wrapper') 196 | self.assertEqual(len(get_keywords_from(ret)), 2) 197 | try: 198 | # Robot Framework < 4.0 199 | from robot.result.model import Keyword 200 | self.assertEqual(ret.type, Keyword.SETUP_TYPE) 201 | except AttributeError: 202 | # Robot Framework >= 4.0 203 | from robot.model import BodyItem 204 | self.assertEqual(ret.type, BodyItem.SETUP) 205 | 206 | def validate_metadata(self, actual): 207 | self.assertEqual(actual.name, EXAMPLE_SUITES[0]['name']) 208 | self.assertEqual(dict(actual.metadata), EXAMPLE_SUITES[0]['metadata']) 209 | 210 | def test_result_build_suite_with_metadata(self): 211 | _, ret = self.iface.result.build_suite(self.now(), EXAMPLE_SUITES[0]) 212 | self.validate_metadata(ret) 213 | 214 | def test_result_create_wrapper_keyword_for_teardown(self): 215 | ret = self.iface.result.create_wrapper_keyword('My Wrapper', 216 | '20200507 13:42:50.001', 217 | '20200507 14:59:01.999', 218 | False, 219 | RobotKeyword()) 220 | try: 221 | # Robot Framework < 4.0 222 | from robot.result.model import Keyword 223 | self.assertEqual(ret.type, Keyword.TEARDOWN_TYPE) 224 | except AttributeError: 225 | # Robot Framework >= 4.0 226 | from robot.model import BodyItem 227 | self.assertEqual(ret.type, BodyItem.TEARDOWN) 228 | 229 | def test_running_build_suite(self): 230 | ret = self.iface.running.build_suite(EXAMPLE_SUITES[1]) 231 | 232 | self.assertIsInstance(ret, RobotRunningSuite) 233 | self.assertEqual(ret.name, EXAMPLE_SUITES[1]['name']) 234 | 235 | def test_running_build_suite_with_metadata(self): 236 | ret = self.iface.running.build_suite(EXAMPLE_SUITES[0]) 237 | self.validate_metadata(ret) 238 | -------------------------------------------------------------------------------- /tests/utest/robot_interface/test_time_conversions.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from unittest import TestCase 3 | 4 | from mock import patch 5 | 6 | from oxygen.robot_interface import RobotInterface 7 | 8 | 9 | class TestMsToTimestamp(TestCase): 10 | def setUp(self): 11 | self.interface = RobotInterface() 12 | 13 | def test_should_be_correct(self): 14 | timestamp = self.interface.result.ms_to_timestamp(1533625284100.0) 15 | self.assertEqual(timestamp, '20180807 07:01:24.100000') 16 | 17 | timestamp = self.interface.result.ms_to_timestamp(1533625284451.0) 18 | self.assertEqual(timestamp, '20180807 07:01:24.451000') 19 | 20 | def test_should_be_associative(self): 21 | timestamp = '20180807 07:01:24.300000' 22 | 23 | milliseconds = self.interface.result.timestamp_to_ms(timestamp) 24 | self.assertEqual(milliseconds, 1533625284300.0) 25 | 26 | timestamp = self.interface.result.ms_to_timestamp(milliseconds) 27 | self.assertEqual(timestamp, '20180807 07:01:24.300000') 28 | 29 | milliseconds = self.interface.result.timestamp_to_ms(timestamp) 30 | self.assertEqual(milliseconds, 1533625284300.0) 31 | 32 | timestamp = self.interface.result.ms_to_timestamp(milliseconds) 33 | self.assertEqual(timestamp, '20180807 07:01:24.300000') 34 | 35 | def _validate_timestamp(self, interface): 36 | timestamp = interface.ms_to_timestamp(-10) 37 | expected = '19700101 00:00:00.990000' 38 | 39 | self.assertEqual(timestamp, expected) 40 | 41 | def test_ms_before_epoch_are_reset_to_epoch(self): 42 | from oxygen.robot4_interface import RobotResultInterface as RF4ResultIface 43 | with patch.object(RF4ResultIface, 'get_timezone_delta') as m: 44 | m.return_value = timedelta(seconds=7200) 45 | self._validate_timestamp(RF4ResultIface()) 46 | 47 | from oxygen.robot3_interface import RobotResultInterface as RF3ResultIface 48 | with patch.object(RF3ResultIface, 'get_timezone_delta') as m: 49 | m.return_value = timedelta(seconds=7200) 50 | self._validate_timestamp(RF3ResultIface()) 51 | 52 | 53 | class TestTimestampToMs(TestCase): 54 | def setUp(self): 55 | self.iface = RobotInterface() 56 | 57 | def test_should_be_correct(self): 58 | milliseconds = self.iface.result.timestamp_to_ms('20180807 07:01:24.000') 59 | self.assertEqual(milliseconds, 1533625284000.0) 60 | 61 | milliseconds = self.iface.result.timestamp_to_ms('20180807 07:01:24.555') 62 | self.assertEqual(milliseconds, 1533625284555.0) 63 | 64 | def test_should_be_associative(self): 65 | milliseconds = 1533625284300.0 66 | 67 | timestamp = self.iface.result.ms_to_timestamp(milliseconds) 68 | self.assertEqual(timestamp, '20180807 07:01:24.300000') 69 | 70 | milliseconds = self.iface.result.timestamp_to_ms(timestamp) 71 | self.assertEqual(milliseconds, 1533625284300.0) 72 | 73 | timestamp = self.iface.result.ms_to_timestamp(milliseconds) 74 | self.assertEqual(timestamp, '20180807 07:01:24.300000') 75 | -------------------------------------------------------------------------------- /tests/utest/zap/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eficode/robotframework-oxygen/93bc908a3d38006086d7b9832a63c54549a8d1f8/tests/utest/zap/__init__.py -------------------------------------------------------------------------------- /tests/utest/zap/test_parse_zap_alert_dict.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock 3 | 4 | from oxygen.zap import ZAProxyHandler 5 | from ..helpers import get_config 6 | 7 | 8 | class TestParseZapAlertDict(TestCase): 9 | def setUp(self): 10 | self.object = ZAProxyHandler(get_config()['oxygen.zap']) 11 | self._params = { 12 | 'instances': [True, False], 13 | } 14 | self.parser_mock = MagicMock(return_value=None) 15 | self.object._parse_zap_instance = self.parser_mock 16 | 17 | def test_has_defaults(self): 18 | return_dict = self.object._parse_zap_alert_dict(self._params) 19 | assert('name' in return_dict) 20 | assert(return_dict['name'] == '[Unknown Plugin ID] Unknown Alert Name') 21 | 22 | def test_keeps_params(self): 23 | self._params['pluginid'] = 'my plugin' 24 | self._params['name'] = 'my name' 25 | return_dict = self.object._parse_zap_alert_dict(self._params) 26 | assert('name' in return_dict) 27 | assert(return_dict['name'] == 'my plugin my name') 28 | 29 | def test_passes_down(self): 30 | return_dict = self.object._parse_zap_alert_dict(self._params) 31 | self.parser_mock.assert_any_call(True, True, True) 32 | self.parser_mock.assert_any_call(False, True, True) 33 | assert(self.parser_mock.call_count == 2) 34 | -------------------------------------------------------------------------------- /tests/utest/zap/test_parse_zap_dict.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock 3 | 4 | from oxygen.zap import ZAProxyHandler 5 | from ..helpers import get_config 6 | 7 | 8 | class TestParseZapDict(TestCase): 9 | def setUp(self): 10 | self.object = ZAProxyHandler(get_config()['oxygen.zap']) 11 | self.params = { 12 | 'version': None, 13 | 'generated': None, 14 | 'site': [True, False], 15 | } 16 | self.parser_mock = MagicMock(return_value=None) 17 | self.object._parse_zap_site_dict = self.parser_mock 18 | 19 | def test_has_defaults(self): 20 | return_dict = self.object._parse_zap_dict(self.params) 21 | expected_name = ('Oxygen ZAProxy Report (Unknown ZAProxy Version, ' 22 | 'Unknown ZAProxy Run Time)') 23 | assert('name' in return_dict) 24 | assert(return_dict['name'] == expected_name) 25 | 26 | def test_keepsparams(self): 27 | self.params['version'] = 'my version' 28 | self.params['generated'] = 'when' 29 | return_dict = self.object._parse_zap_dict(self.params) 30 | assert('name' in return_dict) 31 | assert(return_dict['name'] == ('Oxygen ZAProxy Report ' 32 | '(my version, when)')) 33 | 34 | def test_handles_oddparams(self): 35 | self.params['@version'] = 'my version' 36 | self.params['@generated'] = 'when' 37 | return_dict = self.object._parse_zap_dict(self.params) 38 | assert('name' in return_dict) 39 | assert(return_dict['name'] == ('Oxygen ZAProxy Report ' 40 | '(my version, when)')) 41 | 42 | def test_calls_down(self): 43 | return_dict = self.object._parse_zap_dict(self.params) 44 | self.parser_mock.assert_any_call(True) 45 | self.parser_mock.assert_any_call(False) 46 | assert(self.parser_mock.call_count == 2) 47 | -------------------------------------------------------------------------------- /tests/utest/zap/test_parse_zap_instance.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock 3 | 4 | from oxygen.zap import ZAProxyHandler 5 | from ..helpers import get_config 6 | 7 | 8 | class TestParseZapInstance(TestCase): 9 | def setUp(self): 10 | self.object = ZAProxyHandler(get_config()['oxygen.zap']) 11 | self.object._parse_zap_site_dict = MagicMock(return_value=None) 12 | self.params = {} 13 | 14 | def test_has_defaults(self): 15 | return_dict = self.object._parse_zap_instance(self.params, True, True) 16 | expected_name = ('[Unknown HTTP Method] [Unknown Target URI]: ' 17 | '[Unknown Target Parameter]') 18 | assert('name' in return_dict) 19 | assert(return_dict['name'] == expected_name) 20 | 21 | def test_keepsparams(self): 22 | self.params = { 23 | 'uri': 'foo', 24 | 'method': 'bar', 25 | 'param': 'baz', 26 | } 27 | return_dict = self.object._parse_zap_instance(self.params, True, True) 28 | assert('name' in return_dict) 29 | assert(return_dict['name'] == 'bar foo: baz') 30 | 31 | def test_is_fail_if_risk_and_confident(self): 32 | return_dict = self.object._parse_zap_instance(self.params, True, True) 33 | assert('pass' in return_dict) 34 | assert(return_dict['pass'] == False) 35 | 36 | def test_is_pass_if_risk_and_not_confident(self): 37 | return_dict = self.object._parse_zap_instance(self.params, True, False) 38 | assert('pass' in return_dict) 39 | assert(return_dict['pass'] == True) 40 | 41 | def test_is_pass_if_not_risk_and_confident(self): 42 | return_dict = self.object._parse_zap_instance(self.params, False, True) 43 | assert('pass' in return_dict) 44 | assert(return_dict['pass'] == True) 45 | 46 | def test_is_pass_if_not_risk_and_not_confident(self): 47 | return_dict = self.object._parse_zap_instance( 48 | self.params, False, False) 49 | assert('pass' in return_dict) 50 | assert(return_dict['pass'] == True) 51 | -------------------------------------------------------------------------------- /tests/utest/zap/test_parse_zap_site_dict.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import MagicMock 3 | 4 | from oxygen.zap import ZAProxyHandler 5 | from ..helpers import get_config 6 | 7 | 8 | class TestParseZapSiteDict(TestCase): 9 | def setUp(self): 10 | self.object = ZAProxyHandler(get_config()['oxygen.zap']) 11 | self._params = { 12 | 'alerts': [True, False], 13 | } 14 | self._parser = MagicMock(return_value=None) 15 | self.object._parse_zap_alert_dict = self._parser 16 | 17 | def test_has_defaults(self): 18 | return_dict = self.object._parse_zap_site_dict(self._params) 19 | assert('name' in return_dict) 20 | assert(return_dict['name'] == 'Site: Unknown Site Name') 21 | assert('tests' in return_dict) 22 | self._parser.assert_any_call(True) 23 | self._parser.assert_any_call(False) 24 | assert(self._parser.call_count == 2) 25 | 26 | def test_reads_prefixed(self): 27 | self._params['@name'] = 'My Site Name' 28 | return_dict = self.object._parse_zap_site_dict(self._params) 29 | assert('name' in return_dict) 30 | assert(return_dict['name'] == 'Site: My Site Name') 31 | assert('tests' in return_dict) 32 | self._parser.assert_any_call(True) 33 | self._parser.assert_any_call(False) 34 | assert(self._parser.call_count == 2) 35 | -------------------------------------------------------------------------------- /tests/utest/zap/test_xml_to_dict.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from xml.etree import ElementTree as ET 3 | 4 | from oxygen.zap import ZAProxyHandler 5 | from ..helpers import get_config 6 | 7 | 8 | class TestXmlToDict(TestCase): 9 | def _create_example_xml(self): 10 | xml_head = ET.Element('xml_head') 11 | xml_container = ET.SubElement(xml_head, 'xml_container') 12 | ET.SubElement(xml_head, 'second_child') 13 | ET.SubElement(xml_container, 'first_contained') 14 | ET.SubElement(xml_container, 'second_contained') 15 | return xml_head 16 | 17 | def setUp(self): 18 | self.object = ZAProxyHandler(get_config()['oxygen.zap']) 19 | self._xml = self._create_example_xml() 20 | 21 | def test_converts_xml(self): 22 | returned_dict = self.object._xml_to_dict(self._xml) 23 | assert('xml_head' in returned_dict) 24 | assert('xml_container' in returned_dict['xml_head']) 25 | assert('second_child' in returned_dict['xml_head']) 26 | assert('first_contained' in returned_dict['xml_head']['xml_container']) 27 | assert('second_contained' in returned_dict['xml_head']['xml_container']) 28 | -------------------------------------------------------------------------------- /tests/utest/zap/test_zap_cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from unittest import TestCase 4 | from unittest.mock import ANY, Mock, create_autospec, patch 5 | 6 | from robot.running.model import TestSuite 7 | 8 | from oxygen.oxygen import OxygenCLI 9 | from ..helpers import RESOURCES_PATH 10 | 11 | 12 | class TestOxygenZapCLI(TestCase): 13 | ZAP_XML = str(RESOURCES_PATH / "zap" / "zap.xml") 14 | 15 | def setUp(self): 16 | self.cli = OxygenCLI() 17 | self.handler = self.cli.handlers["oxygen.zap"] 18 | self.expected_suite = create_autospec(TestSuite) 19 | self.mock = Mock() 20 | self.mock.running.build_suite = Mock(return_value=self.expected_suite) 21 | 22 | def tearDown(self): 23 | self.cli = None 24 | self.handler = None 25 | self.expected_suite = None 26 | self.mock = None 27 | 28 | def test_cli(self): 29 | self.assertEqual( 30 | self.handler.cli(), 31 | { 32 | ("--accepted-risk-level",): { 33 | "help": "Set accepted risk level", 34 | "type": int, 35 | }, 36 | ("--required-confidence-level",): { 37 | "help": "Set required confidence level", 38 | "type": int, 39 | }, 40 | ("result_file",): {}, 41 | }, 42 | ) 43 | 44 | @patch("oxygen.oxygen.RobotInterface") 45 | def test_cli_run(self, mock_robot_iface): 46 | mock_robot_iface.return_value = self.mock 47 | 48 | cmd_args = f"oxygen oxygen.zap {self.ZAP_XML}" 49 | with patch.object(sys, "argv", cmd_args.split()): 50 | self.cli.run() 51 | 52 | self.assertEqual(self.handler._config["accepted_risk_level"], 2) 53 | self.assertEqual(self.handler._config["required_confidence_level"], 1) 54 | 55 | self.mock.running.build_suite.assert_called_once() 56 | 57 | self.expected_suite.run.assert_called_once_with( 58 | output=str(RESOURCES_PATH / "zap" / "zap_robot_output.xml"), 59 | log=None, 60 | report=None, 61 | stdout=ANY, 62 | ) 63 | 64 | @patch("oxygen.oxygen.RobotInterface") 65 | def test_cli_run_with_levels(self, mock_robot_iface): 66 | mock_robot_iface.return_value = self.mock 67 | 68 | cmd_args = ( 69 | f"oxygen oxygen.zap {self.ZAP_XML} --accepted-risk-level 3" 70 | " --required-confidence-level 3" 71 | ) 72 | with patch.object(sys, "argv", cmd_args.split()): 73 | self.cli.run() 74 | 75 | self.assertEqual(self.handler._config["accepted_risk_level"], 3) 76 | self.assertEqual(self.handler._config["required_confidence_level"], 3) 77 | 78 | @patch("oxygen.oxygen.RobotInterface") 79 | def test_cli_run_with_accepted_risk_level(self, mock_robot_iface): 80 | mock_robot_iface.return_value = self.mock 81 | 82 | cmd_args = f"oxygen oxygen.zap {self.ZAP_XML} --accepted-risk-level 3" 83 | with patch.object(sys, "argv", cmd_args.split()): 84 | self.cli.run() 85 | 86 | self.assertEqual(self.handler._config["accepted_risk_level"], 3) 87 | self.assertEqual(self.handler._config["required_confidence_level"], 1) 88 | 89 | @patch("oxygen.oxygen.RobotInterface") 90 | def test_cli_run_with_required_confidence_level(self, mock_robot_iface): 91 | mock_robot_iface.return_value = self.mock 92 | 93 | cmd_args = f"oxygen oxygen.zap {self.ZAP_XML} --required-confidence-level 3" 94 | with patch.object(sys, "argv", cmd_args.split()): 95 | self.cli.run() 96 | 97 | self.assertEqual(self.handler._config["accepted_risk_level"], 2) 98 | self.assertEqual(self.handler._config["required_confidence_level"], 3) 99 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35,py36,py37 3 | 4 | [testenv] 5 | whitelist_externals = invoke 6 | install_command = invoke install -p {packages} 7 | install = 8 | commands = 9 | invoke test 10 | --------------------------------------------------------------------------------