mirror-rcaloras-bash-preexec/test/bash-preexec.bats
Platon Pronko 5d8ba5dd9c Fix initialization hook in the PROMPT_COMMAND array on Bash 5.1+
If PROMPT_COMMAND contains unrelated commands before installing
bash-preexec, then these unrelated commands may trigger
`__bp_preexec_invoke_exec`, which causes interactive mode marker to be
unset.  This causes preexec to be missed for the first interactive
command, since bash-preexec assumes that we are still not interactive.

Example of what can trigger such behavior: on Arch Linux in a login
shell /etc/profile.d/80-systemd-osc-context.sh causes
`__systemd_osc_context_precmdline` to be added to PROMPT_COMMAND, and
`__systemd_osc_context_precmdline` trips
`__bp_preexec_interactive_mode`.

This patch avoids the issue by appending our initialization hook as
the last element of PROMPT_COMMAND in Bash >= 5.1, instead of
appending the hook to the first element of PROMPT_COMMAND.
2026-04-08 11:39:54 +09:00

639 lines
19 KiB
Bash

#!/usr/bin/env bats
setup() {
PROMPT_COMMAND='' # in case the invoking shell has set this
history -s fake command # preexec requires there be some history
set -o nounset # in case the user has this set
__bp_delay_install="true"
source "${BATS_TEST_DIRNAME}/../bash-preexec.sh"
}
# Evaluates all the elements of PROMPT_COMMAND
eval_PROMPT_COMMAND() {
local prompt_command
for prompt_command in "${PROMPT_COMMAND[@]}"; do
eval "$prompt_command"
done
}
# Joins the elements of PROMPT_COMMAND with $'\n'
join_PROMPT_COMMAND() {
local IFS=$'\n'
echo "${PROMPT_COMMAND[*]}"
}
bp_install() {
__bp_install_after_session_init
eval_PROMPT_COMMAND
}
test_echo() {
echo "test echo"
}
test_preexec_echo() {
printf "%s\n" "$1"
}
# Helper functions necessary because Bats' run doesn't preserve $?
return_exit_code() {
return $1
}
set_exit_code_and_run_precmd() {
return_exit_code "${1:-0}" "${2-$_}"
__bp_precmd_invoke_cmd
}
@test "sourcing bash-preexec should exit with 1 if we're not using bash" {
unset BASH_VERSION
run source "${BATS_TEST_DIRNAME}/../bash-preexec.sh"
[ $status -eq 1 ]
[ -z "$output" ]
}
@test "sourcing bash-preexec should exit with 1 if we're using an older version of bash" {
if type -p bash-3.0 &>/dev/null; then
run bash-3.0 -c "source \"${BATS_TEST_DIRNAME}/../bash-preexec.sh\""
[ "$status" -eq 1 ]
[ -z "$output" ]
else
skip
fi
}
@test "__bp_install should exit if it's already installed" {
bp_install
run '__bp_install'
[ $status -eq 1 ]
[ -z "$output" ]
}
@test "__bp_install should remove trap logic and itself from PROMPT_COMMAND" {
__bp_install_after_session_init
# Assert that before running, the command contains the install string, and
# afterwards it does not
[[ "$(join_PROMPT_COMMAND)" == *"$__bp_install_string"* ]] || return 1
eval_PROMPT_COMMAND
[[ "$(join_PROMPT_COMMAND)" != *"$__bp_install_string"* ]] || return 1
}
@test "__bp_install should remove trap logic and itself from modified PROMPT_COMMAND" {
PROMPT_COMMAND=()
__bp_install_after_session_init
PROMPT_COMMAND="$PROMPT_COMMAND; true"
# Assert that before running, the command contains the install string, and
# afterwards it does not
[[ "$(join_PROMPT_COMMAND)" == *"$__bp_install_string"* ]] || return 1
eval_PROMPT_COMMAND
[[ "$(join_PROMPT_COMMAND)" != *"$__bp_install_string"* ]] || return 1
}
@test "__bp_install should preserve an existing DEBUG trap" {
trap_invoked_count=0
foo() { (( trap_invoked_count += 1 )); }
# note setting this causes BATS to mis-report the failure line when this test fails
trap foo DEBUG
original_debug_trap=$(trap -p DEBUG)
[ "$(cut -d' ' -f3 <<< "$original_debug_trap")" == "'foo'" ]
bp_install
trap_count_snapshot=$trap_invoked_count
if [[ $__bp_hook_preexec_proc == '__bp_hook_preexec_into_debug' ]]; then
# We override the DEBUG trap with the DEBUG approach to preexec
[ "$(trap -p DEBUG | cut -d' ' -f3)" == "'__bp_preexec_invoke_exec" ]
[[ "${preexec_functions[*]}" == *"__bp_original_debug_trap"* ]] || return 1
[[ $(declare -f __bp_original_debug_trap) == *$'\n'" foo"$'\n'* ]] || return 1
else
# We do not modify the DEBUG trap in the other approaches
[ "$(trap -p DEBUG)" == "$original_debug_trap" ]
fi
__bp_interactive_mode # triggers the DEBUG trap
# ensure the trap count is still being incremented after the trap's been overwritten
(( trap_count_snapshot < trap_invoked_count ))
}
@test "__bp_install should preserve an existing DEBUG trap containing quotes" {
trap_invoked_count=0
foo() { (( trap_invoked_count += 1 )); }
# constants
single_quote="'" single_quote_escape="'\''"
original_trap_command="foo && echo 'hello' > /dev/null"
# note setting this causes BATS to mis-report the failure line when this test fails
trap "$original_trap_command" debug
original_debug_trap=$(trap -p DEBUG)
[ "$original_debug_trap" == "trap -- '${original_trap_command//$single_quote/$single_quote_escape}' DEBUG" ]
bp_install
trap_count_snapshot=$trap_invoked_count
if [[ $__bp_hook_preexec_proc == '__bp_hook_preexec_into_debug' ]]; then
# We override the DEBUG trap with the DEBUG approach to preexec
[ "$(trap -p DEBUG | cut -d' ' -f3)" == "'__bp_preexec_invoke_exec" ]
[[ "${preexec_functions[*]}" == *"__bp_original_debug_trap"* ]] || return 1
[[ $(declare -f __bp_original_debug_trap) == *$'\n'" $original_trap_command"$'\n'* ]] || return 1
else
# We do not modify the DEBUG trap in the other approaches
[ "$(trap -p DEBUG)" == "$original_debug_trap" ]
fi
__bp_interactive_mode # triggers the DEBUG trap
# ensure the trap count is still being incremented after the trap's been overwritten
(( trap_count_snapshot < trap_invoked_count ))
}
@test "__bp_install should preserve an existing PS0" {
original_PS0=${PS0-}
bp_install
if [[ $__bp_hook_preexec_proc == '__bp_hook_preexec_into_ps0' ]]; then
# We modify PS0 with the PS0 approach to preexec, but the original contents
# of PS0 should be preserved.
[ "${PS0-}" != "$original_PS0" ]
[[ ${PS0-} == *"$original_PS0"* ]] || return 1
else
# We do not modify PS0 in the other approaches
[ "${PS0-}" == "$original_PS0" ]
fi
}
@test "__bp_install_prompt_command should adjust modified PROMPT_COMMAND" {
unset -v PROMPT_COMMAND
PROMPT_COMMAND="echo PREHOOK"
# First install
__bp_install_prompt_command
expected_result=$'__bp_precmd_invoke_cmd "$_"\necho PREHOOK\n__bp_interactive_mode'
[ "$(join_PROMPT_COMMAND)" == "$expected_result" ]
# User modification
if __bp_use_array_prompt_command; then
PROMPT_COMMAND+=('echo POSTHOOK')
else
PROMPT_COMMAND+=$'\necho POSTHOOK'
fi
expected_result=$'__bp_precmd_invoke_cmd "$_"\necho PREHOOK\n__bp_interactive_mode\necho POSTHOOK'
[ "$(join_PROMPT_COMMAND)" == "$expected_result" ]
# Re-adjust
__bp_install_prompt_command
expected_result=$'__bp_precmd_invoke_cmd "$_"\necho PREHOOK\necho POSTHOOK\n__bp_interactive_mode'
[ "$(join_PROMPT_COMMAND)" == "$expected_result" ]
}
@test "__bp_sanitize_string should remove semicolons and trim space" {
__bp_sanitize_string output " true1; "$'\n'
[ "$output" == "true1" ]
__bp_sanitize_string output " ; true2; "
[ "$output" == "true2" ]
__bp_sanitize_string output $'\n'" ; true3; "
[ "$output" == "true3" ]
}
@test "__bp_sanitize_string should remove no-op colons" {
__bp_sanitize_string output ':'
[ "$output" == "" ]
__bp_sanitize_string output $':\n:'
[ "$output" == "" ]
__bp_sanitize_string output $':\n:;echo USER1'
[ "$output" == "echo USER1" ]
__bp_sanitize_string output $'echo USER2\n:\necho USER3'
expected_result=$'echo USER2\necho USER3'
[ "$output" == "$expected_result" ]
__bp_sanitize_string output $'echo USER4;:;echo USER5'
expected_result=$'echo USER4\necho USER5'
[ "$output" == "$expected_result" ]
__bp_sanitize_string output $'echo USER6;:\necho USER7'
expected_result=$'echo USER6\necho USER7'
[ "$output" == "$expected_result" ]
__bp_sanitize_string output $':\n: ; echo USER8'
[ "$output" == "echo USER8" ]
__bp_sanitize_string output $':\n: ; echo USER9'
[ "$output" == "echo USER9" ]
__bp_sanitize_string output $'echo USER10 ; :\n: ; echo USER11'
expected_result=$'echo USER10\necho USER11'
[ "$output" == "$expected_result" ]
}
@test "Appending to PROMPT_COMMAND should work after bp_install" {
bp_install
PROMPT_COMMAND="$PROMPT_COMMAND; true"
eval_PROMPT_COMMAND
}
@test "Appending or prepending to PROMPT_COMMAND should work after bp_install_after_session_init" {
__bp_install_after_session_init
nl=$'\n'
# On Bash 5.1+ we append to the array properly,
# so first element of PROMPT_COMMAND may still be empty.
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }true"
PROMPT_COMMAND="$PROMPT_COMMAND $nl true"
PROMPT_COMMAND="$PROMPT_COMMAND; true"
PROMPT_COMMAND="true; $PROMPT_COMMAND"
PROMPT_COMMAND="true; $PROMPT_COMMAND"
PROMPT_COMMAND="true; $PROMPT_COMMAND"
PROMPT_COMMAND="true $nl $PROMPT_COMMAND"
eval_PROMPT_COMMAND
}
@test "Appending or prepending to PROMPT_COMMAND array should work after bp_install_after_session_init" {
if __bp_use_array_prompt_command; then
PROMPT_COMMAND=()
__bp_install_after_session_init
nl=$'\n'
PROMPT_COMMAND=("${PROMPT_COMMAND[@]}" "true")
PROMPT_COMMAND=("${PROMPT_COMMAND[@]}" "$nl true")
PROMPT_COMMAND=("${PROMPT_COMMAND[@]}" "true")
PROMPT_COMMAND=("true" "${PROMPT_COMMAND[@]}")
PROMPT_COMMAND=("true" "${PROMPT_COMMAND[@]}")
PROMPT_COMMAND=("true" "${PROMPT_COMMAND[@]}")
PROMPT_COMMAND=("true $nl" "${PROMPT_COMMAND[@]}")
eval_PROMPT_COMMAND
else
skip
fi
}
# Case where a user is appending or prepending to PROMPT_COMMAND.
# This can happen after 'source bash-preexec.sh' e.g.
# source bash-preexec.sh; PROMPT_COMMAND="$PROMPT_COMMAND; other_prompt_command_hook"
@test "Adding to PROMPT_COMMAND before and after initiating install" {
PROMPT_COMMAND="echo before"
PROMPT_COMMAND="$PROMPT_COMMAND; echo before2"
__bp_install_after_session_init
PROMPT_COMMAND="$PROMPT_COMMAND"$'\necho after'
PROMPT_COMMAND="echo after2; $PROMPT_COMMAND;"
eval_PROMPT_COMMAND
expected_result=$'__bp_precmd_invoke_cmd "$_"\necho after2; echo before; echo before2\necho after\n__bp_interactive_mode'
[ "$(join_PROMPT_COMMAND)" == "$expected_result" ]
}
@test "Adding to PROMPT_COMMAND after with semicolon" {
PROMPT_COMMAND="echo before"
__bp_install_after_session_init
if __bp_use_array_prompt_command; then
PROMPT_COMMAND[${#PROMPT_COMMAND[@]}-1]+='; echo after'
else
PROMPT_COMMAND+='; echo after'
fi
eval_PROMPT_COMMAND
expected_result=$'__bp_precmd_invoke_cmd "$_"\necho before\necho after\n__bp_interactive_mode'
[ "$(join_PROMPT_COMMAND)" == "$expected_result" ]
}
@test "during install PROMPT_COMMAND and precmd functions should be executed each once" {
PROMPT_COMMAND="echo before"
PROMPT_COMMAND="$PROMPT_COMMAND; echo before2"
__bp_install_after_session_init
PROMPT_COMMAND="$PROMPT_COMMAND; echo after"
PROMPT_COMMAND="echo after2; $PROMPT_COMMAND;"
precmd() { echo "inside precmd"; }
run eval_PROMPT_COMMAND
[ "${#lines[@]}" == '5' ]
[ "${lines[0]}" == "after2" ]
[ "${lines[1]}" == "before" ]
[ "${lines[2]}" == "before2" ]
if __bp_use_array_prompt_command; then
[ "${lines[3]}" == "after" ]
[ "${lines[4]}" == "inside precmd" ]
else
[ "${lines[3]}" == "inside precmd" ]
[ "${lines[4]}" == "after" ]
fi
}
@test "during install PROMPT_COMMAND and precmd functions should be executed each once (Bash 5.1+ PROMPT_COMMAND array)" {
if __bp_use_array_prompt_command; then
PROMPT_COMMAND=("echo before")
PROMPT_COMMAND=("${PROMPT_COMMAND[@]}" "echo before2")
__bp_install_after_session_init
PROMPT_COMMAND=("${PROMPT_COMMAND[@]}" "echo after")
PROMPT_COMMAND=("echo after2" "${PROMPT_COMMAND[@]}")
precmd() { echo "inside precmd"; }
run eval_PROMPT_COMMAND
[ "${#lines[@]}" == '5' ]
[ "${lines[0]}" == "after2" ]
[ "${lines[1]}" == "before" ]
[ "${lines[2]}" == "before2" ]
[ "${lines[3]}" == "inside precmd" ]
[ "${lines[4]}" == "after" ]
else
skip
fi
}
@test "No functions defined for preexec should simply return" {
__bp_interactive_mode
run '__bp_preexec_invoke_exec' 'true'
[ $status -eq 0 ]
[ -z "$output" ]
}
@test "precmd should execute a function once" {
precmd_functions+=(test_echo)
run set_exit_code_and_run_precmd
[ $status -eq 0 ]
[ "$output" == "test echo" ]
}
@test "precmd should set \$? to be the previous exit code" {
echo_exit_code() {
echo "$?"
}
precmd_functions+=(echo_exit_code)
run set_exit_code_and_run_precmd 251
[ $status -eq 251 ]
[ "$output" == "251" ]
}
@test "precmd should set \$BP_PIPESTATUS to the previous \$PIPESTATUS" {
echo_pipestatus() {
echo "${BP_PIPESTATUS[*]}"
}
# Helper function is necessary because Bats' run doesn't preserve $PIPESTATUS
set_pipestatus_and_run_precmd() {
false | true
__bp_precmd_invoke_cmd
}
precmd_functions+=(echo_pipestatus)
run 'set_pipestatus_and_run_precmd'
[ $status -eq 0 ]
[ "$output" == "1 0" ]
}
@test "precmd should set \$_ to be the previous last arg" {
echo_last_arg() {
echo "$_"
}
precmd_functions+=(echo_last_arg)
bats_trap=$(trap -p DEBUG)
trap DEBUG # remove the Bats stack-trace trap so $_ doesn't get overwritten
: "last-arg"
__bp_preexec_interactive_mode=1 __bp_preexec_invoke_exec "$_"
eval "$bats_trap" # Restore trap
run set_exit_code_and_run_precmd 0 "$__bp_last_argument_prev_command"
[ $status -eq 0 ]
[ "$output" == "last-arg" ]
}
@test "preexec should execute a function with the last command in our history" {
preexec_functions+=(test_preexec_echo)
__bp_interactive_mode
git_command="git commit -a -m 'committing some stuff'"
history -s $git_command
run '__bp_preexec_invoke_exec'
[ $status -eq 0 ]
[ "$output" == "$git_command" ]
}
@test "preexec should execute multiple functions in the order added to their arrays" {
fun_1() { echo "$1 one"; }
fun_2() { echo "$1 two"; }
preexec_functions+=(fun_1)
preexec_functions+=(fun_2)
__bp_interactive_mode
run '__bp_preexec_invoke_exec'
[ $status -eq 0 ]
[ "${#lines[@]}" == '2' ]
[ "${lines[0]}" == "fake command one" ]
[ "${lines[1]}" == "fake command two" ]
}
@test "precmd should execute multiple functions in the order added to their arrays" {
fun_1() { echo "one"; }
fun_2() { echo "two"; }
precmd_functions+=(fun_1)
precmd_functions+=(fun_2)
run set_exit_code_and_run_precmd
[ $status -eq 0 ]
[ "${#lines[@]}" == '2' ]
[ "${lines[0]}" == "one" ]
[ "${lines[1]}" == "two" ]
}
@test "preexec should execute a function with IFS defined to local scope" {
IFS=_
name_with_underscores_1() { parts=(1_2); echo $parts; }
preexec_functions+=(name_with_underscores_1)
__bp_interactive_mode
run '__bp_preexec_invoke_exec'
[ $status -eq 0 ]
[ "$output" == "1 2" ]
}
@test "precmd should execute a function with IFS defined to local scope" {
IFS=_
name_with_underscores_2() { parts=(2_2); echo $parts; }
precmd_functions+=(name_with_underscores_2)
run set_exit_code_and_run_precmd
[ $status -eq 0 ]
[ "$output" == "2 2" ]
}
@test "preexec should set \$? to be the exit code of preexec_functions" {
return_nonzero() {
return 1
}
preexec_functions+=(return_nonzero)
__bp_interactive_mode
run '__bp_preexec_invoke_exec'
[ $status -eq 1 ]
}
@test "__bp_invoke_precmd_functions should be transparent for \$? and \$_" {
tester1() { test1_lastexit=$? test1_lastarg=$_; }
tester2() { test2_lastexit=$? test2_lastarg=$_; }
precmd_functions=(tester1 tester2)
trap - DEBUG # remove the Bats stack-trace trap so $_ doesn't get overwritten
__bp_invoke_precmd_functions 111 'vxxJlwNx9VPJDA' || true
[ "$test1_lastexit" == 111 ]
[ "$test1_lastarg" == 'vxxJlwNx9VPJDA' ]
[ "$test2_lastexit" == 111 ]
[ "$test2_lastarg" == 'vxxJlwNx9VPJDA' ]
}
@test "__bp_invoke_precmd_functions returns the last non-zero exit status" {
tester1() { return 91; }
tester2() { return 38; }
tester3() { return 0; }
precmd_functions=(tester1 tester2 tester3)
status=0
__bp_invoke_precmd_functions 1 'lastarg' || status=$?
[ "$status" == 38 ]
precmd_functions=(tester3)
status=0
__bp_invoke_precmd_functions 1 'lastarg' || status=$?
[ "$status" == 0 ]
}
@test "__bp_invoke_preexec_functions should be transparent for \$? and \$_" {
tester1() { test1_lastexit=$? test1_lastarg=$_; }
tester2() { test2_lastexit=$? test2_lastarg=$_; }
preexec_functions=(tester1 tester2)
trap - DEBUG # remove the Bats stack-trace trap so $_ doesn't get overwritten
__bp_invoke_preexec_functions 87 'ehQrzHTHtE2E7Q' 'command' || true
[ "$test1_lastexit" == 87 ]
[ "$test1_lastarg" == 'ehQrzHTHtE2E7Q' ]
[ "$test2_lastexit" == 87 ]
[ "$test2_lastarg" == 'ehQrzHTHtE2E7Q' ]
}
@test "__bp_invoke_preexec_functions returns the last non-zero exit status" {
tester1() { return 52; }
tester2() { return 112; }
tester3() { return 0; }
preexec_functions=(tester1 tester2 tester3)
status=0
__bp_invoke_preexec_functions 1 'lastarg' 'command' || status=$?
[ "$status" == 112 ]
preexec_functions=(tester3)
status=0
__bp_invoke_preexec_functions 1 'lastarg' 'command' || status=$?
[ "$status" == 0 ]
}
@test "__bp_invoke_preexec_functions should supply a current command in the first argument" {
tester1() { test1_bash_command=$1; }
tester2() { test2_bash_command=$1; }
preexec_functions=(tester1 tester2)
__bp_invoke_preexec_functions 1 'lastarg' 'UEVkErELArSwjA' || true
[ "$test1_bash_command" == 'UEVkErELArSwjA' ]
[ "$test2_bash_command" == 'UEVkErELArSwjA' ]
}
@test "in_prompt_command should detect if a command is part of PROMPT_COMMAND" {
PROMPT_COMMAND=$'precmd_invoke_cmd\n something; echo yo\n __bp_interactive_mode'
run '__bp_in_prompt_command' "something"
[ $status -eq 0 ]
run '__bp_in_prompt_command' "something_else"
[ $status -eq 1 ]
# Should trim commands and arguments here.
PROMPT_COMMAND=" precmd_invoke_cmd ; something ; some_stuff_here;"
run '__bp_in_prompt_command' " precmd_invoke_cmd "
[ $status -eq 0 ]
PROMPT_COMMAND=" precmd_invoke_cmd ; something ; some_stuff_here;"
run '__bp_in_prompt_command' " not_found"
[ $status -eq 1 ]
}
@test "__bp_adjust_histcontrol should remove ignorespace and ignoreboth" {
# Should remove ignorespace
HISTCONTROL="ignorespace:ignoredups:*"
__bp_adjust_histcontrol
[ "$HISTCONTROL" == ":ignoredups:*" ]
# Should remove ignoreboth and replace it with ignoredups
HISTCONTROL="ignoreboth"
__bp_adjust_histcontrol
[ "$HISTCONTROL" == "ignoredups:" ]
# Handle a few inputs
HISTCONTROL="ignoreboth:ignorespace:some_thing_else"
__bp_adjust_histcontrol
echo "$HISTCONTROL"
[ "$HISTCONTROL" == "ignoredups:::some_thing_else" ]
}
@test "preexec should respect HISTTIMEFORMAT" {
preexec_functions+=(test_preexec_echo)
__bp_interactive_mode
git_command="git commit -a -m 'committing some stuff'"
HISTTIMEFORMAT='%F %T '
history -s $git_command
run '__bp_preexec_invoke_exec'
[ $status -eq 0 ]
[ "$output" == "$git_command" ]
}
@test "preexec should not strip whitespace from commands" {
preexec_functions+=(test_preexec_echo)
__bp_interactive_mode
history -s " this command has whitespace "
run '__bp_preexec_invoke_exec'
[ $status -eq 0 ]
[ "$output" == " this command has whitespace " ]
}
@test "preexec should preserve multi-line strings in commands" {
preexec_functions+=(test_preexec_echo)
__bp_interactive_mode
history -s "this 'command contains
a multiline string'"
run '__bp_preexec_invoke_exec'
[ $status -eq 0 ]
[ "$output" == "this 'command contains
a multiline string'" ]
}
@test "preexec should work on options to 'echo' commands" {
preexec_functions+=(test_preexec_echo)
__bp_interactive_mode
history -s -- '-n'
run '__bp_preexec_invoke_exec'
[ $status -eq 0 ]
[ "$output" == '-n' ]
}