package worker import ( "context" "errors" "fmt" "path/filepath" "strings" "testing" "time" "github.com/kamelion/ratatoskr-go/internal/opencode" "github.com/kamelion/ratatoskr-go/internal/storage" ) type mockRunnerWorker struct { result *opencode.Result err error } func (m *mockRunnerWorker) Run(_ context.Context, _, _, _, _ string) (*opencode.Result, error) { return m.result, m.err } func setupWorkerDB(t *testing.T) *storage.Storage { t.Helper() ctx := context.Background() f := filepath.Join(t.TempDir(), "test.db") s, err := storage.Open(ctx, f) if err != nil { t.Fatalf("open storage: %v", err) } t.Cleanup(func() { s.Close() }) return s } func createReadyTask(t *testing.T, s *storage.Storage, title string) *storage.Task { t.Helper() ctx := context.Background() task := &storage.Task{ ChatID: "tg://worker-test", Title: title, Goal: "сделать " + title, Repo: "test/" + title, Why: "для теста", AC: "работает", TaskTag: "test-" + title, } id, err := s.CreateTask(ctx, task) if err != nil { t.Fatalf("create task: %v", err) } task.ID = id task.Status = storage.StatusCollecting if err := s.UpdateTask(ctx, task); err != nil { t.Fatalf("set collecting: %v", err) } task.Status = storage.StatusReady if err := s.UpdateTask(ctx, task); err != nil { t.Fatalf("set ready: %v", err) } task, _ = s.GetTask(ctx, id) return task } func TestWorkerHappyPath(t *testing.T) { s := setupWorkerDB(t) task := createReadyTask(t, s, "calc") w := &Worker{ Store: s, Runner: &mockRunnerWorker{result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"}}, Worktree: t.TempDir(), Agent: "dev", } ctx := context.Background() if err := w.runTask(ctx, task); err != nil { t.Fatalf("runTask: %v", err) } task, err := s.GetTask(ctx, task.ID) if err != nil { t.Fatalf("get task: %v", err) } if task.Status != storage.StatusSuccess { t.Errorf("status = %q, want success", task.Status) } traces, err := s.GetTraces(ctx, task.ID) if err != nil { t.Fatalf("get traces: %v", err) } if len(traces) != 1 { t.Fatalf("got %d traces, want 1", len(traces)) } if traces[0].Status != storage.TraceSuccess { t.Errorf("trace status = %q, want success", traces[0].Status) } if traces[0].Agent != "dev" { t.Errorf("agent = %q, want dev", traces[0].Agent) } if traces[0].SessionID != "sess-1" { t.Errorf("session = %q, want sess-1", traces[0].SessionID) } } func TestWorkerTimeout(t *testing.T) { s := setupWorkerDB(t) task := createReadyTask(t, s, "slow") w := &Worker{ Store: s, Runner: &mockRunnerWorker{result: &opencode.Result{RC: -1, Stdout: ""}}, Worktree: t.TempDir(), } ctx := context.Background() _ = w.runTask(ctx, task) task, err := s.GetTask(ctx, task.ID) if err != nil { t.Fatalf("get task: %v", err) } if task.Status != storage.StatusTimeout { t.Errorf("status = %q, want timeout", task.Status) } traces, err := s.GetTraces(ctx, task.ID) if err != nil { t.Fatalf("get traces: %v", err) } if len(traces) != 1 { t.Fatalf("got %d traces, want 1", len(traces)) } if traces[0].Status != storage.TraceTimeout { t.Errorf("trace status = %q, want timeout", traces[0].Status) } } func TestWorkerSpawnError(t *testing.T) { s := setupWorkerDB(t) task := createReadyTask(t, s, "spawn-fail") w := &Worker{ Store: s, Runner: &mockRunnerWorker{err: errors.New("opencode not found")}, Worktree: t.TempDir(), } ctx := context.Background() _ = w.runTask(ctx, task) task, err := s.GetTask(ctx, task.ID) if err != nil { t.Fatalf("get task: %v", err) } if task.Status != storage.StatusFailed { t.Errorf("status = %q, want failed", task.Status) } traces, err := s.GetTraces(ctx, task.ID) if err != nil { t.Fatalf("get traces: %v", err) } if len(traces) != 1 { t.Fatalf("got %d traces, want 1", len(traces)) } if traces[0].Status != storage.TraceFailed { t.Errorf("trace status = %q, want failed", traces[0].Status) } } func TestWorkerNonZeroExit(t *testing.T) { s := setupWorkerDB(t) task := createReadyTask(t, s, "fail") w := &Worker{ Store: s, Runner: &mockRunnerWorker{result: &opencode.Result{RC: 7, Stdout: "error"}}, Worktree: t.TempDir(), } ctx := context.Background() _ = w.runTask(ctx, task) task, err := s.GetTask(ctx, task.ID) if err != nil { t.Fatalf("get task: %v", err) } if task.Status != storage.StatusFailed { t.Errorf("status = %q, want failed", task.Status) } traces, err := s.GetTraces(ctx, task.ID) if err != nil { t.Fatalf("get traces: %v", err) } if len(traces) != 1 { t.Fatalf("got %d traces, want 1", len(traces)) } if traces[0].Status != storage.TraceFailed { t.Errorf("trace status = %q, want failed", traces[0].Status) } if traces[0].Output != "error" { t.Errorf("output = %q, want error", traces[0].Output) } } func TestWorkerBadStatus(t *testing.T) { s := setupWorkerDB(t) ctx := context.Background() task := &storage.Task{ChatID: "tg://bad", Title: "bad-status", TaskTag: "bad"} id, err := s.CreateTask(ctx, task) if err != nil { t.Fatalf("create task: %v", err) } task, _ = s.GetTask(ctx, id) err = (&Worker{Store: s}).runTask(ctx, task) if err == nil { t.Fatal("expected error for non-ready task") } if !errors.Is(err, ErrLaunch) { t.Errorf("err = %v, want W2", err) } } func TestWorkerPromptRendered(t *testing.T) { s := setupWorkerDB(t) task := createReadyTask(t, s, "prompt-test") w := &Worker{ Store: s, Runner: &mockRunnerWorker{result: &opencode.Result{RC: 0, Stdout: "ok", SessionID: "s"}}, Worktree: t.TempDir(), } ctx := context.Background() _ = w.runTask(ctx, task) traces, err := s.GetTraces(ctx, task.ID) if err != nil { t.Fatalf("get traces: %v", err) } if len(traces) == 0 { t.Fatal("no traces") } tr := traces[0] if tr.Prompt == "" { t.Error("prompt empty — шаблон не срендерился") } if !strings.Contains(tr.Prompt, "prompt-test") { t.Error("prompt не содержит название задачи") } if !strings.Contains(tr.Prompt, "test/prompt-test") { t.Error("prompt не содержит repo") } } func TestWorkerResolveCwd(t *testing.T) { base := "/opt/data/src" w := &Worker{Worktree: base} if got := w.resolveCwd(""); got != base { t.Errorf("empty repo: got %q, want %q", got, base) } if got := w.resolveCwd("tools/calc"); got != filepath.Join(base, "tools/calc") { t.Errorf("repo: got %q, want %q", got, filepath.Join(base, "tools/calc")) } } func TestWorkerStartStop(t *testing.T) { s := setupWorkerDB(t) w := &Worker{ Store: s, Runner: &mockRunnerWorker{result: &opencode.Result{RC: 0}}, Worktree: t.TempDir(), MaxJobs: 1, Interval: 50 * time.Millisecond, } ctx := context.Background() w.Start(ctx) time.Sleep(150 * time.Millisecond) w.Stop() } func TestWorkerSemaphore(t *testing.T) { s := setupWorkerDB(t) // создаём 2 ready-задачи for i := 0; i < 2; i++ { createReadyTask(t, s, fmt.Sprintf("task-%d", i)) } w := &Worker{ Store: s, Runner: &mockRunnerWorker{result: &opencode.Result{RC: 0, Stdout: "ok"}}, Worktree: t.TempDir(), MaxJobs: 1, Interval: 50 * time.Millisecond, } w.sem = make(chan struct{}, 1) w.sem <- struct{}{} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // первый poll — запустит 1 задачу (макс. 1) w.pollAndDispatch(ctx) time.Sleep(200 * time.Millisecond) // 1 должна быть success, 1 — всё ещё ready success, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusSuccess}) ready, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusReady}) if len(success) != 1 { t.Errorf("success = %d, want 1 (ready=%d)", len(success), len(ready)) } if len(ready) != 1 { t.Errorf("ready = %d, want 1", len(ready)) } // первая завершилась и вернула токен в сем — можем диспатчить вторую w.pollAndDispatch(ctx) time.Sleep(200 * time.Millisecond) success, _ = s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusSuccess}) if len(success) != 2 { t.Errorf("после освобождения слота success = %d, want 2", len(success)) } }