From 2633722423c4781e6647212f3a8a04d3c7a7b1e1 Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Tue, 2 Dec 2025 01:15:42 +0000 Subject: [PATCH] test(refactor): switch to bashunit and start fleshing out a new test suite --- .env | 1 + .gitignore | 7 +- AGENTS.md | 150 + CLAUDE.md | 1 + Makefile | 83 +- lib/layout-helpers.sh | 6 +- lib/util.sh | 68 + libexec/tmuxifier-tmux-version | 24 - {test => test-legacy}/lib/env.test.sh | 0 .../lib/layout-helpers/__expand_path.test.sh | 0 .../__get_current_window_index.test.sh | 0 .../__get_first_window_index.test.sh | 0 .../layout-helpers/__go_to_session.test.sh | 0 .../__go_to_window_or_session_path.test.sh | 0 .../lib/layout-helpers/new_window.test.sh | 0 .../lib/layout-helpers/select_window.test.sh | 0 .../lib/layout-helpers/split_h.test.sh | 70 + .../lib/layout-helpers/split_hl.test.sh | 70 + .../lib/layout-helpers/split_v.test.sh | 70 + .../lib/layout-helpers/split_vl.test.sh | 70 + .../lib/layout-helpers/tmux.test.sh | 0 {test => test-legacy}/lib/runtime.test.sh | 0 {test => test-legacy}/lib/util.test.sh | 0 .../libexec/tmuxifier-tmux.test.sh | 4 +- {test => test-legacy}/test-helper.sh | 8 +- tests/bashunit | 4501 +++++++++++++++++ tests/bootstrap.sh | 13 + .../__get_current_window_index_test.sh | 209 + .../__get_first_window_index_test.sh | 146 + .../__go_to_window_or_session_path_test.sh | 197 + .../balance_windows_horizontal_test.sh | 56 + .../balance_windows_vertical_test.sh | 56 + tests/lib/layout-helpers/clock_test.sh | 54 + .../finalize_and_go_to_session_test.sh | 255 + .../layout-helpers/initialize_session_test.sh | 430 ++ tests/lib/layout-helpers/load_session_test.sh | 200 + tests/lib/layout-helpers/load_window_test.sh | 175 + tests/lib/layout-helpers/new_window_test.sh | 99 + tests/lib/layout-helpers/run_cmd_test.sh | 79 + tests/lib/layout-helpers/select_pane_test.sh | 56 + .../lib/layout-helpers/select_window_test.sh | 56 + tests/lib/layout-helpers/send_keys_test.sh | 76 + tests/lib/layout-helpers/session_root_test.sh | 90 + tests/lib/layout-helpers/split_h_test.sh | 118 + tests/lib/layout-helpers/split_hl_test.sh | 71 + tests/lib/layout-helpers/split_v_test.sh | 118 + tests/lib/layout-helpers/split_vl_test.sh | 71 + .../layout-helpers/synchronize_off_test.sh | 56 + .../lib/layout-helpers/synchronize_on_test.sh | 56 + tests/lib/layout-helpers/tmux_test.sh | 54 + tests/lib/layout-helpers/window_root_test.sh | 90 + tests/lib/util/calling-complete_test.sh | 53 + tests/lib/util/calling-help_test.sh | 83 + tests/lib/util/vercomp_test.sh | 132 + 54 files changed, 8203 insertions(+), 79 deletions(-) create mode 100644 .env create mode 100644 AGENTS.md create mode 120000 CLAUDE.md rename {test => test-legacy}/lib/env.test.sh (100%) rename {test => test-legacy}/lib/layout-helpers/__expand_path.test.sh (100%) rename {test => test-legacy}/lib/layout-helpers/__get_current_window_index.test.sh (100%) rename {test => test-legacy}/lib/layout-helpers/__get_first_window_index.test.sh (100%) rename {test => test-legacy}/lib/layout-helpers/__go_to_session.test.sh (100%) rename {test => test-legacy}/lib/layout-helpers/__go_to_window_or_session_path.test.sh (100%) rename {test => test-legacy}/lib/layout-helpers/new_window.test.sh (100%) rename {test => test-legacy}/lib/layout-helpers/select_window.test.sh (100%) create mode 100755 test-legacy/lib/layout-helpers/split_h.test.sh create mode 100755 test-legacy/lib/layout-helpers/split_hl.test.sh create mode 100755 test-legacy/lib/layout-helpers/split_v.test.sh create mode 100755 test-legacy/lib/layout-helpers/split_vl.test.sh rename {test => test-legacy}/lib/layout-helpers/tmux.test.sh (100%) rename {test => test-legacy}/lib/runtime.test.sh (100%) rename {test => test-legacy}/lib/util.test.sh (100%) rename {test => test-legacy}/libexec/tmuxifier-tmux.test.sh (75%) rename {test => test-legacy}/test-helper.sh (90%) create mode 100755 tests/bashunit create mode 100755 tests/bootstrap.sh create mode 100755 tests/lib/layout-helpers/__get_current_window_index_test.sh create mode 100755 tests/lib/layout-helpers/__get_first_window_index_test.sh create mode 100755 tests/lib/layout-helpers/__go_to_window_or_session_path_test.sh create mode 100755 tests/lib/layout-helpers/balance_windows_horizontal_test.sh create mode 100755 tests/lib/layout-helpers/balance_windows_vertical_test.sh create mode 100755 tests/lib/layout-helpers/clock_test.sh create mode 100755 tests/lib/layout-helpers/finalize_and_go_to_session_test.sh create mode 100755 tests/lib/layout-helpers/initialize_session_test.sh create mode 100755 tests/lib/layout-helpers/load_session_test.sh create mode 100755 tests/lib/layout-helpers/load_window_test.sh create mode 100755 tests/lib/layout-helpers/new_window_test.sh create mode 100755 tests/lib/layout-helpers/run_cmd_test.sh create mode 100755 tests/lib/layout-helpers/select_pane_test.sh create mode 100755 tests/lib/layout-helpers/select_window_test.sh create mode 100755 tests/lib/layout-helpers/send_keys_test.sh create mode 100755 tests/lib/layout-helpers/session_root_test.sh create mode 100755 tests/lib/layout-helpers/split_h_test.sh create mode 100755 tests/lib/layout-helpers/split_hl_test.sh create mode 100755 tests/lib/layout-helpers/split_v_test.sh create mode 100755 tests/lib/layout-helpers/split_vl_test.sh create mode 100755 tests/lib/layout-helpers/synchronize_off_test.sh create mode 100755 tests/lib/layout-helpers/synchronize_on_test.sh create mode 100755 tests/lib/layout-helpers/tmux_test.sh create mode 100755 tests/lib/layout-helpers/window_root_test.sh create mode 100755 tests/lib/util/calling-complete_test.sh create mode 100755 tests/lib/util/calling-help_test.sh create mode 100755 tests/lib/util/vercomp_test.sh diff --git a/.env b/.env new file mode 100644 index 0000000..c305d32 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +BASHUNIT_BOOTSTRAP=tests/bootstrap.sh diff --git a/.gitignore b/.gitignore index 6946830..4e64864 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ -test-runner.sh -test/assert.sh -test/stub.sh +test-legacy/test-runner.sh +test-legacy/assert.sh +test-legacy/stub.sh +test/bashunit diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..facd1bc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,150 @@ +# AGENTS.md + +This file provides guidance to AI Agents when working with code in this +repository. + +## Project Overview + +Tmuxifier is a shell-based tool for creating and managing complex Tmux session +and window layouts. Users write layout files as shell scripts that use tmux +commands and helper functions to define session/window configurations. + +## Architecture + +### Core Components + +- **bin/tmuxifier**: Main executable that bootstraps the environment, validates + Tmux version (≥1.6), and dispatches to libexec commands +- **libexec/**: Command implementations (tmuxifier-*, e.g., + tmuxifier-load-session, tmuxifier-new-window) +- **lib/layout-helpers.sh**: Helper functions available within layout files + (new_window, split_v, split_h, run_cmd, select_pane, etc.) +- **lib/runtime.sh**: Runtime environment loader sourced by layout files +- **lib/env.sh**: Sets up TMUXIFIER_LAYOUT_PATH (defaults to $TMUXIFIER/layouts) +- **templates/**: Templates for new session.sh and window.sh layout files +- **examples/**: Example layout files demonstrating usage + +### Layout File Types + +**Session layouts** (*.session.sh): + +- Define entire Tmux sessions with multiple windows +- Must call `initialize_session` to create the session +- Can load window layouts via `load_window` or define windows inline +- Must call `finalize_and_go_to_session` at the end +- Can set `session_root` for default directory + +**Window layouts** (*.window.sh): + +- Define single window configurations with panes +- Loaded into existing sessions or from session layouts +- Can set `window_root` for window-specific directory +- Use helper functions to split panes and run commands + +### Key Concepts + +- Layout files are executed as shell scripts with lib/layout-helpers.sh sourced +- Helper functions wrap tmux commands, managing session/window context +- The `tmux` command itself is aliased to tmuxifier-tmux wrapper +- Session creation moves default window to position 999, then kills it in + finalize_and_go_to_session +- TMUXIFIER_TMUX_OPTS allows passing custom arguments to tmux + +## Development Commands + +### Testing + +Tests use [bashunit](https://github.com/TypedDevs/bashunit) framework. Use +deepwiki MCP tool to lookup bashunit documentation if needed. + +```bash +make test # Run all tests +make test FILE=tests/lib/util/foo_test.sh # Run a single test file +make bootstrap # Fetch test dependencies +``` + +Tests are located in `tests/` directory and follow bashunit conventions. Test +files are named `*_test.sh`. + +Legacy tests in `test-legacy/` use test-runner.sh framework with assert.sh and +stub.sh libraries. Run with `make test-legacy`. + +### Manual Testing + +```bash +# Create and load a test window layout +./bin/tmuxifier new-window test-window +./bin/tmuxifier load-window test-window + +# Create and load a test session layout +./bin/tmuxifier new-session test-session +./bin/tmuxifier load-session test-session + +# List available layouts +./bin/tmuxifier list-sessions +./bin/tmuxifier list-windows +``` + +## Code Style + +- Shell scripts follow Bash conventions +- 2-space indentation +- Functions document arguments in comments +- Use local variables for function scope +- Prefer `[ ]` over `[[ ]]` for basic tests +- Command substitution uses `$()` not backticks + +## Important Implementation Details + +### Helper Function Pattern + +Helper functions in lib/layout-helpers.sh follow this pattern: + +```bash +function_name() { + # Parse optional arguments + if [ -n "$1" ]; then local arg=(-flag "$1"); fi + + # Execute tmux command with session/window context + tmuxifier-tmux command -t "$session:$window" "${arg[@]}" + + # Update state if needed + __go_to_window_or_session_path +} +``` + +### Tmux Version Handling + +Code must support Tmux 1.6+. Version-specific behavior uses +tmuxifier-tmux-version comparisons: + +```bash +if [ "$(tmuxifier-tmux-version "1.9")" == "<" ]; then + # Tmux 1.8 and earlier +else + # Tmux 1.9 and later +fi +``` + +### Path Expansion + +Use `__expand_path` to handle ~ and variables in paths: + +```bash +session_root() { + local dir="$(__expand_path $@)" + if [ -d "$dir" ]; then + session_root="$dir" + fi +} +``` + +## Environment Variables + +- **TMUXIFIER**: Set to installation directory (auto-detected from bin location) +- **TMUXIFIER_LAYOUT_PATH**: Custom layouts directory (default: + $TMUXIFIER/layouts) +- **TMUXIFIER_TMUX_OPTS**: Custom arguments passed to tmux +- **TMUXIFIER_TMUX_ITERM_ATTACH**: Set to "-CC" for iTerm2 integration +- **TMUXIFIER_NO_COMPLETE**: Disable shell completion if set +- **TMUXIFIER_MIN_TMUX_VERSION**: Minimum required Tmux version (1.6) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..55bf822 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +./AGENTS.md \ No newline at end of file diff --git a/Makefile b/Makefile index 8a83c37..5f9f8a9 100644 --- a/Makefile +++ b/Makefile @@ -1,50 +1,43 @@ +FETCHED_FILES := + +# $(1) = local file path, $(2) = remote URL, $(3) = optional post-curl command +define FETCH_FILE +FETCHED_FILES += $(1) + +$(1): + echo "fetching $(1)..." && \ + mkdir -p $(dir $(1)) && \ + curl -s -L -o $(1) $(2)$(if $(3), && $(3),) + +remove_$(1): + test -f "$(1)" && rm "$(1)" && echo "removed $(1)" || true + +update_$(1): remove_$(1) $(1) +endef + +$(eval $(call FETCH_FILE,tests/bashunit,\ + https://github.com/TypedDevs/bashunit/releases/download/0.26.0/bashunit,\ + chmod +x tests/bashunit)) + +$(eval $(call FETCH_FILE,test-legacy/test-runner.sh,\ + https://github.com/jimeh/test-runner.sh/raw/v0.2.0/test-runner.sh,\ + chmod +x test-legacy/test-runner.sh)) +$(eval $(call FETCH_FILE,test-legacy/assert.sh,\ + https://raw.github.com/lehmannro/assert.sh/v1.0.2/assert.sh)) +$(eval $(call FETCH_FILE,test-legacy/stub.sh,\ + https://raw.github.com/jimeh/stub.sh/v1.0.1/stub.sh)) + test: bootstrap - ./test-runner.sh + ./tests/bashunit $(FILE) -bootstrap: test-runner.sh test/assert.sh test/stub.sh -clean: remove_test-runner.sh remove_test/assert.sh remove_test/stub.sh -update: update_test-runner.sh update_test/assert.sh update_test/stub.sh +test-legacy: bootstrap + ./test-legacy/test-runner.sh $(FILE) -test-runner.sh: - echo "fetching test-runner.sh..." && \ - curl -s -L -o test-runner.sh \ - https://github.com/jimeh/test-runner.sh/raw/v0.2.0/test-runner.sh && \ - chmod +x test-runner.sh - -remove_test-runner.sh: - ( \ - test -f "test-runner.sh" && rm "test-runner.sh" && \ - echo "removed test-runner.sh"\ - ) || exit 0 - -update_test-runner.sh: remove_test-runner.sh test-runner.sh - -test/assert.sh: - echo "fetching test/assert.sh..." && \ - curl -s -L -o test/assert.sh \ - https://raw.github.com/lehmannro/assert.sh/v1.0.2/assert.sh - -remove_test/assert.sh: - test -f "test/assert.sh" && \ - rm "test/assert.sh" && \ - echo "removed test/assert.sh" - -update_test/assert.sh: remove_test/assert.sh test/assert.sh - -test/stub.sh: - echo "fetching test/stub.sh..." && \ - curl -s -L -o test/stub.sh \ - https://raw.github.com/jimeh/stub.sh/v1.0.1/stub.sh - -remove_test/stub.sh: - test -f "test/stub.sh" && \ - rm "test/stub.sh" && \ - echo "removed test/stub.sh" - -update_test/stub.sh: remove_test/stub.sh test/stub.sh +bootstrap: $(FETCHED_FILES) +clean: $(addprefix remove_,$(FETCHED_FILES)) +update: $(addprefix update_,$(FETCHED_FILES)) .SILENT: -.PHONY: test bootstrap clean \ - remove_test-runner.sh update_test-runner.sh \ - remove_test/assert.sh update_test/assert.sh \ - remove_test/stub.sh update_test/stub.sh +.PHONY: test bootstrap clean update \ + $(addprefix remove_,$(FETCHED_FILES)) \ + $(addprefix update_,$(FETCHED_FILES)) diff --git a/lib/layout-helpers.sh b/lib/layout-helpers.sh index 1e0a226..bb26ad0 100644 --- a/lib/layout-helpers.sh +++ b/lib/layout-helpers.sh @@ -300,7 +300,7 @@ initialize_session() { # Tmux 1.8 and earlier. if [ "$(tmuxifier-tmux-version "1.9")" == "<" ]; then # Create the new session. - env TMUX="" tmuxifier-tmux new-session -d -s "$session" + TMUX="" tmuxifier-tmux new-session -d -s "$session" # Set default-path for session if [ -n "$session_root" ] && [ -d "$session_root" ]; then @@ -317,7 +317,7 @@ initialize_session() { local session_args=(-c "$session_root") fi - env TMUX="" tmuxifier-tmux new-session \ + TMUX="" tmuxifier-tmux new-session \ -d -s "$session" "${session_args[@]}" fi @@ -362,7 +362,7 @@ finalize_and_go_to_session() { # /Users/jimeh/Projects # __expand_path() { - echo $(eval echo "$@") + eval echo "$*" } __get_first_window_index() { diff --git a/lib/util.sh b/lib/util.sh index 02e4a2e..efe2ad5 100644 --- a/lib/util.sh +++ b/lib/util.sh @@ -1,11 +1,79 @@ +# Check if --help or -h flag is present in arguments. +# +# Usage: +# calling-help "$@" && { show_help; exit 0; } +# +# Arguments: +# $@ - Command-line arguments to check +# +# Returns: +# 0 - If --help or -h is present as a standalone argument +# 1 - Otherwise calling-help() { if [[ " $* " != *" --help "* ]] && [[ " $* " != *" -h "* ]]; then return 1 fi } +# Check if --complete flag is present in arguments. +# +# Used to detect when shell completion is requesting completions. +# +# Usage: +# calling-complete "$@" && { generate_completions; exit 0; } +# +# Arguments: +# $@ - Command-line arguments to check +# +# Returns: +# 0 - If --complete is present as a standalone argument +# 1 - Otherwise calling-complete() { if [[ " $* " != *" --complete "* ]]; then return 1 fi } + +# Compare two dot-separated version strings. +# +# Based on: http://stackoverflow.com/a/4025065/42146 +# +# Usage: +# vercomp "1.9.0" "1.10.0" +# case $? in +# 0) echo "equal" ;; +# 1) echo "first is greater" ;; +# 2) echo "second is greater" ;; +# esac +# +# Arguments: +# $1 - First version string (e.g., "1.2.3") +# $2 - Second version string (e.g., "1.2.4") +# +# Returns: +# 0 - Versions are equal +# 1 - First version is greater than second +# 2 - First version is less than second +vercomp() { + if [[ "$1" == "$2" ]]; then return 0; fi + + local IFS=. i + local -a ver1 ver2 + read -ra ver1 <<< "$1" + read -ra ver2 <<< "$2" + + # Fill empty fields in ver1 with zeros + for ((i = ${#ver1[@]}; i < ${#ver2[@]}; i++)); do ver1[i]=0; done + + for ((i = 0; i < ${#ver1[@]}; i++)); do + # Fill empty fields in ver2 with zeros + if [[ -z ${ver2[i]} ]]; then ver2[i]=0; fi + + if ((10#${ver1[i]} > 10#${ver2[i]})); then + return 1 + elif ((10#${ver1[i]} < 10#${ver2[i]})); then + return 2 + fi + done + return 0 +} diff --git a/libexec/tmuxifier-tmux-version b/libexec/tmuxifier-tmux-version index 8dedff5..8e5b059 100755 --- a/libexec/tmuxifier-tmux-version +++ b/libexec/tmuxifier-tmux-version @@ -17,30 +17,6 @@ The three possible outputs are \"=\", \"<\", and \">\"." exit fi -# The vercomp() function is shamelessly ripped/borrowed from the following -# StackOverflow answer: http://stackoverflow.com/a/4025065/42146 -vercomp() { - if [[ $1 == $2 ]]; then return 0; fi - - local IFS=. - local i ver1=($1) ver2=($2) - - # fill empty fields in ver1 with zeros - for ((i = ${#ver1[@]}; i < ${#ver2[@]}; i++)); do ver1[i]=0; done - - for ((i = 0; i < ${#ver1[@]}; i++)); do - # fill empty fields in ver2 with zeros - if [[ -z ${ver2[i]} ]]; then ver2[i]=0; fi - - if ((10#${ver1[i]} > 10#${ver2[i]})); then - return 1 - elif ((10#${ver1[i]} < 10#${ver2[i]})); then - return 2 - fi - done - return 0 -} - version=$(tmux -V) version=${version/tmux /} diff --git a/test/lib/env.test.sh b/test-legacy/lib/env.test.sh similarity index 100% rename from test/lib/env.test.sh rename to test-legacy/lib/env.test.sh diff --git a/test/lib/layout-helpers/__expand_path.test.sh b/test-legacy/lib/layout-helpers/__expand_path.test.sh similarity index 100% rename from test/lib/layout-helpers/__expand_path.test.sh rename to test-legacy/lib/layout-helpers/__expand_path.test.sh diff --git a/test/lib/layout-helpers/__get_current_window_index.test.sh b/test-legacy/lib/layout-helpers/__get_current_window_index.test.sh similarity index 100% rename from test/lib/layout-helpers/__get_current_window_index.test.sh rename to test-legacy/lib/layout-helpers/__get_current_window_index.test.sh diff --git a/test/lib/layout-helpers/__get_first_window_index.test.sh b/test-legacy/lib/layout-helpers/__get_first_window_index.test.sh similarity index 100% rename from test/lib/layout-helpers/__get_first_window_index.test.sh rename to test-legacy/lib/layout-helpers/__get_first_window_index.test.sh diff --git a/test/lib/layout-helpers/__go_to_session.test.sh b/test-legacy/lib/layout-helpers/__go_to_session.test.sh similarity index 100% rename from test/lib/layout-helpers/__go_to_session.test.sh rename to test-legacy/lib/layout-helpers/__go_to_session.test.sh diff --git a/test/lib/layout-helpers/__go_to_window_or_session_path.test.sh b/test-legacy/lib/layout-helpers/__go_to_window_or_session_path.test.sh similarity index 100% rename from test/lib/layout-helpers/__go_to_window_or_session_path.test.sh rename to test-legacy/lib/layout-helpers/__go_to_window_or_session_path.test.sh diff --git a/test/lib/layout-helpers/new_window.test.sh b/test-legacy/lib/layout-helpers/new_window.test.sh similarity index 100% rename from test/lib/layout-helpers/new_window.test.sh rename to test-legacy/lib/layout-helpers/new_window.test.sh diff --git a/test/lib/layout-helpers/select_window.test.sh b/test-legacy/lib/layout-helpers/select_window.test.sh similarity index 100% rename from test/lib/layout-helpers/select_window.test.sh rename to test-legacy/lib/layout-helpers/select_window.test.sh diff --git a/test-legacy/lib/layout-helpers/split_h.test.sh b/test-legacy/lib/layout-helpers/split_h.test.sh new file mode 100755 index 0000000..a147783 --- /dev/null +++ b/test-legacy/lib/layout-helpers/split_h.test.sh @@ -0,0 +1,70 @@ +#! /usr/bin/env bash +source "../../test-helper.sh" +source "${root}/lib/layout-helpers.sh" + +# +# split_h() tests. +# + +# When called without arguments, calls tmuxifier-tmux split-window with -h flag. +session="test-session" +window="0" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_h +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t test-session:0. -h" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# When called with percentage argument, includes -p flag. +session="test-session" +window="0" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_h 30 +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t test-session:0. -h -p 30" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# When called with percentage and target pane, targets that pane. +session="mysession" +window="2" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_h 50 1 +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t mysession:2.1 -h -p 50" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# When called with only target pane (empty percentage), targets that pane. +session="test" +window="1" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_h "" 2 +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t test:1.2 -h" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# Integration: actually splits pane in tmux session. +create-test-session +window="0" +stub __go_to_window_or_session_path +assert "test-socket-pane-count" "1" +split_h +assert "test-socket-pane-count" "2" +split_h 30 +assert "test-socket-pane-count" "3" +restore __go_to_window_or_session_path +kill-test-session + +# End of tests. +assert_end "split_h()" diff --git a/test-legacy/lib/layout-helpers/split_hl.test.sh b/test-legacy/lib/layout-helpers/split_hl.test.sh new file mode 100755 index 0000000..532149e --- /dev/null +++ b/test-legacy/lib/layout-helpers/split_hl.test.sh @@ -0,0 +1,70 @@ +#! /usr/bin/env bash +source "../../test-helper.sh" +source "${root}/lib/layout-helpers.sh" + +# +# split_hl() tests. +# + +# When called without arguments, calls tmuxifier-tmux split-window with -h flag. +session="test-session" +window="0" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_hl +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t test-session:0. -h" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# When called with column count argument, includes -l flag. +session="test-session" +window="0" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_hl 20 +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t test-session:0. -h -l 20" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# When called with column count and target pane, targets that pane. +session="mysession" +window="2" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_hl 25 1 +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t mysession:2.1 -h -l 25" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# When called with only target pane (empty column count), targets that pane. +session="test" +window="1" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_hl "" 2 +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t test:1.2 -h" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# Integration: actually splits pane in tmux session. +create-test-session +window="0" +stub __go_to_window_or_session_path +assert "test-socket-pane-count" "1" +split_hl +assert "test-socket-pane-count" "2" +split_hl 10 +assert "test-socket-pane-count" "3" +restore __go_to_window_or_session_path +kill-test-session + +# End of tests. +assert_end "split_hl()" diff --git a/test-legacy/lib/layout-helpers/split_v.test.sh b/test-legacy/lib/layout-helpers/split_v.test.sh new file mode 100755 index 0000000..c8ddeb2 --- /dev/null +++ b/test-legacy/lib/layout-helpers/split_v.test.sh @@ -0,0 +1,70 @@ +#! /usr/bin/env bash +source "../../test-helper.sh" +source "${root}/lib/layout-helpers.sh" + +# +# split_v() tests. +# + +# When called without arguments, calls tmuxifier-tmux split-window with -v flag. +session="test-session" +window="0" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_v +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t test-session:0. -v" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# When called with percentage argument, includes -p flag. +session="test-session" +window="0" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_v 30 +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t test-session:0. -v -p 30" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# When called with percentage and target pane, targets that pane. +session="mysession" +window="2" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_v 50 1 +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t mysession:2.1 -v -p 50" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# When called with only target pane (empty percentage), targets that pane. +session="test" +window="1" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_v "" 2 +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t test:1.2 -v" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# Integration: actually splits pane in tmux session. +create-test-session +window="0" +stub __go_to_window_or_session_path +assert "test-socket-pane-count" "1" +split_v +assert "test-socket-pane-count" "2" +split_v 30 +assert "test-socket-pane-count" "3" +restore __go_to_window_or_session_path +kill-test-session + +# End of tests. +assert_end "split_v()" diff --git a/test-legacy/lib/layout-helpers/split_vl.test.sh b/test-legacy/lib/layout-helpers/split_vl.test.sh new file mode 100755 index 0000000..bfced67 --- /dev/null +++ b/test-legacy/lib/layout-helpers/split_vl.test.sh @@ -0,0 +1,70 @@ +#! /usr/bin/env bash +source "../../test-helper.sh" +source "${root}/lib/layout-helpers.sh" + +# +# split_vl() tests. +# + +# When called without arguments, calls tmuxifier-tmux split-window with -v flag. +session="test-session" +window="0" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_vl +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t test-session:0. -v" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# When called with line count argument, includes -l flag. +session="test-session" +window="0" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_vl 10 +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t test-session:0. -v -l 10" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# When called with line count and target pane, targets that pane. +session="mysession" +window="2" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_vl 15 1 +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t mysession:2.1 -v -l 15" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# When called with only target pane (empty line count), targets that pane. +session="test" +window="1" +stub tmuxifier-tmux +stub __go_to_window_or_session_path +split_vl "" 2 +assert_raises \ + "stub_called_with tmuxifier-tmux split-window -t test:1.2 -v" 0 +assert "stub_called_times __go_to_window_or_session_path" "1" +restore __go_to_window_or_session_path +restore tmuxifier-tmux + +# Integration: actually splits pane in tmux session. +create-test-session +window="0" +stub __go_to_window_or_session_path +assert "test-socket-pane-count" "1" +split_vl +assert "test-socket-pane-count" "2" +split_vl 5 +assert "test-socket-pane-count" "3" +restore __go_to_window_or_session_path +kill-test-session + +# End of tests. +assert_end "split_vl()" diff --git a/test/lib/layout-helpers/tmux.test.sh b/test-legacy/lib/layout-helpers/tmux.test.sh similarity index 100% rename from test/lib/layout-helpers/tmux.test.sh rename to test-legacy/lib/layout-helpers/tmux.test.sh diff --git a/test/lib/runtime.test.sh b/test-legacy/lib/runtime.test.sh similarity index 100% rename from test/lib/runtime.test.sh rename to test-legacy/lib/runtime.test.sh diff --git a/test/lib/util.test.sh b/test-legacy/lib/util.test.sh similarity index 100% rename from test/lib/util.test.sh rename to test-legacy/lib/util.test.sh diff --git a/test/libexec/tmuxifier-tmux.test.sh b/test-legacy/libexec/tmuxifier-tmux.test.sh similarity index 75% rename from test/libexec/tmuxifier-tmux.test.sh rename to test-legacy/libexec/tmuxifier-tmux.test.sh index 9716360..881888a 100755 --- a/test/libexec/tmuxifier-tmux.test.sh +++ b/test-legacy/libexec/tmuxifier-tmux.test.sh @@ -7,12 +7,12 @@ source "${root}/lib/util.sh" # # Setup. -libexec="${root}/libexec" test-socket-tmux new-session -d -s foobar test-socket-tmux new-session -d -s dude +baseCommand="${root}/bin/tmuxifier tmux" # Passes all arguments to Tmux. -assert "${libexec}/tmuxifier-tmux list-sessions -F \"- #{session_name}\"" \ +assert "${baseCommand} list-sessions -F \"- #{session_name}\"" \ "- dude\n- foobar" # Tear down. diff --git a/test/test-helper.sh b/test-legacy/test-helper.sh similarity index 90% rename from test/test-helper.sh rename to test-legacy/test-helper.sh index ed081be..ecf0ef4 100644 --- a/test/test-helper.sh +++ b/test-legacy/test-helper.sh @@ -47,14 +47,13 @@ unset TMUXIFIER_NO_COMPLETE source "${testroot}/assert.sh" source "${testroot}/stub.sh" - # # Test Helpers # test-socket-tmux() { export TMUXIFIER_TMUX_OPTS="-L tmuxifier-tests" - "$TMUX_BIN" $TMUXIFIER_TMUX_OPTS $@ + "$TMUX_BIN" $TMUXIFIER_TMUX_OPTS "$@" } create-test-session() { @@ -85,3 +84,8 @@ test-socket-window-count() { echo "$list" | wc -l | awk '{print $1}' fi } + +test-socket-pane-count() { + local list="$(test-socket-tmux list-panes -t "$session:")" + echo "$list" | wc -l | awk '{print $1}' +} diff --git a/tests/bashunit b/tests/bashunit new file mode 100755 index 0000000..96fb08b --- /dev/null +++ b/tests/bashunit @@ -0,0 +1,4501 @@ +#!/usr/bin/env bash +# check_os.sh + +# shellcheck disable=SC2034 +_OS="Unknown" +_DISTRO="Unknown" + +function check_os::init() { + if check_os::is_linux; then + _OS="Linux" + if check_os::is_ubuntu; then + _DISTRO="Ubuntu" + elif check_os::is_alpine; then + _DISTRO="Alpine" + elif check_os::is_nixos; then + _DISTRO="NixOS" + else + _DISTRO="Other" + fi + elif check_os::is_macos; then + _OS="OSX" + elif check_os::is_windows; then + _OS="Windows" + else + _OS="Unknown" + _DISTRO="Unknown" + fi +} + +function check_os::is_ubuntu() { + command -v apt > /dev/null +} + +function check_os::is_alpine() { + command -v apk > /dev/null +} + +function check_os::is_nixos() { + [[ -f /etc/NIXOS ]] && return 0 + grep -q '^ID=nixos' /etc/os-release 2>/dev/null +} + +function check_os::is_linux() { + [[ "$(uname)" == "Linux" ]] +} + +function check_os::is_macos() { + [[ "$(uname)" == "Darwin" ]] +} + +function check_os::is_windows() { + case "$(uname)" in + *MINGW*|*MSYS*|*CYGWIN*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +function check_os::is_busybox() { + + case "$_DISTRO" in + + "Alpine") + return 0 + ;; + *) + return 1 + ;; + esac +} + +check_os::init + +export _OS +export _DISTRO +export -f check_os::is_alpine +export -f check_os::is_busybox +export -f check_os::is_ubuntu +export -f check_os::is_nixos + +# str.sh + +function str::rpad() { + local left_text="$1" + local right_word="$2" + local width_padding="${3:-$TERMINAL_WIDTH}" + # Subtract 1 more to account for the extra space + local padding=$((width_padding - ${#right_word} - 1)) + if (( padding < 0 )); then + padding=0 + fi + + # Remove ANSI escape sequences (non-visible characters) for length calculation + # shellcheck disable=SC2155 + local clean_left_text=$(echo -e "$left_text" | sed 's/\x1b\[[0-9;]*m//g') + + local is_truncated=false + # If the visible left text exceeds the padding, truncate it and add "..." + if [[ ${#clean_left_text} -gt $padding ]]; then + local truncation_length=$((padding < 3 ? 0 : padding - 3)) + clean_left_text="${clean_left_text:0:$truncation_length}" + is_truncated=true + fi + + # Rebuild the text with ANSI codes intact, preserving the truncation + local result_left_text="" + local i=0 + local j=0 + while [[ $i -lt ${#clean_left_text} && $j -lt ${#left_text} ]]; do + local char="${clean_left_text:$i:1}" + local original_char="${left_text:$j:1}" + + # If the current character is part of an ANSI sequence, skip it and copy it + if [[ "$original_char" == $'\x1b' ]]; then + while [[ "${left_text:$j:1}" != "m" && $j -lt ${#left_text} ]]; do + result_left_text+="${left_text:$j:1}" + ((j++)) + done + result_left_text+="${left_text:$j:1}" # Append the final 'm' + ((j++)) + elif [[ "$char" == "$original_char" ]]; then + # Match the actual character + result_left_text+="$char" + ((i++)) + ((j++)) + else + ((j++)) + fi + done + + local remaining_space + if $is_truncated ; then + result_left_text+="..." + # 1: due to a blank space + # 3: due to the appended ... + remaining_space=$((width_padding - ${#clean_left_text} - ${#right_word} - 1 - 3)) + else + # Copy any remaining characters after the truncation point + result_left_text+="${left_text:$j}" + remaining_space=$((width_padding - ${#clean_left_text} - ${#right_word} - 1)) + fi + + # Ensure the right word is placed exactly at the far right of the screen + # filling the remaining space with padding + if [[ $remaining_space -lt 0 ]]; then + remaining_space=0 + fi + + printf "%s%${remaining_space}s %s\n" "$result_left_text" "" "$right_word" +} + +# globals.sh +set -euo pipefail + +# This file provides a set of global functions to developers. + +function current_dir() { + dirname "${BASH_SOURCE[1]}" +} + +function current_filename() { + basename "${BASH_SOURCE[1]}" +} + +function caller_filename() { + dirname "${BASH_SOURCE[2]}" +} + +function caller_line() { + echo "${BASH_LINENO[1]}" +} + +function current_timestamp() { + date +"%Y-%m-%d %H:%M:%S" +} + +function is_command_available() { + command -v "$1" >/dev/null 2>&1 +} + +function random_str() { + local length=${1:-6} + local chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + local str='' + for (( i=0; i> "$BASHUNIT_DEV_LOG" +} + +function internal_log() { + if ! env::is_dev_mode_enabled || ! env::is_internal_log_enabled; then + return + fi + + echo "$(current_timestamp) [INTERNAL]: $* #${BASH_SOURCE[1]}:${BASH_LINENO[0]}" >> "$BASHUNIT_DEV_LOG" +} + +function print_line() { + local length="${1:-70}" # Default to 70 if not passed + local char="${2:--}" # Default to '-' if not passed + printf '%*s\n' "$length" '' | tr ' ' "$char" +} + +function data_set() { + local arg + local first=true + + for arg in "$@"; do + if [ "$first" = true ]; then + printf '%q' "$arg" + first=false + else + printf ' %q' "$arg" + fi + done + printf ' %q\n' "" +} + +# dependencies.sh +set -euo pipefail + +function dependencies::has_perl() { + command -v perl >/dev/null 2>&1 +} + +function dependencies::has_powershell() { + command -v powershell > /dev/null 2>&1 +} + +function dependencies::has_adjtimex() { + command -v adjtimex >/dev/null 2>&1 +} + +function dependencies::has_bc() { + command -v bc >/dev/null 2>&1 +} + +function dependencies::has_awk() { + command -v awk >/dev/null 2>&1 +} + +function dependencies::has_git() { + command -v git >/dev/null 2>&1 +} + +function dependencies::has_curl() { + command -v curl >/dev/null 2>&1 +} + +function dependencies::has_wget() { + command -v wget >/dev/null 2>&1 +} + +function dependencies::has_python() { + command -v python >/dev/null 2>&1 +} + +function dependencies::has_node() { + command -v node >/dev/null 2>&1 +} + +# io.sh + +function io::download_to() { + local url="$1" + local output="$2" + if dependencies::has_curl; then + curl -L -J -o "$output" "$url" 2>/dev/null + elif dependencies::has_wget; then + wget -q -O "$output" "$url" 2>/dev/null + else + return 1 + fi +} + +# math.sh + +function math::calculate() { + local expr="$*" + + if dependencies::has_bc; then + echo "$expr" | bc + return + fi + + if [[ "$expr" == *.* ]]; then + if dependencies::has_awk; then + awk "BEGIN { print ($expr) }" + return + fi + # Downgrade to integer math by stripping decimals + expr=$(echo "$expr" | sed -E 's/([0-9]+)\.[0-9]+/\1/g') + fi + + # Remove leading zeros from integers + expr=$(echo "$expr" | sed -E 's/\b0*([1-9][0-9]*)/\1/g') + + local result=$(( expr )) + echo "$result" +} + +# parallel.sh + +function parallel::aggregate_test_results() { + local temp_dir_parallel_test_suite=$1 + + internal_log "aggregate_test_results" "dir:$temp_dir_parallel_test_suite" + + local total_failed=0 + local total_passed=0 + local total_skipped=0 + local total_incomplete=0 + local total_snapshot=0 + + for script_dir in "$temp_dir_parallel_test_suite"/*; do + shopt -s nullglob + local result_files=("$script_dir"/*.result) + shopt -u nullglob + + if [ ${#result_files[@]} -eq 0 ]; then + printf "%sNo tests found%s" "$_COLOR_SKIPPED" "$_COLOR_DEFAULT" + continue + fi + + for result_file in "${result_files[@]}"; do + local result_line + result_line=$(tail -n 1 "$result_file") + + local failed="${result_line##*##ASSERTIONS_FAILED=}" + failed="${failed%%##*}"; failed=${failed:-0} + + local passed="${result_line##*##ASSERTIONS_PASSED=}" + passed="${passed%%##*}"; passed=${passed:-0} + + local skipped="${result_line##*##ASSERTIONS_SKIPPED=}" + skipped="${skipped%%##*}"; skipped=${skipped:-0} + + local incomplete="${result_line##*##ASSERTIONS_INCOMPLETE=}" + incomplete="${incomplete%%##*}"; incomplete=${incomplete:-0} + + local snapshot="${result_line##*##ASSERTIONS_SNAPSHOT=}" + snapshot="${snapshot%%##*}"; snapshot=${snapshot:-0} + + local exit_code="${result_line##*##TEST_EXIT_CODE=}" + exit_code="${exit_code%%##*}"; exit_code=${exit_code:-0} + + # Add to the total counts + total_failed=$((total_failed + failed)) + total_passed=$((total_passed + passed)) + total_skipped=$((total_skipped + skipped)) + total_incomplete=$((total_incomplete + incomplete)) + total_snapshot=$((total_snapshot + snapshot)) + + if [ "${failed:-0}" -gt 0 ]; then + state::add_tests_failed + continue + fi + + if [ "${exit_code:-0}" -ne 0 ]; then + state::add_tests_failed + continue + fi + + if [ "${snapshot:-0}" -gt 0 ]; then + state::add_tests_snapshot + continue + fi + + if [ "${incomplete:-0}" -gt 0 ]; then + state::add_tests_incomplete + continue + fi + + if [ "${skipped:-0}" -gt 0 ]; then + state::add_tests_skipped + continue + fi + + state::add_tests_passed + done + done + + export _ASSERTIONS_FAILED=$total_failed + export _ASSERTIONS_PASSED=$total_passed + export _ASSERTIONS_SKIPPED=$total_skipped + export _ASSERTIONS_INCOMPLETE=$total_incomplete + export _ASSERTIONS_SNAPSHOT=$total_snapshot + + internal_log "aggregate_totals" \ + "failed:$total_failed" \ + "passed:$total_passed" \ + "skipped:$total_skipped" \ + "incomplete:$total_incomplete" \ + "snapshot:$total_snapshot" +} + +function parallel::mark_stop_on_failure() { + touch "$TEMP_FILE_PARALLEL_STOP_ON_FAILURE" +} + +function parallel::must_stop_on_failure() { + [[ -f "$TEMP_FILE_PARALLEL_STOP_ON_FAILURE" ]] +} + +function parallel::cleanup() { + # shellcheck disable=SC2153 + rm -rf "$TEMP_DIR_PARALLEL_TEST_SUITE" +} + +function parallel::init() { + parallel::cleanup + mkdir -p "$TEMP_DIR_PARALLEL_TEST_SUITE" +} + +function parallel::is_enabled() { + internal_log "parallel::is_enabled" "requested:$BASHUNIT_PARALLEL_RUN" "os:${_OS:-Unknown}" + + if env::is_parallel_run_enabled && \ + (check_os::is_macos || check_os::is_ubuntu || check_os::is_windows); then + return 0 + fi + return 1 +} + +# env.sh + +# shellcheck disable=SC2034 + +set -o allexport +# shellcheck source=/dev/null +[[ -f ".env" ]] && source .env +set +o allexport + +_DEFAULT_DEFAULT_PATH="tests" +_DEFAULT_BOOTSTRAP="tests/bootstrap.sh" +_DEFAULT_DEV_LOG="" +_DEFAULT_LOG_JUNIT="" +_DEFAULT_REPORT_HTML="" + +: "${BASHUNIT_DEFAULT_PATH:=${DEFAULT_PATH:=$_DEFAULT_DEFAULT_PATH}}" +: "${BASHUNIT_DEV_LOG:=${DEV_LOG:=$_DEFAULT_DEV_LOG}}" +: "${BASHUNIT_BOOTSTRAP:=${BOOTSTRAP:=$_DEFAULT_BOOTSTRAP}}" +: "${BASHUNIT_LOG_JUNIT:=${LOG_JUNIT:=$_DEFAULT_LOG_JUNIT}}" +: "${BASHUNIT_REPORT_HTML:=${REPORT_HTML:=$_DEFAULT_REPORT_HTML}}" + +# Booleans +_DEFAULT_PARALLEL_RUN="false" +_DEFAULT_SHOW_HEADER="true" +_DEFAULT_HEADER_ASCII_ART="false" +_DEFAULT_SIMPLE_OUTPUT="false" +_DEFAULT_STOP_ON_FAILURE="false" +_DEFAULT_SHOW_EXECUTION_TIME="true" +_DEFAULT_VERBOSE="false" +_DEFAULT_BENCH_MODE="false" +_DEFAULT_NO_OUTPUT="false" +_DEFAULT_INTERNAL_LOG="false" + +: "${BASHUNIT_PARALLEL_RUN:=${PARALLEL_RUN:=$_DEFAULT_PARALLEL_RUN}}" +: "${BASHUNIT_SHOW_HEADER:=${SHOW_HEADER:=$_DEFAULT_SHOW_HEADER}}" +: "${BASHUNIT_HEADER_ASCII_ART:=${HEADER_ASCII_ART:=$_DEFAULT_HEADER_ASCII_ART}}" +: "${BASHUNIT_SIMPLE_OUTPUT:=${SIMPLE_OUTPUT:=$_DEFAULT_SIMPLE_OUTPUT}}" +: "${BASHUNIT_STOP_ON_FAILURE:=${STOP_ON_FAILURE:=$_DEFAULT_STOP_ON_FAILURE}}" +: "${BASHUNIT_SHOW_EXECUTION_TIME:=${SHOW_EXECUTION_TIME:=$_DEFAULT_SHOW_EXECUTION_TIME}}" +: "${BASHUNIT_VERBOSE:=${VERBOSE:=$_DEFAULT_VERBOSE}}" +: "${BASHUNIT_BENCH_MODE:=${BENCH_MODE:=$_DEFAULT_BENCH_MODE}}" +: "${BASHUNIT_NO_OUTPUT:=${NO_OUTPUT:=$_DEFAULT_NO_OUTPUT}}" +: "${BASHUNIT_INTERNAL_LOG:=${INTERNAL_LOG:=$_DEFAULT_INTERNAL_LOG}}" + +function env::is_parallel_run_enabled() { + [[ "$BASHUNIT_PARALLEL_RUN" == "true" ]] +} + +function env::is_show_header_enabled() { + [[ "$BASHUNIT_SHOW_HEADER" == "true" ]] +} + +function env::is_header_ascii_art_enabled() { + [[ "$BASHUNIT_HEADER_ASCII_ART" == "true" ]] +} + +function env::is_simple_output_enabled() { + [[ "$BASHUNIT_SIMPLE_OUTPUT" == "true" ]] +} + +function env::is_stop_on_failure_enabled() { + [[ "$BASHUNIT_STOP_ON_FAILURE" == "true" ]] +} + +function env::is_show_execution_time_enabled() { + [[ "$BASHUNIT_SHOW_EXECUTION_TIME" == "true" ]] +} + +function env::is_dev_mode_enabled() { + [[ -n "$BASHUNIT_DEV_LOG" ]] +} + +function env::is_internal_log_enabled() { + [[ "$BASHUNIT_INTERNAL_LOG" == "true" ]] +} + +function env::is_verbose_enabled() { + [[ "$BASHUNIT_VERBOSE" == "true" ]] +} + +function env::is_bench_mode_enabled() { + [[ "$BASHUNIT_BENCH_MODE" == "true" ]] +} + +function env::is_no_output_enabled() { + [[ "$BASHUNIT_NO_OUTPUT" == "true" ]] +} + +function env::active_internet_connection() { + if [[ "${BASHUNIT_NO_NETWORK:-}" == "true" ]]; then + return 1 + fi + + if command -v curl >/dev/null 2>&1; then + curl -sfI https://github.com >/dev/null 2>&1 && return 0 + elif command -v wget >/dev/null 2>&1; then + wget -q --spider https://github.com && return 0 + fi + + if ping -c 1 -W 3 google.com &> /dev/null; then + return 0 + fi + + return 1 +} + +function env::find_terminal_width() { + local cols="" + + if [[ -z "$cols" ]] && command -v tput > /dev/null; then + cols=$(tput cols 2>/dev/null) + fi + + if [[ -z "$cols" ]] && command -v stty > /dev/null; then + cols=$(stty size 2>/dev/null | cut -d' ' -f2) + fi + + # Directly echo the value with fallback + echo "${cols:-100}" +} + +function env::print_verbose() { + internal_log "Printing verbose environment variables" + local keys=( + "BASHUNIT_DEFAULT_PATH" + "BASHUNIT_DEV_LOG" + "BASHUNIT_BOOTSTRAP" + "BASHUNIT_LOG_JUNIT" + "BASHUNIT_REPORT_HTML" + "BASHUNIT_PARALLEL_RUN" + "BASHUNIT_SHOW_HEADER" + "BASHUNIT_HEADER_ASCII_ART" + "BASHUNIT_SIMPLE_OUTPUT" + "BASHUNIT_STOP_ON_FAILURE" + "BASHUNIT_SHOW_EXECUTION_TIME" + "BASHUNIT_VERBOSE" + ) + + local max_length=0 + + for key in "${keys[@]}"; do + if (( ${#key} > max_length )); then + max_length=${#key} + fi + done + + for key in "${keys[@]}"; do + internal_log "$key=${!key}" + printf "%s:%*s%s\n" "$key" $((max_length - ${#key} + 1)) "" "${!key}" + done +} + +EXIT_CODE_STOP_ON_FAILURE=4 +# Use a unique directory per run to avoid conflicts when bashunit is invoked +# recursively or multiple instances are executed in parallel. +TEMP_DIR_PARALLEL_TEST_SUITE="${TMPDIR:-/tmp}/bashunit/parallel/${_OS:-Unknown}/$(random_str 8)" +TEMP_FILE_PARALLEL_STOP_ON_FAILURE="$TEMP_DIR_PARALLEL_TEST_SUITE/.stop-on-failure" +TERMINAL_WIDTH="$(env::find_terminal_width)" +FAILURES_OUTPUT_PATH=$(mktemp) +CAT="$(command -v cat)" + +if env::is_dev_mode_enabled; then + internal_log "info" "Dev log enabled" "file:$BASHUNIT_DEV_LOG" +fi + +# clock.sh + +_CLOCK_NOW_IMPL="" + +function clock::_choose_impl() { + local shell_time + local attempts=() + + # 1. Try Perl with Time::HiRes + attempts+=("Perl") + if dependencies::has_perl && perl -MTime::HiRes -e "" &>/dev/null; then + _CLOCK_NOW_IMPL="perl" + return 0 + fi + + # 2. Try Python 3 with time module + attempts+=("Python") + if dependencies::has_python; then + _CLOCK_NOW_IMPL="python" + return 0 + fi + + # 3. Try Node.js + attempts+=("Node") + if dependencies::has_node; then + _CLOCK_NOW_IMPL="node" + return 0 + fi + # 4. Windows fallback with PowerShell + attempts+=("PowerShell") + if check_os::is_windows && dependencies::has_powershell; then + _CLOCK_NOW_IMPL="powershell" + return 0 + fi + + # 5. Unix fallback using `date +%s%N` (if not macOS or Alpine) + attempts+=("date") + if ! check_os::is_macos && ! check_os::is_alpine; then + local result + result=$(date +%s%N 2>/dev/null) + if [[ "$result" != *N && "$result" =~ ^[0-9]+$ ]]; then + _CLOCK_NOW_IMPL="date" + return 0 + fi + fi + + # 6. Try using native shell EPOCHREALTIME (if available) + attempts+=("EPOCHREALTIME") + if shell_time="$(clock::shell_time)"; then + _CLOCK_NOW_IMPL="shell" + return 0 + fi + + # 7. Very last fallback: seconds resolution only + attempts[${#attempts[@]}]="date-seconds" + if date +%s &>/dev/null; then + _CLOCK_NOW_IMPL="date-seconds" + return 0 + fi + + # 8. All methods failed + printf "clock::now implementations tried: %s\n" "${attempts[*]}" >&2 + echo "" + return 1 +} + +function clock::now() { + if [[ -z "$_CLOCK_NOW_IMPL" ]]; then + clock::_choose_impl || return 1 + fi + + case "$_CLOCK_NOW_IMPL" in + perl) + perl -MTime::HiRes -e 'printf("%.0f\n", Time::HiRes::time() * 1000000000)' + ;; + python) + python - <<'EOF' +import time, sys +sys.stdout.write(str(int(time.time() * 1000000000))) +EOF + ;; + node) + node -e 'process.stdout.write((BigInt(Date.now()) * 1000000n).toString())' + ;; + powershell) + powershell -Command "\ + \$unixEpoch = [DateTime]'1970-01-01 00:00:00';\ + \$now = [DateTime]::UtcNow;\ + \$ticksSinceEpoch = (\$now - \$unixEpoch).Ticks;\ + \$nanosecondsSinceEpoch = \$ticksSinceEpoch * 100;\ + Write-Output \$nanosecondsSinceEpoch\ + " + ;; + date) + date +%s%N + ;; + date-seconds) + local seconds + seconds=$(date +%s) + math::calculate "$seconds * 1000000000" + ;; + shell) + # shellcheck disable=SC2155 + local shell_time="$(clock::shell_time)" + local seconds="${shell_time%%.*}" + local microseconds="${shell_time#*.}" + math::calculate "($seconds * 1000000000) + ($microseconds * 1000)" + ;; + *) + clock::_choose_impl || return 1 + clock::now + ;; + esac +} + +function clock::shell_time() { + # Get time directly from the shell variable EPOCHREALTIME (Bash 5+) + [[ -n ${EPOCHREALTIME+x} && -n "$EPOCHREALTIME" ]] && LC_ALL=C echo "$EPOCHREALTIME" +} + +function clock::total_runtime_in_milliseconds() { + local end_time + end_time=$(clock::now) + if [[ -n $end_time ]]; then + math::calculate "($end_time - $_START_TIME) / 1000000" + else + echo "" + fi +} + +function clock::total_runtime_in_nanoseconds() { + local end_time + end_time=$(clock::now) + if [[ -n $end_time ]]; then + math::calculate "$end_time - $_START_TIME" + else + echo "" + fi +} + +function clock::init() { + _START_TIME=$(clock::now) +} + +# state.sh + +_TESTS_PASSED=0 +_TESTS_FAILED=0 +_TESTS_SKIPPED=0 +_TESTS_INCOMPLETE=0 +_TESTS_SNAPSHOT=0 +_ASSERTIONS_PASSED=0 +_ASSERTIONS_FAILED=0 +_ASSERTIONS_SKIPPED=0 +_ASSERTIONS_INCOMPLETE=0 +_ASSERTIONS_SNAPSHOT=0 +_DUPLICATED_FUNCTION_NAMES="" +_FILE_WITH_DUPLICATED_FUNCTION_NAMES="" +_DUPLICATED_TEST_FUNCTIONS_FOUND=false +_TEST_OUTPUT="" +_TEST_TITLE="" +_TEST_EXIT_CODE=0 +_TEST_HOOK_FAILURE="" +_TEST_HOOK_MESSAGE="" +_CURRENT_TEST_INTERPOLATED_NAME="" + +function state::get_tests_passed() { + echo "$_TESTS_PASSED" +} + +function state::add_tests_passed() { + ((_TESTS_PASSED++)) || true +} + +function state::get_tests_failed() { + echo "$_TESTS_FAILED" +} + +function state::add_tests_failed() { + ((_TESTS_FAILED++)) || true +} + +function state::get_tests_skipped() { + echo "$_TESTS_SKIPPED" +} + +function state::add_tests_skipped() { + ((_TESTS_SKIPPED++)) || true +} + +function state::get_tests_incomplete() { + echo "$_TESTS_INCOMPLETE" +} + +function state::add_tests_incomplete() { + ((_TESTS_INCOMPLETE++)) || true +} + +function state::get_tests_snapshot() { + echo "$_TESTS_SNAPSHOT" +} + +function state::add_tests_snapshot() { + ((_TESTS_SNAPSHOT++)) || true +} + +function state::get_assertions_passed() { + echo "$_ASSERTIONS_PASSED" +} + +function state::add_assertions_passed() { + ((_ASSERTIONS_PASSED++)) || true +} + +function state::get_assertions_failed() { + echo "$_ASSERTIONS_FAILED" +} + +function state::add_assertions_failed() { + ((_ASSERTIONS_FAILED++)) || true +} + +function state::get_assertions_skipped() { + echo "$_ASSERTIONS_SKIPPED" +} + +function state::add_assertions_skipped() { + ((_ASSERTIONS_SKIPPED++)) || true +} + +function state::get_assertions_incomplete() { + echo "$_ASSERTIONS_INCOMPLETE" +} + +function state::add_assertions_incomplete() { + ((_ASSERTIONS_INCOMPLETE++)) || true +} + +function state::get_assertions_snapshot() { + echo "$_ASSERTIONS_SNAPSHOT" +} + +function state::add_assertions_snapshot() { + ((_ASSERTIONS_SNAPSHOT++)) || true +} + +function state::is_duplicated_test_functions_found() { + echo "$_DUPLICATED_TEST_FUNCTIONS_FOUND" +} + +function state::set_duplicated_test_functions_found() { + _DUPLICATED_TEST_FUNCTIONS_FOUND=true +} + +function state::get_duplicated_function_names() { + echo "$_DUPLICATED_FUNCTION_NAMES" +} + +function state::set_duplicated_function_names() { + _DUPLICATED_FUNCTION_NAMES="$1" +} + +function state::get_file_with_duplicated_function_names() { + echo "$_FILE_WITH_DUPLICATED_FUNCTION_NAMES" +} + +function state::set_file_with_duplicated_function_names() { + _FILE_WITH_DUPLICATED_FUNCTION_NAMES="$1" +} + +function state::add_test_output() { + _TEST_OUTPUT+="$1" +} + +function state::get_test_exit_code() { + echo "$_TEST_EXIT_CODE" +} + +function state::set_test_exit_code() { + _TEST_EXIT_CODE="$1" +} + +function state::get_test_title() { + echo "$_TEST_TITLE" +} + +function state::set_test_title() { + _TEST_TITLE="$1" +} + +function state::reset_test_title() { + _TEST_TITLE="" +} + +function state::get_current_test_interpolated_function_name() { + echo "$_CURRENT_TEST_INTERPOLATED_NAME" +} + +function state::set_current_test_interpolated_function_name() { + _CURRENT_TEST_INTERPOLATED_NAME="$1" +} + +function state::reset_current_test_interpolated_function_name() { + _CURRENT_TEST_INTERPOLATED_NAME="" +} + +function state::get_test_hook_failure() { + echo "$_TEST_HOOK_FAILURE" +} + +function state::set_test_hook_failure() { + _TEST_HOOK_FAILURE="$1" +} + +function state::reset_test_hook_failure() { + _TEST_HOOK_FAILURE="" +} + +function state::get_test_hook_message() { + echo "$_TEST_HOOK_MESSAGE" +} + +function state::set_test_hook_message() { + _TEST_HOOK_MESSAGE="$1" +} + +function state::reset_test_hook_message() { + _TEST_HOOK_MESSAGE="" +} + +function state::set_duplicated_functions_merged() { + state::set_duplicated_test_functions_found + state::set_file_with_duplicated_function_names "$1" + state::set_duplicated_function_names "$2" +} + +function state::initialize_assertions_count() { + _ASSERTIONS_PASSED=0 + _ASSERTIONS_FAILED=0 + _ASSERTIONS_SKIPPED=0 + _ASSERTIONS_INCOMPLETE=0 + _ASSERTIONS_SNAPSHOT=0 + _TEST_OUTPUT="" + _TEST_TITLE="" + _TEST_HOOK_FAILURE="" + _TEST_HOOK_MESSAGE="" +} + +function state::export_subshell_context() { + local encoded_test_output + local encoded_test_title + + local encoded_test_hook_message + + if base64 --help 2>&1 | grep -q -- "-w"; then + # Alpine requires the -w 0 option to avoid wrapping + encoded_test_output=$(echo -n "$_TEST_OUTPUT" | base64 -w 0) + encoded_test_title=$(echo -n "$_TEST_TITLE" | base64 -w 0) + encoded_test_hook_message=$(echo -n "$_TEST_HOOK_MESSAGE" | base64 -w 0) + else + # macOS and others: default base64 without wrapping + encoded_test_output=$(echo -n "$_TEST_OUTPUT" | base64) + encoded_test_title=$(echo -n "$_TEST_TITLE" | base64) + encoded_test_hook_message=$(echo -n "$_TEST_HOOK_MESSAGE" | base64) + fi + + cat < + Run a core assert function standalone (outside test context). + + -b, --bench [file] + Run benchmark functions from file or '*.bench.sh' under + BASHUNIT_DEFAULT_PATH when no file is provided. + + --debug [file] + Enable shell debug mode. Logs to file if provided. + + -e, --env, --boot + Load a custom env/bootstrap file to override .env or define globals. + + -f, --filter + Only run tests matching the given name. + + -h, --help + Show this help message. + + --init [dir] + Generate a sample test suite in current or specified directory. + + -l, --log-junit + Write test results as JUnit XML report. + + -p, --parallel | --no-parallel + Run tests in parallel (default: enabled). Random execution order. + + -r, --report-html + Write test results as an HTML report. + + -s, --simple | --detailed + Choose console output style (default: detailed). + + -S, --stop-on-failure + Stop execution immediately on the first failing test. + + --upgrade + Upgrade bashunit to the latest version. + + -vvv, --verbose + Show internal execution details per test. + + --version + Display the current version of bashunit. + +More info: https://bashunit.typeddevs.com/command-line +EOF +} + +# console_results.sh +# shellcheck disable=SC2155 + +_TOTAL_TESTS_COUNT=0 + +function console_results::render_result() { + if [[ "$(state::is_duplicated_test_functions_found)" == true ]]; then + console_results::print_execution_time + printf "%s%s%s\n" "${_COLOR_RETURN_ERROR}" "Duplicate test functions found" "${_COLOR_DEFAULT}" + printf "File with duplicate functions: %s\n" "$(state::get_file_with_duplicated_function_names)" + printf "Duplicate functions: %s\n" "$(state::get_duplicated_function_names)" + return 1 + fi + + if env::is_simple_output_enabled; then + printf "\n\n" + fi + + local total_tests=0 + ((total_tests += $(state::get_tests_passed))) || true + ((total_tests += $(state::get_tests_skipped))) || true + ((total_tests += $(state::get_tests_incomplete))) || true + ((total_tests += $(state::get_tests_snapshot))) || true + ((total_tests += $(state::get_tests_failed))) || true + + local total_assertions=0 + ((total_assertions += $(state::get_assertions_passed))) || true + ((total_assertions += $(state::get_assertions_skipped))) || true + ((total_assertions += $(state::get_assertions_incomplete))) || true + ((total_assertions += $(state::get_assertions_snapshot))) || true + ((total_assertions += $(state::get_assertions_failed))) || true + + printf "%sTests: %s" "$_COLOR_FAINT" "$_COLOR_DEFAULT" + if [[ "$(state::get_tests_passed)" -gt 0 ]] || [[ "$(state::get_assertions_passed)" -gt 0 ]]; then + printf " %s%s passed%s," "$_COLOR_PASSED" "$(state::get_tests_passed)" "$_COLOR_DEFAULT" + fi + if [[ "$(state::get_tests_skipped)" -gt 0 ]] || [[ "$(state::get_assertions_skipped)" -gt 0 ]]; then + printf " %s%s skipped%s," "$_COLOR_SKIPPED" "$(state::get_tests_skipped)" "$_COLOR_DEFAULT" + fi + if [[ "$(state::get_tests_incomplete)" -gt 0 ]] || [[ "$(state::get_assertions_incomplete)" -gt 0 ]]; then + printf " %s%s incomplete%s," "$_COLOR_INCOMPLETE" "$(state::get_tests_incomplete)" "$_COLOR_DEFAULT" + fi + if [[ "$(state::get_tests_snapshot)" -gt 0 ]] || [[ "$(state::get_assertions_snapshot)" -gt 0 ]]; then + printf " %s%s snapshot%s," "$_COLOR_SNAPSHOT" "$(state::get_tests_snapshot)" "$_COLOR_DEFAULT" + fi + if [[ "$(state::get_tests_failed)" -gt 0 ]] || [[ "$(state::get_assertions_failed)" -gt 0 ]]; then + printf " %s%s failed%s," "$_COLOR_FAILED" "$(state::get_tests_failed)" "$_COLOR_DEFAULT" + fi + printf " %s total\n" "$total_tests" + + printf "%sAssertions:%s" "$_COLOR_FAINT" "$_COLOR_DEFAULT" + if [[ "$(state::get_tests_passed)" -gt 0 ]] || [[ "$(state::get_assertions_passed)" -gt 0 ]]; then + printf " %s%s passed%s," "$_COLOR_PASSED" "$(state::get_assertions_passed)" "$_COLOR_DEFAULT" + fi + if [[ "$(state::get_tests_skipped)" -gt 0 ]] || [[ "$(state::get_assertions_skipped)" -gt 0 ]]; then + printf " %s%s skipped%s," "$_COLOR_SKIPPED" "$(state::get_assertions_skipped)" "$_COLOR_DEFAULT" + fi + if [[ "$(state::get_tests_incomplete)" -gt 0 ]] || [[ "$(state::get_assertions_incomplete)" -gt 0 ]]; then + printf " %s%s incomplete%s," "$_COLOR_INCOMPLETE" "$(state::get_assertions_incomplete)" "$_COLOR_DEFAULT" + fi + if [[ "$(state::get_tests_snapshot)" -gt 0 ]] || [[ "$(state::get_assertions_snapshot)" -gt 0 ]]; then + printf " %s%s snapshot%s," "$_COLOR_SNAPSHOT" "$(state::get_assertions_snapshot)" "$_COLOR_DEFAULT" + fi + if [[ "$(state::get_tests_failed)" -gt 0 ]] || [[ "$(state::get_assertions_failed)" -gt 0 ]]; then + printf " %s%s failed%s," "$_COLOR_FAILED" "$(state::get_assertions_failed)" "$_COLOR_DEFAULT" + fi + printf " %s total\n" "$total_assertions" + + if [[ "$(state::get_tests_failed)" -gt 0 ]]; then + printf "\n%s%s%s\n" "$_COLOR_RETURN_ERROR" " Some tests failed " "$_COLOR_DEFAULT" + console_results::print_execution_time + return 1 + fi + + if [[ "$(state::get_tests_incomplete)" -gt 0 ]]; then + printf "\n%s%s%s\n" "$_COLOR_RETURN_INCOMPLETE" " Some tests incomplete " "$_COLOR_DEFAULT" + console_results::print_execution_time + return 0 + fi + + if [[ "$(state::get_tests_skipped)" -gt 0 ]]; then + printf "\n%s%s%s\n" "$_COLOR_RETURN_SKIPPED" " Some tests skipped " "$_COLOR_DEFAULT" + console_results::print_execution_time + return 0 + fi + + if [[ "$(state::get_tests_snapshot)" -gt 0 ]]; then + printf "\n%s%s%s\n" "$_COLOR_RETURN_SNAPSHOT" " Some snapshots created " "$_COLOR_DEFAULT" + console_results::print_execution_time + return 0 + fi + + if [[ $total_tests -eq 0 ]]; then + printf "\n%s%s%s\n" "$_COLOR_RETURN_ERROR" " No tests found " "$_COLOR_DEFAULT" + console_results::print_execution_time + return 1 + fi + + printf "\n%s%s%s\n" "$_COLOR_RETURN_SUCCESS" " All tests passed " "$_COLOR_DEFAULT" + console_results::print_execution_time + return 0 +} + +function console_results::print_execution_time() { + if ! env::is_show_execution_time_enabled; then + return + fi + + local time=$(clock::total_runtime_in_milliseconds | awk '{printf "%.0f", $1}') + + if [[ "$time" -lt 1000 ]]; then + printf "${_COLOR_BOLD}%s${_COLOR_DEFAULT}\n" \ + "Time taken: $time ms" + return + fi + + local time_in_seconds=$(( time / 1000 )) + local remainder_ms=$(( time % 1000 )) + local formatted_seconds=$(echo "$time_in_seconds.$remainder_ms" | awk '{printf "%.0f", $1}') + + printf "${_COLOR_BOLD}%s${_COLOR_DEFAULT}\n" \ + "Time taken: $formatted_seconds s" +} + +function console_results::print_successful_test() { + local test_name=$1 + shift + local duration=${1:-"0"} + shift + + local line + if [[ -z "$*" ]]; then + line=$(printf "%s✓ Passed%s: %s" "$_COLOR_PASSED" "$_COLOR_DEFAULT" "$test_name") + else + local quoted_args="" + for arg in "$@"; do + if [[ -z "$quoted_args" ]]; then + quoted_args="'$arg'" + else + quoted_args="$quoted_args, '$arg'" + fi + done + line=$(printf "%s✓ Passed%s: %s (%s)" "$_COLOR_PASSED" "$_COLOR_DEFAULT" "$test_name" "$quoted_args") + fi + + local full_line=$line + if env::is_show_execution_time_enabled; then + full_line="$(printf "%s\n" "$(str::rpad "$line" "$duration ms")")" + fi + + state::print_line "successful" "$full_line" +} + +function console_results::print_failure_message() { + local test_name=$1 + local failure_message=$2 + + local line + line="$(printf "\ +${_COLOR_FAILED}✗ Failed${_COLOR_DEFAULT}: %s + ${_COLOR_FAINT}Message:${_COLOR_DEFAULT} ${_COLOR_BOLD}'%s'${_COLOR_DEFAULT}\n"\ + "${test_name}" "${failure_message}")" + + state::print_line "failure" "$line" +} + +function console_results::print_failed_test() { + local function_name=$1 + local expected=$2 + local failure_condition_message=$3 + local actual=$4 + local extra_key=${5-} + local extra_value=${6-} + + local line + line="$(printf "\ +${_COLOR_FAILED}✗ Failed${_COLOR_DEFAULT}: %s + ${_COLOR_FAINT}Expected${_COLOR_DEFAULT} ${_COLOR_BOLD}'%s'${_COLOR_DEFAULT} + ${_COLOR_FAINT}%s${_COLOR_DEFAULT} ${_COLOR_BOLD}'%s'${_COLOR_DEFAULT}\n" \ + "${function_name}" "${expected}" "${failure_condition_message}" "${actual}")" + + if [ -n "$extra_key" ]; then + line+="$(printf "\ + + ${_COLOR_FAINT}%s${_COLOR_DEFAULT} ${_COLOR_BOLD}'%s'${_COLOR_DEFAULT}\n" \ + "${extra_key}" "${extra_value}")" + fi + + state::print_line "failed" "$line" +} + + +function console_results::print_failed_snapshot_test() { + local function_name=$1 + local snapshot_file=$2 + local actual_content=${3-} + + local line + line="$(printf "${_COLOR_FAILED}✗ Failed${_COLOR_DEFAULT}: %s + ${_COLOR_FAINT}Expected to match the snapshot${_COLOR_DEFAULT}\n" "$function_name")" + + if dependencies::has_git; then + local actual_file="${snapshot_file}.tmp" + echo "$actual_content" > "$actual_file" + + local git_diff_output + git_diff_output="$(git diff --no-index --word-diff --color=always \ + "$snapshot_file" "$actual_file" 2>/dev/null \ + | tail -n +6 | sed "s/^/ /")" + + line+="$git_diff_output" + rm "$actual_file" + fi + + state::print_line "failed_snapshot" "$line" +} + +function console_results::print_skipped_test() { + local function_name=$1 + local reason=${2-} + + local line + line="$(printf "${_COLOR_SKIPPED}↷ Skipped${_COLOR_DEFAULT}: %s\n" "${function_name}")" + + if [[ -n "$reason" ]]; then + line+="$(printf "${_COLOR_FAINT} %s${_COLOR_DEFAULT}\n" "${reason}")" + fi + + state::print_line "skipped" "$line" +} + +function console_results::print_incomplete_test() { + local function_name=$1 + local pending=${2-} + + local line + line="$(printf "${_COLOR_INCOMPLETE}✒ Incomplete${_COLOR_DEFAULT}: %s\n" "${function_name}")" + + if [[ -n "$pending" ]]; then + line+="$(printf "${_COLOR_FAINT} %s${_COLOR_DEFAULT}\n" "${pending}")" + fi + + state::print_line "incomplete" "$line" +} + +function console_results::print_snapshot_test() { + local function_name=$1 + local test_name + test_name=$(helper::normalize_test_function_name "$function_name") + + local line + line="$(printf "${_COLOR_SNAPSHOT}✎ Snapshot${_COLOR_DEFAULT}: %s\n" "${test_name}")" + + state::print_line "snapshot" "$line" +} + +function console_results::print_error_test() { + local function_name=$1 + local error="$2" + + local test_name + test_name=$(helper::normalize_test_function_name "$function_name") + + local line + line="$(printf "${_COLOR_FAILED}✗ Error${_COLOR_DEFAULT}: %s + ${_COLOR_FAINT}%s${_COLOR_DEFAULT}\n" "${test_name}" "${error}")" + + state::print_line "error" "$line" +} + +function console_results::print_failing_tests_and_reset() { + if [[ -s "$FAILURES_OUTPUT_PATH" ]]; then + local total_failed + total_failed=$(state::get_tests_failed) + + if env::is_simple_output_enabled; then + printf "\n\n" + fi + + if [[ "$total_failed" -eq 1 ]]; then + echo -e "${_COLOR_BOLD}There was 1 failure:${_COLOR_DEFAULT}\n" + else + echo -e "${_COLOR_BOLD}There were $total_failed failures:${_COLOR_DEFAULT}\n" + fi + + sed '${/^$/d;}' "$FAILURES_OUTPUT_PATH" | sed 's/^/|/' + rm "$FAILURES_OUTPUT_PATH" + + echo "" + fi +} + +# helpers.sh + +declare -r BASHUNIT_GIT_REPO="https://github.com/TypedDevs/bashunit" + +# +# @param $1 string Eg: "test_some_logic_camelCase" +# +# @return string Eg: "Some logic camelCase" +# +function helper::normalize_test_function_name() { + local original_fn_name="${1-}" + local interpolated_fn_name="${2-}" + + local custom_title + custom_title="$(state::get_test_title)" + if [[ -n "$custom_title" ]]; then + echo "$custom_title" + return + fi + + if [[ -z "${interpolated_fn_name-}" && "${original_fn_name}" == *"::"* ]]; then + local state_interpolated_fn_name + state_interpolated_fn_name="$(state::get_current_test_interpolated_function_name)" + + if [[ -n "$state_interpolated_fn_name" ]]; then + interpolated_fn_name="$state_interpolated_fn_name" + fi + fi + + if [[ -n "${interpolated_fn_name-}" ]]; then + original_fn_name="$interpolated_fn_name" + fi + + local result + + # Remove the first "test_" prefix, if present + result="${original_fn_name#test_}" + # If no "test_" was removed (e.g., "testFoo"), remove the "test" prefix + if [[ "$result" == "$original_fn_name" ]]; then + result="${original_fn_name#test}" + fi + # Replace underscores with spaces + result="${result//_/ }" + # Capitalize the first letter + result="$(tr '[:lower:]' '[:upper:]' <<< "${result:0:1}")${result:1}" + + echo "$result" +} + +function helper::escape_single_quotes() { + local value="$1" + # shellcheck disable=SC1003 + echo "${value//\'/'\'\\''\'}" +} + +function helper::interpolate_function_name() { + local function_name="$1" + shift + local args=("$@") + local result="$function_name" + + for ((i=0; i<${#args[@]}; i++)); do + local placeholder="::$((i+1))::" + # shellcheck disable=SC2155 + local value="$(helper::escape_single_quotes "${args[$i]}")" + value="'$value'" + result="${result//${placeholder}/${value}}" + done + + echo "$result" +} + +function helper::encode_base64() { + local value="$1" + + if command -v base64 >/dev/null; then + echo "$value" | base64 | tr -d '\n' + else + echo "$value" | openssl enc -base64 -A + fi +} + +function helper::decode_base64() { + local value="$1" + + if command -v base64 >/dev/null; then + echo "$value" | base64 -d + else + echo "$value" | openssl enc -d -base64 + fi +} + +function helper::check_duplicate_functions() { + local script="$1" + + local filtered_lines + filtered_lines=$(grep -E '^[[:space:]]*(function[[:space:]]+)?test[a-zA-Z_][a-zA-Z0-9_]*\s*\(\)\s*\{' "$script") + + local function_names + function_names=$(echo "$filtered_lines" | awk '{ + for (i=1; i<=NF; i++) { + if ($i ~ /^test[a-zA-Z_][a-zA-Z0-9_]*\(\)$/) { + gsub(/\(\)/, "", $i) + print $i + break + } + } + }') + + local duplicates + duplicates=$(echo "$function_names" | sort | uniq -d) + if [ -n "$duplicates" ]; then + state::set_duplicated_functions_merged "$script" "$duplicates" + return 1 + fi + return 0 +} + +# +# @param $1 string Eg: "prefix" +# @param $2 string Eg: "filter" +# @param $3 array Eg: "[fn1, fn2, prefix_filter_fn3, fn4, ...]" +# +# @return array Eg: "[prefix_filter_fn3, ...]" The filtered functions with prefix +# +function helper::get_functions_to_run() { + local prefix=$1 + local filter=${2/test_/} + local function_names=$3 + + local filtered_functions="" + + for fn in $function_names; do + if [[ $fn == ${prefix}_*${filter}* ]]; then + if [[ $filtered_functions == *" $fn"* ]]; then + return 1 + fi + filtered_functions+=" $fn" + fi + done + + echo "${filtered_functions# }" +} + +# +# @param $1 string Eg: "do_something" +# +function helper::execute_function_if_exists() { + local fn_name="$1" + + if [[ "$(type -t "$fn_name")" == "function" ]]; then + "$fn_name" + return $? + fi + + return 0 +} + +# +# @param $1 string Eg: "do_something" +# +function helper::unset_if_exists() { + unset "$1" 2>/dev/null +} + +function helper::find_files_recursive() { + ## Remove trailing slash using parameter expansion + local path="${1%%/}" + local pattern="${2:-*[tT]est.sh}" + + local alt_pattern="" + if [[ $pattern == *test.sh ]] || [[ $pattern =~ \[tT\]est\.sh$ ]]; then + alt_pattern="${pattern%.sh}.bash" + fi + + if [[ "$path" == *"*"* ]]; then + if [[ -n $alt_pattern ]]; then + eval "find $path -type f \( -name \"$pattern\" -o -name \"$alt_pattern\" \)" | sort -u + else + eval "find $path -type f -name \"$pattern\"" | sort -u + fi + elif [[ -d "$path" ]]; then + if [[ -n $alt_pattern ]]; then + find "$path" -type f \( -name "$pattern" -o -name "$alt_pattern" \) | sort -u + else + find "$path" -type f -name "$pattern" | sort -u + fi + else + echo "$path" + fi +} + +function helper::normalize_variable_name() { + local input_string="$1" + local normalized_string + + normalized_string="${input_string//[^a-zA-Z0-9_]/_}" + + if [[ ! $normalized_string =~ ^[a-zA-Z_] ]]; then + normalized_string="_$normalized_string" + fi + + echo "$normalized_string" +} + +function helper::get_provider_data() { + local function_name="$1" + local script="$2" + + if [[ ! -f "$script" ]]; then + return + fi + + local data_provider_function + data_provider_function=$( + # shellcheck disable=SC1087 + grep -B 2 -E "function[[:space:]]+$function_name[[:space:]]*\(\)" "$script" 2>/dev/null | \ + grep -E "^[[:space:]]*# *@?data_provider[[:space:]]+" | \ + sed -E 's/^[[:space:]]*# *@?data_provider[[:space:]]+//' || true + ) + + if [[ -n "$data_provider_function" ]]; then + helper::execute_function_if_exists "$data_provider_function" + fi +} + +function helper::trim() { + local input_string="$1" + local trimmed_string + + trimmed_string="${input_string#"${input_string%%[![:space:]]*}"}" + trimmed_string="${trimmed_string%"${trimmed_string##*[![:space:]]}"}" + + echo "$trimmed_string" +} + +function helper::get_latest_tag() { + if ! dependencies::has_git; then + return 1 + fi + + git ls-remote --tags "$BASHUNIT_GIT_REPO" | + awk '{print $2}' | + sed 's|^refs/tags/||' | + sort -Vr | + head -n 1 +} + +function helper::find_total_tests() { + local filter=${1:-} + local files=("${@:2}") + + if [[ ${#files[@]} -eq 0 ]]; then + echo 0 + return + fi + + local total_count=0 + local file + + for file in "${files[@]}"; do + if [[ ! -f "$file" ]]; then + continue + fi + + local file_count + file_count=$( ( + # shellcheck source=/dev/null + source "$file" + local all_fn_names + all_fn_names=$(declare -F | awk '{print $3}') + local filtered_functions + filtered_functions=$(helper::get_functions_to_run "test" "$filter" "$all_fn_names") || true + + local count=0 + if [[ -n "$filtered_functions" ]]; then + # shellcheck disable=SC2206 + # shellcheck disable=SC2207 + local functions_to_run=($filtered_functions) + for fn_name in "${functions_to_run[@]}"; do + local provider_data=() + while IFS=" " read -r line; do + provider_data+=("$line") + done <<< "$(helper::get_provider_data "$fn_name" "$file")" + + if [[ "${#provider_data[@]}" -eq 0 ]]; then + count=$((count + 1)) + else + count=$((count + ${#provider_data[@]})) + fi + done + fi + + echo "$count" + ) ) + + total_count=$((total_count + file_count)) + done + + echo "$total_count" +} + +function helper::load_test_files() { + local filter=$1 + local files=("${@:2}") + + local test_files=() + + if [[ "${#files[@]}" -eq 0 ]]; then + if [[ -n "${BASHUNIT_DEFAULT_PATH}" ]]; then + while IFS='' read -r line; do + test_files+=("$line") + done < <(helper::find_files_recursive "$BASHUNIT_DEFAULT_PATH") + fi + else + test_files=("${files[@]}") + fi + + printf "%s\n" "${test_files[@]}" +} + +function helper::load_bench_files() { + local filter=$1 + local files=("${@:2}") + + local bench_files=() + + if [[ "${#files[@]}" -eq 0 ]]; then + if [[ -n "${BASHUNIT_DEFAULT_PATH}" ]]; then + while IFS='' read -r line; do + bench_files+=("$line") + done < <(helper::find_files_recursive "$BASHUNIT_DEFAULT_PATH" '*[bB]ench.sh') + fi + else + bench_files=("${files[@]}") + fi + + printf "%s\n" "${bench_files[@]}" +} + +# +# @param $1 string function name +# @return number line number of the function in the source file +# +function helper::get_function_line_number() { + local fn_name=$1 + + shopt -s extdebug + local line_number + line_number=$(declare -F "$fn_name" | awk '{print $2}') + shopt -u extdebug + + echo "$line_number" +} + +function helper::generate_id() { + local basename="$1" + local sanitized_basename + sanitized_basename="$(helper::normalize_variable_name "$basename")" + if env::is_parallel_run_enabled; then + echo "${sanitized_basename}_$$_$(random_str 6)" + else + echo "${sanitized_basename}_$$" + fi +} + +# test_title.sh + +function set_test_title() { + state::set_test_title "$1" +} + +# upgrade.sh + +function upgrade::upgrade() { + local script_path + script_path="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + local latest_tag + latest_tag="$(helper::get_latest_tag)" + + if [[ "$BASHUNIT_VERSION" == "$latest_tag" ]]; then + echo "> You are already on latest version" + return + fi + + echo "> Upgrading bashunit to latest version" + cd "$script_path" || exit + + if ! io::download_to "https://github.com/TypedDevs/bashunit/releases/download/$latest_tag/bashunit" "bashunit"; then + echo "Failed to download bashunit" + fi + + chmod u+x "bashunit" + + echo "> bashunit upgraded successfully to latest version $latest_tag" +} + +# assertions.sh + + +# assert.sh + +function fail() { + local message="${1:-${FUNCNAME[1]}}" + + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failure_message "${label}" "$message" +} + +function assert_true() { + local actual="$1" + + # Check for expected literal values first + case "$actual" in + "true"|"0") state::add_assertions_passed; return ;; + "false"|"1") handle_bool_assertion_failure "true or 0" "$actual"; return ;; + esac + + # Run command or eval and check the exit code + run_command_or_eval "$actual" + local exit_code=$? + + if [[ $exit_code -ne 0 ]]; then + handle_bool_assertion_failure "command or function with zero exit code" "exit code: $exit_code" + else + state::add_assertions_passed + fi +} + +function assert_false() { + local actual="$1" + + # Check for expected literal values first + case "$actual" in + "false"|"1") state::add_assertions_passed; return ;; + "true"|"0") handle_bool_assertion_failure "false or 1" "$actual"; return ;; + esac + + # Run command or eval and check the exit code + run_command_or_eval "$actual" + local exit_code=$? + + if [[ $exit_code -eq 0 ]]; then + handle_bool_assertion_failure "command or function with non-zero exit code" "exit code: $exit_code" + else + state::add_assertions_passed + fi +} + +function run_command_or_eval() { + local cmd="$1" + + if [[ "$cmd" =~ ^eval ]]; then + eval "${cmd#eval }" &> /dev/null + elif [[ "$(command -v "$cmd")" =~ ^alias ]]; then + eval "$cmd" &> /dev/null + else + "$cmd" &> /dev/null + fi + return $? +} + +function handle_bool_assertion_failure() { + local expected="$1" + local got="$2" + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[2]}")" + + state::add_assertions_failed + console_results::print_failed_test "$label" "$expected" "but got " "$got" +} + +function assert_same() { + local expected="$1" + local actual="$2" + + if [[ "$expected" != "$actual" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "but got " "${actual}" + return + fi + + state::add_assertions_passed +} + +function assert_equals() { + local expected="$1" + local actual="$2" + + # Remove ANSI escape sequences (color codes) + local actual_cleaned + actual_cleaned=$(echo -e "$actual" | sed -r "s/\x1B\[[0-9;]*[mK]//g") + local expected_cleaned + expected_cleaned=$(echo -e "$expected" | sed -r "s/\x1B\[[0-9;]*[mK]//g") + + # Remove all control characters and whitespace (optional, depending on your needs) + actual_cleaned=$(echo "$actual_cleaned" | tr -d '[:cntrl:]') + expected_cleaned=$(echo "$expected_cleaned" | tr -d '[:cntrl:]') + + if [[ "$expected_cleaned" != "$actual_cleaned" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected_cleaned}" "but got " "${actual_cleaned}" + return + fi + + state::add_assertions_passed +} + +function assert_not_equals() { + local expected="$1" + local actual="$2" + + # Remove ANSI escape sequences (color codes) + local actual_cleaned + actual_cleaned=$(echo -e "$actual" | sed -r "s/\x1B\[[0-9;]*[mK]//g") + local expected_cleaned + expected_cleaned=$(echo -e "$expected" | sed -r "s/\x1B\[[0-9;]*[mK]//g") + + # Remove all control characters and whitespace (optional, depending on your needs) + actual_cleaned=$(echo "$actual_cleaned" | tr -d '[:cntrl:]') + expected_cleaned=$(echo "$expected_cleaned" | tr -d '[:cntrl:]') + + if [[ "$expected_cleaned" == "$actual_cleaned" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected_cleaned}" "but got " "${actual_cleaned}" + return + fi + + state::add_assertions_passed +} + +function assert_empty() { + local expected="$1" + + if [[ "$expected" != "" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "to be empty" "but got " "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_not_empty() { + local expected="$1" + + if [[ "$expected" == "" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "to not be empty" "but got " "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_not_same() { + local expected="$1" + local actual="$2" + + if [[ "$expected" == "$actual" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "but got " "${actual}" + return + fi + + state::add_assertions_passed +} + +function assert_contains() { + local expected="$1" + local actual_arr=("${@:2}") + local actual + actual=$(printf '%s\n' "${actual_arr[@]}") + + if ! [[ $actual == *"$expected"* ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual}" "to contain" "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_contains_ignore_case() { + local expected="$1" + local actual="$2" + + shopt -s nocasematch + + if ! [[ $actual =~ $expected ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual}" "to contain" "${expected}" + shopt -u nocasematch + return + fi + + shopt -u nocasematch + state::add_assertions_passed +} + +function assert_not_contains() { + local expected="$1" + local actual_arr=("${@:2}") + local actual + actual=$(printf '%s\n' "${actual_arr[@]}") + + if [[ $actual == *"$expected"* ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual}" "to not contain" "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_matches() { + local expected="$1" + local actual_arr=("${@:2}") + local actual + actual=$(printf '%s\n' "${actual_arr[@]}") + + if ! [[ $actual =~ $expected ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual}" "to match" "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_not_matches() { + local expected="$1" + local actual_arr=("${@:2}") + local actual + actual=$(printf '%s\n' "${actual_arr[@]}") + + if [[ $actual =~ $expected ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual}" "to not match" "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_exec() { + local cmd="$1" + shift + + local expected_exit=0 + local expected_stdout="" + local expected_stderr="" + local check_stdout=false + local check_stderr=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --exit) + expected_exit="$2" + shift 2 + ;; + --stdout) + expected_stdout="$2" + check_stdout=true + shift 2 + ;; + --stderr) + expected_stderr="$2" + check_stderr=true + shift 2 + ;; + *) + shift + ;; + esac + done + + local stdout_file stderr_file + stdout_file=$(mktemp) + stderr_file=$(mktemp) + + eval "$cmd" >"$stdout_file" 2>"$stderr_file" + local exit_code=$? + + local stdout + stdout=$(cat "$stdout_file") + local stderr + stderr=$(cat "$stderr_file") + + rm -f "$stdout_file" "$stderr_file" + + local expected_desc="exit: $expected_exit" + local actual_desc="exit: $exit_code" + local failed=0 + + if [[ "$exit_code" -ne "$expected_exit" ]]; then + failed=1 + fi + + if $check_stdout; then + expected_desc+=$'\n'"stdout: $expected_stdout" + actual_desc+=$'\n'"stdout: $stdout" + if [[ "$stdout" != "$expected_stdout" ]]; then + failed=1 + fi + fi + + if $check_stderr; then + expected_desc+=$'\n'"stderr: $expected_stderr" + actual_desc+=$'\n'"stderr: $stderr" + if [[ "$stderr" != "$expected_stderr" ]]; then + failed=1 + fi + fi + + if [[ $failed -eq 1 ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "$label" "$expected_desc" "but got " "$actual_desc" + return + fi + + state::add_assertions_passed +} + +function assert_exit_code() { + local actual_exit_code=${3-"$?"} + local expected_exit_code="$1" + + if [[ "$actual_exit_code" -ne "$expected_exit_code" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual_exit_code}" "to be" "${expected_exit_code}" + return + fi + + state::add_assertions_passed +} + +function assert_successful_code() { + local actual_exit_code=${3-"$?"} + local expected_exit_code=0 + + if [[ "$actual_exit_code" -ne "$expected_exit_code" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual_exit_code}" "to be exactly" "${expected_exit_code}" + return + fi + + state::add_assertions_passed +} + +function assert_unsuccessful_code() { + local actual_exit_code=${3-"$?"} + + if [[ "$actual_exit_code" -eq 0 ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual_exit_code}" "to be non-zero" "but was 0" + return + fi + + state::add_assertions_passed +} + +function assert_general_error() { + local actual_exit_code=${3-"$?"} + local expected_exit_code=1 + + if [[ "$actual_exit_code" -ne "$expected_exit_code" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual_exit_code}" "to be exactly" "${expected_exit_code}" + return + fi + + state::add_assertions_passed +} + +function assert_command_not_found() { + local actual_exit_code=${3-"$?"} + local expected_exit_code=127 + + if [[ $actual_exit_code -ne "$expected_exit_code" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual_exit_code}" "to be exactly" "${expected_exit_code}" + return + fi + + state::add_assertions_passed +} + +function assert_string_starts_with() { + local expected="$1" + local actual_arr=("${@:2}") + local actual + actual=$(printf '%s\n' "${actual_arr[@]}") + + if [[ $actual != "$expected"* ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual}" "to start with" "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_string_not_starts_with() { + local expected="$1" + local actual="$2" + + if [[ $actual == "$expected"* ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual}" "to not start with" "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_string_ends_with() { + local expected="$1" + local actual_arr=("${@:2}") + local actual + actual=$(printf '%s\n' "${actual_arr[@]}") + + if [[ $actual != *"$expected" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual}" "to end with" "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_string_not_ends_with() { + local expected="$1" + local actual_arr=("${@:2}") + local actual + actual=$(printf '%s\n' "${actual_arr[@]}") + + if [[ $actual == *"$expected" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual}" "to not end with" "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_less_than() { + local expected="$1" + local actual="$2" + + if ! [[ "$actual" -lt "$expected" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual}" "to be less than" "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_less_or_equal_than() { + local expected="$1" + local actual="$2" + + if ! [[ "$actual" -le "$expected" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual}" "to be less or equal than" "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_greater_than() { + local expected="$1" + local actual="$2" + + if ! [[ "$actual" -gt "$expected" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual}" "to be greater than" "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_greater_or_equal_than() { + local expected="$1" + local actual="$2" + + if ! [[ "$actual" -ge "$expected" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual}" "to be greater or equal than" "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_line_count() { + local expected="$1" + local input_arr=("${@:2}") + local input_str + input_str=$(printf '%s\n' "${input_arr[@]}") + + if [ -z "$input_str" ]; then + local actual=0 + else + local actual + actual=$(echo "$input_str" | wc -l | tr -d '[:blank:]') + local additional_new_lines + additional_new_lines=$(grep -o '\\n' <<< "$input_str" | wc -l | tr -d '[:blank:]') + ((actual+=additional_new_lines)) + fi + + if [[ "$expected" != "$actual" ]]; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + + state::add_assertions_failed + console_results::print_failed_test "${label}" "${input_str}"\ + "to contain number of lines equal to" "${expected}"\ + "but found" "${actual}" + return + fi + + state::add_assertions_passed +} + +# assert_arrays.sh + +function assert_array_contains() { + local expected="$1" + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + shift + + local actual=("${@}") + + if ! [[ "${actual[*]}" == *"$expected"* ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual[*]}" "to contain" "${expected}" + return + fi + + state::add_assertions_passed +} + +function assert_array_not_contains() { + local expected="$1" + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + shift + local actual=("$@") + + if [[ "${actual[*]}" == *"$expected"* ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${actual[*]}" "to not contain" "${expected}" + return + fi + + state::add_assertions_passed +} + +# assert_files.sh + +function assert_file_exists() { + local expected="$1" + local label="${3:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + + if [[ ! -f "$expected" ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "to exist but" "do not exist" + return + fi + + state::add_assertions_passed +} + +function assert_file_not_exists() { + local expected="$1" + local label="${3:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + + if [[ -f "$expected" ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "to not exist but" "the file exists" + return + fi + + state::add_assertions_passed +} + +function assert_is_file() { + local expected="$1" + local label="${3:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + + if [[ ! -f "$expected" ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "to be a file" "but is not a file" + return + fi + + state::add_assertions_passed +} + +function assert_is_file_empty() { + local expected="$1" + local label="${3:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + + if [[ -s "$expected" ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "to be empty" "but is not empty" + return + fi + + state::add_assertions_passed +} + +function assert_files_equals() { + local expected="$1" + local actual="$2" + + if [[ "$(diff -u "$expected" "$actual")" != '' ]] ; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + + console_results::print_failed_test "${label}" "${expected}" "Compared" "${actual}" \ + "Diff" "$(diff -u "$expected" "$actual" | sed '1,2d')" + return + fi + + state::add_assertions_passed +} + +function assert_files_not_equals() { + local expected="$1" + local actual="$2" + + if [[ "$(diff -u "$expected" "$actual")" == '' ]] ; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + + console_results::print_failed_test "${label}" "${expected}" "Compared" "${actual}" \ + "Diff" "Files are equals" + return + fi + + state::add_assertions_passed +} + +function assert_file_contains() { + local file="$1" + local string="$2" + + if ! grep -F -q "$string" "$file"; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + + console_results::print_failed_test "${label}" "${file}" "to contain" "${string}" + return + fi + + state::add_assertions_passed +} + +function assert_file_not_contains() { + local file="$1" + local string="$2" + + if grep -q "$string" "$file"; then + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + state::add_assertions_failed + + console_results::print_failed_test "${label}" "${file}" "to not contain" "${string}" + return + fi + + state::add_assertions_passed +} + +# assert_folders.sh + +function assert_directory_exists() { + local expected="$1" + local label="${2:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + + if [[ ! -d "$expected" ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "to exist but" "do not exist" + return + fi + + state::add_assertions_passed +} + +function assert_directory_not_exists() { + local expected="$1" + local label="${2:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + + if [[ -d "$expected" ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "to not exist but" "the directory exists" + return + fi + + state::add_assertions_passed +} + +function assert_is_directory() { + local expected="$1" + local label="${2:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + + if [[ ! -d "$expected" ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "to be a directory" "but is not a directory" + return + fi + + state::add_assertions_passed +} + +function assert_is_directory_empty() { + local expected="$1" + local label="${2:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + + if [[ ! -d "$expected" || -n "$(ls -A "$expected")" ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "to be empty" "but is not empty" + return + fi + + state::add_assertions_passed +} + +function assert_is_directory_not_empty() { + local expected="$1" + local label="${2:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + + if [[ ! -d "$expected" || -z "$(ls -A "$expected")" ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "to not be empty" "but is empty" + return + fi + + state::add_assertions_passed +} + +function assert_is_directory_readable() { + local expected="$1" + local label="${2:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + + if [[ ! -d "$expected" || ! -r "$expected" || ! -x "$expected" ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "to be readable" "but is not readable" + return + fi + + state::add_assertions_passed +} + +function assert_is_directory_not_readable() { + local expected="$1" + local label="${2:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + + if [[ ! -d "$expected" ]] || [[ -r "$expected" && -x "$expected" ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "to be not readable" "but is readable" + return + fi + + state::add_assertions_passed +} + +function assert_is_directory_writable() { + local expected="$1" + local label="${2:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + + if [[ ! -d "$expected" || ! -w "$expected" ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "to be writable" "but is not writable" + return + fi + + state::add_assertions_passed +} + +function assert_is_directory_not_writable() { + local expected="$1" + local label="${2:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + + if [[ ! -d "$expected" || -w "$expected" ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" "to be not writable" "but is writable" + return + fi + + state::add_assertions_passed +} + +# assert_snapshot.sh +# shellcheck disable=SC2155 + +function assert_match_snapshot() { + local actual=$(echo -n "$1" | tr -d '\r') + local snapshot_file=$(snapshot::resolve_file "${2:-}" "${FUNCNAME[1]}") + + if [[ ! -f "$snapshot_file" ]]; then + snapshot::initialize "$snapshot_file" "$actual" + return + fi + + snapshot::compare "$actual" "$snapshot_file" "${FUNCNAME[1]}" +} + +function assert_match_snapshot_ignore_colors() { + local actual=$(echo -n "$1" | sed 's/\x1B\[[0-9;]*[mK]//g' | tr -d '\r') + local snapshot_file=$(snapshot::resolve_file "${2:-}" "${FUNCNAME[1]}") + + if [[ ! -f "$snapshot_file" ]]; then + snapshot::initialize "$snapshot_file" "$actual" + return + fi + + snapshot::compare "$actual" "$snapshot_file" "${FUNCNAME[1]}" +} + +function snapshot::match_with_placeholder() { + local actual="$1" + local snapshot="$2" + local placeholder="${BASHUNIT_SNAPSHOT_PLACEHOLDER:-::ignore::}" + local token="__BASHUNIT_IGNORE__" + + local sanitized="${snapshot//$placeholder/$token}" + local escaped=$(printf '%s' "$sanitized" | sed -e 's/[.[\\^$*+?{}()|]/\\&/g') + local regex="^${escaped//$token/(.|\\n)*}$" + + if command -v perl >/dev/null 2>&1; then + echo "$actual" | REGEX="$regex" perl -0 -e ' + my $r = $ENV{REGEX}; + my $input = join("", ); + exit($input =~ /$r/s ? 0 : 1); + ' && return 0 || return 1 + else + local fallback=$(printf '%s' "$snapshot" | sed -e "s|$placeholder|.*|g" -e 's/[][\.^$*+?{}|()]/\\&/g') + fallback="^${fallback}$" + echo "$actual" | grep -Eq "$fallback" && return 0 || return 1 + fi +} + +function snapshot::resolve_file() { + local file_hint="$1" + local func_name="$2" + + if [[ -n "$file_hint" ]]; then + echo "$file_hint" + else + local dir="./$(dirname "${BASH_SOURCE[2]}")/snapshots" + local test_file="$(helper::normalize_variable_name "$(basename "${BASH_SOURCE[2]}")")" + local name="$(helper::normalize_variable_name "$func_name").snapshot" + echo "${dir}/${test_file}.${name}" + fi +} + +function snapshot::initialize() { + local path="$1" + local content="$2" + mkdir -p "$(dirname "$path")" + echo "$content" > "$path" + state::add_assertions_snapshot +} + +function snapshot::compare() { + local actual="$1" + local snapshot_path="$2" + local func_name="$3" + + local snapshot + snapshot=$(tr -d '\r' < "$snapshot_path") + + if ! snapshot::match_with_placeholder "$actual" "$snapshot"; then + local label=$(helper::normalize_test_function_name "$func_name") + state::add_assertions_failed + console_results::print_failed_snapshot_test "$label" "$snapshot_path" "$actual" + return 1 + fi + + state::add_assertions_passed +} + +# skip_todo.sh + +function skip() { + local reason=${1-} + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + + console_results::print_skipped_test "${label}" "${reason}" + + state::add_assertions_skipped +} + +function todo() { + local pending=${1-} + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[1]}")" + + console_results::print_incomplete_test "${label}" "${pending}" + + state::add_assertions_incomplete +} + +# test_doubles.sh + +declare -a MOCKED_FUNCTIONS=() + +function unmock() { + local command=$1 + + for i in "${!MOCKED_FUNCTIONS[@]}"; do + if [[ "${MOCKED_FUNCTIONS[$i]}" == "$command" ]]; then + unset "MOCKED_FUNCTIONS[$i]" + unset -f "$command" + local variable + variable="$(helper::normalize_variable_name "$command")" + local times_file_var="${variable}_times_file" + local params_file_var="${variable}_params_file" + [[ -f "${!times_file_var-}" ]] && rm -f "${!times_file_var}" + [[ -f "${!params_file_var-}" ]] && rm -f "${!params_file_var}" + unset "$times_file_var" + unset "$params_file_var" + break + fi + done +} + +function mock() { + local command=$1 + shift + + if [[ $# -gt 0 ]]; then + eval "function $command() { $* ; }" + else + eval "function $command() { echo \"$($CAT)\" ; }" + fi + + export -f "${command?}" + + MOCKED_FUNCTIONS+=("$command") +} + +function spy() { + local command=$1 + local variable + variable="$(helper::normalize_variable_name "$command")" + + local times_file params_file + local test_id="${BASHUNIT_CURRENT_TEST_ID:-global}" + times_file=$(temp_file "${test_id}_${variable}_times") + params_file=$(temp_file "${test_id}_${variable}_params") + echo 0 > "$times_file" + : > "$params_file" + export "${variable}_times_file"="$times_file" + export "${variable}_params_file"="$params_file" + + eval "function $command() { + local raw=\"\$*\" + local serialized=\"\" + local arg + for arg in \"\$@\"; do + serialized+=\"\$(printf '%q' \"\$arg\")$'\\x1f'\" + done + serialized=\${serialized%$'\\x1f'} + printf '%s\x1e%s\\n' \"\$raw\" \"\$serialized\" >> '$params_file' + local _c=\$(cat '$times_file') + _c=\$((_c+1)) + echo \"\$_c\" > '$times_file' + }" + + export -f "${command?}" + + MOCKED_FUNCTIONS+=("$command") +} + +function assert_have_been_called() { + local command=$1 + local variable + variable="$(helper::normalize_variable_name "$command")" + local file_var="${variable}_times_file" + local times=0 + if [[ -f "${!file_var-}" ]]; then + times=$(cat "${!file_var}") + fi + local label="${2:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + + if [[ $times -eq 0 ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${command}" "to have been called" "once" + return + fi + + state::add_assertions_passed +} + +function assert_have_been_called_with() { + local command=$1 + shift + + local index="" + if [[ ${!#} =~ ^[0-9]+$ ]]; then + index=${!#} + set -- "${@:1:$#-1}" + fi + + local expected="$*" + + local variable + variable="$(helper::normalize_variable_name "$command")" + local file_var="${variable}_params_file" + local line="" + if [[ -f "${!file_var-}" ]]; then + if [[ -n $index ]]; then + line=$(sed -n "${index}p" "${!file_var}") + else + line=$(tail -n 1 "${!file_var}") + fi + fi + + local raw + IFS=$'\x1e' read -r raw _ <<<"$line" + + if [[ "$expected" != "$raw" ]]; then + state::add_assertions_failed + console_results::print_failed_test "$(helper::normalize_test_function_name \ + "${FUNCNAME[1]}")" "$expected" "but got " "$raw" + return + fi + + state::add_assertions_passed +} + +function assert_have_been_called_times() { + local expected_count=$1 + local command=$2 + local variable + variable="$(helper::normalize_variable_name "$command")" + local file_var="${variable}_times_file" + local times=0 + if [[ -f "${!file_var-}" ]]; then + times=$(cat "${!file_var}") + fi + local label="${3:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + if [[ $times -ne $expected_count ]]; then + state::add_assertions_failed + console_results::print_failed_test "${label}" "${command}" \ + "to have been called" "${expected_count} times" \ + "actual" "${times} times" + return + fi + + state::add_assertions_passed +} + +function assert_not_called() { + local command=$1 + local label="${2:-$(helper::normalize_test_function_name "${FUNCNAME[1]}")}" + assert_have_been_called_times 0 "$command" "$label" +} + +# reports.sh +# shellcheck disable=SC2155 + +_REPORTS_TEST_FILES=() +_REPORTS_TEST_NAMES=() +_REPORTS_TEST_STATUSES=() +_REPORTS_TEST_DURATIONS=() +_REPORTS_TEST_ASSERTIONS=() + +function reports::add_test_snapshot() { + reports::add_test "$1" "$2" "$3" "$4" "snapshot" +} + +function reports::add_test_incomplete() { + reports::add_test "$1" "$2" "$3" "$4" "incomplete" +} + +function reports::add_test_skipped() { + reports::add_test "$1" "$2" "$3" "$4" "skipped" +} + +function reports::add_test_passed() { + reports::add_test "$1" "$2" "$3" "$4" "passed" +} + +function reports::add_test_failed() { + reports::add_test "$1" "$2" "$3" "$4" "failed" +} + +function reports::add_test() { + # Skip tracking when no report output is requested + [[ -n "${BASHUNIT_LOG_JUNIT:-}" || -n "${BASHUNIT_REPORT_HTML:-}" ]] || return 0 + + local file="$1" + local test_name="$2" + local duration="$3" + local assertions="$4" + local status="$5" + + _REPORTS_TEST_FILES+=("$file") + _REPORTS_TEST_NAMES+=("$test_name") + _REPORTS_TEST_STATUSES+=("$status") + _REPORTS_TEST_ASSERTIONS+=("$assertions") + _REPORTS_TEST_DURATIONS+=("$duration") +} + +function reports::generate_junit_xml() { + local output_file="$1" + + local test_passed=$(state::get_tests_passed) + local tests_skipped=$(state::get_tests_skipped) + local tests_incomplete=$(state::get_tests_incomplete) + local tests_snapshot=$(state::get_tests_snapshot) + local tests_failed=$(state::get_tests_failed) + local time=$(clock::total_runtime_in_milliseconds) + + { + echo "" + echo "" + echo " " + + for i in "${!_REPORTS_TEST_NAMES[@]}"; do + local file="${_REPORTS_TEST_FILES[$i]}" + local name="${_REPORTS_TEST_NAMES[$i]}" + local assertions="${_REPORTS_TEST_ASSERTIONS[$i]}" + local status="${_REPORTS_TEST_STATUSES[$i]}" + local test_time="${_REPORTS_TEST_DURATIONS[$i]}" + + echo " " + echo " " + done + + echo " " + echo "" + } > "$output_file" +} + +function reports::generate_report_html() { + local output_file="$1" + + local test_passed=$(state::get_tests_passed) + local tests_skipped=$(state::get_tests_skipped) + local tests_incomplete=$(state::get_tests_incomplete) + local tests_snapshot=$(state::get_tests_snapshot) + local tests_failed=$(state::get_tests_failed) + local time=$(clock::total_runtime_in_milliseconds) + + # Temporary file to store test cases by file + local temp_file="temp_test_cases.txt" + + # Collect test cases by file + : > "$temp_file" # Clear temp file if it exists + for i in "${!_REPORTS_TEST_NAMES[@]}"; do + local file="${_REPORTS_TEST_FILES[$i]}" + local name="${_REPORTS_TEST_NAMES[$i]}" + local status="${_REPORTS_TEST_STATUSES[$i]}" + local test_time="${_REPORTS_TEST_DURATIONS[$i]}" + local test_case="$file|$name|$status|$test_time" + + echo "$test_case" >> "$temp_file" + done + + { + echo "" + echo "" + echo "" + echo " " + echo " " + echo " Test Report" + echo " " + echo "" + echo "" + echo "

