Repository: behave/behave

Issue:
JUnit XML output is not well-formed
The JUnit XML output logic just puts all log output into a CDATA section without handling characters that are illegal in XML (like control characters), This will for example happen if the log output happess to be the log of a pexpect session controlling an interactive application with keyboard shoutcuts.

Discussion:
stale[bot]: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

clembou: For the record I think this is still needed - we are having to monkey patch the formatter with a change mostly taken from this PR to keep Jenkins happy.
ncoghlan: The code here looks reasonable and has associated tests and release notes - any chance this could be picked for the next release @jenisys @jeamland ?
stale[bot]: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

ncoghlan: @bittner Is there anything else we can do to nudge this for the next release? I've been using this code to try to intercept data before it gets to behave, but that's a game of whack-a-mole that involves a lot of guessing every time Jenkins throws its utterly useless "failed to parse XML" traceback.

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 2bed57e..f3b15a7 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -52,6 +52,7 @@ FIXED:
 
 * FIXED: Some tests related to python3.9
 * FIXED: active-tag logic if multiple tags with same category exists.
+* issue #1061: Scenario should inherit Rule tags (submitted by: testgitdl)
 * pull  #967: Update __init__.py in behave import to fix pylint (provided by: dsayling)
 * issue #955: setup: Remove attribute 'use_2to3' (submitted by: krisgesling)
 * issue #772: ScenarioOutline.Examples without table (submitted by: The-QA-Geek)
diff --git a/behave/i18n.py b/behave/i18n.py
index 708e715..3aa8a26 100644
--- a/behave/i18n.py
+++ b/behave/i18n.py
@@ -299,7 +299,13 @@ languages = \
                      'Tha the ',
                      'Þa þe ',
                      'Ða ðe '],
-            'when': ['* ', 'Tha ', 'Þa ', 'Ða ']},
+            'when': ['* ',
+                     'Bæþsealf ',
+                     'Bæþsealfa ',
+                     'Bæþsealfe ',
+                     'Ciricæw ',
+                     'Ciricæwe ',
+                     'Ciricæwa ']},
  'en-pirate': {'and': ['* ', 'Aye '],
                'background': ['Yo-ho-ho'],
                'but': ['* ', 'Avast! '],
@@ -735,7 +741,7 @@ languages = \
         'scenario_outline': ['परिदृश्य रूपरेखा'],
         'then': ['* ', 'मग ', 'तेव्हा '],
         'when': ['* ', 'जेव्हा ']},
