|
#!/usr/bin/env bash |
|
# Minimal assertion + harness library for jenny's test suite. |
|
# Plain bash on purpose: no dev dependency, runs anywhere jenny runs. |
|
|
|
TESTS_RUN=0 |
|
TESTS_FAILED=0 |
|
CURRENT_TEST="" |
|
|
|
# ANSI colours, disabled when not a tty or when NO_COLOR is set. |
|
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then |
|
C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m' |
|
C_DIM=$'\033[2m'; C_OFF=$'\033[0m' |
|
else |
|
C_RED=""; C_GREEN=""; C_YELLOW=""; C_DIM=""; C_OFF="" |
|
fi |
|
|
|
# Root of the repo under test, and the jenny entrypoint. |
|
TEST_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) |
|
REPO_ROOT=$(cd "$TEST_LIB_DIR/../.." && pwd) |
|
JENNY="${JENNY_BIN:-$REPO_ROOT/bin/jenny}" |
|
export JENNY_SHARE_DIR="${JENNY_SHARE_DIR:-$REPO_ROOT/share/jenny}" |
|
|
|
# ---------------------------------------------------------------- test runner |
|
|
|
# it <description> -- starts a new test case |
|
it() { |
|
CURRENT_TEST="$1" |
|
TESTS_RUN=$((TESTS_RUN + 1)) |
|
} |
|
|
|
pass() { |
|
printf ' %sok%s %s\n' "$C_GREEN" "$C_OFF" "$CURRENT_TEST" |
|
} |
|
|
|
fail() { |
|
TESTS_FAILED=$((TESTS_FAILED + 1)) |
|
printf ' %sFAIL%s %s\n' "$C_RED" "$C_OFF" "$CURRENT_TEST" |
|
local line |
|
while IFS= read -r line; do |
|
printf ' %s%s%s\n' "$C_DIM" "$line" "$C_OFF" |
|
done <<< "$1" |
|
} |
|
|
|
skip() { |
|
printf ' %sskip%s %s %s(%s)%s\n' \ |
|
"$C_YELLOW" "$C_OFF" "$CURRENT_TEST" "$C_DIM" "$1" "$C_OFF" |
|
} |
|
|
|
suite() { |
|
printf '\n%s\n' "$1" |
|
} |
|
|
|
# Call at the end of a test file. Exits non-zero if anything failed. |
|
summary() { |
|
printf '\n' |
|
if [ "$TESTS_FAILED" -eq 0 ]; then |
|
printf '%s%d passed%s\n' "$C_GREEN" "$TESTS_RUN" "$C_OFF" |
|
return 0 |
|
fi |
|
printf '%s%d of %d failed%s\n' "$C_RED" "$TESTS_FAILED" "$TESTS_RUN" "$C_OFF" |
|
return 1 |
|
} |
|
|
|
# ----------------------------------------------------------------- assertions |
|
|
|
assert_eq() { |
|
# assert_eq <actual> <expected> [label] |
|
if [ "$1" = "$2" ]; then |
|
pass |
|
else |
|
fail "${3:+$3 |
|
}expected: $2 |
|
actual: $1" |
|
fi |
|
} |
|
|
|
assert_contains() { |
|
# assert_contains <haystack> <needle> [label] |
|
case "$1" in |
|
*"$2"*) pass ;; |
|
*) fail "${3:+$3 |
|
}expected to contain: $2 |
|
actual: $1" ;; |
|
esac |
|
} |
|
|
|
assert_not_contains() { |
|
case "$1" in |
|
*"$2"*) fail "${3:+$3 |
|
}expected NOT to contain: $2 |
|
actual: $1" ;; |
|
*) pass ;; |
|
esac |
|
} |
|
|
|
assert_file_contains() { |
|
# assert_file_contains <file> <fixed-string> [label] |
|
if [ ! -f "$1" ]; then |
|
fail "${3:+$3 |
|
}no such file: $1" |
|
return |
|
fi |
|
if grep -qF -- "$2" "$1"; then |
|
pass |
|
else |
|
fail "${3:+$3 |
|
}$1 does not contain: $2" |
|
fi |
|
} |
|
|
|
assert_file_not_contains() { |
|
if [ ! -f "$1" ]; then |
|
fail "${3:+$3 |
|
}no such file: $1" |
|
return |
|
fi |
|
if grep -qF -- "$2" "$1"; then |
|
fail "${3:+$3 |
|
}$1 unexpectedly contains: $2" |
|
else |
|
pass |
|
fi |
|
} |
|
|
|
assert_file_exists() { |
|
if [ -f "$1" ]; then pass; else fail "${2:+$2 |
|
}expected file to exist: $1"; fi |
|
} |
|
|
|
assert_file_absent() { |
|
if [ ! -e "$1" ]; then pass; else fail "${2:+$2 |
|
}expected file to be absent: $1"; fi |
|
} |
|
|
|
assert_success() { |
|
# assert_success <status> [output] |
|
if [ "$1" -eq 0 ]; then pass; else fail "expected exit 0, got $1 |
|
${2:-}"; fi |
|
} |
|
|
|
assert_failure() { |
|
# assert_failure <status> [output] |
|
if [ "$1" -ne 0 ]; then pass; else fail "expected non-zero exit, got 0 |
|
${2:-}"; fi |
|
} |
|
|
|
# --------------------------------------------------------------- blog fixture |
|
|
|
# make_blog <dir> [posts_per_page] |
|
# Builds the standard repro blog from the handoff document. |
|
make_blog() { |
|
local dir=$1 ppp=${2:-3} |
|
rm -rf "$dir" |
|
mkdir -p "$dir" |
|
cat > "$dir/.blogrc" <<EOF |
|
BLOG_HOST="http://localhost:8000" |
|
BLOG_TITLE="Test Blog" |
|
POSTS_PER_PAGE=$ppp |
|
DIST=\$PWD/.dist |
|
EOF |
|
local i |
|
for i in 01 02 03 04 05 06 07; do |
|
printf '# Post %s\n\ntags: alpha\n\nBody %s\n' "$i" "$i" > "$dir/2024-01-$i post-$i.md" |
|
done |
|
printf '# Tricky & <Title> "quoted"\n\nBody\n' > "$dir/2024-02-01 tricky.md" |
|
printf '# Real Title\n\n```\n# not a title\n```\n' > "$dir/2024-02-02 fenced.md" |
|
printf '# MD Test\n\n- item one\n- item two\n\n**bold** and `code`\n' > "$dir/2024-03-01 mdtest.md" |
|
} |
|
|
|
# make_blog_min <dir> <count> [posts_per_page] |
|
# A blog with exactly <count> plain posts and nothing tricky. |
|
make_blog_min() { |
|
local dir=$1 count=$2 ppp=${3:-3} |
|
rm -rf "$dir" |
|
mkdir -p "$dir" |
|
cat > "$dir/.blogrc" <<EOF |
|
BLOG_HOST="http://localhost:8000" |
|
BLOG_TITLE="Test Blog" |
|
POSTS_PER_PAGE=$ppp |
|
DIST=\$PWD/.dist |
|
EOF |
|
local i n |
|
for i in $(seq 1 "$count"); do |
|
n=$(printf '%02d' "$i") |
|
printf '# Post %s\n\nBody %s\n' "$n" "$n" > "$dir/2024-01-$n post-$n.md" |
|
done |
|
} |
|
|
|
# copy_fixture <name> <dest> |
|
# Copies a committed fixture blog to a scratch directory. Fixtures are |
|
# committed rather than generated so the legacy layouts they carry stay |
|
# byte-identical to what shipped before the record format changed. |
|
copy_fixture() { |
|
local name=$1 dest=$2 |
|
rm -rf "$dest" |
|
cp -r "$REPO_ROOT/test/fixtures/$name" "$dest" |
|
} |
|
|
|
# run_jenny <dir> [args...] |
|
# Runs jenny in <dir>, capturing stdout+stderr into $output and status into $status. |
|
run_jenny() { |
|
local dir=$1; shift |
|
output=$(cd "$dir" && "$JENNY" "$@" 2>&1) |
|
status=$? |
|
return 0 |
|
} |
|
|
|
# run_jenny_split <dir> [args...] |
|
# Same, but keeps stderr separate in $stderr_output. |
|
run_jenny_split() { |
|
local dir=$1; shift |
|
local errfile |
|
errfile=$(mktemp) |
|
output=$(cd "$dir" && "$JENNY" "$@" 2>"$errfile") |
|
status=$? |
|
stderr_output=$(cat "$errfile") |
|
rm -f "$errfile" |
|
return 0 |
|
} |
|
|
|
# ---------------------------------------------------------------- link checker |
|
|
|
# check_links <dist_dir> |
|
# Extracts every href/src from every .html file under <dist_dir> and verifies |
|
# that site-relative and document-relative targets resolve to a real file, |
|
# treating <dist_dir> as the document root. Prints one line per broken link. |
|
# Returns 0 when everything resolves. |
|
check_links() { |
|
local dist=$1 |
|
local broken=0 |
|
local html rel dir link target |
|
|
|
while IFS= read -r html; do |
|
rel=${html#"$dist"/} |
|
dir=$(dirname "$rel") |
|
# Pull href="..." and src="..." values. |
|
while IFS= read -r link; do |
|
[ -z "$link" ] && continue |
|
# Skip external, protocol-relative, anchors, and data/mailto URIs. |
|
case "$link" in |
|
http://*|https://*|//*|\#*|data:*|mailto:*|javascript:*) continue ;; |
|
esac |
|
# Strip query string and fragment. |
|
link=${link%%\#*} |
|
link=${link%%\?*} |
|
[ -z "$link" ] && continue |
|
|
|
if [ "${link:0:1}" = "/" ]; then |
|
target="$dist$link" |
|
elif [ "$dir" = "." ]; then |
|
target="$dist/$link" |
|
else |
|
target="$dist/$dir/$link" |
|
fi |
|
|
|
# A trailing slash (or a bare directory) means index.html. |
|
if [ "${target%/}" != "$target" ] || [ -d "$target" ]; then |
|
target="${target%/}/index.html" |
|
fi |
|
|
|
# With URL_STYLE=extensionless jenny emits /post/slug, which no file |
|
# matches on disk -- Cloudflare Pages, Netlify and Vercel resolve it by |
|
# appending .html. Model that here so the check reflects how the output |
|
# is actually served, rather than passing only in the default style. |
|
if [ ! -e "$target" ] && [ -e "$target.html" ]; then |
|
target="$target.html" |
|
fi |
|
|
|
if [ ! -e "$target" ]; then |
|
echo "$rel -> $link (resolved: ${target#"$dist"/})" |
|
broken=$((broken + 1)) |
|
fi |
|
done < <(grep -oE '(href|src)="[^"]*"' "$html" | sed -E 's/^(href|src)="//; s/"$//') |
|
done < <(find "$dist" -name '*.html' -type f) |
|
|
|
[ "$broken" -eq 0 ] |
|
} |
|
|
|
# feed_item_count <feed.xml> |
|
feed_item_count() { |
|
grep -c '<item>' "$1" 2>/dev/null || echo 0 |
|
} |
|
|
|
# index_post_count <index.html> -- number of real post links (excludes break marker) |
|
index_post_count() { |
|
# Borrowed posts carry an extra class (post-link in-next), so the match is |
|
# on the class prefix rather than the exact attribute value. |
|
grep -oE '<li class="post-link[^"]*">' "$1" 2>/dev/null | wc -l | tr -d ' ' |
|
} |