From 96477c30212ca38ac70e8ab4a27f2045113e91c9 Mon Sep 17 00:00:00 2001 From: Torsten Long Date: Wed, 26 Jun 2024 10:41:23 +0200 Subject: [PATCH] Minor release 0.9.0 Signed-off-by: Torsten Long --- .github/workflows/test-lint-deploy.yml | 2 +- Makefile | 1 - README.md | 17 +-- bin/mock_exe.sh | 171 ++++++++++++++++++------- docs/build.md | 4 +- docs/usage.md | 2 +- generate_deployable.sh | 70 +++++++--- lib/command_commands.bash | 8 +- lib/command_global_config.bash | 2 + lib/{shellmock.bash => internal.bash} | 50 +++----- lib/main.bash | 45 +++++++ lib/mock_management.bash | 136 +++++++++++++------- lib/store.bash | 4 +- tests/extended.bats | 28 ++-- tests/main.bats | 6 +- tests/misc.bats | 40 +++++- 16 files changed, 406 insertions(+), 180 deletions(-) rename lib/{shellmock.bash => internal.bash} (88%) create mode 100644 lib/main.bash diff --git a/.github/workflows/test-lint-deploy.yml b/.github/workflows/test-lint-deploy.yml index ae953a8..cd5c24c 100644 --- a/.github/workflows/test-lint-deploy.yml +++ b/.github/workflows/test-lint-deploy.yml @@ -41,7 +41,7 @@ jobs: run: | sudo apt-get update -qq sudo apt-get install -qqy --no-install-recommends \ - build-essential ca-certificates coreutils curl gawk gcc git jq \ + build-essential ca-certificates coreutils curl gcc git jq \ kcov make parallel shellcheck - name: Install bats diff --git a/Makefile b/Makefile index 0fe5868..bfedf1e 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,6 @@ default: build check-dependencies: command -v bash &>/dev/null || (echo "ERROR, please install bash" >&2; exit 1) command -v bats &>/dev/null || (echo "ERROR, please install bats" >&2; exit 1) - command -v find &>/dev/null || (echo "ERROR, please install find" >&2; exit 1) command -v shellcheck &>/dev/null || (echo "ERROR, please install shellcheck" >&2; exit 1) command -v shfmt &>/dev/null || (echo "ERROR, please install shfmt" >&2; exit 1) command -v jq &>/dev/null || (echo "ERROR, please install jq" >&2; exit 1) diff --git a/README.md b/README.md index 33500fc..d1a2dd3 100644 --- a/README.md +++ b/README.md @@ -47,30 +47,17 @@ See [below](#documentation-overview) for `shellmock`'s documentation. The following tools are needed to use `shellmock`: - `base32` -- `base64` -- `basename` - `bash` (at least version 4.4) - `cat` - `chmod` -- `env` -- `find` -- `flock` -- `gawk` -- `grep` - `mkdir` - `mktemp` -- `ps` - `rm` -- `sed` -- `sort` -- `touch` -- `tr` -- `xargs` On Debian-based systems, they can be installed via: ```bash -sudo apt install -yqq bash coreutils findutils gawk grep procps sed util-linux +sudo apt install -yqq bash coreutils ``` You also need the [bats-core] testing framework that @@ -81,6 +68,8 @@ the version installable via `npm` is up to date. To run the [`commands` command](./docs/usage.md#commands), you also need a [Golang][golang] toolchain. +For optimal performance, install `flock`, which is contained within the +`util-linux` package on Debian-based systems. [bats-npm-install]: https://bats-core.readthedocs.io/en/stable/installation.html#any-os-npm [golang]: https://go.dev/doc/install diff --git a/bin/mock_exe.sh b/bin/mock_exe.sh index 6b38a68..19266ad 100755 --- a/bin/mock_exe.sh +++ b/bin/mock_exe.sh @@ -28,36 +28,112 @@ # Check whether required environment variables are set. env_var_check() { - local var - for var in __SHELLMOCK_MOCKBIN __SHELLMOCK_FUNCSTORE __SHELLMOCK_OUTPUT; do - if ! [[ -d ${!var-} ]]; then - echo >&2 "Vairable ${var} not defined or no directory." - exit 1 + local var dir + local vars=( + __SHELLMOCK_ORGPATH + ) + local dirs=( + __SHELLMOCK_MOCKBIN + __SHELLMOCK_FUNCSTORE + __SHELLMOCK_OUTPUT + ) + for dir in "${dirs[@]}"; do + if ! [[ -d ${!dir-} ]]; then + echo >&2 "Variable ${dir} not defined or no directory." + _kill_parent fi done + for var in "${vars[@]}"; do + if [[ -z ${!var-} ]]; then + echo >&2 "Variable ${var} not defined." + _kill_parent + fi + done +} + +# Remove shellmock's mockbin directory from PATH. We do so by using the env var +# set by the main shellmock code. +rm_mock_path() { + export PATH="${__SHELLMOCK_ORGPATH}" +} + +# Make sure that we can find all the executables we need. +binary_deps_check() { + local has_err=0 + local cmd + for cmd in base32 cat mkdir; do + if ! command -v "${cmd}" &> /dev/null; then + echo >&2 "Required executable ${cmd} not found." + has_err=1 + fi + done + if ! command -v flock &> /dev/null; then + echo >&2 "SHELLMOCK: Optional executable flock not found." \ + "Please install for best performance." + fi + if [[ ${has_err} -ne 0 ]]; then + _kill_parent + return 1 + fi + return 0 } get_and_ensure_outdir() { local cmd_b32="$1" + + local max_num_calls=${SHELLMOCK_MAX_CALLS_PER_MOCK:-100} + if ! [[ ${max_num_calls} =~ ^[0-9][0-9]*$ ]]; then + echo >&2 "SHELLMOCK_MAX_CALLS_PER_MOCK must be a number." + _kill_parent + return 1 + fi + local tmp + tmp=$((max_num_calls - 1)) + local max_digits=${#tmp} + + # shellmock: uses-command=flock + local _flock=flock + if + ! command -v flock &> /dev/null \ + || [[ ${__SHELLMOCK_TESTING_WO_FLOCK-0} -eq 1 ]] + then + _flock=true + fi + # Ensure no two calls overwrite each other in a thread-safe way. - local count=0 - local outdir="${__SHELLMOCK_OUTPUT}/${cmd_b32}/${count}" + local padded count=0 + padded=$(printf "%0${max_digits}d" "${count}") + mkdir -p "${__SHELLMOCK_OUTPUT}/${cmd_b32}" + local outdir="${__SHELLMOCK_OUTPUT}/${cmd_b32}/${padded}" while ! ( # Increment the counter until we find one that has not been used before. - flock -n 9 || exit 1 - [[ -d ${outdir} ]] && exit 1 - mkdir -p "${outdir}" + "${_flock}" -n 9 || exit 1 + mkdir "${outdir}" &> /dev/null ) 9> "${__SHELLMOCK_OUTPUT}/lockfile_${cmd_b32}_${count}"; do count=$((count + 1)) - outdir="${__SHELLMOCK_OUTPUT}/${cmd_b32}/${count}" + padded=$(printf "%0${max_digits}d" "${count}") + outdir="${__SHELLMOCK_OUTPUT}/${cmd_b32}/${padded}" + + if [[ ${count} -ge ${max_num_calls} ]]; then + echo >&2 "The maximum number of calls per mock is ${max_num_calls}." \ + "Consider increasing SHELLMOCK_MAX_CALLS_PER_MOCK, which is currently" \ + "set to '${max_num_calls}'." + _kill_parent + return 1 + fi done + echo "${outdir}" } # When called, this script will write its own errors to a file so that they can # be retrieved later when asserting expectations. errecho() { - echo >> "${STDERR}" "$@" + if [[ -n ${STDERR} ]]; then + echo >> "${STDERR}" "$@" + else + echo >&2 "$@" + fi } output_args_and_stdin() { @@ -82,6 +158,7 @@ _find_arg() { shift local args=("$@") + local check for check in "${args[@]}"; do if [[ ${arg} == "${check}" ]]; then return 0 @@ -96,6 +173,7 @@ _find_regex_arg() { shift local args=("$@") + local check for check in "${args[@]}"; do if [[ ${check} =~ ${regex} ]]; then return 0 @@ -114,8 +192,8 @@ _match_spec() { local spec while read -r spec; do local id val - id="$(gawk -F: '{print $1}' <<< "${spec}")" - val="${spec##"${id}":}" + id="${spec%%:*}" + val="${spec#*:}" if [[ ${spec} =~ ^any: ]]; then if ! _find_arg "${val}" "$@"; then @@ -138,7 +216,7 @@ _match_spec() { errecho "Internal error, incorrect spec ${spec}" return 1 fi - done < <(base64 --decode <<< "${full_spec}") && wait $! || return 1 + done < <(base32 --decode <<< "${full_spec}") && wait $! || return 1 } # Check whether the given process is a bats process. A bats process is a bash @@ -162,7 +240,7 @@ _is_bats_process() { } _kill_parent() { - local parent="$1" + local parent="${PPID}" # Do not kill the parent process if it is a bats process. If we did, bats # would no longer be able to track the test. @@ -172,10 +250,9 @@ _kill_parent() { return 0 fi - errecho "Killing parent process with information:" - # In case the `ps` command fails (e.g. because we mock it), don't fail this - # mock. - errecho "$(ps -p "${parent}" -lF || :)" + local cmd_w_args + mapfile -t -d $'\0' cmd_w_args < "/proc/${parent}/cmdline" + errecho "Killing parent process: ${cmd_w_args[*]@Q}" kill "${parent}" } @@ -187,33 +264,34 @@ find_matching_argspec() { local cmd_b32="${3}" shift 3 - local env_var + local env_var var while read -r env_var; do if _match_spec "${!env_var}" "$@"; then - echo "${env_var##MOCK_ARGSPEC_BASE64_}" + echo "${env_var##MOCK_ARGSPEC_BASE32_}" echo "${env_var}" > "${outdir}/argspec" return 0 fi done < <( - env | sed 's/=.*$//' \ - | { - grep -x "MOCK_ARGSPEC_BASE64_${cmd_b32}_[0-9][0-9]*" || : - } | sort -u + for var in "${!MOCK_ARGSPEC_BASE32_@}"; do + if [[ ${var} == "MOCK_ARGSPEC_BASE32_${cmd_b32}_"* ]]; then + echo "${var}" + fi + done ) && wait $! || return 1 errecho "SHELLMOCK: unexpected call '${cmd} $*'" - _kill_parent "${PPID}" + _kill_parent return 1 } provide_output() { local cmd_spec="$1" - # Base64 encoding is an easy way to be able to store arbitrary data in + # Base32 encoding is an easy way to be able to store arbitrary data in # environment variables. - output_base64="MOCK_OUTPUT_BASE64_${cmd_spec}" - if [[ -n ${!output_base64-} ]]; then - base64 --decode <<< "${!output_base64}" + output_base32="MOCK_OUTPUT_BASE32_${cmd_spec}" + if [[ -n ${!output_base32-} ]]; then + base32 --decode <<< "${!output_base32}" fi } @@ -233,7 +311,7 @@ run_hook() { # output. Anything output via errecho will end up in a file that is only # looked at when asserting expectations. echo >&2 "SHELLMOCK: error calling hook '${!hook_env_var}'" - _kill_parent "${PPID}" + _kill_parent return 1 fi fi @@ -270,27 +348,32 @@ forward() { shift local args=("$@") - while read -r -d: path; do - if - [[ ${path} != "${__SHELLMOCK_MOCKBIN}" ]] \ - && PATH="${path}" command -v "${cmd}" &> /dev/null - then - local exe="${path}/${cmd}" - echo >&2 "SHELLMOCK: forwarding call: ${exe} $*" - exec "${exe}" "${args[@]}" - fi - done <<< "${__SHELLMOCK_FUNCSTORE}:${PATH}" + local exe + local path="${__SHELLMOCK_FUNCSTORE}:${PATH}" + # Extend PATH by shellmock's funcstore because we may want to forward to a + # function instead of a binary. + if + ! exe=$(PATH="${path}" command -v "${cmd}") + then + echo >&2 "SHELLMOCK: failed to find executable to forward to: ${cmd}" + _kill_parent + fi + echo >&2 "SHELLMOCK: forwarding call: ${exe@Q} ${*@Q}" + exec "${exe}" "${args[@]}" } main() { # Make sure that shell aliases never interfere with this mock. unalias -a + rm_mock_path + binary_deps_check env_var_check # Determine our name. This assumes that the first value in argv is the name of # the command. This is almost always so. local cmd cmd_b32 args - cmd="$(basename "$0")" - cmd_b32="$(base32 -w0 <<< "${cmd}" | tr "=" "_")" + cmd="${0##*/}" + cmd_b32="$(base32 -w0 <<< "${cmd}")" + cmd_b32="${cmd_b32//=/_}" local outdir outdir="$(get_and_ensure_outdir "${cmd_b32}")" declare -g STDERR="${outdir}/stderr" diff --git a/docs/build.md b/docs/build.md index b39aa26..9fc6c1b 100644 --- a/docs/build.md +++ b/docs/build.md @@ -19,5 +19,5 @@ # Build the Deployable Simply run the script `./generate_deployable.sh` at the top level of this -repository to generate `shellmoch.bash`. -It uses only the standard Unix tools `bash`, `cat`, and `gawk` to generate it. +repository to generate `shellmock.bash`. +It uses only `bash` to generate it. diff --git a/docs/usage.md b/docs/usage.md index cf3b93f..d3e3e97 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -618,7 +618,7 @@ func2() { local ls=ls func1 # Output all files in a directory using `cat` via their full paths. This calls - # `cat` and `basename` via `xargs`. + # `cat` and `readlink` via `xargs`. # shellmock: uses-command=readlink,cat find . -type f | xargs readlink -f | xargs cat # shellmock: uses-command=ls # Calling "ls" with its name in a variable. diff --git a/generate_deployable.sh b/generate_deployable.sh index b6daf01..09ac975 100755 --- a/generate_deployable.sh +++ b/generate_deployable.sh @@ -22,11 +22,26 @@ __SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/" &> /dev/null && pwd)" +_cat() { + ( + local line + IFS= + while read -d $'\n' -r line; do + printf -- '%s\n' "${line}" + done + ) +} + deployable() { # Output header including the licence file. echo '#!/bin/bash' - sed 's/^/# /' LICENSE - cat << 'EOF' + ( + IFS= + while read -r line; do + echo "# ${line}" + done < LICENSE + ) + _cat << 'EOF' # This file is auto-generated. It is the main deployable of shellmock. To make # contributions to shellmock, please visit the repository under: @@ -36,38 +51,51 @@ EOF # Output all bats helper files containing function definitions. for bats_file in lib/*.bash; do printf -- "\n# FILE: %s\n" "${bats_file}" - cat "${bats_file}" + _cat < "${bats_file}" done # Create a function providing the help text. - cat << 'ENDOFFILE' + _cat << 'ENDOFFILE' __shellmock__help() { "${PAGER-cat}" << 'EOF' This is shellmock, a tool to mock executables called within shell scripts. ENDOFFILE - gawk \ - -v start='' \ - -v end='' \ - 'BEGIN{act=0} {if($0==end){act=0}; if(act==1){print}; if($0==start){act=1;};}' \ - ./docs/usage.md - - cat << 'ENDOFFILE' + # Add helptexts from the usage docs, but only a reduced version. The docs are + # enclosed by the HTML comments given below. + ( + local line + IFS= + do_print=0 + while read -r line; do + if [[ ${line} == '' ]]; then + do_print=0 + fi + if [[ ${do_print} -eq 1 ]]; then + echo "${line}" + fi + if [[ ${line} == '' ]]; then + do_print=1 + fi + done < ./docs/usage.md + ) + + _cat << 'ENDOFFILE' EOF } ENDOFFILE # Create a function that outputs the mock executable to its stdout. - cat << 'EOF' + _cat << 'EOF' # Mock executable writer. __shellmock_write_mock_exe() { EOF - echo "cat << 'ENDOFFILE'" - cat ./bin/mock_exe.sh + echo "PATH=\"\${__SHELLMOCK_ORGPATH}\" cat << 'ENDOFFILE'" + _cat < ./bin/mock_exe.sh - cat << 'EOF' + _cat << 'EOF' ENDOFFILE } @@ -76,17 +104,17 @@ __shellmock_internal_init_command_search() { local path=$1 EOF - echo "cat > \"\${path}/go.mod\" << 'ENDOFFILE'" - cat ./go/go.mod + echo "PATH=\"\${__SHELLMOCK_ORGPATH}\" cat > \"\${path}/go.mod\" << 'ENDOFFILE'" + _cat < ./go/go.mod - cat << 'EOF' + _cat << 'EOF' ENDOFFILE EOF - echo "cat > \"\${path}/main.go\" << 'ENDOFFILE'" - cat ./go/main.go + echo "PATH=\"\${__SHELLMOCK_ORGPATH}\" cat > \"\${path}/main.go\" << 'ENDOFFILE'" + _cat < ./go/main.go - cat << 'EOF' + _cat << 'EOF' ENDOFFILE } diff --git a/lib/command_commands.bash b/lib/command_commands.bash index 4cc4aca..e2e0b65 100644 --- a/lib/command_commands.bash +++ b/lib/command_commands.bash @@ -41,7 +41,7 @@ __shellmock__commands() { shift done - if ! command -v go &> /dev/null; then + if ! PATH="${__SHELLMOCK_ORGPATH}" command -v go &> /dev/null; then echo >&2 "The 'commands' command requires a Go toolchain." \ "Get it from here: https://go.dev/doc/install" return 1 @@ -52,13 +52,15 @@ __shellmock__commands() { return 1 fi local code - code="$(cat -)" + code="$(PATH="${__SHELLMOCK_ORGPATH}" cat -)" # Build the binary used to analyse the shell code. local bin="${__SHELLMOCK_GO_MOD}/main" if ! [[ -x ${bin} ]]; then __shellmock_internal_init_command_search "${__SHELLMOCK_GO_MOD}" - (cd "${__SHELLMOCK_GO_MOD}" && go get && go build) 1>&2 + (cd "${__SHELLMOCK_GO_MOD}" \ + && PATH="${__SHELLMOCK_ORGPATH}" go get \ + && PATH="${__SHELLMOCK_ORGPATH}" go build) 1>&2 fi declare -A builtins diff --git a/lib/command_global_config.bash b/lib/command_global_config.bash index 424c85c..5ec0467 100644 --- a/lib/command_global_config.bash +++ b/lib/command_global_config.bash @@ -16,6 +16,8 @@ # License for the specific language governing permissions and limitations under # the License. +# This file contains the definition of the global-config command. + # The global-config command that can be used to get and set some global options. __shellmock__global-config() { __shellmock_internal_pathcheck diff --git a/lib/shellmock.bash b/lib/internal.bash similarity index 88% rename from lib/shellmock.bash rename to lib/internal.bash index 1f908b9..b8f6950 100644 --- a/lib/shellmock.bash +++ b/lib/internal.bash @@ -16,14 +16,17 @@ # License for the specific language governing permissions and limitations under # the License. +# This file contains internal helper functions used by different shellmock +# commands. + __shellmock_mktemp() { local has_bats=$1 local what=$2 local dir local base="${BATS_TEST_TMPDIR-${TMPDIR-/tmp}}/shellmock" local template="${what// /_}.XXXXXXXXXX" - mkdir -p "${base}" - dir=$(mktemp -d -p "${base}" "${template}") + PATH="${__SHELLMOCK_ORGPATH}" mkdir -p "${base}" + dir=$(PATH="${__SHELLMOCK_ORGPATH}" mktemp -d -p "${base}" "${template}") if [[ ${has_bats} -eq 0 ]]; then echo >&2 "Keeping ${what} in: ${dir}" fi @@ -50,10 +53,13 @@ __shellmock_internal_init() { echo >&2 "Running outside of bats, temporary directories will be kept." fi + declare -gx __SHELLMOCK_ORGPATH + # Remember the original value of "${PATH}" when shellmock was loaded. + __SHELLMOCK_ORGPATH="${PATH}" + # Modify PATH to permit injecting executables. declare -gx __SHELLMOCK_MOCKBIN __SHELLMOCK_MOCKBIN="$(__shellmock_mktemp "${has_bats}" "mocks")" - export PATH="${__SHELLMOCK_MOCKBIN}:${PATH}" declare -gx __SHELLMOCK_OUTPUT __SHELLMOCK_OUTPUT="$(__shellmock_mktemp "${has_bats}" "mock call data")" @@ -71,10 +77,12 @@ __shellmock_internal_init() { __shellmock_mktemp "${has_bats}" "go code" )" + export PATH="${__SHELLMOCK_MOCKBIN}:${PATH}" declare -gx __SHELLMOCK_PATH # Remember the value of "${PATH}" when shellmock was loaded, including the # prepended mockbin dir. __SHELLMOCK_PATH="${PATH}" + # By default, perform checks for changes made to PATH because that can prevent # mocking from working. declare -gx __SHELLMOCK__CHECKPATH=1 @@ -165,8 +173,14 @@ __shellmock_internal_trap() { then local defined_cmds readarray -d $'\n' -t defined_cmds < <( - # shellmock: uses-command=basename - find "${__SHELLMOCK_MOCKBIN}" -type f -print0 | xargs -r -0 -I{} basename {} + # Recursively get the name of the mock binary commands. + shopt -s globstar + local file + for file in "${__SHELLMOCK_MOCKBIN}"/**; do + if [[ -f ${file} ]]; then + echo "${file##*/}" + fi + done ) && wait $! local cmd has_err=0 @@ -191,29 +205,3 @@ __shellmock_internal_trap() { fi fi } - -# Main shellmock command. Subcommands can be added by creating a shell function -# following a specific naming scheme. We avoid complex parsing of arguments with -# a tool such as getopt or getopts. -shellmock() { - # Handle the user requesting a help text. - for arg in "$@"; do - if [[ ${arg} == --help ]]; then - set -- "help" - break - fi - done - - local cmd="$1" - shift - - # Execute subcommand with arguments but only if they are shell functions and - # exist. - if [[ $(type -t "__shellmock__${cmd}") == function ]]; then - "__shellmock__${cmd}" "$@" - else - echo >&2 "Unknown command for shellmock: ${cmd}." \ - "Call with --help to view the help text." - return 1 - fi -} diff --git a/lib/main.bash b/lib/main.bash new file mode 100644 index 0000000..e6a1e6b --- /dev/null +++ b/lib/main.bash @@ -0,0 +1,45 @@ +#!/bin/bash + +# Copyright (c) 2022 - for information on the respective copyright owner +# see the NOTICE file or the repository +# https://github.com/boschresearch/shellmock +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# This file contains the main entrypoint, namely the shellmock function. + +# Main shellmock command. Subcommands can be added by creating a shell function +# following a specific naming scheme. We avoid complex parsing of arguments with +# a tool such as getopt or getopts. +shellmock() { + # Handle the user requesting a help text. + for arg in "$@"; do + if [[ ${arg} == --help ]]; then + set -- "help" + break + fi + done + + local cmd="$1" + shift + + # Execute subcommand with arguments but only if they are shell functions and + # exist. + if [[ $(type -t "__shellmock__${cmd}") == function ]]; then + "__shellmock__${cmd}" "$@" + else + echo >&2 "Unknown command for shellmock: ${cmd}." \ + "Call with --help to view the help text." + return 1 + fi +} diff --git a/lib/mock_management.bash b/lib/mock_management.bash index f8ba971..42d6b27 100644 --- a/lib/mock_management.bash +++ b/lib/mock_management.bash @@ -16,6 +16,10 @@ # License for the specific language governing permissions and limitations under # the License. +# This file contains the definition of all of shellmock's commands that are +# needed to manage mocks. This file also contains some helper functions for the +# individual commands. + # Create a new mock for an executable or a function. Functions are unset first # or they could not be mocked with executables. __shellmock__new() { @@ -29,12 +33,14 @@ __shellmock__new() { # injected executable. However, store the original function so that we could # restore it. __shellmock_internal_funcstore "${cmd}" > "${__SHELLMOCK_FUNCSTORE}/${cmd}" - chmod +x "${__SHELLMOCK_FUNCSTORE}/${cmd}" + PATH="${__SHELLMOCK_ORGPATH}" chmod +x "${__SHELLMOCK_FUNCSTORE}/${cmd}" unset -f "${cmd}" fi + # The function __shellmock_write_mock_exe is generated when building the + # deployable. __shellmock_write_mock_exe > "${__SHELLMOCK_MOCKBIN}/${cmd}" - chmod +x "${__SHELLMOCK_MOCKBIN}/${cmd}" + PATH="${__SHELLMOCK_ORGPATH}" chmod +x "${__SHELLMOCK_MOCKBIN}/${cmd}" } # An alias for the "new" command. @@ -48,14 +54,15 @@ __shellmock__unmock() { local cmd="$1" local cmd_b32 - cmd_b32=$(base32 -w0 <<< "${cmd}" | tr "=" "_") + cmd_b32=$(PATH="${__SHELLMOCK_ORGPATH}" base32 -w0 <<< "${cmd}") + cmd_b32=${cmd_b32//=/_} # Restore the function if we are mocking one. local store="${__SHELLMOCK_FUNCSTORE}/${cmd}" if [[ -f ${store} ]]; then # shellcheck disable=SC1090 source "${store}" - rm "${store}" + PATH="${__SHELLMOCK_ORGPATH}" rm "${store}" fi # In any case, remove the mock and unset all env vars defined for it. Mocks @@ -65,12 +72,19 @@ __shellmock__unmock() { while read -r env_var; do unset "${env_var}" done < <( - env | sed 's/=.*$//' \ - | { grep -xE "^MOCK_(RC|ARGSPEC_BASE64)_${cmd_b32}_[0-9][0-9]*" || :; } + local var + for var in "${!MOCK_RC_@}" "${!MOCK_ARGSPEC_BASE32_@}"; do + if + [[ ${var} == "MOCK_RC_${cmd_b32}_"* ]] \ + || [[ ${var} == "MOCK_ARGSPEC_BASE32_${cmd_b32}_"* ]] + then + echo "${var}" + fi + done ) && wait $! || return 1 if [[ -f "${__SHELLMOCK_MOCKBIN}/${cmd}" ]]; then - rm "${__SHELLMOCK_MOCKBIN}/${cmd}" + PATH="${__SHELLMOCK_ORGPATH}" rm "${__SHELLMOCK_MOCKBIN}/${cmd}" fi } @@ -83,7 +97,7 @@ __shellmock_assert_no_duplicate_argspecs() { local args=("$@") declare -A arg_idx_count=() - declare -A duplicate_arg_indices=() + declare -a duplicate_arg_indices=() local count for arg in "${args[@]}"; do idx=${arg%%:*} @@ -93,15 +107,13 @@ __shellmock_assert_no_duplicate_argspecs() { fi count=${arg_idx_count["${idx}"]-0} arg_idx_count["${idx}"]=$((count + 1)) - if [[ ${count} -gt 0 ]]; then - duplicate_arg_indices["${idx}"]=1 + if [[ ${count} -eq 1 ]]; then + duplicate_arg_indices+=("${idx}") fi done if [[ ${#duplicate_arg_indices[@]} -gt 0 ]]; then - local dups - dups=$(printf '%s\n' "${!duplicate_arg_indices[@]}" | sort -n | tr '\n' ' ') echo >&2 "Multiple arguments specified for the following indices, cannot" \ - "continue: ${dups}" + "continue: ${duplicate_arg_indices[*]}" return 1 fi } @@ -115,7 +127,8 @@ __shellmock__config() { # Fake output is read from stdin. local cmd="$1" local cmd_b32 - cmd_b32=$(base32 -w0 <<< "${cmd}" | tr "=" "_") + cmd_b32=$(PATH="${__SHELLMOCK_ORGPATH}" base32 -w0 <<< "${cmd}") + cmd_b32=${cmd_b32//=/_} local rc="$2" shift 2 @@ -136,7 +149,7 @@ __shellmock__config() { local has_err=0 local regex='^(regex-[0-9][0-9]*|regex-any|i|[0-9][0-9]*|any):' for arg in "$@"; do - if ! grep -qE "${regex}" <<< "${arg}"; then + if ! [[ ${arg} =~ ${regex} ]]; then echo >&2 "Incorrect format of argspec: ${arg}" has_err=1 fi @@ -179,37 +192,56 @@ __shellmock__config() { return 1 fi + local max_num_configs=${SHELLMOCK_MAX_CONFIGS_PER_MOCK:-100} + if ! [[ ${max_num_configs} =~ ^[0-9][0-9]*$ ]]; then + echo >&2 "SHELLMOCK_MAX_CONFIGS_PER_MOCK must be a number." + return 1 + fi + local tmp + tmp=$((max_num_configs - 1)) + local max_digits=${#tmp} + # Handle fake exit code. Use the exit code as a proxy to determine which count # to use next because all mock configurations have to set the exit code but # not all of them have to provide arg specs or output. - local count=0 + local count=0 padded + padded=$(printf "%0${max_digits}d" "${count}") local env_var_val="${rc}" - local env_var_name="MOCK_RC_${cmd_b32}_${count}" + local env_var_name="MOCK_RC_${cmd_b32}_${padded}" while [[ -n ${!env_var_name-} ]]; do count=$((count + 1)) - env_var_name="MOCK_RC_${cmd_b32}_${count}" + padded=$(printf "%0${max_digits}d" "${count}") + env_var_name="MOCK_RC_${cmd_b32}_${padded}" done + + if [[ ${count} -ge ${max_num_configs} ]]; then + echo >&2 "The maximum number of configs per mock is ${max_num_configs}." \ + "Consider increasing SHELLMOCK_MAX_CONFIGS_PER_MOCK, which is currently" \ + "set to '${max_num_configs}'." + return 1 + fi + declare -gx "${env_var_name}=${env_var_val}" # Handle arg specs. env_var_val=$(for arg in "${args[@]}"; do echo "${arg}" - done | base64 -w0) - env_var_name="MOCK_ARGSPEC_BASE64_${cmd_b32}_${count}" + done | PATH="${__SHELLMOCK_ORGPATH}" base32 -w0) + env_var_name="MOCK_ARGSPEC_BASE32_${cmd_b32}_${padded}" declare -gx "${env_var_name}=${env_var_val}" - # Handle fake output. Read from stdin but only if stdin is not a terminal. + # Handle mock's output. Read from stdin but only if stdin is not a terminal. if ! [[ -t 0 ]]; then - env_var_val="$(base64 -w0)" + env_var_val="$(PATH="${__SHELLMOCK_ORGPATH}" base32 -w0)" else env_var_val= fi - env_var_name="MOCK_OUTPUT_BASE64_${cmd_b32}_${count}" + env_var_name="MOCK_OUTPUT_BASE32_${cmd_b32}_${padded}" declare -gx "${env_var_name}=${env_var_val}" # Handle hook. if [[ -n ${hook-} ]]; then - env_var_name="MOCK_HOOKFN_${cmd_b32}_${count}" + env_var_name="MOCK_HOOKFN_${cmd_b32}_${padded}" declare -gx "${env_var_name}=${hook}" fi } @@ -222,7 +254,8 @@ __shellmock__assert() { local assert_type="$1" local cmd="$2" local cmd_b32 - cmd_b32=$(base32 -w0 <<< "${cmd}" | tr "=" "_") + cmd_b32=$(PATH="${__SHELLMOCK_ORGPATH}" base32 -w0 <<< "${cmd}") + cmd_b32=${cmd_b32//=/_} # Ensure we only assert on existing mocks. if ! [[ -x "${__SHELLMOCK_MOCKBIN}/${cmd}" ]]; then @@ -230,7 +263,8 @@ __shellmock__assert() { return 1 fi - touch "${__SHELLMOCK_EXPECTATIONS_DIR}/${cmd}" + # Create new empty file. + : > "${__SHELLMOCK_EXPECTATIONS_DIR}/${cmd}" case "${assert_type}" in # Make sure that no calls were issued to the mock that we did not expect. By @@ -249,11 +283,17 @@ __shellmock__assert() { local stderr while read -r stderr; do if [[ -s ${stderr} ]]; then - cat >&2 "${stderr}" + PATH="${__SHELLMOCK_ORGPATH}" cat >&2 "${stderr}" has_err=1 fi done < <( - find "${__SHELLMOCK_OUTPUT}/${cmd_b32}" -mindepth 2 -type f -name stderr + shopt -s globstar + local file + for file in "${__SHELLMOCK_OUTPUT}/${cmd_b32}/"**"/stderr"; do + if [[ -f ${file} ]]; then + echo "${file}" + fi + done ) && wait $! || return 1 if [[ ${has_err} -ne 0 ]]; then echo >&2 "SHELLMOCK: got at least one unexpected call for mock ${cmd}." @@ -267,20 +307,25 @@ __shellmock__assert() { call-correspondence) declare -a actual_argspecs mapfile -t actual_argspecs < <( + local file if [[ -d "${__SHELLMOCK_OUTPUT}/${cmd_b32}" ]]; then - # shellmock: uses-command=cat - find "${__SHELLMOCK_OUTPUT}/${cmd_b32}" -mindepth 2 -type f \ - -name argspec -print0 | xargs -r -0 cat | sort -u + shopt -s globstar + for file in "${__SHELLMOCK_OUTPUT}/${cmd_b32}/"**"/argspec"; do + if [[ -f ${file} ]]; then + PATH="${__SHELLMOCK_ORGPATH}" cat "${file}" + fi + done fi ) && wait $! || return 1 declare -a expected_argspecs mapfile -t expected_argspecs < <( - # Ignore grep's exit code, which is relevant with the "pipefail" option. - # The case of no matches is OK here. - env | sed 's/=.*$//' \ - | { grep -x "MOCK_ARGSPEC_BASE64_${cmd_b32}_[0-9][0-9]*" || :; } \ - | sort -u + local var + for var in "${!MOCK_ARGSPEC_BASE32_@}"; do + if [[ ${var} == "MOCK_ARGSPEC_BASE32_${cmd_b32}_"* ]]; then + echo "${var}" + fi + done ) && wait $! || return 1 local has_err=0 @@ -288,7 +333,7 @@ __shellmock__assert() { if ! [[ " ${actual_argspecs[*]} " == *"${argspec}"* ]]; then has_err=1 echo >&2 "SHELLMOCK: cannot find call for mock ${cmd} and argspec:" \ - "$(base64 --decode <<< "${!argspec}")" + "$(PATH="${__SHELLMOCK_ORGPATH}" base32 --decode <<< "${!argspec}")" fi done if [[ ${has_err} -ne 0 ]]; then @@ -347,7 +392,8 @@ __shellmock__calls() { local cmd="$1" local format="${2-"--plain"}" local cmd_b32 - cmd_b32=$(base32 -w0 <<< "${cmd}" | tr "=" "_") + cmd_b32=$(PATH="${__SHELLMOCK_ORGPATH}" base32 -w0 <<< "${cmd}") + cmd_b32=${cmd_b32//=/_} local cmd_quoted cmd_quoted="$(printf "%q" "${cmd}")" @@ -361,8 +407,12 @@ __shellmock__calls() { local call_ids readarray -d $'\n' -t call_ids < <( - find "${__SHELLMOCK_OUTPUT}/${cmd_b32}" -mindepth 1 -maxdepth 1 -type d \ - | sort -n + local dir + for dir in "${__SHELLMOCK_OUTPUT}/${cmd_b32}/"*; do + if [[ -d ${dir} ]]; then + echo "${dir}" + fi + done ) && wait $! || return 1 for call_idx in "${!call_ids[@]}"; do @@ -380,7 +430,7 @@ __shellmock__calls() { done local stdin= if [[ -s "${call_id}/stdin" ]]; then - stdin="$(cat "${call_id}/stdin")" + stdin="$(PATH="${__SHELLMOCK_ORGPATH}" cat "${call_id}/stdin")" shell_quoted+=("<<<" "$(printf "%q" "${stdin}")") fi local suggestion="shellmock config ${cmd_quoted} 0 ${shell_quoted[*]}" @@ -391,7 +441,7 @@ __shellmock__calls() { if [[ ${call_num} -ne 1 ]]; then echo fi - cat << EOF + PATH="${__SHELLMOCK_ORGPATH}" cat << EOF name: ${cmd} id: ${call_num} args: ${args[*]} @@ -404,7 +454,7 @@ EOF if [[ ${call_num} -eq 1 ]]; then echo $'[\n {' fi - cat << EOF + PATH="${__SHELLMOCK_ORGPATH}" cat << EOF "name": "$(__shellmock_jsonify_string "${cmd}")", "id": "$(__shellmock_jsonify_string "${call_num}")", "args": $(__shellmock_jsonify_array " " "${args[@]}"), diff --git a/lib/store.bash b/lib/store.bash index 397725c..8802aa7 100644 --- a/lib/store.bash +++ b/lib/store.bash @@ -26,12 +26,12 @@ __shellmock_internal_funcstore() { # are using at the moment. printf "#!%s\n# " "${BASH}" type "${cmd}" - cat << 'EOF' + PATH="${__SHELLMOCK_ORGPATH}" cat << 'EOF' # Run only if executed directly. if [[ -z ${BASH_SOURCE[0]-} ]] || [[ ${BASH_SOURCE[0]} == "${0}" ]]; then EOF printf '%s "$@"\n' "${cmd}" - cat << 'EOF' + PATH="${__SHELLMOCK_ORGPATH}" cat << 'EOF' else : fi diff --git a/tests/extended.bats b/tests/extended.bats index 819a5c0..398438f 100644 --- a/tests/extended.bats +++ b/tests/extended.bats @@ -158,25 +158,13 @@ EOF exes=( base32 - base64 - basename cat chmod - env - find flock - gawk go - grep mkdir mktemp - ps rm - sed - sort - touch - tr - xargs ) [[ ${output} == $(_join $'\n' "${exes[@]}") ]] } @@ -204,3 +192,19 @@ EOF run -0 shellmock commands -c <<< "${script}" [[ ${output} == *"WARNING: found unknown shellmock directive in line 4: ${line}"* ]] } + +@test "running without flock" { + ids=() + shellmock new exe + shellmock config exe 0 + for _ in {1..50}; do + __SHELLMOCK_TESTING_WO_FLOCK=1 exe & + ids+=("$!") + done + wait "${ids[@]}" + shellmock assert expectations exe + # Ensure that the mock has actually been called 30 times. This is a soft check + # for the absence of race conditions. + outputs=("${__SHELLMOCK_OUTPUT}/"*"/"*) + [[ ${#outputs[@]} -eq 50 ]] +} diff --git a/tests/main.bats b/tests/main.bats index 5abfb6e..7bf5e2e 100644 --- a/tests/main.bats +++ b/tests/main.bats @@ -473,7 +473,7 @@ EOF run ! shellmock config my_exe 0 2:two 1:one i:three regex-1:another-one local expected="Multiple arguments specified for the following \ -indices, cannot continue: 1 2 " +indices, cannot continue: 2 1" [[ ${output} == "${expected}" ]] } @@ -599,7 +599,7 @@ indices, cannot continue: 1 2 " ls=$(command -v ls) shellmock new ls mkdir -p "${BATS_TEST_TMPDIR}/dir" - touch "${BATS_TEST_TMPDIR}/dir/file" + : > "${BATS_TEST_TMPDIR}/dir/file" shellmock config ls forward 1:"${BATS_TEST_TMPDIR}/dir" # Making the linter happy. @@ -608,7 +608,7 @@ indices, cannot continue: 1 2 " run --separate-stderr -0 ls "${BATS_TEST_TMPDIR}/dir" [[ ${output} == "file" ]] shellmock assert expectations ls - [[ ${stderr} == *"SHELLMOCK: forwarding call: ${ls} ${BATS_TEST_TMPDIR}"* ]] + [[ ${stderr} == *"SHELLMOCK: forwarding call: ${ls@Q} '${BATS_TEST_TMPDIR}"* ]] } @test "mocking only some calls to an executable" { diff --git a/tests/misc.bats b/tests/misc.bats index f850f6a..55be150 100644 --- a/tests/misc.bats +++ b/tests/misc.bats @@ -96,9 +96,9 @@ setup() { } @test "changing PATH after init issues warning" { - export PATH="/I/do/not/exist:${PATH}" local stderr - run -0 --separate-stderr shellmock new my_exe + PATH="/I/do/not/exist:${PATH}" \ + run -0 --separate-stderr shellmock new my_exe local regex="^WARNING: value for PATH has changed since loading shellmock" [[ ${stderr} =~ ${regex} ]] } @@ -134,3 +134,39 @@ setup() { shellmock new some_executable shellmock assert expectations some_executable } + +@test "disallow calling more often than specified" { + export SHELLMOCK_MAX_CALLS_PER_MOCK=3 + shellmock new some_executable + shellmock config some_executable 0 + # The first 3 calls work out. + some_executable + some_executable + some_executable + # The next call fails. + run ! some_executable + + shellmock assert expectations some_executable +} + +@test "disallow configuring more often than specified" { + export SHELLMOCK_MAX_CONFIGS_PER_MOCK=3 + shellmock new some_executable + # The first 3 configs can be set. + shellmock config some_executable 0 1:arg1 + shellmock config some_executable 0 1:arg2 + shellmock config some_executable 0 1:arg3 + # The next one fails. + run ! shellmock config some_executable 0 1:arg4 +} + +@test "shellmock works also with almost empty PATH" { + orgpath="${PATH}" + export PATH="${__SHELLMOCK_MOCKBIN}" + shellmock new my_exe + shellmock config my_exe 0 1:arg + my_exe arg + shellmock assert expectations my_exe + run ! my_exe asdf + export PATH=${orgpath} +}