diff --git a/.env.example b/.env.example index dfc60c82..12ff3d33 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,7 @@ BASHUNIT_LOGIN_SHELL= # Default: false (source login shell profile # Reports #─────────────────────────────────────────────────────────────────────────────── BASHUNIT_LOG_JUNIT= # JUnit XML report path (e.g., report.xml) +BASHUNIT_LOG_GHA= # GitHub Actions workflow-commands log path (e.g., gha.log) BASHUNIT_REPORT_HTML= # HTML test report path (e.g., report.html) #─────────────────────────────────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index 30acfbf5..59430f9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - `bashunit::spy` accepts an optional exit code or custom implementation function (#600) - Assert functions accept an optional trailing label to override the failure title (#77) - `--fail-on-risky` flag and `BASHUNIT_FAIL_ON_RISKY` env var treat no-assertion tests as failures (#115) +- `--log-gha ` flag and `BASHUNIT_LOG_GHA` env var emit GitHub Actions workflow commands so failed, risky and incomplete tests show up as inline PR annotations (#280) ### Changed - Parallel test execution is now enabled on Alpine Linux (#370) diff --git a/docs/command-line.md b/docs/command-line.md index ec7fb86e..f06f8a51 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -62,6 +62,7 @@ bashunit test tests/ --parallel --simple | `--output ` | Output format (`tap` for TAP version 13) | | `-w, --watch` | Watch files and re-run tests on change | | `--log-junit ` | Write JUnit XML report | +| `--log-gha ` | Write GitHub Actions workflow-commands log | | `-j, --jobs ` | Run tests in parallel with max N concurrent jobs | | `-p, --parallel` | Run tests in parallel | | `--no-parallel` | Run tests sequentially | @@ -358,8 +359,13 @@ bashunit test tests/ --log-junit results.xml ```bash [HTML Report] bashunit test tests/ --report-html report.html ``` +```bash [GitHub Actions] +bashunit test tests/ --log-gha gha.log && cat gha.log +``` ::: +The `--log-gha` flag writes GitHub Actions workflow commands (`::error`, `::warning`, `::notice`) for failed, risky and incomplete tests. When streamed to stdout on a runner, they appear as inline annotations in the "Files changed" tab of a pull request. + ### Show Output on Failure > `bashunit test --show-output` diff --git a/docs/configuration.md b/docs/configuration.md index 4ff0177f..2b515ef9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -231,6 +231,23 @@ BASHUNIT_LOG_JUNIT=log-junit.xml ``` ::: +## Log GitHub Actions + +> `BASHUNIT_LOG_GHA=file` + +Write GitHub Actions workflow commands (`::error`, `::warning`, `::notice`) to the given file, so failed, risky and incomplete tests show up as inline annotations in the "Files changed" tab of a pull request. + +On a CI runner, stream the generated file to stdout so GitHub parses it: + +::: code-group +```bash [Example] +BASHUNIT_LOG_GHA=gha.log +``` +```yaml [GitHub Actions workflow] +- run: ./bashunit --log-gha gha.log tests/ || (cat gha.log && exit 1) +``` +::: + ## Report HTML > `BASHUNIT_REPORT_HTML=file` diff --git a/src/env.sh b/src/env.sh index 62b3eb67..2a809131 100644 --- a/src/env.sh +++ b/src/env.sh @@ -14,6 +14,7 @@ _BASHUNIT_DEFAULT_DEFAULT_PATH="tests" _BASHUNIT_DEFAULT_BOOTSTRAP="tests/bootstrap.sh" _BASHUNIT_DEFAULT_DEV_LOG="" _BASHUNIT_DEFAULT_LOG_JUNIT="" +_BASHUNIT_DEFAULT_LOG_GHA="" _BASHUNIT_DEFAULT_REPORT_HTML="" # Coverage defaults (following kcov, bashcov, SimpleCov conventions) @@ -31,6 +32,7 @@ _BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH="80" : "${BASHUNIT_BOOTSTRAP:=${BOOTSTRAP:=$_BASHUNIT_DEFAULT_BOOTSTRAP}}" : "${BASHUNIT_BOOTSTRAP_ARGS:=${BOOTSTRAP_ARGS:=}}" : "${BASHUNIT_LOG_JUNIT:=${LOG_JUNIT:=$_BASHUNIT_DEFAULT_LOG_JUNIT}}" +: "${BASHUNIT_LOG_GHA:=${LOG_GHA:=$_BASHUNIT_DEFAULT_LOG_GHA}}" : "${BASHUNIT_REPORT_HTML:=${REPORT_HTML:=$_BASHUNIT_DEFAULT_REPORT_HTML}}" # Coverage @@ -236,6 +238,7 @@ function bashunit::env::print_verbose() { "BASHUNIT_BOOTSTRAP" "BASHUNIT_BOOTSTRAP_ARGS" "BASHUNIT_LOG_JUNIT" + "BASHUNIT_LOG_GHA" "BASHUNIT_REPORT_HTML" "BASHUNIT_PARALLEL_RUN" "BASHUNIT_SHOW_HEADER" diff --git a/src/main.sh b/src/main.sh index 8d35d2c5..d33d8335 100644 --- a/src/main.sh +++ b/src/main.sh @@ -96,6 +96,10 @@ function bashunit::main::cmd_test() { export BASHUNIT_LOG_JUNIT="$2" shift ;; + --log-gha) + export BASHUNIT_LOG_GHA="$2" + shift + ;; -r | --report-html) export BASHUNIT_REPORT_HTML="$2" shift @@ -692,6 +696,10 @@ function bashunit::main::exec_tests() { bashunit::reports::generate_junit_xml "$BASHUNIT_LOG_JUNIT" fi + if [ -n "$BASHUNIT_LOG_GHA" ]; then + bashunit::reports::generate_gha_log "$BASHUNIT_LOG_GHA" + fi + if [ -n "$BASHUNIT_REPORT_HTML" ]; then bashunit::reports::generate_report_html "$BASHUNIT_REPORT_HTML" fi diff --git a/src/reports.sh b/src/reports.sh index 66e79f3b..85e1dbf6 100755 --- a/src/reports.sh +++ b/src/reports.sh @@ -34,7 +34,11 @@ function bashunit::reports::add_test_failed() { function bashunit::reports::add_test() { # Skip tracking when no report output is requested - { [ -n "${BASHUNIT_LOG_JUNIT:-}" ] || [ -n "${BASHUNIT_REPORT_HTML:-}" ]; } || return 0 + { + [ -n "${BASHUNIT_LOG_JUNIT:-}" ] || + [ -n "${BASHUNIT_REPORT_HTML:-}" ] || + [ -n "${BASHUNIT_LOG_GHA:-}" ] + } || return 0 local file="$1" local test_name="$2" @@ -114,6 +118,56 @@ function bashunit::reports::generate_junit_xml() { } >"$output_file" } +function bashunit::reports::__gha_encode() { + local text="$1" + # Strip ANSI escape sequences first (one sed call) + text=$(printf '%s' "$text" | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g') + # Percent-encode reserved chars per GHA workflow-commands spec. + # Bash 3.0+ parameter expansion avoids extra awk/sed calls. + # Order matters: encode '%' first so the sequences we inject stay literal. + text="${text//%/%25}" + text="${text//$'\r'/%0D}" + text="${text//$'\n'/%0A}" + printf '%s' "$text" +} + +function bashunit::reports::generate_gha_log() { + local output_file="$1" + + : >"$output_file" + + local i + for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do + local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}" + local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}" + local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}" + local failure_message="${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}" + local level="" message="" + + case "$status" in + failed) + level="error" + message="$failure_message" + ;; + risky) + level="warning" + message="Test has no assertions (risky)" + ;; + incomplete) + level="notice" + message="Test incomplete" + ;; + *) + continue + ;; + esac + + local encoded_message + encoded_message=$(bashunit::reports::__gha_encode "$message") + echo "::${level} file=${file},title=${name}::${encoded_message}" >>"$output_file" + done +} + function bashunit::reports::generate_report_html() { local output_file="$1" diff --git a/tests/acceptance/bashunit_log_gha_test.sh b/tests/acceptance/bashunit_log_gha_test.sh new file mode 100644 index 00000000..4efa9e19 --- /dev/null +++ b/tests/acceptance/bashunit_log_gha_test.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +function set_up_before_script() { + TEST_ENV_FILE="tests/acceptance/fixtures/.env.default" + TEST_ENV_FILE_BASHUNIT_LOG_GHA="tests/acceptance/fixtures/.env.log_gha" +} + +function test_bashunit_when_log_gha_option() { + local test_file=./tests/acceptance/fixtures/test_bashunit_when_log_junit.sh + local log_file + log_file=$(mktemp "${TMPDIR:-/tmp}/bashunit-gha-opt.XXXXXX") + + # Inner suite has a failing test, so bashunit exits nonzero; tolerate it + # so the acceptance test keeps running under --strict (set -e). + ./bashunit --no-parallel --env "$TEST_ENV_FILE" --log-gha "$log_file" "$test_file" >/dev/null || true + + assert_file_exists "$log_file" + assert_contains "::error file=$test_file" "$(cat "$log_file")" + assert_contains "title=Failure" "$(cat "$log_file")" + rm -f "$log_file" +} + +function test_bashunit_when_log_gha_env() { + local test_file=./tests/acceptance/fixtures/test_bashunit_when_log_junit.sh + + ./bashunit --no-parallel --env "$TEST_ENV_FILE_BASHUNIT_LOG_GHA" "$test_file" >/dev/null || true + + assert_file_exists log-gha.txt + assert_contains "::error" "$(cat log-gha.txt)" + rm -f log-gha.txt +} diff --git a/tests/acceptance/fixtures/.env.log_gha b/tests/acceptance/fixtures/.env.log_gha new file mode 100644 index 00000000..e3e87cf5 --- /dev/null +++ b/tests/acceptance/fixtures/.env.log_gha @@ -0,0 +1,12 @@ +BASHUNIT_DEFAULT_PATH= +BASHUNIT_LOG_GHA=log-gha.txt + +BASHUNIT_SHOW_HEADER=false +BASHUNIT_HEADER_ASCII_ART=false +BASHUNIT_SIMPLE_OUTPUT=false +BASHUNIT_STOP_ON_FAILURE=false +BASHUNIT_SHOW_EXECUTION_TIME=false +BASHUNIT_VERBOSE=false +BASHUNIT_SHOW_SKIPPED=false +BASHUNIT_SHOW_INCOMPLETE=false +BASHUNIT_FAILURES_ONLY=false diff --git a/tests/unit/reports_test.sh b/tests/unit/reports_test.sh index e2558ebe..82e01d4d 100644 --- a/tests/unit/reports_test.sh +++ b/tests/unit/reports_test.sh @@ -18,6 +18,7 @@ function set_up() { # Unset report env vars by default unset BASHUNIT_LOG_JUNIT unset BASHUNIT_REPORT_HTML + unset BASHUNIT_LOG_GHA # Create temp file for output tests _TEMP_OUTPUT_FILE=$(mktemp) @@ -30,6 +31,7 @@ function tear_down() { # Restore env vars unset BASHUNIT_LOG_JUNIT unset BASHUNIT_REPORT_HTML + unset BASHUNIT_LOG_GHA } # Mock functions for report generation tests @@ -117,6 +119,14 @@ function test_add_test_tracks_when_html_report_enabled() { assert_same "1" "${#_BASHUNIT_REPORTS_TEST_NAMES[@]}" } +function test_add_test_tracks_when_gha_log_enabled() { + BASHUNIT_LOG_GHA="gha.log" + + bashunit::reports::add_test "file.sh" "test_name" "100" "3" "passed" + + assert_same "1" "${#_BASHUNIT_REPORTS_TEST_NAMES[@]}" +} + function test_add_test_populates_all_arrays() { BASHUNIT_LOG_JUNIT="report.xml" @@ -305,6 +315,111 @@ function test_generate_report_html_groups_tests_by_file() { assert_contains '

