diff --git a/CHANGES.rst b/CHANGES.rst index 3b94132cb..12f9fb599 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -29,6 +29,7 @@ Removed Fixed +++++ +* Restored the ``FeatureError`` for a step placed before the first scenario or background. ``gherkin-official`` 31 and later no longer raise a parse error for this and fold the line into the feature description, so the check is now performed by pytest-bdd to keep the behaviour consistent across supported ``gherkin-official`` versions. `#779 `_ * Made type annotations stronger and removed most of the ``typing.Any`` usages and ``# type: ignore`` annotations. `#658 `_ Security diff --git a/src/pytest_bdd/gherkin_parser.py b/src/pytest_bdd/gherkin_parser.py index 8a6e4abdd..1455083f2 100644 --- a/src/pytest_bdd/gherkin_parser.py +++ b/src/pytest_bdd/gherkin_parser.py @@ -8,6 +8,7 @@ from dataclasses import dataclass, field from typing import Any +from gherkin.dialect import Dialect # type: ignore from gherkin.errors import CompositeParserException # type: ignore from gherkin.parser import Parser # type: ignore @@ -324,9 +325,71 @@ def get_gherkin_document(abs_filename: str, encoding: str = "utf-8") -> GherkinD raise exceptions.GherkinParseError(f"Unknown parsing error: {message}", line, line_content, filename) from e # At this point, the `gherkin_data` should be valid if no exception was raised + _raise_if_step_in_feature_description(gherkin_data, feature_file_text, abs_filename) + return GherkinDocument.from_dict(gherkin_data) +def _description_step_keywords(language: str) -> tuple[str, ...]: + """Return the step keywords for ``language`` as Gherkin itself defines them. + + Reusing the dialect keywords means the check follows the same logic Gherkin + uses to recognise steps, so it works for every language Gherkin supports + rather than English alone. The generic ``*`` keyword is omitted: it only + opens a step inside a scenario, and matching it against a description would + flag an ordinary bullet list. + """ + dialect = Dialect.for_name(language) + if dialect is None: + return () + keywords = ( + dialect.given_keywords + + dialect.when_keywords + + dialect.then_keywords + + dialect.and_keywords + + dialect.but_keywords + ) + return tuple(keyword for keyword in keywords if keyword.strip() != "*") + + +def _raise_if_step_in_feature_description( + gherkin_data: Mapping[str, Any], feature_file_text: str, abs_filename: str +) -> None: + """Raise a FeatureError if a step keyword sits before the first scenario or background. + + Older gherkin-official versions raised a parse error in this case; newer ones + (>= 31) fold the offending line into the feature description instead. We detect + it here so the behaviour stays consistent across gherkin-official versions (#779). + """ + feature = gherkin_data.get("feature") + if not feature: + return + description = feature.get("description") or "" + non_empty_lines = [line for line in description.splitlines() if line.strip()] + if not non_empty_lines: + return + # A step keyword is only misplaced when it opens the description. A keyword that + # appears later is legitimate free-text inside a multi-line description. + first_line = non_empty_lines[0].strip() + step_keywords = _description_step_keywords(feature.get("language", "en")) + if not any(first_line.startswith(keyword) for keyword in step_keywords): + return + for lineno, file_line in enumerate(feature_file_text.splitlines(), start=1): + if file_line.strip() == first_line: + raise exceptions.FeatureError( + "Step definition outside of a Scenario or a Background.", + lineno, + file_line, + abs_filename, + ) + raise exceptions.FeatureError( + "Step definition outside of a Scenario or a Background.", + feature["location"]["line"], + first_line, + abs_filename, + ) + + def handle_gherkin_parser_error( raw_error: str, line: int, line_content: str, filename: str, original_exception: Exception | None = None ) -> None: diff --git a/tests/parser/test_errors.py b/tests/parser/test_errors.py index 5c616388a..dade2f94f 100644 --- a/tests/parser/test_errors.py +++ b/tests/parser/test_errors.py @@ -72,6 +72,47 @@ def step_inside_scenario(): result.stdout.fnmatch_lines(["*FeatureError: Step definition outside of a Scenario or a Background.*"]) +def test_step_outside_scenario_or_background_error_localized(pytester): + """A misplaced step is detected regardless of the feature language. + + The detection uses the Gherkin dialect for the file's language, so a step + keyword from a non-English dialect (here French ``Soit``) is reported the + same way as the English one. + """ + features = pytester.mkdir("features") + features.joinpath("test.feature").write_text( + textwrap.dedent( + """\ + # language: fr + Fonctionnalité: Fonctionnalité invalide + Soit une étape hors de tout scénario + + Scénario: Un scénario valide + Soit une étape dans le scénario + """ + ), + encoding="utf-8", + ) + + pytester.makepyfile( + textwrap.dedent( + """ + from pytest_bdd import scenarios, given + + @given("une étape dans le scénario") + def step_inside_scenario(): + pass + + scenarios('features') + """ + ) + ) + + result = pytester.runpytest() + + result.stdout.fnmatch_lines(["*FeatureError: Step definition outside of a Scenario or a Background.*"]) + + def test_multiple_backgrounds_error(pytester): """Test multiple backgrounds in a single feature.""" features = pytester.mkdir("features")