- 'ne': {'and': ['* ', 'र ', 'अनी '],
+ 'ne': {'and': ['* ', 'र ', 'अनि '],
         'background': ['पृष्ठभूमी'],
         'but': ['* ', 'तर '],
         'examples': ['उदाहरण', 'उदाहरणहरु'],
@@ -1036,7 +1042,7 @@ languages = \
         'but': ['* ', 'Лекин ', 'Бирок ', 'Аммо '],
         'examples': ['Мисоллар'],
         'feature': ['Функционал'],
-        'given': ['* ', 'Агар '],
+        'given': ['* ', 'Belgilangan '],
         'name': 'Uzbek',
         'native': 'Узбекча',
         'rule': ['Rule'],
@@ -1065,7 +1071,7 @@ languages = \
            'given': ['* ', '假如', '假设', '假定'],
            'name': 'Chinese simplified',
            'native': '简体中文',
-           'rule': ['Rule'],
+           'rule': ['Rule', '规则'],
            'scenario': ['场景', '剧本'],
            'scenario_outline': ['场景大纲', '剧本大纲'],
            'then': ['* ', '那么'],
diff --git a/behave/model.py b/behave/model.py
index f1ec725..73f1a61 100644
--- a/behave/model.py
+++ b/behave/model.py
@@ -300,21 +300,24 @@ class ScenarioContainer(TagAndStatusStatement, Replayable):
         return answer
 
     def should_run_with_tags(self, tag_expression):
-        """Determines if this feature should run when the tag expression is used.
-        A feature should run if:
-          * it should run according to its tags
-          * any of its scenarios should run according to its tags
+        """Determines if this feature or rule should run when the tag expression is used.
+
+        A feature (or rule) should run if:
+
+        * it should run according to its tags
+        * any of its scenarios should run according to its tags
 
         :param tag_expression:  Runner/config environment tags to use.
         :return: True, if feature should run. False, otherwise (skip it).
         """
-        run_feature = tag_expression.check(self.tags)
-        if not run_feature:
-            for scenario in self:
-                if scenario.should_run_with_tags(tag_expression):
-                    run_feature = True
-                    break
-        return run_feature
+        if tag_expression.check(self.effective_tags):
+            return True
+
+        for run_item in self.run_items:
+            if run_item.should_run_with_tags(tag_expression):
+                return True
+        # -- OTHERWISE: Should NOT run
+        return False
 
     def mark_skipped(self):
         """Marks this feature (and all its scenarios and steps) as skipped.
@@ -1040,21 +1043,8 @@ class Scenario(TagAndStatusStatement, Replayable):
             scenario_duration += step.duration
         return scenario_duration
 
-    @property
-    def effective_tags(self):
-        """
-        Effective tags for this scenario:
-          * own tags
-          * tags inherited from its feature
-        """
-        tags = self.tags
-        if self.feature:
-            tags = self.feature.tags + self.tags
-        return tags
-
     def should_run(self, config=None):
-        """
-        Determines if this Scenario (or ScenarioOutline) should run.
+        """Determines if this Scenario (or ScenarioOutline) should run.
         Implements the run decision logic for a scenario.
         The decision depends on:
 
@@ -1071,14 +1061,6 @@ class Scenario(TagAndStatusStatement, Replayable):
                       self.should_run_with_name_select(config))
         return answer
 
-    def should_run_with_tags(self, tag_expression):
-        """Checks if scenario should run when the tag expression is used.
-
-        :param tag_expression:  Runner/config environment tags to use.
-        :return: True, if scenario should run. False, otherwise (skip it).
-        """
-        return tag_expression.check(self.effective_tags)
-
     def should_run_with_name_select(self, config):
         """Checks if scenario should run when it is selected by name.
 
@@ -1308,6 +1290,10 @@ class ScenarioOutlineBuilder(object):
         return self.annotation_schema.format(name=scenario_name,
                                              examples=example_data, row=row_data)
 
+    @staticmethod
+    def is_parametrized_tag(tag):
+        return "<" in tag and ">" in tag
+
     @classmethod
     def make_row_tags(cls, outline_tags, row, params=None):
         if not outline_tags:
@@ -1315,9 +1301,9 @@ class ScenarioOutlineBuilder(object):
 
         tags = []
         for tag in outline_tags:
-            if "<" in tag and ">" in tag:
+            if cls.is_parametrized_tag(tag):
                 tag = cls.render_template(tag, row, params)
-            if "<" in tag or ">" in tag:
+            if cls.is_parametrized_tag(tag):
                 # -- OOPS: Unknown placeholder, drop tag.
                 continue
             new_tag = Tag.make_name(tag, unescape=True)
@@ -1494,6 +1480,26 @@ class ScenarioOutline(Scenario):
         self._scenarios = builder.build_scenarios(self)
         return self._scenarios
 
+    @property
+    def effective_tags(self):
+        """Compute effective tags of this ScenarioOutline/ScenarioTemplate.
+        This is includes the own tags and the inherited tags from the parents.
+        Note that parametrized tags are filter out.
+
+        :return: Set of effective tags
+
+        .. note:: Overrides generic implementation in base class.
+        """
+        # -- SPECIAL CASE: ScenarioOutline/ScenarioTemplate
+        # Filter out "abstract tags" (parametrized tags) used in this template.
+        tags = set([tag for tag in self.tags
+                    if not ScenarioOutlineBuilder.is_parametrized_tag(tag)])
+        if self.parent:
+            # -- INHERIT TAGS: From parent(s), recursively
+            inherited_tags = self.parent.effective_tags
+            tags.update(inherited_tags)
+        return tags
+
     def __repr__(self):
         return '<ScenarioOutline "%s">' % self.name
 
@@ -2207,13 +2213,19 @@ class Text(six.text_type):
         """
         if self == expected:
             return True
+
+        # -- DETAILED HINTS: Why comparison failed.
         diff = []
         for line in difflib.unified_diff(self.splitlines(),
                                          expected.splitlines()):
             diff.append(line)
-        # strip unnecessary diff prefix
+        if not diff:
+            # -- MAYBE: Only differences in line-endings => GRACEFULLY ACCEPT as OK.
+            return True
+
+        # -- HINT: Strip unnecessary diff prefix
         diff = ["Text does not match:"] + diff[3:]
-        raise AssertionError("\n".join(diff))
+        raise AssertionError("\n".join(diff) +";")
 
 
 # -----------------------------------------------------------------------------
diff --git a/behave/model_core.py b/behave/model_core.py
index 515ab6b..d4a9d4f 100644
--- a/behave/model_core.py
+++ b/behave/model_core.py
@@ -358,7 +358,11 @@ class TagStatement(BasicStatement):
 
 
 class TagAndStatusStatement(BasicStatement):
-    # final_status = ('passed', 'failed', 'skipped')
+    """Base class for statements with:
+
+    * tags (as: taggable statement)
+    * status (has a result after a test run)
+    """
     final_status = (Status.passed, Status.failed, Status.skipped)
 
     def __init__(self, filename, line, keyword, name, tags, parent=None):
@@ -369,13 +373,29 @@ class TagAndStatusStatement(BasicStatement):
         self.skip_reason = None
         self._cached_status = Status.untested
 
+    @property
+    def effective_tags(self):
+        """Compute effective tags of this entity.
+        This is includes the own tags and the inherited tags from the parents.
+
+        :return: Set of effective tags
+
+        .. versionadded:: 1.2.7
+        """
+        tags = set(self.tags)
+        if self.parent:
+            # -- INHERIT TAGS: From parent(s), recursively
+            inherited_tags = self.parent.effective_tags
+            tags.update(inherited_tags)
+        return tags
+
     def should_run_with_tags(self, tag_expression):
         """Determines if statement should run when the tag expression is used.
 
         :param tag_expression:  Runner/config environment tags to use.
-        :return: True, if examples should run. False, otherwise (skip it).
+        :return: True, if this statement should run. False, otherwise (skip it).
         """
-        return tag_expression.check(self.tags)
+        return tag_expression.check(self.effective_tags)
 
     @property
     def status(self):
diff --git a/behave/reporter/junit.py b/behave/reporter/junit.py
index 9018399..ab4740d 100644
--- a/behave/reporter/junit.py
+++ b/behave/reporter/junit.py
@@ -72,6 +72,8 @@ Best sources are:
 from __future__ import absolute_import
 import os.path
 import codecs
+import re
+import sys
 from xml.etree import ElementTree
 from datetime import datetime
 from behave.reporter.base import Reporter
@@ -87,6 +89,7 @@ if six.PY2:
     import traceback2 as traceback
 else:
     import traceback
+    unichr = chr
 
 
 def CDATA(text=None):   # pylint: disable=invalid-name
@@ -95,6 +98,42 @@ def CDATA(text=None):   # pylint: disable=invalid-name
     element.text = ansi_escapes.strip_escapes(text)
     return element
 
+def _compile_invalid_re():
+    # http://stackoverflow.com/questions/1707890/fast-way-to-filter-illegal-xml-unicode-chars-in-python
+    illegal_unichrs = [
+        (0x00, 0x08), (0x0B, 0x1F), (0x7F, 0x84), (0x86, 0x9F),
+        (0xD800, 0xDFFF), (0xFDD0, 0xFDDF), (0xFFFE, 0xFFFF),
+        (0x1FFFE, 0x1FFFF), (0x2FFFE, 0x2FFFF), (0x3FFFE, 0x3FFFF),
+        (0x4FFFE, 0x4FFFF), (0x5FFFE, 0x5FFFF), (0x6FFFE, 0x6FFFF),
+        (0x7FFFE, 0x7FFFF), (0x8FFFE, 0x8FFFF), (0x9FFFE, 0x9FFFF),
+        (0xAFFFE, 0xAFFFF), (0xBFFFE, 0xBFFFF), (0xCFFFE, 0xCFFFF),
+        (0xDFFFE, 0xDFFFF), (0xEFFFE, 0xEFFFF), (0xFFFFE, 0xFFFFF),
+        (0x10FFFE, 0x10FFFF),
+    ]
+
+    illegal_ranges = [
+        "%s-%s" % (unichr(low), unichr(high))
+        for (low, high) in illegal_unichrs
+        if low < sys.maxunicode]
+
+    return re.compile(u'[%s]' % u''.join(illegal_ranges))
+
+
+_invalid_re = _compile_invalid_re()
+
+def _escape_invalid_xml_chars(text):
+    # replace invalid chars with Unicode hex
+    return _invalid_re.subn(lambda c: u'U+{0:0=4}'.format(ord(c.group())), text)[0]
+
+
+def escape_CDATA(text):  # pylint: disable=invalid-name
+    # -- issue #510 escape text in CDATA
+    # CDATA cannot contain the string "]]>" anywhere in the XML document.
+    if not text:
+        return text
+    text = text.replace(u']]>', u']]&gt;')
+    return _escape_invalid_xml_chars(text)
+
 
 class ElementTreeWithCDATA(ElementTree.ElementTree):
     # pylint: disable=redefined-builtin, no-member
@@ -114,7 +153,7 @@ if hasattr(ElementTree, '_serialize'):
                         orig=ElementTree._serialize_xml):
         if elem.tag == '![CDATA[':
             write("\n<%s%s]]>\n" % \
-                  (elem.tag, elem.text.encode(encoding, "xmlcharrefreplace")))
+                  (elem.tag, escape_CDATA(elem.text).encode(encoding, "xmlcharrefreplace")))
             return
         return orig(write, elem, encoding, qnames, namespaces)
 
