diff --git a/README.md b/README.md index f0362d6..c254897 100644 --- a/README.md +++ b/README.md @@ -62,15 +62,12 @@ through the repository `features/` directory. Implemented pieces include: -- feature discovery from a directory or a single `.feature` file -- optional support script loading with `--load` -- step definition loading from `step_definitions/*.sh` and `*.bash` -- step matching with `{token}` placeholders -- `Background`, `Scenario`, `Given` / `When` / `Then`, `And` / `But`, and `*` -- doc strings via Gherkin-style `"""` blocks exposed as `DOC_STRING` -- data tables exposed as `TABLE_HEADER` and `TABLE_ROWS` arrays -- `Scenario Outline` expansion from one `Examples` table -- colored terminal output and scenario summary +- feature discovery, validation, filtering, and scenario selection +- English Gherkin features, rules, backgrounds, scenarios, and outlines +- steps, tags, comments, doc strings, and data tables +- step definitions with named placeholders and lifecycle hooks +- optional support script loading +- fail-fast execution, deferred cleanup, and scenario summaries ### Gherkin Feature Support @@ -78,8 +75,11 @@ Implemented pieces include: |:------------------------------|:------------| | `Feature` | Supported | | Feature description text | Supported | -| `Scenario` | Supported | +| `Rule` | Supported | | `Background` | Supported | +| `Scenario` | Supported | +| `Scenario Outline` | Supported | +| `Examples` | Supported | | `Given`, `When`, `Then` | Supported | | `And` , `But` | Supported | | `*` step keyword | Supported | @@ -89,9 +89,10 @@ Implemented pieces include: | `Before`, `After` hooks | Supported | | `BeforeAll`, `AfterAll` hooks | Supported | | Data tables | Supported | -| `Scenario Outline` | Supported | -| `Examples` | Supported | -| `Rule` | Unsupported | + +Shellkin intentionally implements a compact English Gherkin dialect. Keyword +aliases and localization, multiple Examples blocks, tags on Examples blocks, +and escaped data-table cells are not currently supported. Tags can be selected with `--tag` / `-t` and skipped with `--exclude-tag` / `-x`. `@Before` and `@After` hooks are declared in step definition files and may be diff --git a/doc/shellkin-feature.5 b/doc/shellkin-feature.5 index 036bc1f..bc41e15 100644 --- a/doc/shellkin-feature.5 +++ b/doc/shellkin-feature.5 @@ -16,6 +16,8 @@ Shellkin currently supports these Gherkin keywords: .IP \(bu 2 \f[B]Background\f[R] .IP \(bu 2 +\f[B]Rule\f[R] +.IP \(bu 2 \f[B]Scenario\f[R] .IP \(bu 2 \f[B]Scenario Outline\f[R], \f[B]Examples\f[R] @@ -55,6 +57,37 @@ in the feature. Background: Given I am in a temp directory .EE +.SS Rule +Use \f[B]Rule:\f[R] to group scenarios that illustrate one business +rule. +Tags on a Rule are inherited by every scenario in that Rule. +Free\-form description text may appear below the Rule header. +.IP +.EX +\(atadmin +Rule: Administrators can manage users + Administrators have access to user management. + + Scenario: Deactivate a user + When I deactivate user \(aqAlice\(aq + Then user \(aqAlice\(aq should be inactive +.EE +.PP +A Rule may have one \f[B]Background:\f[R] before its first scenario. +Feature Background steps run first, followed by Rule Background steps. +.IP +.EX +Background: + Given the current user is \(aqDana\(aq + +Rule: Administrators can manage users + Background: + Given the current role is \(aqadmin\(aq + + Scenario: Deactivate a user + When I deactivate user \(aqAlice\(aq + Then user \(aqAlice\(aq should be inactive +.EE .SS Scenario Outline Use \f[B]Scenario Outline:\f[R] with one \f[B]Examples:\f[R] table to run the same scenario with several values. @@ -169,10 +202,6 @@ Given these users exist All rows must contain the same number of cells. Cell values are trimmed. Escaped pipe characters in cells are not currently supported. -.SH UNSUPPORTED CONSTRUCTS -The following common Gherkin constructs are not currently supported: -.IP \(bu 2 -\f[B]Rule\f[R] .SH EXAMPLE .IP .EX diff --git a/doc/shellkin-feature.md b/doc/shellkin-feature.md index 951e3a0..691b945 100644 --- a/doc/shellkin-feature.md +++ b/doc/shellkin-feature.md @@ -23,6 +23,7 @@ Shellkin currently supports these Gherkin keywords: - **Feature** - **Background** +- **Rule** - **Scenario** - **Scenario Outline**, **Examples** - **Given**, **When**, **Then** @@ -73,6 +74,39 @@ Background: Given I am in a temp directory ``` +Rule +-------------------------------------------------- + +Use **Rule:** to group scenarios that illustrate one business rule. Tags on a +Rule are inherited by every scenario in that Rule. Free-form description text +may appear below the Rule header. + +```gherkin +@admin +Rule: Administrators can manage users + Administrators have access to user management. + + Scenario: Deactivate a user + When I deactivate user 'Alice' + Then user 'Alice' should be inactive +``` + +A Rule may have one **Background:** before its first scenario. Feature +Background steps run first, followed by Rule Background steps. + +```gherkin +Background: + Given the current user is 'Dana' + +Rule: Administrators can manage users + Background: + Given the current role is 'admin' + + Scenario: Deactivate a user + When I deactivate user 'Alice' + Then user 'Alice' should be inactive +``` + Scenario Outline -------------------------------------------------- @@ -196,13 +230,6 @@ Given these users exist All rows must contain the same number of cells. Cell values are trimmed. Escaped pipe characters in cells are not currently supported. -UNSUPPORTED CONSTRUCTS -================================================== - -The following common Gherkin constructs are not currently supported: - -- **Rule** - EXAMPLE ================================================== diff --git a/features/fixtures/rules/rules.feature b/features/fixtures/rules/rules.feature new file mode 100644 index 0000000..4a2e5ca --- /dev/null +++ b/features/fixtures/rules/rules.feature @@ -0,0 +1,30 @@ +@access +Feature: rules + Group examples by business rule + +Background: + Given the current user is 'Dana' + +@elevated +Rule: Elevated roles can access protected areas + These roles have broader access. + + Background: + Given the current role is 'admin' + + Scenario Outline: Accessing + Then the user should have access to '' + + Examples: + | area | + | settings | + | reports | + +@standard +Rule: Standard roles cannot access protected areas + + Background: + Given the current role is 'member' + + Scenario: Accessing settings + Then the user should not have access to 'settings' diff --git a/features/fixtures/rules/step_definitions/core.sh b/features/fixtures/rules/step_definitions/core.sh new file mode 100644 index 0000000..64441a2 --- /dev/null +++ b/features/fixtures/rules/step_definitions/core.sh @@ -0,0 +1,15 @@ +@Given the current user is '{name}' + CURRENT_USER=$name + +@Given the current role is '{role}' + CURRENT_ROLE=$role + +@Then the user should have access to '{area}' + [[ $CURRENT_USER == Dana ]] + [[ $CURRENT_ROLE == admin ]] + [[ $area == settings || $area == reports ]] + +@Then the user should not have access to '{area}' + [[ $CURRENT_USER == Dana ]] + [[ $CURRENT_ROLE == member ]] + [[ $area == settings ]] diff --git a/features/test.feature b/features/test.feature index 4198217..4f5dcee 100644 --- a/features/test.feature +++ b/features/test.feature @@ -43,6 +43,22 @@ Scenario: Selecting one expanded scenario outline row And the output should include '1 scenario, 0 failing' And the exit code should mean success +Scenario: Running scenarios grouped by Rules + When I run 'shellkin features/fixtures/rules' + Then the output should include 'Feature: rules' + And the output should include 'Scenario 1: Accessing settings' + And the output should include 'Scenario 2: Accessing reports' + And the output should include 'Scenario 3: Accessing settings' + And the output should include '3 scenarios, 0 failing' + And the exit code should mean success + +Scenario: Filtering scenarios by a Rule tag + When I run 'shellkin --tag @standard features/fixtures/rules' + Then the output should include 'Scenario 3: Accessing settings' + And the output should not include 'Scenario 1: Accessing settings' + And the output should include '1 scenario, 0 failing' + And the exit code should mean success + Scenario: Running a failing test When I run 'shellkin features/fixtures/selective/failing.feature' Then the output should include 'Feature: two' diff --git a/shellkin b/shellkin index 802dbbc..427e6a7 100755 --- a/shellkin +++ b/shellkin @@ -819,6 +819,13 @@ feature_parse() { local line_number=0 local section= local feature_seen=0 + local feature_background_seen=0 + local feature_scenario_seen=0 + local rule_seen=0 + local rule_background_seen=0 + local rule_scenario_seen=0 + local rule_line=0 + local rule_name= local scenario_seen=0 local scenario_outline=0 local scenario_line=0 @@ -834,11 +841,14 @@ feature_parse() { local pending_tags_line=0 local pending_tags_context= local -a feature_tags=() + local -a rule_tags=() local -a pending_tags=() local -a parsed_tags=() local -a scenario_tags=() local -a background_steps=() local -a background_step_lines=() + local -a feature_background_steps=() + local -a feature_background_step_lines=() local -a scenario_steps=() local -a scenario_step_lines=() local -a example_rows=() @@ -934,8 +944,45 @@ feature_parse() { fi continue ;; + rule) + if ((feature_seen == 0)); then + feature_validation_set_error "$line_number" "Rule must appear after Feature" "$(trim "$line")" + failed=1 + break + fi + if ((scenario_seen != 0)); then + feature__parsed_scenario_finish "$scenario_outline" "$scenario_line" "$examples_line" feature_tags scenario_tags background_steps background_step_lines scenario_steps scenario_step_lines example_rows example_row_lines || { + failed=1 + break + } + scenario_seen=0 + fi + if ((rule_seen != 0 && rule_scenario_seen == 0)); then + feature_validation_set_error "$rule_line" "Rule must contain at least one Scenario" "Rule: $rule_name" + failed=1 + break + fi + if ((rule_seen == 0)); then + feature_background_steps=("${background_steps[@]}") + feature_background_step_lines=("${background_step_lines[@]}") + fi + background_steps=("${feature_background_steps[@]}") + background_step_lines=("${feature_background_step_lines[@]}") + rule_seen=1 + rule_background_seen=0 + rule_scenario_seen=0 + rule_line=$line_number + rule_name=$FEATURE_LINE_NAME + rule_tags=("${pending_tags[@]}") + pending_tags=() + pending_tags_line=0 + pending_tags_context= + section=rule + in_description=1 + continue + ;; background) - if ((feature_seen == 0 || scenario_seen != 0)); then + if ((feature_seen == 0)); then feature_validation_set_error "$line_number" "Background must appear after Feature and before the first Scenario" "$(trim "$line")" failed=1 break @@ -945,6 +992,21 @@ feature_parse() { failed=1 break fi + if ((rule_seen != 0)); then + if ((scenario_seen != 0 || rule_scenario_seen != 0 || rule_background_seen != 0)); then + feature_validation_set_error "$line_number" "Rule Background must appear before the first Scenario and only once" "$(trim "$line")" + failed=1 + break + fi + rule_background_seen=1 + else + if ((scenario_seen != 0 || feature_scenario_seen != 0 || feature_background_seen != 0)); then + feature_validation_set_error "$line_number" "Background must appear after Feature and before the first Scenario" "$(trim "$line")" + failed=1 + break + fi + feature_background_seen=1 + fi section=background in_description=0 continue @@ -970,10 +1032,15 @@ feature_parse() { scenario_line=$line_number examples_seen=0 examples_line=0 + if ((rule_seen != 0)); then + rule_scenario_seen=1 + else + feature_scenario_seen=1 + fi section=scenario in_description=0 FEATURE_SCENARIO_NAME=$FEATURE_LINE_NAME - scenario_tags=("${pending_tags[@]}") + scenario_tags=("${rule_tags[@]}" "${pending_tags[@]}") pending_tags=() pending_tags_line=0 pending_tags_context= @@ -1034,7 +1101,7 @@ feature_parse() { continue ;; other) - if [[ $section == feature && $in_description == 1 ]]; then + if [[ $section == feature || $section == rule ]] && ((in_description == 1)); then continue fi feature_validation_set_error "$line_number" "invalid feature syntax" "$FEATURE_LINE_NAME" @@ -1059,6 +1126,11 @@ feature_parse() { feature__parsed_scenario_finish "$scenario_outline" "$scenario_line" "$examples_line" feature_tags scenario_tags background_steps background_step_lines scenario_steps scenario_step_lines example_rows example_row_lines || failed=1 fi + if ((failed == 0 && rule_seen != 0 && rule_scenario_seen == 0)); then + feature_validation_set_error "$rule_line" "Rule must contain at least one Scenario" "Rule: $rule_name" + failed=1 + fi + return "$failed" } @@ -1232,6 +1304,12 @@ feature_line_parse() { return 0 fi + if [[ $line =~ ^Rule:[[:space:]]*(.*)$ ]]; then + FEATURE_LINE_KIND=rule + FEATURE_LINE_NAME=${BASH_REMATCH[1]} + return 0 + fi + if [[ $line =~ ^Scenario:[[:space:]]*(.*)$ ]]; then FEATURE_LINE_KIND=scenario FEATURE_LINE_NAME=${BASH_REMATCH[1]} diff --git a/src/lib/feature/parse.sh b/src/lib/feature/parse.sh index 0817f52..c22ec23 100644 --- a/src/lib/feature/parse.sh +++ b/src/lib/feature/parse.sh @@ -5,6 +5,13 @@ feature_parse() { local line_number=0 local section= local feature_seen=0 + local feature_background_seen=0 + local feature_scenario_seen=0 + local rule_seen=0 + local rule_background_seen=0 + local rule_scenario_seen=0 + local rule_line=0 + local rule_name= local scenario_seen=0 local scenario_outline=0 local scenario_line=0 @@ -20,11 +27,14 @@ feature_parse() { local pending_tags_line=0 local pending_tags_context= local -a feature_tags=() + local -a rule_tags=() local -a pending_tags=() local -a parsed_tags=() local -a scenario_tags=() local -a background_steps=() local -a background_step_lines=() + local -a feature_background_steps=() + local -a feature_background_step_lines=() local -a scenario_steps=() local -a scenario_step_lines=() local -a example_rows=() @@ -120,8 +130,45 @@ feature_parse() { fi continue ;; + rule) + if ((feature_seen == 0)); then + feature_validation_set_error "$line_number" "Rule must appear after Feature" "$(trim "$line")" + failed=1 + break + fi + if ((scenario_seen != 0)); then + feature__parsed_scenario_finish "$scenario_outline" "$scenario_line" "$examples_line" feature_tags scenario_tags background_steps background_step_lines scenario_steps scenario_step_lines example_rows example_row_lines || { + failed=1 + break + } + scenario_seen=0 + fi + if ((rule_seen != 0 && rule_scenario_seen == 0)); then + feature_validation_set_error "$rule_line" "Rule must contain at least one Scenario" "Rule: $rule_name" + failed=1 + break + fi + if ((rule_seen == 0)); then + feature_background_steps=("${background_steps[@]}") + feature_background_step_lines=("${background_step_lines[@]}") + fi + background_steps=("${feature_background_steps[@]}") + background_step_lines=("${feature_background_step_lines[@]}") + rule_seen=1 + rule_background_seen=0 + rule_scenario_seen=0 + rule_line=$line_number + rule_name=$FEATURE_LINE_NAME + rule_tags=("${pending_tags[@]}") + pending_tags=() + pending_tags_line=0 + pending_tags_context= + section=rule + in_description=1 + continue + ;; background) - if ((feature_seen == 0 || scenario_seen != 0)); then + if ((feature_seen == 0)); then feature_validation_set_error "$line_number" "Background must appear after Feature and before the first Scenario" "$(trim "$line")" failed=1 break @@ -131,6 +178,21 @@ feature_parse() { failed=1 break fi + if ((rule_seen != 0)); then + if ((scenario_seen != 0 || rule_scenario_seen != 0 || rule_background_seen != 0)); then + feature_validation_set_error "$line_number" "Rule Background must appear before the first Scenario and only once" "$(trim "$line")" + failed=1 + break + fi + rule_background_seen=1 + else + if ((scenario_seen != 0 || feature_scenario_seen != 0 || feature_background_seen != 0)); then + feature_validation_set_error "$line_number" "Background must appear after Feature and before the first Scenario" "$(trim "$line")" + failed=1 + break + fi + feature_background_seen=1 + fi section=background in_description=0 continue @@ -156,10 +218,15 @@ feature_parse() { scenario_line=$line_number examples_seen=0 examples_line=0 + if ((rule_seen != 0)); then + rule_scenario_seen=1 + else + feature_scenario_seen=1 + fi section=scenario in_description=0 FEATURE_SCENARIO_NAME=$FEATURE_LINE_NAME - scenario_tags=("${pending_tags[@]}") + scenario_tags=("${rule_tags[@]}" "${pending_tags[@]}") pending_tags=() pending_tags_line=0 pending_tags_context= @@ -220,7 +287,7 @@ feature_parse() { continue ;; other) - if [[ $section == feature && $in_description == 1 ]]; then + if [[ $section == feature || $section == rule ]] && ((in_description == 1)); then continue fi feature_validation_set_error "$line_number" "invalid feature syntax" "$FEATURE_LINE_NAME" @@ -245,6 +312,11 @@ feature_parse() { feature__parsed_scenario_finish "$scenario_outline" "$scenario_line" "$examples_line" feature_tags scenario_tags background_steps background_step_lines scenario_steps scenario_step_lines example_rows example_row_lines || failed=1 fi + if ((failed == 0 && rule_seen != 0 && rule_scenario_seen == 0)); then + feature_validation_set_error "$rule_line" "Rule must contain at least one Scenario" "Rule: $rule_name" + failed=1 + fi + return "$failed" } diff --git a/src/lib/feature/syntax.sh b/src/lib/feature/syntax.sh index ea7568e..984584f 100644 --- a/src/lib/feature/syntax.sh +++ b/src/lib/feature/syntax.sh @@ -32,6 +32,12 @@ feature_line_parse() { return 0 fi + if [[ $line =~ ^Rule:[[:space:]]*(.*)$ ]]; then + FEATURE_LINE_KIND=rule + FEATURE_LINE_NAME=${BASH_REMATCH[1]} + return 0 + fi + if [[ $line =~ ^Scenario:[[:space:]]*(.*)$ ]]; then FEATURE_LINE_KIND=scenario FEATURE_LINE_NAME=${BASH_REMATCH[1]} diff --git a/test/lib/feature/parse.bats b/test/lib/feature/parse.bats index 21b1ea3..f15e197 100644 --- a/test/lib/feature/parse.bats +++ b/test/lib/feature/parse.bats @@ -212,3 +212,83 @@ EOF [ "$status" -eq 1 ] [ "$FEATURE_VALIDATION_MESSAGE" = "Examples must appear once after a Scenario Outline" ] } + +@test "feature_parse applies Rule tags and Background only within its Rule" { + write_file test.feature <<'EOF' +@feature +Feature: Rules + +Background: + Given feature setup + +@first-rule +Rule: First rule + A useful description + + Background: + Given first rule setup + + @first-scenario + Scenario: First + Then first result + +@second-rule +Rule: Second rule + + Scenario: Second + Then second result +EOF + + feature_parse "$TEST_ROOT/test.feature" + + [ "${FEATURE_PARSED_SCENARIO_NAMES[*]}" = "First Second" ] + [ "${FEATURE_PARSED_SCENARIO_TAGS[0]}" = "@feature @first-rule @first-scenario" ] + [ "${FEATURE_PARSED_SCENARIO_TAGS[1]}" = "@feature @second-rule" ] + [ "${FEATURE_PARSED_SCENARIO_STEP_COUNTS[*]}" = "3 2" ] + [ "${FEATURE_PARSED_STEPS[0]}" = $'Given\tfeature setup' ] + [ "${FEATURE_PARSED_STEPS[1]}" = $'Given\tfirst rule setup' ] + [ "${FEATURE_PARSED_STEPS[2]}" = $'Then\tfirst result' ] + [ "${FEATURE_PARSED_STEPS[3]}" = $'Given\tfeature setup' ] + [ "${FEATURE_PARSED_STEPS[4]}" = $'Then\tsecond result' ] +} + +@test "feature_parse rejects a Rule without scenarios" { + write_file test.feature <<'EOF' +Feature: Rules + +Rule: Empty rule + A description without scenarios +EOF + + if feature_parse "$TEST_ROOT/test.feature"; then + status=0 + else + status=$? + fi + + [ "$status" -eq 1 ] + [ "$FEATURE_VALIDATION_MESSAGE" = "Rule must contain at least one Scenario" ] +} + +@test "feature_parse rejects a second Background in one Rule" { + write_file test.feature <<'EOF' +Feature: Rules + +Rule: One rule + Background: + Given first setup + Background: + Given second setup + Scenario: Example + Then result +EOF + + if feature_parse "$TEST_ROOT/test.feature"; then + status=0 + else + status=$? + fi + + [ "$status" -eq 1 ] + [ "$FEATURE_VALIDATION_MESSAGE" = "Rule Background must appear before the first Scenario and only once" ] +} diff --git a/test/lib/feature/syntax.bats b/test/lib/feature/syntax.bats index e097fab..0803b15 100644 --- a/test/lib/feature/syntax.bats +++ b/test/lib/feature/syntax.bats @@ -32,6 +32,13 @@ teardown() { [ "$FEATURE_LINE_NAME" = "Create a file" ] } +@test "feature_line_parse recognizes a rule header" { + feature_line_parse "Rule: Administrators can manage users" + + [ "$FEATURE_LINE_KIND" = rule ] + [ "$FEATURE_LINE_NAME" = "Administrators can manage users" ] +} + @test "feature_line_parse recognizes a scenario outline header" { feature_line_parse "Scenario Outline: Create a "