From 5f6779f87621ae41cee109972066479a4a60feba Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 2 Oct 2026 13:58:51 +0200 Subject: [PATCH 1/2] perf(reports): avoid plain JSON escape forks Closes #1369 --- CHANGELOG.md | 3 ++ src/reports/json.sh | 25 ++++++++---- tests/unit/reports/report_json_test.sh | 55 ++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d8aa2bc9..e499bc796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Changed +- JSON reports escape ordinary file paths, test names and empty messages without starting `sed` or `tr`, and write those fields without per-field capture subshells. Control characters retain the existing escaping behavior (#1369) + ## [0.51.0](https://github.com/TypedDevs/bashunit/compare/0.50.1...0.51.0) - 2026-09-18 ### Changed diff --git a/src/reports/json.sh b/src/reports/json.sh index 002a0706e..756ea729f 100644 --- a/src/reports/json.sh +++ b/src/reports/json.sh @@ -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" } ## @@ -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,' \ diff --git a/tests/unit/reports/report_json_test.sh b/tests/unit/reports/report_json_test.sh index 8a2870bb7..b06e3a55f 100644 --- a/tests/unit/reports/report_json_test.sh +++ b/tests/unit/reports/report_json_test.sh @@ -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 @@ -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" } From d88977d8cde5bfd16e0b12218f57a23b45c0b005 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 2 Oct 2026 14:59:54 +0200 Subject: [PATCH 2/2] docs: tighten unreleased entries --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff4407ddc..897d90f47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,10 @@ ## Unreleased ### Changed -- JSON reports escape ordinary file paths, test names and empty messages without starting `sed` or `tr`, and write those fields without per-field capture subshells. Control characters retain the existing escaping behavior (#1369) +- 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