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