test(cli): port the E2E suite to cross-platform Go; slim the Docker harness
Replaces the growing bash smoke test with a Go os/exec suite behind the `e2e` build tag (backend/internal/cli/e2e_test.go). It builds the real binary and drives start/status/doctor/stop + the daemon-control HTTP surface against isolated state (temp dir + OS-assigned free port), and now runs natively on ubuntu + macOS + WINDOWS in CI — finally covering the Windows CREATE_NEW_PROCESS_GROUP detach path and per-OS os.UserConfigDir resolution that a Linux container can't observe. `go test -tags e2e -v` logs every command and its output, replacing the bash -v flag. - backend/internal/cli/e2e_test.go: 8 table-style TestE2E_* cases; strips any inherited AO_* env so a real daemon's AO_PORT can't leak in. - test/cli/install-check.sh: small, linear fresh-install proof the Dockerfile runs (binary on PATH, no toolchain) — kept as the hardening tier. - test/cli/Dockerfile: run install-check.sh instead of the full bash suite. - .github/workflows/cli-e2e.yml: `native` is now a go test matrix over ubuntu+macos+windows; `container` builds the image and runs it with --init. - Removes test/cli/smoke.sh and test/cli/run-local.sh (superseded by `go test`). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4f13dd1b83
commit
c0bf99eb22
|
|
@ -13,17 +13,20 @@ permissions:
|
|||
contents: read
|
||||
|
||||
jobs:
|
||||
# Primary tier: run the REAL `ao` binary on GitHub's native VM runners. These
|
||||
# runners are the "VMs" — the only place that exercises the OS-specific code
|
||||
# paths (unix Setsid process-group detach + macOS os.UserConfigDir resolution).
|
||||
# Bash is available on both ubuntu and macos runners, so the one smoke.sh runs
|
||||
# unchanged. State is isolated per run (own temp dir + a free loopback port).
|
||||
# Primary tier: the cross-platform Go E2E suite (build tag `e2e`) runs the real
|
||||
# `ao` binary against isolated state on every OS GitHub hosts. These runners
|
||||
# are the "VMs" — the only place that exercises the OS-specific process-detach
|
||||
# paths (unix Setsid vs Windows CREATE_NEW_PROCESS_GROUP) and os.UserConfigDir
|
||||
# resolution. The suite builds its own binary and self-allocates a free port.
|
||||
native:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -32,25 +35,21 @@ jobs:
|
|||
go-version: "1.25"
|
||||
cache: false
|
||||
|
||||
- name: Build ao
|
||||
run: cd backend && CGO_ENABLED=0 go build -trimpath -o "$RUNNER_TEMP/ao" ./cmd/ao
|
||||
- name: CLI E2E (native)
|
||||
run: go test -tags e2e -v ./internal/cli/...
|
||||
|
||||
- name: CLI smoke test
|
||||
run: AO_BIN="$RUNNER_TEMP/ao" bash test/cli/smoke.sh
|
||||
|
||||
# Secondary hardening tier: model "install ao on a fresh machine" in a clean,
|
||||
# locked-down Linux container with no access to a developer's real state.
|
||||
# --init gives the container a real PID-1 reaper (tini) so the live daemon the
|
||||
# `start` test spawns (it detaches via setsid) is reaped after `stop` rather
|
||||
# than lingering as a zombie. The suite doesn't depend on it (the stale case
|
||||
# uses a fabricated dead PID), but it keeps process accounting clean.
|
||||
# Secondary hardening tier: prove that a freshly installed binary works on a
|
||||
# clean machine with no Go toolchain and no developer state. The Dockerfile
|
||||
# installs `ao` on PATH in a slim image and runs test/cli/install-check.sh.
|
||||
# --init gives a real PID-1 reaper so the daemon the check starts is reaped
|
||||
# after `stop` instead of lingering as a zombie.
|
||||
container:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build smoke image (fresh-machine install)
|
||||
- name: Build fresh-install image
|
||||
run: docker build -f test/cli/Dockerfile -t ao-cli-smoke .
|
||||
|
||||
- name: Run CLI smoke test in container
|
||||
- name: Fresh-install check (container)
|
||||
run: docker run --rm --init ao-cli-smoke
|
||||
|
|
|
|||
|
|
@ -0,0 +1,346 @@
|
|||
//go:build e2e
|
||||
|
||||
// Package cli_test holds the end-to-end suite for the `ao` CLI. It builds the
|
||||
// real binary and drives it (start/status/doctor/stop + the daemon-control HTTP
|
||||
// surface) against fully isolated state — a per-test temp run-file, data dir,
|
||||
// and an OS-assigned free loopback port — so it never touches a developer's real
|
||||
// AO install. Unlike the Linux-only container smoke test, this runs natively on
|
||||
// every OS in CI (ubuntu/macos/windows), which is the only way to exercise the
|
||||
// unix setsid vs Windows CREATE_NEW_PROCESS_GROUP detach paths and the per-OS
|
||||
// os.UserConfigDir resolution.
|
||||
//
|
||||
// It is gated behind the `e2e` build tag so it never runs in the normal
|
||||
// `go test ./...` lane (it spawns processes and binds ports):
|
||||
//
|
||||
// go test -tags e2e ./internal/cli/... # run it
|
||||
// go test -tags e2e -v -run TestE2E ./internal/cli/... # verbose, see every command
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// aoBin is the path to the binary built once for the whole suite.
|
||||
var aoBin string
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "ao-e2e-bin")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "e2e: mktemp:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
aoBin = filepath.Join(dir, "ao")
|
||||
if runtime.GOOS == "windows" {
|
||||
aoBin += ".exe"
|
||||
}
|
||||
build := exec.Command("go", "build", "-o", aoBin, "github.com/aoagents/agent-orchestrator/backend/cmd/ao")
|
||||
build.Stdout, build.Stderr = os.Stderr, os.Stderr
|
||||
if err := build.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "e2e: build ao:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// env is an isolated CLI environment: its own state files and free port.
|
||||
type env struct {
|
||||
runFile string
|
||||
dataDir string
|
||||
port int
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) env {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
return env{
|
||||
runFile: filepath.Join(dir, "running.json"),
|
||||
dataDir: filepath.Join(dir, "data"),
|
||||
port: freePort(t),
|
||||
}
|
||||
}
|
||||
|
||||
// environ builds the child env: the ambient environment with every inherited
|
||||
// AO_* var stripped (so a real daemon's AO_PORT can't leak in) plus our isolated
|
||||
// settings. portOverride, when non-empty, replaces the numeric AO_PORT — used to
|
||||
// inject an invalid value.
|
||||
func (e env) environ(portOverride string) []string {
|
||||
out := make([]string, 0, len(os.Environ())+3)
|
||||
for _, kv := range os.Environ() {
|
||||
if strings.HasPrefix(kv, "AO_") {
|
||||
continue
|
||||
}
|
||||
out = append(out, kv)
|
||||
}
|
||||
port := fmt.Sprintf("%d", e.port)
|
||||
if portOverride != "" {
|
||||
port = portOverride
|
||||
}
|
||||
return append(out, "AO_RUN_FILE="+e.runFile, "AO_DATA_DIR="+e.dataDir, "AO_PORT="+port)
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("alloc free port: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// run executes `ao args...` in env e and returns combined output + exit code.
|
||||
func (e env) run(t *testing.T, args ...string) (string, int) {
|
||||
t.Helper()
|
||||
return e.runEnv(t, e.environ(""), args...)
|
||||
}
|
||||
|
||||
func (e env) runEnv(t *testing.T, environ []string, args ...string) (string, int) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(aoBin, args...)
|
||||
cmd.Env = environ
|
||||
b, err := cmd.CombinedOutput()
|
||||
out := string(b)
|
||||
code := 0
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if asExit(err, &ee) {
|
||||
code = ee.ExitCode()
|
||||
} else {
|
||||
t.Fatalf("run %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
t.Logf("$ ao %s\n%s(exit %d)", strings.Join(args, " "), out, code)
|
||||
return out, code
|
||||
}
|
||||
|
||||
func asExit(err error, target **exec.ExitError) bool {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
*target = ee
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// startDaemon brings the daemon up and registers a stop on cleanup.
|
||||
func (e env) startDaemon(t *testing.T) {
|
||||
t.Helper()
|
||||
out, code := e.run(t, "start")
|
||||
if code != 0 {
|
||||
t.Fatalf("start failed (exit %d): %s", code, out)
|
||||
}
|
||||
t.Cleanup(func() { e.run(t, "stop") })
|
||||
}
|
||||
|
||||
func mustContain(t *testing.T, out, want string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("expected output to contain %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
func mustNotContain(t *testing.T, out, notWant string) {
|
||||
t.Helper()
|
||||
if strings.Contains(out, notWant) {
|
||||
t.Fatalf("expected output NOT to contain %q; got:\n%s", notWant, out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestE2E_VersionAndHelp(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
|
||||
if out, code := e.run(t, "version"); code != 0 || strings.TrimSpace(out) == "" {
|
||||
t.Fatalf("version: exit %d, out %q", code, out)
|
||||
}
|
||||
if _, code := e.run(t, "--version"); code != 0 {
|
||||
t.Fatalf("--version exit %d", code)
|
||||
}
|
||||
|
||||
out, code := e.run(t, "--help")
|
||||
if code != 0 {
|
||||
t.Fatalf("--help exit %d", code)
|
||||
}
|
||||
for _, want := range []string{"start", "stop", "status", "doctor", "completion", "version"} {
|
||||
mustContain(t, out, want)
|
||||
}
|
||||
// the internal daemon command is hidden from help (rendered as "\n daemon")
|
||||
mustNotContain(t, out, "\n daemon")
|
||||
}
|
||||
|
||||
func TestE2E_DoctorDoesNotTouchTheStore(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
|
||||
out, code := e.run(t, "doctor")
|
||||
if code != 0 {
|
||||
t.Fatalf("doctor (fresh) exit %d: %s", code, out)
|
||||
}
|
||||
mustContain(t, out, "git")
|
||||
mustContain(t, out, "database not created yet") // sqlite WARN, never migrated
|
||||
|
||||
// doctor must NOT create/migrate the DB — the daemon is the sole writer.
|
||||
if _, err := os.Stat(filepath.Join(e.dataDir, "ao.db")); err == nil {
|
||||
t.Fatal("doctor created ao.db; the CLI must not open/migrate the store")
|
||||
}
|
||||
|
||||
if out, code := e.run(t, "doctor", "--json"); code != 0 || !strings.Contains(out, `"ok": true`) {
|
||||
t.Fatalf("doctor --json: exit %d, out %s", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_StatusStopped(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
out, code := e.run(t, "status", "--json")
|
||||
if code != 0 { // status always exits 0
|
||||
t.Fatalf("status exit %d", code)
|
||||
}
|
||||
mustContain(t, out, `"state": "stopped"`)
|
||||
mustNotContain(t, out, "startedAt")
|
||||
|
||||
if out, code := e.run(t, "stop"); code != 0 || !strings.Contains(out, "stopped") {
|
||||
t.Fatalf("stop-when-stopped: exit %d, out %s", code, out) // idempotent
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Lifecycle(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.startDaemon(t)
|
||||
|
||||
out, _ := e.run(t, "status", "--json")
|
||||
mustContain(t, out, `"state": "ready"`)
|
||||
mustContain(t, out, fmt.Sprintf(`"port": %d`, e.port))
|
||||
|
||||
// idempotent
|
||||
if out, code := e.run(t, "start"); code != 0 || !strings.Contains(out, "ready") {
|
||||
t.Fatalf("idempotent start: exit %d, out %s", code, out)
|
||||
}
|
||||
|
||||
// now the daemon (not the CLI) has created + migrated the store
|
||||
if _, err := os.Stat(filepath.Join(e.dataDir, "ao.db")); err != nil {
|
||||
t.Fatalf("daemon should have created ao.db: %v", err)
|
||||
}
|
||||
out, _ = e.run(t, "doctor")
|
||||
mustContain(t, out, "migrations are applied by the daemon")
|
||||
|
||||
// /healthz identity
|
||||
body := httpGet(t, e.port, "/healthz")
|
||||
mustContain(t, body, "agent-orchestrator-daemon")
|
||||
|
||||
if out, code := e.run(t, "stop"); code != 0 || !strings.Contains(out, "stopped") {
|
||||
t.Fatalf("stop: exit %d, out %s", code, out)
|
||||
}
|
||||
if _, err := os.Stat(e.runFile); !os.IsNotExist(err) {
|
||||
t.Fatal("run-file should be removed after stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_ShutdownGuard(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.startDaemon(t)
|
||||
|
||||
// A cross-site Origin header must be rejected without stopping the daemon.
|
||||
if code := postShutdown(t, e.port, func(r *http.Request) { r.Header.Set("Origin", "https://evil.example") }); code != http.StatusForbidden {
|
||||
t.Fatalf("cross-origin /shutdown = %d, want 403", code)
|
||||
}
|
||||
// A non-loopback Host (DNS-rebinding) must be rejected too.
|
||||
if code := postShutdown(t, e.port, func(r *http.Request) { r.Host = "evil.example" }); code != http.StatusForbidden {
|
||||
t.Fatalf("rebinding-host /shutdown = %d, want 403", code)
|
||||
}
|
||||
// The daemon survived both.
|
||||
out, _ := e.run(t, "status", "--json")
|
||||
mustContain(t, out, `"state": "ready"`)
|
||||
}
|
||||
|
||||
func TestE2E_StaleRunFile(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// PID 2147483647 is never alive -> the CLI must classify this as stale.
|
||||
content := fmt.Sprintf(`{"pid":2147483647,"port":%d,"startedAt":"2020-01-01T00:00:00Z"}`, e.port)
|
||||
if err := os.MkdirAll(filepath.Dir(e.runFile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(e.runFile, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, _ := e.run(t, "status", "--json")
|
||||
mustContain(t, out, `"state": "stale"`)
|
||||
|
||||
if out, code := e.run(t, "stop"); code != 0 || !strings.Contains(out, "stopped") {
|
||||
t.Fatalf("stop stale: exit %d, out %s", code, out)
|
||||
}
|
||||
if _, err := os.Stat(e.runFile); !os.IsNotExist(err) {
|
||||
t.Fatal("stale run-file should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_ExitCodes(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
|
||||
if _, code := e.run(t, "status", "--definitely-not-a-flag"); code != 2 {
|
||||
t.Fatalf("bad flag exit %d, want 2", code)
|
||||
}
|
||||
if _, code := e.run(t, "completion"); code != 2 { // missing required arg
|
||||
t.Fatalf("missing-arg exit %d, want 2", code)
|
||||
}
|
||||
if _, code := e.run(t, "completion", "notashell"); code == 0 { // runtime error
|
||||
t.Fatal("unsupported shell should be non-zero")
|
||||
}
|
||||
// invalid config is a runtime error (1), not a usage error (2).
|
||||
if _, code := e.runEnv(t, e.environ("notaport"), "status"); code != 1 {
|
||||
t.Fatalf("invalid AO_PORT exit %d, want 1", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Completion(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
for _, sh := range []string{"bash", "zsh", "fish", "powershell"} {
|
||||
out, code := e.run(t, "completion", sh)
|
||||
if code != 0 || strings.TrimSpace(out) == "" {
|
||||
t.Fatalf("completion %s: exit %d, empty=%v", sh, code, strings.TrimSpace(out) == "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers (loopback)
|
||||
|
||||
func httpClient() *http.Client { return &http.Client{Timeout: 3 * time.Second} }
|
||||
|
||||
func httpGet(t *testing.T, port int, path string) string {
|
||||
t.Helper()
|
||||
resp, err := httpClient().Get(fmt.Sprintf("http://127.0.0.1:%d%s", port, path))
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b := make([]byte, 4096)
|
||||
n, _ := resp.Body.Read(b)
|
||||
return string(b[:n])
|
||||
}
|
||||
|
||||
// postShutdown issues POST /shutdown with mutator applied, returns the status code.
|
||||
func postShutdown(t *testing.T, port int, mutate func(*http.Request)) int {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/shutdown", port), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mutate(req)
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST /shutdown: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
|
@ -33,15 +33,15 @@ RUN apt-get update \
|
|||
|
||||
# "Install" the CLI the way a user would: drop the binary on PATH.
|
||||
COPY --from=build /out/ao /usr/local/bin/ao
|
||||
COPY test/cli/smoke.sh /usr/local/bin/ao-smoke.sh
|
||||
RUN chmod +x /usr/local/bin/ao /usr/local/bin/ao-smoke.sh
|
||||
COPY test/cli/install-check.sh /usr/local/bin/ao-install-check.sh
|
||||
RUN chmod +x /usr/local/bin/ao /usr/local/bin/ao-install-check.sh
|
||||
|
||||
# Run as an unprivileged user with a real HOME, like a normal install.
|
||||
RUN useradd --create-home --shell /bin/bash ao
|
||||
USER ao
|
||||
WORKDIR /home/ao
|
||||
|
||||
# Sanity: prove the install resolved before the suite runs.
|
||||
# Sanity: prove the install resolved before the check runs.
|
||||
RUN ao version
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/ao-smoke.sh"]
|
||||
ENTRYPOINT ["/usr/local/bin/ao-install-check.sh"]
|
||||
|
|
|
|||
|
|
@ -1,75 +1,65 @@
|
|||
# `ao` CLI end-to-end tests
|
||||
|
||||
These tests install and drive the **real `ao` binary** the way a user would —
|
||||
`start` → `status` → `doctor` → `stop`, plus the daemon-control HTTP surface —
|
||||
and assert the whole thing works end to end. They run against **isolated,
|
||||
throwaway state** (their own temp run-file + data dir + a free loopback port),
|
||||
so they never touch a developer's real AO installation.
|
||||
These tests drive the **real `ao` binary** the way a user would — `start` →
|
||||
`status` → `doctor` → `stop`, plus the daemon-control HTTP surface — and assert
|
||||
the whole thing works. They run against **isolated, throwaway state** (a per-test
|
||||
temp run-file + data dir + an OS-assigned free loopback port), so they never
|
||||
touch a developer's real AO installation.
|
||||
|
||||
## Files
|
||||
## Two tiers
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `smoke.sh` | The suite. Host-agnostic bash; drives the binary at `$AO_BIN` (default `ao` on PATH) and prints a PASS/FAIL line per assertion. |
|
||||
| `Dockerfile` | Models **installing `ao` on a fresh machine**: builds the binary, drops it on `PATH` in a clean Debian image with only runtime deps (`git`, `tmux`, `curl`), then runs `smoke.sh` as a non-root user. |
|
||||
| `run-local.sh` | Convenience wrapper: build from source and run `smoke.sh` natively against a temp binary. |
|
||||
| Tier | What | Where |
|
||||
|------|------|-------|
|
||||
| **Comprehensive (primary)** | A cross-platform Go suite that builds `ao` and exercises the full behaviour. Runs natively on **ubuntu + macOS + windows** — the only way to cover the OS-specific process-detach paths (`setsid` vs `CREATE_NEW_PROCESS_GROUP`) and `os.UserConfigDir()` resolution. | `backend/internal/cli/e2e_test.go` (build tag `e2e`) |
|
||||
| **Fresh-install (hardening)** | Proves a freshly installed binary works on a clean machine with no Go toolchain and no developer state. | `test/cli/Dockerfile` + `test/cli/install-check.sh` |
|
||||
|
||||
## Run it
|
||||
|
||||
**Native (fastest, uses your toolchain):**
|
||||
**The Go suite (fastest, cross-platform):**
|
||||
```bash
|
||||
test/cli/run-local.sh # PASS/FAIL per assertion
|
||||
test/cli/run-local.sh -v # also print every command and its full output
|
||||
# or, against a binary you already built:
|
||||
AO_BIN=/path/to/ao test/cli/smoke.sh [-v]
|
||||
cd backend
|
||||
go test -tags e2e ./internal/cli/... # run it
|
||||
go test -tags e2e -v -run TestE2E ./internal/cli/... # verbose: prints every command + output
|
||||
```
|
||||
It builds its own `ao` binary; `git` must be on PATH (required by `doctor`).
|
||||
`-v` logs each `ao` invocation and its full output, which is the audit trail you
|
||||
get for free from `go test`.
|
||||
|
||||
**Fresh-machine install, in a clean container:**
|
||||
```bash
|
||||
docker build -f test/cli/Dockerfile -t ao-cli-smoke .
|
||||
docker run --rm --init ao-cli-smoke
|
||||
```
|
||||
> `--init` gives the container a real PID-1 reaper (tini) so the live daemon
|
||||
> spawned during the `start` test is reaped after `stop` instead of lingering as
|
||||
> a zombie. The suite itself doesn't depend on it — the stale-daemon case uses a
|
||||
> fabricated dead PID — but it keeps process accounting clean.
|
||||
> `--init` gives the container a real PID-1 reaper (tini) so the daemon the
|
||||
> check starts is reaped after `stop` instead of lingering as a zombie.
|
||||
|
||||
## What it covers
|
||||
## What the Go suite covers
|
||||
|
||||
Install resolves on PATH · `version`/`--version` · `--help` (and hides the
|
||||
internal `daemon` command) · `doctor` text + `--json` (and that it **does not**
|
||||
open/migrate SQLite) · `status` stopped/stale/ready · `start` (fresh +
|
||||
idempotent) · daemon-created store · `/healthz` identity · the `/shutdown`
|
||||
CSRF/DNS-rebinding guard (403 + daemon survives) · `stop` (graceful + stale +
|
||||
idempotent) · run-file cleanup/ownership · exit codes (`2` usage, `1` runtime) ·
|
||||
completion for all four shells.
|
||||
`TestE2E_VersionAndHelp` (version/`--version`/help, daemon hidden) ·
|
||||
`TestE2E_DoctorDoesNotTouchTheStore` (doctor text + `--json`; proves it does
|
||||
**not** create/migrate `ao.db`) · `TestE2E_StatusStopped` (stopped + idempotent
|
||||
stop) · `TestE2E_Lifecycle` (start, ready, idempotent, daemon-created store,
|
||||
`/healthz` identity, stop, run-file cleanup) · `TestE2E_ShutdownGuard` (the
|
||||
`/shutdown` CSRF + DNS-rebinding 403 guard, daemon survives) ·
|
||||
`TestE2E_StaleRunFile` (dead-PID run-file → stale → cleaned) · `TestE2E_ExitCodes`
|
||||
(2 usage / 1 runtime / config error) · `TestE2E_Completion` (all four shells).
|
||||
|
||||
## Testing strategy — why it's shaped this way
|
||||
## Why a Go suite (not bash, not Python)
|
||||
|
||||
We deliberately don't make Docker the *only* tier. A daemon that detaches with
|
||||
`setsid` and outlives the launching process is exactly the workload that
|
||||
container PID-1 semantics mishandle, and the OS-specific bits (`setsid` vs
|
||||
Windows `CREATE_NEW_PROCESS_GROUP`, and `os.UserConfigDir()` resolving to
|
||||
`~/Library/Application Support` on macOS, `%AppData%` on Windows, `~/.config`
|
||||
on Linux) can't be observed from a Linux container at all.
|
||||
The bash version grew past the point where bash was a good fit, and a Linux
|
||||
container can't observe the macOS/Windows code paths at all. A Go `os/exec`
|
||||
suite is the right home: it uses the repo's own toolchain (runs under `go test`),
|
||||
gives real assertions and structured data, and — critically — runs natively on
|
||||
the Windows and macOS runners, finally covering the `CREATE_NEW_PROCESS_GROUP`
|
||||
detach path and per-OS config-dir resolution. The container stays as a thin
|
||||
"clean install actually works" check.
|
||||
|
||||
So CI (`.github/workflows/cli-e2e.yml`) runs two tiers:
|
||||
## Extending
|
||||
|
||||
1. **`native`** — the primary signal. Builds and runs the real binary on a
|
||||
GitHub matrix of `ubuntu-latest` + `macos-latest` (those runners *are* the
|
||||
VMs), covering the unix detach path and macOS config-dir resolution.
|
||||
2. **`container`** — a hardening tier. The `Dockerfile` proves a clean-machine
|
||||
install works and that the CLI has no hidden dependence on developer state,
|
||||
run with `--init`.
|
||||
|
||||
### Extending
|
||||
|
||||
- Add an assertion: drop a `step`/`assert_*` pair into the relevant section of
|
||||
`smoke.sh`. The helpers (`assert_eq`, `assert_contains`, `assert_not_contains`,
|
||||
`run_rc`) keep cases one-liners.
|
||||
- Cover Windows: add a `windows-latest` leg to the `native` matrix (Git Bash
|
||||
ships on the runner) once the suite is confirmed green there, or add Go-based
|
||||
`os/exec` E2E tests for the Windows process-group path.
|
||||
- Deeper per-OS path assertions (that state resolves under the OS-native config
|
||||
dir when `AO_RUN_FILE`/`AO_DATA_DIR` are unset) are best added as Go unit
|
||||
tests in `internal/config`.
|
||||
- **Add a case:** a new `TestE2E_*` function (or a `t.Run` subtest) in
|
||||
`e2e_test.go`. Use `newEnv(t)` for isolated state and the `env.run`/`httpGet`/
|
||||
`postShutdown` helpers.
|
||||
- **Add an OS:** extend the `matrix.os` list in `.github/workflows/cli-e2e.yml`.
|
||||
- Deeper per-OS path assertions (state resolves under the OS-native config dir
|
||||
when `AO_RUN_FILE`/`AO_DATA_DIR` are unset) fit best as unit tests in
|
||||
`internal/config`.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Fresh-machine install check. The Dockerfile installs `ao` on PATH in a clean
|
||||
# image and runs this; it proves a freshly installed binary actually works on a
|
||||
# machine with no Go toolchain and no developer state. The COMPREHENSIVE,
|
||||
# cross-platform behavioural suite lives in Go (backend/internal/cli/e2e_test.go,
|
||||
# `go test -tags e2e`); this stays deliberately small and linear.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
AO_BIN="${AO_BIN:-ao}"
|
||||
tmp="$(mktemp -d)"
|
||||
export AO_RUN_FILE="$tmp/running.json"
|
||||
export AO_DATA_DIR="$tmp/data"
|
||||
export AO_PORT="${AO_PORT:-3001}" # the container is isolated; 3001 is free
|
||||
trap '"$AO_BIN" stop >/dev/null 2>&1 || true; rm -rf "$tmp"' EXIT
|
||||
|
||||
fail() { echo "FAIL: $1" >&2; exit 1; }
|
||||
|
||||
echo "ao binary : $(command -v "$AO_BIN")"
|
||||
"$AO_BIN" version >/dev/null || fail "version"
|
||||
"$AO_BIN" doctor >/dev/null || fail "doctor"
|
||||
"$AO_BIN" start >/dev/null || fail "start"
|
||||
|
||||
"$AO_BIN" status --json | grep -q '"state": "ready"' || fail "daemon not ready after start"
|
||||
|
||||
# the /shutdown control endpoint rejects a cross-origin caller (403) and survives
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H 'Origin: https://evil.example' "http://127.0.0.1:$AO_PORT/shutdown")"
|
||||
[ "$code" = "403" ] || fail "cross-origin /shutdown returned $code, want 403"
|
||||
"$AO_BIN" status --json | grep -q '"state": "ready"' || fail "daemon died after rejected shutdown"
|
||||
|
||||
"$AO_BIN" stop >/dev/null || fail "stop"
|
||||
"$AO_BIN" status --json | grep -q '"state": "stopped"' || fail "daemon not stopped"
|
||||
|
||||
echo "fresh-install check: OK"
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Convenience wrapper: build `ao` from source and run the CLI smoke test against
|
||||
# it natively, using an isolated temp state dir and a free port. Touches nothing
|
||||
# in your real AO installation.
|
||||
#
|
||||
# test/cli/run-local.sh # PASS/FAIL per assertion
|
||||
# test/cli/run-local.sh -v # also print every command and its full output
|
||||
#
|
||||
# To run the same suite the way a brand-new user would install it (clean Linux
|
||||
# container, binary on PATH), use Docker instead:
|
||||
#
|
||||
# docker build -f test/cli/Dockerfile -t ao-cli-smoke . && docker run --rm --init ao-cli-smoke
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Pass through -v/--verbose (anything else is forwarded to smoke.sh too).
|
||||
smoke_args=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-v|--verbose) smoke_args+=("--verbose") ;;
|
||||
-h|--help) echo "usage: run-local.sh [-v|--verbose]"; exit 0 ;;
|
||||
*) smoke_args+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
repo_root="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
bindir="$(mktemp -d)"
|
||||
trap 'rm -rf "$bindir"' EXIT
|
||||
|
||||
echo "building ao ..."
|
||||
( cd "$repo_root/backend" && CGO_ENABLED=0 go build -trimpath -o "$bindir/ao" ./cmd/ao )
|
||||
|
||||
echo "running smoke test ..."
|
||||
AO_BIN="$bindir/ao" bash "$repo_root/test/cli/smoke.sh" "${smoke_args[@]+"${smoke_args[@]}"}"
|
||||
|
|
@ -1,334 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# End-to-end smoke test for the `ao` CLI.
|
||||
#
|
||||
# It models a *fresh machine*: `ao` is expected to already be installed on PATH
|
||||
# (the Dockerfile in this directory installs it, simulating a new user), and the
|
||||
# whole test runs against isolated, throwaway state — its own temp run-file,
|
||||
# data dir, and a free loopback port — so it never touches a developer's real
|
||||
# AO installation.
|
||||
#
|
||||
# Run locally against a binary you built:
|
||||
# AO_BIN=/path/to/ao test/cli/smoke.sh
|
||||
# Verbose — print each command and its full output:
|
||||
# AO_BIN=/path/to/ao test/cli/smoke.sh -v (or AO_SMOKE_VERBOSE=1)
|
||||
# Or in the container (models install-on-a-fresh-machine):
|
||||
# docker build -f test/cli/Dockerfile -t ao-cli-smoke . && docker run --rm --init ao-cli-smoke
|
||||
#
|
||||
# Exit code: 0 if every assertion passes, 1 otherwise.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
AO_BIN="${AO_BIN:-ao}"
|
||||
|
||||
# Verbose mode prints every command and its complete output, not just PASS/FAIL.
|
||||
VERBOSE="${AO_SMOKE_VERBOSE:-0}"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-v|--verbose) VERBOSE=1 ;;
|
||||
-h|--help) echo "usage: [AO_BIN=...] smoke.sh [-v|--verbose]"; exit 0 ;;
|
||||
*) echo "unknown argument: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tiny assertion framework
|
||||
# ---------------------------------------------------------------------------
|
||||
PASS=0
|
||||
FAIL=0
|
||||
CURRENT=""
|
||||
|
||||
section() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; }
|
||||
|
||||
step() {
|
||||
CURRENT="$1"
|
||||
if [ "$VERBOSE" = 1 ]; then printf ' • %s\n' "$1"; else printf ' • %s ... ' "$1"; fi
|
||||
}
|
||||
|
||||
ok() {
|
||||
PASS=$((PASS + 1))
|
||||
if [ "$VERBOSE" = 1 ]; then printf ' \033[32m→ PASS\033[0m\n'; else printf '\033[32mPASS\033[0m\n'; fi
|
||||
}
|
||||
bad() {
|
||||
FAIL=$((FAIL + 1))
|
||||
if [ "$VERBOSE" = 1 ]; then printf ' \033[31m→ FAIL\033[0m %s\n' "$1"; else printf '\033[31mFAIL\033[0m\n %s\n' "$1"; fi
|
||||
}
|
||||
|
||||
# vdump <command-label> <output> <exit-code> : in verbose mode, echo the command
|
||||
# and its complete output, indented. A no-op otherwise.
|
||||
vdump() {
|
||||
[ "$VERBOSE" = 1 ] || return 0
|
||||
printf ' \033[2m$ %s\033[0m\n' "$1"
|
||||
if [ -n "$2" ]; then printf '%s\n' "$2" | sed 's/^/ | /'; fi
|
||||
printf ' \033[2m(exit %s)\033[0m\n' "$3"
|
||||
}
|
||||
|
||||
# assert_eq <actual> <expected> [msg]
|
||||
assert_eq() {
|
||||
if [ "$1" = "$2" ]; then ok; else bad "${3:-}: expected [$2], got [$1]"; fi
|
||||
}
|
||||
# assert_contains <haystack> <needle>
|
||||
assert_contains() {
|
||||
case "$1" in
|
||||
*"$2"*) ok ;;
|
||||
*) bad "expected output to contain [$2]; got: $(printf '%s' "$1" | head -c 400)" ;;
|
||||
esac
|
||||
}
|
||||
# assert_not_contains <haystack> <needle>
|
||||
assert_not_contains() {
|
||||
case "$1" in
|
||||
*"$2"*) bad "expected output to NOT contain [$2]" ;;
|
||||
*) ok ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# run_rc <cmd...> -> sets RC and OUT (stdout+stderr combined)
|
||||
run_rc() {
|
||||
OUT="$("$@" 2>&1)"
|
||||
RC=$?
|
||||
vdump "$*" "$OUT" "$RC"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Isolated, throwaway environment
|
||||
# ---------------------------------------------------------------------------
|
||||
TMP="$(mktemp -d)"
|
||||
export AO_RUN_FILE="$TMP/running.json"
|
||||
export AO_DATA_DIR="$TMP/data"
|
||||
|
||||
# Pick a free loopback port (bash /dev/tcp probe; connect-refused == free).
|
||||
find_free_port() {
|
||||
local p
|
||||
for p in $(seq 3071 3170); do
|
||||
if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then
|
||||
echo "$p"; return 0
|
||||
fi
|
||||
exec 3>&- 2>/dev/null || true
|
||||
done
|
||||
echo 3071
|
||||
}
|
||||
# Always run against an isolated, free port. We deliberately do NOT honour an
|
||||
# inherited AO_PORT — it might point at a real daemon, which is exactly the
|
||||
# collision this isolation is meant to prevent. Override only via AO_SMOKE_PORT.
|
||||
export AO_PORT="${AO_SMOKE_PORT:-$(find_free_port)}"
|
||||
|
||||
cleanup() {
|
||||
"$AO_BIN" stop >/dev/null 2>&1 || true
|
||||
rm -rf "$TMP"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
printf 'ao smoke test\n binary : %s\n port : %s\n state : %s\n' \
|
||||
"$(command -v "$AO_BIN" || echo "$AO_BIN")" "$AO_PORT" "$TMP"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Install verification — `ao` is a real, runnable binary on this machine
|
||||
# ---------------------------------------------------------------------------
|
||||
section "install"
|
||||
|
||||
step "ao resolves on PATH / at AO_BIN"
|
||||
if command -v "$AO_BIN" >/dev/null 2>&1; then ok; else bad "ao not found"; fi
|
||||
|
||||
step "ao version prints build metadata"
|
||||
run_rc "$AO_BIN" version
|
||||
if [ "$RC" -eq 0 ] && [ -n "$OUT" ]; then ok; else bad "rc=$RC out=$OUT"; fi
|
||||
|
||||
step "ao --version works"
|
||||
run_rc "$AO_BIN" --version
|
||||
assert_eq "$RC" "0" "--version rc"
|
||||
|
||||
step "ao --help lists product commands"
|
||||
run_rc "$AO_BIN" --help
|
||||
assert_contains "$OUT" "start"
|
||||
step "ao --help lists status/stop/doctor"
|
||||
assert_contains "$OUT" "doctor"
|
||||
step "ao --help hides internal daemon command"
|
||||
assert_not_contains "$OUT" $'\n daemon'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. doctor on a fresh machine (no daemon yet)
|
||||
# ---------------------------------------------------------------------------
|
||||
section "doctor (fresh)"
|
||||
|
||||
step "doctor exits 0 when required tools present"
|
||||
run_rc "$AO_BIN" doctor
|
||||
assert_eq "$RC" "0" "doctor rc (git/tmux must be installed in the image)"
|
||||
|
||||
step "doctor reports git found"
|
||||
assert_contains "$OUT" "git"
|
||||
|
||||
step "doctor does NOT migrate the store (sqlite WARN, db absent)"
|
||||
assert_contains "$OUT" "database not created yet"
|
||||
|
||||
step "doctor data dir was created but ao.db was NOT (CLI is not the store writer)"
|
||||
if [ ! -f "$AO_DATA_DIR/ao.db" ]; then ok; else bad "ao.db exists — doctor must not create/migrate the DB"; fi
|
||||
|
||||
step "doctor --json is valid JSON with ok=true"
|
||||
run_rc "$AO_BIN" doctor --json
|
||||
assert_contains "$OUT" '"ok": true'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. status when stopped
|
||||
# ---------------------------------------------------------------------------
|
||||
section "status (stopped)"
|
||||
|
||||
step "status --json reports stopped"
|
||||
run_rc "$AO_BIN" status --json
|
||||
assert_contains "$OUT" '"state": "stopped"'
|
||||
step "status exits 0 even when stopped (status never errors)"
|
||||
assert_eq "$RC" "0" "status exit code"
|
||||
step "stopped status omits startedAt"
|
||||
assert_not_contains "$OUT" "startedAt"
|
||||
|
||||
step "stop is idempotent when already stopped"
|
||||
run_rc "$AO_BIN" stop
|
||||
if [ "$RC" -eq 0 ]; then assert_contains "$OUT" "stopped"; else bad "stop-when-stopped rc=$RC"; fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. start → ready, and status reflects it
|
||||
# ---------------------------------------------------------------------------
|
||||
section "start"
|
||||
|
||||
step "start brings the daemon up and reports ready"
|
||||
run_rc "$AO_BIN" start
|
||||
if [ "$RC" -eq 0 ]; then assert_contains "$OUT" "ready"; else bad "start rc=$RC out=$OUT"; fi
|
||||
|
||||
step "status --json reports ready with pid+port"
|
||||
run_rc "$AO_BIN" status --json
|
||||
assert_contains "$OUT" '"state": "ready"'
|
||||
step "status carries the bound port"
|
||||
assert_contains "$OUT" "\"port\": $AO_PORT"
|
||||
|
||||
step "start is idempotent (second start returns ready, no error)"
|
||||
run_rc "$AO_BIN" start
|
||||
if [ "$RC" -eq 0 ]; then assert_contains "$OUT" "ready"; else bad "idempotent start rc=$RC"; fi
|
||||
|
||||
# Capture the live pid for later assertions.
|
||||
PID="$("$AO_BIN" status --json | sed -n 's/.*"pid": \([0-9]*\).*/\1/p' | head -1)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. doctor while running — now the daemon (not the CLI) has created the store
|
||||
# ---------------------------------------------------------------------------
|
||||
section "doctor (running)"
|
||||
|
||||
step "daemon created and migrated the store"
|
||||
if [ -f "$AO_DATA_DIR/ao.db" ]; then ok; else bad "daemon should have created ao.db"; fi
|
||||
|
||||
step "doctor now reports sqlite present + daemon-migrated"
|
||||
run_rc "$AO_BIN" doctor
|
||||
assert_contains "$OUT" "migrations are applied by the daemon"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Health endpoint identity (loopback)
|
||||
# ---------------------------------------------------------------------------
|
||||
section "health endpoint"
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
step "/healthz reports the AO daemon service + pid"
|
||||
run_rc curl -fsS "http://127.0.0.1:$AO_PORT/healthz"
|
||||
assert_contains "$OUT" "agent-orchestrator-daemon"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 7. /shutdown CSRF / DNS-rebinding guard (review fix M3)
|
||||
# -------------------------------------------------------------------------
|
||||
section "/shutdown guard"
|
||||
|
||||
step "cross-origin POST /shutdown is rejected (403)"
|
||||
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H 'Origin: https://evil.example' "http://127.0.0.1:$AO_PORT/shutdown")"
|
||||
vdump "curl -X POST -H 'Origin: https://evil.example' http://127.0.0.1:$AO_PORT/shutdown" "HTTP $CODE" "-"
|
||||
assert_eq "$CODE" "403" "cross-origin shutdown"
|
||||
|
||||
step "non-loopback Host POST /shutdown is rejected (403)"
|
||||
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H 'Host: evil.example' "http://127.0.0.1:$AO_PORT/shutdown")"
|
||||
vdump "curl -X POST -H 'Host: evil.example' http://127.0.0.1:$AO_PORT/shutdown" "HTTP $CODE" "-"
|
||||
assert_eq "$CODE" "403" "rebinding-host shutdown"
|
||||
|
||||
step "daemon survived the rejected shutdown attempts"
|
||||
run_rc "$AO_BIN" status --json
|
||||
assert_contains "$OUT" '"state": "ready"'
|
||||
else
|
||||
section "/shutdown guard"
|
||||
step "curl unavailable — skipping HTTP-level guard checks"
|
||||
printf '\033[33mSKIP\033[0m\n'
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. stop → stopped, run-file cleaned up
|
||||
# ---------------------------------------------------------------------------
|
||||
section "stop"
|
||||
|
||||
step "stop gracefully stops the daemon"
|
||||
run_rc "$AO_BIN" stop
|
||||
if [ "$RC" -eq 0 ]; then assert_contains "$OUT" "stopped"; else bad "stop rc=$RC out=$OUT"; fi
|
||||
|
||||
step "run-file removed after stop"
|
||||
if [ ! -f "$AO_RUN_FILE" ]; then ok; else bad "running.json still present"; fi
|
||||
|
||||
step "status --json reports stopped after stop"
|
||||
run_rc "$AO_BIN" status --json
|
||||
assert_contains "$OUT" '"state": "stopped"'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. stale run-file (dead PID) — deterministic, no real process needed
|
||||
# ---------------------------------------------------------------------------
|
||||
section "stale run-file"
|
||||
|
||||
# PID 2147483647 is never alive; the CLI must classify this as stale, not kill it.
|
||||
printf '{"pid":2147483647,"port":%s,"startedAt":"2020-01-01T00:00:00Z"}\n' "$AO_PORT" > "$AO_RUN_FILE"
|
||||
|
||||
step "status reports stale for a dead-PID run-file"
|
||||
run_rc "$AO_BIN" status --json
|
||||
assert_contains "$OUT" '"state": "stale"'
|
||||
step "status still exits 0 for a stale daemon (reports, never errors)"
|
||||
assert_eq "$RC" "0" "stale status exit code"
|
||||
|
||||
step "stop clears a stale run-file and reports stopped"
|
||||
run_rc "$AO_BIN" stop
|
||||
assert_contains "$OUT" "stopped"
|
||||
step "stale run-file removed"
|
||||
if [ ! -f "$AO_RUN_FILE" ]; then ok; else bad "stale running.json not removed"; fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. exit codes: 2 for usage errors, 1 for runtime errors
|
||||
# ---------------------------------------------------------------------------
|
||||
section "exit codes"
|
||||
|
||||
step "unknown flag exits 2 (usage error)"
|
||||
run_rc "$AO_BIN" status --definitely-not-a-flag
|
||||
assert_eq "$RC" "2" "bad-flag exit code"
|
||||
|
||||
step "missing required arg exits 2 (completion needs a shell)"
|
||||
run_rc "$AO_BIN" completion
|
||||
assert_eq "$RC" "2" "missing-arg exit code"
|
||||
|
||||
step "unsupported shell exits non-zero (runtime error)"
|
||||
run_rc "$AO_BIN" completion notashell
|
||||
if [ "$RC" -ne 0 ]; then ok; else bad "expected non-zero for bad shell"; fi
|
||||
|
||||
step "invalid config (AO_PORT out of range) exits 1, not 2"
|
||||
OUT="$(AO_PORT=99999 "$AO_BIN" status 2>&1)"; RC=$?
|
||||
vdump "AO_PORT=99999 $AO_BIN status" "$OUT" "$RC"
|
||||
assert_eq "$RC" "1" "config-error exit code (runtime, not usage)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. shell completion generators
|
||||
# ---------------------------------------------------------------------------
|
||||
section "completion"
|
||||
|
||||
for sh in bash zsh fish powershell; do
|
||||
step "completion $sh generates a script"
|
||||
run_rc "$AO_BIN" completion "$sh"
|
||||
if [ "$RC" -eq 0 ] && [ -n "$OUT" ]; then ok; else bad "completion $sh rc=$RC"; fi
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result
|
||||
# ---------------------------------------------------------------------------
|
||||
printf '\n\033[1m== result ==\033[0m\n passed: %s\n failed: %s\n' "$PASS" "$FAIL"
|
||||
if [ "$FAIL" -ne 0 ]; then
|
||||
printf '\033[31mSMOKE TEST FAILED\033[0m\n'
|
||||
exit 1
|
||||
fi
|
||||
printf '\033[32mSMOKE TEST PASSED\033[0m\n'
|
||||
Loading…
Reference in New Issue