This commit is contained in:
2026-03-10 00:14:14 -05:00
commit 78ca7b4d8b
25 changed files with 1801 additions and 0 deletions

21
tests/fixtures/all_types.env.example vendored Normal file
View File

@@ -0,0 +1,21 @@
# @type string
STR_VAR=hello
# @type number
NUM_VAR=42
# @type bool
BOOL_VAR=false
# @type secret
# @required
SECRET_VAR=
# @type url
URL_VAR=https://example.com
# @type email
EMAIL_VAR=user@example.com
# @type enum:a,b,c
ENUM_VAR=a

3
tests/fixtures/basic.env.example vendored Normal file
View File

@@ -0,0 +1,3 @@
# Description
# @required
BASIC_VAR=default

0
tests/fixtures/empty.env.example vendored Normal file
View File

View File

@@ -0,0 +1,3 @@
# First line
# Second line
MULTI_VAR=value

View File

@@ -0,0 +1,2 @@
PLAIN_VAR=value
ANOTHER_VAR=

View File

@@ -0,0 +1,4 @@
# This comment has no variable
# It should be ignored
REAL_VAR=value

View File

@@ -0,0 +1,3 @@
# A URL with query parameters (value contains = signs)
# @type url
DB_URL=postgres://host:5432/db?sslmode=require&connect_timeout=10

163
tests/framework.sh Normal file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/env bash
# Minimal test framework. Source this file; do not execute it directly.
#
# Globals maintained per-process:
# _PASS — count of passing assertions
# _FAIL — count of failing assertions
_PASS=0
_FAIL=0
# Color codes (self-contained; do not rely on colors.sh)
_R='\033[0;31m' # red
_G='\033[0;32m' # green
_Y='\033[1;33m' # yellow
_B='\033[1m' # bold
_D='\033[2m' # dim
_N='\033[0m' # reset
# suite <name>
# Print a section header for a group of related tests.
suite() {
printf "\n${_B}%s${_N}\n" "$1"
}
# pass / fail — low-level counters; prefer assert_* helpers instead
pass() {
_PASS=$((_PASS + 1))
printf " ${_G}${_N} %s\n" "$1"
}
fail() {
_FAIL=$((_FAIL + 1))
printf " ${_R}${_N} %s\n" "$1"
[[ -n "${2:-}" ]] && printf " expected: %s\n" "$2"
[[ -n "${3:-}" ]] && printf " got: %s\n" "${3:-<empty>}"
}
# ---------------------------------------------------------------------------
# Assertion helpers
# ---------------------------------------------------------------------------
assert_eq() {
local expected="$1" actual="$2" msg="${3:-assert_eq}"
if [[ "$expected" == "$actual" ]]; then
pass "$msg"
else
fail "$msg" "$expected" "$actual"
fi
}
assert_not_eq() {
local unexpected="$1" actual="$2" msg="${3:-assert_not_eq}"
if [[ "$unexpected" != "$actual" ]]; then
pass "$msg"
else
fail "$msg (expected values to differ)" "$unexpected" "$actual"
fi
}
assert_empty() {
local actual="$1" msg="${2:-assert_empty}"
if [[ -z "$actual" ]]; then
pass "$msg"
else
fail "$msg (expected empty)" "" "$actual"
fi
}
assert_not_empty() {
local actual="$1" msg="${2:-assert_not_empty}"
if [[ -n "$actual" ]]; then
pass "$msg"
else
fail "$msg (expected non-empty)"
fi
}
assert_contains() {
local needle="$1" haystack="$2" msg="${3:-assert_contains}"
if [[ "$haystack" == *"$needle"* ]]; then
pass "$msg"
else
fail "$msg" "*${needle}*" "$haystack"
fi
}
# assert_success <msg> <cmd> [args...]
# Passes when the command exits 0.
assert_success() {
local msg="$1"; shift
local rc=0
"$@" 2>/dev/null || rc=$?
if [[ $rc -eq 0 ]]; then
pass "$msg"
else
fail "$msg (expected exit 0, got $rc)"
fi
}
# assert_failure <msg> <cmd> [args...]
# Passes when the command exits non-zero.
assert_failure() {
local msg="$1"; shift
local rc=0
"$@" 2>/dev/null || rc=$?
if [[ $rc -ne 0 ]]; then
pass "$msg"
else
fail "$msg (expected non-zero exit)"
fi
}
assert_file_exists() {
local file="$1" msg="${2:-assert_file_exists}"
if [[ -f "$file" ]]; then
pass "$msg"
else
fail "$msg" "<file: $file>" "(not found)"
fi
}
assert_file_contains() {
local pattern="$1" file="$2" msg="${3:-assert_file_contains}"
if [[ ! -f "$file" ]]; then
fail "$msg (file not found: $file)"
return
fi
if grep -qF "$pattern" "$file"; then
pass "$msg"
else
fail "$msg" "$pattern" "(not found in $file)"
fi
}
assert_file_not_contains() {
local pattern="$1" file="$2" msg="${3:-assert_file_not_contains}"
if [[ ! -f "$file" ]]; then
fail "$msg (file not found: $file)"
return
fi
if ! grep -qF "$pattern" "$file"; then
pass "$msg"
else
fail "$msg" "(should not contain $pattern)" "(found in $file)"
fi
}
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
# print_summary
# Print pass/fail totals and exit 1 if any tests failed.
print_summary() {
local total=$((_PASS + _FAIL))
printf "\n"
if [[ $_FAIL -eq 0 ]]; then
printf "${_G}%d/%d passed${_N}\n" "$_PASS" "$total"
else
printf "${_R}%d failed${_N}, %d passed, %d total\n" "$_FAIL" "$_PASS" "$total"
fi
[[ $_FAIL -eq 0 ]]
}

