From 62604bf9cf5cd0769cc67fcf9c91ceba876df735 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Thu, 9 Jul 2026 13:06:10 +0300 Subject: [PATCH] - Add support for `@Before` and `@After` hooks --- README.md | 57 ++++- doc/shellkin-feature.5 | 2 +- doc/shellkin-feature.md | 2 +- doc/shellkin-stepdefs.5 | 58 ++++- doc/shellkin-stepdefs.md | 57 ++++- features/fixtures/hooks/hooks.feature | 11 + .../fixtures/hooks/step_definitions/core.sh | 14 + features/fixtures/hooks/support.sh | 3 + features/test.feature | 6 + shellkin | 240 ++++++++++++++++-- skills/shellkin/SKILL.md | 8 +- skills/shellkin/references/patterns.md | 2 +- src/lib/feature/core.sh | 15 ++ src/lib/output/test.sh | 8 + src/lib/stepdef/files.sh | 49 +++- src/lib/stepdef/hooks.sh | 102 ++++++++ src/lib/stepdef/parse.sh | 60 ++++- src/root_command.sh | 6 + test/lib/feature/core.bats | 63 ++++- test/lib/stepdef/files.bats | 47 +++- test/lib/stepdef/parse.bats | 24 ++ 21 files changed, 784 insertions(+), 50 deletions(-) create mode 100644 features/fixtures/hooks/hooks.feature create mode 100644 features/fixtures/hooks/step_definitions/core.sh create mode 100644 features/fixtures/hooks/support.sh create mode 100644 src/lib/stepdef/hooks.sh diff --git a/README.md b/README.md index bf9db3d..1e0f0b7 100644 --- a/README.md +++ b/README.md @@ -84,14 +84,16 @@ Implemented pieces include: | Doc strings (`"""`) | Supported | | Comments (`#`) | Supported | | Tags (`@tag`) | Supported | +| `Before`, `After` hooks | 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`. +`@Before` and `@After` hooks are declared in step definition files and may be +limited to a tag. ## Usage @@ -209,10 +211,6 @@ shellkin --init Step definitions are shell snippets declared in files under `step_definitions/`. -To share helper functions across step definition files, place them in -`support.sh` under the features directory. For additional support scripts, use -`--load` or configure them in `.shellkin`. - ```bash @When I run '{command}' run "$command" @@ -261,7 +259,54 @@ Then the text should include 'Jim "Jimbo" Jackson' The opening and closing quote in the feature step must match. Quoted token patterns do not match unquoted values. -Each definition continues until the next step header or the end of the file. +Each definition continues until the next step or hook header or the end of the +file. + +## Step Definition Hooks + +Step definition files can also declare scenario hooks with `@Before` and +`@After`. + +```bash +@Before + mkdir -p tmp + +@After + rm -rf tmp + +@Before @needs-server + ./server start + +@After @needs-server + ./server stop +``` + +Hooks without a tag run for every scenario. Tagged hooks run only for scenarios +with that tag, including tags inherited from the feature. `@Before` hooks run +before background and scenario steps. `@After` hooks run after the scenario +steps, even when a step or `@Before` hook fails. + +Hooks can call helper functions from `support.sh`: + +```bash +# features/support.sh +start_server() { + ./server start +} + +stop_server() { + ./server stop +} +``` + +```bash +# features/step_definitions/hooks.sh +@Before @needs-server + start_server + +@After @needs-server + stop_server +``` ## Step Helpers diff --git a/doc/shellkin-feature.5 b/doc/shellkin-feature.5 index 2154dc4..5397584 100644 --- a/doc/shellkin-feature.5 +++ b/doc/shellkin-feature.5 @@ -135,7 +135,7 @@ The following common Gherkin constructs are not currently supported: .IP \(bu 2 data tables .IP \(bu 2 -hooks +\f[B]BeforeAll\f[R] and \f[B]AfterAll\f[R] hooks .SH EXAMPLE .IP .EX diff --git a/doc/shellkin-feature.md b/doc/shellkin-feature.md index df2ff06..59214bb 100644 --- a/doc/shellkin-feature.md +++ b/doc/shellkin-feature.md @@ -162,7 +162,7 @@ The following common Gherkin constructs are not currently supported: - **Scenario Outline** - **Examples** - data tables -- hooks +- **BeforeAll** and **AfterAll** hooks EXAMPLE ================================================== diff --git a/doc/shellkin-stepdefs.5 b/doc/shellkin-stepdefs.5 index ffcfb6c..f22386a 100644 --- a/doc/shellkin-stepdefs.5 +++ b/doc/shellkin-stepdefs.5 @@ -41,8 +41,8 @@ The step body is plain shell code. .PP Indenting the body is recommended for readability, but optional. .PP -Each definition continues until the next valid step header or the end of -the file. +Each definition continues until the next valid step or hook header or +the end of the file. .SS Tokens Patterns may contain named tokens in braces. .IP @@ -74,6 +74,60 @@ Then the text should include \(aqJim \(dqJimbo\(dq Jackson\(aq .PP The opening and closing quote in the feature step must match. Quoted token patterns do not match unquoted values. +.SH HOOKS +Step definition files can also declare scenario hooks with +\f[B]\(atBefore\f[R] and \f[B]\(atAfter\f[R]. +.IP +.EX +\(atBefore + mkdir \-p tmp + +\(atAfter + rm \-rf tmp + +\(atBefore \(atneeds\-server + ./server start + +\(atAfter \(atneeds\-server + ./server stop +.EE +.PP +Hooks without a tag run for every scenario. +Tagged hooks run only for scenarios with that tag, including tags +inherited from the feature. +.PP +\f[B]\(atBefore\f[R] hooks run before background and scenario steps. +If a \f[B]\(atBefore\f[R] hook fails, the scenario fails and the +remaining steps are skipped. +.PP +\f[B]\(atAfter\f[R] hooks run after scenario steps, even when a step or +\f[B]\(atBefore\f[R] hook fails. +If an \f[B]\(atAfter\f[R] hook fails, the scenario fails. +.PP +Passing hooks are quiet. +Failing hooks are shown in the error report. +.PP +Hooks can call helper functions from \f[B]support.sh\f[R]: +.IP +.EX +\f[I]# features/support.sh\f[R] +start_server() \f[B]{\f[R] + ./server start +\f[B]}\f[R] + +stop_server() \f[B]{\f[R] + ./server stop +\f[B]}\f[R] +.EE +.IP +.EX +\f[I]# features/step_definitions/hooks.sh\f[R] +\(atBefore \(atneeds\-server + start_server + +\(atAfter \(atneeds\-server + stop_server +.EE .SH HELPERS .SS run Run a shell command and capture its result for later assertions. diff --git a/doc/shellkin-stepdefs.md b/doc/shellkin-stepdefs.md index 83cca9a..5fcb535 100644 --- a/doc/shellkin-stepdefs.md +++ b/doc/shellkin-stepdefs.md @@ -52,8 +52,8 @@ The step body is plain shell code. Indenting the body is recommended for readability, but optional. -Each definition continues until the next valid step header or the end of the -file. +Each definition continues until the next valid step or hook header or the end +of the file. Tokens -------------------------------------------------- @@ -88,6 +88,59 @@ Then the text should include 'Jim "Jimbo" Jackson' The opening and closing quote in the feature step must match. Quoted token patterns do not match unquoted values. +HOOKS +================================================== + +Step definition files can also declare scenario hooks with **@Before** and +**@After**. + +```bash +@Before + mkdir -p tmp + +@After + rm -rf tmp + +@Before @needs-server + ./server start + +@After @needs-server + ./server stop +``` + +Hooks without a tag run for every scenario. Tagged hooks run only for scenarios +with that tag, including tags inherited from the feature. + +**@Before** hooks run before background and scenario steps. If a **@Before** +hook fails, the scenario fails and the remaining steps are skipped. + +**@After** hooks run after scenario steps, even when a step or **@Before** hook +fails. If an **@After** hook fails, the scenario fails. + +Passing hooks are quiet. Failing hooks are shown in the error report. + +Hooks can call helper functions from **support.sh**: + +```bash +# features/support.sh +start_server() { + ./server start +} + +stop_server() { + ./server stop +} +``` + +```bash +# features/step_definitions/hooks.sh +@Before @needs-server + start_server + +@After @needs-server + stop_server +``` + HELPERS ================================================== diff --git a/features/fixtures/hooks/hooks.feature b/features/fixtures/hooks/hooks.feature new file mode 100644 index 0000000..9e889fb --- /dev/null +++ b/features/fixtures/hooks/hooks.feature @@ -0,0 +1,11 @@ +Feature: hooks + Before and After hooks + +@needs-server +Scenario: tagged hooks run before matching scenarios + Then the hook log should include 'before' + And the hook log should include 'server-start' + +Scenario: after hooks run after scenarios + Then the hook log should include 'after' + And the hook log should include 'server-stop' diff --git a/features/fixtures/hooks/step_definitions/core.sh b/features/fixtures/hooks/step_definitions/core.sh new file mode 100644 index 0000000..ab1708b --- /dev/null +++ b/features/fixtures/hooks/step_definitions/core.sh @@ -0,0 +1,14 @@ +@Before + printf 'before\n' >>"$HOOK_LOG" + +@After + printf 'after\n' >>"$HOOK_LOG" + +@Before @needs-server + printf 'server-start\n' >>"$HOOK_LOG" + +@After @needs-server + printf 'server-stop\n' >>"$HOOK_LOG" + +@Then the hook log should include '{text}' + [[ "$(cat "$HOOK_LOG")" == *"$text"* ]] diff --git a/features/fixtures/hooks/support.sh b/features/fixtures/hooks/support.sh new file mode 100644 index 0000000..aeb4bfa --- /dev/null +++ b/features/fixtures/hooks/support.sh @@ -0,0 +1,3 @@ +HOOK_LOG="${TMPDIR:-/tmp}/shellkin-hooks-$$.log" +export HOOK_LOG +: >"$HOOK_LOG" diff --git a/features/test.feature b/features/test.feature index e1baa3e..b15be7a 100644 --- a/features/test.feature +++ b/features/test.feature @@ -14,6 +14,12 @@ Scenario: Filtering scenarios by tag And the output should include '1 scenario, 0 failing' And the exit code should mean success +Scenario: Running hooks from step definition files + When I run 'shellkin features/fixtures/hooks' + Then the output should include 'Feature: hooks' + And the output should include '2 scenarios, 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 1378d76..5935ba2 100755 --- a/shellkin +++ b/shellkin @@ -84,6 +84,12 @@ root_command() { STEPDEF_TOKENS_LIST=() STEPDEF_CAPTURE_INDEXES_LIST=() STEPDEF_BODIES=() + SHELLKIN_BEFORE_HOOK_TAGS=() + SHELLKIN_BEFORE_HOOK_HEADERS=() + SHELLKIN_BEFORE_HOOK_BODIES=() + SHELLKIN_AFTER_HOOK_TAGS=() + SHELLKIN_AFTER_HOOK_HEADERS=() + SHELLKIN_AFTER_HOOK_BODIES=() if [[ -f $TARGET_PATH ]]; then FEATURES_DIR="$(dirname "$TARGET_PATH")" @@ -902,6 +908,14 @@ feature_scenario_run() { output_scenario_start "$scenario_number" "$scenario_name" set +e + if hooks__run_before_all; then + : + else + scenario_failed=1 + skip_remaining=1 + output_hook_failure "$HOOK_FAILED_HEADER" + fi + for step in "${background_steps_ref[@]}"; do if ((skip_remaining != 0)); then feature__recorded_step_parse "$step" @@ -931,6 +945,13 @@ feature_scenario_run() { fi done + if hooks__run_after_all; then + : + else + scenario_failed=1 + output_hook_failure "$HOOK_FAILED_HEADER" + fi + if defer__run_all; then : else @@ -1359,6 +1380,13 @@ output_deferred_failure() { output_error_report "Deferred cleanup" } +output_hook_failure() { + local hook_header=$1 + + red " ✗ $hook_header" + output_error_report "$hook_header" +} + output_summary() { local total_scenarios=$1 local failed_scenarios=$2 @@ -1529,12 +1557,20 @@ stepdefs_file_parse() { local next_regex= local next_tokens= local next_capture_indexes= + local next_header_kind= + local next_hook_type= + local next_hook_tag= + local next_hook_header= local parsed_new_header=0 local current_type= local current_pattern= local current_regex= local current_tokens= local current_capture_indexes= + local current_header_kind= + local current_hook_type= + local current_hook_tag= + local current_hook_header= local current_body= while IFS= read -r line || [[ -n $line ]]; do @@ -1544,36 +1580,48 @@ stepdefs_file_parse() { if [[ $trimmed_line == @* ]]; then if stepdef_parse "$trimmed_line"; then parsed_new_header=1 + next_header_kind=$STEPDEF_HEADER_KIND next_type=$STEPDEF_TYPE next_pattern=$STEPDEF_PATTERN next_regex=$STEPDEF_REGEX next_tokens=$STEPDEF_TOKENS next_capture_indexes=$STEPDEF_CAPTURE_INDEXES - elif [[ -z $current_type ]]; then + next_hook_type=$STEPDEF_HOOK_TYPE + next_hook_tag=$STEPDEF_HOOK_TAG + next_hook_header=$STEPDEF_HOOK_HEADER + elif [[ -z $current_header_kind ]]; then return 1 fi fi if ((parsed_new_header != 0)); then - if [[ -n $current_type ]]; then + if [[ -n $current_header_kind ]]; then + STEPDEF_HEADER_KIND=$current_header_kind STEPDEF_TYPE=$current_type STEPDEF_PATTERN=$current_pattern STEPDEF_REGEX=$current_regex STEPDEF_TOKENS=$current_tokens STEPDEF_CAPTURE_INDEXES=$current_capture_indexes - stepdef_register "$current_body" + STEPDEF_HOOK_TYPE=$current_hook_type + STEPDEF_HOOK_TAG=$current_hook_tag + STEPDEF_HOOK_HEADER=$current_hook_header + stepdefs__current_register "$current_body" fi + current_header_kind=$next_header_kind current_type=$next_type current_pattern=$next_pattern current_regex=$next_regex current_tokens=$next_tokens current_capture_indexes=$next_capture_indexes + current_hook_type=$next_hook_type + current_hook_tag=$next_hook_tag + current_hook_header=$next_hook_header current_body= continue fi - if [[ -z $current_type ]]; then + if [[ -z $current_header_kind ]]; then continue fi @@ -1583,16 +1631,136 @@ stepdefs_file_parse() { current_body+=$line done <"$file" - if [[ -n $current_type ]]; then + if [[ -n $current_header_kind ]]; then + STEPDEF_HEADER_KIND=$current_header_kind STEPDEF_TYPE=$current_type STEPDEF_PATTERN=$current_pattern STEPDEF_REGEX=$current_regex STEPDEF_TOKENS=$current_tokens STEPDEF_CAPTURE_INDEXES=$current_capture_indexes - stepdef_register "$current_body" + STEPDEF_HOOK_TYPE=$current_hook_type + STEPDEF_HOOK_TAG=$current_hook_tag + STEPDEF_HOOK_HEADER=$current_hook_header + stepdefs__current_register "$current_body" fi } +stepdefs__current_register() { + local body=$1 + + case $STEPDEF_HEADER_KIND in + step) + stepdef_register "$body" + ;; + hook) + stepdef_hook_register "$body" + ;; + esac +} + +# src/lib/stepdef/hooks.sh +stepdef_hook_register() { + local body=$1 + + case $STEPDEF_HOOK_TYPE in + Before) + SHELLKIN_BEFORE_HOOK_TAGS+=("$STEPDEF_HOOK_TAG") + SHELLKIN_BEFORE_HOOK_HEADERS+=("$STEPDEF_HOOK_HEADER") + SHELLKIN_BEFORE_HOOK_BODIES+=("$body") + ;; + After) + SHELLKIN_AFTER_HOOK_TAGS+=("$STEPDEF_HOOK_TAG") + SHELLKIN_AFTER_HOOK_HEADERS+=("$STEPDEF_HOOK_HEADER") + SHELLKIN_AFTER_HOOK_BODIES+=("$body") + ;; + esac +} + +hooks__run_before_all() { + local index + local tag + local hook_header + local body + + HOOK_FAILED_HEADER= + + for index in "${!SHELLKIN_BEFORE_HOOK_BODIES[@]}"; do + tag=${SHELLKIN_BEFORE_HOOK_TAGS[$index]} + hooks__matches_scenario "$tag" || continue + + hook_header=${SHELLKIN_BEFORE_HOOK_HEADERS[$index]} + body=${SHELLKIN_BEFORE_HOOK_BODIES[$index]} + hooks__run_one "$hook_header" "$body" || return 1 + done +} + +hooks__run_after_all() { + local index + local tag + local hook_header + local body + local failed=0 + + HOOK_FAILED_HEADER= + + for index in "${!SHELLKIN_AFTER_HOOK_BODIES[@]}"; do + tag=${SHELLKIN_AFTER_HOOK_TAGS[$index]} + hooks__matches_scenario "$tag" || continue + + hook_header=${SHELLKIN_AFTER_HOOK_HEADERS[$index]} + body=${SHELLKIN_AFTER_HOOK_BODIES[$index]} + if hooks__run_one "$hook_header" "$body"; then + : + else + failed=1 + fi + done + + return "$failed" +} + +hooks__run_one() { + local hook_header=$1 + local body=$2 + + FAIL_MESSAGE= + DOC_STRING= + export FAIL_MESSAGE DOC_STRING + + eval "$body" || { + HOOK_FAILED_HEADER=$hook_header + if [[ -z ${FAIL_MESSAGE:-} ]]; then + FAIL_MESSAGE="hook failed: $hook_header" + export FAIL_MESSAGE + fi + return 1 + } +} + +hooks__matches_scenario() { + local tag=$1 + local -a scenario_tags=() + + [[ -n $tag ]] || return 0 + + # shellcheck disable=SC2034 # consumed through nameref by hooks__tags_include + read -r -a scenario_tags <<<"${FEATURE_SCENARIO_TAGS:-}" + hooks__tags_include scenario_tags "$tag" +} + +hooks__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 +} + # src/lib/stepdef/parse.sh stepdef_type_valid() { case $1 in @@ -1618,31 +1786,71 @@ stepdef_register() { stepdef_parse() { local line=$1 + local keyword + local remainder local pattern + STEPDEF_HEADER_KIND= STEPDEF_TYPE= STEPDEF_PATTERN= STEPDEF_REGEX= STEPDEF_TOKENS= STEPDEF_CAPTURE_INDEXES= + STEPDEF_HOOK_TYPE= + STEPDEF_HOOK_TAG= + STEPDEF_HOOK_HEADER= - if [[ ! $line =~ ^@([A-Za-z]+)[[:space:]]+(.+)$ ]]; then + if [[ ! $line =~ ^@([A-Za-z]+)([[:space:]]+(.*))?$ ]]; then return 1 fi - pattern=${BASH_REMATCH[2]} + keyword=${BASH_REMATCH[1]} + remainder=${BASH_REMATCH[3]:-} - if ! stepdef_type_valid "${BASH_REMATCH[1]}"; then - return 1 + if stepdef_type_valid "$keyword"; then + [[ -n $remainder ]] || return 1 + + pattern=$remainder + STEPDEF_HEADER_KIND=step + STEPDEF_TYPE=$keyword + STEPDEF_PATTERN=$pattern + STEPDEF_REGEX=$(pattern_regex "$pattern") + STEPDEF_TOKENS=$(pattern_tokens "$pattern") + STEPDEF_CAPTURE_INDEXES=$(pattern_capture_indexes "$pattern") + return 0 fi - STEPDEF_TYPE=${BASH_REMATCH[1]} - STEPDEF_PATTERN=$pattern - STEPDEF_REGEX=$(pattern_regex "$pattern") - STEPDEF_TOKENS=$(pattern_tokens "$pattern") - STEPDEF_CAPTURE_INDEXES=$(pattern_capture_indexes "$pattern") + if stepdef_hook_type_valid "$keyword"; then + if [[ -n $remainder ]]; then + stepdef_hook_tag_valid "$remainder" || return 1 + fi - return 0 + STEPDEF_HEADER_KIND=hook + STEPDEF_HOOK_TYPE=$keyword + STEPDEF_HOOK_TAG=$remainder + STEPDEF_HOOK_HEADER="@$keyword" + if [[ -n $remainder ]]; then + STEPDEF_HOOK_HEADER+=" $remainder" + fi + return 0 + fi + + return 1 +} + +stepdef_hook_type_valid() { + case $1 in + Before | After) + return 0 + ;; + *) + return 1 + ;; + esac +} + +stepdef_hook_tag_valid() { + [[ $1 =~ ^@[[:alnum:]_][[:alnum:]_.:-]*$ ]] } # src/lib/stepdef/pattern.sh diff --git a/skills/shellkin/SKILL.md b/skills/shellkin/SKILL.md index 0ddf4cf..965e613 100644 --- a/skills/shellkin/SKILL.md +++ b/skills/shellkin/SKILL.md @@ -19,7 +19,7 @@ Check these first when you need Shellkin-specific facts: - `man 5 shellkin-feature` - `man 5 shellkin-stepdefs` -Use the man pages to confirm command usage, environment variables, supported feature-file syntax, step definition rules, and helper behavior before making assumptions. +Use the man pages to confirm command usage, environment variables, supported feature-file syntax, step definition rules, hooks, and helper behavior before making assumptions. If external reference is useful, Shellkin source code and documentation are available at `https://github.com/DannyBen/shellkin`, but prefer installed man pages for user-facing behavior. @@ -69,6 +69,7 @@ If that file grows too large, keep `support.sh` as the entrypoint, add a sibling - 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. +- Use `@Before` and `@After` hooks in step definition files when setup or cleanup should wrap 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. @@ -79,7 +80,7 @@ Do not use unsupported constructs: - `Scenario Outline` - `Examples` - data tables -- hooks +- `BeforeAll` and `AfterAll` hooks ## Step Definition Rules @@ -92,7 +93,8 @@ Step definitions live in shell files under `step_definitions/` and use headers l Follow these rules: -- Headers must begin with `@Given`, `@When`, or `@Then`. +- Step headers must begin with `@Given`, `@When`, or `@Then`. +- Hook headers must be `@Before`, `@After`, `@Before @tag`, or `@After @tag`. - The body continues until the next header or end of file. - Indent step bodies by two spaces for readability. - Use named `{tokens}` for variable parts. diff --git a/skills/shellkin/references/patterns.md b/skills/shellkin/references/patterns.md index ad45c4a..2868b41 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`, hooks, or tables +- Unsupported Gherkin forms such as `Scenario Outline`, `Examples`, `BeforeAll` / `AfterAll`, 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/lib/feature/core.sh b/src/lib/feature/core.sh index 3e8d200..da1be55 100644 --- a/src/lib/feature/core.sh +++ b/src/lib/feature/core.sh @@ -518,6 +518,14 @@ feature_scenario_run() { output_scenario_start "$scenario_number" "$scenario_name" set +e + if hooks__run_before_all; then + : + else + scenario_failed=1 + skip_remaining=1 + output_hook_failure "$HOOK_FAILED_HEADER" + fi + for step in "${background_steps_ref[@]}"; do if ((skip_remaining != 0)); then feature__recorded_step_parse "$step" @@ -547,6 +555,13 @@ feature_scenario_run() { fi done + if hooks__run_after_all; then + : + else + scenario_failed=1 + output_hook_failure "$HOOK_FAILED_HEADER" + fi + if defer__run_all; then : else diff --git a/src/lib/output/test.sh b/src/lib/output/test.sh index 5109c8c..0b5ecd0 100644 --- a/src/lib/output/test.sh +++ b/src/lib/output/test.sh @@ -89,6 +89,14 @@ output_deferred_failure() { output_error_report "Deferred cleanup" } +## Prints a hook failure section. +output_hook_failure() { + local hook_header=$1 + + red " ✗ $hook_header" + output_error_report "$hook_header" +} + ## Prints the final summary for a test run. output_summary() { local total_scenarios=$1 diff --git a/src/lib/stepdef/files.sh b/src/lib/stepdef/files.sh index 756f5e1..e5a7655 100644 --- a/src/lib/stepdef/files.sh +++ b/src/lib/stepdef/files.sh @@ -15,12 +15,20 @@ stepdefs_file_parse() { local next_regex= local next_tokens= local next_capture_indexes= + local next_header_kind= + local next_hook_type= + local next_hook_tag= + local next_hook_header= local parsed_new_header=0 local current_type= local current_pattern= local current_regex= local current_tokens= local current_capture_indexes= + local current_header_kind= + local current_hook_type= + local current_hook_tag= + local current_hook_header= local current_body= while IFS= read -r line || [[ -n $line ]]; do @@ -30,36 +38,48 @@ stepdefs_file_parse() { if [[ $trimmed_line == @* ]]; then if stepdef_parse "$trimmed_line"; then parsed_new_header=1 + next_header_kind=$STEPDEF_HEADER_KIND next_type=$STEPDEF_TYPE next_pattern=$STEPDEF_PATTERN next_regex=$STEPDEF_REGEX next_tokens=$STEPDEF_TOKENS next_capture_indexes=$STEPDEF_CAPTURE_INDEXES - elif [[ -z $current_type ]]; then + next_hook_type=$STEPDEF_HOOK_TYPE + next_hook_tag=$STEPDEF_HOOK_TAG + next_hook_header=$STEPDEF_HOOK_HEADER + elif [[ -z $current_header_kind ]]; then return 1 fi fi if ((parsed_new_header != 0)); then - if [[ -n $current_type ]]; then + if [[ -n $current_header_kind ]]; then + STEPDEF_HEADER_KIND=$current_header_kind STEPDEF_TYPE=$current_type STEPDEF_PATTERN=$current_pattern STEPDEF_REGEX=$current_regex STEPDEF_TOKENS=$current_tokens STEPDEF_CAPTURE_INDEXES=$current_capture_indexes - stepdef_register "$current_body" + STEPDEF_HOOK_TYPE=$current_hook_type + STEPDEF_HOOK_TAG=$current_hook_tag + STEPDEF_HOOK_HEADER=$current_hook_header + stepdefs__current_register "$current_body" fi + current_header_kind=$next_header_kind current_type=$next_type current_pattern=$next_pattern current_regex=$next_regex current_tokens=$next_tokens current_capture_indexes=$next_capture_indexes + current_hook_type=$next_hook_type + current_hook_tag=$next_hook_tag + current_hook_header=$next_hook_header current_body= continue fi - if [[ -z $current_type ]]; then + if [[ -z $current_header_kind ]]; then continue fi @@ -69,12 +89,29 @@ stepdefs_file_parse() { current_body+=$line done <"$file" - if [[ -n $current_type ]]; then + if [[ -n $current_header_kind ]]; then + STEPDEF_HEADER_KIND=$current_header_kind STEPDEF_TYPE=$current_type STEPDEF_PATTERN=$current_pattern STEPDEF_REGEX=$current_regex STEPDEF_TOKENS=$current_tokens STEPDEF_CAPTURE_INDEXES=$current_capture_indexes - stepdef_register "$current_body" + STEPDEF_HOOK_TYPE=$current_hook_type + STEPDEF_HOOK_TAG=$current_hook_tag + STEPDEF_HOOK_HEADER=$current_hook_header + stepdefs__current_register "$current_body" fi } + +stepdefs__current_register() { + local body=$1 + + case $STEPDEF_HEADER_KIND in + step) + stepdef_register "$body" + ;; + hook) + stepdef_hook_register "$body" + ;; + esac +} diff --git a/src/lib/stepdef/hooks.sh b/src/lib/stepdef/hooks.sh new file mode 100644 index 0000000..2d5a418 --- /dev/null +++ b/src/lib/stepdef/hooks.sh @@ -0,0 +1,102 @@ +## Registers a parsed scenario hook body. +stepdef_hook_register() { + local body=$1 + + case $STEPDEF_HOOK_TYPE in + Before) + SHELLKIN_BEFORE_HOOK_TAGS+=("$STEPDEF_HOOK_TAG") + SHELLKIN_BEFORE_HOOK_HEADERS+=("$STEPDEF_HOOK_HEADER") + SHELLKIN_BEFORE_HOOK_BODIES+=("$body") + ;; + After) + SHELLKIN_AFTER_HOOK_TAGS+=("$STEPDEF_HOOK_TAG") + SHELLKIN_AFTER_HOOK_HEADERS+=("$STEPDEF_HOOK_HEADER") + SHELLKIN_AFTER_HOOK_BODIES+=("$body") + ;; + esac +} + +hooks__run_before_all() { + local index + local tag + local hook_header + local body + + HOOK_FAILED_HEADER= + + for index in "${!SHELLKIN_BEFORE_HOOK_BODIES[@]}"; do + tag=${SHELLKIN_BEFORE_HOOK_TAGS[$index]} + hooks__matches_scenario "$tag" || continue + + hook_header=${SHELLKIN_BEFORE_HOOK_HEADERS[$index]} + body=${SHELLKIN_BEFORE_HOOK_BODIES[$index]} + hooks__run_one "$hook_header" "$body" || return 1 + done +} + +hooks__run_after_all() { + local index + local tag + local hook_header + local body + local failed=0 + + HOOK_FAILED_HEADER= + + for index in "${!SHELLKIN_AFTER_HOOK_BODIES[@]}"; do + tag=${SHELLKIN_AFTER_HOOK_TAGS[$index]} + hooks__matches_scenario "$tag" || continue + + hook_header=${SHELLKIN_AFTER_HOOK_HEADERS[$index]} + body=${SHELLKIN_AFTER_HOOK_BODIES[$index]} + if hooks__run_one "$hook_header" "$body"; then + : + else + failed=1 + fi + done + + return "$failed" +} + +hooks__run_one() { + local hook_header=$1 + local body=$2 + + FAIL_MESSAGE= + DOC_STRING= + export FAIL_MESSAGE DOC_STRING + + eval "$body" || { + HOOK_FAILED_HEADER=$hook_header + if [[ -z ${FAIL_MESSAGE:-} ]]; then + FAIL_MESSAGE="hook failed: $hook_header" + export FAIL_MESSAGE + fi + return 1 + } +} + +hooks__matches_scenario() { + local tag=$1 + local -a scenario_tags=() + + [[ -n $tag ]] || return 0 + + # shellcheck disable=SC2034 # consumed through nameref by hooks__tags_include + read -r -a scenario_tags <<<"${FEATURE_SCENARIO_TAGS:-}" + hooks__tags_include scenario_tags "$tag" +} + +hooks__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 +} diff --git a/src/lib/stepdef/parse.sh b/src/lib/stepdef/parse.sh index a456c08..58cf40d 100644 --- a/src/lib/stepdef/parse.sh +++ b/src/lib/stepdef/parse.sh @@ -25,29 +25,69 @@ stepdef_register() { ## Parses one step definition header into reusable fields. stepdef_parse() { local line=$1 + local keyword + local remainder local pattern + STEPDEF_HEADER_KIND= STEPDEF_TYPE= STEPDEF_PATTERN= STEPDEF_REGEX= STEPDEF_TOKENS= STEPDEF_CAPTURE_INDEXES= + STEPDEF_HOOK_TYPE= + STEPDEF_HOOK_TAG= + STEPDEF_HOOK_HEADER= - if [[ ! $line =~ ^@([A-Za-z]+)[[:space:]]+(.+)$ ]]; then + if [[ ! $line =~ ^@([A-Za-z]+)([[:space:]]+(.*))?$ ]]; then return 1 fi - pattern=${BASH_REMATCH[2]} + keyword=${BASH_REMATCH[1]} + remainder=${BASH_REMATCH[3]:-} - if ! stepdef_type_valid "${BASH_REMATCH[1]}"; then - return 1 + if stepdef_type_valid "$keyword"; then + [[ -n $remainder ]] || return 1 + + pattern=$remainder + STEPDEF_HEADER_KIND=step + STEPDEF_TYPE=$keyword + STEPDEF_PATTERN=$pattern + STEPDEF_REGEX=$(pattern_regex "$pattern") + STEPDEF_TOKENS=$(pattern_tokens "$pattern") + STEPDEF_CAPTURE_INDEXES=$(pattern_capture_indexes "$pattern") + return 0 + fi + + if stepdef_hook_type_valid "$keyword"; then + if [[ -n $remainder ]]; then + stepdef_hook_tag_valid "$remainder" || return 1 + fi + + STEPDEF_HEADER_KIND=hook + STEPDEF_HOOK_TYPE=$keyword + STEPDEF_HOOK_TAG=$remainder + STEPDEF_HOOK_HEADER="@$keyword" + if [[ -n $remainder ]]; then + STEPDEF_HOOK_HEADER+=" $remainder" + fi + return 0 fi - STEPDEF_TYPE=${BASH_REMATCH[1]} - STEPDEF_PATTERN=$pattern - STEPDEF_REGEX=$(pattern_regex "$pattern") - STEPDEF_TOKENS=$(pattern_tokens "$pattern") - STEPDEF_CAPTURE_INDEXES=$(pattern_capture_indexes "$pattern") + return 1 +} + +stepdef_hook_type_valid() { + case $1 in + Before | After) + return 0 + ;; + *) + return 1 + ;; + esac +} - return 0 +stepdef_hook_tag_valid() { + [[ $1 =~ ^@[[:alnum:]_][[:alnum:]_.:-]*$ ]] } diff --git a/src/root_command.sh b/src/root_command.sh index 9c22b37..e5c2b82 100644 --- a/src/root_command.sh +++ b/src/root_command.sh @@ -70,6 +70,12 @@ STEPDEF_REGEXES=() STEPDEF_TOKENS_LIST=() STEPDEF_CAPTURE_INDEXES_LIST=() STEPDEF_BODIES=() +SHELLKIN_BEFORE_HOOK_TAGS=() +SHELLKIN_BEFORE_HOOK_HEADERS=() +SHELLKIN_BEFORE_HOOK_BODIES=() +SHELLKIN_AFTER_HOOK_TAGS=() +SHELLKIN_AFTER_HOOK_HEADERS=() +SHELLKIN_AFTER_HOOK_BODIES=() if [[ -f $TARGET_PATH ]]; then FEATURES_DIR="$(dirname "$TARGET_PATH")" diff --git a/test/lib/feature/core.bats b/test/lib/feature/core.bats index 401013f..3758eb0 100644 --- a/test/lib/feature/core.bats +++ b/test/lib/feature/core.bats @@ -5,7 +5,7 @@ load ../../test_helper.bash setup() { setup_test_environment eval "$(declare -f run | sed '1s/^run /bats_run /')" - source_libs core/colors core/trim stepdef/pattern stepdef/parse feature/syntax stepdef/files user_helpers/run user_helpers/fail user_helpers/defer step/core output/test feature/core + source_libs core/colors core/trim stepdef/pattern stepdef/parse stepdef/hooks feature/syntax stepdef/files user_helpers/run user_helpers/fail user_helpers/defer step/core output/test feature/core STEPDEF_TYPES=() STEPDEF_PATTERNS=() @@ -23,6 +23,12 @@ setup() { FEATURE_RECORDED_STEP_TEXT= FEATURE_RECORDED_STEP_DOC_STRING= SCENARIO_DEFERRED_COMMANDS=() + SHELLKIN_BEFORE_HOOK_TAGS=() + SHELLKIN_BEFORE_HOOK_HEADERS=() + SHELLKIN_BEFORE_HOOK_BODIES=() + SHELLKIN_AFTER_HOOK_TAGS=() + SHELLKIN_AFTER_HOOK_HEADERS=() + SHELLKIN_AFTER_HOOK_BODIES=() TEST_SCENARIOS_TOTAL=0 TEST_SCENARIOS_FAILED=0 TEST_FAIL_FAST=0 @@ -61,6 +67,61 @@ teardown() { [ -f "$TEMP_DIR/somefile" ] } +@test "feature_scenario_run runs Before and After hooks around scenario steps" { + stepdef_parse "@Before" + stepdef_hook_register 'printf "before\n" >>"$TEST_ROOT/order.txt"' + stepdef_parse "@After" + stepdef_hook_register 'printf "after\n" >>"$TEST_ROOT/order.txt"' + stepdef_parse "@When I record the step" + stepdef_register 'printf "step\n" >> "$TEST_ROOT/order.txt"' + + background_steps=() + scenario_steps=($'When\tI record the step') + + feature_scenario_run "Example" background_steps scenario_steps + + [ "$(cat "$TEST_ROOT/order.txt")" = $'before\nstep\nafter' ] +} + +@test "feature_scenario_run runs tagged hooks only for matching scenarios" { + stepdef_parse "@Before @needs-server" + stepdef_hook_register 'printf "tagged\n" >>"$TEST_ROOT/hooks.txt"' + stepdef_parse "@Before @other" + stepdef_hook_register 'printf "untagged\n" >>"$TEST_ROOT/hooks.txt"' + FEATURE_SCENARIO_TAGS="@needs-server" + background_steps=() + scenario_steps=() + + feature_scenario_run "Example" background_steps scenario_steps + + [ "$(cat "$TEST_ROOT/hooks.txt")" = "tagged" ] +} + +@test "feature_scenario_run skips steps when Before fails and still runs After" { + stepdef_parse "@Before" + stepdef_hook_register 'fail "setup failed"' + stepdef_parse "@After" + stepdef_hook_register 'touch "$TEST_ROOT/after-ran"' + stepdef_parse "@When I create a file" + stepdef_register 'touch "$TEST_ROOT/should-not-exist"' + background_steps=() + scenario_steps=($'When\tI create a file') + + if feature_scenario_run "Example" background_steps scenario_steps >"$TEST_ROOT/output.txt"; then + status=0 + else + status=$? + fi + + [ "$status" -eq 1 ] + [ ! -e "$TEST_ROOT/should-not-exist" ] + [ -e "$TEST_ROOT/after-ran" ] + output=$(strip_ansi <"$TEST_ROOT/output.txt") + assert_output_contains "✗ @Before" + assert_output_contains "FAIL_MESSAGE: setup failed" + assert_output_contains "- When I create a file (skipped)" +} + @test "feature_scenario_run skips the remaining steps after a failure" { stepdef_parse "@Given I fail setup" stepdef_register 'return 1' diff --git a/test/lib/stepdef/files.bats b/test/lib/stepdef/files.bats index 1965887..fe89505 100644 --- a/test/lib/stepdef/files.bats +++ b/test/lib/stepdef/files.bats @@ -4,7 +4,7 @@ load ../../test_helper.bash setup() { setup_test_environment - source_libs core/trim stepdef/pattern stepdef/parse stepdef/files + source_libs core/trim stepdef/pattern stepdef/parse stepdef/hooks stepdef/files STEPDEF_TYPES=() STEPDEF_PATTERNS=() @@ -12,6 +12,12 @@ setup() { STEPDEF_TOKENS_LIST=() STEPDEF_CAPTURE_INDEXES_LIST=() STEPDEF_BODIES=() + SHELLKIN_BEFORE_HOOK_TAGS=() + SHELLKIN_BEFORE_HOOK_HEADERS=() + SHELLKIN_BEFORE_HOOK_BODIES=() + SHELLKIN_AFTER_HOOK_TAGS=() + SHELLKIN_AFTER_HOOK_HEADERS=() + SHELLKIN_AFTER_HOOK_BODIES=() } teardown() { @@ -98,6 +104,34 @@ EOF [[ "${STEPDEF_BODIES[1]}" == *'[[ -f "$path" ]]'* ]] } +@test "stepdefs_file_parse registers hook bodies from step definition files" { + write_file stepdefs.sh <<'EOF' +@Before +setup_each + +@Before @needs-server +start_server + +@After @needs-server +stop_server + +@Then the hook log should include '{text}' +[[ "$HOOK_LOG" == *"$text"* ]] +EOF + + stepdefs_file_parse "$TEST_ROOT/stepdefs.sh" + + [ "${#SHELLKIN_BEFORE_HOOK_BODIES[@]}" -eq 2 ] + [ "${SHELLKIN_BEFORE_HOOK_HEADERS[0]}" = "@Before" ] + [ "${SHELLKIN_BEFORE_HOOK_BODIES[0]}" = $'setup_each\n' ] + [ "${SHELLKIN_BEFORE_HOOK_TAGS[1]}" = "@needs-server" ] + [ "${SHELLKIN_BEFORE_HOOK_HEADERS[1]}" = "@Before @needs-server" ] + [ "${SHELLKIN_BEFORE_HOOK_BODIES[1]}" = $'start_server\n' ] + [ "${SHELLKIN_AFTER_HOOK_HEADERS[0]}" = "@After @needs-server" ] + [ "${SHELLKIN_AFTER_HOOK_BODIES[0]}" = $'stop_server\n' ] + [ "${STEPDEF_TYPES[0]}" = "Then" ] +} + @test "stepdefs_file_parse returns non-zero for an invalid step definition header" { write_file stepdefs.sh <<'EOF' @When @@ -109,6 +143,17 @@ EOF [ "$status" -eq 1 ] } +@test "stepdefs_file_parse returns non-zero for an invalid hook header" { + write_file stepdefs.sh <<'EOF' +@Before needs-server +start_server +EOF + + run stepdefs_file_parse "$TEST_ROOT/stepdefs.sh" + + [ "$status" -eq 1 ] +} + @test "stepdefs_file_parse returns non-zero for an unsupported step type" { write_file stepdefs.sh <<'EOF' @However I run '{command}' diff --git a/test/lib/stepdef/parse.bats b/test/lib/stepdef/parse.bats index 5e0bd55..cab8d64 100644 --- a/test/lib/stepdef/parse.bats +++ b/test/lib/stepdef/parse.bats @@ -57,6 +57,24 @@ teardown() { [ "$STEPDEF_TOKENS" = "directory" ] } +@test "stepdef_parse reads a global Before hook" { + stepdef_parse "@Before" + + [ "$STEPDEF_HEADER_KIND" = "hook" ] + [ "$STEPDEF_HOOK_TYPE" = "Before" ] + [ "$STEPDEF_HOOK_TAG" = "" ] + [ "$STEPDEF_HOOK_HEADER" = "@Before" ] +} + +@test "stepdef_parse reads a tagged After hook" { + stepdef_parse "@After @needs-server" + + [ "$STEPDEF_HEADER_KIND" = "hook" ] + [ "$STEPDEF_HOOK_TYPE" = "After" ] + [ "$STEPDEF_HOOK_TAG" = "@needs-server" ] + [ "$STEPDEF_HOOK_HEADER" = "@After @needs-server" ] +} + @test "stepdef_parse returns non-zero for a line that is not a step definition" { run stepdef_parse "echo hello" @@ -75,6 +93,12 @@ teardown() { [ "$status" -eq 1 ] } +@test "stepdef_parse returns non-zero for an invalid hook tag" { + run stepdef_parse "@Before needs-server" + + [ "$status" -eq 1 ] +} + @test "stepdef_register stores the parsed definition and body" { stepdef_parse "@When I run '{command}'"