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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@
- The `--parallel` unsupported-OS warning no longer claims Alpine is excluded: Alpine has been a supported parallel platform since the race conditions were fixed, the message was simply never updated

### Fixed
- `--parallel` no longer discards stderr written inside a worker but outside a test body, which made the same run report differently depending on the mode. Each file worker had been spawned with `2>/dev/null` since #358 to keep its noise off the progress line; its stderr is now captured per file and rendered afterwards as a `Stderr from <file>` block, so data-provider diagnostics, hook-plumbing messages and scratch-dir errors survive. Output written by a test *body* was never affected β€” it is merged into that test's captured stdout and already appeared in its failure block (#864)
- The minimum-bash gate now compares the minor version as well as the major, so it enforces whatever `BASHUNIT_MIN_BASH_VERSION` declares. It previously accepted any `3.x` regardless of the stated minimum, and a version string carrying a suffix (`5.2.37(1)-release`) is now parsed instead of tripping the comparison. The floor itself is unchanged at **Bash 3.0+**; `printf -v`, `+=` and `[[ =~ ]]` are now rejected in `src/` by the compatibility gate, since 3.0/3.1 lack the first two and 3.2 changed quoted-pattern semantics for the third
- An empty entry in `.env` no longer overrides a value the caller exported or set on the command line. `.env` is sourced under `set -o allexport`, so every line was an unconditional assignment: merely *listing* a name blanked it, and `BASHUNIT_OUTPUT_FORMAT=tap ./bashunit` silently stopped working in any project whose `.env` mentioned that setting. An empty entry now means "not configured here"; an entry with a value still takes effect. This had been actively concealing defects β€” two of the bugs fixed in #879 were not reproducible from inside a repo checkout for exactly this reason (#865)
- A malformed benchmark annotation is now an error instead of a silent fallback to the default: `@revs=abc` quietly ran a single revolution, `@its=abc` a single iteration, and `@max_ms=abc` dropped the threshold entirely so the benchmark could never fail β€” each reporting success while measuring something other than what was written (#884)
Expand Down
18 changes: 18 additions & 0 deletions src/console_results.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -559,6 +559,24 @@ function bashunit::console_results::print_error_test() {
bashunit::state::print_line "error" "$line"
}

##
# Render stderr a parallel worker wrote outside any test body.
# A sequential run lets this straight through to the terminal; parallel workers
# have it captured per file so concurrent writes cannot shred the progress
# line. Attributed to the file, not a test: it is emitted where no test owns it
# (data providers, hook plumbing). Test-body stderr is merged into the captured
# stdout and still surfaces in that test's own failure block.
# Arguments: $1 - test file the worker ran, $2 - captured stderr file
##
function bashunit::console_results::print_worker_stderr() {
local test_file="$1"
local stderr_file="$2"

printf "\n%sStderr from %s%s\n" \
"$_BASHUNIT_COLOR_SKIPPED" "$test_file" "$_BASHUNIT_COLOR_DEFAULT"
sed 's/^/|/' "$stderr_file"
}

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
Expand Down
5 changes: 5 additions & 0 deletions src/env.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -654,6 +654,11 @@ SKIPPED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/skipped"
INCOMPLETE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/incomplete"
RISKY_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/risky"
PROFILE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/profile"
# Prefix for one file-per-worker capture of stderr written inside a parallel
# worker but outside a test body. An ordinal is appended per spawned worker;
# these must not live under TEMP_DIR_PARALLEL_TEST_SUITE, whose every entry is
# walked by state::aggregate_parallel_results (#864).
WORKER_STDERR_OUTPUT_PREFIX="$_BASHUNIT_RUN_OUTPUT_DIR/worker-stderr"
# Collects "<test_file>:<function_name>" for every failing test in a run so the
# next --rerun-failed can replay just those. Shared across parallel subshells.
RERUN_FAILED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/rerun-failed"
Expand Down
23 changes: 22 additions & 1 deletion src/runner.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -396,6 +396,9 @@ function bashunit::runner::load_test_files() {
files=("$@")
local -a scripts_ids=()
local scripts_ids_count=0
local -a worker_stderr_paths=()
local -a worker_stderr_owners=()
local worker_stderr_count=0

# Randomize file execution order (deterministic for the resolved seed).
if bashunit::env::is_random_order_enabled; then
Expand DownExpand Up@@ -538,7 +541,15 @@ function bashunit::runner::load_test_files() {
local _cached_fns="$functions_for_script"
if bashunit::parallel::is_enabled; then
bashunit::runner::wait_for_job_slot
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>/dev/null &
# Capture rather than discard: a worker's stderr cannot be written
# straight to the terminal without shredding the progress line, but
# dropping it made the same run report differently under --parallel
# (#358 added the discard, #864 replaced it with this capture).
local _worker_stderr="${WORKER_STDERR_OUTPUT_PREFIX}.${worker_stderr_count}"
worker_stderr_paths[worker_stderr_count]="$_worker_stderr"
worker_stderr_owners[worker_stderr_count]="$test_file"
worker_stderr_count=$((worker_stderr_count + 1))
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>"$_worker_stderr" &
else
bashunit::runner::call_test_functions "$test_file" "$_cached_fns"
fi
Expand All@@ -561,6 +572,16 @@ function bashunit::runner::load_test_files() {
disown "$spinner_pid" 2>/dev/null || true
kill "$spinner_pid" 2>/dev/null || true
printf "\r \r" # Clear the spinner output

local _stderr_idx=0
while [ "$_stderr_idx" -lt "$worker_stderr_count" ]; do
if [ -s "${worker_stderr_paths[_stderr_idx]:-}" ]; then
bashunit::console_results::print_worker_stderr \
"${worker_stderr_owners[_stderr_idx]:-}" "${worker_stderr_paths[_stderr_idx]:-}"
fi
_stderr_idx=$((_stderr_idx + 1))
done

local script_id
for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do
export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}"
Expand Down
16 changes: 16 additions & 0 deletions tests/acceptance/fixtures/test_worker_stderr.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash

# A data provider runs inside the parallel file worker but outside any test
# body, so its stderr is the plain case that the worker-level redirect used to
# discard. Test-body stderr is a different path: it is merged into the captured
# stdout and already surfaces in the failure block.

function data_provider_noisy() {
echo "WORKER-SCOPE-DIAGNOSTIC" >&2
echo "1"
}

# @data_provider data_provider_noisy
function test_uses_a_noisy_provider() {
assert_equals "1" "$1"
}
42 changes: 42 additions & 0 deletions tests/acceptance/worker_stderr_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash

# Parallel workers used to run with `2>/dev/null`, so anything written to
# stderr inside the worker but outside a test body vanished, and the same run
# reported differently depending on --parallel (#864).
#
# The fixture's provider is also executed by the test-counting pass in the main
# shell, so merely *finding* the diagnostic proves nothing β€” it leaks in from
# that pass either way. What the bug actually broke is the two modes agreeing,
# so that is what these compare.

FIXTURE="tests/acceptance/fixtures/test_worker_stderr.sh"

function _count_diagnostic_in() {
local parallel_flag="$1"

./bashunit "$parallel_flag" "$FIXTURE" 2>&1 |
grep -c "WORKER-SCOPE-DIAGNOSTIC"
}

function test_parallel_reports_worker_stderr_as_often_as_sequential() {
local sequential parallel
sequential="$(_count_diagnostic_in --no-parallel)"
parallel="$(_count_diagnostic_in --parallel)"

assert_not_equals "0" "$sequential"
assert_equals "$sequential" "$parallel"
}

function test_parallel_run_attributes_worker_stderr_to_its_file() {
local output
output=$(./bashunit --parallel "$FIXTURE" 2>&1)

assert_contains "Stderr from $FIXTURE" "$output"
}

function test_parallel_run_stays_quiet_when_no_worker_writes_stderr() {
local output
output=$(./bashunit --parallel tests/acceptance/fixtures/test_coverage_engine.sh 2>&1)

assert_not_contains "Stderr from" "$output"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@
- The `--parallel` unsupported-OS warning no longer claims Alpine is excluded: Alpine has been a supported parallel platform since the race conditions were fixed, the message was simply never updated

### Fixed
- `--parallel` no longer discards stderr written inside a worker but outside a test body, which made the same run report differently depending on the mode. Each file worker had been spawned with `2>/dev/null` since #358 to keep its noise off the progress line; its stderr is now captured per file and rendered afterwards as a `Stderr from <file>` block, so data-provider diagnostics, hook-plumbing messages and scratch-dir errors survive. Output written by a test *body* was never affected β€” it is merged into that test's captured stdout and already appeared in its failure block (#864)
- The minimum-bash gate now compares the minor version as well as the major, so it enforces whatever `BASHUNIT_MIN_BASH_VERSION` declares. It previously accepted any `3.x` regardless of the stated minimum, and a version string carrying a suffix (`5.2.37(1)-release`) is now parsed instead of tripping the comparison. The floor itself is unchanged at **Bash 3.0+**; `printf -v`, `+=` and `[[ =~ ]]` are now rejected in `src/` by the compatibility gate, since 3.0/3.1 lack the first two and 3.2 changed quoted-pattern semantics for the third
- An empty entry in `.env` no longer overrides a value the caller exported or set on the command line. `.env` is sourced under `set -o allexport`, so every line was an unconditional assignment: merely *listing* a name blanked it, and `BASHUNIT_OUTPUT_FORMAT=tap ./bashunit` silently stopped working in any project whose `.env` mentioned that setting. An empty entry now means "not configured here"; an entry with a value still takes effect. This had been actively concealing defects β€” two of the bugs fixed in #879 were not reproducible from inside a repo checkout for exactly this reason (#865)
- A malformed benchmark annotation is now an error instead of a silent fallback to the default: `@revs=abc` quietly ran a single revolution, `@its=abc` a single iteration, and `@max_ms=abc` dropped the threshold entirely so the benchmark could never fail β€” each reporting success while measuring something other than what was written (#884)
Expand Down
18 changes: 18 additions & 0 deletions src/console_results.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -559,6 +559,24 @@ function bashunit::console_results::print_error_test() {
bashunit::state::print_line "error" "$line"
}

##
# Render stderr a parallel worker wrote outside any test body.
# A sequential run lets this straight through to the terminal; parallel workers
# have it captured per file so concurrent writes cannot shred the progress
# line. Attributed to the file, not a test: it is emitted where no test owns it
# (data providers, hook plumbing). Test-body stderr is merged into the captured
# stdout and still surfaces in that test's own failure block.
# Arguments: $1 - test file the worker ran, $2 - captured stderr file
##
function bashunit::console_results::print_worker_stderr() {
local test_file="$1"
local stderr_file="$2"

printf "\n%sStderr from %s%s\n" \
"$_BASHUNIT_COLOR_SKIPPED" "$test_file" "$_BASHUNIT_COLOR_DEFAULT"
sed 's/^/|/' "$stderr_file"
}

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
Expand Down
5 changes: 5 additions & 0 deletions src/env.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -654,6 +654,11 @@ SKIPPED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/skipped"
INCOMPLETE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/incomplete"
RISKY_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/risky"
PROFILE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/profile"
# Prefix for one file-per-worker capture of stderr written inside a parallel
# worker but outside a test body. An ordinal is appended per spawned worker;
# these must not live under TEMP_DIR_PARALLEL_TEST_SUITE, whose every entry is
# walked by state::aggregate_parallel_results (#864).
WORKER_STDERR_OUTPUT_PREFIX="$_BASHUNIT_RUN_OUTPUT_DIR/worker-stderr"
# Collects "<test_file>:<function_name>" for every failing test in a run so the
# next --rerun-failed can replay just those. Shared across parallel subshells.
RERUN_FAILED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/rerun-failed"
Expand Down
23 changes: 22 additions & 1 deletion src/runner.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -396,6 +396,9 @@ function bashunit::runner::load_test_files() {
files=("$@")
local -a scripts_ids=()
local scripts_ids_count=0
local -a worker_stderr_paths=()
local -a worker_stderr_owners=()
local worker_stderr_count=0

# Randomize file execution order (deterministic for the resolved seed).
if bashunit::env::is_random_order_enabled; then
Expand DownExpand Up@@ -538,7 +541,15 @@ function bashunit::runner::load_test_files() {
local _cached_fns="$functions_for_script"
if bashunit::parallel::is_enabled; then
bashunit::runner::wait_for_job_slot
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>/dev/null &
# Capture rather than discard: a worker's stderr cannot be written
# straight to the terminal without shredding the progress line, but
# dropping it made the same run report differently under --parallel
# (#358 added the discard, #864 replaced it with this capture).
local _worker_stderr="${WORKER_STDERR_OUTPUT_PREFIX}.${worker_stderr_count}"
worker_stderr_paths[worker_stderr_count]="$_worker_stderr"
worker_stderr_owners[worker_stderr_count]="$test_file"
worker_stderr_count=$((worker_stderr_count + 1))
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>"$_worker_stderr" &
else
bashunit::runner::call_test_functions "$test_file" "$_cached_fns"
fi
Expand All@@ -561,6 +572,16 @@ function bashunit::runner::load_test_files() {
disown "$spinner_pid" 2>/dev/null || true
kill "$spinner_pid" 2>/dev/null || true
printf "\r \r" # Clear the spinner output

local _stderr_idx=0
while [ "$_stderr_idx" -lt "$worker_stderr_count" ]; do
if [ -s "${worker_stderr_paths[_stderr_idx]:-}" ]; then
bashunit::console_results::print_worker_stderr \
"${worker_stderr_owners[_stderr_idx]:-}" "${worker_stderr_paths[_stderr_idx]:-}"
fi
_stderr_idx=$((_stderr_idx + 1))
done

local script_id
for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do
export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}"
Expand Down
16 changes: 16 additions & 0 deletions tests/acceptance/fixtures/test_worker_stderr.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash

# A data provider runs inside the parallel file worker but outside any test
# body, so its stderr is the plain case that the worker-level redirect used to
# discard. Test-body stderr is a different path: it is merged into the captured
# stdout and already surfaces in the failure block.

function data_provider_noisy() {
echo "WORKER-SCOPE-DIAGNOSTIC" >&2
echo "1"
}

# @data_provider data_provider_noisy
function test_uses_a_noisy_provider() {
assert_equals "1" "$1"
}
42 changes: 42 additions & 0 deletions tests/acceptance/worker_stderr_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash

# Parallel workers used to run with `2>/dev/null`, so anything written to
# stderr inside the worker but outside a test body vanished, and the same run
# reported differently depending on --parallel (#864).
#
# The fixture's provider is also executed by the test-counting pass in the main
# shell, so merely *finding* the diagnostic proves nothing β€” it leaks in from
# that pass either way. What the bug actually broke is the two modes agreeing,
# so that is what these compare.

FIXTURE="tests/acceptance/fixtures/test_worker_stderr.sh"

function _count_diagnostic_in() {
local parallel_flag="$1"

./bashunit "$parallel_flag" "$FIXTURE" 2>&1 |
grep -c "WORKER-SCOPE-DIAGNOSTIC"
}

function test_parallel_reports_worker_stderr_as_often_as_sequential() {
local sequential parallel
sequential="$(_count_diagnostic_in --no-parallel)"
parallel="$(_count_diagnostic_in --parallel)"

assert_not_equals "0" "$sequential"
assert_equals "$sequential" "$parallel"
}

function test_parallel_run_attributes_worker_stderr_to_its_file() {
local output
output=$(./bashunit --parallel "$FIXTURE" 2>&1)

assert_contains "Stderr from $FIXTURE" "$output"
}

function test_parallel_run_stays_quiet_when_no_worker_writes_stderr() {
local output
output=$(./bashunit --parallel tests/acceptance/fixtures/test_coverage_engine.sh 2>&1)

assert_not_contains "Stderr from" "$output"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@
- The `--parallel` unsupported-OS warning no longer claims Alpine is excluded: Alpine has been a supported parallel platform since the race conditions were fixed, the message was simply never updated

### Fixed
- `--parallel` no longer discards stderr written inside a worker but outside a test body, which made the same run report differently depending on the mode. Each file worker had been spawned with `2>/dev/null` since #358 to keep its noise off the progress line; its stderr is now captured per file and rendered afterwards as a `Stderr from <file>` block, so data-provider diagnostics, hook-plumbing messages and scratch-dir errors survive. Output written by a test *body* was never affected β€” it is merged into that test's captured stdout and already appeared in its failure block (#864)
- The minimum-bash gate now compares the minor version as well as the major, so it enforces whatever `BASHUNIT_MIN_BASH_VERSION` declares. It previously accepted any `3.x` regardless of the stated minimum, and a version string carrying a suffix (`5.2.37(1)-release`) is now parsed instead of tripping the comparison. The floor itself is unchanged at **Bash 3.0+**; `printf -v`, `+=` and `[[ =~ ]]` are now rejected in `src/` by the compatibility gate, since 3.0/3.1 lack the first two and 3.2 changed quoted-pattern semantics for the third
- An empty entry in `.env` no longer overrides a value the caller exported or set on the command line. `.env` is sourced under `set -o allexport`, so every line was an unconditional assignment: merely *listing* a name blanked it, and `BASHUNIT_OUTPUT_FORMAT=tap ./bashunit` silently stopped working in any project whose `.env` mentioned that setting. An empty entry now means "not configured here"; an entry with a value still takes effect. This had been actively concealing defects β€” two of the bugs fixed in #879 were not reproducible from inside a repo checkout for exactly this reason (#865)
- A malformed benchmark annotation is now an error instead of a silent fallback to the default: `@revs=abc` quietly ran a single revolution, `@its=abc` a single iteration, and `@max_ms=abc` dropped the threshold entirely so the benchmark could never fail β€” each reporting success while measuring something other than what was written (#884)
Expand Down
18 changes: 18 additions & 0 deletions src/console_results.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -559,6 +559,24 @@ function bashunit::console_results::print_error_test() {
bashunit::state::print_line "error" "$line"
}

##
# Render stderr a parallel worker wrote outside any test body.
# A sequential run lets this straight through to the terminal; parallel workers
# have it captured per file so concurrent writes cannot shred the progress
# line. Attributed to the file, not a test: it is emitted where no test owns it
# (data providers, hook plumbing). Test-body stderr is merged into the captured
# stdout and still surfaces in that test's own failure block.
# Arguments: $1 - test file the worker ran, $2 - captured stderr file
##
function bashunit::console_results::print_worker_stderr() {
local test_file="$1"
local stderr_file="$2"

printf "\n%sStderr from %s%s\n" \
"$_BASHUNIT_COLOR_SKIPPED" "$test_file" "$_BASHUNIT_COLOR_DEFAULT"
sed 's/^/|/' "$stderr_file"
}

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
Expand Down
5 changes: 5 additions & 0 deletions src/env.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -654,6 +654,11 @@ SKIPPED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/skipped"
INCOMPLETE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/incomplete"
RISKY_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/risky"
PROFILE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/profile"
# Prefix for one file-per-worker capture of stderr written inside a parallel
# worker but outside a test body. An ordinal is appended per spawned worker;
# these must not live under TEMP_DIR_PARALLEL_TEST_SUITE, whose every entry is
# walked by state::aggregate_parallel_results (#864).
WORKER_STDERR_OUTPUT_PREFIX="$_BASHUNIT_RUN_OUTPUT_DIR/worker-stderr"
# Collects "<test_file>:<function_name>" for every failing test in a run so the
# next --rerun-failed can replay just those. Shared across parallel subshells.
RERUN_FAILED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/rerun-failed"
Expand Down
23 changes: 22 additions & 1 deletion src/runner.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -396,6 +396,9 @@ function bashunit::runner::load_test_files() {
files=("$@")
local -a scripts_ids=()
local scripts_ids_count=0
local -a worker_stderr_paths=()
local -a worker_stderr_owners=()
local worker_stderr_count=0

# Randomize file execution order (deterministic for the resolved seed).
if bashunit::env::is_random_order_enabled; then
Expand DownExpand Up@@ -538,7 +541,15 @@ function bashunit::runner::load_test_files() {
local _cached_fns="$functions_for_script"
if bashunit::parallel::is_enabled; then
bashunit::runner::wait_for_job_slot
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>/dev/null &
# Capture rather than discard: a worker's stderr cannot be written
# straight to the terminal without shredding the progress line, but
# dropping it made the same run report differently under --parallel
# (#358 added the discard, #864 replaced it with this capture).
local _worker_stderr="${WORKER_STDERR_OUTPUT_PREFIX}.${worker_stderr_count}"
worker_stderr_paths[worker_stderr_count]="$_worker_stderr"
worker_stderr_owners[worker_stderr_count]="$test_file"
worker_stderr_count=$((worker_stderr_count + 1))
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>"$_worker_stderr" &
else
bashunit::runner::call_test_functions "$test_file" "$_cached_fns"
fi
Expand All@@ -561,6 +572,16 @@ function bashunit::runner::load_test_files() {
disown "$spinner_pid" 2>/dev/null || true
kill "$spinner_pid" 2>/dev/null || true
printf "\r \r" # Clear the spinner output

local _stderr_idx=0
while [ "$_stderr_idx" -lt "$worker_stderr_count" ]; do
if [ -s "${worker_stderr_paths[_stderr_idx]:-}" ]; then
bashunit::console_results::print_worker_stderr \
"${worker_stderr_owners[_stderr_idx]:-}" "${worker_stderr_paths[_stderr_idx]:-}"
fi
_stderr_idx=$((_stderr_idx + 1))
done

local script_id
for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do
export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}"
Expand Down
16 changes: 16 additions & 0 deletions tests/acceptance/fixtures/test_worker_stderr.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash

# A data provider runs inside the parallel file worker but outside any test
# body, so its stderr is the plain case that the worker-level redirect used to
# discard. Test-body stderr is a different path: it is merged into the captured
# stdout and already surfaces in the failure block.

function data_provider_noisy() {
echo "WORKER-SCOPE-DIAGNOSTIC" >&2
echo "1"
}

# @data_provider data_provider_noisy
function test_uses_a_noisy_provider() {
assert_equals "1" "$1"
}
42 changes: 42 additions & 0 deletions tests/acceptance/worker_stderr_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash

# Parallel workers used to run with `2>/dev/null`, so anything written to
# stderr inside the worker but outside a test body vanished, and the same run
# reported differently depending on --parallel (#864).
#
# The fixture's provider is also executed by the test-counting pass in the main
# shell, so merely *finding* the diagnostic proves nothing β€” it leaks in from
# that pass either way. What the bug actually broke is the two modes agreeing,
# so that is what these compare.

FIXTURE="tests/acceptance/fixtures/test_worker_stderr.sh"

function _count_diagnostic_in() {
local parallel_flag="$1"

./bashunit "$parallel_flag" "$FIXTURE" 2>&1 |
grep -c "WORKER-SCOPE-DIAGNOSTIC"
}

function test_parallel_reports_worker_stderr_as_often_as_sequential() {
local sequential parallel
sequential="$(_count_diagnostic_in --no-parallel)"
parallel="$(_count_diagnostic_in --parallel)"

assert_not_equals "0" "$sequential"
assert_equals "$sequential" "$parallel"
}

function test_parallel_run_attributes_worker_stderr_to_its_file() {
local output
output=$(./bashunit --parallel "$FIXTURE" 2>&1)

assert_contains "Stderr from $FIXTURE" "$output"
}

function test_parallel_run_stays_quiet_when_no_worker_writes_stderr() {
local output
output=$(./bashunit --parallel tests/acceptance/fixtures/test_coverage_engine.sh 2>&1)

assert_not_contains "Stderr from" "$output"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@
- The `--parallel` unsupported-OS warning no longer claims Alpine is excluded: Alpine has been a supported parallel platform since the race conditions were fixed, the message was simply never updated

### Fixed
- `--parallel` no longer discards stderr written inside a worker but outside a test body, which made the same run report differently depending on the mode. Each file worker had been spawned with `2>/dev/null` since #358 to keep its noise off the progress line; its stderr is now captured per file and rendered afterwards as a `Stderr from <file>` block, so data-provider diagnostics, hook-plumbing messages and scratch-dir errors survive. Output written by a test *body* was never affected β€” it is merged into that test's captured stdout and already appeared in its failure block (#864)
- The minimum-bash gate now compares the minor version as well as the major, so it enforces whatever `BASHUNIT_MIN_BASH_VERSION` declares. It previously accepted any `3.x` regardless of the stated minimum, and a version string carrying a suffix (`5.2.37(1)-release`) is now parsed instead of tripping the comparison. The floor itself is unchanged at **Bash 3.0+**; `printf -v`, `+=` and `[[ =~ ]]` are now rejected in `src/` by the compatibility gate, since 3.0/3.1 lack the first two and 3.2 changed quoted-pattern semantics for the third
- An empty entry in `.env` no longer overrides a value the caller exported or set on the command line. `.env` is sourced under `set -o allexport`, so every line was an unconditional assignment: merely *listing* a name blanked it, and `BASHUNIT_OUTPUT_FORMAT=tap ./bashunit` silently stopped working in any project whose `.env` mentioned that setting. An empty entry now means "not configured here"; an entry with a value still takes effect. This had been actively concealing defects β€” two of the bugs fixed in #879 were not reproducible from inside a repo checkout for exactly this reason (#865)
- A malformed benchmark annotation is now an error instead of a silent fallback to the default: `@revs=abc` quietly ran a single revolution, `@its=abc` a single iteration, and `@max_ms=abc` dropped the threshold entirely so the benchmark could never fail β€” each reporting success while measuring something other than what was written (#884)
Expand Down
18 changes: 18 additions & 0 deletions src/console_results.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -559,6 +559,24 @@ function bashunit::console_results::print_error_test() {
bashunit::state::print_line "error" "$line"
}

##
# Render stderr a parallel worker wrote outside any test body.
# A sequential run lets this straight through to the terminal; parallel workers
# have it captured per file so concurrent writes cannot shred the progress
# line. Attributed to the file, not a test: it is emitted where no test owns it
# (data providers, hook plumbing). Test-body stderr is merged into the captured
# stdout and still surfaces in that test's own failure block.
# Arguments: $1 - test file the worker ran, $2 - captured stderr file
##
function bashunit::console_results::print_worker_stderr() {
local test_file="$1"
local stderr_file="$2"

printf "\n%sStderr from %s%s\n" \
"$_BASHUNIT_COLOR_SKIPPED" "$test_file" "$_BASHUNIT_COLOR_DEFAULT"
sed 's/^/|/' "$stderr_file"
}

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
Expand Down
5 changes: 5 additions & 0 deletions src/env.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -654,6 +654,11 @@ SKIPPED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/skipped"
INCOMPLETE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/incomplete"
RISKY_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/risky"
PROFILE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/profile"
# Prefix for one file-per-worker capture of stderr written inside a parallel
# worker but outside a test body. An ordinal is appended per spawned worker;
# these must not live under TEMP_DIR_PARALLEL_TEST_SUITE, whose every entry is
# walked by state::aggregate_parallel_results (#864).
WORKER_STDERR_OUTPUT_PREFIX="$_BASHUNIT_RUN_OUTPUT_DIR/worker-stderr"
# Collects "<test_file>:<function_name>" for every failing test in a run so the
# next --rerun-failed can replay just those. Shared across parallel subshells.
RERUN_FAILED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/rerun-failed"
Expand Down
23 changes: 22 additions & 1 deletion src/runner.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -396,6 +396,9 @@ function bashunit::runner::load_test_files() {
files=("$@")
local -a scripts_ids=()
local scripts_ids_count=0
local -a worker_stderr_paths=()
local -a worker_stderr_owners=()
local worker_stderr_count=0

# Randomize file execution order (deterministic for the resolved seed).
if bashunit::env::is_random_order_enabled; then
Expand DownExpand Up@@ -538,7 +541,15 @@ function bashunit::runner::load_test_files() {
local _cached_fns="$functions_for_script"
if bashunit::parallel::is_enabled; then
bashunit::runner::wait_for_job_slot
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>/dev/null &
# Capture rather than discard: a worker's stderr cannot be written
# straight to the terminal without shredding the progress line, but
# dropping it made the same run report differently under --parallel
# (#358 added the discard, #864 replaced it with this capture).
local _worker_stderr="${WORKER_STDERR_OUTPUT_PREFIX}.${worker_stderr_count}"
worker_stderr_paths[worker_stderr_count]="$_worker_stderr"
worker_stderr_owners[worker_stderr_count]="$test_file"
worker_stderr_count=$((worker_stderr_count + 1))
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>"$_worker_stderr" &
else
bashunit::runner::call_test_functions "$test_file" "$_cached_fns"
fi
Expand All@@ -561,6 +572,16 @@ function bashunit::runner::load_test_files() {
disown "$spinner_pid" 2>/dev/null || true
kill "$spinner_pid" 2>/dev/null || true
printf "\r \r" # Clear the spinner output

local _stderr_idx=0
while [ "$_stderr_idx" -lt "$worker_stderr_count" ]; do
if [ -s "${worker_stderr_paths[_stderr_idx]:-}" ]; then
bashunit::console_results::print_worker_stderr \
"${worker_stderr_owners[_stderr_idx]:-}" "${worker_stderr_paths[_stderr_idx]:-}"
fi
_stderr_idx=$((_stderr_idx + 1))
done

local script_id
for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do
export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}"
Expand Down
16 changes: 16 additions & 0 deletions tests/acceptance/fixtures/test_worker_stderr.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash

# A data provider runs inside the parallel file worker but outside any test
# body, so its stderr is the plain case that the worker-level redirect used to
# discard. Test-body stderr is a different path: it is merged into the captured
# stdout and already surfaces in the failure block.

function data_provider_noisy() {
echo "WORKER-SCOPE-DIAGNOSTIC" >&2
echo "1"
}

# @data_provider data_provider_noisy
function test_uses_a_noisy_provider() {
assert_equals "1" "$1"
}
42 changes: 42 additions & 0 deletions tests/acceptance/worker_stderr_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash

# Parallel workers used to run with `2>/dev/null`, so anything written to
# stderr inside the worker but outside a test body vanished, and the same run
# reported differently depending on --parallel (#864).
#
# The fixture's provider is also executed by the test-counting pass in the main
# shell, so merely *finding* the diagnostic proves nothing β€” it leaks in from
# that pass either way. What the bug actually broke is the two modes agreeing,
# so that is what these compare.

FIXTURE="tests/acceptance/fixtures/test_worker_stderr.sh"

function _count_diagnostic_in() {
local parallel_flag="$1"

./bashunit "$parallel_flag" "$FIXTURE" 2>&1 |
grep -c "WORKER-SCOPE-DIAGNOSTIC"
}

function test_parallel_reports_worker_stderr_as_often_as_sequential() {
local sequential parallel
sequential="$(_count_diagnostic_in --no-parallel)"
parallel="$(_count_diagnostic_in --parallel)"

assert_not_equals "0" "$sequential"
assert_equals "$sequential" "$parallel"
}

function test_parallel_run_attributes_worker_stderr_to_its_file() {
local output
output=$(./bashunit --parallel "$FIXTURE" 2>&1)

assert_contains "Stderr from $FIXTURE" "$output"
}

function test_parallel_run_stays_quiet_when_no_worker_writes_stderr() {
local output
output=$(./bashunit --parallel tests/acceptance/fixtures/test_coverage_engine.sh 2>&1)

assert_not_contains "Stderr from" "$output"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@
- The `--parallel` unsupported-OS warning no longer claims Alpine is excluded: Alpine has been a supported parallel platform since the race conditions were fixed, the message was simply never updated

### Fixed
- `--parallel` no longer discards stderr written inside a worker but outside a test body, which made the same run report differently depending on the mode. Each file worker had been spawned with `2>/dev/null` since #358 to keep its noise off the progress line; its stderr is now captured per file and rendered afterwards as a `Stderr from <file>` block, so data-provider diagnostics, hook-plumbing messages and scratch-dir errors survive. Output written by a test *body* was never affected β€” it is merged into that test's captured stdout and already appeared in its failure block (#864)
- The minimum-bash gate now compares the minor version as well as the major, so it enforces whatever `BASHUNIT_MIN_BASH_VERSION` declares. It previously accepted any `3.x` regardless of the stated minimum, and a version string carrying a suffix (`5.2.37(1)-release`) is now parsed instead of tripping the comparison. The floor itself is unchanged at **Bash 3.0+**; `printf -v`, `+=` and `[[ =~ ]]` are now rejected in `src/` by the compatibility gate, since 3.0/3.1 lack the first two and 3.2 changed quoted-pattern semantics for the third
- An empty entry in `.env` no longer overrides a value the caller exported or set on the command line. `.env` is sourced under `set -o allexport`, so every line was an unconditional assignment: merely *listing* a name blanked it, and `BASHUNIT_OUTPUT_FORMAT=tap ./bashunit` silently stopped working in any project whose `.env` mentioned that setting. An empty entry now means "not configured here"; an entry with a value still takes effect. This had been actively concealing defects β€” two of the bugs fixed in #879 were not reproducible from inside a repo checkout for exactly this reason (#865)
- A malformed benchmark annotation is now an error instead of a silent fallback to the default: `@revs=abc` quietly ran a single revolution, `@its=abc` a single iteration, and `@max_ms=abc` dropped the threshold entirely so the benchmark could never fail β€” each reporting success while measuring something other than what was written (#884)
Expand Down
18 changes: 18 additions & 0 deletions src/console_results.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -559,6 +559,24 @@ function bashunit::console_results::print_error_test() {
bashunit::state::print_line "error" "$line"
}

##
# Render stderr a parallel worker wrote outside any test body.
# A sequential run lets this straight through to the terminal; parallel workers
# have it captured per file so concurrent writes cannot shred the progress
# line. Attributed to the file, not a test: it is emitted where no test owns it
# (data providers, hook plumbing). Test-body stderr is merged into the captured
# stdout and still surfaces in that test's own failure block.
# Arguments: $1 - test file the worker ran, $2 - captured stderr file
##
function bashunit::console_results::print_worker_stderr() {
local test_file="$1"
local stderr_file="$2"

printf "\n%sStderr from %s%s\n" \
"$_BASHUNIT_COLOR_SKIPPED" "$test_file" "$_BASHUNIT_COLOR_DEFAULT"
sed 's/^/|/' "$stderr_file"
}

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
Expand Down
5 changes: 5 additions & 0 deletions src/env.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -654,6 +654,11 @@ SKIPPED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/skipped"
INCOMPLETE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/incomplete"
RISKY_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/risky"
PROFILE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/profile"
# Prefix for one file-per-worker capture of stderr written inside a parallel
# worker but outside a test body. An ordinal is appended per spawned worker;
# these must not live under TEMP_DIR_PARALLEL_TEST_SUITE, whose every entry is
# walked by state::aggregate_parallel_results (#864).
WORKER_STDERR_OUTPUT_PREFIX="$_BASHUNIT_RUN_OUTPUT_DIR/worker-stderr"
# Collects "<test_file>:<function_name>" for every failing test in a run so the
# next --rerun-failed can replay just those. Shared across parallel subshells.
RERUN_FAILED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/rerun-failed"
Expand Down
23 changes: 22 additions & 1 deletion src/runner.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -396,6 +396,9 @@ function bashunit::runner::load_test_files() {
files=("$@")
local -a scripts_ids=()
local scripts_ids_count=0
local -a worker_stderr_paths=()
local -a worker_stderr_owners=()
local worker_stderr_count=0

# Randomize file execution order (deterministic for the resolved seed).
if bashunit::env::is_random_order_enabled; then
Expand DownExpand Up@@ -538,7 +541,15 @@ function bashunit::runner::load_test_files() {
local _cached_fns="$functions_for_script"
if bashunit::parallel::is_enabled; then
bashunit::runner::wait_for_job_slot
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>/dev/null &
# Capture rather than discard: a worker's stderr cannot be written
# straight to the terminal without shredding the progress line, but
# dropping it made the same run report differently under --parallel
# (#358 added the discard, #864 replaced it with this capture).
local _worker_stderr="${WORKER_STDERR_OUTPUT_PREFIX}.${worker_stderr_count}"
worker_stderr_paths[worker_stderr_count]="$_worker_stderr"
worker_stderr_owners[worker_stderr_count]="$test_file"
worker_stderr_count=$((worker_stderr_count + 1))
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>"$_worker_stderr" &
else
bashunit::runner::call_test_functions "$test_file" "$_cached_fns"
fi
Expand All@@ -561,6 +572,16 @@ function bashunit::runner::load_test_files() {
disown "$spinner_pid" 2>/dev/null || true
kill "$spinner_pid" 2>/dev/null || true
printf "\r \r" # Clear the spinner output

local _stderr_idx=0
while [ "$_stderr_idx" -lt "$worker_stderr_count" ]; do
if [ -s "${worker_stderr_paths[_stderr_idx]:-}" ]; then
bashunit::console_results::print_worker_stderr \
"${worker_stderr_owners[_stderr_idx]:-}" "${worker_stderr_paths[_stderr_idx]:-}"
fi
_stderr_idx=$((_stderr_idx + 1))
done

local script_id
for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do
export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}"
Expand Down
16 changes: 16 additions & 0 deletions tests/acceptance/fixtures/test_worker_stderr.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash

# A data provider runs inside the parallel file worker but outside any test
# body, so its stderr is the plain case that the worker-level redirect used to
# discard. Test-body stderr is a different path: it is merged into the captured
# stdout and already surfaces in the failure block.

function data_provider_noisy() {
echo "WORKER-SCOPE-DIAGNOSTIC" >&2
echo "1"
}

# @data_provider data_provider_noisy
function test_uses_a_noisy_provider() {
assert_equals "1" "$1"
}
42 changes: 42 additions & 0 deletions tests/acceptance/worker_stderr_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash

# Parallel workers used to run with `2>/dev/null`, so anything written to
# stderr inside the worker but outside a test body vanished, and the same run
# reported differently depending on --parallel (#864).
#
# The fixture's provider is also executed by the test-counting pass in the main
# shell, so merely *finding* the diagnostic proves nothing β€” it leaks in from
# that pass either way. What the bug actually broke is the two modes agreeing,
# so that is what these compare.

FIXTURE="tests/acceptance/fixtures/test_worker_stderr.sh"

function _count_diagnostic_in() {
local parallel_flag="$1"

./bashunit "$parallel_flag" "$FIXTURE" 2>&1 |
grep -c "WORKER-SCOPE-DIAGNOSTIC"
}

function test_parallel_reports_worker_stderr_as_often_as_sequential() {
local sequential parallel
sequential="$(_count_diagnostic_in --no-parallel)"
parallel="$(_count_diagnostic_in --parallel)"

assert_not_equals "0" "$sequential"
assert_equals "$sequential" "$parallel"
}

function test_parallel_run_attributes_worker_stderr_to_its_file() {
local output
output=$(./bashunit --parallel "$FIXTURE" 2>&1)

assert_contains "Stderr from $FIXTURE" "$output"
}

function test_parallel_run_stays_quiet_when_no_worker_writes_stderr() {
local output
output=$(./bashunit --parallel tests/acceptance/fixtures/test_coverage_engine.sh 2>&1)

assert_not_contains "Stderr from" "$output"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@
- The `--parallel` unsupported-OS warning no longer claims Alpine is excluded: Alpine has been a supported parallel platform since the race conditions were fixed, the message was simply never updated

### Fixed
- `--parallel` no longer discards stderr written inside a worker but outside a test body, which made the same run report differently depending on the mode. Each file worker had been spawned with `2>/dev/null` since #358 to keep its noise off the progress line; its stderr is now captured per file and rendered afterwards as a `Stderr from <file>` block, so data-provider diagnostics, hook-plumbing messages and scratch-dir errors survive. Output written by a test *body* was never affected β€” it is merged into that test's captured stdout and already appeared in its failure block (#864)
- The minimum-bash gate now compares the minor version as well as the major, so it enforces whatever `BASHUNIT_MIN_BASH_VERSION` declares. It previously accepted any `3.x` regardless of the stated minimum, and a version string carrying a suffix (`5.2.37(1)-release`) is now parsed instead of tripping the comparison. The floor itself is unchanged at **Bash 3.0+**; `printf -v`, `+=` and `[[ =~ ]]` are now rejected in `src/` by the compatibility gate, since 3.0/3.1 lack the first two and 3.2 changed quoted-pattern semantics for the third
- An empty entry in `.env` no longer overrides a value the caller exported or set on the command line. `.env` is sourced under `set -o allexport`, so every line was an unconditional assignment: merely *listing* a name blanked it, and `BASHUNIT_OUTPUT_FORMAT=tap ./bashunit` silently stopped working in any project whose `.env` mentioned that setting. An empty entry now means "not configured here"; an entry with a value still takes effect. This had been actively concealing defects β€” two of the bugs fixed in #879 were not reproducible from inside a repo checkout for exactly this reason (#865)
- A malformed benchmark annotation is now an error instead of a silent fallback to the default: `@revs=abc` quietly ran a single revolution, `@its=abc` a single iteration, and `@max_ms=abc` dropped the threshold entirely so the benchmark could never fail β€” each reporting success while measuring something other than what was written (#884)
Expand Down
18 changes: 18 additions & 0 deletions src/console_results.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -559,6 +559,24 @@ function bashunit::console_results::print_error_test() {
bashunit::state::print_line "error" "$line"
}

##
# Render stderr a parallel worker wrote outside any test body.
# A sequential run lets this straight through to the terminal; parallel workers
# have it captured per file so concurrent writes cannot shred the progress
# line. Attributed to the file, not a test: it is emitted where no test owns it
# (data providers, hook plumbing). Test-body stderr is merged into the captured
# stdout and still surfaces in that test's own failure block.
# Arguments: $1 - test file the worker ran, $2 - captured stderr file
##
function bashunit::console_results::print_worker_stderr() {
local test_file="$1"
local stderr_file="$2"

printf "\n%sStderr from %s%s\n" \
"$_BASHUNIT_COLOR_SKIPPED" "$test_file" "$_BASHUNIT_COLOR_DEFAULT"
sed 's/^/|/' "$stderr_file"
}

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
Expand Down
5 changes: 5 additions & 0 deletions src/env.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -654,6 +654,11 @@ SKIPPED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/skipped"
INCOMPLETE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/incomplete"
RISKY_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/risky"
PROFILE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/profile"
# Prefix for one file-per-worker capture of stderr written inside a parallel
# worker but outside a test body. An ordinal is appended per spawned worker;
# these must not live under TEMP_DIR_PARALLEL_TEST_SUITE, whose every entry is
# walked by state::aggregate_parallel_results (#864).
WORKER_STDERR_OUTPUT_PREFIX="$_BASHUNIT_RUN_OUTPUT_DIR/worker-stderr"
# Collects "<test_file>:<function_name>" for every failing test in a run so the
# next --rerun-failed can replay just those. Shared across parallel subshells.
RERUN_FAILED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/rerun-failed"
Expand Down
23 changes: 22 additions & 1 deletion src/runner.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -396,6 +396,9 @@ function bashunit::runner::load_test_files() {
files=("$@")
local -a scripts_ids=()
local scripts_ids_count=0
local -a worker_stderr_paths=()
local -a worker_stderr_owners=()
local worker_stderr_count=0

# Randomize file execution order (deterministic for the resolved seed).
if bashunit::env::is_random_order_enabled; then
Expand DownExpand Up@@ -538,7 +541,15 @@ function bashunit::runner::load_test_files() {
local _cached_fns="$functions_for_script"
if bashunit::parallel::is_enabled; then
bashunit::runner::wait_for_job_slot
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>/dev/null &
# Capture rather than discard: a worker's stderr cannot be written
# straight to the terminal without shredding the progress line, but
# dropping it made the same run report differently under --parallel
# (#358 added the discard, #864 replaced it with this capture).
local _worker_stderr="${WORKER_STDERR_OUTPUT_PREFIX}.${worker_stderr_count}"
worker_stderr_paths[worker_stderr_count]="$_worker_stderr"
worker_stderr_owners[worker_stderr_count]="$test_file"
worker_stderr_count=$((worker_stderr_count + 1))
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>"$_worker_stderr" &
else
bashunit::runner::call_test_functions "$test_file" "$_cached_fns"
fi
Expand All@@ -561,6 +572,16 @@ function bashunit::runner::load_test_files() {
disown "$spinner_pid" 2>/dev/null || true
kill "$spinner_pid" 2>/dev/null || true
printf "\r \r" # Clear the spinner output

local _stderr_idx=0
while [ "$_stderr_idx" -lt "$worker_stderr_count" ]; do
if [ -s "${worker_stderr_paths[_stderr_idx]:-}" ]; then
bashunit::console_results::print_worker_stderr \
"${worker_stderr_owners[_stderr_idx]:-}" "${worker_stderr_paths[_stderr_idx]:-}"
fi
_stderr_idx=$((_stderr_idx + 1))
done

local script_id
for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do
export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}"
Expand Down
16 changes: 16 additions & 0 deletions tests/acceptance/fixtures/test_worker_stderr.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash

# A data provider runs inside the parallel file worker but outside any test
# body, so its stderr is the plain case that the worker-level redirect used to
# discard. Test-body stderr is a different path: it is merged into the captured
# stdout and already surfaces in the failure block.

function data_provider_noisy() {
echo "WORKER-SCOPE-DIAGNOSTIC" >&2
echo "1"
}

# @data_provider data_provider_noisy
function test_uses_a_noisy_provider() {
assert_equals "1" "$1"
}
42 changes: 42 additions & 0 deletions tests/acceptance/worker_stderr_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash

# Parallel workers used to run with `2>/dev/null`, so anything written to
# stderr inside the worker but outside a test body vanished, and the same run
# reported differently depending on --parallel (#864).
#
# The fixture's provider is also executed by the test-counting pass in the main
# shell, so merely *finding* the diagnostic proves nothing β€” it leaks in from
# that pass either way. What the bug actually broke is the two modes agreeing,
# so that is what these compare.

FIXTURE="tests/acceptance/fixtures/test_worker_stderr.sh"

function _count_diagnostic_in() {
local parallel_flag="$1"

./bashunit "$parallel_flag" "$FIXTURE" 2>&1 |
grep -c "WORKER-SCOPE-DIAGNOSTIC"
}

function test_parallel_reports_worker_stderr_as_often_as_sequential() {
local sequential parallel
sequential="$(_count_diagnostic_in --no-parallel)"
parallel="$(_count_diagnostic_in --parallel)"

assert_not_equals "0" "$sequential"
assert_equals "$sequential" "$parallel"
}

function test_parallel_run_attributes_worker_stderr_to_its_file() {
local output
output=$(./bashunit --parallel "$FIXTURE" 2>&1)

assert_contains "Stderr from $FIXTURE" "$output"
}

function test_parallel_run_stays_quiet_when_no_worker_writes_stderr() {
local output
output=$(./bashunit --parallel tests/acceptance/fixtures/test_coverage_engine.sh 2>&1)

assert_not_contains "Stderr from" "$output"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@
- The `--parallel` unsupported-OS warning no longer claims Alpine is excluded: Alpine has been a supported parallel platform since the race conditions were fixed, the message was simply never updated

### Fixed
- `--parallel` no longer discards stderr written inside a worker but outside a test body, which made the same run report differently depending on the mode. Each file worker had been spawned with `2>/dev/null` since #358 to keep its noise off the progress line; its stderr is now captured per file and rendered afterwards as a `Stderr from <file>` block, so data-provider diagnostics, hook-plumbing messages and scratch-dir errors survive. Output written by a test *body* was never affected β€” it is merged into that test's captured stdout and already appeared in its failure block (#864)
- The minimum-bash gate now compares the minor version as well as the major, so it enforces whatever `BASHUNIT_MIN_BASH_VERSION` declares. It previously accepted any `3.x` regardless of the stated minimum, and a version string carrying a suffix (`5.2.37(1)-release`) is now parsed instead of tripping the comparison. The floor itself is unchanged at **Bash 3.0+**; `printf -v`, `+=` and `[[ =~ ]]` are now rejected in `src/` by the compatibility gate, since 3.0/3.1 lack the first two and 3.2 changed quoted-pattern semantics for the third
- An empty entry in `.env` no longer overrides a value the caller exported or set on the command line. `.env` is sourced under `set -o allexport`, so every line was an unconditional assignment: merely *listing* a name blanked it, and `BASHUNIT_OUTPUT_FORMAT=tap ./bashunit` silently stopped working in any project whose `.env` mentioned that setting. An empty entry now means "not configured here"; an entry with a value still takes effect. This had been actively concealing defects β€” two of the bugs fixed in #879 were not reproducible from inside a repo checkout for exactly this reason (#865)
- A malformed benchmark annotation is now an error instead of a silent fallback to the default: `@revs=abc` quietly ran a single revolution, `@its=abc` a single iteration, and `@max_ms=abc` dropped the threshold entirely so the benchmark could never fail β€” each reporting success while measuring something other than what was written (#884)
Expand Down
18 changes: 18 additions & 0 deletions src/console_results.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -559,6 +559,24 @@ function bashunit::console_results::print_error_test() {
bashunit::state::print_line "error" "$line"
}

##
# Render stderr a parallel worker wrote outside any test body.
# A sequential run lets this straight through to the terminal; parallel workers
# have it captured per file so concurrent writes cannot shred the progress
# line. Attributed to the file, not a test: it is emitted where no test owns it
# (data providers, hook plumbing). Test-body stderr is merged into the captured
# stdout and still surfaces in that test's own failure block.
# Arguments: $1 - test file the worker ran, $2 - captured stderr file
##
function bashunit::console_results::print_worker_stderr() {
local test_file="$1"
local stderr_file="$2"

printf "\n%sStderr from %s%s\n" \
"$_BASHUNIT_COLOR_SKIPPED" "$test_file" "$_BASHUNIT_COLOR_DEFAULT"
sed 's/^/|/' "$stderr_file"
}

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
Expand Down
5 changes: 5 additions & 0 deletions src/env.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -654,6 +654,11 @@ SKIPPED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/skipped"
INCOMPLETE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/incomplete"
RISKY_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/risky"
PROFILE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/profile"
# Prefix for one file-per-worker capture of stderr written inside a parallel
# worker but outside a test body. An ordinal is appended per spawned worker;
# these must not live under TEMP_DIR_PARALLEL_TEST_SUITE, whose every entry is
# walked by state::aggregate_parallel_results (#864).
WORKER_STDERR_OUTPUT_PREFIX="$_BASHUNIT_RUN_OUTPUT_DIR/worker-stderr"
# Collects "<test_file>:<function_name>" for every failing test in a run so the
# next --rerun-failed can replay just those. Shared across parallel subshells.
RERUN_FAILED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/rerun-failed"
Expand Down
23 changes: 22 additions & 1 deletion src/runner.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -396,6 +396,9 @@ function bashunit::runner::load_test_files() {
files=("$@")
local -a scripts_ids=()
local scripts_ids_count=0
local -a worker_stderr_paths=()
local -a worker_stderr_owners=()
local worker_stderr_count=0

# Randomize file execution order (deterministic for the resolved seed).
if bashunit::env::is_random_order_enabled; then
Expand DownExpand Up@@ -538,7 +541,15 @@ function bashunit::runner::load_test_files() {
local _cached_fns="$functions_for_script"
if bashunit::parallel::is_enabled; then
bashunit::runner::wait_for_job_slot
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>/dev/null &
# Capture rather than discard: a worker's stderr cannot be written
# straight to the terminal without shredding the progress line, but
# dropping it made the same run report differently under --parallel
# (#358 added the discard, #864 replaced it with this capture).
local _worker_stderr="${WORKER_STDERR_OUTPUT_PREFIX}.${worker_stderr_count}"
worker_stderr_paths[worker_stderr_count]="$_worker_stderr"
worker_stderr_owners[worker_stderr_count]="$test_file"
worker_stderr_count=$((worker_stderr_count + 1))
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>"$_worker_stderr" &
else
bashunit::runner::call_test_functions "$test_file" "$_cached_fns"
fi
Expand All@@ -561,6 +572,16 @@ function bashunit::runner::load_test_files() {
disown "$spinner_pid" 2>/dev/null || true
kill "$spinner_pid" 2>/dev/null || true
printf "\r \r" # Clear the spinner output

local _stderr_idx=0
while [ "$_stderr_idx" -lt "$worker_stderr_count" ]; do
if [ -s "${worker_stderr_paths[_stderr_idx]:-}" ]; then
bashunit::console_results::print_worker_stderr \
"${worker_stderr_owners[_stderr_idx]:-}" "${worker_stderr_paths[_stderr_idx]:-}"
fi
_stderr_idx=$((_stderr_idx + 1))
done

local script_id
for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do
export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}"
Expand Down
16 changes: 16 additions & 0 deletions tests/acceptance/fixtures/test_worker_stderr.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash

# A data provider runs inside the parallel file worker but outside any test
# body, so its stderr is the plain case that the worker-level redirect used to
# discard. Test-body stderr is a different path: it is merged into the captured
# stdout and already surfaces in the failure block.

function data_provider_noisy() {
echo "WORKER-SCOPE-DIAGNOSTIC" >&2
echo "1"
}

# @data_provider data_provider_noisy
function test_uses_a_noisy_provider() {
assert_equals "1" "$1"
}
42 changes: 42 additions & 0 deletions tests/acceptance/worker_stderr_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash

# Parallel workers used to run with `2>/dev/null`, so anything written to
# stderr inside the worker but outside a test body vanished, and the same run
# reported differently depending on --parallel (#864).
#
# The fixture's provider is also executed by the test-counting pass in the main
# shell, so merely *finding* the diagnostic proves nothing β€” it leaks in from
# that pass either way. What the bug actually broke is the two modes agreeing,
# so that is what these compare.

FIXTURE="tests/acceptance/fixtures/test_worker_stderr.sh"

function _count_diagnostic_in() {
local parallel_flag="$1"

./bashunit "$parallel_flag" "$FIXTURE" 2>&1 |
grep -c "WORKER-SCOPE-DIAGNOSTIC"
}

function test_parallel_reports_worker_stderr_as_often_as_sequential() {
local sequential parallel
sequential="$(_count_diagnostic_in --no-parallel)"
parallel="$(_count_diagnostic_in --parallel)"

assert_not_equals "0" "$sequential"
assert_equals "$sequential" "$parallel"
}

function test_parallel_run_attributes_worker_stderr_to_its_file() {
local output
output=$(./bashunit --parallel "$FIXTURE" 2>&1)

assert_contains "Stderr from $FIXTURE" "$output"
}

function test_parallel_run_stays_quiet_when_no_worker_writes_stderr() {
local output
output=$(./bashunit --parallel tests/acceptance/fixtures/test_coverage_engine.sh 2>&1)

assert_not_contains "Stderr from" "$output"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@
- The `--parallel` unsupported-OS warning no longer claims Alpine is excluded: Alpine has been a supported parallel platform since the race conditions were fixed, the message was simply never updated

### Fixed
- `--parallel` no longer discards stderr written inside a worker but outside a test body, which made the same run report differently depending on the mode. Each file worker had been spawned with `2>/dev/null` since #358 to keep its noise off the progress line; its stderr is now captured per file and rendered afterwards as a `Stderr from <file>` block, so data-provider diagnostics, hook-plumbing messages and scratch-dir errors survive. Output written by a test *body* was never affected β€” it is merged into that test's captured stdout and already appeared in its failure block (#864)
- The minimum-bash gate now compares the minor version as well as the major, so it enforces whatever `BASHUNIT_MIN_BASH_VERSION` declares. It previously accepted any `3.x` regardless of the stated minimum, and a version string carrying a suffix (`5.2.37(1)-release`) is now parsed instead of tripping the comparison. The floor itself is unchanged at **Bash 3.0+**; `printf -v`, `+=` and `[[ =~ ]]` are now rejected in `src/` by the compatibility gate, since 3.0/3.1 lack the first two and 3.2 changed quoted-pattern semantics for the third
- An empty entry in `.env` no longer overrides a value the caller exported or set on the command line. `.env` is sourced under `set -o allexport`, so every line was an unconditional assignment: merely *listing* a name blanked it, and `BASHUNIT_OUTPUT_FORMAT=tap ./bashunit` silently stopped working in any project whose `.env` mentioned that setting. An empty entry now means "not configured here"; an entry with a value still takes effect. This had been actively concealing defects β€” two of the bugs fixed in #879 were not reproducible from inside a repo checkout for exactly this reason (#865)
- A malformed benchmark annotation is now an error instead of a silent fallback to the default: `@revs=abc` quietly ran a single revolution, `@its=abc` a single iteration, and `@max_ms=abc` dropped the threshold entirely so the benchmark could never fail β€” each reporting success while measuring something other than what was written (#884)
Expand Down
18 changes: 18 additions & 0 deletions src/console_results.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -559,6 +559,24 @@ function bashunit::console_results::print_error_test() {
bashunit::state::print_line "error" "$line"
}

##
# Render stderr a parallel worker wrote outside any test body.
# A sequential run lets this straight through to the terminal; parallel workers
# have it captured per file so concurrent writes cannot shred the progress
# line. Attributed to the file, not a test: it is emitted where no test owns it
# (data providers, hook plumbing). Test-body stderr is merged into the captured
# stdout and still surfaces in that test's own failure block.
# Arguments: $1 - test file the worker ran, $2 - captured stderr file
##
function bashunit::console_results::print_worker_stderr() {
local test_file="$1"
local stderr_file="$2"

printf "\n%sStderr from %s%s\n" \
"$_BASHUNIT_COLOR_SKIPPED" "$test_file" "$_BASHUNIT_COLOR_DEFAULT"
sed 's/^/|/' "$stderr_file"
}

function bashunit::console_results::print_failing_tests_and_reset() {
if [ -s "$FAILURES_OUTPUT_PATH" ]; then
local total_failed
Expand Down
5 changes: 5 additions & 0 deletions src/env.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -654,6 +654,11 @@ SKIPPED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/skipped"
INCOMPLETE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/incomplete"
RISKY_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/risky"
PROFILE_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/profile"
# Prefix for one file-per-worker capture of stderr written inside a parallel
# worker but outside a test body. An ordinal is appended per spawned worker;
# these must not live under TEMP_DIR_PARALLEL_TEST_SUITE, whose every entry is
# walked by state::aggregate_parallel_results (#864).
WORKER_STDERR_OUTPUT_PREFIX="$_BASHUNIT_RUN_OUTPUT_DIR/worker-stderr"
# Collects "<test_file>:<function_name>" for every failing test in a run so the
# next --rerun-failed can replay just those. Shared across parallel subshells.
RERUN_FAILED_OUTPUT_PATH="$_BASHUNIT_RUN_OUTPUT_DIR/rerun-failed"
Expand Down
23 changes: 22 additions & 1 deletion src/runner.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -396,6 +396,9 @@ function bashunit::runner::load_test_files() {
files=("$@")
local -a scripts_ids=()
local scripts_ids_count=0
local -a worker_stderr_paths=()
local -a worker_stderr_owners=()
local worker_stderr_count=0

# Randomize file execution order (deterministic for the resolved seed).
if bashunit::env::is_random_order_enabled; then
Expand DownExpand Up@@ -538,7 +541,15 @@ function bashunit::runner::load_test_files() {
local _cached_fns="$functions_for_script"
if bashunit::parallel::is_enabled; then
bashunit::runner::wait_for_job_slot
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>/dev/null &
# Capture rather than discard: a worker's stderr cannot be written
# straight to the terminal without shredding the progress line, but
# dropping it made the same run report differently under --parallel
# (#358 added the discard, #864 replaced it with this capture).
local _worker_stderr="${WORKER_STDERR_OUTPUT_PREFIX}.${worker_stderr_count}"
worker_stderr_paths[worker_stderr_count]="$_worker_stderr"
worker_stderr_owners[worker_stderr_count]="$test_file"
worker_stderr_count=$((worker_stderr_count + 1))
bashunit::runner::call_test_functions "$test_file" "$_cached_fns" 2>"$_worker_stderr" &
else
bashunit::runner::call_test_functions "$test_file" "$_cached_fns"
fi
Expand All@@ -561,6 +572,16 @@ function bashunit::runner::load_test_files() {
disown "$spinner_pid" 2>/dev/null || true
kill "$spinner_pid" 2>/dev/null || true
printf "\r \r" # Clear the spinner output

local _stderr_idx=0
while [ "$_stderr_idx" -lt "$worker_stderr_count" ]; do
if [ -s "${worker_stderr_paths[_stderr_idx]:-}" ]; then
bashunit::console_results::print_worker_stderr \
"${worker_stderr_owners[_stderr_idx]:-}" "${worker_stderr_paths[_stderr_idx]:-}"
fi
_stderr_idx=$((_stderr_idx + 1))
done

local script_id
for script_id in "${scripts_ids[@]+"${scripts_ids[@]}"}"; do
export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}"
Expand Down
16 changes: 16 additions & 0 deletions tests/acceptance/fixtures/test_worker_stderr.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash

# A data provider runs inside the parallel file worker but outside any test
# body, so its stderr is the plain case that the worker-level redirect used to
# discard. Test-body stderr is a different path: it is merged into the captured
# stdout and already surfaces in the failure block.

function data_provider_noisy() {
echo "WORKER-SCOPE-DIAGNOSTIC" >&2
echo "1"
}

# @data_provider data_provider_noisy
function test_uses_a_noisy_provider() {
assert_equals "1" "$1"
}
42 changes: 42 additions & 0 deletions tests/acceptance/worker_stderr_test.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash

# Parallel workers used to run with `2>/dev/null`, so anything written to
# stderr inside the worker but outside a test body vanished, and the same run
# reported differently depending on --parallel (#864).
#
# The fixture's provider is also executed by the test-counting pass in the main
# shell, so merely *finding* the diagnostic proves nothing β€” it leaks in from
# that pass either way. What the bug actually broke is the two modes agreeing,
# so that is what these compare.

FIXTURE="tests/acceptance/fixtures/test_worker_stderr.sh"

function _count_diagnostic_in() {
local parallel_flag="$1"

./bashunit "$parallel_flag" "$FIXTURE" 2>&1 |
grep -c "WORKER-SCOPE-DIAGNOSTIC"
}

function test_parallel_reports_worker_stderr_as_often_as_sequential() {
local sequential parallel
sequential="$(_count_diagnostic_in --no-parallel)"
parallel="$(_count_diagnostic_in --parallel)"

assert_not_equals "0" "$sequential"
assert_equals "$sequential" "$parallel"
}

function test_parallel_run_attributes_worker_stderr_to_its_file() {
local output
output=$(./bashunit --parallel "$FIXTURE" 2>&1)

assert_contains "Stderr from $FIXTURE" "$output"
}

function test_parallel_run_stays_quiet_when_no_worker_writes_stderr() {
local output
output=$(./bashunit --parallel tests/acceptance/fixtures/test_coverage_engine.sh 2>&1)

assert_not_contains "Stderr from" "$output"
}
Loading