Files
ratatoskr-go/internal/worker/worker_test.go
Hermes bd3d825738
All checks were successful
CI / test (push) Successful in 46s
CI / build-and-package (amd64, darwin) (push) Successful in 36s
CI / build-and-package (amd64, linux) (push) Successful in 37s
CI / build-and-package (amd64, windows) (push) Successful in 44s
CI / build-and-package (arm64, darwin) (push) Successful in 35s
CI / build-and-package (arm64, linux) (push) Successful in 36s
feat: множественные репозитории (Repos) и клонирование в воркере
- Task.Repos []string (XML-колонка repos, обратная совместимость с repo)
- config: блок git {base_url, token}
- аналитик: ответ repos[], шаблон показывает список
- core: propose без repos → возврат в сбор (E1)
- worker вариант A: один dev из общего cwd, prepareRepos клонирует
  недостающие репо (git clone), validateRepoName (E3), ErrRepoNotGit (E4)
- ошибки E1-E4 в worker/errors.go
2026-08-16 09:18:15 +05:00

406 lines
10 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 worker
import (
"context"
"errors"
"fmt"
"os"
"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,
Repos: []string{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
}
// seedFakeRepo создаёт в worktree/<repo>/ папку с .git, чтобы prepareRepos
// прошёл без реального git clone.
func seedFakeRepo(t *testing.T, worktree, repo string) {
t.Helper()
dir := filepath.Join(worktree, repo)
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatalf("seed repo %s: %v", repo, err)
}
}
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",
}
seedFakeRepo(t, w.Worktree, "calc")
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(),
}
seedFakeRepo(t, w.Worktree, "slow")
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(),
}
seedFakeRepo(t, w.Worktree, "spawn-fail")
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(),
}
seedFakeRepo(t, w.Worktree, "fail")
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(),
}
seedFakeRepo(t, w.Worktree, "prompt-test")
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, " - prompt-test") {
t.Error("prompt не содержит репозиторий prompt-test")
}
}
func TestValidateRepoName(t *testing.T) {
valid := []string{"calc", "proj-a", "my.repo", "node_2"}
for _, r := range valid {
if err := validateRepoName(r); err != nil {
t.Errorf("validateRepoName(%q) = %v, want nil", r, err)
}
}
invalid := []string{"", "../etc", "a/b", "a/../b", ".."}
for _, r := range invalid {
if err := validateRepoName(r); err == nil {
t.Errorf("validateRepoName(%q) = nil, want E3", r)
} else if !errors.Is(err, ErrRepoPathHint) {
t.Errorf("validateRepoName(%q) err = %v, want E3", r, err)
}
}
}
func TestBuildCloneURL(t *testing.T) {
if got := buildCloneURL("http://gitea.hal9000.home", "proj-a"); got != "http://gitea.hal9000.home/proj-a.git" {
t.Errorf("buildCloneURL = %q", got)
}
if got := buildCloneURL("http://gitea.hal9000.home/", "proj-b"); got != "http://gitea.hal9000.home/proj-b.git" {
t.Errorf("buildCloneURL trailing slash = %q", got)
}
}
func TestPrepareRepos(t *testing.T) {
s := setupWorkerDB(t)
_ = s
wt := t.TempDir()
w := &Worker{Worktree: wt, GitBaseURL: "http://gitea.hal9000.home"}
// seedFakeRepo уже создал .git — prepareRepos должен пройти без клона.
seedFakeRepo(t, wt, "proj-a")
if err := w.prepareRepos(context.Background(), []string{"proj-a"}); err != nil {
t.Fatalf("prepareRepos existing: %v", err)
}
// отсутствующий репо без git в PATH → E2 (clone упал), но не паника.
err := w.prepareRepos(context.Background(), []string{"missing"})
if err == nil {
t.Fatal("prepareRepos missing: expected error")
}
if !errors.Is(err, ErrClone) {
t.Errorf("prepareRepos missing err = %v, want E2", err)
}
}
func TestPrepareReposNonGitDir(t *testing.T) {
wt := t.TempDir()
w := &Worker{Worktree: wt}
// папка есть, но без .git → E4.
if err := os.MkdirAll(filepath.Join(wt, "plain"), 0o755); err != nil {
t.Fatal(err)
}
err := w.prepareRepos(context.Background(), []string{"plain"})
if err == nil {
t.Fatal("expected E4 error")
}
if !errors.Is(err, ErrRepoNotGit) {
t.Errorf("err = %v, want E4", err)
}
}
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,
}
seedFakeRepo(t, w.Worktree, "task-0")
seedFakeRepo(t, w.Worktree, "task-1")
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))
}
}