171 lines
5.3 KiB
Go
171 lines
5.3 KiB
Go
package opencode
|
||
|
||
import (
|
||
"context"
|
||
"io"
|
||
"net/http/httptest"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// fakePool создаёт Pool, в котором уже «живёт» сервер для каталога (без spawn):
|
||
// Server{URL: fake.URL}, поэтому Runner ходит по HTTP на фейк-API.
|
||
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(6000 * 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
|
||
} |