v0.1.80 63e28ad

swarm_test.go RawHistoryFolder

1package swarm
2
3import (
4	"context"
5	"errors"
6	"path/filepath"
7	"strings"
8	"sync"
9	"testing"
10	"time"
11)
12
13// newTestSwarm builds a Swarm rooted in t.TempDir with the in-memory
14// worktree and a Runner factory the test controls. Returns the swarm
15// plus a slice of runners keyed by spawn order so tests can assert
16// they were actually invoked.
17func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm {
18	t.Helper()
19	root := t.TempDir()
20	return New(Config{
21		Root:      root,
22		RepoRoot:  root,
23		Worktree:  MemWorktree(filepath.Join(root, "worktrees")),
24		NewRunner: mk,
25	})
26}
27
28func TestSpawnRunsAndCompletes(t *testing.T) {
29	ran := make(chan string, 1)
30	f := newTestSwarm(t, func(a *Agent) Runner {
31		return RunnerFunc(func(ctx context.Context, sink Sink) error {
32			ran <- a.Task
33			sink.Activity("hello")
34			sink.Transcript("line one")
35			sink.Transcript("line two")
36			return nil
37		})
38	})
39	a, err := f.Spawn(context.Background(), "do a thing")
40	if err != nil {
41		t.Fatal(err)
42	}
43	select {
44	case got := <-ran:
45		if got != "do a thing" {
46			t.Fatalf("runner got task %q; want %q", got, "do a thing")
47		}
48	case <-time.After(time.Second):
49		t.Fatal("runner did not start")
50	}
51	a.Wait()
52	if a.Status() != StatusDone {
53		t.Fatalf("status %s; want done", a.Status())
54	}
55	if got := a.Transcript(); len(got) != 2 || got[0] != "line one" || got[1] != "line two" {
56		t.Fatalf("transcript = %q", got)
57	}
58	if !strings.Contains(a.ID, "do-a-thing") {
59		t.Fatalf("id %q missing slug", a.ID)
60	}
61	if a.Branch != "swarm/"+a.ID {
62		t.Fatalf("branch = %q", a.Branch)
63	}
64}
65
66func TestSpawnEmptyTaskFails(t *testing.T) {
67	f := newTestSwarm(t, func(a *Agent) Runner {
68		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
69	})
70	if _, err := f.Spawn(context.Background(), "   "); err == nil {
71		t.Fatal("expected error on empty task")
72	}
73}
74
75func TestRunnerErrorMarksFailed(t *testing.T) {
76	wantErr := errors.New("boom")
77	f := newTestSwarm(t, func(a *Agent) Runner {
78		return RunnerFunc(func(ctx context.Context, sink Sink) error { return wantErr })
79	})
80	a, _ := f.Spawn(context.Background(), "explode")
81	a.Wait()
82	if a.Status() != StatusFailed {
83		t.Fatalf("status %s; want failed", a.Status())
84	}
85	if !errors.Is(a.Err(), wantErr) {
86		t.Fatalf("err = %v", a.Err())
87	}
88	if !strings.Contains(a.Activity(), "boom") {
89		t.Fatalf("activity = %q; want it to mention the error", a.Activity())
90	}
91}
92
93func TestStopCancelsRunningAgent(t *testing.T) {
94	started := make(chan struct{})
95	f := newTestSwarm(t, func(a *Agent) Runner {
96		return RunnerFunc(func(ctx context.Context, sink Sink) error {
97			close(started)
98			<-ctx.Done()
99			return ctx.Err()
100		})
101	})
102	a, _ := f.Spawn(context.Background(), "long")
103	<-started
104	if err := f.Stop(a.ID); err != nil {
105		t.Fatal(err)
106	}
107	a.Wait()
108	if a.Status() != StatusKilled {
109		t.Fatalf("status = %s; want killed", a.Status())
110	}
111}
112
113func TestStopAfterDoneIsNoop(t *testing.T) {
114	f := newTestSwarm(t, func(a *Agent) Runner {
115		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
116	})
117	a, _ := f.Spawn(context.Background(), "quick")
118	a.Wait()
119	if err := f.Stop(a.ID); err != nil {
120		t.Fatalf("stop after done: %v", err)
121	}
122	if a.Status() != StatusDone {
123		t.Fatalf("status flipped to %s", a.Status())
124	}
125}
126
127func TestGetPrefixMatch(t *testing.T) {
128	f := newTestSwarm(t, func(a *Agent) Runner {
129		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
130	})
131	a, _ := f.Spawn(context.Background(), "alpha task")
132	a.Wait()
133	// Full id works.
134	if got := f.Get(a.ID); got != a {
135		t.Fatal("get by full id failed")
136	}
137	// Slug prefix works as long as it's unique.
138	if got := f.Get("alpha"); got != a {
139		t.Fatal("get by prefix failed")
140	}
141	// Bogus id returns nil.
142	if got := f.Get("zzz-nope"); got != nil {
143		t.Fatalf("expected nil; got %#v", got)
144	}
145}
146
147func TestRemoveRequiresTerminalState(t *testing.T) {
148	hold := make(chan struct{})
149	f := newTestSwarm(t, func(a *Agent) Runner {
150		return RunnerFunc(func(ctx context.Context, sink Sink) error {
151			<-hold
152			return nil
153		})
154	})
155	a, _ := f.Spawn(context.Background(), "still going")
156	// Wait for run goroutine to flip to running.
157	for i := 0; i < 100 && a.Status() != StatusRunning; i++ {
158		time.Sleep(time.Millisecond)
159	}
160	if err := f.Remove(a.ID); err == nil {
161		t.Fatal("remove of running agent should fail")
162	}
163	close(hold)
164	a.Wait()
165	if err := f.Remove(a.ID); err != nil {
166		t.Fatalf("remove after done: %v", err)
167	}
168	if got := f.Get(a.ID); got != nil {
169		t.Fatal("agent still present after remove")
170	}
171}
172
173func TestSnapshotIsStableAcrossAccess(t *testing.T) {
174	var wg sync.WaitGroup
175	wg.Add(1)
176	f := newTestSwarm(t, func(a *Agent) Runner {
177		return RunnerFunc(func(ctx context.Context, sink Sink) error {
178			defer wg.Done()
179			for i := 0; i < 200; i++ {
180				sink.Transcript("chunk")
181				sink.Activity("step")
182			}
183			return nil
184		})
185	})
186	a, _ := f.Spawn(context.Background(), "race")
187	// Hammer Snapshot while the runner is writing; the -race detector
188	// is the real assertion here.
189	stop := make(chan struct{})
190	go func() {
191		for {
192			select {
193			case <-stop:
194				return
195			default:
196				_ = a.Snapshot()
197			}
198		}
199	}()
200	wg.Wait()
201	a.Wait()
202	close(stop)
203	if a.Status() != StatusDone {
204		t.Fatalf("status = %s", a.Status())
205	}
206}
207
208func TestTaskSlug(t *testing.T) {
209	cases := map[string]string{
210		"fix the login form":                   "fix-the-login-form",
211		"  weird --- spaces!!  ":               "weird-spaces",
212		"":                                     "agent",
213		"a-very-long-task-name-that-overflows": "a-very-long-task-name-th",
214	}
215	for in, want := range cases {
216		if got := taskSlug(in); got != want {
217			t.Errorf("taskSlug(%q) = %q; want %q", in, got, want)
218		}
219	}
220}
221
222func TestSnapshotAllSorted(t *testing.T) {
223	f := newTestSwarm(t, func(a *Agent) Runner {
224		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
225	})
226	a1, _ := f.Spawn(context.Background(), "first")
227	// Force second spawn into a later nanosecond bucket.
228	time.Sleep(2 * time.Millisecond)
229	a2, _ := f.Spawn(context.Background(), "second")
230	a1.Wait()
231	a2.Wait()
232	snaps := f.SnapshotAll()
233	if len(snaps) != 2 {
234		t.Fatalf("want 2 snapshots; got %d", len(snaps))
235	}
236	if !snaps[0].Started.Before(snaps[1].Started) && !snaps[0].Started.Equal(snaps[1].Started) {
237		t.Fatal("snapshots not in spawn order")
238	}
239}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close