Files
WP-LIT-MCP/tests/Integration/tb-smoke.sh
T
janniclas.loosenandClaude Opus 5.5 beb8706d43 test: tb-smoke checks restore_url and the forbidden answer without Theme Builder rights
The confirmed live layout change must name the WordPress revision screen
(audit L3); an editor without the Theme Builder gets forbidden from
tb-assign and tb-activate whatever the ID (review M-9).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-27 23:18:31 +02:00

544 lines
38 KiB
Bash
Executable File
Raw 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 Etappe 3b (Divi Theme Builder, Pods fields, find-similar, make-global, P10, P13) through the
# OAuth-protected endpoint. Runs INSIDE the DDEV web container (Divi 5 must be the active theme):
# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/tb-smoke.sh
# REQUIRE_PODS=1 makes the Pods section mandatory (else it is skipped with SKIP when Pods is inactive).
#
# Creates only temporary data of this run (titles "LIT Smoke <run> …"): draft pages, a post, Theme Builder templates
# and layouts, library entries, its OWN temporary Pods pod (ruling Q6 – lit_person and lit_fixture_person are never
# touched), a role cloned from "editor" (no edit_theme_options, so no Theme Builder) with one user, and it changes the
# options lit_mcp_allowed_roles/lit_mcp_exposure for a while. Visible site: the only template this script ever
# enables has exactly one condition – a temporary DRAFT page, which visitors cannot open (it is rendered through a
# logged-in preview). The EXIT trap removes, by exact ID: the created templates from the live Theme Builder list,
# all created posts (with revisions, meta and term relationships), a Theme Builder draft it may have created,
# change-log rows, the pod, the user with its app passwords and sessions and the role; it restores every option it
# touched (lit_mcp_*, Divi's Theme Builder backup option, rewrite_rules; absent stays absent) and clears Divi's
# caches. The last section checks that the Theme Builder list and every template's enabled state equal the state
# before the run and that nothing of the run is left. [Fixture]/[Test] content is only read.
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)
STAGE3=$PLUGIN/tests/Fixtures/divi/stage3
RUN="$(date +%s)-$$"
PREFIX="LIT Smoke $RUN"
DIGITS=$(printf '%s' "$RUN" | tr -dc '0-9')
# Per-run names: cleanup never touches an account, role or pod that existed before this run.
EDITOR_LOGIN="lit-tb-editor-$RUN"
EDITOR_ROLE="lit_tb_editor_${RUN//-/_}"
POD="lit_smk_${DIGITS: -8}"
EDITOR_CREATED=0
ROLE_CREATED=0
POD_CREATED=0
POD_POSTS=()
CREATED_POSTS=()
CREATED_TEMPLATES=()
DRAFT_TB=""
SESSION_COOKIE=""
# State before the run (restored by cleanup, compared at the end).
option_json() { wp option get "$1" --format=json 2>/dev/null || echo '__none__'; }
ORIGINAL_ROLES=$(option_json lit_mcp_allowed_roles)
ORIGINAL_EXPOSURE=$(option_json lit_mcp_exposure)
ORIGINAL_REWRITE=$(option_json rewrite_rules)
TB_ID=$(wp eval 'echo (int) et_theme_builder_get_theme_builder_post_id(true, false);')
BACKUP_OPTION="et_tb_templates_backup_$TB_ID"
ORIGINAL_BACKUP=$(option_json "$BACKUP_OPTION")
# The live Theme Builder list with every template's _et_enabled, exactly as stored.
tb_state() {
LIT_TB="$TB_ID" wp eval '
$tb = (int) getenv("LIT_TB");
$rows = get_post_meta($tb, "_et_template");
$enabled = [];
foreach ($rows as $id) { $enabled[(string) $id] = get_post_meta((int) $id, "_et_enabled", true); }
echo wp_json_encode(["list" => $rows, "enabled" => $enabled]);'
}
TB_BEFORE=$(tb_state)
# Divi's front-end feature caches (created by any page view, e.g. this script's renders): absent stays absent.
divi_caches() {
wp eval 'global $wpdb; echo implode(" ", $wpdb->get_col("SELECT option_name FROM $wpdb->options WHERE option_name LIKE \"\\_et\\_builder\\_%\\_feature\\_cache\" ORDER BY option_name"));'
}
DIVI_CACHES_BEFORE=" $(divi_caches) "
restore_option() { # $1 = option, $2 = original JSON or __none__
if [ "$2" = "__none__" ]; then
wp option delete "$1" >/dev/null 2>&1 || true
else
wp option update "$1" "$2" --format=json >/dev/null 2>&1 || true
fi
}
# Every created post (and those a stopped run could not record: title prefix of this run) – idempotent.
cleanup() {
local found id name
found=$(LIT_PREFIX="$PREFIX " wp eval '
global $wpdb;
echo implode(" ", $wpdb->get_col($wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE post_title LIKE %s AND post_type IN (%s, %s, %s, %s, %s, %s, %s)",
$wpdb->esc_like((string) getenv("LIT_PREFIX")) . "%",
"page", "post", "et_pb_layout", "et_template", "et_header_layout", "et_body_layout", "et_footer_layout"
)));' 2>/dev/null || true)
for id in $found; do CREATED_POSTS+=("$id"); done
# 1. Off the live list first (nothing of this run may stay visible or listed), then disabled.
for id in "${CREATED_TEMPLATES[@]}"; do
LIT_ID="$id" LIT_TB="$TB_ID" wp eval '
$id = (string) getenv("LIT_ID");
delete_post_meta((int) getenv("LIT_TB"), "_et_template", $id);
update_post_meta((int) $id, "_et_enabled", "0");' >/dev/null 2>&1 || true
done
if [ -n "$DRAFT_TB" ]; then wp post delete "$DRAFT_TB" --force >/dev/null 2>&1 || true; DRAFT_TB=""; fi
# 2. Change-log rows: every row of the temporary user and every row about a post of this run.
LIT_LOGIN="$EDITOR_LOGIN" LIT_IDS="${CREATED_POSTS[*]} ${CREATED_TEMPLATES[*]}" wp eval '
global $wpdb;
$table = $wpdb->prefix . "lit_mcp_log";
$u = get_user_by("login", getenv("LIT_LOGIN"));
if ($u) { $wpdb->delete($table, ["user_id" => $u->ID]); }
$types = ["page", "post", "et_pb_layout", "et_template", "et_header_layout", "et_body_layout", "et_footer_layout"];
foreach (array_unique(array_filter(explode(" ", (string) getenv("LIT_IDS")))) as $id) {
foreach ($types as $type) { $wpdb->delete($table, ["object_type" => $type, "object_id" => $id]); }
}' >/dev/null 2>&1 || true
# 3. Posts with revisions and meta; term relationships also for types a taxonomy is not registered for.
LIT_IDS="${CREATED_POSTS[*]} ${CREATED_TEMPLATES[*]}" wp eval '
global $wpdb;
foreach (array_unique(array_filter(array_map("intval", explode(" ", (string) getenv("LIT_IDS"))))) as $id) {
if (get_post($id)) { wp_delete_post($id, true); }
$wpdb->delete($wpdb->term_relationships, ["object_id" => $id]);
}' >/dev/null 2>&1 || true
CREATED_POSTS=()
CREATED_TEMPLATES=()
# 4. The temporary pod (definition, fields, content) and anything of it left by ID.
if [ "$POD_CREATED" = 1 ]; then
LIT_POD="$POD" wp eval '
if (function_exists("pods_api") && pods_api()->load_pod(["name" => getenv("LIT_POD")])) {
pods_api()->delete_pod(["name" => getenv("LIT_POD")], false, true);
}' >/dev/null 2>&1 || true
LIT_POD="$POD" LIT_IDS="${POD_POSTS[*]}" wp eval '
global $wpdb;
$ids = array_merge(array_map("intval", array_filter(explode(" ", (string) getenv("LIT_IDS")))),
array_map("intval", $wpdb->get_col($wpdb->prepare("SELECT ID FROM $wpdb->posts WHERE post_type = %s", getenv("LIT_POD")))));
foreach (array_unique($ids) as $id) { if ($id > 0 && get_post($id)) { wp_delete_post($id, true); } }' >/dev/null 2>&1 || true
POD_CREATED=0
fi
# 5. Accounts, options, caches.
cleanup_app_passwords_for "$ADMIN_LOGIN"
if [ "$EDITOR_CREATED" = 1 ]; then
cleanup_app_passwords_for "$EDITOR_LOGIN"
wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true
EDITOR_CREATED=0
fi
if [ "$ROLE_CREATED" = 1 ]; then
wp role delete "$EDITOR_ROLE" >/dev/null 2>&1 || true
ROLE_CREATED=0
fi
restore_option lit_mcp_allowed_roles "$ORIGINAL_ROLES"
restore_option lit_mcp_exposure "$ORIGINAL_EXPOSURE"
restore_option "$BACKUP_OPTION" "$ORIGINAL_BACKUP"
restore_option rewrite_rules "$ORIGINAL_REWRITE"
for name in $(divi_caches 2>/dev/null || true); do
if [[ "$DIVI_CACHES_BEFORE" != *" $name "* ]]; then wp option delete "$name" >/dev/null 2>&1 || true; fi
done
wp eval 'if (function_exists("et_theme_builder_clear_wp_cache")) { et_theme_builder_clear_wp_cache("all"); }
if (class_exists("ET_Core_PageResource")) { ET_Core_PageResource::remove_static_resources("all", "all", true); }' \
>/dev/null 2>&1 || true
}
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
has() { grep -qF -- "$2" <<<"$1"; } # $1 = haystack, $2 = needle (no pipe: pipefail + grep -q would race)
create_post() { # $1 = post type, $2 = title suffix, $3 = status, $4 = markup (optional) -> ID
LIT_TYPE="$1" LIT_TITLE="$PREFIX $2" LIT_STATUS="$3" LIT_CONTENT="${4:-}" wp --user="$ADMIN_LOGIN" eval '
$id = wp_insert_post(["post_type" => getenv("LIT_TYPE"), "post_status" => getenv("LIT_STATUS"),
"post_title" => getenv("LIT_TITLE"), "post_content" => wp_slash((string) getenv("LIT_CONTENT"))]);
update_post_meta($id, "_et_pb_use_builder", "on");
update_post_meta($id, "_et_pb_use_divi_5", "on");
echo $id;'
}
content_of() { wp eval "echo (string) get_post($1)->post_content;"; }
content_hash() { wp eval "echo hash('sha256', (string) get_post($1)->post_content);"; }
rev_of() { tool lit-divi-outline "{\"post_id\":$1}" | jq -r '.tree_rev'; }
enabled_of() { wp post meta get "$1" _et_enabled; }
listed() { tb_state | jq -e --arg t "$1" '.list | index($t) != null' >/dev/null; } # on the live Theme Builder list
tb_list() { # $1 = arguments JSON (default {})
local args='{}'
if [ $# -gt 0 ]; then args=$1; fi
ability lit/tb-list "$args"
}
template_rev() { tb_list "{\"template_id\":$1}" | jq -r '.templates[0].template_rev // .detached[0].template_rev'; }
activate() { # $1 = template ID, $2 = enabled (true|false), $3 = confirm (true|false)
ability lit/tb-activate "$(jq -cn --argjson id "$1" --arg rev "$(template_rev "$1")" --argjson e "$2" --argjson c "$3" \
'{template_id: $id, template_rev: $rev, enabled: $e} + (if $c then {confirm: true} else {} end)')"
}
details() { echo "$1" | jq -r '.__error' | jq -c '.'; } # the details of a tool error (confirm_required etc.)
# Front-end HTML: $1 = post ID; $2 = "preview" renders it for the logged-in temporary editor (drafts), else anonymous.
render_of() {
local cookie=()
if [ "${2:-}" = preview ]; then cookie=(-H "Cookie: $SESSION_COOKIE"); fi
curl -s --max-time 30 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' "${cookie[@]}" "http://localhost/?page_id=$1"
}
render_home() { curl -s --max-time 30 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' "http://localhost/"; }
[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme"
[ "$TB_ID" -gt 0 ] || fail "setup: no live Theme Builder post (save the Theme Builder once)"
[ "$(wp post list --post_type=et_theme_builder --post_status=auto-draft --format=count)" = "0" ] \
|| fail "setup: the Theme Builder has unsaved changes – save or discard them first"
echo "$TB_BEFORE" | jq -e '[.enabled[]] | all(. != "1")' >/dev/null \
|| fail "setup: a Theme Builder template is enabled – this local site must show no active template before the run"
if wp user get "$EDITOR_LOGIN" >/dev/null 2>&1 || wp role exists "$EDITOR_ROLE" >/dev/null 2>&1; then
fail "user $EDITOR_LOGIN or role $EDITOR_ROLE exists already"
fi
# Temporary editor (per-run role cloned from editor: no edit_theme_options, Divi's "Theme Builder" option off).
wp role create "$EDITOR_ROLE" "LIT Smoke Theme Builder" --clone=editor >/dev/null
ROLE_CREATED=1
wp option update lit_mcp_allowed_roles "[\"administrator\",\"$EDITOR_ROLE\"]" --format=json >/dev/null
wp user create "$EDITOR_LOGIN" "$EDITOR_LOGIN@example.invalid" --role="$EDITOR_ROLE" >/dev/null
EDITOR_CREATED=1
[ "$(LIT_LOGIN="$EDITOR_LOGIN" wp eval 'echo user_can(get_user_by("login", getenv("LIT_LOGIN")), "edit_theme_options") ? "yes" : "no";')" = "no" ] \
|| fail "setup: the temporary editor must lack edit_theme_options"
# Its logged-in cookie previews the temporary draft page (session stored on the temporary user only).
SESSION_COOKIE=$(LIT_LOGIN="$EDITOR_LOGIN" wp eval '
$u = get_user_by("login", getenv("LIT_LOGIN"));
$expires = time() + 1800;
$token = WP_Session_Tokens::get_instance($u->ID)->create($expires);
echo LOGGED_IN_COOKIE . "=" . wp_generate_auth_cookie($u->ID, $expires, "logged_in", $token);')
connect "$ADMIN_LOGIN"
# 1. Exposure: still 22 direct lit tools; the six new abilities are indirect and reachable; no TB compat problem
LIST=$(rpc tools/list '{}')
DIRECT='["lit-content-create","lit-content-get","lit-content-query","lit-content-types","lit-content-update","lit-divi-catalog",
"lit-divi-delete","lit-divi-design-system","lit-divi-insert","lit-divi-move","lit-divi-node-get","lit-divi-outline","lit-divi-update",
"lit-guide","lit-library-insert","lit-media-download","lit-media-preview","lit-media-search","lit-media-upload-from-url",
"lit-page-render-summary","lit-post-publish","lit-site-status"]'
echo "$LIST" | jq -e --argjson want "$DIRECT" '[.result.tools[].name | select(startswith("lit-"))] | sort == ($want | sort)' >/dev/null \
|| fail "direct lit tools: $(echo "$LIST" | jq -c '[.result.tools[].name]')"
for a in lit/tb-list lit/tb-create-template lit/tb-assign lit/tb-activate lit/library-find-similar lit/library-make-global; do
if echo "$LIST" | jq -e --arg t "lit-${a#lit/}" '.result.tools[] | select(.name == $t)' >/dev/null; then fail "$a must be indirect"; fi
tool mcp-adapter-get-ability-info "$(jq -cn --arg a "$a" '{ability_name: $a}')" | jq -e 'has("__error") | not' >/dev/null \
|| fail "$a is not reachable"
done
STATUS=$(tool lit-site-status '{}')
echo "$STATUS" | jq -e '.components[] | select(.id == "divi") | .active == true and .problems == []' >/dev/null \
|| fail "Divi compatibility findings: $STATUS"
pass "exposure: 22 direct tools, the six new abilities indirect and reachable; no Divi/Theme Builder compatibility problem"
# 2. Create a template: disabled, on the live list, body layout by Divi with the plugin's content, invisible
PAGE=$(create_post page "Entwurf" draft '')
CREATED_POSTS+=("$PAGE")
[ "$(wp post get "$PAGE" --field=post_status)" = "draft" ] || fail "setup: the target page must be a draft"
MARKER="LIT-Smoke-Marker-$RUN"
BASE_PREVIEW=$(render_of "$PAGE" preview)
has "$BASE_PREVIEW" "$PREFIX Entwurf" || fail "setup: the preview does not show the draft page: $(head -c 300 <<<"$BASE_PREVIEW")"
CREATED=$(ability lit/tb-create-template "$(jq -cn --arg t "$PREFIX Vorlage" --arg m "$MARKER" --argjson p "$PAGE" '{
title: $t,
header: {source: "global"},
body: {source: "new", nodes: [{type: "section", modules: [{type: "heading", text: "{{post:title}}"}, {type: "text", content: $m}]}]},
footer: {source: "hidden"},
use_on: ["singular:post_type:page:id:\($p)"],
exclude_from: ["search"]}')")
TPL=$(echo "$CREATED" | jq -r '.template_id // empty')
[ -n "$TPL" ] || fail "tb-create-template: $CREATED"
CREATED_TEMPLATES+=("$TPL")
BODY=$(echo "$CREATED" | jq -r '.layouts.body.id')
CREATED_POSTS+=("$BODY")
echo "$CREATED" | jq -e '.enabled == false and .layouts.footer.enabled == false and .layouts.header.source == "global"
and .layouts.body.source == "new"' >/dev/null || fail "tb-create-template result: $CREATED"
[ "$(enabled_of "$TPL")" = "0" ] || fail "the new template is enabled"
[ "$(wp post meta get "$TPL" _et_use_on)" = "singular:post_type:page:id:$PAGE" ] || fail "conditions: $(wp post meta get "$TPL" _et_use_on)"
listed "$TPL" || fail "template $TPL is not on the live Theme Builder list"
[ "$(wp post get "$BODY" --field=post_type)" = "et_body_layout" ] || fail "body layout type"
[ "$(wp post meta get "$BODY" _et_pb_use_divi_5)" = "on" ] || fail "body layout without the Divi 5 flag"
BODY_CONTENT=$(content_of "$BODY")
[[ "$BODY_CONTENT" == "<!-- wp:divi/placeholder -->"* ]] || fail "body layout content: $BODY_CONTENT"
has "$BODY_CONTENT" "$MARKER" || fail "body layout lacks the marker"
tb_list "{\"template_id\":$TPL}" | jq -e '.templates[0] | .enabled == false and .live == false and .attached == true' >/dev/null \
|| fail "tb-list does not show the new template disabled and attached"
if has "$(render_of "$PAGE" preview)" "$MARKER"; then fail "a disabled template is rendered"; fi
pass "tb-create-template: disabled, on the live list, body layout by Divi with the plugin's content, not rendered"
# 3. Divi tools on the body of a disabled template save at once; post-content is allowed in the body
UPD=$(tool lit-divi-insert "$(jq -cn --argjson id "$BODY" --arg rev "$(rev_of "$BODY")" \
'{post_id: $id, tree_rev: $rev, parent: "s0.r0.c0", nodes: [{type: "post-content"}]}')")
echo "$UPD" | jq -e '.ok == true and .templates == []' >/dev/null || fail "insert into the body of a disabled template: $UPD"
has "$(content_of "$BODY")" "wp:divi/post-content" || fail "post-content not stored in the body"
pass "Divi tools write the body of a disabled template without confirm; post-content allowed in the body"
# 4. tb-assign while disabled (Q3): an omitted exclude_from stays, conditions still only the draft page
ASSIGN=$(ability lit/tb-assign "$(jq -cn --argjson id "$TPL" --arg rev "$(template_rev "$TPL")" --argjson p "$PAGE" \
'{template_id: $id, template_rev: $rev, use_on: ["singular:post_type:page:id:\($p)"]}')")
echo "$ASSIGN" | jq -e '.ok == true and .live == false and .previous.exclude_from == ["search"]' >/dev/null || fail "tb-assign: $ASSIGN"
[ "$(wp post meta get "$TPL" _et_exclude_from)" = "search" ] || fail "an omitted exclude_from changed the exceptions"
[ "$(wp post meta get "$TPL" _et_use_on)" = "singular:post_type:page:id:$PAGE" ] || fail "use_on after tb-assign"
[ "$(error_code "$(ability lit/tb-assign "$(jq -cn --argjson id "$TPL" --argjson p "$PAGE" \
'{template_id: $id, template_rev: "abcdef012345", use_on: ["singular:post_type:page:id:\($p)"]}')")")" = "rev_mismatch" ] \
|| fail "tb-assign with a stale template_rev"
pass "tb-assign on a disabled template: saved at once, omitted exclude_from kept (Q3), stale template_rev refused"
# 5. Activation: dry run, then confirm; the draft page shows the template, visitors see nothing new
DRY=$(activate "$TPL" true false)
[ "$(error_code "$DRY")" = "confirm_required" ] || fail "activation without confirm: $DRY"
details "$DRY" | jq -e --arg p "$PAGE" '.use_on | length == 1 and (.[0] | contains($p))' >/dev/null || fail "activation dry run: $DRY"
[ "$(enabled_of "$TPL")" = "0" ] || fail "the dry run enabled the template"
ON=$(activate "$TPL" true true)
echo "$ON" | jq -e '.enabled == true and .live == true' >/dev/null || fail "activation: $ON"
[ "$(enabled_of "$TPL")" = "1" ] || fail "template not enabled"
PREVIEW=$(render_of "$PAGE" preview)
has "$PREVIEW" "$MARKER" || fail "the active template is not rendered for its draft page"
if has "$(render_of "$PAGE")" "$MARKER"; then fail "a visitor sees the draft page's template"; fi
if has "$(render_home)" "$MARKER"; then fail "the template shows on the home page"; fi
pass "tb-activate: dry run first, then enabled; only the draft page (preview) shows it, visitors see nothing"
# 6. Live template: layout changes and condition changes are a dry run first (Q9), Q3 on the live template
HASH=$(content_hash "$BODY")
CHANGE=$(jq -cn --arg m "$MARKER-geändert" '[{id: "s0.r0.c0.m1", set: {content: $m}}]')
LIVE=$(tool lit-divi-update "$(jq -cn --argjson id "$BODY" --arg rev "$(rev_of "$BODY")" --argjson c "$CHANGE" '{post_id: $id, tree_rev: $rev, changes: $c}')")
[ "$(error_code "$LIVE")" = "confirm_required" ] || fail "visible layout changed without confirm: $LIVE"
details "$LIVE" | jq -e --argjson t "$TPL" '.templates | map(.id) | index($t) != null' >/dev/null || fail "dry run lacks the template: $LIVE"
[ "$(content_hash "$BODY")" = "$HASH" ] || fail "the dry run wrote the layout"
DEL=$(tool lit-divi-delete "$(jq -cn --argjson id "$BODY" --arg rev "$(rev_of "$BODY")" '{post_id: $id, tree_rev: $rev, ids: ["s0.r0.c0.m0"]}')")
[ "$(error_code "$DEL")" = "confirm_required" ] || fail "visible layout: delete without confirm: $DEL"
[ "$(content_hash "$BODY")" = "$HASH" ] || fail "the delete dry run wrote the layout"
DONE=$(tool lit-divi-update "$(jq -cn --argjson id "$BODY" --arg rev "$(rev_of "$BODY")" --argjson c "$CHANGE" \
'{post_id: $id, tree_rev: $rev, confirm: true, changes: $c}')")
echo "$DONE" | jq -e --argjson t "$TPL" '.ok == true and (.templates | map(.id) | index($t) != null)
and (.restore_url | test("/wp-admin/revision\\.php\\?revision=[0-9]+$"))' >/dev/null \
|| fail "confirmed layout change (templates, restore_url): $DONE"
has "$(render_of "$PAGE" preview)" "$MARKER-geändert" || fail "the changed layout is not rendered"
REASSIGN=$(jq -cn --argjson id "$TPL" --arg rev "$(template_rev "$TPL")" --argjson p "$PAGE" \
'{template_id: $id, template_rev: $rev, use_on: ["singular:post_type:page:id:\($p)"], exclude_from: []}')
ADRY=$(ability lit/tb-assign "$REASSIGN")
[ "$(error_code "$ADRY")" = "confirm_required" ] || fail "conditions of an active template changed without confirm: $ADRY"
details "$ADRY" | jq -e '(.before.exclude_from | length) == 1 and .after.exclude_from == [] and (.after.use_on | length) == 1' >/dev/null \
|| fail "tb-assign dry run before/after: $ADRY"
[ "$(wp post meta get "$TPL" _et_exclude_from)" = "search" ] || fail "the dry run changed the exceptions"
ACONF=$(ability lit/tb-assign "$(echo "$REASSIGN" | jq -c '. + {confirm: true}')")
echo "$ACONF" | jq -e '.ok == true and .live == true and .previous.exclude_from == ["search"]' >/dev/null || fail "confirmed tb-assign: $ACONF"
[ -z "$(wp post meta get "$TPL" _et_exclude_from 2>/dev/null || true)" ] || fail "exclude_from [] did not clear the exceptions"
[ "$(wp post meta get "$TPL" _et_use_on)" = "singular:post_type:page:id:$PAGE" ] || fail "use_on of the live template changed"
pass "live template: layout update/delete and conditions only after a dry run; confirmed change rendered; [] clears exceptions"
# 7. Theme Builder draft: blocks create/assign/enable, the off-switch still works (Q20); default template refused
DEFAULT=$(tb_list | jq -r '[.templates[] | select(.default)][0].id // empty')
if [ -n "$DEFAULT" ]; then
DEF_ENABLED=$(tb_list "{\"template_id\":$DEFAULT}" | jq -r '.templates[0].enabled')
[ "$(error_code "$(activate "$DEFAULT" "$([ "$DEF_ENABLED" = true ] && echo false || echo true)" false)")" = "unsupported" ] \
|| fail "the default template is not refused"
fi
TPL_REV=$(template_rev "$TPL")
DRAFT_TB=$(wp eval 'echo (int) wp_insert_post(["post_type" => "et_theme_builder", "post_status" => "auto-draft", "post_title" => "Theme Builder"]);')
[ "$DRAFT_TB" -gt 0 ] || fail "setup: Theme Builder draft"
[ "$(error_code "$(ability lit/tb-create-template "$(jq -cn --arg t "$PREFIX Gesperrt" '{title: $t}')")")" = "unsupported" ] \
|| fail "tb-create-template despite a Theme Builder draft"
[ "$(error_code "$(ability lit/tb-assign "$(jq -cn --argjson id "$TPL" --arg rev "$TPL_REV" --argjson p "$PAGE" \
'{template_id: $id, template_rev: $rev, use_on: ["singular:post_type:page:id:\($p)"], confirm: true}')")")" = "unsupported" ] \
|| fail "tb-assign despite a Theme Builder draft"
OFF_DRY=$(ability lit/tb-activate "$(jq -cn --argjson id "$TPL" --arg rev "$TPL_REV" '{template_id: $id, template_rev: $rev, enabled: false}')")
[ "$(error_code "$OFF_DRY")" = "confirm_required" ] || fail "deactivation without confirm: $OFF_DRY"
[ "$(enabled_of "$TPL")" = "1" ] || fail "the deactivation dry run switched"
OFF=$(ability lit/tb-activate "$(jq -cn --argjson id "$TPL" --arg rev "$TPL_REV" '{template_id: $id, template_rev: $rev, enabled: false, confirm: true}')")
echo "$OFF" | jq -e '.enabled == false and .live == false and (.warnings | map(contains("Entwurf")) | any)' >/dev/null \
|| fail "off-switch with a Theme Builder draft: $OFF"
[ "$(enabled_of "$TPL")" = "0" ] || fail "template still enabled"
[ "$(error_code "$(activate "$TPL" true true)")" = "unsupported" ] || fail "tb-activate (on) despite a Theme Builder draft"
[ "$(enabled_of "$TPL")" = "0" ] || fail "enabled despite a Theme Builder draft"
wp post delete "$DRAFT_TB" --force >/dev/null
DRAFT_TB=""
if has "$(render_of "$PAGE" preview)" "$MARKER"; then fail "a deactivated template is still rendered"; fi
pass "Theme Builder draft blocks create, assign and enable; the off-switch works with a warning; default template refused"
# 8. Detached template (a stale Theme Builder save removed it): listed, refused, reattached disabled on request
LIT_TB="$TB_ID" LIT_ID="$TPL" wp eval '
delete_post_meta((int) getenv("LIT_TB"), "_et_template", (string) getenv("LIT_ID"));
update_post_meta((int) getenv("LIT_ID"), "_et_theme_builder_marked_as_unused", "2026-09-27 00:00:00");' >/dev/null
if listed "$TPL"; then fail "setup: template still listed"; fi
tb_list | jq -e --argjson t "$TPL" '(.detached | map(.id) | index($t) != null) and (.templates | map(.id) | index($t) == null)' >/dev/null \
|| fail "detached template not listed under detached"
[ "$(error_code "$(activate "$TPL" true true)")" = "validation_failed" ] || fail "a detached template was activated"
[ "$(error_code "$(ability lit/tb-assign "$(jq -cn --argjson id "$TPL" --arg rev "$(template_rev "$TPL")" --argjson p "$PAGE" \
'{template_id: $id, template_rev: $rev, use_on: ["singular:post_type:page:id:\($p)"]}')")")" = "validation_failed" ] \
|| fail "tb-assign on a detached template without reattach"
RE=$(ability lit/tb-assign "$(jq -cn --argjson id "$TPL" --arg rev "$(template_rev "$TPL")" --argjson p "$PAGE" \
'{template_id: $id, template_rev: $rev, use_on: ["singular:post_type:page:id:\($p)"], reattach: true}')")
echo "$RE" | jq -e '.ok == true and .live == false' >/dev/null || fail "reattach: $RE"
listed "$TPL" || fail "not reattached"
[ "$(enabled_of "$TPL")" = "0" ] || fail "reattached enabled"
[ -z "$(wp post meta get "$TPL" _et_theme_builder_marked_as_unused 2>/dev/null || true)" ] || fail "unused mark kept"
pass "stale-editor detach: listed as detached, activate/assign refused, reattach puts it back disabled and unmarked"
# 9. Rights: an editor without the Theme Builder; group "nur lesen" and "deaktiviert" gate the Divi tools
connect "$EDITOR_LOGIN"
[ "$(error_code "$(ability lit/tb-list '{}')")" = "forbidden" ] || fail "editor lists the Theme Builder"
[ "$(error_code "$(ability lit/tb-create-template "$(jq -cn --arg t "$PREFIX Redakteur" '{title: $t}')")")" = "forbidden" ] \
|| fail "editor creates a template"
# Review M-9: without the Theme Builder rights the refusal is forbidden whatever the ID (like tb-list).
[ "$(error_code "$(ability lit/tb-activate "$(jq -cn --argjson id "$TPL" '{template_id: $id, template_rev: "abcdef012345", enabled: true, confirm: true}')")")" = "forbidden" ] \
|| fail "editor reaches the template"
[ "$(error_code "$(ability lit/tb-assign "$(jq -cn --argjson id "$TPL" --argjson p "$PAGE" \
'{template_id: $id, template_rev: "abcdef012345", use_on: ["singular:post_type:page:id:\($p)"]}')")")" = "forbidden" ] \
|| fail "editor reaches the template's conditions"
[ "$(error_code "$(tool lit-divi-outline "{\"post_id\":$BODY}")")" = "not_found" ] || fail "editor reads a Theme Builder layout"
[ "$(enabled_of "$TPL")" = "0" ] || fail "editor refusal changed the template"
connect "$ADMIN_LOGIN"
wp option update lit_mcp_exposure '{"@readonly:divi-theme-builder":true}' --format=json >/dev/null
connect "$ADMIN_LOGIN"
[ "$(error_code "$(tool lit-divi-update "$(jq -cn --argjson id "$BODY" --arg rev "$(rev_of "$BODY")" \
'{post_id: $id, tree_rev: $rev, changes: [{id: "s0.r0.c0.m1", label: "Nur lesen"}]}')")")" = "forbidden" ] \
|| fail "group read-only: Theme Builder layout written"
[ -n "$(template_rev "$TPL")" ] || fail "group read-only: tb-list must stay readable"
RO=$(activate "$TPL" true true)
has "$RO" "Ability 'lit/tb-activate' not found" || fail "group read-only: tb-activate still executable: $RO"
[ "$(enabled_of "$TPL")" = "0" ] || fail "group read-only: template enabled"
wp option update lit_mcp_exposure '{"@group:divi-theme-builder":"disabled"}' --format=json >/dev/null
connect "$ADMIN_LOGIN"
[ "$(error_code "$(tool lit-divi-outline "{\"post_id\":$BODY}")")" = "not_found" ] || fail "group disabled: Theme Builder layout readable"
GONE=$(ability lit/tb-list '{}')
echo "$GONE" | jq -e 'has("__error")' >/dev/null && has "$GONE" 'not found' || fail "group disabled: tb-list still executable: $GONE"
restore_option lit_mcp_exposure "$ORIGINAL_EXPOSURE"
connect "$ADMIN_LOGIN"
pass "editor without the Theme Builder: forbidden/not_found everywhere; group read-only and disabled gate the Divi tools"
# 10. Pods fields in a Theme Builder body – with a temporary pod of this run (ruling Q6)
if wp plugin is-active pods >/dev/null 2>&1; then
if [ "$(LIT_POD="$POD" wp eval 'echo pods_api()->load_pod(["name" => getenv("LIT_POD")]) ? "yes" : "no";')" != "no" ] \
|| [ "$POD" = lit_person ] || [ "$POD" = lit_fixture_person ]; then
fail "pod $POD exists already"
fi
POD_CREATED=1
POD_IDS=$(LIT_POD="$POD" LIT_LABEL="$PREFIX Personen" wp eval '
$api = pods_api();
$pod = getenv("LIT_POD");
$ids = [(int) $api->save_pod(["name" => $pod, "label" => getenv("LIT_LABEL"), "label_singular" => getenv("LIT_LABEL"),
"type" => "post_type", "storage" => "meta", "public" => 1, "show_ui" => 1])];
foreach ([
["name" => "role", "label" => "Funktion", "type" => "text"],
["name" => "level", "label" => "Stufe", "type" => "pick", "pick_object" => "custom-simple", "pick_custom" => "junior|Junior\nsenior|Senior"],
["name" => "mentor", "label" => "Mentor", "type" => "pick", "pick_object" => "post_type", "pick_val" => $pod, "pick_format_type" => "single"],
] as $field) {
$ids[] = (int) $api->save_field(array_merge(["pod" => $pod], $field));
}
echo implode(" ", $ids);')
for id in $POD_IDS; do POD_POSTS+=("$id"); done
[ "$(echo "$POD_IDS" | wc -w)" = 4 ] || fail "setup: temporary pod $POD: $POD_IDS"
CONDS=$(tb_list '{"conditions":true}')
echo "$CONDS" | jq -e --arg c "singular:post_type:$POD:all" '.conditions | map(.id) | index($c) != null' >/dev/null \
|| fail "Divi offers no condition for the temporary pod"
PODS=$(ability lit/tb-create-template "$(jq -cn --arg t "$PREFIX Person-Vorlage" --arg pod "$POD" '{
title: $t, header: {source: "default"}, footer: {source: "default"},
body: {source: "new", nodes: [{type: "section", modules: [
{type: "heading", text: "{{post:title}}"}, {type: "text", content: "{{field:role}}"},
{type: "text", content: "{{field:level}}"}, {type: "text", content: "{{field:mentor}}"}]}]},
use_on: ["singular:post_type:\($pod):all"]}')")
PTPL=$(echo "$PODS" | jq -r '.template_id // empty')
[ -n "$PTPL" ] || fail "Pods template: $PODS"
CREATED_TEMPLATES+=("$PTPL")
CREATED_POSTS+=("$(echo "$PODS" | jq -r '.layouts.body.id')")
[ "$(enabled_of "$PTPL")" = "0" ] || fail "the Pods template is enabled"
echo "$PODS" | jq -e '[.warnings[] | select(contains("„mentor“"))] | length == 1' >/dev/null || fail "no warning for mentor: $PODS"
echo "$PODS" | jq -e '[.warnings[] | select(contains("„level“"))] | length == 1' >/dev/null || fail "no note for level: $PODS"
echo "$PODS" | jq -e '[.warnings[] | select(contains("„role“"))] | length == 0' >/dev/null || fail "role wrongly warned: $PODS"
PBODY=$(echo "$PODS" | jq -r '.layouts.body.id')
PUPD=$(tool lit-divi-insert "$(jq -cn --argjson id "$PBODY" --arg rev "$(rev_of "$PBODY")" \
'{post_id: $id, tree_rev: $rev, parent: "s0.r0.c0", nodes: [{type: "text", content: "{{field:mentor}}"}]}')")
echo "$PUPD" | jq -e '.ok == true and ([.warnings[] | select(contains("„mentor“"))] | length >= 1)' >/dev/null \
|| fail "Divi tool on the Pods body without the mentor warning: $PUPD"
pass "Pods (temporary pod $POD): condition offered; relation field warned, choice field noted, text field clean; template disabled"
elif [ "${REQUIRE_PODS:-0}" = 1 ]; then
fail "Pods is not active (REQUIRE_PODS=1)"
else
echo "SKIP - Pods section (Pods inactive)"
fi
# 11. find-similar and make-global (mode 1, mode 2: dry run, confirm, identical only)
SECTION=$(tr -d '\r' < "$STAGE3/library--library-abschnitt.html")
VARIANT=${SECTION/Library-Blurb\"/Library-Blurb anders\"}
[ "$VARIANT" != "$SECTION" ] || fail "setup: variant equals the section"
A=$(create_post page "Ähnlich A" draft "$SECTION")
B=$(create_post page "Ähnlich B" draft "$SECTION")
C=$(create_post page "Ähnlich C" draft "$VARIANT")
CREATED_POSTS+=("$A" "$B" "$C")
SIM=$(ability lit/library-find-similar "$(jq -cn --argjson a "$A" '{post_id: $a, id: "s0", post_type: "page", limit: 200}')")
echo "$SIM" | jq -e --argjson b "$B" --argjson c "$C" '
([.matches[] | select(.post_id == $b and .id == "s0" and .match == "identical")] | length == 1) and
([.matches[] | select(.post_id == $c and .id == "s0" and .match == "structure")] | length == 1)' >/dev/null \
|| fail "find-similar: $SIM"
MG=$(ability lit/library-make-global "$(jq -cn --argjson a "$A" --arg rev "$(rev_of "$A")" --arg t "$PREFIX Global" \
'{post_id: $a, tree_rev: $rev, id: "s0", title: $t}')")
LIB=$(echo "$MG" | jq -r '.library_id // empty')
[ -n "$LIB" ] || fail "make-global: $MG"
CREATED_POSTS+=("$LIB")
[ "$(content_of "$LIB")" = "$SECTION" ] || fail "global entry bytes differ from the section"
[ "$(wp eval "echo implode(',', wp_get_object_terms($LIB, 'scope', ['fields' => 'slugs']));")" = "global" ] || fail "entry is not global"
has "$(content_of "$A")" "\"globalModule\":\"$LIB\",\"blockName\":\"divi/section\"" || fail "page A holds no instance"
BHASH=$(content_hash "$B")
CHASH=$(content_hash "$C")
BREVS=$(wp eval "echo count(wp_get_post_revisions($B));")
TARGETS=$(jq -cn --argjson lib "$LIB" --argjson b "$B" --arg rev "$(rev_of "$B")" '{library_id: $lib, targets: [{post_id: $b, tree_rev: $rev, ids: ["s0"]}]}')
MDRY=$(ability lit/library-make-global "$TARGETS")
[ "$(error_code "$MDRY")" = "confirm_required" ] || fail "replace without dry run: $MDRY"
details "$MDRY" | jq -e --argjson b "$B" '.affected | map(.post_id) == [$b]' >/dev/null || fail "make-global dry run: $MDRY"
[ "$(content_hash "$B")" = "$BHASH" ] || fail "the dry run changed page B"
BAD=$(ability lit/library-make-global "$(jq -cn --argjson lib "$LIB" --argjson c "$C" --arg rev "$(rev_of "$C")" \
'{library_id: $lib, confirm: true, targets: [{post_id: $c, tree_rev: $rev, ids: ["s0"]}]}')")
[ "$(error_code "$BAD")" = "validation_failed" ] || fail "a section that is not identical was replaced: $BAD"
[ "$(content_hash "$C")" = "$CHASH" ] || fail "the refused target C changed"
MCONF=$(ability lit/library-make-global "$(echo "$TARGETS" | jq -c '. + {confirm: true}')")
echo "$MCONF" | jq -e --argjson b "$B" '.pages[0].post_id == $b and .pages[0].revision_id > 0' >/dev/null || fail "replace occurrences: $MCONF"
has "$(content_of "$B")" "\"globalModule\":\"$LIB\"" || fail "page B holds no instance"
[ "$(wp eval "echo count(wp_get_post_revisions($B));")" -gt "$BREVS" ] || fail "replacing made no revision of page B"
[ "$(content_hash "$C")" = "$CHASH" ] || fail "page C changed"
pass "find-similar: identical and structure; make-global mode 1 (entry + instance), mode 2 dry run, confirm, identical only"
# 12. P13: with the library group disabled no library ID is named; P10: post_link_url_page refuses a post
wp option update lit_mcp_exposure '{"@group:divi-library":"disabled"}' --format=json >/dev/null
connect "$ADMIN_LOGIN"
if has "$(tool lit-divi-outline "{\"post_id\":$A}")" "$LIB"; then fail "outline names library ID $LIB with the library hidden"; fi
if has "$(tool lit-divi-node-get "{\"post_id\":$A,\"id\":\"g0\"}")" "$LIB"; then fail "node-get names library ID $LIB"; fi
if has "$(tool lit-page-render-summary "{\"post_id\":$A}")" "$LIB"; then fail "render summary names library ID $LIB"; fi
restore_option lit_mcp_exposure "$ORIGINAL_EXPOSURE"
connect "$ADMIN_LOGIN"
has "$(tool lit-divi-outline "{\"post_id\":$A}")" "$LIB" || fail "control: outline does not name $LIB with the library visible"
POST=$(create_post post "Beitrag" draft '')
CREATED_POSTS+=("$POST")
LINK="\$variable({\"type\":\"content\",\"value\":{\"name\":\"post_link_url_page\",\"settings\":{\"post_id\":\"$POST\"}}})\$"
PHASH=$(content_hash "$PAGE")
P10=$(tool lit-divi-insert "$(jq -cn --argjson id "$PAGE" --arg rev "$(rev_of "$PAGE")" --arg l "$LINK" \
'{post_id: $id, tree_rev: $rev, nodes: [{type: "section", modules: [{type: "button", text: "Mehr", url: $l}]}]}')")
[ "$(error_code "$P10")" = "validation_failed" ] || fail "post_link_url_page accepted a post: $P10"
[ "$(content_hash "$PAGE")" = "$PHASH" ] || fail "the refused link changed the page"
pass "P13: no library ID without library access; P10: post_link_url_page refuses a post as target"
# 13. Cleanup now and check it: Theme Builder list and enabled states as before, nothing of the run left
if has "$(render_home)" "$MARKER"; then fail "the home page shows the marker"; fi
KEEP_IDS="${CREATED_POSTS[*]} ${CREATED_TEMPLATES[*]} ${POD_POSTS[*]}"
cleanup
[ " $(divi_caches) " = "$DIVI_CACHES_BEFORE" ] || fail "Divi feature cache options differ: before$DIVI_CACHES_BEFORE, after $(divi_caches)"
[ "$(tb_state)" = "$TB_BEFORE" ] || fail "Theme Builder list/enabled states differ: before $TB_BEFORE, after $(tb_state)"
tb_state | jq -e '[.enabled[]] | all(. != "1")' >/dev/null || fail "a Theme Builder template is enabled after the run: $(tb_state)"
[ "$(option_json "$BACKUP_OPTION")" = "$ORIGINAL_BACKUP" ] || fail "Theme Builder backup option not restored"
[ "$(option_json lit_mcp_exposure)" = "$ORIGINAL_EXPOSURE" ] || fail "lit_mcp_exposure not restored"
[ "$(option_json lit_mcp_allowed_roles)" = "$ORIGINAL_ROLES" ] || fail "lit_mcp_allowed_roles not restored"
[ "$(option_json rewrite_rules)" = "$ORIGINAL_REWRITE" ] || fail "rewrite_rules not restored"
LEFT=$(LIT_IDS="$KEEP_IDS" LIT_PREFIX="$PREFIX" LIT_POD="$POD" LIT_LOGIN="$EDITOR_LOGIN" LIT_ROLE="$EDITOR_ROLE" wp eval '
global $wpdb;
$ids = array_values(array_unique(array_filter(array_map("intval", explode(" ", (string) getenv("LIT_IDS"))))));
$in = $ids === [] ? "0" : implode(",", $ids);
$left = [];
$left["posts"] = (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->posts WHERE ID IN ($in) OR post_parent IN ($in)");
$left["meta"] = (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->postmeta WHERE post_id IN ($in)");
$left["terms"] = (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->term_relationships WHERE object_id IN ($in)");
$left["log"] = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}lit_mcp_log WHERE object_id IN (\"" . implode("\",\"", $ids ?: [0]) . "\")");
$left["titles"] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM $wpdb->posts WHERE post_title LIKE %s",
$wpdb->esc_like((string) getenv("LIT_PREFIX")) . "%"));
$left["pod"] = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM $wpdb->posts WHERE post_type = %s OR (post_type IN (\"_pods_pod\", \"_pods_field\") AND post_name = %s)",
getenv("LIT_POD"), getenv("LIT_POD")));
$left["user"] = get_user_by("login", getenv("LIT_LOGIN")) ? 1 : 0;
$left["role"] = wp_roles()->is_role(getenv("LIT_ROLE")) ? 1 : 0;
echo wp_json_encode(array_filter($left));')
[ "$LEFT" = "[]" ] || fail "left over after cleanup: $LEFT"
pass "cleanup: Theme Builder list as before, no template enabled, options restored, no post/meta/term/log/pod/user/role left"
finish