213 lines
6.4 KiB
Go
213 lines
6.4 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type activityCapture struct {
|
|
body string
|
|
path string
|
|
hits int
|
|
}
|
|
|
|
// activityServer accepts POST /api/v1/sessions/{id}/activity and records what
|
|
// the CLI sent. It mirrors sendServer in send_test.go.
|
|
func activityServer(t *testing.T, status int, respBody string) (*httptest.Server, *activityCapture) {
|
|
t.Helper()
|
|
capture := &activityCapture{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/activity") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
capture.body = string(body)
|
|
capture.path = r.URL.Path
|
|
capture.hits++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = io.WriteString(w, respBody)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv, capture
|
|
}
|
|
|
|
func capturedState(t *testing.T, capture *activityCapture) string {
|
|
t.Helper()
|
|
var req struct {
|
|
State string `json:"state"`
|
|
}
|
|
if err := json.Unmarshal([]byte(capture.body), &req); err != nil {
|
|
t.Fatalf("decode body: %v\nbody=%s", err, capture.body)
|
|
}
|
|
return req.State
|
|
}
|
|
|
|
func TestHooks_NotificationReportsWaitingInput(t *testing.T) {
|
|
t.Setenv("AO_SESSION_ID", "ao-7")
|
|
cfg := setConfigEnv(t)
|
|
srv, capture := activityServer(t, http.StatusOK, `{"ok":true,"sessionId":"ao-7","state":"waiting_input"}`)
|
|
writeRunFileFor(t, cfg, srv)
|
|
|
|
_, errOut, err := executeCLI(t, Deps{
|
|
In: strings.NewReader(`{"notification_type":"idle_prompt"}`),
|
|
ProcessAlive: func(int) bool { return true },
|
|
}, "hooks", "claude-code", "notification")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
|
|
}
|
|
if capture.path != "/api/v1/sessions/ao-7/activity" {
|
|
t.Errorf("path = %q, want /api/v1/sessions/ao-7/activity", capture.path)
|
|
}
|
|
if got := capturedState(t, capture); got != "waiting_input" {
|
|
t.Errorf("state = %q, want waiting_input", got)
|
|
}
|
|
}
|
|
|
|
func TestHooks_SessionEndReportsExited(t *testing.T) {
|
|
t.Setenv("AO_SESSION_ID", "ao-7")
|
|
cfg := setConfigEnv(t)
|
|
srv, capture := activityServer(t, http.StatusOK, `{"ok":true}`)
|
|
writeRunFileFor(t, cfg, srv)
|
|
|
|
_, _, err := executeCLI(t, Deps{
|
|
In: strings.NewReader(`{"reason":"logout"}`),
|
|
ProcessAlive: func(int) bool { return true },
|
|
}, "hooks", "claude-code", "session-end")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got := capturedState(t, capture); got != "exited" {
|
|
t.Errorf("state = %q, want exited", got)
|
|
}
|
|
}
|
|
|
|
func TestHooks_StopReportsIdle(t *testing.T) {
|
|
t.Setenv("AO_SESSION_ID", "ao-7")
|
|
cfg := setConfigEnv(t)
|
|
srv, capture := activityServer(t, http.StatusOK, `{"ok":true}`)
|
|
writeRunFileFor(t, cfg, srv)
|
|
|
|
_, _, err := executeCLI(t, Deps{
|
|
In: strings.NewReader(`{}`),
|
|
ProcessAlive: func(int) bool { return true },
|
|
}, "hooks", "claude-code", "stop")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got := capturedState(t, capture); got != "idle" {
|
|
t.Errorf("state = %q, want idle", got)
|
|
}
|
|
}
|
|
|
|
func TestHooks_CodexPermissionRequestReportsWaitingInput(t *testing.T) {
|
|
t.Setenv("AO_SESSION_ID", "ao-7")
|
|
cfg := setConfigEnv(t)
|
|
srv, capture := activityServer(t, http.StatusOK, `{"ok":true}`)
|
|
writeRunFileFor(t, cfg, srv)
|
|
|
|
_, _, err := executeCLI(t, Deps{
|
|
In: strings.NewReader(`{"tool_name":"Bash"}`),
|
|
ProcessAlive: func(int) bool { return true },
|
|
}, "hooks", "codex", "permission-request")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got := capturedState(t, capture); got != "waiting_input" {
|
|
t.Errorf("state = %q, want waiting_input", got)
|
|
}
|
|
}
|
|
|
|
func TestHooks_OpenCodeUserPromptReportsActive(t *testing.T) {
|
|
t.Setenv("AO_SESSION_ID", "ao-7")
|
|
cfg := setConfigEnv(t)
|
|
srv, capture := activityServer(t, http.StatusOK, `{"ok":true}`)
|
|
writeRunFileFor(t, cfg, srv)
|
|
|
|
_, _, err := executeCLI(t, Deps{
|
|
In: strings.NewReader(`{"session_id":"ses-1","prompt":"fix this"}`),
|
|
ProcessAlive: func(int) bool { return true },
|
|
}, "hooks", "opencode", "user-prompt-submit")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got := capturedState(t, capture); got != "active" {
|
|
t.Errorf("state = %q, want active", got)
|
|
}
|
|
}
|
|
|
|
func TestHooks_NoSessionIDIsNoOp(t *testing.T) {
|
|
t.Setenv("AO_SESSION_ID", "")
|
|
cfg := setConfigEnv(t)
|
|
srv, capture := activityServer(t, http.StatusOK, `{}`)
|
|
writeRunFileFor(t, cfg, srv)
|
|
|
|
_, _, err := executeCLI(t, Deps{
|
|
In: strings.NewReader(`{"notification_type":"idle_prompt"}`),
|
|
ProcessAlive: func(int) bool { return true },
|
|
}, "hooks", "claude-code", "notification")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if capture.hits != 0 {
|
|
t.Errorf("expected no daemon call for a non-AO session, got %d", capture.hits)
|
|
}
|
|
}
|
|
|
|
func TestHooks_UntrackedEventIsNoOp(t *testing.T) {
|
|
t.Setenv("AO_SESSION_ID", "ao-7")
|
|
cfg := setConfigEnv(t)
|
|
srv, capture := activityServer(t, http.StatusOK, `{}`)
|
|
writeRunFileFor(t, cfg, srv)
|
|
|
|
_, _, err := executeCLI(t, Deps{
|
|
In: strings.NewReader(`{"notification_type":"auth_success"}`),
|
|
ProcessAlive: func(int) bool { return true },
|
|
}, "hooks", "claude-code", "notification")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if capture.hits != 0 {
|
|
t.Errorf("expected no daemon call for an untracked notification, got %d", capture.hits)
|
|
}
|
|
}
|
|
|
|
func TestHooks_DaemonDownIsBestEffort(t *testing.T) {
|
|
t.Setenv("AO_SESSION_ID", "ao-7")
|
|
setConfigEnv(t) // no run-file written: daemon is "not running"
|
|
|
|
_, _, err := executeCLI(t, Deps{
|
|
In: strings.NewReader(`{"reason":"logout"}`),
|
|
}, "hooks", "claude-code", "session-end")
|
|
if err != nil {
|
|
t.Fatalf("hooks must be best-effort (exit 0) when the daemon is down, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHooks_DaemonErrorIsSwallowed(t *testing.T) {
|
|
t.Setenv("AO_SESSION_ID", "ao-7")
|
|
cfg := setConfigEnv(t)
|
|
srv, _ := activityServer(t, http.StatusInternalServerError,
|
|
`{"error":"internal","code":"BOOM","message":"boom"}`)
|
|
writeRunFileFor(t, cfg, srv)
|
|
|
|
_, errOut, err := executeCLI(t, Deps{
|
|
In: strings.NewReader(`{"reason":"logout"}`),
|
|
ProcessAlive: func(int) bool { return true },
|
|
}, "hooks", "claude-code", "session-end")
|
|
if err != nil {
|
|
t.Fatalf("hooks must exit 0 even on a daemon error, got: %v", err)
|
|
}
|
|
if !strings.Contains(errOut, "ao hooks") {
|
|
t.Errorf("expected the failure surfaced to stderr, got %q", errOut)
|
|
}
|
|
}
|