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
7 changes: 6 additions & 1 deletion .claude/rules/architecture-map.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,7 +74,12 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end).
| `parallel.sh` | worker temp tree, aggregation, stop-on-failure flag file |
| `console/index.sh` | aggregator only — sources the `src/console/` module below |
| `console/colors.sh` | the `_BASHUNIT_COLOR_*` palette and `bashunit::sgr` |
| `console/header.sh` / `console/results.sh` | header/totals rendering, deferred failed/skipped/incomplete/risky blocks (scratch files under the run dir) |
| `console/header.sh` | the "Running N tests" header |
| `console/line.sh` | `print_line`, the primitive every result line goes through, and its TAP variant |
| `console/duration.sh` / `console/diff.sh` | duration formatting; unified and line-by-line diffs under a failure |
| `console/test_line.sh` | the per-test result lines (passed/failed/skipped/incomplete/snapshot/risky/error) |
| `console/deferred.sh` | end-of-run blocks buffered during the run (scratch files under the run dir) |
| `console/summary.sh` | run totals, execution time, hook completion |
| `assert/index.sh` | aggregator only — sources the `src/assert/` module below, plus `skip_todo.sh` and `test_doubles.sh` |
| `assert/core.sh` | `assert::should_skip`, `assert::fail_with`, `assert::join_to_slot` and the comparison assertions the other files build on |
| `assert/{arrays,assertions,dates,duration,files,folders,json,once,snapshot}.sh` | the per-topic assertions; the per-assertion path must stay fork-free |
Expand Down
109 changes: 109 additions & 0 deletions src/console/deferred.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash

# End-of-run blocks buffered during the run and flushed once it finishes.

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
total_failed=$(bashunit::state::get_tests_failed)

if bashunit::env::is_simple_output_enabled; then
printf "\n\n"
fi

if [ "$total_failed" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 failure:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_failed failures:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

sed '${/^$/d;}' "$FAILURES_OUTPUT_PATH" | sed 's/^/|/'
rm "$FAILURES_OUTPUT_PATH"

echo ""
fi
}


##
# Prints the slowest tests recorded during the run, sorted by duration
# descending, limited to BASHUNIT_PROFILE_COUNT entries. Reads the
# tab-separated records appended to PROFILE_OUTPUT_PATH (duration, name, file).
##
function bashunit::console_results::print_profile_and_reset() {
if [ ! -s "$PROFILE_OUTPUT_PATH" ]; then
rm -f "$PROFILE_OUTPUT_PATH"
return
fi

local count="${BASHUNIT_PROFILE_COUNT:-10}"

echo -e "\n${_BASHUNIT_COLOR_BOLD}Slowest tests:${_BASHUNIT_COLOR_DEFAULT}"

local duration name file formatted
# -rn on the first (numeric) field; head limits to the requested count.
while IFS=$'\t' read -r duration name file; do
formatted=$(bashunit::console_results::format_duration "$duration")
printf " %s\t%s (%s)\n" "$formatted" "$name" "$file"
done < <(sort -t"$(printf '\t')" -k1 -rn "$PROFILE_OUTPUT_PATH" | head -n "$count")

echo ""

rm -f "$PROFILE_OUTPUT_PATH"
}


