=====================================================================

Excited to bring V5 to life. This includes some BREAKING CHANGES to
several aspects of ZSH-type scwrypts. Please refer to the readme
for upgrade details (specifically docs/upgrade/v4-to-v5.md)

--- New Features -------------------------

- ZSH testing library with basic mock capabilities

- new scwrypts environment file format includes metadata and more
  advanced features like optional parent env overrides, selection
  inheritence, and improved structurual flexibility

- speedup cache for non-CI runs of ZSH-type scwrypts

- ${scwryptsmodule} syntax now allows a consistent unique-naming
  scheme for functions in ZSH-type scwrypts while providing better
  insight into origin of API calls in other modules

- reusable, case-statement-driven argument parsers in ZSH-type scwrypts

--- Changes ------------------------------

- several utility function renames in ZSH-type scwrypts to improve
  consistency

- documentation comments included in ZSH libraries

- ZSH-type scwrypts now allow library modules to live alongside
  executables
  (zsh/lib still supported; autodetection determines default)

--- Bug Fixes ----------------------------

- hardened environment checking for REQUIRED_ENV variables; this removes
  the ability to overwrite variables in local function contexts
This commit is contained in:
2024-05-10 13:32:02 -06:00
committed by Wryn (yage) Wagner
parent 1b4060dd1c
commit 7f14edd039
271 changed files with 11459 additions and 10516 deletions
+51
View File
@@ -0,0 +1,51 @@
#####################################################################
use unittest/operations
#####################################################################
${scwryptsmodule}() {
local ERRORS=0 TEST_ERRORS=0
[ "$SCWRYPTS_LOG_LEVEL" ] || SCWRYPTS_LOG_LEVEL=4
local UNITTESTS=($(echo "${(k)functions}" | sed 's/ /\n/g' | grep '^test\.' | sort))
[[ ${#UNITTESTS[@]} -gt 0 ]] \
|| echo.error "must define at least one unittest" \
|| return 1
echo.status "${SCWRYPTS_TEST_MODULE_STRING}starting test suite"
local UNITTEST_RESULTS_DIR="${SCWRYPTS_TEMP_PATH}/test"
mkdir -p "${UNITTEST_RESULTS_DIR}"
local TEST_COUNT=0
command -v beforeall &>/dev/null && beforeall
local UNITTEST
for UNITTEST in ${UNITTESTS[@]}
do
((TEST_COUNT+=1))
ERRORS=0
command -v beforeeach &>/dev/null && beforeeach
${UNITTEST} &> "${UNITTEST_RESULTS_DIR}/${UNITTEST}.txt" \
&& echo.success "${SCWRYPTS_TEST_MODULE_STRING}${UNITTEST}" \
|| { echo.error "${SCWRYPTS_TEST_MODULE_STRING}${UNITTEST}"; ((TEST_ERRORS+=1)); echo "--- begin test output ---">&2; cat "${UNITTEST_RESULTS_DIR}/${UNITTEST}.txt"; echo "--- end test output ---\n">&2; }
command -v aftereach &>/dev/null && aftereach
unittest.operations restore
unittest.mock.env.restore
done
command -v afterall &>/dev/null && afterall
local EXIT_CODE=$TEST_ERRORS
[[ $TEST_ERRORS -eq 0 ]] \
&& echo.success "${SCWRYPTS_TEST_MODULE_STRING}passed ${TEST_COUNT} / ${TEST_COUNT} test(s)" \
|| echo.error "${SCWRYPTS_TEST_MODULE_STRING}failed ${EXIT_CODE} / ${TEST_COUNT} test(s)" \
;
return $EXIT_CODE
}
+196
View File
@@ -0,0 +1,196 @@
#####################################################################
DEPENDENCIES+=(sed)
#####################################################################
#
# "mocking" is critical to unittesting workflows, and although this
# provides a preliminary implementation idea for "mocking" in ZSH,
# this is very new and subject to change
#
# for critical workflows, though, some limited tests are way better
# than no tests at all!
#
# known issues:
# - callstack assertions are never set when the function call is
# a non-terminal member of a pipestream; for example:
#
# unittest.mock func-a --stdout asdf
#
# func-a | grep -q "asdf"
# ^ grep will succeed, indicating the mock worked
#
# func-a.assert.called
# ^ will fail; I guess pipe streams are executed in a subshell
# where export doesn't work as expected?
#
MOCKS=()
${scwryptsmodule}() {
local DESCRIPTION="
(beta) generates a function mock for basic ZSH unit testing
"
eval "$(utils.parse.autosetup)"
##########################################
export MOCK__ORIGINAL_IMPLEMENTATION__${FUNCTION_VARIABLE}="$(which ${FUNCTION})"
[ "${STDOUT}" ] && export MOCK__STDOUT__${FUNCTION_VARIABLE}="${STDOUT}"
[ "${STDERR}" ] && export MOCK__STDERR__${FUNCTION_VARIABLE}="${STDERR}"
[ ${EXIT_CODE} ] || EXIT_CODE=0
export MOCK__EXIT_CODE__${FUNCTION_VARIABLE}=${EXIT_CODE}
##########################################
# tricky! in order to set the ${FUNCTION} as literal within zsh functions, we need
# to run all the test definitions as an eval line :S
eval "
export MOCK__CALLSTACK__${FUNCTION_VARIABLE}=()
export MOCK__CALLCOUNT__${FUNCTION_VARIABLE}=0
${FUNCTION}() {
MOCK__CALLSTACK__${FUNCTION_VARIABLE}+=(\"\$@\")
((MOCK__CALLCOUNT__${FUNCTION_VARIABLE}+=1))
printf \"\$MOCK__STDOUT__${FUNCTION_VARIABLE}\"
printf \"\$MOCK__STDERR__${FUNCTION_VARIABLE}\" >&2
return \$(eval echo '\$MOCK__EXIT_CODE__'${FUNCTION_VARIABLE})
}
${FUNCTION}.assert.called() {
local ERRORS=0
[[ MOCK__CALLCOUNT__${FUNCTION_VARIABLE} -gt 0 ]] \
|| echo.error \"${FUNCTION} was not called\"
}
${FUNCTION}.assert.not.called() {
local ERRORS=0
${FUNCTION}.assert.called &>/dev/null
[[ \$? -ne 0 ]] \
|| echo.error \"${FUNCTION} was called\"
}
${FUNCTION}.assert.callstack() {
local ERRORS=0
[[ \"\$@\" =~ ^\${MOCK__CALLSTACK__${FUNCTION_VARIABLE}}$ ]] \
|| echo.error \"${FUNCTION} callstack does not match\nexpected : \$@\nreceived : \${MOCK__CALLSTACK__${FUNCTION_VARIABLE}}\"
}
${FUNCTION}.assert.callstackincludes() {
local ERRORS=0
[[ \${MOCK__CALLSTACK__${FUNCTION_VARIABLE}} =~ \$@ ]] \
|| echo.error \"${FUNCTION} callstack does not include\nexpected : \$@\ncallstack : \${MOCK__CALLSTACK__${FUNCTION_VARIABLE}}\"
}
${FUNCTION}.reset() {
unset \
MOCK__CALLSTACK__${FUNCTION_VARIABLE} \
MOCK__CALLCOUNT__${FUNCTION_VARIABLE} \
;
}
${FUNCTION}.restore() {
MOCKS=(\$(echo \"\$MOCKS\" | sed 's/\s\+/\n/g' | grep -v \"^${FUNCTION}$\"))
unset \
MOCK__CALLSTACK__${FUNCTION_VARIABLE} \
MOCK__CALLCOUNT__${FUNCTION_VARIABLE} \
;
unset -f \
${FUNCTION} \
${FUNCTION}.assert.called \
${FUNCTION}.assert.not.called \
${FUNCTION}.assert.callstack \
${FUNCTION}.assert.callstackincludes \
${FUNCTION}.reset \
${FUNCTION}.restore \
;
local ORIGINAL_IMPLEMENTATION=\"\$(eval echo '\$MOCK__ORIGINAL_IMPLEMENTATION__'${FUNCTION_VARIABLE})\"
[[ \$(echo \"\$ORIGINAL_IMPLEMENTATION\" | wc -l) -gt 1 ]] \
&& eval \"\$ORIGINAL_IMPLEMENTATION\"
unset ORIGINAL__IMPLEMENTATION__${FUNCTION_VARIABLE}
}
"
MOCKS+=(${FUNCTION})
}
#####################################################################
${scwryptsmodule}.parse.locals() {
local FUNCTION
local FUNCTION_VARIABLE
local STDOUT
local STDERR
local EXIT_CODE
}
${scwryptsmodule}.parse() {
# local FUNCTION STDOUT STDERR EXIT_CODE
local PARSED=0
case $1 in
( --stdout )
PARSED=2
STDOUT="$2"
;;
( --stderr )
PARSED=2
STDERR="$2"
;;
( --exit-code )
PARSED=2
EXIT_CODE="$2"
;;
( * ) [[ ${POSITIONAL_ARGS} -gt 0 ]] && return 0
((POSITIONAL_ARGS+=1))
PARSED=1
case ${POSITIONAL_ARGS} in
( 1 ) FUNCTION="$1" ;;
esac
;;
esac
return ${PARSED}
}
${scwryptsmodule}.parse.usage() {
USAGE__usage+=' function [...options...]'
USAGE__args+='
function the function to be mocked
'
USAGE__options+='
--stdout <string> mock the stdout output for the call
--stderr <string> mock the stdout output for the call
--exit-code <number> mock the exit code for the call
'
}
${scwryptsmodule}.parse.validate() {
FUNCTION_VARIABLE="$(echo "${FUNCTION}" | sed 's/\./___/g; s/-/_____/g')"
[ "${FUNCTION_VARIABLE}" ] || echo.error "failed to determine safe variable name for '${FUNCTION}'"
[ ${FUNCTION} ] && command -v ${FUNCTION} &>/dev/null || {
[[ $(eval echo "\$MOCK_UNCALLABLE_WARNING_ISSUED__${FUNCTION_VARIABLE}") =~ true ]] || {
echo.warning "mocking uncallable '${FUNCTION}'"
export MOCK_UNCALLABLE_WARNING_ISSUED__${FUNCTION_VARIABLE}=true
}
}
echo "${MOCKS}" | sed 's/\s\+/\n/g' | grep -q "^${FUNCTION}$" \
&& echo.error "cannot mock '${FUNCTION}' (it is already mocked)"
}
+85
View File
@@ -0,0 +1,85 @@
#####################################################################
DEPENDENCIES+=(sed)
#####################################################################
MOCKED_ENV=()
${scwryptsmodule}() {
local DESCRIPTION="
(beta) mocks an environment variable for testing
"
eval "$(utils.parse.autosetup)"
##########################################
MOCKED_ENV+=(${ENVIRONMENT_VARIABLE_NAME})
export ${ENVIRONMENT_VARIABLE_NAME}__original_value="${(P)ENVIRONMENT_VARIABLE_NAME}"
export ${ENVIRONMENT_VARIABLE_NAME}=${ENVIRONMENT_VARIABLE_VALUE}
}
${scwryptsmodule}.restore() {
local ENVIRONMENT_VARIABLE_NAME ORIGINAL_VALUE
for ENVIRONMENT_VARIABLE_NAME in ${MOCKED_ENV[@]}
do
ORIGINAL_VALUE="$(eval echo '$'$ENVIRONMENT_VARIABLE_NAME'__original_value')"
[ "$ORIGINAL_VALUE" ] \
&& export ${ENVIRONMENT_VARIABLE_NAME}="$ORIGINAL_VALUE" \
|| unset ${ENVIRONMENT_VARIABLE_NAME} \
;
unset ${ENVIRONMENT_VARIABLE_NAME}__checked 2>/dev/null
done
MOCKED_ENV=()
}
#####################################################################
${scwryptsmodule}.parse.locals() {
local ENVIRONMENT_VARIABLE_NAME
local ENVIRONMENT_VARIABLE_VALUE
}
${scwryptsmodule}.parse() {
local PARSED=0
case $1 in
( --value )
PARSED=2
ENVIRONMENT_VARIABLE_VALUE="$2"
;;
( * )
[[ $POSITIONAL_ARGS -gt 0 ]] && return 0
((POSITIONAL_ARGS+=1))
PARSED=1
case $POSITIONAL_ARGS in
( 1 ) ENVIRONMENT_VARIABLE_NAME="$1"
;;
esac
;;
esac
return $PARSED
}
${scwryptsmodule}.parse.usage() {
USAGE__usage+=' function [...options...]'
USAGE__args+='
name the name of the environment variable
'
USAGE__options+='
--value the value of the environment variable
'
}
${scwryptsmodule}.parse.validate() {
[ "$ENVIRONMENT_VARIABLE_NAME" ] \
|| echo.error "no environment variable specified"
echo "$MOCKED_ENV" | sed 's/\s\+/\n/g' | grep -q "^$ENVIRONMENT_VARIABLE_NAME$" \
&& echo.error "environment variable '$ENVIRONMENT_VARIABLE_NAME' has already been mocked"
}
+10
View File
@@ -0,0 +1,10 @@
#
# function mocking utilities for ZSH unit testing
#
# primary mock generator
use unittest/mock/create
eval "${scwryptsmodule}() { ${scwryptsmodule}.create \$@; }"
# mock environment variables
use unittest/mock/env
+38
View File
@@ -0,0 +1,38 @@
#####################################################################
${scwryptsmodule}() {
local DESCRIPTION="
allows batch operations against existing mocks for lib/test/unittest
"
eval "$(utils.parse.autosetup)"
##########################################
local MOCK
for MOCK in ${MOCKS[@]}
do
${MOCK}.${OPERATION}
done
}
#####################################################################
${scwryptsmodule}.locals() {
local OPERATION
}
${scwryptsmodule}.parse() {
local PARSED=0
case $1 in
( restore | reset ) PARSED=1; OPERATION="$1" ;;
esac
return ${PARSED}
}
${scwryptsmodule}.parse.usage() {
USAGE__args+='
$1 one of (restore reset) to perform on all active mocks
'
}
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env zsh
#####################################################################
use scwrypts/meta
#####################################################################
USAGE__description="
runs tests across scwrypts zsh modules (beta)
"
USAGE__args='
$@ paths or lookup patterns to test
'
#####################################################################
MAIN() {
local \
ARGS=() \
PARSERS=()
eval "$ZSHPARSEARGS"
##########################################
local MODE
[[ ${#ARGS[@]} -eq 0 ]] \
&& MODE=all \
|| MODE=filter \
;
local TEST_FILES="" GROUP
for GROUP in ${SCWRYPTS_GROUPS[@]}
do
TEST_FILES+="$(find "$(scwrypts.config.group ${GROUP} root)" -type f -name \*.test.zsh)"
done
for FILTER in ${ARGS[@]}
do
TEST_FILES="$(echo "${TEST_FILES}" | grep "${FILTER}")"
done
local SCWRYPTS_CONFIG="$(scwrypts.meta.run --config)"
local TEST_FILE
local TEST_SUITE_COUNT=0 TEST_SUITE_FAILED_COUNT=0
for TEST_FILE in $(echo "${TEST_FILES}")
do
((TEST_SUITE_COUNT+=1))
local TEST_SUITE_NAME="$(basename -- "$(dirname -- ${TEST_FILE})")/$(basename -- "${TEST_FILE}")"
zsh <<< "
() {
local ERRORS=0
local CI=true
$SCWRYPTS_CONFIG
source '$TEST_FILE'
use unittest/execute-test-file
utils.check-environment
SUPPRESS_USAGE_OUTPUT=true SCWRYPTS_TEST_MODULE_STRING=\"${TEST_SUITE_NAME} : \" unittest.execute-test-file
}
" || ((TEST_SUITE_FAILED_COUNT+=1))
done
[[ ${TEST_SUITE_FAILED_COUNT} -eq 0 ]] \
&& echo.success "\nsuccessfully passed ${TEST_SUITE_COUNT} / ${TEST_SUITE_COUNT} test suite(s)\n " \
|| echo.error "\nfailed ${TEST_SUITE_FAILED_COUNT} / ${TEST_SUITE_COUNT} test suite(s)\n " \
;
}
+8
View File
@@ -0,0 +1,8 @@
${scwryptsmodule}() {
local ERRORS=0
[ $1 ] \
|| echo.error "must specify a function name to provide" \
|| return 2
command -v $1 || echo.error "missing '$1'"
}
+9
View File
@@ -0,0 +1,9 @@
#
# common logic used in testing
#
# use uuidgen for random string generation
DEPENDENCIES+=(uuidgen)
# ensure a module provides a function by name
use unittest/test/provides
+30
View File
@@ -0,0 +1,30 @@
#
# (beta) module for performing unit tests on scwrypts zsh modules
#
# provides function and environment variable mocking (+ some assertions)
use unittest/mock
# provides common logic used in testing
use unittest/test
#
# create a test file to match your module name
# ├── my-thing.module.zsh
# └── my-thing.test.zsh
#
# add the import line 'use unittest'
#
# define tests by creating functions called 'test.your-test-name()'
# - the test "passes" on successful return code (e.g. 'return 0')
# - the test "fails" on any other return code
#
# some other testing features implemented:
# - defining 'beforeall()' < executed before the test suite runs
# - defining 'beforeeach()' < executed before each test function
# - defining 'aftereach()' < executed after each test function
# - defining 'afterall()' < executed after the test suite completes
#
# using 'scwrypts unittest run' will run each test suite in an isolated
# subshell, so configurations are not persisted between test files
#