@@ -123,7 +162,7 @@ if hasattr(ElementTree, '_serialize'):
                         orig=ElementTree._serialize_xml):
         if elem.tag == '![CDATA[':
             write("\n<{tag}{text}]]>\n".format(
-                tag=elem.tag, text=elem.text))
+                tag=elem.tag, text=escape_CDATA(elem.text)))
             return
         if short_empty_elements:
             # python >=3.3
@@ -397,7 +436,7 @@ class JUnitReporter(Reporter):
                 step_text = self.describe_step(step).rstrip()
                 text = u"\nFailing step: %s\nLocation: %s\n" % \
                        (step_text, step.location)
-                message = _text(step.exception)
+                message = _text(step.exception).strip()
                 failure.set(u'type', step.exception.__class__.__name__)
                 failure.set(u'message', message)
                 text += _text(step.error_message)
@@ -407,7 +446,7 @@ class JUnitReporter(Reporter):
                 if scenario.exception:
                     failure_type = scenario.exception.__class__.__name__
                 failure.set(u'type', failure_type)
-                failure.set(u'message', scenario.error_message or "")
+                failure.set(u'message', scenario.error_message.strip() or "")
                 traceback_lines = traceback.format_tb(scenario.exc_traceback)
                 traceback_lines.insert(0, u"Traceback:\n")
                 text = _text(u"".join(traceback_lines))
@@ -420,9 +459,10 @@ class JUnitReporter(Reporter):
             if step:
                 # -- UNDEFINED-STEP:
                 report.counts_failed += 1
+                message = u"Undefined Step: %s" % step.name.strip()
                 failure = ElementTree.Element(u"failure")
                 failure.set(u"type", u"undefined")
-                failure.set(u"message", (u"Undefined Step: %s" % step.name))
+                failure.set(u"message", message)
                 case.append(failure)
             else:
                 skip = ElementTree.Element(u'skipped')
diff --git a/behave4cmd0/command_steps.py b/behave4cmd0/command_steps.py
index 19c3e80..f9bc980 100644
--- a/behave4cmd0/command_steps.py
+++ b/behave4cmd0/command_steps.py
@@ -11,6 +11,8 @@ TODO:
 """
 
 from __future__ import absolute_import, print_function
+
+import codecs
 import contextlib
 import difflib
 import os
@@ -37,6 +39,17 @@ if BehaveWinCommandOutputProcessor.enabled:
 # -----------------------------------------------------------------------------
 # UTILITIES:
 # -----------------------------------------------------------------------------
+def print_differences(actual, expected):
+    # diff = difflib.unified_diff(expected.splitlines(), actual.splitlines(),
+    #                            "expected", "actual")
+    diff = difflib.ndiff(expected.splitlines(), actual.splitlines())
+    diff_text = u"\n".join(diff)
+    print(u"DIFF (+ ACTUAL, - EXPECTED):\n{0}\n".format(diff_text))
+    if DEBUG:
+        print(u"expected:\n{0}\n".format(expected))
+        print(u"actual:\n{0}\n".format(actual))
+
+
 @contextlib.contextmanager
 def on_assert_failed_print_details(actual, expected):
     """
