diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d16400..9480949 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,9 +95,18 @@ jobs: "$HOME/.local/bin/towel" update && true; test $? -eq 64 ' + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install bats + run: sudo apt-get update && sudo apt-get install -y bats + - name: Run unit tests + run: bats --print-output-on-failure tests + release: if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: [shellcheck, shfmt, install-smoke] + needs: [shellcheck, shfmt, install-smoke, tests] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.releaserc.yml b/.releaserc.yml index d8d965e..b9a88b9 100644 --- a/.releaserc.yml +++ b/.releaserc.yml @@ -17,6 +17,7 @@ plugins: --exclude='*/node_modules' \ --exclude='./.pi' \ --exclude='./.opencode' \ + --exclude='./tests' \ --transform "s,^\\.,towel-${nextRelease.version}," \ -czf "towel-${nextRelease.version}.tar.gz" . sha256sum "towel-${nextRelease.version}.tar.gz" > "towel-${nextRelease.version}.tar.gz.sha256" diff --git a/AGENTS.md b/AGENTS.md index b7d9d5c..efc7bf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,10 @@ High-signal notes for OpenCode agents working in this repo. - `shellcheck -x install user/local/share/towel/bin/towel user/local/share/towel/bin/towel-*` - Format only shell scripts, not the whole repo: - `shfmt -w install user/local/share/towel/bin/towel user/local/share/towel/bin/towel-*` +- Run the unit tests (bats-core; `apt-get install -y bats`): + - `bats --print-output-on-failure tests` + - Tests source the scripts under `user/local/share/towel/bin/`; `towel-update` is + source-safe via a `(return 0 2>/dev/null) && return 0` guard before its imperative tail. ## Style conventions observed in code - Tabs are used for indentation in shell scripts. diff --git a/tests/cli.bats b/tests/cli.bats new file mode 100644 index 0000000..1bfae04 --- /dev/null +++ b/tests/cli.bats @@ -0,0 +1,46 @@ +#!/usr/bin/env bats +# +# Behavioural tests for the CLI entrypoints, run as real subprocesses. These +# lock in the documented exit-code contract (e.g. exit 64 on misuse) that the +# install smoke test partially relies on. + +setup() { + load helpers/load + export TOWEL_DATA="$BATS_TEST_TMPDIR/data" + export TOWEL_BIN_DIR + # Keep the dispatcher's auto update check from doing any network I/O. + export TOWEL_NO_UPDATE_CHECK=1 + mkdir -p "$TOWEL_DATA" +} + +@test "towel update with no mode flag exits 64" { + run "$TOWEL_BIN_DIR/towel-update" + [ "$status" -eq 64 ] + [[ "$output" == *"a mode flag is required"* ]] +} + +@test "towel update with an unknown argument exits 64" { + run "$TOWEL_BIN_DIR/towel-update" --definitely-not-a-flag + [ "$status" -eq 64 ] + [[ "$output" == *"Unknown argument"* ]] +} + +@test "towel update --help exits 0 and prints usage" { + run "$TOWEL_BIN_DIR/towel-update" --help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage:"* ]] +} + +@test "towel --version prints a semver-formatted line" { + printf '9.9.9' >"$TOWEL_DATA/VERSION" + run "$TOWEL_BIN_DIR/towel" --version + [ "$status" -eq 0 ] + [ "$output" = "towel 9.9.9" ] +} + +@test "towel with an unknown command exits 1" { + printf '9.9.9' >"$TOWEL_DATA/VERSION" + run "$TOWEL_BIN_DIR/towel" not-a-command + [ "$status" -eq 1 ] + [[ "$output" == *"Unknown command"* ]] +} diff --git a/tests/common_helpers.bats b/tests/common_helpers.bats new file mode 100644 index 0000000..9e01c18 --- /dev/null +++ b/tests/common_helpers.bats @@ -0,0 +1,98 @@ +#!/usr/bin/env bats +# +# Unit tests for the pure/near-pure helpers in towel-common. + +setup() { + load helpers/load + load_towel towel-common +} + +@test "inside_towel: true when CONTAINER_ID=towel" { + export CONTAINER_ID=towel + run inside_towel + [ "$status" -eq 0 ] +} + +@test "inside_towel: false when CONTAINER_ID is unset" { + unset CONTAINER_ID + run inside_towel + [ "$status" -ne 0 ] +} + +@test "inside_towel: false for a different container" { + export CONTAINER_ID=somethingelse + run inside_towel + [ "$status" -ne 0 ] +} + +@test "towel_exports_bin_dir is exports/bin under TOWEL_DATA" { + run towel_exports_bin_dir + [ "$status" -eq 0 ] + [ "$output" = "$TOWEL_DATA/exports/bin" ] +} + +@test "towel_exports_apps_dir is exports/applications under TOWEL_DATA" { + run towel_exports_apps_dir + [ "$output" = "$TOWEL_DATA/exports/applications" ] +} + +@test "towel_host_bin_dir is ~/.local/bin" { + local saved="$HOME" + HOME=/tmp/fakehome + run towel_host_bin_dir + HOME="$saved" + [ "$output" = "/tmp/fakehome/.local/bin" ] +} + +@test "towel_host_apps_dir is ~/.local/share/applications" { + local saved="$HOME" + HOME=/tmp/fakehome + run towel_host_apps_dir + HOME="$saved" + [ "$output" = "/tmp/fakehome/.local/share/applications" ] +} + +@test "towel_repoquery_exportables keeps bins and desktops, drops everything else" { + mkdir -p "$BATS_TEST_TMPDIR/bin" + cat >"$BATS_TEST_TMPDIR/bin/dnf" <<'EOF' +#!/usr/bin/env bash +# Emulate: dnf repoquery -l +cat <<'LIST' +/usr/bin/foo +/usr/bin/bar +/usr/share/applications/foo.desktop +/usr/lib/foo/libfoo.so +/etc/foo/foo.conf +/usr/share/doc/foo/README +LIST +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/dnf" + + local saved="$PATH" + PATH="$BATS_TEST_TMPDIR/bin:$PATH" + run towel_repoquery_exportables foo + PATH="$saved" + + [ "$status" -eq 0 ] + [[ "$output" == *"/usr/bin/foo"* ]] + [[ "$output" == *"/usr/bin/bar"* ]] + [[ "$output" == *"/usr/share/applications/foo.desktop"* ]] + [[ "$output" != *"/usr/lib/foo/libfoo.so"* ]] + [[ "$output" != *"/etc/foo/foo.conf"* ]] + [[ "$output" != *"/usr/share/doc/foo/README"* ]] +} + +@test "towel_export_bin_wrapper creates an executable wrapper and host symlink" { + HOME="$BATS_TEST_TMPDIR/home" + mkdir -p "$HOME" + + run towel_export_bin_wrapper /usr/bin/somecmd + [ "$status" -eq 0 ] + + local wrapper="$TOWEL_DATA/exports/bin/somecmd" + [ -f "$wrapper" ] + [ -x "$wrapper" ] + grep -q "command: somecmd" "$wrapper" + grep -q 'towel" exec "somecmd"' "$wrapper" + [ -L "$HOME/.local/bin/somecmd" ] +} diff --git a/tests/helpers/load.bash b/tests/helpers/load.bash new file mode 100644 index 0000000..6be1b10 --- /dev/null +++ b/tests/helpers/load.bash @@ -0,0 +1,23 @@ +# Shared helpers for the towel bats test suite. + +REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" +TOWEL_BIN_DIR="$REPO_ROOT/user/local/share/towel/bin" + +# Source a towel script into the current test shell with an isolated TOWEL_DATA. +# Usage: load_towel towel-common / load_towel towel-update +load_towel() { + export TOWEL_DATA="${TOWEL_DATA:-$BATS_TEST_TMPDIR/data}" + export TOWEL_BIN_DIR + + # towel/towel-update harden the shell with `enable`/`unalias`, which can + # return non-zero; bats runs setup under errexit, so disable it around the + # source (restoring it afterwards) to avoid an unrelated abort. + local errexit_was_set=0 + case $- in *e*) errexit_was_set=1 ;; esac + set +e + # shellcheck source=/dev/null + source "$TOWEL_BIN_DIR/$1" + local rc=$? + [ "$errexit_was_set" -eq 1 ] && set -e + return "$rc" +} diff --git a/tests/update_compare.bats b/tests/update_compare.bats new file mode 100644 index 0000000..3b64595 --- /dev/null +++ b/tests/update_compare.bats @@ -0,0 +1,51 @@ +#!/usr/bin/env bats +# +# Unit tests for _towel_update_compare (semver comparison). This is the +# function that decides whether a newer release exists, so a regression here +# is exactly the kind of bug that ships a "broken release". + +setup() { + load helpers/load + load_towel towel-update +} + +@test "compare: equal versions return eq" { + run _towel_update_compare 1.2.3 1.2.3 + [ "$status" -eq 0 ] + [ "$output" = "eq" ] +} + +@test "compare: lower patch returns lt" { + run _towel_update_compare 1.2.3 1.2.4 + [ "$output" = "lt" ] +} + +@test "compare: higher patch returns gt" { + run _towel_update_compare 1.2.4 1.2.3 + [ "$output" = "gt" ] +} + +@test "compare: lower minor returns lt" { + run _towel_update_compare 1.2.0 1.3.0 + [ "$output" = "lt" ] +} + +@test "compare: higher major returns gt" { + run _towel_update_compare 2.0.0 1.9.9 + [ "$output" = "gt" ] +} + +@test "compare: missing patch field defaults to zero (eq)" { + run _towel_update_compare 1.2 1.2.0 + [ "$output" = "eq" ] +} + +@test "compare: two-digit field compares numerically not lexically (1.10 > 1.9)" { + run _towel_update_compare 1.10.0 1.9.0 + [ "$output" = "gt" ] +} + +@test "compare: two-digit field, reversed (1.9 < 1.10)" { + run _towel_update_compare 1.9.0 1.10.0 + [ "$output" = "lt" ] +} diff --git a/user/local/share/towel/bin/towel-update b/user/local/share/towel/bin/towel-update index ae5d0ae..3358cc1 100755 --- a/user/local/share/towel/bin/towel-update +++ b/user/local/share/towel/bin/towel-update @@ -64,6 +64,10 @@ function _towel_fetch_latest_version() { sed -E 's/.*"v?([^"]+)"/\1/' } +# When sourced (e.g. by the test suite) stop here and expose the functions +# above only; the imperative tail below runs solely on direct execution. +(return 0 2>/dev/null) && return 0 + mode="" assume_yes="false" quiet="false"