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
- 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
406 lines
10 KiB
Go
406 lines
10 KiB
Go
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))
|
||
}
|
||
} |