diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b88fefe..3fb3e19 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,7 +48,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: '3.12' - name: Install Hatch run: pip install hatch @@ -63,7 +63,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - name: Checkout Repository uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index bb6b757..f798f27 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,8 @@ image.png output_doc_robot.html devel.html build/** -uv.lock \ No newline at end of file +uv.lock +atest/output_classic.html +atest/output_big.html +results/ +oc_output.html diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..c18f1ff --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,101 @@ +# Development + +## Release Creation + +### Precondition + +- You are required to have an account for **[PyPi](https://pypi.org/)**. +- Additionally, check that you have required permissions for the projects at ``PyPi & GitHub``! +- Check that all pipeline-checks did pass, your code changes are ready and everything is already merged to the main branch! +- Your local git configuration must work - check your authentication! + +### *Preferred* - Using Shell Script for Release Creation + +In the ``root directory`` of this repository you will find a script called ``create_release.sh``. +When executing this script, it expects exactly one argument: the new name of the release / tag. +Here you need to provide always the syntax ``X.X.X``, e.g. ``0.1.9``, ``0.5.0`` or ``1.0.0``. + +> [!TIP] +> Please use following versioning to create ``alpha``, ``beta`` or ``releasecandidate`` release at ``PyPi``! +> Example: +> ```bash +> ./create_release.sh 1.0.0a1 # alpha +> ./create_release.sh 1.0.0b2 # beta +> ./create_release.sh 1.0.0rc1 # release candidate +> ``` + +The script will do two things automatically for you: +1. Updating the version string in the **[\_\_about\_\_.py](src/testdoc/__about__.py)** +2. Creating & Pushing a new tag to GitHub. + +Execute the following command to create a new release: +```bash +cd +./create_release.sh 1.0.0 +``` + +After executing the script, three pipeline jobs are getting triggered automatically: +1. First job creates a new ``Release`` in github with the name of the created ``Tag``. +2. Second job uploads the new wheel package to ``PyPi`` with the ``__version__`` from the ``__about__.py`` file. +3. Third job generates a new keyword documentation via ``libdoc`` on the main branch, but pushes it to the ``gh_pages`` where it is available as public ``GitHub Page``. + +### *Backup* - Manual Version Bump & Tag Creation + +If the preferred way using the ``create_release.sh`` script does not work, you can also manually create a new release. +Therefore, execute the following steps. + +#### 1. Increase Version + +Open the file **[\_\_about\_\_.py](src/testdoc/__about__.py)** and increase the version (``0.0.5 = major.minor.path``) before building & uploading the new python wheel package! + +The new version **must be unique** & **not already existing** in ``PyPi`` & ``GitHub Releases``!!! + +Commit & push the changed version into the ``main`` branch of the repository. + +#### 2. Create new Tag + +Now, create a new tag from the main branch with the syntax ``vX.X.X`` -> example: ``v1.0.5``. + +Use the following commands to create & push the tag: +``` +git tag v0.0.5 +git push origin v0.0.5 +``` + +#### 2.1. Creating GitHub Release & Deploy Wheel Package to PyPi + +After pushing the new tag, three pipeline jobs are getting triggered automatically: +1. First job creates a new ``Release`` in github with the name of the created ``Tag``. +2. Second job uploads the new wheel package to ``PyPi`` with the ``__version__`` from the ``__about__.py`` file. +3. Third job generates a new keyword documentation via ``libdoc`` on the main branch, but pushes it to the ``gh_pages`` where it is available as public ``GitHub Page``. + +## Installing Dev Dependencies + +Contributing to this project & working with it locally, requires you to install some ``dev dependencies`` - use the following command in the project root directory: +``` +pip install -e .[dev] +``` + +Afterwards, the library gets installed in ``editable mode`` & you will have a ``dev dependencies`` installed. + +## Hatch + +You will need the python package tool ``hatch`` for several operations in this repository. +Hatch can be used to execute the linter, the tests or to build a wheel package. + +Use the following command: +```shell +pip install hatch +``` + +### Ececute Linting Checks via ruff + +```shell +hatch run dev:lint +``` + +### Execute Acceptance Tests via PyTest + +```shell +hatch run dev:atest +``` \ No newline at end of file diff --git a/README.md b/README.md index fa3d1db..7ff9496 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,20 @@ testdoc --hide-source tests/ TestDocumentation.html testdoc --hide-keywords tests/ TestDocumentation.html ``` +## Visualization of Keywords + +Keywords are visualized in a specific design and to replicate the robot framework test case structure (model) as best as possible. + +### User / Library Keywords called in a Test Case Body + +This view does actually replicate the robot framework suite file with the ``*** Test Cases ***`` section, the ``Test Case Name`` and the ``Test Case Body``. + +![alt text](docs/test_keywords_visualization.png) + +### User Keyword defined in a Test Suite + +![alt text](docs/suite_keywords_visualization.png) + ## Robot Framework Tags The commandline arguments ``include`` & ``exclude`` have more or less the same functionality like in the known ``robot ...`` command. You can decide to weither include and / or exclude specific test cases into the test documentation. @@ -138,6 +152,8 @@ border_color = "#CCCCCC" text_color = "#CCCCCC" title_color = "#00ffb9" robot_icon = "#00ffb9" +code_area_background = "#303030" +code_area_foreground = "#f1f1f1" ``` ## HTML Template Selection @@ -149,7 +165,7 @@ These template can be configured via ``cli arguments`` or within a ``.toml confi - v2 -### Available HTML Templates +### Available HTML Templates - NOT RECOMMENDED You can choose one of the following designs: - v1 @@ -230,6 +246,8 @@ border_color = "#CCCCCC" text_color = "#CCCCCC" title_color = "#00ffb9" robot_icon = "#00ffb9" +code_area_background = "#303030" +code_area_foreground = "#f1f1f1" ``` > [!TIP] @@ -247,4 +265,8 @@ robot_icon = "#00ffb9" #### Robot / Default -![alt text](docs/style_robot.png) \ No newline at end of file +![alt text](docs/style_robot.png) + +## Contribution & Development + +See [Development.md](./DEVELOPMENT.md) for more information about contributing & developing this library. \ No newline at end of file diff --git a/atest/second_test_cli.robot b/atest/second_test_cli.robot new file mode 100644 index 0000000..ef5bb1f --- /dev/null +++ b/atest/second_test_cli.robot @@ -0,0 +1,14 @@ +*** Settings *** +Name Suite 2 +Documentation Basic Console Logging - Suite Doc +Metadata Author=Marvin Klerx +Metadata Creation=January 2026 +Test Tags Global-Tag + + +*** Test Cases *** +Suite 2 - TC-001 + [Documentation] Basic Console Logging + [Tags] RobotTestDoc + + Log Log message in suite 2 - TC-001 diff --git a/atest/test_cli.py b/atest/test_cli.py index 5c2d3b0..ac46858 100644 --- a/atest/test_cli.py +++ b/atest/test_cli.py @@ -12,12 +12,23 @@ def test_cli_help(): def test_cli_cmd(): current_dir = os.path.dirname(os.path.abspath(__file__)) robot = os.path.join(current_dir, "test_cli.robot") - output = os.path.join(current_dir, "output.html") + output = os.path.join(current_dir, "output_classic.html") runner = CliRunner() result = runner.invoke(main, [robot, output]) assert result.exit_code == 0 assert "Generated" in result.output - assert "output.html" in result.output + assert "output_classic.html" in result.output + assert os.path.exists(output) + +def test_cli_cmd_big_suite(): + current_dir = os.path.dirname(os.path.abspath(__file__)) + robot = os.path.join(current_dir) + output = os.path.join(current_dir, "output_big.html") + runner = CliRunner() + result = runner.invoke(main, [robot, output]) + assert result.exit_code == 0 + assert "Generated" in result.output + assert "output_big.html" in result.output assert os.path.exists(output) diff --git a/atest/test_cli.robot b/atest/test_cli.robot index 4cb86c5..d669cda 100644 --- a/atest/test_cli.robot +++ b/atest/test_cli.robot @@ -9,4 +9,18 @@ Test Tags Global-Tag Log Message [Documentation] Basic Console Logging [Tags] RobotTestDoc - Log RobotFramework Test Documentation Generator! \ No newline at end of file + + # this is my commentary + Log RobotFramework Test Documentation Generator! + + +*** Keywords *** +MyUserKeword + [Documentation] User Keyword + + Log This is a user keyword + +My Second User Keyword + [Documentation] User Keyword + + Log This is a user keyword diff --git a/atest/testcases/component_a/suite_a.robot b/atest/testcases/component_a/suite_a.robot new file mode 100644 index 0000000..f909c85 --- /dev/null +++ b/atest/testcases/component_a/suite_a.robot @@ -0,0 +1,14 @@ +*** Test Cases *** +Suite A - TC-001 + [Tags] CompA Regression + Log A-TC001 + +Suite A - TC002 + [Tags] CompA Smoke + Log A-TC002 + + +*** Keywords *** +Suite A - User Keyword A + [Documentation] docs + Log A-KW001 diff --git a/atest/testcases/component_b/subcomponent_b/subsuite_b.robot b/atest/testcases/component_b/subcomponent_b/subsuite_b.robot new file mode 100644 index 0000000..7d92fe0 --- /dev/null +++ b/atest/testcases/component_b/subcomponent_b/subsuite_b.robot @@ -0,0 +1,14 @@ +*** Test Cases *** +Subsuite B - TC-001 + [Tags] CompB Regression + Log SB-TC001 + +Subsuite B - TC002 + [Tags] CompB Smoke Feature-XYZ + Log SB-TC002 + + +*** Keywords *** +Subsuite B - User Keyword B + [Documentation] docs + Log SB-KW001 \ No newline at end of file diff --git a/atest/testcases/component_b/suite_b.robot b/atest/testcases/component_b/suite_b.robot new file mode 100644 index 0000000..8ea4256 --- /dev/null +++ b/atest/testcases/component_b/suite_b.robot @@ -0,0 +1,20 @@ +*** Settings *** +Name Custom Name Suite B +Metadata author=Marvin Klerx +Metadata creation_date=January 2026 +Metadata company=imbus AG + + +*** Test Cases *** +Suite C - TC001 + [Tags] CompC robot:exclude + Log C-TC001 + +Suite C - TC002 + [Tags] CompC test:retry(1) + Log C-TC002 + + +*** Keywords *** +Suite C - User Keyword 1 + Log Suite C - User Keyword 1 diff --git a/docs/suite_keywords_visualization.png b/docs/suite_keywords_visualization.png new file mode 100644 index 0000000..e46efc0 Binary files /dev/null and b/docs/suite_keywords_visualization.png differ diff --git a/docs/test_keywords_visualization.png b/docs/test_keywords_visualization.png new file mode 100644 index 0000000..2ce2cb7 Binary files /dev/null and b/docs/test_keywords_visualization.png differ diff --git a/pyproject.toml b/pyproject.toml index 34a1ef6..1313357 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "robotframework-testdoc" dynamic = ["version"] description = "A CLI Tool to generate a Test Documentation for your RobotFramework Test Scripts." readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" authors = [ { name = "Marvin Klerx", email = "marvinklerx20@gmail.com" } ] @@ -16,7 +16,6 @@ license = { text = "Apache-2.0" } classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12" @@ -26,7 +25,8 @@ dependencies = [ "click", "robotframework", "jinja2", - "tomli" + "tomli", + "pydantic" ] [project.optional-dependencies] diff --git a/src/testdoc/html/rendering/render.py b/src/testdoc/html/rendering/render.py index bc1995d..fcceb9e 100644 --- a/src/testdoc/html/rendering/render.py +++ b/src/testdoc/html/rendering/render.py @@ -1,6 +1,8 @@ from jinja2 import Environment, FileSystemLoader import os +from ...parser.models import SuiteInfoModel + from ...html.themes.theme_config import ThemeConfig from ...helper.cliargs import CommandLineArguments from ...helper.datetimeconverter import DateTimeConverter @@ -26,7 +28,7 @@ def _html_templ_selection(self): raise ValueError(f"CLI Argument 'html_template' got value '{self.args.html_template}' - value not known!") def render_testdoc(self, - suites, + suites: list[SuiteInfoModel], output_file ): env = Environment(loader=FileSystemLoader(self.TEMPLATE_DIR)) diff --git a/src/testdoc/html/templates/v2/jinja_template_03.html b/src/testdoc/html/templates/v2/jinja_template_03.html index 544edbc..f8114b7 100644 --- a/src/testdoc/html/templates/v2/jinja_template_03.html +++ b/src/testdoc/html/templates/v2/jinja_template_03.html @@ -16,35 +16,53 @@ {% macro render_test_cases(suite) %} {% if suite.tests %}
-
Generic Suite Details:
+
Test Suite Details:
+
- + {% if not suite.is_folder %} - + {% endif %} - + {% if suite.doc is not none %} - + {% endif %} + {% if suite.source is not none %} + + + + + {% endif %} {% if suite.metadata is not none %} - + {% endif %}
📁 Suite Name:📁 Suite Name: {{ suite.name }}
📄 File Name:📄 File Name: {{ suite.filename }}
📊 Number of Tests:📊 Number of Tests: {{ suite.num_tests }}
📝 Docs:📝 Docs: {{ suite.doc }}
🔗 Source: + {{ suite.source }} +
⚙️ Metadata:⚙️ Metadata: {{ suite.metadata }}
+ {% if suite.user_keywords is not none %} +
+
🔑 Suite User Keywords:
+ {% if suite.user_keywords %} +
+*** Keywords ***
+    {{ suite.user_keywords | join('\n    ') }}
+ {% endif %} + {% endif %}
{% for test in suite.tests %}
@@ -57,6 +75,8 @@
+
Test Case Details:
+
{% set has_info = test.doc is not none or test.source is not none or test.tags is not none or test.keywords is not none %} {% if test.doc is not none %} @@ -67,7 +87,7 @@ {% endif %} {% if test.source is not none %} - + @@ -75,30 +95,31 @@ {% endif %} {% if test.tags is not none %} - + {% endif %} - {% if test.keywords is not none %} - - - - - {% endif %} {% if not has_info %} - {% endif %}
🔗 Source:🔗 Source: {{ test.source }}
🏷 Tags:🏷 Tags: {{ test.tags | join(', ') }}
🔑 Keywords: - {% if test.keywords %} -
- {{ test.keywords | join('\n- ') }}
- {% endif %} -
+ No Details Available / Enabled !
+ {% if test.keywords is not none %} +
+
🔑 Keywords / Test Case Body:
+ {% if test.keywords %} +
+*** Test Cases ***
+{{ test.name }}
+    {{ test.keywords | join('\n    ') }}
+                                
+ {% endif %} + {% endif %}
@@ -106,25 +127,34 @@ {% endif %} {% if suite.sub_suites %}
-
Generic Parent Suite Details:
+
Parent Test Suite Details:
+
- + - + {% if suite.doc is not none %} - + {% endif %} + {% if suite.source is not none %} + + + + + {% endif %} {% if suite.metadata is not none %} - + {% endif %} @@ -210,6 +240,21 @@ .sidebar { min-width: 120px; max-width: 180px; } .main-content { padding: 10px; } } + .keywords-block, + .keywords-block pre { + color: {{ colors.text_color }}; + } + + .keywords-block-div { + margin-bottom: 8px; + } + + .keywords-block-pre { + background-color: {{ colors.code_area_background }}; + color: {{ colors.code_area_foreground }}; + border-radius: 8px; + padding: 12px 14px; + } diff --git a/src/testdoc/html/themes/themes.py b/src/testdoc/html/themes/themes.py index 92b24d8..bbb7ebe 100644 --- a/src/testdoc/html/themes/themes.py +++ b/src/testdoc/html/themes/themes.py @@ -8,6 +8,8 @@ "text_color": "#353535", "title_color": "#343a40", "robot_icon": "#00c0b5", + "code_area_background": "#f8f9fa", + "code_area_foreground": "#353535" } ROBOT_THEME = { @@ -19,6 +21,8 @@ "text_color": "black", "title_color": "black", "robot_icon": "#00c0b5", + "code_area_background": "#dcdcdc", + "code_area_foreground": "#black" } ROBOT_THEME_DARK = { @@ -30,6 +34,8 @@ "text_color": "#e3e3e3", "title_color": "#e3e3e3", "robot_icon": "#00c0b5", + "code_area_background": "#272729", + "code_area_foreground": "#e3e3e3" } GREEN_THEME = { @@ -41,6 +47,8 @@ "text_color": "#009770", "title_color": "#009770", "robot_icon": "#009770", + "code_area_background": "#272729", + "code_area_foreground": "#009770" } DARK_THEME = { @@ -52,6 +60,8 @@ "text_color": "white", "title_color": "#00c0b5", "robot_icon": "#00c0b5", + "code_area_background": "#5F5F5F", + "code_area_foreground": "#f1f1f1" } BLUE_THEME = { @@ -63,4 +73,6 @@ "text_color": "#CCCCCC", "title_color": "#00ffb9", "robot_icon": "#00ffb9", + "code_area_background": "#303030", + "code_area_foreground": "#f1f1f1" } diff --git a/src/testdoc/parser/models.py b/src/testdoc/parser/models.py new file mode 100644 index 0000000..0d6f501 --- /dev/null +++ b/src/testdoc/parser/models.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel + +class SuiteInfoModel(BaseModel): + id: str + filename: str + name: str + doc: str | None + is_folder: bool + num_tests: int + source: str + total_tests: int = 0 + tests: list = [] + user_keywords: list | None = None + sub_suites: list = [] + metadata: str | None + +class TestInfoModel(BaseModel): + name: str + doc: str + tags: list + source: str + keywords: list[str] | list \ No newline at end of file diff --git a/src/testdoc/parser/modifier/sourceprefixmodifier.py b/src/testdoc/parser/modifier/sourceprefixmodifier.py index 8993c4f..235ab63 100644 --- a/src/testdoc/parser/modifier/sourceprefixmodifier.py +++ b/src/testdoc/parser/modifier/sourceprefixmodifier.py @@ -1,8 +1,11 @@ import os from abc import ABC, abstractmethod +from typing import cast from robot.api import TestSuite +from ...parser.models import SuiteInfoModel, TestInfoModel + from ...helper.cliargs import CommandLineArguments from ...helper.logger import Logger @@ -78,7 +81,7 @@ def _get_git_branch(self, git_root): with open(head_file, "r") as f: content = f.read().strip() if content.startswith("ref:"): - return content.split("/")[-1] + return content.replace("ref: refs/heads/", "") return "main" def _convert_to_gitlab_url(self, file_path, prefix): @@ -89,19 +92,20 @@ def _convert_to_gitlab_url(self, file_path, prefix): rel_path = os.path.relpath(file_path, git_root).replace(os.sep, "/") return prefix.rstrip("/") + "/-/blob/" + git_branch + "/" + rel_path - def apply(self, suite_dict, prefix): + def apply(self, suite_dict: SuiteInfoModel, prefix): try: - suite_dict["source"] = self._convert_to_gitlab_url(suite_dict["source"], prefix) + suite_dict.source = self._convert_to_gitlab_url(suite_dict.source, prefix) except: - suite_dict["source"] = "GitLink error" + suite_dict.source = "GitLink error" - for test in suite_dict.get("tests", []): + for test in suite_dict.tests: + test = cast(TestInfoModel, test) try: - test["source"] = self._convert_to_gitlab_url(test["source"], prefix) + test.source = self._convert_to_gitlab_url(test.source, prefix) except: - test["source"] = "GitLink error" + test.source = "GitLink error" - for sub_suite in suite_dict.get("sub_suites", []): + for sub_suite in suite_dict.sub_suites: self.apply(sub_suite, prefix) ######################################## diff --git a/src/testdoc/parser/modifier/suitefilemodifier.py b/src/testdoc/parser/modifier/suitefilemodifier.py index 5213b2f..2d94187 100644 --- a/src/testdoc/parser/modifier/suitefilemodifier.py +++ b/src/testdoc/parser/modifier/suitefilemodifier.py @@ -3,6 +3,7 @@ from ...helper.cliargs import CommandLineArguments from ...helper.logger import Logger from .sourceprefixmodifier import SourcePrefixModifier +from ..models import SuiteInfoModel class SuiteFileModifier(): @@ -12,7 +13,10 @@ def __init__(self): ############################################################################################################################# - def run(self, suite_object: TestSuite = None): + def run( + self, + suite_object: list[SuiteInfoModel] | None = None + ) -> list[SuiteInfoModel]: if not suite_object: raise KeyError(f"[{self.__class__}] - Error - Suite Object must not be None!") self.suite = suite_object diff --git a/src/testdoc/parser/testcaseparser.py b/src/testdoc/parser/testcaseparser.py index 150c0f9..fabca84 100644 --- a/src/testdoc/parser/testcaseparser.py +++ b/src/testdoc/parser/testcaseparser.py @@ -2,6 +2,7 @@ from robot.running.model import Keyword, Body from robot.errors import DataError from ..helper.cliargs import CommandLineArguments +from .models import SuiteInfoModel, TestInfoModel import textwrap class TestCaseParser(): @@ -11,19 +12,19 @@ def __init__(self): def parse_test(self, suite: TestSuite, - suite_info: dict - ) -> dict: + suite_info: SuiteInfoModel + ) -> SuiteInfoModel: for test in suite.tests: - test_info = { - "name": test.name, - "doc": "
".join(line.replace("\\n","") for line in test.doc.splitlines() + test_info: TestInfoModel = TestInfoModel( + name=test.name, + doc="
".join(line.replace("\\n","") for line in test.doc.splitlines() if line.strip()) if test.doc else "No Test Case Documentation Available", - "tags": test.tags if test.tags else ["No Tags Configured"], - "source": str(test.source), - "keywords": self._keyword_parser(test.body) - } - suite_info["tests"].append(test_info) + tags=test.tags if test.tags else ["No Tags Configured"], + source=str(test.source), + keywords=self._keyword_parser(test.body) + ) + suite_info.tests.append(test_info) return suite_info # Consider tags via officially provided robot api @@ -88,7 +89,9 @@ def _handle_keyword_types(self, kw: Keyword, indent: int = 0): result.append(header) for subkw in getattr(branch, 'body', []): result.extend(self._handle_keyword_types(subkw, indent=indent+1)) - result.append(f"{_indent}END") + result.append( + f"{_indent}END\n" if indent == 0 else f"{_indent}END" + ) # FOR loop elif kw_type == "FOR": @@ -103,7 +106,9 @@ def _handle_keyword_types(self, kw: Keyword, indent: int = 0): if hasattr(kw, 'body'): for subkw in kw.body: result.extend(self._handle_keyword_types(subkw, indent=indent+1)) - result.append(f"{_indent}END") + result.append( + f"{_indent}END\n" if indent == 0 else f"{_indent}END" + ) # GROUP loop elif kw_type == "GROUP": @@ -116,7 +121,9 @@ def _handle_keyword_types(self, kw: Keyword, indent: int = 0): if hasattr(kw, 'body'): for subkw in kw.body: result.extend(self._handle_keyword_types(subkw, indent=indent+1)) - result.append(f"{_indent}END") + result.append( + f"{_indent}END\n" if indent == 0 else f"{_indent}END" + ) # WHILE loop elif kw_type == "WHILE": @@ -127,7 +134,9 @@ def _handle_keyword_types(self, kw: Keyword, indent: int = 0): if hasattr(kw, 'body'): for subkw in kw.body: result.extend(self._handle_keyword_types(subkw, indent=indent+1)) - result.append(f"{_indent}END") + result.append( + f"{_indent}END\n" if indent == 0 else f"{_indent}END" + ) # TRY/EXCEPT/FINALLY elif kw_type in ("TRY", "EXCEPT", "FINALLY"): @@ -141,7 +150,9 @@ def _handle_keyword_types(self, kw: Keyword, indent: int = 0): for subkw in kw.body: result.extend(self._handle_keyword_types(subkw, indent=indent+1)) if kw_type in ("EXCEPT", "FINALLY"): - result.append(f"{_indent}END") + result.append( + f"{_indent}END\n" if indent == 0 else f"{_indent}END" + ) # BREAK, CONTINUE, RETURN, ERROR elif kw_type in ("BREAK", "CONTINUE", "RETURN", "ERROR"): diff --git a/src/testdoc/parser/testsuiteparser.py b/src/testdoc/parser/testsuiteparser.py index bc3afb0..0658a00 100644 --- a/src/testdoc/parser/testsuiteparser.py +++ b/src/testdoc/parser/testsuiteparser.py @@ -2,12 +2,17 @@ # Derived code: see class `RobotSuiteFiltering`. import os from pathlib import Path +from typing import Tuple, cast from robot.api import SuiteVisitor, TestSuite +from robot.api.parsing import get_model +from robot.parsing.model.blocks import File, KeywordSection, Keyword +from robot import running from .testcaseparser import TestCaseParser from .modifier.suitefilemodifier import SuiteFileModifier from ..helper.cliargs import CommandLineArguments -from..helper.pathconverter import PathConverter +from ..helper.pathconverter import PathConverter +from .models import SuiteInfoModel from robot.conf import RobotSettings from robot.running import TestSuiteBuilder @@ -19,7 +24,7 @@ class RobotSuiteParser(SuiteVisitor): def __init__(self): self.suite_counter = 0 - self.suites = [] + self.suites: list[SuiteInfoModel] = [] self.tests = [] self.args = CommandLineArguments() @@ -28,32 +33,51 @@ def visit_suite(self, suite): # Skip suite if its already parsed into list self._already_parsed(suite) + suite_info: SuiteInfoModel = SuiteInfoModel( + id=str(suite.longname).lower().replace(".", "_").replace(" ", "_"), + filename=str(Path(suite.source).name) if suite.source else suite.name, + name=suite.name, + doc="
".join(line.replace("\\n","") for line in suite.doc.splitlines() if line.strip()) if suite.doc else None, + is_folder=self._is_directory(suite), + num_tests=len(suite.tests), + source=str(suite.source), + metadata="
".join([f"{k} {v}" for k, v in suite.metadata.items()]) if suite.metadata else None, + user_keywords=None + ) + # Test Suite Parser - suite_info = { - "id": str(suite.longname).lower().replace(".", "_").replace(" ", "_"), - "filename": str(Path(suite.source).name) if suite.source else suite.name, - "name": suite.name, - "doc": "
".join(line.replace("\\n","") for line in suite.doc.splitlines() if line.strip()) if suite.doc else None, - "is_folder": self._is_directory(suite), - "num_tests": len(suite.tests), - "source": str(suite.source), - "total_tests": 0, - "tests": [], - "sub_suites": [], - "metadata": "
".join([f"{k}: {v}" for k, v in suite.metadata.items()]) if suite.metadata else None - } + # suite_info = { + # "id": str(suite.longname).lower().replace(".", "_").replace(" ", "_"), + # "filename": str(Path(suite.source).name) if suite.source else suite.name, + # "name": suite.name, + # "doc": "
".join(line.replace("\\n","") for line in suite.doc.splitlines() if line.strip()) if suite.doc else None, + # "is_folder": self._is_directory(suite), + # "num_tests": len(suite.tests), + # "source": str(suite.source), + # "total_tests": 0, + # "tests": [], + # "user_keywords": [], + # "sub_suites": [], + # "metadata": "
".join([f"{k}: {v}" for k, v in suite.metadata.items()]) if suite.metadata else None + # } # Parse Test Cases suite_info = TestCaseParser().parse_test(suite, suite_info) + if not suite_info.is_folder: + # visit suite model to check if user keywords got created + suite_info = self.get_suite_user_keywords(str(suite.source) ,suite_info) + # Collect sub-suites recursive suite_info, total_tests = self._recursive_sub_suite(suite, suite_info) + # add count of total tests + suite_info.total_tests = total_tests + # Append to suites object - suite_info["total_tests"] = total_tests self.suites.append(suite_info) - def parse_suite(self): + def parse_suite(self) -> list[SuiteInfoModel]: # Use official Robot Framework Application Package to parse cli arguments and modify suite object. robot_options = self._convert_args() _rfs = RobotSuiteFiltering() @@ -63,30 +87,56 @@ def parse_suite(self): # Custom suite object modification with new test doc library suite = SuiteFileModifier()._modify_root_suite_details(suite) suite.visit(self) + return self.suites ############################################################################################## # Helper: ############################################################################################## + def get_suite_user_keywords( + self, + suite_path: str, + suite_info: SuiteInfoModel + ) -> SuiteInfoModel: + """ + function checks if user keywords are defined within the currently visiting suite object + """ + + suite_model: File = get_model(suite_path) + for section in suite_model.sections: + if not isinstance(section, KeywordSection): + continue + + if len(section.body) == 0: + return + + section = cast(KeywordSection, section) + suite_keywords: list = [] + for kw in section.body: + kw = cast(Keyword, kw) + suite_keywords.append(kw.name) + suite_info.user_keywords = suite_keywords + return suite_info + def _recursive_sub_suite(self, suite: TestSuite, - suite_info: dict - ): - total_tests = suite_info["num_tests"] + suite_info: SuiteInfoModel + ) -> Tuple[SuiteInfoModel, int]: + total_tests = suite_info.num_tests for sub_suite in suite.suites: sub_parser = RobotSuiteParser() sub_parser.visit_suite(sub_suite) - suite_info["sub_suites"].extend(sub_parser.suites) - total_tests += sum(s["total_tests"] for s in sub_parser.suites) + suite_info.sub_suites.extend(sub_parser.suites) + total_tests += sum(s.total_tests for s in sub_parser.suites) return suite_info, total_tests def _is_directory(self, suite) -> bool: suite_path = suite.source if suite.source else "" return(os.path.isdir(suite_path) if suite_path else False) - def _already_parsed(self, suite): - existing_suite = next((s for s in self.suites if s["name"] == suite.name), None) + def _already_parsed(self, suite: running.TestSuite): + existing_suite = next((s for s in self.suites if s.name == suite.name), None) if existing_suite: return diff --git a/src/testdoc/testdoc.py b/src/testdoc/testdoc.py index 1d5662c..7acd53e 100644 --- a/src/testdoc/testdoc.py +++ b/src/testdoc/testdoc.py @@ -2,12 +2,13 @@ from .parser.testsuiteparser import RobotSuiteParser from .html.rendering.render import TestDocHtmlRendering from .parser.modifier.suitefilemodifier import SuiteFileModifier +from .parser.models import SuiteInfoModel class TestDoc(): - def main(self): + def main(self): # Parse suite object & return complete suite object with all information - suite_object = RobotSuiteParser().parse_suite() + suite_object: list[SuiteInfoModel] = RobotSuiteParser().parse_suite() # Run SuiteFileModifier to modify the test suite object suite_object = SuiteFileModifier().run(suite_object)
📁 Suite Name:📁 Suite Name: {{ suite.name }}
📊 Number of Tests:📊 Number of Tests: {{ suite.total_tests }}
📝 Docs:📝 Docs: {{ suite.doc }}
🔗 Source: + {{ suite.source }} +
⚙️ Metadata:⚙️ Metadata: {{ suite.metadata }}