File: file_b.sh

' "$content" } +# === GitHub Actions log generation tests === + +function test_generate_gha_log_emits_error_for_failed_test() { + _mock_state_functions + BASHUNIT_LOG_GHA="gha.log" + + bashunit::reports::add_test "tests/foo_test.sh" "test_fail" "100" "1" "failed" "expected 1 got 2" + bashunit::reports::generate_gha_log "$_TEMP_OUTPUT_FILE" + + local content + content=$(cat "$_TEMP_OUTPUT_FILE") + + assert_contains '::error file=tests/foo_test.sh' "$content" + assert_contains 'title=test_fail' "$content" + assert_contains 'expected 1 got 2' "$content" +} + +function test_generate_gha_log_emits_warning_for_risky_test() { + _mock_state_functions + BASHUNIT_LOG_GHA="gha.log" + + bashunit::reports::add_test "tests/foo_test.sh" "test_risky" "10" "0" "risky" + bashunit::reports::generate_gha_log "$_TEMP_OUTPUT_FILE" + + local content + content=$(cat "$_TEMP_OUTPUT_FILE") + + assert_contains '::warning file=tests/foo_test.sh' "$content" + assert_contains 'title=test_risky' "$content" + assert_contains 'no assertions' "$content" +} + +function test_generate_gha_log_emits_notice_for_incomplete_test() { + _mock_state_functions + BASHUNIT_LOG_GHA="gha.log" + + bashunit::reports::add_test "tests/foo_test.sh" "test_incomplete" "0" "0" "incomplete" + bashunit::reports::generate_gha_log "$_TEMP_OUTPUT_FILE" + + local content + content=$(cat "$_TEMP_OUTPUT_FILE") + + assert_contains '::notice file=tests/foo_test.sh' "$content" + assert_contains 'title=test_incomplete' "$content" +} + +function test_generate_gha_log_skips_passed_test() { + _mock_state_functions + BASHUNIT_LOG_GHA="gha.log" + + bashunit::reports::add_test "tests/foo_test.sh" "test_ok" "100" "1" "passed" + bashunit::reports::generate_gha_log "$_TEMP_OUTPUT_FILE" + + local content + content=$(cat "$_TEMP_OUTPUT_FILE") + + assert_empty "$content" +} + +function test_generate_gha_log_skips_skipped_test() { + _mock_state_functions + BASHUNIT_LOG_GHA="gha.log" + + bashunit::reports::add_test "tests/foo_test.sh" "test_skip" "0" "0" "skipped" + bashunit::reports::generate_gha_log "$_TEMP_OUTPUT_FILE" + + local content + content=$(cat "$_TEMP_OUTPUT_FILE") + + assert_empty "$content" +} + +function test_generate_gha_log_encodes_newlines_in_message() { + _mock_state_functions + BASHUNIT_LOG_GHA="gha.log" + + local msg + msg=$(printf 'line one\nline two') + bashunit::reports::add_test "tests/foo_test.sh" "test_multi" "100" "1" "failed" "$msg" + bashunit::reports::generate_gha_log "$_TEMP_OUTPUT_FILE" + + local content + content=$(cat "$_TEMP_OUTPUT_FILE") + + assert_contains 'line one%0Aline two' "$content" + assert_not_contains 'line one +line two' "$content" +} + +function test_generate_gha_log_strips_ansi_color_codes() { + _mock_state_functions + BASHUNIT_LOG_GHA="gha.log" + + local msg + msg=$(printf 'expected \033[32mgreen\033[0m got \033[31mred\033[0m') + bashunit::reports::add_test "tests/foo_test.sh" "test_color" "100" "1" "failed" "$msg" + bashunit::reports::generate_gha_log "$_TEMP_OUTPUT_FILE" + + local content + content=$(cat "$_TEMP_OUTPUT_FILE") + + assert_contains 'expected green got red' "$content" + assert_not_contains $'\033[' "$content" +} + function test_generate_report_html_applies_status_css_classes() { _mock_state_functions BASHUNIT_REPORT_HTML="report.html"