v0.3.97 c2267b3

sessionscmd_test.go RawHistoryFolder

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}

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