Files
WP-LIT-MCP/tests/Integration/super-cache-smoke.sh
T
Jan-Niclas LoosenandClaude Opus 5.5 661d3945fd feat(cache): Verhaltensprobe und Smoke für WP Super Cache
SuperCacheProbe prüft beim Prüfen, ob wp_cache_post_edit die Cache-Datei einer Testseite entfernt; während der Probe ist nur der Einzelseiten-Purge erlaubt, der Pfad kommt aus home und es werden nur feste Dateinamen gelöscht. ProbeStep bündelt die Schrittlogik von Pods und WP Super Cache; ThrowawayPost hält Marker und Titel zentral und räumt auch private Probe-Seiten auf (WP Super Cache wie Pods beim Laden des Reiters). super-cache-smoke.sh prüft gegen das echte Plugin; lib.sh sperrt parallele Smoke-Läufe per flock, wp-config.php wird atomar wiederhergestellt. Doku: Host-Abhängigkeit des Einzel-Purges, WP-Cron für den Nachlauf, Komplett-Purge durch Divi beim Prüfen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:25:37 +01:00

304 lines
19 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# End-to-end checks of the WP Super Cache profile of the compatibility layer (plan 2026-11-06-kompatibilitaetsschicht,
# 2.5 and 2.6): status, check route /wpsc-check with the purge probe (cache off and on), the purge after a write of
# this plugin without wp_update_post (terms), the whole-cache purge after a setting, its throttle and the trailing
# purge by cron. Runs INSIDE the DDEV web container:
# cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/super-cache-smoke.sh
#
# SIDE EFFECTS, all undone by the EXIT trap and proven at the end (verify_restored): activating WP Super Cache edits
# wp-config.php (WP_CACHE, WPCACHEHOME), creates wp-content/advanced-cache.php and wp-content/wp-cache-config.php,
# fills wp-content/cache/ and schedules wp_cache_* cron events and options. The script saves wp-config.php (and the
# files named in SAVED_FILES, if they exist) first, deactivates the plugin and writes the saved state back, byte for byte (sha256).
# It uses the plain PHP cache mode ($wp_cache_mod_rewrite = 0): no .htaccess rewrite rules are ever written; the run
# refuses to start if the cache directory is not empty, WP Super Cache is already active or WP_CACHE is already set.
set -euo pipefail
cd /var/www/html
# shellcheck source=lib.sh
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh
ROOT=/var/www/html
CACHE_DIR=$ROOT/wp-content/cache
SUPERCACHE_DIR=$CACHE_DIR/supercache
WP_CONFIG=$ROOT/wp-config.php
HTACCESS=$ROOT/.htaccess
SAVED_FILES=(wp-content/advanced-cache.php wp-content/wp-cache-config.php .htaccess)
CRON_PATTERN='wp_cache|wpsc|lit_mcp_wpsc'
OPTION_PATTERN='wpsc|wp_super_cache|wp_cache|super_cache|lit_mcp_wpsc'
TRAILING_HOOK=lit_mcp_wpsc_trailing_purge
PURGED_AT=lit_mcp_wpsc_purged_at
BASE=http://localhost
ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
PREFIX=$(wp db prefix)
# --- Guards: nothing is changed before all of them pass ------------------------------------------------------------
wp plugin is-active wp-super-cache 2>/dev/null && fail "WP Super Cache is already active; this smoke expects it inactive"
grep -Eq "^\s*(define\s*\(\s*['\"]WP_CACHE['\"]|const\s+WP_CACHE\s*=)" "$WP_CONFIG" && fail "wp-config.php already sets WP_CACHE"
[ -d "$CACHE_DIR" ] && [ -n "$(find "$CACHE_DIR" -mindepth 1 -print -quit)" ] && fail "$CACHE_DIR is not empty; refusing to touch a cache"
# --- Snapshot of everything the run can change ---------------------------------------------------------------------
BACKUP=$(mktemp -d)
sums() { sha256sum "$@" 2>/dev/null | awk '{print $1}'; }
CONFIG_SUM_BEFORE=$(sums "$WP_CONFIG")
CONFIG_STAT_BEFORE=$(stat -c '%a %U:%G' "$WP_CONFIG")
# cp -a, not -p: wp-config.php carries an ACL entry; WP Super Cache replaces the file (new inode) and loses it, and
# only cp -a writes it back with the content.
cp -a "$WP_CONFIG" "$BACKUP/wp-config.php"
[ "$(sums "$BACKUP/wp-config.php")" = "$CONFIG_SUM_BEFORE" ] || fail "could not save wp-config.php"
for f in "${SAVED_FILES[@]}"; do
if [ -e "$ROOT/$f" ]; then cp -a "$ROOT/$f" "$BACKUP/$(basename "$f")"; echo "$(sums "$ROOT/$f")" > "$BACKUP/$(basename "$f").sum"; fi
done
HTACCESS_BEFORE=absent; [ -e "$HTACCESS" ] && HTACCESS_BEFORE=$(sums "$HTACCESS")
CACHE_DIR_EXISTED=no; CACHE_DIR_MODE=775
if [ -d "$CACHE_DIR" ]; then CACHE_DIR_EXISTED=yes; CACHE_DIR_MODE=$(stat -c '%a' "$CACHE_DIR"); fi
list_hooks() { wp cron event list --fields=hook --format=csv | tail -n +2 | grep -E "$CRON_PATTERN" | sort -u || true; }
list_options() { wp db query "SELECT option_name FROM ${PREFIX}options WHERE option_name REGEXP '$OPTION_PATTERN' ORDER BY option_name" --skip-column-names; }
HOOKS_BEFORE=$(list_hooks)
OPTIONS_BEFORE=$(list_options)
ACTIVE_PLUGINS_BEFORE=$(wp option get active_plugins --format=json)
ORIGINAL_DESCRIPTION=$(wp option get blogdescription)
CREATED_POSTS=()
CREATED_TERMS=()
# --- Restore ---------------------------------------------------------------------------------------------------------
# A saved file is written back atomically: a copy next to the target (same file system), then mv – a run killed in
# the middle never leaves a half-written wp-config.php.
restore_file() { # $1 = path relative to ROOT
local name tmp; name=$(basename "$1")
if [ -e "$BACKUP/$name" ]; then
tmp=$(mktemp "$ROOT/$(dirname "$1")/.$name.restore.XXXXXX") \
&& cp -a "$BACKUP/$name" "$tmp" && mv -f "$tmp" "$ROOT/$1" || { rm -f "$tmp"; return 1; }
else
rm -f "$ROOT/$1"
fi
}
restore() {
set +e
local id term hook option
for id in "${CREATED_POSTS[@]}"; do wp post delete "$id" --force >/dev/null 2>&1; done
for term in "${CREATED_TERMS[@]}"; do wp term delete category "$term" >/dev/null 2>&1; done
wp option update blogdescription "$ORIGINAL_DESCRIPTION" >/dev/null 2>&1
wp option delete lit_mcp_wpsc_check_report lit_mcp_wpsc_releases lit_mcp_wpsc_check_lock >/dev/null 2>&1
wp transient delete "$PURGED_AT" >/dev/null 2>&1
cleanup_app_passwords_for "$ADMIN_LOGIN"
# Deactivation lets WP Super Cache undo its own edits (WP_CACHE, WPCACHEHOME, advanced-cache.php, cache files) ...
wp plugin is-active wp-super-cache >/dev/null 2>&1 && wp plugin deactivate wp-super-cache >/dev/null 2>&1
# ... and what it leaves, or does differently, is set back from the saved state.
restore_file wp-config.php
for id in "${SAVED_FILES[@]}"; do restore_file "$id"; done
[ -d "$CACHE_DIR" ] && find "$CACHE_DIR" -mindepth 1 -delete
if [ "$CACHE_DIR_EXISTED" = no ]; then
rmdir "$CACHE_DIR" 2>/dev/null
else
# Deactivation removes the whole cache directory; mkdir -m keeps the inherited ACL of its parent.
[ -d "$CACHE_DIR" ] || mkdir -m "$CACHE_DIR_MODE" "$CACHE_DIR"
fi
# The profile's cron event and every option or event the plugin added.
for hook in $(list_hooks); do
grep -qx "$hook" <<<"$HOOKS_BEFORE" || wp cron event delete "$hook" >/dev/null 2>&1
done
for option in $(list_options); do
grep -qx "$option" <<<"$OPTIONS_BEFORE" || wp option delete "$option" >/dev/null 2>&1
done
set -e
}
# Prints the proof; returns non-zero on any difference (used in the trap, so it must not exit).
verify_restored() {
local ok=0 name after
after=$(sums "$WP_CONFIG")
if [ "$after" = "$CONFIG_SUM_BEFORE" ]; then pass "restored: wp-config.php sha256 $CONFIG_SUM_BEFORE (before) == $after (after)"
else echo "FAIL: wp-config.php sha256 $CONFIG_SUM_BEFORE (before) != $after (after)"; ok=1; fi
[ "$(stat -c '%a %U:%G' "$WP_CONFIG")" = "$CONFIG_STAT_BEFORE" ] \
|| { echo "FAIL: wp-config.php mode/owner changed: $CONFIG_STAT_BEFORE -> $(stat -c '%a %U:%G' "$WP_CONFIG")"; ok=1; }
for f in "${SAVED_FILES[@]}"; do
name=$(basename "$f")
if [ -e "$BACKUP/$name" ]; then
[ "$(sums "$ROOT/$f")" = "$(cat "$BACKUP/$name.sum")" ] || { echo "FAIL: $f differs from the saved one"; ok=1; }
else
[ ! -e "$ROOT/$f" ] || { echo "FAIL: $f was absent before and exists now"; ok=1; }
fi
done
[ "$ok" = 0 ] && pass "restored: advanced-cache.php and wp-cache-config.php as before (absent: $([ -e "$BACKUP/advanced-cache.php" ] && echo no || echo yes))"
local htaccess_after=absent; [ -e "$HTACCESS" ] && htaccess_after=$(sums "$HTACCESS")
[ "$htaccess_after" = "$HTACCESS_BEFORE" ] || { echo "FAIL: .htaccess changed ($HTACCESS_BEFORE -> $htaccess_after)"; ok=1; }
if [ "$CACHE_DIR_EXISTED" = yes ]; then
[ -d "$CACHE_DIR" ] && [ -z "$(find "$CACHE_DIR" -mindepth 1 -print -quit)" ] && [ "$(stat -c '%a' "$CACHE_DIR")" = "$CACHE_DIR_MODE" ] \
|| { echo "FAIL: cache directory not empty, gone or its mode changed"; ok=1; }
else
[ ! -e "$CACHE_DIR" ] || { echo "FAIL: cache directory was absent before and exists now"; ok=1; }
fi
wp plugin is-active wp-super-cache 2>/dev/null && { echo "FAIL: WP Super Cache is still active"; ok=1; }
[ "$(wp option get active_plugins --format=json)" = "$ACTIVE_PLUGINS_BEFORE" ] || { echo "FAIL: active_plugins differs"; ok=1; }
[ "$(list_hooks)" = "$HOOKS_BEFORE" ] || { echo "FAIL: cron events differ: $(list_hooks | tr '\n' ' ')"; ok=1; }
[ "$(list_options)" = "$OPTIONS_BEFORE" ] || { echo "FAIL: options differ: $(list_options | tr '\n' ' ')"; ok=1; }
[ -z "$(wp transient get "$PURGED_AT" 2>/dev/null || true)" ] || { echo "FAIL: transient $PURGED_AT left"; ok=1; }
[ "$(wp option get blogdescription)" = "$ORIGINAL_DESCRIPTION" ] || { echo "FAIL: blogdescription not restored"; ok=1; }
[ "$(wp post list --post_type=any --post_status=any --meta_key=_lit_mcp_check_entry --format=count)" = 0 ] \
|| { echo "FAIL: a probe page is left"; ok=1; }
[ "$(wp post list --post_type=post --post_status=any --name=lit-wpsc-smoke --format=count)" = 0 ] \
|| { echo "FAIL: the test post is left"; ok=1; }
return "$ok"
}
on_exit() {
local rc=$1
restore
verify_restored || rc=1
rm -rf "$BACKUP"
guard_exit "$rc" || exit $?
exit "$rc"
}
trap 'on_exit $?' EXIT
trap 'exit 130' INT TERM HUP
# --- Helpers ---------------------------------------------------------------------------------------------------------
wpsc_component() { tool lit-site-status '{}' | jq -c '.components[] | select(.id == "wpsc")'; }
stored_option() { wp option get "$1" --format=json 2>/dev/null || true; }
event_count() { wp cron event list --hook="$TRAILING_HOOK" --format=count 2>/dev/null || echo 0; }
# The cache file of the test post as WP Super Cache keeps it (index.html; a ".needs-rebuild" copy is a purged one).
cache_file() { find "$SUPERCACHE_DIR" -type f -path '*lit-wpsc-smoke*' -name index.html 2>/dev/null | head -1; }
wait_for() { # $1 = description, rest = command; polls up to 15 s
local what=$1 i; shift
for i in $(seq 1 15); do "$@" && return 0; sleep 1; done
fail "timeout: $what"
}
cache_file_exists() { [ -n "$(cache_file)" ]; }
cache_file_gone() { [ -z "$(cache_file)" ]; }
front_page_by_wpsc() { [[ "$(curl -s -H "Host: $HOST" "$BASE/")" == *WP-Super-Cache* ]]; }
# One public request for the test post; the cache file appears at the end of the request.
fetch_post() {
local code
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: $HOST" "$BASE$POST_PATH")
[ "$code" = 200 ] || fail "GET $POST_PATH answered $code"
}
# Requests the test post until WP Super Cache has written its file (the first request after a purge or a new post
# is not always the one that is stored).
fill_cache() {
local i
for i in $(seq 1 10); do
fetch_post
sleep 1
cache_file_exists && return 0
done
echo "--- response of $POST_PATH (selected headers) and the cache tree:"
curl -s -i -H "Host: $HOST" "$BASE$POST_PATH" | grep -iE '^(HTTP|cache-control|set-cookie)|WP Super Cache|Page not cached' | head -10
find "$CACHE_DIR" | head -20
fail "timeout: cache file of the test post"
}
config_value() { grep -E "^\s*\\\$$1\s*=" "$ROOT/wp-content/wp-cache-config.php" | head -1; }
# --- 0. Activate and look at what WP Super Cache did ------------------------------------------------------------------
wp plugin activate wp-super-cache >/dev/null 2>&1 || fail "could not activate WP Super Cache"
grep -q "define( *'WP_CACHE', *true *)" "$WP_CONFIG" || fail "activation did not set WP_CACHE (the run cannot cache)"
[ -e "$ROOT/wp-content/advanced-cache.php" ] || fail "activation did not create advanced-cache.php"
# WP Super Cache keys its files by the host of the request: MCP calls must carry the host the page was cached under.
MCP_HOST=$HOST
connect "$ADMIN_LOGIN"
# 1. Status
wpsc_component | jq -e '.active == true and .version == "3.1.5" and .problems == []' >/dev/null \
|| fail "site status must show WP Super Cache 3.1.5 active without problems: $(wpsc_component)"
pass "lit-site-status: WP Super Cache 3.1.5 active, no problems"
# 2. Check route with the cache still switched off: the probe step is green and says "not checked"
APP_PASSWORD=$(wp user application-password create "$ADMIN_LOGIN" lit-mcp-integration-test --porcelain)
run_check() {
local headers
headers=$(curl -s -o /dev/null -D - -X POST -H "Host: $HOST" -H 'X-Forwarded-Proto: https' -u "$ADMIN_LOGIN:$APP_PASSWORD" "$BASE/wp-json/lit-mcp/v1/wpsc-check")
echo "$headers" | grep -qi '^HTTP/[0-9.]* 303' || fail "check route must answer 303: $headers"
echo "$headers" | grep -qi '^location:.*tab=compatibility.*lit_mcp_notice=compat_checked' || fail "redirect to the tab: $headers"
}
[ "$(wp eval 'echo (int) ! empty($GLOBALS["cache_enabled"]);')" = 0 ] || fail "the cache must be off after activation"
run_check
REPORT=$(stored_option lit_mcp_wpsc_check_report)
echo "$REPORT" | jq -e '(.steps | length) == 2 and all(.steps[]; .problems == [] and .skipped == false)
and ([.steps[] | select(.key == "probe")] | length == 1 and (.[0].label | contains("Cache ist ausgeschaltet, nicht geprüft")))' >/dev/null \
|| fail "cache off: the report must be green and the probe must say it did not check: $REPORT"
pass "/wpsc-check with the cache off: green, probe step says 'Cache ist ausgeschaltet, nicht geprüft'"
# 3. Switch the cache on in the plain PHP mode (no mod_rewrite, no .htaccess)
wp eval 'wp_cache_enable();' >/dev/null 2>&1 || fail "wp_cache_enable failed"
# PHP-FPM's opcache re-reads a changed wp-cache-config.php at most every 2 s: wait until a public request is served
# by WP Super Cache (its HTML comment), the state the checks below need.
wait_for "WP Super Cache serves public requests" front_page_by_wpsc
config_value cache_enabled | grep -q 'true' || fail "wp-cache-config.php: cache_enabled is not true: $(config_value cache_enabled)"
config_value wp_cache_mod_rewrite | grep -Eq '= *0;' || fail "mod_rewrite mode is on: $(config_value wp_cache_mod_rewrite)"
config_value super_cache_enabled | grep -q 'true' || fail "super_cache_enabled is not true: $(config_value super_cache_enabled)"
[ "$(config_value wp_cache_clear_on_post_edit | grep -Ec '= *0;')" = 1 ] || fail "clear_on_post_edit must be 0: $(config_value wp_cache_clear_on_post_edit)"
[ ! -e "$HTACCESS" ] || [ "$(sums "$HTACCESS")" = "$HTACCESS_BEFORE" ] || fail ".htaccess changed"
pass "cache on in the plain PHP mode (mod_rewrite 0, clear_on_post_edit 0), .htaccess untouched"
# 4. Check route with the cache on: the purge probe really runs
run_check
REPORT=$(stored_option lit_mcp_wpsc_check_report)
echo "$REPORT" | jq -e '(.steps | length) == 2 and all(.steps[]; .problems == [] and .skipped == false)
and ([.steps[] | select(.key == "probe")] | length == 1 and (.[0].label | contains("nicht geprüft") | not))
and .version == "3.1.5"' >/dev/null || fail "cache on: the report must be green with a real probe step: $REPORT"
[ -z "$(stored_option lit_mcp_wpsc_check_lock)" ] || fail "the check lock must be released"
[ "$(wp post list --post_type=any --post_status=any --meta_key=_lit_mcp_check_entry --format=count)" = 0 ] || fail "the probe left a page behind"
[ "$(find "$SUPERCACHE_DIR" -path '*lit-mcp-check-*' -print -quit 2>/dev/null)" = "" ] || fail "the probe left a cache directory behind"
UNAUTH=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Host: $HOST" -H 'X-Forwarded-Proto: https' "$BASE/wp-json/lit-mcp/v1/wpsc-check")
[ "$UNAUTH" = 401 ] || [ "$UNAUTH" = 403 ] || fail "anonymous check must be refused, got $UNAUTH"
pass "/wpsc-check with the cache on: green with the purge probe, no page or cache directory left, anonymous refused"
# 5. A published test post, fetched until it is in the cache
CAT_A=$(wp term create category "LIT WPSC Smoke A" --porcelain); CREATED_TERMS+=("$CAT_A")
CAT_B=$(wp term create category "LIT WPSC Smoke B" --porcelain); CREATED_TERMS+=("$CAT_B")
POST_ID=$(wp post create --post_type=post --post_status=publish --post_title="LIT WPSC Smoke" --post_name=lit-wpsc-smoke \
--post_content="Testbeitrag fuer den Super-Cache-Smoke." --porcelain)
CREATED_POSTS+=("$POST_ID")
POST_PATH=$(wp eval "echo wp_parse_url(get_permalink($POST_ID), PHP_URL_PATH);")
fill_cache
pass "test post $POST_ID cached: $(cache_file | sed "s#$CACHE_DIR/##")"
# 6. Control: a term change outside this plugin does not touch the cache (WP Super Cache never sees it) ...
wp post term set "$POST_ID" category "$CAT_A" >/dev/null
sleep 2
cache_file_exists || fail "control: WP Super Cache itself purged on a plain term change; the next check proves nothing"
# ... but the same kind of change through lit-content-update (no wp_update_post) empties the cache of the post.
REV=$(tool lit-content-get "{\"post_id\":$POST_ID}" | jq -r '.rev')
UPDATED=$(tool lit-content-update "{\"post_id\":$POST_ID,\"rev\":\"$REV\",\"terms\":{\"category\":[$CAT_B]}}")
echo "$UPDATED" | jq -e '.ok == true' >/dev/null || fail "lit-content-update (terms only): $UPDATED"
wait_for "the cache file of the post is gone after lit-content-update" cache_file_gone
[ "$(event_count)" = 0 ] || fail "a single-post purge must not schedule a trailing purge"
pass "terms-only lit-content-update empties the cache of the post (a plain term change does not), no trailing event"
# 7. Whole-cache purge after a setting; the second one inside 30 s is throttled and leaves exactly one cron event
fill_cache
[ -z "$(wp transient get "$PURGED_AT" 2>/dev/null || true)" ] || fail "transient $PURGED_AT set before the first whole purge"
ability lit/wp-settings-update '{"settings":{"blogdescription":"LIT WPSC Smoke 1"}}' | jq -e '.ok == true' >/dev/null || fail "settings update 1"
wait_for "the cache is empty after a setting change" cache_file_gone
wait_for "the purge time is stored" bash -c "[ -n \"\$(wp transient get $PURGED_AT 2>/dev/null)\" ]"
LAST=$(wp transient get "$PURGED_AT")
[ "$(event_count)" = 0 ] || fail "the first whole purge must not schedule a trailing purge"
pass "setting change empties the whole cache (purge time stored, no trailing event)"
fill_cache
ability lit/wp-settings-update '{"settings":{"blogdescription":"LIT WPSC Smoke 2"}}' | jq -e '.ok == true' >/dev/null || fail "settings update 2"
wait_for "one trailing purge is scheduled" bash -c "[ \"\$(wp cron event list --hook=$TRAILING_HOOK --format=count)\" = 1 ]"
sleep 2
cache_file_exists || fail "throttled: the second whole purge ran inside the lock time"
ability lit/wp-settings-update '{"settings":{"blogdescription":"LIT WPSC Smoke 3"}}' | jq -e '.ok == true' >/dev/null || fail "settings update 3"
sleep 3
[ "$(event_count)" = 1 ] || fail "exactly one trailing event must stay scheduled, found $(event_count)"
cache_file_exists || fail "throttled: the third whole purge ran inside the lock time"
[ "$(wp transient get "$PURGED_AT")" = "$LAST" ] || fail "throttled purges must not move the purge time"
pass "second and third whole purge inside 30 s: throttled (file stays, purge time unchanged), exactly one $TRAILING_HOOK event"
# 8. Run the trailing purge once the lock is over: the cache is emptied and nothing is scheduled any more
WAIT=$((LAST + 32 - $(date +%s))); [ "$WAIT" -gt 0 ] && sleep "$WAIT"
wp cron event run "$TRAILING_HOOK" >/dev/null 2>&1 || fail "wp cron event run $TRAILING_HOOK"
cache_file_gone || fail "the trailing purge did not empty the cache"
[ "$(event_count)" = 0 ] || fail "no trailing event may be left after it ran"
[ "$(wp transient get "$PURGED_AT")" -gt "$LAST" ] || fail "the trailing purge must store a new purge time"
pass "trailing purge by cron: cache emptied, event gone, purge time renewed"
# 9. Everything back as before (the EXIT trap would do it too; here it is part of the verdict)
restore
verify_restored || fail "the state before the run was not restored"
pass "cleanup: plugin inactive, test post, categories, options, transient and cron events gone"
finish