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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

## Unreleased

### Changed
- JSON reports write ordinary filenames, test names and empty messages without per-field processes, preserving existing escaping (#1369)

### Fixed
- Captured parallel output no longer waits for an inactive spinner's sleep child to close its pipes (#1368)
- Piped parallel output no longer waits for an inactive spinner's sleep process (#1368)

## [0.51.0](https://github.com/TypedDevs/bashunit/compare/0.50.1...0.51.0) - 2026-09-18

Expand Down
25 changes: 17 additions & 8 deletions src/reports/json.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@

# Machine-readable JSON report writer.

# Escapes a string for embedding in a JSON string literal (pure Bash, no jq).
# Strips ANSI/control chars that cannot appear inline, keeps \t\r\n as escapes.
function bashunit::reports::__json_escape() {
_BASHUNIT_REPORTS_JSON_ESCAPE_OUT=""
function bashunit::reports::__json_escape_to_slot() {
local text="$1"
text=$(bashunit::reports::__strip_ansi "$text" | tr -d '\000-\010\013\014\016-\037')
case "$text" in
*[[:cntrl:]]*) text=$(bashunit::reports::__strip_ansi "$text" | tr -d '\000-\010\013\014\016-\037') ;;
esac
# Backslash first so escapes added below are not doubled.
text="${text//\\/\\\\}"
text="${text//\"/\\\"}"
text="${text//$'\t'/\\t}"
text="${text//$'\r'/\\r}"
text="${text//$'\n'/\\n}"
printf '%s' "$text"
_BASHUNIT_REPORTS_JSON_ESCAPE_OUT="$text"
}

function bashunit::reports::__json_escape() {
bashunit::reports::__json_escape_to_slot "$1"
printf '%s' "$_BASHUNIT_REPORTS_JSON_ESCAPE_OUT"
}

##
Expand Down Expand Up @@ -60,11 +66,14 @@ function bashunit::reports::print_report_json() {
local seq=0
for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
local file name status duration message sep
file=$(bashunit::reports::__json_escape "${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}")
name=$(bashunit::reports::__json_escape "${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}")
bashunit::reports::__json_escape_to_slot "${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}"
file="$_BASHUNIT_REPORTS_JSON_ESCAPE_OUT"
bashunit::reports::__json_escape_to_slot "${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}"
name="$_BASHUNIT_REPORTS_JSON_ESCAPE_OUT"
status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}"
duration="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-0}"
message=$(bashunit::reports::__json_escape "${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}")
bashunit::reports::__json_escape_to_slot "${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}"
message="$_BASHUNIT_REPORTS_JSON_ESCAPE_OUT"
sep=","
[ "$seq" -eq "$((total - 1))" ] && sep=""
printf ' { "file": "%s", "name": "%s", "status": "%s", "duration_ms": %d,' \
Expand Down
55 changes: 55 additions & 0 deletions tests/unit/reports/report_json_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,60 @@ function test_json_escape_escapes_newlines_and_tabs() {
assert_same 'a\tb\nc' "$(bashunit::reports::__json_escape "$(printf 'a\tb\nc')")"
}

function test_json_escape_slot_matches_stdout_for_plain_and_control_values() {
local value expected
for value in '' 'plain/path.sh' 'a"b\c' $'tab\tcr\rline\nnext' \
$'red\033[31mtext\033[0m' $'other\001control\177' '日本語 😀'; do
expected="$(bashunit::reports::__json_escape "$value")"
bashunit::reports::__json_escape_to_slot "$value"
assert_same "$expected" "$_BASHUNIT_REPORTS_JSON_ESCAPE_OUT"
done

value="$(printf 'a%.0s' {1..10000})"$'\033[31m\t\n'
expected="$(bashunit::reports::__json_escape "$value")"
bashunit::reports::__json_escape_to_slot "$value"
assert_same "$expected" "$_BASHUNIT_REPORTS_JSON_ESCAPE_OUT"
}

function test_json_escape_preserves_control_and_unicode_behavior() {
bashunit::reports::__json_escape_to_slot $'tab\tcr\rline\nnext'
assert_same 'tab\tcr\rline\nnext' "$_BASHUNIT_REPORTS_JSON_ESCAPE_OUT"

bashunit::reports::__json_escape_to_slot $'red\033[31mtext\033[0m\001\177'
assert_same $'redtext\177' "$_BASHUNIT_REPORTS_JSON_ESCAPE_OUT"

bashunit::reports::__json_escape_to_slot '日本語 😀'
assert_same '日本語 😀' "$_BASHUNIT_REPORTS_JSON_ESCAPE_OUT"
}

function test_report_json_plain_fields_do_not_run_strip_ansi_or_tr() {
local out marker
out="$(mktemp)"
marker="$(mktemp)"
(
function bashunit::reports::__strip_ansi() {
printf 'strip\n' >>"$marker"
printf '%s' "$1"
}
function tr() {
printf 'tr\n' >>"$marker"
command tr "$@"
}
_BASHUNIT_REPORTS_TEST_FILES=("tests/plain.sh")
_BASHUNIT_REPORTS_TEST_NAMES=("test_plain")
_BASHUNIT_REPORTS_TEST_STATUSES=("passed")
_BASHUNIT_REPORTS_TEST_DURATIONS=("5")
_BASHUNIT_REPORTS_TEST_FAILURES=("")
_BASHUNIT_REPORTS_TEST_RETRIES=("0")
bashunit::reports::generate_report_json "$out"
assert_same '0' "${#_BASHUNIT_REPORTS_JSON_ESCAPE_OUT}"
)

assert_same '' "$(cat "$marker")"
assert_contains '"file": "tests/plain.sh", "name": "test_plain"' "$(cat "$out")"
rm -f "$out" "$marker"
}

function test_generate_report_json_summary_counts() {
if [ "$_JQ_AVAILABLE" = false ]; then bashunit::skip "jq required"; return; fi
local out
Expand All @@ -39,6 +93,7 @@ function test_generate_report_json_is_valid_and_escapes_messages() {
assert_successful_code "$(jq empty "$out" 2>&1)"
assert_same 'failed' "$(jq -r '.tests[1].status' "$out")"
assert_contains 'say "hi"' "$(jq -r '.tests[1].message' "$out")"
assert_same 'say \"hi\"\nnext' "$_BASHUNIT_REPORTS_JSON_ESCAPE_OUT"
rm -f "$out"
}

Expand Down
Loading