Test Report

" + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo "
Total TestsPassedFailedIncompleteSkippedSnapshotTime (ms)
${#_REPORTS_TEST_NAMES[@]}$test_passed$tests_failed$tests_incomplete$tests_skipped$tests_snapshot$time
" + echo "

Time: $time ms

" + + # Read the temporary file and group by file + local current_file="" + while IFS='|' read -r file name status test_time; do + if [ "$file" != "$current_file" ]; then + if [ -n "$current_file" ]; then + echo " " + echo " " + fi + echo "

File: $file

" + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + echo " " + current_file="$file" + fi + echo " " + echo " " + echo " " + echo " " + echo " " + done < "$temp_file" + + # Close the last table + if [ -n "$current_file" ]; then + echo " " + echo "
Test NameStatusTime (ms)
$name$status$test_time
" + fi + + echo "" + echo "" + } > "$output_file" + + # Clean up temporary file + rm -f "$temp_file" +} + +# runner.sh +# shellcheck disable=SC2155 + +# Pre-compiled regex pattern for parsing test result assertions +if [[ -z ${RUNNER_PARSE_RESULT_REGEX+x} ]]; then + declare -r RUNNER_PARSE_RESULT_REGEX='ASSERTIONS_FAILED=([0-9]*)##ASSERTIONS_PASSED=([0-9]*)##'\ +'ASSERTIONS_SKIPPED=([0-9]*)##ASSERTIONS_INCOMPLETE=([0-9]*)##ASSERTIONS_SNAPSHOT=([0-9]*)##'\ +'TEST_EXIT_CODE=([0-9]*)' +fi + +function runner::load_test_files() { + local filter=$1 + shift + local files=("${@}") + local scripts_ids=() + + for test_file in "${files[@]}"; do + if [[ ! -f $test_file ]]; then + continue + fi + unset BASHUNIT_CURRENT_TEST_ID + export BASHUNIT_CURRENT_SCRIPT_ID="$(helper::generate_id "${test_file}")" + scripts_ids+=("${BASHUNIT_CURRENT_SCRIPT_ID}") + internal_log "Loading file" "$test_file" + # shellcheck source=/dev/null + source "$test_file" + # Update function cache after sourcing new test file + CACHED_ALL_FUNCTIONS=$(declare -F | awk '{print $3}') + if ! runner::run_set_up_before_script "$test_file"; then + runner::clean_set_up_and_tear_down_after_script + if ! parallel::is_enabled; then + cleanup_script_temp_files + fi + continue + fi + if parallel::is_enabled; then + runner::call_test_functions "$test_file" "$filter" 2>/dev/null & + else + runner::call_test_functions "$test_file" "$filter" + fi + runner::run_tear_down_after_script "$test_file" + runner::clean_set_up_and_tear_down_after_script + if ! parallel::is_enabled; then + cleanup_script_temp_files + fi + internal_log "Finished file" "$test_file" + done + + if parallel::is_enabled; then + wait + runner::spinner & + local spinner_pid=$! + parallel::aggregate_test_results "$TEMP_DIR_PARALLEL_TEST_SUITE" + # Kill the spinner once the aggregation finishes + disown "$spinner_pid" && kill "$spinner_pid" &>/dev/null + printf "\r " # Clear the spinner output + for script_id in "${scripts_ids[@]}"; do + export BASHUNIT_CURRENT_SCRIPT_ID="${script_id}" + cleanup_script_temp_files + done + fi +} + +function runner::load_bench_files() { + local filter=$1 + shift + local files=("${@}") + + for bench_file in "${files[@]}"; do + [[ -f $bench_file ]] || continue + unset BASHUNIT_CURRENT_TEST_ID + export BASHUNIT_CURRENT_SCRIPT_ID="$(helper::generate_id "${bench_file}")" + # shellcheck source=/dev/null + source "$bench_file" + # Update function cache after sourcing new bench file + CACHED_ALL_FUNCTIONS=$(declare -F | awk '{print $3}') + if ! runner::run_set_up_before_script "$bench_file"; then + runner::clean_set_up_and_tear_down_after_script + cleanup_script_temp_files + continue + fi + runner::call_bench_functions "$bench_file" "$filter" + runner::run_tear_down_after_script "$bench_file" + runner::clean_set_up_and_tear_down_after_script + cleanup_script_temp_files + done +} + +function runner::spinner() { + if env::is_simple_output_enabled; then + printf "\n" + fi + + local delay=0.1 + local spin_chars="|/-\\" + while true; do + for ((i=0; i<${#spin_chars}; i++)); do + printf "\r%s" "${spin_chars:$i:1}" + sleep "$delay" + done + done +} + +function runner::functions_for_script() { + local script="$1" + local all_fn_names="$2" + + # Filter the names down to the ones defined in the script, sort them by line number + shopt -s extdebug + # shellcheck disable=SC2086 + declare -F $all_fn_names | + awk -v s="$script" '$3 == s {print $1" " $2}' | + sort -k2 -n | + awk '{print $1}' + shopt -u extdebug +} + +function runner::parse_data_provider_args() { + local input="$1" + local current_arg="" + local in_quotes=false + local quote_char="" + local escaped=false + local i + local arg + local encoded_arg + local -a args=() + + # Check for shell metacharacters that would break eval or cause globbing + local has_metachar=false + if [[ "$input" =~ [^\\][\|\&\;\*] ]] || [[ "$input" =~ ^[\|\&\;\*] ]]; then + has_metachar=true + fi + + # Try eval first (needed for $'...' from printf '%q'), unless metacharacters present + if [[ "$has_metachar" == false ]] && eval "args=($input)" 2>/dev/null && [[ ${#args[@]} -gt 0 ]]; then + # Successfully parsed - remove sentinel if present + local last_idx=$((${#args[@]} - 1)) + if [[ -z "${args[$last_idx]}" ]]; then + unset 'args[$last_idx]' + fi + # Print args and return early + for arg in "${args[@]}"; do + encoded_arg="$(helper::encode_base64 "${arg}")" + printf '%s\n' "$encoded_arg" + done + return + fi + + # Fallback: parse args from the input string into an array, respecting quotes and escapes + for ((i=0; i<${#input}; i++)); do + local char="${input:$i:1}" + if [ "$escaped" = true ]; then + case "$char" in + t) current_arg+=$'\t' ;; + n) current_arg+=$'\n' ;; + *) current_arg+="$char" ;; + esac + escaped=false + elif [ "$char" = "\\" ]; then + escaped=true + elif [ "$in_quotes" = false ]; then + case "$char" in + "$") + # Handle $'...' syntax + if [[ "${input:$i:2}" == "$'" ]]; then + in_quotes=true + quote_char="'" + # Skip the $ + i=$((i + 1)) + else + current_arg+="$char" + fi + ;; + "'" | '"') + in_quotes=true + quote_char="$char" + ;; + " " | $'\t') + # Only add non-empty arguments to avoid duplicates from consecutive separators + if [[ -n "$current_arg" ]]; then + args+=("$current_arg") + fi + current_arg="" + ;; + *) + current_arg+="$char" + ;; + esac + elif [ "$char" = "$quote_char" ]; then + in_quotes=false + quote_char="" + else + current_arg+="$char" + fi + done + args+=("$current_arg") + # Remove all trailing empty strings + while [[ ${#args[@]} -gt 0 ]]; do + local last_idx=$((${#args[@]} - 1)) + if [[ -z "${args[$last_idx]}" ]]; then + unset 'args[$last_idx]' + else + break + fi + done + # Print one arg per line to stdout, base64-encoded to preserve newlines in the data + for arg in "${args[@]+"${args[@]}"}"; do + encoded_arg="$(helper::encode_base64 "${arg}")" + printf '%s\n' "$encoded_arg" + done +} + +function runner::call_test_functions() { + local script="$1" + local filter="$2" + local prefix="test" + # Use cached function names for better performance + local filtered_functions=$(helper::get_functions_to_run "$prefix" "$filter" "$CACHED_ALL_FUNCTIONS") + # shellcheck disable=SC2207 + local functions_to_run=($(runner::functions_for_script "$script" "$filtered_functions")) + + if [[ "${#functions_to_run[@]}" -le 0 ]]; then + return + fi + + runner::render_running_file_header "$script" + helper::check_duplicate_functions "$script" || true + + for fn_name in "${functions_to_run[@]}"; do + if parallel::is_enabled && parallel::must_stop_on_failure; then + break + fi + + local provider_data=() + while IFS=" " read -r line; do + provider_data+=("$line") + done <<< "$(helper::get_provider_data "$fn_name" "$script")" + + # No data provider found + if [[ "${#provider_data[@]}" -eq 0 ]]; then + runner::run_test "$script" "$fn_name" + unset fn_name + continue + fi + + # Execute the test function for each line of data + for data in "${provider_data[@]}"; do + local parsed_data=() + while IFS= read -r line; do + parsed_data+=( "$(helper::decode_base64 "${line}")" ) + done <<< "$(runner::parse_data_provider_args "$data")" + runner::run_test "$script" "$fn_name" "${parsed_data[@]}" + done + unset fn_name + done + + if ! env::is_simple_output_enabled; then + echo "" + fi +} + +function runner::call_bench_functions() { + local script="$1" + local filter="$2" + local prefix="bench" + + # Use cached function names for better performance + local filtered_functions=$(helper::get_functions_to_run "$prefix" "$filter" "$CACHED_ALL_FUNCTIONS") + # shellcheck disable=SC2207 + local functions_to_run=($(runner::functions_for_script "$script" "$filtered_functions")) + + if [[ "${#functions_to_run[@]}" -le 0 ]]; then + return + fi + + if env::is_bench_mode_enabled; then + runner::render_running_file_header "$script" + fi + + for fn_name in "${functions_to_run[@]}"; do + read -r revs its max_ms <<< "$(benchmark::parse_annotations "$fn_name" "$script")" + benchmark::run_function "$fn_name" "$revs" "$its" "$max_ms" + unset fn_name + done + + if ! env::is_simple_output_enabled; then + echo "" + fi +} + +function runner::render_running_file_header() { + local script="$1" + + internal_log "Running file" "$script" + + if parallel::is_enabled; then + return + fi + + if ! env::is_simple_output_enabled; then + if env::is_verbose_enabled; then + printf "\n${_COLOR_BOLD}%s${_COLOR_DEFAULT}\n" "Running $script" + else + printf "${_COLOR_BOLD}%s${_COLOR_DEFAULT}\n" "Running $script" + fi + elif env::is_verbose_enabled; then + printf "\n\n${_COLOR_BOLD}%s${_COLOR_DEFAULT}" "Running $script" + fi +} + +function runner::run_test() { + local start_time + start_time=$(clock::now) + + local test_file="$1" + shift + local fn_name="$1" + shift + + internal_log "Running test" "$fn_name" "$*" + # Export a unique test identifier so that test doubles can + # create temporary files scoped per test run. This prevents + # race conditions when running tests in parallel. + export BASHUNIT_CURRENT_TEST_ID="$(helper::generate_id "$fn_name")" + + state::reset_test_title + + local interpolated_fn_name="$(helper::interpolate_function_name "$fn_name" "$@")" + if [[ "$interpolated_fn_name" != "$fn_name" ]]; then + state::set_current_test_interpolated_function_name "$interpolated_fn_name" + else + state::reset_current_test_interpolated_function_name + fi + local current_assertions_failed="$(state::get_assertions_failed)" + local current_assertions_snapshot="$(state::get_assertions_snapshot)" + local current_assertions_incomplete="$(state::get_assertions_incomplete)" + local current_assertions_skipped="$(state::get_assertions_skipped)" + + # (FD = File Descriptor) + # Duplicate the current std-output (FD 1) and assigns it to FD 3. + # This means that FD 3 now points to wherever the std-output was pointing. + exec 3>&1 + + local test_execution_result=$( + # shellcheck disable=SC2064 + trap 'exit_code=$?; runner::cleanup_on_exit "$test_file" "$exit_code"' EXIT + state::initialize_assertions_count + if ! runner::run_set_up "$test_file"; then + status=$? + exit "$status" + fi + + # 2>&1: Redirects the std-error (FD 2) to the std-output (FD 1). + # points to the original std-output. + "$fn_name" "$@" 2>&1 + + ) + + # Closes FD 3, which was used temporarily to hold the original stdout. + exec 3>&- + + local end_time=$(clock::now) + local duration_ns=$((end_time - start_time)) + local duration=$((duration_ns / 1000000)) + + if env::is_verbose_enabled; then + if env::is_simple_output_enabled; then + echo "" + fi + + printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '=' + printf "%s\n" "File: $test_file" + printf "%s\n" "Function: $fn_name" + printf "%s\n" "Duration: $duration ms" + local raw_text=${test_execution_result%%##ASSERTIONS_*} + [[ -n $raw_text ]] && printf "%s" "Raw text: ${test_execution_result%%##ASSERTIONS_*}" + printf "%s\n" "##ASSERTIONS_${test_execution_result#*##ASSERTIONS_}" + printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '-' + fi + + local subshell_output=$(runner::decode_subshell_output "$test_execution_result") + + if [[ -n "$subshell_output" ]]; then + # Formatted as "[type]line" @see `state::print_line()` + local type="${subshell_output%%]*}" # Remove everything after "]" + type="${type#[}" # Remove the leading "[" + local line="${subshell_output#*]}" # Remove everything before and including "]" + + # Replace [type] with a newline to split the messages + line="${line//\[failed\]/$'\n'}" # Replace [failed] with newline + line="${line//\[skipped\]/$'\n'}" # Replace [skipped] with newline + line="${line//\[incomplete\]/$'\n'}" # Replace [incomplete] with newline + + state::print_line "$type" "$line" + + subshell_output=$line + fi + + local runtime_output="${test_execution_result%%##ASSERTIONS_*}" + + local runtime_error="" + for error in "command not found" "unbound variable" "permission denied" \ + "no such file or directory" "syntax error" "bad substitution" \ + "division by 0" "cannot allocate memory" "bad file descriptor" \ + "segmentation fault" "illegal option" "argument list too long" \ + "readonly variable" "missing keyword" "killed" \ + "cannot execute binary file" "invalid arithmetic operator"; do + if [[ "$runtime_output" == *"$error"* ]]; then + runtime_error="${runtime_output#*: }" # Remove everything up to and including ": " + runtime_error="${runtime_error//$'\n'/}" # Remove all newlines using parameter expansion + break + fi + done + + runner::parse_result "$fn_name" "$test_execution_result" "$@" + + local total_assertions="$(state::calculate_total_assertions "$test_execution_result")" + local test_exit_code="$(state::get_test_exit_code)" + + local encoded_test_title + encoded_test_title="${test_execution_result##*##TEST_TITLE=}" + encoded_test_title="${encoded_test_title%%##*}" + local test_title="" + [[ -n "$encoded_test_title" ]] && test_title="$(helper::decode_base64 "$encoded_test_title")" + + local encoded_hook_failure + encoded_hook_failure="${test_execution_result##*##TEST_HOOK_FAILURE=}" + encoded_hook_failure="${encoded_hook_failure%%##*}" + local hook_failure="" + if [[ "$encoded_hook_failure" != "$test_execution_result" ]]; then + hook_failure="$encoded_hook_failure" + fi + + local encoded_hook_message + encoded_hook_message="${test_execution_result##*##TEST_HOOK_MESSAGE=}" + encoded_hook_message="${encoded_hook_message%%##*}" + local hook_message="" + if [[ -n "$encoded_hook_message" ]]; then + hook_message="$(helper::decode_base64 "$encoded_hook_message")" + fi + + state::set_test_title "$test_title" + local label + label="$(helper::normalize_test_function_name "$fn_name" "$interpolated_fn_name")" + state::reset_test_title + state::reset_current_test_interpolated_function_name + + local failure_label="$label" + local failure_function="$fn_name" + if [[ -n "$hook_failure" ]]; then + failure_label="$(helper::normalize_test_function_name "$hook_failure")" + failure_function="$hook_failure" + fi + + if [[ -n $runtime_error || $test_exit_code -ne 0 ]]; then + state::add_tests_failed + local error_message="$runtime_error" + if [[ -n "$hook_failure" && -n "$hook_message" ]]; then + error_message="$hook_message" + elif [[ -z "$error_message" && -n "$hook_message" ]]; then + error_message="$hook_message" + fi + console_results::print_error_test "$failure_function" "$error_message" + reports::add_test_failed "$test_file" "$failure_label" "$duration" "$total_assertions" + runner::write_failure_result_output "$test_file" "$failure_function" "$error_message" + internal_log "Test error" "$failure_label" "$error_message" + return + fi + + if [[ "$current_assertions_failed" != "$(state::get_assertions_failed)" ]]; then + state::add_tests_failed + reports::add_test_failed "$test_file" "$label" "$duration" "$total_assertions" + runner::write_failure_result_output "$test_file" "$fn_name" "$subshell_output" + + internal_log "Test failed" "$label" + + if env::is_stop_on_failure_enabled; then + if parallel::is_enabled; then + parallel::mark_stop_on_failure + else + exit "$EXIT_CODE_STOP_ON_FAILURE" + fi + fi + return + fi + + if [[ "$current_assertions_snapshot" != "$(state::get_assertions_snapshot)" ]]; then + state::add_tests_snapshot + console_results::print_snapshot_test "$label" + reports::add_test_snapshot "$test_file" "$label" "$duration" "$total_assertions" + internal_log "Test snapshot" "$label" + return + fi + + if [[ "$current_assertions_incomplete" != "$(state::get_assertions_incomplete)" ]]; then + state::add_tests_incomplete + reports::add_test_incomplete "$test_file" "$label" "$duration" "$total_assertions" + internal_log "Test incomplete" "$label" + return + fi + + if [[ "$current_assertions_skipped" != "$(state::get_assertions_skipped)" ]]; then + state::add_tests_skipped + reports::add_test_skipped "$test_file" "$label" "$duration" "$total_assertions" + internal_log "Test skipped" "$label" + return + fi + + if [[ "$fn_name" == "$interpolated_fn_name" ]]; then + console_results::print_successful_test "${label}" "$duration" "$@" + else + console_results::print_successful_test "${label}" "$duration" + fi + state::add_tests_passed + reports::add_test_passed "$test_file" "$label" "$duration" "$total_assertions" + internal_log "Test passed" "$label" +} + +function runner::cleanup_on_exit() { + local test_file="$1" + local exit_code="$2" + + set +e + local teardown_status=0 + runner::run_tear_down "$test_file" || teardown_status=$? + runner::clear_mocks + cleanup_testcase_temp_files + + if [[ $teardown_status -ne 0 ]]; then + state::set_test_exit_code "$teardown_status" + else + state::set_test_exit_code "$exit_code" + fi + + state::export_subshell_context +} + +function runner::decode_subshell_output() { + local test_execution_result="$1" + + local test_output_base64="${test_execution_result##*##TEST_OUTPUT=}" + test_output_base64="${test_output_base64%%##*}" + helper::decode_base64 "$test_output_base64" +} + +function runner::parse_result() { + local fn_name=$1 + shift + local execution_result=$1 + shift + local args=("$@") + + if parallel::is_enabled; then + runner::parse_result_parallel "$fn_name" "$execution_result" "${args[@]}" + else + runner::parse_result_sync "$fn_name" "$execution_result" + fi +} + +function runner::parse_result_parallel() { + local fn_name=$1 + shift + local execution_result=$1 + shift + local args=("$@") + + local test_suite_dir="${TEMP_DIR_PARALLEL_TEST_SUITE}/$(basename "$test_file" .sh)" + mkdir -p "$test_suite_dir" + + local sanitized_args + sanitized_args=$(echo "${args[*]}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-|-$//') + local template + if [[ -z "$sanitized_args" ]]; then + template="${fn_name}.XXXXXX" + else + template="${fn_name}-${sanitized_args}.XXXXXX" + fi + + local unique_test_result_file + if unique_test_result_file=$(mktemp -p "$test_suite_dir" "$template" 2>/dev/null); then + true + else + unique_test_result_file=$(mktemp "$test_suite_dir/$template") + fi + mv "$unique_test_result_file" "${unique_test_result_file}.result" + unique_test_result_file="${unique_test_result_file}.result" + + internal_log "[PARA]" "fn_name:$fn_name" "execution_result:$execution_result" + + runner::parse_result_sync "$fn_name" "$execution_result" + + echo "$execution_result" > "$unique_test_result_file" +} + +# shellcheck disable=SC2295 +function runner::parse_result_sync() { + local fn_name=$1 + local execution_result=$2 + + local result_line + result_line="${execution_result##*$'\n'}" + + local assertions_failed=0 + local assertions_passed=0 + local assertions_skipped=0 + local assertions_incomplete=0 + local assertions_snapshot=0 + local test_exit_code=0 + + # Use pre-compiled regex constant + if [[ $result_line =~ $RUNNER_PARSE_RESULT_REGEX ]]; then + assertions_failed="${BASH_REMATCH[1]}" + assertions_passed="${BASH_REMATCH[2]}" + assertions_skipped="${BASH_REMATCH[3]}" + assertions_incomplete="${BASH_REMATCH[4]}" + assertions_snapshot="${BASH_REMATCH[5]}" + test_exit_code="${BASH_REMATCH[6]}" + fi + + internal_log "[SYNC]" "fn_name:$fn_name" "execution_result:$execution_result" + + ((_ASSERTIONS_PASSED += assertions_passed)) || true + ((_ASSERTIONS_FAILED += assertions_failed)) || true + ((_ASSERTIONS_SKIPPED += assertions_skipped)) || true + ((_ASSERTIONS_INCOMPLETE += assertions_incomplete)) || true + ((_ASSERTIONS_SNAPSHOT += assertions_snapshot)) || true + ((_TEST_EXIT_CODE += test_exit_code)) || true + + internal_log "result_summary" \ + "failed:$assertions_failed" \ + "passed:$assertions_passed" \ + "skipped:$assertions_skipped" \ + "incomplete:$assertions_incomplete" \ + "snapshot:$assertions_snapshot" \ + "exit_code:$test_exit_code" +} + +function runner::write_failure_result_output() { + local test_file=$1 + local fn_name=$2 + local error_msg=$3 + + local line_number + line_number=$(helper::get_function_line_number "$fn_name") + + local test_nr="*" + if ! parallel::is_enabled; then + test_nr=$(state::get_tests_failed) + fi + + echo -e "$test_nr) $test_file:$line_number\n$error_msg" >> "$FAILURES_OUTPUT_PATH" +} + +function runner::record_file_hook_failure() { + local hook_name="$1" + local test_file="$2" + local hook_output="$3" + local status="$4" + local render_header="${5:-false}" + + if [[ "$render_header" == true ]]; then + runner::render_running_file_header "$test_file" + fi + + if [[ -z "$hook_output" ]]; then + hook_output="Hook '$hook_name' failed with exit code $status" + fi + + state::add_tests_failed + console_results::print_error_test "$hook_name" "$hook_output" + reports::add_test_failed "$test_file" "$(helper::normalize_test_function_name "$hook_name")" 0 0 + runner::write_failure_result_output "$test_file" "$hook_name" "$hook_output" + + return "$status" +} + +function runner::execute_file_hook() { + local hook_name="$1" + local test_file="$2" + local render_header="${3:-false}" + + if [[ "$(type -t "$hook_name")" != "function" ]]; then + return 0 + fi + + local hook_output="" + local status=0 + local hook_output_file + hook_output_file=$(temp_file "${hook_name}_output") + + { + "$hook_name" + } >"$hook_output_file" 2>&1 || status=$? + + if [[ -f "$hook_output_file" ]]; then + hook_output="" + while IFS= read -r line; do + [[ -z "$hook_output" ]] && hook_output="$line" || hook_output="$hook_output"$'\n'"$line" + done < "$hook_output_file" + rm -f "$hook_output_file" + fi + + if [[ $status -ne 0 ]]; then + runner::record_file_hook_failure "$hook_name" "$test_file" "$hook_output" "$status" "$render_header" + return $status + fi + + if [[ -n "$hook_output" ]]; then + printf "%s\n" "$hook_output" + fi + + return 0 +} + +function runner::run_set_up() { + local _test_file="${1-}" + internal_log "run_set_up" + runner::execute_test_hook 'set_up' +} + +function runner::run_set_up_before_script() { + local test_file="$1" + internal_log "run_set_up_before_script" + runner::execute_file_hook 'set_up_before_script' "$test_file" true +} + +function runner::run_tear_down() { + local _test_file="${1-}" + internal_log "run_tear_down" + runner::execute_test_hook 'tear_down' +} + +function runner::execute_test_hook() { + local hook_name="$1" + + if [[ "$(type -t "$hook_name")" != "function" ]]; then + return 0 + fi + + local hook_output="" + local status=0 + local hook_output_file + hook_output_file=$(temp_file "${hook_name}_output") + + { + "$hook_name" + } >"$hook_output_file" 2>&1 || status=$? + + if [[ -f "$hook_output_file" ]]; then + hook_output="" + while IFS= read -r line; do + [[ -z "$hook_output" ]] && hook_output="$line" || hook_output="$hook_output"$'\n'"$line" + done < "$hook_output_file" + rm -f "$hook_output_file" + fi + + if [[ $status -ne 0 ]]; then + local message="$hook_output" + if [[ -n "$hook_output" ]]; then + printf "%s" "$hook_output" + else + message="Hook '$hook_name' failed with exit code $status" + printf "%s\n" "$message" >&2 + fi + runner::record_test_hook_failure "$hook_name" "$message" "$status" + return "$status" + fi + + if [[ -n "$hook_output" ]]; then + printf "%s" "$hook_output" + fi + + return 0 +} + +function runner::record_test_hook_failure() { + local hook_name="$1" + local hook_message="$2" + local status="$3" + + if [[ -n "$(state::get_test_hook_failure)" ]]; then + return "$status" + fi + + state::set_test_hook_failure "$hook_name" + state::set_test_hook_message "$hook_message" + + return "$status" +} + +function runner::clear_mocks() { + for i in "${!MOCKED_FUNCTIONS[@]}"; do + unmock "${MOCKED_FUNCTIONS[$i]}" + done +} + +function runner::run_tear_down_after_script() { + local test_file="$1" + internal_log "run_tear_down_after_script" + runner::execute_file_hook 'tear_down_after_script' "$test_file" +} + +function runner::clean_set_up_and_tear_down_after_script() { + internal_log "clean_set_up_and_tear_down_after_script" + helper::unset_if_exists 'set_up' + helper::unset_if_exists 'tear_down' + helper::unset_if_exists 'set_up_before_script' + helper::unset_if_exists 'tear_down_after_script' +} + +# init.sh + +function init::project() { + local tests_dir="${1:-$BASHUNIT_DEFAULT_PATH}" + mkdir -p "$tests_dir" + + local bootstrap_file="$tests_dir/bootstrap.sh" + if [[ ! -f "$bootstrap_file" ]]; then + cat >"$bootstrap_file" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +# Place your common test setup here +SH + chmod +x "$bootstrap_file" + echo "> Created $bootstrap_file" + fi + + local example_test="$tests_dir/example_test.sh" + if [[ ! -f "$example_test" ]]; then + cat >"$example_test" <<'SH' +#!/usr/bin/env bash + +function test_bashunit_is_installed() { + assert_same "bashunit is installed" "bashunit is installed" +} +SH + chmod +x "$example_test" + echo "> Created $example_test" + fi + + local env_file=".env" + local env_line="BASHUNIT_BOOTSTRAP=$bootstrap_file" + if [[ -f "$env_file" ]]; then + if grep -q "^BASHUNIT_BOOTSTRAP=" "$env_file"; then + if check_os::is_macos; then + sed -i '' -e "s/^BASHUNIT_BOOTSTRAP=/#&/" "$env_file" + else + sed -i -e "s/^BASHUNIT_BOOTSTRAP=/#&/" "$env_file" + fi + fi + echo "$env_line" >> "$env_file" + else + echo "$env_line" > "$env_file" + fi + + echo "> bashunit initialized in $tests_dir" +} + +# bashunit.sh + +# This file provides a facade to developers who wants +# to interact with the internals of bashunit. +# e.g. adding custom assertions + +function bashunit::assertion_failed() { + local expected=$1 + local actual=$2 + local failure_condition_message=${3:-"but got "} + + local label + label="$(helper::normalize_test_function_name "${FUNCNAME[2]}")" + state::add_assertions_failed + console_results::print_failed_test "${label}" "${expected}" \ + "$failure_condition_message" "${actual}" +} + +function bashunit::assertion_passed() { + state::add_assertions_passed +} + +# main.sh + +function main::exec_tests() { + local filter=$1 + local files=("${@:2}") + + local test_files=() + while IFS= read -r line; do + test_files+=("$line") + done < <(helper::load_test_files "$filter" "${files[@]}") + + internal_log "exec_tests" "filter:$filter" "files:${test_files[*]}" + + if [[ ${#test_files[@]} -eq 0 || -z "${test_files[0]}" ]]; then + printf "%sError: At least one file path is required.%s\n" "${_COLOR_FAILED}" "${_COLOR_DEFAULT}" + console_header::print_help + exit 1 + fi + + # Trap SIGINT (Ctrl-C) and call the cleanup function + trap 'main::cleanup' SIGINT + trap '[[ $? -eq $EXIT_CODE_STOP_ON_FAILURE ]] && main::handle_stop_on_failure_sync' EXIT + + if env::is_parallel_run_enabled && ! parallel::is_enabled; then + printf "%sWarning: Parallel tests are supported on macOS, Ubuntu and Windows.\n" "${_COLOR_INCOMPLETE}" + printf "For other OS (like Alpine), --parallel is not enabled due to inconsistent results,\n" + printf "particularly involving race conditions.%s " "${_COLOR_DEFAULT}" + printf "%sFallback using --no-parallel%s\n" "${_COLOR_SKIPPED}" "${_COLOR_DEFAULT}" + fi + + if parallel::is_enabled; then + parallel::init + fi + + console_header::print_version_with_env "$filter" "${test_files[@]}" + + if env::is_verbose_enabled; then + if env::is_simple_output_enabled; then + echo "" + fi + printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '#' + printf "%s\n" "Filter: ${filter:-None}" + printf "%s\n" "Total files: ${#test_files[@]}" + printf "%s\n" "Test files:" + printf -- "- %s\n" "${test_files[@]}" + printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '.' + env::print_verbose + printf '%*s\n' "$TERMINAL_WIDTH" '' | tr ' ' '#' + fi + + runner::load_test_files "$filter" "${test_files[@]}" + + if parallel::is_enabled; then + wait + fi + + if parallel::is_enabled && parallel::must_stop_on_failure; then + printf "\r%sStop on failure enabled...%s\n" "${_COLOR_SKIPPED}" "${_COLOR_DEFAULT}" + fi + + console_results::print_failing_tests_and_reset + console_results::render_result + exit_code=$? + + if [[ -n "$BASHUNIT_LOG_JUNIT" ]]; then + reports::generate_junit_xml "$BASHUNIT_LOG_JUNIT" + fi + + if [[ -n "$BASHUNIT_REPORT_HTML" ]]; then + reports::generate_report_html "$BASHUNIT_REPORT_HTML" + fi + + if parallel::is_enabled; then + parallel::cleanup + fi + + internal_log "Finished tests" "exit_code:$exit_code" + exit $exit_code +} + +function main::exec_benchmarks() { + local filter=$1 + local files=("${@:2}") + + local bench_files=() + while IFS= read -r line; do + bench_files+=("$line") + done < <(helper::load_bench_files "$filter" "${files[@]}") + + internal_log "exec_benchmarks" "filter:$filter" "files:${bench_files[*]}" + + if [[ ${#bench_files[@]} -eq 0 || -z "${bench_files[0]}" ]]; then + printf "%sError: At least one file path is required.%s\n" "${_COLOR_FAILED}" "${_COLOR_DEFAULT}" + console_header::print_help + exit 1 + fi + + console_header::print_version_with_env "$filter" "${bench_files[@]}" + + runner::load_bench_files "$filter" "${bench_files[@]}" + + benchmark::print_results + + internal_log "Finished benchmarks" +} + +function main::cleanup() { + printf "%sCaught Ctrl-C, killing all child processes...%s\n" "${_COLOR_SKIPPED}" "${_COLOR_DEFAULT}" + # Kill all child processes of this script + pkill -P $$ + cleanup_script_temp_files + if parallel::is_enabled; then + parallel::cleanup + fi + exit 1 +} + +function main::handle_stop_on_failure_sync() { + printf "\n%sStop on failure enabled...%s\n" "${_COLOR_SKIPPED}" "${_COLOR_DEFAULT}" + console_results::print_failing_tests_and_reset + console_results::render_result + cleanup_script_temp_files + if parallel::is_enabled; then + parallel::cleanup + fi + exit 1 +} + +function main::exec_assert() { + local original_assert_fn=$1 + local args=("${@:2}") + + local assert_fn=$original_assert_fn + + # Check if the function exists + if ! type "$assert_fn" > /dev/null 2>&1; then + assert_fn="assert_$assert_fn" + if ! type "$assert_fn" > /dev/null 2>&1; then + echo "Function $original_assert_fn does not exist." 1>&2 + exit 127 + fi + fi + + # Get the last argument safely by calculating the array length + local last_index=$((${#args[@]} - 1)) + local last_arg="${args[$last_index]}" + local output="" + local inner_exit_code=0 + local bashunit_exit_code=0 + + # Handle different assert_* functions + case "$assert_fn" in + assert_exit_code) + output=$(main::handle_assert_exit_code "$last_arg") + inner_exit_code=$? + # Remove the last argument and append the exit code + args=("${args[@]:0:last_index}") + args+=("$inner_exit_code") + ;; + *) + # Add more cases here for other assert_* handlers if needed + ;; + esac + + if [[ -n "$output" ]]; then + echo "$output" 1>&1 + assert_fn="assert_same" + fi + + # Run the assertion function and write into stderr + "$assert_fn" "${args[@]}" 1>&2 + bashunit_exit_code=$? + + if [[ "$(state::get_tests_failed)" -gt 0 ]] || [[ "$(state::get_assertions_failed)" -gt 0 ]]; then + return 1 + fi + + return "$bashunit_exit_code" +} + +function main::handle_assert_exit_code() { + local cmd="$1" + local output + local inner_exit_code=0 + + if [[ $(command -v "${cmd%% *}") ]]; then + output=$(eval "$cmd" 2>&1 || echo "inner_exit_code:$?") + local last_line + last_line=$(echo "$output" | tail -n 1) + if echo "$last_line" | grep -q 'inner_exit_code:[0-9]*'; then + inner_exit_code=$(echo "$last_line" | grep -o 'inner_exit_code:[0-9]*' | cut -d':' -f2) + if ! [[ $inner_exit_code =~ ^[0-9]+$ ]]; then + inner_exit_code=1 + fi + output=$(echo "$output" | sed '$d') + fi + echo "$output" + return "$inner_exit_code" + else + echo "Command not found: $cmd" 1>&2 + return 127 + fi +} + +#!/usr/bin/env bash +set -euo pipefail + +declare -r BASHUNIT_MIN_BASH_VERSION="3.2" + +function _check_bash_version() { + local current_version + if [[ -n ${BASHUNIT_TEST_BASH_VERSION:-} ]]; then + # Checks if BASHUNIT_TEST_BASH_VERSION is set (typically for testing purposes) + current_version="${BASHUNIT_TEST_BASH_VERSION}" + elif [[ -n ${BASH_VERSINFO+set} ]]; then + # Checks if the special Bash array BASH_VERSINFO exists. This array is only defined in Bash. + current_version="${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}" + else + # If not in Bash (e.g., running from Zsh). The pipeline extracts just the major.minor version (e.g., 3.2). + current_version="$(bash --version | head -n1 | cut -d' ' -f4 | cut -d. -f1,2)" + fi + + local major minor + IFS=. read -r major minor _ <<< "$current_version" + + if (( major < 3 )) || { (( major == 3 )) && (( minor < 2 )); }; then + printf 'Bashunit requires Bash >= %s. Current version: %s\n' "$BASHUNIT_MIN_BASH_VERSION" "$current_version" >&2 + exit 1 + fi +} + +_check_bash_version + +# shellcheck disable=SC2034 +declare -r BASHUNIT_VERSION="0.26.0" + +# shellcheck disable=SC2155 +declare -r BASHUNIT_ROOT_DIR="$(dirname "${BASH_SOURCE[0]}")" +export BASHUNIT_ROOT_DIR + + +_ASSERT_FN="" +_FILTER="" +_RAW_ARGS=() +_ARGS=() +_BENCH_MODE=false + +check_os::init +clock::init + +# Argument parsing +while [[ $# -gt 0 ]]; do + case "$1" in + -a|--assert) + _ASSERT_FN="$2" + shift + ;; + -f|--filter) + _FILTER="$2" + shift + ;; + -s|--simple) + export BASHUNIT_SIMPLE_OUTPUT=true + ;; + --detailed) + export BASHUNIT_SIMPLE_OUTPUT=false + ;; + --debug) + OUTPUT_FILE="${2:-}" + if [[ -n "$OUTPUT_FILE" ]]; then + exec > "$OUTPUT_FILE" 2>&1 + fi + set -x + ;; + -b|--bench) + _BENCH_MODE=true + export BASHUNIT_BENCH_MODE=true + source "$BASHUNIT_ROOT_DIR/src/benchmark.sh" + ;; + -S|--stop-on-failure) + export BASHUNIT_STOP_ON_FAILURE=true + ;; + -p|--parallel) + export BASHUNIT_PARALLEL_RUN=true + ;; + --no-parallel) + export BASHUNIT_PARALLEL_RUN=false + ;; + -e|--env|--boot) + # shellcheck disable=SC1090 + source "$2" + shift + ;; + -l|--log-junit) + export BASHUNIT_LOG_JUNIT="$2" + shift + ;; + -r|--report-html) + export BASHUNIT_REPORT_HTML="$2" + shift + ;; + --no-output) + export BASHUNIT_NO_OUTPUT=true + ;; + -vvv|--verbose) + export BASHUNIT_VERBOSE=true + ;; + -v|--version) + console_header::print_version + trap '' EXIT && exit 0 + ;; + --upgrade) + upgrade::upgrade + trap '' EXIT && exit 0 + ;; + --init) + if [[ -n ${2:-} && ${2:0:1} != "-" ]]; then + init::project "$2" + shift + else + init::project + fi + trap '' EXIT && exit 0 + ;; + -h|--help) + console_header::print_help + trap '' EXIT && exit 0 + ;; + *) + _RAW_ARGS+=("$1") + ;; + esac + shift +done + +# Expand positional arguments after all options have been processed +if [[ ${#_RAW_ARGS[@]} -gt 0 ]]; then + pattern='*[tT]est.sh' + [[ "$_BENCH_MODE" == true ]] && pattern='*[bB]ench.sh' + for arg in "${_RAW_ARGS[@]}"; do + while IFS= read -r file; do + _ARGS+=("$file") + done < <(helper::find_files_recursive "$arg" "$pattern") + done +fi + +# Optional bootstrap +# shellcheck disable=SC1090 +[[ -f "${BASHUNIT_BOOTSTRAP:-}" ]] && source "$BASHUNIT_BOOTSTRAP" + +if [[ "${BASHUNIT_NO_OUTPUT:-false}" == true ]]; then + exec >/dev/null 2>&1 +fi + +set +eu + +################# +# Main execution +################# +if [[ -n "$_ASSERT_FN" ]]; then + main::exec_assert "$_ASSERT_FN" "${_ARGS[@]}" +elif [[ "$_BENCH_MODE" == true ]]; then + main::exec_benchmarks "$_FILTER" "${_ARGS[@]}" +else + main::exec_tests "$_FILTER" "${_ARGS[@]}" +fi diff --git a/tests/bootstrap.sh b/tests/bootstrap.sh new file mode 100755 index 0000000..795b062 --- /dev/null +++ b/tests/bootstrap.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +# Place your common test setup here + +# Resolve the project root directory +_test_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +_root_dir="$(cd "${_test_dir}/.." && pwd)" + +# Set TMUXIFIER so libexec commands can find lib/util.sh +export TMUXIFIER="${_root_dir}" + +# Add libexec to PATH so tmuxifier commands are available +export PATH="${_root_dir}/libexec:${PATH}" diff --git a/tests/lib/layout-helpers/__get_current_window_index_test.sh b/tests/lib/layout-helpers/__get_current_window_index_test.sh new file mode 100755 index 0000000..bbf8c2b --- /dev/null +++ b/tests/lib/layout-helpers/__get_current_window_index_test.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# __get_current_window_index() tests +# + +function set_up() { + # Reset session variable to known state + session="" +} + +function tear_down() { + unset session +} + +# +# Basic functionality +# + +function test_get_current_window_index_returns_active_window_index() { + session="test-session" + mock tmuxifier-tmux << EOF +1:0 +0:1 +0:2 +EOF + + local result + result=$(__get_current_window_index) + + assert_same "0" "$result" +} + +function test_get_current_window_index_returns_index_when_second_window_active() { + session="test-session" + mock tmuxifier-tmux << EOF +0:0 +1:1 +0:2 +EOF + + local result + result=$(__get_current_window_index) + + assert_same "1" "$result" +} + +function test_get_current_window_index_returns_index_when_last_window_active() { + session="test-session" + mock tmuxifier-tmux << EOF +0:0 +0:1 +1:2 +EOF + + local result + result=$(__get_current_window_index) + + assert_same "2" "$result" +} + +# +# Non-standard window indices +# + +function test_get_current_window_index_handles_non_zero_base_index() { + session="test-session" + # When base-index is 1 + mock tmuxifier-tmux << EOF +1:1 +0:2 +0:3 +EOF + + local result + result=$(__get_current_window_index) + + assert_same "1" "$result" +} + +function test_get_current_window_index_handles_high_window_index() { + session="test-session" + mock tmuxifier-tmux << EOF +0:0 +0:1 +1:999 +EOF + + local result + result=$(__get_current_window_index) + + assert_same "999" "$result" +} + +function test_get_current_window_index_handles_gaps_in_window_indices() { + session="test-session" + # Windows 0, 5, 10 with window 5 being active + mock tmuxifier-tmux << EOF +0:0 +1:5 +0:10 +EOF + + local result + result=$(__get_current_window_index) + + assert_same "5" "$result" +} + +# +# Single window +# + +function test_get_current_window_index_returns_index_for_single_window() { + session="test-session" + mock tmuxifier-tmux echo "1:0" + + local result + result=$(__get_current_window_index) + + assert_same "0" "$result" +} + +function test_get_current_window_index_returns_index_for_single_window_non_zero() { + session="test-session" + mock tmuxifier-tmux echo "1:5" + + local result + result=$(__get_current_window_index) + + assert_same "5" "$result" +} + +# +# Edge cases and error handling +# + +function test_get_current_window_index_returns_empty_when_no_active_window() { + session="test-session" + # No window marked as active (shouldn't happen in practice) + mock tmuxifier-tmux << EOF +0:0 +0:1 +0:2 +EOF + + local result + result=$(__get_current_window_index) + + assert_empty "$result" +} + +function test_get_current_window_index_returns_empty_when_no_windows() { + session="test-session" + mock tmuxifier-tmux echo "" + + local result + result=$(__get_current_window_index) + + assert_empty "$result" +} + +function test_get_current_window_index_returns_empty_on_tmux_error() { + session="test-session" + # Simulate tmux error (stderr is redirected to /dev/null in the function) + mock tmuxifier-tmux return 1 + + local result + result=$(__get_current_window_index) + + assert_empty "$result" +} + +# +# Tmux command verification +# + +function test_get_current_window_index_calls_tmux_with_correct_args() { + session="my-session" + spy tmuxifier-tmux + + __get_current_window_index + + assert_have_been_called_with tmuxifier-tmux \ + "list-windows -t my-session: -F #{window_active}:#{window_index}" +} + +function test_get_current_window_index_uses_session_variable() { + session="another-session" + spy tmuxifier-tmux + + __get_current_window_index + + assert_have_been_called_with tmuxifier-tmux \ + "list-windows -t another-session: -F #{window_active}:#{window_index}" +} + +function test_get_current_window_index_handles_special_session_names() { + session="my-project_v2" + spy tmuxifier-tmux + + __get_current_window_index + + assert_have_been_called_with tmuxifier-tmux \ + "list-windows -t my-project_v2: -F #{window_active}:#{window_index}" +} diff --git a/tests/lib/layout-helpers/__get_first_window_index_test.sh b/tests/lib/layout-helpers/__get_first_window_index_test.sh new file mode 100755 index 0000000..0d4633f --- /dev/null +++ b/tests/lib/layout-helpers/__get_first_window_index_test.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# __get_first_window_index() tests +# + +function set_up() { + # Reset session variable to known state + session="" +} + +function tear_down() { + unset session +} + +# +# Return value behavior +# + +function test_get_first_window_index_returns_first_index_from_list() { + session="test-session" + mock tmuxifier-tmux echo "0" + + local result + result=$(__get_first_window_index) + + assert_same "0" "$result" +} + +function test_get_first_window_index_returns_first_of_multiple_indices() { + session="test-session" + mock tmuxifier-tmux echo $'1\n2\n3' + + local result + result=$(__get_first_window_index) + + assert_same "1" "$result" +} + +function test_get_first_window_index_returns_0_when_list_empty() { + session="test-session" + mock tmuxifier-tmux echo "" + + local result + result=$(__get_first_window_index) + + assert_same "0" "$result" +} + +function test_get_first_window_index_returns_0_when_command_fails() { + session="test-session" + # Simulate tmux command failure by returning nothing + mock tmuxifier-tmux true + + local result + result=$(__get_first_window_index) + + assert_same "0" "$result" +} + +# +# Non-zero first window index +# + +function test_get_first_window_index_handles_nonzero_first_index() { + session="test-session" + mock tmuxifier-tmux echo "5" + + local result + result=$(__get_first_window_index) + + assert_same "5" "$result" +} + +function test_get_first_window_index_returns_first_even_when_not_sequential() { + session="test-session" + # Simulate windows at indices 3, 7, 12 + mock tmuxifier-tmux echo $'3\n7\n12' + + local result + result=$(__get_first_window_index) + + assert_same "3" "$result" +} + +# +# Session target format +# + +function test_get_first_window_index_uses_session_variable() { + session="my-project" + spy tmuxifier-tmux + + __get_first_window_index > /dev/null + + assert_have_been_called_with tmuxifier-tmux \ + "list-windows -t my-project: -F #{window_index}" +} + +function test_get_first_window_index_includes_trailing_colon_in_target() { + session="test" + spy tmuxifier-tmux + + __get_first_window_index > /dev/null + + # Verify the -t argument includes trailing colon + assert_have_been_called_with tmuxifier-tmux \ + "list-windows -t test: -F #{window_index}" +} + +# +# Edge cases +# + +function test_get_first_window_index_handles_session_with_special_chars() { + session="my-project_v2" + spy tmuxifier-tmux + + __get_first_window_index > /dev/null + + assert_have_been_called_with tmuxifier-tmux \ + "list-windows -t my-project_v2: -F #{window_index}" +} + +function test_get_first_window_index_handles_high_window_index() { + session="test-session" + mock tmuxifier-tmux echo "999" + + local result + result=$(__get_first_window_index) + + assert_same "999" "$result" +} + +function test_get_first_window_index_handles_empty_session_name() { + session="" + spy tmuxifier-tmux + + __get_first_window_index > /dev/null + + assert_have_been_called_with tmuxifier-tmux \ + "list-windows -t : -F #{window_index}" +} diff --git a/tests/lib/layout-helpers/__go_to_window_or_session_path_test.sh b/tests/lib/layout-helpers/__go_to_window_or_session_path_test.sh new file mode 100755 index 0000000..6c368a2 --- /dev/null +++ b/tests/lib/layout-helpers/__go_to_window_or_session_path_test.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# __go_to_window_or_session_path() tests +# + +function set_up() { + # Reset all path-related variables before each test + window_root="" + session_root="" + TMUXIFIER_SESSION_ROOT="" + + # Default session and window for run_cmd context + session="test-session" + window="0" +} + +function tear_down() { + unset window_root session_root TMUXIFIER_SESSION_ROOT + unset session window +} + +# +# No path set +# + +function test_does_nothing_when_no_paths_set() { + spy run_cmd + + __go_to_window_or_session_path + + assert_not_called run_cmd +} + +function test_does_nothing_with_empty_string_paths() { + window_root="" + session_root="" + TMUXIFIER_SESSION_ROOT="" + spy run_cmd + + __go_to_window_or_session_path + + assert_not_called run_cmd +} + +# +# Single path set +# + +function test_uses_session_root_when_only_session_root_is_set() { + session_root="/path/to/session" + spy run_cmd + + __go_to_window_or_session_path + + assert_have_been_called_times 2 run_cmd + assert_have_been_called_with run_cmd ' cd "/path/to/session"' 1 + assert_have_been_called_with run_cmd ' clear' 2 +} + +function test_uses_tmuxifier_session_root_when_only_env_var_is_set() { + TMUXIFIER_SESSION_ROOT="/path/from/env" + spy run_cmd + + __go_to_window_or_session_path + + assert_have_been_called_times 2 run_cmd + assert_have_been_called_with run_cmd ' cd "/path/from/env"' 1 + assert_have_been_called_with run_cmd ' clear' 2 +} + +function test_uses_window_root_when_only_window_root_is_set() { + window_root="/path/to/window" + spy run_cmd + + __go_to_window_or_session_path + + assert_have_been_called_times 2 run_cmd + assert_have_been_called_with run_cmd ' cd "/path/to/window"' 1 + assert_have_been_called_with run_cmd ' clear' 2 +} + +# +# Priority: window_root > TMUXIFIER_SESSION_ROOT > session_root +# + +function test_window_root_takes_priority_over_session_root() { + window_root="/window/path" + session_root="/session/path" + spy run_cmd + + __go_to_window_or_session_path + + assert_have_been_called_with run_cmd ' cd "/window/path"' 1 +} + +function test_tmuxifier_session_root_takes_priority_over_session_root() { + TMUXIFIER_SESSION_ROOT="/env/path" + session_root="/session/path" + spy run_cmd + + __go_to_window_or_session_path + + assert_have_been_called_with run_cmd ' cd "/env/path"' 1 +} + +function test_window_root_takes_priority_over_tmuxifier_session_root() { + window_root="/window/path" + TMUXIFIER_SESSION_ROOT="/env/path" + spy run_cmd + + __go_to_window_or_session_path + + assert_have_been_called_with run_cmd ' cd "/window/path"' 1 +} + +function test_window_root_takes_priority_over_all_other_paths() { + window_root="/window/path" + TMUXIFIER_SESSION_ROOT="/env/path" + session_root="/session/path" + spy run_cmd + + __go_to_window_or_session_path + + assert_have_been_called_with run_cmd ' cd "/window/path"' 1 +} + +# +# Command format +# + +function test_cd_command_has_leading_space_for_history_suppression() { + session_root="/some/path" + spy run_cmd + + __go_to_window_or_session_path + + # Leading space prevents command from being saved in shell history + assert_have_been_called_with run_cmd ' cd "/some/path"' 1 +} + +function test_clear_command_has_leading_space_for_history_suppression() { + session_root="/some/path" + spy run_cmd + + __go_to_window_or_session_path + + # Leading space prevents command from being saved in shell history + assert_have_been_called_with run_cmd ' clear' 2 +} + +function test_path_is_quoted_in_cd_command() { + session_root="/path/with spaces/in it" + spy run_cmd + + __go_to_window_or_session_path + + # Path should be quoted to handle spaces + assert_have_been_called_with run_cmd ' cd "/path/with spaces/in it"' 1 +} + +# +# Edge cases +# + +function test_handles_path_with_special_characters() { + session_root="/path/with\$pecial-chars_123" + spy run_cmd + + __go_to_window_or_session_path + + assert_have_been_called_with run_cmd ' cd "/path/with$pecial-chars_123"' 1 +} + +function test_handles_home_directory_path() { + session_root="$HOME" + spy run_cmd + + __go_to_window_or_session_path + + assert_have_been_called_with run_cmd " cd \"$HOME\"" 1 +} + +function test_always_calls_clear_after_cd() { + window_root="/any/path" + spy run_cmd + + __go_to_window_or_session_path + + # Verify order: cd first, then clear + assert_have_been_called_times 2 run_cmd + assert_have_been_called_with run_cmd ' cd "/any/path"' 1 + assert_have_been_called_with run_cmd ' clear' 2 +} diff --git a/tests/lib/layout-helpers/balance_windows_horizontal_test.sh b/tests/lib/layout-helpers/balance_windows_horizontal_test.sh new file mode 100755 index 0000000..a67ba0a --- /dev/null +++ b/tests/lib/layout-helpers/balance_windows_horizontal_test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# balance_windows_horizontal() tests +# + +function set_up() { + # Default session and window for tests + session="test-session" + window="0" +} + +function tear_down() { + unset session window +} + +function test_balance_windows_horizontal_uses_current_window_by_default() { + spy tmuxifier-tmux + + balance_windows_horizontal + + assert_have_been_called_with \ + tmuxifier-tmux "select-layout -t test-session:0 even-horizontal" +} + +function test_balance_windows_horizontal_with_specific_window() { + spy tmuxifier-tmux + + balance_windows_horizontal 2 + + assert_have_been_called_with \ + tmuxifier-tmux "select-layout -t test-session:2 even-horizontal" +} + +function test_balance_windows_horizontal_with_window_name() { + spy tmuxifier-tmux + + balance_windows_horizontal "editor" + + assert_have_been_called_with \ + tmuxifier-tmux "select-layout -t test-session:editor even-horizontal" +} + +function test_balance_windows_horizontal_with_different_session() { + session="mysession" + window="3" + spy tmuxifier-tmux + + balance_windows_horizontal + + assert_have_been_called_with \ + tmuxifier-tmux "select-layout -t mysession:3 even-horizontal" +} diff --git a/tests/lib/layout-helpers/balance_windows_vertical_test.sh b/tests/lib/layout-helpers/balance_windows_vertical_test.sh new file mode 100755 index 0000000..9de2429 --- /dev/null +++ b/tests/lib/layout-helpers/balance_windows_vertical_test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# balance_windows_vertical() tests +# + +function set_up() { + # Default session and window for tests + session="test-session" + window="0" +} + +function tear_down() { + unset session window +} + +function test_balance_windows_vertical_uses_current_window_by_default() { + spy tmuxifier-tmux + + balance_windows_vertical + + assert_have_been_called_with \ + tmuxifier-tmux "select-layout -t test-session:0 even-vertical" +} + +function test_balance_windows_vertical_with_specific_window() { + spy tmuxifier-tmux + + balance_windows_vertical 2 + + assert_have_been_called_with \ + tmuxifier-tmux "select-layout -t test-session:2 even-vertical" +} + +function test_balance_windows_vertical_with_window_name() { + spy tmuxifier-tmux + + balance_windows_vertical "editor" + + assert_have_been_called_with \ + tmuxifier-tmux "select-layout -t test-session:editor even-vertical" +} + +function test_balance_windows_vertical_with_different_session() { + session="mysession" + window="3" + spy tmuxifier-tmux + + balance_windows_vertical + + assert_have_been_called_with \ + tmuxifier-tmux "select-layout -t mysession:3 even-vertical" +} diff --git a/tests/lib/layout-helpers/clock_test.sh b/tests/lib/layout-helpers/clock_test.sh new file mode 100755 index 0000000..e75db57 --- /dev/null +++ b/tests/lib/layout-helpers/clock_test.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# clock() tests +# + +function set_up() { + # Default session and window for tests + session="test-session" + window="0" +} + +function tear_down() { + unset session window +} + +function test_clock_calls_tmux_clock_mode() { + spy tmuxifier-tmux + + clock + + assert_have_been_called_with tmuxifier-tmux "clock-mode -t test-session:0." +} + +function test_clock_with_target_pane() { + spy tmuxifier-tmux + + clock 1 + + assert_have_been_called_with tmuxifier-tmux "clock-mode -t test-session:0.1" +} + +function test_clock_with_different_session_and_window() { + session="mysession" + window="2" + spy tmuxifier-tmux + + clock 3 + + assert_have_been_called_with tmuxifier-tmux "clock-mode -t mysession:2.3" +} + +function test_clock_with_named_window() { + session="dev" + window="editor" + spy tmuxifier-tmux + + clock 0 + + assert_have_been_called_with tmuxifier-tmux "clock-mode -t dev:editor.0" +} diff --git a/tests/lib/layout-helpers/finalize_and_go_to_session_test.sh b/tests/lib/layout-helpers/finalize_and_go_to_session_test.sh new file mode 100755 index 0000000..4ad556f --- /dev/null +++ b/tests/lib/layout-helpers/finalize_and_go_to_session_test.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# finalize_and_go_to_session() tests +# + +function set_up() { + # Create temp directory for testing + _test_tmp_dir=$(mktemp -d) + + # Save original values + _orig_home="$HOME" + _orig_tmux="$TMUX" + _orig_iterm_attach="$TMUXIFIER_TMUX_ITERM_ATTACH" + HOME="$_test_tmp_dir" + + # Reset variables to known state + session="" + TMUX="" + TMUXIFIER_TMUX_ITERM_ATTACH="" +} + +function tear_down() { + HOME="$_orig_home" + TMUX="$_orig_tmux" + TMUXIFIER_TMUX_ITERM_ATTACH="$_orig_iterm_attach" + unset session + rm -rf "$_test_tmp_dir" +} + +# +# Kill window 999 behavior +# + +function test_finalize_kills_window_999() { + session="mysession" + spy tmuxifier-tmux + mock tmuxifier-current-session echo "mysession" + + finalize_and_go_to_session + + assert_have_been_called_with tmuxifier-tmux "kill-window -t mysession:999" +} + +function test_finalize_continues_when_kill_window_fails() { + session="mysession" + # Simulate kill-window failure (window doesn't exist) + mock tmuxifier-tmux return 1 + mock tmuxifier-current-session echo "mysession" + + local result + finalize_and_go_to_session + result=$? + + # Function should succeed even if kill-window fails due to ! negation + assert_same "0" "$result" +} + +function test_finalize_continues_when_kill_window_succeeds() { + session="mysession" + mock tmuxifier-tmux return 0 + mock tmuxifier-current-session echo "mysession" + + local result + finalize_and_go_to_session + result=$? + + # Function succeeds when kill-window succeeds + # Note: ! negation means exit code is inverted (1 becomes 0, 0 becomes 1) + # but the conditional check for __go_to_session still runs + assert_successful_code "true" +} + +# +# Session switching when current session differs +# + +function test_finalize_calls_attach_when_current_session_differs_and_not_in_tmux() { + session="newsession" + TMUX="" + spy tmuxifier-tmux + mock tmuxifier-current-session echo "othersession" + + finalize_and_go_to_session + + # Should call attach-session when not inside tmux + assert_have_been_called_with tmuxifier-tmux \ + "-u attach-session -t newsession:" 2 +} + +function test_finalize_calls_switch_when_current_session_differs_and_inside_tmux() { + session="newsession" + TMUX="/tmp/tmux-1000/default,12345,0" + spy tmuxifier-tmux + mock tmuxifier-current-session echo "othersession" + + finalize_and_go_to_session + + # Should call switch-client when inside tmux + assert_have_been_called_with tmuxifier-tmux \ + "-u switch-client -t newsession:" 2 +} + +# +# Session switching when current session matches +# + +function test_finalize_does_not_switch_when_current_session_matches() { + session="mysession" + TMUX="" + spy tmuxifier-tmux + mock tmuxifier-current-session echo "mysession" + + finalize_and_go_to_session + + # Should only call kill-window, not attach/switch + assert_have_been_called_times 1 tmuxifier-tmux + assert_have_been_called_with tmuxifier-tmux "kill-window -t mysession:999" 1 +} + +function test_finalize_does_not_switch_when_already_in_session_inside_tmux() { + session="current" + TMUX="/tmp/tmux-1000/default,12345,0" + spy tmuxifier-tmux + mock tmuxifier-current-session echo "current" + + finalize_and_go_to_session + + # Should only call kill-window + assert_have_been_called_times 1 tmuxifier-tmux +} + +# +# iTerm2 integration +# + +function test_finalize_uses_iterm_attach_flag_when_set() { + session="newsession" + TMUX="" + TMUXIFIER_TMUX_ITERM_ATTACH="-CC" + spy tmuxifier-tmux + mock tmuxifier-current-session echo "othersession" + + finalize_and_go_to_session + + # Should include -CC flag for iTerm2 integration + assert_have_been_called_with tmuxifier-tmux \ + "-CC -u attach-session -t newsession:" 2 +} + +function test_finalize_iterm_flag_not_used_when_switching_client() { + session="newsession" + TMUX="/tmp/tmux-1000/default,12345,0" + TMUXIFIER_TMUX_ITERM_ATTACH="-CC" + spy tmuxifier-tmux + mock tmuxifier-current-session echo "othersession" + + finalize_and_go_to_session + + # switch-client doesn't use ITERM_ATTACH + assert_have_been_called_with tmuxifier-tmux \ + "-u switch-client -t newsession:" 2 +} + +# +# Edge cases +# + +function test_finalize_handles_session_with_special_characters() { + session="my-project_v2.0" + spy tmuxifier-tmux + mock tmuxifier-current-session echo "my-project_v2.0" + + finalize_and_go_to_session + + assert_have_been_called_with tmuxifier-tmux \ + "kill-window -t my-project_v2.0:999" +} + +function test_finalize_handles_empty_current_session_output() { + session="newsession" + TMUX="" + spy tmuxifier-tmux + # Empty output from tmuxifier-current-session (not in any session) + mock tmuxifier-current-session echo "" + + finalize_and_go_to_session + + # Empty != "newsession", so should call attach + assert_have_been_called_with tmuxifier-tmux \ + "-u attach-session -t newsession:" 2 +} + +function test_finalize_handles_session_name_with_spaces() { + session="my session" + spy tmuxifier-tmux + mock tmuxifier-current-session echo "my session" + + finalize_and_go_to_session + + assert_have_been_called_with tmuxifier-tmux \ + "kill-window -t my session:999" +} + +# +# Integration-style tests +# + +function test_finalize_full_flow_when_session_exists_and_matches() { + session="existing" + TMUX="" + spy tmuxifier-tmux + mock tmuxifier-current-session echo "existing" + + finalize_and_go_to_session + + # Verify single call to kill-window only + assert_have_been_called_times 1 tmuxifier-tmux + assert_have_been_called_with tmuxifier-tmux "kill-window -t existing:999" 1 +} + +function test_finalize_full_flow_when_session_needs_attach() { + session="newproject" + TMUX="" + spy tmuxifier-tmux + mock tmuxifier-current-session echo "" + + finalize_and_go_to_session + + # Verify both calls: kill-window and attach-session + assert_have_been_called_times 2 tmuxifier-tmux + assert_have_been_called_with tmuxifier-tmux \ + "kill-window -t newproject:999" 1 + assert_have_been_called_with tmuxifier-tmux \ + "-u attach-session -t newproject:" 2 +} + +function test_finalize_full_flow_when_switching_from_another_session() { + session="target" + TMUX="/tmp/tmux-1000/default,12345,0" + spy tmuxifier-tmux + mock tmuxifier-current-session echo "source" + + finalize_and_go_to_session + + # Verify both calls: kill-window and switch-client + assert_have_been_called_times 2 tmuxifier-tmux + assert_have_been_called_with tmuxifier-tmux \ + "kill-window -t target:999" 1 + assert_have_been_called_with tmuxifier-tmux \ + "-u switch-client -t target:" 2 +} diff --git a/tests/lib/layout-helpers/initialize_session_test.sh b/tests/lib/layout-helpers/initialize_session_test.sh new file mode 100755 index 0000000..795606a --- /dev/null +++ b/tests/lib/layout-helpers/initialize_session_test.sh @@ -0,0 +1,430 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# initialize_session() tests +# + +function set_up() { + # Create temp directory for testing + _test_tmp_dir=$(mktemp -d) + + # Save original values + _orig_home="$HOME" + HOME="$_test_tmp_dir" + + # Reset variables to known state + session="" + session_root="$HOME" + set_default_path=true + window="" + TMUX="" +} + +function tear_down() { + HOME="$_orig_home" + unset session session_root set_default_path window + rm -rf "$_test_tmp_dir" +} + +# +# Session name handling +# + +function test_initialize_session_uses_session_variable_when_no_argument() { + session="my-session" + mock tmuxifier-tmux echo "" + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + initialize_session + + assert_same "my-session" "$session" +} + +function test_initialize_session_uses_argument_as_session_name() { + session="" + mock tmuxifier-tmux echo "" + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + initialize_session "custom-session" + + assert_same "custom-session" "$session" +} + +function test_initialize_session_overrides_session_variable_with_argument() { + session="original" + mock tmuxifier-tmux echo "" + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + initialize_session "override" + + assert_same "override" "$session" +} + +# +# Server startup +# + +function test_initialize_session_starts_tmux_server() { + session="test" + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + initialize_session + + assert_have_been_called_with tmuxifier-tmux "start-server" 1 +} + +# +# Session existence check +# + +function test_initialize_session_returns_1_when_session_exists() { + session="existing" + # Mock list-sessions to return a matching session (output is also used by + # start-server but ignored there) + mock tmuxifier-tmux printf '%s\n' "existing: 1 windows" + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + local result + initialize_session + result=$? + + assert_same "1" "$result" +} + +function test_initialize_session_returns_0_when_session_does_not_exist() { + session="newsession" + mock tmuxifier-tmux echo "" + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + local result + initialize_session + result=$? + + assert_same "0" "$result" +} + +function test_initialize_session_checks_exact_session_name_match() { + session="test" + # Return a session with similar but different name - grep pattern "^test:" + # won't match "test-other:" + mock tmuxifier-tmux printf '%s\n' "test-other: 1 windows" + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + local result + initialize_session + result=$? + + # Should succeed because "test:" pattern doesn't match "test-other:" + assert_same "0" "$result" +} + +# +# Tmux 1.9+ behavior (modern tmux) +# + +function test_initialize_session_creates_session_with_c_flag_for_tmux_19_plus() { + session="newsession" + session_root="$_test_tmp_dir" + set_default_path=true + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + initialize_session + + # Calls: start-server(1), list-sessions(2), new-session(3), setenv(4), + # move-window(5) + assert_have_been_called_with tmuxifier-tmux \ + "new-session -d -s newsession -c $_test_tmp_dir" 3 +} + +function test_initialize_session_omits_c_flag_when_set_default_path_false() { + session="newsession" + session_root="$_test_tmp_dir" + set_default_path=false + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + initialize_session + + # Calls: start-server(1), list-sessions(2), new-session(3), move-window(4) + assert_have_been_called_with tmuxifier-tmux \ + "new-session -d -s newsession" 3 +} + +# +# Tmux 1.8 and earlier behavior (legacy tmux) +# + +function test_initialize_session_creates_session_without_c_for_tmux_18() { + session="newsession" + session_root="$_test_tmp_dir" + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< "<" + mock __get_first_window_index echo "0" + + initialize_session + + # Calls: start-server(1), list-sessions(2), new-session(3), ... + assert_have_been_called_with tmuxifier-tmux \ + "new-session -d -s newsession" 3 +} + +function test_initialize_session_sets_default_path_option_for_tmux_18() { + session="newsession" + session_root="$_test_tmp_dir" + set_default_path=true + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< "<" + mock __get_first_window_index echo "0" + + initialize_session + + # Calls: start-server(1), list-sessions(2), new-session(3), set-option(4), + # setenv(5), move-window(6) + assert_have_been_called_with tmuxifier-tmux \ + "set-option -t newsession: default-path $_test_tmp_dir" 4 +} + +function test_initialize_session_skips_default_path_when_set_default_path_false() { + session="newsession" + session_root="$_test_tmp_dir" + set_default_path=false + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< "<" + mock __get_first_window_index echo "0" + + initialize_session + + # Should have 4 calls: start-server, list-sessions, new-session, move-window + # (no set-option default-path call, no setenv call) + assert_have_been_called_times 4 tmuxifier-tmux + assert_have_been_called_with tmuxifier-tmux "start-server" 1 + assert_have_been_called_with tmuxifier-tmux "list-sessions" 2 + assert_have_been_called_with tmuxifier-tmux "new-session -d -s newsession" 3 + assert_have_been_called_with tmuxifier-tmux \ + "move-window -s newsession:0 -t newsession:999" 4 +} + +# +# Session root environment variable +# + +function test_initialize_session_sets_session_root_env_when_not_home() { + session="newsession" + # Use a subdirectory so session_root != HOME + mkdir -p "$_test_tmp_dir/project" + session_root="$_test_tmp_dir/project" + set_default_path=true + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + initialize_session + + # Calls: start-server(1), list-sessions(2), new-session(3), setenv(4), + # move-window(5) + assert_have_been_called_with tmuxifier-tmux \ + "setenv -t newsession: TMUXIFIER_SESSION_ROOT $_test_tmp_dir/project" 4 +} + +function test_initialize_session_skips_session_root_env_when_equal_to_home() { + session="newsession" + session_root="$HOME" + set_default_path=true + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + initialize_session + + # Should have 4 calls (no setenv call when session_root == HOME) + assert_have_been_called_times 4 tmuxifier-tmux + assert_have_been_called_with tmuxifier-tmux "start-server" 1 + assert_have_been_called_with tmuxifier-tmux "list-sessions" 2 + assert_have_been_called_with tmuxifier-tmux \ + "new-session -d -s newsession -c $HOME" 3 + assert_have_been_called_with tmuxifier-tmux \ + "move-window -s newsession:0 -t newsession:999" 4 +} + +function test_initialize_session_skips_session_root_env_when_set_default_path_false() { + session="newsession" + session_root="$_test_tmp_dir" + set_default_path=false + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + initialize_session + + # Should have 4 calls (no setenv call when set_default_path is false) + assert_have_been_called_times 4 tmuxifier-tmux + assert_have_been_called_with tmuxifier-tmux "start-server" 1 + assert_have_been_called_with tmuxifier-tmux "list-sessions" 2 + assert_have_been_called_with tmuxifier-tmux "new-session -d -s newsession" 3 + assert_have_been_called_with tmuxifier-tmux \ + "move-window -s newsession:0 -t newsession:999" 4 +} + +# +# Default window handling +# + +function test_initialize_session_moves_default_window_to_position_999() { + session="newsession" + session_root="$HOME" + set_default_path=true + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + initialize_session + + assert_have_been_called_with tmuxifier-tmux \ + "move-window -s newsession:0 -t newsession:999" +} + +function test_initialize_session_uses_first_window_index_for_move() { + session="newsession" + session_root="$HOME" + set_default_path=true + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "1" + + initialize_session + + # Should use the actual first window index (1 in this case) + assert_have_been_called_with tmuxifier-tmux \ + "move-window -s newsession:1 -t newsession:999" +} + +# +# Integration-style tests +# + +function test_initialize_session_full_flow_tmux_19_returns_success() { + session="myproject" + mkdir -p "$_test_tmp_dir/project" + session_root="$_test_tmp_dir/project" + set_default_path=true + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + local result + initialize_session + result=$? + + assert_same "0" "$result" + assert_same "myproject" "$session" +} + +function test_initialize_session_full_flow_tmux_19_calls_expected_commands() { + session="myproject" + mkdir -p "$_test_tmp_dir/project" + session_root="$_test_tmp_dir/project" + set_default_path=true + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + initialize_session + + # Verify all expected calls + assert_have_been_called_times 5 tmuxifier-tmux + assert_have_been_called_with tmuxifier-tmux "start-server" 1 + assert_have_been_called_with tmuxifier-tmux "list-sessions" 2 + assert_have_been_called_with tmuxifier-tmux \ + "new-session -d -s myproject -c $_test_tmp_dir/project" 3 + assert_have_been_called_with tmuxifier-tmux \ + "setenv -t myproject: TMUXIFIER_SESSION_ROOT $_test_tmp_dir/project" 4 + assert_have_been_called_with tmuxifier-tmux \ + "move-window -s myproject:0 -t myproject:999" 5 +} + +function test_initialize_session_full_flow_tmux_18_returns_success() { + session="oldproject" + mkdir -p "$_test_tmp_dir/project" + session_root="$_test_tmp_dir/project" + set_default_path=true + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< "<" + mock __get_first_window_index echo "0" + + local result + initialize_session + result=$? + + assert_same "0" "$result" + assert_same "oldproject" "$session" +} + +function test_initialize_session_full_flow_tmux_18_calls_expected_commands() { + session="oldproject" + mkdir -p "$_test_tmp_dir/project" + session_root="$_test_tmp_dir/project" + set_default_path=true + spy tmuxifier-tmux + mock tmuxifier-tmux-version <<< "<" + mock __get_first_window_index echo "0" + + initialize_session + + # Verify all expected calls + assert_have_been_called_times 6 tmuxifier-tmux + assert_have_been_called_with tmuxifier-tmux "start-server" 1 + assert_have_been_called_with tmuxifier-tmux "list-sessions" 2 + assert_have_been_called_with tmuxifier-tmux \ + "new-session -d -s oldproject" 3 + assert_have_been_called_with tmuxifier-tmux \ + "set-option -t oldproject: default-path $_test_tmp_dir/project" 4 + assert_have_been_called_with tmuxifier-tmux \ + "setenv -t oldproject: TMUXIFIER_SESSION_ROOT $_test_tmp_dir/project" 5 + assert_have_been_called_with tmuxifier-tmux \ + "move-window -s oldproject:0 -t oldproject:999" 6 +} + +# +# Edge cases +# + +function test_initialize_session_does_not_create_when_session_already_exists() { + session="existing" + mock tmuxifier-tmux printf '%s\n' "existing: 1 windows" + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + local result + initialize_session + result=$? + + assert_same "1" "$result" +} + +function test_initialize_session_handles_session_with_special_chars() { + session="my-project_v2" + mock tmuxifier-tmux echo "" + mock tmuxifier-tmux-version <<< ">" + mock __get_first_window_index echo "0" + + local result + initialize_session + result=$? + + assert_same "0" "$result" + assert_same "my-project_v2" "$session" +} diff --git a/tests/lib/layout-helpers/load_session_test.sh b/tests/lib/layout-helpers/load_session_test.sh new file mode 100755 index 0000000..dd93fa7 --- /dev/null +++ b/tests/lib/layout-helpers/load_session_test.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# load_session() tests +# + +function set_up() { + # Create temp directory structure for testing (per-test for parallel support) + _test_tmp_dir=$(mktemp -d) + _test_layout_path="${_test_tmp_dir}/layouts" + mkdir -p "$_test_layout_path" + + # Create a simple session layout file in layout path + cat > "${_test_layout_path}/mysession.session.sh" << 'EOF' +_test_layout_sourced="mysession" +EOF + + # Create layout file with .sh extension only + cat > "${_test_layout_path}/other.sh" << 'EOF' +_test_layout_sourced="other" +EOF + + # Create a layout file as a direct path (with slash) + cat > "${_test_tmp_dir}/direct.session.sh" << 'EOF' +_test_layout_sourced="direct" +EOF + + # Save original values and set test values + _orig_layout_path="$TMUXIFIER_LAYOUT_PATH" + _orig_home="$HOME" + TMUXIFIER_LAYOUT_PATH="$_test_layout_path" + + # Reset variables + session="" + session_root="" + set_default_path="" + _test_layout_sourced="" +} + +function tear_down() { + TMUXIFIER_LAYOUT_PATH="$_orig_layout_path" + HOME="$_orig_home" + unset session session_root set_default_path _test_layout_sourced + rm -rf "$_test_tmp_dir" +} + +function test_load_session_finds_layout_by_name_in_layout_path() { + load_session "mysession" + + assert_same "mysession" "$_test_layout_sourced" +} + +function test_load_session_finds_layout_by_direct_file_path() { + load_session "${_test_tmp_dir}/direct.session.sh" + + assert_same "direct" "$_test_layout_sourced" +} + +function test_load_session_sets_session_from_name_stripping_session_sh() { + # Capture session value during source, before it's reset + cat > "${_test_layout_path}/capture.session.sh" << 'EOF' +_captured_session="$session" +EOF + + load_session "capture" + + assert_same "capture" "$_captured_session" +} + +function test_load_session_sets_session_from_path_stripping_session_sh() { + cat > "${_test_tmp_dir}/pathtest.session.sh" << 'EOF' +_captured_session="$session" +EOF + + load_session "${_test_tmp_dir}/pathtest.session.sh" + + assert_same "${_test_tmp_dir}/pathtest" "$_captured_session" +} + +function test_load_session_uses_override_name_when_provided() { + cat > "${_test_layout_path}/named.session.sh" << 'EOF' +_captured_session="$session" +EOF + + load_session "named" "custom-session" + + assert_same "custom-session" "$_captured_session" +} + +function test_load_session_resets_session_variable_after_load() { + load_session "mysession" + + assert_empty "$session" +} + +function test_load_session_sets_set_default_path_to_true() { + cat > "${_test_layout_path}/checkpath.session.sh" << 'EOF' +_captured_set_default_path="$set_default_path" +EOF + + load_session "checkpath" + + assert_same "true" "$_captured_set_default_path" +} + +function test_load_session_resets_session_root_to_home_when_different() { + HOME="$_test_tmp_dir" + session_root="${_test_tmp_dir}/layouts" + + load_session "mysession" + + assert_same "$_test_tmp_dir" "$session_root" +} + +function test_load_session_does_not_reset_session_root_when_equal_to_home() { + HOME="$_test_tmp_dir" + session_root="$_test_tmp_dir" + + # Create a layout that changes session_root + cat > "${_test_layout_path}/nochange.session.sh" << 'EOF' +# This layout doesn't change session_root +EOF + + load_session "nochange" + + # session_root should still be the same (HOME) + assert_same "$_test_tmp_dir" "$session_root" +} + +function test_load_session_returns_1_when_file_not_found() { + load_session "nonexistent" 2> /dev/null + local exit_code=$? + + assert_same "1" "$exit_code" +} + +function test_load_session_prints_error_to_stderr_when_not_found() { + local stderr_output + stderr_output=$(load_session "nonexistent" 2>&1 > /dev/null) + + assert_contains "nonexistent" "$stderr_output" + assert_contains "not found" "$stderr_output" +} + +function test_load_session_sources_file_content() { + cat > "${_test_layout_path}/content.session.sh" << 'EOF' +_test_var_one="session_value1" +_test_var_two="session_value2" +EOF + + load_session "content" + + assert_same "session_value1" "$_test_var_one" + assert_same "session_value2" "$_test_var_two" +} + +function test_load_session_prefers_layout_path_when_no_slash_in_name() { + # Create a file in layout path + cat > "${_test_layout_path}/conflict.session.sh" << 'EOF' +_test_layout_sourced="from_layout_path" +EOF + + # When given name without slash, should use layout path + load_session "conflict" + + assert_same "from_layout_path" "$_test_layout_sourced" +} + +function test_load_session_uses_direct_path_when_slash_present() { + mkdir -p "${_test_layout_path}/sub" + cat > "${_test_layout_path}/sub/nested.session.sh" << 'EOF' +_test_layout_sourced="from_nested" +EOF + + # When given path with slash, should use it directly + load_session "${_test_layout_path}/sub/nested.session.sh" + + assert_same "from_nested" "$_test_layout_sourced" +} + +function test_load_session_handles_relative_path_in_current_dir() { + # Save current directory and change to temp dir + local orig_pwd="$PWD" + cd "$_test_tmp_dir" + + # Create a file without .session.sh suffix in current dir + cat > "localfile.sh" << 'EOF' +_test_layout_sourced="from_local" +EOF + + # When file exists locally and no slash, it should prepend ./ + load_session "localfile.sh" + + assert_same "from_local" "$_test_layout_sourced" + + cd "$orig_pwd" +} diff --git a/tests/lib/layout-helpers/load_window_test.sh b/tests/lib/layout-helpers/load_window_test.sh new file mode 100755 index 0000000..069a347 --- /dev/null +++ b/tests/lib/layout-helpers/load_window_test.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# load_window() tests +# + +function set_up() { + # Create temp directory structure for testing (per-test for parallel support) + _test_tmp_dir=$(mktemp -d) + _test_layout_path="${_test_tmp_dir}/layouts" + mkdir -p "$_test_layout_path" + + # Create a simple window layout file in layout path + cat > "${_test_layout_path}/mywindow.window.sh" << 'EOF' +_test_layout_sourced="mywindow" +EOF + + # Create layout file with .sh extension only + cat > "${_test_layout_path}/other.sh" << 'EOF' +_test_layout_sourced="other" +EOF + + # Create a layout file as a direct path + cat > "${_test_tmp_dir}/direct.window.sh" << 'EOF' +_test_layout_sourced="direct" +EOF + + # Create a layout that modifies window_root + cat > "${_test_layout_path}/chroot.window.sh" << 'EOF' +_test_layout_sourced="chroot" +EOF + + # Save original TMUXIFIER_LAYOUT_PATH and set test value + _orig_layout_path="$TMUXIFIER_LAYOUT_PATH" + TMUXIFIER_LAYOUT_PATH="$_test_layout_path" + + # Reset variables + window="" + window_root="" + session_root="" + _test_layout_sourced="" +} + +function tear_down() { + TMUXIFIER_LAYOUT_PATH="$_orig_layout_path" + unset window window_root session_root _test_layout_sourced + rm -rf "$_test_tmp_dir" +} + +function test_load_window_finds_layout_by_name_in_layout_path() { + load_window "mywindow" + + assert_same "mywindow" "$_test_layout_sourced" +} + +function test_load_window_finds_layout_by_direct_file_path() { + load_window "${_test_tmp_dir}/direct.window.sh" + + assert_same "direct" "$_test_layout_sourced" +} + +function test_load_window_sets_window_from_name_stripping_window_sh() { + # We need to capture window value during source, before it's reset + cat > "${_test_layout_path}/capture.window.sh" << 'EOF' +_captured_window="$window" +EOF + + load_window "capture" + + assert_same "capture" "$_captured_window" +} + +function test_load_window_sets_window_from_name_stripping_sh_only() { + cat > "${_test_layout_path}/simple.sh" << 'EOF' +_captured_window="$window" +EOF + + # Load by direct path to test .sh stripping + load_window "${_test_layout_path}/simple.sh" + + assert_same "${_test_layout_path}/simple" "$_captured_window" +} + +function test_load_window_uses_override_name_when_provided() { + cat > "${_test_layout_path}/named.window.sh" << 'EOF' +_captured_window="$window" +EOF + + load_window "named" "custom-name" + + assert_same "custom-name" "$_captured_window" +} + +function test_load_window_resets_window_variable_after_load() { + load_window "mywindow" + + assert_empty "$window" +} + +function test_load_window_resets_window_root_when_different_from_session_root() { + session_root="$_test_tmp_dir" + window_root="${_test_tmp_dir}/layouts" + + # Mock the window_root function to track if it's called + _window_root_called="" + _window_root_arg="" + function window_root() { + _window_root_called="yes" + _window_root_arg="$1" + } + + load_window "mywindow" + + assert_same "yes" "$_window_root_called" + assert_same "$_test_tmp_dir" "$_window_root_arg" +} + +function test_load_window_does_not_reset_window_root_when_equal_to_session_root() { + session_root="$_test_tmp_dir" + window_root="$_test_tmp_dir" + + _window_root_called="" + function window_root() { + _window_root_called="yes" + } + + load_window "mywindow" + + assert_empty "$_window_root_called" +} + +function test_load_window_returns_1_when_file_not_found() { + load_window "nonexistent" 2> /dev/null + local exit_code=$? + + assert_same "1" "$exit_code" +} + +function test_load_window_prints_error_to_stderr_when_not_found() { + local stderr_output + stderr_output=$(load_window "nonexistent" 2>&1 > /dev/null) + + assert_contains "nonexistent" "$stderr_output" + assert_contains "not found" "$stderr_output" +} + +function test_load_window_sources_file_content() { + cat > "${_test_layout_path}/content.window.sh" << 'EOF' +_test_var_one="value1" +_test_var_two="value2" +EOF + + load_window "content" + + assert_same "value1" "$_test_var_one" + assert_same "value2" "$_test_var_two" +} + +function test_load_window_prefers_direct_file_over_layout_path() { + # Create a file that would match both direct path and layout path lookup + cat > "${_test_layout_path}/conflict.window.sh" << 'EOF' +_test_layout_sourced="from_layout_path" +EOF + cat > "${_test_tmp_dir}/conflict.window.sh" << 'EOF' +_test_layout_sourced="from_direct_path" +EOF + + # When given as direct path, should use direct file + load_window "${_test_tmp_dir}/conflict.window.sh" + + assert_same "from_direct_path" "$_test_layout_sourced" +} diff --git a/tests/lib/layout-helpers/new_window_test.sh b/tests/lib/layout-helpers/new_window_test.sh new file mode 100755 index 0000000..8f155fb --- /dev/null +++ b/tests/lib/layout-helpers/new_window_test.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# new_window() tests +# + +function set_up() { + session="test-session" + window="" +} + +function tear_down() { + unset session window +} + +function test_new_window_calls_tmux_new_window() { + spy tmuxifier-tmux + spy __go_to_window_or_session_path + mock __get_current_window_index echo "1" + + new_window + + assert_have_been_called_with tmuxifier-tmux "new-window -t test-session:" +} + +function test_new_window_calls_go_to_window_or_session_path() { + spy tmuxifier-tmux + spy __go_to_window_or_session_path + mock __get_current_window_index echo "1" + + new_window + + assert_have_been_called __go_to_window_or_session_path +} + +function test_new_window_sets_window_variable_to_current_index() { + spy tmuxifier-tmux + spy __go_to_window_or_session_path + mock __get_current_window_index echo "5" + + new_window + + assert_same "5" "$window" +} + +function test_new_window_with_name_includes_n_flag() { + spy tmuxifier-tmux + spy __go_to_window_or_session_path + mock __get_current_window_index echo "1" + + new_window "mywindow" + + assert_have_been_called_with tmuxifier-tmux "new-window -t test-session: -n mywindow" 1 +} + +function test_new_window_with_name_disables_allow_rename() { + spy tmuxifier-tmux + spy __go_to_window_or_session_path + mock __get_current_window_index echo "1" + + new_window "mywindow" + + assert_have_been_called_with tmuxifier-tmux "set-option -t mywindow allow-rename off" 2 +} + +function test_new_window_with_name_and_command() { + spy tmuxifier-tmux + spy __go_to_window_or_session_path + mock __get_current_window_index echo "1" + + new_window "editor" "vim" + + assert_have_been_called_with \ + tmuxifier-tmux "new-window -t test-session: -n editor vim" 1 +} + +function test_new_window_with_only_command_via_empty_name() { + spy tmuxifier-tmux + spy __go_to_window_or_session_path + mock __get_current_window_index echo "1" + + new_window "" "htop" + + assert_have_been_called_with tmuxifier-tmux "new-window -t test-session: htop" +} + +function test_new_window_without_name_does_not_disable_rename() { + spy tmuxifier-tmux + spy __go_to_window_or_session_path + mock __get_current_window_index echo "1" + + new_window + + # Only one call to tmuxifier-tmux (new-window), no set-option call + assert_have_been_called_times 1 tmuxifier-tmux +} diff --git a/tests/lib/layout-helpers/run_cmd_test.sh b/tests/lib/layout-helpers/run_cmd_test.sh new file mode 100755 index 0000000..8367bd4 --- /dev/null +++ b/tests/lib/layout-helpers/run_cmd_test.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# run_cmd() tests +# + +function set_up() { + # Default session and window for tests + session="test-session" + window="0" +} + +function tear_down() { + unset session window +} + +function test_run_cmd_sends_command_then_enter() { + spy tmuxifier-tmux + + run_cmd "ls -la" + + assert_have_been_called_times 2 tmuxifier-tmux +} + +function test_run_cmd_first_call_sends_command() { + spy tmuxifier-tmux + + run_cmd "ls -la" + + assert_have_been_called_with \ + tmuxifier-tmux "send-keys -t test-session:0. ls -la" 1 +} + +function test_run_cmd_second_call_sends_enter_key() { + spy tmuxifier-tmux + + run_cmd "ls -la" + + assert_have_been_called_with \ + tmuxifier-tmux "send-keys -t test-session:0. C-m" 2 +} + +function test_run_cmd_with_target_pane() { + spy tmuxifier-tmux + + run_cmd "echo hello" 1 + + assert_have_been_called_with \ + tmuxifier-tmux "send-keys -t test-session:0.1 echo hello" 1 + assert_have_been_called_with \ + tmuxifier-tmux "send-keys -t test-session:0.1 C-m" 2 +} + +function test_run_cmd_with_different_session_and_window() { + session="mysession" + window="2" + spy tmuxifier-tmux + + run_cmd "npm start" 3 + + assert_have_been_called_with \ + tmuxifier-tmux "send-keys -t mysession:2.3 npm start" 1 + assert_have_been_called_with \ + tmuxifier-tmux "send-keys -t mysession:2.3 C-m" 2 +} + +function test_run_cmd_with_complex_command() { + spy tmuxifier-tmux + + run_cmd "cd /tmp && ls" + + assert_have_been_called_with \ + tmuxifier-tmux "send-keys -t test-session:0. cd /tmp && ls" 1 + assert_have_been_called_with \ + tmuxifier-tmux "send-keys -t test-session:0. C-m" 2 +} diff --git a/tests/lib/layout-helpers/select_pane_test.sh b/tests/lib/layout-helpers/select_pane_test.sh new file mode 100755 index 0000000..46f8b84 --- /dev/null +++ b/tests/lib/layout-helpers/select_pane_test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# select_pane() tests +# + +function set_up() { + # Default session and window for tests + session="test-session" + window="0" +} + +function tear_down() { + unset session window +} + +function test_select_pane_calls_tmux_select_pane() { + spy tmuxifier-tmux + + select_pane 1 + + assert_have_been_called_with \ + tmuxifier-tmux "select-pane -t test-session:0.1" +} + +function test_select_pane_with_pane_zero() { + spy tmuxifier-tmux + + select_pane 0 + + assert_have_been_called_with \ + tmuxifier-tmux "select-pane -t test-session:0.0" +} + +function test_select_pane_with_different_session_and_window() { + session="mysession" + window="2" + spy tmuxifier-tmux + + select_pane 3 + + assert_have_been_called_with tmuxifier-tmux "select-pane -t mysession:2.3" +} + +function test_select_pane_with_named_window() { + session="dev" + window="editor" + spy tmuxifier-tmux + + select_pane 1 + + assert_have_been_called_with tmuxifier-tmux "select-pane -t dev:editor.1" +} diff --git a/tests/lib/layout-helpers/select_window_test.sh b/tests/lib/layout-helpers/select_window_test.sh new file mode 100755 index 0000000..e14053b --- /dev/null +++ b/tests/lib/layout-helpers/select_window_test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# select_window() tests +# + +function set_up() { + # Default session and window for tests + session="test-session" + window="0" +} + +function tear_down() { + unset session window +} + +function test_select_window_calls_tmux_select_window() { + spy tmuxifier-tmux + mock __get_current_window_index echo "1" + + select_window 1 + + assert_have_been_called_with tmuxifier-tmux "select-window -t test-session:1" +} + +function test_select_window_with_window_name() { + spy tmuxifier-tmux + mock __get_current_window_index echo "editor" + + select_window "editor" + + assert_have_been_called_with \ + tmuxifier-tmux "select-window -t test-session:editor" +} + +function test_select_window_updates_window_variable() { + spy tmuxifier-tmux + mock __get_current_window_index echo "5" + + select_window 5 + + assert_equals "5" "$window" +} + +function test_select_window_with_different_session() { + session="mysession" + spy tmuxifier-tmux + mock __get_current_window_index echo "2" + + select_window 2 + + assert_have_been_called_with tmuxifier-tmux "select-window -t mysession:2" +} diff --git a/tests/lib/layout-helpers/send_keys_test.sh b/tests/lib/layout-helpers/send_keys_test.sh new file mode 100755 index 0000000..abf6d19 --- /dev/null +++ b/tests/lib/layout-helpers/send_keys_test.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# send_keys() tests +# + +function set_up() { + # Default session and window for tests + session="test-session" + window="0" +} + +function tear_down() { + unset session window +} + +function test_send_keys_sends_string_to_current_pane() { + spy tmuxifier-tmux + + send_keys "hello" + + assert_have_been_called_with \ + tmuxifier-tmux "send-keys -t test-session:0. hello" +} + +function test_send_keys_with_target_pane() { + spy tmuxifier-tmux + + send_keys "hello" 1 + + assert_have_been_called_with \ + tmuxifier-tmux "send-keys -t test-session:0.1 hello" +} + +function test_send_keys_with_special_key() { + spy tmuxifier-tmux + + send_keys "C-m" + + assert_have_been_called_with \ + tmuxifier-tmux "send-keys -t test-session:0. C-m" +} + +function test_send_keys_with_command_string() { + spy tmuxifier-tmux + + send_keys "ls -la" + + assert_have_been_called_with \ + tmuxifier-tmux "send-keys -t test-session:0. ls -la" +} + +function test_send_keys_with_different_session_and_window() { + session="mysession" + window="2" + spy tmuxifier-tmux + + send_keys "echo test" 3 + + assert_have_been_called_with \ + tmuxifier-tmux "send-keys -t mysession:2.3 echo test" +} + +function test_send_keys_with_named_window() { + session="dev" + window="editor" + spy tmuxifier-tmux + + send_keys "vim ." 0 + + assert_have_been_called_with \ + tmuxifier-tmux "send-keys -t dev:editor.0 vim ." +} diff --git a/tests/lib/layout-helpers/session_root_test.sh b/tests/lib/layout-helpers/session_root_test.sh new file mode 100755 index 0000000..0ad9e32 --- /dev/null +++ b/tests/lib/layout-helpers/session_root_test.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# session_root() tests +# + +function set_up() { + # Create temp directories for testing (per-test for parallel support) + _test_tmp_dir=$(mktemp -d) + _test_valid_dir="${_test_tmp_dir}/valid" + mkdir -p "$_test_valid_dir" + _test_nonexistent_dir="${_test_tmp_dir}/nonexistent" + + # Reset session_root variable before each test + session_root="" +} + +function tear_down() { + unset session_root + rm -rf "$_test_tmp_dir" +} + +function test_session_root_sets_variable_for_existing_directory() { + # Mock __expand_path to return the valid directory + mock __expand_path echo "$_test_valid_dir" + + session_root "~/some/path" + + assert_same "$_test_valid_dir" "$session_root" +} + +function test_session_root_does_not_set_variable_for_nonexistent_directory() { + # Mock __expand_path to return a nonexistent directory + mock __expand_path echo "$_test_nonexistent_dir" + + session_root "~/nonexistent" + + assert_empty "$session_root" +} + +function test_session_root_calls_expand_path_with_arguments() { + spy __expand_path + # Since spy doesn't return anything, the dir check will fail + # but we can still verify __expand_path was called + + session_root "~/Projects" + + assert_have_been_called_with __expand_path "~/Projects" +} + +function test_session_root_passes_multiple_arguments_to_expand_path() { + spy __expand_path + + session_root '~/$USER/path' + + assert_have_been_called_with __expand_path '~/$USER/path' +} + +function test_session_root_preserves_existing_value_on_invalid_path() { + session_root="$_test_valid_dir" + mock __expand_path echo "$_test_nonexistent_dir" + + session_root "~/invalid" + + # Original value should be preserved since new path doesn't exist + assert_same "$_test_valid_dir" "$session_root" +} + +function test_session_root_overwrites_existing_value_on_valid_path() { + local new_dir="${_test_tmp_dir}/another" + mkdir -p "$new_dir" + session_root="$_test_valid_dir" + mock __expand_path echo "$new_dir" + + session_root "~/another" + + assert_same "$new_dir" "$session_root" +} + +function test_session_root_handles_home_directory() { + # Use actual HOME which should exist + mock __expand_path echo "$HOME" + + session_root "~" + + assert_same "$HOME" "$session_root" +} diff --git a/tests/lib/layout-helpers/split_h_test.sh b/tests/lib/layout-helpers/split_h_test.sh new file mode 100755 index 0000000..8ff4cd9 --- /dev/null +++ b/tests/lib/layout-helpers/split_h_test.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# split_h() tests +# + +function set_up() { + # Default session and window for tests + session="test-session" + window="0" +} + +function tear_down() { + unset session window +} + +function test_split_h_calls_tmux_then_go_to_path() { + local calls=() + function tmuxifier-tmux() { calls+=("tmuxifier-tmux:$*"); } + function __go_to_window_or_session_path() { calls+=("go_to_path"); } + + split_h + + assert_equals "tmuxifier-tmux:split-window -t test-session:0. -h" "${calls[0]}" + assert_equals "go_to_path" "${calls[1]}" +} + +# +# Tmux 3.1+ tests (uses -l with % suffix) +# + +function test_split_h_tmux_31_with_percentage_uses_l_flag() { + mock tmuxifier-tmux-version <<< ">" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_h 30 + + assert_have_been_called_with \ + tmuxifier-tmux "split-window -t test-session:0. -h -l 30%" +} + +function test_split_h_tmux_31_with_percentage_and_target_pane() { + session="mysession" + window="2" + mock tmuxifier-tmux-version <<< ">" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_h 50 1 + + assert_have_been_called_with \ + tmuxifier-tmux "split-window -t mysession:2.1 -h -l 50%" +} + +function test_split_h_tmux_31_with_only_target_pane_empty_percentage() { + session="test" + window="1" + mock tmuxifier-tmux-version <<< ">" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_h "" 2 + + assert_have_been_called_with tmuxifier-tmux "split-window -t test:1.2 -h" +} + +# +# Tmux 3.0 and earlier tests (uses -p flag) +# + +function test_split_h_tmux_30_with_percentage_uses_p_flag() { + mock tmuxifier-tmux-version <<< "=" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_h 30 + + assert_have_been_called_with \ + tmuxifier-tmux "split-window -t test-session:0. -h -p 30" +} + +function test_split_h_tmux_30_with_percentage_and_target_pane() { + session="mysession" + window="2" + mock tmuxifier-tmux-version <<< "<" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_h 50 1 + + assert_have_been_called_with \ + tmuxifier-tmux "split-window -t mysession:2.1 -h -p 50" +} + +function test_split_h_tmux_30_with_only_target_pane_empty_percentage() { + session="test" + window="1" + mock tmuxifier-tmux-version <<< "=" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_h "" 2 + + assert_have_been_called_with tmuxifier-tmux "split-window -t test:1.2 -h" +} + +function test_split_h_always_calls_go_to_window_or_session_path() { + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_h 50 1 + + assert_have_been_called_times 1 __go_to_window_or_session_path +} diff --git a/tests/lib/layout-helpers/split_hl_test.sh b/tests/lib/layout-helpers/split_hl_test.sh new file mode 100755 index 0000000..3140d94 --- /dev/null +++ b/tests/lib/layout-helpers/split_hl_test.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# split_hl() tests +# + +function set_up() { + # Default session and window for tests + session="test-session" + window="0" +} + +function tear_down() { + unset session window +} + +function test_split_hl_calls_tmux_then_go_to_path() { + local calls=() + function tmuxifier-tmux() { calls+=("tmuxifier-tmux:$*"); } + function __go_to_window_or_session_path() { calls+=("go_to_path"); } + + split_hl + + assert_equals "tmuxifier-tmux:split-window -t test-session:0. -h" "${calls[0]}" + assert_equals "go_to_path" "${calls[1]}" +} + +function test_split_hl_with_column_count_includes_l_flag() { + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_hl 80 + + assert_have_been_called_with \ + tmuxifier-tmux "split-window -t test-session:0. -h -l 80" +} + +function test_split_hl_with_column_count_and_target_pane() { + session="mysession" + window="2" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_hl 40 1 + + assert_have_been_called_with \ + tmuxifier-tmux "split-window -t mysession:2.1 -h -l 40" +} + +function test_split_hl_with_only_target_pane_empty_count() { + session="test" + window="1" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_hl "" 2 + + assert_have_been_called_with tmuxifier-tmux "split-window -t test:1.2 -h" +} + +function test_split_hl_always_calls_go_to_window_or_session_path() { + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_hl 60 1 + + assert_have_been_called_times 1 __go_to_window_or_session_path +} diff --git a/tests/lib/layout-helpers/split_v_test.sh b/tests/lib/layout-helpers/split_v_test.sh new file mode 100755 index 0000000..54638c0 --- /dev/null +++ b/tests/lib/layout-helpers/split_v_test.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# split_v() tests +# + +function set_up() { + # Default session and window for tests + session="test-session" + window="0" +} + +function tear_down() { + unset session window +} + +function test_split_v_calls_tmux_then_go_to_path() { + local calls=() + function tmuxifier-tmux() { calls+=("tmuxifier-tmux:$*"); } + function __go_to_window_or_session_path() { calls+=("go_to_path"); } + + split_v + + assert_equals "tmuxifier-tmux:split-window -t test-session:0. -v" "${calls[0]}" + assert_equals "go_to_path" "${calls[1]}" +} + +# +# Tmux 3.1+ tests (uses -l with % suffix) +# + +function test_split_v_tmux_31_with_percentage_uses_l_flag() { + mock tmuxifier-tmux-version <<< ">" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_v 30 + + assert_have_been_called_with \ + tmuxifier-tmux "split-window -t test-session:0. -v -l 30%" +} + +function test_split_v_tmux_31_with_percentage_and_target_pane() { + session="mysession" + window="2" + mock tmuxifier-tmux-version <<< ">" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_v 50 1 + + assert_have_been_called_with \ + tmuxifier-tmux "split-window -t mysession:2.1 -v -l 50%" +} + +function test_split_v_tmux_31_with_only_target_pane_empty_percentage() { + session="test" + window="1" + mock tmuxifier-tmux-version <<< ">" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_v "" 2 + + assert_have_been_called_with tmuxifier-tmux "split-window -t test:1.2 -v" +} + +# +# Tmux 3.0 and earlier tests (uses -p flag) +# + +function test_split_v_tmux_30_with_percentage_uses_p_flag() { + mock tmuxifier-tmux-version <<< "=" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_v 30 + + assert_have_been_called_with \ + tmuxifier-tmux "split-window -t test-session:0. -v -p 30" +} + +function test_split_v_tmux_30_with_percentage_and_target_pane() { + session="mysession" + window="2" + mock tmuxifier-tmux-version <<< "<" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_v 50 1 + + assert_have_been_called_with \ + tmuxifier-tmux "split-window -t mysession:2.1 -v -p 50" +} + +function test_split_v_tmux_30_with_only_target_pane_empty_percentage() { + session="test" + window="1" + mock tmuxifier-tmux-version <<< "=" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_v "" 2 + + assert_have_been_called_with tmuxifier-tmux "split-window -t test:1.2 -v" +} + +function test_split_v_always_calls_go_to_window_or_session_path() { + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_v 50 1 + + assert_have_been_called_times 1 __go_to_window_or_session_path +} diff --git a/tests/lib/layout-helpers/split_vl_test.sh b/tests/lib/layout-helpers/split_vl_test.sh new file mode 100755 index 0000000..f4f7ac4 --- /dev/null +++ b/tests/lib/layout-helpers/split_vl_test.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# split_vl() tests +# + +function set_up() { + # Default session and window for tests + session="test-session" + window="0" +} + +function tear_down() { + unset session window +} + +function test_split_vl_calls_tmux_then_go_to_path() { + local calls=() + function tmuxifier-tmux() { calls+=("tmuxifier-tmux:$*"); } + function __go_to_window_or_session_path() { calls+=("go_to_path"); } + + split_vl + + assert_equals "tmuxifier-tmux:split-window -t test-session:0. -v" "${calls[0]}" + assert_equals "go_to_path" "${calls[1]}" +} + +function test_split_vl_with_line_count_includes_l_flag() { + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_vl 20 + + assert_have_been_called_with \ + tmuxifier-tmux "split-window -t test-session:0. -v -l 20" +} + +function test_split_vl_with_line_count_and_target_pane() { + session="mysession" + window="2" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_vl 15 1 + + assert_have_been_called_with \ + tmuxifier-tmux "split-window -t mysession:2.1 -v -l 15" +} + +function test_split_vl_with_only_target_pane_empty_count() { + session="test" + window="1" + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_vl "" 2 + + assert_have_been_called_with tmuxifier-tmux "split-window -t test:1.2 -v" +} + +function test_split_vl_always_calls_go_to_window_or_session_path() { + spy tmuxifier-tmux + spy __go_to_window_or_session_path + + split_vl 10 1 + + assert_have_been_called_times 1 __go_to_window_or_session_path +} diff --git a/tests/lib/layout-helpers/synchronize_off_test.sh b/tests/lib/layout-helpers/synchronize_off_test.sh new file mode 100755 index 0000000..5777f0f --- /dev/null +++ b/tests/lib/layout-helpers/synchronize_off_test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# synchronize_off() tests +# + +function set_up() { + # Default session and window for tests + session="test-session" + window="0" +} + +function tear_down() { + unset session window +} + +function test_synchronize_off_uses_current_window_by_default() { + spy tmuxifier-tmux + + synchronize_off + + assert_have_been_called_with \ + tmuxifier-tmux "set-window-option -t test-session:0 synchronize-panes off" +} + +function test_synchronize_off_with_specific_window() { + spy tmuxifier-tmux + + synchronize_off 2 + + assert_have_been_called_with \ + tmuxifier-tmux "set-window-option -t test-session:2 synchronize-panes off" +} + +function test_synchronize_off_with_window_name() { + spy tmuxifier-tmux + + synchronize_off "editor" + + assert_have_been_called_with \ + tmuxifier-tmux "set-window-option -t test-session:editor synchronize-panes off" +} + +function test_synchronize_off_with_different_session() { + session="mysession" + window="3" + spy tmuxifier-tmux + + synchronize_off + + assert_have_been_called_with \ + tmuxifier-tmux "set-window-option -t mysession:3 synchronize-panes off" +} diff --git a/tests/lib/layout-helpers/synchronize_on_test.sh b/tests/lib/layout-helpers/synchronize_on_test.sh new file mode 100755 index 0000000..a88dd04 --- /dev/null +++ b/tests/lib/layout-helpers/synchronize_on_test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# synchronize_on() tests +# + +function set_up() { + # Default session and window for tests + session="test-session" + window="0" +} + +function tear_down() { + unset session window +} + +function test_synchronize_on_uses_current_window_by_default() { + spy tmuxifier-tmux + + synchronize_on + + assert_have_been_called_with \ + tmuxifier-tmux "set-window-option -t test-session:0 synchronize-panes on" +} + +function test_synchronize_on_with_specific_window() { + spy tmuxifier-tmux + + synchronize_on 2 + + assert_have_been_called_with \ + tmuxifier-tmux "set-window-option -t test-session:2 synchronize-panes on" +} + +function test_synchronize_on_with_window_name() { + spy tmuxifier-tmux + + synchronize_on "editor" + + assert_have_been_called_with \ + tmuxifier-tmux "set-window-option -t test-session:editor synchronize-panes on" +} + +function test_synchronize_on_with_different_session() { + session="mysession" + window="3" + spy tmuxifier-tmux + + synchronize_on + + assert_have_been_called_with \ + tmuxifier-tmux "set-window-option -t mysession:3 synchronize-panes on" +} diff --git a/tests/lib/layout-helpers/tmux_test.sh b/tests/lib/layout-helpers/tmux_test.sh new file mode 100755 index 0000000..55d5ea2 --- /dev/null +++ b/tests/lib/layout-helpers/tmux_test.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# tmux() tests +# + +function test_tmux_passes_single_arg_to_tmuxifier_tmux() { + spy tmuxifier-tmux + + tmux -V + + assert_have_been_called_with tmuxifier-tmux "-V" +} + +function test_tmux_passes_help_flag_to_tmuxifier_tmux() { + spy tmuxifier-tmux + + tmux --help + + assert_have_been_called_with tmuxifier-tmux "--help" +} + +function test_tmux_passes_multiple_args_to_tmuxifier_tmux() { + spy tmuxifier-tmux + + tmux new -s dude + + assert_have_been_called_with tmuxifier-tmux "new -s dude" +} + +function test_tmux_passes_complex_args_to_tmuxifier_tmux() { + spy tmuxifier-tmux + + tmux new-session -d -s "my-session" -n "main" + + assert_have_been_called_with \ + tmuxifier-tmux "new-session -d -s my-session -n main" +} + +function test_tmux_called_multiple_times() { + spy tmuxifier-tmux + + tmux list-sessions + tmux list-windows + tmux list-panes + + assert_have_been_called_times 3 tmuxifier-tmux + assert_have_been_called_with tmuxifier-tmux "list-sessions" 1 + assert_have_been_called_with tmuxifier-tmux "list-windows" 2 + assert_have_been_called_with tmuxifier-tmux "list-panes" 3 +} diff --git a/tests/lib/layout-helpers/window_root_test.sh b/tests/lib/layout-helpers/window_root_test.sh new file mode 100755 index 0000000..e2dc9e4 --- /dev/null +++ b/tests/lib/layout-helpers/window_root_test.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash + +# Load the layout-helpers.sh library under test +source "${_root_dir}/lib/layout-helpers.sh" + +# +# window_root() tests +# + +function set_up() { + # Create temp directories for testing (per-test for parallel support) + _test_tmp_dir=$(mktemp -d) + _test_valid_dir="${_test_tmp_dir}/valid" + mkdir -p "$_test_valid_dir" + _test_nonexistent_dir="${_test_tmp_dir}/nonexistent" + + # Reset window_root variable before each test + window_root="" +} + +function tear_down() { + unset window_root + rm -rf "$_test_tmp_dir" +} + +function test_window_root_sets_variable_for_existing_directory() { + # Mock __expand_path to return the valid directory + mock __expand_path echo "$_test_valid_dir" + + window_root "~/some/path" + + assert_same "$_test_valid_dir" "$window_root" +} + +function test_window_root_does_not_set_variable_for_nonexistent_directory() { + # Mock __expand_path to return a nonexistent directory + mock __expand_path echo "$_test_nonexistent_dir" + + window_root "~/nonexistent" + + assert_empty "$window_root" +} + +function test_window_root_calls_expand_path_with_arguments() { + spy __expand_path + # Since spy doesn't return anything, the dir check will fail + # but we can still verify __expand_path was called + + window_root "~/Projects" + + assert_have_been_called_with __expand_path "~/Projects" +} + +function test_window_root_passes_multiple_arguments_to_expand_path() { + spy __expand_path + + window_root '~/$USER/path' + + assert_have_been_called_with __expand_path '~/$USER/path' +} + +function test_window_root_preserves_existing_value_on_invalid_path() { + window_root="$_test_valid_dir" + mock __expand_path echo "$_test_nonexistent_dir" + + window_root "~/invalid" + + # Original value should be preserved since new path doesn't exist + assert_same "$_test_valid_dir" "$window_root" +} + +function test_window_root_overwrites_existing_value_on_valid_path() { + local new_dir="${_test_tmp_dir}/another" + mkdir -p "$new_dir" + window_root="$_test_valid_dir" + mock __expand_path echo "$new_dir" + + window_root "~/another" + + assert_same "$new_dir" "$window_root" +} + +function test_window_root_handles_home_directory() { + # Use actual HOME which should exist + mock __expand_path echo "$HOME" + + window_root "~" + + assert_same "$HOME" "$window_root" +} diff --git a/tests/lib/util/calling-complete_test.sh b/tests/lib/util/calling-complete_test.sh new file mode 100755 index 0000000..6eec8bf --- /dev/null +++ b/tests/lib/util/calling-complete_test.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +# Load the util.sh library under test +source "${_root_dir}/lib/util.sh" + +# +# calling-complete() tests +# + +function test_calling-complete_returns_0_with_complete_flag() { + calling-complete --complete + assert_exit_code "0" +} + +function test_calling-complete_returns_0_with_complete_flag_after_arg() { + calling-complete foo --complete + assert_exit_code "0" +} + +function test_calling-complete_returns_0_with_complete_flag_before_arg() { + calling-complete --complete bar + assert_exit_code "0" +} + +function test_calling-complete_returns_0_with_complete_flag_between_args() { + calling-complete foo --complete bar + assert_exit_code "0" +} + +function test_calling-complete_returns_1_with_no_args() { + calling-complete + assert_exit_code "1" +} + +function test_calling-complete_returns_1_with_unrelated_arg() { + calling-complete foo + assert_exit_code "1" +} + +function test_calling-complete_returns_1_with_multiple_unrelated_args() { + calling-complete foo bar + assert_exit_code "1" +} + +function test_calling-complete_returns_1_when_complete_is_not_freestanding() { + calling-complete --complete-me + assert_exit_code "1" +} + +function test_calling-complete_returns_1_when_complete_is_suffix() { + calling-complete foo--complete + assert_exit_code "1" +} diff --git a/tests/lib/util/calling-help_test.sh b/tests/lib/util/calling-help_test.sh new file mode 100755 index 0000000..0e27d41 --- /dev/null +++ b/tests/lib/util/calling-help_test.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +# Load the util.sh library under test +source "${_root_dir}/lib/util.sh" + +# +# calling-help() tests +# + +function test_calling-help_returns_0_with_help_flag() { + calling-help --help + assert_exit_code "0" +} + +function test_calling-help_returns_0_with_help_flag_after_arg() { + calling-help foo --help + assert_exit_code "0" +} + +function test_calling-help_returns_0_with_help_flag_before_arg() { + calling-help --help bar + assert_exit_code "0" +} + +function test_calling-help_returns_0_with_help_flag_between_args() { + calling-help foo --help bar + assert_exit_code "0" +} + +function test_calling-help_returns_0_with_h_flag() { + calling-help -h + assert_exit_code "0" +} + +function test_calling-help_returns_0_with_h_flag_after_arg() { + calling-help foo -h + assert_exit_code "0" +} + +function test_calling-help_returns_0_with_h_flag_before_arg() { + calling-help -h bar + assert_exit_code "0" +} + +function test_calling-help_returns_0_with_h_flag_between_args() { + calling-help foo -h bar + assert_exit_code "0" +} + +function test_calling-help_returns_1_with_no_args() { + calling-help + assert_exit_code "1" +} + +function test_calling-help_returns_1_with_unrelated_arg() { + calling-help foo + assert_exit_code "1" +} + +function test_calling-help_returns_1_with_multiple_unrelated_args() { + calling-help foo bar + assert_exit_code "1" +} + +function test_calling-help_returns_1_when_help_is_not_freestanding() { + calling-help --help-me + assert_exit_code "1" +} + +function test_calling-help_returns_1_when_help_is_suffix() { + calling-help foo--help + assert_exit_code "1" +} + +function test_calling-help_returns_1_when_h_is_not_freestanding() { + calling-help -hj + assert_exit_code "1" +} + +function test_calling-help_returns_1_when_h_is_embedded_in_word() { + calling-help welcome-home + assert_exit_code "1" +} diff --git a/tests/lib/util/vercomp_test.sh b/tests/lib/util/vercomp_test.sh new file mode 100755 index 0000000..164a837 --- /dev/null +++ b/tests/lib/util/vercomp_test.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash + +# Load the util.sh library under test +source "${_root_dir}/lib/util.sh" + +# +# vercomp() tests +# +# Return values: +# 0 = versions are equal +# 1 = first version is greater +# 2 = first version is less +# + +# Equal versions +function test_vercomp_returns_0_for_identical_versions() { + vercomp "1.0.0" "1.0.0" + assert_exit_code "0" +} + +function test_vercomp_returns_0_for_identical_two_part_versions() { + vercomp "1.2" "1.2" + assert_exit_code "0" +} + +function test_vercomp_returns_0_for_identical_single_part_versions() { + vercomp "5" "5" + assert_exit_code "0" +} + +function test_vercomp_returns_0_when_trailing_zeros_differ() { + vercomp "1.0" "1.0.0" + assert_exit_code "0" +} + +function test_vercomp_returns_0_when_trailing_zeros_differ_reversed() { + vercomp "1.0.0" "1.0" + assert_exit_code "0" +} + +function test_vercomp_returns_0_for_equal_versions_with_many_parts() { + vercomp "1.2.3.4.5" "1.2.3.4.5" + assert_exit_code "0" +} + +# First version greater (returns 1) +function test_vercomp_returns_1_when_major_is_greater() { + vercomp "2.0.0" "1.0.0" + assert_exit_code "1" +} + +function test_vercomp_returns_1_when_minor_is_greater() { + vercomp "1.2.0" "1.1.0" + assert_exit_code "1" +} + +function test_vercomp_returns_1_when_patch_is_greater() { + vercomp "1.0.2" "1.0.1" + assert_exit_code "1" +} + +function test_vercomp_returns_1_when_first_has_more_parts_and_greater() { + vercomp "1.0.1" "1.0" + assert_exit_code "1" +} + +function test_vercomp_returns_1_for_double_digit_greater() { + vercomp "1.10.0" "1.9.0" + assert_exit_code "1" +} + +function test_vercomp_returns_1_for_large_version_numbers() { + vercomp "100.200.300" "100.200.299" + assert_exit_code "1" +} + +# First version less (returns 2) +function test_vercomp_returns_2_when_major_is_less() { + vercomp "1.0.0" "2.0.0" + assert_exit_code "2" +} + +function test_vercomp_returns_2_when_minor_is_less() { + vercomp "1.1.0" "1.2.0" + assert_exit_code "2" +} + +function test_vercomp_returns_2_when_patch_is_less() { + vercomp "1.0.1" "1.0.2" + assert_exit_code "2" +} + +function test_vercomp_returns_2_when_second_has_more_parts_and_greater() { + vercomp "1.0" "1.0.1" + assert_exit_code "2" +} + +function test_vercomp_returns_2_for_double_digit_less() { + vercomp "1.9.0" "1.10.0" + assert_exit_code "2" +} + +function test_vercomp_returns_2_for_large_version_numbers() { + vercomp "100.200.299" "100.200.300" + assert_exit_code "2" +} + +# Edge cases +function test_vercomp_returns_0_for_empty_strings() { + vercomp "" "" + assert_exit_code "0" +} + +function test_vercomp_returns_0_for_zeros() { + vercomp "0" "0" + assert_exit_code "0" +} + +function test_vercomp_returns_0_for_zero_and_zero_zero() { + vercomp "0" "0.0" + assert_exit_code "0" +} + +function test_vercomp_handles_leading_zeros_in_parts() { + vercomp "1.01" "1.1" + assert_exit_code "0" +} + +function test_vercomp_handles_leading_zeros_comparison() { + vercomp "1.02" "1.1" + assert_exit_code "1" +}