75
tests/run_tests.sh Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Discovers and runs all test_*.sh files, printing a combined summary.
#
# Usage:
# ./tests/run_tests.sh # run all suites
# ./tests/run_tests.sh [file] # run a specific suite
set -uo pipefail
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ---- Colors (self-contained) ----
_R='\033[0;31m'; _G='\033[0;32m'; _B='\033[1m'; _D='\033[2m'; _N='\033[0m'
total_pass=0
total_fail=0
failed_suites=()
run_suite() {
local file="$1"
local name
name="$(basename "$file")"
printf "\n${_B}══════ %s ══════${_N}\n" "$name"
local output exit_code=0
output=$(bash "$file" 2>&1) || exit_code=$?
echo "$output"
# Count results — strip ANSI codes first so the unicode chars are findable
local stripped p f
stripped=$(printf '%s' "$output" | sed 's/\x1b\[[0-9;]*[mGKHF]//g')
p=$(printf '%s\n' "$stripped" | grep -c '✓' || true)
f=$(printf '%s\n' "$stripped" | grep -c '✗' || true)
total_pass=$((total_pass + p))
total_fail=$((total_fail + f))
if [[ $exit_code -ne 0 ]]; then
failed_suites+=("$name")
fi
}
# Collect files to run
if [[ $# -gt 0 ]]; then
files=("$@")
else
files=("$TESTS_DIR"/test_*.sh)
fi
for f in "${files[@]}"; do
[[ -f "$f" ]] || { printf "${_R}Not found: %s${_N}\n" "$f" >&2; continue; }
run_suite "$f"
done
# ---- Aggregate summary ----
total=$((total_pass + total_fail))
printf "\n${_B}══════ Summary ══════${_N}\n"
printf "Suites run: %d\n" "${#files[@]}"
printf "Tests: %d total, " "$total"
if [[ $total_fail -eq 0 ]]; then
printf "${_G}%d passed${_N}\n" "$total_pass"
printf "\n${_G}All tests passed.${_N}\n"
exit 0
else
printf "${_G}%d passed${_N}, ${_R}%d failed${_N}\n" "$total_pass" "$total_fail"
printf "\n${_R}Failed suites:${_N}\n"
for s in "${failed_suites[@]}"; do
printf " - %s\n" "$s"
done
exit 1
fi

242
tests/test_e2e.sh Executable file
View File

@@ -0,0 +1,242 @@
#!/usr/bin/env bash
# End-to-end tests for generate-env.sh.
#
# Each test pipes a crafted stdin to generate-env.sh and inspects the output
# .env file. Input lines are counted to match each variable's prompt exactly.
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$TESTS_DIR")"
SCRIPT="$ROOT_DIR/generate-env.sh"
source "$TESTS_DIR/framework.sh"
TMPDIR_SELF="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_SELF"' EXIT
# run_gen <input_string> <input_file> <output_file>
# Runs generate-env.sh, feeding <input_string> as stdin.
# Suppresses all terminal output. Removes <output_file> first so the
# overwrite guard never triggers.
run_gen() {
local input="$1"
local in_file="$2"
local out_file="$3"
rm -f "$out_file"
# -o must come before the positional input-file argument so getopts sees it
printf "%s" "$input" | bash "$SCRIPT" -o "$out_file" "$in_file" 2>/dev/null
}
# ---------------------------------------------------------------------------
suite "e2e: accept all defaults"
# ---------------------------------------------------------------------------
# Fixture: three optional vars with defaults, no required fields
IN="$TMPDIR_SELF/defaults.env.example"
OUT="$TMPDIR_SELF/defaults.env"
cat > "$IN" << 'EOF'
# App name
APP_NAME=MyApp
# Port
# @type number
PORT=3000
# Debug mode
# @type bool
DEBUG=false
EOF
# Input: three Enter presses (accept all defaults)
run_gen $'\n\n\n' "$IN" "$OUT"
assert_file_exists "$OUT" "output file created"
assert_file_contains "APP_NAME=MyApp" "$OUT" "string default accepted"
assert_file_contains "PORT=3000" "$OUT" "number default accepted"
assert_file_contains "DEBUG=false" "$OUT" "bool default accepted"
# ---------------------------------------------------------------------------
suite "e2e: override defaults"
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/override.env.example"
OUT="$TMPDIR_SELF/override.env"
cat > "$IN" << 'EOF'
APP_NAME=MyApp
PORT=3000
EOF
# Override both
run_gen $'CustomApp\n8080\n' "$IN" "$OUT"
assert_file_contains "APP_NAME=CustomApp" "$OUT" "string default overridden"
assert_file_contains "PORT=8080" "$OUT" "number default overridden"
# ---------------------------------------------------------------------------
suite "e2e: required fields must be filled"
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/required.env.example"
OUT="$TMPDIR_SELF/required.env"
cat > "$IN" << 'EOF'
# @required
MUST_HAVE=
EOF
# First send empty (should re-prompt), then a value
run_gen $'\nfilled\n' "$IN" "$OUT"
assert_file_contains "MUST_HAVE=filled" "$OUT" "required field filled on retry"
# ---------------------------------------------------------------------------
suite "e2e: bool values"
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/bool.env.example"
OUT="$TMPDIR_SELF/bool.env"
cat > "$IN" << 'EOF'
# @type bool
FLAG_A=false
# @type bool
FLAG_B=true
EOF
# y for FLAG_A (-> true), Enter for FLAG_B (-> default true)
run_gen $'y\n\n' "$IN" "$OUT"
assert_file_contains "FLAG_A=true" "$OUT" "y -> true"
assert_file_contains "FLAG_B=true" "$OUT" "empty accepts true default"
# ---------------------------------------------------------------------------
suite "e2e: enum with numeric choice"
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/enum.env.example"
OUT="$TMPDIR_SELF/enum.env"
cat > "$IN" << 'EOF'
# @type enum:development,staging,production
# @required
APP_ENV=development
EOF
# Choose option 3 (production)
run_gen $'3\n' "$IN" "$OUT"
assert_file_contains "APP_ENV=production" "$OUT" "enum numeric choice 3 -> production"
# ---------------------------------------------------------------------------
suite "e2e: enum with string choice"
# ---------------------------------------------------------------------------
OUT="$TMPDIR_SELF/enum_str.env"
run_gen $'staging\n' "$IN" "$OUT"
assert_file_contains "APP_ENV=staging" "$OUT" "enum string choice -> staging"
# ---------------------------------------------------------------------------
suite "e2e: enum accepts default"
# ---------------------------------------------------------------------------
OUT="$TMPDIR_SELF/enum_default.env"
run_gen $'\n' "$IN" "$OUT"
assert_file_contains "APP_ENV=development" "$OUT" "enum empty input uses default"
# ---------------------------------------------------------------------------
suite "e2e: secret with confirmation"
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/secret.env.example"
OUT="$TMPDIR_SELF/secret.env"
cat > "$IN" << 'EOF'
# @type secret
# @required
JWT_SECRET=
EOF
# Enter value twice (entry + confirm)
run_gen $'supersecret\nsupersecret\n' "$IN" "$OUT"
assert_file_contains "JWT_SECRET=supersecret" "$OUT" "secret stored after confirmation"
# ---------------------------------------------------------------------------
suite "e2e: secret uses default on empty input"
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/secret_default.env.example"
OUT="$TMPDIR_SELF/secret_default.env"
cat > "$IN" << 'EOF'
# @type secret
JWT_SECRET=existing_token
EOF
# Empty input -> keep default
run_gen $'\n' "$IN" "$OUT"
assert_file_contains "JWT_SECRET=existing_token" "$OUT" "secret empty input keeps default"
# ---------------------------------------------------------------------------
suite "e2e: -o flag sets output path"
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/flag.env.example"
CUSTOM_OUT="$TMPDIR_SELF/custom_output.env"
cat > "$IN" << 'EOF'
KEY=val
EOF
rm -f "$CUSTOM_OUT"
printf "\n" | bash "$SCRIPT" -o "$CUSTOM_OUT" "$IN" 2>/dev/null
assert_file_exists "$CUSTOM_OUT" "custom output path created via -o flag"
assert_file_contains "KEY=val" "$CUSTOM_OUT" "content written to custom path"
# ---------------------------------------------------------------------------
suite "e2e: empty optional field written as KEY="
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/optional.env.example"
OUT="$TMPDIR_SELF/optional.env"
cat > "$IN" << 'EOF'
# Optional field with no default
# @type email
ADMIN_EMAIL=
EOF
# Leave empty
run_gen $'\n' "$IN" "$OUT"
assert_file_contains "ADMIN_EMAIL=" "$OUT" "optional empty field written as KEY="
assert_file_not_contains "ADMIN_EMAIL=x" "$OUT" "no stray value"
# ---------------------------------------------------------------------------
suite "e2e: full example.env.example"
# ---------------------------------------------------------------------------
#
# Variable order and input count:
# 1. APP_NAME (string, required, default "MyApp") → "\n" accept default
# 2. APP_ENV (enum:…, required, default "development") → "\n" accept default (1)
# 3. PORT (number, optional, default "3000") → "\n" accept default
# 4. DEBUG (bool, optional, default "false") → "n\n" explicit n
# 5. JWT_SECRET(secret, required, no default) → "tok\ntok\n" entry+confirm
# 6. DATABASE_URL(url, required, has default) → "\n" accept default
# 7. ADMIN_EMAIL (email, optional, no default) → "\n" leave empty
OUT="$TMPDIR_SELF/full.env"
INPUT=$'\n\n\nn\ntok\ntok\n\n\n'
run_gen "$INPUT" "$ROOT_DIR/example.env.example" "$OUT"
assert_file_exists "$OUT" "output file created"
assert_file_contains "APP_NAME=MyApp" "$OUT" "APP_NAME default"
assert_file_contains "APP_ENV=development" "$OUT" "APP_ENV default"
assert_file_contains "PORT=3000" "$OUT" "PORT default"
assert_file_contains "DEBUG=false" "$OUT" "DEBUG=false (n)"
assert_file_contains "JWT_SECRET=tok" "$OUT" "JWT_SECRET value"
assert_file_contains "DATABASE_URL=postgres://localhost:5432/myapp" "$OUT" "DATABASE_URL default"
assert_file_contains "ADMIN_EMAIL=" "$OUT" "ADMIN_EMAIL empty"
assert_file_contains "# Generated by generate-env" "$OUT" "header present"
print_summary

192
tests/test_output.sh Executable file
View File

@@ -0,0 +1,192 @@
#!/usr/bin/env bash
# Unit tests for lib/output.sh
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$TESTS_DIR")"
source "$TESTS_DIR/framework.sh"
source "$ROOT_DIR/lib/colors.sh"
source "$ROOT_DIR/lib/output.sh"
TMPDIR_SELF="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_SELF"' EXIT
# ---------------------------------------------------------------------------
suite "output: basic file creation"
# ---------------------------------------------------------------------------
ENV_VARS=("FOO" "BAR")
ENV_DESC_FOO=""
ENV_DESC_BAR=""
ENV_VALUE_FOO="hello"
ENV_VALUE_BAR="world"
OUT="$TMPDIR_SELF/basic.env"
write_env_file "$OUT" 2>/dev/null
assert_file_exists "$OUT" "output file created"
assert_file_contains "FOO=hello" "$OUT" "FOO written correctly"
assert_file_contains "BAR=world" "$OUT" "BAR written correctly"
assert_file_contains "# Generated by generate-env" "$OUT" "header present"
# ---------------------------------------------------------------------------
suite "output: variable order matches ENV_VARS"
# ---------------------------------------------------------------------------
ENV_VARS=("ALPHA" "BETA" "GAMMA")
ENV_VALUE_ALPHA="1"
ENV_VALUE_BETA="2"
ENV_VALUE_GAMMA="3"
OUT="$TMPDIR_SELF/order.env"
write_env_file "$OUT" 2>/dev/null
# grep -n returns the line number; verify order
alpha_line=$(grep -n "ALPHA=" "$OUT" | cut -d: -f1)
beta_line=$(grep -n "BETA=" "$OUT" | cut -d: -f1)
gamma_line=$(grep -n "GAMMA=" "$OUT" | cut -d: -f1)
if (( alpha_line < beta_line && beta_line < gamma_line )); then
pass "variable order preserved"
else
fail "variable order preserved" "ALPHA < BETA < GAMMA" "lines $alpha_line, $beta_line, $gamma_line"
fi
# ---------------------------------------------------------------------------
suite "output: empty values written as KEY="
# ---------------------------------------------------------------------------
ENV_VARS=("WITH_VALUE" "NO_VALUE")
ENV_VALUE_WITH_VALUE="set"
ENV_VALUE_NO_VALUE=""
OUT="$TMPDIR_SELF/empty.env"
write_env_file "$OUT" 2>/dev/null
assert_file_contains "WITH_VALUE=set" "$OUT" "non-empty value written"
assert_file_contains "NO_VALUE=" "$OUT" "empty value written as KEY="
assert_file_not_contains "NO_VALUE=x" "$OUT" "no stray value for empty var"
# ---------------------------------------------------------------------------
suite "output: values containing special characters"
# ---------------------------------------------------------------------------
ENV_VARS=("SPECIAL")
ENV_VALUE_SPECIAL='postgres://user:p@ss!#$@host:5432/db?ssl=true'
OUT="$TMPDIR_SELF/special.env"
write_env_file "$OUT" 2>/dev/null
assert_file_contains "SPECIAL=postgres://user:p@ss" "$OUT" "special chars in value preserved"
# ---------------------------------------------------------------------------
suite "output: values containing equals signs"
# ---------------------------------------------------------------------------
ENV_VARS=("DB_URL")
ENV_VALUE_DB_URL="postgres://host/db?sslmode=require&timeout=10"
OUT="$TMPDIR_SELF/equals.env"
write_env_file "$OUT" 2>/dev/null
assert_file_contains "DB_URL=postgres://host/db?sslmode=require&timeout=10" \
"$OUT" "equals signs in value preserved"
# ---------------------------------------------------------------------------
suite "output: header contains ISO-8601 timestamp"
# ---------------------------------------------------------------------------
ENV_VARS=("X")
ENV_VALUE_X="1"
OUT="$TMPDIR_SELF/ts.env"
write_env_file "$OUT" 2>/dev/null
# Header should contain a UTC timestamp in the form YYYY-MM-DDTHH:MM:SSZ
if grep -qE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z' "$OUT"; then
pass "header contains ISO-8601 UTC timestamp"
else
fail "header contains ISO-8601 UTC timestamp" \
"YYYY-MM-DDTHH:MM:SSZ" "(not found in header)"
fi
# ---------------------------------------------------------------------------
suite "output: single variable"
# ---------------------------------------------------------------------------
ENV_VARS=("SOLO")
ENV_VALUE_SOLO="only"
OUT="$TMPDIR_SELF/solo.env"
write_env_file "$OUT" 2>/dev/null
assert_file_exists "$OUT" "file created for single var"
assert_file_contains "SOLO=only" "$OUT" "single var written"
# ---------------------------------------------------------------------------
suite "output: description written as comment before KEY=VALUE"
# ---------------------------------------------------------------------------
ENV_VARS=("DESCRIBED" "NODESC")
ENV_DESC_DESCRIBED="Explains this variable"
ENV_DESC_NODESC=""
ENV_VALUE_DESCRIBED="val1"
ENV_VALUE_NODESC="val2"
OUT="$TMPDIR_SELF/desc.env"
write_env_file "$OUT" 2>/dev/null
assert_file_contains "# Explains this variable" "$OUT" "description written as comment"
assert_file_contains "DESCRIBED=val1" "$OUT" "KEY=VALUE follows comment"
# The comment line should immediately precede the variable line
desc_line=$(grep -n "# Explains this variable" "$OUT" | cut -d: -f1)
var_line=$(grep -n "^DESCRIBED=" "$OUT" | cut -d: -f1)
if (( var_line == desc_line + 1 )); then
pass "comment is on the line directly before KEY=VALUE"
else
fail "comment is on the line directly before KEY=VALUE" \
"$((desc_line + 1))" "$var_line"
fi
# Variable with no description should have no comment line immediately before it
nodesc_line=$(grep -n "^NODESC=" "$OUT" | cut -d: -f1)
prev_line_content=$(sed -n "$((nodesc_line - 1))p" "$OUT")
if [[ -z "$prev_line_content" ]]; then
pass "variable with no description has blank line before it (no comment)"
else
fail "variable with no description has blank line before it (no comment)" \
"" "$prev_line_content"
fi
# ---------------------------------------------------------------------------
suite "output: blank line separates each variable block"
# ---------------------------------------------------------------------------
ENV_VARS=("A" "B" "C")
ENV_DESC_A="First"
ENV_DESC_B=""
ENV_DESC_C="Third"
ENV_VALUE_A="1"
ENV_VALUE_B="2"
ENV_VALUE_C="3"
OUT="$TMPDIR_SELF/spacing.env"
write_env_file "$OUT" 2>/dev/null
# Each variable block ends with a blank line; verify A and C have their comments
assert_file_contains "# First" "$OUT" "first var description present"
assert_file_contains "# Third" "$OUT" "third var description present"
a_line=$(grep -n "^A=" "$OUT" | cut -d: -f1)
b_line=$(grep -n "^B=" "$OUT" | cut -d: -f1)
# There must be a blank line between A= and B=
between=$(sed -n "$((a_line + 1)),$((b_line - 1))p" "$OUT")
if [[ -z "$between" || "$between" =~ ^[[:space:]]*$ ]]; then
pass "blank line between variable blocks"
else
fail "blank line between variable blocks" "(blank)" "$between"
fi
print_summary

138
tests/test_parse.sh Executable file
View File

@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# Unit tests for lib/parse.sh
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$TESTS_DIR")"
FIXTURES="$TESTS_DIR/fixtures"
source "$TESTS_DIR/framework.sh"
source "$ROOT_DIR/lib/colors.sh"
source "$ROOT_DIR/lib/parse.sh"
# ---------------------------------------------------------------------------
suite "parse: basic variable"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/basic.env.example"
assert_eq "1" "${#ENV_VARS[@]}" "finds exactly one variable"
assert_eq "BASIC_VAR" "${ENV_VARS[0]}" "correct variable name"
assert_eq "default" "$ENV_DEFAULT_BASIC_VAR" "correct default value"
assert_eq "true" "$ENV_REQUIRED_BASIC_VAR" "required flag set"
assert_eq "Description" "$ENV_DESC_BASIC_VAR" "description captured"
assert_eq "string" "$ENV_TYPE_BASIC_VAR" "type defaults to string"
# ---------------------------------------------------------------------------
suite "parse: all type annotations"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/all_types.env.example"
assert_eq "string" "$ENV_TYPE_STR_VAR" "explicit string type"
assert_eq "number" "$ENV_TYPE_NUM_VAR" "number type"
assert_eq "bool" "$ENV_TYPE_BOOL_VAR" "bool type"
assert_eq "secret" "$ENV_TYPE_SECRET_VAR" "secret type"
assert_eq "url" "$ENV_TYPE_URL_VAR" "url type"
assert_eq "email" "$ENV_TYPE_EMAIL_VAR" "email type"
assert_eq "enum:a,b,c" "$ENV_TYPE_ENUM_VAR" "enum type with options"
assert_eq "true" "$ENV_REQUIRED_SECRET_VAR" "required annotation on secret"
assert_eq "false" "$ENV_REQUIRED_STR_VAR" "non-required defaults to false"
assert_eq "42" "$ENV_DEFAULT_NUM_VAR" "numeric default"
assert_eq "https://example.com" "$ENV_DEFAULT_URL_VAR" "URL default"
assert_empty "$ENV_DEFAULT_SECRET_VAR" "empty default preserved"
# ---------------------------------------------------------------------------
suite "parse: variable order preserved"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/all_types.env.example"
assert_eq "7" "${#ENV_VARS[@]}" "all seven variables found"
assert_eq "STR_VAR" "${ENV_VARS[0]}" "first variable"
assert_eq "NUM_VAR" "${ENV_VARS[1]}" "second variable"
assert_eq "BOOL_VAR" "${ENV_VARS[2]}" "third variable"
assert_eq "SECRET_VAR" "${ENV_VARS[3]}" "fourth variable"
assert_eq "URL_VAR" "${ENV_VARS[4]}" "fifth variable"
assert_eq "EMAIL_VAR" "${ENV_VARS[5]}" "sixth variable"
assert_eq "ENUM_VAR" "${ENV_VARS[6]}" "seventh variable"
# ---------------------------------------------------------------------------
suite "parse: variable with no annotations"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/no_annotations.env.example"
assert_eq "2" "${#ENV_VARS[@]}" "finds two variables"
assert_eq "string" "$ENV_TYPE_PLAIN_VAR" "type defaults to string"
assert_eq "false" "$ENV_REQUIRED_PLAIN_VAR" "required defaults to false"
assert_empty "$ENV_DESC_PLAIN_VAR" "no description"
assert_eq "value" "$ENV_DEFAULT_PLAIN_VAR" "correct default"
assert_empty "$ENV_DEFAULT_ANOTHER_VAR" "empty default preserved"
# ---------------------------------------------------------------------------
suite "parse: multi-line description"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/multiline_desc.env.example"
assert_contains "First line" "$ENV_DESC_MULTI_VAR" "first description line included"
assert_contains "Second line" "$ENV_DESC_MULTI_VAR" "second description line appended"
# ---------------------------------------------------------------------------
suite "parse: orphaned comment block (no variable follows)"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/orphaned_comments.env.example"
assert_eq "1" "${#ENV_VARS[@]}" "only one real variable found"
assert_eq "REAL_VAR" "${ENV_VARS[0]}" "correct variable captured"
assert_eq "value" "$ENV_DEFAULT_REAL_VAR" "correct default"
# ---------------------------------------------------------------------------
suite "parse: empty file"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/empty.env.example"
assert_eq "0" "${#ENV_VARS[@]}" "no variables in empty file"
# ---------------------------------------------------------------------------
suite "parse: value containing equals signs"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/value_with_equals.env.example"
assert_eq "1" "${#ENV_VARS[@]}" "one variable"
assert_contains "sslmode=require" "$ENV_DEFAULT_DB_URL" "first query param preserved"
assert_contains "connect_timeout=10" "$ENV_DEFAULT_DB_URL" "second query param preserved"
assert_eq "url" "$ENV_TYPE_DB_URL" "type annotation on same var"
# ---------------------------------------------------------------------------
suite "parse: missing file returns error"
# ---------------------------------------------------------------------------
assert_failure "non-existent file returns non-zero" \
parse_env_example "/nonexistent/path.env.example"
# ---------------------------------------------------------------------------
suite "parse: real-world example.env.example"
# ---------------------------------------------------------------------------
parse_env_example "$ROOT_DIR/example.env.example"
assert_eq "7" "${#ENV_VARS[@]}" "all 7 variables"
assert_eq "APP_NAME" "${ENV_VARS[0]}" "first var is APP_NAME"
assert_eq "APP_ENV" "${ENV_VARS[1]}" "second var is APP_ENV"
assert_eq "true" "$ENV_REQUIRED_APP_NAME" "APP_NAME required"
assert_eq "true" "$ENV_REQUIRED_APP_ENV" "APP_ENV required"
assert_eq "false" "$ENV_REQUIRED_PORT" "PORT optional"
assert_eq "number" "$ENV_TYPE_PORT" "PORT is number type"
assert_eq "bool" "$ENV_TYPE_DEBUG" "DEBUG is bool type"
assert_eq "secret" "$ENV_TYPE_JWT_SECRET" "JWT_SECRET is secret type"
assert_eq "url" "$ENV_TYPE_DATABASE_URL" "DATABASE_URL is url type"
assert_eq "email" "$ENV_TYPE_ADMIN_EMAIL" "ADMIN_EMAIL is email type"
assert_eq "enum:development,staging,production" "$ENV_TYPE_APP_ENV" "APP_ENV enum options"
print_summary

190
tests/test_prompt.sh Executable file
View File

@@ -0,0 +1,190 @@
#!/usr/bin/env bash
# Unit tests for prompt helper functions in lib/prompt.sh.
#
# All interactive functions write prompts to stderr and read from stdin.
# Tests pipe input via stdin and capture stdout, suppressing stderr.
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$TESTS_DIR")"
source "$TESTS_DIR/framework.sh"
source "$ROOT_DIR/lib/colors.sh"
source "$ROOT_DIR/lib/validate.sh"
source "$ROOT_DIR/lib/prompt.sh"
# Helpers: feed stdin to a function and capture its stdout
feed() {
# feed <input_string> <function> [args...]
local input="$1"; shift
printf "%s" "$input" | "$@" 2>/dev/null
}
# ---------------------------------------------------------------------------
suite "_prompt_enum: numeric selection"
# ---------------------------------------------------------------------------
assert_eq "a" "$(feed "1"$'\n' _prompt_enum "V" "a,b,c" "a")" "choice 1 -> first option"
assert_eq "b" "$(feed "2"$'\n' _prompt_enum "V" "a,b,c" "a")" "choice 2 -> second option"
assert_eq "c" "$(feed "3"$'\n' _prompt_enum "V" "a,b,c" "a")" "choice 3 -> third option"
# ---------------------------------------------------------------------------
suite "_prompt_enum: string selection"
# ---------------------------------------------------------------------------
assert_eq "a" "$(feed "a"$'\n' _prompt_enum "V" "a,b,c" "")" "string 'a' selects a"
assert_eq "b" "$(feed "b"$'\n' _prompt_enum "V" "a,b,c" "")" "string 'b' selects b"
assert_eq "c" "$(feed "c"$'\n' _prompt_enum "V" "a,b,c" "")" "string 'c' selects c"
# ---------------------------------------------------------------------------
suite "_prompt_enum: empty input uses default"
# ---------------------------------------------------------------------------
assert_eq "a" "$(feed $'\n' _prompt_enum "V" "a,b,c" "a")" "empty -> first default"
assert_eq "b" "$(feed $'\n' _prompt_enum "V" "a,b,c" "b")" "empty -> middle default"
assert_eq "c" "$(feed $'\n' _prompt_enum "V" "a,b,c" "c")" "empty -> last default"
# ---------------------------------------------------------------------------
suite "_prompt_enum: invalid choice then valid"
# ---------------------------------------------------------------------------
# Send out-of-range number, then a valid number
assert_eq "b" "$(printf "99\n2\n" | _prompt_enum "V" "a,b,c" "a" 2>/dev/null)" \
"invalid index followed by valid index"
# Send unknown string, then valid string
assert_eq "c" "$(printf "zzz\nc\n" | _prompt_enum "V" "a,b,c" "a" 2>/dev/null)" \
"invalid string followed by valid string"
# ---------------------------------------------------------------------------
suite "_prompt_enum: real-world APP_ENV"
# ---------------------------------------------------------------------------
assert_eq "development" \
"$(feed $'\n' _prompt_enum "APP_ENV" "development,staging,production" "development")" \
"empty -> development (default)"
assert_eq "production" \
"$(feed "3"$'\n' _prompt_enum "APP_ENV" "development,staging,production" "development")" \
"choice 3 -> production"
assert_eq "staging" \
"$(feed "staging"$'\n' _prompt_enum "APP_ENV" "development,staging,production" "development")" \
"string 'staging' -> staging"
# ---------------------------------------------------------------------------
suite "_prompt_bool: explicit inputs"
# ---------------------------------------------------------------------------
assert_eq "true" "$(feed "y"$'\n' _prompt_bool "")" "y -> true"
assert_eq "true" "$(feed "yes"$'\n' _prompt_bool "")" "yes -> true"
assert_eq "true" "$(feed "true"$'\n' _prompt_bool "")" "true -> true"
assert_eq "true" "$(feed "1"$'\n' _prompt_bool "")" "1 -> true"
assert_eq "false" "$(feed "n"$'\n' _prompt_bool "")" "n -> false"
assert_eq "false" "$(feed "no"$'\n' _prompt_bool "")" "no -> false"
assert_eq "false" "$(feed "false"$'\n' _prompt_bool "")" "false -> false"
assert_eq "false" "$(feed "0"$'\n' _prompt_bool "")" "0 -> false"
# ---------------------------------------------------------------------------
suite "_prompt_bool: empty input uses default"
# ---------------------------------------------------------------------------
assert_eq "true" "$(feed $'\n' _prompt_bool "true")" "empty -> true default"
assert_eq "true" "$(feed $'\n' _prompt_bool "yes")" "empty -> yes default normalizes to true"
assert_eq "false" "$(feed $'\n' _prompt_bool "false")" "empty -> false default"
assert_eq "false" "$(feed $'\n' _prompt_bool "no")" "empty -> no default normalizes to false"
# ---------------------------------------------------------------------------
suite "_prompt_bool: invalid then valid"
# ---------------------------------------------------------------------------
assert_eq "true" "$(printf "banana\ny\n" | _prompt_bool "" 2>/dev/null)" \
"invalid input followed by y"
assert_eq "false" "$(printf "maybe\nn\n" | _prompt_bool "" 2>/dev/null)" \
"invalid input followed by n"
# ---------------------------------------------------------------------------
suite "_prompt_secret: matching values accepted"
# ---------------------------------------------------------------------------
assert_eq "mysecret" \
"$(printf "mysecret\nmysecret\n" | _prompt_secret "V" "" 2>/dev/null)" \
"matching entries return value"
assert_eq "p@ss!#\$word" \
"$(printf 'p@ss!#$word\np@ss!#$word\n' | _prompt_secret "V" "" 2>/dev/null)" \
"special characters in secret preserved"
# ---------------------------------------------------------------------------
suite "_prompt_secret: empty input uses default"
# ---------------------------------------------------------------------------
assert_eq "defaultsecret" \
"$(printf "\n" | _prompt_secret "V" "defaultsecret" 2>/dev/null)" \
"empty input returns default"
# ---------------------------------------------------------------------------
suite "_prompt_secret: mismatch retries"
# ---------------------------------------------------------------------------
# First pair mismatches, second pair matches
assert_eq "correct" \
"$(printf "wrong\nmismatch\ncorrect\ncorrect\n" | _prompt_secret "V" "" 2>/dev/null)" \
"mismatch retries until match"
# ---------------------------------------------------------------------------
suite "prompt_variable: string type stores value"
# ---------------------------------------------------------------------------
# Use process substitution (<(...)) so prompt_variable runs in the current
# shell and its variable assignments (ENV_VALUE_*) persist.
ENV_VARS=("MYVAR")
ENV_DESC_MYVAR="A test variable"
ENV_TYPE_MYVAR="string"
ENV_REQUIRED_MYVAR="false"
ENV_DEFAULT_MYVAR="default_val"
# Accept default
prompt_variable "MYVAR" < <(printf "\n") 2>/dev/null
assert_eq "default_val" "$ENV_VALUE_MYVAR" "empty input stores default"
# Provide a value
prompt_variable "MYVAR" < <(printf "custom\n") 2>/dev/null
assert_eq "custom" "$ENV_VALUE_MYVAR" "typed value stored"
# ---------------------------------------------------------------------------
suite "prompt_variable: number type re-prompts on invalid input"
# ---------------------------------------------------------------------------
ENV_TYPE_MYVAR="number"
ENV_DEFAULT_MYVAR="42"
prompt_variable "MYVAR" < <(printf "abc\n99\n") 2>/dev/null
assert_eq "99" "$ENV_VALUE_MYVAR" "invalid number re-prompts, then stores valid"
# ---------------------------------------------------------------------------
suite "prompt_variable: bool type"
# ---------------------------------------------------------------------------
ENV_TYPE_MYVAR="bool"
ENV_DEFAULT_MYVAR="false"
prompt_variable "MYVAR" < <(printf "y\n") 2>/dev/null
assert_eq "true" "$ENV_VALUE_MYVAR" "bool y stored as true"
prompt_variable "MYVAR" < <(printf "\n") 2>/dev/null
assert_eq "false" "$ENV_VALUE_MYVAR" "bool empty uses false default"
# ---------------------------------------------------------------------------
suite "prompt_variable: enum type"
# ---------------------------------------------------------------------------
ENV_TYPE_MYVAR="enum:x,y,z"
ENV_DEFAULT_MYVAR="x"
prompt_variable "MYVAR" < <(printf "2\n") 2>/dev/null
assert_eq "y" "$ENV_VALUE_MYVAR" "enum choice 2 stores y"
prompt_variable "MYVAR" < <(printf "\n") 2>/dev/null
assert_eq "x" "$ENV_VALUE_MYVAR" "enum empty uses default x"
print_summary

131
tests/test_validate.sh Executable file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Unit tests for lib/validate.sh
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$TESTS_DIR")"
source "$TESTS_DIR/framework.sh"
source "$ROOT_DIR/lib/colors.sh"
source "$ROOT_DIR/lib/validate.sh"
# Shorthand wrappers to reduce repetition
ok() { assert_success "$1" validate_value "VAR" "$2" "$3" "$4"; }
bad() { assert_failure "$1" validate_value "VAR" "$2" "$3" "$4"; }
# ---------------------------------------------------------------------------
suite "validate: required field"
# ---------------------------------------------------------------------------
bad "empty value is invalid when required" "" "string" "true"
ok "non-empty value is valid when required" "x" "string" "true"
ok "empty value is valid when optional" "" "string" "false"
# ---------------------------------------------------------------------------
suite "validate: number"
# ---------------------------------------------------------------------------
ok "positive integer" "42" "number" "false"
ok "zero" "0" "number" "false"
ok "negative integer" "-7" "number" "false"
ok "decimal" "3.14" "number" "false"
ok "negative decimal" "-0.5" "number" "false"
bad "plain string" "abc" "number" "false"
bad "mixed alphanumeric" "42abc" "number" "false"
bad "leading decimal" ".5" "number" "false"
bad "multiple dots" "1.2.3" "number" "false"
ok "empty optional number" "" "number" "false"
bad "empty required number" "" "number" "true"
# ---------------------------------------------------------------------------
suite "validate: bool"
# ---------------------------------------------------------------------------
ok "true" "true" "bool" "false"
ok "false" "false" "bool" "false"
ok "yes" "yes" "bool" "false"
ok "no" "no" "bool" "false"
ok "1" "1" "bool" "false"
ok "0" "0" "bool" "false"
ok "y" "y" "bool" "false"
ok "n" "n" "bool" "false"
ok "TRUE (uppercase)" "TRUE" "bool" "false"
ok "FALSE (uppercase)" "FALSE" "bool" "false"
bad "banana" "banana" "bool" "false"
bad "yes-no" "yes-no" "bool" "false"
ok "empty optional bool" "" "bool" "false"
bad "empty required bool" "" "bool" "true"
# ---------------------------------------------------------------------------
suite "validate: url"
# ---------------------------------------------------------------------------
# URL validation only warns — it always returns 0
ok "http URL passes" "http://example.com" "url" "false"
ok "https URL passes" "https://example.com" "url" "false"
ok "non-URL still passes (warn only)" "not-a-url" "url" "false"
ok "empty optional url" "" "url" "false"
bad "empty required url" "" "url" "true"
# ---------------------------------------------------------------------------
suite "validate: email"
# ---------------------------------------------------------------------------
ok "simple email" "user@example.com" "email" "false"
ok "subdomain email" "user@mail.example.com" "email" "false"
ok "plus addressing" "user+tag@example.com" "email" "false"
bad "missing @" "userexample.com" "email" "false"
bad "missing domain" "user@" "email" "false"
bad "missing TLD" "user@example" "email" "false"
bad "spaces in email" "user @example.com" "email" "false"
ok "empty optional email" "" "email" "false"
bad "empty required email" "" "email" "true"
# ---------------------------------------------------------------------------
suite "validate: enum"
# ---------------------------------------------------------------------------
ok "first option" "a" "enum:a,b,c" "false"
ok "middle option" "b" "enum:a,b,c" "false"
ok "last option" "c" "enum:a,b,c" "false"
bad "not in list" "d" "enum:a,b,c" "false"
bad "partial match" "ab" "enum:a,b,c" "false"
bad "case mismatch" "A" "enum:a,b,c" "false"
ok "single-option enum" "only" "enum:only" "false"
bad "not the single option" "other" "enum:only" "false"
ok "empty optional enum" "" "enum:a,b,c" "false"
bad "empty required enum" "" "enum:a,b,c" "true"
# Enum with real-world values
ok "development" "development" "enum:development,staging,production" "true"
ok "staging" "staging" "enum:development,staging,production" "true"
ok "production" "production" "enum:development,staging,production" "true"
bad "test env" "test" "enum:development,staging,production" "true"
# ---------------------------------------------------------------------------
suite "validate: string and secret (no structural validation)"
# ---------------------------------------------------------------------------
ok "any string value" "anything goes" "string" "false"
ok "string with specials" "f@o=bar!#$%" "string" "false"
ok "any secret value" "s3cr3t!@#" "secret" "false"
ok "empty optional string" "" "string" "false"
bad "empty required string" "" "string" "true"
# ---------------------------------------------------------------------------
suite "normalize_bool"
# ---------------------------------------------------------------------------
assert_eq "true" "$(normalize_bool "true")" "true -> true"
assert_eq "true" "$(normalize_bool "yes")" "yes -> true"
assert_eq "true" "$(normalize_bool "1")" "1 -> true"
assert_eq "true" "$(normalize_bool "y")" "y -> true"
assert_eq "true" "$(normalize_bool "TRUE")" "TRUE -> true (case insensitive)"
assert_eq "true" "$(normalize_bool "YES")" "YES -> true (case insensitive)"
assert_eq "false" "$(normalize_bool "false")" "false -> false"
assert_eq "false" "$(normalize_bool "no")" "no -> false"
assert_eq "false" "$(normalize_bool "0")" "0 -> false"
assert_eq "false" "$(normalize_bool "n")" "n -> false"
assert_eq "false" "$(normalize_bool "FALSE")" "FALSE -> false (case insensitive)"
assert_eq "banana" "$(normalize_bool "banana")" "unknown value passes through"
print_summary