Files

264 lines
19 KiB
Bash
Executable File

#!/usr/bin/env bash
# End-to-end checks of Etappe 4e (loop content: loop_* options inside a Divi loop, refused outside and in a loop of
# another kind, dynamic sources per node, move warning, real frontend render) through the OAuth-protected endpoint
# against the real Divi. Runs INSIDE the DDEV web container:
# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-loop-smoke.sh
# Runs only with the plugins postlos and asynclistry deactivated (Monolog 2 / deprecation output of the WP-CLI calls).
# Creates one draft page with a post loop, a plain row and a term loop (and one temporary published post when the site
# has none). The EXIT trap deletes the page and the temporary post by exact ID, removes their change-log rows and puts
# the options et_divi and et_divi_global_variables back byte-exact (serialized, base64 through the environment).
set -euo pipefail
cd /var/www/html
# shellcheck source=lib.sh
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh
ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
RUN="$(date +%s)-$$"
# $1 = option name. An option that does not exist is captured as the sentinel string, so restore() can delete it again.
capture() { wp eval "echo base64_encode(serialize(get_option('$1', '__lit_absent__')));"; }
ORIGINAL_THEME=$(capture et_divi)
ORIGINAL_VARIABLES=$(capture et_divi_global_variables)
# Abort before any change: without a capture there is nothing to restore.
[ -n "$ORIGINAL_THEME" ] || { echo "FAIL: could not capture et_divi"; exit 1; }
[ -n "$ORIGINAL_VARIABLES" ] || { echo "FAIL: could not capture et_divi_global_variables"; exit 1; }
PAGE_ID=""
TEMP_POST_ID=""
restore() { # $1 = option name, $2 = name of the environment variable that holds the capture
local LIT_CAPTURE="$2"; export LIT_CAPTURE
LIT_OPTION="$1" wp eval '$n = getenv("LIT_OPTION"); $v = unserialize(base64_decode(getenv(getenv("LIT_CAPTURE")))); if ($v === "__lit_absent__") { delete_option($n); exit(get_option($n, "__lit_absent__") === $v ? 0 : 1); } update_option($n, $v); exit(serialize(get_option($n)) === serialize($v) ? 0 : 1);' >/dev/null 2>&1 \
|| { echo "FAIL: $1 not restored"; return 1; }
}
delete_content() { # deletes the page and the temporary post by exact ID (with revisions), then their change-log rows
local ids="$PAGE_ID $TEMP_POST_ID"
for id in $ids; do
wp post delete "$id" --force >/dev/null 2>&1 || true
done
LIT_IDS="$ids" wp eval '
global $wpdb;
$table = $wpdb->prefix . "lit_mcp_log";
foreach (array_filter(explode(" ", (string) getenv("LIT_IDS"))) as $id) {
$wpdb->delete($table, ["object_id" => $id]);
}' >/dev/null 2>&1 || true
}
cleanup() {
local failed=0
delete_content
restore et_divi LIT_THEME || failed=1
restore et_divi_global_variables LIT_VARIABLES || failed=1
wp eval 'ET_Core_PageResource::remove_static_resources("all", "all", true);' >/dev/null 2>&1 || true
cleanup_app_passwords_for "$ADMIN_LOGIN"
return "$failed"
}
export LIT_THEME="$ORIGINAL_THEME" LIT_VARIABLES="$ORIGINAL_VARIABLES"
trap 'rc=$?; cleanup || rc=1; guard_exit "$rc" || exit $?' EXIT
[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme"
connect "$ADMIN_LOGIN"
# --- helpers -----------------------------------------------------------------------------------------------------
# Draft page: s0.r0 = column c0 with a POST loop (queryType post_types, post type "post") and one static text module,
# s0.r1 = column c0 WITHOUT a loop and one static text module (below the loop), s0.r2 = column c0 with a TERM loop
# (queryType post_taxonomies, taxonomy category) and one static text module. Loop attributes exactly as Divi's own
# builder stores them (module.advanced.loop.desktop.value, enable "on").
create_loop_page() { # prints the ID
LIT_TITLE="LIT Smoke $RUN Schleife" wp --user="$ADMIN_LOGIN" eval '
$b = static fn (string $n, array $a, array $k = []): array => ["blockName" => $n, "attrs" => $a, "innerBlocks" => $k,
"innerHTML" => "", "innerContent" => array_fill(0, count($k), null)];
$text = static fn (string $t): array => $b("divi/text", ["content" => ["innerContent" => ["desktop" => ["value" => $t]]]]);
$width = ["module" => ["decoration" => ["sizing" => ["desktop" => ["value" => ["flexType" => "24_24"]]]]]];
$loop = static fn (string $id, string $type, string $sub, string $label): array => array_replace_recursive($width, [
"module" => ["advanced" => ["loop" => ["desktop" => ["value" => ["enable" => "on", "loopId" => $id,
"queryType" => $type, "subTypes" => [["value" => $sub, "label" => $label]], "postPerPage" => "3"]]]]]]);
$row = static fn (array $column): array => $b("divi/row", [], [$column]);
$section = $b("divi/section", [], [
$row($b("divi/column", $loop("loop-litsmoke01", "post_types", "post", "Post"), [$text("<p>Schleife</p>")])),
$row($b("divi/column", $width, [$text("<p>Ausserhalb</p>")])),
$row($b("divi/column", $loop("loop-litsmoke02", "post_taxonomies", "category", "Categories"), [$text("<p>Terme</p>")])),
]);
$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"),
"post_content" => wp_slash(serialize_block($b("divi/placeholder", [], [$section])))]);
update_post_meta($id, "_et_pb_use_builder", "on");
update_post_meta($id, "_et_pb_use_divi_5", "on");
echo $id;' 2>/dev/null
}
stored() { wp post get "$PAGE_ID" --field=post_content; }
# Substring tests without a pipe: with pipefail, grep -q closing the pipe early would fail the pipeline (SIGPIPE).
stored_has() { local s; s=$(stored); [[ "$s" == *"$1"* ]]; }
has_line() { grep -qx -- "$2" <<<"$1"; } # $1 = lines, $2 = exact line
has_prefix() { grep -q -- "^$2" <<<"$1"; }
tree_rev() { tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev'; }
# insert <parent path id> <content value>: one text module with the given content (short form or $variable string).
insert_text() {
tool lit-divi-insert "$(jq -cn --argjson p "$PAGE_ID" --arg r "$(tree_rev)" --arg parent "$1" --arg c "$2" \
'{post_id: $p, tree_rev: $r, parent: $parent, nodes: [{type: "text", content: $c}]}')"
}
# refused_with <answer> <text>: the call was refused (an error, not the dry run of anything) and the error names <text>.
refused_with() {
echo "$1" | jq -e --arg t "$2" 'has("__error") and (.__error | contains($t))' >/dev/null
}
# variable_ref <option name> [settings JSON]: the full form $variable({...})$ of a content reference.
variable_ref() {
local settings="${2:-}"; [ -n "$settings" ] || settings='{}'
jq -rn --arg n "$1" --argjson s "$settings" \
'"$variable(" + ({type: "content", value: {name: $n, settings: ({before: "", after: ""} + $s)}} | tojson) + ")$"'
}
# loop_names <dynamic-sources answer>: every option name starting with loop_ anywhere in the answer, one per line.
# ADAPT (Task 5): the jq walk finds "name" keys in any nesting; narrow it when the answer has a fixed list.
loop_names() { echo "$1" | jq -r '[.. | objects | .name? // empty | strings | select(startswith("loop_"))] | unique[]'; }
# --- 1. The loop page --------------------------------------------------------------------------------------------
PAGE_ID=$(create_loop_page)
[ -n "$PAGE_ID" ] || fail "loop page not created"
OUTLINE=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}")
echo "$OUTLINE" | jq -e '.tree_rev | type == "string"' >/dev/null || fail "outline of the loop page: $OUTLINE"
for NODE in s0.r0.c0.m0 s0.r1.c0.m0 s0.r2.c0.m0; do
[[ "$OUTLINE" == *"$NODE"* ]] || fail "outline lacks $NODE: $OUTLINE"
done
stored_has '"enable":"on"' || fail "stored page has no enabled loop"
pass "loop page $PAGE_ID created (post loop, plain row, term loop)"
# --- 2. {{loop:post_date}} inside the post loop is accepted and stored -----------------------------------------------
# ADAPT (Task 4): the new module is the second child of the loop column, so its path id is s0.r0.c0.m1.
LOOP_NODE=s0.r0.c0.m1
DONE=$(insert_text s0.r0.c0 '{{loop:post_date}}')
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "{{loop:post_date}} in the loop refused: $DONE"
stored_has 'loop_post_date' || fail "stored content lacks loop_post_date after the insert"
# Full form with a German date format (the select of Divi offers no German format, so "custom"): update the module.
FULL=$(variable_ref loop_post_date '{"loop_position":"","date_format":"custom","custom_date_format":"d.m.Y"}')
DONE=$(tool lit-divi-update "$(jq -cn --argjson p "$PAGE_ID" --arg r "$(tree_rev)" --arg n "$LOOP_NODE" --arg c "$FULL" \
'{post_id: $p, tree_rev: $r, changes: [{id: $n, set: {content: $c}}]}')")
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "full-form loop_post_date in the loop refused: $DONE"
stored_has 'custom_date_format' || fail "stored content lacks the date format of the full form"
# Positive control for the security boundary below: the post meta key option belongs to a post loop.
META=$(variable_ref loop_post_meta_key_manual_custom_field \
'{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"lit_smoke_key"}')
DONE=$(insert_text s0.r0.c0 "$META")
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "post meta key option in a post loop refused: $DONE"
pass "loop_post_date (short and full form) and the post meta key option accepted in the post loop"
# --- 3. The same content OUTSIDE any loop is refused ---------------------------------------------------------------
BEFORE=$(stored | md5sum)
for VALUE in '{{loop:post_date}}' "$FULL"; do
BAD=$(insert_text s0.r1.c0 "$VALUE")
refused_with "$BAD" 'braucht eine Divi-Schleife' || fail "loop content outside a loop not refused with the loop message: $BAD"
refused_with "$BAD" 'loop_post_date' || fail "refusal does not name the option: $BAD"
done
# Updating the static module outside the loop is refused as well, and so is a module whose parent is the section row.
BAD=$(tool lit-divi-update "$(jq -cn --argjson p "$PAGE_ID" --arg r "$(tree_rev)" --arg c "$FULL" \
'{post_id: $p, tree_rev: $r, changes: [{id: "s0.r1.c0.m0", set: {content: $c}}]}')")
refused_with "$BAD" 'braucht eine Divi-Schleife' || fail "update outside a loop not refused: $BAD"
[ "$(stored | md5sum)" = "$BEFORE" ] || fail "a refused call changed the stored page"
pass "loop content outside a loop refused (insert short and full form, update), page unchanged"
# --- 4. A term loop refuses post-kind options (security boundary: meta keys read foreign objects) -----------------------
BEFORE=$(stored | md5sum)
POST_META=$(variable_ref loop_post_meta_key_manual_custom_field \
'{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"lit_smoke_key"}')
USER_META=$(variable_ref loop_user_meta_key_manual_custom_field \
'{"select_loop_meta_key":"loop_user_meta_key_manual_custom_field_value","loop_meta_key":"lit_smoke_key"}')
for VALUE in '{{loop:post_date}}' "$FULL" "$POST_META" "$USER_META"; do
BAD=$(insert_text s0.r2.c0 "$VALUE")
refused_with "$BAD" 'passt nicht zur Schleife' || fail "post/user-kind option in a term loop not refused: $BAD"
refused_with "$BAD" 'Terme' || fail "refusal does not name the loop kind (Terme): $BAD"
done
[ "$(stored | md5sum)" = "$BEFORE" ] || fail "a refused call changed the stored page"
# The reverse direction: a user-kind option in the POST loop.
BAD=$(insert_text s0.r0.c0 "$USER_META")
refused_with "$BAD" 'passt nicht zur Schleife' || fail "user meta key option in a post loop not refused: $BAD"
# Positive control: an option of the term kind is accepted in the term loop (ADAPT (Task 3): short name term_name).
DONE=$(insert_text s0.r2.c0 '{{loop:term_name}}')
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "{{loop:term_name}} in the term loop refused: $DONE"
stored_has 'loop_term_name' || fail "stored content lacks loop_term_name"
pass "post/user-kind options (incl. meta keys) refused in the wrong loop kind, term option accepted in the term loop"
# --- 4b. A node that switches on its OWN term loop in the same call gets no loop content (fail closed) ----------------------
# Security regressions of the Task 4 review: the loop of a new node counts only once saved, and the check runs on the
# composed blocks, not on the client text (a control character inside {{ }} is stripped on write, a leading space
# before $variable( is trimmed). Both must still be refused in the same call that switches the term loop on.
BEFORE=$(stored | md5sum)
TERM_LOOP_ATTRS='{"module.advanced.loop":{"desktop":{"enable":"on","queryType":"post_taxonomies"}}}'
SNEAKY=$'{\x01{loop:post_title}}'
SPACED=" $(variable_ref loop_post_meta_key_manual_custom_field \
'{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"lit_smoke_key"}')"
for VALUE in "$SNEAKY" "$SPACED"; do
BAD=$(tool lit-divi-insert "$(jq -cn --argjson p "$PAGE_ID" --arg r "$(tree_rev)" --arg c "$VALUE" --argjson a "$TERM_LOOP_ATTRS" \
'{post_id: $p, tree_rev: $r, parent: "s0.r0.c0", nodes: [{type: "text", content: $c, attrs: $a}]}')")
echo "$BAD" | jq -e 'has("__error")' >/dev/null || fail "loop content under a loop of the same call was accepted: $BAD"
done
[ "$(stored | md5sum)" = "$BEFORE" ] || fail "a refused call changed the stored page"
pass "loop content (control character inside the braces, leading space before \$variable) refused under a loop of the same call"
# --- 5. lit/divi-dynamic-sources per node -------------------------------------------------------------------------
# ADAPT (Task 5): input post_type + post_id + node; the answer lists the loop options; without a loop context it says so.
sources() { ability lit/divi-dynamic-sources "$(jq -cn --argjson p "$PAGE_ID" --arg n "$1" '{post_type: "page", post_id: $p, node: $n}')"; }
IN_LOOP=$(sources "$LOOP_NODE")
IN_LOOP_NAMES=$(loop_names "$IN_LOOP")
has_line "$IN_LOOP_NAMES" loop_post_date || fail "no loop_post_date for the module in the post loop: $IN_LOOP"
has_line "$IN_LOOP_NAMES" loop_post_meta_key_manual_custom_field || fail "no post meta key option in the post loop: $IN_LOOP"
! has_prefix "$IN_LOOP_NAMES" 'loop_term_\|^loop_user_' || fail "post loop lists term/user options: $IN_LOOP"
IN_TERM=$(sources s0.r2.c0.m0)
IN_TERM_NAMES=$(loop_names "$IN_TERM")
has_line "$IN_TERM_NAMES" loop_term_name || fail "no loop_term_name for the module in the term loop: $IN_TERM"
! has_prefix "$IN_TERM_NAMES" loop_post_ || fail "term loop lists post options: $IN_TERM"
OUTSIDE=$(sources s0.r1.c0.m0)
[ -z "$(loop_names "$OUTSIDE")" ] || fail "loop options listed for a node outside any loop: $OUTSIDE"
[[ "$OUTSIDE" == *'Schleifen-Kontext'* ]] || fail "no hint about the missing loop context: $OUTSIDE"
pass "dynamic sources: post options in the post loop, term options in the term loop, none outside"
# --- 7. Frontend render (before the move: afterwards the module is outside the loop and renders empty) ------------------
# At least one published post must exist; otherwise one temporary post is created (and deleted by exact ID).
if [ "$(wp post list --post_type=post --post_status=publish --format=count)" = "0" ]; then
TEMP_POST_ID=$(wp post create --post_type=post --post_status=publish --post_title="LIT Smoke $RUN Beitrag" --porcelain)
fi
HTML=$(LIT_ID="$PAGE_ID" wp eval 'global $post; $post = get_post((int) getenv("LIT_ID")); setup_postdata($post); echo apply_filters("the_content", $post->post_content);' 2>&1)
[ -n "$HTML" ] || fail "the page rendered nothing"
if grep -Eqi 'Fatal error|Parse error|Warning:|Notice:|Deprecated:|Stack trace|Uncaught' <<<"$HTML"; then
fail "PHP error text in the rendered page: $(grep -Eio '.{0,80}(Fatal error|Parse error|Warning:|Notice:|Deprecated:|Stack trace|Uncaught).{0,120}' <<<"$HTML" | head -3)"
fi
[[ "$HTML" != *'$variable('* ]] || fail "an unresolved \$variable reference is in the rendered page"
[[ "$HTML" != *loop_post_date* ]] || fail "the option name loop_post_date is in the rendered page"
# The module renders the date of a published post in d.m.Y (the loop shows up to 3 of them, newest first).
DATES=$(wp eval 'foreach (get_posts(["post_type" => "post", "post_status" => "publish", "numberposts" => -1, "fields" => "ids"]) as $i) { echo get_the_date("d.m.Y", $i), "\n"; }')
[ -n "$DATES" ] || fail "no published post to compare against"
grep -qF -f <(echo "$DATES") <<<"$HTML" \
|| fail "the rendered page shows no formatted date (d.m.Y) of a published post; dates: $(echo "$DATES" | head -3 | tr '\n' ' ')"
pass "frontend render shows a formatted post date, no PHP error text"
# --- 6. Moving loop content out of the loop: warning, not refusal ----------------------------------------------------
# ADAPT (Task 4): the warning is a string in .warnings that names the loop content ("Schleife").
MOVED=$(tool lit-divi-move "$(jq -cn --argjson p "$PAGE_ID" --arg r "$(tree_rev)" --arg n "$LOOP_NODE" \
'{post_id: $p, tree_rev: $r, id: $n, parent: "s0.r1.c0"}')")
[ "$(echo "$MOVED" | jq -r '.ok')" = "true" ] || fail "moving loop content out of the loop was refused: $MOVED"
echo "$MOVED" | jq -e '(.warnings // []) | map(select(test("Schleife"))) | length > 0' >/dev/null \
|| fail "move out of the loop carries no warning about loop content: $MOVED"
stored_has 'loop_post_date' || fail "the moved module lost its loop content"
pass "move out of the loop answers ok with a loop warning"
# --- 6b. Switching the loop kind under unchanged loop content: ok with a warning ---------------------------------------------
# The post meta key module (s0.r0.c0.m1 after the move) keeps loop_post_meta_key_*; the container becomes a term loop.
DONE=$(tool lit-divi-update "$(jq -cn --argjson p "$PAGE_ID" --arg r "$(tree_rev)" --argjson a "$TERM_LOOP_ATTRS" \
'{post_id: $p, tree_rev: $r, changes: [{id: "s0.r0.c0", attrs: $a}]}')")
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "switching the loop kind was refused: $DONE"
echo "$DONE" | jq -e '(.warnings // []) | map(select(startswith("s0.r0.c0:") and contains("Schleife") and contains("loop_post_meta_key"))) | length > 0' \
>/dev/null || fail "switching the loop kind carries no warning naming the node and the loop content: $DONE"
pass "update of the loop kind under unchanged loop content answers ok with a warning naming node and option"
# --- 8. Everything restored, no leftover pages ----------------------------------------------------------------------
GONE_PAGE=$PAGE_ID
GONE_POST=$TEMP_POST_ID
delete_content
PAGE_ID=""
TEMP_POST_ID=""
[ -z "$(wp post list --post_type=any --post_status=any --post__in="$GONE_PAGE" --format=ids)" ] || fail "page $GONE_PAGE still exists"
[ -z "$GONE_POST" ] || [ -z "$(wp post list --post_type=any --post_status=any --post__in="$GONE_POST" --format=ids)" ] \
|| fail "temporary post $GONE_POST still exists"
[ -z "$(wp post list --post_type=any --post_status=any --s="LIT Smoke $RUN" --format=ids)" ] || fail "a post of this run is left"
[ "$(capture et_divi)" = "$ORIGINAL_THEME" ] || fail "et_divi differs from its state before the run"
[ "$(capture et_divi_global_variables)" = "$ORIGINAL_VARIABLES" ] || fail "et_divi_global_variables differs from its state before the run"
pass "page and temporary post deleted, options unchanged"
finish