diff --git a/Makefile b/Makefile index 6f00f29..9856be4 100644 --- a/Makefile +++ b/Makefile @@ -52,13 +52,8 @@ format: # Run tests under all possible combinations of some shell options. .PHONY: test test: build - for opts in {-e,+e}{-u,+u}{-o,+o}; do \ - TEST_OPTS="$$(sed 's/+/ +/g;s/-/ -/g;s/o/o pipefail/g' <<< "$${opts}")" && \ - export TEST_OPTS && \ - echo >&2 "Testing $${BASH_VERSION} with shell options $${TEST_OPTS}" && \ - bats --jobs "$$(nproc --all)" --print-output-on-failure ./tests/*.bats \ - || exit 1; \ - done + echo >&2 "Running tests for version $${BASH_VERSION}." + bats --jobs "$$(nproc --all)" --print-output-on-failure ./tests/*.bats DOWNLOAD_URL_PREFIX := https://mirror.kumi.systems/gnu/bash/ diff --git a/bin/mock_exe.sh b/bin/mock_exe.sh index ce60c72..8f26c14 100755 --- a/bin/mock_exe.sh +++ b/bin/mock_exe.sh @@ -41,12 +41,14 @@ env_var_check() { if ! [[ -d ${!dir-} ]]; then echo >&2 "Variable ${dir} not defined or no directory." _kill_parent + return 1 fi done for var in "${vars[@]}"; do if [[ -z ${!var-} ]]; then echo >&2 "Variable ${var} not defined." _kill_parent + return 1 fi done } @@ -338,18 +340,46 @@ should_forward() { local cmd_spec="$1" local rc_env_var rc_env_var="MOCK_RC_${cmd_spec}" - [[ -n ${!rc_env_var-} && ${!rc_env_var} == forward ]] + [[ -n ${!rc_env_var-} ]] \ + && [[ ${!rc_env_var} == forward || ${!rc_env_var} == "forward:"* ]] } # Forward the arguments to the first executable in PATH that is not controlled # by shellmock, that is the first executable not in __SHELLMOCK_MOCKBIN. We can # also forward to functions that we stored, but those functions cannot access -# shell variables of the surrounding shell. +# shell variables of the surrounding shell. If requested, we will first call an +# exported shell function that may modify the arguments that we forward. forward() { - local cmd=$1 - shift + local cmd_spec=$1 + local cmd=$2 + shift 2 local args=("$@") + # Update arguments if requested. + local rc_env_var + rc_env_var="MOCK_RC_${cmd_spec}" + if [[ ${!rc_env_var} == "forward:"* ]]; then + local forward_fn="${!rc_env_var##forward:}" + local updated_args=() + if ! { + mapfile -t -d $'\0' updated_args < <( + # The function will be invoked indirectly by the forward function. + # shellcheck disable=SC2317 + update_args() { + local arg && for arg in "$@"; do printf -- '%s\0' "${arg}"; done + } + "${forward_fn}" "${cmd}" "${args[@]}" + ) && wait $! + }; then + echo >&2 "SHELLMOCK: failed to call function ${forward_fn@Q} that" \ + "shall modify arguments forwarded to ${cmd@Q}." + _kill_parent + return 1 + fi + cmd=${updated_args[0]} + args=("${updated_args[@]:1}") + fi + local exe local path="${__SHELLMOCK_FUNCSTORE}:${PATH}" # Extend PATH by shellmock's funcstore because we may want to forward to a @@ -359,6 +389,7 @@ forward() { then echo >&2 "SHELLMOCK: failed to find executable to forward to: ${cmd}" _kill_parent + return 1 fi echo >&2 "SHELLMOCK: forwarding call: ${exe@Q} ${*@Q}" exec "${exe}" "${args[@]}" @@ -386,11 +417,11 @@ main() { # it cannot be found, either exit with an error or kill the parent process. local cmd_spec cmd_spec="$(find_matching_argspec "${outdir}" "${cmd}" "${cmd_b32}" "$@")" + run_hook "${cmd_spec}" "$@" if should_forward "${cmd_spec}"; then - forward "${cmd}" "$@" + forward "${cmd_spec}" "${cmd}" "$@" else provide_output "${cmd_spec}" - run_hook "${cmd_spec}" "$@" return_with_code "${cmd_spec}" fi } @@ -399,7 +430,6 @@ main() { # which simplifies testing. if [[ -z ${BASH_SOURCE[0]-} ]] || [[ ${BASH_SOURCE[0]} == "${0}" ]]; then set -euo pipefail + shopt -s inherit_errexit main "$@" -else - : fi diff --git a/docs/usage.md b/docs/usage.md index 02b8050..6f121b5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -60,7 +60,7 @@ It is implemented as a shell function with the following sub-commands: - `delete` / `unmock`: Remove all mocks for an executable. - `is-mock`: - Determine whether an executable has been mocked by shellmock. + Determine whether an executable has been mocked by Shellmock. - `help`: Provide a help text. @@ -91,6 +91,7 @@ You can jump to the respective section via the following links. - [calls](#calls) - [Example](#example) - [delete](#delete) +- [is-mock](#is-mock) ### new @@ -123,7 +124,7 @@ from that point forward, assuming no code changes `PATH`. Syntax: -`shellmock config  [|forward] [hook:] [1: [...]]` +`shellmock config  [|forward[:]] [hook:] [1: [...]]` The `config` command defines expectations for calls to your mocked executable. You need to define expectations before you can make assertions on your mock. @@ -137,7 +138,8 @@ The `config` command takes at least two arguments: 1. the `name` of the mock you wish you define expectations for, and 2. the mock's `exit_code` for invocations matching the expectations configured - with this call or the literal string `forward`. + with this call or the literal string `forward` optionally followed by the + name of a function that may modify the forwarded arguments. See [below](#forwarding-calls) for details on forwarding calls. Next, you may optionally specify the name of a `bash` function that the mock @@ -170,7 +172,7 @@ shellmock config git 0 1:branch <<< "* main" ``` **Note:** The example shows one possible way to define the output of the mock. -The example uses a _here string_ to define the input to shellmock. +The example uses a _here string_ to define the input to `shellmock`. There are different ways to write to standard input, which even depend on the used shell. Here strings are known to work for `bash` and `zsh`, for example. @@ -447,8 +449,8 @@ requests should still be issued. Or you may want to mock all calls to `git push` while other commands should still be executed. -You can forward specific calls to an executable by specifying the literal string -`forward` as the second argument to the `config` command. +You can forward specific calls to an executable by specifying only the literal +string `forward` as the second argument to the `config` command. Calls matching argspecs provided this way will be forwarded to the actual executable. @@ -477,6 +479,69 @@ shellmock config git 0 1:push shellmock config git forward ``` +While forwarding, it might be desirable to modify some arguments. +You can do so by providing the name of a function that may modify the arguments +that will be forwarded. +That function receives all the arguments that the mock has been called with. +It is expected to pass all arguments that shall be forwarded via the function +`update_args`, either individually or in bulk. +Note that the first argument received is the name of the executable to forward +to. +That means it is possible to forward to a different executable. + +**Example**: + +```bash +# Initialising mock for curl. +shellmock new curl +# Forwarding all GET requests, i.e. calls that have the literal string GET as +# argument anywhere. However, we do not want to forward the `--silent` flag. +# Thus, we first define a function that modifies the arguments. +remove_silent_flag() { + # Keep all arguments apart from the one we want to skip. + for arg in "$@"; do + if [[ ${arg} != --silent ]]; then + # Report each argument that shall be forwarded individually. + update_args "${arg}" + fi + done +} +# Then, we use it to modify the arguments that will be forwarded. +shellmock config curl forward:remove_silent_flag any:GET +# This GET request will be forwarded but we will not forward the --silent flag. +curl -X GET --silent --output shellmock.bash \ + https://github.com/boschresearch/shellmock/releases/latest/download/shellmock.bash +``` + +**Example**: + +```bash +# Initialising mock for git. +shellmock new git +# Mocking all push commands, i.e. calls that have the literal string push as +# first argument. +shellmock config git 0 1:push +# Forwarding all other calls. Specific configurations have to go first. When +# forwarding, we want to make sure all forwarded calls are executed in a +# specific temporary directory, which we can do via git's `-C` flag. +# Thus, we first define an environment variable containing the desired path. +export NEW_GIT_WORKDIR=$(mktemp -d) +# Then, we define a function that modifies the arguments. +modify_git_workdir() { + # Report the executable to forward to first. We keep the same one. + update_args "$1" + # Discard the first argument. + shift + # Report the arguments modifying the workdir first. + update_args "-C" "${NEW_GIT_WORKDIR}" + # Then, report all the original arguments in bulk. + update_args "${@}" +} +# Then, we use it to modify the arguments that will be forwarded. +shellmock config git forward:modify_git_workdir +# This call will be forwarded but it will use the directory of our choice. +``` + ### assert @@ -603,10 +668,10 @@ However, its name is stored in a shell variable. To be able to detect such cases, the values of all shell variables would have to be known, which is not possible without executing the script. -To support examples like the one above, `shellmock` allows for specifying -commands that are used indirectly by adding specific directives as comments. -Lines containing directives generally look like `# shellmock: -uses-command=cmd1,cmd2` and may be followed by a comment. +To support examples like the one above, Shellmock allows for specifying commands +that are used indirectly by adding specific directives as comments. +Lines containing directives generally look like +`# shellmock: uses-command=cmd1,cmd2` and may be followed by a comment. The above example can thus be updated to report all used executables. **Example**: @@ -720,8 +785,8 @@ Use `shellmock global-config getval killparent` to retrieve the current setting. #### ensure-assertions When creating and configuring a mock using Shellmock, you have to make sure to -assert that your configured mock has been used as expected via the `shellmock -assert` command. +assert that your configured mock has been used as expected via the +`shellmock assert` command. Otherwise, you might not detect unexpected calls to your mock, or even the fact that your mock has not even been used! By default, Shellmock will fail a test that creates a mock without also running diff --git a/lib/main.bash b/lib/main.bash index 95bddca..785e8f9 100644 --- a/lib/main.bash +++ b/lib/main.bash @@ -22,6 +22,19 @@ # following a specific naming scheme. We avoid complex parsing of arguments with # a tool such as getopt or getopts. shellmock() { + # Ensure that only those shell options are set that shellmock needs. + local - # Restrict all changes to shell options to this function. + # Options available via "set". Options available via "shopt" cannot easily be + # scoped to a function without using a RETURN trap, but we are already uisng + # one for another purpose. + local opt opts=() flags=() + IFS=: read -r -a opts <<< "${SHELLOPTS}" + for opt in "${opts[@]}"; do flags+=(+o "${opt}"); done + set "${flags[@]}" + # Set the ones we expect and need. + set -euo pipefail + + # Main code follows. # Handle the user requesting a help text. local arg for arg in "$@"; do diff --git a/lib/mock_management.bash b/lib/mock_management.bash index 4b88176..e649f42 100644 --- a/lib/mock_management.bash +++ b/lib/mock_management.bash @@ -144,13 +144,37 @@ __shellmock__config() { local rc="$2" shift 2 + # Sanity check the provided return code. + if + ! [[ ${rc} =~ ^[0-9][0-9]*$ || ${rc} == "forward" || ${rc} == "forward:"* ]] + then + echo >&2 "Incorrect format for second argument to 'shellmock config'." \ + "It must be numeric, 'forward', or 'forward:'" + return 1 + fi + # If a forwarding function has been set, check whether it is a function and + # export it. + if [[ ${rc} == "forward:"* ]]; then + local forward_fn + forward_fn="${rc##forward:}" + if [[ $(type -t "${forward_fn}") != function ]]; then + echo >&2 "Requested forwarding function ${forward_fn@Q} does not exist." + return 1 + fi + if [[ ${forward_fn} == update_args ]]; then + echo >&2 "Forwarding function must not be called 'update_args'." + return 1 + fi + export -f "${forward_fn?}" + fi + # If a hook has been set, check whether it is a function and export it. local hook if [[ ${1-} == "hook:"* ]]; then hook="${1##hook:}" shift if [[ $(type -t "${hook}") != function ]]; then - echo >&2 "Requested hook function '${hook}' does not exist." + echo >&2 "Requested hook function ${hook@Q} does not exist." return 1 fi export -f "${hook?}" diff --git a/tests/extended.bats b/tests/extended.bats index 398438f..6ba699e 100644 --- a/tests/extended.bats +++ b/tests/extended.bats @@ -25,8 +25,6 @@ setup_file() { setup() { root="$(git rev-parse --show-toplevel)" load ../shellmock - # shellcheck disable=SC2086 # We want to perform word splitting here. - set ${TEST_OPTS-"--"} } @test "auto-detection of forgotten assertions" { @@ -208,3 +206,75 @@ EOF outputs=("${__SHELLMOCK_OUTPUT}/"*"/"*) [[ ${#outputs[@]} -eq 50 ]] } + +@test "modifying arguments" { + _modify_my_args() { + echo "${*@Q}" + return "$1" + } + # Ensure the function works as expected. + run -2 _modify_my_args 2 "as df" "foo bar" + [[ ${output} == "'2' 'as df' 'foo bar'" ]] + # Prepare modifying the arguments. + _modify_args() { + # Replace one argument by another one. + for arg in "$@"; do + if [[ ${arg} == "as df" ]]; then + arg="foo bar" + fi + update_args "${arg}" + done + # Append some arguments in bulk. + update_args some more arguments + } + shellmock new _modify_my_args + shellmock config _modify_my_args forward:_modify_args + # Ensure the function's arguments were modified. + run -3 --separate-stderr _modify_my_args 3 "as df" "foo bar" + [[ ${output} == "'3' 'foo bar' 'foo bar' 'some' 'more' 'arguments'" ]] + shellmock assert expectations _modify_my_args +} + +@test "reporting failure modifying arguments" { + _fail_to_modify_my_args() { + echo "${*@Q}" + } + _fail_to_modify_args() { + return 1 + } + shellmock new _fail_to_modify_my_args + shellmock config _fail_to_modify_my_args forward:_fail_to_modify_args + # Ensure a failure to modify arguments counts as a failure executing the mock. + run ! _fail_to_modify_my_args "as df" "foo bar" + shellmock assert expectations _fail_to_modify_my_args +} + +@test "forwarding to a different executable" { + _forward_to_someone_else() { + echo "I don't want to be called." + return 3 + } + _forward_to_me() { + echo "Call me!" "${@@Q}" + return 4 + } + # Prepare modifying the arguments. + _redirect() { + update_args _forward_to_me + shift + update_args "$@" + } + shellmock new _forward_to_someone_else + # We do not expect the mock for _forward_to_me to be called. Instead, we + # forward only to executables and not to their mocks. Thus, we will forward to + # the script that stores the original function _forward_to_me instead of the + # mock that is in the directory that shellmock prepended to PATH. + shellmock new _forward_to_me + shellmock config _forward_to_someone_else forward:_redirect + # Call the first mock, which will forward to the function that shellmock + # stored in a file when mocking the second one. + run -4 --separate-stderr _forward_to_someone_else "some arg" "another arg" + [[ ${output} == "Call me! 'some arg' 'another arg'" ]] + shellmock assert expectations _forward_to_someone_else + shellmock assert expectations _forward_to_me +} diff --git a/tests/from_docs.bats b/tests/from_docs.bats index c59c566..9612850 100644 --- a/tests/from_docs.bats +++ b/tests/from_docs.bats @@ -26,8 +26,6 @@ setup() { # automatically. load ../shellmock script=script - # shellcheck disable=SC2086 # We want to perform word splitting here. - set ${TEST_OPTS-"--"} } # We replace the script with a function to have a self-contained example. diff --git a/tests/main.bats b/tests/main.bats index a463af0..84d0fb6 100644 --- a/tests/main.bats +++ b/tests/main.bats @@ -32,8 +32,6 @@ setup() { #shellcheck disable=SC2317 load ../shellmock shellmock global-config setval ensure-assertions 0 - # shellcheck disable=SC2086 # We want to perform word splitting here. - set ${TEST_OPTS-"--"} } @test "we can mock an executable" { @@ -392,13 +390,10 @@ setup() { # should only be used for mock development. However, to validate what was # written to stdout for this test, we ignore the return value here if it is # the expected "1". - logs=$( - shellmock calls git --plain - [[ $? -eq 1 ]] - ) + run -1 --separate-stderr shellmock calls git --plain # We default to plain text by default. - paste <(echo "${logs}") <(shellmock calls git || :) - diff <(echo "${logs}") <(shellmock calls git || :) + paste <(echo "${output}") <(shellmock calls git || :) + diff <(echo "${output}") <(shellmock calls git || :) # Check that we generated what we expected to. local expected expected=$( @@ -422,7 +417,7 @@ stdin: suggestion: shellmock config git 0 1:reset 2:--soft 3:with\"quotes EOF ) - diff <(echo "${expected}") <(echo "${logs}") + diff <(echo "${expected}") <(echo "${output}") } @test "logging mock calls as json" { @@ -438,39 +433,36 @@ EOF # should only be used for mock development. However, to validate what was # written to stdout for this test, we ignore the return value here if it is # the expected "1". - logs=$( - shellmock calls git --json - [[ $? -eq 1 ]] - ) + run -1 --separate-stderr shellmock calls git --json # Check that we generate valid JSON. - jq > /dev/null <<< "${logs}" + jq > /dev/null <<< "${output}" # Check that we generated what we expected to. Use raw strings throughout, # i.e. have jq undo the JSON quoting done by shellmock. # Names. - [[ $(jq -r ".[].name" <<< "${logs}" | sort | uniq) == git ]] + [[ $(jq -r ".[].name" <<< "${output}" | sort | uniq) == git ]] # IDs. - [[ $(jq -r ".[].id" <<< "${logs}") == $'1\n2\n3' ]] + [[ $(jq -r ".[].id" <<< "${output}") == $'1\n2\n3' ]] # STDINs. - [[ "$(jq -r ".[0].stdin" <<< "${logs}")" == "nu'll" ]] - [[ -z "$(jq -r ".[1].stdin" <<< "${logs}")" ]] - [[ -z "$(jq -r ".[2].stdin" <<< "${logs}")" ]] + [[ "$(jq -r ".[0].stdin" <<< "${output}")" == "nu'll" ]] + [[ -z "$(jq -r ".[1].stdin" <<< "${output}")" ]] + [[ -z "$(jq -r ".[2].stdin" <<< "${output}")" ]] # Args. - [[ "$(jq -r ".[0].args[0]" <<< "${logs}")" == "branch" ]] - [[ "$(jq -r ".[0].args[1]" <<< "${logs}")" == "-l" ]] - [[ "$(jq -r ".[1].args[0]" <<< "${logs}")" == "checkout" ]] - [[ "$(jq -r ".[1].args[1]" <<< "${logs}")" == "-b" ]] - [[ "$(jq -r ".[1].args[2]" <<< "${logs}")" == 'strange\branch' ]] - [[ "$(jq -r ".[2].args[0]" <<< "${logs}")" == "reset" ]] - [[ "$(jq -r ".[2].args[1]" <<< "${logs}")" == "--soft" ]] - [[ "$(jq -r ".[2].args[2]" <<< "${logs}")" == 'with"quotes' ]] + [[ "$(jq -r ".[0].args[0]" <<< "${output}")" == "branch" ]] + [[ "$(jq -r ".[0].args[1]" <<< "${output}")" == "-l" ]] + [[ "$(jq -r ".[1].args[0]" <<< "${output}")" == "checkout" ]] + [[ "$(jq -r ".[1].args[1]" <<< "${output}")" == "-b" ]] + [[ "$(jq -r ".[1].args[2]" <<< "${output}")" == 'strange\branch' ]] + [[ "$(jq -r ".[2].args[0]" <<< "${output}")" == "reset" ]] + [[ "$(jq -r ".[2].args[1]" <<< "${output}")" == "--soft" ]] + [[ "$(jq -r ".[2].args[2]" <<< "${output}")" == 'with"quotes' ]] # Suggestions. - suggestion="$(jq -r ".[0].suggestion" <<< "${logs}")" + suggestion="$(jq -r ".[0].suggestion" <<< "${output}")" expectation="shellmock config git 0 1:branch 2:-l <<< nu\\'ll" [[ ${suggestion} == "${expectation}" ]] - suggestion="$(jq -r ".[1].suggestion" <<< "${logs}")" + suggestion="$(jq -r ".[1].suggestion" <<< "${output}")" expectation='shellmock config git 0 1:checkout 2:-b 3:strange\\branch' [[ ${suggestion} == "${expectation}" ]] - suggestion="$(jq -r ".[2].suggestion" <<< "${logs}")" + suggestion="$(jq -r ".[2].suggestion" <<< "${output}")" expectation='shellmock config git 0 1:reset 2:--soft 3:with\"quotes' [[ ${suggestion} == "${expectation}" ]] } diff --git a/tests/misc.bats b/tests/misc.bats index 25bacf1..71c5bc6 100644 --- a/tests/misc.bats +++ b/tests/misc.bats @@ -25,8 +25,6 @@ setup_file() { setup() { load ../shellmock shellmock global-config setval ensure-assertions 0 - # shellcheck disable=SC2086 # We want to perform word splitting here. - set ${TEST_OPTS-"--"} } @test "incorrect argspecs fail the configuration" {