package opencode import ( "context" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) // fakeServeBin создаёт скрипт, имитирующий opencode serve: просто держит // процесс живым (sleep), чтобы супервайзер мог им владеть и убивать его. func fakeServeBin(t *testing.T, workdir string) string { t.Helper() bin := filepath.Join(workdir, "opencode-serve") script := `#!/bin/sh echo "fake serve started" sleep 300 ` if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { t.Fatalf("write fake serve bin: %v", err) } return bin } // healthHandler — http.Health, отвечающий на GET /global/health 200. func healthHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) } func TestServer_ExternalURL(t *testing.T) { // внешний сервер — ходим на реальный httptest-адрес, процессом не владеем ts := httptest.NewServer(healthHandler()) defer ts.Close() s := &Server{ URL: ts.URL, PollInterval: 20 * time.Millisecond, Stdout: io.Discard, } if err := s.Start(context.Background()); err != nil { t.Fatalf("Start(внешний) err: %v", err) } if got := s.Addr(); got != ts.URL { t.Errorf("Addr() = %q, want %q", got, ts.URL) } // Close в режиме внешнего — не должен ничего падать (proc==nil) s.Close() } func TestServer_OwnProcess_StartAHealthyClose(t *testing.T) { dir := t.TempDir() bin := fakeServeBin(t, dir) // поднимаем реальный health-сервер на известном порту, чтобы superватизору // было на что отвечать /global/health ts := httptest.NewServer(healthHandler()) defer ts.Close() host := strings.TrimPrefix(ts.URL, "http://") // host:port var hostname, port string if i := strings.IndexByte(host, ':'); i >= 0 { hostname, port = host[:i], host[i+1:] } else { hostname = host port = "80" } s := &Server{ Bin: bin, Host: hostname, Port: 0, // сюда передадим порт ниже PollInterval: 20 * time.Millisecond, Stdout: io.Discard, } // переопределение порта на порт health-сервера sport := atoiOrZero(port) s.Port = sport ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := s.Start(ctx); err != nil { t.Fatalf("Start(владеющий) err: %v", err) } if s.proc == nil || s.proc.Process == nil { t.Fatal("proc не запущен после Start") } // Close должен убить процесс s.Close() if s.proc.ProcessState == nil { t.Log("процесс ещё числится запущенным (Close в Go не всегда виден сразу) — ок") } } // TestServer_OwningProcess_Restart проверяет, что Run перезапускает упавший // процесс: после первого старта убиваем вручную, Run должен поднять вновь. func TestServer_OwningProcess_Restart(t *testing.T) { dir := t.TempDir() bin := fakeServeBin(t, dir) ts := httptest.NewServer(healthHandler()) defer ts.Close() host := strings.TrimPrefix(ts.URL, "http://") hostname, port := host, "80" if i := strings.IndexByte(host, ':'); i >= 0 { hostname, port = host[:i], host[i+1:] } s := &Server{ Bin: bin, Host: hostname, Port: atoiOrZero(port), PollInterval: 30 * time.Millisecond, Stdout: io.Discard, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := s.Start(ctx); err != nil { t.Fatalf("Start err: %v", err) } // убиваем первый процесс, чтобы спровоцировать рестарт. Wait НЕ вызываем // сами — reaper-горутина (Start) владеет реaper'ом и установит // cmd.ProcessState; ждём, когда статус покажет выход. first := s.proc if first == nil { t.Fatal("proc nil после Start") } _ = first.Process.Kill() waitExited(t, first) // Run крутится в фон: даём время на рестарт done := make(chan struct{}) go func() { s.Run(ctx) close(done) }() defer func() { cancel() <-done s.Close() }() // ждём, пока proc появится вновь (Run пересоздаст serveCmd) deadline := time.Now().Add(3 * time.Second) var restarted bool for time.Now().Before(deadline) { s.mu.Lock() p := s.proc s.mu.Unlock() if p != nil && p != first && p.Process != nil { restarted = true break } time.Sleep(20 * time.Millisecond) } if !restarted { t.Fatal("процесс не был перезапущен после падения") } } // waitExited ждёт, когда reaper-горутина (cmd.Wait) отметит выход процесса. func waitExited(t *testing.T, cmd *exec.Cmd) { t.Helper() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { if cmd.ProcessState != nil { return } time.Sleep(10 * time.Millisecond) } t.Fatal("процесс так и не встал в exited после Kill") } func atoiOrZero(s string) int { n := 0 for _, c := range s { if c < '0' || c > '9' { break } n = n*10 + int(c-'0') } return n }