1package agent
2
3import (
4 "bytes"
5 "io/fs"
6 "os"
7 "path/filepath"
8 "reflect"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/patriceckhart/zot/packages/core"
14 "github.com/patriceckhart/zot/packages/provider"
15)
16
17func TestParseSessionSelection(t *testing.T) {
18 tests := []struct {
19 input string
20 count int
21 want []int
22 wantErr bool
23 }{
24 {input: "", count: 3},
25 {input: "none", count: 3},
26 {input: "all", count: 3, want: []int{0, 1, 2}},
27 {input: "1,3-4,3", count: 4, want: []int{0, 2, 3}},
28 {input: "0", count: 3, wantErr: true},
29 {input: "4", count: 3, wantErr: true},
30 {input: "3-2", count: 3, wantErr: true},
31 {input: "1,,2", count: 3, wantErr: true},
32 }
33 for _, tt := range tests {
34 t.Run(tt.input, func(t *testing.T) {
35 got, err := parseSessionSelection(tt.input, tt.count)
36 if (err != nil) != tt.wantErr {
37 t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
38 }
39 if !reflect.DeepEqual(got, tt.want) {
40 t.Fatalf("selection = %v, want %v", got, tt.want)
41 }
42 })
43 }
44}
45
46func TestParseSessionAge(t *testing.T) {
47 now := time.Date(2026, time.August, 22, 12, 0, 0, 0, time.UTC)
48 tests := []struct {
49 input string
50 want time.Time
51 wantErr bool
52 }{
53 {input: "30m", want: now.Add(-30 * time.Minute)},
54 {input: "4h", want: now.Add(-4 * time.Hour)},
55 {input: "30d", want: now.Add(-30 * 24 * time.Hour)},
56 {input: "2w", want: now.Add(-14 * 24 * time.Hour)},
57 {input: "1mo", want: now.AddDate(0, -1, 0)},
58 {input: "1y", want: now.AddDate(-1, 0, 0)},
59 {input: "", wantErr: true},
60 {input: "0d", wantErr: true},
61 {input: "-1d", wantErr: true},
62 {input: "1.5h", wantErr: true},
63 {input: "1s", wantErr: true},
64 }
65 for _, tt := range tests {
66 t.Run(tt.input, func(t *testing.T) {
67 age, err := parseSessionAge(tt.input)
68 if (err != nil) != tt.wantErr {
69 t.Fatalf("parseSessionAge(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
70 }
71 if tt.wantErr {
72 return
73 }
74 got, err := age.cutoff(now)
75 if err != nil {
76 t.Fatal(err)
77 }
78 if !got.Equal(tt.want) {
79 t.Fatalf("cutoff = %v, want %v", got, tt.want)
80 }
81 })
82 }
83}
84
85func TestSessionsPruneDryRunPreservesSessions(t *testing.T) {
86 home := t.TempDir()
87 t.Setenv("ZOT_HOME", home)
88 missing := filepath.Join(home, "missing-project")
89 existing := t.TempDir()
90 missingPath := createPruneTestSession(t, home, missing)
91 existingPath := createPruneTestSession(t, home, existing)
92
93 var out, errOut bytes.Buffer
94 if err := runSessionsPrune(sessionsPruneOptions{dryRun: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
95 t.Fatal(err)
96 }
97 if !strings.Contains(out.String(), missing) || strings.Contains(out.String(), existing) {
98 t.Fatalf("output = %q, want only missing cwd", out.String())
99 }
100 if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") {
101 t.Fatalf("output = %q, want dry-run summary", out.String())
102 }
103 for _, path := range []string{missingPath, existingPath} {
104 if _, err := os.Stat(path); err != nil {
105 t.Fatalf("dry run removed %s: %v", path, err)
106 }
107 }
108}
109
110func TestSessionsPruneDisplaysHumanReadableGroupSizes(t *testing.T) {
111 home := t.TempDir()
112 t.Setenv("ZOT_HOME", home)
113 missing := filepath.Join(home, "missing-project")
114 createPruneTestSessionWithText(t, home, missing, strings.Repeat("a", 2048))
115
116 var out, errOut bytes.Buffer
117 if err := runSessionsPrune(sessionsPruneOptions{dryRun: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
118 t.Fatal(err)
119 }
120 if !strings.Contains(out.String(), "KiB") {
121 t.Fatalf("output = %q, want binary size unit", out.String())
122 }
123 if strings.Contains(out.String(), " bytes)") {
124 t.Fatalf("output = %q, want human-readable size instead of raw bytes", out.String())
125 }
126}
127
128func TestSessionsPruneInteractiveDeletesSelectedGroup(t *testing.T) {
129 home := t.TempDir()
130 t.Setenv("ZOT_HOME", home)
131 firstCWD := filepath.Join(home, "gone-a")
132 secondCWD := filepath.Join(home, "gone-b")
133 first := createPruneTestSession(t, home, firstCWD)
134 second := createPruneTestSession(t, home, secondCWD)
135
136 var out, errOut bytes.Buffer
137 if err := runSessionsPrune(sessionsPruneOptions{}, strings.NewReader("2\ny\n"), &out, &errOut, os.Stat); err != nil {
138 t.Fatal(err)
139 }
140 if _, err := os.Stat(first); err != nil {
141 t.Fatalf("unselected session was removed: %v", err)
142 }
143 if _, err := os.Stat(second); !os.IsNotExist(err) {
144 t.Fatalf("selected session still exists or stat failed unexpectedly: %v", err)
145 }
146 if !strings.Contains(out.String(), "deleted 1 session from 1 directory") {
147 t.Fatalf("output = %q", out.String())
148 }
149}
150
151func TestSessionsPrunePreservesGroupWhenDirectoryCheckIsInconclusive(t *testing.T) {
152 home := t.TempDir()
153 t.Setenv("ZOT_HOME", home)
154 cwd := filepath.Join(home, "unavailable-mount")
155 path := createPruneTestSession(t, home, cwd)
156 stat := func(string) (fs.FileInfo, error) { return nil, fs.ErrPermission }
157
158 var out, errOut bytes.Buffer
159 if err := runSessionsPrune(sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat); err != nil {
160 t.Fatal(err)
161 }
162 if _, err := os.Stat(path); err != nil {
163 t.Fatalf("session was removed after inconclusive stat: %v", err)
164 }
165 if !strings.Contains(errOut.String(), "permission denied") {
166 t.Fatalf("stderr = %q, want stat warning", errOut.String())
167 }
168 if !strings.Contains(out.String(), "no stale session directories found") {
169 t.Fatalf("output = %q", out.String())
170 }
171}
172
173func TestSessionAgeClampsCalendarBoundaries(t *testing.T) {
174 month, err := parseSessionAge("1mo")
175 if err != nil {
176 t.Fatal(err)
177 }
178 got, err := month.cutoff(time.Date(2025, time.March, 31, 12, 0, 0, 0, time.UTC))
179 if err != nil {
180 t.Fatal(err)
181 }
182 want := time.Date(2025, time.February, 28, 12, 0, 0, 0, time.UTC)
183 if !got.Equal(want) {
184 t.Fatalf("one month cutoff = %v, want %v", got, want)
185 }
186
187 year, err := parseSessionAge("1y")
188 if err != nil {
189 t.Fatal(err)
190 }
191 got, err = year.cutoff(time.Date(2024, time.February, 29, 12, 0, 0, 0, time.UTC))
192 if err != nil {
193 t.Fatal(err)
194 }
195 want = time.Date(2023, time.February, 28, 12, 0, 0, 0, time.UTC)
196 if !got.Equal(want) {
197 t.Fatalf("one year cutoff = %v, want %v", got, want)
198 }
199}
200
201func TestSessionsPruneOlderThanFiltersByLastActivity(t *testing.T) {
202 home := t.TempDir()
203 t.Setenv("ZOT_HOME", home)
204 cwd := t.TempDir()
205 oldPath := createPruneTestSession(t, home, cwd)
206 newPath := createPruneTestSession(t, home, cwd)
207 oldTime := time.Now().Add(-48 * time.Hour)
208 if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
209 t.Fatal(err)
210 }
211
212 age, err := parseSessionAge("24h")
213 if err != nil {
214 t.Fatal(err)
215 }
216 var out, errOut bytes.Buffer
217 opts := sessionsPruneOptions{dryRun: true, olderThan: &age}
218 if err := runSessionsPrune(opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
219 t.Fatal(err)
220 }
221 if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") {
222 t.Fatalf("output = %q", out.String())
223 }
224 for _, path := range []string{oldPath, newPath} {
225 if _, err := os.Stat(path); err != nil {
226 t.Fatalf("dry run removed %s: %v", path, err)
227 }
228 }
229}
230
231func TestSessionsPruneOlderThanLimitsDeletionToCWD(t *testing.T) {
232 home := t.TempDir()
233 t.Setenv("ZOT_HOME", home)
234 firstCWD := t.TempDir()
235 secondCWD := t.TempDir()
236 first := createPruneTestSession(t, home, firstCWD)
237 second := createPruneTestSession(t, home, secondCWD)
238 oldTime := time.Now().Add(-48 * time.Hour)
239 for _, path := range []string{first, second} {
240 if err := os.Chtimes(path, oldTime, oldTime); err != nil {
241 t.Fatal(err)
242 }
243 }
244
245 age, err := parseSessionAge("24h")
246 if err != nil {
247 t.Fatal(err)
248 }
249 var out, errOut bytes.Buffer
250 opts := sessionsPruneOptions{all: true, yes: true, olderThan: &age, cwd: firstCWD}
251 if err := runSessionsPrune(opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
252 t.Fatal(err)
253 }
254 if _, err := os.Stat(first); !os.IsNotExist(err) {
255 t.Fatalf("matching session still exists or stat failed unexpectedly: %v", err)
256 }
257 if _, err := os.Stat(second); err != nil {
258 t.Fatalf("session from another cwd was removed: %v", err)
259 }
260}
261
262func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) {
263 home := t.TempDir()
264 t.Setenv("ZOT_HOME", home)
265 cwd := filepath.Join(home, "temporarily-missing")
266 path := createPruneTestSession(t, home, cwd)
267 calls := 0
268 stat := func(path string) (fs.FileInfo, error) {
269 calls++
270 if calls == 1 {
271 return nil, fs.ErrNotExist
272 }
273 return nil, nil
274 }
275
276 var out, errOut bytes.Buffer
277 err := runSessionsPrune(sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat)
278 if err != nil {
279 t.Fatal(err)
280 }
281 if _, err := os.Stat(path); err != nil {
282 t.Fatalf("session was removed after cwd reappeared: %v", err)
283 }
284 if !strings.Contains(errOut.String(), "directory now exists") {
285 t.Fatalf("stderr = %q, want recheck warning", errOut.String())
286 }
287}
288
289func TestParseSessionsPruneOptions(t *testing.T) {
290 if _, err := parseSessionsPruneOptions([]string{"--yes"}); err == nil {
291 t.Fatal("--yes without --all was accepted")
292 }
293 if _, err := parseSessionsPruneOptions([]string{"--cwd", "/tmp/project"}); err == nil {
294 t.Fatal("--cwd without --older-than was accepted")
295 }
296 opts, err := parseSessionsPruneOptions([]string{"--older-than", "1mo", "--cwd", "/tmp/project", "--all", "--yes"})
297 if err != nil {
298 t.Fatal(err)
299 }
300 if !opts.all || !opts.yes || opts.olderThan == nil || opts.olderThan.unit != "mo" || opts.cwd != "/tmp/project" {
301 t.Fatalf("options = %#v", opts)
302 }
303}
304
305func createPruneTestSession(t *testing.T, root, cwd string) string {
306 t.Helper()
307 return createPruneTestSessionWithText(t, root, cwd, "hello")
308}
309
310func createPruneTestSessionWithText(t *testing.T, root, cwd, text string) string {
311 t.Helper()
312 session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
313 if err != nil {
314 t.Fatal(err)
315 }
316 if err := session.AppendMessage(provider.Message{
317 Role: provider.RoleUser,
318 Content: []provider.Content{provider.TextBlock{Text: text}},
319 }); err != nil {
320 t.Fatal(err)
321 }
322 if err := session.Close(); err != nil {
323 t.Fatal(err)
324 }
325 return session.Path
326}