diff --git a/tests/Integration/divi-loop-smoke.sh b/tests/Integration/divi-loop-smoke.sh
new file mode 100755
index 0000000..9035863
--- /dev/null
+++ b/tests/Integration/divi-loop-smoke.sh
@@ -0,0 +1,263 @@
+#!/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("
Schleife
")])),
+ $row($b("divi/column", $width, [$text("Ausserhalb
")])),
+ $row($b("divi/column", $loop("loop-litsmoke02", "post_taxonomies", "category", "Categories"), [$text("Terme
")])),
+]);
+$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 : 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 : the call was refused (an error, not the dry run of anything) and the error names .
+refused_with() {
+ echo "$1" | jq -e --arg t "$2" 'has("__error") and (.__error | contains($t))' >/dev/null
+}
+# variable_ref