From d13d608a64dc8a5645261b36fa210bf3352f3a7d Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Thu, 9 Jul 2026 11:22:16 +0300 Subject: [PATCH] - Add support for `@tags` --- README.md | 42 ++-- doc/shellkin-feature.5 | 19 +- doc/shellkin-feature.md | 21 +- doc/shellkin.1 | 19 +- doc/shellkin.md | 22 +- features/fixtures/features/one.feature | 1 + features/fixtures/features/two.feature | 1 + features/help.feature | 3 + features/test.feature | 7 + shellkin | 332 ++++++++++++++++++++++++- skills/shellkin/SKILL.md | 4 +- skills/shellkin/references/patterns.md | 2 +- src/bashly.yml | 17 +- src/lib/feature/core.sh | 194 ++++++++++++++- src/lib/feature/syntax.sh | 7 + src/root_command.sh | 19 ++ test/commands/test.bats | 103 ++++++++ test/commands/validate.bats | 45 ++++ test/lib/feature/syntax.bats | 7 + 19 files changed, 823 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index f66d076..bf9db3d 100644 --- a/README.md +++ b/README.md @@ -72,22 +72,26 @@ Implemented pieces include: ### Gherkin Feature Support -| Feature | Status | -|:-------------------------|:------------| -| `Feature` | Supported | -| Feature description text | Supported | -| `Scenario` | Supported | -| `Background` | Supported | -| `Given`, `When`, `Then` | Supported | -| `And` , `But` | Supported | -| `*` step keyword | Supported | -| Doc strings (`"""`) | Supported | -| Comments (`#`) | Supported | -| `Rule` | Unsupported | -| `Scenario Outline` | Unsupported | -| `Examples` | Unsupported | -| Data tables | Unsupported | -| Tags (`@tag`) | Unsupported | +| Feature | Status | +|:------------------------------|:------------| +| `Feature` | Supported | +| Feature description text | Supported | +| `Scenario` | Supported | +| `Background` | Supported | +| `Given`, `When`, `Then` | Supported | +| `And` , `But` | Supported | +| `*` step keyword | Supported | +| Doc strings (`"""`) | Supported | +| Comments (`#`) | Supported | +| Tags (`@tag`) | Supported | +| `Rule` | Unsupported | +| `Scenario Outline` | Unsupported | +| `Examples` | Unsupported | +| Data tables | Unsupported | +| `Before`, `After` hooks | Unsupported | +| `BeforeAll`, `AfterAll` hooks | Unsupported | + +Tags can be selected with `--tag` / `-t` and skipped with `--exclude-tag` / `-x`. ## Usage @@ -104,6 +108,12 @@ shellkin --validate # Stop after the first failing scenario: shellkin --fail-fast +# Run scenarios tagged @smoke: +shellkin -t @smoke + +# Skip scenarios tagged @slow: +shellkin -x @slow + # Run a specific directory: shellkin path/to/features diff --git a/doc/shellkin-feature.5 b/doc/shellkin-feature.5 index a8aa94c..2154dc4 100644 --- a/doc/shellkin-feature.5 +++ b/doc/shellkin-feature.5 @@ -91,6 +91,23 @@ Lines starting with \f[B]#\f[R] are treated as comments. # This is a comment Feature: Example .EE +.SH TAGS +Tags can be placed before a \f[B]Feature:\f[R] or \f[B]Scenario:\f[R]. +.IP +.EX +\(atfilesystem +Feature: Files + +\(atsmoke +Scenario: Create a file + When I run \(aqtouch one\(aq + Then the file \(aqone\(aq should exist +.EE +.PP +Feature tags are inherited by the scenarios in that feature. +Use \f[B]shellkin \-\-tag \(attag\f[R] to run scenarios with a tag, and +\f[B]shellkin \-\-exclude\-tag \(attag\f[R] to skip scenarios with a +tag. .SH DOC STRINGS Shellkin supports Gherkin\-style doc strings using \f[B]\(dq\(dq\(dq\f[R] fences. @@ -118,7 +135,7 @@ The following common Gherkin constructs are not currently supported: .IP \(bu 2 data tables .IP \(bu 2 -tags such as \f[B]\(atslow\f[R] +hooks .SH EXAMPLE .IP .EX diff --git a/doc/shellkin-feature.md b/doc/shellkin-feature.md index ba72667..df2ff06 100644 --- a/doc/shellkin-feature.md +++ b/doc/shellkin-feature.md @@ -116,6 +116,25 @@ Lines starting with **#** are treated as comments. Feature: Example ``` +TAGS +================================================== + +Tags can be placed before a **Feature:** or **Scenario:**. + +```gherkin +@filesystem +Feature: Files + +@smoke +Scenario: Create a file + When I run 'touch one' + Then the file 'one' should exist +``` + +Feature tags are inherited by the scenarios in that feature. Use +**shellkin --tag @tag** to run scenarios with a tag, and +**shellkin --exclude-tag @tag** to skip scenarios with a tag. + DOC STRINGS ================================================== @@ -143,7 +162,7 @@ The following common Gherkin constructs are not currently supported: - **Scenario Outline** - **Examples** - data tables -- tags such as **@slow** +- hooks EXAMPLE ================================================== diff --git a/doc/shellkin.1 b/doc/shellkin.1 index 9372e71..578d1ed 100644 --- a/doc/shellkin.1 +++ b/doc/shellkin.1 @@ -32,12 +32,13 @@ Conflicts With: \f[B]\-\-init\f[R] .SS \-\-validate, \-v Validate feature and step definition files .IP \(bu 2 -Conflicts With: \f[B]\-\-init\f[R] +Conflicts With: \f[B]\-\-init, \-\-tag, \-\-exclude\-tag\f[R] .SS \-\-init Initialize a Shellkin features directory .IP \(bu 2 -Conflicts With: \f[B]\-\-validate, \-\-fail\-fast, \-\-load\f[R] -.SS \-\-default\-target, \-t DIR +Conflicts With: \f[B]\-\-validate, \-\-fail\-fast, \-\-load, \-\-tag, +\-\-exclude\-tag\f[R] +.SS \-\-default\-target DIR Path to features directory .PP Relative to working directory @@ -45,6 +46,18 @@ Relative to working directory Normally only used in a .shellkin argfile .IP \(bu 2 Default Value: \f[B]features\f[R] +.SS \-\-tag, \-t TAG +Run scenarios with this tag +.IP \(bu 2 +\f[I]Repeatable\f[R] +.IP \(bu 2 +Conflicts With: \f[B]\-\-init, \-\-validate\f[R] +.SS \-\-exclude\-tag, \-x TAG +Skip scenarios with this tag +.IP \(bu 2 +\f[I]Repeatable\f[R] +.IP \(bu 2 +Conflicts With: \f[B]\-\-init, \-\-validate\f[R] .SS \-\-stepdefs, \-s DIR Path to step definitions directory .PP diff --git a/doc/shellkin.md b/doc/shellkin.md index 71b1d77..363bb96 100644 --- a/doc/shellkin.md +++ b/doc/shellkin.md @@ -53,16 +53,16 @@ Abort after the first failing scenario Validate feature and step definition files -- Conflicts With: **--init** +- Conflicts With: **--init, --tag, --exclude-tag** --init -------------------------------------------------- Initialize a Shellkin features directory -- Conflicts With: **--validate, --fail-fast, --load** +- Conflicts With: **--validate, --fail-fast, --load, --tag, --exclude-tag** ---default-target, -t DIR +--default-target DIR -------------------------------------------------- Path to features directory @@ -74,6 +74,22 @@ Normally only used in a .shellkin argfile - Default Value: **features** +--tag, -t TAG +-------------------------------------------------- + +Run scenarios with this tag + +- *Repeatable* +- Conflicts With: **--init, --validate** + +--exclude-tag, -x TAG +-------------------------------------------------- + +Skip scenarios with this tag + +- *Repeatable* +- Conflicts With: **--init, --validate** + --stepdefs, -s DIR -------------------------------------------------- diff --git a/features/fixtures/features/one.feature b/features/fixtures/features/one.feature index 86cb71f..db14e8d 100644 --- a/features/fixtures/features/one.feature +++ b/features/fixtures/features/one.feature @@ -1,6 +1,7 @@ Feature: one Passing fixture +@smoke Scenario: one passes When I run 'printf one' Then the output should include 'one' diff --git a/features/fixtures/features/two.feature b/features/fixtures/features/two.feature index 7860f72..5a4f3e7 100644 --- a/features/fixtures/features/two.feature +++ b/features/fixtures/features/two.feature @@ -1,6 +1,7 @@ Feature: two Another passing fixture +@smoke @slow Scenario: two passes When I run 'printf two' Then the output should include 'two' diff --git a/features/help.feature b/features/help.feature index aaa7468..48a8d8f 100644 --- a/features/help.feature +++ b/features/help.feature @@ -5,3 +5,6 @@ Scenario: Run --help When I run 'shellkin --help' Then the output should include 'shellkin [TARGET] [OPTIONS]' And the output should include '--fail-fast, -f' + And the output should include '--default-target DIR' + And the output should include '--tag, -t' + And the output should include '--exclude-tag, -x' diff --git a/features/test.feature b/features/test.feature index 673dfe0..e1baa3e 100644 --- a/features/test.feature +++ b/features/test.feature @@ -7,6 +7,13 @@ Scenario: Running all feature tests And the output should include '3 scenarios, 0 failing' And the exit code should mean success +Scenario: Filtering scenarios by tag + When I run 'shellkin -t @smoke -x @slow features/fixtures/features' + Then the output should include 'Feature: one' + And the output should include 'Scenario 2: one passes' + 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 4baae35..1378d76 100755 --- a/shellkin +++ b/shellkin @@ -21,7 +21,11 @@ root_command() { validate_mode=${args['--validate']:-0} init_mode=${args['--init']:-0} load_args=${args['--load']:-} + tag_args=${args['--tag']:-} + exclude_tag_args=${args['--exclude-tag']:-} load_paths=() + include_tags=() + exclude_tags=() feature_status=0 validation_status=0 validation_total=0 @@ -32,6 +36,19 @@ root_command() { eval "load_paths=( $load_args )" fi + if [[ -n $tag_args ]]; then + eval "include_tags=( $tag_args )" + fi + + if [[ -n $exclude_tag_args ]]; then + eval "exclude_tags=( $exclude_tag_args )" + fi + + if ! feature_tags_validate "${include_tags[@]}" "${exclude_tags[@]}"; then + printf 'validation error in TAGS:\n%s\n' "$FEATURE_TAG_ERROR" >&2 + return 1 + fi + if ((init_mode != 0)); then init_target=${target_arg:-$default_target} @@ -150,6 +167,8 @@ root_command() { TEST_SCENARIOS_TOTAL=0 TEST_SCENARIOS_FAILED=0 TEST_FAIL_FAST="$fail_fast" + TEST_INCLUDE_TAGS=("${include_tags[@]}") + TEST_EXCLUDE_TAGS=("${exclude_tags[@]}") TEST_ABORT_RUN=0 for feature_file in "${feature_files[@]}"; do @@ -205,21 +224,33 @@ shellkin_usage() { # :flag.usage printf " %s\n" "$(green "--validate, -v")" printf " Validate feature and step definition files\n" - printf " %s\n" "Conflicts: --init" + printf " %s\n" "Conflicts: --init, --tag, --exclude-tag" echo # :flag.usage printf " %s\n" "$(green "--init")" printf " Initialize a Shellkin features directory\n" - printf " %s\n" "Conflicts: --validate, --fail-fast, --load" + printf " %s\n" "Conflicts: --validate, --fail-fast, --load, --tag, --exclude-tag" echo # :flag.usage - printf " %s\n" "$(green "--default-target, -t DIR")" + printf " %s\n" "$(green "--default-target DIR")" printf " Path to features directory \n Relative to working directory \n Normally only used in a .shellkin argfile\n" printf " %s\n" "Default: features" echo + # :flag.usage + printf " %s\n" "$(green "--tag, -t TAG (repeatable)")" + printf " Run scenarios with this tag\n" + printf " %s\n" "Conflicts: --init, --validate" + echo + + # :flag.usage + printf " %s\n" "$(green "--exclude-tag, -x TAG (repeatable)")" + printf " Skip scenarios with this tag\n" + printf " %s\n" "Conflicts: --init, --validate" + echo + # :flag.usage printf " %s\n" "$(green "--stepdefs, -s DIR")" printf " Path to step definitions directory \n Relative to features root\n" @@ -372,11 +403,16 @@ feature_run() { local doc_string_indent= local doc_string_content= local doc_string_line= + local -a feature_tags=() + local -a pending_tags=() + local -a parsed_tags=() + local -a scenario_tags=() local -a background_steps=() local -a scenario_steps=() FEATURE_NAME= FEATURE_FILE=$feature_file + FEATURE_SCENARIO_TAGS= set +e @@ -413,10 +449,42 @@ feature_run() { section=feature in_description=1 FEATURE_NAME=$FEATURE_LINE_NAME + # shellcheck disable=SC2034 # consumed through nameref by feature__scenario_tags_set + feature_tags=("${pending_tags[@]}") + pending_tags=() + continue + ;; + tag) + parsed_tags=() + feature__tags_parse "$FEATURE_TAG_TEXT" parsed_tags || { + failed=1 + break + } + if ((feature_seen == 0)); then + pending_tags+=("${parsed_tags[@]}") + else + if ((scenario_seen != 0)); then + feature__scenario_tags_set feature_tags scenario_tags + if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then + : + else + failed=1 + if ((TEST_FAIL_FAST != 0)); then + TEST_ABORT_RUN=1 + scenario_seen=0 + break + fi + fi + scenario_seen=0 + fi + pending_tags+=("${parsed_tags[@]}") + section=tag + in_description=0 + fi continue ;; background) - if ((feature_seen == 0 || scenario_seen != 0)); then + if ((feature_seen == 0 || scenario_seen != 0 || ${#pending_tags[@]} != 0)); then failed=1 break fi @@ -430,6 +498,7 @@ feature_run() { break fi if ((scenario_seen != 0)); then + feature__scenario_tags_set feature_tags scenario_tags if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then : else @@ -445,6 +514,9 @@ feature_run() { section=scenario in_description=0 FEATURE_SCENARIO_NAME=$FEATURE_LINE_NAME + # shellcheck disable=SC2034 # consumed through nameref by feature__scenario_tags_set + scenario_tags=("${pending_tags[@]}") + pending_tags=() scenario_steps=() continue ;; @@ -478,7 +550,12 @@ feature_run() { esac done <"$feature_file" + if ((failed == 0 && ${#pending_tags[@]} != 0)); then + failed=1 + fi + if ((failed == 0 && scenario_seen != 0)); then + feature__scenario_tags_set feature_tags scenario_tags if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then : else @@ -488,6 +565,7 @@ feature_run() { fi fi elif ((scenario_seen != 0)); then + feature__scenario_tags_set feature_tags scenario_tags if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then : else @@ -614,6 +692,10 @@ feature_validate() { local doc_string_content= local doc_string_line= local doc_string_start_line=0 + local pending_tags_line=0 + local pending_tags_context= + local -a pending_tags=() + local -a parsed_tags=() local -a background_steps=() local -a background_step_lines=() local -a scenario_steps=() @@ -669,6 +751,36 @@ feature_validate() { section=feature in_description=1 FEATURE_NAME=$FEATURE_LINE_NAME + pending_tags=() + pending_tags_line=0 + pending_tags_context= + continue + ;; + tag) + parsed_tags=() + if feature__tags_parse "$FEATURE_TAG_TEXT" parsed_tags; then + : + else + feature_validation_set_error "$line_number" "invalid tag syntax" "$(trim "$line")" + failed=1 + break + fi + if ((feature_seen == 0)); then + pending_tags+=("${parsed_tags[@]}") + else + if ((scenario_seen != 0)); then + feature_scenario_validate background_steps background_step_lines scenario_steps scenario_step_lines || failed=1 + ((failed == 0)) || break + scenario_seen=0 + fi + pending_tags+=("${parsed_tags[@]}") + section=tag + in_description=0 + fi + if ((pending_tags_line == 0)); then + pending_tags_line=$line_number + pending_tags_context=$(trim "$line") + fi continue ;; background) @@ -677,6 +789,11 @@ feature_validate() { failed=1 break fi + if ((pending_tags_line != 0)); then + feature_validation_set_error "$pending_tags_line" "tag must appear before Feature or Scenario" "$pending_tags_context" + failed=1 + break + fi section=background in_description=0 continue @@ -695,6 +812,9 @@ feature_validate() { section=scenario in_description=0 FEATURE_SCENARIO_NAME=$FEATURE_LINE_NAME + pending_tags=() + pending_tags_line=0 + pending_tags_context= scenario_steps=() scenario_step_lines=() continue @@ -741,6 +861,11 @@ feature_validate() { failed=1 fi + if ((failed == 0 && pending_tags_line != 0)); then + feature_validation_set_error "$pending_tags_line" "tag must appear before Feature or Scenario" "$pending_tags_context" + failed=1 + fi + if ((failed == 0 && scenario_seen != 0)); then feature_scenario_validate background_steps background_step_lines scenario_steps scenario_step_lines || failed=1 fi @@ -765,6 +890,8 @@ feature_scenario_run() { fi TARGET_SCENARIO_MATCHED=1 + feature__scenario_tag_match || return 0 + ((TEST_SCENARIOS_TOTAL += 1)) FEATURE_PREVIOUS_STEP_TYPE= SCENARIO_DEFERRED_COMMANDS=() @@ -846,6 +973,102 @@ feature_doc_string_apply() { esac } +feature_tags_validate() { + local tag + + FEATURE_TAG_ERROR= + for tag in "$@"; do + if feature__tag_valid "$tag"; then + : + else + FEATURE_TAG_ERROR="invalid tag: $tag" + return 1 + fi + done +} + +feature__tags_parse() { + local text=$1 + local -n tags_ref=$2 + local tag + + tags_ref=() + for tag in $text; do + feature__tag_valid "$tag" || return 1 + tags_ref+=("$tag") + done + + ((${#tags_ref[@]} > 0)) +} + +feature__scenario_tags_set() { + local -n feature_tags_ref=$1 + local -n scenario_tags_ref=$2 + local tag + local -a tags=() + + FEATURE_SCENARIO_TAGS= + + for tag in "${feature_tags_ref[@]}" "${scenario_tags_ref[@]}"; do + feature__tags_include tags "$tag" && continue + tags+=("$tag") + done + + ((${#tags[@]} == 0)) && return 0 + printf -v FEATURE_SCENARIO_TAGS '%s ' "${tags[@]}" + FEATURE_SCENARIO_TAGS=${FEATURE_SCENARIO_TAGS% } +} + +feature__tags_include() { + # shellcheck disable=SC2178 # nameref to an array variable by name + local -n tags_ref=$1 + local expected=$2 + local tag + + for tag in "${tags_ref[@]}"; do + [[ $tag == "$expected" ]] && return 0 + done + + return 1 +} + +feature__tag_valid() { + [[ $1 =~ ^@[[:alnum:]_][[:alnum:]_.:-]*$ ]] +} + +feature__scenario_tag_match() { + local tag + local include_matched=0 + local -a include_tags=() + local -a exclude_tags=() + local -a scenario_tags=() + + if declare -p TEST_INCLUDE_TAGS >/dev/null 2>&1; then + include_tags=("${TEST_INCLUDE_TAGS[@]}") + fi + if declare -p TEST_EXCLUDE_TAGS >/dev/null 2>&1; then + exclude_tags=("${TEST_EXCLUDE_TAGS[@]}") + fi + + # shellcheck disable=SC2034 # consumed through nameref by feature__tags_include + read -r -a scenario_tags <<<"$FEATURE_SCENARIO_TAGS" + + for tag in "${exclude_tags[@]}"; do + feature__tags_include scenario_tags "$tag" && return 1 + done + + ((${#include_tags[@]} == 0)) && return 0 + + for tag in "${include_tags[@]}"; do + if feature__tags_include scenario_tags "$tag"; then + include_matched=1 + break + fi + done + + ((include_matched != 0)) +} + # src/lib/feature/syntax.sh feature_line_parse() { local line @@ -856,6 +1079,7 @@ feature_line_parse() { FEATURE_LINE_NAME= FEATURE_STEP_TYPE= FEATURE_STEP_TEXT= + FEATURE_TAG_TEXT= if [[ -z $line ]]; then FEATURE_LINE_KIND=blank @@ -884,6 +1108,12 @@ feature_line_parse() { return 0 fi + if [[ $line == @* ]]; then + FEATURE_LINE_KIND=tag + FEATURE_TAG_TEXT=$line + return 0 + fi + if [[ $line =~ ^(Given|When|Then|And|But|\*)[[:space:]]+(.+)$ ]]; then FEATURE_LINE_KIND=step FEATURE_STEP_TYPE=${BASH_REMATCH[1]} @@ -1773,13 +2003,37 @@ parse_requirements() { ;; # :flag.argfile_case - --default-target | -t) + --default-target) # :flag.argfile_case_arg if [[ -n "$argfile_value" ]]; then [[ -n ${args['--default-target']+x} ]] || args['--default-target']="$argfile_value" fi ;; # :flag.argfile_case + --tag | -t) + # :flag.argfile_case_arg + if [[ -n "$argfile_value" ]]; then + escaped="$(printf '%q' "$argfile_value")" + if [[ -z ${args['--tag']+x} ]]; then + args['--tag']="$escaped" + else + args['--tag']="${args['--tag']} $escaped" + fi + fi + ;; + # :flag.argfile_case + --exclude-tag | -x) + # :flag.argfile_case_arg + if [[ -n "$argfile_value" ]]; then + escaped="$(printf '%q' "$argfile_value")" + if [[ -z ${args['--exclude-tag']+x} ]]; then + args['--exclude-tag']="$escaped" + else + args['--exclude-tag']="${args['--exclude-tag']} $escaped" + fi + fi + ;; + # :flag.argfile_case --stepdefs | -s) # :flag.argfile_case_arg if [[ -n "$argfile_value" ]]; then @@ -1826,10 +2080,12 @@ parse_requirements() { # :flag.case --validate | -v) # :flag.conflicts - if [[ -n "${args['--init']:-}" ]]; then - printf "conflicting options: %s cannot be used with %s\n" "$key" "--init" >&2 - exit 1 - fi + for conflict in --init --tag --exclude-tag; do + if [[ -n "${args[$conflict]:-}" ]]; then + printf "conflicting options: %s cannot be used with %s\n" "$key" "$conflict" >&2 + exit 1 + fi + done # :flag.case_no_arg args['--validate']=1 @@ -1839,7 +2095,7 @@ parse_requirements() { # :flag.case --init) # :flag.conflicts - for conflict in --validate --fail-fast --load; do + for conflict in --validate --fail-fast --load --tag --exclude-tag; do if [[ -n "${args[$conflict]:-}" ]]; then printf "conflicting options: %s cannot be used with %s\n" "$key" "$conflict" >&2 exit 1 @@ -1852,7 +2108,7 @@ parse_requirements() { ;; # :flag.case - --default-target | -t) + --default-target) # :flag.case_arg if [[ -n ${2+x} ]]; then @@ -1860,7 +2116,59 @@ parse_requirements() { shift shift else - printf "%s\n" "--default-target requires an argument: --default-target, -t DIR" >&2 + printf "%s\n" "--default-target requires an argument: --default-target DIR" >&2 + exit 1 + fi + ;; + + # :flag.case + --tag | -t) + # :flag.conflicts + for conflict in --init --validate; do + if [[ -n "${args[$conflict]:-}" ]]; then + printf "conflicting options: %s cannot be used with %s\n" "$key" "$conflict" >&2 + exit 1 + fi + done + + # :flag.case_arg + if [[ -n ${2+x} ]]; then + escaped="$(printf '%q' "$2")" + if [[ -z ${args['--tag']+x} ]]; then + args['--tag']="$escaped" + else + args['--tag']="${args['--tag']} $escaped" + fi + shift + shift + else + printf "%s\n" "--tag requires an argument: --tag, -t TAG" >&2 + exit 1 + fi + ;; + + # :flag.case + --exclude-tag | -x) + # :flag.conflicts + for conflict in --init --validate; do + if [[ -n "${args[$conflict]:-}" ]]; then + printf "conflicting options: %s cannot be used with %s\n" "$key" "$conflict" >&2 + exit 1 + fi + done + + # :flag.case_arg + if [[ -n ${2+x} ]]; then + escaped="$(printf '%q' "$2")" + if [[ -z ${args['--exclude-tag']+x} ]]; then + args['--exclude-tag']="$escaped" + else + args['--exclude-tag']="${args['--exclude-tag']} $escaped" + fi + shift + shift + else + printf "%s\n" "--exclude-tag requires an argument: --exclude-tag, -x TAG" >&2 exit 1 fi ;; diff --git a/skills/shellkin/SKILL.md b/skills/shellkin/SKILL.md index 96b60f1..0ddf4cf 100644 --- a/skills/shellkin/SKILL.md +++ b/skills/shellkin/SKILL.md @@ -67,6 +67,8 @@ If that file grows too large, keep `support.sh` as the entrypoint, add a sibling - Free text under `Feature:` is allowed before the first section. - Use `Background:` only for setup shared by every scenario in that file. - Use `Scenario:` for each executable example. +- Tags such as `@slow` may be placed before `Feature:` or `Scenario:`. +- Use `shellkin -t @tag` to run matching scenarios and `shellkin -x @tag` to skip matching scenarios. - Keep scenarios small and concrete. - `And`, `But`, and `*` inherit the semantic type of the previous step, so they cannot be the first step in a scenario or background. - Use doc strings with `"""` for multiline expectations or input. @@ -77,7 +79,7 @@ Do not use unsupported constructs: - `Scenario Outline` - `Examples` - data tables -- tags such as `@slow` +- hooks ## Step Definition Rules diff --git a/skills/shellkin/references/patterns.md b/skills/shellkin/references/patterns.md index 05c6534..ad45c4a 100644 --- a/skills/shellkin/references/patterns.md +++ b/skills/shellkin/references/patterns.md @@ -91,7 +91,7 @@ Use generic path-oriented steps when possible so they can be reused in many feat When reviewing Shellkin tests, check for: -- Unsupported Gherkin forms such as `Scenario Outline`, `Examples`, tags, or tables +- Unsupported Gherkin forms such as `Scenario Outline`, `Examples`, hooks, or tables - `And` or `*` used as the first step in a scenario or background - Step text in features that does not exactly line up with step definition headers - One-off step definitions that should be generalized with `{tokens}` diff --git a/src/bashly.yml b/src/bashly.yml index 1a1c64f..f4c7183 100644 --- a/src/bashly.yml +++ b/src/bashly.yml @@ -27,18 +27,29 @@ flags: - long: --validate short: -v help: Validate feature and step definition files - conflicts: [--init] + conflicts: [--init, --tag, --exclude-tag] - long: --init help: Initialize a Shellkin features directory - conflicts: [--validate, --fail-fast, --load] + conflicts: [--validate, --fail-fast, --load, --tag, --exclude-tag] - long: --default-target - short: -t help: | Path to features directory Relative to working directory Normally only used in a .shellkin argfile arg: dir default: features +- long: --tag + short: -t + arg: tag + help: Run scenarios with this tag + repeatable: true + conflicts: [--init, --validate] +- long: --exclude-tag + short: -x + arg: tag + help: Skip scenarios with this tag + repeatable: true + conflicts: [--init, --validate] - long: --stepdefs short: -s arg: dir diff --git a/src/lib/feature/core.sh b/src/lib/feature/core.sh index 5f07eda..3e8d200 100644 --- a/src/lib/feature/core.sh +++ b/src/lib/feature/core.sh @@ -12,11 +12,16 @@ feature_run() { local doc_string_indent= local doc_string_content= local doc_string_line= + local -a feature_tags=() + local -a pending_tags=() + local -a parsed_tags=() + local -a scenario_tags=() local -a background_steps=() local -a scenario_steps=() FEATURE_NAME= FEATURE_FILE=$feature_file + FEATURE_SCENARIO_TAGS= set +e @@ -53,10 +58,42 @@ feature_run() { section=feature in_description=1 FEATURE_NAME=$FEATURE_LINE_NAME + # shellcheck disable=SC2034 # consumed through nameref by feature__scenario_tags_set + feature_tags=("${pending_tags[@]}") + pending_tags=() + continue + ;; + tag) + parsed_tags=() + feature__tags_parse "$FEATURE_TAG_TEXT" parsed_tags || { + failed=1 + break + } + if ((feature_seen == 0)); then + pending_tags+=("${parsed_tags[@]}") + else + if ((scenario_seen != 0)); then + feature__scenario_tags_set feature_tags scenario_tags + if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then + : + else + failed=1 + if ((TEST_FAIL_FAST != 0)); then + TEST_ABORT_RUN=1 + scenario_seen=0 + break + fi + fi + scenario_seen=0 + fi + pending_tags+=("${parsed_tags[@]}") + section=tag + in_description=0 + fi continue ;; background) - if ((feature_seen == 0 || scenario_seen != 0)); then + if ((feature_seen == 0 || scenario_seen != 0 || ${#pending_tags[@]} != 0)); then failed=1 break fi @@ -70,6 +107,7 @@ feature_run() { break fi if ((scenario_seen != 0)); then + feature__scenario_tags_set feature_tags scenario_tags if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then : else @@ -85,6 +123,9 @@ feature_run() { section=scenario in_description=0 FEATURE_SCENARIO_NAME=$FEATURE_LINE_NAME + # shellcheck disable=SC2034 # consumed through nameref by feature__scenario_tags_set + scenario_tags=("${pending_tags[@]}") + pending_tags=() scenario_steps=() continue ;; @@ -118,7 +159,12 @@ feature_run() { esac done <"$feature_file" + if ((failed == 0 && ${#pending_tags[@]} != 0)); then + failed=1 + fi + if ((failed == 0 && scenario_seen != 0)); then + feature__scenario_tags_set feature_tags scenario_tags if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then : else @@ -128,6 +174,7 @@ feature_run() { fi fi elif ((scenario_seen != 0)); then + feature__scenario_tags_set feature_tags scenario_tags if feature_scenario_run "$FEATURE_SCENARIO_NAME" background_steps scenario_steps; then : else @@ -260,6 +307,10 @@ feature_validate() { local doc_string_content= local doc_string_line= local doc_string_start_line=0 + local pending_tags_line=0 + local pending_tags_context= + local -a pending_tags=() + local -a parsed_tags=() local -a background_steps=() local -a background_step_lines=() local -a scenario_steps=() @@ -315,6 +366,36 @@ feature_validate() { section=feature in_description=1 FEATURE_NAME=$FEATURE_LINE_NAME + pending_tags=() + pending_tags_line=0 + pending_tags_context= + continue + ;; + tag) + parsed_tags=() + if feature__tags_parse "$FEATURE_TAG_TEXT" parsed_tags; then + : + else + feature_validation_set_error "$line_number" "invalid tag syntax" "$(trim "$line")" + failed=1 + break + fi + if ((feature_seen == 0)); then + pending_tags+=("${parsed_tags[@]}") + else + if ((scenario_seen != 0)); then + feature_scenario_validate background_steps background_step_lines scenario_steps scenario_step_lines || failed=1 + ((failed == 0)) || break + scenario_seen=0 + fi + pending_tags+=("${parsed_tags[@]}") + section=tag + in_description=0 + fi + if ((pending_tags_line == 0)); then + pending_tags_line=$line_number + pending_tags_context=$(trim "$line") + fi continue ;; background) @@ -323,6 +404,11 @@ feature_validate() { failed=1 break fi + if ((pending_tags_line != 0)); then + feature_validation_set_error "$pending_tags_line" "tag must appear before Feature or Scenario" "$pending_tags_context" + failed=1 + break + fi section=background in_description=0 continue @@ -341,6 +427,9 @@ feature_validate() { section=scenario in_description=0 FEATURE_SCENARIO_NAME=$FEATURE_LINE_NAME + pending_tags=() + pending_tags_line=0 + pending_tags_context= scenario_steps=() scenario_step_lines=() continue @@ -387,6 +476,11 @@ feature_validate() { failed=1 fi + if ((failed == 0 && pending_tags_line != 0)); then + feature_validation_set_error "$pending_tags_line" "tag must appear before Feature or Scenario" "$pending_tags_context" + failed=1 + fi + if ((failed == 0 && scenario_seen != 0)); then feature_scenario_validate background_steps background_step_lines scenario_steps scenario_step_lines || failed=1 fi @@ -412,6 +506,8 @@ feature_scenario_run() { fi TARGET_SCENARIO_MATCHED=1 + feature__scenario_tag_match || return 0 + ((TEST_SCENARIOS_TOTAL += 1)) FEATURE_PREVIOUS_STEP_TYPE= SCENARIO_DEFERRED_COMMANDS=() @@ -493,3 +589,99 @@ feature_doc_string_apply() { ;; esac } + +feature_tags_validate() { + local tag + + FEATURE_TAG_ERROR= + for tag in "$@"; do + if feature__tag_valid "$tag"; then + : + else + FEATURE_TAG_ERROR="invalid tag: $tag" + return 1 + fi + done +} + +feature__tags_parse() { + local text=$1 + local -n tags_ref=$2 + local tag + + tags_ref=() + for tag in $text; do + feature__tag_valid "$tag" || return 1 + tags_ref+=("$tag") + done + + ((${#tags_ref[@]} > 0)) +} + +feature__scenario_tags_set() { + local -n feature_tags_ref=$1 + local -n scenario_tags_ref=$2 + local tag + local -a tags=() + + FEATURE_SCENARIO_TAGS= + + for tag in "${feature_tags_ref[@]}" "${scenario_tags_ref[@]}"; do + feature__tags_include tags "$tag" && continue + tags+=("$tag") + done + + ((${#tags[@]} == 0)) && return 0 + printf -v FEATURE_SCENARIO_TAGS '%s ' "${tags[@]}" + FEATURE_SCENARIO_TAGS=${FEATURE_SCENARIO_TAGS% } +} + +feature__tags_include() { + # shellcheck disable=SC2178 # nameref to an array variable by name + local -n tags_ref=$1 + local expected=$2 + local tag + + for tag in "${tags_ref[@]}"; do + [[ $tag == "$expected" ]] && return 0 + done + + return 1 +} + +feature__tag_valid() { + [[ $1 =~ ^@[[:alnum:]_][[:alnum:]_.:-]*$ ]] +} + +feature__scenario_tag_match() { + local tag + local include_matched=0 + local -a include_tags=() + local -a exclude_tags=() + local -a scenario_tags=() + + if declare -p TEST_INCLUDE_TAGS >/dev/null 2>&1; then + include_tags=("${TEST_INCLUDE_TAGS[@]}") + fi + if declare -p TEST_EXCLUDE_TAGS >/dev/null 2>&1; then + exclude_tags=("${TEST_EXCLUDE_TAGS[@]}") + fi + + # shellcheck disable=SC2034 # consumed through nameref by feature__tags_include + read -r -a scenario_tags <<<"$FEATURE_SCENARIO_TAGS" + + for tag in "${exclude_tags[@]}"; do + feature__tags_include scenario_tags "$tag" && return 1 + done + + ((${#include_tags[@]} == 0)) && return 0 + + for tag in "${include_tags[@]}"; do + if feature__tags_include scenario_tags "$tag"; then + include_matched=1 + break + fi + done + + ((include_matched != 0)) +} diff --git a/src/lib/feature/syntax.sh b/src/lib/feature/syntax.sh index afe5001..b80bbcd 100644 --- a/src/lib/feature/syntax.sh +++ b/src/lib/feature/syntax.sh @@ -8,6 +8,7 @@ feature_line_parse() { FEATURE_LINE_NAME= FEATURE_STEP_TYPE= FEATURE_STEP_TEXT= + FEATURE_TAG_TEXT= if [[ -z $line ]]; then FEATURE_LINE_KIND=blank @@ -36,6 +37,12 @@ feature_line_parse() { return 0 fi + if [[ $line == @* ]]; then + FEATURE_LINE_KIND=tag + FEATURE_TAG_TEXT=$line + return 0 + fi + if [[ $line =~ ^(Given|When|Then|And|But|\*)[[:space:]]+(.+)$ ]]; then FEATURE_LINE_KIND=step FEATURE_STEP_TYPE=${BASH_REMATCH[1]} diff --git a/src/root_command.sh b/src/root_command.sh index 8020a27..9c22b37 100644 --- a/src/root_command.sh +++ b/src/root_command.sh @@ -7,7 +7,11 @@ fail_fast=${args['--fail-fast']:-0} validate_mode=${args['--validate']:-0} init_mode=${args['--init']:-0} load_args=${args['--load']:-} +tag_args=${args['--tag']:-} +exclude_tag_args=${args['--exclude-tag']:-} load_paths=() +include_tags=() +exclude_tags=() feature_status=0 validation_status=0 validation_total=0 @@ -18,6 +22,19 @@ if [[ -n $load_args ]]; then eval "load_paths=( $load_args )" fi +if [[ -n $tag_args ]]; then + eval "include_tags=( $tag_args )" +fi + +if [[ -n $exclude_tag_args ]]; then + eval "exclude_tags=( $exclude_tag_args )" +fi + +if ! feature_tags_validate "${include_tags[@]}" "${exclude_tags[@]}"; then + printf 'validation error in TAGS:\n%s\n' "$FEATURE_TAG_ERROR" >&2 + return 1 +fi + if ((init_mode != 0)); then init_target=${target_arg:-$default_target} @@ -136,6 +153,8 @@ else TEST_SCENARIOS_TOTAL=0 TEST_SCENARIOS_FAILED=0 TEST_FAIL_FAST="$fail_fast" + TEST_INCLUDE_TAGS=("${include_tags[@]}") + TEST_EXCLUDE_TAGS=("${exclude_tags[@]}") TEST_ABORT_RUN=0 for feature_file in "${feature_files[@]}"; do diff --git a/test/commands/test.bats b/test/commands/test.bats index 8ee4250..e775bd3 100644 --- a/test/commands/test.bats +++ b/test/commands/test.bats @@ -43,6 +43,109 @@ EOF assert_output_contains "1 scenario, 0 failing" } +@test "shellkin accepts feature and scenario tags" { + write_file features/sample.feature <<'EOF' +@filesystem +Feature: Create a file + +@smoke @needs-server +Scenario: Touch a file + Then I write 'tagged' +EOF + + write_file features/step_definitions/core.sh <<'EOF' +@Then I write '{text}' +printf '%s' "$text" > "$TEST_ROOT/result.txt" +EOF + + run "$SHELLKIN_REPO_ROOT/shellkin" "$TEST_ROOT/features" + + [ "$status" -eq 0 ] + [ "$(cat "$TEST_ROOT/result.txt")" = "tagged" ] + assert_output_contains "Feature: Create a file" + assert_output_contains "Scenario 1: Touch a file" + assert_output_contains "1 scenario, 0 failing" +} + +@test "shellkin filters scenarios by included and excluded tags" { + write_file features/sample.feature <<'EOF' +Feature: Tagged scenarios + +@slow +Scenario: Slow + Then I write 'slow' + +@smoke +Scenario: Smoke + Then I write 'smoke' + +@unit +Scenario: Unit + Then I write 'unit' +EOF + + write_file features/step_definitions/core.sh <<'EOF' +@Then I write '{text}' +printf '%s\n' "$text" >> "$TEST_ROOT/result.txt" +EOF + + run "$SHELLKIN_REPO_ROOT/shellkin" -t @smoke -t @unit -x @slow "$TEST_ROOT/features" + + [ "$status" -eq 0 ] + [ "$(cat "$TEST_ROOT/result.txt")" = $'smoke\nunit' ] + [[ "$output" != *"Scenario 1: Slow"* ]] + assert_output_contains "Scenario 2: Smoke" + assert_output_contains "Scenario 3: Unit" + assert_output_contains "2 scenarios, 0 failing" +} + +@test "shellkin tag filters include inherited feature tags" { + write_file features/sample.feature <<'EOF' +@filesystem +Feature: Tagged feature + +@slow +Scenario: Slow + Then I write 'slow' + +Scenario: Normal + Then I write 'normal' +EOF + + write_file features/step_definitions/core.sh <<'EOF' +@Then I write '{text}' +printf '%s\n' "$text" >> "$TEST_ROOT/result.txt" +EOF + + run "$SHELLKIN_REPO_ROOT/shellkin" --tag @filesystem --exclude-tag @slow "$TEST_ROOT/features" + + [ "$status" -eq 0 ] + [ "$(cat "$TEST_ROOT/result.txt")" = "normal" ] + [[ "$output" != *"Scenario 1: Slow"* ]] + assert_output_contains "Scenario 2: Normal" + assert_output_contains "1 scenario, 0 failing" +} + +@test "shellkin rejects invalid CLI tag filters" { + write_file features/sample.feature <<'EOF' +Feature: Tagged feature + +Scenario: Example + Then I pass +EOF + + write_file features/step_definitions/core.sh <<'EOF' +@Then I pass +true +EOF + + run "$SHELLKIN_REPO_ROOT/shellkin" -t smoke "$TEST_ROOT/features" + + [ "$status" -eq 1 ] + assert_output_contains "validation error in TAGS:" + assert_output_contains "invalid tag: smoke" +} + @test "shellkin uses --stepdefs relative to the features root" { write_file custom_features/sample.feature <<'EOF' Feature: Create a file diff --git a/test/commands/validate.bats b/test/commands/validate.bats index f118a86..7838325 100644 --- a/test/commands/validate.bats +++ b/test/commands/validate.bats @@ -75,3 +75,48 @@ EOF [ "$status" -eq 1 ] assert_output_contains "line 7: Background must appear after Feature and before the first Scenario" } + +@test "shellkin --validate fails for invalid tag syntax" { + write_file features/sample.feature <<'EOF' +Feature: Invalid tag + +@valid not-a-tag +Scenario: Example + Given setup +EOF + + write_file features/step_definitions/core.sh <<'EOF' +@Given setup +true +EOF + + run "$SHELLKIN_REPO_ROOT/shellkin" --validate "$TEST_ROOT/features" + + [ "$status" -eq 1 ] + assert_output_contains "line 3: invalid tag syntax" + assert_output_contains "@valid not-a-tag" +} + +@test "shellkin --validate fails for a tag before Background" { + write_file features/sample.feature <<'EOF' +Feature: Invalid tag placement + +@setup +Background: + Given setup + +Scenario: Example + Given setup +EOF + + write_file features/step_definitions/core.sh <<'EOF' +@Given setup +true +EOF + + run "$SHELLKIN_REPO_ROOT/shellkin" --validate "$TEST_ROOT/features" + + [ "$status" -eq 1 ] + assert_output_contains "line 3: tag must appear before Feature or Scenario" + assert_output_contains "@setup" +} diff --git a/test/lib/feature/syntax.bats b/test/lib/feature/syntax.bats index d57e758..57d2146 100644 --- a/test/lib/feature/syntax.bats +++ b/test/lib/feature/syntax.bats @@ -53,6 +53,13 @@ teardown() { [ "$FEATURE_LINE_KIND" = "comment" ] } +@test "feature_line_parse recognizes a tag line" { + feature_line_parse " @fast @needs-server" + + [ "$FEATURE_LINE_KIND" = "tag" ] + [ "$FEATURE_TAG_TEXT" = "@fast @needs-server" ] +} + @test "feature_line_parse leaves unsupported text as other" { feature_line_parse "plain descriptive text"