Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)

#───────────────────────────────────────────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` 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)
Expand Down
6 changes: 6 additions & 0 deletions docs/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ bashunit test tests/ --parallel --simple
| `--output <format>` | Output format (`tap` for TAP version 13) |
| `-w, --watch` | Watch files and re-run tests on change |
| `--log-junit <file>` | Write JUnit XML report |
| `--log-gha <file>` | Write GitHub Actions workflow-commands log |
| `-j, --jobs <N>` | Run tests in parallel with max N concurrent jobs |
| `-p, --parallel` | Run tests in parallel |
| `--no-parallel` | Run tests sequentially |
Expand Down Expand Up @@ -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`
Expand Down
17 changes: 17 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 3 additions & 0 deletions src/env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions src/main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
56 changes: 55 additions & 1 deletion src/reports.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"

Expand Down
31 changes: 31 additions & 0 deletions tests/acceptance/bashunit_log_gha_test.sh
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions tests/acceptance/fixtures/.env.log_gha
Original file line number Diff line number Diff line change
@@ -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
115 changes: 115 additions & 0 deletions tests/unit/reports_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -305,6 +315,111 @@ function test_generate_report_html_groups_tests_by_file() {
assert_contains '<h2>File: file_b.sh</h2>' "$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"
Expand Down
Loading