1package modes
2
3import (
4 "context"
5 "strings"
6 "sync"
7 "testing"
8 "time"
9 "unicode"
10
11 "github.com/bnema/zut/packages/core"
12 "github.com/bnema/zut/packages/provider"
13 "github.com/bnema/zut/packages/tui"
14)
15
16const sessionDialogTestTimeout = 10 * time.Second
17
18func TestFormatSessionRowPlainSanitizesControlBytes(t *testing.T) {
19 row := formatSessionRowPlain(core.SessionSummary{
20 Provider: "test\x1b]0;bad\a",
21 Model: "model\x1b[31m",
22 MessageCount: 1,
23 FirstUserText: "hello\x1b[2J\nworld",
24 }, 120)
25 if strings.IndexFunc(row, unicode.IsControl) >= 0 {
26 t.Fatalf("session row contains control characters: %q", row)
27 }
28 if !strings.Contains(row, "hello world") {
29 t.Fatalf("sanitized session text missing: %q", row)
30 }
31}
32
33func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
34 root := t.TempDir()
35 cwd := t.TempDir()
36 session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
37 if err != nil {
38 t.Fatal(err)
39 }
40 if err := session.AppendMessage(provider.Message{
41 Role: provider.RoleUser,
42 Content: []provider.Content{provider.TextBlock{Text: "load this session"}},
43 }); err != nil {
44 t.Fatal(err)
45 }
46 if err := session.Close(); err != nil {
47 t.Fatal(err)
48 }
49
50 d := newSessionDialog()
51 events := d.Open(context.Background(), root, cwd)
52 t.Cleanup(d.Close)
53 if !d.Active() || !d.Loading() {
54 t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())
55 }
56 loadingText := strings.Join(d.Render(tui.Dark, 100), "\n")
57 if !strings.Contains(loadingText, "Loading sessions") {
58 t.Fatalf("loading render = %q, want spinner status", loadingText)
59 }
60 act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
61 if act.Select || !d.Active() {
62 t.Fatalf("enter while loading = %+v, active %v; want no selection and active dialog", act, d.Active())
63 }
64
65 for event := range events {
66 d.ApplyLoad(event)
67 }
68
69 if d.Loading() {
70 t.Fatal("dialog still loading after final result")
71 }
72 if len(d.sessions) != 1 || d.sessions[0].Path != session.Path {
73 t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path)
74 }
75 loadedText := strings.Join(d.Render(tui.Dark, 100), "\n")
76 if strings.Contains(loadedText, "Loading sessions") {
77 t.Fatalf("completed render still shows loading spinner: %q", loadedText)
78 }
79 if !strings.Contains(loadedText, "load this session") {
80 t.Fatalf("completed render missing session text: %q", loadedText)
81 }
82}
83
84func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) {
85 ctx, cancel := context.WithCancel(context.Background())
86 cancel()
87
88 d := newSessionDialog()
89 events := d.Open(ctx, t.TempDir(), t.TempDir())
90 if d.Loading() {
91 t.Fatal("dialog remained loading with an already-canceled parent")
92 }
93 if d.Active() {
94 t.Fatal("dialog remained active with an already-canceled parent")
95 }
96 if _, ok := <-events; ok {
97 t.Fatal("canceled dialog emitted a load event")
98 }
99}
100
101func TestSessionDialogCancellationFinishesLoading(t *testing.T) {
102 ctx, cancel := context.WithCancel(context.Background())
103 d := newSessionDialog()
104 events := d.Open(ctx, t.TempDir(), t.TempDir())
105 cancel()
106 for range events {
107 }
108 d.ApplyLoadClosed()
109 if d.Loading() {
110 t.Fatal("dialog remained loading after parent cancellation")
111 }
112 if d.Active() {
113 t.Fatal("dialog remained active after parent cancellation")
114 }
115}
116
117func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) {
118 root := t.TempDir()
119 cwd := t.TempDir()
120 session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
121 if err != nil {
122 t.Fatal(err)
123 }
124 if err := session.AppendMessage(provider.Message{
125 Role: provider.RoleUser,
126 Content: []provider.Content{provider.TextBlock{Text: "cancel this load"}},
127 }); err != nil {
128 t.Fatal(err)
129 }
130 if err := session.Close(); err != nil {
131 t.Fatal(err)
132 }
133
134 entered := make(chan struct{})
135 d := newSessionDialog()
136 d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
137 close(entered)
138 <-ctx.Done()
139 return core.SessionSummary{Path: path}
140 }
141 ctx, cancel := context.WithCancel(context.Background())
142 events := d.Open(ctx, root, cwd)
143 t.Cleanup(func() {
144 cancel()
145 d.Close()
146 })
147
148 select {
149 case event, ok := <-events:
150 if !ok || event.kind != sessionLoadStarted {
151 t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
152 }
153 d.ApplyLoad(event)
154 case <-time.After(sessionDialogTestTimeout):
155 t.Fatal("timed out waiting for sessionLoadStarted")
156 }
157 select {
158 case <-entered:
159 case <-time.After(sessionDialogTestTimeout):
160 t.Fatal("timed out waiting for the session worker")
161 }
162
163 cancel()
164 for {
165 select {
166 case event, ok := <-events:
167 if !ok {
168 d.ApplyLoadClosed()
169 if d.Loading() || d.Active() {
170 t.Fatalf("dialog after cancellation = loading %v, active %v; want both false", d.Loading(), d.Active())
171 }
172 return
173 }
174 if event.kind == sessionLoadFinished {
175 t.Fatal("canceled load emitted sessionLoadFinished")
176 }
177 d.ApplyLoad(event)
178 case <-time.After(sessionDialogTestTimeout):
179 t.Fatal("timed out waiting for canceled session load to close")
180 }
181 }
182}
183
184func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T) {
185 root := t.TempDir()
186 cwd := t.TempDir()
187 session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
188 if err != nil {
189 t.Fatal(err)
190 }
191 if err := session.AppendMessage(provider.Message{
192 Role: provider.RoleUser,
193 Content: []provider.Content{provider.TextBlock{Text: "wait for the prior load"}},
194 }); err != nil {
195 t.Fatal(err)
196 }
197 if err := session.Close(); err != nil {
198 t.Fatal(err)
199 }
200
201 entered := make(chan struct{})
202 release := make(chan struct{})
203 var enteredOnce sync.Once
204 var releaseOnce sync.Once
205 releaseLoad := func() { releaseOnce.Do(func() { close(release) }) }
206 d := newSessionDialog()
207 d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
208 enteredOnce.Do(func() { close(entered) })
209 <-release
210 return core.SessionSummary{Path: path, MessageCount: 1}
211 }
212 firstCtx, firstCancel := context.WithCancel(context.Background())
213 firstEvents := d.Open(firstCtx, root, cwd)
214 t.Cleanup(func() {
215 firstCancel()
216 releaseLoad()
217 d.Close()
218 })
219
220 select {
221 case event, ok := <-firstEvents:
222 if !ok || event.kind != sessionLoadStarted {
223 t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
224 }
225 d.ApplyLoad(event)
226 case <-time.After(sessionDialogTestTimeout):
227 t.Fatal("timed out waiting for the first load to start")
228 }
229 select {
230 case <-entered:
231 case <-time.After(sessionDialogTestTimeout):
232 t.Fatal("timed out waiting for the first worker")
233 }
234
235 preCanceled, cancelPreCanceled := context.WithCancel(context.Background())
236 cancelPreCanceled()
237 preCanceledEvents := d.Open(preCanceled, root, cwd)
238 if _, ok := <-preCanceledEvents; ok {
239 t.Fatal("pre-canceled open emitted a load event")
240 }
241
242 thirdCtx, cancelThird := context.WithCancel(context.Background())
243 thirdEvents := d.Open(thirdCtx, root, cwd)
244 select {
245 case event, ok := <-thirdEvents:
246 t.Fatalf("third open started before the first load finished: %+v, open %v", event, ok)
247 case <-time.After(100 * time.Millisecond):
248 }
249
250 releaseLoad()
251 firstCancel()
252 select {
253 case event, ok := <-thirdEvents:
254 if !ok || event.kind != sessionLoadStarted {
255 t.Fatalf("third load event = %+v, open %v; want sessionLoadStarted after barrier", event, ok)
256 }
257 d.ApplyLoad(event)
258 case <-time.After(sessionDialogTestTimeout):
259 t.Fatal("timed out waiting for third load after releasing the first")
260 }
261 cancelThird()
262 for event := range thirdEvents {
263 d.ApplyLoad(event)
264 }
265}
266
267func TestSessionDialogEscapeCancelsLoading(t *testing.T) {
268 d := newSessionDialog()
269 events := d.Open(context.Background(), t.TempDir(), t.TempDir())
270 act := d.HandleKey(tui.Key{Kind: tui.KeyEsc})
271 if !act.Close || d.Active() || d.Loading() {
272 t.Fatalf("escape action = %+v, active %v, loading %v; want closed dialog", act, d.Active(), d.Loading())
273 }
274 for range events {
275 }
276}
277
278func TestSessionDialogAppliesLoadedEntriesIncrementallyInPathOrder(t *testing.T) {
279 d := newSessionDialog()
280 d.active = true
281 d.loading = true
282 d.loadGeneration = 7
283
284 d.ApplyLoad(sessionLoadEvent{
285 kind: sessionLoadStarted,
286 generation: 7,
287 total: 4,
288 })
289 d.ApplyLoad(sessionLoadEvent{
290 kind: sessionLoadEntry,
291 generation: 7,
292 index: 2,
293 summary: core.SessionSummary{Path: "third", MessageCount: 1},
294 })
295 if len(d.sessions) != 0 {
296 t.Fatalf("older out-of-order entry appeared before newest entry: %+v", d.sessions)
297 }
298 d.ApplyLoad(sessionLoadEvent{
299 kind: sessionLoadEntry,
300 generation: 7,
301 index: 1,
302 summary: core.SessionSummary{Path: "hidden", MessageCount: 1, HideFromSessions: true},
303 })
304 d.ApplyLoad(sessionLoadEvent{
305 kind: sessionLoadEntry,
306 generation: 7,
307 index: 3,
308 summary: core.SessionSummary{Path: "empty"},
309 })
310 d.ApplyLoad(sessionLoadEvent{
311 kind: sessionLoadEntry,
312 generation: 7,
313 index: 0,
314 summary: core.SessionSummary{Path: "first", MessageCount: 1},
315 })
316 if !d.Loading() {
317 t.Fatal("dialog stopped loading before the terminal event")
318 }
319 if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
320 t.Fatalf("incremental sessions = %+v, want first then third", d.sessions)
321 }
322
323 d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7})
324
325 if d.Loading() {
326 t.Fatal("dialog still loading after final result")
327 }
328 if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
329 t.Fatalf("loaded sessions = %+v, want first then third", d.sessions)
330 }
331}
332
333func TestSessionDialogCanSelectLoadedEntryWhileLoading(t *testing.T) {
334 d := newSessionDialog()
335 d.active = true
336 d.loading = true
337 d.loadGeneration = 9
338
339 d.ApplyLoad(sessionLoadEvent{
340 kind: sessionLoadStarted,
341 generation: 9,
342 total: 2,
343 })
344 d.ApplyLoad(sessionLoadEvent{
345 kind: sessionLoadEntry,
346 generation: 9,
347 index: 0,
348 summary: core.SessionSummary{Path: "newest", MessageCount: 1, FirstUserText: "recent work"},
349 })
350
351 if !d.Loading() {
352 t.Fatal("dialog stopped loading after first entry")
353 }
354 rendered := strings.Join(d.Render(tui.Dark, 100), "\n")
355 if !strings.Contains(rendered, "recent work") {
356 t.Fatalf("incremental render missing newest entry: %q", rendered)
357 }
358
359 act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
360 if !act.Select || act.Path != "newest" || d.Active() {
361 t.Fatalf("select while loading = %+v, active %v; want newest selected and dialog closed", act, d.Active())
362 }
363}
364
365func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
366 first := newSessionDialog()
367 firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
368 firstGeneration := first.loadGeneration
369
370 secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
371 secondGeneration := first.loadGeneration
372 if secondGeneration == firstGeneration {
373 t.Fatal("reopening picker did not advance load generation")
374 }
375 first.ApplyLoad(sessionLoadEvent{
376 kind: sessionLoadStarted,
377 generation: secondGeneration,
378 total: 1,
379 })
380
381 first.ApplyLoad(sessionLoadEvent{
382 kind: sessionLoadEntry,
383 generation: firstGeneration,
384 index: 0,
385 summary: core.SessionSummary{
386 Path: "stale",
387 MessageCount: 1,
388 },
389 })
390 if len(first.sessions) != 0 {
391 t.Fatalf("stale results repopulated picker: %+v", first.sessions)
392 }
393
394 first.Close()
395 for range firstEvents {
396 }
397 for range secondEvents {
398 }
399}