[^|]+?)\s*\|"
+ r"\s*(?P\d+)\s*\|"
+ r"\s*(?P\d+)\s*\|"
+ r"\s*(?P\d+)\s*\|"
+ r"\s*(?P\d+)\s*\|"
+ r"\s*(?P\d+)\s*\|\s*$"
+)
+TOTAL_ROW_RE = re.compile(
+ r"^\|\s*\*\*TOTAL\*\*\s*\|"
+ r"\s*\*\*(?P\d+)\*\*\s*\|"
+ r"\s*\*\*(?P\d+)\*\*\s*\|"
+ r"\s*\*\*(?P\d+)\*\*\s*\|"
+ r"\s*\*\*(?P\d+)\*\*\s*\|"
+ r"\s*\*\*(?P\d+)\*\*\s*\|\s*$"
+)
+ALL_N_TESTS_RE = re.compile(r"All\s+(\d+)\s+tests", re.IGNORECASE)
+
+
+def count_symbols(line: str) -> tuple[int, int, int, int]:
+ p = line.count(PASS_SYM)
+ w = line.count(WEAK_SYM)
+ i = line.count(INADEQ_SYM)
+ n = 0
+ if re.search(r"\|\s*N/A\s*\|", line) or re.search(r"\|\s*N/A\s*\u2014", line):
+ bulk = ALL_N_TESTS_RE.search(line)
+ n = int(bulk.group(1)) if bulk else 1
+ return p, w, i, n
+
+
+def parse_file_verdict(body: str) -> tuple[int, int, int, int]:
+ p = w = i = n = 0
+ for match in re.finditer(r"(\d+)/\d+\s*(\S)", body):
+ num, sym = int(match.group(1)), match.group(2)
+ if sym == PASS_SYM:
+ p = num
+ elif sym == WEAK_SYM:
+ w = num
+ elif sym == INADEQ_SYM:
+ i = num
+ n_match = re.search(r"(\d+)/\d+\s*N/A", body)
+ if n_match:
+ n = int(n_match.group(1))
+ return p, w, i, n
+
+
+def main() -> int:
+ content = AUDIT.read_text(encoding="utf-8").splitlines()
+
+ sections: dict[str, dict[str, tuple[int, int, int, int]]] = {}
+ current_file: str | None = None
+ row_tally = (0, 0, 0, 0)
+ in_table = False
+
+ for line in content:
+ m = SECTION_RE.match(line)
+ if m:
+ if current_file is not None:
+ sections.setdefault(current_file, {})["rows"] = row_tally
+ current_file = os.path.basename(m.group("file").strip())
+ row_tally = (0, 0, 0, 0)
+ in_table = False
+ continue
+ if current_file is None:
+ continue
+ if line.startswith("| Test ") or re.match(r"^\|\s*-+", line) or line.startswith("|---"):
+ in_table = True
+ continue
+ if in_table and line.startswith("|"):
+ p, w, i, n = count_symbols(line)
+ row_tally = (row_tally[0] + p, row_tally[1] + w, row_tally[2] + i, row_tally[3] + n)
+ continue
+ if in_table and not line.startswith("|"):
+ in_table = False
+ fv = VERDICT_LINE_RE.match(line)
+ if fv and current_file:
+ sections.setdefault(current_file, {})["file_verdict"] = parse_file_verdict(fv.group("body"))
+
+ if current_file is not None:
+ sections.setdefault(current_file, {})["rows"] = row_tally
+
+ summary_rows: dict[str, tuple[int, int, int, int, int]] = {}
+ total_row: tuple[int, int, int, int, int] | None = None
+ for line in content:
+ tm = TOTAL_ROW_RE.match(line)
+ if tm:
+ total_row = (
+ int(tm.group("p")), int(tm.group("w")), int(tm.group("i")),
+ int(tm.group("n")), int(tm.group("t")),
+ )
+ continue
+ sm = SUMMARY_ROW_RE.match(line)
+ if sm:
+ name = sm.group("file").strip()
+ if name.lower() == "file" or name.startswith("**") or name.startswith("---"):
+ continue
+ summary_rows[name] = (
+ int(sm.group("p")), int(sm.group("w")), int(sm.group("i")),
+ int(sm.group("n")), int(sm.group("t")),
+ )
+
+ ok = True
+ print(f"Audit: {AUDIT}")
+ print(f"Sections parsed: {len(sections)} Summary rows parsed: {len(summary_rows)}")
+ print("-" * 90)
+ hdr = f"{'File':44} {'Rows(P W I N)':16} {'FileVerdict':16} {'Summary(P W I N T)':20} OK"
+ print(hdr)
+ print("-" * 90)
+
+ section_totals = [0, 0, 0, 0, 0]
+ for name, data in sections.items():
+ rows = data.get("rows", (0, 0, 0, 0))
+ fv = data.get("file_verdict", (0, 0, 0, 0))
+ sm_row = summary_rows.get(name)
+
+ row_str = "{} {} {} {}".format(*rows)
+ fv_str = "{} {} {} {}".format(*fv) if fv != (0, 0, 0, 0) else "(none)"
+ sm_str = "{} {} {} {} {}".format(*sm_row) if sm_row else "(missing)"
+
+ row_matches_sm = sm_row is not None and rows == sm_row[:4]
+ fv_matches_rows = fv == rows
+ this_ok = row_matches_sm and fv_matches_rows
+ mark = "\u2713" if this_ok else "\u2717"
+ if not this_ok:
+ ok = False
+ print(f"{name:44} {row_str:16} {fv_str:16} {sm_str:20} {mark}")
+ if sm_row:
+ for idx in range(5):
+ section_totals[idx] += sm_row[idx]
+
+ unseen_summary = set(summary_rows.keys()) - set(sections.keys())
+ for name in unseen_summary:
+ sm_row = summary_rows[name]
+ for idx in range(5):
+ section_totals[idx] += sm_row[idx]
+ print(f"{name:44} {'(no section)':16} {'(n/a)':16} {'{} {} {} {} {}'.format(*sm_row):20} -")
+
+ print("-" * 90)
+ if total_row:
+ reported_total = total_row
+ computed = tuple(section_totals)
+ total_ok = reported_total == computed
+ mark = "\u2713" if total_ok else "\u2717"
+ print(f"{'TOTAL (from Summary)':44} reported={reported_total} computed={computed} {mark}")
+ if not total_ok:
+ ok = False
+ else:
+ print("TOTAL row NOT FOUND")
+ ok = False
+
+ print()
+ print("PASS" if ok else "FAIL")
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/e2e/test_e2e_uv_compile.py b/tests/cli/test_uv_compile.py
similarity index 74%
rename from tests/e2e/test_e2e_uv_compile.py
rename to tests/cli/test_uv_compile.py
index c3434df0..666d813b 100644
--- a/tests/e2e/test_e2e_uv_compile.py
+++ b/tests/cli/test_uv_compile.py
@@ -189,65 +189,44 @@ class TestReinstall:
"""cm-cli reinstall --uv-compile"""
def test_reinstall_with_uv_compile(self):
- """Reinstall an existing pack with --uv-compile."""
- # Install first
+ """Reinstall an existing pack with --uv-compile — resolver MUST run."""
_run_cm_cli("install", REPO_TEST1)
assert _pack_exists(PACK_TEST1)
- # Reinstall with --uv-compile
r = _run_cm_cli("reinstall", "--uv-compile", REPO_TEST1)
combined = r.stdout + r.stderr
- # Reinstall should re-resolve or report the pack exists
- # Note: Manager's reinstall may fail to remove the existing directory
- # before re-cloning (known issue — purge_node_state bug)
assert _pack_exists(PACK_TEST1)
- assert "Resolving dependencies" in combined or "Already exists" in combined
+ assert "Resolving dependencies" in combined, (
+ f"Expected resolver to run on reinstall but output had: {combined[:500]!r}"
+ )
-class TestUpdate:
- """cm-cli update --uv-compile"""
+class TestUvCompileVerbs:
+ """cm-cli verbs that support --uv-compile.
- def test_update_single_with_uv_compile(self):
- """Update an installed pack with --uv-compile."""
+ WI-NN Cluster 5 (bloat-sweep dev:ci-004/005/006/007/011 B9 copy-paste):
+ consolidates 5 previously-separate test functions that all assert the same
+ "Resolving dependencies" emission after install+verb. Parametrized across
+ the 5 supported verb/target combinations.
+ """
+
+ @pytest.mark.parametrize(
+ "cm_args",
+ [
+ pytest.param(("update", "--uv-compile", REPO_TEST1), id="update-single"),
+ pytest.param(("update", "--uv-compile", "all"), id="update-all"),
+ pytest.param(("fix", "--uv-compile", REPO_TEST1), id="fix-single"),
+ pytest.param(("fix", "--uv-compile", "all"), id="fix-all"),
+ pytest.param(("restore-dependencies", "--uv-compile"), id="restore-dependencies"),
+ ],
+ )
+ def test_verb_with_uv_compile_runs_resolver(self, cm_args):
+ """Every --uv-compile-aware verb triggers dependency resolution."""
_run_cm_cli("install", REPO_TEST1)
assert _pack_exists(PACK_TEST1)
- r = _run_cm_cli("update", "--uv-compile", REPO_TEST1)
- combined = r.stdout + r.stderr
-
- assert "Resolving dependencies" in combined
-
- def test_update_all_with_uv_compile(self):
- """update all --uv-compile runs uv-compile after updating."""
- _run_cm_cli("install", REPO_TEST1)
- assert _pack_exists(PACK_TEST1)
-
- r = _run_cm_cli("update", "--uv-compile", "all")
- combined = r.stdout + r.stderr
-
- assert "Resolving dependencies" in combined
-
-
-class TestFix:
- """cm-cli fix --uv-compile"""
-
- def test_fix_single_with_uv_compile(self):
- """Fix an installed pack with --uv-compile."""
- _run_cm_cli("install", REPO_TEST1)
- assert _pack_exists(PACK_TEST1)
-
- r = _run_cm_cli("fix", "--uv-compile", REPO_TEST1)
- combined = r.stdout + r.stderr
-
- assert "Resolving dependencies" in combined
-
- def test_fix_all_with_uv_compile(self):
- """fix all --uv-compile runs uv-compile after fixing."""
- _run_cm_cli("install", REPO_TEST1)
- assert _pack_exists(PACK_TEST1)
-
- r = _run_cm_cli("fix", "--uv-compile", "all")
+ r = _run_cm_cli(*cm_args)
combined = r.stdout + r.stderr
assert "Resolving dependencies" in combined
@@ -256,14 +235,41 @@ class TestFix:
class TestUvCompileStandalone:
"""cm-cli uv-sync (standalone command, formerly uv-compile)"""
- def test_uv_compile_no_packs(self):
- """uv-compile with no node packs → 'No custom node packs found'."""
+ def test_uv_compile_no_test_packs_exits_zero(self):
+ """uv-sync without test packs must exit rc==0 (clean success).
+
+ WI-OO Item 2 (bloat dev:ci-008 B5): split from the previous OR-fallback
+ test. This half pins the exit-code contract.
+ """
+ r = _run_cm_cli("uv-sync")
+ assert r.returncode == 0, (
+ f"uv-sync should exit 0 when no test packs are installed; "
+ f"got rc={r.returncode}. Output: {(r.stdout + r.stderr)[:500]!r}"
+ )
+
+ def test_uv_compile_no_test_packs_emits_signal(self):
+ """uv-sync emits a definitive signal — never silent success.
+
+ WI-OO Item 2 (bloat dev:ci-008 B5): split from the previous OR-fallback
+ test. This half pins the output-signal contract. The emitted marker
+ depends on what's installed in the E2E sandbox at the moment — either
+ 'No custom node packs' (empty tree with no resolvable requirements) or
+ 'Resolved' (non-empty tree with successful resolution). Asserting the
+ disjunction here is narrower than the original OR (which also accepted
+ rc==0 with completely silent output); this test requires an actual
+ human-readable marker in the output stream.
+ """
r = _run_cm_cli("uv-sync")
combined = r.stdout + r.stderr
-
- # Only ComfyUI-Manager exists (no requirements.txt in it normally)
- # so either "No custom node packs found" or resolves 0
- assert r.returncode == 0 or "No custom node packs" in combined
+ # Precondition: exit success is verified by the sibling test above.
+ assert r.returncode == 0, f"Precondition failed: uv-sync rc={r.returncode}"
+ empty_marker = "No custom node packs" in combined
+ resolved_marker = "Resolved" in combined
+ assert empty_marker or resolved_marker, (
+ f"Expected 'No custom node packs' (empty tree) or 'Resolved' "
+ f"(non-empty tree) marker; output was silent or unrecognized: "
+ f"{combined[:500]!r}"
+ )
def test_uv_compile_with_packs(self):
"""uv-compile after installing test pack → resolves."""
@@ -276,33 +282,6 @@ class TestUvCompileStandalone:
assert "Resolving dependencies" in combined
assert "Resolved" in combined
- def test_uv_compile_conflict_attribution(self):
- """uv-compile with conflicting packs → shows attribution."""
- _run_cm_cli("install", REPO_TEST1)
- _run_cm_cli("install", REPO_TEST2)
-
- r = _run_cm_cli("uv-sync")
- combined = r.stdout + r.stderr
-
- assert r.returncode != 0
- assert "Conflicting packages (by node pack):" in combined
- assert PACK_TEST1 in combined
- assert PACK_TEST2 in combined
-
-
-class TestRestoreDependencies:
- """cm-cli restore-dependencies --uv-compile"""
-
- def test_restore_dependencies_with_uv_compile(self):
- """restore-dependencies --uv-compile runs resolver after restore."""
- _run_cm_cli("install", REPO_TEST1)
- assert _pack_exists(PACK_TEST1)
-
- r = _run_cm_cli("restore-dependencies", "--uv-compile")
- combined = r.stdout + r.stderr
-
- assert "Resolving dependencies" in combined
-
class TestConflictAttributionDetail:
"""Verify conflict attribution output details."""
diff --git a/tests/e2e/scripts/setup_e2e_env.sh b/tests/e2e/scripts/setup_e2e_env.sh
index 64faf637..fcc4bf44 100755
--- a/tests/e2e/scripts/setup_e2e_env.sh
+++ b/tests/e2e/scripts/setup_e2e_env.sh
@@ -202,9 +202,14 @@ uv pip install \
-r "$E2E_ROOT/comfyui/requirements.txt" \
--extra-index-url "$PYTORCH_CPU_INDEX"
-# Step 4: Install ComfyUI-Manager (non-editable, production-like)
-log "Step 4/8: Installing ComfyUI-Manager..."
-uv pip install --python "$VENV_PY" "$MANAGER_ROOT"
+# Step 4: Install ComfyUI-Manager (editable — venv tracks workspace edits)
+# Editable install prevents silent drift between the workspace source and the
+# installed package: any change to comfyui_manager/** is visible to E2E
+# immediately without re-running this script. The 2026-04-18 junk_value-rejection
+# regression (surfaced in WI-E/WI-G, root-caused in WI-I) was masked for weeks by
+# a non-editable snapshot — this flag closes that failure mode.
+log "Step 4/8: Installing ComfyUI-Manager (editable)..."
+uv pip install --python "$VENV_PY" -e "$MANAGER_ROOT"
# Step 5: Create symlink for custom_nodes discovery
log "Step 5/8: Creating custom_nodes symlink..."
diff --git a/tests/e2e/scripts/start_comfyui.sh b/tests/e2e/scripts/start_comfyui.sh
index f97b8b79..ef125198 100755
--- a/tests/e2e/scripts/start_comfyui.sh
+++ b/tests/e2e/scripts/start_comfyui.sh
@@ -6,9 +6,15 @@
# Claude's Bash tool — the call returns only when ComfyUI is accepting requests.
#
# Input env vars:
-# E2E_ROOT — (required) path to E2E environment from setup_e2e_env.sh
-# PORT — ComfyUI listen port (default: 8199)
-# TIMEOUT — max seconds to wait for readiness (default: 120)
+# E2E_ROOT — (required) path to E2E environment from setup_e2e_env.sh
+# PORT — ComfyUI listen port (default: 8199)
+# TIMEOUT — max seconds to wait for readiness (default: 120)
+# ENABLE_LEGACY_UI — if set to "1"/"true"/"yes", add --enable-manager-legacy-ui
+# (for Playwright legacy-UI tests; pytest suites should
+# leave this unset because glob and legacy manager_server
+# modules are mutex-loaded and several pytest suites hit
+# glob-only v2 endpoints such as /v2/manager/queue/task).
+# The convenience wrapper start_comfyui_legacy.sh sets it.
#
# Output (last line on success):
# COMFYUI_PID= PORT=
@@ -36,7 +42,11 @@ PY="$E2E_ROOT/venv/bin/python"
COMFY_DIR="$E2E_ROOT/comfyui"
LOG_DIR="$E2E_ROOT/logs"
LOG_FILE="$LOG_DIR/comfyui.log"
-PID_FILE="$LOG_DIR/comfyui.pid"
+# Port-namespaced PID file — prevents concurrent tests on different ports
+# (e.g., teammate running pytest on 8199 while Playwright runs on 8200)
+# from overwriting each other's PID, which would cause stop_comfyui.sh to
+# kill the wrong process (observed in WI-CC: 8200 stop killed 8199 PID 2979469).
+PID_FILE="$LOG_DIR/comfyui.${PORT}.pid"
mkdir -p "$LOG_DIR"
@@ -69,12 +79,30 @@ log "Starting ComfyUI on port $PORT..."
# Create empty log file (ensures tail -f works from the start)
: > "$LOG_FILE"
-# Launch with unbuffered Python output so log lines appear immediately
+# Assemble manager flags. ENABLE_LEGACY_UI toggles --enable-manager-legacy-ui
+# without forcing every caller to care — pytest leaves it unset (glob mode),
+# start_comfyui_legacy.sh sets it (legacy UI mode).
+MANAGER_FLAGS=(--enable-manager)
+case "${ENABLE_LEGACY_UI:-}" in
+ 1|true|TRUE|yes|YES)
+ MANAGER_FLAGS+=(--enable-manager-legacy-ui)
+ log "Legacy UI enabled via ENABLE_LEGACY_UI=${ENABLE_LEGACY_UI}"
+ ;;
+esac
+
+# Launch with unbuffered Python output so log lines appear immediately.
+# COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS=1 is the WI-WW safety belt:
+# any install/update/reinstall path that would normally run
+# `pip install -r manager_requirements.txt` becomes a no-op log line.
+# Essential for WI-YY real-E2E tests that trigger install/update flows
+# — without it, a real update_comfyui task could run unbounded pip
+# installs on the test venv.
PYTHONUNBUFFERED=1 \
HOME="$E2E_ROOT/home" \
+COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS=1 \
nohup "$PY" "$COMFY_DIR/main.py" \
--cpu \
- --enable-manager \
+ "${MANAGER_FLAGS[@]}" \
--port "$PORT" \
> "$LOG_FILE" 2>&1 &
COMFYUI_PID=$!
diff --git a/tests/e2e/scripts/start_comfyui_legacy.sh b/tests/e2e/scripts/start_comfyui_legacy.sh
new file mode 100755
index 00000000..ad075526
--- /dev/null
+++ b/tests/e2e/scripts/start_comfyui_legacy.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+# start_comfyui_legacy.sh — Thin wrapper that launches ComfyUI in LEGACY UI mode.
+#
+# Delegates to start_comfyui.sh with ENABLE_LEGACY_UI=1. The underlying script
+# translates that into --enable-manager-legacy-ui on main.py, which registers
+# the legacy Manager dialog frontend and routes POST /v2/manager/queue/* to
+# the legacy handler module (legacy/manager_server.py).
+#
+# Use this wrapper for Playwright legacy-UI tests (tests/playwright/legacy-ui-*).
+# Do NOT use for pytest suites that hit glob-only v2 endpoints (e.g.
+# /v2/manager/queue/task), because glob/manager_server and legacy/manager_server
+# are mutex-loaded — see comfyui_manager/__init__.py::start().
+#
+# Input env vars (forwarded to start_comfyui.sh):
+# E2E_ROOT — required
+# PORT — default 8199
+# TIMEOUT — default 120
+#
+# Output (last line on success, inherited from start_comfyui.sh):
+# COMFYUI_PID= PORT=
+#
+# Exit: 0=ready, 1=timeout/failure
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+exec env ENABLE_LEGACY_UI=1 bash "$SCRIPT_DIR/start_comfyui.sh" "$@"
diff --git a/tests/e2e/scripts/start_comfyui_permissive.sh b/tests/e2e/scripts/start_comfyui_permissive.sh
new file mode 100755
index 00000000..99ad95e1
--- /dev/null
+++ b/tests/e2e/scripts/start_comfyui_permissive.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+# start_comfyui_permissive.sh — Launch ComfyUI in PERMISSIVE security mode
+# for WI-YY real-E2E tests of `high+` gated endpoints.
+#
+# Patches `security_level = normal-` into the manager config.ini before
+# launching (with backup of original value), then delegates to
+# start_comfyui.sh with ENABLE_LEGACY_UI=1 (wi-037 and wi-038 are
+# legacy-only routes). The corresponding stop_comfyui.sh teardown should
+# be paired with restore_config() inside the pytest fixture — this
+# script does NOT restore on its own, so fixture teardown MUST cleanup.
+#
+# Why permissive mode is needed:
+# Three endpoints check is_allowed_security_level('high+')
+# (security_utils.py:20-26): at is_local_mode=True (127.0.0.1 listen)
+# the gate requires security_level ∈ {weak, normal-}. Default
+# `security_level = normal` fails, so the POST returns 403.
+# - wi-014 POST /v2/comfyui_manager/comfyui_switch_version
+# - wi-037 POST /v2/customnode/install/git_url
+# - wi-038 POST /v2/customnode/install/pip
+# Setting security_level = normal- allows real E2E execution of these
+# endpoints with fixed, trusted inputs (never test-input-derived URLs).
+#
+# SECURITY NOTE:
+# The endpoints are gated at high+ because they execute arbitrary remote
+# code (git clone / pip install / version switch). This harness opens
+# the gate ONLY in the E2E sandbox with HARDCODED trusted inputs
+# (ComfyUI_examples repo; text-unidecode package). Never use with
+# user-input-derived inputs — the 403 contract at default security is
+# the positive-path security behavior we want to preserve in production.
+#
+# Input env vars (forwarded to start_comfyui.sh):
+# E2E_ROOT — required
+# PORT — default 8199
+# TIMEOUT — default 120
+#
+# Output (last line on success, inherited from start_comfyui.sh):
+# COMFYUI_PID= PORT=
+#
+# Exit: 0=ready, 1=timeout/failure
+#
+# Side effect: $E2E_ROOT/comfyui/user/__manager/config.ini gets
+# `security_level = normal-`. The original value is preserved at
+# config.ini.before-permissive for the fixture to restore on teardown.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+[[ -n "${E2E_ROOT:-}" ]] || { echo "[start_comfyui_permissive] ERROR: E2E_ROOT is not set" >&2; exit 1; }
+
+CONFIG="$E2E_ROOT/comfyui/user/__manager/config.ini"
+BACKUP="$CONFIG.before-permissive"
+
+[[ -f "$CONFIG" ]] || { echo "[start_comfyui_permissive] ERROR: config not found at $CONFIG" >&2; exit 1; }
+
+# Preserve original config so the fixture can restore it on teardown.
+# If a previous run left a backup, do NOT overwrite.
+if [[ ! -f "$BACKUP" ]]; then
+ cp "$CONFIG" "$BACKUP"
+ echo "[start_comfyui_permissive] Backed up original config to $BACKUP"
+fi
+
+# Patch security_level to normal- (idempotent).
+if grep -qE '^security_level\s*=' "$CONFIG"; then
+ sed -i -E 's/^security_level\s*=.*/security_level = normal-/' "$CONFIG"
+else
+ sed -i -E '/^\[default\]/a security_level = normal-' "$CONFIG"
+fi
+echo "[start_comfyui_permissive] Patched security_level = normal- in $CONFIG"
+
+exec env ENABLE_LEGACY_UI=1 bash "$SCRIPT_DIR/start_comfyui.sh" "$@"
diff --git a/tests/e2e/scripts/start_comfyui_strict.sh b/tests/e2e/scripts/start_comfyui_strict.sh
new file mode 100755
index 00000000..281b718e
--- /dev/null
+++ b/tests/e2e/scripts/start_comfyui_strict.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# start_comfyui_strict.sh — Launch ComfyUI in STRICT security mode for SECGATE tests.
+#
+# Patches `security_level = strong` into the manager config.ini before launching
+# (with backup of original value), then delegates to start_comfyui.sh. The
+# corresponding stop_comfyui.sh teardown should be paired with restore_config()
+# inside the pytest fixture (this script does NOT restore on its own — restore
+# happens at fixture teardown to keep this wrapper symmetric with
+# start_comfyui_legacy.sh).
+#
+# Why strict mode is needed:
+# Several state-changing endpoints (snapshot/remove [middle], snapshot/restore
+# [middle+], reboot [middle], queue/update_all [middle+]) check
+# is_allowed_security_level(). At the default `security_level = normal`
+# (and is_local_mode = True since we listen on 127.0.0.1), middle and middle+
+# operations are ALLOWED — so the 403 path is unreachable. Setting
+# security_level = strong puts NORMAL out of the allowed sets and makes the
+# 403 contract observable.
+#
+# At-or-below `normal` configurations cannot test the 403 path for these gates;
+# `strong` is required.
+#
+# Input env vars (forwarded to start_comfyui.sh):
+# E2E_ROOT — required
+# PORT — default 8199
+# TIMEOUT — default 120
+#
+# Output (last line on success, inherited from start_comfyui.sh):
+# COMFYUI_PID= PORT=
+#
+# Exit: 0=ready, 1=timeout/failure
+#
+# Side effect: $E2E_ROOT/comfyui/user/__manager/config.ini gets
+# `security_level = strong`. The original value is preserved at
+# config.ini.before-strict for the fixture to restore on teardown.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+[[ -n "${E2E_ROOT:-}" ]] || { echo "[start_comfyui_strict] ERROR: E2E_ROOT is not set" >&2; exit 1; }
+
+CONFIG="$E2E_ROOT/comfyui/user/__manager/config.ini"
+BACKUP="$CONFIG.before-strict"
+
+[[ -f "$CONFIG" ]] || { echo "[start_comfyui_strict] ERROR: config not found at $CONFIG" >&2; exit 1; }
+
+# Preserve original config so the fixture can restore it on teardown.
+# If a previous run left a backup, do NOT overwrite (preserves the *true*
+# pre-strict baseline across crashed test runs).
+if [[ ! -f "$BACKUP" ]]; then
+ cp "$CONFIG" "$BACKUP"
+ echo "[start_comfyui_strict] Backed up original config to $BACKUP"
+fi
+
+# Patch security_level to strong (idempotent — works whether the line is
+# already `strong`, `normal`, or any other value).
+if grep -qE '^security_level\s*=' "$CONFIG"; then
+ sed -i -E 's/^security_level\s*=.*/security_level = strong/' "$CONFIG"
+else
+ # security_level missing entirely (unusual) — append under [default]
+ sed -i -E '/^\[default\]/a security_level = strong' "$CONFIG"
+fi
+echo "[start_comfyui_strict] Patched security_level = strong in $CONFIG"
+
+exec bash "$SCRIPT_DIR/start_comfyui.sh" "$@"
diff --git a/tests/e2e/scripts/stop_comfyui.sh b/tests/e2e/scripts/stop_comfyui.sh
index d110c44c..8574bf3d 100755
--- a/tests/e2e/scripts/stop_comfyui.sh
+++ b/tests/e2e/scripts/stop_comfyui.sh
@@ -23,7 +23,15 @@ die() { err "$@"; exit 1; }
# --- Validate ---
[[ -n "${E2E_ROOT:-}" ]] || die "E2E_ROOT is not set"
-PID_FILE="$E2E_ROOT/logs/comfyui.pid"
+PID_FILE="$E2E_ROOT/logs/comfyui.${PORT}.pid"
+# Legacy single-port path — warn if encountered so concurrent tests on
+# different ports don't overwrite each other's PID file (observed during
+# WI-CC: stop_comfyui.sh on port 8200 accidentally killed another teammate's
+# PID 2979469 running on port 8199 because both shared $E2E_ROOT/logs/comfyui.pid).
+LEGACY_PID_FILE="$E2E_ROOT/logs/comfyui.pid"
+if [[ -f "$LEGACY_PID_FILE" ]] && [[ ! -f "$PID_FILE" ]]; then
+ log "WARN: found legacy unported PID file $LEGACY_PID_FILE but no ${PID_FILE}. Cross-port risk — ignoring legacy file."
+fi
# --- Read PID ---
COMFYUI_PID=""
diff --git a/tests/e2e/test_e2e_config_api.py b/tests/e2e/test_e2e_config_api.py
new file mode 100644
index 00000000..fd9bbf0b
--- /dev/null
+++ b/tests/e2e/test_e2e_config_api.py
@@ -0,0 +1,720 @@
+"""E2E tests for ComfyUI Manager configuration API endpoints.
+
+Tests the dual GET (read) + POST (write) configuration endpoints:
+- /v2/manager/db_mode
+- /v2/manager/policy/update
+- /v2/manager/channel_url_list
+
+Each write test reads the original value, sets a new value via POST,
+reads back via GET to verify, then restores the original to ensure
+idempotency.
+
+Requires a pre-built E2E environment (from setup_e2e_env.sh).
+Set E2E_ROOT env var to point at it, or the tests will be skipped.
+
+Usage:
+ E2E_ROOT=/tmp/e2e_full_test pytest tests/e2e/test_e2e_config_api.py -v
+"""
+
+from __future__ import annotations
+
+import hashlib
+import os
+import subprocess
+import time
+
+import pytest
+import requests
+
+E2E_ROOT = os.environ.get("E2E_ROOT", "")
+COMFYUI_PATH = os.path.join(E2E_ROOT, "comfyui") if E2E_ROOT else ""
+MANAGER_CONFIG_INI = (
+ os.path.join(COMFYUI_PATH, "user", "__manager", "config.ini")
+ if COMFYUI_PATH
+ else ""
+)
+SCRIPTS_DIR = os.path.join(
+ os.path.dirname(os.path.abspath(__file__)), "scripts"
+)
+
+PORT = 8199
+BASE_URL = f"http://127.0.0.1:{PORT}"
+
+# Reboot recovery window (same as test_e2e_system_info TestReboot)
+REBOOT_TIMEOUT = 60.0
+REBOOT_INTERVAL = 2.0
+
+
+def _read_config_ini_value(key: str) -> str | None:
+ """Read a value directly from the manager config.ini (for disk-level assertion)."""
+ if not os.path.isfile(MANAGER_CONFIG_INI):
+ return None
+ import configparser
+ cp = configparser.ConfigParser()
+ cp.read(MANAGER_CONFIG_INI)
+ for section in cp.sections():
+ if cp.has_option(section, key):
+ return cp.get(section, key)
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Disk-persistence helpers (Stage2 WI-E PoC — reusable by the WEAK-rated 5
+# tests listed in reports/e2e_verification_audit.md §4 once a follow-up WI
+# propagates them). Callable with `None` expected value for "absent" checks.
+# ---------------------------------------------------------------------------
+
+def _assert_config_ini_contains(key: str, expected: str | None) -> None:
+ """Assert the manager config.ini has ``key`` set to ``expected`` on disk.
+
+ Interface:
+ key — config.ini option name (searched across all sections)
+ expected — the exact string value the key must hold, OR None to
+ assert the key is absent / config.ini missing.
+
+ Raises AssertionError with pre/post hashes + on-disk value for diagnosis.
+ The helper verifies persistence independently from the HTTP API —
+ catches no-op handlers that return 200 without writing to disk.
+ """
+ actual = _read_config_ini_value(key)
+ if expected is None:
+ assert actual is None, (
+ f"config.ini[{key}] expected absent, found {actual!r}"
+ )
+ return
+
+ # For diagnosis on failure, capture a hash of the file so reviewers can
+ # tell whether ANY mutation happened vs. the wrong-value case.
+ file_hash = ""
+ if os.path.isfile(MANAGER_CONFIG_INI):
+ with open(MANAGER_CONFIG_INI, "rb") as fh:
+ file_hash = hashlib.sha256(fh.read()).hexdigest()[:12]
+ assert actual == expected, (
+ f"config.ini[{key}] disk mismatch: expected {expected!r}, "
+ f"got {actual!r} (file sha256[:12]={file_hash}, path={MANAGER_CONFIG_INI})"
+ )
+
+
+def _assert_config_ini_persists_across_reboot(
+ key: str,
+ expected: str,
+ timeout: float = REBOOT_TIMEOUT,
+) -> None:
+ """Assert ``key=expected`` survives a ComfyUI reboot on disk AND via API.
+
+ Interface:
+ key — config.ini option name
+ expected — value the key must still hold post-reboot
+ timeout — max seconds to wait for the server to come back healthy
+
+ Behavior:
+ 1. Issue POST /v2/manager/reboot (tolerates ConnectionError mid-
+ response — server drops the connection during shutdown).
+ 2. Poll /system_stats until the server answers 200 or timeout.
+ 3. Re-read config.ini from disk → must equal ``expected``.
+ 4. Re-read the value via the appropriate GET endpoint (derived
+ from the key) → must equal ``expected`` as well.
+
+ Note: This helper WILL replace the ComfyUI process. Any fixture that
+ pins a PID should treat the post-reboot PID as unknown. The
+ module-scoped ``comfyui`` fixture's teardown calls stop_comfyui.sh,
+ which kills by port rather than stored PID, so teardown continues
+ to work.
+ """
+ try:
+ resp = requests.post(f"{BASE_URL}/v2/manager/reboot", timeout=10)
+ if resp.status_code == 403:
+ pytest.skip(
+ "reboot denied by security policy "
+ "(E2E_SECURITY_LEVEL does not permit 'middle')"
+ )
+ assert resp.status_code == 200, (
+ f"reboot returned unexpected status {resp.status_code}: {resp.text}"
+ )
+ except requests.ConnectionError:
+ # Server closed the socket mid-reboot response. Expected on some
+ # platforms; treat as success-so-far and rely on healthcheck below.
+ pass
+
+ time.sleep(2) # grace period for shutdown
+
+ deadline = time.monotonic() + timeout
+ recovered = False
+ while time.monotonic() < deadline:
+ try:
+ r = requests.get(f"{BASE_URL}/system_stats", timeout=5)
+ if r.status_code == 200:
+ recovered = True
+ break
+ except (requests.ConnectionError, requests.Timeout):
+ pass
+ time.sleep(REBOOT_INTERVAL)
+ assert recovered, (
+ f"server did not recover within {timeout}s after reboot — "
+ f"cannot verify {key!r} persistence"
+ )
+
+ # Disk side: config.ini preserved the value.
+ _assert_config_ini_contains(key, expected)
+
+ # API side: the restarted server re-read config.ini and serves the value.
+ api_path = {
+ "db_mode": "/v2/manager/db_mode",
+ "update_policy": "/v2/manager/policy/update",
+ "channel_url": "/v2/manager/channel_url_list",
+ }.get(key)
+ if api_path is None:
+ return # caller responsible for API verification for non-standard keys
+
+ api_resp = requests.get(f"{BASE_URL}{api_path}", timeout=10)
+ api_resp.raise_for_status()
+ if api_path.endswith("channel_url_list"):
+ # channel_url asymmetry: config.ini stores the full URL, API returns the
+ # reverse-mapped channel NAME. When caller passes the URL as `expected`,
+ # translate URL→NAME via the API's own `list` (`name::url` entries).
+ # Callers passing a NAME (legacy path) continue to work unchanged.
+ body = api_resp.json()
+ actual_api = body.get("selected")
+ expected_to_compare = expected
+ if isinstance(expected, str) and "://" in expected:
+ for entry in body.get("list", []):
+ if isinstance(entry, str) and "::" in entry:
+ name, url = entry.split("::", 1)
+ if url == expected:
+ expected_to_compare = name
+ break
+ else:
+ # URL not in the known list → server reports "custom"
+ expected_to_compare = "custom"
+ else:
+ actual_api = api_resp.text
+ expected_to_compare = expected
+ assert actual_api == expected_to_compare, (
+ f"post-reboot API mismatch for {key}: "
+ f"config.ini has {expected!r} but GET {api_path} returned {actual_api!r}"
+ )
+
+pytestmark = pytest.mark.skipif(
+ not E2E_ROOT
+ or not os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete")),
+ reason="E2E_ROOT not set or E2E environment not ready (run setup_e2e_env.sh first)",
+)
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _start_comfyui() -> int:
+ """Start ComfyUI and return its PID."""
+ env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
+ r = subprocess.run(
+ ["bash", os.path.join(SCRIPTS_DIR, "start_comfyui.sh")],
+ capture_output=True,
+ text=True,
+ timeout=180,
+ env=env,
+ )
+ if r.returncode != 0:
+ raise RuntimeError(f"Failed to start ComfyUI:\n{r.stderr}")
+ for part in r.stdout.strip().split():
+ if part.startswith("COMFYUI_PID="):
+ return int(part.split("=")[1])
+ raise RuntimeError(f"Could not parse PID from start_comfyui output:\n{r.stdout}")
+
+
+def _stop_comfyui():
+ """Stop ComfyUI."""
+ env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
+ subprocess.run(
+ ["bash", os.path.join(SCRIPTS_DIR, "stop_comfyui.sh")],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ env=env,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+@pytest.fixture(scope="module")
+def comfyui():
+ """Start ComfyUI once for the module, stop after all tests."""
+ pid = _start_comfyui()
+ yield pid
+ _stop_comfyui()
+
+
+@pytest.fixture(scope="module", autouse=True)
+def config_snapshot(comfyui):
+ """Snapshot config values at module start, restore at module teardown.
+
+ Guards against state leak if any in-module test fails mid-mutation
+ (leaving config.ini in a corrupted/unexpected state that would poison
+ "original" reads in subsequent tests).
+ """
+ snapshot = {
+ "db_mode": requests.get(f"{BASE_URL}/v2/manager/db_mode", timeout=10).text,
+ "update_policy": requests.get(
+ f"{BASE_URL}/v2/manager/policy/update", timeout=10
+ ).text,
+ "channel_selected": requests.get(
+ f"{BASE_URL}/v2/manager/channel_url_list", timeout=10
+ ).json().get("selected"),
+ }
+ yield snapshot
+ # Best-effort restore; log but don't fail if restore hits issues
+ for path, key, value in (
+ ("/v2/manager/db_mode", "db_mode", snapshot["db_mode"]),
+ ("/v2/manager/policy/update", "update_policy", snapshot["update_policy"]),
+ ("/v2/manager/channel_url_list", "channel_selected", snapshot["channel_selected"]),
+ ):
+ try:
+ resp = requests.post(
+ f"{BASE_URL}{path}",
+ json={"value": value},
+ timeout=10,
+ )
+ if not resp.ok:
+ print(
+ f"[config_snapshot] restore FAILED for {key}={value!r}: "
+ f"status={resp.status_code}",
+ )
+ except Exception as e: # noqa: BLE001
+ print(f"[config_snapshot] restore EXCEPTION for {key}: {e}")
+
+
+# ---------------------------------------------------------------------------
+# Tests — db_mode
+# ---------------------------------------------------------------------------
+
+class TestConfigDbMode:
+ """Test GET/POST /v2/manager/db_mode round-trip."""
+
+ DB_MODE_VALUES = ("cache", "channel", "local", "remote")
+
+ def test_read_db_mode(self, comfyui):
+ """GET /v2/manager/db_mode returns a valid db mode string."""
+ resp = requests.get(f"{BASE_URL}/v2/manager/db_mode", timeout=10)
+ assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
+ assert resp.text in self.DB_MODE_VALUES, (
+ f"Unexpected db_mode value: {resp.text!r}"
+ )
+
+ def test_set_and_restore_db_mode(self, comfyui):
+ """POST sets db_mode, GET reads it back, disk + reboot persistence proven, then original is restored.
+
+ Stage2 WI-E PoC — demonstrates the two disk-persistence helpers:
+ * _assert_config_ini_contains → disk-level verification
+ * _assert_config_ini_persists_across_reboot → restart-survival verification
+
+ This test is the first of the six §4 WEAK round-trip tests (per
+ reports/e2e_verification_audit.md) to gain independent disk state
+ assertions. Propagation to the other five is tracked as a follow-up
+ WI — see the completion report accompanying this change.
+ """
+ # Read original — baseline for both round-trip and restore verification.
+ resp = requests.get(f"{BASE_URL}/v2/manager/db_mode", timeout=10)
+ resp.raise_for_status()
+ original = resp.text
+
+ # Pick a different value so the mutation is observable.
+ new_mode = "local" if original != "local" else "remote"
+
+ # Snapshot config.ini BEFORE mutation — reviewers can tell from
+ # pre_hash vs. post_hash whether the POST actually touched the file.
+ pre_hash = (
+ hashlib.sha256(open(MANAGER_CONFIG_INI, "rb").read()).hexdigest()[:12]
+ if os.path.isfile(MANAGER_CONFIG_INI)
+ else ""
+ )
+
+ try:
+ # Set new value via POST.
+ resp = requests.post(
+ f"{BASE_URL}/v2/manager/db_mode",
+ json={"value": new_mode},
+ timeout=10,
+ )
+ assert resp.status_code == 200, (
+ f"POST db_mode failed: {resp.status_code} {resp.text}"
+ )
+
+ # (1) API round-trip — the existing WEAK check.
+ resp = requests.get(f"{BASE_URL}/v2/manager/db_mode", timeout=10)
+ resp.raise_for_status()
+ assert resp.text == new_mode, (
+ f"db_mode not updated: expected {new_mode!r}, got {resp.text!r}"
+ )
+
+ # (2) Disk persistence — helper #1 asserts config.ini on disk
+ # reflects the new value. This defeats a "no-op handler that
+ # caches in memory but never writes" regression.
+ _assert_config_ini_contains("db_mode", new_mode)
+
+ # Capture post-POST hash — assertion diagnostic only; failing the
+ # above already reports the mismatch. Required for AC-5c evidence.
+ post_hash = (
+ hashlib.sha256(open(MANAGER_CONFIG_INI, "rb").read()).hexdigest()[:12]
+ if os.path.isfile(MANAGER_CONFIG_INI)
+ else ""
+ )
+ assert pre_hash != post_hash or pre_hash == "", (
+ f"config.ini hash unchanged after POST: {pre_hash}; "
+ f"server may be caching without writing to disk"
+ )
+
+ # (3) Reboot persistence — helper #2 restarts ComfyUI and
+ # re-verifies both disk and API still report new_mode. This
+ # defeats a "value only in memory, lost on restart" regression.
+ # NOTE: this helper replaces the ComfyUI process; downstream
+ # tests in this module will hit the fresh instance. The
+ # module-scoped `comfyui` fixture's teardown kills by port, so
+ # cleanup still works regardless of the new PID.
+ _assert_config_ini_persists_across_reboot("db_mode", new_mode)
+ finally:
+ # Restore original value on whichever server instance is live
+ # (pre- or post-reboot — the restored value also persists to
+ # disk via the restarted handler).
+ requests.post(
+ f"{BASE_URL}/v2/manager/db_mode",
+ json={"value": original},
+ timeout=10,
+ )
+
+ # Verify restoration end-to-end: API + disk.
+ resp = requests.get(f"{BASE_URL}/v2/manager/db_mode", timeout=10)
+ resp.raise_for_status()
+ assert resp.text == original, (
+ f"Failed to restore db_mode: expected {original!r}, got {resp.text!r}"
+ )
+ _assert_config_ini_contains("db_mode", original)
+
+
+
+# ---------------------------------------------------------------------------
+# Tests — update policy
+# ---------------------------------------------------------------------------
+
+class TestConfigUpdatePolicy:
+ """Test GET/POST /v2/manager/policy/update round-trip."""
+
+ POLICY_VALUES = ("stable", "stable-comfyui", "nightly", "nightly-comfyui")
+
+ def test_read_update_policy(self, comfyui):
+ """GET /v2/manager/policy/update returns a valid policy string."""
+ resp = requests.get(
+ f"{BASE_URL}/v2/manager/policy/update", timeout=10
+ )
+ assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
+ assert resp.text in self.POLICY_VALUES, (
+ f"Unexpected policy value: {resp.text!r}"
+ )
+
+ def test_set_and_restore_update_policy(self, comfyui):
+ """POST sets update policy, disk + reboot persistence proven, then original restored (WI-G).
+
+ WI-G full-helper application (mirrors test_set_and_restore_db_mode PoC):
+ * _assert_config_ini_contains → disk-level verification
+ * _assert_config_ini_persists_across_reboot → restart-survival verification
+ """
+ # Read original — baseline for both round-trip and restore verification.
+ resp = requests.get(
+ f"{BASE_URL}/v2/manager/policy/update", timeout=10
+ )
+ resp.raise_for_status()
+ original = resp.text
+
+ # Pick a different value
+ new_policy = "nightly" if original != "nightly" else "stable"
+
+ try:
+ # Set new value via POST
+ resp = requests.post(
+ f"{BASE_URL}/v2/manager/policy/update",
+ json={"value": new_policy},
+ timeout=10,
+ )
+ assert resp.status_code == 200, (
+ f"POST policy/update failed: {resp.status_code} {resp.text}"
+ )
+
+ # (1) API round-trip — existing WEAK check retained.
+ resp = requests.get(
+ f"{BASE_URL}/v2/manager/policy/update", timeout=10
+ )
+ resp.raise_for_status()
+ assert resp.text == new_policy, (
+ f"Policy not updated: expected {new_policy!r}, got {resp.text!r}"
+ )
+
+ # (2) Disk persistence — helper #1 proves config.ini on disk was mutated.
+ _assert_config_ini_contains("update_policy", new_policy)
+
+ # (3) Reboot persistence — helper #2 proves the value survives a
+ # full ComfyUI restart on both disk and via API.
+ _assert_config_ini_persists_across_reboot("update_policy", new_policy)
+ finally:
+ # Restore original value on whichever server instance is live.
+ requests.post(
+ f"{BASE_URL}/v2/manager/policy/update",
+ json={"value": original},
+ timeout=10,
+ )
+
+ # Verify restoration end-to-end: API + disk.
+ resp = requests.get(
+ f"{BASE_URL}/v2/manager/policy/update", timeout=10
+ )
+ resp.raise_for_status()
+ assert resp.text == original, (
+ f"Failed to restore policy: expected {original!r}, got {resp.text!r}"
+ )
+ _assert_config_ini_contains("update_policy", original)
+
+
+
+# ---------------------------------------------------------------------------
+# Tests — channel_url_list
+# ---------------------------------------------------------------------------
+
+class TestConfigChannelUrlList:
+ """Test GET/POST /v2/manager/channel_url_list round-trip."""
+
+ def test_read_channel_url_list(self, comfyui):
+ """GET /v2/manager/channel_url_list returns {selected, list} structure."""
+ resp = requests.get(
+ f"{BASE_URL}/v2/manager/channel_url_list", timeout=10
+ )
+ assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
+ data = resp.json()
+ assert "selected" in data, "Response missing 'selected' field"
+ assert "list" in data, "Response missing 'list' field"
+ assert isinstance(data["list"], list), (
+ f"'list' should be an array, got {type(data['list']).__name__}"
+ )
+ assert isinstance(data["selected"], str), (
+ f"'selected' should be a string, got {type(data['selected']).__name__}"
+ )
+
+ def test_channel_list_entries_are_name_url_strings(self, comfyui):
+ """Each entry in channel list is a 'name::url' string."""
+ resp = requests.get(
+ f"{BASE_URL}/v2/manager/channel_url_list", timeout=10
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ for i, entry in enumerate(data["list"]):
+ assert isinstance(entry, str), (
+ f"Entry {i} should be a string, got {type(entry).__name__}"
+ )
+ assert "::" in entry, (
+ f"Entry {i} should contain '::' separator: {entry!r}"
+ )
+
+ def test_set_and_restore_channel(self, comfyui):
+ """POST sets channel, disk + reboot persistence proven, then original restored (WI-G).
+
+ WI-G full-helper application. Notes on the channel_url asymmetry:
+ * config.ini stores the full URL under key `channel_url`
+ * GET /channel_url_list returns the NAME (reverse-mapped from URL)
+ * POST /channel_url_list accepts {value: NAME} and maps to URL
+ The helpers resolve URL↔NAME internally when key == "channel_url".
+ """
+ # Read original — capture both NAME (for API round-trip) and URL (for disk checks).
+ resp = requests.get(
+ f"{BASE_URL}/v2/manager/channel_url_list", timeout=10
+ )
+ resp.raise_for_status()
+ original_data = resp.json()
+ original_selected = original_data["selected"]
+ channel_map = {} # name -> url
+ for entry in original_data["list"]:
+ if isinstance(entry, str) and "::" in entry:
+ name, url = entry.split("::", 1)
+ channel_map[name] = url
+ original_url = channel_map.get(original_selected)
+ available_channels = list(channel_map.keys())
+
+ if len(available_channels) < 2:
+ pytest.skip("Only one channel available, cannot test switching")
+
+ # Pick a different channel (name + its URL)
+ new_channel = next(
+ (ch for ch in available_channels if ch != original_selected),
+ None,
+ )
+ if new_channel is None or original_url is None:
+ pytest.skip("No alternative channel found or original URL unresolved")
+ new_channel_url = channel_map[new_channel]
+
+ try:
+ # Set new channel via POST (server maps NAME → URL internally)
+ resp = requests.post(
+ f"{BASE_URL}/v2/manager/channel_url_list",
+ json={"value": new_channel},
+ timeout=10,
+ )
+ assert resp.status_code == 200, (
+ f"POST channel_url_list failed: {resp.status_code} {resp.text}"
+ )
+
+ # (1) API round-trip — existing WEAK check retained (verifies NAME).
+ resp = requests.get(
+ f"{BASE_URL}/v2/manager/channel_url_list", timeout=10
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ assert data["selected"] == new_channel, (
+ f"Channel not updated: expected {new_channel!r}, "
+ f"got {data['selected']!r}"
+ )
+
+ # (2) Disk persistence — helper asserts config.ini holds the URL.
+ _assert_config_ini_contains("channel_url", new_channel_url)
+
+ # (3) Reboot persistence — helper reboots and re-verifies disk URL
+ # + API NAME (internal URL→NAME translation handles the asymmetry).
+ _assert_config_ini_persists_across_reboot("channel_url", new_channel_url)
+ finally:
+ # Restore original channel on whichever server instance is live.
+ requests.post(
+ f"{BASE_URL}/v2/manager/channel_url_list",
+ json={"value": original_selected},
+ timeout=10,
+ )
+
+ # Verify restoration end-to-end: API NAME + disk URL.
+ resp = requests.get(
+ f"{BASE_URL}/v2/manager/channel_url_list", timeout=10
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ assert data["selected"] == original_selected, (
+ f"Failed to restore channel: expected {original_selected!r}, "
+ f"got {data['selected']!r}"
+ )
+ _assert_config_ini_contains("channel_url", original_url)
+
+
+# ---------------------------------------------------------------------------
+# Parametrized consolidations (WI-NN bloat Priority 3)
+# ---------------------------------------------------------------------------
+#
+# Two parametrized tests below consolidate the 6 copy-paste tests that
+# previously lived on the 3 per-endpoint TestConfig* classes:
+# * test_set_*_invalid_body (×3 endpoints) → one parametrized
+# * test_set_*_junk_value_rejected / unknown_name → one parametrized
+#
+# Cluster 1 (roundtrip, set_and_restore_* ×3) remained unparametrized:
+# the channel_url_list case carries URL↔NAME asymmetry that the helpers
+# resolve internally only when `key == "channel_url"`, and the channel-
+# map extraction step has no counterpart in the db_mode/policy bodies.
+# Forcing a single parametrized body produced a ~100-line branch soup;
+# the three per-endpoint tests remain distinct functions for readability.
+
+
+# Config endpoints that accept/reject via {"value": ...} JSON body + config.ini
+# on-disk persistence. Each descriptor supplies the config.ini key AND the
+# junk-value payload; the valid-values whitelist is used to both pick a
+# valid restore target and to sanity-check post-rejection disk state.
+_CONFIG_POST_ENDPOINTS = [
+ pytest.param(
+ "/v2/manager/db_mode",
+ "db_mode",
+ "pwned_junk_value_xyz",
+ ("cache", "channel", "local", "remote"),
+ id="db_mode",
+ ),
+ pytest.param(
+ "/v2/manager/policy/update",
+ "update_policy",
+ "pwned_junk_policy_xyz",
+ ("stable", "stable-comfyui", "nightly", "nightly-comfyui"),
+ id="update_policy",
+ ),
+ pytest.param(
+ "/v2/manager/channel_url_list",
+ "channel_url",
+ "pwned_unknown_channel_xyz",
+ None, # channel uses dynamic whitelist (name→url map); see _read_channel_selected
+ id="channel_url_list",
+ ),
+]
+
+
+def _read_channel_selected() -> str | None:
+ resp = requests.get(f"{BASE_URL}/v2/manager/channel_url_list", timeout=10)
+ if not resp.ok:
+ return None
+ return resp.json().get("selected")
+
+
+class TestConfigPostNegativeContracts:
+ """Parametrized negative-path tests for the 3 config POST endpoints.
+
+ WI-NN Cluster 2 (invalid body) + Cluster 3 (junk value) consolidate the
+ 6 previous copy-paste tests. Each parametrize case exercises one endpoint;
+ the two test functions cover the two negative contracts separately so
+ failures still point at the correct contract.
+ """
+
+ @pytest.mark.parametrize("endpoint,key,_junk,_values", _CONFIG_POST_ENDPOINTS)
+ def test_malformed_body_returns_400(self, comfyui, endpoint, key, _junk, _values):
+ """WI-NN Cluster 2 (teng:ci-003/ci-008/ci-015 B9): malformed JSON → 400 + disk unchanged."""
+ before = _read_config_ini_value(key)
+ resp = requests.post(
+ f"{BASE_URL}{endpoint}",
+ data="not-json",
+ headers={"Content-Type": "application/json"},
+ timeout=10,
+ )
+ assert resp.status_code == 400, (
+ f"Expected 400 for malformed JSON on {endpoint}, got {resp.status_code}"
+ )
+ # Disk-state invariant — malformed POST must not touch config.ini.
+ _assert_config_ini_contains(key, before)
+
+ @pytest.mark.parametrize("endpoint,key,junk,values", _CONFIG_POST_ENDPOINTS)
+ def test_junk_value_rejected(self, comfyui, endpoint, key, junk, values):
+ """WI-NN Cluster 3 (teng:ci-004/ci-009/ci-014 B9): unknown/junk value → 400 + disk/API unchanged.
+
+ For db_mode/policy the whitelist is static and verifiable directly
+ via `_read_config_ini_value`. For channel_url_list the whitelist is
+ dynamic (server-built name→url map), so we compare the API-level
+ `selected` string before/after instead.
+ """
+ # Capture pre-state that the endpoint's own API exposes. Also capture
+ # disk state for the static-whitelist endpoints.
+ pre_disk = _read_config_ini_value(key)
+ pre_api_selected = _read_channel_selected() if key == "channel_url" else None
+
+ resp = requests.post(
+ f"{BASE_URL}{endpoint}",
+ json={"value": junk},
+ timeout=10,
+ )
+ assert resp.status_code == 400, (
+ f"Unknown/junk value on {endpoint} should return 400, got {resp.status_code}"
+ )
+
+ if values is not None:
+ # Static whitelist: on-disk value must still be a whitelisted
+ # value (server did not write junk).
+ post_disk = _read_config_ini_value(key)
+ assert post_disk in values, (
+ f"config.ini {key} corrupted with junk value: {post_disk!r}"
+ )
+ else:
+ # Dynamic whitelist (channel): API-level NAME must be unchanged.
+ post_api_selected = _read_channel_selected()
+ assert pre_api_selected == post_api_selected, (
+ f"{endpoint} selected mutated on invalid request: "
+ f"{pre_api_selected!r} -> {post_api_selected!r}"
+ )
+ # Also check config.ini URL is unchanged (if pre was present).
+ assert _read_config_ini_value(key) == pre_disk, (
+ f"config.ini {key} changed on invalid {endpoint} POST"
+ )
diff --git a/tests/e2e/test_e2e_csrf.py b/tests/e2e/test_e2e_csrf.py
new file mode 100644
index 00000000..6be1b140
--- /dev/null
+++ b/tests/e2e/test_e2e_csrf.py
@@ -0,0 +1,315 @@
+"""E2E tests for the GET-rejection contract on state-changing endpoints.
+
+SCOPE — important clarification:
+This suite verifies ONE specific CSRF mitigation layer: that state-changing
+endpoints reject HTTP GET requests (so that
/ link-click /
+redirect-based cross-origin triggers cannot mutate server state). This is
+the contract established in commit 99caef55 which converted 12+ endpoints
+from GET to POST.
+
+NOT COVERED by this suite:
+- Origin / Referer header validation
+- Same-site cookie enforcement
+- Anti-CSRF token verification
+- Cross-site form POST defense
+
+Those remaining CSRF defenses are handled separately (e.g., via the
+origin_only_middleware at the aiohttp layer) and are the subject of
+other test layers. Do NOT read PASS here as "CSRF fully solved" — read
+it as "the method-conversion contract holds".
+
+Requires a pre-built E2E environment (from setup_e2e_env.sh).
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+
+import pytest
+import requests
+
+E2E_ROOT = os.environ.get("E2E_ROOT", "")
+COMFYUI_PATH = os.path.join(E2E_ROOT, "comfyui") if E2E_ROOT else ""
+SCRIPTS_DIR = os.path.join(
+ os.path.dirname(os.path.abspath(__file__)), "scripts"
+)
+
+PORT = 8199
+BASE_URL = f"http://127.0.0.1:{PORT}"
+
+pytestmark = pytest.mark.skipif(
+ not E2E_ROOT
+ or not os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete")),
+ reason="E2E_ROOT not set or E2E environment not ready",
+)
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _start_comfyui() -> int:
+ env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
+ r = subprocess.run(
+ ["bash", os.path.join(SCRIPTS_DIR, "start_comfyui.sh")],
+ capture_output=True,
+ text=True,
+ timeout=180,
+ env=env,
+ )
+ if r.returncode != 0:
+ raise RuntimeError(f"Failed to start ComfyUI:\n{r.stderr}")
+ for part in r.stdout.strip().split():
+ if part.startswith("COMFYUI_PID="):
+ return int(part.split("=")[1])
+ raise RuntimeError(f"Could not parse PID:\n{r.stdout}")
+
+
+def _stop_comfyui():
+ env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
+ subprocess.run(
+ ["bash", os.path.join(SCRIPTS_DIR, "stop_comfyui.sh")],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ env=env,
+ )
+
+
+@pytest.fixture(scope="module")
+def comfyui():
+ pid = _start_comfyui()
+ yield pid
+ _stop_comfyui()
+
+
+# ---------------------------------------------------------------------------
+# State-changing endpoints that MUST reject GET per CSRF mitigation contract
+# ---------------------------------------------------------------------------
+
+# (method, path, description) — derived from commit 99caef55 scope
+STATE_CHANGING_POST_ENDPOINTS = [
+ ("/v2/manager/queue/start", "start worker"),
+ ("/v2/manager/queue/reset", "reset queue"),
+ ("/v2/manager/queue/update_all", "update all packs"),
+ ("/v2/manager/queue/update_comfyui", "update ComfyUI core"),
+ ("/v2/manager/queue/install_model", "queue model download"),
+ ("/v2/manager/queue/task", "enqueue task"),
+ ("/v2/snapshot/save", "save snapshot"),
+ ("/v2/snapshot/remove", "remove snapshot"),
+ ("/v2/snapshot/restore", "restore snapshot"),
+ ("/v2/manager/reboot", "reboot server"),
+ ("/v2/comfyui_manager/comfyui_switch_version", "switch ComfyUI version"),
+ ("/v2/customnode/import_fail_info", "import fail info"),
+ ("/v2/customnode/import_fail_info_bulk", "bulk import fail info"),
+]
+
+
+class TestStateChangingEndpointsRejectGet:
+ """Every state-changing endpoint MUST reject HTTP GET.
+
+ This is the narrow CSRF-mitigation contract established by the
+ GET→POST conversion (commit 99caef55). It blocks
-tag,
+ link-click, and redirect-based cross-origin triggers. Full origin
+ verification is a separate layer and is NOT tested here.
+ """
+
+ @pytest.mark.parametrize(
+ "path,description",
+ STATE_CHANGING_POST_ENDPOINTS,
+ ids=[p for p, _ in STATE_CHANGING_POST_ENDPOINTS],
+ )
+ def test_get_is_rejected(self, comfyui, path, description):
+ resp = requests.get(
+ f"{BASE_URL}{path}",
+ timeout=10,
+ allow_redirects=False,
+ )
+ # GET must NOT succeed with any 2xx or redirect status on a
+ # state-changing endpoint. Prior assertion had a Python operator-
+ # precedence bug (`A or (X is False)` → dead code). Use explicit
+ # membership check instead.
+ assert resp.status_code not in range(200, 400), (
+ f"CSRF-CONTRACT BYPASS: GET {path} returned {resp.status_code} "
+ f"(2xx/3xx indicates accept or redirect — endpoint must reject): "
+ f"{description}"
+ )
+ # Narrow the accepted rejection statuses to method-not-allowed /
+ # not-found / forbidden / bad-request. Other 4xx/5xx codes are
+ # suspicious and should be investigated.
+ assert resp.status_code in (400, 403, 404, 405), (
+ f"GET {path} returned unexpected status {resp.status_code} "
+ f"(expected 400/403/404/405): {resp.text[:200]}"
+ )
+
+
+class TestCsrfPostWorks:
+ """Sanity check: the POST counterparts actually work (CSRF fix didn't break the API)."""
+
+ def test_queue_reset_post_works(self, comfyui):
+ """POST queue/reset should succeed (the same path rejects GET)."""
+ resp = requests.post(f"{BASE_URL}/v2/manager/queue/reset", timeout=10)
+ assert resp.status_code == 200, (
+ f"POST queue/reset should succeed, got {resp.status_code}: {resp.text[:200]}"
+ )
+
+ def test_snapshot_save_post_works(self, comfyui):
+ """POST snapshot/save should succeed."""
+ resp = requests.post(f"{BASE_URL}/v2/snapshot/save", timeout=30)
+ assert resp.status_code == 200, (
+ f"POST snapshot/save should succeed, got {resp.status_code}: {resp.text[:200]}"
+ )
+ # Cleanup — remove the snapshot we just created
+ list_resp = requests.get(f"{BASE_URL}/v2/snapshot/getlist", timeout=10)
+ if list_resp.ok:
+ items = list_resp.json().get("items", [])
+ if items:
+ requests.post(
+ f"{BASE_URL}/v2/snapshot/remove",
+ params={"target": items[0]},
+ timeout=10,
+ )
+
+
+class TestCsrfReadEndpointsStillAllowGet:
+ """Negative control: read-only endpoints should still allow GET.
+
+ Ensures the CSRF fix didn't over-correct by making pure-read endpoints
+ POST-only, which would break the UI.
+ """
+
+ @pytest.mark.parametrize(
+ "path",
+ [
+ "/v2/manager/version",
+ "/v2/manager/db_mode",
+ "/v2/manager/policy/update",
+ "/v2/manager/channel_url_list",
+ "/v2/manager/queue/status",
+ "/v2/manager/queue/history_list",
+ "/v2/manager/is_legacy_manager_ui",
+ "/v2/customnode/installed",
+ "/v2/snapshot/getlist",
+ "/v2/snapshot/get_current",
+ "/v2/comfyui_manager/comfyui_versions",
+ ],
+ )
+ def test_get_read_endpoint_succeeds(self, comfyui, path):
+ resp = requests.get(f"{BASE_URL}{path}", timeout=10)
+ assert resp.status_code == 200, (
+ f"Read endpoint GET {path} should succeed, got {resp.status_code}: "
+ f"{resp.text[:200]}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Content-Type gate — second CSRF mitigation layer
+# ---------------------------------------------------------------------------
+#
+# GET→POST conversion alone does NOT block