From 5a86658d4043c2f7603d68b0c843ffc5f9dfb36a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 04:40:38 +0000 Subject: [PATCH] tests: real install round-trip on systemd, launchd, and Windows SCM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complements install-tests.yml (lint-only) with three drivers that actually install→activate→stop→uninstall the service on the platform's real service manager: tests/install/roundtrip-linux.sh systemctl --user enable --now; also kills MainPID and asserts Restart=on-failure respawns tests/install/roundtrip-macos.sh launchctl bootstrap gui/$UID with stub PATH= patched into the plist (launchd ignores user env) tests/install/roundtrip-windows.ps1 creates a throwaway local user, installs non-interactively via a new -Credential param on wsl-bolt-udp-forward.ps1, asserts SCM state, uninstalls, removes user in finally{} tests/install/stubs/{mix,deno} are sleep-forever stand-ins so the spawned units satisfy systemd/launchd's "Active" check without needing the full Elixir/Deno toolchain in CI. Drivers all clean up after themselves (trap EXIT preserves real exit code with `ec=$?` capture before cleanup; try/finally on the Windows side). scripts/wsl-bolt-udp-forward.ps1: new -Credential [PSCredential] parameter on -Install. When supplied (CI), skips the Get-Credential prompt. When omitted (default, human use), behaviour is unchanged. .github/workflows/install-roundtrip.yml: three-OS matrix — ubuntu-latest (with `loginctl enable-linger $USER` so user-systemd starts without a login session), macos-14, windows-latest. Same path-filter as install-tests.yml. run.sh's shellcheck pass now covers the new round-trip drivers too; caught the SC2154 false positive on `trap 'ec=$?; ...'` (disabled inline) and the unset-$USER bug surfaced under `set -u` on stripped containers. --- .github/workflows/install-roundtrip.yml | 81 ++++++++++++++ CHANGELOG.md | 20 ++++ scripts/wsl-bolt-udp-forward.ps1 | 31 ++++-- tests/install/README.md | 48 ++++++--- tests/install/roundtrip-linux.sh | 132 +++++++++++++++++++++++ tests/install/roundtrip-macos.sh | 128 ++++++++++++++++++++++ tests/install/roundtrip-windows.ps1 | 138 ++++++++++++++++++++++++ tests/install/run.sh | 10 +- tests/install/stubs/deno | 6 ++ tests/install/stubs/mix | 10 ++ 10 files changed, 575 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/install-roundtrip.yml create mode 100755 tests/install/roundtrip-linux.sh create mode 100755 tests/install/roundtrip-macos.sh create mode 100644 tests/install/roundtrip-windows.ps1 create mode 100755 tests/install/stubs/deno create mode 100755 tests/install/stubs/mix diff --git a/.github/workflows/install-roundtrip.yml b/.github/workflows/install-roundtrip.yml new file mode 100644 index 0000000..c283f41 --- /dev/null +++ b/.github/workflows/install-roundtrip.yml @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# install-roundtrip.yml — actual install→activate→stop→uninstall cycle +# for the cross-platform service install machinery, on a real systemd / +# launchd / Windows SCM. Complements install-tests.yml (lint-only). +# +# Each job: +# 1. Stubs `mix` and `deno` so the spawned unit doesn't need the full +# Elixir/Deno toolchain (which would make every job slow + flaky). +# 2. Runs the platform's roundtrip-*.sh / .ps1 driver. +# 3. Drivers always clean up after themselves (trap EXIT / try/finally). + +name: Install round-trip + +on: + push: + branches: [main, master, 'claude/**'] + paths: + - 'setup.sh' + - 'setup.ps1' + - 'scripts/install-service.sh' + - 'scripts/wsl-bolt-udp-forward.ps1' + - 'assets/services/**' + - 'tests/install/**' + - '.github/workflows/install-roundtrip.yml' + pull_request: + branches: [main, master] + paths: + - 'setup.sh' + - 'setup.ps1' + - 'scripts/install-service.sh' + - 'scripts/wsl-bolt-udp-forward.ps1' + - 'assets/services/**' + - 'tests/install/**' + - '.github/workflows/install-roundtrip.yml' + +permissions: + contents: read + +jobs: + linux-user-roundtrip: + name: Linux --user systemd round-trip + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Enable user-systemd lingering for runner + # GH Actions runs jobs without a real login session, so the + # per-user systemd instance isn't auto-started. `enable-linger` + # makes systemd-logind start it for the runner user even with + # no session. + run: | + sudo loginctl enable-linger "$USER" + # Wait for the user instance to come up. + for i in 1 2 3 4 5 6 7 8 9 10; do + if systemctl --user list-units >/dev/null 2>&1; then + echo "user-systemd ready (attempt $i)"; exit 0 + fi + sleep 1 + done + echo "user-systemd never became reachable"; exit 1 + + - name: Run round-trip + run: tests/install/roundtrip-linux.sh + + macos-roundtrip: + name: macOS launchd LaunchAgent round-trip + runs-on: macos-14 + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Run round-trip + run: tests/install/roundtrip-macos.sh + + windows-roundtrip: + name: Windows Service round-trip + runs-on: windows-latest + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Run round-trip + shell: pwsh + run: tests/install/roundtrip-windows.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d899a9..3300f0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Real install→activate→stop→uninstall round-trip tests for all three + service managers (complements the lint-only tests): + - `tests/install/roundtrip-linux.sh` — `systemctl --user` round-trip. + Also kills the unit's main PID and asserts `Restart=on-failure` + respawns it within `RestartSec=5`. + - `tests/install/roundtrip-macos.sh` — `launchctl bootstrap gui/$UID` + / `bootout` round-trip with stub `PATH=` patched into the plist + (launchd ignores the user shell's env). + - `tests/install/roundtrip-windows.ps1` — creates a throwaway local + user (`burble-ci-test`) with a random password, installs the + Windows Service non-interactively via a new `-Credential` + parameter on `wsl-bolt-udp-forward.ps1` (skips the + `Get-Credential` prompt), asserts SCM state, uninstalls, removes + the user in a `finally` block. + - `tests/install/stubs/{mix,deno}` — sleep-forever stand-ins so the + spawned units satisfy systemd/launchd's "Active" check without + needing the full Elixir/Deno toolchain in CI. + - `.github/workflows/install-roundtrip.yml` — CI matrix: + `ubuntu-latest` (with `loginctl enable-linger` for user-systemd), + `macos-14`, `windows-latest`. Path-filtered to install machinery. - `tests/install/run.sh` — cross-platform validation for the install machinery, safe to run anywhere. Renders systemd units in both system and user modes and checks invariants (no unsubstituted diff --git a/scripts/wsl-bolt-udp-forward.ps1 b/scripts/wsl-bolt-udp-forward.ps1 index fbfcea7..964f008 100755 --- a/scripts/wsl-bolt-udp-forward.ps1 +++ b/scripts/wsl-bolt-udp-forward.ps1 @@ -59,7 +59,10 @@ param( [Parameter(ParameterSetName = 'Status')] [switch]$Status, [string]$Distro, [int[]]$Ports = @(7373, 9), - [switch]$Firewall + [switch]$Firewall, + # Optional: pre-built PSCredential for non-interactive install (CI). + # When omitted, -Install prompts via Get-Credential as usual. + [System.Management.Automation.PSCredential]$Credential ) $ErrorActionPreference = 'Stop' @@ -301,7 +304,12 @@ function Compile-ServiceHost { } function Install-Service { - param([string]$Distro, [int[]]$Ports, [switch]$Firewall) + param( + [string]$Distro, + [int[]]$Ports, + [switch]$Firewall, + [System.Management.Automation.PSCredential]$Credential + ) Assert-Elevated -Action '-Install (creates a Windows Service)' $self = $PSCommandPath if (-not $self) { $self = $MyInvocation.MyCommand.Path } @@ -328,12 +336,17 @@ function Install-Service { Compile-ServiceHost - Write-Host "[bolt-fwd] WSL distros are per-user. The service needs to run under YOUR account" - Write-Host " so it can launch wsl.exe and see your distro. New-Service stores the" - Write-Host " password securely via LSA Secrets — you only enter it once." - $cred = Get-Credential -UserName "$env:USERDOMAIN\$env:USERNAME" ` - -Message "Password for $env:USERDOMAIN\$env:USERNAME (so the service can launch wsl.exe as you)" - if (-not $cred) { Write-Error "Cancelled — no credential supplied."; exit 1 } + if ($Credential) { + $cred = $Credential + Write-Host "[bolt-fwd] Using pre-supplied credential for $($cred.UserName) (non-interactive install)." + } else { + Write-Host "[bolt-fwd] WSL distros are per-user. The service needs to run under YOUR account" + Write-Host " so it can launch wsl.exe and see your distro. New-Service stores the" + Write-Host " password securely via LSA Secrets — you only enter it once." + $cred = Get-Credential -UserName "$env:USERDOMAIN\$env:USERNAME" ` + -Message "Password for $env:USERDOMAIN\$env:USERNAME (so the service can launch wsl.exe as you)" + if (-not $cred) { Write-Error "Cancelled — no credential supplied."; exit 1 } + } # Grant the service user Modify on the install dir — it lives under # %ProgramData% which is admin-only by default, but the service runs @@ -395,7 +408,7 @@ function Uninstall-Service { } switch ($PSCmdlet.ParameterSetName) { - 'Install' { Install-Service -Distro $Distro -Ports $Ports -Firewall:$Firewall } + 'Install' { Install-Service -Distro $Distro -Ports $Ports -Firewall:$Firewall -Credential $Credential } 'Uninstall' { Uninstall-Service } 'Status' { $ip = Resolve-WslIp -Distro $Distro diff --git a/tests/install/README.md b/tests/install/README.md index 05e55ff..3baeaea 100644 --- a/tests/install/README.md +++ b/tests/install/README.md @@ -36,20 +36,34 @@ not a failure. Install whichever are missing to widen coverage: Triggered on any change to the install machinery. -## What's NOT tested - -Real install/start/stop round-trip on a clean host: - -- **Linux systemd round-trip** — needs `loginctl enable-linger` + a real - user session bus in CI for `systemctl --user`, or root for system - units. Tractable but not wired up yet. -- **Windows `New-Service`** — needs a throwaway local user with `Log on - as a service` for the `-Credential` argument. Tractable but not wired - up yet. -- **macOS `launchctl bootstrap`** — would work in CI but requires the - Mix/Deno toolchains to be present for the spawned process to do - anything meaningful. - -Until those land, the lint suite catches everything we've actually hit -in practice (unsubstituted tokens, `AmbientCapabilities=` in user mode, -malformed PowerShell, missing csc.exe path). +## Round-trip tests (actually mutate the host) + +In addition to the lint suite, there are three platform-specific +drivers that do a real install → activate → stop → uninstall cycle +against the platform's service manager. They use stub `mix` / `deno` +binaries (`tests/install/stubs/`) so the spawned units survive long +enough to be Active without needing the full Elixir/Deno toolchain. + +| Driver | Platform | What it round-trips | +|---|---|---| +| `roundtrip-linux.sh` | Linux | `systemctl --user enable --now` round-trip (also kills the main PID and asserts `Restart=on-failure` respawns it) | +| `roundtrip-macos.sh` | macOS | `launchctl bootstrap gui/$UID` / `bootout` | +| `roundtrip-windows.ps1` | Windows | creates throwaway local user, installs Windows Service non-interactively via the new `-Credential` parameter on `wsl-bolt-udp-forward.ps1`, asserts SCM state, uninstalls, removes user | + +All three are idempotent and clean up after themselves on failure +(`trap EXIT` / `try { … } finally { … }`). They will mutate your host +for the duration of the test — safe locally if you're OK with a brief +service install. + +CI workflow `install-roundtrip.yml` runs them on: +- `ubuntu-latest` (with `loginctl enable-linger` to bring up user-systemd) +- `macos-14` (Apple Silicon) +- `windows-latest` + +## What's still NOT tested + +- The actual UDP forwarding (the Windows round-trip exercises install + + SCM state, not whether packets actually relay — that requires a + real WSL distro target). +- The Elixir/Deno app itself starting up correctly under systemd / + launchd — covered by `elixir-ci.yml`. diff --git a/tests/install/roundtrip-linux.sh b/tests/install/roundtrip-linux.sh new file mode 100755 index 0000000..ff0cba9 --- /dev/null +++ b/tests/install/roundtrip-linux.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# tests/install/roundtrip-linux.sh — full install→activate→stop→uninstall +# cycle for the Linux --user systemd path. +# +# Strategy: +# * stub `mix` and `deno` with a script that sleeps forever, so the +# unit's ExecStart succeeds without needing the Elixir/Deno toolchain +# * inject the stub dir into the systemd --user instance's PATH via +# `systemctl --user import-environment` +# * install via scripts/install-service.sh install --user +# * assert both units reach `active` +# * uninstall, assert both are gone +# +# Safe-ish locally: it WILL mutate ~/.config/systemd/user/ and start +# burble.service in your user-systemd session for the duration of the +# test. The uninstall step undoes both even on failure (trap EXIT). + +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +STUBS="$REPO_DIR/tests/install/stubs" +TMPLOG="$(mktemp -t burble-roundtrip-linux.XXXXXX.log)" +# shellcheck disable=SC2154 # ec is assigned inside the trap body +trap 'ec=$?; cleanup; exit $ec' EXIT + +PASS=0; FAIL=0 +pass() { printf ' \033[0;32mPASS\033[0m %s\n' "$1"; PASS=$((PASS+1)); } +fail() { printf ' \033[0;31mFAIL\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); } +hdr() { printf '\n\033[1;36m── %s ──\033[0m\n' "$1"; } + +cleanup() { + echo + hdr "Cleanup" + "$REPO_DIR/scripts/install-service.sh" uninstall >>"$TMPLOG" 2>&1 || true + rm -f "$TMPLOG" +} + +# ─── Preflight ──────────────────────────────────────────────────────────── +hdr "Preflight" +command -v systemctl >/dev/null || { echo "systemctl required"; exit 2; } +[ -x "$STUBS/mix" ] && [ -x "$STUBS/deno" ] || { echo "missing stubs/"; exit 2; } + +# Need a user systemd session. `systemctl --user` requires either an +# interactive login (which CI lacks) or `loginctl enable-linger`. If we +# don't have a working bus, abort with a clear message. +CUR_USER="${USER:-$(id -un)}" +if ! systemctl --user is-system-running >/dev/null 2>&1 && \ + ! systemctl --user list-units >/dev/null 2>&1; then + echo "No working systemd --user instance for $CUR_USER." + echo "On CI: sudo loginctl enable-linger $CUR_USER && sleep 2" + echo "On a dev box, log in interactively or run 'systemctl --user start default.target'" + exit 2 +fi +pass "systemd --user instance reachable" + +# ─── Inject stub PATH into the user-systemd environment ─────────────────── +hdr "Stub PATH" +export PATH="$STUBS:$PATH" +systemctl --user import-environment PATH +pass "PATH=$STUBS:... imported into user-systemd env" + +# ─── Install ────────────────────────────────────────────────────────────── +hdr "Install (--user)" +if "$REPO_DIR/scripts/install-service.sh" install --user >>"$TMPLOG" 2>&1; then + pass "install --user exit 0" +else + fail "install --user exit $?" + tail -20 "$TMPLOG" | sed 's/^/ /' +fi + +# Give systemd a moment to actually start the services. +sleep 2 + +# ─── Assert active ──────────────────────────────────────────────────────── +hdr "Assert active" +for unit in burble.service burble-ai-bridge.service; do + state=$(systemctl --user is-active "$unit" 2>/dev/null || echo unknown) + if [ "$state" = "active" ]; then + pass "$unit is active" + else + fail "$unit state=$state (expected active)" + systemctl --user status "$unit" --no-pager 2>&1 | sed 's/^/ /' | head -15 + fi +done + +# ─── Assert restart-on-failure works ────────────────────────────────────── +hdr "Restart-on-failure" +# Kill the burble.service main process; systemd should respawn it +# within RestartSec=5 because of Restart=on-failure. +mainpid=$(systemctl --user show -p MainPID --value burble.service 2>/dev/null) +if [ -n "$mainpid" ] && [ "$mainpid" != "0" ]; then + kill -9 "$mainpid" 2>/dev/null || true + sleep 8 + newpid=$(systemctl --user show -p MainPID --value burble.service 2>/dev/null) + if [ -n "$newpid" ] && [ "$newpid" != "0" ] && [ "$newpid" != "$mainpid" ]; then + pass "burble.service respawned after kill (pid $mainpid -> $newpid)" + else + fail "burble.service did not respawn (pid still $newpid)" + fi +else + fail "burble.service has no MainPID — cannot test restart" +fi + +# ─── Uninstall ──────────────────────────────────────────────────────────── +hdr "Uninstall" +if "$REPO_DIR/scripts/install-service.sh" uninstall >>"$TMPLOG" 2>&1; then + pass "uninstall exit 0" +else + fail "uninstall exit $?" + tail -20 "$TMPLOG" | sed 's/^/ /' +fi + +sleep 1 + +# ─── Assert gone ────────────────────────────────────────────────────────── +hdr "Assert removed" +for unit in burble.service burble-ai-bridge.service; do + if [ -f "$HOME/.config/systemd/user/$unit" ]; then + fail "$unit file still present after uninstall" + else + pass "$unit file removed" + fi +done + +# ─── Summary ────────────────────────────────────────────────────────────── +echo +echo "──────────────────────────────────────────" +printf 'Results: \033[0;32m%d pass\033[0m, \033[0;31m%d fail\033[0m\n' "$PASS" "$FAIL" +echo "──────────────────────────────────────────" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/tests/install/roundtrip-macos.sh b/tests/install/roundtrip-macos.sh new file mode 100755 index 0000000..a76f06e --- /dev/null +++ b/tests/install/roundtrip-macos.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# tests/install/roundtrip-macos.sh — full install→activate→uninstall +# cycle for the macOS launchd LaunchAgent path. +# +# Strategy mirrors roundtrip-linux.sh: stub mix/deno on PATH so the +# LaunchAgent's program survives long enough to be Active before we +# tear it down. The stub PATH is baked into the rendered plist via +# pre-rendering — launchd doesn't import the user shell's environment. + +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +STUBS="$REPO_DIR/tests/install/stubs" +TMPLOG="$(mktemp -t burble-roundtrip-macos.XXXXXX.log)" +# shellcheck disable=SC2154 # ec is assigned inside the trap body +trap 'ec=$?; cleanup; exit $ec' EXIT + +PASS=0; FAIL=0 +pass() { printf ' \033[0;32mPASS\033[0m %s\n' "$1"; PASS=$((PASS+1)); } +fail() { printf ' \033[0;31mFAIL\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); } +hdr() { printf '\n\033[1;36m── %s ──\033[0m\n' "$1"; } + +cleanup() { + echo + hdr "Cleanup" + "$REPO_DIR/scripts/install-service.sh" uninstall >>"$TMPLOG" 2>&1 || true + rm -f "$TMPLOG" + # Undo the PATH-injection hack if we used it. + git -C "$REPO_DIR" checkout -- assets/services/*.plist 2>/dev/null || true +} + +# ─── Preflight ──────────────────────────────────────────────────────────── +hdr "Preflight" +[ "$(uname -s)" = Darwin ] || { echo "macOS only"; exit 2; } +command -v launchctl >/dev/null || { echo "launchctl required"; exit 2; } +[ -x "$STUBS/mix" ] && [ -x "$STUBS/deno" ] || { echo "missing stubs/"; exit 2; } +pass "launchctl present, stubs ready" + +# ─── Inject stub PATH into the plists ───────────────────────────────────── +# launchd ignores the user shell's PATH, so add EnvironmentVariables.PATH +# directly into the plist source. The render step in install-service.sh +# will copy it verbatim into ~/Library/LaunchAgents/. +hdr "Patch plists with stub PATH" +patch_plist() { + local plist="$1" + if grep -q 'PATH' "$plist"; then return 0; fi + # Insert PATH=… into the EnvironmentVariables dict. If the dict + # doesn't exist, create one. + if grep -q 'EnvironmentVariables' "$plist"; then + # Append PATH… inside the existing dict. + sed -i.bak "s|EnvironmentVariables\\ + |EnvironmentVariables\\ + \\ + PATH\\ + $STUBS:/usr/bin:/bin|" "$plist" + else + # Insert before at the top level. + sed -i.bak "/<\/dict>/i\\ + EnvironmentVariables\\ + \\ + PATH\\ + $STUBS:/usr/bin:/bin\\ + " "$plist" + fi +} +for p in "$REPO_DIR/assets/services/com.hyperpolymath.burble.plist" \ + "$REPO_DIR/assets/services/com.hyperpolymath.burble.ai-bridge.plist"; do + patch_plist "$p" + plutil -lint "$p" >/dev/null 2>&1 && pass "$(basename "$p") still valid after patch" \ + || fail "$(basename "$p") invalid after patch" +done + +# ─── Install ────────────────────────────────────────────────────────────── +hdr "Install" +if "$REPO_DIR/scripts/install-service.sh" install >>"$TMPLOG" 2>&1; then + pass "install exit 0" +else + fail "install exit $?" + tail -20 "$TMPLOG" | sed 's/^/ /' +fi + +sleep 2 + +# ─── Assert loaded ──────────────────────────────────────────────────────── +hdr "Assert loaded" +for label in com.hyperpolymath.burble com.hyperpolymath.burble.ai-bridge; do + if launchctl print "gui/$UID/$label" >/dev/null 2>&1; then + pass "$label loaded in gui/$UID" + else + fail "$label not loaded" + launchctl print "gui/$UID/$label" 2>&1 | head -10 | sed 's/^/ /' + fi +done + +# ─── Uninstall ──────────────────────────────────────────────────────────── +hdr "Uninstall" +if "$REPO_DIR/scripts/install-service.sh" uninstall >>"$TMPLOG" 2>&1; then + pass "uninstall exit 0" +else + fail "uninstall exit $?" +fi + +sleep 1 + +# ─── Assert gone ────────────────────────────────────────────────────────── +hdr "Assert removed" +for label in com.hyperpolymath.burble com.hyperpolymath.burble.ai-bridge; do + if launchctl print "gui/$UID/$label" >/dev/null 2>&1; then + fail "$label still loaded after uninstall" + else + pass "$label gone" + fi + plist="$HOME/Library/LaunchAgents/$label.plist" + if [ -f "$plist" ]; then + fail "$plist still on disk" + else + pass "$plist removed from disk" + fi +done + +# ─── Summary ────────────────────────────────────────────────────────────── +echo +echo "──────────────────────────────────────────" +printf 'Results: \033[0;32m%d pass\033[0m, \033[0;31m%d fail\033[0m\n' "$PASS" "$FAIL" +echo "──────────────────────────────────────────" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/tests/install/roundtrip-windows.ps1 b/tests/install/roundtrip-windows.ps1 new file mode 100644 index 0000000..915cf1b --- /dev/null +++ b/tests/install/roundtrip-windows.ps1 @@ -0,0 +1,138 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# tests/install/roundtrip-windows.ps1 — full install→start→stop→ +# uninstall cycle for the Windows Service path in +# scripts/wsl-bolt-udp-forward.ps1 -Install. +# +# Strategy: +# * create a throwaway local user `burble-ci-test` with a random +# password and add it to the Users group +# * pass a pre-built PSCredential to -Install (skips Get-Credential +# interactive prompt — that's why we added the -Credential param) +# * assert the service installs, Get-Service shows Stopped/Running, +# Start-Service works (or fails benignly because wsl.exe can't +# resolve a distro in CI — that's fine, we only care about the +# SCM install/uninstall round-trip) +# * Uninstall, assert Get-Service no longer finds the service +# * Always remove the throwaway user in a `finally` block +# +# Run from an elevated PowerShell. Mostly intended for CI +# (windows-latest); also runnable on a dev box if you don't mind the +# transient local user. + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +$RepoDir = (Resolve-Path "$PSScriptRoot\..\..").Path +$Forwarder = Join-Path $RepoDir 'scripts\wsl-bolt-udp-forward.ps1' +$SvcName = 'BurbleBoltUdpForward' +$TestUser = 'burble-ci-test' + +$script:Pass = 0; $script:Fail = 0 +function Pass($m) { Write-Host (" PASS {0}" -f $m) -ForegroundColor Green; $script:Pass++ } +function Fail($m) { Write-Host (" FAIL {0}" -f $m) -ForegroundColor Red; $script:Fail++ } +function Hdr($m) { Write-Host ""; Write-Host ("── {0} ──" -f $m) -ForegroundColor Cyan } + +# ─── Preflight ──────────────────────────────────────────────────────────── +Hdr "Preflight" +$elev = ([Security.Principal.WindowsPrincipal] ` + [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltinRole]::Administrator) +if (-not $elev) { Write-Error "Must run elevated."; exit 2 } +Pass "elevated" + +if (-not (Test-Path $Forwarder)) { + Write-Error "Forwarder script not found at $Forwarder"; exit 2 +} +Pass "forwarder script found" + +# ─── Create throwaway local user ────────────────────────────────────────── +Hdr "Create throwaway local user" +$Plain = -join ((33..126) | Get-Random -Count 24 | ForEach-Object { [char]$_ }) +# Strip characters that the SCM's credential parser dislikes +$Plain = ($Plain -replace '[\\"`]', 'x') +$SecurePw = ConvertTo-SecureString $Plain -AsPlainText -Force + +try { + Remove-LocalUser -Name $TestUser -ErrorAction SilentlyContinue + New-LocalUser -Name $TestUser -Password $SecurePw ` + -AccountNeverExpires -PasswordNeverExpires ` + -Description 'Burble install round-trip test user — safe to delete' | Out-Null + Add-LocalGroupMember -Group 'Users' -Member $TestUser -ErrorAction SilentlyContinue + Pass "created local user $TestUser" + + $Cred = New-Object System.Management.Automation.PSCredential( + ".\$TestUser", $SecurePw) + + # ─── Install via -Credential (non-interactive) ─────────────────────── + Hdr "Install service" + try { + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $Forwarder ` + -Install -Credential $Cred -Distro 'Ubuntu' -Ports 7373,9 + if ($LASTEXITCODE -eq 0) { Pass "install exit 0" } + else { Fail "install exit $LASTEXITCODE" } + } catch { + Fail "install threw: $($_.Exception.Message)" + } + + Start-Sleep -Seconds 2 + + # ─── Assert service registered ─────────────────────────────────────── + Hdr "Assert service registered" + $svc = Get-Service -Name $SvcName -ErrorAction SilentlyContinue + if ($svc) { + Pass "Get-Service finds $SvcName (status=$($svc.Status), startup=$($svc.StartType))" + } else { + Fail "Get-Service did not find $SvcName" + } + + # The relay child will likely crash in CI because wsl.exe can't + # resolve a real distro IP. That's expected — we only care that the + # SCM successfully launched the service host. We tolerate either + # Running (briefly) or Stopped (after the child died and the + # service host exited too). + if ($svc -and $svc.Status -in @('Running','Stopped','StartPending')) { + Pass "service reached a known state ($($svc.Status))" + } + + # ─── Try to stop cleanly ───────────────────────────────────────────── + Hdr "Stop service" + Stop-Service -Name $SvcName -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + $svc = Get-Service -Name $SvcName -ErrorAction SilentlyContinue + if ($svc.Status -eq 'Stopped') { Pass "service stopped" } + else { Fail "service status=$($svc.Status) after Stop" } + + # ─── Uninstall ─────────────────────────────────────────────────────── + Hdr "Uninstall" + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $Forwarder -Uninstall + if ($LASTEXITCODE -eq 0) { Pass "uninstall exit 0" } + else { Fail "uninstall exit $LASTEXITCODE" } + + Start-Sleep -Seconds 1 + $svc = Get-Service -Name $SvcName -ErrorAction SilentlyContinue + if (-not $svc) { Pass "service removed (Get-Service finds nothing)" } + else { Fail "service still present: $($svc.Status)" } + +} finally { + # ─── Cleanup — always remove the throwaway user ────────────────────── + Hdr "Cleanup" + try { + & sc.exe delete $SvcName | Out-Null + } catch {} + try { + Remove-LocalUser -Name $TestUser -ErrorAction SilentlyContinue + Pass "removed throwaway user $TestUser" + } catch { + Write-Host " WARN failed to remove $TestUser: $($_.Exception.Message)" -ForegroundColor Yellow + } +} + +# ─── Summary ────────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "──────────────────────────────────────────" +Write-Host ("Results: {0} pass, {1} fail" -f $script:Pass, $script:Fail) -ForegroundColor $(if ($script:Fail -eq 0) { 'Green' } else { 'Red' }) +Write-Host "──────────────────────────────────────────" +if ($script:Fail -ne 0) { exit 1 } diff --git a/tests/install/run.sh b/tests/install/run.sh index 7e2ad5b..64757e1 100755 --- a/tests/install/run.sh +++ b/tests/install/run.sh @@ -23,13 +23,16 @@ section() { printf '\n\033[1;36m── %s ──\033[0m\n' "$1"; } # ─── 1. Shell syntax + shellcheck ───────────────────────────────────────── section "Shell scripts" -for f in setup.sh scripts/install-service.sh; do +SHELL_FILES=(setup.sh scripts/install-service.sh + tests/install/run.sh tests/install/roundtrip-linux.sh + tests/install/roundtrip-macos.sh) +for f in "${SHELL_FILES[@]}"; do if bash -n "$REPO_DIR/$f" 2>/dev/null; then pass "bash -n $f" else fail "bash -n $f"; fi done if command -v shellcheck >/dev/null 2>&1; then - for f in setup.sh scripts/install-service.sh; do + for f in "${SHELL_FILES[@]}"; do # SC1091: don't try to follow optionally-sourced files # SC2086: word splitting is intentional in arg arrays if shellcheck -S warning -e SC1091 -e SC2086 "$REPO_DIR/$f" >"$TMP/sc.out" 2>&1; then @@ -173,7 +176,8 @@ done # ─── 4. PowerShell scripts (syntax + analyzer) ──────────────────────────── section "PowerShell scripts" -PS_FILES=(setup.ps1 scripts/wsl-bolt-udp-forward.ps1) +PS_FILES=(setup.ps1 scripts/wsl-bolt-udp-forward.ps1 + tests/install/roundtrip-windows.ps1) PWSH="" command -v pwsh >/dev/null 2>&1 && PWSH=pwsh [ -z "$PWSH" ] && command -v powershell >/dev/null 2>&1 && PWSH=powershell diff --git a/tests/install/stubs/deno b/tests/install/stubs/deno new file mode 100755 index 0000000..d582c38 --- /dev/null +++ b/tests/install/stubs/deno @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# Test stub for `deno` — see stubs/mix for rationale. +echo "[stub deno] $* (sleeping forever to keep the unit Active)" +exec sleep infinity diff --git a/tests/install/stubs/mix b/tests/install/stubs/mix new file mode 100755 index 0000000..8cf1175 --- /dev/null +++ b/tests/install/stubs/mix @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# Test stub for `mix` used by tests/install/roundtrip-*.sh. The install +# round-trip tests don't care whether Phoenix actually starts — they +# care whether the unit installs, activates, deactivates, and uninstalls +# cleanly. Sleeping forever satisfies systemd's "service is running" +# check without needing the full Elixir/Erlang toolchain. +echo "[stub mix] $* (sleeping forever to keep the unit Active)" +exec sleep infinity