@@ -50,14 +63,7 @@ def on_assert_failed_print_details(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 = u"\n".join(diff)
-        print(u"DIFF (+ ACTUAL, - EXPECTED):\n{0}\n".format(diff_text))
-        if DEBUG:
-            print(u"expected:\n{0}\n".format(expected))
-            print(u"actual:\n{0}\n".format(actual))
+        print_differences(actual, expected)
         raise
 
 @contextlib.contextmanager
@@ -73,14 +79,17 @@ def on_error_print_details(actual, expected):
     try:
         yield
     except Exception:
-        diff = difflib.ndiff(expected.splitlines(), actual.splitlines())
-        diff_text = u"\n".join(diff)
-        print(u"DIFF (+ ACTUAL, - EXPECTED):\n{0}\n".format(diff_text))
-        if DEBUG:
-            print(u"expected:\n{0}\n".format(expected))
-            print(u"actual:\n{0}".format(actual))
+        print_differences(actual, expected)
         raise
 
+
+def is_encoding_valid(encoding):
+    try:
+        return bool(codecs.lookup(encoding))
+    except LookupError:
+        return False
+
+
 # -----------------------------------------------------------------------------
 # STEPS: WORKING DIR
 # -----------------------------------------------------------------------------
@@ -94,14 +103,14 @@ def step_a_new_working_directory(context):
     shutil.rmtree(context.workdir, ignore_errors=True)
     command_util.ensure_workdir_exists(context)
 
+
 @given(u'I use the current directory as working directory')
 def step_use_curdir_as_working_directory(context):
-    """
-    Uses the current directory as working directory
-    """
+    """Uses the current directory as working directory"""
     context.workdir = os.path.abspath(".")
     command_util.ensure_workdir_exists(context)
 
+
 @step(u'I use the directory "{directory}" as working directory')
 def step_use_directory_as_working_directory(context, directory):
     """Uses the directory as new working directory"""
@@ -125,16 +134,16 @@ def step_use_directory_as_working_directory(context, directory):
     context.workdir = workdir
     command_util.ensure_workdir_exists(context)
 
+
 # -----------------------------------------------------------------------------
 # STEPS: Create files with contents
 # -----------------------------------------------------------------------------
 @given(u'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
+    assert is_encoding_valid(encoding), "INVALID: encoding=%s;" % encoding
     command_util.ensure_workdir_exists(context)
     filename2 = os.path.join(context.workdir, filename)
     pathutil.create_textfile_with_contents(filename2, context.text, encoding)
@@ -178,40 +187,48 @@ def step_i_run_command(context, command):
         print(u"run_command: {0}".format(command))
         print(u"run_command.output {0}".format(context.command_result.output))
 
+
 @when(u'I successfully run "{command}"')
 @when(u'I successfully run `{command}`')
 def step_i_successfully_run_command(context, command):
     step_i_run_command(context, command)
     step_it_should_pass(context)
 
+
 @then(u'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(u'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(u'the command returncode is "{result:int}"')
 def step_the_command_returncode_is(context, result):
     assert_that(context.command_result.returncode, equal_to(result))
 
+
 @then(u'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(u'it should pass')
 def step_it_should_pass(context):
     assert_that(context.command_result.returncode, equal_to(0),
                 context.command_result.output)
 
+
 @then(u'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(u'it should pass with')
 def step_it_should_pass_with(context):
     '''
@@ -299,11 +316,13 @@ def step_command_output_should_contain_text_multiple_times(context, text, count)
              __CWD__     = posixpath_normpath(os.getcwd())
         )
     actual_output = context.command_result.output
-    with on_assert_failed_print_details(actual_output, expected_text):
+    expected_text_part = expected_text
+    with on_assert_failed_print_details(actual_output, expected_text_part):
         textutil.assert_normtext_should_contain_multiple_times(actual_output,
-                                                               expected_text,
+                                                               expected_text_part,
                                                                count)
 
+
 @then(u'the command output should contain exactly "{text}"')
 def step_command_output_should_contain_exactly_text(context, text):
     """
@@ -366,7 +385,9 @@ def step_command_output_should_not_contain(context):
             """
     '''
     assert context.text is not None, "REQUIRE: multi-line text"
-    step_command_output_should_not_contain_text(context, context.text.strip())
+    text = context.text.rstrip()
+    step_command_output_should_not_contain_text(context, text)
+
 
 @then(u'the command output should contain {count:d} times')
 def step_command_output_should_contain_multiple_times(context, count):
@@ -381,19 +402,22 @@ def step_command_output_should_contain_multiple_times(context, count):
             """
     '''
     assert context.text is not None, "REQUIRE: multi-line text"
-    step_command_output_should_contain_text_multiple_times(context,
-                                                           context.text, count)
+    text = context.text.rstrip()
+    step_command_output_should_contain_text_multiple_times(context, text, count)
+
 
 @then(u'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)
+    text = context.text.rstrip()
+    step_command_output_should_contain_exactly_text(context, text)
 
 
 @then(u'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)
+    text = context.text.rstrip()
+    step_command_output_should_not_contain_exactly_text(context, text)
 
 
 # -----------------------------------------------------------------------------
@@ -408,6 +432,7 @@ def step_remove_directory(context, directory):
         shutil.rmtree(path_, ignore_errors=True)
     assert_that(not os.path.isdir(path_))
 
+
 @given(u'I ensure that the directory "{directory}" exists')
 def step_given_ensure_that_the_directory_exists(context, directory):
     path_ = directory
@@ -417,10 +442,12 @@ def step_given_ensure_that_the_directory_exists(context, directory):
         os.makedirs(path_)
     assert_that(os.path.isdir(path_))
 
+
 @given(u'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(u'a directory named "{path}"')
 def step_directory_named_dirname(context, path):
     assert context.workdir, "REQUIRE: context.workdir"
@@ -429,6 +456,7 @@ def step_directory_named_dirname(context, path):
         os.makedirs(path_)
     assert os.path.isdir(path_)
 
+
 @then(u'the directory "{directory}" should exist')
 def step_the_directory_should_exist(context, directory):
     path_ = directory
@@ -436,6 +464,7 @@ def step_the_directory_should_exist(context, directory):
         path_ = os.path.join(context.workdir, os.path.normpath(directory))
     assert_that(os.path.isdir(path_))
 
+
 @then(u'the directory "{directory}" should not exist')
 def step_the_directory_should_not_exist(context, directory):
     path_ = directory
@@ -443,6 +472,7 @@ def step_the_directory_should_not_exist(context, directory):
         path_ = os.path.join(context.workdir, os.path.normpath(directory))
     assert_that(not os.path.isdir(path_))
 
+
 @step(u'the directory "{directory}" exists')
 def step_directory_exists(context, directory):
     """
@@ -455,6 +485,7 @@ def step_directory_exists(context, directory):
     """
     step_the_directory_should_exist(context, directory)
 
+
 @step(u'the directory "{directory}" does not exist')
 def step_directory_named_does_not_exist(context, directory):
     """
@@ -467,6 +498,7 @@ def step_directory_named_does_not_exist(context, directory):
     """
     step_the_directory_should_not_exist(context, directory)
 
+
 # -----------------------------------------------------------------------------
 # FILE STEPS:
 # -----------------------------------------------------------------------------
@@ -482,6 +514,7 @@ def step_file_named_filename_exists(context, filename):
     """
     step_file_named_filename_should_exist(context, filename)
 
+
 @step(u'a file named "{filename}" does not exist')
 @step(u'the file named "{filename}" does not exist')
 def step_file_named_filename_does_not_exist(context, filename):
@@ -495,18 +528,21 @@ def step_file_named_filename_does_not_exist(context, filename):
     """
     step_file_named_filename_should_not_exist(context, filename)
 
+
 @then(u'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(u'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(u'I remove the file "{filename}"')
 def step_remove_file(context, filename):
     path_ = filename
@@ -567,6 +603,7 @@ def step_I_set_the_environment_variable_to(context, env_name, env_value):
     context.environ[env_name] = env_value
     os.environ[env_name] = env_value
 
+
 @step(u'I remove the environment variable "{env_name}"')
 def step_I_remove_the_environment_variable(context, env_name):
     if not hasattr(context, "environ"):
diff --git a/behave4cmd0/textutil.py b/behave4cmd0/textutil.py
index 7f04dc2..c628458 100644
--- a/behave4cmd0/textutil.py
+++ b/behave4cmd0/textutil.py
@@ -171,6 +171,7 @@ def text_remove_empty_lines(text):
     lines = [ line.rstrip()  for line in text.splitlines()  if line.strip() ]
     return "\n".join(lines)
 
+
 def text_normalize(text):
     """
     Whitespace normalization:
@@ -187,6 +188,11 @@ def text_normalize(text):
     lines = [ line.strip()  for line in text.splitlines()  if line.strip() ]
     return "\n".join(lines)
 
+
+def text_normalize_line_endings(text):
+    return text.replace("\r\n", "\n")
+
+
 # -----------------------------------------------------------------------------
 # ASSERTIONS:
 # -----------------------------------------------------------------------------
@@ -230,34 +236,52 @@ def contains_substring_multiple_times(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):
+    text = text_normalize_line_endings(text)
+    expected_part = text_normalize_line_endings(expected_part)
     assert_that(text, contains_string(expected_part))
 
+
 def assert_text_should_not_contain_exactly(text, expected_part):
+    text = text_normalize_line_endings(text)
+    expected_part = text_normalize_line_endings(expected_part)
     assert_that(text, is_not(contains_string(expected_part)))
 
+
 def assert_text_should_contain(text, expected_part):
+    text = text_normalize_line_endings(text)
+    expected_part = text_normalize_line_endings(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_normtext_should_contain_multiple_times(text, expected_text_part, count):
+    text = text_normalize(text.strip())
+    expected_text_part = text_normalize(expected_text_part.strip())
+    assert_that(text, contains_substring_multiple_times(expected_text_part, count))
+
 
 def assert_text_should_not_contain(text, unexpected_part):
+    text = text_normalize_line_endings(text)
+    unexpected_part = text_normalize_line_endings(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())
@@ -266,6 +290,7 @@ def assert_normtext_should_contain(text, expected_part):
         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())
diff --git a/etc/gherkin/gherkin-languages.json b/etc/gherkin/gherkin-languages.json
index 83f0559..14b5211 100644
--- a/etc/gherkin/gherkin-languages.json
+++ b/etc/gherkin/gherkin-languages.json
@@ -967,9 +967,12 @@
     ],
     "when": [
       "* ",
-      "Tha ",
-      "Þa ",
-      "Ða "
+      "Bæþsealf ",
+      "Bæþsealfa ",
+      "Bæþsealfe ",
+      "Ciricæw ",
+      "Ciricæwe ",
+      "Ciricæwa "
     ]
   },
   "en-pirate": {
@@ -2395,7 +2398,7 @@
     "and": [
       "* ",
       "र ",
-      "अनी "
+      "अनि "
     ],
     "background": [
       "पृष्ठभूमी"
@@ -3459,7 +3462,7 @@
     ],
     "given": [
       "* ",
-      "Агар "
+      "Belgilangan "
     ],
     "name": "Uzbek",
     "native": "Узбекча",
@@ -3555,7 +3558,8 @@
     "name": "Chinese simplified",
     "native": "简体中文",
     "rule": [
-      "Rule"
+      "Rule",
+      "规则"
     ],
     "scenario": [
       "场景",
diff --git a/features/i18n_emoji.feature b/features/i18n_emoji.feature
index db23ac2..43ca99f 100644
--- a/features/i18n_emoji.feature
+++ b/features/i18n_emoji.feature
@@ -1,6 +1,10 @@
 # language: em
 # SOURCE: https://github.com/cucumber/cucumber/blob/master/gherkin/testdata/good/i18n_emoji.feature
+# HINT:
+#   Temporarily disabled on os=win32 (Windows) until unicode encoding issues are fixed.
+#   Try with environment variable: PYTHONUTF8=1
 
+@not.with_os=win32
 📚: 🙈🙉🙊
 
   📕: 💃
diff --git a/issue.features/issue0510.feature b/issue.features/issue0510.feature
index f9e93e1..f174545 100644
--- a/issue.features/issue0510.feature
+++ b/issue.features/issue0510.feature
@@ -1,6 +1,5 @@
 @issue
 @junit
-@wip
 Feature: Issue #510 -- JUnit XML output is not well-formed (in some cases)
 
   . Special control characters in JUnit stdout/stderr sections
@@ -12,12 +11,24 @@ Feature: Issue #510 -- JUnit XML output is not well-formed (in some cases)
   .   Char ::=  #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
   .   /* any Unicode character, excluding the surrogate blocks, FFFE, and FFFF. */
   .
-  . [XML-charsets] "The normative reference is XML 1.0 (Fifth Edition),
+  . [XML-charsets] The normative reference is XML 1.0 (Fifth Edition),
   .     section 2.2, https://www.w3.org/TR/REC-xml/#charsets
+  .
+  .  Within a CDATA section, only the CDEnd string is recognized as markup,
+  .  so that left angle brackets and ampersands may occur in their literal form;
+  .  they need not (and cannot) be escaped using " &lt; " and " &amp; ".
+  .  CDATA sections cannot nest.
+  .
+  .  CDSect	 ::=   	CDStart CData CDEnd
+  .  CDStart ::=   	'<![CDATA['
+  .  CData	 ::=   	(Char* - (Char* ']]>' Char*))
+  .  CDEnd	 ::=   	']]>'
+  .
+  . [CDATA Sections] https://www.w3.org/TR/REC-xml/#sec-cdata-sect
+  .
 
 
   @use.with_xmllint=yes
-  @xfail
   Scenario:
     Given a new working directory
     And a file named "features/steps/special_char_steps.py" with:
@@ -49,3 +60,42 @@ Feature: Issue #510 -- JUnit XML output is not well-formed (in some cases)
       reports/TESTS-special_char.xml:12: parser error : PCDATA invalid Char value 4
       """
     And note that "xmllint reports additional correlated errors"
+
+  @use.with_xmllint=yes
+  Scenario:
+    Given a new working directory
+    And a file named "features/steps/cdata_end.py" with:
+      """
+      # -*- coding: UTF-8 -*-
+      from __future__ import print_function
+      from behave import step
+      import logging
+
+      @step(u'we print ]]>')
+      def step_cdata_end(context):
+          print(u"]]>")
+
+      @step(u'we log ]]>')
+      def step_log_cdata_end(context):
+          logging.warning(u"]]>")
+      """
+    And a file named "features/cdata_end.feature" with:
+      """
+      Feature: A CDATA end
+        Scenario: Print and log CDATA end
+          When we print ]]>
+          And we log ]]>
+      """
+    When I run "behave --junit features/cdata_end.feature"
+    Then it should pass with:
+      """
+      1 scenario passed, 0 failed, 0 skipped
+      """
+    When I run "xmllint reports/TESTS-cdata_end.xml"
+    Then it should pass
+    And the command output should not contain "parser error"
+    And the command output should not contain:
+      """
+      reports/TESTS-cdata_end.xml:6: parser error : Sequence ']]>' not allowed in content
+      """
+    And note that "xmllint reports additional correlated errors"
diff --git a/issue.features/issue1061.feature b/issue.features/issue1061.feature
new file mode 100644
index 0000000..e909242
--- /dev/null
+++ b/issue.features/issue1061.feature
@@ -0,0 +1,42 @@
+@issue
+Feature: Issue #1061 -- Syndrome: Scenario does not inherit Rule Tags
+
+  Background: Setup
+    Given a new working directory
+    And a file named "features/syndrome_1061.feature" with:
+      """
+      Feature: F1
+
+        @rule_tag
+        Rule: R1
+
+          Scenario: S1
+            Given a step passes
+            When another step passes
+      """
+    And a file named "features/steps/use_step_library.py" with:
+      """
+      # -- REUSE STEPS:
+      import behave4cmd0.passing_steps
+      """
+    And a file named "behave.ini" with:
+      """
+      [behave]
+      show_timings = false
+      """
+
+  Scenario: Verify syndrome is fixed
+    When I run "behave -f plain --tags=rule_tag features/syndrome_1061.feature"
+    Then it should pass with:
+      """
+      Scenario: S1
+        Given a step passes ... passed
+        When another step passes ... passed
+      """
+    And the command output should contain:
+      """
+      1 rule passed, 0 failed, 0 skipped
+      1 scenario passed, 0 failed, 0 skipped
+      2 steps passed, 0 failed, 0 skipped
+      """
+    And note that "the rule scenario is NOT SKIPPED"


Test patch:
diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml
index e02163d..e7cbe89 100644
--- a/.github/workflows/tests-windows.yml
+++ b/.github/workflows/tests-windows.yml
@@ -7,6 +7,10 @@ on:
   pull_request:
     branches: [ main ]
 
+# -- TEST BALLOON: Fix encoding="cp1252" problems by using "UTF-8"
+env:
+  PYTHONUTF8: 1
+
 jobs:
   test:
     runs-on: ${{ matrix.os }}
diff --git a/tests/functional/test_tag_inheritance.py b/tests/functional/test_tag_inheritance.py
new file mode 100644
index 0000000..c6078b2
--- /dev/null
+++ b/tests/functional/test_tag_inheritance.py
@@ -0,0 +1,501 @@
+"""
+Test the tag inheritance mechanism between model entities:
+
+* Feature(s)
+* Rule(s)
+* ScenarioOutline(s)
+* Scenario(s)
+
+Tag inheritance mechanism:
+
+* Inner model element inherits tags from its outer/parent elements
+* Parametrized tags from a ScenarioOutline/ScenarioTemplate are filtered out
+
+EXAMPLES:
+
+* Scenario inherits the tags of its Feature
+* Scenario inherits the tags of its Rule
+* Scenario derives its tags of its ScenarioOutline (and Examples table)
+
+* Rule inherits tags of its Feature
+* ScenarioOutline/ScenarioTemplate inherits tags from its Feature
+* ScenarioOutline/ScenarioTemplate inherits tags from its Rule
+"""
+
+from __future__ import absolute_import, print_function
+from behave.parser import parse_feature
+import pytest
+
+
+# -----------------------------------------------------------------------------
+# TEST SUPPORT
+# -----------------------------------------------------------------------------
+def get_inherited_tags(model_element):
+    inherited_tags = set(model_element.effective_tags).difference(model_element.tags)
+    return sorted(inherited_tags)   # -- ENSURE: Deterministic ordering
+
+
+def assert_tags_same_as_effective_tags(model_element):
+    assert set(model_element.tags) == set(model_element.effective_tags)
+
+
+def assert_inherited_tags_equal_to(model_element, expected_tags):
+    inherited_tags = get_inherited_tags(model_element)
+    assert inherited_tags == expected_tags
+
+
+def assert_no_tags_are_inherited(model_element):
+    assert_inherited_tags_equal_to(model_element, [])
+
+
+# -----------------------------------------------------------------------------
+# TEST SUITE
+# -----------------------------------------------------------------------------
+class TestTagInheritance4Feature(object):
+    """A Feature is the outermost model element.
+    Therefore, it cannot inherit any features.
+    """
+
+    @pytest.mark.parametrize("tags, case", [
+        ([], "without tags"),
+        (["feature_tag1", "feature_tag2"], "with tags"),
+    ])
+    def test_no_inherited_tags(self, tags, case):
+        tag_line = " ".join("@%s" % tag for tag in tags)
+        text = u"""
+            {tag_line}
+            Feature: F1
+            """.format(tag_line=tag_line)
+        this_feature = parse_feature(text)
+        assert this_feature.tags == tags
+        assert this_feature.effective_tags == set(tags)
+        assert_no_tags_are_inherited(this_feature)
+
+
+class TestTagInheritance4Rule(object):
+    def test_no_inherited_tags__without_feature_tags(self):
+        text = u"""
+        Feature: F1
+          @rule_tag1
+          Rule: R1
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        assert this_feature.tags == []
+        assert this_rule.tags == ["rule_tag1"]
+        assert_tags_same_as_effective_tags(this_rule)
+        assert_no_tags_are_inherited(this_rule)
+
+    def test_inherited_tags__with_feature_tags(self):
+        text = u"""
+        @feature_tag1 @feature_tag2
+        Feature: F2
+          @rule_tag1
+          Rule: R2
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        expected_feature_tags = ["feature_tag1", "feature_tag2"]
+        assert this_feature.tags == expected_feature_tags
+        assert this_rule.tags == ["rule_tag1"]
+        assert_inherited_tags_equal_to(this_rule, expected_feature_tags)
+
+    def test_duplicated_tags_are_removed_from_inherited_tags(self):
+        text = u"""
+        @feature_tag1 @duplicated_tag
+        Feature: F2
+          @rule_tag1 @duplicated_tag
+          Rule: R2
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        assert this_feature.tags == ["feature_tag1", "duplicated_tag"]
+        assert this_rule.tags == ["rule_tag1", "duplicated_tag"]
+        assert_inherited_tags_equal_to(this_rule, ["feature_tag1"])
+
+
+class TestTagInheritance4ScenarioOutline(object):
+    def test_no_inherited_tags__without_feature_tags(self):
+        text = u"""
+        Feature: F3
+            @outline_tag1
+            Scenario Outline: T1
+        """
+        this_feature = parse_feature(text)
+        this_scenario_outline = this_feature.run_items[0]
+        assert this_feature.tags == []
+        assert this_scenario_outline.tags == ["outline_tag1"]
+        assert_no_tags_are_inherited(this_scenario_outline)
+
+    def test_no_inherited_tags__without_feature_and_rule_tags(self):
+        text = u"""
+        Feature: F3
+          Rule: R3
+            @outline_tag1
+            Scenario Outline: T1
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        this_scenario_outline = this_rule.run_items[0]
+        assert this_feature.tags == []
+        assert this_rule.tags == []
+        assert this_scenario_outline.tags == ["outline_tag1"]
+        assert_no_tags_are_inherited(this_scenario_outline)
+
+    def test_inherited_tags__with_feature_tags(self):
+        text = u"""
+        @feature_tag1 @feature_tag2
+        Feature: F3
+            @outline_tag1
+            Scenario Outline: T3
+        """
+        this_feature = parse_feature(text)
+        this_scenario_outline = this_feature.run_items[0]
+        expected_feature_tags = ["feature_tag1", "feature_tag2"]
+        assert this_feature.tags == expected_feature_tags
+        assert this_scenario_outline.tags == ["outline_tag1"]
+        assert_inherited_tags_equal_to(this_scenario_outline, expected_feature_tags)
+
+    def test_inherited_tags__with_rule_tags(self):
+        text = u"""
+        Feature: F3
+          @rule_tag1 @rule_tag2
+          Rule: R3
+            @outline_tag1
+            Scenario Outline: T3
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        this_scenario_outline = this_rule.run_items[0]
+        expected_rule_tags = ["rule_tag1", "rule_tag2"]
+        assert this_feature.tags == []
+        assert this_rule.tags == expected_rule_tags
+        assert this_scenario_outline.tags == ["outline_tag1"]
+        assert_inherited_tags_equal_to(this_scenario_outline, expected_rule_tags)
+
+    def test_inherited_tags__with_feature_and_rule_tags(self):
+        text = u"""
+        @feature_tag1
+        Feature: F3
+          @rule_tag1 @rule_tag2
+          Rule: R3
+            @outline_tag1
+            Scenario Outline: T3
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        this_scenario_outline = this_rule.run_items[0]
+        expected_feature_tags = ["feature_tag1"]
+        expected_rule_tags = ["rule_tag1", "rule_tag2"]
+        expected_inherited_tags = ["feature_tag1", "rule_tag1", "rule_tag2"]
+        assert this_feature.tags == expected_feature_tags
+        assert this_rule.tags == expected_rule_tags
+        assert this_scenario_outline.tags == ["outline_tag1"]
+        assert_inherited_tags_equal_to(this_scenario_outline, expected_inherited_tags)
+
+    def test_duplicated_tags_are_removed_from_inherited_tags(self):
+        text = u"""
+        @feature_tag1 @duplicated_tag
+        Feature: F3
+          @rule_tag1 @duplicated_tag
+          Rule: R3
+            @outline_tag1 @duplicated_tag
+            Scenario Outline: T3
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        this_scenario_outline = this_rule.run_items[0]
+        assert this_feature.tags == ["feature_tag1", "duplicated_tag"]
+        assert this_rule.tags == ["rule_tag1", "duplicated_tag"]
+        assert this_scenario_outline.tags == ["outline_tag1", "duplicated_tag"]
+        assert_inherited_tags_equal_to(this_scenario_outline, ["feature_tag1", "rule_tag1"])
+
+
+class TestTagInheritance4Scenario(object):
+    def test_no_inherited_tags__without_feature_tags(self):
+        text = u"""
+        Feature: F4
+            @scenario_tag1
+            Scenario: S4
+        """
+        this_feature = parse_feature(text)
+        this_scenario = this_feature.scenarios[0]
+        assert this_feature.tags == []
+        assert this_scenario.tags == ["scenario_tag1"]
+        assert_no_tags_are_inherited(this_scenario)
+
+    def test_no_inherited_tags__without_feature_and_rule_tags(self):
+        text = u"""
+        Feature: F4
+          Rule: R4
+            @scenario_tag1
+            Scenario: S4
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        this_scenario = this_rule.scenarios[0]
+        assert this_feature.tags == []
+        assert this_rule.tags == []
+        assert this_scenario.tags == ["scenario_tag1"]
+        assert_no_tags_are_inherited(this_scenario)
+
+    def test_inherited_tags__with_feature_tags(self):
+        text = u"""
+        @feature_tag1 @feature_tag2
+        Feature: F4
+            @scenario_tag1
+            Scenario: S4
+        """
+        this_feature = parse_feature(text)
+        this_scenario = this_feature.scenarios[0]
+        expected_feature_tags = ["feature_tag1", "feature_tag2"]
+        assert this_feature.tags == expected_feature_tags
+        assert this_scenario.tags == ["scenario_tag1"]
+        assert_inherited_tags_equal_to(this_scenario, expected_feature_tags)
+
+    def test_inherited_tags__with_rule_tags(self):
+        text = u"""
+        Feature: F3
+          @rule_tag1 @rule_tag2
+          Rule: R3
+            @scenario_tag1
+            Scenario: S4
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        this_scenario = this_rule.scenarios[0]
+        expected_rule_tags = ["rule_tag1", "rule_tag2"]
+        assert this_feature.tags == []
+        assert this_rule.tags == expected_rule_tags
+        assert this_scenario.tags == ["scenario_tag1"]
+        assert_inherited_tags_equal_to(this_scenario, expected_rule_tags)
+
+    def test_inherited_tags__with_feature_and_rule_tags(self):
+        text = u"""
+        @feature_tag1
+        Feature: F4
+          @rule_tag1 @rule_tag2
+          Rule: R4
+            @scenario_tag1
+            Scenario: S4
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        this_scenario = this_rule.scenarios[0]
+        expected_feature_tags = ["feature_tag1"]
+        expected_rule_tags = ["rule_tag1", "rule_tag2"]
+        expected_inherited_tags = ["feature_tag1", "rule_tag1", "rule_tag2"]
+        assert this_feature.tags == expected_feature_tags
+        assert this_rule.tags == expected_rule_tags
+        assert this_scenario.tags == ["scenario_tag1"]
+        assert_inherited_tags_equal_to(this_scenario, expected_inherited_tags)
+
+    def test_duplicated_tags_are_removed_from_inherited_tags(self):
+        text = u"""
+        @feature_tag1 @duplicated_tag
+        Feature: F4
+          @rule_tag1 @duplicated_tag
+          Rule: R4
+            @scenario_tag1 @duplicated_tag
+            Scenario: S4
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        this_scenario = this_rule.scenarios[0]
+        assert this_feature.tags == ["feature_tag1", "duplicated_tag"]
+        assert this_rule.tags == ["rule_tag1", "duplicated_tag"]
+        assert this_scenario.tags == ["scenario_tag1", "duplicated_tag"]
+        assert_inherited_tags_equal_to(this_scenario, ["feature_tag1", "rule_tag1"])
+
+
+class TestTagInheritance4ScenarioFromTemplate(object):
+    """Test tag inheritance for scenarios from a ScenarioOutline or
+    ScenarioTemplate (as alias for ScenarioOutline).
+
+    SCENARIO TEMPLATE MECHANISM::
+
+        scenario_template := scenario_outline
+        scenario.tags := scenario_template.tags + scenario_template.examples[i].tags
+    """
+
+    def test_no_inherited_tags__without_feature_tags(self):
+        text = u"""
+        Feature: F5
+            @template_tag1
+            Scenario Outline: T5
+              Given I meet "<name>"
+
+              @examples_tag1
+              Examples:
+                | name |
+                | Alice |
+        """
+        this_feature = parse_feature(text)
+        this_scenario_outline = this_feature.run_items[0]
+        this_scenario = this_scenario_outline.scenarios[0]
+        assert this_feature.tags == []
+        assert this_scenario_outline.tags == ["template_tag1"]
+        assert this_scenario.tags == ["template_tag1", "examples_tag1"]
+        assert_no_tags_are_inherited(this_scenario)
+
+    def test_no_inherited_tags__without_feature_and_rule_tags(self):
+        text = u"""
+        Feature: F5
+          Rule: R5
+            @template_tag1
+            Scenario Outline: T5
+              Given I meet "<name>"
+
+              @examples_tag1
+              Examples:
+                | name |
+                | Alice |
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        this_scenario_outline = this_rule.run_items[0]
+        this_scenario = this_scenario_outline.scenarios[0]
+        assert this_feature.tags == []
+        assert this_rule.tags == []
+        assert this_scenario_outline.tags == ["template_tag1"]
+        assert this_scenario.tags == ["template_tag1", "examples_tag1"]
+        assert_no_tags_are_inherited(this_scenario)
+
+    def test_inherited_tags__with_feature_tags(self):
+        text = u"""
+        @feature_tag1 @feature_tag2
+        Feature: F5
+            @template_tag1
+            Scenario Outline: T5
+              Given I meet "<name>"
+
+              Examples:
+                | name |
+                | Alice |
+        """
+        this_feature = parse_feature(text)
+        this_scenario_outline = this_feature.run_items[0]
+        this_scenario = this_scenario_outline.scenarios[0]
+        expected_feature_tags = ["feature_tag1", "feature_tag2"]
+        assert this_feature.tags == expected_feature_tags
+        assert this_scenario.tags == ["template_tag1"]
+        assert_inherited_tags_equal_to(this_scenario, expected_feature_tags)
+
+    def test_inherited_tags__with_rule_tags(self):
+        text = u"""
+        Feature: F5
+          @rule_tag1 @rule_tag2
+          Rule: R5
+
+            @template_tag1
+            Scenario Outline: T5
+              Given I meet "<name>"
+
+              Examples:
+                | name |
+                | Alice |
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        this_scenario_outline = this_feature.run_items[0]
+        this_scenario = this_scenario_outline.scenarios[0]
+        expected_rule_tags = ["rule_tag1", "rule_tag2"]
+        assert this_feature.tags == []
+        assert this_rule.tags == expected_rule_tags
+        assert this_scenario.tags == ["template_tag1"]
+        assert_inherited_tags_equal_to(this_scenario, expected_rule_tags)
+
+    def test_inherited_tags__with_feature_and_rule_tags(self):
+        text = u"""
+        @feature_tag1
+        Feature: F4
+          @rule_tag1 @rule_tag2
+          Rule: R4
+
+            @template_tag1
+            Scenario Outline: T5
+              Given I meet "<name>"
+
+              Examples:
+                | name |
+                | Alice |
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        this_scenario_outline = this_rule.run_items[0]
+        this_scenario = this_scenario_outline.scenarios[0]
+
+        expected_feature_tags = ["feature_tag1"]
+        expected_rule_tags = ["rule_tag1", "rule_tag2"]
+        expected_inherited_tags = expected_feature_tags + expected_rule_tags
+        assert this_feature.tags == expected_feature_tags
+        assert this_rule.tags == expected_rule_tags
+        assert this_scenario.tags == ["template_tag1"]
+        assert_inherited_tags_equal_to(this_scenario, expected_inherited_tags)
+
+    def test_tags_are_derived_from_template(self):
+        text = u"""
+        Feature: F5
+
+            @template_tag1 @param_name_<name>
+            Scenario Outline: T5
+              Given I meet "<name>"
+
+              Examples:
+                | name |
+                | Alice |
+        """
+        this_feature = parse_feature(text)
+        this_scenario_template = this_feature.run_items[0]
+        this_scenario = this_scenario_template.scenarios[0]
+
+        assert this_feature.tags == []
+        assert this_scenario_template.tags == ["template_tag1", "param_name_<name>"]
+        assert this_scenario.tags == ["template_tag1", "param_name_Alice"]
+        assert_no_tags_are_inherited(this_scenario)
+
+    def test_tags_are_derived_from_template_examples_for_table_row(self):
+        text = u"""
+        Feature: F5
+          Rule: R5
+            Scenario Outline: T5
+              Given I meet "<name>"
+
+              @examples_tag1
+              Examples:
+                | name |
+                | Alice |
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        this_scenario_outline = this_rule.run_items[0]
+        this_scenario = this_scenario_outline.scenarios[0]
+
+        assert this_feature.tags == []
+        assert this_scenario.tags == ["examples_tag1"]
+        assert_no_tags_are_inherited(this_scenario)
+
+    def test_duplicated_tags_are_removed_from_inherited_tags(self):
+        text = u"""
+        @feature_tag1 @duplicated_tag
+        Feature: F4
+          @rule_tag1 @duplicated_tag
+          Rule: R4
+
+            @template_tag1 @duplicated_tag
+            Scenario Outline: T5
+              Given I meet "<name>"
+
+              @examples_tag1
+              Examples:
+                | name |
+                | Alice |
+        """
+        this_feature = parse_feature(text)
+        this_rule = this_feature.rules[0]
+        this_scenario_template = this_rule.scenarios[0]
+        this_scenario = this_scenario_template.scenarios[0]
+        assert this_feature.tags == ["feature_tag1", "duplicated_tag"]
+        assert this_rule.tags == ["rule_tag1", "duplicated_tag"]
+        assert this_scenario.tags == ["template_tag1", "duplicated_tag", "examples_tag1"]
+        assert_inherited_tags_equal_to(this_scenario, ["feature_tag1", "rule_tag1"])
diff --git a/tests/issues/test_issue1061.py b/tests/issues/test_issue1061.py
new file mode 100644
index 0000000..2522320
--- /dev/null
+++ b/tests/issues/test_issue1061.py
@@ -0,0 +1,70 @@
+"""
+https://github.com/behave/behave/issues/1061
+"""
+
+from __future__ import absolute_import, print_function
+from behave.parser import parse_feature
+from tests.functional.test_tag_inheritance import \
+    get_inherited_tags, assert_inherited_tags_equal_to
+
+
+# -----------------------------------------------------------------------------
+# TEST SUITE
+# -----------------------------------------------------------------------------
+class TestIssue(object):
+    """Verifies that issue is fixed.
+    Verifies basics that tag-inheritance mechanism works.
+
+    .. seealso:: tests/functional/test_tag_inheritance.py
+    """
+
+    def test_scenario_inherits_tags_with_feature(self):
+        """Verifies that issue #1047 is fixed."""
+        text = u"""
+            @feature_tag1
+            Feature: F1
+
+              @scenario_tag1
+              Scenario: S1
+            """
+        this_feature = parse_feature(text)
+        this_scenario = this_feature.scenarios[0]
+        expected_tags = set(["scenario_tag1", "feature_tag1"])
+        assert this_scenario.effective_tags == expected_tags
+        assert_inherited_tags_equal_to(this_scenario, ["feature_tag1"])
+
+    def test_scenario_inherits_tags_with_rule(self):
+        text = u"""
+            @feature_tag1
+            Feature: F1
+              @rule_tag1 @rule_tag2
+              Rule: R1
+                @scenario_tag1
+                Scenario: S1
+            """
+        this_feature = parse_feature(text)
+        this_scenario = this_feature.rules[0].scenarios[0]
+        inherited_tags = ["feature_tag1", "rule_tag1", "rule_tag2"]
+        expected_tags = set(["scenario_tag1"]).union(inherited_tags)
+        assert this_scenario.effective_tags == expected_tags
+        assert_inherited_tags_equal_to(this_scenario, inherited_tags)
+
+    def test_issue_scenario_inherits_tags_with_scenario_outline_and_rule(self):
+        text = u"""
+            @feature_tag1
+            Feature: F1
+              @rule_tag1 @rule_tag2
+              Rule: R1
+                @scenario_tag1
+                Scenario Outline: S1
+                  Examples:
+                    | name  |
+                    | Alice |
+            """
+        this_feature = parse_feature(text)
+        this_scenario_outline = this_feature.rules[0].scenarios[0]
+        this_scenario = this_scenario_outline.scenarios[0]
+        inherited_tags = ["feature_tag1", "rule_tag1", "rule_tag2"]
+        expected_tags = set(["scenario_tag1"]).union(inherited_tags)
+        assert this_scenario.effective_tags == expected_tags
+        assert_inherited_tags_equal_to(this_scenario, inherited_tags)