diff --git a/.gitignore b/.gitignore
index 2e1f4a9..a349f38 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,5 @@ output_doc.html
output.html
src/testdoc/html/templates/jinja_template_02.html
image.png
+output_doc_robot.html
+devel.html
diff --git a/README.md b/README.md
index a482892..ef002b6 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,37 @@ For using this config file, just call the following command:
testdoc -c path/to/config.toml tests/ TestDocumentation.html
```
+## HTML Template Selection
+
+You can choose between multiple HTML template for the design of your test documentation.
+These template can be configured via ``cli arguments`` or within a ``.toml configuration file`` with the parameter ``html_template (-ht / --html-template)``.
+
+### Default Design
+
+- v2
+
+### Available HTML Templates
+
+You can choose one of the following designs:
+- v1
+- v2
+
+### Version 1
+
+#### Visit Tests
+
+
+
+### Version 2
+
+#### Visit Tests on Root Suite Level
+
+
+
+#### Visit Tests on Suite File Level
+
+
+
## Theme Selection / Color Configuration
You can select between several themes (color configurations) for your HTML document to create!
diff --git a/docs/html_v1_common.png b/docs/html_v1_common.png
new file mode 100644
index 0000000..b0f1af9
Binary files /dev/null and b/docs/html_v1_common.png differ
diff --git a/docs/html_v2_root.png b/docs/html_v2_root.png
new file mode 100644
index 0000000..7c85f01
Binary files /dev/null and b/docs/html_v2_root.png differ
diff --git a/docs/html_v2_suitefile.png b/docs/html_v2_suitefile.png
new file mode 100644
index 0000000..1f2abd3
Binary files /dev/null and b/docs/html_v2_suitefile.png differ
diff --git a/pyproject.toml b/pyproject.toml
index bd2165f..651b3ed 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,11 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "robotframework-testdoc"
-version = "0.1.8"
+version = "0.2.0"
description = "A CLI Tool to generate a Test Documentation for your RobotFramework Test Scripts."
readme = "README.md"
requires-python = ">=3.7"
-authors = [{ name = "Marvin Klerx", email = "marvinklerx20@gmail.com" }]
+authors = [
+ { name = "Marvin Klerx", email = "marvinklerx20@gmail.com" }
+]
license = { text = "MIT" }
dependencies = [
@@ -21,6 +23,16 @@ dependencies = [
[project.scripts]
testdoc = "testdoc.cli:main"
+[tool.setuptools]
+include-package-data = true
+
+[tool.setuptools.package-data]
+"testdoc" = [
+ "html/images/*.svg",
+ "html/templates/**/*.html",
+ "default.toml"
+]
+
[tool.ruff]
line-length = 150
lint.select = ["E", "F"] # Pyflakes & pycodestyle
diff --git a/src/testdoc/cli.py b/src/testdoc/cli.py
index 213b326..adc1be1 100644
--- a/src/testdoc/cli.py
+++ b/src/testdoc/cli.py
@@ -4,7 +4,8 @@
from .testdoc import TestDoc
from .helper.cliargs import CommandLineArguments
-@click.command()
+CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
+@click.command(context_settings=CONTEXT_SETTINGS)
@click.option("-t","--title", required=False, help="Modify the title of the test documentation page")
@click.option("-n","--name", required=False, help="Modify the name of the root suite element")
@click.option("-d","--doc", required=False, help="Modify the documentation of the root suite element")
@@ -20,8 +21,10 @@
@click.option("--hide-source", is_flag=True, required=False, help="If given, test suite/ test case source is hidden")
@click.option("--hide-keywords", is_flag=True, required=False, help="If given, keyword calls in test cases are hidden")
@click.option("-S", "--style", required=False, help="Choose a predefined default style theme - 'default', 'robot', 'dark' or 'blue' ")
+@click.option("-ht","--html-template", required=False, help="Select the HTML template - possible values: 'v1', 'v2'")
@click.option("-c", "--configfile", required=False, help="Optional .toml configuration file (includes all cmd-args)")
@click.option("-v", "--verbose", is_flag=True, required=False, help="More precise debugging into shell")
+@click.version_option(package_name='robotframework-testdoc')
@click.argument("PATH")
@click.argument("OUTPUT")
def main(
@@ -38,6 +41,7 @@ def main(
hide_source,
hide_keywords,
style,
+ html_template,
configfile,
verbose,
path,
@@ -81,6 +85,7 @@ def main(
"hide_keywords": hide_keywords or None,
"verbose_mode": verbose or None,
"style": style or None,
+ "html_template": html_template or None,
"config_file": configfile or None,
}
args.suite_file = path
diff --git a/src/testdoc/helper/cliargs.py b/src/testdoc/helper/cliargs.py
index af7f092..2c0fcc4 100644
--- a/src/testdoc/helper/cliargs.py
+++ b/src/testdoc/helper/cliargs.py
@@ -1,6 +1,6 @@
from dataclasses import dataclass, field
from typing import Any, List
-import tomli
+from .toml_reader import TOMLReader
import os
@dataclass
@@ -21,6 +21,7 @@ class CommandLineArgumentsData:
verbose_mode: bool = False
suite_file: str = None
style: str = None
+ html_template: str = "v2"
output_file: str = None
colors: dict = None
@@ -37,9 +38,7 @@ def __new__(cls):
### Load configuration file
###
def load_from_config_file(self, file_path: str):
- with open(file_path, "rb") as f:
- config = tomli.load(f)
-
+ config = TOMLReader()._read_toml(file_path)
_is_pyproject = self._is_pyproject_config(file_path)
if _is_pyproject:
self._handle_pyproject_config(config)
diff --git a/src/testdoc/helper/toml_reader.py b/src/testdoc/helper/toml_reader.py
new file mode 100644
index 0000000..0a2f5ac
--- /dev/null
+++ b/src/testdoc/helper/toml_reader.py
@@ -0,0 +1,11 @@
+import tomli
+
+class TOMLReader():
+
+ def _read_toml(self, file_path:str):
+ try:
+ with open(file_path, "rb") as f:
+ config = tomli.load(f)
+ return config
+ except Exception as e:
+ raise ImportError(f"Cannot read toml file in: {file_path} with error: \n{e}")
\ No newline at end of file
diff --git a/src/testdoc/html/templates/jinja_template_01.html b/src/testdoc/html/templates/v1/jinja_template_01.html
similarity index 98%
rename from src/testdoc/html/templates/jinja_template_01.html
rename to src/testdoc/html/templates/v1/jinja_template_01.html
index 957276b..b1d433f 100644
--- a/src/testdoc/html/templates/jinja_template_01.html
+++ b/src/testdoc/html/templates/v1/jinja_template_01.html
@@ -121,7 +121,9 @@
+ Generated at: {{ generated_at }} robotframework-testdoc by Marvin Klerx
+
+
+
+
diff --git a/src/testdoc/html/templates/v2/jinja_template_03.html b/src/testdoc/html/templates/v2/jinja_template_03.html
new file mode 100644
index 0000000..86fc5f1
--- /dev/null
+++ b/src/testdoc/html/templates/v2/jinja_template_03.html
@@ -0,0 +1,316 @@
+
+{% macro render_suite_tree(suite, parent_id='root') %}
+
+ {{ suite.filename }}
+ {% if suite.sub_suites %}
+
+ {% for sub_suite in suite.sub_suites %}
+ {{ render_suite_tree(sub_suite, sub_suite.id) }}
+ {% endfor %}
+
+ {% endif %}
+
+{% endmacro %}
+
+
+{% macro render_test_cases(suite) %}
+ {% if suite.tests %}
+
+ {% for test in suite.tests %}
+
+
+
+
+
+ {% 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 %}
+
+ 📝 Docs:
+ {{ test.doc }}
+
+ {% endif %}
+ {% if test.source is not none %}
+
+ 🔗 Source:
+
+ {{ test.source }}
+
+
+ {% endif %}
+ {% if test.tags is not none %}
+
+ 🏷 Tags:
+
+ {% if test.tags and test.tags is string %}
+ {{ test.tags }}
+ {% else %}
+ {{ test.tags | join(', ') }}
+ {% endif %}
+
+
+ {% endif %}
+ {% if test.keywords is not none %}
+
+ 🔑 Keywords:
+
+ {% if test.keywords %}
+ - {{ test.keywords | join('\n- ') }}
+ {% endif %}
+
+
+ {% endif %}
+ {% if not has_info %}
+
+
+ No Details Available / Enabled !
+
+
+ {% endif %}
+
+
+
+
+ {% endfor %}
+ {% endif %}
+ {% if suite.sub_suites %}
+
+ {% for sub_suite in suite.sub_suites %}
+ {{ render_test_cases(sub_suite) }}
+ {% endfor %}
+ {% endif %}
+{% endmacro %}
+
+
+
+
+
+
+ Robot Framework - Test Documentation
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/testdoc/html_rendering/render.py b/src/testdoc/html_rendering/render.py
index b04eaba..13502ab 100644
--- a/src/testdoc/html_rendering/render.py
+++ b/src/testdoc/html_rendering/render.py
@@ -8,23 +8,36 @@
class TestDocHtmlRendering():
- TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "html", "templates")
-
def __init__(self):
self.args = CommandLineArguments().data
+ self._html_templ_selection()
+
+ def _html_templ_selection(self):
+ """ Check which HTML template should selected - custom specific configuration """
+ if self.args.html_template == "v1":
+ self.HTML_TEMPLATE_VERSION = self.args.html_template
+ self.HTML_TEMPLATE_NAME = "jinja_template_01.html"
+ self.TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "html", "templates", self.HTML_TEMPLATE_VERSION)
+ elif self.args.html_template == "v2":
+ self.HTML_TEMPLATE_VERSION = self.args.html_template
+ self.HTML_TEMPLATE_NAME = "jinja_template_03.html"
+ self.TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "html", "templates", self.HTML_TEMPLATE_VERSION)
+ else:
+ raise ValueError(f"CLI Argument 'html_template' got value '{self.args.html_template}' - value not known!")
def render_testdoc(self,
suites,
output_file
):
env = Environment(loader=FileSystemLoader(self.TEMPLATE_DIR))
- template = env.get_template("jinja_template_01.html")
+ template = env.get_template(self.HTML_TEMPLATE_NAME)
rendered_html = template.render(
suites=suites,
generated_at=DateTimeConverter().get_generated_datetime(),
title=self.args.title,
- colors=ThemeConfig().theme()
+ colors=ThemeConfig().theme(),
+ contact_mail = "marvinklerx20@gmail.com"
)
with open(output_file, "w", encoding="utf-8") as f:
f.write(rendered_html)
diff --git a/src/testdoc/parser/testcaseparser.py b/src/testdoc/parser/testcaseparser.py
index 870019d..54a266e 100644
--- a/src/testdoc/parser/testcaseparser.py
+++ b/src/testdoc/parser/testcaseparser.py
@@ -1,6 +1,8 @@
from robot.api import TestSuite
+from robot.running.model import Keyword, Body
from robot.errors import DataError
from ..helper.cliargs import CommandLineArguments
+import textwrap
class TestCaseParser():
@@ -19,7 +21,7 @@ def parse_test(self,
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": [kw.name for kw in test.body if hasattr(kw, 'name')] or "No Keyword Calls in Test"
+ "keywords": self._keyword_parser(test.body)
}
suite_info["tests"].append(test_info)
return suite_info
@@ -33,4 +35,133 @@ def consider_tags(self, suite: TestSuite) -> TestSuite:
suite.configure(exclude_tags=self.args.exclude)
return suite
except DataError as e:
- raise DataError(e.message)
\ No newline at end of file
+ raise DataError(e.message)
+
+ def _keyword_parser(self, test_body: Body):
+ """ Parse keywords and their child-items """
+ _keyword_object = []
+ for kw in test_body:
+ _keyword_object.extend(self._handle_keyword_types(kw))
+
+ _keyword_object = self._kw_post_processing(_keyword_object)
+
+ # Fallback in case of no keywords
+ if len(_keyword_object) == 0:
+ return "No Keyword Calls in Test"
+ return _keyword_object
+
+ def _handle_keyword_types(self, kw: Keyword, indent: int = 0):
+ """ Handle different keyword types """
+ result = []
+ kw_type = getattr(kw, 'type', None)
+
+ _sd = " " # classic rfw delimiter with 4 spaces
+ _indent = _sd * indent
+
+ # Classic keyword
+ if kw_type == "KEYWORD" and getattr(kw, 'name', None):
+ args = _sd.join(kw.args) if getattr(kw, 'args', None) else ""
+ entry = _indent + kw.name
+ if args:
+ entry += _sd + args
+ wrapped = textwrap.wrap(entry, width=150, subsequent_indent=_indent + "..." + _sd)
+ result.extend(wrapped)
+
+ # VAR syntax
+ elif kw_type == "VAR" and getattr(kw, 'name', None):
+ value = _sd.join(kw.value) if getattr(kw, 'value', None) else ""
+ result.append(f"{_indent}VAR {kw.name} = {value}")
+
+ # IF/ELSE/ELSE IF
+ elif kw_type == "IF/ELSE ROOT":
+ for branch in getattr(kw, 'body', []):
+ branch_type = getattr(branch, 'type', None)
+ if branch_type == "IF":
+ header = f"{_indent}IF{_sd}{getattr(branch, 'condition', '')}".rstrip()
+ elif branch_type == "ELSE IF":
+ header = f"{_indent}ELSE IF{_sd}{getattr(branch, 'condition', '')}".rstrip()
+ elif branch_type == "ELSE":
+ header = f"{_indent}ELSE"
+ else:
+ header = f"{_indent}{branch_type or ''}"
+ if header:
+ result.append(header)
+ for subkw in getattr(branch, 'body', []):
+ result.extend(self._handle_keyword_types(subkw, indent=indent+1))
+ result.append(f"{_indent}END")
+
+ # FOR loop
+ elif kw_type == "FOR":
+ header = f"{_indent}FOR"
+ if hasattr(kw, 'assign') and kw.assign:
+ header += f" {' '.join(kw.assign)}"
+ if hasattr(kw, 'flavor') and kw.flavor:
+ header += f" {kw.flavor}"
+ if hasattr(kw, 'values') and kw.values:
+ header += f" IN {' '.join(kw.values)}"
+ result.append(header)
+ if hasattr(kw, 'body'):
+ for subkw in kw.body:
+ result.extend(self._handle_keyword_types(subkw, indent=indent+1))
+ result.append(f"{_indent}END")
+
+ # WHILE loop
+ elif kw_type == "WHILE":
+ header = f"{_indent}WHILE"
+ if hasattr(kw, 'condition') and kw.condition:
+ header += f" {kw.condition}"
+ result.append(header)
+ if hasattr(kw, 'body'):
+ for subkw in kw.body:
+ result.extend(self._handle_keyword_types(subkw, indent=indent+1))
+ result.append(f"{_indent}END")
+
+ # TRY/EXCEPT/FINALLY
+ elif kw_type in ("TRY", "EXCEPT", "FINALLY"):
+ header = f"{_indent}{kw_type}"
+ if hasattr(kw, 'patterns') and kw.patterns:
+ header += f" {' '.join(kw.patterns)}"
+ if hasattr(kw, 'condition') and kw.condition:
+ header += f" {kw.condition}"
+ result.append(header)
+ if hasattr(kw, 'body'):
+ 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")
+
+ # BREAK, CONTINUE, RETURN, ERROR
+ elif kw_type in ("BREAK", "CONTINUE", "RETURN", "ERROR"):
+ entry = f"{_indent}{kw_type}"
+ if hasattr(kw, 'values') and kw.values:
+ entry += f" {' '.join(kw.values)}"
+ result.append(entry)
+
+ # Other types
+ elif kw_type in ("COMMENT", "EMPTY"):
+ pass
+
+ # Unknown types
+ elif hasattr(kw, 'body'):
+ for subkw in kw.body:
+ result.extend(self._handle_keyword_types(subkw))
+
+ return result
+
+ def _kw_post_processing(self, kw: list):
+ """ Post-processing of generated keyword list to handle special cases """
+ # TRY/EXCEPT/FINALLY
+ # post-process list for specific handling
+ for i in range(len(kw) - 1):
+ _cur = str(kw[i]).replace(" ", "")
+ _next = str(kw[i + 1]).replace(" ", "")
+ if _cur == "END" and _next == "FINALLY":
+ kw.pop(i)
+ break
+ return kw
+
+
+
+
+
+
diff --git a/src/testdoc/parser/testsuiteparser.py b/src/testdoc/parser/testsuiteparser.py
index 606ca5a..fe2c24c 100644
--- a/src/testdoc/parser/testsuiteparser.py
+++ b/src/testdoc/parser/testsuiteparser.py
@@ -1,4 +1,5 @@
import os
+from pathlib import Path
from robot.api import SuiteVisitor, TestSuite
from .testcaseparser import TestCaseParser
@@ -6,6 +7,7 @@
class RobotSuiteParser(SuiteVisitor):
def __init__(self):
+ self.suite_counter = 0
self.suites = []
self.tests = []
@@ -16,6 +18,8 @@ def visit_suite(self, suite):
# Test Suite Parser
suite_info = {
+ "id": str(suite.longname).lower().replace(".", "_").replace(" ", "_"),
+ "filename": str(Path(suite.source).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),