From fc1a9947253d4d8f59780195551f008cca8c3437 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Sat, 11 Apr 2026 22:17:28 -0700 Subject: [PATCH] Make bash-wrapper work; add tests --- .gitignore | 13 + cli/bash/bin/README.md | 71 +++ cli/bash/bin/bash-wrapper | 139 +++++ cli/bash/bin/test_cmd | 1 + cli/bash/bin/tests/bash-wrapper.bats | 255 ++++++++ cli/bash/commands/test_cmd/test_cmd.sh | 1 + cli/bash/lib/std/README.md | 4 + cli/bash/lib/std/lib_std.sh | 12 +- cli/bash/lib/std/tests/lib_std.bats | 832 ++++++++++++++++++++++++- cli/bash/tests/test_helper.bash | 1 + 10 files changed, 1316 insertions(+), 13 deletions(-) create mode 100644 cli/bash/bin/README.md create mode 100755 cli/bash/bin/bash-wrapper create mode 120000 cli/bash/bin/test_cmd create mode 100644 cli/bash/bin/tests/bash-wrapper.bats create mode 100644 cli/bash/commands/test_cmd/test_cmd.sh diff --git a/.gitignore b/.gitignore index 7b8eeeb..3a47d61 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,15 @@ +# macOS .DS_Store + +# Editors .vscode/ + +# Python +__pycache__/ +*.py[cod] +.venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ diff --git a/cli/bash/bin/README.md b/cli/bash/bin/README.md new file mode 100644 index 0000000..da145bc --- /dev/null +++ b/cli/bash/bin/README.md @@ -0,0 +1,71 @@ +# `cli/bash/bin` + +This directory holds the user-facing Bash entrypoints. + +## Layout + +- `bash-wrapper` + The shared dispatcher used to launch Bash commands. +- `` symlinks + Each command symlink points to `bash-wrapper`. The wrapper uses the invoked filename to decide which command to run. +- `tests/` + Wrapper-specific BATS coverage for `bash-wrapper`. + +## How `bash-wrapper` Works + +The wrapper supports two invocation styles: + +```bash +bash-wrapper [args...] + [args...] +``` + +Behavior: + +- When invoked as `bash-wrapper`, the first argument is treated as the command name. +- When invoked through a symlink, the symlink name is treated as the command name. +- Commands are resolved under `../commands//main.sh`. +- As a compatibility fallback, `../commands//.sh` is also supported. + +## What the Wrapper Provides + +Before sourcing the command script, `bash-wrapper`: + +- resolves the repository, CLI, and Bash root directories +- exports wrapper metadata: + - `BANYAN_REPO_ROOT` + - `BANYAN_CLI_ROOT` + - `BANYAN_BASH_ROOT` + - `BANYAN_BASH_BIN_DIR` + - `BANYAN_BASH_COMMAND_NAME` + - `BANYAN_BASH_COMMAND_DIR` + - `BANYAN_BASH_COMMAND_SCRIPT` +- preloads `../lib/std/lib_std.sh` + +That means command scripts can use the stdlib helpers without sourcing `lib_std.sh` themselves. + +The wrapper also sets `BANYAN_BASH_BOOTSTRAP_SOURCE` before loading the stdlib so stdlib path detection still treats the command script as the real caller. + +## Examples + +Direct dispatch: + +```bash +cli/bash/bin/bash-wrapper my-command --flag value +``` + +Symlink dispatch: + +```bash +ln -s bash-wrapper cli/bash/bin/my-command +cli/bash/bin/my-command --flag value +``` + +## Tests + +Run the wrapper test suite with: + +```bash +cd cli/bash +bats bin/tests/bash-wrapper.bats +``` diff --git a/cli/bash/bin/bash-wrapper b/cli/bash/bin/bash-wrapper new file mode 100755 index 0000000..c6c3406 --- /dev/null +++ b/cli/bash/bin/bash-wrapper @@ -0,0 +1,139 @@ +#!/usr/bin/env bash + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +resolve_script_path() { + local source_path="${1:-}" link_dir target + + [[ -n "$source_path" ]] || return 1 + + if [[ "$source_path" != */* ]]; then + source_path="$(command -v -- "$source_path" 2>/dev/null || true)" + [[ -n "$source_path" ]] || return 1 + fi + + while [[ -L "$source_path" ]]; do + link_dir="$(cd "$(dirname "$source_path")" && pwd -P)" + target="$(readlink "$source_path")" + if [[ "$target" == /* ]]; then + source_path="$target" + else + source_path="$link_dir/$target" + fi + done + + link_dir="$(cd "$(dirname "$source_path")" && pwd -P)" + printf '%s/%s\n' "$link_dir" "$(basename "$source_path")" +} + +list_commands() { + local commands_dir="$1" + local command_dir command_name found=0 + + [[ -d "$commands_dir" ]] || return 0 + + while IFS= read -r command_dir; do + [[ -d "$command_dir" ]] || continue + command_name="$(basename "$command_dir")" + if [[ -f "$command_dir/main.sh" || -f "$command_dir/${command_name}.sh" ]]; then + printf ' %s\n' "$command_name" + found=1 + fi + done < <(find "$commands_dir" -mindepth 1 -maxdepth 1 -type d | sort) + + ((found)) || printf ' (none yet)\n' +} + +print_usage() { + local wrapper_name="$1" + local commands_dir="$2" + + cat < [args...] + [args...] + +Behavior: + - When invoked as 'bash-wrapper', the first argument is treated as the command name. + - When invoked through a symlink, the symlink name is treated as the command name. + - Commands are resolved under cli/bash/commands//main.sh. + - As a compatibility fallback, cli/bash/commands//.sh is also accepted. + +Available commands: +EOF + list_commands "$commands_dir" +} + +main() { + local invoked_as wrapper_path bin_dir bash_root cli_root repo_root commands_dir stdlib_path + local command_name command_dir command_script + + invoked_as="$(basename "$0")" + wrapper_path="$(resolve_script_path "$0")" || die "Unable to resolve wrapper path for '$0'." + bin_dir="$(cd "$(dirname "$wrapper_path")" && pwd -P)" + bash_root="$(cd "$bin_dir/.." && pwd -P)" + cli_root="$(cd "$bash_root/.." && pwd -P)" + repo_root="$(cd "$cli_root/.." && pwd -P)" + commands_dir="$bash_root/commands" + + if [[ "$invoked_as" == "bash-wrapper" ]]; then + case "${1:-}" in + "" ) + print_usage "$invoked_as" "$commands_dir" + exit 1 + ;; + -h|--help|help ) + print_usage "$invoked_as" "$commands_dir" + exit 0 + ;; + --list|list ) + list_commands "$commands_dir" + exit 0 + ;; + esac + + command_name="$1" + shift + else + command_name="$invoked_as" + fi + + case "$command_name" in + ""|.|..|*/* ) + die "Invalid command name '$command_name'." + ;; + esac + + command_dir="$commands_dir/$command_name" + if [[ -f "$command_dir/main.sh" ]]; then + command_script="$command_dir/main.sh" + elif [[ -f "$command_dir/${command_name}.sh" ]]; then + command_script="$command_dir/${command_name}.sh" + else + die "Command '$command_name' was not found under '$command_dir'." + fi + + export BANYAN_REPO_ROOT="$repo_root" + export BANYAN_CLI_ROOT="$cli_root" + export BANYAN_BASH_ROOT="$bash_root" + export BANYAN_BASH_BIN_DIR="$bin_dir" + export BANYAN_BASH_COMMAND_NAME="$command_name" + export BANYAN_BASH_COMMAND_DIR="$command_dir" + export BANYAN_BASH_COMMAND_SCRIPT="$command_script" + + stdlib_path="$bash_root/lib/std/lib_std.sh" + [[ -f "$stdlib_path" ]] || die "Required stdlib '$stdlib_path' was not found." + + BANYAN_BASH_BOOTSTRAP_SOURCE="$command_script" + # Source the stdlib in the wrapper shell so command scripts inherit the shared helpers. + source "$stdlib_path" + unset BANYAN_BASH_BOOTSTRAP_SOURCE + + # Source the command in the same shell so it can use stdlib helpers without per-command boilerplate. + source "$command_script" +} + +main "$@" diff --git a/cli/bash/bin/test_cmd b/cli/bash/bin/test_cmd new file mode 120000 index 0000000..e118386 --- /dev/null +++ b/cli/bash/bin/test_cmd @@ -0,0 +1 @@ +bash-wrapper \ No newline at end of file diff --git a/cli/bash/bin/tests/bash-wrapper.bats b/cli/bash/bin/tests/bash-wrapper.bats new file mode 100644 index 0000000..3b2a033 --- /dev/null +++ b/cli/bash/bin/tests/bash-wrapper.bats @@ -0,0 +1,255 @@ +#!/usr/bin/env bats + +load ../../tests/test_helper.bash + +create_bare_wrapper_layout() { + local layout_root="$1" + + mkdir -p "$layout_root/bin" "$layout_root/commands" "$layout_root/lib/std" + cp "$BANYAN_BASH_DIR/bin/bash-wrapper" "$layout_root/bin/bash-wrapper" + cp "$BANYAN_BASH_DIR/lib/std/lib_std.sh" "$layout_root/lib/std/lib_std.sh" + chmod +x "$layout_root/bin/bash-wrapper" +} + +create_wrapper_layout() { + local layout_root="$1" + local command_name="$2" + local command_script_name="${3:-main.sh}" + + create_bare_wrapper_layout "$layout_root" + mkdir -p "$layout_root/commands/$command_name" + + cat > "$layout_root/commands/$command_name/$command_script_name" <<'EOF' +#!/usr/bin/env bash +printf 'script_dir=%s\n' "${__SCRIPT_DIR__:-}" +printf 'orig_args=%s\n' "${__SCRIPT_ARGS__[*]:-}" +printf 'command=%s\n' "${BANYAN_BASH_COMMAND_NAME:-}" +printf 'repo=%s\n' "${BANYAN_REPO_ROOT:-}" +printf 'bash_root=%s\n' "${BANYAN_BASH_ROOT:-}" +printf 'script=%s\n' "${BANYAN_BASH_COMMAND_SCRIPT:-}" +printf 'argv=%s\n' "$*" +EOF + chmod +x "$layout_root/commands/$command_name/$command_script_name" +} + +@test "bash-wrapper dispatches directly to commands//main.sh" { + local repo_root="$BATS_TEST_TMPDIR/repo" + local layout="$repo_root/cli/bash" + local expected_repo_root expected_bash_root expected_script_path + local expected_command_dir + + create_wrapper_layout "$layout" demo + expected_repo_root="$(cd "$repo_root" && pwd -P)" + expected_bash_root="$(cd "$layout" && pwd -P)" + expected_command_dir="$(cd "$layout/commands/demo" && pwd -P)" + expected_script_path="$(cd "$layout/commands/demo" && pwd -P)/main.sh" + + run "$layout/bin/bash-wrapper" demo --debug-wrapper alpha beta + + [ "$status" -eq 0 ] + [[ "$output" == *"script_dir=$expected_command_dir"* ]] + [[ "$output" == *"orig_args=--debug-wrapper alpha beta"* ]] + [[ "$output" == *"command=demo"* ]] + [[ "$output" == *"repo=$expected_repo_root"* ]] + [[ "$output" == *"bash_root=$expected_bash_root"* ]] + [[ "$output" == *"script=$expected_script_path"* ]] + [[ "$output" == *"argv=alpha beta"* ]] +} + +@test "symlink name selects the command" { + local repo_root="$BATS_TEST_TMPDIR/repo" + local layout="$repo_root/cli/bash" + local expected_script_path + local expected_command_dir + + create_wrapper_layout "$layout" greet + ln -s bash-wrapper "$layout/bin/greet" + expected_command_dir="$(cd "$layout/commands/greet" && pwd -P)" + expected_script_path="$(cd "$layout/commands/greet" && pwd -P)/main.sh" + + run "$layout/bin/greet" hello world + + [ "$status" -eq 0 ] + [[ "$output" == *"script_dir=$expected_command_dir"* ]] + [[ "$output" == *"command=greet"* ]] + [[ "$output" == *"script=$expected_script_path"* ]] + [[ "$output" == *"argv=hello world"* ]] +} + +@test "wrapper supports the fallback commands//.sh layout" { + local repo_root="$BATS_TEST_TMPDIR/repo" + local layout="$repo_root/cli/bash" + local expected_script_path + local expected_command_dir + + create_wrapper_layout "$layout" legacy "legacy.sh" + expected_command_dir="$(cd "$layout/commands/legacy" && pwd -P)" + expected_script_path="$(cd "$layout/commands/legacy" && pwd -P)/legacy.sh" + + run "$layout/bin/bash-wrapper" legacy arg1 + + [ "$status" -eq 0 ] + [[ "$output" == *"script_dir=$expected_command_dir"* ]] + [[ "$output" == *"command=legacy"* ]] + [[ "$output" == *"script=$expected_script_path"* ]] + [[ "$output" == *"argv=arg1"* ]] +} + +@test "wrapper prints usage when no command is provided" { + local repo_root="$BATS_TEST_TMPDIR/repo" + local layout="$repo_root/cli/bash" + + create_bare_wrapper_layout "$layout" + + run "$layout/bin/bash-wrapper" + + [ "$status" -eq 1 ] + [[ "$output" == *"Usage:"* ]] +} + +@test "wrapper prints usage for help flags" { + local repo_root="$BATS_TEST_TMPDIR/repo" + local layout="$repo_root/cli/bash" + + create_bare_wrapper_layout "$layout" + + run "$layout/bin/bash-wrapper" --help + + [ "$status" -eq 0 ] + [[ "$output" == *"Usage:"* ]] + [[ "$output" == *"Available commands:"* ]] +} + +@test "wrapper lists commands with command scripts and skips empty directories" { + local repo_root="$BATS_TEST_TMPDIR/repo" + local layout="$repo_root/cli/bash" + + create_wrapper_layout "$layout" alpha + mkdir -p "$layout/commands/empty-dir" + mkdir -p "$layout/commands/readme-only" + printf '# no script here\n' > "$layout/commands/readme-only/README.md" + mkdir -p "$layout/commands/legacy" + cat > "$layout/commands/legacy/legacy.sh" <<'EOF' +#!/usr/bin/env bash +echo "legacy" +EOF + chmod +x "$layout/commands/legacy/legacy.sh" + + run "$layout/bin/bash-wrapper" --list + + [ "$status" -eq 0 ] + [[ "$output" == *" alpha"* ]] + [[ "$output" == *" legacy"* ]] + [[ "$output" != *"empty-dir"* ]] + [[ "$output" != *"readme-only"* ]] +} + +@test "wrapper lists none when no commands exist yet" { + local repo_root="$BATS_TEST_TMPDIR/repo" + local layout="$repo_root/cli/bash" + + create_bare_wrapper_layout "$layout" + + run "$layout/bin/bash-wrapper" --list + + [ "$status" -eq 0 ] + [[ "$output" == *" (none yet)"* ]] +} + +@test "wrapper rejects invalid command names in direct mode" { + local repo_root="$BATS_TEST_TMPDIR/repo" + local layout="$repo_root/cli/bash" + + create_bare_wrapper_layout "$layout" + + run "$layout/bin/bash-wrapper" ../bad + + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid command name '../bad'."* ]] +} + +@test "wrapper errors when the command directory is missing" { + local repo_root="$BATS_TEST_TMPDIR/repo" + local layout="$repo_root/cli/bash" + + create_bare_wrapper_layout "$layout" + + run "$layout/bin/bash-wrapper" missing + + [ "$status" -eq 1 ] + [[ "$output" == *"Command 'missing' was not found"* ]] +} + +@test "wrapper errors when the stdlib is missing" { + local repo_root="$BATS_TEST_TMPDIR/repo" + local layout="$repo_root/cli/bash" + + create_wrapper_layout "$layout" demo + rm -f "$layout/lib/std/lib_std.sh" + + run "$layout/bin/bash-wrapper" demo + + [ "$status" -eq 1 ] + [[ "$output" == *"Required stdlib"* ]] +} + +@test "wrapper preloads stdlib so commands can call stdlib helpers without sourcing it" { + local repo_root="$BATS_TEST_TMPDIR/repo" + local layout="$repo_root/cli/bash" + + create_bare_wrapper_layout "$layout" + mkdir -p "$layout/commands/stdlib-demo" + cat > "$layout/commands/stdlib-demo/main.sh" <<'EOF' +#!/usr/bin/env bash +set_log_level DEBUG +run echo "wrapped output" +safe_touch "$BATS_TEST_TMPDIR/stdout.txt" +printf 'touched=%s\n' "$BATS_TEST_TMPDIR/stdout.txt" +EOF + chmod +x "$layout/commands/stdlib-demo/main.sh" + + run "$layout/bin/bash-wrapper" stdlib-demo + + [ "$status" -eq 0 ] + [[ "$output" == *"wrapped output"* ]] + [[ "$output" == *"touched=$BATS_TEST_TMPDIR/stdout.txt"* ]] + [ -f "$BATS_TEST_TMPDIR/stdout.txt" ] +} + +@test "wrapper strips wrapper flags before the command sees argv" { + local repo_root="$BATS_TEST_TMPDIR/repo" + local layout="$repo_root/cli/bash" + + create_bare_wrapper_layout "$layout" + mkdir -p "$layout/commands/flags" + cat > "$layout/commands/flags/main.sh" <<'EOF' +#!/usr/bin/env bash +printf 'orig=%s\n' "${__SCRIPT_ARGS__[*]}" +printf 'argv=%s\n' "$*" +printf 'log_debug=%s\n' "${LOG_DEBUG:-}" +printf 'log_utc=%s\n' "${LOG_UTC:-}" +EOF + chmod +x "$layout/commands/flags/main.sh" + + run "$layout/bin/bash-wrapper" flags --verbose-wrapper --utc-wrapper --color one two + + [ "$status" -eq 0 ] + [[ "$output" == *"orig=--verbose-wrapper --utc-wrapper --color one two"* ]] + [[ "$output" == *"argv=one two"* ]] + [[ "$output" == *"log_debug=1"* ]] + [[ "$output" == *"log_utc=1"* ]] +} + +@test "symlink invocation reports missing command scripts for the symlink name" { + local repo_root="$BATS_TEST_TMPDIR/repo" + local layout="$repo_root/cli/bash" + + create_bare_wrapper_layout "$layout" + ln -s bash-wrapper "$layout/bin/orphan" + mkdir -p "$layout/commands/orphan" + + run "$layout/bin/orphan" + + [ "$status" -eq 1 ] + [[ "$output" == *"Command 'orphan' was not found"* ]] +} diff --git a/cli/bash/commands/test_cmd/test_cmd.sh b/cli/bash/commands/test_cmd/test_cmd.sh new file mode 100644 index 0000000..b1f91e0 --- /dev/null +++ b/cli/bash/commands/test_cmd/test_cmd.sh @@ -0,0 +1 @@ +log_info "I am starting" diff --git a/cli/bash/lib/std/README.md b/cli/bash/lib/std/README.md index 82e70f9..50513ed 100644 --- a/cli/bash/lib/std/README.md +++ b/cli/bash/lib/std/README.md @@ -17,6 +17,8 @@ Shared foundation library for Bash code under `cli/bash`. ## Usage +Standalone script usage: + ```bash source "/absolute/path/to/cli/bash/lib/std/lib_std.sh" @@ -29,6 +31,8 @@ run echo "hello" - Requires Bash 4.0 or newer. - Sourcing the file runs `__stdlib_init__`. +- `cli/bash/bin/bash-wrapper` preloads this library for command scripts so commands do not need per-command stdlib sourcing boilerplate. +- The wrapper sets `BANYAN_BASH_BOOTSTRAP_SOURCE` before sourcing this file so `__SCRIPT_DIR__` still points at the command script rather than the wrapper. - Wrapper-level flags such as `--debug-wrapper` and `--verbose-wrapper` are consumed during initialization. - Other Bash libraries in this tree rely on this file for logging and error handling. diff --git a/cli/bash/lib/std/lib_std.sh b/cli/bash/lib/std/lib_std.sh index eaff1a2..12b7ffc 100644 --- a/cli/bash/lib/std/lib_std.sh +++ b/cli/bash/lib/std/lib_std.sh @@ -16,7 +16,7 @@ # Quick Reference # -------------------------------------------------------------------------------------------------------------------- # Sourcing: -# source "/shared/scripts/lib_std.sh" +# source "/cli/bash/lib/std/lib_std.sh" # # Caller-visible globals: # __SCRIPT_ARGS__ Original "$@" before lib_std consumed global flags. @@ -38,7 +38,8 @@ # add_to_path -p "/opt/tools" # inject directories without duplicates. # # Notes: -# - Global options --debug/--verbose/--utc/--color are stripped from "$@" automatically. +# - Global options --debug-wrapper/--verbose-wrapper/--utc-wrapper/--color are stripped from "$@" automatically. +# - Wrappers may override the caller path seen by this library through BANYAN_BASH_BOOTSTRAP_SOURCE. # ################################################# INITIALIZATION ####################################################### @@ -55,11 +56,14 @@ readonly __LIB_STD_PATH__="${BASH_SOURCE[0]}" # Memorize the original script arguments at the very beginning. # This allows the library to parse global options before the main script does. # We retain the original arguments in __SCRIPT_ARGS__ and the script source directory in __SCRIPT_DIR__ as readonly -# variables which could be used by the caller. +# variables which could be used by the caller. When a wrapper preloads this library on behalf of another script, it can +# provide BANYAN_BASH_BOOTSTRAP_SOURCE so __SCRIPT_DIR__ still resolves to the real command script. # readonly __SCRIPT_ARGS__=("$@") __new_args__=() -readonly __SCRIPT_DIR__=$(cd -- "$(dirname -- "${BASH_SOURCE[1]}" )" &>/dev/null && pwd -P) +readonly __SCRIPT_DIR__=$( + cd -- "$(dirname -- "${BANYAN_BASH_BOOTSTRAP_SOURCE:-${BASH_SOURCE[1]}}" )" &>/dev/null && pwd -P +) ############################################ BASH VERSION CHECKER ####################################################### diff --git a/cli/bash/lib/std/tests/lib_std.bats b/cli/bash/lib/std/tests/lib_std.bats index 9f0aa90..9575e84 100644 --- a/cli/bash/lib/std/tests/lib_std.bats +++ b/cli/bash/lib/std/tests/lib_std.bats @@ -2,38 +2,490 @@ load ../../../tests/test_helper.bash +readonly STDLIB_PATH="$BANYAN_BASH_DIR/lib/std/lib_std.sh" + +create_script() { + local script_path="$1" + cat > "$script_path" + chmod +x "$script_path" +} + +normalize_tty_output() { + local text="$1" + text="${text//$'\r'/}" + text="${text//$'\b'/}" + printf '%s' "$text" +} + +run_tty_script() { + local script_path="$1" + shift + bats_run script -q /dev/null "$script_path" "$@" +} + setup() { setup_test_tmpdir - source "$BANYAN_BASH_DIR/lib/std/lib_std.sh" + PATH="$BANYAN_TEST_ORIG_PATH" + unset DRY_RUN dry_run LOG_DEBUG LOG_UTC BANYAN_BASH_BOOTSTRAP_SOURCE + source "$STDLIB_PATH" +} + +teardown() { + PATH="$BANYAN_TEST_ORIG_PATH" +} + +@test "sourcing stdlib preserves original args and strips wrapper flags" { + local script="$TEST_TMPDIR/check-init.sh" + + create_script "$script" < "$relative_dir/relative.sh" <<'EOF' +REL_IMPORTED="relative" +EOF + cat > "$absolute_lib" <<'EOF' +ABS_IMPORTED="absolute" +EOF + + create_script "$script" <"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"add_to_path: invalid option"* ]] +} + +@test "dedupe_path removes duplicates and empty entries" { + PATH="/one:/two:/one::/three:/two" + + dedupe_path + + [ "$PATH" = "/one:/two:/three" ] +} + +@test "print_path emits one path entry per line" { + PATH="/one:/two:/three" + + bats_run print_path + + [ "$status" -eq 0 ] + [ "$output" = $'/one\n/two\n/three' ] +} + +@test "__join_message__ joins fragments with single spaces" { + local joined + + joined="$(__join_message__ alpha beta "gamma delta")" + + [ "$joined" = "alpha beta gamma delta" ] +} + +@test "log initialization sets the default logger map" { + [ "${_log_levels[ERROR]}" -eq 1 ] + [ "${_log_levels[VERBOSE]}" -eq 5 ] + [ "${_loggers_level_map[default]}" -eq 3 ] + [ -z "${COLOR_RED:-}" ] +} + +@test "set_log_level updates named loggers and falls back for unknown levels" { + local stderr_file="$TEST_TMPDIR/set-log-level.err" + + set_log_level -l custom DEBUG + [ "${_loggers_level_map[custom]}" -eq 4 ] + + set_log_level -l custom NOPE 2>"$stderr_file" + [ "${_loggers_level_map[custom]}" -eq 3 ] + [[ "$(cat "$stderr_file")" == *"Unknown log level 'NOPE'"* ]] +} + +@test "_print_log requires a log level" { + ! _print_log +} + +@test "log wrappers respect the configured log level" { + local stderr_file="$TEST_TMPDIR/log-wrappers.err" + + : > "$stderr_file" + log_debug hidden 2>"$stderr_file" + [ ! -s "$stderr_file" ] + + set_log_level VERBOSE + { + log_fatal "fatal message" + log_error "error message" + log_warn "warn message" + log_info "info message" + log_debug "debug message" + log_verbose "verbose message" + } 2>"$stderr_file" + + [[ "$(cat "$stderr_file")" == *"FATAL"* ]] + [[ "$(cat "$stderr_file")" == *"ERROR"* ]] + [[ "$(cat "$stderr_file")" == *"WARN"* ]] + [[ "$(cat "$stderr_file")" == *"INFO"* ]] + [[ "$(cat "$stderr_file")" == *"DEBUG"* ]] + [[ "$(cat "$stderr_file")" == *"VERBOSE"* ]] +} + +@test "file logging helpers print contents and warn on unknown loggers" { + local target="$TEST_TMPDIR/log-target.txt" + local stderr_file="$TEST_TMPDIR/log-file.err" + + printf 'hello file\n' > "$target" + + log_debug_file "$target" 2>"$stderr_file" + [ ! -s "$stderr_file" ] + + set_log_level DEBUG + log_debug_file "$target" 2>"$stderr_file" + [[ "$(cat "$stderr_file")" == *"Contents of file '$target':"* ]] + [[ "$(cat "$stderr_file")" == *"hello file"* ]] + + _print_log_file INFO -l missing "$target" 2>"$stderr_file" + [[ "$(cat "$stderr_file")" == *"Unknown logger 'missing'"* ]] +} + +@test "enter and leave logging helpers include the caller name" { + local stderr_file="$TEST_TMPDIR/enter-leave.err" + + trace_me() { + log_info_enter + log_debug_enter + log_verbose_enter + log_info_leave + log_debug_leave + log_verbose_leave + } + + set_log_level VERBOSE + trace_me 2>"$stderr_file" + + [[ "$(cat "$stderr_file")" == *"Entering function trace_me"* ]] + [[ "$(cat "$stderr_file")" == *"Leaving function trace_me"* ]] +} + +@test "print helpers emit expected text" { + local stderr_file="$TEST_TMPDIR/print.err" + local stdout_file="$TEST_TMPDIR/print.out" + + { + print_error "bad news" + print_warn "careful" + print_info "heads up" + print_success "all good" + } 2>"$stderr_file" + + { + print_bold "strong text" + print_message "line one" "line two" + } >"$stdout_file" + + [[ "$(cat "$stderr_file")" == *"ERROR: bad news"* ]] + [[ "$(cat "$stderr_file")" == *"WARN: careful"* ]] + [[ "$(cat "$stderr_file")" == *"heads up"* ]] + [[ "$(cat "$stderr_file")" == *"SUCCESS: all good"* ]] + [ "$(cat "$stdout_file")" = $'strong text\nline one\nline two' ] +} + +@test "print_tty is silent without a tty" { + local stdout_file="$TEST_TMPDIR/tty.out" + + print_tty "hidden output" >"$stdout_file" + + [ ! -s "$stdout_file" ] +} + +@test "print_tty emits output when a tty is present" { + local script="$TEST_TMPDIR/print-tty.sh" + local normalized + + create_script "$script" <"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"run: No command provided."* ]] } @test "run honors dry-run mode without executing the command" { @@ -42,10 +494,68 @@ setup() { run touch "$target" - [ "$status" -eq 0 ] + [ "$?" -eq 0 ] [ ! -e "$target" ] } +@test "run --no-exit returns the underlying failure status" { + local stderr_file="$TEST_TMPDIR/run-no-exit.err" + local rc + + if run --no-exit bash -c 'exit 7' 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 7 ] + [[ "$(cat "$stderr_file")" == *"continuing"* ]] +} + +@test "run exits the script on failure by default" { + local script="$TEST_TMPDIR/run-fail.sh" + + create_script "$script" <"$stderr_file" + + [ "$?" -eq 0 ] + [[ "$(cat "$stderr_file")" == *"safe_touch: No files provided to touch."* ]] +} + +@test "safe_touch exits when a file cannot be touched" { + local script="$TEST_TMPDIR/safe-touch-fail.sh" + + create_script "$script" < "$target" + safe_truncate "$target" + + [ ! -s "$target" ] +} + +@test "safe_truncate warns when no files are provided" { + local stderr_file="$TEST_TMPDIR/safe-truncate.err" + + safe_truncate 2>"$stderr_file" + + [ "$?" -eq 0 ] + [[ "$(cat "$stderr_file")" == *"safe_truncate: No files provided to truncate."* ]] +} + +@test "safe_truncate exits when a file cannot be truncated" { + local script="$TEST_TMPDIR/safe-truncate-fail.sh" + + create_script "$script" <"$stderr_file" + [ "$?" -eq 0 ] + [[ "$(cat "$stderr_file")" == *"assert_command_exists: No commands provided to check."* ]] +} + +@test "assert_command_exists exits for missing commands" { + local script="$TEST_TMPDIR/assert-command-fail.sh" + + create_script "$script" <"$stderr_file" + [ "$?" -eq 0 ] + [[ "$(cat "$stderr_file")" == *"assert_file_exists: No files provided to check."* ]] +} + +@test "assert_file_exists exits for missing files" { + local script="$TEST_TMPDIR/assert-file-fail.sh" + + create_script "$script" <"$stderr_file" + [ "$?" -eq 0 ] + [[ "$(cat "$stderr_file")" == *"assert_dir_exists: No directories provided to check."* ]] +} + +@test "assert_dir_exists exits for missing directories" { + local script="$TEST_TMPDIR/assert-dir-fail.sh" + + create_script "$script" </dev/null 2>&1 } + +@test "get_my_source_dir returns the caller script directory" { + local script="$TEST_TMPDIR/get-source-dir.sh" + local expected_dir + + create_script "$script" <"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"ask_yes_no: invalid arguments"* ]] +} + +@test "wait_for_enter returns after receiving a newline on a tty" { + skip "wait_for_enter reads from /dev/tty and needs a more reliable pseudo-tty harness on macOS." +} diff --git a/cli/bash/tests/test_helper.bash b/cli/bash/tests/test_helper.bash index efe3caf..c5b68ff 100644 --- a/cli/bash/tests/test_helper.bash +++ b/cli/bash/tests/test_helper.bash @@ -8,6 +8,7 @@ fi readonly BANYAN_BASH_TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" readonly BANYAN_BASH_DIR="$(cd "$BANYAN_BASH_TESTS_DIR/.." && pwd -P)" readonly BANYAN_REPO_ROOT="$(cd "$BANYAN_BASH_DIR/../.." && pwd -P)" +readonly BANYAN_TEST_ORIG_PATH="$PATH" setup_test_tmpdir() { TEST_TMPDIR="${BATS_TEST_TMPDIR}/workspace"