diff --git a/.github/workflows/test-lint-deploy.yml b/.github/workflows/test-lint-deploy.yml index cd5c24c..945b05f 100644 --- a/.github/workflows/test-lint-deploy.yml +++ b/.github/workflows/test-lint-deploy.yml @@ -30,7 +30,7 @@ concurrency: jobs: build-deploy: - timeout-minutes: 30 + timeout-minutes: 45 runs-on: ubuntu-22.04 @@ -92,8 +92,8 @@ jobs: chmod +x mdslw sudo mv mdslw /usr/local/bin env: - MDSLW_VERSION: 0.6.1 - MDSLW_SHA256: 20814371ec7c1801b995aa2e506a71ea1a3b23a4affa977ca75b02ad3dd5c562 + MDSLW_VERSION: 0.11.1 + MDSLW_SHA256: 75a172e9d9a59793a4dbb5adb0e0dd0778171c6aa07188fc0733bb20c6889e1e BASE_URL: "https://github.com/razziel89/mdslw/releases/download" - uses: actions/checkout@v3 diff --git a/Makefile b/Makefile index bfedf1e..5fa5b24 100644 --- a/Makefile +++ b/Makefile @@ -79,7 +79,7 @@ test-bash-version: echo >&2 "INFO: using $$(which bash) @ $$(bash -c 'echo $${BASH_VERSION}')" && \ $(MAKE) test TEST_SHELL="$$(which bash)" -SUPPORTED_VERSIONS := 5.2 5.1 5.0 4.4 +SUPPORTED_VERSIONS ?= 5.2 5.1 5.0 4.4 .PHONY: test-bash-versions test-bash-versions: build diff --git a/bin/mock_exe.sh b/bin/mock_exe.sh index 19266ad..24bb1f8 100755 --- a/bin/mock_exe.sh +++ b/bin/mock_exe.sh @@ -140,10 +140,10 @@ output_args_and_stdin() { local outdir="$1" shift - # Split arguments by newlines. This will cause problems if there are ever - # arguments with newlines, of course. Improvements are welcome. + # Split arguments by null bytes. + local arg for arg in "$@"; do - printf -- "%s\n" "${arg-}" + printf -- "%s\0" "${arg-}" done > "${outdir}/args" # If stdin is a terminal, we are called interactively. Don't output our stdin # in this case. Only output our stdin if we are not invoked interactively. @@ -190,7 +190,7 @@ _match_spec() { shift local spec - while read -r spec; do + while IFS= read -d $'\0' -r spec; do local id val id="${spec%%:*}" val="${spec#*:}" @@ -265,7 +265,7 @@ find_matching_argspec() { shift 3 local env_var var - while read -r env_var; do + while IFS= read -r env_var; do if _match_spec "${!env_var}" "$@"; then echo "${env_var##MOCK_ARGSPEC_BASE32_}" diff --git a/lib/command_commands.bash b/lib/command_commands.bash index e2e0b65..e871c66 100644 --- a/lib/command_commands.bash +++ b/lib/command_commands.bash @@ -65,12 +65,12 @@ __shellmock__commands() { declare -A builtins local tmp - while read -r tmp; do + while IFS= read -r tmp; do builtins["${tmp}"]=1 done < <(compgen -b) && wait $! || return 1 local cmd - while read -r tmp; do + while IFS= read -r tmp; do cmd="${tmp%:*}" # Only output if it is neither a currently defined function or a built-in. if diff --git a/lib/main.bash b/lib/main.bash index e6a1e6b..95bddca 100644 --- a/lib/main.bash +++ b/lib/main.bash @@ -23,6 +23,7 @@ # a tool such as getopt or getopts. shellmock() { # Handle the user requesting a help text. + local arg for arg in "$@"; do if [[ ${arg} == --help ]]; then set -- "help" diff --git a/lib/mock_management.bash b/lib/mock_management.bash index 42d6b27..0cea801 100644 --- a/lib/mock_management.bash +++ b/lib/mock_management.bash @@ -69,7 +69,7 @@ __shellmock__unmock() { # are identified by their argspecs or return codes. Thus, we only remove those # env vars. local env_var - while read -r env_var; do + while IFS= read -r env_var; do unset "${env_var}" done < <( local var @@ -98,7 +98,7 @@ __shellmock_assert_no_duplicate_argspecs() { declare -A arg_idx_count=() declare -a duplicate_arg_indices=() - local count + local count arg for arg in "${args[@]}"; do idx=${arg%%:*} idx=${idx#regex-} @@ -145,6 +145,7 @@ __shellmock__config() { fi # Validate input format. + local arg local args=() local has_err=0 local regex='^(regex-[0-9][0-9]*|regex-any|i|[0-9][0-9]*|any):' @@ -225,7 +226,7 @@ __shellmock__config() { # Handle arg specs. env_var_val=$(for arg in "${args[@]}"; do - echo "${arg}" + printf "%s\0" "${arg}" done | PATH="${__SHELLMOCK_ORGPATH}" base32 -w0) env_var_name="MOCK_ARGSPEC_BASE32_${cmd_b32}_${padded}" declare -gx "${env_var_name}=${env_var_val}" @@ -281,7 +282,7 @@ __shellmock__assert() { local has_err=0 local stderr - while read -r stderr; do + while IFS= read -r stderr; do if [[ -s ${stderr} ]]; then PATH="${__SHELLMOCK_ORGPATH}" cat >&2 "${stderr}" has_err=1 @@ -332,8 +333,14 @@ __shellmock__assert() { for argspec in "${expected_argspecs[@]}"; do if ! [[ " ${actual_argspecs[*]} " == *"${argspec}"* ]]; then has_err=1 - echo >&2 "SHELLMOCK: cannot find call for mock ${cmd} and argspec:" \ - "$(PATH="${__SHELLMOCK_ORGPATH}" base32 --decode <<< "${!argspec}")" + local msg_args=() + readarray -d $'\0' -t msg_args < <( + PATH="${__SHELLMOCK_ORGPATH}" base32 --decode <<< "${!argspec}" + ) && wait $! || exit 1 + ( + IFS=" " && echo >&2 "SHELLMOCK: cannot find call for mock ${cmd}" \ + "and argspec: ${msg_args[*]}" + ) fi done if [[ ${has_err} -ne 0 ]]; then @@ -422,7 +429,7 @@ __shellmock__calls() { # Extract arguments and stdin. Shell-quote everything for the suggestion. local args=() - readarray -d $'\n' -t args < "${call_id}/args" + readarray -d $'\0' -t args < "${call_id}/args" local idx for idx in "${!args[@]}"; do local arg="${args[${idx}]}" diff --git a/tests/main.bats b/tests/main.bats index 7bf5e2e..f6fe89e 100644 --- a/tests/main.bats +++ b/tests/main.bats @@ -60,6 +60,15 @@ setup() { git checkout } +@test "a concrete example with whitespace" { + # This concrete example uses the "git" executable with the "checkout" command. + # You could mock it like this analogous to the previous test. + shellmock new git + shellmock config git 0 1:checkout 2:"my branch" 3:main + # Git mock can be called with the checkout command. + git checkout "my branch" main +} + @test "we can mock a function" { # When calling an identifier, e.g. "git", the shell will give precedence to # functions. That is, if there is a function called "git" and an executable in diff --git a/tests/misc.bats b/tests/misc.bats index 55be150..2e494ec 100644 --- a/tests/misc.bats +++ b/tests/misc.bats @@ -79,6 +79,7 @@ setup() { @test "mocking executables with unusual names" { for exe in happy😀face exe-with-dash chinese龙dragon "exe with spaces"; do ( + echo >&2 "Testing executable: ${exe@Q}" # Make sure the test fails as soon as one command errors out, even though # we are in a subshell. set -euo pipefail @@ -91,6 +92,26 @@ setup() { "${exe}" arg # Make sure assertions work for such mocks. shellmock assert expectations "${exe}" + echo >&2 "Success for executable: ${exe@Q}" + ) + done +} + +@test "using arguments with (fancy) whitespace" { + for arg in "a b" $'a\tb' $'a\nb' 'a b' " " " a "; do + ( + echo >&2 "Testing arg: ${arg@Q}" + # Make sure the test fails as soon as one test errors out, even though + # we are in a subshell. + set -euo pipefail + shellmock new exe + # Define the mock to return with success. + shellmock config exe 0 1:first-arg 2:"${arg}" 3:third-arg + # Call the mock. + exe first-arg "${arg}" third-arg + # Make sure assertions work for such mocks. + shellmock assert expectations exe + echo >&2 "Success for arg: ${arg@Q}" ) done }