package opencode import ( "context" "io" "net/http/httptest" "testing" "time" ) // fakePool создаёт Pool, в котором уже «живёт» сервер для каталога (без spawn): // Server{URL: fake.URL}, поэтому Runner ходит по HTTP на фейк-API. // XDG_CONFIG_HOME уводится во временный каталог, чтобы ReadModelRef не читал // реальный пользовательский конфиг opencode (детерминизм тестов). func fakePool(t *testing.T, f *fakeAPIServer, dir string) (*Pool, *Client) { t.Helper() ts := httptestURL(t, f) p := NewPool(dir) p.mu.Lock() p.segs[dir] = &Server{URL: ts, PollInterval: time.Millisecond} p.mu.Unlock() return p, &Client{BaseURL: ts} } // httptestURL запускает фейк-API и возвращает его URL. func httptestURL(t *testing.T, f *fakeAPIServer) string { t.Helper() ts := httptest.NewServer(f.handler()) t.Cleanup(ts.Close) return ts.URL } func TestRun_Success(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) dir := t.TempDir() f := &fakeAPIServer{verdictText: "done"} p, _ := fakePool(t, f, dir) r := &Runner{Pool: p, PollInterval: 5 * time.Millisecond, Stdout: io.Discard} res, err := r.Run(context.Background(), "task", dir, "dev", "") if err != nil { t.Fatalf("Run err: %v", err) } if res.RC != 0 { t.Errorf("RC = %d, want 0", res.RC) } if res.SessionID != "sess-fake" { t.Errorf("SessionID = %q, want sess-fake", res.SessionID) } if !contains(res.Stdout, "done") { t.Errorf("Stdout = %q, want contain done", res.Stdout) } } func TestRun_IdleTimeout(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) dir := t.TempDir() // агент «завис»: active=true, прогресс не растёт → idle abort f := &fakeAPIServer{blockPrompt: true} p, _ := fakePool(t, f, dir) r := &Runner{Pool: p, IdleTimeout: 30 * time.Millisecond, PollInterval: 5 * time.Millisecond, Stdout: io.Discard} res, err := r.Run(context.Background(), "task", dir, "dev", "") if err != nil { t.Fatalf("Run err: %v", err) } if res.RC != -1 { t.Errorf("RC = %d, want -1 (idle timeout)", res.RC) } } func TestRun_StreamingGrowth(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) dir := t.TempDir() // стрим: один text-парт растёт с каждым опросом (дольше, чем idle timeout), // но модель жива → idle НЕ должен сработать. f := &fakeAPIServer{streamGrow: true, streamPolls: 30} p, _ := fakePool(t, f, dir) r := &Runner{Pool: p, IdleTimeout: 60 * time.Millisecond, PollInterval: 5 * time.Millisecond, Stdout: io.Discard} res, err := r.Run(context.Background(), "task", dir, "dev", "") if err != nil { t.Fatalf("Run err: %v", err) } if res.RC != 0 { t.Errorf("RC = %d, want 0 (растущий стрим не должен считаться hung)", res.RC) } if !contains(res.Stdout, "done-stream") { t.Errorf("Stdout = %q, want contain done-stream", res.Stdout) } } func TestRun_ReasoningGrowth(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) dir := t.TempDir() // стрим: растёт только reasoning-парт (текста нет) — тоже живая активность. f := &fakeAPIServer{streamGrow: true, streamReasoning: true, streamPolls: 30} p, _ := fakePool(t, f, dir) r := &Runner{Pool: p, IdleTimeout: 60 * time.Millisecond, PollInterval: 5 * time.Millisecond, Stdout: io.Discard} res, err := r.Run(context.Background(), "task", dir, "dev", "") if err != nil { t.Fatalf("Run err: %v", err) } if res.RC != 0 { t.Errorf("RC = %d, want 0 (растущий reasoning не должен считаться hung)", res.RC) } } func TestRun_ReasoningOnlyVerdict(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) dir := t.TempDir() // завершённый ответ без text-парта, только reasoning — вердикт собирается // из reasoning (fallback) вместо ошибки «нет text-части в ответе». f := &fakeAPIServer{verdictReasoning: "размышления без текста"} p, _ := fakePool(t, f, dir) r := &Runner{Pool: p, IdleTimeout: time.Minute, HardTimeout: time.Minute, PollInterval: 5 * time.Millisecond, Stdout: io.Discard} res, err := r.Run(context.Background(), "task", dir, "dev", "") if err != nil { t.Fatalf("Run err: %v", err) } if res.RC != 0 { t.Errorf("RC = %d, want 0 (reasoning-only вердикт)", res.RC) } if !contains(res.Stdout, "размышления без текста") { t.Errorf("Stdout = %q, want reasoning fallback", res.Stdout) } } func TestRun_ContextCancel(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) dir := t.TempDir() f := &fakeAPIServer{blockPrompt: true} p, _ := fakePool(t, f, dir) ctx, cancel := context.WithCancel(context.Background()) r := &Runner{Pool: p, IdleTimeout: time.Minute, HardTimeout: time.Minute, PollInterval: 5 * time.Millisecond, Stdout: io.Discard} done := make(chan *Result, 1) errCh := make(chan error, 1) go func() { res, err := r.Run(ctx, "task", dir, "dev", "") done <- res errCh <- err }() time.Sleep(30 * time.Millisecond) cancel() res := <-done if err := <-errCh; err != nil { t.Fatalf("Run err: %v", err) } if res.RC != -1 { t.Errorf("RC = %d, want -1", res.RC) } } func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0) } func indexOf(s, sub string) int { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return i } } return -1 }