aboutsummaryrefslogtreecommitdiff
path: root/scripts/test_linux
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/test_linux')
-rwxr-xr-xscripts/test_linux/block-clone-test.sh143
-rwxr-xr-xscripts/test_linux/crashpad-test.sh184
-rwxr-xr-xscripts/test_linux/service-test.sh380
3 files changed, 707 insertions, 0 deletions
diff --git a/scripts/test_linux/block-clone-test.sh b/scripts/test_linux/block-clone-test.sh
new file mode 100755
index 000000000..0a74283f2
--- /dev/null
+++ b/scripts/test_linux/block-clone-test.sh
@@ -0,0 +1,143 @@
+#!/usr/bin/env bash
+# Test block-clone functionality on temporary Btrfs and XFS loopback filesystems.
+#
+# Requires: root/sudo, btrfs-progs (mkfs.btrfs), xfsprogs (mkfs.xfs)
+#
+# Usage:
+# sudo ./scripts/test_linux/block-clone-test.sh [path-to-zencore-test]
+#
+# If no path is given, defaults to build/linux/x86_64/debug/zencore-test
+# relative to the repository root.
+#
+# Options:
+# --btrfs-only Only test Btrfs
+# --xfs-only Only test XFS
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+
+TEST_BINARY=""
+RUN_BTRFS=true
+RUN_XFS=true
+
+for arg in "$@"; do
+ case "$arg" in
+ --btrfs-only) RUN_XFS=false ;;
+ --xfs-only) RUN_BTRFS=false ;;
+ *) TEST_BINARY="$arg" ;;
+ esac
+done
+
+TEST_BINARY="${TEST_BINARY:-$REPO_ROOT/build/linux/x86_64/debug/zencore-test}"
+IMAGE_SIZE="512M"
+TEST_CASES="TryCloneFile,CopyFile.Clone,SupportsBlockRefCounting,CloneQueryInterface"
+
+# Track all temp files for cleanup
+CLEANUP_MOUNTS=()
+CLEANUP_DIRS=()
+CLEANUP_FILES=()
+
+cleanup() {
+ local exit_code=$?
+ set +e
+
+ for mnt in "${CLEANUP_MOUNTS[@]}"; do
+ if mountpoint -q "$mnt" 2>/dev/null; then
+ umount "$mnt"
+ fi
+ done
+ for dir in "${CLEANUP_DIRS[@]}"; do
+ [ -d "$dir" ] && rmdir "$dir"
+ done
+ for f in "${CLEANUP_FILES[@]}"; do
+ [ -f "$f" ] && rm -f "$f"
+ done
+
+ if [ $exit_code -ne 0 ]; then
+ echo "FAILED (exit code $exit_code)"
+ fi
+ exit $exit_code
+}
+trap cleanup EXIT
+
+# --- Preflight checks ---
+
+if [ "$(id -u)" -ne 0 ]; then
+ echo "error: this script must be run as root (for mount/umount)" >&2
+ exit 1
+fi
+
+if [ ! -x "$TEST_BINARY" ]; then
+ echo "error: test binary not found or not executable: $TEST_BINARY" >&2
+ echo "hint: build with 'xmake config -m debug && xmake build zencore-test'" >&2
+ exit 1
+fi
+
+if $RUN_BTRFS && ! command -v mkfs.btrfs &>/dev/null; then
+ echo "warning: mkfs.btrfs not found — install btrfs-progs to test Btrfs, skipping" >&2
+ RUN_BTRFS=false
+fi
+
+if $RUN_XFS && ! command -v mkfs.xfs &>/dev/null; then
+ echo "warning: mkfs.xfs not found — install xfsprogs to test XFS, skipping" >&2
+ RUN_XFS=false
+fi
+
+if ! $RUN_BTRFS && ! $RUN_XFS; then
+ echo "error: no filesystems to test" >&2
+ exit 1
+fi
+
+# --- Helper to create, mount, and run tests on a loopback filesystem ---
+
+run_tests_on_fs() {
+ local fs_type="$1"
+ local mkfs_cmd="$2"
+
+ echo ""
+ echo "========================================"
+ echo " Testing block-clone on $fs_type"
+ echo "========================================"
+
+ local image_path mount_path
+ image_path="$(mktemp "/tmp/${fs_type}-clone-test-XXXXXX.img")"
+ mount_path="$(mktemp -d "/tmp/${fs_type}-clone-mount-XXXXXX")"
+ CLEANUP_FILES+=("$image_path")
+ CLEANUP_DIRS+=("$mount_path")
+ CLEANUP_MOUNTS+=("$mount_path")
+
+ echo "Creating ${IMAGE_SIZE} ${fs_type} image at ${image_path} ..."
+ truncate -s "$IMAGE_SIZE" "$image_path"
+ $mkfs_cmd "$image_path"
+
+ echo "Mounting at ${mount_path} ..."
+ mount -o loop "$image_path" "$mount_path"
+ chmod 777 "$mount_path"
+
+ echo "Copying test binary ..."
+ cp "$TEST_BINARY" "$mount_path/zencore-test"
+ chmod +x "$mount_path/zencore-test"
+
+ echo "Running tests ..."
+ echo "---"
+ "$mount_path/zencore-test" \
+ --test-suite="core.filesystem" \
+ --test-case="$TEST_CASES"
+ echo "---"
+ echo "$fs_type: all block-clone tests passed."
+}
+
+# --- Run ---
+
+if $RUN_BTRFS; then
+ run_tests_on_fs "btrfs" "mkfs.btrfs -q"
+fi
+
+if $RUN_XFS; then
+ run_tests_on_fs "xfs" "mkfs.xfs -q -m reflink=1"
+fi
+
+echo ""
+echo "All block-clone tests passed."
diff --git a/scripts/test_linux/crashpad-test.sh b/scripts/test_linux/crashpad-test.sh
new file mode 100755
index 000000000..bd08cefc5
--- /dev/null
+++ b/scripts/test_linux/crashpad-test.sh
@@ -0,0 +1,184 @@
+#!/usr/bin/env bash
+# Verify that crashpad_handler is active when zenserver (release build) starts.
+#
+# This test:
+# 1. Launches zenserver from the release build.
+# 2. Waits for the HTTP health endpoint to become ready.
+# 3. Checks that a crashpad_handler child process is running.
+# 4. Checks that the startup log contains "sentry initialized" (not a failure).
+#
+# Usage:
+# ./scripts/test_linux/crashpad-test.sh [path-to-zenserver]
+#
+# If no path is given, defaults to build/linux/x86_64/release/zenserver
+# relative to the repository root.
+#
+# The test exits 0 on success, 1 on failure.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+
+ZENSERVER_BINARY="${1:-$REPO_ROOT/build/linux/x86_64/release/zenserver}"
+CRASHPAD_HANDLER="$(dirname "$ZENSERVER_BINARY")/crashpad_handler"
+PORT=18558 # Use a non-default port so we don't collide with a running zenserver
+DATA_DIR="$(mktemp -d)"
+STDOUT_FILE="$DATA_DIR/stdout.log"
+ZENSERVER_PID=""
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+PASSED=0
+FAILED=0
+
+pass() {
+ echo -e " ${GREEN}PASS${NC} $1"
+ (( PASSED++ )) || true
+}
+
+fail() {
+ echo -e " ${RED}FAIL${NC} $1"
+ (( FAILED++ )) || true
+}
+
+cleanup() {
+ set +e
+ if [ -n "$ZENSERVER_PID" ] && kill -0 "$ZENSERVER_PID" 2>/dev/null; then
+ kill "$ZENSERVER_PID" 2>/dev/null
+ wait "$ZENSERVER_PID" 2>/dev/null
+ fi
+ rm -rf "$DATA_DIR"
+}
+trap cleanup EXIT
+
+# ── Preflight ────────────────────────────────────────────────────────────────
+
+echo ""
+echo "=============================="
+echo " Crashpad active check"
+echo "=============================="
+echo ""
+
+if [ ! -f "$ZENSERVER_BINARY" ]; then
+ echo -e "${RED}ERROR: zenserver binary not found: $ZENSERVER_BINARY${NC}"
+ echo " Build with: xmake config -m release && xmake build zenserver"
+ exit 1
+fi
+
+if [ ! -f "$CRASHPAD_HANDLER" ]; then
+ echo -e "${RED}ERROR: crashpad_handler not found alongside zenserver: $CRASHPAD_HANDLER${NC}"
+ echo " It should be copied there automatically by the build."
+ exit 1
+fi
+
+echo "zenserver: $ZENSERVER_BINARY"
+echo "crashpad_handler: $CRASHPAD_HANDLER"
+echo "port: $PORT"
+echo "data dir: $DATA_DIR"
+echo ""
+
+# ── Start zenserver ──────────────────────────────────────────────────────────
+
+# zenserver runs in the foreground until SIGINT/SIGTERM. Launch in background
+# so we can poll its health endpoint and inspect child processes.
+"$ZENSERVER_BINARY" \
+ --port="$PORT" \
+ --data-dir="$DATA_DIR" \
+ > "$STDOUT_FILE" 2>&1 &
+ZENSERVER_PID=$!
+
+echo "Started zenserver (pid $ZENSERVER_PID), waiting for health endpoint..."
+
+# ── Wait for health endpoint ─────────────────────────────────────────────────
+
+READY=false
+for i in $(seq 1 40); do
+ if curl -sf "http://localhost:$PORT/health" > /dev/null 2>&1; then
+ READY=true
+ break
+ fi
+ sleep 0.5
+done
+
+if [ "$READY" = false ]; then
+ echo -e "${RED}ERROR: zenserver did not become ready within 20 seconds${NC}"
+ if [ -f "$STDOUT_FILE" ]; then
+ echo ""
+ echo "--- stdout ---"
+ cat "$STDOUT_FILE"
+ fi
+ exit 1
+fi
+
+echo "Server is ready."
+
+# Give the server a moment to finish startup logging (sentry init log
+# message is emitted after the health endpoint comes up).
+sleep 1
+echo ""
+
+# ── Test 1: crashpad_handler process is running for our data dir ─────────────
+#
+# sentry-native starts crashpad_handler with --database pointing at
+# $DATA_DIR/.sentry-native. The process re-parents itself out of zenserver's
+# process tree, so we look for it by its command-line arguments rather than
+# by parent PID.
+
+HANDLER_PID="$(pgrep -f "crashpad_handler.*${DATA_DIR}" 2>/dev/null | head -1 || true)"
+if [ -n "$HANDLER_PID" ]; then
+ pass "crashpad_handler is running (pid $HANDLER_PID) with database in our data dir"
+else
+ fail "crashpad_handler process not found — sentry_init may have failed or sentry is disabled"
+fi
+
+# ── Test 2: No sentry_init failure in startup log ────────────────────────────
+#
+# The "sentry initialized" success message is logged at INFO level under the
+# sentry-sdk log category, which is filtered to Warn by default — so it won't
+# appear in normal stdout. We check for the absence of the failure message
+# instead (which IS at Warn level and would appear if sentry_init failed).
+
+if grep -q "sentry_init returned failure" "$STDOUT_FILE" 2>/dev/null; then
+ ERRMSG="$(grep "sentry_init returned failure" "$STDOUT_FILE" | head -1)"
+ fail "sentry_init reported failure: $ERRMSG"
+else
+ pass "No sentry_init failure message in startup log"
+fi
+
+# ── Test 3: ldd sanity — crashpad_handler must not need libc++.so.1 ──────────
+
+MISSING_LIBCXX="$(ldd "$CRASHPAD_HANDLER" 2>/dev/null | grep "libc++\.so\.1" | grep "not found" || true)"
+if [ -n "$MISSING_LIBCXX" ]; then
+ fail "crashpad_handler has an unsatisfied libc++.so.1 dependency (static linking patch not applied)"
+elif ldd "$CRASHPAD_HANDLER" 2>/dev/null | grep -q "libc++\.so\.1"; then
+ fail "crashpad_handler links libc++.so.1 dynamically — it should be statically linked"
+else
+ pass "crashpad_handler has no dynamic libc++.so.1 dependency"
+fi
+
+# ── Summary ──────────────────────────────────────────────────────────────────
+
+echo ""
+echo "=============================="
+printf " Passed: "
+echo -e "${GREEN}${PASSED}${NC}"
+printf " Failed: "
+if [ "$FAILED" -gt 0 ]; then
+ echo -e "${RED}${FAILED}${NC}"
+else
+ echo -e "${GREEN}${FAILED}${NC}"
+fi
+echo "=============================="
+echo ""
+
+if [ "$FAILED" -gt 0 ]; then
+ if [ -f "$STDOUT_FILE" ]; then
+ echo "--- stdout ---"
+ cat "$STDOUT_FILE"
+ fi
+ exit 1
+fi
diff --git a/scripts/test_linux/service-test.sh b/scripts/test_linux/service-test.sh
new file mode 100755
index 000000000..f91339e1b
--- /dev/null
+++ b/scripts/test_linux/service-test.sh
@@ -0,0 +1,380 @@
+#!/usr/bin/env bash
+# Test zen service command lifecycle on Linux (systemd).
+#
+# Requires: root/sudo, systemd
+#
+# Usage:
+# sudo ./scripts/test_linux/service-test.sh [path-to-zen]
+#
+# If no path is given, defaults to build/linux/x86_64/debug/zen
+# relative to the repository root.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+
+ZEN_BINARY="${1:-$REPO_ROOT/build/linux/x86_64/debug/zen}"
+ZENSERVER_BINARY="$(dirname "$ZEN_BINARY")/zenserver"
+SERVICE_NAME="ZenServerTest-$$"
+UNIT_NAME="com.epicgames.unreal.${SERVICE_NAME}"
+UNIT_FILE="/etc/systemd/system/${UNIT_NAME}.service"
+
+PASSED=0
+FAILED=0
+TESTS_RUN=0
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+cleanup() {
+ local exit_code=$?
+ set +e
+
+ echo ""
+ echo "--- Cleanup ---"
+
+ # Stop service if running
+ if systemctl is-active --quiet "$UNIT_NAME" 2>/dev/null; then
+ echo "Stopping test service..."
+ systemctl stop "$UNIT_NAME" 2>/dev/null
+ fi
+
+ # Disable and remove unit file
+ if [ -f "$UNIT_FILE" ]; then
+ echo "Removing test unit file..."
+ systemctl disable "$UNIT_NAME" 2>/dev/null || true
+ rm -f "$UNIT_FILE"
+ systemctl daemon-reload 2>/dev/null || true
+ fi
+
+ echo ""
+ echo "=============================="
+ printf " Tests run: %d\n" "$TESTS_RUN"
+ printf " ${GREEN}Passed: %d${NC}\n" "$PASSED"
+ if [ "$FAILED" -gt 0 ]; then
+ printf " ${RED}Failed: %d${NC}\n" "$FAILED"
+ else
+ printf " Failed: %d\n" "$FAILED"
+ fi
+ echo "=============================="
+
+ if [ "$FAILED" -gt 0 ]; then
+ exit 1
+ fi
+ exit "$exit_code"
+}
+
+trap cleanup EXIT
+
+pass() {
+ TESTS_RUN=$((TESTS_RUN + 1))
+ PASSED=$((PASSED + 1))
+ printf " ${GREEN}PASS${NC}: %s\n" "$1"
+}
+
+fail() {
+ TESTS_RUN=$((TESTS_RUN + 1))
+ FAILED=$((FAILED + 1))
+ printf " ${RED}FAIL${NC}: %s\n" "$1"
+ if [ -n "${2:-}" ]; then
+ printf " %s\n" "$2"
+ fi
+}
+
+# ── Preflight checks ──────────────────────────────────────────────
+
+if [ "$(id -u)" -ne 0 ]; then
+ echo "Error: this test must be run as root (sudo)."
+ exit 1
+fi
+
+if ! command -v systemctl &>/dev/null; then
+ echo "Error: systemctl not found — this test requires systemd."
+ exit 1
+fi
+
+if [ ! -x "$ZEN_BINARY" ]; then
+ echo "Error: zen binary not found at '$ZEN_BINARY'"
+ echo "Build with: xmake config -m debug && xmake build zen"
+ exit 1
+fi
+
+if [ ! -x "$ZENSERVER_BINARY" ]; then
+ echo "Error: zenserver binary not found at '$ZENSERVER_BINARY'"
+ echo "Build with: xmake config -m debug && xmake build zenserver"
+ exit 1
+fi
+
+echo "zen binary: $ZEN_BINARY"
+echo "zenserver binary: $ZENSERVER_BINARY"
+echo "service name: $SERVICE_NAME"
+echo "unit name: $UNIT_NAME"
+echo ""
+
+# Determine which user to run the service as (the user who invoked sudo)
+SERVICE_USER="${SUDO_USER:-$(whoami)}"
+
+# ── Test: status before install (should fail) ─────────────────────
+
+echo "--- Test: status before install ---"
+
+OUTPUT=$("$ZEN_BINARY" service status "$SERVICE_NAME" 2>&1 || true)
+if echo "$OUTPUT" | grep -qi "not installed"; then
+ pass "status reports 'not installed' for non-existent service"
+else
+ fail "status should report 'not installed'" "got: $OUTPUT"
+fi
+
+# ── Test: install ─────────────────────────────────────────────────
+
+echo "--- Test: install ---"
+
+OUTPUT=$("$ZEN_BINARY" service install "$ZENSERVER_BINARY" "$SERVICE_NAME" --user "$SERVICE_USER" 2>&1)
+EXIT_CODE=$?
+
+if [ "$EXIT_CODE" -eq 0 ]; then
+ pass "install exits with code 0"
+else
+ fail "install exits with code 0" "got exit code: $EXIT_CODE, output: $OUTPUT"
+fi
+
+if [ -f "$UNIT_FILE" ]; then
+ pass "unit file created at $UNIT_FILE"
+else
+ fail "unit file created at $UNIT_FILE"
+fi
+
+# Verify unit file contents
+if [ -f "$UNIT_FILE" ]; then
+ if grep -q "ExecStart=.*zenserver" "$UNIT_FILE"; then
+ pass "unit file contains ExecStart with zenserver"
+ else
+ fail "unit file contains ExecStart with zenserver" "$(cat "$UNIT_FILE")"
+ fi
+
+ if grep -q "User=$SERVICE_USER" "$UNIT_FILE"; then
+ pass "unit file contains correct User=$SERVICE_USER"
+ else
+ fail "unit file contains correct User=$SERVICE_USER" "$(grep User "$UNIT_FILE")"
+ fi
+
+ if grep -q "Type=notify" "$UNIT_FILE"; then
+ pass "unit file uses Type=notify"
+ else
+ fail "unit file uses Type=notify"
+ fi
+
+ if grep -q "WantedBy=multi-user.target" "$UNIT_FILE"; then
+ pass "unit file has WantedBy=multi-user.target"
+ else
+ fail "unit file has WantedBy=multi-user.target"
+ fi
+
+ # Verify the service is enabled
+ if systemctl is-enabled --quiet "$UNIT_NAME" 2>/dev/null; then
+ pass "service is enabled after install"
+ else
+ fail "service is enabled after install"
+ fi
+fi
+
+# ── Test: install again (already installed, no --full) ────────────
+
+echo "--- Test: install again (idempotent) ---"
+
+OUTPUT=$("$ZEN_BINARY" service install "$ZENSERVER_BINARY" "$SERVICE_NAME" --user "$SERVICE_USER" 2>&1)
+EXIT_CODE=$?
+
+if [ "$EXIT_CODE" -eq 0 ]; then
+ pass "re-install exits with code 0"
+else
+ fail "re-install exits with code 0" "got exit code: $EXIT_CODE"
+fi
+
+if echo "$OUTPUT" | grep -qi "already installed"; then
+ pass "re-install reports service already installed"
+else
+ fail "re-install reports service already installed" "got: $OUTPUT"
+fi
+
+# ── Test: status after install (not yet started) ──────────────────
+
+echo "--- Test: status after install (stopped) ---"
+
+OUTPUT=$("$ZEN_BINARY" service status "$SERVICE_NAME" 2>&1 || true)
+# The status command throws if not running, so we expect an error message
+if echo "$OUTPUT" | grep -qi "not running"; then
+ pass "status reports 'not running' for stopped service"
+else
+ fail "status reports 'not running' for stopped service" "got: $OUTPUT"
+fi
+
+# ── Test: start ───────────────────────────────────────────────────
+
+echo "--- Test: start ---"
+
+OUTPUT=$("$ZEN_BINARY" service start "$SERVICE_NAME" 2>&1)
+EXIT_CODE=$?
+
+if [ "$EXIT_CODE" -eq 0 ]; then
+ pass "start exits with code 0"
+else
+ # The TODO comments in the code indicate start may not work perfectly
+ fail "start exits with code 0" "got exit code: $EXIT_CODE, output: $OUTPUT"
+fi
+
+# Give the service a moment to start
+sleep 1
+
+if systemctl is-active --quiet "$UNIT_NAME" 2>/dev/null; then
+ pass "service is active after start"
+
+ # ── Test: status while running ────────────────────────────────
+
+ echo "--- Test: status (running) ---"
+
+ OUTPUT=$("$ZEN_BINARY" service status "$SERVICE_NAME" 2>&1 || true)
+ if echo "$OUTPUT" | grep -qi "Running"; then
+ pass "status reports 'Running'"
+ else
+ fail "status reports 'Running'" "got: $OUTPUT"
+ fi
+
+ if echo "$OUTPUT" | grep -qi "zenserver"; then
+ pass "status shows executable path"
+ else
+ fail "status shows executable path" "got: $OUTPUT"
+ fi
+
+ # ── Test: start again (already running) ───────────────────────
+
+ echo "--- Test: start again (already running) ---"
+
+ OUTPUT=$("$ZEN_BINARY" service start "$SERVICE_NAME" 2>&1)
+ if echo "$OUTPUT" | grep -qi "already running"; then
+ pass "start reports service already running"
+ else
+ fail "start reports service already running" "got: $OUTPUT"
+ fi
+
+ # ── Test: stop ────────────────────────────────────────────────
+
+ echo "--- Test: stop ---"
+
+ OUTPUT=$("$ZEN_BINARY" service stop "$SERVICE_NAME" 2>&1)
+ EXIT_CODE=$?
+
+ if [ "$EXIT_CODE" -eq 0 ]; then
+ pass "stop exits with code 0"
+ else
+ fail "stop exits with code 0" "got exit code: $EXIT_CODE, output: $OUTPUT"
+ fi
+
+ sleep 1
+
+ if ! systemctl is-active --quiet "$UNIT_NAME" 2>/dev/null; then
+ pass "service is inactive after stop"
+ else
+ fail "service is inactive after stop"
+ fi
+else
+ fail "service is active after start" "(skipping start-dependent tests)"
+fi
+
+# ── Test: stop when already stopped ───────────────────────────────
+
+echo "--- Test: stop when already stopped ---"
+
+# Make sure it's stopped first
+systemctl stop "$UNIT_NAME" 2>/dev/null || true
+sleep 1
+
+OUTPUT=$("$ZEN_BINARY" service stop "$SERVICE_NAME" 2>&1 || true)
+if echo "$OUTPUT" | grep -qi "not running"; then
+ pass "stop reports 'not running' when already stopped"
+else
+ fail "stop reports 'not running' when already stopped" "got: $OUTPUT"
+fi
+
+# ── Test: uninstall while running (should fail) ───────────────────
+
+echo "--- Test: uninstall while running (should fail) ---"
+
+# Start the service so we can test uninstall-while-running
+"$ZEN_BINARY" service start "$SERVICE_NAME" 2>&1 || true
+sleep 1
+
+if systemctl is-active --quiet "$UNIT_NAME" 2>/dev/null; then
+ OUTPUT=$("$ZEN_BINARY" service uninstall "$SERVICE_NAME" 2>&1 || true)
+ EXIT_CODE=$?
+
+ if [ "$EXIT_CODE" -ne 0 ] || echo "$OUTPUT" | grep -qi "running.*stop"; then
+ pass "uninstall refuses while service is running"
+ else
+ fail "uninstall refuses while service is running" "got: exit=$EXIT_CODE, output: $OUTPUT"
+ fi
+
+ # Stop it for the real uninstall test
+ "$ZEN_BINARY" service stop "$SERVICE_NAME" 2>&1 || true
+ systemctl stop "$UNIT_NAME" 2>/dev/null || true
+ sleep 1
+else
+ echo " (skipped: could not start service for this test)"
+fi
+
+# ── Test: uninstall ───────────────────────────────────────────────
+
+echo "--- Test: uninstall ---"
+
+# Ensure stopped
+systemctl stop "$UNIT_NAME" 2>/dev/null || true
+sleep 1
+
+OUTPUT=$("$ZEN_BINARY" service uninstall "$SERVICE_NAME" 2>&1)
+EXIT_CODE=$?
+
+if [ "$EXIT_CODE" -eq 0 ]; then
+ pass "uninstall exits with code 0"
+else
+ fail "uninstall exits with code 0" "got exit code: $EXIT_CODE, output: $OUTPUT"
+fi
+
+if [ ! -f "$UNIT_FILE" ]; then
+ pass "unit file removed after uninstall"
+else
+ fail "unit file removed after uninstall"
+fi
+
+# ── Test: status after uninstall ──────────────────────────────────
+
+echo "--- Test: status after uninstall ---"
+
+OUTPUT=$("$ZEN_BINARY" service status "$SERVICE_NAME" 2>&1 || true)
+if echo "$OUTPUT" | grep -qi "not installed"; then
+ pass "status reports 'not installed' after uninstall"
+else
+ fail "status reports 'not installed' after uninstall" "got: $OUTPUT"
+fi
+
+# ── Test: uninstall when not installed (idempotent) ───────────────
+
+echo "--- Test: uninstall when not installed ---"
+
+OUTPUT=$("$ZEN_BINARY" service uninstall "$SERVICE_NAME" 2>&1)
+EXIT_CODE=$?
+
+if [ "$EXIT_CODE" -eq 0 ]; then
+ pass "uninstall of non-existent service exits with code 0"
+else
+ fail "uninstall of non-existent service exits with code 0" "got exit code: $EXIT_CODE"
+fi
+
+if echo "$OUTPUT" | grep -qi "not installed"; then
+ pass "uninstall reports service not installed"
+else
+ fail "uninstall reports service not installed" "got: $OUTPUT"
+fi