Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions tests/cli.bats
Original file line number Diff line number Diff line change
@@ -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" ]
}
107 changes: 107 additions & 0 deletions tests/dashboard.bats
Original file line number Diff line number Diff line change
@@ -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" ]
}
58 changes: 58 additions & 0 deletions tests/env_file.bats
Original file line number Diff line number Diff line change
@@ -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"
}
Loading
Loading