##
# Flushes a deferred summary block (skipped/incomplete/risky): prints the
# "There was 1 <noun>" / "There were N <nouns>" header, then the recorded lines
# from output_path (carriage returns stripped, blank lines dropped, each prefixed
# with "|"), removes the file and prints a trailing blank line. Callers own the
# `[ -s path ]` (and any `is_show_*`) guard so each block keeps its own gate.
# Arguments: $1 output path, $2 total count, $3 singular noun, $4 plural noun
##
function bashunit::console_results::flush_deferred_block() {
local output_path=$1
local total=$2
local singular=$3
local plural=$4

if bashunit::env::is_simple_output_enabled; then
printf "\n"
fi

if [ "$total" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 ${singular}:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were ${total} ${plural}:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

tr -d '\r' <"$output_path" | sed '/^[[:space:]]*$/d' | sed 's/^/|/'
rm "$output_path"

echo ""
}


function bashunit::console_results::print_skipped_tests_and_reset() {
if [ -s "$SKIPPED_OUTPUT_PATH" ] && bashunit::env::is_show_skipped_enabled; then
bashunit::console_results::flush_deferred_block "$SKIPPED_OUTPUT_PATH" \
"$(bashunit::state::get_tests_skipped)" "skipped test" "skipped tests"
fi
}


function bashunit::console_results::print_incomplete_tests_and_reset() {
if [ -s "$INCOMPLETE_OUTPUT_PATH" ] && bashunit::env::is_show_incomplete_enabled; then
bashunit::console_results::flush_deferred_block "$INCOMPLETE_OUTPUT_PATH" \
"$(bashunit::state::get_tests_incomplete)" "incomplete test" "incomplete tests"
fi
}


function bashunit::console_results::print_risky_tests_and_reset() {
if [ -s "$RISKY_OUTPUT_PATH" ]; then
bashunit::console_results::flush_deferred_block "$RISKY_OUTPUT_PATH" \
"$(bashunit::state::get_tests_risky)" "risky test" "risky tests"
fi
}

119 changes: 119 additions & 0 deletions src/console/diff.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env bash

# Unified and line-by-line diffs rendered under a failed assertion or snapshot.

##
# Renders a git word-diff of two files, indented, and echoes it. Colorized
# unless --no-color is active. Empty when git is unavailable or files match.
# Shared by the snapshot-failure and multiline assert-failure renderers.
# Arguments: $1 expected file path, $2 actual file path
##
function bashunit::console_results::render_diff() {
local expected_file=$1
local actual_file=$2

if ! bashunit::dependencies::has_git; then
return 0
fi

local color_flag="--color=always"
if bashunit::env::is_no_color_enabled; then
color_flag="--color=never"
fi

# `git diff` exits non-zero when the files differ; the `|| true` keeps that
# from tripping `set -e`/`pipefail` under --strict. `tail -n +6` drops git's
# header lines; `sed` indents the body. `--no-ext-diff` ignores a user's
# `diff.external`/`GIT_EXTERNAL_DIFF`, which would replace this word-diff.
git diff --no-index --no-ext-diff --word-diff "$color_flag" \
"$expected_file" "$actual_file" 2>/dev/null |
tail -n +6 | sed "s/^/ /" || true
}


##
# Echoes a value's first line, appending an ellipsis when it spans several
# lines. Used to keep the inline quoted value on one line when a diff follows.
##
function bashunit::console_results::first_line_ellipsis() {
local text=$1
local first="${text%%$'\n'*}"
if [ "$first" != "$text" ]; then
printf '%s…' "$first"
else
printf '%s' "$text"
fi
}


##
# Renders a readable line-by-line diff between an expected snapshot and the
# actual content, used as a fallback when git is unavailable. Common lines are
# shown as context, expected-only lines are prefixed with '-' and actual-only
# lines with '+'. Bash 3.0+ compatible (no mapfile, no associative arrays).
# Arguments: $1 expected content, $2 actual content
##
function bashunit::console_results::snapshot_line_diff() {
local expected=$1
local actual=$2

# Explicit empty-array init so referencing the arrays is safe under `set -u`
# on Bash 4.4+ (Bash 3.x is lenient; newer Bash treats an unset array as unbound).
# Declare and assign separately: bash 3.0 does not expand a compound array
# assignment attached to `local`, it stores the literal "()" as element 0.
local expected_lines actual_lines
expected_lines=()
actual_lines=()
local _line=""
local i=0
while IFS= read -r _line || [ -n "$_line" ]; do
expected_lines[i]=$_line
i=$((i + 1))
done <<EOF
$expected
EOF
local expected_count=$i

i=0
while IFS= read -r _line || [ -n "$_line" ]; do
actual_lines[i]=$_line
i=$((i + 1))
done <<EOF
$actual
EOF
local actual_count=$i

local max=$expected_count
if [ "$actual_count" -gt "$max" ]; then
max=$actual_count
fi

local out=""
i=0
while [ "$i" -lt "$max" ]; do
local e="" a="" has_e=0 has_a=0
if [ "$i" -lt "$expected_count" ]; then
e=${expected_lines[i]:-}
has_e=1
fi
if [ "$i" -lt "$actual_count" ]; then
a=${actual_lines[i]:-}
has_a=1
fi

if [ "$has_e" = 1 ] && [ "$has_a" = 1 ] && [ "$e" = "$a" ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
else
if [ "$has_e" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAILED}- %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
fi
if [ "$has_a" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_PASSED}+ %s${_BASHUNIT_COLOR_DEFAULT}" "$a")"
fi
fi
i=$((i + 1))
done

printf "%s" "$out"
}

37 changes: 37 additions & 0 deletions src/console/duration.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash

# Formatting a millisecond duration for display.

##
# Writes a human-readable duration (Xm Ys / X.XXs / Xms) into
# _BASHUNIT_CONSOLE_DURATION_OUT. Fork-free, so per-test render paths can format
# a duration without a $(...) capture.
# Arguments: $1 - duration in milliseconds
##
function bashunit::console_results::format_duration_to_slot() {
local duration_ms="$1"

if [ "$duration_ms" -ge 60000 ]; then
local time_in_seconds=$((duration_ms / 1000))
local minutes=$((time_in_seconds / 60))
local seconds=$((time_in_seconds % 60))
_BASHUNIT_CONSOLE_DURATION_OUT="${minutes}m ${seconds}s"
elif [ "$duration_ms" -ge 1000 ]; then
local integer_part=$((duration_ms / 1000))
local decimal_part=$(((duration_ms % 1000) / 10))
# Pad the hundredths by hand: printf would cost a fork on this hot path.
if [ "$decimal_part" -lt 10 ]; then
decimal_part="0${decimal_part}"
fi
_BASHUNIT_CONSOLE_DURATION_OUT="${integer_part}.${decimal_part}s"
else
_BASHUNIT_CONSOLE_DURATION_OUT="${duration_ms}ms"
fi
}


function bashunit::console_results::format_duration() {
bashunit::console_results::format_duration_to_slot "$1"
echo "$_BASHUNIT_CONSOLE_DURATION_OUT"
}

14 changes: 10 additions & 4 deletions src/console/index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,9 +5,15 @@
# lines, so any statement here would run before its dependencies in the built
# binary (adrs/adr-010-src-module-directories.md).
#
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette that header.sh and
# results.sh render with. Order matches the entrypoint's before this module
# existed.
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette everything else
# renders with. The former results.sh is now six files, sourced leaves first:
# line/duration/diff have no console_results callees; test_line, deferred and
# summary build on them.
source "$BASHUNIT_ROOT_DIR/src/console/colors.sh"
source "$BASHUNIT_ROOT_DIR/src/console/header.sh"
source "$BASHUNIT_ROOT_DIR/src/console/results.sh"
source "$BASHUNIT_ROOT_DIR/src/console/line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/duration.sh"
source "$BASHUNIT_ROOT_DIR/src/console/diff.sh"
source "$BASHUNIT_ROOT_DIR/src/console/test_line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/deferred.sh"
source "$BASHUNIT_ROOT_DIR/src/console/summary.sh"
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
refactor(console): split results.sh into six single-purpose files by Chemaclass · Pull Request #947 · TypedDevs/bashunit · GitHub
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
7 changes: 6 additions & 1 deletion .claude/rules/architecture-map.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,7 +74,12 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end).
| `parallel.sh` | worker temp tree, aggregation, stop-on-failure flag file |
| `console/index.sh` | aggregator only — sources the `src/console/` module below |
| `console/colors.sh` | the `_BASHUNIT_COLOR_*` palette and `bashunit::sgr` |
| `console/header.sh` / `console/results.sh` | header/totals rendering, deferred failed/skipped/incomplete/risky blocks (scratch files under the run dir) |
| `console/header.sh` | the "Running N tests" header |
| `console/line.sh` | `print_line`, the primitive every result line goes through, and its TAP variant |
| `console/duration.sh` / `console/diff.sh` | duration formatting; unified and line-by-line diffs under a failure |
| `console/test_line.sh` | the per-test result lines (passed/failed/skipped/incomplete/snapshot/risky/error) |
| `console/deferred.sh` | end-of-run blocks buffered during the run (scratch files under the run dir) |
| `console/summary.sh` | run totals, execution time, hook completion |
| `assert/index.sh` | aggregator only — sources the `src/assert/` module below, plus `skip_todo.sh` and `test_doubles.sh` |
| `assert/core.sh` | `assert::should_skip`, `assert::fail_with`, `assert::join_to_slot` and the comparison assertions the other files build on |
| `assert/{arrays,assertions,dates,duration,files,folders,json,once,snapshot}.sh` | the per-topic assertions; the per-assertion path must stay fork-free |
Expand Down
109 changes: 109 additions & 0 deletions src/console/deferred.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash

# End-of-run blocks buffered during the run and flushed once it finishes.

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
total_failed=$(bashunit::state::get_tests_failed)

if bashunit::env::is_simple_output_enabled; then
printf "\n\n"
fi

if [ "$total_failed" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 failure:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_failed failures:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

sed '${/^$/d;}' "$FAILURES_OUTPUT_PATH" | sed 's/^/|/'
rm "$FAILURES_OUTPUT_PATH"

echo ""
fi
}


##
# Prints the slowest tests recorded during the run, sorted by duration
# descending, limited to BASHUNIT_PROFILE_COUNT entries. Reads the
# tab-separated records appended to PROFILE_OUTPUT_PATH (duration, name, file).
##
function bashunit::console_results::print_profile_and_reset() {
if [ ! -s "$PROFILE_OUTPUT_PATH" ]; then
rm -f "$PROFILE_OUTPUT_PATH"
return
fi

local count="${BASHUNIT_PROFILE_COUNT:-10}"

echo -e "\n${_BASHUNIT_COLOR_BOLD}Slowest tests:${_BASHUNIT_COLOR_DEFAULT}"

local duration name file formatted
# -rn on the first (numeric) field; head limits to the requested count.
while IFS=$'\t' read -r duration name file; do
formatted=$(bashunit::console_results::format_duration "$duration")
printf " %s\t%s (%s)\n" "$formatted" "$name" "$file"
done < <(sort -t"$(printf '\t')" -k1 -rn "$PROFILE_OUTPUT_PATH" | head -n "$count")

echo ""

rm -f "$PROFILE_OUTPUT_PATH"
}


##
# Flushes a deferred summary block (skipped/incomplete/risky): prints the
# "There was 1 <noun>" / "There were N <nouns>" header, then the recorded lines
# from output_path (carriage returns stripped, blank lines dropped, each prefixed
# with "|"), removes the file and prints a trailing blank line. Callers own the
# `[ -s path ]` (and any `is_show_*`) guard so each block keeps its own gate.
# Arguments: $1 output path, $2 total count, $3 singular noun, $4 plural noun
##
function bashunit::console_results::flush_deferred_block() {
local output_path=$1
local total=$2
local singular=$3
local plural=$4

if bashunit::env::is_simple_output_enabled; then
printf "\n"
fi

if [ "$total" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 ${singular}:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were ${total} ${plural}:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

tr -d '\r' <"$output_path" | sed '/^[[:space:]]*$/d' | sed 's/^/|/'
rm "$output_path"

echo ""
}


function bashunit::console_results::print_skipped_tests_and_reset() {
if [ -s "$SKIPPED_OUTPUT_PATH" ] && bashunit::env::is_show_skipped_enabled; then
bashunit::console_results::flush_deferred_block "$SKIPPED_OUTPUT_PATH" \
"$(bashunit::state::get_tests_skipped)" "skipped test" "skipped tests"
fi
}


function bashunit::console_results::print_incomplete_tests_and_reset() {
if [ -s "$INCOMPLETE_OUTPUT_PATH" ] && bashunit::env::is_show_incomplete_enabled; then
bashunit::console_results::flush_deferred_block "$INCOMPLETE_OUTPUT_PATH" \
"$(bashunit::state::get_tests_incomplete)" "incomplete test" "incomplete tests"
fi
}


function bashunit::console_results::print_risky_tests_and_reset() {
if [ -s "$RISKY_OUTPUT_PATH" ]; then
bashunit::console_results::flush_deferred_block "$RISKY_OUTPUT_PATH" \
"$(bashunit::state::get_tests_risky)" "risky test" "risky tests"
fi
}

119 changes: 119 additions & 0 deletions src/console/diff.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env bash

# Unified and line-by-line diffs rendered under a failed assertion or snapshot.

##
# Renders a git word-diff of two files, indented, and echoes it. Colorized
# unless --no-color is active. Empty when git is unavailable or files match.
# Shared by the snapshot-failure and multiline assert-failure renderers.
# Arguments: $1 expected file path, $2 actual file path
##
function bashunit::console_results::render_diff() {
local expected_file=$1
local actual_file=$2

if ! bashunit::dependencies::has_git; then
return 0
fi

local color_flag="--color=always"
if bashunit::env::is_no_color_enabled; then
color_flag="--color=never"
fi

# `git diff` exits non-zero when the files differ; the `|| true` keeps that
# from tripping `set -e`/`pipefail` under --strict. `tail -n +6` drops git's
# header lines; `sed` indents the body. `--no-ext-diff` ignores a user's
# `diff.external`/`GIT_EXTERNAL_DIFF`, which would replace this word-diff.
git diff --no-index --no-ext-diff --word-diff "$color_flag" \
"$expected_file" "$actual_file" 2>/dev/null |
tail -n +6 | sed "s/^/ /" || true
}


##
# Echoes a value's first line, appending an ellipsis when it spans several
# lines. Used to keep the inline quoted value on one line when a diff follows.
##
function bashunit::console_results::first_line_ellipsis() {
local text=$1
local first="${text%%$'\n'*}"
if [ "$first" != "$text" ]; then
printf '%s…' "$first"
else
printf '%s' "$text"
fi
}


##
# Renders a readable line-by-line diff between an expected snapshot and the
# actual content, used as a fallback when git is unavailable. Common lines are
# shown as context, expected-only lines are prefixed with '-' and actual-only
# lines with '+'. Bash 3.0+ compatible (no mapfile, no associative arrays).
# Arguments: $1 expected content, $2 actual content
##
function bashunit::console_results::snapshot_line_diff() {
local expected=$1
local actual=$2

# Explicit empty-array init so referencing the arrays is safe under `set -u`
# on Bash 4.4+ (Bash 3.x is lenient; newer Bash treats an unset array as unbound).
# Declare and assign separately: bash 3.0 does not expand a compound array
# assignment attached to `local`, it stores the literal "()" as element 0.
local expected_lines actual_lines
expected_lines=()
actual_lines=()
local _line=""
local i=0
while IFS= read -r _line || [ -n "$_line" ]; do
expected_lines[i]=$_line
i=$((i + 1))
done <<EOF
$expected
EOF
local expected_count=$i

i=0
while IFS= read -r _line || [ -n "$_line" ]; do
actual_lines[i]=$_line
i=$((i + 1))
done <<EOF
$actual
EOF
local actual_count=$i

local max=$expected_count
if [ "$actual_count" -gt "$max" ]; then
max=$actual_count
fi

local out=""
i=0
while [ "$i" -lt "$max" ]; do
local e="" a="" has_e=0 has_a=0
if [ "$i" -lt "$expected_count" ]; then
e=${expected_lines[i]:-}
has_e=1
fi
if [ "$i" -lt "$actual_count" ]; then
a=${actual_lines[i]:-}
has_a=1
fi

if [ "$has_e" = 1 ] && [ "$has_a" = 1 ] && [ "$e" = "$a" ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
else
if [ "$has_e" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAILED}- %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
fi
if [ "$has_a" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_PASSED}+ %s${_BASHUNIT_COLOR_DEFAULT}" "$a")"
fi
fi
i=$((i + 1))
done

printf "%s" "$out"
}

37 changes: 37 additions & 0 deletions src/console/duration.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash

# Formatting a millisecond duration for display.

##
# Writes a human-readable duration (Xm Ys / X.XXs / Xms) into
# _BASHUNIT_CONSOLE_DURATION_OUT. Fork-free, so per-test render paths can format
# a duration without a $(...) capture.
# Arguments: $1 - duration in milliseconds
##
function bashunit::console_results::format_duration_to_slot() {
local duration_ms="$1"

if [ "$duration_ms" -ge 60000 ]; then
local time_in_seconds=$((duration_ms / 1000))
local minutes=$((time_in_seconds / 60))
local seconds=$((time_in_seconds % 60))
_BASHUNIT_CONSOLE_DURATION_OUT="${minutes}m ${seconds}s"
elif [ "$duration_ms" -ge 1000 ]; then
local integer_part=$((duration_ms / 1000))
local decimal_part=$(((duration_ms % 1000) / 10))
# Pad the hundredths by hand: printf would cost a fork on this hot path.
if [ "$decimal_part" -lt 10 ]; then
decimal_part="0${decimal_part}"
fi
_BASHUNIT_CONSOLE_DURATION_OUT="${integer_part}.${decimal_part}s"
else
_BASHUNIT_CONSOLE_DURATION_OUT="${duration_ms}ms"
fi
}


function bashunit::console_results::format_duration() {
bashunit::console_results::format_duration_to_slot "$1"
echo "$_BASHUNIT_CONSOLE_DURATION_OUT"
}

14 changes: 10 additions & 4 deletions src/console/index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,9 +5,15 @@
# lines, so any statement here would run before its dependencies in the built
# binary (adrs/adr-010-src-module-directories.md).
#
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette that header.sh and
# results.sh render with. Order matches the entrypoint's before this module
# existed.
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette everything else
# renders with. The former results.sh is now six files, sourced leaves first:
# line/duration/diff have no console_results callees; test_line, deferred and
# summary build on them.
source "$BASHUNIT_ROOT_DIR/src/console/colors.sh"
source "$BASHUNIT_ROOT_DIR/src/console/header.sh"
source "$BASHUNIT_ROOT_DIR/src/console/results.sh"
source "$BASHUNIT_ROOT_DIR/src/console/line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/duration.sh"
source "$BASHUNIT_ROOT_DIR/src/console/diff.sh"
source "$BASHUNIT_ROOT_DIR/src/console/test_line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/deferred.sh"
source "$BASHUNIT_ROOT_DIR/src/console/summary.sh"
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(console): split results.sh into six single-purpose files by Chemaclass · Pull Request #947 · TypedDevs/bashunit · GitHub
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
7 changes: 6 additions & 1 deletion .claude/rules/architecture-map.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,7 +74,12 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end).
| `parallel.sh` | worker temp tree, aggregation, stop-on-failure flag file |
| `console/index.sh` | aggregator only — sources the `src/console/` module below |
| `console/colors.sh` | the `_BASHUNIT_COLOR_*` palette and `bashunit::sgr` |
| `console/header.sh` / `console/results.sh` | header/totals rendering, deferred failed/skipped/incomplete/risky blocks (scratch files under the run dir) |
| `console/header.sh` | the "Running N tests" header |
| `console/line.sh` | `print_line`, the primitive every result line goes through, and its TAP variant |
| `console/duration.sh` / `console/diff.sh` | duration formatting; unified and line-by-line diffs under a failure |
| `console/test_line.sh` | the per-test result lines (passed/failed/skipped/incomplete/snapshot/risky/error) |
| `console/deferred.sh` | end-of-run blocks buffered during the run (scratch files under the run dir) |
| `console/summary.sh` | run totals, execution time, hook completion |
| `assert/index.sh` | aggregator only — sources the `src/assert/` module below, plus `skip_todo.sh` and `test_doubles.sh` |
| `assert/core.sh` | `assert::should_skip`, `assert::fail_with`, `assert::join_to_slot` and the comparison assertions the other files build on |
| `assert/{arrays,assertions,dates,duration,files,folders,json,once,snapshot}.sh` | the per-topic assertions; the per-assertion path must stay fork-free |
Expand Down
109 changes: 109 additions & 0 deletions src/console/deferred.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash

# End-of-run blocks buffered during the run and flushed once it finishes.

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
total_failed=$(bashunit::state::get_tests_failed)

if bashunit::env::is_simple_output_enabled; then
printf "\n\n"
fi

if [ "$total_failed" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 failure:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_failed failures:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

sed '${/^$/d;}' "$FAILURES_OUTPUT_PATH" | sed 's/^/|/'
rm "$FAILURES_OUTPUT_PATH"

echo ""
fi
}


##
# Prints the slowest tests recorded during the run, sorted by duration
# descending, limited to BASHUNIT_PROFILE_COUNT entries. Reads the
# tab-separated records appended to PROFILE_OUTPUT_PATH (duration, name, file).
##
function bashunit::console_results::print_profile_and_reset() {
if [ ! -s "$PROFILE_OUTPUT_PATH" ]; then
rm -f "$PROFILE_OUTPUT_PATH"
return
fi

local count="${BASHUNIT_PROFILE_COUNT:-10}"

echo -e "\n${_BASHUNIT_COLOR_BOLD}Slowest tests:${_BASHUNIT_COLOR_DEFAULT}"

local duration name file formatted
# -rn on the first (numeric) field; head limits to the requested count.
while IFS=$'\t' read -r duration name file; do
formatted=$(bashunit::console_results::format_duration "$duration")
printf " %s\t%s (%s)\n" "$formatted" "$name" "$file"
done < <(sort -t"$(printf '\t')" -k1 -rn "$PROFILE_OUTPUT_PATH" | head -n "$count")

echo ""

rm -f "$PROFILE_OUTPUT_PATH"
}


##
# Flushes a deferred summary block (skipped/incomplete/risky): prints the
# "There was 1 <noun>" / "There were N <nouns>" header, then the recorded lines
# from output_path (carriage returns stripped, blank lines dropped, each prefixed
# with "|"), removes the file and prints a trailing blank line. Callers own the
# `[ -s path ]` (and any `is_show_*`) guard so each block keeps its own gate.
# Arguments: $1 output path, $2 total count, $3 singular noun, $4 plural noun
##
function bashunit::console_results::flush_deferred_block() {
local output_path=$1
local total=$2
local singular=$3
local plural=$4

if bashunit::env::is_simple_output_enabled; then
printf "\n"
fi

if [ "$total" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 ${singular}:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were ${total} ${plural}:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

tr -d '\r' <"$output_path" | sed '/^[[:space:]]*$/d' | sed 's/^/|/'
rm "$output_path"

echo ""
}


function bashunit::console_results::print_skipped_tests_and_reset() {
if [ -s "$SKIPPED_OUTPUT_PATH" ] && bashunit::env::is_show_skipped_enabled; then
bashunit::console_results::flush_deferred_block "$SKIPPED_OUTPUT_PATH" \
"$(bashunit::state::get_tests_skipped)" "skipped test" "skipped tests"
fi
}


function bashunit::console_results::print_incomplete_tests_and_reset() {
if [ -s "$INCOMPLETE_OUTPUT_PATH" ] && bashunit::env::is_show_incomplete_enabled; then
bashunit::console_results::flush_deferred_block "$INCOMPLETE_OUTPUT_PATH" \
"$(bashunit::state::get_tests_incomplete)" "incomplete test" "incomplete tests"
fi
}


function bashunit::console_results::print_risky_tests_and_reset() {
if [ -s "$RISKY_OUTPUT_PATH" ]; then
bashunit::console_results::flush_deferred_block "$RISKY_OUTPUT_PATH" \
"$(bashunit::state::get_tests_risky)" "risky test" "risky tests"
fi
}

119 changes: 119 additions & 0 deletions src/console/diff.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env bash

# Unified and line-by-line diffs rendered under a failed assertion or snapshot.

##
# Renders a git word-diff of two files, indented, and echoes it. Colorized
# unless --no-color is active. Empty when git is unavailable or files match.
# Shared by the snapshot-failure and multiline assert-failure renderers.
# Arguments: $1 expected file path, $2 actual file path
##
function bashunit::console_results::render_diff() {
local expected_file=$1
local actual_file=$2

if ! bashunit::dependencies::has_git; then
return 0
fi

local color_flag="--color=always"
if bashunit::env::is_no_color_enabled; then
color_flag="--color=never"
fi

# `git diff` exits non-zero when the files differ; the `|| true` keeps that
# from tripping `set -e`/`pipefail` under --strict. `tail -n +6` drops git's
# header lines; `sed` indents the body. `--no-ext-diff` ignores a user's
# `diff.external`/`GIT_EXTERNAL_DIFF`, which would replace this word-diff.
git diff --no-index --no-ext-diff --word-diff "$color_flag" \
"$expected_file" "$actual_file" 2>/dev/null |
tail -n +6 | sed "s/^/ /" || true
}


##
# Echoes a value's first line, appending an ellipsis when it spans several
# lines. Used to keep the inline quoted value on one line when a diff follows.
##
function bashunit::console_results::first_line_ellipsis() {
local text=$1
local first="${text%%$'\n'*}"
if [ "$first" != "$text" ]; then
printf '%s…' "$first"
else
printf '%s' "$text"
fi
}


##
# Renders a readable line-by-line diff between an expected snapshot and the
# actual content, used as a fallback when git is unavailable. Common lines are
# shown as context, expected-only lines are prefixed with '-' and actual-only
# lines with '+'. Bash 3.0+ compatible (no mapfile, no associative arrays).
# Arguments: $1 expected content, $2 actual content
##
function bashunit::console_results::snapshot_line_diff() {
local expected=$1
local actual=$2

# Explicit empty-array init so referencing the arrays is safe under `set -u`
# on Bash 4.4+ (Bash 3.x is lenient; newer Bash treats an unset array as unbound).
# Declare and assign separately: bash 3.0 does not expand a compound array
# assignment attached to `local`, it stores the literal "()" as element 0.
local expected_lines actual_lines
expected_lines=()
actual_lines=()
local _line=""
local i=0
while IFS= read -r _line || [ -n "$_line" ]; do
expected_lines[i]=$_line
i=$((i + 1))
done <<EOF
$expected
EOF
local expected_count=$i

i=0
while IFS= read -r _line || [ -n "$_line" ]; do
actual_lines[i]=$_line
i=$((i + 1))
done <<EOF
$actual
EOF
local actual_count=$i

local max=$expected_count
if [ "$actual_count" -gt "$max" ]; then
max=$actual_count
fi

local out=""
i=0
while [ "$i" -lt "$max" ]; do
local e="" a="" has_e=0 has_a=0
if [ "$i" -lt "$expected_count" ]; then
e=${expected_lines[i]:-}
has_e=1
fi
if [ "$i" -lt "$actual_count" ]; then
a=${actual_lines[i]:-}
has_a=1
fi

if [ "$has_e" = 1 ] && [ "$has_a" = 1 ] && [ "$e" = "$a" ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
else
if [ "$has_e" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAILED}- %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
fi
if [ "$has_a" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_PASSED}+ %s${_BASHUNIT_COLOR_DEFAULT}" "$a")"
fi
fi
i=$((i + 1))
done

printf "%s" "$out"
}

37 changes: 37 additions & 0 deletions src/console/duration.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash

# Formatting a millisecond duration for display.

##
# Writes a human-readable duration (Xm Ys / X.XXs / Xms) into
# _BASHUNIT_CONSOLE_DURATION_OUT. Fork-free, so per-test render paths can format
# a duration without a $(...) capture.
# Arguments: $1 - duration in milliseconds
##
function bashunit::console_results::format_duration_to_slot() {
local duration_ms="$1"

if [ "$duration_ms" -ge 60000 ]; then
local time_in_seconds=$((duration_ms / 1000))
local minutes=$((time_in_seconds / 60))
local seconds=$((time_in_seconds % 60))
_BASHUNIT_CONSOLE_DURATION_OUT="${minutes}m ${seconds}s"
elif [ "$duration_ms" -ge 1000 ]; then
local integer_part=$((duration_ms / 1000))
local decimal_part=$(((duration_ms % 1000) / 10))
# Pad the hundredths by hand: printf would cost a fork on this hot path.
if [ "$decimal_part" -lt 10 ]; then
decimal_part="0${decimal_part}"
fi
_BASHUNIT_CONSOLE_DURATION_OUT="${integer_part}.${decimal_part}s"
else
_BASHUNIT_CONSOLE_DURATION_OUT="${duration_ms}ms"
fi
}


function bashunit::console_results::format_duration() {
bashunit::console_results::format_duration_to_slot "$1"
echo "$_BASHUNIT_CONSOLE_DURATION_OUT"
}

14 changes: 10 additions & 4 deletions src/console/index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,9 +5,15 @@
# lines, so any statement here would run before its dependencies in the built
# binary (adrs/adr-010-src-module-directories.md).
#
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette that header.sh and
# results.sh render with. Order matches the entrypoint's before this module
# existed.
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette everything else
# renders with. The former results.sh is now six files, sourced leaves first:
# line/duration/diff have no console_results callees; test_line, deferred and
# summary build on them.
source "$BASHUNIT_ROOT_DIR/src/console/colors.sh"
source "$BASHUNIT_ROOT_DIR/src/console/header.sh"
source "$BASHUNIT_ROOT_DIR/src/console/results.sh"
source "$BASHUNIT_ROOT_DIR/src/console/line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/duration.sh"
source "$BASHUNIT_ROOT_DIR/src/console/diff.sh"
source "$BASHUNIT_ROOT_DIR/src/console/test_line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/deferred.sh"
source "$BASHUNIT_ROOT_DIR/src/console/summary.sh"
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(console): split results.sh into six single-purpose files by Chemaclass · Pull Request #947 · TypedDevs/bashunit · GitHub
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
7 changes: 6 additions & 1 deletion .claude/rules/architecture-map.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,7 +74,12 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end).
| `parallel.sh` | worker temp tree, aggregation, stop-on-failure flag file |
| `console/index.sh` | aggregator only — sources the `src/console/` module below |
| `console/colors.sh` | the `_BASHUNIT_COLOR_*` palette and `bashunit::sgr` |
| `console/header.sh` / `console/results.sh` | header/totals rendering, deferred failed/skipped/incomplete/risky blocks (scratch files under the run dir) |
| `console/header.sh` | the "Running N tests" header |
| `console/line.sh` | `print_line`, the primitive every result line goes through, and its TAP variant |
| `console/duration.sh` / `console/diff.sh` | duration formatting; unified and line-by-line diffs under a failure |
| `console/test_line.sh` | the per-test result lines (passed/failed/skipped/incomplete/snapshot/risky/error) |
| `console/deferred.sh` | end-of-run blocks buffered during the run (scratch files under the run dir) |
| `console/summary.sh` | run totals, execution time, hook completion |
| `assert/index.sh` | aggregator only — sources the `src/assert/` module below, plus `skip_todo.sh` and `test_doubles.sh` |
| `assert/core.sh` | `assert::should_skip`, `assert::fail_with`, `assert::join_to_slot` and the comparison assertions the other files build on |
| `assert/{arrays,assertions,dates,duration,files,folders,json,once,snapshot}.sh` | the per-topic assertions; the per-assertion path must stay fork-free |
Expand Down
109 changes: 109 additions & 0 deletions src/console/deferred.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash

# End-of-run blocks buffered during the run and flushed once it finishes.

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
total_failed=$(bashunit::state::get_tests_failed)

if bashunit::env::is_simple_output_enabled; then
printf "\n\n"
fi

if [ "$total_failed" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 failure:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_failed failures:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

sed '${/^$/d;}' "$FAILURES_OUTPUT_PATH" | sed 's/^/|/'
rm "$FAILURES_OUTPUT_PATH"

echo ""
fi
}


##
# Prints the slowest tests recorded during the run, sorted by duration
# descending, limited to BASHUNIT_PROFILE_COUNT entries. Reads the
# tab-separated records appended to PROFILE_OUTPUT_PATH (duration, name, file).
##
function bashunit::console_results::print_profile_and_reset() {
if [ ! -s "$PROFILE_OUTPUT_PATH" ]; then
rm -f "$PROFILE_OUTPUT_PATH"
return
fi

local count="${BASHUNIT_PROFILE_COUNT:-10}"

echo -e "\n${_BASHUNIT_COLOR_BOLD}Slowest tests:${_BASHUNIT_COLOR_DEFAULT}"

local duration name file formatted
# -rn on the first (numeric) field; head limits to the requested count.
while IFS=$'\t' read -r duration name file; do
formatted=$(bashunit::console_results::format_duration "$duration")
printf " %s\t%s (%s)\n" "$formatted" "$name" "$file"
done < <(sort -t"$(printf '\t')" -k1 -rn "$PROFILE_OUTPUT_PATH" | head -n "$count")

echo ""

rm -f "$PROFILE_OUTPUT_PATH"
}


##
# Flushes a deferred summary block (skipped/incomplete/risky): prints the
# "There was 1 <noun>" / "There were N <nouns>" header, then the recorded lines
# from output_path (carriage returns stripped, blank lines dropped, each prefixed
# with "|"), removes the file and prints a trailing blank line. Callers own the
# `[ -s path ]` (and any `is_show_*`) guard so each block keeps its own gate.
# Arguments: $1 output path, $2 total count, $3 singular noun, $4 plural noun
##
function bashunit::console_results::flush_deferred_block() {
local output_path=$1
local total=$2
local singular=$3
local plural=$4

if bashunit::env::is_simple_output_enabled; then
printf "\n"
fi

if [ "$total" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 ${singular}:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were ${total} ${plural}:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

tr -d '\r' <"$output_path" | sed '/^[[:space:]]*$/d' | sed 's/^/|/'
rm "$output_path"

echo ""
}


function bashunit::console_results::print_skipped_tests_and_reset() {
if [ -s "$SKIPPED_OUTPUT_PATH" ] && bashunit::env::is_show_skipped_enabled; then
bashunit::console_results::flush_deferred_block "$SKIPPED_OUTPUT_PATH" \
"$(bashunit::state::get_tests_skipped)" "skipped test" "skipped tests"
fi
}


function bashunit::console_results::print_incomplete_tests_and_reset() {
if [ -s "$INCOMPLETE_OUTPUT_PATH" ] && bashunit::env::is_show_incomplete_enabled; then
bashunit::console_results::flush_deferred_block "$INCOMPLETE_OUTPUT_PATH" \
"$(bashunit::state::get_tests_incomplete)" "incomplete test" "incomplete tests"
fi
}


function bashunit::console_results::print_risky_tests_and_reset() {
if [ -s "$RISKY_OUTPUT_PATH" ]; then
bashunit::console_results::flush_deferred_block "$RISKY_OUTPUT_PATH" \
"$(bashunit::state::get_tests_risky)" "risky test" "risky tests"
fi
}

119 changes: 119 additions & 0 deletions src/console/diff.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env bash

# Unified and line-by-line diffs rendered under a failed assertion or snapshot.

##
# Renders a git word-diff of two files, indented, and echoes it. Colorized
# unless --no-color is active. Empty when git is unavailable or files match.
# Shared by the snapshot-failure and multiline assert-failure renderers.
# Arguments: $1 expected file path, $2 actual file path
##
function bashunit::console_results::render_diff() {
local expected_file=$1
local actual_file=$2

if ! bashunit::dependencies::has_git; then
return 0
fi

local color_flag="--color=always"
if bashunit::env::is_no_color_enabled; then
color_flag="--color=never"
fi

# `git diff` exits non-zero when the files differ; the `|| true` keeps that
# from tripping `set -e`/`pipefail` under --strict. `tail -n +6` drops git's
# header lines; `sed` indents the body. `--no-ext-diff` ignores a user's
# `diff.external`/`GIT_EXTERNAL_DIFF`, which would replace this word-diff.
git diff --no-index --no-ext-diff --word-diff "$color_flag" \
"$expected_file" "$actual_file" 2>/dev/null |
tail -n +6 | sed "s/^/ /" || true
}


##
# Echoes a value's first line, appending an ellipsis when it spans several
# lines. Used to keep the inline quoted value on one line when a diff follows.
##
function bashunit::console_results::first_line_ellipsis() {
local text=$1
local first="${text%%$'\n'*}"
if [ "$first" != "$text" ]; then
printf '%s…' "$first"
else
printf '%s' "$text"
fi
}


##
# Renders a readable line-by-line diff between an expected snapshot and the
# actual content, used as a fallback when git is unavailable. Common lines are
# shown as context, expected-only lines are prefixed with '-' and actual-only
# lines with '+'. Bash 3.0+ compatible (no mapfile, no associative arrays).
# Arguments: $1 expected content, $2 actual content
##
function bashunit::console_results::snapshot_line_diff() {
local expected=$1
local actual=$2

# Explicit empty-array init so referencing the arrays is safe under `set -u`
# on Bash 4.4+ (Bash 3.x is lenient; newer Bash treats an unset array as unbound).
# Declare and assign separately: bash 3.0 does not expand a compound array
# assignment attached to `local`, it stores the literal "()" as element 0.
local expected_lines actual_lines
expected_lines=()
actual_lines=()
local _line=""
local i=0
while IFS= read -r _line || [ -n "$_line" ]; do
expected_lines[i]=$_line
i=$((i + 1))
done <<EOF
$expected
EOF
local expected_count=$i

i=0
while IFS= read -r _line || [ -n "$_line" ]; do
actual_lines[i]=$_line
i=$((i + 1))
done <<EOF
$actual
EOF
local actual_count=$i

local max=$expected_count
if [ "$actual_count" -gt "$max" ]; then
max=$actual_count
fi

local out=""
i=0
while [ "$i" -lt "$max" ]; do
local e="" a="" has_e=0 has_a=0
if [ "$i" -lt "$expected_count" ]; then
e=${expected_lines[i]:-}
has_e=1
fi
if [ "$i" -lt "$actual_count" ]; then
a=${actual_lines[i]:-}
has_a=1
fi

if [ "$has_e" = 1 ] && [ "$has_a" = 1 ] && [ "$e" = "$a" ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
else
if [ "$has_e" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAILED}- %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
fi
if [ "$has_a" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_PASSED}+ %s${_BASHUNIT_COLOR_DEFAULT}" "$a")"
fi
fi
i=$((i + 1))
done

printf "%s" "$out"
}

37 changes: 37 additions & 0 deletions src/console/duration.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash

# Formatting a millisecond duration for display.

##
# Writes a human-readable duration (Xm Ys / X.XXs / Xms) into
# _BASHUNIT_CONSOLE_DURATION_OUT. Fork-free, so per-test render paths can format
# a duration without a $(...) capture.
# Arguments: $1 - duration in milliseconds
##
function bashunit::console_results::format_duration_to_slot() {
local duration_ms="$1"

if [ "$duration_ms" -ge 60000 ]; then
local time_in_seconds=$((duration_ms / 1000))
local minutes=$((time_in_seconds / 60))
local seconds=$((time_in_seconds % 60))
_BASHUNIT_CONSOLE_DURATION_OUT="${minutes}m ${seconds}s"
elif [ "$duration_ms" -ge 1000 ]; then
local integer_part=$((duration_ms / 1000))
local decimal_part=$(((duration_ms % 1000) / 10))
# Pad the hundredths by hand: printf would cost a fork on this hot path.
if [ "$decimal_part" -lt 10 ]; then
decimal_part="0${decimal_part}"
fi
_BASHUNIT_CONSOLE_DURATION_OUT="${integer_part}.${decimal_part}s"
else
_BASHUNIT_CONSOLE_DURATION_OUT="${duration_ms}ms"
fi
}


function bashunit::console_results::format_duration() {
bashunit::console_results::format_duration_to_slot "$1"
echo "$_BASHUNIT_CONSOLE_DURATION_OUT"
}

14 changes: 10 additions & 4 deletions src/console/index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,9 +5,15 @@
# lines, so any statement here would run before its dependencies in the built
# binary (adrs/adr-010-src-module-directories.md).
#
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette that header.sh and
# results.sh render with. Order matches the entrypoint's before this module
# existed.
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette everything else
# renders with. The former results.sh is now six files, sourced leaves first:
# line/duration/diff have no console_results callees; test_line, deferred and
# summary build on them.
source "$BASHUNIT_ROOT_DIR/src/console/colors.sh"
source "$BASHUNIT_ROOT_DIR/src/console/header.sh"
source "$BASHUNIT_ROOT_DIR/src/console/results.sh"
source "$BASHUNIT_ROOT_DIR/src/console/line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/duration.sh"
source "$BASHUNIT_ROOT_DIR/src/console/diff.sh"
source "$BASHUNIT_ROOT_DIR/src/console/test_line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/deferred.sh"
source "$BASHUNIT_ROOT_DIR/src/console/summary.sh"
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' refactor(console): split results.sh into six single-purpose files by Chemaclass · Pull Request #947 · TypedDevs/bashunit · GitHub
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
7 changes: 6 additions & 1 deletion .claude/rules/architecture-map.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,7 +74,12 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end).
| `parallel.sh` | worker temp tree, aggregation, stop-on-failure flag file |
| `console/index.sh` | aggregator only — sources the `src/console/` module below |
| `console/colors.sh` | the `_BASHUNIT_COLOR_*` palette and `bashunit::sgr` |
| `console/header.sh` / `console/results.sh` | header/totals rendering, deferred failed/skipped/incomplete/risky blocks (scratch files under the run dir) |
| `console/header.sh` | the "Running N tests" header |
| `console/line.sh` | `print_line`, the primitive every result line goes through, and its TAP variant |
| `console/duration.sh` / `console/diff.sh` | duration formatting; unified and line-by-line diffs under a failure |
| `console/test_line.sh` | the per-test result lines (passed/failed/skipped/incomplete/snapshot/risky/error) |
| `console/deferred.sh` | end-of-run blocks buffered during the run (scratch files under the run dir) |
| `console/summary.sh` | run totals, execution time, hook completion |
| `assert/index.sh` | aggregator only — sources the `src/assert/` module below, plus `skip_todo.sh` and `test_doubles.sh` |
| `assert/core.sh` | `assert::should_skip`, `assert::fail_with`, `assert::join_to_slot` and the comparison assertions the other files build on |
| `assert/{arrays,assertions,dates,duration,files,folders,json,once,snapshot}.sh` | the per-topic assertions; the per-assertion path must stay fork-free |
Expand Down
109 changes: 109 additions & 0 deletions src/console/deferred.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash

# End-of-run blocks buffered during the run and flushed once it finishes.

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
total_failed=$(bashunit::state::get_tests_failed)

if bashunit::env::is_simple_output_enabled; then
printf "\n\n"
fi

if [ "$total_failed" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 failure:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_failed failures:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

sed '${/^$/d;}' "$FAILURES_OUTPUT_PATH" | sed 's/^/|/'
rm "$FAILURES_OUTPUT_PATH"

echo ""
fi
}


##
# Prints the slowest tests recorded during the run, sorted by duration
# descending, limited to BASHUNIT_PROFILE_COUNT entries. Reads the
# tab-separated records appended to PROFILE_OUTPUT_PATH (duration, name, file).
##
function bashunit::console_results::print_profile_and_reset() {
if [ ! -s "$PROFILE_OUTPUT_PATH" ]; then
rm -f "$PROFILE_OUTPUT_PATH"
return
fi

local count="${BASHUNIT_PROFILE_COUNT:-10}"

echo -e "\n${_BASHUNIT_COLOR_BOLD}Slowest tests:${_BASHUNIT_COLOR_DEFAULT}"

local duration name file formatted
# -rn on the first (numeric) field; head limits to the requested count.
while IFS=$'\t' read -r duration name file; do
formatted=$(bashunit::console_results::format_duration "$duration")
printf " %s\t%s (%s)\n" "$formatted" "$name" "$file"
done < <(sort -t"$(printf '\t')" -k1 -rn "$PROFILE_OUTPUT_PATH" | head -n "$count")

echo ""

rm -f "$PROFILE_OUTPUT_PATH"
}


##
# Flushes a deferred summary block (skipped/incomplete/risky): prints the
# "There was 1 <noun>" / "There were N <nouns>" header, then the recorded lines
# from output_path (carriage returns stripped, blank lines dropped, each prefixed
# with "|"), removes the file and prints a trailing blank line. Callers own the
# `[ -s path ]` (and any `is_show_*`) guard so each block keeps its own gate.
# Arguments: $1 output path, $2 total count, $3 singular noun, $4 plural noun
##
function bashunit::console_results::flush_deferred_block() {
local output_path=$1
local total=$2
local singular=$3
local plural=$4

if bashunit::env::is_simple_output_enabled; then
printf "\n"
fi

if [ "$total" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 ${singular}:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were ${total} ${plural}:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

tr -d '\r' <"$output_path" | sed '/^[[:space:]]*$/d' | sed 's/^/|/'
rm "$output_path"

echo ""
}


function bashunit::console_results::print_skipped_tests_and_reset() {
if [ -s "$SKIPPED_OUTPUT_PATH" ] && bashunit::env::is_show_skipped_enabled; then
bashunit::console_results::flush_deferred_block "$SKIPPED_OUTPUT_PATH" \
"$(bashunit::state::get_tests_skipped)" "skipped test" "skipped tests"
fi
}


function bashunit::console_results::print_incomplete_tests_and_reset() {
if [ -s "$INCOMPLETE_OUTPUT_PATH" ] && bashunit::env::is_show_incomplete_enabled; then
bashunit::console_results::flush_deferred_block "$INCOMPLETE_OUTPUT_PATH" \
"$(bashunit::state::get_tests_incomplete)" "incomplete test" "incomplete tests"
fi
}


function bashunit::console_results::print_risky_tests_and_reset() {
if [ -s "$RISKY_OUTPUT_PATH" ]; then
bashunit::console_results::flush_deferred_block "$RISKY_OUTPUT_PATH" \
"$(bashunit::state::get_tests_risky)" "risky test" "risky tests"
fi
}

119 changes: 119 additions & 0 deletions src/console/diff.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env bash

# Unified and line-by-line diffs rendered under a failed assertion or snapshot.

##
# Renders a git word-diff of two files, indented, and echoes it. Colorized
# unless --no-color is active. Empty when git is unavailable or files match.
# Shared by the snapshot-failure and multiline assert-failure renderers.
# Arguments: $1 expected file path, $2 actual file path
##
function bashunit::console_results::render_diff() {
local expected_file=$1
local actual_file=$2

if ! bashunit::dependencies::has_git; then
return 0
fi

local color_flag="--color=always"
if bashunit::env::is_no_color_enabled; then
color_flag="--color=never"
fi

# `git diff` exits non-zero when the files differ; the `|| true` keeps that
# from tripping `set -e`/`pipefail` under --strict. `tail -n +6` drops git's
# header lines; `sed` indents the body. `--no-ext-diff` ignores a user's
# `diff.external`/`GIT_EXTERNAL_DIFF`, which would replace this word-diff.
git diff --no-index --no-ext-diff --word-diff "$color_flag" \
"$expected_file" "$actual_file" 2>/dev/null |
tail -n +6 | sed "s/^/ /" || true
}


##
# Echoes a value's first line, appending an ellipsis when it spans several
# lines. Used to keep the inline quoted value on one line when a diff follows.
##
function bashunit::console_results::first_line_ellipsis() {
local text=$1
local first="${text%%$'\n'*}"
if [ "$first" != "$text" ]; then
printf '%s…' "$first"
else
printf '%s' "$text"
fi
}


##
# Renders a readable line-by-line diff between an expected snapshot and the
# actual content, used as a fallback when git is unavailable. Common lines are
# shown as context, expected-only lines are prefixed with '-' and actual-only
# lines with '+'. Bash 3.0+ compatible (no mapfile, no associative arrays).
# Arguments: $1 expected content, $2 actual content
##
function bashunit::console_results::snapshot_line_diff() {
local expected=$1
local actual=$2

# Explicit empty-array init so referencing the arrays is safe under `set -u`
# on Bash 4.4+ (Bash 3.x is lenient; newer Bash treats an unset array as unbound).
# Declare and assign separately: bash 3.0 does not expand a compound array
# assignment attached to `local`, it stores the literal "()" as element 0.
local expected_lines actual_lines
expected_lines=()
actual_lines=()
local _line=""
local i=0
while IFS= read -r _line || [ -n "$_line" ]; do
expected_lines[i]=$_line
i=$((i + 1))
done <<EOF
$expected
EOF
local expected_count=$i

i=0
while IFS= read -r _line || [ -n "$_line" ]; do
actual_lines[i]=$_line
i=$((i + 1))
done <<EOF
$actual
EOF
local actual_count=$i

local max=$expected_count
if [ "$actual_count" -gt "$max" ]; then
max=$actual_count
fi

local out=""
i=0
while [ "$i" -lt "$max" ]; do
local e="" a="" has_e=0 has_a=0
if [ "$i" -lt "$expected_count" ]; then
e=${expected_lines[i]:-}
has_e=1
fi
if [ "$i" -lt "$actual_count" ]; then
a=${actual_lines[i]:-}
has_a=1
fi

if [ "$has_e" = 1 ] && [ "$has_a" = 1 ] && [ "$e" = "$a" ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
else
if [ "$has_e" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAILED}- %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
fi
if [ "$has_a" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_PASSED}+ %s${_BASHUNIT_COLOR_DEFAULT}" "$a")"
fi
fi
i=$((i + 1))
done

printf "%s" "$out"
}

37 changes: 37 additions & 0 deletions src/console/duration.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash

# Formatting a millisecond duration for display.

##
# Writes a human-readable duration (Xm Ys / X.XXs / Xms) into
# _BASHUNIT_CONSOLE_DURATION_OUT. Fork-free, so per-test render paths can format
# a duration without a $(...) capture.
# Arguments: $1 - duration in milliseconds
##
function bashunit::console_results::format_duration_to_slot() {
local duration_ms="$1"

if [ "$duration_ms" -ge 60000 ]; then
local time_in_seconds=$((duration_ms / 1000))
local minutes=$((time_in_seconds / 60))
local seconds=$((time_in_seconds % 60))
_BASHUNIT_CONSOLE_DURATION_OUT="${minutes}m ${seconds}s"
elif [ "$duration_ms" -ge 1000 ]; then
local integer_part=$((duration_ms / 1000))
local decimal_part=$(((duration_ms % 1000) / 10))
# Pad the hundredths by hand: printf would cost a fork on this hot path.
if [ "$decimal_part" -lt 10 ]; then
decimal_part="0${decimal_part}"
fi
_BASHUNIT_CONSOLE_DURATION_OUT="${integer_part}.${decimal_part}s"
else
_BASHUNIT_CONSOLE_DURATION_OUT="${duration_ms}ms"
fi
}


function bashunit::console_results::format_duration() {
bashunit::console_results::format_duration_to_slot "$1"
echo "$_BASHUNIT_CONSOLE_DURATION_OUT"
}

14 changes: 10 additions & 4 deletions src/console/index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,9 +5,15 @@
# lines, so any statement here would run before its dependencies in the built
# binary (adrs/adr-010-src-module-directories.md).
#
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette that header.sh and
# results.sh render with. Order matches the entrypoint's before this module
# existed.
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette everything else
# renders with. The former results.sh is now six files, sourced leaves first:
# line/duration/diff have no console_results callees; test_line, deferred and
# summary build on them.
source "$BASHUNIT_ROOT_DIR/src/console/colors.sh"
source "$BASHUNIT_ROOT_DIR/src/console/header.sh"
source "$BASHUNIT_ROOT_DIR/src/console/results.sh"
source "$BASHUNIT_ROOT_DIR/src/console/line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/duration.sh"
source "$BASHUNIT_ROOT_DIR/src/console/diff.sh"
source "$BASHUNIT_ROOT_DIR/src/console/test_line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/deferred.sh"
source "$BASHUNIT_ROOT_DIR/src/console/summary.sh"
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(console): split results.sh into six single-purpose files by Chemaclass · Pull Request #947 · TypedDevs/bashunit · GitHub
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
7 changes: 6 additions & 1 deletion .claude/rules/architecture-map.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,7 +74,12 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end).
| `parallel.sh` | worker temp tree, aggregation, stop-on-failure flag file |
| `console/index.sh` | aggregator only — sources the `src/console/` module below |
| `console/colors.sh` | the `_BASHUNIT_COLOR_*` palette and `bashunit::sgr` |
| `console/header.sh` / `console/results.sh` | header/totals rendering, deferred failed/skipped/incomplete/risky blocks (scratch files under the run dir) |
| `console/header.sh` | the "Running N tests" header |
| `console/line.sh` | `print_line`, the primitive every result line goes through, and its TAP variant |
| `console/duration.sh` / `console/diff.sh` | duration formatting; unified and line-by-line diffs under a failure |
| `console/test_line.sh` | the per-test result lines (passed/failed/skipped/incomplete/snapshot/risky/error) |
| `console/deferred.sh` | end-of-run blocks buffered during the run (scratch files under the run dir) |
| `console/summary.sh` | run totals, execution time, hook completion |
| `assert/index.sh` | aggregator only — sources the `src/assert/` module below, plus `skip_todo.sh` and `test_doubles.sh` |
| `assert/core.sh` | `assert::should_skip`, `assert::fail_with`, `assert::join_to_slot` and the comparison assertions the other files build on |
| `assert/{arrays,assertions,dates,duration,files,folders,json,once,snapshot}.sh` | the per-topic assertions; the per-assertion path must stay fork-free |
Expand Down
109 changes: 109 additions & 0 deletions src/console/deferred.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash

# End-of-run blocks buffered during the run and flushed once it finishes.

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
total_failed=$(bashunit::state::get_tests_failed)

if bashunit::env::is_simple_output_enabled; then
printf "\n\n"
fi

if [ "$total_failed" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 failure:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_failed failures:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

sed '${/^$/d;}' "$FAILURES_OUTPUT_PATH" | sed 's/^/|/'
rm "$FAILURES_OUTPUT_PATH"

echo ""
fi
}


##
# Prints the slowest tests recorded during the run, sorted by duration
# descending, limited to BASHUNIT_PROFILE_COUNT entries. Reads the
# tab-separated records appended to PROFILE_OUTPUT_PATH (duration, name, file).
##
function bashunit::console_results::print_profile_and_reset() {
if [ ! -s "$PROFILE_OUTPUT_PATH" ]; then
rm -f "$PROFILE_OUTPUT_PATH"
return
fi

local count="${BASHUNIT_PROFILE_COUNT:-10}"

echo -e "\n${_BASHUNIT_COLOR_BOLD}Slowest tests:${_BASHUNIT_COLOR_DEFAULT}"

local duration name file formatted
# -rn on the first (numeric) field; head limits to the requested count.
while IFS=$'\t' read -r duration name file; do
formatted=$(bashunit::console_results::format_duration "$duration")
printf " %s\t%s (%s)\n" "$formatted" "$name" "$file"
done < <(sort -t"$(printf '\t')" -k1 -rn "$PROFILE_OUTPUT_PATH" | head -n "$count")

echo ""

rm -f "$PROFILE_OUTPUT_PATH"
}


##
# Flushes a deferred summary block (skipped/incomplete/risky): prints the
# "There was 1 <noun>" / "There were N <nouns>" header, then the recorded lines
# from output_path (carriage returns stripped, blank lines dropped, each prefixed
# with "|"), removes the file and prints a trailing blank line. Callers own the
# `[ -s path ]` (and any `is_show_*`) guard so each block keeps its own gate.
# Arguments: $1 output path, $2 total count, $3 singular noun, $4 plural noun
##
function bashunit::console_results::flush_deferred_block() {
local output_path=$1
local total=$2
local singular=$3
local plural=$4

if bashunit::env::is_simple_output_enabled; then
printf "\n"
fi

if [ "$total" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 ${singular}:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were ${total} ${plural}:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

tr -d '\r' <"$output_path" | sed '/^[[:space:]]*$/d' | sed 's/^/|/'
rm "$output_path"

echo ""
}


function bashunit::console_results::print_skipped_tests_and_reset() {
if [ -s "$SKIPPED_OUTPUT_PATH" ] && bashunit::env::is_show_skipped_enabled; then
bashunit::console_results::flush_deferred_block "$SKIPPED_OUTPUT_PATH" \
"$(bashunit::state::get_tests_skipped)" "skipped test" "skipped tests"
fi
}


function bashunit::console_results::print_incomplete_tests_and_reset() {
if [ -s "$INCOMPLETE_OUTPUT_PATH" ] && bashunit::env::is_show_incomplete_enabled; then
bashunit::console_results::flush_deferred_block "$INCOMPLETE_OUTPUT_PATH" \
"$(bashunit::state::get_tests_incomplete)" "incomplete test" "incomplete tests"
fi
}


function bashunit::console_results::print_risky_tests_and_reset() {
if [ -s "$RISKY_OUTPUT_PATH" ]; then
bashunit::console_results::flush_deferred_block "$RISKY_OUTPUT_PATH" \
"$(bashunit::state::get_tests_risky)" "risky test" "risky tests"
fi
}

119 changes: 119 additions & 0 deletions src/console/diff.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env bash

# Unified and line-by-line diffs rendered under a failed assertion or snapshot.

##
# Renders a git word-diff of two files, indented, and echoes it. Colorized
# unless --no-color is active. Empty when git is unavailable or files match.
# Shared by the snapshot-failure and multiline assert-failure renderers.
# Arguments: $1 expected file path, $2 actual file path
##
function bashunit::console_results::render_diff() {
local expected_file=$1
local actual_file=$2

if ! bashunit::dependencies::has_git; then
return 0
fi

local color_flag="--color=always"
if bashunit::env::is_no_color_enabled; then
color_flag="--color=never"
fi

# `git diff` exits non-zero when the files differ; the `|| true` keeps that
# from tripping `set -e`/`pipefail` under --strict. `tail -n +6` drops git's
# header lines; `sed` indents the body. `--no-ext-diff` ignores a user's
# `diff.external`/`GIT_EXTERNAL_DIFF`, which would replace this word-diff.
git diff --no-index --no-ext-diff --word-diff "$color_flag" \
"$expected_file" "$actual_file" 2>/dev/null |
tail -n +6 | sed "s/^/ /" || true
}


##
# Echoes a value's first line, appending an ellipsis when it spans several
# lines. Used to keep the inline quoted value on one line when a diff follows.
##
function bashunit::console_results::first_line_ellipsis() {
local text=$1
local first="${text%%$'\n'*}"
if [ "$first" != "$text" ]; then
printf '%s…' "$first"
else
printf '%s' "$text"
fi
}


##
# Renders a readable line-by-line diff between an expected snapshot and the
# actual content, used as a fallback when git is unavailable. Common lines are
# shown as context, expected-only lines are prefixed with '-' and actual-only
# lines with '+'. Bash 3.0+ compatible (no mapfile, no associative arrays).
# Arguments: $1 expected content, $2 actual content
##
function bashunit::console_results::snapshot_line_diff() {
local expected=$1
local actual=$2

# Explicit empty-array init so referencing the arrays is safe under `set -u`
# on Bash 4.4+ (Bash 3.x is lenient; newer Bash treats an unset array as unbound).
# Declare and assign separately: bash 3.0 does not expand a compound array
# assignment attached to `local`, it stores the literal "()" as element 0.
local expected_lines actual_lines
expected_lines=()
actual_lines=()
local _line=""
local i=0
while IFS= read -r _line || [ -n "$_line" ]; do
expected_lines[i]=$_line
i=$((i + 1))
done <<EOF
$expected
EOF
local expected_count=$i

i=0
while IFS= read -r _line || [ -n "$_line" ]; do
actual_lines[i]=$_line
i=$((i + 1))
done <<EOF
$actual
EOF
local actual_count=$i

local max=$expected_count
if [ "$actual_count" -gt "$max" ]; then
max=$actual_count
fi

local out=""
i=0
while [ "$i" -lt "$max" ]; do
local e="" a="" has_e=0 has_a=0
if [ "$i" -lt "$expected_count" ]; then
e=${expected_lines[i]:-}
has_e=1
fi
if [ "$i" -lt "$actual_count" ]; then
a=${actual_lines[i]:-}
has_a=1
fi

if [ "$has_e" = 1 ] && [ "$has_a" = 1 ] && [ "$e" = "$a" ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
else
if [ "$has_e" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAILED}- %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
fi
if [ "$has_a" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_PASSED}+ %s${_BASHUNIT_COLOR_DEFAULT}" "$a")"
fi
fi
i=$((i + 1))
done

printf "%s" "$out"
}

37 changes: 37 additions & 0 deletions src/console/duration.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash

# Formatting a millisecond duration for display.

##
# Writes a human-readable duration (Xm Ys / X.XXs / Xms) into
# _BASHUNIT_CONSOLE_DURATION_OUT. Fork-free, so per-test render paths can format
# a duration without a $(...) capture.
# Arguments: $1 - duration in milliseconds
##
function bashunit::console_results::format_duration_to_slot() {
local duration_ms="$1"

if [ "$duration_ms" -ge 60000 ]; then
local time_in_seconds=$((duration_ms / 1000))
local minutes=$((time_in_seconds / 60))
local seconds=$((time_in_seconds % 60))
_BASHUNIT_CONSOLE_DURATION_OUT="${minutes}m ${seconds}s"
elif [ "$duration_ms" -ge 1000 ]; then
local integer_part=$((duration_ms / 1000))
local decimal_part=$(((duration_ms % 1000) / 10))
# Pad the hundredths by hand: printf would cost a fork on this hot path.
if [ "$decimal_part" -lt 10 ]; then
decimal_part="0${decimal_part}"
fi
_BASHUNIT_CONSOLE_DURATION_OUT="${integer_part}.${decimal_part}s"
else
_BASHUNIT_CONSOLE_DURATION_OUT="${duration_ms}ms"
fi
}


function bashunit::console_results::format_duration() {
bashunit::console_results::format_duration_to_slot "$1"
echo "$_BASHUNIT_CONSOLE_DURATION_OUT"
}

14 changes: 10 additions & 4 deletions src/console/index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,9 +5,15 @@
# lines, so any statement here would run before its dependencies in the built
# binary (adrs/adr-010-src-module-directories.md).
#
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette that header.sh and
# results.sh render with. Order matches the entrypoint's before this module
# existed.
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette everything else
# renders with. The former results.sh is now six files, sourced leaves first:
# line/duration/diff have no console_results callees; test_line, deferred and
# summary build on them.
source "$BASHUNIT_ROOT_DIR/src/console/colors.sh"
source "$BASHUNIT_ROOT_DIR/src/console/header.sh"
source "$BASHUNIT_ROOT_DIR/src/console/results.sh"
source "$BASHUNIT_ROOT_DIR/src/console/line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/duration.sh"
source "$BASHUNIT_ROOT_DIR/src/console/diff.sh"
source "$BASHUNIT_ROOT_DIR/src/console/test_line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/deferred.sh"
source "$BASHUNIT_ROOT_DIR/src/console/summary.sh"
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(console): split results.sh into six single-purpose files by Chemaclass · Pull Request #947 · TypedDevs/bashunit · GitHub
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
7 changes: 6 additions & 1 deletion .claude/rules/architecture-map.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,7 +74,12 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end).
| `parallel.sh` | worker temp tree, aggregation, stop-on-failure flag file |
| `console/index.sh` | aggregator only — sources the `src/console/` module below |
| `console/colors.sh` | the `_BASHUNIT_COLOR_*` palette and `bashunit::sgr` |
| `console/header.sh` / `console/results.sh` | header/totals rendering, deferred failed/skipped/incomplete/risky blocks (scratch files under the run dir) |
| `console/header.sh` | the "Running N tests" header |
| `console/line.sh` | `print_line`, the primitive every result line goes through, and its TAP variant |
| `console/duration.sh` / `console/diff.sh` | duration formatting; unified and line-by-line diffs under a failure |
| `console/test_line.sh` | the per-test result lines (passed/failed/skipped/incomplete/snapshot/risky/error) |
| `console/deferred.sh` | end-of-run blocks buffered during the run (scratch files under the run dir) |
| `console/summary.sh` | run totals, execution time, hook completion |
| `assert/index.sh` | aggregator only — sources the `src/assert/` module below, plus `skip_todo.sh` and `test_doubles.sh` |
| `assert/core.sh` | `assert::should_skip`, `assert::fail_with`, `assert::join_to_slot` and the comparison assertions the other files build on |
| `assert/{arrays,assertions,dates,duration,files,folders,json,once,snapshot}.sh` | the per-topic assertions; the per-assertion path must stay fork-free |
Expand Down
109 changes: 109 additions & 0 deletions src/console/deferred.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash

# End-of-run blocks buffered during the run and flushed once it finishes.

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
total_failed=$(bashunit::state::get_tests_failed)

if bashunit::env::is_simple_output_enabled; then
printf "\n\n"
fi

if [ "$total_failed" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 failure:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_failed failures:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

sed '${/^$/d;}' "$FAILURES_OUTPUT_PATH" | sed 's/^/|/'
rm "$FAILURES_OUTPUT_PATH"

echo ""
fi
}


##
# Prints the slowest tests recorded during the run, sorted by duration
# descending, limited to BASHUNIT_PROFILE_COUNT entries. Reads the
# tab-separated records appended to PROFILE_OUTPUT_PATH (duration, name, file).
##
function bashunit::console_results::print_profile_and_reset() {
if [ ! -s "$PROFILE_OUTPUT_PATH" ]; then
rm -f "$PROFILE_OUTPUT_PATH"
return
fi

local count="${BASHUNIT_PROFILE_COUNT:-10}"

echo -e "\n${_BASHUNIT_COLOR_BOLD}Slowest tests:${_BASHUNIT_COLOR_DEFAULT}"

local duration name file formatted
# -rn on the first (numeric) field; head limits to the requested count.
while IFS=$'\t' read -r duration name file; do
formatted=$(bashunit::console_results::format_duration "$duration")
printf " %s\t%s (%s)\n" "$formatted" "$name" "$file"
done < <(sort -t"$(printf '\t')" -k1 -rn "$PROFILE_OUTPUT_PATH" | head -n "$count")

echo ""

rm -f "$PROFILE_OUTPUT_PATH"
}


##
# Flushes a deferred summary block (skipped/incomplete/risky): prints the
# "There was 1 <noun>" / "There were N <nouns>" header, then the recorded lines
# from output_path (carriage returns stripped, blank lines dropped, each prefixed
# with "|"), removes the file and prints a trailing blank line. Callers own the
# `[ -s path ]` (and any `is_show_*`) guard so each block keeps its own gate.
# Arguments: $1 output path, $2 total count, $3 singular noun, $4 plural noun
##
function bashunit::console_results::flush_deferred_block() {
local output_path=$1
local total=$2
local singular=$3
local plural=$4

if bashunit::env::is_simple_output_enabled; then
printf "\n"
fi

if [ "$total" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 ${singular}:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were ${total} ${plural}:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

tr -d '\r' <"$output_path" | sed '/^[[:space:]]*$/d' | sed 's/^/|/'
rm "$output_path"

echo ""
}


function bashunit::console_results::print_skipped_tests_and_reset() {
if [ -s "$SKIPPED_OUTPUT_PATH" ] && bashunit::env::is_show_skipped_enabled; then
bashunit::console_results::flush_deferred_block "$SKIPPED_OUTPUT_PATH" \
"$(bashunit::state::get_tests_skipped)" "skipped test" "skipped tests"
fi
}


function bashunit::console_results::print_incomplete_tests_and_reset() {
if [ -s "$INCOMPLETE_OUTPUT_PATH" ] && bashunit::env::is_show_incomplete_enabled; then
bashunit::console_results::flush_deferred_block "$INCOMPLETE_OUTPUT_PATH" \
"$(bashunit::state::get_tests_incomplete)" "incomplete test" "incomplete tests"
fi
}


function bashunit::console_results::print_risky_tests_and_reset() {
if [ -s "$RISKY_OUTPUT_PATH" ]; then
bashunit::console_results::flush_deferred_block "$RISKY_OUTPUT_PATH" \
"$(bashunit::state::get_tests_risky)" "risky test" "risky tests"
fi
}

119 changes: 119 additions & 0 deletions src/console/diff.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env bash

# Unified and line-by-line diffs rendered under a failed assertion or snapshot.

##
# Renders a git word-diff of two files, indented, and echoes it. Colorized
# unless --no-color is active. Empty when git is unavailable or files match.
# Shared by the snapshot-failure and multiline assert-failure renderers.
# Arguments: $1 expected file path, $2 actual file path
##
function bashunit::console_results::render_diff() {
local expected_file=$1
local actual_file=$2

if ! bashunit::dependencies::has_git; then
return 0
fi

local color_flag="--color=always"
if bashunit::env::is_no_color_enabled; then
color_flag="--color=never"
fi

# `git diff` exits non-zero when the files differ; the `|| true` keeps that
# from tripping `set -e`/`pipefail` under --strict. `tail -n +6` drops git's
# header lines; `sed` indents the body. `--no-ext-diff` ignores a user's
# `diff.external`/`GIT_EXTERNAL_DIFF`, which would replace this word-diff.
git diff --no-index --no-ext-diff --word-diff "$color_flag" \
"$expected_file" "$actual_file" 2>/dev/null |
tail -n +6 | sed "s/^/ /" || true
}


##
# Echoes a value's first line, appending an ellipsis when it spans several
# lines. Used to keep the inline quoted value on one line when a diff follows.
##
function bashunit::console_results::first_line_ellipsis() {
local text=$1
local first="${text%%$'\n'*}"
if [ "$first" != "$text" ]; then
printf '%s…' "$first"
else
printf '%s' "$text"
fi
}


##
# Renders a readable line-by-line diff between an expected snapshot and the
# actual content, used as a fallback when git is unavailable. Common lines are
# shown as context, expected-only lines are prefixed with '-' and actual-only
# lines with '+'. Bash 3.0+ compatible (no mapfile, no associative arrays).
# Arguments: $1 expected content, $2 actual content
##
function bashunit::console_results::snapshot_line_diff() {
local expected=$1
local actual=$2

# Explicit empty-array init so referencing the arrays is safe under `set -u`
# on Bash 4.4+ (Bash 3.x is lenient; newer Bash treats an unset array as unbound).
# Declare and assign separately: bash 3.0 does not expand a compound array
# assignment attached to `local`, it stores the literal "()" as element 0.
local expected_lines actual_lines
expected_lines=()
actual_lines=()
local _line=""
local i=0
while IFS= read -r _line || [ -n "$_line" ]; do
expected_lines[i]=$_line
i=$((i + 1))
done <<EOF
$expected
EOF
local expected_count=$i

i=0
while IFS= read -r _line || [ -n "$_line" ]; do
actual_lines[i]=$_line
i=$((i + 1))
done <<EOF
$actual
EOF
local actual_count=$i

local max=$expected_count
if [ "$actual_count" -gt "$max" ]; then
max=$actual_count
fi

local out=""
i=0
while [ "$i" -lt "$max" ]; do
local e="" a="" has_e=0 has_a=0
if [ "$i" -lt "$expected_count" ]; then
e=${expected_lines[i]:-}
has_e=1
fi
if [ "$i" -lt "$actual_count" ]; then
a=${actual_lines[i]:-}
has_a=1
fi

if [ "$has_e" = 1 ] && [ "$has_a" = 1 ] && [ "$e" = "$a" ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
else
if [ "$has_e" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAILED}- %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
fi
if [ "$has_a" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_PASSED}+ %s${_BASHUNIT_COLOR_DEFAULT}" "$a")"
fi
fi
i=$((i + 1))
done

printf "%s" "$out"
}

37 changes: 37 additions & 0 deletions src/console/duration.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash

# Formatting a millisecond duration for display.

##
# Writes a human-readable duration (Xm Ys / X.XXs / Xms) into
# _BASHUNIT_CONSOLE_DURATION_OUT. Fork-free, so per-test render paths can format
# a duration without a $(...) capture.
# Arguments: $1 - duration in milliseconds
##
function bashunit::console_results::format_duration_to_slot() {
local duration_ms="$1"

if [ "$duration_ms" -ge 60000 ]; then
local time_in_seconds=$((duration_ms / 1000))
local minutes=$((time_in_seconds / 60))
local seconds=$((time_in_seconds % 60))
_BASHUNIT_CONSOLE_DURATION_OUT="${minutes}m ${seconds}s"
elif [ "$duration_ms" -ge 1000 ]; then
local integer_part=$((duration_ms / 1000))
local decimal_part=$(((duration_ms % 1000) / 10))
# Pad the hundredths by hand: printf would cost a fork on this hot path.
if [ "$decimal_part" -lt 10 ]; then
decimal_part="0${decimal_part}"
fi
_BASHUNIT_CONSOLE_DURATION_OUT="${integer_part}.${decimal_part}s"
else
_BASHUNIT_CONSOLE_DURATION_OUT="${duration_ms}ms"
fi
}


function bashunit::console_results::format_duration() {
bashunit::console_results::format_duration_to_slot "$1"
echo "$_BASHUNIT_CONSOLE_DURATION_OUT"
}

14 changes: 10 additions & 4 deletions src/console/index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,9 +5,15 @@
# lines, so any statement here would run before its dependencies in the built
# binary (adrs/adr-010-src-module-directories.md).
#
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette that header.sh and
# results.sh render with. Order matches the entrypoint's before this module
# existed.
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette everything else
# renders with. The former results.sh is now six files, sourced leaves first:
# line/duration/diff have no console_results callees; test_line, deferred and
# summary build on them.
source "$BASHUNIT_ROOT_DIR/src/console/colors.sh"
source "$BASHUNIT_ROOT_DIR/src/console/header.sh"
source "$BASHUNIT_ROOT_DIR/src/console/results.sh"
source "$BASHUNIT_ROOT_DIR/src/console/line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/duration.sh"
source "$BASHUNIT_ROOT_DIR/src/console/diff.sh"
source "$BASHUNIT_ROOT_DIR/src/console/test_line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/deferred.sh"
source "$BASHUNIT_ROOT_DIR/src/console/summary.sh"
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); refactor(console): split results.sh into six single-purpose files by Chemaclass · Pull Request #947 · TypedDevs/bashunit · GitHub
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
7 changes: 6 additions & 1 deletion .claude/rules/architecture-map.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,7 +74,12 @@ shell (or, in parallel, in per-test `.result` files aggregated at the end).
| `parallel.sh` | worker temp tree, aggregation, stop-on-failure flag file |
| `console/index.sh` | aggregator only — sources the `src/console/` module below |
| `console/colors.sh` | the `_BASHUNIT_COLOR_*` palette and `bashunit::sgr` |
| `console/header.sh` / `console/results.sh` | header/totals rendering, deferred failed/skipped/incomplete/risky blocks (scratch files under the run dir) |
| `console/header.sh` | the "Running N tests" header |
| `console/line.sh` | `print_line`, the primitive every result line goes through, and its TAP variant |
| `console/duration.sh` / `console/diff.sh` | duration formatting; unified and line-by-line diffs under a failure |
| `console/test_line.sh` | the per-test result lines (passed/failed/skipped/incomplete/snapshot/risky/error) |
| `console/deferred.sh` | end-of-run blocks buffered during the run (scratch files under the run dir) |
| `console/summary.sh` | run totals, execution time, hook completion |
| `assert/index.sh` | aggregator only — sources the `src/assert/` module below, plus `skip_todo.sh` and `test_doubles.sh` |
| `assert/core.sh` | `assert::should_skip`, `assert::fail_with`, `assert::join_to_slot` and the comparison assertions the other files build on |
| `assert/{arrays,assertions,dates,duration,files,folders,json,once,snapshot}.sh` | the per-topic assertions; the per-assertion path must stay fork-free |
Expand Down
109 changes: 109 additions & 0 deletions src/console/deferred.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash

# End-of-run blocks buffered during the run and flushed once it finishes.

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
total_failed=$(bashunit::state::get_tests_failed)

if bashunit::env::is_simple_output_enabled; then
printf "\n\n"
fi

if [ "$total_failed" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 failure:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were $total_failed failures:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

sed '${/^$/d;}' "$FAILURES_OUTPUT_PATH" | sed 's/^/|/'
rm "$FAILURES_OUTPUT_PATH"

echo ""
fi
}


##
# Prints the slowest tests recorded during the run, sorted by duration
# descending, limited to BASHUNIT_PROFILE_COUNT entries. Reads the
# tab-separated records appended to PROFILE_OUTPUT_PATH (duration, name, file).
##
function bashunit::console_results::print_profile_and_reset() {
if [ ! -s "$PROFILE_OUTPUT_PATH" ]; then
rm -f "$PROFILE_OUTPUT_PATH"
return
fi

local count="${BASHUNIT_PROFILE_COUNT:-10}"

echo -e "\n${_BASHUNIT_COLOR_BOLD}Slowest tests:${_BASHUNIT_COLOR_DEFAULT}"

local duration name file formatted
# -rn on the first (numeric) field; head limits to the requested count.
while IFS=$'\t' read -r duration name file; do
formatted=$(bashunit::console_results::format_duration "$duration")
printf " %s\t%s (%s)\n" "$formatted" "$name" "$file"
done < <(sort -t"$(printf '\t')" -k1 -rn "$PROFILE_OUTPUT_PATH" | head -n "$count")

echo ""

rm -f "$PROFILE_OUTPUT_PATH"
}


##
# Flushes a deferred summary block (skipped/incomplete/risky): prints the
# "There was 1 <noun>" / "There were N <nouns>" header, then the recorded lines
# from output_path (carriage returns stripped, blank lines dropped, each prefixed
# with "|"), removes the file and prints a trailing blank line. Callers own the
# `[ -s path ]` (and any `is_show_*`) guard so each block keeps its own gate.
# Arguments: $1 output path, $2 total count, $3 singular noun, $4 plural noun
##
function bashunit::console_results::flush_deferred_block() {
local output_path=$1
local total=$2
local singular=$3
local plural=$4

if bashunit::env::is_simple_output_enabled; then
printf "\n"
fi

if [ "$total" -eq 1 ]; then
echo -e "${_BASHUNIT_COLOR_BOLD}There was 1 ${singular}:${_BASHUNIT_COLOR_DEFAULT}\n"
else
echo -e "${_BASHUNIT_COLOR_BOLD}There were ${total} ${plural}:${_BASHUNIT_COLOR_DEFAULT}\n"
fi

tr -d '\r' <"$output_path" | sed '/^[[:space:]]*$/d' | sed 's/^/|/'
rm "$output_path"

echo ""
}


function bashunit::console_results::print_skipped_tests_and_reset() {
if [ -s "$SKIPPED_OUTPUT_PATH" ] && bashunit::env::is_show_skipped_enabled; then
bashunit::console_results::flush_deferred_block "$SKIPPED_OUTPUT_PATH" \
"$(bashunit::state::get_tests_skipped)" "skipped test" "skipped tests"
fi
}


function bashunit::console_results::print_incomplete_tests_and_reset() {
if [ -s "$INCOMPLETE_OUTPUT_PATH" ] && bashunit::env::is_show_incomplete_enabled; then
bashunit::console_results::flush_deferred_block "$INCOMPLETE_OUTPUT_PATH" \
"$(bashunit::state::get_tests_incomplete)" "incomplete test" "incomplete tests"
fi
}


function bashunit::console_results::print_risky_tests_and_reset() {
if [ -s "$RISKY_OUTPUT_PATH" ]; then
bashunit::console_results::flush_deferred_block "$RISKY_OUTPUT_PATH" \
"$(bashunit::state::get_tests_risky)" "risky test" "risky tests"
fi
}

119 changes: 119 additions & 0 deletions src/console/diff.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env bash

# Unified and line-by-line diffs rendered under a failed assertion or snapshot.

##
# Renders a git word-diff of two files, indented, and echoes it. Colorized
# unless --no-color is active. Empty when git is unavailable or files match.
# Shared by the snapshot-failure and multiline assert-failure renderers.
# Arguments: $1 expected file path, $2 actual file path
##
function bashunit::console_results::render_diff() {
local expected_file=$1
local actual_file=$2

if ! bashunit::dependencies::has_git; then
return 0
fi

local color_flag="--color=always"
if bashunit::env::is_no_color_enabled; then
color_flag="--color=never"
fi

# `git diff` exits non-zero when the files differ; the `|| true` keeps that
# from tripping `set -e`/`pipefail` under --strict. `tail -n +6` drops git's
# header lines; `sed` indents the body. `--no-ext-diff` ignores a user's
# `diff.external`/`GIT_EXTERNAL_DIFF`, which would replace this word-diff.
git diff --no-index --no-ext-diff --word-diff "$color_flag" \
"$expected_file" "$actual_file" 2>/dev/null |
tail -n +6 | sed "s/^/ /" || true
}


##
# Echoes a value's first line, appending an ellipsis when it spans several
# lines. Used to keep the inline quoted value on one line when a diff follows.
##
function bashunit::console_results::first_line_ellipsis() {
local text=$1
local first="${text%%$'\n'*}"
if [ "$first" != "$text" ]; then
printf '%s…' "$first"
else
printf '%s' "$text"
fi
}


##
# Renders a readable line-by-line diff between an expected snapshot and the
# actual content, used as a fallback when git is unavailable. Common lines are
# shown as context, expected-only lines are prefixed with '-' and actual-only
# lines with '+'. Bash 3.0+ compatible (no mapfile, no associative arrays).
# Arguments: $1 expected content, $2 actual content
##
function bashunit::console_results::snapshot_line_diff() {
local expected=$1
local actual=$2

# Explicit empty-array init so referencing the arrays is safe under `set -u`
# on Bash 4.4+ (Bash 3.x is lenient; newer Bash treats an unset array as unbound).
# Declare and assign separately: bash 3.0 does not expand a compound array
# assignment attached to `local`, it stores the literal "()" as element 0.
local expected_lines actual_lines
expected_lines=()
actual_lines=()
local _line=""
local i=0
while IFS= read -r _line || [ -n "$_line" ]; do
expected_lines[i]=$_line
i=$((i + 1))
done <<EOF
$expected
EOF
local expected_count=$i

i=0
while IFS= read -r _line || [ -n "$_line" ]; do
actual_lines[i]=$_line
i=$((i + 1))
done <<EOF
$actual
EOF
local actual_count=$i

local max=$expected_count
if [ "$actual_count" -gt "$max" ]; then
max=$actual_count
fi

local out=""
i=0
while [ "$i" -lt "$max" ]; do
local e="" a="" has_e=0 has_a=0
if [ "$i" -lt "$expected_count" ]; then
e=${expected_lines[i]:-}
has_e=1
fi
if [ "$i" -lt "$actual_count" ]; then
a=${actual_lines[i]:-}
has_a=1
fi

if [ "$has_e" = 1 ] && [ "$has_a" = 1 ] && [ "$e" = "$a" ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAINT} %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
else
if [ "$has_e" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_FAILED}- %s${_BASHUNIT_COLOR_DEFAULT}" "$e")"
fi
if [ "$has_a" = 1 ]; then
out="$out$(printf "\n ${_BASHUNIT_COLOR_PASSED}+ %s${_BASHUNIT_COLOR_DEFAULT}" "$a")"
fi
fi
i=$((i + 1))
done

printf "%s" "$out"
}

37 changes: 37 additions & 0 deletions src/console/duration.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash

# Formatting a millisecond duration for display.

##
# Writes a human-readable duration (Xm Ys / X.XXs / Xms) into
# _BASHUNIT_CONSOLE_DURATION_OUT. Fork-free, so per-test render paths can format
# a duration without a $(...) capture.
# Arguments: $1 - duration in milliseconds
##
function bashunit::console_results::format_duration_to_slot() {
local duration_ms="$1"

if [ "$duration_ms" -ge 60000 ]; then
local time_in_seconds=$((duration_ms / 1000))
local minutes=$((time_in_seconds / 60))
local seconds=$((time_in_seconds % 60))
_BASHUNIT_CONSOLE_DURATION_OUT="${minutes}m ${seconds}s"
elif [ "$duration_ms" -ge 1000 ]; then
local integer_part=$((duration_ms / 1000))
local decimal_part=$(((duration_ms % 1000) / 10))
# Pad the hundredths by hand: printf would cost a fork on this hot path.
if [ "$decimal_part" -lt 10 ]; then
decimal_part="0${decimal_part}"
fi
_BASHUNIT_CONSOLE_DURATION_OUT="${integer_part}.${decimal_part}s"
else
_BASHUNIT_CONSOLE_DURATION_OUT="${duration_ms}ms"
fi
}


function bashunit::console_results::format_duration() {
bashunit::console_results::format_duration_to_slot "$1"
echo "$_BASHUNIT_CONSOLE_DURATION_OUT"
}

14 changes: 10 additions & 4 deletions src/console/index.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,9 +5,15 @@
# lines, so any statement here would run before its dependencies in the built
# binary (adrs/adr-010-src-module-directories.md).
#
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette that header.sh and
# results.sh render with. Order matches the entrypoint's before this module
# existed.
# colors.sh first: it defines the _BASHUNIT_COLOR_* palette everything else
# renders with. The former results.sh is now six files, sourced leaves first:
# line/duration/diff have no console_results callees; test_line, deferred and
# summary build on them.
source "$BASHUNIT_ROOT_DIR/src/console/colors.sh"
source "$BASHUNIT_ROOT_DIR/src/console/header.sh"
source "$BASHUNIT_ROOT_DIR/src/console/results.sh"
source "$BASHUNIT_ROOT_DIR/src/console/line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/duration.sh"
source "$BASHUNIT_ROOT_DIR/src/console/diff.sh"
source "$BASHUNIT_ROOT_DIR/src/console/test_line.sh"
source "$BASHUNIT_ROOT_DIR/src/console/deferred.sh"
source "$BASHUNIT_ROOT_DIR/src/console/summary.sh"
Loading
Loading