Files
ratatoskr-go/internal/opencode/server_test.go
ki.sagidullin b5fb583c90
All checks were successful
CI / test (pull_request) Successful in 41s
CI / build-and-package (amd64, linux) (pull_request) Successful in 37s
CI / build-and-package (amd64, windows) (pull_request) Successful in 34s
test: починить тесты на Windows
- E2E (app): e2eFakeAPI переведён на v2 HTTP API opencode (/api/*) с
  определением агента по тексту промпта; Router получает processed-счётчик
  и WaitProcessed, e2eChannel.deliver ждёт асинхронную обработку — убирает
  гонку «запрос сразу после deliver» и коллатеральный 'database is closed'.
- app_test: одинарные YAML-кавычки для путей Windows (backslash-escape) +
  закрытие Store в TestNew/TestNew_RunCtxCancel/TestNew_UpdateWiring.
- config_test: абсолютный путь строится с корнем тома (C:\...) и одинарными
  кавычками YAML.
- opencode/server_test: fakeServeBin на Windows — .cmd с ping (#!/bin/sh
  не исполняется).
- worker_test: TestWorkerSemaphore поллит до целевого статуса вместо
  фиксированных sleep (git на Windows медленнее).
2026-08-19 10:47:55 +05:00

201 lines
5.8 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
}