diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e08a63e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Install bats + run: sudo apt-get update -q && sudo apt-get install -y -q bats + + - name: ShellCheck + run: shellcheck systemd-compose.sh host-setup.sh tests/stubs/* tests/test_helper.bash tests/*.bats + + - name: Tests + run: bats tests diff --git a/README.md b/README.md index 21037ab..6764502 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,20 @@ systemctl --user daemon-reload --- +## ๐Ÿงช Development + +The tests use [bats](https://github.com/bats-core/bats-core) with fake `systemctl`, `ss` and `journalctl` commands (`tests/stubs/`), so they never touch your real services. + +```bash +sudo apt install bats shellcheck +bats tests +shellcheck systemd-compose.sh host-setup.sh tests/stubs/* tests/test_helper.bash tests/*.bats +``` + +CI runs both on every pull request. + +--- + ## ๐Ÿ—‘ Uninstall ```bash diff --git a/tests/cli.bats b/tests/cli.bats new file mode 100644 index 0000000..c3a4f43 --- /dev/null +++ b/tests/cli.bats @@ -0,0 +1,69 @@ +#!/usr/bin/env bats +# Argument parsing, usage and config validation + +load test_helper + +@test "no arguments prints usage and fails" { + run bash "$SDC" + [ "$status" -eq 1 ] + assert_output_contains "Usage:" +} + +@test "unknown command fails" { + make_app "$TEST_ROOT/app" app false + run bash "$SDC" "$TEST_ROOT/app" frobnicate + [ "$status" -eq 1 ] + assert_output_contains "Unknown command: frobnicate" +} + +@test "missing project folder fails" { + run bash "$SDC" "$TEST_ROOT/nope" status + [ "$status" -eq 1 ] + assert_output_contains "not found" +} + +@test "folder without config.env fails" { + mkdir -p "$TEST_ROOT/empty" + run bash "$SDC" "$TEST_ROOT/empty" status + [ "$status" -eq 1 ] + assert_output_contains "config.env not found" +} + +@test "APP_NAME with a path is rejected and writes nothing" { + make_app "$TEST_ROOT/evil" "../../evil" false + run bash "$SDC" "$TEST_ROOT/evil" run + [ "$status" -eq 1 ] + assert_output_contains "is invalid" + [ ! -e "$HOME/.config/evil.service" ] + [ ! -e "$HOME/evil.service" ] +} + +@test "empty APP_NAME is rejected" { + make_app "$TEST_ROOT/app" "" false + run bash "$SDC" "$TEST_ROOT/app" status + [ "$status" -eq 1 ] + assert_output_contains "is invalid" +} + +@test "spaces in APP_NAME become dashes" { + make_app "$TEST_ROOT/app" "Task List" false + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 0 ] + [ -f "$UNIT_DIR/sdc-Task-List.service" ] +} + +@test "missing ENTRYPOINT gives a clear error" { + mkdir -p "$TEST_ROOT/app" + printf 'APP_NAME=app\nREQUIRE_PORT=false\n' > "$TEST_ROOT/app/config.env" + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 1 ] + assert_output_contains "ENTRYPOINT is not set" +} + +@test "unquoted values in config.env work" { + make_app "$TEST_ROOT/app" app false + sed -i 's/^APP_NAME=.*/APP_NAME=plain/' "$TEST_ROOT/app/config.env" + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 0 ] + [ -f "$UNIT_DIR/sdc-plain.service" ] +} diff --git a/tests/dashboard.bats b/tests/dashboard.bats new file mode 100644 index 0000000..9b88927 --- /dev/null +++ b/tests/dashboard.bats @@ -0,0 +1,107 @@ +#!/usr/bin/env bats +# ps / stop-all / start-all and name conflicts between projects + +load test_helper + +@test "ps on a fresh install prints an empty table" { + run bash "$SDC" ps + [ "$status" -eq 0 ] + assert_output_contains "APP NAME" + [ "$(echo "$output" | wc -l)" -eq 4 ] +} + +@test "ps lists apps from any folder with the right status" { + make_app "$TEST_ROOT/apps/web" web true 8000 app + make_app "$TEST_ROOT/elsewhere/bot" bot false + make_app "$TEST_ROOT/elsewhere/worker" worker false + STUB_LISTEN=8000 bash "$SDC" "$TEST_ROOT/apps/web" run + bash "$SDC" "$TEST_ROOT/elsewhere/bot" run + bash "$SDC" "$TEST_ROOT/elsewhere/worker" run + unit_state sdc-bot failed + unit_state sdc-worker inactive + + run bash "$SDC" ps + [ "$status" -eq 0 ] + # One line per app, no stray "unknown" lines + [ "$(echo "$output" | wc -l)" -eq 7 ] + refute_output_contains "unknown" + [[ "$output" =~ web[[:space:]]+RUNNING[[:space:]].*8000.*apps/web ]] + [[ "$output" =~ bot[[:space:]]+CRASHED ]] + [[ "$output" =~ worker[[:space:]]+STOPPED ]] +} + +@test "ps shows RESTARTING for an app waiting to be restarted" { + make_app "$TEST_ROOT/app" app false + bash "$SDC" "$TEST_ROOT/app" run + unit_state sdc-app activating + STUB_SUBSTATE=auto-restart run bash "$SDC" ps + [[ "$output" =~ app[[:space:]]+RESTARTING ]] +} + +@test "ps marks a project folder that no longer exists" { + make_app "$TEST_ROOT/app" app false + bash "$SDC" "$TEST_ROOT/app" run + mv "$TEST_ROOT/app" "$TEST_ROOT/moved" + run bash "$SDC" ps + assert_output_contains "(missing)" +} + +@test "a copied project with the same APP_NAME is refused" { + make_app "$TEST_ROOT/app" web false + bash "$SDC" "$TEST_ROOT/app" run + cp -r "$TEST_ROOT/app" "$TEST_ROOT/copy" + + for cmd in run stop restart down status logs; do + run bash "$SDC" "$TEST_ROOT/copy" "$cmd" + [ "$status" -eq 1 ] + assert_output_contains "Name Conflict" + done + # The original app was not touched + [ "$(current_state sdc-web)" == "active" ] + [ -f "$UNIT_DIR/sdc-web.service" ] +} + +@test "a moved project can claim its old name" { + make_app "$TEST_ROOT/app" web false + bash "$SDC" "$TEST_ROOT/app" run + mv "$TEST_ROOT/app" "$TEST_ROOT/moved" + run bash "$SDC" "$TEST_ROOT/moved" run + [ "$status" -eq 0 ] + assert_output_contains "Claiming orphaned service name" + grep -q "^X-SDC-Project=$TEST_ROOT/moved$" "$UNIT_DIR/sdc-web.service" +} + +@test "stop-all stops every app, including crash-looping ones" { + make_app "$TEST_ROOT/a" a false + make_app "$TEST_ROOT/b" b false + bash "$SDC" "$TEST_ROOT/a" run + bash "$SDC" "$TEST_ROOT/b" run + unit_state sdc-b activating + run bash "$SDC" stop-all + [ "$status" -eq 0 ] + assert_output_contains "Processed 2 apps" + [ "$(current_state sdc-a)" == "inactive" ] + [ "$(current_state sdc-b)" == "inactive" ] +} + +@test "start-all starts every installed app and clears failed state" { + make_app "$TEST_ROOT/a" a false + make_app "$TEST_ROOT/b" b false + bash "$SDC" "$TEST_ROOT/a" run + bash "$SDC" "$TEST_ROOT/b" run + unit_state sdc-a inactive + unit_state sdc-b failed + run bash "$SDC" start-all + [ "$status" -eq 0 ] + [ "$(current_state sdc-a)" == "active" ] + [ "$(current_state sdc-b)" == "active" ] +} + +@test "other user services are not listed or touched" { + mkdir -p "$UNIT_DIR" + printf '[Service]\nExecStart=/bin/true\n' > "$UNIT_DIR/pipewire.service" + unit_state pipewire active + run bash "$SDC" stop-all + refute_output_contains "pipewire" + [ "$(current_state pipewire)" == "active" ] +} diff --git a/tests/env_file.bats b/tests/env_file.bats new file mode 100644 index 0000000..90dc829 --- /dev/null +++ b/tests/env_file.bats @@ -0,0 +1,58 @@ +#!/usr/bin/env bats +# How .env and config.env are read + +# Single quotes below are intentional: the "$(...)" must reach .env literally. +# shellcheck disable=SC2016 + +load test_helper + +@test ".env values are read, quotes stripped, comments skipped" { + make_app "$TEST_ROOT/app" app true + printf '# a comment\n\nPORT="8123"\r\n' > "$TEST_ROOT/app/.env" + STUB_LISTEN=8123 run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 0 ] + grep -q '^X-SDC-Port=8123$' "$UNIT_DIR/sdc-app.service" +} + +@test ".env is never executed as shell code" { + make_app "$TEST_ROOT/app" app false + printf 'NAME=hello world $(touch %s/pwned)\nOTHER=`touch %s/pwned2`\n' "$TEST_ROOT" "$TEST_ROOT" > "$TEST_ROOT/app/.env" + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 0 ] + [ ! -e "$TEST_ROOT/pwned" ] + [ ! -e "$TEST_ROOT/pwned2" ] +} + +@test ".env cannot override PATH or INSTALL_DIR inside systemd-compose" { + make_app "$TEST_ROOT/app" app false + printf 'PATH=/nope\nINSTALL_DIR=/tmp/elsewhere\n' > "$TEST_ROOT/app/.env" + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 0 ] + assert_output_contains "'PATH' is reserved" + assert_output_contains "'INSTALL_DIR' is reserved" + grep -q "^WorkingDirectory=$TEST_ROOT/app/.$" "$UNIT_DIR/sdc-app.service" +} + +@test "export lines in .env are skipped with a warning" { + make_app "$TEST_ROOT/app" app true + printf 'export PORT=9000\n' > "$TEST_ROOT/app/.env" + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 1 ] + assert_output_contains "systemd ignores 'export' lines" + assert_output_contains "'PORT' variable is missing" +} + +@test "export lines in config.env trigger a warning on run" { + make_app "$TEST_ROOT/app" app false + echo 'export WEB_WORKERS=1' >> "$TEST_ROOT/app/config.env" + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 0 ] + assert_output_contains "Those are NOT passed to your app" +} + +@test "an app without .env starts when REQUIRE_PORT=false" { + make_app "$TEST_ROOT/app" app false + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 0 ] + grep -q '^EnvironmentFile=-' "$UNIT_DIR/sdc-app.service" +} diff --git a/tests/lifecycle.bats b/tests/lifecycle.bats new file mode 100644 index 0000000..2cc3a69 --- /dev/null +++ b/tests/lifecycle.bats @@ -0,0 +1,154 @@ +#!/usr/bin/env bats +# build / run / stop / restart / status / down for a single project + +load test_helper + +@test "build creates a real venv" { + mkdir -p "$TEST_ROOT/app" + printf 'APP_NAME=app\nENTRYPOINT=python\nREQUIRE_PORT=false\n' > "$TEST_ROOT/app/config.env" + run bash "$SDC" "$TEST_ROOT/app" build + [ "$status" -eq 0 ] + assert_output_contains "Build Complete." + [ -x "$TEST_ROOT/app/venv/bin/python" ] +} + +@test "build fails and up stops when pip fails" { + make_app "$TEST_ROOT/app" app false + printf '#!/bin/sh\nexit 1\n' > "$TEST_ROOT/app/venv/bin/pip" + echo "some-package" > "$TEST_ROOT/app/requirements.txt" + run bash "$SDC" "$TEST_ROOT/app" up + [ "$status" -eq 1 ] + assert_output_contains "Dependency installation failed" + refute_output_contains "Build Complete." + [ ! -f "$UNIT_DIR/sdc-app.service" ] +} + +@test "run fails when the venv binary is missing" { + make_app "$TEST_ROOT/app" app false + rm "$TEST_ROOT/app/venv/bin/python" + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 1 ] + assert_output_contains "not found in venv" +} + +@test "run fails when REQUIRE_PORT=true and PORT is missing" { + make_app "$TEST_ROOT/app" app true + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 1 ] + assert_output_contains "'PORT' variable is missing" +} + +@test "run rejects a non-numeric PORT" { + make_app "$TEST_ROOT/app" app true abc + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 1 ] + assert_output_contains "not a valid port" +} + +@test "run fails when another program holds the port" { + make_app "$TEST_ROOT/app" app true 8000 + echo "8000 someone-else" > "$STUB_STATE/ports" + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 1 ] + assert_output_contains "Port 8000 is already in use" +} + +@test "run on an app that is already running does not trip the port check" { + make_app "$TEST_ROOT/app" app true 8000 + export STUB_LISTEN=8000 + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 0 ] + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 0 ] + assert_output_contains "Port 8000 is free" + [ "$(current_state sdc-app)" == "active" ] +} + +@test "run reports a start failure with logs" { + make_app "$TEST_ROOT/app" app false + STUB_START_FAIL=1 run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 1 ] + assert_output_contains "Systemd failed to start the service" + assert_output_contains "[journal:" +} + +@test "run reports an app that crashes right after starting" { + make_app "$TEST_ROOT/app" app false + STUB_STATE_AFTER_START=activating run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 1 ] + assert_output_contains "exited during startup" +} + +@test "run reports an app that systemd had to restart" { + make_app "$TEST_ROOT/app" app false + STUB_NRESTARTS=2 run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 1 ] + assert_output_contains "restarted it (2x)" +} + +@test "run reports a port app that never listens" { + make_app "$TEST_ROOT/app" app true 8000 + echo 'STARTUP_TIMEOUT=2' >> "$TEST_ROOT/app/config.env" + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 1 ] + assert_output_contains "not listening on port 8000 after 2s" +} + +@test "run clears a previous failed state before starting" { + make_app "$TEST_ROOT/app" app false + run bash "$SDC" "$TEST_ROOT/app" run + unit_state sdc-app failed + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 0 ] + grep -q 'reset-failed sdc-app.service' "$STUB_STATE/calls" +} + +@test "stop stops an app stuck in a crash loop" { + make_app "$TEST_ROOT/app" app false + run bash "$SDC" "$TEST_ROOT/app" run + unit_state sdc-app activating + run bash "$SDC" "$TEST_ROOT/app" stop + [ "$status" -eq 0 ] + [ "$(current_state sdc-app)" == "inactive" ] +} + +@test "stop on an app that was never installed is not an error" { + make_app "$TEST_ROOT/app" app false + run bash "$SDC" "$TEST_ROOT/app" stop + [ "$status" -eq 0 ] + assert_output_contains "not installed" +} + +@test "restart fails when the app is not installed" { + make_app "$TEST_ROOT/app" app false + run bash "$SDC" "$TEST_ROOT/app" restart + [ "$status" -eq 1 ] + assert_output_contains "is not installed" +} + +@test "restart reports a failure with a non-zero exit" { + make_app "$TEST_ROOT/app" app false + run bash "$SDC" "$TEST_ROOT/app" run + STUB_STATE_AFTER_START=activating run bash "$SDC" "$TEST_ROOT/app" restart + [ "$status" -eq 1 ] + assert_output_contains "Restart Failed" +} + +@test "status shows memory of the whole service and restarts" { + make_app "$TEST_ROOT/app" app false + run bash "$SDC" "$TEST_ROOT/app" run + STUB_MEM=52428800 run bash "$SDC" "$TEST_ROOT/app" status + [ "$status" -eq 0 ] + assert_output_contains "Memory Usage: 50 MB" + assert_output_contains "Restarts: 0" +} + +@test "down removes the service" { + make_app "$TEST_ROOT/app" app false + run bash "$SDC" "$TEST_ROOT/app" run + [ -f "$UNIT_DIR/sdc-app.service" ] + run bash "$SDC" "$TEST_ROOT/app" down + [ "$status" -eq 0 ] + [ ! -f "$UNIT_DIR/sdc-app.service" ] + [ "$(current_state sdc-app)" == "inactive" ] +} diff --git a/tests/stubs/journalctl b/tests/stubs/journalctl new file mode 100755 index 0000000..38e4f19 --- /dev/null +++ b/tests/stubs/journalctl @@ -0,0 +1,4 @@ +#!/bin/bash +# Fake journalctl for tests: records the call instead of reading logs. +echo "journalctl $*" >> "${STUB_STATE:?}/calls" +echo "[journal: $*]" diff --git a/tests/stubs/sleep b/tests/stubs/sleep new file mode 100755 index 0000000..7e7a1cf --- /dev/null +++ b/tests/stubs/sleep @@ -0,0 +1,3 @@ +#!/bin/bash +# Fake sleep for tests: the startup check polls once per second; don't wait. +exit 0 diff --git a/tests/stubs/ss b/tests/stubs/ss new file mode 100755 index 0000000..a296f4d --- /dev/null +++ b/tests/stubs/ss @@ -0,0 +1,10 @@ +#!/bin/bash +# Fake `ss -tuln` for tests: lists the ports recorded in $STUB_STATE/ports +# (written by stubs/systemctl when an app "starts" with STUB_LISTEN set). +echo "Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port" +if [ -f "${STUB_STATE:?}/ports" ]; then + while read -r port _; do + echo "tcp LISTEN 0 128 0.0.0.0:$port 0.0.0.0:*" + done < "$STUB_STATE/ports" +fi +exit 0 diff --git a/tests/stubs/systemctl b/tests/stubs/systemctl new file mode 100755 index 0000000..bba6ca9 --- /dev/null +++ b/tests/stubs/systemctl @@ -0,0 +1,101 @@ +#!/bin/bash +# Fake `systemctl --user` for tests. It keeps unit states in files under +# $STUB_STATE and never touches the real systemd. +# +# Knobs (environment variables): +# STUB_START_FAIL=1 start/restart fails, unit ends up "failed" +# STUB_STATE_AFTER_START=state state after a start (default "active") +# STUB_LISTEN=port the started app "listens" on this port (see stubs/ss) +# STUB_NRESTARTS=n value of the NRestarts property +# STUB_MEM=bytes value of the MemoryCurrent property +# STUB_SUBSTATE=state value of the SubState property + +STATE_DIR="${STUB_STATE:?STUB_STATE must be set}" +mkdir -p "$STATE_DIR/units" +echo "systemctl $*" >> "$STATE_DIR/calls" + +args=() +quiet=0 +for arg in "$@"; do + case "$arg" in + --quiet) quiet=1 ;; + --user|--no-pager) ;; + *) args+=("$arg") ;; + esac +done + +cmd="${args[0]:-}" +unit="${args[${#args[@]}-1]}" +unit="${unit%.service}" +unit_file="$HOME/.config/systemd/user/$unit.service" + +state() { cat "$STATE_DIR/units/$unit" 2>/dev/null || echo inactive; } +set_state() { echo "$1" > "$STATE_DIR/units/$unit"; } +release_ports() { + if [ -f "$STATE_DIR/ports" ]; then + sed -i "/ $unit\$/d" "$STATE_DIR/ports" + fi +} + +case "$cmd" in + daemon-reload|enable|disable) + exit 0 + ;; + reset-failed) + if [ "$(state)" == "failed" ]; then set_state inactive; fi + exit 0 + ;; + start|restart) + if [ ! -f "$unit_file" ]; then + echo "Failed to start $unit.service: Unit $unit.service not found." >&2 + exit 5 + fi + release_ports + if [ "${STUB_START_FAIL:-0}" == "1" ]; then + set_state failed + echo "Job for $unit.service failed." >&2 + exit 1 + fi + set_state "${STUB_STATE_AFTER_START:-active}" + if [ -n "${STUB_LISTEN:-}" ] && [ "$(state)" == "active" ]; then + echo "$STUB_LISTEN $unit" >> "$STATE_DIR/ports" + fi + exit 0 + ;; + stop) + if [ ! -f "$unit_file" ] && [ ! -f "$STATE_DIR/units/$unit" ]; then + echo "Failed to stop $unit.service: Unit $unit.service not loaded." >&2 + exit 5 + fi + set_state inactive + release_ports + exit 0 + ;; + is-active) + current="$(state)" + if [ "$quiet" == "0" ]; then echo "$current"; fi + [ "$current" == "active" ] + ;; + show) + prop="" + for ((i = 0; i < ${#args[@]}; i++)); do + case "${args[i]}" in + --property|-p) prop="${args[i+1]}" ;; + --property=*) prop="${args[i]#*=}" ;; + esac + done + case "$prop" in + WorkingDirectory) grep '^WorkingDirectory=' "$unit_file" 2>/dev/null | cut -d= -f2- ;; + MainPID) if [ "$(state)" == "active" ]; then echo "$$"; else echo 0; fi ;; + ActiveState) state ;; + SubState) echo "${STUB_SUBSTATE:-running}" ;; + NRestarts) echo "${STUB_NRESTARTS:-0}" ;; + MemoryCurrent) echo "${STUB_MEM:-[not set]}" ;; + *) echo "" ;; + esac + ;; + *) + echo "stub systemctl: unsupported command: $*" >&2 + exit 1 + ;; +esac diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..441a936 --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,73 @@ +# Shared setup for the bats tests. +# SDC/UNIT_DIR are used by the test files; $output is set by bats `run`. +# shellcheck disable=SC2034,SC2154 +# +# Each test gets a throwaway HOME and fake systemctl/ss/journalctl/sleep +# (tests/stubs), so nothing touches the real systemd or your apps. + +REPO_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")/.." && pwd)" +SDC="$REPO_DIR/systemd-compose.sh" + +setup() { + TEST_ROOT="$(mktemp -d)" + export HOME="$TEST_ROOT/home" + export STUB_STATE="$TEST_ROOT/state" + export PATH="$REPO_DIR/tests/stubs:$PATH" + mkdir -p "$HOME" "$STUB_STATE" + UNIT_DIR="$HOME/.config/systemd/user" + unset STUB_START_FAIL STUB_STATE_AFTER_START STUB_LISTEN STUB_NRESTARTS STUB_MEM STUB_SUBSTATE +} + +teardown() { + rm -rf "${TEST_ROOT:?}" +} + +# make_app [REQUIRE_PORT] [PORT] [APP_DIR] +# Creates a project with a fake, already-built venv. +make_app() { + local dir="$1" name="$2" require_port="${3:-true}" port="${4:-}" app_dir="${5:-.}" + mkdir -p "$dir/$app_dir" "$dir/venv/bin" + printf '#!/bin/sh\n' > "$dir/venv/bin/python" + printf '#!/bin/sh\nexit 0\n' > "$dir/venv/bin/pip" + chmod +x "$dir/venv/bin/python" "$dir/venv/bin/pip" + cat > "$dir/config.env" < "$dir/.env" + fi +} + +# Sets the stub state of an installed unit, e.g. unit_state sdc-web activating +unit_state() { + mkdir -p "$STUB_STATE/units" + echo "$2" > "$STUB_STATE/units/$1" +} + +current_state() { + cat "$STUB_STATE/units/$1" 2>/dev/null || echo inactive +} + +# Fails the test with the command output when a substring is missing +assert_output_contains() { + if [[ "$output" != *"$1"* ]]; then + echo "expected output to contain: $1" + echo "--- actual output:" + echo "$output" + return 1 + fi +} + +refute_output_contains() { + if [[ "$output" == *"$1"* ]]; then + echo "expected output NOT to contain: $1" + echo "--- actual output:" + echo "$output" + return 1 + fi +} diff --git a/tests/unit_file.bats b/tests/unit_file.bats new file mode 100644 index 0000000..b664448 --- /dev/null +++ b/tests/unit_file.bats @@ -0,0 +1,97 @@ +#!/usr/bin/env bats +# The generated ~/.config/systemd/user/sdc-*.service file + +# Single quotes below are intentional: the "$" must reach the files literally. +# shellcheck disable=SC2016 + +bats_require_minimum_version 1.5.0 + +load test_helper + +unit_file() { + echo "$UNIT_DIR/sdc-$1.service" +} + +@test "service file has the expected settings" { + make_app "$TEST_ROOT/app" app true 8000 src + STUB_LISTEN=8000 run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 0 ] + local f + f="$(unit_file app)" + grep -q "^X-SDC-Project=$TEST_ROOT/app$" "$f" + grep -q '^X-SDC-Port=8000$' "$f" + grep -q '^Type=exec$' "$f" + grep -q '^Restart=on-failure$' "$f" + grep -q '^StartLimitBurst=5$' "$f" + grep -q "^WorkingDirectory=$TEST_ROOT/app/src$" "$f" + grep -q "^EnvironmentFile=-$TEST_ROOT/app/.env$" "$f" + run ! grep -q "^After=" "$f" +} + +@test "background apps record no port" { + make_app "$TEST_ROOT/app" app false + echo "PORT=9000" > "$TEST_ROOT/app/.env" + run bash "$SDC" "$TEST_ROOT/app" run + grep -q '^X-SDC-Port=$' "$(unit_file app)" +} + +@test "special characters are escaped, not interpreted" { + make_app "$TEST_ROOT/app" app false + cat > "$TEST_ROOT/app/config.env" <<'EOF' +APP_NAME="app" +DESCRIPTION="Tom & Jerry | 100% \done" +ENTRYPOINT="python" +ARGS="--fmt '%h %r' --price '$9'" +REQUIRE_PORT="false" +EOF + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 0 ] + local f + f="$(unit_file app)" + grep -qF 'Description=Tom & Jerry | 100%% \done' "$f" + grep -qF -- "--fmt '%%h %%r'" "$f" +} + +@test "a literal dollar sign in ARGS is escaped in ExecStart" { + make_app "$TEST_ROOT/app" app false + printf 'TOKEN=abc$def\n' > "$TEST_ROOT/app/.env" + sed -i 's|^ARGS=.*|ARGS="--token $TOKEN"|' "$TEST_ROOT/app/config.env" + run bash "$SDC" "$TEST_ROOT/app" run + [ "$status" -eq 0 ] + grep -qF -- '--token abc$$def' "$(unit_file app)" +} + +@test "a project path with spaces is quoted" { + make_app "$TEST_ROOT/my app" app false + run bash "$SDC" "$TEST_ROOT/my app" run + [ "$status" -eq 0 ] + grep -qF "ExecStart=\"$TEST_ROOT/my app/venv/bin/python\"" "$(unit_file app)" + grep -qF "Environment=\"PATH=$TEST_ROOT/my app/venv/bin:" "$(unit_file app)" +} + +@test "a project path with a double quote is rejected" { + make_app "$TEST_ROOT/bad\"dir" app false + run bash "$SDC" "$TEST_ROOT/bad\"dir" run + [ "$status" -eq 1 ] + assert_output_contains "can't contain" +} + +@test "systemd accepts the generated file and parses ExecStart as intended" { + if ! command -v systemd-analyze >/dev/null; then + skip "systemd-analyze is not installed" + fi + make_app "$TEST_ROOT/my app" app false + ln -sf "$(type -P true)" "$TEST_ROOT/my app/venv/bin/python" + sed -i "s|^ARGS=.*|ARGS=\"--fmt '%h %r' --dir '\${INSTALL_DIR}'\"|" "$TEST_ROOT/my app/config.env" + run bash "$SDC" "$TEST_ROOT/my app" run + [ "$status" -eq 0 ] + + cp "$(unit_file app)" "$TEST_ROOT/sdc-app.service" + run systemd-analyze verify "$TEST_ROOT/sdc-app.service" + [ "$status" -eq 0 ] + + # The debug dump shows the command line after systemd has parsed it + run env SYSTEMD_LOG_LEVEL=debug systemd-analyze verify "$TEST_ROOT/sdc-app.service" + assert_output_contains "--fmt \"%h %r\"" + assert_output_contains "--dir \"$TEST_ROOT/my app\"" +}