Files
ratatoskr-go/internal/opencode/server_test.go
ki.sagidullin c9ab753c1e
Some checks failed
CI / test (push) Failing after 2m13s
CI / build-and-package (amd64, linux) (push) Failing after 1m3s
CI / build-and-package (amd64, windows) (push) Successful in 29s
fix(opencode): x-opencode-directory, Content-Length, warmup /api/model; serve без явного Env
2026-08-24 08:38:59 +05:00

225 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package opencode
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// fakeServeBin создаёт скрипт, имитирующий opencode serve: просто держит
// процесс живым (sleep/ping), чтобы супервайзер мог им владеть и убивать его.
// На Windows используется .cmd (с #!/bin/sh нельзя — он не исполняется).
func fakeServeBin(t *testing.T, workdir string) string {
t.Helper()
if runtime.GOOS == "windows" {
bin := filepath.Join(workdir, "opencode-serve.cmd")
script := "@echo off\r\necho fake serve started\r\nping -n 300 127.0.0.1 >nul\r\n"
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatalf("write fake serve bin: %v", err)
}
return bin
}
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
}
func TestServeCmd_NoCustomEnv(t *testing.T) {
// serveCmd не должен задавать свой Env: serve наследует окружение родителя
// и сам резолвит конфиг/БД/пароль как обычно.
s := &Server{}
s.defaults()
cmd := s.serveCmd(context.Background())
if cmd.Env != nil {
for _, kv := range cmd.Env {
if strings.HasPrefix(kv, "OPENCODE_") {
t.Errorf("serveCmd: не ожидал env %q", kv)
}
}
}
}
func TestServerCmd_CustomOpenCodeConfig(t *testing.T) {
// явно заданный путь переопределяет дефолтный
s := &Server{ConfigPath: `C:\custom\opencode.jsonc`}
s.defaults()
if s.ConfigPath != `C:\custom\opencode.jsonc` {
t.Errorf("ConfigPath = %q, want explicit", s.ConfigPath)
}
}