Files
ratatoskr-go/internal/opencode/runner_test.go
Hermes 733e63339a
Some checks failed
CI / test (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 35s
CI / build-and-package (amd64, windows) (push) Failing after 26s
feat: opencode через HTTP API — пул serve-серверов вместо spawn/NDJSON
Runner теперь ходит к постоянным serve по HTTP API (v1.17+, /api):
- клиент Client (create/send/wait/abort/messages/verdict)
- Pool: по одному serve на каталог, ленивый подъём, root-сервер в worktree,
  выделение портов, ReleaseTask при завершении задачи
- Run: CreateSession('ratatoskr-<агент>') -> Send -> поллинг Verdict из
  text-частей assistant-сообщений; idle/hard таймауты дают RC=-1
- вердикт извлекается из последнего assistant text-парта (плоский text)
- тесты: unit на фейковом HTTP-сервере; e2e эмулирует serve через httptest,
  агент определяется по title сессии
2026-08-18 13:36:54 +05:00

133 lines
3.7 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"
"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) {
dir := t.TempDir()
f := &fakeAPIServer{
messages: []sessionMessage{{Type: "assistant", Content: []struct {
Type string `json:"type"`
Text string `json:"text"`
}{textPart("done")}}},
}
p, _ := fakePool(t, f, dir)
r := &Runner{Pool: p, PollInterval: 5 * time.Millisecond}
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) {
dir := t.TempDir()
// сервер никогда не отдаёт text → всегда неготов, прогресс не растёт
f := &fakeAPIServer{}
p, _ := fakePool(t, f, dir)
r := &Runner{Pool: p, IdleTimeout: 30 * time.Millisecond,
PollInterval: 5 * time.Millisecond}
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_ContextCancel(t *testing.T) {
dir := t.TempDir()
f := &fakeAPIServer{}
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}
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)
}
}
// TestResumeDev_Fallback: resume (sessionID) "падает" rc!=0 только когда самого
// сервера нет; в фейке такого нет, поэтому проверяем, что при успехе
// fallback не срабатывает и таймаут не выставляется.
func TestResumeDev_NoFallbackOnSuccess(t *testing.T) {
dir := t.TempDir()
f := &fakeAPIServer{
messages: []sessionMessage{{Type: "assistant", Content: []struct {
Type string `json:"type"`
Text string `json:"text"`
}{textPart("ok")}}},
}
p, _ := fakePool(t, f, dir)
r := &Runner{Pool: p, PollInterval: 5 * time.Millisecond}
res, timedOut := r.ResumeDev(context.Background(), "task", dir, "lost-session")
if timedOut {
t.Error("timedOut = true, want false (успех не должен считаться таймаутом)")
}
if res.RC != 0 {
t.Errorf("RC = %d, want 0", 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
}