Repository: behave-contrib/behave-html-pretty-formatter (language: python)

Issue:
Tests for Python 3.6 are failing
The tests for Python 3.6 are failing. This is due to the setup of the CI runner on GitHub's Azure infrastructure. Python 3.6 is only available on older Ubuntu runners:

```
Version 3.6 was not found in the local cache
Error: Version 3.6 with arch x64 not found
The list of all available versions can be found here:
https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json
```

I would suggest to drop version 3.6, unless it's important to you to support older (unsupported) Python versions. I'd also add Python 3.10 and 3.11 to the list of Pythons to run tests against.

I'll start with adapting the Tox and GHA pipeline setup for [behave-html-formatter](https://github.com/behave-contrib/behave-html-formatter/blob/main/tox.ini#L8). Do you want try to fix that yourself for this repository?


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
new file mode 100644
index 0000000..d12d278
--- /dev/null
+++ b/.github/workflows/checks.yml
@@ -0,0 +1,31 @@
+name: Checks
+
+on:
+  pull_request:
+    branches:
+    - main
+  push:
+    branches:
+    - main
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        env:
+        - flake8
+        - pylint
+        - readme
+    steps:
+    - uses: actions/checkout@v2
+    - uses: actions/setup-python@v2
+      with:
+        python-version: '3.x'
+    - name: Install build tools
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install --upgrade setuptools tox wheel
+    - name: Run ${{ matrix.env }}
+      run: tox -e ${{ matrix.env }}
diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml
new file mode 100644
index 0000000..f31cf2d
--- /dev/null
+++ b/.github/workflows/packaging.yml
@@ -0,0 +1,26 @@
+name: Publish
+
+on:
+  push:
+    tags:
+    - "*"
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v2
+    - uses: actions/setup-python@v2
+      with:
+        python-version: '3.x'
+    - name: Install build tools
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install --upgrade setuptools build twine wheel
+    - name: Build package
+      run: python -m build
+    - name: Upload to PyPI
+      env:
+        TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
+        TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
+      run: twine upload dist/*
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..d59b301
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,14 @@
+{
+    "python.testing.pytestArgs": [
+        "tests"
+    ],
+    "python.testing.unittestEnabled": false,
+    "python.testing.pytestEnabled": true,
+    "python.linting.pylintPath": "pylint",
+    "python.linting.pylintArgs": [
+        "--rcfile=tox.ini"
+    ],
+    "python.linting.pylintCategorySeverity.warning": "Warning",
+    "python.linting.pylintEnabled": true,
+    "python.linting.pylintCategorySeverity.refactor": "Warning"
+}
\ No newline at end of file
diff --git a/CHANGES b/CHANGES
new file mode 100644
index 0000000..1fe1e21
--- /dev/null
+++ b/CHANGES
@@ -0,0 +1,5 @@
+behave-html-pretty-formatter 1.0
+================================
+Initial commit.
+Prepare for pip.
+Improve the project.
diff --git a/MAINTAINERS b/MAINTAINERS
new file mode 100644
index 0000000..0b6d668
--- /dev/null
+++ b/MAINTAINERS
@@ -0,0 +1,7 @@
+Michal Odehnal
+Email: modehnal@redhat.com
+Userid: modehnal
+
+Filip Pokryvka
+Email: fpokryvk@redhat.com
+Userid: fpokryvk
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..cab9016
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,3 @@
+include LICENSE
+include README.md
+recursive-include behave_html_pretty_formatter *.css *.js *.py
\ No newline at end of file
diff --git a/README.md b/README.md
index e6e7a81..03dc05a 100644
--- a/README.md
+++ b/README.md
@@ -1,1 +1,271 @@
-# behave-html-pretty-formatter
\ No newline at end of file
+# HTML Pretty formatter for Behave
+
+- Inspired by [jest-html-reporter](https://github.com/Hargne/jest-html-reporter)
+- Using project [dominate](https://github.com/Knio/dominate) to generate the page
+
+## Installation - NOT LIVE YET
+
+```shell
+sudo python3 -m pip install behave-html-pretty-formatter
+```
+
+
+## Usage
+
+To use it with behave create `behave.ini` file in project folder (or in home) with
+following content:
+
+```ini
+# -- FILE: behave.ini
+# Define ALIAS for PrettyHTMLFormatter.
+[behave.formatters]
+html-pretty = behave_html_pretty_formatter:PrettyHTMLFormatter
+
+# Optional configuration of PrettyHTMLFormmater
+# also possible to use "behave ... -D behave.formatter.html-pretty.{setting}={value}".
+[behave.userdata]
+behave.formatter.html-pretty.title_string = Test Suite Reporter
+# Example usecase, print {before/after}_scenarios as steps with attached data.
+behave.formatter.html-pretty.pseudo_steps = false
+# Structure of the result html page readable(pretty) or condensed.
+behave.formatter.html-pretty.pretty_output = true
+# The '%' must be escaped in ini format.
+behave.formatter.html-pretty.date_format = %%d-%%m-%%Y %%H:%%M:%%S
+# Defines if the summary is expanded upon start.
+behave.formatter.html-pretty.show_summary = false
+# Defines if the user is interested in what steps are not executed.
+behave.formatter.html-pretty.show_unexecuted_steps = true
+```
+
+and then use it by running behave with `-f`/`--format` parameter, e.g.
+
+```console
+behave -f help
+behave -f html-pretty
+behave -f html-pretty -o behave-report.html
+```
+
+You can find information about behave and user-defined formatters in the
+[behave docs](https://behave.readthedocs.io/en/latest/formatters.html).
+
+## HTML Pretty has High contrast option
+
+- [Default Static Example](#pretty-html-formatter):
+- [High contrast Static Example](#pretty-html-formatter-high-contrast):
+  - Started as High contrast with Dark Mode TODO
+  - Colours adjusted.
+  - Extra information is added before every decorator about the status of the step.
+  - Text is bigger.
+
+You can switch between the different contrasts with the toggle button.
+
+
+## Summary is hidden by default
+
+- [Pretty HTML Formatter With Summary Expanded and Collapsed Steps](#pretty-html-formatter-with-summary-expanded-and-collapsed-steps):
+- [Pretty HTML Formatter High contrast With Summary Expanded and Collapsed Steps](#pretty-html-formatter-high-contrast-with-summary-expanded-and-collapsed-steps):
+
+To change the setting use the .ini file.
+```summary
+behave.formatter.html-pretty.show_summary = true
+```
+
+## HACKING
+
+### MIME Types
+
+Take a look at the  [Common MIME types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types), few of them are useful for us.
+
+### Encoding to base64
+
+```python
+data_base64 = base64.b64encode(open("/path/to/image", "rb").read())
+data_encoded = data_base64.decode("utf-8").replace("\n", "")
+```
+
+### Format in which the data is inserted to the html
+
+```python
+# Format
+"data:<mime_type>;<encoding>,<data_encoded>"
+# Example
+f"data:image/png;base64,{data_encoded}"
+```
+
+## Defined functions you can use
+
+### Basic setup for using formatter functions
+
+```python
+for formatter in context._runner.formatters:
+    if formatter.name == "html-pretty":
+        context.formatter = formatter
+```
+
+### Set Icon
+
+```python
+icon_data = f"data:image/svg+xml;base64,{data_encoded}"
+context.formatter.set_icon(icon=icon_data)
+```
+
+Example use case - an indicator if the test is running under X11 or Wayland:
+
+![Examples](src/set_icon_examples.png)
+
+### Set Title for the HTML page
+
+```python
+context.formatter.set_title(title="Test Suite Reporter")
+```
+
+ - This is configurable also from the behave.ini file `behave.formatter.html-pretty.title_string = Test Suite Reporter`
+
+
+### Commentary step in HTML report
+
+Used as an information panel to describe or provide infomation about the page contents.
+You will need to define your own step where you will set flag for commentary step.
+
+```python
+@step('Commentary')
+def commentary_step(context):
+    # Get the correct step to override.
+    scenario = context.formatter.current_scenario
+    step = scenario.current_step
+    # Override the step, this will prevent the decorator to be generated and only the text will show.
+    step.commentary_override = True
+```
+
+Feature file example usage:
+
+```gherkin
+  @commentary
+  Scenario: Example of commentary usage.
+    * Start application "zenity" via command "zenity --about"
+    * Commentary
+      """
+      This field is generated from decorator 'Commentary'
+      Where you insert text and override step to not print its decorator.
+      The text will get printed and will be seen, as you can see, haha.
+      """
+    * Left click "About" "radio button"
+
+```
+
+Result can be seen in the image examples.
+
+
+## Embedding data to the report
+
+### Basic embedding setup - save embedding function to context
+
+```python
+for formatter in context._runner.formatters:
+    if formatter.name == "html-pretty":
+        context.embed = formatter.embed
+# Not required if you already have the Basic setup.
+# You would than use: 'context.formatter.embed' instead of 'context.embed'
+```
+
+### Embed image
+
+```python
+context.embed(mime_type="image/png", data="/path/to/image.png", caption="Screenshot")
+```
+
+### Embed video
+
+```python
+context.embed(mime_type="video/webm", data="/path/to/video.webm", caption="Video")
+```
+
+### Image and Video examples:
+
+![Pretty HTML Formatter](src/image_and_video_examples.gif)
+
+
+### Defined MIME types and corresponding accepted data
+
+These are examples we use on daily basis, we can define more if required.
+
+```python
+mime_type="video/webm", data="/path/to/video.webm" or data="<base64_encoded_video>"
+mime_type="image/png", data="/path/to/image.png" or data="<base64_encoded_image>"
+mime_type="text/plain", data="<string>"
+mime_type="link", data="list(<link>, <label>)"
+```
+
+You can simply set `data=data_encoded` generated as described in [Encoding to base64](#encoding-to-base64) section and the formatter will generate the proper [Format](#format-in-which-the-data-is-inserted-to-the-html) based on MIME type or you can just use the `data="/path/to/file"` and formatter will attempt to convert it.
+
+Function `embed()` returns object, which can be saved and modified later via `set_data()` and `set_fail_only()` methods. This is if you want to embed some data which are still being processes (output of a backgroud process started in a step, etc.).
+
+### Pseudo steps
+
+If the testsuite uses `before_scenario()` and `after_scenario()` and you would like to see them as steps in HTML report (for example to have embeds separated from the standard steps), configuration switch in behave.ini file `behave.formatter.html-pretty.pseudo_steps = true` will do the trick, together with calling `context.html_formatter.before_scenario_finish(status)` at the end of `before_scenario()` (analogously for `after_scenario()`). The status is one of `"passed", "failed", "skipped"`. Function will set color class of the pseudo step and also record pseudo step duration.
+
+```python
+# Example use in features.environment.py
+
+def before_scenario(context, scenario):
+  ...
+  # This requires to have html_formatter set by code above.
+  if error_found:
+    context.embed("text", str(error_found), "Error Message")
+    context.html_formatter.before_scenario_finish("failed")
+    raise error_found
+  else:
+    context.html_formatter.before_scenario_finish("passed")
+
+def after_scenario(context, scenario):
+  ...
+  if error_found:
+    context.embed("text", str(error_found), "Error Message")
+    context.html_formatter.after_scenario_finish("failed")
+    raise error_found
+  else:
+    context.html_formatter.after_scenario_finish("passed")
+```
+
+## Contributing
+
+You want to help with improving this software? Please create an issue in our open bug tracker, or open a pull request directly.
+
+We use [tox](https://pypi.org/project/tox/) for running linting and tests, e.g.
+
+```console
+tox
+tox -l
+tox -e flake8
+```
+
+For code formatting we use [black](https://pypi.org/project/black/), which you can run using our Tox setup, e.g.
+
+```console
+tox -e black
+```
+
+If you need to change CSS or JavaScript code: First edit the regular files, then generate the minified versions like so:
+
+```console
+tox -e minify
+```
+
+
+## Image Examples
+
+### Pretty HTML Formatter
+
+![Pretty HTML Formatter](src/full_page_example.png)
+
+### Pretty HTML Formatter High Contrast
+
+![Pretty HTML Formatter Summary High Contrast](src/full_page_high_contrast_example.png)
+
+### Pretty HTML Formatter With Summary Expanded and Collapsed Steps
+
+![Pretty HTML Formatter](src/summary_collapsed_example.png)
+
+### Pretty HTML Formatter High Contrast with Summary Expanded and Collapsed Steps
+
+![Pretty HTML Formatter Summary High Contrast](src/summary_collapsed_high_contrast_example.png)
\ No newline at end of file
diff --git a/behave.ini b/behave.ini
new file mode 100644
index 0000000..f64be62
--- /dev/null
+++ b/behave.ini
@@ -0,0 +1,19 @@
+# -- FILE: behave.ini
+# Define ALIAS for PrettyHTMLFormatter.
+[behave.formatters]
+html-pretty = behave_html_pretty_formatter:PrettyHTMLFormatter
+
+# Optional configuration of PrettyHTMLFormmater
+# also possible to use "behave ... -D behave.formatter.html-pretty.{setting}={value}".
+[behave.userdata]
+behave.formatter.html-pretty.title_string = Test Suite Reporter
+# Example usecase, print {before/after}_scenarios as steps with attached data.
+behave.formatter.html-pretty.pseudo_steps = false
+# Structure of the result html page readable(pretty) or condensed.
+behave.formatter.html-pretty.pretty_output = true
+# The '%' must be escaped in ini format.
+behave.formatter.html-pretty.date_format = %%d-%%m-%%Y %%H:%%M:%%S
+# Defines if the summary is expanded upon start.
+behave.formatter.html-pretty.show_summary = false
+# Defines if the user is interested in what steps are not executed.
+behave.formatter.html-pretty.show_unexecuted_steps = true
\ No newline at end of file
diff --git a/behave_html_pretty_formatter/__init__.py b/behave_html_pretty_formatter/__init__.py
new file mode 100644
index 0000000..00f6860
--- /dev/null
+++ b/behave_html_pretty_formatter/__init__.py
@@ -0,0 +1,4 @@
+"""
+Pretty HTML Formatter for Behave.
+"""
+from .html_pretty import PrettyHTMLFormatter
diff --git a/behave_html_pretty_formatter/html_pretty.py b/behave_html_pretty_formatter/html_pretty.py
new file mode 100644
index 0000000..bccfb7b
--- /dev/null
+++ b/behave_html_pretty_formatter/html_pretty.py
@@ -0,0 +1,1030 @@
+"""
+HTML Pretty formatter for behave
+Inspired by https://github.com/Hargne/jest-html-reporter
+"""
+
+from __future__ import absolute_import
+import os
+import traceback
+import base64
+import time
+from pathlib import Path
+from datetime import datetime
+from collections import OrderedDict
+
+import dominate
+from dominate.tags import (
+    meta,
+    div,
+    span,
+    a,
+    b,
+    i,
+    table,
+    tbody,
+    thead,
+    tr,
+    th,
+    td,
+    pre,
+    video,
+    source,
+    script,
+    img,
+    style,
+)
+from dominate.util import raw
+
+from behave.formatter.base import Formatter
+from behave.model_core import Status
+from behave.runner_util import make_undefined_step_snippets
+
+
+DEFAULT_CAPTION_FOR_MIME_TYPE = {
+    "video/webm": "Video",
+    "image/png": "Screenshot",
+    "text": "Data",
+    "link": "Link",
+}
+
+
+class Feature:
+    """
+    Simplified behave feature used by PrettyHTMLFormatter
+    """
+
+    def __init__(self, feature):
+        self.name = feature.name
+        self.description = feature.description
+        self.location = feature.location
+        self.status = Status.skipped.name
+        self.icon = None
+        self.high_contrast_button = False
+        self.start_time = datetime.now()
+        self.finish_time = None
+
+        self.scenarios = []
+        self._background = None
+        self.to_embed = []
+        self.scenario_finished = True
+        self.scenario_begin_timestamp = time.time()
+        self.before_scenario_duration = 0.0
+        self.before_scenario_status = None
+
+    def add_background(self, background):
+        """
+        Save steps common for all scenarios in feature.
+        """
+        self._background = background
+
+    @property
+    def background_steps(self):
+        """
+        Return steps common for all scenarios in feature.
+        """
+        _steps = []
+        if self._background:
+            _steps = self._background.steps
+        return _steps
+
+    def add_scenario(self, scenario, pseudo_steps=False):
+        """
+        Create new scenario in feature based on behave scenario object
+        """
+        self.scenario_finished = False
+        _scenario = Scenario(scenario, self, pseudo_steps)
+        for embed_data in self.to_embed:
+            _scenario.embed(embed_data)
+        self.to_embed = []
+        # Stop embeding to before_scenario.
+        _scenario.pseudo_step_id = 1
+
+        if pseudo_steps:
+            _step = _scenario.before_scenario_step
+            _step.duration = self.before_scenario_duration
+            _step.status = self.before_scenario_status
+
+        self.scenarios.append(_scenario)
+        return _scenario
+
+    def embed(self, embed_data):
+        """
+        Embeds data to current step in current scenario.
+        """
+        if not self.scenarios or self.scenario_finished:
+            self.to_embed.append(embed_data)
+        else:
+            self.scenarios[-1].embed(embed_data)
+
+    def before_scenario_finish(self, status):
+        """
+        Sets status and duration of before scenario pseudo step.
+        """
+        self.before_scenario_duration = time.time() - self.scenario_begin_timestamp
+        self.before_scenario_status = status
+
+    def after_scenario_finish(self, status):
+        """
+        Sets status and duration of after scenario presudo step.
+        Should be called at the end of behave's `after_scenario()`,
+        so that next embeds are correctly assigned to next scenario.
+        """
+        self.scenario_finished = True
+        _scenario = self.scenarios[-1]
+        _step = _scenario.after_scenario_step
+        if _step is not None:
+            if _scenario.steps_finished_timestamp:
+                _step.duration = time.time() - _scenario.steps_finished_timestamp
+            else:
+                _step.duration = (
+                    time.time()
+                    - self.scenario_begin_timestamp
+                    - self.before_scenario_duration
+                )
+            _step.status = status
+            self.scenario_begin_timestamp = time.time()
+
+    def get_feature_stats(self, date_format):
+        """
+        Compute scenario stats if trere are multiple scenarios.
+        """
+        stats = OrderedDict()
+        stats["Started"] = self.start_time.strftime(date_format)
+        duration = (self.finish_time - self.start_time).total_seconds()
+        stats["Duration"] = f"{duration:.2f}s"
+
+        # Show Passed Failed always, skip and undefined only when present.
+        stats["Passed"] = 0
+        stats["Failed"] = 0
+
+        for scenario in self.scenarios:
+            status = scenario.status.capitalize()
+            if status in stats:
+                stats[status] += 1
+            else:
+                stats[status] = 1
+
+        return stats
+
+    def generate_feature(self, formatter):
+        """
+        Converts this object to HTML.
+        """
+        # Feature Panel.
+        with div(cls="feature-panel"):
+            with div(cls="feature-title"):
+                # Generate icon if present.
+                if self.icon:
+                    with div(cls="feature-icon"):
+                        img(src=self.icon)
+
+                # Generate content of the panel.
+                if self.high_contrast_button:
+                    # Making sure there is a functioning button.
+                    span(f"Feature: {self.name}")
+                    with a(onclick="toggle_contrast('embed')", href="#"):
+                        # Creating the actual text content which is clickable.
+                        span("[High contrast toggle]", cls="button-toggle")
+
+                    # After the High Contrast make a Summary toggle button.
+                    with a(onclick="collapsible_summary('summary')", href="#"):
+                        # Creating the actual text content which is clickable.
+                        span("[Summary]", cls="button-toggle")
+
+                # On following features do not generate the buttons.
+                else:
+                    span(f"Feature: {self.name}")
+
+            # Generate summary.
+            summary_display = "none"
+            if formatter.show_summary:
+                summary_display = "block"
+            with div(
+                cls="feature-summary-container",
+                id="summary",
+                style=f"display: {summary_display}",
+            ):
+                # Generating Summary results.
+                with div(cls="feature-summary-stats"):
+                    stats = self.get_feature_stats(formatter.date_format)
+
+                    for stat, value in stats.items():
+                        div(
+                            f"{stat}: {value}",
+                            cls=f"feature-summary-row {stat.lower()}",
+                        )
+                # Generating clickable buttons for collapsing/expanding.
+                with div(cls="feature-summary-stats"):
+                    with a(onclick="expander('expand_all')", href="#"):
+                        div("[Expand All]", cls="feature-summary-row button")
+                    with a(onclick="expander('collapse_all')", href="#"):
+                        div("[Collapse All]", cls="feature-summary-row button")
+                    with a(onclick="expander('expand_all_failed')", href="#"):
+                        div("[Expand All Failed]", cls="feature-summary-row button")
+
+        # Feature data container.
+        with div(cls="feature-container"):
+            for scenario in self.scenarios:
+                scenario.generate_scenario(formatter)
+
+
+class Scenario:
+    """
+    Simplified behaves's scenario.
+    """
+
+    def __init__(self, scenario, feature, pseudo_steps=False):
+        self._scenario = scenario
+        self.feature = feature
+        self.name = scenario.name
+        self.description = scenario.description
+        self.pseudo_step_id = 0
+        self.pseudo_steps = []
+        if pseudo_steps:
+            self.pseudo_steps = [
+                Step(when, "scenario", None, None, self) for when in ("Before", "After")
+            ]
+            self.pseudo_steps[1].margin_top = True
+
+        # We need another information about a tag, to recognize if it should act as a link or span.
+        self.tags = [Tag(tag) for tag in scenario.effective_tags]
+
+        self.location = scenario.location
+        self.status = Status.skipped.name
+        self.duration = 0.0
+        self.match_id = -1
+        self.steps_finished = False
+        self.steps_finished_timestamp = None
+        self.steps = []
+        self.to_embed = []
+
+        self.reported_error = None
+
+        self.saved_matched_filename = None
+        self.saved_matched_line = None
+        # Process before_scenario errors.
+        self.report_error(scenario)
+
+        # Process steps.
+        background_steps = feature.background_steps
+        for step in background_steps:
+            self.add_step(step.keyword, step.name, step.text, step.table)
+        if background_steps and self.pseudo_steps:
+            self.steps[0].margin_top = True
+        first_step = True
+        for step in scenario.steps:
+            self.add_step(step.keyword, step.name, step.text, step.table)
+            if first_step and (background_steps or pseudo_steps):
+                self.steps[-1].margin_top = True
+            first_step = False
+
+    @property
+    def before_scenario_step(self):
+        """
+        Access to before scenario pseudo step, if exists.
+        """
+        if self.pseudo_steps:
+            return self.pseudo_steps[0]
+
+        return None
+
+    @property
+    def after_scenario_step(self):
+        """
+        Access to before scenario pseudo step, if exists.
+        """
+        if self.pseudo_steps:
+            return self.pseudo_steps[1]
+
+        return None
+
+    @property
+    def current_step(self):
+        """
+        (Pseudo) Step currently being processed.
+        Used mainly for correct embed tracking.
+        """
+        _step = None
+        if self.match_id < 0:
+            if self.pseudo_steps:
+                _step = self.pseudo_steps[self.pseudo_step_id]
+            elif self.steps:
+                _step = self.steps[0]
+
+        if self.steps_finished:
+            _step = self.after_scenario_step
+
+        if _step is None and self.steps:
+            if self.match_id < len(self.steps):
+                _step = self.steps[self.match_id]
+            else:
+                _step = self.steps[-1]
+
+        return _step
+
+    @property
+    def is_last_step(self):
+        """
+        Is last step processed?
+        """
+        return self.match_id + 1 >= len(self.steps)
+
+    def add_step(self, keyword, name, step_text=None, step_table=None):
+        """
+        Add step. Called when new scenario is processed.
+        """
+        _step = Step(keyword, name, step_text, step_table, self)
+        self.steps.append(_step)
+        for embed_data in self.to_embed:
+            _step.embed(embed_data)
+
+        self.to_embed = []
+        return _step
+
+    def add_match(self, match):
+        """
+        Process information about step that will be executed next.
+        """
+        self.match_id += 1
+        step = self.current_step
+        step.location = str(match.location.filename) + ":" + str(match.location.line)
+
+    def add_result(self, result):
+        """
+        Process information about executed step.
+        """
+
+        # If step is "undefined", match() is not called by behave.
+        if result.status == Status.undefined:
+            self.match_id += 1
+
+        step = self.current_step
+        step.add_result(result)
+
+        if (
+            self.is_last_step
+            or result.status == Status.passed
+            or result.status == Status.failed
+            or result.status == Status.skipped
+            or result.status == Status.undefined
+        ):
+            self.status = result.status.name
+            self.duration = self._scenario.duration
+
+        # Check if step execution finished.
+        # Embed to after_scenario_step if pseudo_steps enabled.
+        if self.is_last_step or result.status != Status.passed:
+            self.steps_finished = True
+            self.steps_finished_timestamp = time.time()
+
+        return step
+
+    def report_error(self, behave_obj):
+        """
+        Embeds error message and traceback.
+        """
+        if not behave_obj.error_message:
+            return
+        if self.reported_error:
+            if self.reported_error.data == behave_obj.error_message:
+                return
+            if self.reported_error.data in behave_obj.error_message:
+                # Do not update traceback, as behave saves only first traceback.
+                self.reported_error.data = behave_obj.error_message
+                return
+        self.reported_error = Embed("text", behave_obj.error_message, "Error Message")
+        self.embed(self.reported_error)
+        if "Traceback" not in behave_obj.error_message:
+            self.embed(
+                Embed(
+                    "text",
+                    traceback.format_exception(
+                        type(behave_obj.exception),
+                        behave_obj.exception,
+                        behave_obj.exc_traceback,
+                    ),
+                    "Error Traceback",
+                )
+            )
+        self.status = Status.failed.name
+
+    def embed(self, embed_data):
+        """
+        Embed data to the this step.
+        """
+        _step = self.current_step
+        if _step is not None:
+            _step.embed(embed_data)
+        else:
+            self.to_embed.append(embed_data)
+
+    def generate_scenario(self, formatter):
+        """
+        Converts scenario to HTML.
+        """
+        # Check for after_scenario errors.
+        self.report_error(self._scenario)
+        # Scenario container.
+        with div(cls=f"scenario-capsule {self.status}"):
+
+            for tag in self.tags:
+                tag.generate_tag()
+
+            # Simple container for name + duration.
+            with div(cls="scenario-info"):
+
+                with div(cls="scenario-name"):
+                    span(f"Scenario: {self.name}")
+
+                with div(cls="scenario-duration"):
+                    span(f"Scenario duration: {self.duration:.2f}s")
+
+            steps = self.steps
+            if self.pseudo_steps:
+                steps = [self.pseudo_steps[0]] + steps + [self.pseudo_steps[1]]
+            for step in steps:
+                step.generate_step(formatter, self.status)
+
+
+class Step:
+    """
+    Simplified behave step object.
+    """
+
+    def __init__(self, keyword, name, text, step_table, scenario):
+        self.status = None
+        self.duration = 0.0
+        self.scenario = scenario
+        self.keyword = keyword
+        self.name = name
+        self.text = text
+        self.table = step_table
+        self.location = ""
+        self.location_link = None
+        self.embeds = []
+
+        self.commentary_override = False
+        self.margin_top = False
+
+    def add_result(self, result):
+        """
+        Process result of the executed step.
+        """
+        self.status = result.status.name
+        self.duration = result.duration
+
+        # If the step has error message and step failed, set the error message.
+        if result.error_message and result.status == Status.failed:
+            self.scenario.report_error(result)
+
+        # If the step is undefined use the behave function to provide information about it.
+        if result.status == Status.undefined:
+            undefined_step_message = (
+                "\nYou can implement step definitions for undefined steps with "
+            )
+            undefined_step_message += "these snippets:\n\n"
+            undefined_step_message += "\n".join(
+                make_undefined_step_snippets(undefined_steps=[result])
+            )
+
+            self.embed(Embed("text", undefined_step_message, "Error Message"))
+
+    def embed(self, embed_data):
+        """
+        Save new embed for this step.
+        """
+        self.embeds.append(embed_data)
+
+    def set_commentary(self, value=True):
+        """
+        Turn this step into commentary step (or back to normal step).
+        """
+        self.commentary_override = value
+
+    def generate_step(self, formatter, scenario_status):
+        """
+        Converts Step Object into HTML.
+        """
+        if self.status is None:
+            if not formatter.show_unexecuted_steps:
+                return
+            self.status = Status.skipped.name
+
+        margin_top_cls = ""
+        if self.margin_top:
+            margin_top_cls = "margin-top"
+
+        if self.commentary_override:
+            with div(cls=f"step-capsule commentary {margin_top_cls}"):
+                pre(f"{self.text}")
+        else:
+            with div(cls=f"step-capsule {self.status} {margin_top_cls}"):
+                with div(cls="step-status-decorator-duration-capsule"):
+                    with div(cls="step-status"):
+
+                        # Behave defined status strings are "passed" "failed" "undefined" "skipped".
+                        # Modify these values for high contrast usage.
+                        high_contrast_status = {
+                            "passed": "PASS",
+                            "failed": "FAIL",
+                            "undefined": "SKIP",
+                            "skipped": "SKIP",
+                            "untested": "SKIP",
+                        }
+                        # Step status for high contrast - "PASS" "FAIL" "SKIP".
+                        span(high_contrast_status[self.status])
+
+                    with div(cls="step-decorator"):
+                        # Step decorator.
+                        with b():
+                            i(self.keyword + " ")
+                        formatter.make_bold_text(self.name)
+
+                    with div(cls="step-duration"):
+                        short_duration = f"{self.duration:.2f}s"
+                        # Step duration.
+                        span(f"({short_duration})")
+
+                # Make the link only when the link is provided.
+                if self.location_link:
+                    with div(cls="link"):
+                        with a(href=self.location_link):
+                            span(self.location)
+                else:
+                    span(self.location)
+            # Still in non-commentary.
+            self.generate_text()
+            self.generate_table()
+
+        # Generate all embeds that are in the data structure.
+        # Add div for dashed-line last-child CSS selector.
+        with div(cls="embeds"):
+            for embed_data in self.embeds:
+                if embed_data.fail_only and scenario_status != "failed":
+                    continue
+                self.generate_embed(embed_data)
+
+    def generate_embed(self, embed_data):
+        """
+        Converts embed data into HTML.
+
+        This should not be part of Embed class, as Embed objects are
+        returned to user for later modification of data, we want to
+        prevent accidental call of this.
+        """
+        caption = embed_data.caption
+        mime_type = embed_data.mime_type
+        data = embed_data.data
+
+        # If caption is user defined.
+        if caption is not None:
+            use_caption = caption
+        # If caption is not defined try to use default one for specific mime type.
+        elif mime_type in DEFAULT_CAPTION_FOR_MIME_TYPE:
+            use_caption = DEFAULT_CAPTION_FOR_MIME_TYPE[mime_type]
+        # No caption and no default caption for given mime type.
+        else:
+            use_caption = "uknown-mime-type"
+            data = "data removed"
+
+        # Check if the content of the data is a valid file - if so encode it to base64.
+        if os.path.isfile(str(data)):
+            with open(data, "rb") as _file:
+                data_base64 = base64.b64encode(_file.read())
+                data = data_base64.decode("utf-8").replace("\n", "")
+
+        with div(cls="messages"):
+            with div(cls="embed-capsule"):
+
+                # Embed Caption.
+                with div(cls="embed_button"):
+                    with div(cls="link"):
+                        # Label to be shown.
+                        with a(
+                            href="#/",
+                            onclick=f"collapsible_toggle('embed_{embed_data.uid}')",
+                        ):
+                            span(use_caption)
+
+                # Actual Embed.
+                if "video/webm" in mime_type:
+                    with pre(cls="embed_content"):
+                        with video(
+                            id=f"embed_{embed_data.uid}",
+                            style="display: none",
+                            width="1024",
+                            controls="",
+                        ):
+                            source(
+                                src=f"data:{mime_type};base64,{data}", type=mime_type
+                            )
+
+                if "image/png" in mime_type:
+                    with pre(
+                        cls="embed_content",
+                        id=f"embed_{embed_data.uid}",
+                        style="display: none",
+                    ):
+                        img(src=f"data:{mime_type};base64,{data}")
+
+                if "text" in mime_type:
+                    with pre(
+                        cls="embed_content",
+                        id=f"embed_{embed_data.uid}",
+                        style="display: none",
+                    ):
+                        span(data)
+
+                if "link" in mime_type:
+                    with pre(
+                        cls="embed_content",
+                        id=f"embed_{embed_data.uid}",
+                        style="display: none",
+                    ):
+                        # expected format: set( [link, label], ... )
+                        for single_link in data:
+                            with a(href=single_link[0]):
+                                span(single_link[1])
+
+    def generate_table(self):
+        """
+        Converts step table into HTML.
+        """
+        if not self.table:
+            return
+        table_headings = self.table.headings
+        table_rows = self.table.rows
+
+        # Generate Table.
+        with table(cls="table"):
+
+            # Make a heading.
+            with thead(
+                onclick=f"collapsible_toggle('table_{PrettyHTMLFormatter.table_number}')"
+            ):
+                line = tr()
+                for heading in table_headings:
+                    line += th(heading)
+
+            # Make the body.
+            with tbody(id=f"table_{PrettyHTMLFormatter.table_number}"):
+                for row in table_rows:
+                    with tr() as line:
+                        for cell in row:
+                            line += td(cell)
+
+        PrettyHTMLFormatter.table_number += 1
+
+    def generate_text(self):
+        """
+        Converts step text into HTML.
+        """
+        if not self.text:
+            return
+        with table(cls="table"):
+            # Do not make the table header.
+            with thead(
+                onclick=f"collapsible_toggle('table_{PrettyHTMLFormatter.table_number}')"
+            ):
+                line = tr()
+                line += th("Text")
+            # Make the body.
+            with tbody(id=f"table_{PrettyHTMLFormatter.table_number}"):
+                # Make rows.
+                for row in self.text.split("\n"):
+                    with tr() as line:
+                        line += td(row)
+
+        PrettyHTMLFormatter.table_number += 1
+
+
+class Embed:
+    """
+    Encapsulates data to be embedded to the step.
+    """
+
+    count = 0
+
+    def __init__(self, mime_type, data, caption=None, fail_only=False):
+        self._id = Embed.count
+        Embed.count += 1
+        self.set_data(mime_type, data, caption)
+        self._fail_only = fail_only
+
+    def set_data(self, mime_type, data, caption=None):
+        """
+        Set data, mime_type and caption.
+        """
+        self._mime_type = mime_type
+        # Check that link is in format: set([link, label], ...)
+        if mime_type == "link":
+            new_data = []
+            for single_link in data:
+                link, label = single_link
+                new_data.append([link, label])
+            data = new_data
+        self._data = data
+        self._caption = caption
+
+    def set_fail_only(self, fail_only):
+        """
+        Set fail_only flag, whether embed should be done on pass or not.
+        """
+        self._fail_only = fail_only
+
+    @property
+    def mime_type(self):
+        "Read-only mime_type access."
+        return self._mime_type
+
+    @property
+    def data(self):
+        "Read-only data access."
+        return self._data
+
+    @property
+    def caption(self):
+        "Read-only caption access."
+        return self._caption
+
+    @property
+    def fail_only(self):
+        "Read-only fail_only access."
+        return self._fail_only
+
+    @property
+    def uid(self):
+        """
+        Read-only access for embed ID.
+        """
+        return self._id
+
+
+class Tag:
+    """
+    Adds link to behave's tag
+    """
+
+    def __init__(self, behave_tag, link=None):
+        self.behave_tag = behave_tag
+        self._link = link
+
+    def set_link(self, link):
+        """
+        Set link associated with tag.
+        """
+        assert isinstance(link, str), f"Link must be string, got {type(link)}"
+        self._link = link
+
+    def has_link(self):
+        """
+        Check if link is already set.
+        """
+        return self._link is not None
+
+    def generate_tag(self):
+        """
+        Converts tag to HTML.
+        """
+        with div(cls="scenario-tags"):
+            # Do not make links by default,
+            # this is handled on qecore side for links to bugzilla.
+            # Tags come with structure [<tag>, None] or [<tag>, <bugzilla_link/git_link>].
+            if self.has_link():
+                with div(cls="link"):
+                    with a(href=self._link):
+                        span("@" + self.behave_tag)
+            else:
+                span("@" + self.behave_tag)
+
+
+# Based on behave.formatter.json:JSONFormatter
+# Since we need some form of structure from where we will pull all data upon close.
+# Modifications based on our needs and experimentation.
+class PrettyHTMLFormatter(Formatter):
+    """
+    Behave Pretty HTML Formatter
+    """
+
+    name = "html-pretty"
+    description = "Pretty HTML Formatter"
+    table_number = 0
+
+    def __init__(self, stream, config):
+        super().__init__(stream, config)
+
+        self.features = []
+
+        self.high_contrast_button = False
+
+        self.suite_start_time = datetime.now()
+
+        # Some type of icon can be set.
+        self.icon = None
+
+        # This will return a stream given in behave call -o <file_name>.html.
+        self.stream = self.open()
+
+        config_path = f"behave.formatter.{self.name}"
+
+        self.pseudo_steps = self._str_to_bool(
+            config.userdata.get(f"{config_path}.pseudo_steps", "false")
+        )
+
+        self.title_string = config.userdata.get(
+            f"{config_path}.title_string", "Test Suite Reporter"
+        )
+
+        self.pretty_output = self._str_to_bool(
+            config.userdata.get(f"{config_path}.pretty_output", "true")
+        )
+
+        self.date_format = config.userdata.get(
+            f"{config_path}.date_format", "%d-%m-%Y %H:%M:%S"
+        )
+
+        self.show_summary = self._str_to_bool(
+            config.userdata.get(f"{config_path}.show_summary", "false")
+        )
+
+        self.show_unexecuted_steps = self._str_to_bool(
+            config.userdata.get(f"{config_path}.show_unexecuted_steps", "true")
+        )
+
+    def _str_to_bool(self, value):
+        assert value.lower() in ["true", "false", "yes", "no", "0", "1"]
+        return value.lower() in ["true", "yes", "1"]
+
+    def feature(self, feature):
+        current_feature = self.current_feature
+        if current_feature:
+            current_feature.finish_time = datetime.now()
+        self.features.append(Feature(feature))
+
+    @property
+    def current_feature(self):
+        """
+        Currently executed feature, if any.
+        """
+        if len(self.features) == 0:
+            return None
+        return self.features[-1]
+
+    @property
+    def current_scenario(self):
+        """
+        Currently executed scenario, if any.
+        """
+        _feature = self.current_feature
+        if not _feature or not _feature.scenarios or _feature.scenario_finished:
+            return None
+        return _feature.scenarios[-1]
+
+    def before_scenario_finish(self, status):
+        """
+        Sets status and duration of before scenario pseudo step.
+        """
+        # Call this on Feature, as Scenario is not created yet.
+        self.current_feature.before_scenario_finish(status)
+
+    def after_scenario_finish(self, status):
+        """
+        Sets status and duration of after scenario presudo step.
+        Should be called at the end of behave's `after_scenario()`,
+        so that next embeds are correctly assigned to next scenario.
+        """
+        # Call this on Feature, to be consistent with before_scenario_finish.
+        self.current_feature.after_scenario_finish(status)
+
+    def scenario(self, scenario):
+        """
+        Processes new scenario. It is added to the current feature.
+        """
+        self.current_feature.add_scenario(scenario, self.pseudo_steps)
+
+    def step(self, step):
+        """
+        Register new step for current scenario.
+        """
+        # Not used, parsed in scenario().
+        # self.current_scenario.add_step(step.keyword, step.name, step.text, step.table)
+
+    def match(self, match):
+        """
+        Step is matched and will be executed next.
+        """
+        # Executed before result.
+        # Needed for knowing from where the code is coming from,
+        # instead of just location in the feature file.
+        if match.location:
+            self.current_scenario.add_match(match)
+
+    def result(self, step):
+        """
+        Step execution is finished.
+        """
+        self.current_scenario.add_result(step)
+
+    def reset(self, reset):
+        """
+        Reset.
+        """
+
+    def uri(self, uri):
+        """
+        URI.
+        """
+
+    def background(self, background):
+        """
+        Background call.
+        """
+        feature = self.current_feature
+        if feature:
+            feature.add_background(background)
+
+    def make_bold_text(self, given_string):
+        """
+        Turn string into HTML tags, make bold text in between quotes.
+        """
+        quote_count = given_string.count('"')
+
+        # Save string to iterate over.
+        the_rest = given_string
+        for _ in range(int(quote_count / 2)):
+            first_part, bold_text, the_rest = the_rest.split('"', 2)
+
+            span(first_part)
+            b(f'"{bold_text}"')
+
+        span(the_rest)
+
+    def embed(self, mime_type, data, caption=None, fail_only=False):
+        """
+        Prepares Embed data and append it to the currently executed (pseudo) step.
+        returns: Embed
+        """
+        embed_data = Embed(mime_type, data, caption, fail_only)
+        # Find correct scenario.
+        self.current_feature.embed(embed_data)
+        return embed_data
+
+    def set_title(self, title):
+        """
+        Title setter.
+        """
+        self.title_string = title
+
+    def set_icon(self, icon):
+        """
+        Icon setter.
+        """
+        self.icon = icon
+
+    def close(self):
+        """
+        Generates the entire html page with dominate.
+        """
+        # Set finish time of the last feature.
+        current_feature = self.current_feature
+        if current_feature:
+            current_feature.finish_time = datetime.now()
+
+        # Create dominate document.
+        document = dominate.document(title=self.title_string)
+
+        # Generate the head of the html page.
+        with document.head:
+            # Set content and http-equiv - taken from the base html formatter.
+            meta(content="text/html;charset=utf-8", http_equiv="content-type")
+
+            # Load and insert css theme.
+            css_fname = "theme.css" if self.pretty_output else "theme.min.css"
+            with open(
+                Path(__file__).parent / css_fname, "r", encoding="utf-8"
+            ) as _css_file:
+                css_theme = _css_file.read()
+            with style(rel="stylesheet"):
+                raw(css_theme)
+
+            # Load and insert javascript - important for embed toggles and high contrast switch.
+            js_fname = "script.js" if self.pretty_output else "script.min.js"
+            with open(
+                Path(__file__).parent / js_fname, "r", encoding="utf-8"
+            ) as _script_file:
+                js_script = _script_file.read()
+            with script(type="text/javascript"):
+                raw(js_script)
+
+        # Iterate over the data and generate the page.
+        with document.body as body:
+            body.attributes["onload"] = "detect_contrast();"
+            if self.features:
+                feature = self.features[0]
+                feature.icon = self.icon
+                feature.high_contrast_button = True
+            for feature in self.features:
+                feature.generate_feature(self)
+
+        # Write everything to the stream which corelates to the -o <file> behave option.
+        self.stream.write(document.render(pretty=self.pretty_output))
diff --git a/behave_html_pretty_formatter/script.js b/behave_html_pretty_formatter/script.js
new file mode 100644
index 0000000..be4c583
--- /dev/null
+++ b/behave_html_pretty_formatter/script.js
@@ -0,0 +1,90 @@
+function collapsible_toggle(id) {
+    var elem = document.getElementById(id);
+    var visible_display = "block";
+    if (id.indexOf("table") >= 0) {
+        visible_display = "contents";
+    }
+    elem.style.display = (elem.style.display == "none" ? visible_display : "none");
+};
+
+function collapsible_summary(id) {
+    var elem = document.getElementById(id);
+    var visible_display = "flex";
+    elem.style.display = (elem.style.display == "none" ? visible_display : "none");
+};
+
+
+function expander(action) {
+    var elem = document.getElementsByClassName("scenario-capsule");
+    for(var i = 0; i < elem.length; i++) {
+        if (action == "expand_all") {
+            elem[i].classList.remove("collapse")
+        } else if (action == "collapse_all") {
+            if (!elem[i].classList.contains("collapse")) {
+                elem[i].classList.add("collapse");
+            }
+        } else if (action == "expand_all_failed") {
+            if (!elem[i].classList.contains("passed")) {
+                elem[i].classList.remove("collapse");
+            } else {
+                if (!elem[i].classList.contains("collapse")) {
+                    elem[i].classList.add("collapse");
+                }
+            }
+        }
+    }
+};
+
+
+function toggle_contrast_for(target_class) {
+    var elements = document.getElementsByClassName(target_class);
+    for(var i = 0; i < elements.length; i++) {
+        if (elements[i].classList.contains("contrast")) {
+            elements[i].classList.remove("contrast");
+        } else {
+            elements[i].classList.add("contrast");
+        }
+    }
+};
+
+function toggle_contrast() {
+    var step_status_items = document.getElementsByClassName("step-status");
+    for (var i=0; i < step_status_items.length; i++) {
+        step_status_items[i].style.display = (step_status_items[i].style.display == "block" ? "none" : "block");
+    };
+
+    const contrast_classes = [
+        "feature-title",
+        "feature-summary-container",
+        "feature-summary-row",
+        "feature-icon",
+
+        "scenario-capsule",
+        "scenario-tags",
+        "scenario-duration",
+
+        "step-capsule",
+        "step-status",
+        "step-duration",
+
+        "messages",
+        "embed_button",
+        "link",
+        "table",
+
+    ];
+    contrast_classes.forEach(toggle_contrast_for);
+};
+
+function detect_contrast() {
+    var obj_div = document.createElement("div");
+    obj_div.style.color = "rgb(31, 41, 59)"
+    document.body.appendChild(obj_div);
+    var col = document.defaultView ? document.defaultView.getComputedStyle(obj_div, null).color : obj_div.currentStyle.color;
+    document.body.removeChild(obj_div);
+    col = col.replace(/ /g, "");
+    if (col !== "rgb(31,41,59)") {
+        console.log("High Contrast theme detected.")
+        toggle_contrast();
+    }
+}
diff --git a/behave_html_pretty_formatter/script.min.js b/behave_html_pretty_formatter/script.min.js
new file mode 100644
index 0000000..3b9e439
--- /dev/null
+++ b/behave_html_pretty_formatter/script.min.js
@@ -0,0 +1,1 @@
+function collapsible_toggle(id){var elem=document.getElementById(id);var visible_display="block";if(id.indexOf("table")>=0){visible_display="contents";};elem.style.display=(elem.style.display=="none"?visible_display:"none");};function collapsible_summary(id){var elem=document.getElementById(id);var visible_display="flex";elem.style.display=(elem.style.display=="none"?visible_display:"none");};function expander(action){var elem=document.getElementsByClassName("scenario-capsule");for(var i=0;i<elem.length;i++){if(action=="expand_all"){elem[i].classList.remove("collapse")}else if(action=="collapse_all"){if(!elem[i].classList.contains("collapse")){elem[i].classList.add("collapse");}}else if(action=="expand_all_failed"){if(!elem[i].classList.contains("passed")){elem[i].classList.remove("collapse");}else{if(!elem[i].classList.contains("collapse")){elem[i].classList.add("collapse");}}}}};function toggle_contrast_for(target_class){var elements=document.getElementsByClassName(target_class);for(var i=0;i<elements.length;i++){if(elements[i].classList.contains("contrast")){elements[i].classList.remove("contrast");}else{elements[i].classList.add("contrast");}}};function toggle_contrast(){var step_status_items=document.getElementsByClassName("step-status");for(var i=0;i<step_status_items.length;i++){step_status_items[i].style.display=(step_status_items[i].style.display=="block"?"none":"block");};const contrast_classes=["feature-title","feature-summary-container","feature-summary-row","feature-icon","scenario-capsule","scenario-tags","scenario-duration","step-capsule","step-status","step-duration","messages","embed_button","link","table",];contrast_classes.forEach(toggle_contrast_for);};function detect_contrast(){var obj_div=document.createElement("div");obj_div.style.color="rgb(31, 41, 59)";document.body.appendChild(obj_div);var col=document.defaultView?document.defaultView.getComputedStyle(obj_div,null).color:obj_div.currentStyle.color;document.body.removeChild(obj_div);col=col.replace(/ /g,"");if(col!=="rgb(31,41,59)"){console.log("High Contrast theme detected.");toggle_contrast();}}
\ No newline at end of file
diff --git a/behave_html_pretty_formatter/theme.css b/behave_html_pretty_formatter/theme.css
new file mode 100644
index 0000000..63f93ff
--- /dev/null
+++ b/behave_html_pretty_formatter/theme.css
@@ -0,0 +1,503 @@
+/* Inspired by https://github.com/Hargne/jest-html-reporter */
+
+:root {
+  --body-color: #333;
+  --body-bg: #fff;
+  --strong-color: #000;
+  --feature-bg: #eee;
+  --feature-color: #777;
+  --duration-color: rgb(49, 49, 49);
+  --summary-passed: #4f8a10;
+  --summary-passed-border: #4f8a10;
+  --summary-failed: #d8000c;
+  --summary-failed-border: #d8000c;
+  --summary-undefined: #945901;
+  --summary-undefined-border: #ffdf61;
+  --summary-skipped: #76adff;
+  --summary-skipped-border: #76adff;
+  --passed-bg: #dff2bf;
+  --passed-step-bg: #c6dba3;
+  --passed-border:#b4cc8c;
+  --failed-bg: #f5c9cd;
+  --failed-step-bg: #ea868f;
+  --failed-border: #dd7a82;
+  --undefined-bg: #ffdf61;
+  --undefined-step-bg: #f1cb32;
+  --undefined-border: #917400;
+  --skipped-bg: #eef5ff;
+  --skipped-step-bg: #cfe2ff;
+  --skipped-border: #b8c9e4;
+  --commentary-bg: #b9b9b9;
+  --table-bg-odd: #fff;
+  --table-bg-even: #eee;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --body-color: #ddd;
+    --body-bg: #000;
+    --strong-color: #fff;
+    --feature-bg: #222;
+    --feature-color: #aaa;
+    --duration-color: rgb(206, 206, 206);
+    --passed-bg: #42630a;
+    --passed-step-bg: #697e41;
+    --passed-border: #91a86b;
+    --failed-bg: #69272d;
+    --failed-step-bg: #a8666c;
+    --failed-border: #df888f;
+    --undefined-bg: #665a2a;
+    --undefined-step-bg: #b6940d;
+    --undefined-border: #dbb20e;
+    --skipped-bg: #345381;
+    --skipped-step-bg: #3d659e;
+    --skipped-border: #6981a8;
+    --commentary-bg: #5c5c5c;
+    --table-bg-odd: #555;
+    --table-bg-even: #444;
+  }
+}
+
+html,
+body {
+  font-family: Arial, Helvetica, sans-serif;
+  font-size: 1rem;
+  margin: 0;
+  padding: 0;
+  color: var(--body-color);
+  background: var(--body-bg);
+}
+
+body {
+  padding: 1rem 1rem;
+  font-size: 0.85rem;
+}
+
+header {
+  display: flex;
+  align-items: center;
+}
+
+#title {
+  margin: 0;
+  flex-grow: 1;
+}
+
+pre {
+  margin: 0;
+}
+
+/*FEATURE CONTENT*/
+.feature-panel {
+  margin-bottom: 5px;
+  font-size: 1rem;
+}
+
+.button-toggle {
+  margin-left: 1rem;
+  color: var(--strong-color);
+  font-weight: bold;
+  text-decoration: underline;
+}
+
+.feature-title.contrast .button-toggle {
+  color: #fff;
+}
+
+.feature-title {
+  display: block;
+  background-color: var(--feature-bg);
+  color: var(--feature-color);
+  padding: 1rem;
+  margin-bottom: inherit;
+
+}
+
+.feature-icon {
+  height: 1.2em;
+  display: inline-block;
+  margin-right: 0.3em;
+  align-items: center;
+  text-align: center;
+  vertical-align: middle;
+}
+
+.feature-icon.contrast {
+  display: none;
+}
+
+.feature-title.contrast {
+  font-weight: bold;
+  font-size: 1.25rem;
+  background-color: #000;
+  color: #fff;
+}
+
+.feature-summary-container {
+  padding: 5px;
+  background-color: var(--feature-bg);
+  color: var(--feature-color);
+  justify-content: start;
+  font-size: 0.8rem;
+}
+
+.feature-summary-container.contrast {
+  background-color: #000;
+  color:rgb(248, 248, 248);
+  font-size: 1rem;
+}
+
+
+@media only screen and (max-width: 720px) {
+  .feature-panel {
+    flex-direction: column;
+  }
+
+  .feature-summary-container {
+    margin-left: 0;
+    margin-top: 0.25rem;
+    font-size: 1rem;
+  }
+}
+
+.feature-summary-stats {
+  display: inline-block;
+  margin-right: 5px;
+}
+
+.feature-summary-row:last-child {
+  margin-right: 0em;
+}
+
+.feature-summary-row {
+  color: var(--feature-color);
+  border-left: 0.4rem solid var(--feature-color);
+  padding-left: 0.5rem;
+  margin: 1px;
+}
+
+.feature-summary-row.button {
+  color: var(--body-color);
+  font-weight: bold;
+  text-decoration: underline;
+  border-left: 0.4rem solid var(--body-color);
+  padding-left: 0.5rem;
+  margin: 1px;
+}
+
+.feature-summary-row.passed {
+  color: var(--summary-passed);
+  border-left: 0.4rem solid var(--summary-passed-border);
+}
+
+.feature-summary-row.failed {
+  color: var(--summary-failed);
+  border-left: 0.4rem solid var(--summary-failed-border);
+}
+
+.feature-summary-row.undefined {
+  color: var(--summary-undefined);
+  border-left: 0.4rem solid var(--summary-undefined-border);
+}
+
+.feature-summary-row.skipped {
+  color: var(--summary-skipped);
+  border-left: 0.4rem solid var(--summary-skipped-border);
+}
+
+.feature-summary-row.contrast {
+  color: rgb(248, 248, 248);
+  border-left: 0.4rem solid rgb(248, 248, 248);
+}
+
+.feature-container {
+  margin-bottom: 2rem;
+}
+
+
+/*SCENARIO CONTENT*/
+.scenario-capsule {
+  padding: 1rem;
+  margin-bottom: 0.5rem;
+  color: var(--strong-color);
+}
+
+.scenario-capsule:last-child {
+  border: 0;
+}
+
+.scenario-capsule.passed {
+  background-color: var(--passed-bg);
+}
+
+.scenario-capsule.failed {
+  background-color: var(--failed-bg);
+}
+
+.scenario-capsule.undefined {
+  background-color: var(--undefined-bg);
+}
+
+.scenario-capsule.skipped {
+  background-color: var(--skipped-bg);
+}
+
+.scenario-capsule.contrast {
+  background-color: #000;
+  color: #fff;
+}
+
+.scenario-info {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 1.25rem;
+}
+
+.scenario-name {
+  width: 70%;
+  text-align: left;
+  font-weight: bold;
+  word-break: break-word;
+  padding-bottom: 0.5em;
+}
+
+.scenario-duration {
+  text-align: right;
+  font-size: 0.75rem;
+  font-style: italic;
+  padding: 0 0.5em 0.5em 0;
+}
+
+.scenario-duration.contrast {
+  font-size: 1.25rem;
+  color: #fff;
+}
+
+.scenario-tags {
+  color: var(--body-color);
+  font-weight: bold;
+  font-size: 0.75rem;
+  margin: 0.1rem 0.8em 0.5rem 0;
+  display: inline-block;
+}
+
+.scenario-tags.contrast {
+  color: white;
+  font-weight: bold;
+  font-size: 1rem;
+  margin: 0.1rem 1em 0.5rem 0;
+}
+
+
+/*STEP CONTENT*/
+.step-capsule {
+  margin: 2px 0 2px 2px;
+  padding: 0.5rem;
+  color: var(--strong-color);
+  display: flex;
+  flex-flow: wrap;
+  font-size: 0.75rem;
+  justify-content: space-between;
+}
+
+.step-capsule.passed {
+  background-color: var(--passed-step-bg);
+  border: 1px solid var(--passed-border);
+}
+
+.step-capsule.failed {
+  background-color: var(--failed-step-bg);
+  ;
+  border: 1px solid var(--failed-border);
+}
+
+.step-capsule.undefined {
+  background-color: var(--undefined-step-bg);
+  border: 1px solid var(--undefined-step-bg);
+}
+
+.step-capsule.skipped {
+  background-color: var(--skipped-step-bg);
+  border: 1px solid var(--skipped-border);
+}
+
+.step-capsule.commentary {
+  background-color: #b9b9b9;
+  margin-left: 1rem;
+  margin-right: 1rem;
+}
+
+.step-capsule.contrast {
+  background-color: rgb(36, 35, 35);
+  color: #fff;
+  font-size: 1.25rem;
+  border: none;
+}
+
+.step-capsule.commentary {
+  background-color: var(--commentary-bg);
+  margin-left: 1rem;
+  margin-right: 1rem;
+}
+
+.margin-top {
+  margin-top: 15px;
+}
+
+.step-status-decorator-duration-capsule {
+  display: flex;
+}
+
+.step-status {
+  text-align: left;
+  display: none;
+}
+
+.step-status.contrast {
+  width: 3rem;
+  padding: 0 1rem 0 0;
+  font-weight: bold;
+  font-size: 1.25rem;
+}
+
+.step-decorator {
+  padding: 0 1.5rem 0 0;
+}
+
+.step-duration {
+  color: var(--duration-color);
+  font-style: italic;
+  text-align: left;
+  padding: 0 0.5rem 0 0;
+}
+
+.step-duration.contrast {
+  color: rgb(248, 248, 248);
+}
+
+
+/*EMBED CONTENT*/
+.messages {
+  margin: 0 0 4px 1em
+}
+
+.scenario-capsule.passed .messages:last-child {
+  border-bottom: 1px dashed var(--passed-border);
+}
+
+.scenario-capsule.failed .messages:last-child {
+  border-bottom: 1px dashed var(--failed-border);
+}
+
+.scenario-capsule.undefined .messages:last-child {
+  border-bottom: 1px dashed var(--undefined-border);
+}
+
+.scenario-capsule.skipped .messages:last-child {
+  border-bottom: 1px dashed var(--skipped-border);
+}
+
+.scenario-capsule .messages.contrast:last-child {
+  border-bottom: 1px dashed #fff;
+}
+
+.embed-capsule {
+  margin: 0.5em 0;
+}
+
+.embed_content {
+  white-space: pre-wrap;
+  word-wrap: break-word;
+  font-size: 12px;
+  margin: 0.5rem;
+}
+
+.embed_button {
+  margin: 0 1rem 0.5em 0;
+  font-weight: bold;
+  text-decoration: underline;
+  color: var(--strong-color);
+  font-size: 12px;
+}
+
+.embed_button.contrast {
+  color: #fff;
+  font-size: 20px;
+}
+
+
+/*TABLE FORMATTING*/
+th,
+td {
+  padding: 6px;
+}
+
+thead {
+  background-color: #333;
+  color: #fff;
+  cursor: pointer;
+}
+
+table {
+  color: var(--body-color);
+  margin: 2px 1em 4px 1em;
+  border-collapse: collapse;
+  border: 1px solid #000;
+  text-align: left;
+  vertical-align: middle;
+}
+
+table.contrast {
+  font-size: 1rem;
+}
+
+table tbody tr:nth-child(odd) {
+  background-color: var(--table-bg-odd);
+}
+
+table tbody tr:nth-child(even) {
+  background-color: var(--table-bg-even);
+}
+
+table.contrast tbody tr {
+  background-color: #fff;
+  color: #000;
+  border: 1px solid #000;
+}
+
+
+/*IMAGE AND LINK FORMATTING*/
+img,
+video {
+  max-width: 100%;
+  max-height: 100%;
+}
+
+a {
+  color: inherit;
+  text-decoration: none;
+}
+
+.link a span:hover {
+  text-decoration: underline;
+  text-decoration-color: black;
+}
+
+.link.contrast a span:hover {
+  color: grey;
+  text-decoration: underline;
+  text-decoration-color: grey;
+}
+
+.scenario-capsule.collapse .scenario-tags,
+.scenario-capsule.collapse .step-capsule,
+.scenario-capsule.collapse .embeds,
+.scenario-capsule.collapse table
+{
+  display: none;
+}
+
+.scenario-capsule.collapse {
+  padding: 0.5rem 1rem 0 1rem;
+}
\ No newline at end of file
diff --git a/behave_html_pretty_formatter/theme.min.css b/behave_html_pretty_formatter/theme.min.css
new file mode 100644
index 0000000..fa1063d
--- /dev/null
+++ b/behave_html_pretty_formatter/theme.min.css
@@ -0,0 +1,1 @@
+@charset "utf-8"; :root{--body-color:#333;--body-bg:#fff;--strong-color:#000;--feature-bg:#eee;--feature-color:#777;--duration-color:#313131;--summary-passed:#4f8a10;--summary-passed-border:#4f8a10;--summary-failed:#d8000c;--summary-failed-border:#d8000c;--summary-undefined:#945901;--summary-undefined-border:#ffdf61;--summary-skipped:#76adff;--summary-skipped-border:#76adff;--passed-bg:#dff2bf;--passed-step-bg:#c6dba3;--passed-border:#b4cc8c;--failed-bg:#f5c9cd;--failed-step-bg:#ea868f;--failed-border:#dd7a82;--undefined-bg:#ffdf61;--undefined-step-bg:#f1cb32;--undefined-border:#917400;--skipped-bg:#eef5ff;--skipped-step-bg:#cfe2ff;--skipped-border:#b8c9e4;--commentary-bg:#b9b9b9;--table-bg-odd:#fff;--table-bg-even:#eee}@media (prefers-color-scheme:dark){:root{--body-color:#ddd;--body-bg:#000;--strong-color:#fff;--feature-bg:#222;--feature-color:#aaa;--duration-color:#cecece;--passed-bg:#42630a;--passed-step-bg:#697e41;--passed-border:#91a86b;--failed-bg:#69272d;--failed-step-bg:#a8666c;--failed-border:#df888f;--undefined-bg:#665a2a;--undefined-step-bg:#b6940d;--undefined-border:#dbb20e;--skipped-bg:#345381;--skipped-step-bg:#3d659e;--skipped-border:#6981a8;--commentary-bg:#5c5c5c;--table-bg-odd:#555;--table-bg-even:#444}}html,body{font-family:Arial,Helvetica,sans-serif;font-size:1rem;margin:0;padding:0;color:var(--body-color);background:var(--body-bg)}body{padding:1rem 1rem;font-size:.85rem}header{display:flex;align-items:center}#title{margin:0;flex-grow:1}pre{margin:0}.feature-panel{margin-bottom:5px;font-size:1rem}.button-toggle{margin-left:1rem;color:var(--strong-color);font-weight:bold;text-decoration:underline}.feature-title.contrast .button-toggle{color:#fff}.feature-title{display:block;background-color:var(--feature-bg);color:var(--feature-color);padding:1rem;margin-bottom:inherit}.feature-icon{height:1.2em;display:inline-block;margin-right:.3em;align-items:center;text-align:center;vertical-align:middle}.feature-icon.contrast{display:none}.feature-title.contrast{font-weight:bold;font-size:1.25rem;background-color:#000;color:#fff}.feature-summary-container{padding:5px;background-color:var(--feature-bg);color:var(--feature-color);justify-content:start;font-size:.8rem}.feature-summary-container.contrast{background-color:#000;color:#f8f8f8;font-size:1rem}@media only screen and (max-width:720px){.feature-panel{flex-direction:column}.feature-summary-container{margin-left:0;margin-top:.25rem;font-size:1rem}}.feature-summary-stats{display:inline-block;margin-right:5px}.feature-summary-row:last-child{margin-right:0}.feature-summary-row{color:var(--feature-color);border-left:.4rem solid var(--feature-color);padding-left:.5rem;margin:1px}.feature-summary-row.button{color:var(--body-color);font-weight:bold;text-decoration:underline;border-left:.4rem solid var(--body-color);padding-left:.5rem;margin:1px}.feature-summary-row.passed{color:var(--summary-passed);border-left:.4rem solid var(--summary-passed-border)}.feature-summary-row.failed{color:var(--summary-failed);border-left:.4rem solid var(--summary-failed-border)}.feature-summary-row.undefined{color:var(--summary-undefined);border-left:.4rem solid var(--summary-undefined-border)}.feature-summary-row.skipped{color:var(--summary-skipped);border-left:.4rem solid var(--summary-skipped-border)}.feature-summary-row.contrast{color:#f8f8f8;border-left:.4rem solid #f8f8f8}.feature-container{margin-bottom:2rem}.scenario-capsule{padding:1rem;margin-bottom:.5rem;color:var(--strong-color)}.scenario-capsule:last-child{border:0}.scenario-capsule.passed{background-color:var(--passed-bg)}.scenario-capsule.failed{background-color:var(--failed-bg)}.scenario-capsule.undefined{background-color:var(--undefined-bg)}.scenario-capsule.skipped{background-color:var(--skipped-bg)}.scenario-capsule.contrast{background-color:#000;color:#fff}.scenario-info{display:flex;justify-content:space-between;align-items:center;font-size:1.25rem}.scenario-name{width:70%;text-align:left;font-weight:bold;word-break:break-word;padding-bottom:.5em}.scenario-duration{text-align:right;font-size:.75rem;font-style:italic;padding:0 .5em .5em 0}.scenario-duration.contrast{font-size:1.25rem;color:#fff}.scenario-tags{color:var(--body-color);font-weight:bold;font-size:.75rem;margin:.1rem .8em .5rem 0;display:inline-block}.scenario-tags.contrast{color:white;font-weight:bold;font-size:1rem;margin:.1rem 1em .5rem 0}.step-capsule{margin:2px 0 2px 2px;padding:.5rem;color:var(--strong-color);display:flex;flex-flow:wrap;font-size:.75rem;justify-content:space-between}.step-capsule.passed{background-color:var(--passed-step-bg);border:1px solid var(--passed-border)}.step-capsule.failed{background-color:var(--failed-step-bg);border:1px solid var(--failed-border)}.step-capsule.undefined{background-color:var(--undefined-step-bg);border:1px solid var(--undefined-step-bg)}.step-capsule.skipped{background-color:var(--skipped-step-bg);border:1px solid var(--skipped-border)}.step-capsule.commentary{background-color:#b9b9b9;margin-left:1rem;margin-right:1rem}.step-capsule.contrast{background-color:#242323;color:#fff;font-size:1.25rem;border:none}.step-capsule.commentary{background-color:var(--commentary-bg);margin-left:1rem;margin-right:1rem}.margin-top{margin-top:15px}.step-status-decorator-duration-capsule{display:flex}.step-status{text-align:left;display:none}.step-status.contrast{width:3rem;padding:0 1rem 0 0;font-weight:bold;font-size:1.25rem}.step-decorator{padding:0 1.5rem 0 0}.step-duration{color:var(--duration-color);font-style:italic;text-align:left;padding:0 .5rem 0 0}.step-duration.contrast{color:#f8f8f8}.messages{margin:0 0 4px 1em}.scenario-capsule.passed .messages:last-child{border-bottom:1px dashed var(--passed-border)}.scenario-capsule.failed .messages:last-child{border-bottom:1px dashed var(--failed-border)}.scenario-capsule.undefined .messages:last-child{border-bottom:1px dashed var(--undefined-border)}.scenario-capsule.skipped .messages:last-child{border-bottom:1px dashed var(--skipped-border)}.scenario-capsule .messages.contrast:last-child{border-bottom:1px dashed #fff}.embed-capsule{margin:.5em 0}.embed_content{white-space:pre-wrap;word-wrap:break-word;font-size:12px;margin:.5rem}.embed_button{margin:0 1rem .5em 0;font-weight:bold;text-decoration:underline;color:var(--strong-color);font-size:12px}.embed_button.contrast{color:#fff;font-size:20px}th,td{padding:6px}thead{background-color:#333;color:#fff;cursor:pointer}table{color:var(--body-color);margin:2px 1em 4px 1em;border-collapse:collapse;border:1px solid #000;text-align:left;vertical-align:middle}table.contrast{font-size:1rem}table tbody tr:nth-child(odd){background-color:var(--table-bg-odd)}table tbody tr:nth-child(even){background-color:var(--table-bg-even)}table.contrast tbody tr{background-color:#fff;color:#000;border:1px solid #000}img,video{max-width:100%;max-height:100%}a{color:inherit;text-decoration:none}.link a span:hover{text-decoration:underline;text-decoration-color:black}.link.contrast a span:hover{color:grey;text-decoration:underline;text-decoration-color:grey}.scenario-capsule.collapse .scenario-tags,.scenario-capsule.collapse .step-capsule,.scenario-capsule.collapse .embeds,.scenario-capsule.collapse table{display:none}.scenario-capsule.collapse{padding:.5rem 1rem 0 1rem}
\ No newline at end of file
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..af8b0e7
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+import setuptools
+
+
+def read_file(filename):
+    """
+    Return the content of a file.
+    """
+
+    with open(filename, "r", encoding="utf-8") as file:
+        return file.read()
+
+
+setuptools.setup(
+    name="behave-html-pretty-formatter",
+    version="1.0",
+    author="Michal Odehnal",
+    author_email="modehnal@redhat.com",
+    description="Pretty HTML Formatter for Behave",
+    url="https://github.com/behave-contrib/behave-html-pretty-formatter",
+    packages=setuptools.find_packages(),
+    install_requires=[
+        "behave",
+        "dominate",
+    ],
+    include_package_data=True,
+    python_requires=">=3.6",
+    long_description=read_file("README.md"),
+    long_description_content_type="text/markdown",
+    classifiers=[
+        "Development Status :: 4 - Beta",
+        "Environment :: Console",
+        "Intended Audience :: Developers",
+        "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
+        "Operating System :: OS Independent",
+        "Programming Language :: Python :: 3",
+        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: Implementation :: CPython",
+        "Programming Language :: Python :: Implementation :: PyPy",
+        "Topic :: Software Development :: Testing",
+        "Topic :: Software Development :: Testing :: BDD",
+    ],
+)
diff --git a/src/full_page_example.png b/src/full_page_example.png
new file mode 100644
index 0000000..fb804b5
Binary files /dev/null and b/src/full_page_example.png differ
diff --git a/src/full_page_high_contrast_example.png b/src/full_page_high_contrast_example.png
new file mode 100644
index 0000000..1f93509
Binary files /dev/null and b/src/full_page_high_contrast_example.png differ
diff --git a/src/image_and_video_examples.gif b/src/image_and_video_examples.gif
new file mode 100644
index 0000000..dc8d92c
Binary files /dev/null and b/src/image_and_video_examples.gif differ
diff --git a/src/set_icon_examples.png b/src/set_icon_examples.png
new file mode 100644
index 0000000..cdb439e
Binary files /dev/null and b/src/set_icon_examples.png differ
diff --git a/src/summary_collapsed_example.png b/src/summary_collapsed_example.png
new file mode 100644
index 0000000..88d6f0b
Binary files /dev/null and b/src/summary_collapsed_example.png differ
diff --git a/src/summary_collapsed_high_contrast_example.png b/src/summary_collapsed_high_contrast_example.png
new file mode 100644
index 0000000..1977db0
Binary files /dev/null and b/src/summary_collapsed_high_contrast_example.png differ
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..8bc3d16
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,89 @@
+# tox (https://tox.readthedocs.io/) is a tool for running tests
+# Run tests in multiple virtualenvs.
+
+[tox]
+envlist =
+    behave
+    flake8
+    pylint
+    py{36,37,38,39,py39}
+    minify
+    readme
+    clean
+
+[testenv]
+description = Tests
+deps =
+    behave
+    PyHamcrest
+commands = behave
+
+
+[testenv:behave]
+description = Tests
+deps =
+    behave
+    PyHamcrest
+commands = behave
+
+[testenv:black]
+description = Autoformat your Python code
+deps = black
+commands = black {posargs:behave_html_pretty_formatter setup.py tests}
+
+[testenv:clean]
+description = Clean up bytecode and build artifacts
+deps = pyclean
+commands =
+    pyclean {toxinidir}
+    rm -rf .tox/ build/ dist/ behave_html_pretty_formatter.egg-info/ tests/acceptance/steps/__WORKDIR__/
+whitelist_externals =
+    rm
+
+[testenv:minify]
+description = Minify CSS and JavaScript code
+deps = css-html-js-minify
+commands =
+    css-html-js-minify behave_html_pretty_formatter/theme.css
+    css-html-js-minify behave_html_pretty_formatter/script.js
+
+[testenv:flake8]
+description = Static code analysis and code style
+deps = flake8-black
+commands = flake8 {posargs}
+
+[testenv:pylint]
+description = Check for errors and code smells
+deps = pylint
+commands = pylint --rcfile tox.ini {posargs:behave_html_pretty_formatter setup}
+
+[testenv:readme]
+description = Ensure README renders on PyPI
+deps = twine
+commands =
+    {envpython} setup.py -q sdist bdist_wheel
+    twine check dist/*
+
+[gh-actions]
+python =
+    3.6: py36
+    3.7: py37
+    3.8: py38
+    3.9: py39
+    pypy-3.9: pypy39
+
+[behave]
+paths = tests/acceptance
+
+[behave.formatters]
+html-pretty = behave_html_pretty_formatter:PrettyHTMLFormatter
+
+[flake8]
+exclude = .tox,build,dist,behave_html_pretty_formatter.egg-info
+extend-ignore = E203
+per-file-ignores = ./behave_html_pretty_formatter/__init__.py:F401
+max-line-length = 100
+
+[pylint.main]
+disable = missing-module-docstring,too-many-lines,too-many-instance-attributes,too-many-arguments
+output-format = colorized


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..1dc40b3
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,37 @@
+name: Tests
+
+on:
+  pull_request:
+    branches:
+    - main
+  push:
+    branches:
+    - main
+
+jobs:
+  build:
+    runs-on: ${{ matrix.platform }}
+    strategy:
+      fail-fast: false
+      matrix:
+        platform:
+        - ubuntu-latest
+        - macos-latest
+        - windows-latest
+        python-version:
+        - '3.6'
+        - '3.7'
+        - '3.8'
+        - '3.9'
+        - 'pypy-3.9'
+    steps:
+    - uses: actions/checkout@v2
+    - uses: actions/setup-python@v2
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install build tools
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install --upgrade setuptools tox-gh-actions wheel
+    - name: Run tests
+      run: tox
diff --git a/tests/acceptance/features/help-screen.feature b/tests/acceptance/features/help-screen.feature
new file mode 100644
index 0000000..a06ad33
--- /dev/null
+++ b/tests/acceptance/features/help-screen.feature
@@ -0,0 +1,20 @@
+Feature: List HTML formatter among the available formatters
+
+  As a tester
+  I want to know whether the HTML formatter is configured
+  So that I can verify I have integrated it correctly.
+
+  Scenario: List available formatters
+    Given a file named "behave.ini" with:
+      """
+      [behave.formatters]
+      html-pretty = behave_html_pretty_formatter:PrettyHTMLFormatter
+      """
+    When I run "behave --format help"
+    Then it should pass
+    And the command output should contain:
+      """
+      Available formatters:
+        html-pretty    Pretty HTML Formatter
+        json           JSON dump of test run
+      """
diff --git a/tests/acceptance/features/html-output.feature b/tests/acceptance/features/html-output.feature
new file mode 100644
index 0000000..48e2328
--- /dev/null
+++ b/tests/acceptance/features/html-output.feature
@@ -0,0 +1,47 @@
+Feature: Make behave generate Pretty HTML as output
+
+  As a package maintainer
+  I want to execute the Pretty HTML Formatter and verify its output
+  So that I can ensure integration and basic functionality.
+
+  Scenario: Run behave with Pretty HTML Formatter
+    Given a file named "behave.ini" with:
+      """
+      [behave.formatters]
+      html-pretty = behave_html_pretty_formatter:PrettyHTMLFormatter
+      """
+    When I run "behave --format html-pretty --dry-run"
+    Then it should pass
+    And the command output should contain:
+      """
+      <!DOCTYPE html>
+      <html>
+      """
+    And the command output should contain:
+      """
+      <head>
+      <title>Test Suite Reporter</title>
+      """
+    And the command output should contain:
+      """
+      <meta content="text/html;charset=utf-8" http-equiv="content-type">
+      """
+    And the command output should contain:
+      """
+      <style rel="stylesheet">
+      """
+    And the command output should contain:
+      """
+      <script type="text/javascript">
+      """
+    And the command output should contain:
+      """
+      </script>
+      </head>
+      <body onload="detect_contrast();">
+      """
+    And the command output should contain:
+      """
+      </body>
+      </html>
+      """
diff --git a/tests/acceptance/steps/behave4cmd0/__all_steps__.py b/tests/acceptance/steps/behave4cmd0/__all_steps__.py
new file mode 100644
index 0000000..6779a8d
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/__all_steps__.py
@@ -0,0 +1,8 @@
+"""
+Import all step definitions of this step-library.
+Step definitions are automatically registered in "behave.step_registry".
+"""
+
+from . import command_steps  # noqa
+from . import note_steps  # noqa
+from .log import steps  # noqa
diff --git a/tests/acceptance/steps/behave4cmd0/__init__.py b/tests/acceptance/steps/behave4cmd0/__init__.py
new file mode 100644
index 0000000..368289b
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/__init__.py
@@ -0,0 +1,4 @@
+"""
+Predecessor of behave4cmd library.
+Currently used to provide self-tests for behave.
+"""
diff --git a/tests/acceptance/steps/behave4cmd0/__setup.py b/tests/acceptance/steps/behave4cmd0/__setup.py
new file mode 100644
index 0000000..bb630b3
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/__setup.py
@@ -0,0 +1,9 @@
+"""
+Constants
+"""
+
+import os.path
+
+HERE = os.path.dirname(__file__)
+TOP = os.path.join(HERE, "..")
+TOPA = os.path.abspath(TOP)
diff --git a/tests/acceptance/steps/behave4cmd0/command_shell.py b/tests/acceptance/steps/behave4cmd0/command_shell.py
new file mode 100755
index 0000000..ff9352c
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/command_shell.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python
+"""
+Provide a behave shell to simplify creation of feature files
+and running features, etc.
+
+.. sourcecode:: python
+
+    context.command_result = behave_shell.behave(cmdline, cwd=context.workdir)
+    behave_shell.create_scenario(scenario_text, cwd=context.workdir)
+    behave_shell.create_step_definition(context.text, cwd=context.workdir)
+    context.command_result = behave_shell.run_feature_with_formatter(
+            context.features[0], formatter=formatter, cwd=context.workdir)
+"""
+
+import os.path
+import shlex
+import subprocess
+import sys
+
+
+class CommandResult(object):
+    """
+    ValueObject to store the results of a subprocess command call.
+    """
+
+    def __init__(self, **kwargs):
+        self.command = kwargs.pop("command", None)
+        self.returncode = kwargs.pop("returncode", 0)
+        self.stdout = kwargs.pop("stdout", "")
+        self.stderr = kwargs.pop("stderr", "")
+        self._output = None
+        if kwargs:
+            names = ", ".join(kwargs.keys())
+            raise ValueError("Unexpected: %s" % names)
+
+    @property
+    def output(self):
+        if self._output is None:
+            output = self.stdout
+            if self.stderr:
+                if self.stdout:
+                    output += "\n"
+                output += self.stderr
+            self._output = output
+        return self._output
+
+    @property
+    def failed(self):
+        return self.returncode != 0
+
+    def clear(self):
+        self.command = None
+        self.returncode = 0
+        self.stdout = ""
+        self.stderr = ""
+        self._output = None
+
+
+class Command(object):
+    """
+    Helper class to run commands as subprocess,
+    collect their output and subprocess returncode.
+    """
+
+    DEBUG = False
+    PREPROCESSOR_MAP = {}
+    POSTPROCESSOR_MAP = {}
+    USE_SHELL = sys.platform.startswith("win")
+
+    @staticmethod
+    def preprocess_command(preprocessors, cmdargs, command=None, cwd="."):
+        if not os.path.isdir(cwd):
+            return cmdargs
+        elif not command:
+            command = " ".join(cmdargs)
+
+        for preprocess in preprocessors:
+            cmdargs = preprocess(command, cmdargs, cwd)
+        return cmdargs
+
+    @staticmethod
+    def postprocess_command(postprocessors, command_result):
+        for postprocess in postprocessors:
+            command_result = postprocess(command_result)
+        return command_result
+
+    @classmethod
+    def run(cls, command, cwd=".", **kwargs):
+        """
+        Make a subprocess call, collect its output and returncode.
+        Returns CommandResult instance as ValueObject.
+        """
+        command_result = CommandResult()
+        command_result.command = command
+        use_shell = cls.USE_SHELL
+        if "shell" in kwargs:
+            use_shell = kwargs.pop("shell")
+
+        cmdargs = shlex.split(command)
+
+        command0 = cmdargs[0]
+        preprocessors = cls.PREPROCESSOR_MAP.get(command0)
+        if preprocessors:
+            cmdargs = cls.preprocess_command(preprocessors, cmdargs, command, cwd)
+
+        try:
+            process = subprocess.Popen(
+                cmdargs,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.PIPE,
+                universal_newlines=True,
+                shell=use_shell,
+                cwd=cwd,
+                **kwargs
+            )
+            out, err = process.communicate()
+            process.poll()
+            assert process.returncode is not None
+            command_result.stdout = out
+            command_result.stderr = err
+            command_result.returncode = process.returncode
+            if cls.DEBUG:
+                print("shell.cwd={0}".format(kwargs.get("cwd", None)))
+                print("shell.command: {0}".format(" ".join(cmdargs)))
+                print("shell.command.output:\n{0};".format(command_result.output))
+        except OSError as e:
+            command_result.stderr = "OSError: %s" % e
+            command_result.returncode = e.errno
+            assert e.errno != 0
+
+        postprocessors = cls.POSTPROCESSOR_MAP.get(command0)
+        if postprocessors:
+            command_result = cls.postprocess_command(postprocessors, command_result)
+        return command_result
+
+
+def path_glob(command, cmdargs, cwd="."):
+    import glob
+
+    if not glob.has_magic(command):
+        return cmdargs
+
+    assert os.path.isdir(cwd)
+    try:
+        current_cwd = os.getcwd()
+        os.chdir(cwd)
+        new_cmdargs = []
+        for cmdarg in cmdargs:
+            if not glob.has_magic(cmdarg):
+                new_cmdargs.append(cmdarg)
+                continue
+
+            more_args = glob.glob(cmdarg)
+            if more_args:
+                new_cmdargs.extend(more_args)
+            else:
+                # -- BAD-CASE: Require at least one match.
+                # Otherwise, restore original arg.
+                new_cmdargs.append(cmdarg)
+
+        cmdargs = new_cmdargs
+    finally:
+        os.chdir(current_cwd)
+    return cmdargs
+
+
+def run(command, cwd=".", **kwargs):
+    return Command.run(command, cwd=cwd, **kwargs)
+
+
+def behave(cmdline, cwd=".", **kwargs):
+    """
+    Run behave as subprocess command and return process/shell instance
+    with results (collected output, returncode).
+    """
+    return run("behave " + cmdline, cwd=cwd, **kwargs)
+
+
+if __name__ == "__main__":
+    command = " ".join(sys.argv[1:])
+    output = Command.run(sys.argv[1:])
+    print("command: {0}\n{1}\n".format(command, output))
diff --git a/tests/acceptance/steps/behave4cmd0/command_shell_proc.py b/tests/acceptance/steps/behave4cmd0/command_shell_proc.py
new file mode 100755
index 0000000..7112673
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/command_shell_proc.py
@@ -0,0 +1,251 @@
+#!/usr/bin/env python
+"""
+This module provides pre-/post-processors for the mod:`behave4cmd0.command_shell`.
+"""
+
+import re
+import sys
+
+
+def posixpath_normpath(filename):
+    if not filename:
+        return filename
+    return filename.replace("\\", "/").replace("//", "/")
+
+
+class LineProcessor(object):
+    """Function-like object that may perform text-line transformations."""
+
+    def __init__(self, marker=None):
+        self.marker = marker
+
+    def reset(self):
+        pass
+
+    def __call__(self, text):
+        return text
+
+
+class TracebackLineNormalizer(LineProcessor):
+    """Line processor that tries to normalize path lines in a traceback dump."""
+
+    marker = "Traceback (most recent call last):"
+    file_pattern = re.compile(r'\s\s+File "(?P<path>.*)", line .*')
+
+    def __init__(self):
+        super(TracebackLineNormalizer, self).__init__(self.marker)
+        self.traceback_section = False
+
+    def reset(self):
+        self.traceback_section = False
+
+    def __call__(self, line):
+        """Process a line and optionally transform it.
+
+        :param line: line to process (as text)
+        :return: Same line or transformed/normalized line (as text).
+        """
+        marker = self.marker
+        stripped_line = line.strip()
+        if marker == stripped_line:
+            assert not self.traceback_section
+            self.traceback_section = True
+            # print("XXX: TRACEBACK-START")
+        elif self.traceback_section:
+            matched = self.file_pattern.match(line)
+            if matched:
+                # matched_range = matched.regs[1]
+                filename = matched.groups()[0]
+                new_filename = posixpath_normpath(filename)
+                if new_filename != filename:
+                    # print("XXX: %r => %r" % (filename, new_filename))
+                    line = line.replace(filename, new_filename)
+            elif not stripped_line or line[0].isalpha():
+                # -- DETECTED TRCAEBACK-END: exception-description
+                # print("XXX: TRACEBACK-END")
+                self.traceback_section = False
+        return line
+
+
+class ExceptionWithPathNormalizer(LineProcessor):
+    """Normalize filename path in Exception line (for Windows)."""
+
+    # http://myregexp.com/examples.html
+    # Windows File Name Regexp
+    # (?i) ^ (?! ^ (PRN | AUX | CLOCK\$ | NUL | CON | COM\d | LPT\d |\..* )(\..+)?$)
+    # [ ^\\\./:\ * \?\"<>\|][^\\/:\*\?\"<>\|]{0,254}$
+    problematic_path_patterns = [
+        'ConfigError: No steps directory in "(?P<path>.*)"',
+        'ParserError: Failed to parse "(?P<path>.*)"',
+        "Error: [Errno 2] No such file or directory: '(?P<path>.*)'",
+    ]
+
+    def __init__(self, pattern, marker_text=None):
+        super(ExceptionWithPathNormalizer, self).__init__(marker_text)
+        self.pattern = re.compile(pattern, re.UNICODE)
+        self.marker = marker_text
+
+    def __call__(self, line):
+        matched = self.pattern.search(line)
+        if matched:
+            # -- ONLY: One pattern per line should match.
+            filename = matched.groupdict()["path"]
+            new_filename = posixpath_normpath(filename)
+            if new_filename != filename:
+                line = line.replace(filename, new_filename)
+        return line
+
+
+class CommandPostProcessor(object):
+    """Syntactic sugar to mark a command post-processor."""
+
+
+class CommandOutputProcessor(CommandPostProcessor):
+    """Abstract base class functionality for a CommandPostProcessor that
+    post-processes the output of a command.
+    """
+
+    enabled = True
+    output_parts = ("stderr", "stdout")
+
+    def __init__(self, enabled=None, output_parts=None):
+        if enabled is None:
+            # -- AUTO-DETECT: Enabled on Windows platform
+            enabled = self.__class__.enabled
+        if output_parts is None:
+            output_parts = self.__class__.output_parts
+        self.enabled = enabled
+        self.output_parts = output_parts
+
+    def matches_output(self, text):
+        """Abstract method that should be overwritten."""
+        # pylint: disable=no-self-use, unused-argument
+        return False
+
+    def process_output(self, text):  # pylint: disable=no-self-use
+        """Abstract method that should be overwritten."""
+        changed = False
+        return changed, text
+
+    def __call__(self, command_result):
+        """Core functionality of command output processor.
+
+        :param command_result:  As value object w/ command execution details.
+        :return: Command result
+        """
+        if not self.enabled:
+            return command_result
+
+        changes = 0
+        for output_name in self.output_parts:
+            output = getattr(command_result, output_name)
+            if output and self.matches_output(output):
+                changed, new_output = self.process_output(output)
+                if changed:
+                    changes += 1
+                    setattr(command_result, output_name, new_output)
+
+        if changes:
+            # -- RESET: Composite output
+            # pylint: disable=protected-access
+            command_result._output = None
+        return command_result
+
+
+class LineCommandOutputProcessor(CommandOutputProcessor):
+    """Provides functionality to process text in line-oriented way by using
+    a number of line processors. The line processors perform the actual work
+    for transforming/normalizing the text.
+    """
+
+    enabled = True
+    line_processors = [TracebackLineNormalizer()]
+
+    def __init__(self, line_processors=None):
+        if line_processors is None:
+            line_processors = self.__class__.line_processors
+        super(LineCommandOutputProcessor, self).__init__(self.enabled)
+        self.line_processors = line_processors
+        self.markers = [p.marker for p in self.line_processors if p.marker]
+
+    def matches_output(self, text):
+        """Indicates it text contains sections of interest.
+        :param text:    Text to inspect (as string).
+        :return: True, if text contains Traceback sections. False, otherwise.
+        """
+        if self.markers:
+            for marker in self.markers:
+                if marker in text:
+                    return True
+        return False
+
+    def process_output(self, text):
+        """Normalizes multi-line text by applying the line processors.
+
+        :param text:    Text to process (as string).
+        :return: Tuple (changed : bool, new_text : string)
+        """
+        new_lines = []
+        changed = False
+        for line_processor in self.line_processors:
+            line_processor.reset()
+
+        for line in text.splitlines():
+            # -- LINE PROCESSING PIPELINE:
+            original_line = line
+            for line_processor in self.line_processors:
+                line = line_processor(line)
+
+            if line != original_line:
+                changed = True
+            new_lines.append(line)
+
+        if changed:
+            text = "\n".join(new_lines) + "\n"
+        return changed, text
+
+
+class TextProcessor(CommandOutputProcessor):
+    """Provides an adapter that uses an :class:`CommandOutputProcessor`
+    as text processor (normalizer).
+    """
+
+    def __init__(self, command_output_processor):
+        self.command_output_processor = command_output_processor
+        self.enabled = self.command_output_processor.enabled
+        self.output_parts = self.command_output_processor.output_parts
+
+    def process_output(self, text):
+        return self.command_output_processor.process_output(text)
+
+    def __call__(self, command_result):
+        return self.command_output_processor(command_result)
+
+
+class BehaveWinCommandOutputProcessor(LineCommandOutputProcessor):
+    """Command output post-processor for :mod:`behave` on Windows platform.
+    Mostly, normalizes windows paths in output and exceptions to conform to
+    POSIX path conventions.
+    """
+
+    enabled = sys.platform.startswith("win") or True
+    line_processors = [
+        TracebackLineNormalizer(),
+        ExceptionWithPathNormalizer(
+            "ConfigError: No steps directory in '(?P<path>.*)'",
+            "ConfigError: No steps directory in",
+        ),
+        ExceptionWithPathNormalizer(
+            'ParserError: Failed to parse "(?P<path>.*)"',
+            "ParserError: Failed to parse",
+        ),
+        ExceptionWithPathNormalizer(
+            "No such file or directory: '(?P<path>.*)'",
+            "[Errno 2] No such file or directory:",
+        ),  # IOError
+        ExceptionWithPathNormalizer(
+            # WAS: '^\s*File "(?P<path>.*)", line \d+, in ',
+            r'^\s*File "(?P<path>.*)", line \d+, in ',
+            'File "',
+        ),
+    ]
diff --git a/tests/acceptance/steps/behave4cmd0/command_steps.py b/tests/acceptance/steps/behave4cmd0/command_steps.py
new file mode 100644
index 0000000..b49a4a8
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/command_steps.py
@@ -0,0 +1,581 @@
+# pylint: disable=invalid-name, bad-continuation, bad-whitespace
+"""
+Provides step definitions to:
+
+    * run commands, like behave
+    * create textual files within a working directory
+"""
+
+import contextlib
+import difflib
+import os
+import shutil
+
+from hamcrest import assert_that, equal_to, is_not  # UNUSED: contains_string
+from behave import given, when, then, step, matchers
+
+from . import command_shell, command_util, pathutil, textutil
+from .command_shell_proc import TextProcessor, BehaveWinCommandOutputProcessor
+from .pathutil import posixpath_normpath
+
+
+matchers.register_type(int=int)
+DEBUG = False
+file_contents_normalizer = None
+if BehaveWinCommandOutputProcessor.enabled:
+    file_contents_normalizer = TextProcessor(BehaveWinCommandOutputProcessor())
+
+
+@contextlib.contextmanager
+def on_assert_failed_print_details(actual, expected):
+    """
+    Print text details in case of assertation failed errors.
+
+    .. sourcecode:: python
+
+        with on_assert_failed_print_details(actual_text, expected_text):
+            assert actual == expected
+    """
+    try:
+        yield
+    except AssertionError:
+        # diff = difflib.unified_diff(expected.splitlines(), actual.splitlines(),
+        #                            "expected", "actual")
+        diff = difflib.ndiff(expected.splitlines(), actual.splitlines())
+        diff_text = "\n".join(diff)
+        print("DIFF (+ ACTUAL, - EXPECTED):\n{0}\n".format(diff_text))
+        if DEBUG:
+            print("expected:\n{0}\n".format(expected))
+            print("actual:\n{0}\n".format(actual))
+        raise
+
+
+@contextlib.contextmanager
+def on_error_print_details(actual, expected):
+    """
+    Print text details in case of assertation failed errors.
+
+    .. sourcecode:: python
+
+        with on_error_print_details(actual_text, expected_text):
+            ... # Do something
+    """
+    try:
+        yield
+    except Exception:
+        diff = difflib.ndiff(expected.splitlines(), actual.splitlines())
+        diff_text = "\n".join(diff)
+        print("DIFF (+ ACTUAL, - EXPECTED):\n{0}\n".format(diff_text))
+        if DEBUG:
+            print("expected:\n{0}\n".format(expected))
+            print("actual:\n{0}".format(actual))
+        raise
+
+
+@given("a new working directory")
+def step_a_new_working_directory(context):
+    """Creates a new, empty working directory."""
+    command_util.ensure_context_attribute_exists(context, "workdir", None)
+    # MAYBE: command_util.ensure_workdir_not_exists(context)
+    command_util.ensure_workdir_exists(context)
+    # OOPS:
+    shutil.rmtree(context.workdir, ignore_errors=True)
+    command_util.ensure_workdir_exists(context)
+
+
+@given("I use the current directory as working directory")
+def step_use_curdir_as_working_directory(context):
+    """
+    Uses the current directory as working directory
+    """
+    context.workdir = os.path.abspath(".")
+    command_util.ensure_workdir_exists(context)
+
+
+@step('I use the directory "{directory}" as working directory')
+def step_use_directory_as_working_directory(context, directory):
+    """Uses the directory as new working directory"""
+    command_util.ensure_context_attribute_exists(context, "workdir", None)
+    current_workdir = context.workdir
+    if not current_workdir:
+        current_workdir = os.getcwd()
+
+    if not os.path.isabs(directory):
+        new_workdir = os.path.join(current_workdir, directory)
+        exists_relto_current_dir = os.path.isdir(directory)
+        exists_relto_current_workdir = os.path.isdir(new_workdir)
+        if exists_relto_current_workdir or not exists_relto_current_dir:
+            # -- PREFER: Relative to current workdir
+            workdir = new_workdir
+        else:
+            assert exists_relto_current_workdir
+            workdir = directory
+        workdir = os.path.abspath(workdir)
+
+    context.workdir = workdir
+    command_util.ensure_workdir_exists(context)
+
+
+@given('a file named "{filename}" and encoding="{encoding}" with')
+def step_a_file_named_filename_and_encoding_with(context, filename, encoding):
+    """Creates a textual file with the content provided as docstring."""
+    __encoding_is_valid = True
+    assert context.text is not None, "ENSURE: multiline text is provided."
+    assert not os.path.isabs(filename)
+    assert __encoding_is_valid
+    command_util.ensure_workdir_exists(context)
+    filename2 = os.path.join(context.workdir, filename)
+    pathutil.create_textfile_with_contents(filename2, context.text, encoding)
+
+
+@given('a file named "{filename}" with')
+def step_a_file_named_filename_with(context, filename):
+    """Creates a textual file with the content provided as docstring."""
+    step_a_file_named_filename_and_encoding_with(context, filename, "UTF-8")
+
+    # -- SPECIAL CASE: For usage with behave steps.
+    if filename.endswith(".feature"):
+        command_util.ensure_context_attribute_exists(context, "features", [])
+        context.features.append(filename)
+
+
+@given('an empty file named "{filename}"')
+def step_an_empty_file_named_filename(context, filename):
+    """
+    Creates an empty file.
+    """
+    assert not os.path.isabs(filename)
+    command_util.ensure_workdir_exists(context)
+    filename2 = os.path.join(context.workdir, filename)
+    pathutil.create_textfile_with_contents(filename2, "")
+
+
+@when('I run "{command}"')
+@when("I run `{command}`")
+def step_i_run_command(context, command):
+    """
+    Run a command as subprocess, collect its output and returncode.
+    """
+    command_util.ensure_workdir_exists(context)
+    context.command_result = command_shell.run(command, cwd=context.workdir)
+    command_util.workdir_save_coverage_files(context.workdir)
+    if False and DEBUG:
+        print("run_command: {0}".format(command))
+        print("run_command.output {0}".format(context.command_result.output))
+
+
+@when('I successfully run "{command}"')
+@when("I successfully run `{command}`")
+def step_i_successfully_run_command(context, command):
+    step_i_run_command(context, command)
+    step_it_should_pass(context)
+
+
+@then('it should fail with result "{result:int}"')
+def step_it_should_fail_with_result(context, result):
+    assert_that(context.command_result.returncode, equal_to(result))
+    assert_that(result, is_not(equal_to(0)))
+
+
+@then('the command should fail with returncode="{result:int}"')
+def step_it_should_fail_with_returncode(context, result):
+    assert_that(context.command_result.returncode, equal_to(result))
+    assert_that(result, is_not(equal_to(0)))
+
+
+@then('the command returncode is "{result:int}"')
+def step_the_command_returncode_is(context, result):
+    assert_that(context.command_result.returncode, equal_to(result))
+
+
+@then("the command returncode is non-zero")
+def step_the_command_returncode_is_nonzero(context):
+    assert_that(context.command_result.returncode, is_not(equal_to(0)))
+
+
+@then("it should pass")
+def step_it_should_pass(context):
+    assert_that(
+        context.command_result.returncode, equal_to(0), context.command_result.output
+    )
+
+
+@then("it should fail")
+def step_it_should_fail(context):
+    assert_that(
+        context.command_result.returncode,
+        is_not(equal_to(0)),
+        context.command_result.output,
+    )
+
+
+@then("it should pass with")
+def step_it_should_pass_with(context):
+    '''
+    EXAMPLE:
+        ...
+        when I run "behave ..."
+        then it should pass with:
+            """
+            TEXT
+            """
+    '''
+    assert context.text is not None, "ENSURE: multiline text is provided."
+    step_command_output_should_contain(context)
+    assert_that(
+        context.command_result.returncode, equal_to(0), context.command_result.output
+    )
+
+
+@then("it should fail with")
+def step_it_should_fail_with(context):
+    '''
+    EXAMPLE:
+        ...
+        when I run "behave ..."
+        then it should fail with:
+            """
+            TEXT
+            """
+    '''
+    assert context.text is not None, "ENSURE: multiline text is provided."
+    step_command_output_should_contain(context)
+    assert_that(context.command_result.returncode, is_not(equal_to(0)))
+
+
+@then('the command output should contain "{text}"')
+def step_command_output_should_contain_text(context, text):
+    """
+    EXAMPLE:
+        ...
+        Then the command output should contain "TEXT"
+    """
+    expected_text = text
+    if "{__WORKDIR__}" in expected_text or "{__CWD__}" in expected_text:
+        expected_text = textutil.template_substitute(
+            text,
+            __WORKDIR__=posixpath_normpath(context.workdir),
+            __CWD__=posixpath_normpath(os.getcwd()),
+        )
+    actual_output = context.command_result.output
+    with on_assert_failed_print_details(actual_output, expected_text):
+        textutil.assert_normtext_should_contain(actual_output, expected_text)
+
+
+@then('the command output should not contain "{text}"')
+def step_command_output_should_not_contain_text(context, text):
+    """
+    EXAMPLE:
+        ...
+        then the command output should not contain "TEXT"
+    """
+    expected_text = text
+    if "{__WORKDIR__}" in text or "{__CWD__}" in text:
+        expected_text = textutil.template_substitute(
+            text,
+            __WORKDIR__=posixpath_normpath(context.workdir),
+            __CWD__=posixpath_normpath(os.getcwd()),
+        )
+    actual_output = context.command_result.output
+    with on_assert_failed_print_details(actual_output, expected_text):
+        textutil.assert_normtext_should_not_contain(actual_output, expected_text)
+
+
+@then('the command output should contain "{text}" {count:d} times')
+def step_command_output_should_contain_text_multiple_times(context, text, count):
+    """
+    EXAMPLE:
+        ...
+        Then the command output should contain "TEXT" 3 times
+    """
+    assert count >= 0
+    expected_text = text
+    if "{__WORKDIR__}" in expected_text or "{__CWD__}" in expected_text:
+        expected_text = textutil.template_substitute(
+            text,
+            __WORKDIR__=posixpath_normpath(context.workdir),
+            __CWD__=posixpath_normpath(os.getcwd()),
+        )
+    actual_output = context.command_result.output
+    with on_assert_failed_print_details(actual_output, expected_text):
+        textutil.assert_normtext_should_contain_multiple_times(
+            actual_output, expected_text, count
+        )
+
+
+@then('the command output should contain exactly "{text}"')
+def step_command_output_should_contain_exactly_text(context, text):
+    """
+    Verifies that the command output of the last command contains the
+    expected text.
+
+    .. code-block:: gherkin
+
+        When I run "echo Hello"
+        Then the command output should contain "Hello"
+    """
+    expected_text = text
+    if "{__WORKDIR__}" in text or "{__CWD__}" in text:
+        expected_text = textutil.template_substitute(
+            text,
+            __WORKDIR__=posixpath_normpath(context.workdir),
+            __CWD__=posixpath_normpath(os.getcwd()),
+        )
+    actual_output = context.command_result.output
+    textutil.assert_text_should_contain_exactly(actual_output, expected_text)
+
+
+@then('the command output should not contain exactly "{text}"')
+def step_command_output_should_not_contain_exactly_text(context, text):
+    expected_text = text
+    if "{__WORKDIR__}" in text or "{__CWD__}" in text:
+        expected_text = textutil.template_substitute(
+            text,
+            __WORKDIR__=posixpath_normpath(context.workdir),
+            __CWD__=posixpath_normpath(os.getcwd()),
+        )
+    actual_output = context.command_result.output
+    textutil.assert_text_should_not_contain_exactly(actual_output, expected_text)
+
+
+@then("the command output should contain")
+def step_command_output_should_contain(context):
+    '''
+    EXAMPLE:
+        ...
+        when I run "behave ..."
+        then it should pass
+        and  the command output should contain:
+            """
+            TEXT
+            """
+    '''
+    assert context.text is not None, "REQUIRE: multi-line text"
+    step_command_output_should_contain_text(context, context.text)
+
+
+@then("the command output should not contain")
+def step_command_output_should_not_contain(context):
+    '''
+    EXAMPLE:
+        ...
+        when I run "behave ..."
+        then it should pass
+        and  the command output should not contain:
+            """
+            TEXT
+            """
+    '''
+    assert context.text is not None, "REQUIRE: multi-line text"
+    step_command_output_should_not_contain_text(context, context.text.strip())
+
+
+@then("the command output should contain {count:d} times")
+def step_command_output_should_contain_multiple_times(context, count):
+    '''
+    EXAMPLE:
+        ...
+        then it should pass
+        and  the command output should contain 2 times:
+            """
+        when I run "behave ..."
+            TEXT
+            """
+    '''
+    assert context.text is not None, "REQUIRE: multi-line text"
+    step_command_output_should_contain_text_multiple_times(context, context.text, count)
+
+
+@then("the command output should contain exactly")
+def step_command_output_should_contain_exactly_with_multiline_text(context):
+    assert context.text is not None, "REQUIRE: multi-line text"
+    step_command_output_should_contain_exactly_text(context, context.text)
+
+
+@then("the command output should not contain exactly")
+def step_command_output_should_contain_not_exactly_with_multiline_text(context):
+    assert context.text is not None, "REQUIRE: multi-line text"
+    step_command_output_should_not_contain_exactly_text(context, context.text)
+
+
+@step('I remove the directory "{directory}"')
+def step_remove_directory(context, directory):
+    path_ = directory
+    if not os.path.isabs(directory):
+        path_ = os.path.join(context.workdir, os.path.normpath(directory))
+    if os.path.isdir(path_):
+        shutil.rmtree(path_, ignore_errors=True)
+    assert_that(not os.path.isdir(path_))
+
+
+@given('I ensure that the directory "{directory}" exists')
+def step_given_ensure_that_the_directory_exists(context, directory):
+    path_ = directory
+    if not os.path.isabs(directory):
+        path_ = os.path.join(context.workdir, os.path.normpath(directory))
+    if not os.path.isdir(path_):
+        os.makedirs(path_)
+    assert_that(os.path.isdir(path_))
+
+
+@given('I ensure that the directory "{directory}" does not exist')
+def step_given_the_directory_should_not_exist(context, directory):
+    step_remove_directory(context, directory)
+
+
+@given('a directory named "{path}"')
+def step_directory_named_dirname(context, path):
+    assert context.workdir, "REQUIRE: context.workdir"
+    path_ = os.path.join(context.workdir, os.path.normpath(path))
+    if not os.path.exists(path_):
+        os.makedirs(path_)
+    assert os.path.isdir(path_)
+
+
+@then('the directory "{directory}" should exist')
+def step_the_directory_should_exist(context, directory):
+    path_ = directory
+    if not os.path.isabs(directory):
+        path_ = os.path.join(context.workdir, os.path.normpath(directory))
+    assert_that(os.path.isdir(path_))
+
+
+@then('the directory "{directory}" should not exist')
+def step_the_directory_should_not_exist(context, directory):
+    path_ = directory
+    if not os.path.isabs(directory):
+        path_ = os.path.join(context.workdir, os.path.normpath(directory))
+    assert_that(not os.path.isdir(path_))
+
+
+@step('the directory "{directory}" exists')
+def step_directory_exists(context, directory):
+    """
+    Verifies that a directory exists.
+
+    .. code-block:: gherkin
+
+        Given the directory "abc.txt" exists
+         When the directory "abc.txt" exists
+    """
+    step_the_directory_should_exist(context, directory)
+
+
+@step('the directory "{directory}" does not exist')
+def step_directory_named_does_not_exist(context, directory):
+    """
+    Verifies that a directory does not exist.
+
+    .. code-block:: gherkin
+
+        Given the directory "abc/" does not exist
+         When the directory "abc/" does not exist
+    """
+    step_the_directory_should_not_exist(context, directory)
+
+
+@step('a file named "{filename}" exists')
+def step_file_named_filename_exists(context, filename):
+    """
+    Verifies that a file with this filename exists.
+
+    .. code-block:: gherkin
+
+        Given a file named "abc.txt" exists
+         When a file named "abc.txt" exists
+    """
+    step_file_named_filename_should_exist(context, filename)
+
+
+@step('a file named "{filename}" does not exist')
+@step('the file named "{filename}" does not exist')
+def step_file_named_filename_does_not_exist(context, filename):
+    """
+    Verifies that a file with this filename does not exist.
+
+    .. code-block:: gherkin
+
+        Given a file named "abc.txt" does not exist
+         When a file named "abc.txt" does not exist
+    """
+    step_file_named_filename_should_not_exist(context, filename)
+
+
+@then('a file named "{filename}" should exist')
+def step_file_named_filename_should_exist(context, filename):
+    command_util.ensure_workdir_exists(context)
+    filename_ = pathutil.realpath_with_context(filename, context)
+    assert_that(os.path.exists(filename_) and os.path.isfile(filename_))
+
+
+@then('a file named "{filename}" should not exist')
+def step_file_named_filename_should_not_exist(context, filename):
+    command_util.ensure_workdir_exists(context)
+    filename_ = pathutil.realpath_with_context(filename, context)
+    assert_that(not os.path.exists(filename_))
+
+
+@step('I remove the file "{filename}"')
+def step_remove_file(context, filename):
+    path_ = filename
+    if not os.path.isabs(filename):
+        path_ = os.path.join(context.workdir, os.path.normpath(filename))
+    if os.path.exists(path_) and os.path.isfile(path_):
+        os.remove(path_)
+    assert_that(not os.path.isfile(path_))
+
+
+@then('the file "{filename}" should contain "{text}"')
+def step_file_should_contain_text(context, filename, text):
+    expected_text = text
+    if "{__WORKDIR__}" in text or "{__CWD__}" in text:
+        expected_text = textutil.template_substitute(
+            text,
+            __WORKDIR__=posixpath_normpath(context.workdir),
+            __CWD__=posixpath_normpath(os.getcwd()),
+        )
+    file_contents = pathutil.read_file_contents(filename, context=context)
+    file_contents = file_contents.rstrip()
+    if file_contents_normalizer:
+        # -- HACK: Inject TextProcessor as text normalizer
+        file_contents = file_contents_normalizer(file_contents)
+    with on_assert_failed_print_details(file_contents, expected_text):
+        textutil.assert_normtext_should_contain(file_contents, expected_text)
+
+
+@then('the file "{filename}" should not contain "{text}"')
+def step_file_should_not_contain_text(context, filename, text):
+    file_contents = pathutil.read_file_contents(filename, context=context)
+    file_contents = file_contents.rstrip()
+    textutil.assert_normtext_should_not_contain(file_contents, text)
+    # DISABLED: assert_that(file_contents, is_not(contains_string(text)))
+
+
+@then('the file "{filename}" should contain')
+def step_file_should_contain_multiline_text(context, filename):
+    assert context.text is not None, "REQUIRE: multiline text"
+    step_file_should_contain_text(context, filename, context.text)
+
+
+@then('the file "{filename}" should not contain')
+def step_file_should_not_contain_multiline_text(context, filename):
+    assert context.text is not None, "REQUIRE: multiline text"
+    step_file_should_not_contain_text(context, filename, context.text)
+
+
+@step('I set the environment variable "{env_name}" to "{env_value}"')
+def step_I_set_the_environment_variable_to(context, env_name, env_value):
+    if not hasattr(context, "environ"):
+        context.environ = {}
+    context.environ[env_name] = env_value
+    os.environ[env_name] = env_value
+
+
+@step('I remove the environment variable "{env_name}"')
+def step_I_remove_the_environment_variable(context, env_name):
+    if not hasattr(context, "environ"):
+        context.environ = {}
+    context.environ[env_name] = ""
+    os.environ[env_name] = ""
+    del context.environ[env_name]
+    del os.environ[env_name]
diff --git a/tests/acceptance/steps/behave4cmd0/command_util.py b/tests/acceptance/steps/behave4cmd0/command_util.py
new file mode 100644
index 0000000..d0de7dd
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/command_util.py
@@ -0,0 +1,82 @@
+"""
+Provides some command utility functions.
+"""
+
+import os.path
+import sys
+import shutil
+import time
+import tempfile
+
+from fnmatch import fnmatch
+
+from . import pathutil
+from .__setup import TOP, TOPA
+
+WORKDIR = os.path.join(TOP, "__WORKDIR__")
+
+
+def workdir_save_coverage_files(workdir, destdir=None):
+    assert os.path.isdir(workdir)
+    if not destdir:
+        destdir = TOPA
+    if os.path.abspath(workdir) == os.path.abspath(destdir):
+        return  # -- SKIP: Source directory is destination directory (SAME).
+
+    for fname in os.listdir(workdir):
+        if fnmatch(fname, ".coverage.*"):
+            sourcename = os.path.join(workdir, fname)
+            shutil.move(sourcename, destdir)
+
+
+def ensure_context_attribute_exists(context, name, default_value=None):
+    """
+    Ensure a behave resource exists as attribute in the behave context.
+    If this is not the case, the attribute is created by using the default_value.
+    """
+    if not hasattr(context, name):
+        setattr(context, name, default_value)
+
+
+def ensure_workdir_exists(context):
+    """
+    Ensures that the work directory exists.
+    In addition, the location of the workdir is stored as attribute in
+    the context object.
+    """
+    ensure_context_attribute_exists(context, "workdir", None)
+    if not context.workdir:
+        context.workdir = os.path.abspath(WORKDIR)
+    pathutil.ensure_directory_exists(context.workdir)
+
+
+def ensure_workdir_not_exists(context):
+    """Ensures that the work directory does not exist."""
+    ensure_context_attribute_exists(context, "workdir", None)
+    if context.workdir:
+        orig_dirname = real_dirname = context.workdir
+        context.workdir = None
+        if os.path.exists(real_dirname):
+            renamed_dirname = tempfile.mktemp(
+                prefix=os.path.basename(real_dirname),
+                suffix="_DEAD",
+                dir=os.path.dirname(real_dirname) or ".",
+            )
+            os.rename(real_dirname, renamed_dirname)
+            real_dirname = renamed_dirname
+        max_iterations = 2
+        if sys.platform.startswith("win"):
+            max_iterations = 15
+
+        for iteration in range(max_iterations):
+            if not os.path.exists(real_dirname):
+                if iteration > 1:
+                    print("REMOVE-WORKDIR after %s iterations" % (iteration + 1))
+                break
+            shutil.rmtree(real_dirname, ignore_errors=True)
+            time.sleep(0.5)
+        assert not os.path.isdir(real_dirname), "ENSURE not-isa dir: %s" % real_dirname
+        assert not os.path.exists(real_dirname), (
+            "ENSURE dir not-exists: %s" % real_dirname
+        )
+        assert not os.path.isdir(orig_dirname), "ENSURE not-isa dir: %s" % orig_dirname
diff --git a/tests/acceptance/steps/behave4cmd0/failing_steps.py b/tests/acceptance/steps/behave4cmd0/failing_steps.py
new file mode 100644
index 0000000..499f904
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/failing_steps.py
@@ -0,0 +1,41 @@
+"""
+Generic failing steps.
+Often needed in examples.
+
+EXAMPLES:
+
+    Given a step fails
+    When  another step fails
+    Then  a step fails
+
+    Given ...
+    When  ...
+    Then  it should fail because "the person is unknown".
+"""
+
+from behave import step, then
+
+
+@step("{word:w} step fails")
+def step_fails(context, word):
+    """Step that always fails, mostly needed in examples."""
+    assert False, "EXPECT: Failing step"
+
+
+@step('{word:w} step fails with "{message}"')
+def step_fails_with_message(context, word, message):
+    """Step that always fails, mostly needed in examples."""
+    assert False, "FAILED: %s" % message
+
+
+@step("{word:w} step fails with")
+def step_fails_with_text(context, word):
+    """Step that always fails, mostly needed in examples."""
+    assert context.text is not None, "REQUIRE: text"
+    step_fails_with_message(context, word, context.text)
+
+
+@then('it should fail because "{reason}"')
+def then_it_should_fail_because(context, reason):
+    """Self documenting step that indicates why this step should fail."""
+    assert False, "FAILED: %s" % reason
diff --git a/tests/acceptance/steps/behave4cmd0/log/__init__.py b/tests/acceptance/steps/behave4cmd0/log/__init__.py
new file mode 100644
index 0000000..5670c4b
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/log/__init__.py
@@ -0,0 +1,1 @@
+__author__ = "jens"
diff --git a/tests/acceptance/steps/behave4cmd0/log/steps.py b/tests/acceptance/steps/behave4cmd0/log/steps.py
new file mode 100644
index 0000000..c562820
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/log/steps.py
@@ -0,0 +1,323 @@
+"""
+Provides step definitions to perform tests with the Python logging subsystem.
+"""
+
+import logging
+
+from behave import step, then
+from behave.configuration import LogLevel
+from behave4cmd0.command_steps import (
+    step_file_should_contain_multiline_text,
+    step_file_should_not_contain_multiline_text,
+)
+
+
+def make_log_record(category, level, message):
+    if category in ("root", "__ROOT__"):
+        category = None
+    logger = logging.getLogger(category)
+    logger.log(level, message)
+
+
+def make_log_record_output(
+    category, level, message, format=None, datefmt=None, **kwargs
+):
+    """
+    Create the output for a log record, like performed by :mod:`logging` module.
+
+    :param category:    Name of the logger (as string or None).
+    :param level:       Log level (as number).
+    :param message:     Log message to use.
+    :returns: Log record output (as string)
+    """
+    if not category or (category == "__ROOT__"):
+        category = "root"
+    levelname = logging.getLevelName(level)
+    record_data = dict(name=category, levelname=levelname, msg=message)
+    record_data.update(kwargs)
+    record = logging.makeLogRecord(record_data)
+    formatter = logging.Formatter(format, datefmt=datefmt)
+    return formatter.format(record)
+
+
+class LogRecordTable(object):
+    @classmethod
+    def make_output_for_row(cls, row, format=None, datefmt=None, **kwargs):
+        category = row.get("category", None)
+        level = LogLevel.parse_type(row.get("level", "INFO"))
+        message = row.get("message", "__UNDEFINED__")
+        return make_log_record_output(
+            category, level, message, format, datefmt, **kwargs
+        )
+
+    @staticmethod
+    def annotate_with_row_schema(table, row_schema):
+        """
+        Annotate/extend a table of log-records with additional columns from
+        the log-record schema if columns are missing.
+
+        :param table:   Table w/ log-records (as :class:`behave.model.Table`)
+        :param row_schema:  Log-record row schema (as dict).
+        """
+        for column, value in row_schema.items():
+            if column not in table.headings:
+                table.add_column(column, default_value=value)
+
+
+@step("I create log records with")
+def step_I_create_logrecords_with_table(context):
+    """
+    Step definition that creates one more log records by using a table.
+
+    .. code-block: gherkin
+
+        When I create log records with:
+            | category | level | message   |
+            |  foo     | ERROR | Hello Foo |
+            |  foo.bar | WARN  | Hello Foo.Bar |
+
+    Table description
+    ------------------
+
+    | Column   | Type     | Required | Description |
+    | category | string   | yes      | Category (or logger) to use. |
+    | level    | LogLevel | yes      | Log level to use.   |
+    | message  | string   | yes      | Log message to use. |
+
+    .. code-block: python
+
+        import logging
+        from behave.configuration import LogLevel
+        for row in table.rows:
+            logger = logging.getLogger(row.category)
+            level  = LogLevel.parse_type(row.level)
+            logger.log(level, row.message)
+    """
+    assert context.table, "REQUIRE: context.table"
+    context.table.require_columns(["category", "level", "message"])
+    for row in context.table.rows:
+        category = row["category"]
+        if category == "__ROOT__":
+            category = None
+        level = LogLevel.parse_type(row["level"])
+        message = row["message"]
+        make_log_record(category, level, message)
+
+
+@step("I create a log record with")
+def step_I_create_logrecord_with_table(context):
+    """
+    Create an log record by using a table to provide the parts.
+
+    .. seealso: :func:`step_I_create_logrecords_with_table()`
+    """
+    assert context.table, "REQUIRE: context.table"
+    assert len(context.table.rows) == 1, "REQUIRE: table.row.size == 1"
+    step_I_create_logrecords_with_table(context)
+
+
+@step("I define the log record schema")
+def step_I_define_logrecord_schema_with_table(context):
+    assert context.table, "REQUIRE: context.table"
+    context.table.require_columns(["category", "level", "message"])
+    assert len(context.table.rows) == 1, "REQUIRE: context.table.rows.size(%s) == 1" % (
+        len(context.table.rows)
+    )
+
+    row = context.table.rows[0]
+    row_schema = {
+        "category": row["category"],
+        "level": row["level"],
+        "message": row["message"],
+    }
+    context.log_record_row_schema = row_schema
+
+
+@then("the command output should contain the following log records")
+def step_command_output_should_contain_log_records(context):
+    """
+    Verifies that the command output contains the specified log records
+    (in any order).
+
+    .. code-block: gherkin
+
+        Then the command output should contain the following log records:
+            | category | level   | message |
+            | bar      | CURRENT | xxx     |
+    """
+    assert context.table, "REQUIRE: context.table"
+    context.table.require_columns(["category", "level", "message"])
+    format = getattr(context, "log_record_format", context.config.logging_format)
+    for row in context.table.rows:
+        output = LogRecordTable.make_output_for_row(row, format)
+        context.execute_steps(
+            '''
+            Then the command output should contain:
+                """
+                {expected_output}
+                """
+            '''.format(
+                expected_output=output
+            )
+        )
+
+
+@then("the command output should not contain the following log records")
+def step_command_output_should_not_contain_log_records(context):
+    """
+    Verifies that the command output contains the specified log records
+    (in any order).
+
+    .. code-block: gherkin
+
+        Then the command output should contain the following log records:
+            | category | level   | message |
+            | bar      | CURRENT | xxx     |
+    """
+    assert context.table, "REQUIRE: context.table"
+    context.table.require_columns(["category", "level", "message"])
+    format = getattr(context, "log_record_format", context.config.logging_format)
+    for row in context.table.rows:
+        output = LogRecordTable.make_output_for_row(row, format)
+        context.execute_steps(
+            '''
+            Then the command output should not contain:
+                """
+                {expected_output}
+                """
+            '''.format(
+                expected_output=output
+            )
+        )
+
+
+@then("the command output should contain the following log record")
+def step_command_output_should_contain_log_record(context):
+    assert context.table, "REQUIRE: context.table"
+    assert len(context.table.rows) == 1, "REQUIRE: table.row.size == 1"
+    step_command_output_should_contain_log_records(context)
+
+
+@then("the command output should not contain the following log record")
+def step_command_output_should_not_contain_log_record(context):
+    assert context.table, "REQUIRE: context.table"
+    assert len(context.table.rows) == 1, "REQUIRE: table.row.size == 1"
+    step_command_output_should_not_contain_log_records(context)
+
+
+@then("the command output should contain log records from categories")
+def step_command_output_should_contain_log_records_from_categories(context):
+    """
+    Verifies that the command output contains the specified log records
+    (in any order).
+
+    .. code-block: gherkin
+
+        Given I define a log record schema:
+            | category | level | message |
+            | root     | ERROR | __LOG_MESSAGE__ |
+        Then the command output should contain log records from categories:
+            | category |
+            | bar      |
+    """
+    assert context.table, "REQUIRE: context.table"
+    context.table.require_column("category")
+    record_schema = context.log_record_row_schema
+    LogRecordTable.annotate_with_row_schema(context.table, record_schema)
+    step_command_output_should_contain_log_records(context)
+    context.table.remove_columns(["level", "message"])
+
+
+@then("the command output should not contain log records from categories")
+def step_command_output_should_not_contain_log_records_from_categories(context):
+    """
+    Verifies that the command output contains not log records from
+    the provided log categories (in any order).
+
+    .. code-block: gherkin
+
+        Given I define the log record schema:
+            | category | level | message |
+            | root     | ERROR | __LOG_MESSAGE__ |
+        Then the command output should not contain log records from categories:
+            | category |
+            | bar      |
+    """
+    assert context.table, "REQUIRE: context.table"
+    context.table.require_column("category")
+    record_schema = context.log_record_row_schema
+    LogRecordTable.annotate_with_row_schema(context.table, record_schema)
+    step_command_output_should_not_contain_log_records(context)
+    context.table.remove_columns(["level", "message"])
+
+
+@then('the file "{filename}" should contain the log records')
+def step_file_should_contain_log_records(context, filename):
+    """
+    Verifies that the command output contains the specified log records
+    (in any order).
+
+    .. code-block: gherkin
+
+        Then the file "xxx.log" should contain the log records:
+            | category | level   | message |
+            | bar      | CURRENT | xxx     |
+    """
+    assert context.table, "REQUIRE: context.table"
+    context.table.require_columns(["category", "level", "message"])
+    format = getattr(context, "log_record_format", context.config.logging_format)
+    for row in context.table.rows:
+        output = LogRecordTable.make_output_for_row(row, format)
+        context.text = output
+        step_file_should_contain_multiline_text(context, filename)
+
+
+@then('the file "{filename}" should not contain the log records')
+def step_file_should_not_contain_log_records(context, filename):
+    """
+    Verifies that the command output contains the specified log records
+    (in any order).
+
+    .. code-block: gherkin
+
+        Then the file "xxx.log" should not contain the log records:
+            | category | level   | message |
+            | bar      | CURRENT | xxx     |
+    """
+    assert context.table, "REQUIRE: context.table"
+    context.table.require_columns(["category", "level", "message"])
+    format = getattr(context, "log_record_format", context.config.logging_format)
+    for row in context.table.rows:
+        output = LogRecordTable.make_output_for_row(row, format)
+        context.text = output
+        step_file_should_not_contain_multiline_text(context, filename)
+
+
+@step('I use "{log_record_format}" as log record format')
+def step_use_log_record_format_text(context, log_record_format):
+    context.log_record_format = log_record_format
+
+
+@step("I use the log record configuration")
+def step_use_log_record_configuration(context):
+    """
+    Define log record configuration parameters.
+
+    .. code-block: gherkin
+
+        Given I use the log record configuration:
+            | property | value |
+            | format   |       |
+            | datefmt  |       |
+    """
+    assert context.table, "REQUIRE: context.table"
+    context.table.require_columns(["property", "value"])
+    for row in context.table.rows:
+        property_name = row["property"]
+        value = row["value"]
+        if property_name == "format":
+            context.log_record_format = value
+        elif property_name == "datefmt":
+            context.log_record_datefmt = value
+        else:
+            raise KeyError("Unknown property=%s" % property_name)
diff --git a/tests/acceptance/steps/behave4cmd0/note_steps.py b/tests/acceptance/steps/behave4cmd0/note_steps.py
new file mode 100644
index 0000000..c30d9f5
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/note_steps.py
@@ -0,0 +1,23 @@
+"""
+Step definitions for providing notes/hints.
+The note steps explain what was important in the last few steps of
+this scenario (for a test reader).
+"""
+
+from behave import step
+
+
+@step('note that "{remark}"')
+def step_note_that(context, remark):
+    """
+    Used as generic step that provides an additional remark/hint
+    and enhance the readability/understanding without performing any check.
+
+    .. code-block:: gherkin
+
+        Given that today is "April 1st"
+          But note that "April 1st is Fools day (and beware)"
+    """
+    log = getattr(context, "log", None)
+    if log:
+        log.info("NOTE: %s;" % remark)
diff --git a/tests/acceptance/steps/behave4cmd0/passing_steps.py b/tests/acceptance/steps/behave4cmd0/passing_steps.py
new file mode 100644
index 0000000..668e27e
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/passing_steps.py
@@ -0,0 +1,32 @@
+"""
+Passing steps.
+Often needed in examples.
+
+EXAMPLES:
+
+    Given a step passes
+    When  another step passes
+    Then  a step passes
+
+    Given ...
+    When  ...
+    Then  it should pass because "the answer is correct".
+"""
+
+from behave import step, then
+
+
+@step("{word:w} step passes")
+def step_passes(context, word):
+    """
+    Step that always fails, mostly needed in examples.
+    """
+    pass
+
+
+@then('it should pass because "{reason}"')
+def then_it_should_pass_because(context, reason):
+    """
+    Self documenting step that indicates some reason.
+    """
+    pass
diff --git a/tests/acceptance/steps/behave4cmd0/pathutil.py b/tests/acceptance/steps/behave4cmd0/pathutil.py
new file mode 100644
index 0000000..03e31ea
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/pathutil.py
@@ -0,0 +1,118 @@
+"""
+Provides some command utility functions.
+"""
+
+import os.path
+import sys
+import codecs
+
+
+def realpath_with_context(path, context):
+    """
+    Convert a path into its realpath:
+
+      * For relative path: use :attr:`context.workdir` as root directory
+      * For absolute path: Pass-through without any changes.
+
+    :param path: Filepath to convert (as string).
+    :param context: Behave context object (with :attr:`context.workdir`)
+    :return: Converted path.
+    """
+    if not os.path.isabs(path):
+        assert context.workdir
+        path = os.path.join(context.workdir, os.path.normpath(path))
+    return path
+
+
+def posixpath_normpath(pathname):
+    """
+    Convert path into POSIX path:
+
+      * Normalize path
+      * Replace backslash with slash
+
+    :param pathname: Pathname (as string)
+    :return: Normalized POSIX path.
+    """
+    backslash = "\\"
+    pathname2 = os.path.normpath(pathname) or "."
+    if backslash in pathname2:
+        pathname2 = pathname2.replace(backslash, "/")
+    return pathname2
+
+
+def ensure_makedirs(directory, max_iterations=3):
+    # -- SPORADIC-ERRORS: WindowsError: [Error 5] Access denied: '...'
+    iteration = 0
+    exception_text = None
+    for iteration in range(max_iterations):
+        try:
+            os.makedirs(directory)
+        except OSError as e:
+            if iteration >= max_iterations:  # XXX-BAD: Never occurs
+                raise
+            else:
+                exception_text = "%s:%s" % (e.__class__.__name__, e)
+
+        if os.path.isdir(directory):
+            return
+
+    assert os.path.isdir(
+        directory
+    ), "FAILED: ensure_makedirs(%r) (after %s iterations):\n%s" % (
+        directory,
+        max_iterations,
+        exception_text,
+    )
+
+
+def read_file_contents(filename, context=None, encoding=None):
+    filename_ = realpath_with_context(filename, context)
+    assert os.path.exists(filename_)
+    with open(filename_, "r") as file_:
+        file_contents = file_.read()
+    return file_contents
+
+
+def create_textfile_with_contents(filename, contents, encoding="utf-8"):
+    """
+    Creates a textual file with the provided contents in the workdir.
+    Overwrites an existing file.
+    """
+    ensure_directory_exists(os.path.dirname(filename))
+    if os.path.exists(filename):
+        os.remove(filename)
+    outstream = codecs.open(filename, "w", encoding)
+    outstream.write(contents)
+    if contents and not contents.endswith("\n"):
+        outstream.write("\n")
+    outstream.flush()
+    outstream.close()
+    assert os.path.exists(filename), "ENSURE file exists: %s" % filename
+
+
+def ensure_file_exists(filename, context=None):
+    real_filename = filename
+    if context:
+        real_filename = realpath_with_context(filename, context)
+    if not os.path.exists(real_filename):
+        create_textfile_with_contents(real_filename, "")
+    assert os.path.exists(real_filename), "ENSURE file exists: %s" % filename
+
+
+def ensure_directory_exists(dirname, context=None):
+    """
+    Ensures that a directory exits.
+    If it does not exist, it is automatically created.
+    """
+    real_dirname = dirname
+    if context:
+        real_dirname = realpath_with_context(dirname, context)
+    if not os.path.exists(real_dirname):
+        mas_iterations = 2
+        if sys.platform.startswith("win"):
+            mas_iterations = 10
+        ensure_makedirs(real_dirname, mas_iterations)
+
+    assert os.path.exists(real_dirname), "ENSURE dir exists: %s" % dirname
+    assert os.path.isdir(real_dirname), "ENSURE is directory: %s" % dirname
diff --git a/tests/acceptance/steps/behave4cmd0/setup_command_shell.py b/tests/acceptance/steps/behave4cmd0/setup_command_shell.py
new file mode 100755
index 0000000..129736a
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/setup_command_shell.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python
+"""
+Support functionality to simplify the setup for behave tests.
+
+.. sourcecode:: python
+
+    # -- FILE: features/environment.py
+    from behave4cmd0.setup_command_shell import setup_command_shell_processors4behave
+
+    def before_all(context):
+        setup_command_shell_processors4behave()
+"""
+
+from .command_shell import Command
+from .command_shell_proc import BehaveWinCommandOutputProcessor
+
+
+def setup_command_shell_processors4behave():
+    Command.POSTPROCESSOR_MAP["behave"] = []
+    for processor_class in [BehaveWinCommandOutputProcessor]:
+        if processor_class.enabled:
+            processor = processor_class()
+            Command.POSTPROCESSOR_MAP["behave"].append(processor)
diff --git a/tests/acceptance/steps/behave4cmd0/textutil.py b/tests/acceptance/steps/behave4cmd0/textutil.py
new file mode 100644
index 0000000..91a5064
--- /dev/null
+++ b/tests/acceptance/steps/behave4cmd0/textutil.py
@@ -0,0 +1,150 @@
+"""
+Provides some command utility functions.
+"""
+
+import codecs
+
+from hamcrest import assert_that, is_not, equal_to, contains_string
+from hamcrest.core.helpers.hasmethod import hasmethod
+from hamcrest.library.text.substringmatcher import SubstringMatcher
+
+DEBUG = False
+
+
+def template_substitute(text, **kwargs):
+    """
+    Replace placeholders in text by using the data mapping.
+    Other placeholders that is not represented by data is left untouched.
+
+    :param text:   Text to search and replace placeholders.
+    :param data:   Data mapping/dict for placeholder key and values.
+    :return: Potentially modified text with replaced placeholders.
+    """
+    for name, value in kwargs.items():
+        placeholder_pattern = "{%s}" % name
+        if placeholder_pattern in text:
+            text = text.replace(placeholder_pattern, value)
+    return text
+
+
+def text_remove_empty_lines(text):
+    """
+    Whitespace normalization:
+
+      - Strip empty lines
+      - Strip trailing whitespace
+    """
+    lines = [line.rstrip() for line in text.splitlines() if line.strip()]
+    return "\n".join(lines)
+
+
+def text_normalize(text):
+    """
+    Whitespace normalization:
+
+      - Strip empty lines
+      - Strip leading whitespace  in a line
+      - Strip trailing whitespace in a line
+      - Normalize line endings
+    """
+    if isinstance(text, bytes):
+        text = codecs.decode(text)
+    lines = [line.strip() for line in text.splitlines() if line.strip()]
+    return "\n".join(lines)
+
+
+class StringContainsMultipleTimes(SubstringMatcher):
+    def __init__(self, substring, expected_count):
+        super(StringContainsMultipleTimes, self).__init__(substring)
+        self.expected_count = expected_count
+        self.actual_count = 0
+
+    def describe_to(self, description):
+        description.append_text("a string ").append_text(
+            self.relationship()
+        ).append_text(" ").append_description_of(self.substring).append_text(
+            " "
+        ).append_description_of(
+            self.expected_count
+        ).append_text(
+            " times instead of "
+        ).append_description_of(
+            self.actual_count
+        )
+
+    def _matches(self, item):
+        if not hasmethod(item, "count"):
+            return False
+        self.actual_count = item.count(self.substring)
+        return self.actual_count == self.expected_count
+
+    def relationship(self):
+        return "containing"
+
+
+def contains_substring_multiple_times(substring, expected_count):
+    return StringContainsMultipleTimes(substring, expected_count)
+
+
+def assert_text_should_equal(actual_text, expected_text):
+    assert_that(actual_text, equal_to(expected_text))
+
+
+def assert_text_should_not_equal(actual_text, expected_text):
+    assert_that(actual_text, is_not(equal_to(expected_text)))
+
+
+def assert_text_should_contain_exactly(text, expected_part):
+    assert_that(text, contains_string(expected_part))
+
+
+def assert_text_should_not_contain_exactly(text, expected_part):
+    assert_that(text, is_not(contains_string(expected_part)))
+
+
+def assert_text_should_contain(text, expected_part):
+    assert_that(text, contains_string(expected_part))
+
+
+def assert_normtext_should_contain_multiple_times(text, expected_text, count):
+    assert_that(text, contains_substring_multiple_times(expected_text, count))
+
+
+def assert_text_should_not_contain(text, unexpected_part):
+    assert_that(text, is_not(contains_string(unexpected_part)))
+
+
+def assert_normtext_should_equal(actual_text, expected_text):
+    expected_text2 = text_normalize(expected_text.strip())
+    actual_text2 = text_normalize(actual_text.strip())
+    assert_that(actual_text2, equal_to(expected_text2))
+
+
+def assert_normtext_should_not_equal(actual_text, expected_text):
+    expected_text2 = text_normalize(expected_text.strip())
+    actual_text2 = text_normalize(actual_text.strip())
+    assert_that(actual_text2, is_not(equal_to(expected_text2)))
+
+
+def assert_normtext_should_contain(text, expected_part):
+    expected_part2 = text_normalize(expected_part)
+    actual_text = text_normalize(text.strip())
+    if DEBUG:
+        print("expected:\n{0}".format(expected_part2))
+        print("actual:\n{0}".format(actual_text))
+    assert_text_should_contain(actual_text, expected_part2)
+
+
+def assert_normtext_should_not_contain(text, unexpected_part):
+    unexpected_part2 = text_normalize(unexpected_part)
+    actual_text = text_normalize(text.strip())
+    if DEBUG:
+        print("expected:\n{0}".format(unexpected_part2))
+        print("actual:\n{0}".format(actual_text))
+    assert_text_should_not_contain(actual_text, unexpected_part2)
+
+
+if __name__ == "__main__":
+    import doctest
+
+    doctest.testmod()
diff --git a/tests/acceptance/steps/use_steplib_behave4cmd.py b/tests/acceptance/steps/use_steplib_behave4cmd.py
new file mode 100644
index 0000000..7097c4a
--- /dev/null
+++ b/tests/acceptance/steps/use_steplib_behave4cmd.py
@@ -0,0 +1,8 @@
+"""
+Use behave4cmd0 step library (predecessor of behave4cmd).
+"""
+
+from behave4cmd0 import __all_steps__  # noqa
+from behave4cmd0 import passing_steps  # noqa
+from behave4cmd0 import failing_steps  # noqa
+from behave4cmd0 import note_steps  # noqa