//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 } if strings.HasPrefix(kv, "GITHUB_TOKEN=") || strings.HasPrefix(kv, "GH_TOKEN=") || strings.HasPrefix(kv, "GH_CONFIG_DIR=") { 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, "GH_CONFIG_DIR="+filepath.Join(e.dataDir, "gh-config")) } 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. `ao start` // no longer spawns the daemon (the desktop app owns it now), so the e2e suite // drives the hidden `ao daemon` command directly and polls for readiness. func (e env) startDaemon(t *testing.T) { t.Helper() cmd := exec.Command(aoBin, "daemon") cmd.Env = e.environ("") if err := cmd.Start(); err != nil { t.Fatalf("spawn ao daemon: %v", err) } t.Cleanup(func() { e.run(t, "stop") _, _ = cmd.Process.Wait() }) deadline := time.Now().Add(10 * time.Second) for { out, _ := e.run(t, "status", "--json") if strings.Contains(out, `"state": "ready"`) { return } if time.Now().After(deadline) { t.Fatalf("daemon did not become ready within 10s; last status:\n%s", out) } time.Sleep(100 * time.Millisecond) } } 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)) // 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 }