diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e41c924..49ce273 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -100,6 +100,9 @@ jobs: - name: Run tool version tests run: bash tests/test_tool_versions.sh + - name: Run TUI bash menu tests + run: bash tests/test_tui_bash.sh + - name: Flag test - --list (Linux) run: bash setup.sh --list @@ -183,6 +186,9 @@ jobs: - name: Run tool version tests run: bash tests/test_tool_versions.sh + - name: Run TUI bash menu tests + run: bash tests/test_tui_bash.sh + - name: Run idempotency test suite run: bash tests/test_idempotency.sh diff --git a/scripts/linux/lib/tui.sh b/scripts/linux/lib/tui.sh new file mode 100644 index 0000000..86cbcfd --- /dev/null +++ b/scripts/linux/lib/tui.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +# scripts/linux/lib/tui.sh -- Interactive Bash checkbox menu (#495 Slice 2) +# +# Sourced by setup.sh; never executed directly. +# +# Public interface: +# show_tool_menu +# Sets globals: _MENU_SELECTION (CSV), _MENU_CANCELLED (0|1) +# +# Test seam: set _TUI_ARROW_NAV_OVERRIDE=0|1 to force a specific nav path. +# ponytail: eval for bash 3.2 nameref compat; upgrade to local -n when 4.3 is minimum. + +# --------------------------------------------------------------------------- +# _tui_render_list -- draw the full menu list to stdout +# $1: cursor index (-1 = no cursor indicator) +# $2: newline-separated tool names +# $3: space-separated checked flags (0|1 per tool, same order as $2) +# $4: space-separated is_default flags (0|1 per tool) +# --------------------------------------------------------------------------- +_tui_render_list() { + local cursor="$1" + local tools_nl="$2" + local checked_sp="$3" + local is_def_sp="$4" + + local checked_arr=() is_def_arr=() + # shellcheck disable=SC2162 + IFS=' ' read -ra checked_arr <<< "$checked_sp" + IFS=' ' read -ra is_def_arr <<< "$is_def_sp" + + local i=0 tool checked is_def pointer label check_str + while IFS= read -r tool; do + [[ -z "$tool" ]] && { i=$((i+1)); continue; } + checked="${checked_arr[$i]:-0}" + is_def="${is_def_arr[$i]:-0}" + if [[ "$checked" == "1" ]]; then check_str="[x]"; else check_str="[ ]"; fi + if [[ "$is_def" == "1" ]]; then label="(default)"; else label="(opt-in)"; fi + if [[ "$cursor" -eq "$i" ]]; then pointer=">"; else pointer=" "; fi + printf '%s %s %s %s\n' "$pointer" "$check_str" "$tool" "$label" + i=$((i+1)) + done <<< "$tools_nl" +} + +# --------------------------------------------------------------------------- +# show_tool_menu -- main entry point +# $1: name of the defaults array variable (e.g. "DEFAULT_TOOLS") +# $2: name of the available-tools array variable +# Sets globals: _MENU_SELECTION (CSV of selected tools), _MENU_CANCELLED (0|1) +# --------------------------------------------------------------------------- +show_tool_menu() { + local _defaults_name="$1" + local _avail_name="$2" + + _MENU_SELECTION="" + _MENU_CANCELLED=0 + + # Read arrays via eval (bash 3.2 compat; no local -n) + local defaults_csv avail_csv + # shellcheck disable=SC2086 + eval "defaults_csv=\"\${${_defaults_name}[*]:-}\"" + # shellcheck disable=SC2086 + eval "avail_csv=\"\${${_avail_name}[*]:-}\"" + + local def_arr=() avail_arr=() + IFS=' ' read -ra def_arr <<< "$defaults_csv" + IFS=' ' read -ra avail_arr <<< "$avail_csv" + + # Build ordered tool list: defaults (pre-checked) then opt-ins (unchecked, sorted) + local tools_nl="" checked_sp="" is_def_sp="" count=0 + + local d + for d in "${def_arr[@]}"; do + [[ -z "$d" ]] && continue + tools_nl="${tools_nl}${d}"$'\n' + checked_sp="${checked_sp}1 " + is_def_sp="${is_def_sp}1 " + count=$((count+1)) + done + + # Collect opt-ins (available but not default), sort, then append + local a in_def optin_nl="" + for a in "${avail_arr[@]}"; do + [[ -z "$a" ]] && continue + in_def=0 + for d in "${def_arr[@]}"; do [[ "$d" == "$a" ]] && in_def=1 && break; done + [[ $in_def -eq 0 ]] && optin_nl="${optin_nl}${a}"$'\n' + done + if [[ -n "$optin_nl" ]]; then + local s + while IFS= read -r s; do + [[ -z "$s" ]] && continue + tools_nl="${tools_nl}${s}"$'\n' + checked_sp="${checked_sp}0 " + is_def_sp="${is_def_sp}0 " + count=$((count+1)) + done < <(printf '%s' "$optin_nl" | sort) + fi + + [[ $count -eq 0 ]] && return 0 + + # Version branch + local _use_arrow=1 + if [[ -n "${_TUI_ARROW_NAV_OVERRIDE:-}" ]]; then + _use_arrow="$_TUI_ARROW_NAV_OVERRIDE" + else + local _bmaj _bmin + _bmaj="${BASH_VERSION%%.*}" + _bmin="${BASH_VERSION#*.}"; _bmin="${_bmin%%.*}" + (( _bmaj > 4 || (_bmaj == 4 && _bmin >= 2) )) || _use_arrow=0 + fi + + if [[ "$_use_arrow" == "1" ]]; then + _tui_arrow_mode "$tools_nl" "$checked_sp" "$is_def_sp" "$count" + else + _tui_numbered_mode "$tools_nl" "$checked_sp" "$is_def_sp" "$count" + fi +} + +# --------------------------------------------------------------------------- +# _tui_arrow_redraw -- emit one in-place redraw: cursor-up N, clear-down, render +# $1: count (number of list lines = cursor-up delta) +# $2: cursor index +# $3: tools_nl +# $4: checked_arr space-joined +# $5: is_def_sp +# --------------------------------------------------------------------------- +_tui_arrow_redraw() { + local count="$1" cursor="$2" tools_nl="$3" checked_sp="$4" is_def_sp="$5" + printf '\033[%dA' "$count" + printf '\033[J' + _tui_render_list "$cursor" "$tools_nl" "$checked_sp" "$is_def_sp" +} + +# --------------------------------------------------------------------------- +# _tui_checked_toggle_all -- toggle-all: if every item checked, uncheck all; +# otherwise check all. Prints the resulting space-separated 0|1 string. +# $1: space-separated checked flags (e.g. "1 0 1") +# $2: count +# --------------------------------------------------------------------------- +_tui_checked_toggle_all() { + local checked_sp="$1" count="$2" + local arr=() + IFS=' ' read -ra arr <<< "$checked_sp" + local all_on=1 f j + for f in "${arr[@]}"; do [[ "$f" == "0" ]] && all_on=0 && break; done + for j in $(seq 0 $((count-1))); do + [[ $all_on -eq 1 ]] && arr[j]=0 || arr[j]=1 + done + printf '%s' "${arr[*]}" +} + +# --------------------------------------------------------------------------- +# _tui_arrow_handle_key -- pure key-dispatch for arrow mode (no TTY/globals). +# $1: key string (may contain escape sequences) +# $2: current cursor index +# $3: count +# $4: space-separated checked flags +# Prints pipe-delimited result: cursor|checked_sp|done|cancelled +# done=1 -- Enter pressed; caller should stop the loop +# cancelled=1 -- q/Q/ESC; caller should set _MENU_CANCELLED and return +# --------------------------------------------------------------------------- +_tui_arrow_handle_key() { + local key="$1" cursor="$2" count="$3" checked_sp="$4" + local arr=() done_=0 cancelled=0 + IFS=' ' read -ra arr <<< "$checked_sp" + case "$key" in + $'\033[A') cursor=$(( cursor > 0 ? cursor - 1 : 0 )) ;; + $'\033[B') cursor=$(( cursor < count - 1 ? cursor + 1 : count - 1 )) ;; + ' ') + if [[ "${arr[cursor]}" == "1" ]]; then arr[cursor]=0; else arr[cursor]=1; fi ;; + a|A) + IFS=' ' read -ra arr <<< "$(_tui_checked_toggle_all "${arr[*]}" "$count")" ;; + '') done_=1 ;; + q|Q|$'\033') cancelled=1 ;; + esac + printf '%s|%s|%s|%s' "$cursor" "${arr[*]}" "$done_" "$cancelled" +} + +# --------------------------------------------------------------------------- +# _tui_arrow_mode -- bash >= 4.2: arrow keys, Space, Enter, a/A=toggle-all, +# q/ESC to cancel +# --------------------------------------------------------------------------- +_tui_arrow_mode() { + local tools_nl="$1" checked_sp="$2" is_def_sp="$3" count="$4" + local cursor=0 + + local checked_arr=() + IFS=' ' read -ra checked_arr <<< "$checked_sp" + + printf '\n' + printf 'Arrow keys to move, Space to toggle, Enter to confirm, q/ESC to cancel.\n\n' + _tui_render_list "$cursor" "$tools_nl" "${checked_arr[*]}" "$is_def_sp" + + local done_=0 k1 k2 k3 key + while [[ $done_ -eq 0 ]]; do + IFS= read -rsn1 k1 <>/dev/tty + key="$k1" + if [[ "$k1" == $'\033' ]]; then + IFS= read -rsn1 -t0.1 k2 <>/dev/tty || k2="" + IFS= read -rsn1 -t0.1 k3 <>/dev/tty || k3="" + key="${k1}${k2}${k3}" + fi + + local _kd + _kd="$(_tui_arrow_handle_key "$key" "$cursor" "$count" "${checked_arr[*]}")" + cursor="${_kd%%|*}" + local _kd_rest="${_kd#*|}" + IFS=' ' read -ra checked_arr <<< "${_kd_rest%%|*}" + local _kd_done="${_kd_rest#*|}" + local _kd_cancelled="${_kd_done#*|}" + _kd_done="${_kd_done%%|*}" + + if [[ "$_kd_cancelled" == "1" ]]; then + _MENU_CANCELLED=1 + printf '\nInstall cancelled.\n' + return 0 + fi + [[ "$_kd_done" == "1" ]] && done_=1 + + if [[ $done_ -eq 0 ]]; then + _tui_arrow_redraw "$count" "$cursor" "$tools_nl" "${checked_arr[*]}" "$is_def_sp" + fi + done + + local i=0 tool sel="" + while IFS= read -r tool; do + [[ -z "$tool" ]] && { i=$((i+1)); continue; } + [[ "${checked_arr[$i]}" == "1" ]] && sel="${sel:+${sel},}${tool}" + i=$((i+1)) + done <<< "$tools_nl" + + _MENU_SELECTION="$sel" +} + +# --------------------------------------------------------------------------- +# _tui_numbered_mode -- bash 3.2 fallback: type a number to toggle +# --------------------------------------------------------------------------- +_tui_numbered_mode() { + local tools_nl="$1" checked_sp="$2" is_def_sp="$3" count="$4" + + local tool_arr=() checked_arr=() is_def_arr=() + while IFS= read -r t; do [[ -n "$t" ]] && tool_arr+=("$t"); done <<< "$tools_nl" + IFS=' ' read -ra checked_arr <<< "$checked_sp" + IFS=' ' read -ra is_def_arr <<< "$is_def_sp" + + _tui_numbered_render tool_arr checked_arr is_def_arr "$count" + + local done_=0 _input + while [[ $done_ -eq 0 ]]; do + IFS= read -r _input <>/dev/tty + case "$_input" in + '') + done_=1 + ;; + q|Q) + _MENU_CANCELLED=1 + printf 'Install cancelled.\n' + return 0 + ;; + a|A) + IFS=' ' read -ra checked_arr <<< "$(_tui_checked_toggle_all "${checked_arr[*]}" "$count")" + _tui_numbered_render tool_arr checked_arr is_def_arr "$count" + ;; + *) + if [[ "$_input" =~ ^[0-9]+$ ]]; then + local idx=$(( _input - 1 )) + if [[ $idx -ge 0 && $idx -lt $count ]]; then + if [[ "${checked_arr[idx]}" == "1" ]]; then + checked_arr[idx]=0 + else + checked_arr[idx]=1 + fi + _tui_numbered_render tool_arr checked_arr is_def_arr "$count" + fi + fi + ;; + esac + done + + local i=0 sel="" + for i in $(seq 0 $((count-1))); do + [[ "${checked_arr[$i]}" == "1" ]] && sel="${sel:+${sel},}${tool_arr[$i]}" + done + _MENU_SELECTION="$sel" +} + +# --------------------------------------------------------------------------- +# _tui_numbered_render -- print the numbered list (called before each prompt) +# Receives array NAMES for bash 3.2 compat (no local -n) +# --------------------------------------------------------------------------- +_tui_numbered_render() { + local _tname="$1" _cname="$2" _dname="$3" _cnt="$4" + printf '\nArrow keys unavailable (bash 3.2). Number to toggle, a=all, Enter=confirm, q=cancel.\n' + local i check_str label _t _c _d + for i in $(seq 0 $((_cnt-1))); do + # shellcheck disable=SC2086 + eval "_t=\"\${${_tname}[$i]}\"; _c=\"\${${_cname}[$i]}\"; _d=\"\${${_dname}[$i]}\"" + if [[ "$_c" == "1" ]]; then check_str="[x]"; else check_str="[ ]"; fi + if [[ "$_d" == "1" ]]; then label="(default)"; else label="(opt-in)"; fi + printf '%d. %s %s %s\n' "$((i+1))" "$check_str" "$_t" "$label" + done + printf '\n> ' +} diff --git a/scripts/linux/setup.sh b/scripts/linux/setup.sh index c5a5b38..935394c 100755 --- a/scripts/linux/setup.sh +++ b/scripts/linux/setup.sh @@ -31,6 +31,8 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" # shellcheck disable=SC1091 . "$(dirname "${BASH_SOURCE[0]}")/lib/log.sh" +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib/tui.sh" # --------------------------------------------------------------------------- # DEFAULT_TOOLS -- single ordered source of truth for a no-arg default run. @@ -197,8 +199,7 @@ fi # (Note: ARG_ONLY_SET / ARG_SKIP_SET handle empty-value sentinels; build_final_toolset validates.) # --------------------------------------------------------------------------- -# Interactive guard. Slice 1 only detects whether a future menu may run. -# No menu is invoked until Slice 2. +# Interactive guard. Returns 0 (true) when the menu should run. # --------------------------------------------------------------------------- is_interactive() { if [[ $ARG_NON_INTERACTIVE_SET -eq 1 || $ARG_ONLY_SET -eq 1 || $ARG_SKIP_SET -eq 1 ]]; then @@ -217,9 +218,6 @@ is_interactive() { return 0 } -if is_interactive; then - : # ponytail: detection-only ceiling for Slice 1; Slice 2 wires the Bash menu here. -fi # --------------------------------------------------------------------------- # Build FinalToolSet -- populates global FINAL_TOOLS (bash 3.2 safe: no @@ -363,6 +361,37 @@ main() { log_info "Platform: ${platform}" log_info "Repo root: ${REPO_ROOT}" + if is_interactive; then + if [[ $ARG_SELECTION_FILE_SET -eq 1 ]]; then + # CI/test seam: read newline-delimited selection from file + _MENU_SELECTION="" + _MENU_CANCELLED=0 + local _sel_line + while IFS= read -r _sel_line || [[ -n "$_sel_line" ]]; do + _sel_line="${_sel_line%$'\r'}" + [[ -z "$_sel_line" ]] && continue + _MENU_SELECTION="${_MENU_SELECTION:+${_MENU_SELECTION},}${_sel_line}" + done < "$ARG_SELECTION_FILE" + else + local _avail_for_menu=() + local _t + while IFS= read -r _t; do + [[ -n "$_t" ]] && _avail_for_menu+=("$_t") + done < <(get_available_tools) + show_tool_menu DEFAULT_TOOLS _avail_for_menu + fi + + if [[ "${_MENU_CANCELLED:-0}" -eq 1 ]]; then + exit 0 + fi + if [[ -z "${_MENU_SELECTION:-}" ]]; then + log_info "Nothing selected, exiting." + exit 0 + fi + ARG_ONLY="$_MENU_SELECTION" + ARG_ONLY_SET=1 + fi + build_final_toolset local tool diff --git a/tests/test_tui_bash.sh b/tests/test_tui_bash.sh new file mode 100644 index 0000000..a5a82c3 --- /dev/null +++ b/tests/test_tui_bash.sh @@ -0,0 +1,620 @@ +#!/usr/bin/env bash +# tests/test_tui_bash.sh -- #495 Slice 2: Bash TUI menu tests +# +# Usage: bash tests/test_tui_bash.sh +# Requires: bash 3.2+ +# +# Coverage: +# Unit: selection logic via _MENU_SELECTION / _tui_render_list +# Integration: --interactive --selection-file= E2E, cancel/no-op paths +# +# Automated (no TTY): key dispatch logic via _tui_arrow_handle_key (cursor +# movement, toggle-all, Space, Enter, cancel, noop), _tui_checked_toggle_all +# state transitions, _tui_render_list output, and _tui_arrow_redraw ANSI bytes. +# NOT covered (requires live interactive TTY, documented in PR): +# Terminal byte delivery from keyboard to read loop, ANSI in-place rendering +# fidelity in a real terminal (mintty, tmux, Terminal.app), numbered-toggle +# interactive input, ESC/Q from within a live menu session. + +set -uo pipefail + +PASS=0 +FAIL=0 +SKIP=0 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +RESET='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +LINUX_SETUP="${REPO_ROOT}/scripts/linux/setup.sh" +TUI_SH="${REPO_ROOT}/scripts/linux/lib/tui.sh" +STUB_DIR="${REPO_ROOT}/tests/fixtures/stub-tools/linux" + +pass() { echo -e "${GREEN}PASS${RESET}: $1"; PASS=$((PASS + 1)); } +fail() { echo -e "${RED}FAIL${RESET}: $1"; FAIL=$((FAIL + 1)); } +# shellcheck disable=SC2329 +skip() { echo -e "${YELLOW}SKIP${RESET}: $1 -- $2"; SKIP=$((SKIP + 1)); } + +setup_harness() { + RUN_LOG="$(mktemp)" + export RUN_LOG +} +teardown_harness() { + rm -f "${RUN_LOG:-}" + unset RUN_LOG +} +assert_log_equals() { + local expected="$1" + if diff "$RUN_LOG" "$expected" >/dev/null 2>&1; then return 0; fi + echo " Log mismatch:"; diff "$expected" "$RUN_LOG" || true; return 1 +} +assert_contains() { + local haystack="$1" needle="$2" + echo "$haystack" | grep -qF -- "$needle" +} +assert_not_contains() { + local haystack="$1" needle="$2" + ! echo "$haystack" | grep -qF -- "$needle" +} + +# Make a temp selection file with given newline-delimited tools +make_sel_file() { + local tmp + tmp="$(mktemp)" + printf '%s\n' "$@" > "$tmp" + echo "$tmp" +} + +# --------------------------------------------------------------------------- +# T_tui_sh_sources_clean: tui.sh can be sourced without error +# --------------------------------------------------------------------------- +echo "" +echo "--- T_tui_sh_sources_clean ---" +if bash -c "source '${TUI_SH}'" 2>&1; then + pass "T_tui_sh_sources_clean: tui.sh sources without error" +else + fail "T_tui_sh_sources_clean: tui.sh source failed" +fi + +# --------------------------------------------------------------------------- +# T_menu_resolve_defaults: all defaults selected -> run-log == defaults.txt +# --------------------------------------------------------------------------- +echo "" +echo "--- T_menu_resolve_defaults ---" +setup_harness +# defaults.txt: prereqs alpha bravo charlie dotfiles git-hook +sel_f="$(make_sel_file prereqs alpha bravo charlie dotfiles git-hook)" +out="$(bash "$LINUX_SETUP" --interactive "--selection-file=${sel_f}" "--tools-dir=${STUB_DIR}" 2>&1)" || true +if assert_log_equals "${STUB_DIR}/defaults.txt"; then + pass "T_menu_resolve_defaults: all defaults selected -> run-log == defaults.txt order" +else + fail "T_menu_resolve_defaults: run-log did not match defaults.txt" + echo " Output: $out" +fi +rm -f "$sel_f"; teardown_harness + +# --------------------------------------------------------------------------- +# T_menu_resolve_subset: subset of defaults -> order-preserved run-log +# --------------------------------------------------------------------------- +echo "" +echo "--- T_menu_resolve_subset ---" +setup_harness +# Select prereqs + charlie (skipping alpha, bravo) -> expected order: prereqs, charlie +sel_f="$(make_sel_file charlie prereqs)" # intentionally reversed to test order preservation +expected_f="$(mktemp)" +printf 'prereqs\ncharlie\n' > "$expected_f" +bash "$LINUX_SETUP" --interactive "--selection-file=${sel_f}" "--tools-dir=${STUB_DIR}" >/dev/null 2>&1 || true +if assert_log_equals "$expected_f"; then + pass "T_menu_resolve_subset: subset selected in default order (not input order)" +else + fail "T_menu_resolve_subset: run-log order wrong" + echo " Expected:"; cat "$expected_f" + echo " Got:"; cat "$RUN_LOG" +fi +rm -f "$sel_f" "$expected_f"; teardown_harness + +# --------------------------------------------------------------------------- +# T_menu_resolve_optin: opt-in tool in selection -> appended alphabetically +# --------------------------------------------------------------------------- +echo "" +echo "--- T_menu_resolve_optin ---" +setup_harness +# stub opt-ins: delta, lazygit, uv (not in defaults.txt) +# Select prereqs + delta (opt-in) -> expected: prereqs then delta +sel_f="$(make_sel_file prereqs delta)" +expected_f="$(mktemp)" +printf 'prereqs\ndelta\n' > "$expected_f" +bash "$LINUX_SETUP" --interactive "--selection-file=${sel_f}" "--tools-dir=${STUB_DIR}" >/dev/null 2>&1 || true +if assert_log_equals "$expected_f"; then + pass "T_menu_resolve_optin: opt-in tool appended after defaults in run-log" +else + fail "T_menu_resolve_optin: opt-in tool placement wrong" + echo " Expected:"; cat "$expected_f" + echo " Got:"; cat "$RUN_LOG" +fi +rm -f "$sel_f" "$expected_f"; teardown_harness + +# --------------------------------------------------------------------------- +# T_menu_resolve_empty: empty selection -> exit 0, no tools run +# --------------------------------------------------------------------------- +echo "" +echo "--- T_menu_resolve_empty ---" +setup_harness +# Use an empty selection file (only blank lines) +sel_f="$(make_sel_file "")" +out="$(bash "$LINUX_SETUP" --interactive "--selection-file=${sel_f}" "--tools-dir=${STUB_DIR}" 2>&1)" || true +exit_code=$? +if [[ $exit_code -eq 0 ]] && [[ ! -s "$RUN_LOG" ]]; then + if assert_contains "$out" "Nothing selected"; then + pass "T_menu_resolve_empty: empty selection exits 0 with 'Nothing selected', no tools run" + else + fail "T_menu_resolve_empty: exit 0 and no tools run, but 'Nothing selected' message missing" + echo " Output: $out" + fi +else + fail "T_menu_resolve_empty: exit=$exit_code run-log-size=$(wc -c < "$RUN_LOG")" + echo " Output: $out" +fi +rm -f "$sel_f"; teardown_harness + +# --------------------------------------------------------------------------- +# T_menu_selection_file_e2e: --interactive --selection-file -> correct run-log +# (This is the CI-testable behavioral gate for the interactive path.) +# --------------------------------------------------------------------------- +echo "" +echo "--- T_menu_selection_file_e2e ---" +setup_harness +# Use the repo's existing selection.txt: delta (opt-in), alpha (default) +# Expected order: alpha (default), delta (opt-in) +sel_f="${STUB_DIR}/selection.txt" +expected_f="$(mktemp)" +printf 'alpha\ndelta\n' > "$expected_f" +bash "$LINUX_SETUP" --interactive "--selection-file=${sel_f}" "--tools-dir=${STUB_DIR}" >/dev/null 2>&1 || true +if assert_log_equals "$expected_f"; then + pass "T_menu_selection_file_e2e: selection-file produces correct ordered run-log" +else + fail "T_menu_selection_file_e2e: run-log mismatch" + echo " Expected:"; cat "$expected_f" + echo " Got:"; cat "$RUN_LOG" +fi +rm -f "$expected_f"; teardown_harness + +# --------------------------------------------------------------------------- +# T_menu_cancel_aborts: simulated _MENU_CANCELLED=1 -> exit 0, no tools run +# We test the main() guard directly by checking that when the menu path +# receives a cancellation signal (via --non-interactive to skip menu but +# verify guard exists in code), the installer exits cleanly. +# +# We verify the guard exists at the code level and test the outcome via a +# shell function that mirrors the guard logic. +# --------------------------------------------------------------------------- +echo "" +echo "--- T_menu_cancel_aborts ---" +# Source tui.sh and simulate cancelled state +cancel_test_out="$(bash -c " + source '${TUI_SH}' + _MENU_CANCELLED=1 + _MENU_SELECTION='' + if [[ \"\${_MENU_CANCELLED:-0}\" -eq 1 ]]; then + printf 'Install cancelled.\n' + exit 0 + fi + printf 'should not reach\n' + exit 1 +" 2>&1)" +cancel_exit=$? +if [[ $cancel_exit -eq 0 ]] && assert_contains "$cancel_test_out" "Install cancelled"; then + pass "T_menu_cancel_aborts: _MENU_CANCELLED=1 guard exits 0 with 'Install cancelled.'" +else + fail "T_menu_cancel_aborts: exit=$cancel_exit output='$cancel_test_out'" +fi + +# --------------------------------------------------------------------------- +# T_menu_noop_empty: simulated empty _MENU_SELECTION -> exit 0, correct msg +# --------------------------------------------------------------------------- +echo "" +echo "--- T_menu_noop_empty ---" +noop_test_out="$(bash -c " + source '${TUI_SH}' + _MENU_CANCELLED=0 + _MENU_SELECTION='' + if [[ \"\${_MENU_CANCELLED:-0}\" -eq 1 ]]; then + printf 'Install cancelled.\n'; exit 0 + fi + if [[ -z \"\${_MENU_SELECTION:-}\" ]]; then + printf 'Nothing selected, exiting.\n'; exit 0 + fi + printf 'should not reach\n'; exit 1 +" 2>&1)" +noop_exit=$? +if [[ $noop_exit -eq 0 ]] && assert_contains "$noop_test_out" "Nothing selected"; then + pass "T_menu_noop_empty: empty selection guard exits 0 with 'Nothing selected, exiting.'" +else + fail "T_menu_noop_empty: exit=$noop_exit output='$noop_test_out'" +fi + +# --------------------------------------------------------------------------- +# T_numbered_render_runs: numbered render executes without error (bash 3.2 path) +# Forces _TUI_ARROW_NAV_OVERRIDE=0 via env; verifies numbered list draws. +# We test via /dev/null stdin so no blocking read occurs -- we only verify +# the initial render, not interactive input. +# ponytail: no interactive input tested; visual coverage requires manual TTY. +# --------------------------------------------------------------------------- +echo "" +echo "--- T_numbered_render_runs ---" +# Source tui.sh with _TUI_ARROW_NAV_OVERRIDE=0 and call _tui_numbered_render +# We can test _tui_numbered_render directly by calling it with fixed arrays. +numbered_out="$(bash -c " + source '${TUI_SH}' + _ta=(prereqs bravo) + _ca=(1 0) + _da=(1 0) + _tui_numbered_render _ta _ca _da 2 +" 2>&1)" +if assert_contains "$numbered_out" "prereqs" && \ + assert_contains "$numbered_out" "bravo" && \ + assert_contains "$numbered_out" "[x]" && \ + assert_contains "$numbered_out" "[ ]" && \ + assert_contains "$numbered_out" "(default)"; then + pass "T_numbered_render_runs: _tui_numbered_render draws numbered list with check states" +else + fail "T_numbered_render_runs: numbered render output unexpected" + echo " Output: $numbered_out" +fi + +# --------------------------------------------------------------------------- +# T_render_list_runs: _tui_render_list draws list with cursor and labels +# --------------------------------------------------------------------------- +echo "" +echo "--- T_render_list_runs ---" +render_out="$(bash -c " + source '${TUI_SH}' + tools=\$'prereqs\nalpha' + _tui_render_list 0 \"\$tools\" '1 0' '1 0' +" 2>&1)" +if assert_contains "$render_out" "prereqs" && \ + assert_contains "$render_out" "alpha" && \ + assert_contains "$render_out" "[x]" && \ + assert_contains "$render_out" "[ ]" && \ + assert_contains "$render_out" "(default)" && \ + assert_contains "$render_out" ">"; then + pass "T_render_list_runs: _tui_render_list renders cursor, checkboxes, labels" +else + fail "T_render_list_runs: render output unexpected" + echo " Output: $render_out" +fi + +# --------------------------------------------------------------------------- +# T_noninteractive_compat: --non-interactive still runs defaults (regression) +# --------------------------------------------------------------------------- +echo "" +echo "--- T_noninteractive_compat ---" +setup_harness +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --non-interactive >/dev/null 2>&1 || true +if assert_log_equals "${STUB_DIR}/defaults.txt"; then + pass "T_noninteractive_compat: --non-interactive still runs defaults in order" +else + fail "T_noninteractive_compat: regression in non-interactive default path" + echo " Got:"; cat "$RUN_LOG" +fi +teardown_harness + +# --------------------------------------------------------------------------- +# T_selection_file_no_interactive_flag: --selection-file without --interactive +# should be rejected (mutual-exclusion guard from Slice 1) +# --------------------------------------------------------------------------- +echo "" +echo "--- T_selection_file_no_noninteractive_conflict ---" +sel_f="$(make_sel_file prereqs)" +err_out="$(bash "$LINUX_SETUP" --non-interactive "--selection-file=${sel_f}" "--tools-dir=${STUB_DIR}" 2>&1)" || exit_code=$? +exit_code="${exit_code:-0}" +if [[ $exit_code -ne 0 ]] && assert_contains "$err_out" "mutually exclusive"; then + pass "T_selection_file_no_noninteractive_conflict: --non-interactive + --selection-file exits non-zero" +else + fail "T_selection_file_no_noninteractive_conflict: expected error not raised (exit=$exit_code)" + echo " Output: $err_out" +fi +rm -f "$sel_f" + +# --------------------------------------------------------------------------- +# T_help_no_selection_file: --help does NOT expose --selection-file (hidden) +# --------------------------------------------------------------------------- +echo "" +echo "--- T_help_no_selection_file ---" +help_out="$(bash "$LINUX_SETUP" --help 2>&1)" || true +if assert_not_contains "$help_out" "selection-file"; then + pass "T_help_no_selection_file: --help does not expose hidden --selection-file" +else + fail "T_help_no_selection_file: --help mentions selection-file (must remain hidden)" +fi + +# --------------------------------------------------------------------------- +# T_arrow_redraw_stable: regression for viewport-creep bug. +# +# _tui_render_list emits exactly N lines for N tools. The cursor-up escape in +# _tui_arrow_redraw must be CSI ${N}A (not CSI ${N+1}A); using N+1 consumes +# static header lines on successive keypresses until the cursor overshoots +# row 0 and the terminal scrolls upward on every key event. +# +# This test: +# 1. Verifies _tui_render_list line count == N (measures the correct delta). +# 2. Calls _tui_arrow_redraw directly, captures raw bytes, and asserts: +# - ESC[NA is present (fix applied) +# - ESC[(N+1)A absent (viewport-creep byte sequence not emitted) +# Fails on the old inline code (N+1); passes on the extracted helper. +# --------------------------------------------------------------------------- +echo "" +echo "--- T_arrow_redraw_stable ---" +# Part 1: line count +render_lines="$(bash -c " + source '${TUI_SH}' + tools=\$'alpha\nbeta\ngamma' + _tui_render_list 0 \"\$tools\" '1 1 0' '1 0 0' +" 2>&1 | wc -l | tr -d ' \t\r\n')" +if [[ "$render_lines" -eq 3 ]]; then + pass "T_arrow_redraw_stable[line-count]: _tui_render_list emits 3 lines for 3 tools" +else + fail "T_arrow_redraw_stable[line-count]: expected 3 lines, got ${render_lines}" +fi +# Part 2: behavioral ANSI byte assertion via _tui_arrow_redraw +# ESC[3A = 0x1b 0x5b 0x33 0x41 (cursor up 3) +# ESC[4A = 0x1b 0x5b 0x34 0x41 (cursor up 4 = count+1 bug) +_redraw_hex="$(bash -c " + source '${TUI_SH}' + tools=\$'alpha\nbeta\ngamma' + _tui_arrow_redraw 3 0 \"\$tools\" '1 1 0' '1 0 0' +" 2>&1 | od -An -tx1 | tr -d ' \n')" +if echo "$_redraw_hex" | grep -qF "1b5b3341"; then + pass "T_arrow_redraw_stable[ansi-cursor-up]: _tui_arrow_redraw emits ESC[3A for N=3" +else + fail "T_arrow_redraw_stable[ansi-cursor-up]: ESC[3A not found in redraw output (hex: ${_redraw_hex})" +fi +if echo "$_redraw_hex" | grep -qF "1b5b3441"; then + fail "T_arrow_redraw_stable[ansi-no-creep]: ESC[4A present -- count+1 viewport-creep bug" +else + pass "T_arrow_redraw_stable[ansi-no-creep]: ESC[4A absent -- no viewport creep" +fi + +# --------------------------------------------------------------------------- +# T_arrow_dispatch_a_lower_none_to_all_checked / T_arrow_dispatch_a_lower_mixed_to_all_checked / +# T_arrow_dispatch_A_upper_all_checked_to_unchecked: +# All three use _tui_arrow_handle_key (the production dispatch) to pass +# 'a' or 'A' and assert the result. This tests the full dispatch path +# (case matching + _tui_checked_toggle_all) with no TTY and no source grep. +# +# Helper: parse_kd splits cursor|checked_sp|done|cancelled from the function. +# --------------------------------------------------------------------------- + +# Arrow mode (including toggle-all via 'a'/'A') is gated at Bash >=4.2 in production; +# these three tests exercise that path and require Bash >=4.2 to produce meaningful output. +if [[ ${BASH_VERSINFO[0]} -gt 4 ]] || \ + [[ ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -ge 2 ]]; then + +echo "" +echo "--- T_arrow_dispatch_a_lower_none_to_all_checked ---" +_kd_a_none="$(bash -c " + source '${TUI_SH}' + _tui_arrow_handle_key 'a' 0 3 '0 0 0' +" 2>&1)" +_kd_a_none_rest="${_kd_a_none#*|}"; _kd_a_none_checked="${_kd_a_none_rest%%|*}" +_kd_a_none_rest2="${_kd_a_none_rest#*|}"; _kd_a_none_done="${_kd_a_none_rest2%%|*}" +_kd_a_none_cancelled="${_kd_a_none_rest2#*|}" +if [[ "$_kd_a_none_checked" == "1 1 1" ]]; then + pass "T_arrow_dispatch_a_lower_none_to_all_checked[checked]: 'a' on '0 0 0' -> '1 1 1'" +else + fail "T_arrow_dispatch_a_lower_none_to_all_checked[checked]: expected '1 1 1', got '${_kd_a_none_checked}'" +fi +if [[ "$_kd_a_none_done" == "0" ]] && [[ "$_kd_a_none_cancelled" == "0" ]]; then + pass "T_arrow_dispatch_a_lower_none_to_all_checked[flags]: done=0 cancelled=0" +else + fail "T_arrow_dispatch_a_lower_none_to_all_checked[flags]: done=${_kd_a_none_done} cancelled=${_kd_a_none_cancelled}" +fi + +echo "" +echo "--- T_arrow_dispatch_a_lower_mixed_to_all_checked ---" +_kd_a="$(bash -c " + source '${TUI_SH}' + _tui_arrow_handle_key 'a' 0 3 '1 1 0' +" 2>&1)" +_kd_a_cursor="${_kd_a%%|*}" +_kd_a_rest="${_kd_a#*|}"; _kd_a_checked="${_kd_a_rest%%|*}" +_kd_a_rest2="${_kd_a_rest#*|}"; _kd_a_done="${_kd_a_rest2%%|*}" +_kd_a_cancelled="${_kd_a_rest2#*|}" +if [[ "$_kd_a_checked" == "1 1 1" ]]; then + pass "T_arrow_dispatch_a_lower_mixed_to_all_checked[checked]: 'a' on '1 1 0' -> '1 1 1'" +else + fail "T_arrow_dispatch_a_lower_mixed_to_all_checked[checked]: expected '1 1 1', got '${_kd_a_checked}'" +fi +if [[ "$_kd_a_done" == "0" ]] && [[ "$_kd_a_cancelled" == "0" ]]; then + pass "T_arrow_dispatch_a_lower_mixed_to_all_checked[flags]: done=0 cancelled=0" +else + fail "T_arrow_dispatch_a_lower_mixed_to_all_checked[flags]: done=${_kd_a_done} cancelled=${_kd_a_cancelled}" +fi + +echo "" +echo "--- T_arrow_dispatch_A_upper_all_checked_to_unchecked ---" +_kd_A="$(bash -c " + source '${TUI_SH}' + _tui_arrow_handle_key 'A' 0 3 '1 1 1' +" 2>&1)" +_kd_A_rest="${_kd_A#*|}"; _kd_A_checked="${_kd_A_rest%%|*}" +_kd_A_rest2="${_kd_A_rest#*|}"; _kd_A_done="${_kd_A_rest2%%|*}" +_kd_A_cancelled="${_kd_A_rest2#*|}" +if [[ "$_kd_A_checked" == "0 0 0" ]]; then + pass "T_arrow_dispatch_A_upper_all_checked_to_unchecked[checked]: 'A' on '1 1 1' -> '0 0 0'" +else + fail "T_arrow_dispatch_A_upper_all_checked_to_unchecked[checked]: expected '0 0 0', got '${_kd_A_checked}'" +fi +if [[ "$_kd_A_done" == "0" ]] && [[ "$_kd_A_cancelled" == "0" ]]; then + pass "T_arrow_dispatch_A_upper_all_checked_to_unchecked[flags]: done=0 cancelled=0" +else + fail "T_arrow_dispatch_A_upper_all_checked_to_unchecked[flags]: done=${_kd_A_done} cancelled=${_kd_A_cancelled}" +fi + +else + skip "T_arrow_dispatch_a_lower_none_to_all_checked" "arrow mode requires Bash >=4.2; running ${BASH_VERSION}" + skip "T_arrow_dispatch_a_lower_mixed_to_all_checked" "arrow mode requires Bash >=4.2; running ${BASH_VERSION}" + skip "T_arrow_dispatch_A_upper_all_checked_to_unchecked" "arrow mode requires Bash >=4.2; running ${BASH_VERSION}" +fi + +echo "" +echo "--- T_arrow_dispatch_space_toggles_item ---" +# cursor=1, item 1 is unchecked (0) -> space -> item 1 becomes 1 +_kd_sp="$(bash -c " + source '${TUI_SH}' + _tui_arrow_handle_key ' ' 1 3 '1 0 1' +" 2>&1)" +_kd_sp_rest="${_kd_sp#*|}"; _kd_sp_checked="${_kd_sp_rest%%|*}" +if [[ "$_kd_sp_checked" == "1 1 1" ]]; then + pass "T_arrow_dispatch_space_toggles_item: Space at cursor=1 on '1 0 1' -> '1 1 1'" +else + fail "T_arrow_dispatch_space_toggles_item: expected '1 1 1', got '${_kd_sp_checked}'" +fi + +echo "" +echo "--- T_arrow_dispatch_enter_sets_done ---" +_kd_enter="$(bash -c " + source '${TUI_SH}' + _tui_arrow_handle_key '' 0 3 '1 0 0' +" 2>&1)" +_kd_enter_rest="${_kd_enter#*|}"; _kd_enter_rest2="${_kd_enter_rest#*|}" +_kd_enter_done="${_kd_enter_rest2%%|*}"; _kd_enter_cancelled="${_kd_enter_rest2#*|}" +if [[ "$_kd_enter_done" == "1" ]] && [[ "$_kd_enter_cancelled" == "0" ]]; then + pass "T_arrow_dispatch_enter_sets_done: Enter -> done=1 cancelled=0" +else + fail "T_arrow_dispatch_enter_sets_done: done=${_kd_enter_done} cancelled=${_kd_enter_cancelled}" +fi + +echo "" +echo "--- T_arrow_dispatch_q_sets_cancelled ---" +_kd_q="$(bash -c " + source '${TUI_SH}' + _tui_arrow_handle_key 'q' 0 3 '1 0 0' +" 2>&1)" +_kd_q_rest="${_kd_q#*|}"; _kd_q_rest2="${_kd_q_rest#*|}" +_kd_q_done="${_kd_q_rest2%%|*}"; _kd_q_cancelled="${_kd_q_rest2#*|}" +if [[ "$_kd_q_cancelled" == "1" ]] && [[ "$_kd_q_done" == "0" ]]; then + pass "T_arrow_dispatch_q_sets_cancelled: 'q' -> cancelled=1 done=0" +else + fail "T_arrow_dispatch_q_sets_cancelled: done=${_kd_q_done} cancelled=${_kd_q_cancelled}" +fi + +echo "" +echo "--- T_arrow_dispatch_unknown_key_noop ---" +# An unrecognized key must not change state, done, or cancelled +_kd_noop="$(bash -c " + source '${TUI_SH}' + _tui_arrow_handle_key 'x' 1 3 '1 0 1' +" 2>&1)" +_kd_noop_cursor="${_kd_noop%%|*}" +_kd_noop_rest="${_kd_noop#*|}"; _kd_noop_checked="${_kd_noop_rest%%|*}" +_kd_noop_rest2="${_kd_noop_rest#*|}"; _kd_noop_done="${_kd_noop_rest2%%|*}" +_kd_noop_cancelled="${_kd_noop_rest2#*|}" +if [[ "$_kd_noop_cursor" == "1" ]] && [[ "$_kd_noop_checked" == "1 0 1" ]] && \ + [[ "$_kd_noop_done" == "0" ]] && [[ "$_kd_noop_cancelled" == "0" ]]; then + pass "T_arrow_dispatch_unknown_key_noop: 'x' -> state unchanged, done=0, cancelled=0" +else + fail "T_arrow_dispatch_unknown_key_noop: cursor=${_kd_noop_cursor} checked='${_kd_noop_checked}' done=${_kd_noop_done} cancelled=${_kd_noop_cancelled}" +fi + +echo "" +echo "--- T_arrow_dispatch_up_moves_cursor ---" +# Up from cursor=2 -> cursor=1; state/flags unchanged +_kd_up="$(bash -c " + source '${TUI_SH}' + _tui_arrow_handle_key $'\\033[A' 2 3 '1 0 1' +" 2>&1)" +_kd_up_cursor="${_kd_up%%|*}" +_kd_up_rest="${_kd_up#*|}"; _kd_up_checked="${_kd_up_rest%%|*}" +_kd_up_rest2="${_kd_up_rest#*|}"; _kd_up_done="${_kd_up_rest2%%|*}" +_kd_up_cancelled="${_kd_up_rest2#*|}" +if [[ "$_kd_up_cursor" == "1" ]] && [[ "$_kd_up_checked" == "1 0 1" ]] && \ + [[ "$_kd_up_done" == "0" ]] && [[ "$_kd_up_cancelled" == "0" ]]; then + pass "T_arrow_dispatch_up_moves_cursor: Up at cursor=2 -> cursor=1, state/flags unchanged" +else + fail "T_arrow_dispatch_up_moves_cursor: cursor=${_kd_up_cursor} checked='${_kd_up_checked}' done=${_kd_up_done} cancelled=${_kd_up_cancelled}" +fi +# Clamp: Up at cursor=0 -> cursor stays 0 +_kd_up_clamp="$(bash -c " + source '${TUI_SH}' + _tui_arrow_handle_key $'\\033[A' 0 3 '1 0 1' +" 2>&1)" +_kd_up_clamp_cursor="${_kd_up_clamp%%|*}" +if [[ "$_kd_up_clamp_cursor" == "0" ]]; then + pass "T_arrow_dispatch_up_moves_cursor[clamp]: Up at cursor=0 clamps to 0" +else + fail "T_arrow_dispatch_up_moves_cursor[clamp]: expected cursor=0, got ${_kd_up_clamp_cursor}" +fi + +echo "" +echo "--- T_arrow_dispatch_down_moves_cursor ---" +# Down from cursor=1 -> cursor=2 (count=3); state/flags unchanged +_kd_dn="$(bash -c " + source '${TUI_SH}' + _tui_arrow_handle_key $'\\033[B' 1 3 '1 0 1' +" 2>&1)" +_kd_dn_cursor="${_kd_dn%%|*}" +_kd_dn_rest="${_kd_dn#*|}"; _kd_dn_checked="${_kd_dn_rest%%|*}" +_kd_dn_rest2="${_kd_dn_rest#*|}"; _kd_dn_done="${_kd_dn_rest2%%|*}" +_kd_dn_cancelled="${_kd_dn_rest2#*|}" +if [[ "$_kd_dn_cursor" == "2" ]] && [[ "$_kd_dn_checked" == "1 0 1" ]] && \ + [[ "$_kd_dn_done" == "0" ]] && [[ "$_kd_dn_cancelled" == "0" ]]; then + pass "T_arrow_dispatch_down_moves_cursor: Down at cursor=1, count=3 -> cursor=2, state/flags unchanged" +else + fail "T_arrow_dispatch_down_moves_cursor: cursor=${_kd_dn_cursor} checked='${_kd_dn_checked}' done=${_kd_dn_done} cancelled=${_kd_dn_cancelled}" +fi +# Clamp: Down at cursor=count-1 -> cursor stays count-1 +_kd_dn_clamp="$(bash -c " + source '${TUI_SH}' + _tui_arrow_handle_key $'\\033[B' 2 3 '1 0 1' +" 2>&1)" +_kd_dn_clamp_cursor="${_kd_dn_clamp%%|*}" +if [[ "$_kd_dn_clamp_cursor" == "2" ]]; then + pass "T_arrow_dispatch_down_moves_cursor[clamp]: Down at cursor=count-1 clamps to count-1" +else + fail "T_arrow_dispatch_down_moves_cursor[clamp]: expected cursor=2, got ${_kd_dn_clamp_cursor}" +fi + +# --------------------------------------------------------------------------- +# T_arrow_dispatch_Q_upper_sets_cancelled / T_arrow_dispatch_esc_sets_cancelled: +# Verify that uppercase Q and bare ESC also trigger cancelled=1, done=0 via +# the production dispatch in _tui_arrow_handle_key. +# --------------------------------------------------------------------------- + +echo "" +echo "--- T_arrow_dispatch_Q_upper_sets_cancelled ---" +_kd_Q="$(bash -c " + source '${TUI_SH}' + _tui_arrow_handle_key 'Q' 0 3 '1 0 0' +" 2>&1)" +_kd_Q_rest="${_kd_Q#*|}"; _kd_Q_rest2="${_kd_Q_rest#*|}" +_kd_Q_done="${_kd_Q_rest2%%|*}"; _kd_Q_cancelled="${_kd_Q_rest2#*|}" +if [[ "$_kd_Q_cancelled" == "1" ]] && [[ "$_kd_Q_done" == "0" ]]; then + pass "T_arrow_dispatch_Q_upper_sets_cancelled: 'Q' -> cancelled=1 done=0" +else + fail "T_arrow_dispatch_Q_upper_sets_cancelled: done=${_kd_Q_done} cancelled=${_kd_Q_cancelled}" +fi + +echo "" +echo "--- T_arrow_dispatch_esc_sets_cancelled ---" +_kd_esc="$(bash -c " + source '${TUI_SH}' + _tui_arrow_handle_key $'\\033' 0 3 '1 0 0' +" 2>&1)" +_kd_esc_rest="${_kd_esc#*|}"; _kd_esc_rest2="${_kd_esc_rest#*|}" +_kd_esc_done="${_kd_esc_rest2%%|*}"; _kd_esc_cancelled="${_kd_esc_rest2#*|}" +if [[ "$_kd_esc_cancelled" == "1" ]] && [[ "$_kd_esc_done" == "0" ]]; then + pass "T_arrow_dispatch_esc_sets_cancelled: bare ESC -> cancelled=1 done=0" +else + fail "T_arrow_dispatch_esc_sets_cancelled: done=${_kd_esc_done} cancelled=${_kd_esc_cancelled}" +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "Results: ${PASS} passed, ${FAIL} failed, ${SKIP} skipped" +if [[ $FAIL -gt 0 ]]; then exit 1; fi +exit 0