1package daemon
2
3import (
4 "sync"
5 "testing"
6
7 "github.com/stretchr/testify/require"
8
9 renderer "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"
12 "github.com/bnema/vev/internal/usecase/keys"
13 themeui "github.com/bnema/vev/internal/usecase/theme"
14)
15
16type captureKeyHandler struct {
17 actions []keys.Action
18 forward [][]byte
19}
20
21func (h *captureKeyHandler) Forward(data []byte) {
22 h.forward = append(h.forward, append([]byte(nil), data...))
23}
24func (h *captureKeyHandler) Action(action keys.Action, _ []byte) {
25 h.actions = append(h.actions, action)
26}
27
28func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
29 d := newTestDaemon(t, nil, stubClock{})
30 h := &captureKeyHandler{}
31 r := keys.NewRouter(stubClock{}, h, &d.bindings)
32
33 r.Route([]byte("\x1bh"))
34 require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions)
35
36 d.ApplyConfig(domain.Config{
37 Theme: domain.ThemeAuto,
38 BindingEntries: []domain.ConfigEntry{
39 {Key: "focus-pane-left", Value: "alt+q"},
40 },
41 Codes: map[string]string{
42 "new-tab": "nt",
43 },
44 })
45
46 r.Route([]byte("\x1bh"))
47 require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions, "old key should no longer be intercepted")
48 require.Equal(t, [][]byte{{'\x1b', 'h'}}, h.forward)
49 r.Route([]byte("\x1bq"))
50 require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions)
51
52 cmd, ok := d.commandByEffectiveCode("NT")
53 require.True(t, ok)
54 require.Equal(t, "new-tab", cmd.Slug)
55 require.Equal(t, "NT", cmd.Code)
56}
57
58func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) {
59 p, release := newBlockingPTY(t)
60 defer release()
61 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
62
63 clientPalette := [16]renderer.RGB{}
64 clientPalette[1] = renderer.RGB{R: 1, G: 2, B: 3}
65 clientPalette[12] = renderer.RGB{R: 4, G: 5, B: 6}
66 d.applyTheme(sess, ac, ports.Theme{
67 HasForeground: true,
68 Foreground: renderer.RGB{R: 7, G: 8, B: 9},
69 HasBackground: true,
70 Background: renderer.RGB{R: 10, G: 11, B: 12},
71 Palette: clientPalette,
72 PaletteKnown: 1<<1 | 1<<12,
73 })
74
75 require.Equal(t, clientPalette, ac.getClientTheme().Palette)
76 require.Equal(t, uint16(1<<1|1<<12), ac.getClientTheme().PaletteKnown)
77 require.True(t, ac.getAppliedTheme().Raw.UsePalette)
78
79 cfg := domain.Defaults()
80 cfg.ThemePalette = false
81 d.ApplyConfig(cfg)
82
83 // ApplyConfig reuses the live client report and repaints without requiring
84 // a reconnect; only palette inheritance changes.
85 reloaded := ac.getAppliedTheme().Raw
86 require.Equal(t, clientPalette, reloaded.Palette)
87 require.Equal(t, uint16(1<<1|1<<12), reloaded.PaletteKnown)
88 require.False(t, reloaded.UsePalette)
89}
90
91func TestEffectiveThemePaletteGate(t *testing.T) {
92 clientPalette := [16]renderer.RGB{}
93 clientPalette[3] = renderer.RGB{R: 1, G: 2, B: 3}
94 clientTheme := themeui.Theme{
95 Palette: clientPalette,
96 PaletteKnown: 1 << 3,
97 }
98
99 tests := []struct {
100 name string
101 config domain.Config
102 want themeui.Theme
103 usePalette bool
104 }{
105 {
106 name: "auto inherits terminal palette by default",
107 config: domain.Defaults(),
108 want: clientTheme,
109 usePalette: true,
110 },
111 {
112 name: "auto disables terminal palette",
113 config: func() domain.Config {
114 cfg := domain.Defaults()
115 cfg.ThemePalette = false
116 return cfg
117 }(),
118 want: clientTheme,
119 },
120 {
121 name: "forced dark uses palette free builtin",
122 config: domain.Config{
123 Theme: domain.ThemeDark,
124 ThemePalette: true,
125 },
126 want: themeui.BuiltinDark,
127 },
128 {
129 name: "forced dark ignores disabled palette inheritance",
130 config: domain.Config{
131 Theme: domain.ThemeDark,
132 ThemePalette: false,
133 },
134 want: themeui.BuiltinDark,
135 },
136 {
137 name: "forced light uses palette free builtin",
138 config: domain.Config{
139 Theme: domain.ThemeLight,
140 ThemePalette: true,
141 },
142 want: themeui.BuiltinLight,
143 },
144 {
145 name: "forced light ignores disabled palette inheritance",
146 config: domain.Config{
147 Theme: domain.ThemeLight,
148 ThemePalette: false,
149 },
150 want: themeui.BuiltinLight,
151 },
152 }
153
154 for _, tt := range tests {
155 t.Run(tt.name, func(t *testing.T) {
156 d := newTestDaemon(t, nil, stubClock{})
157 d.ApplyConfig(tt.config)
158
159 got := d.effectiveTheme(clientTheme)
160 want := tt.want
161 want.UsePalette = tt.usePalette
162 require.Equal(t, want, got)
163 })
164 }
165}
166
167func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
168 d := newTestDaemon(t, nil, stubClock{})
169
170 // A nil atomic pointer is the zero/default snapshot: auto theme with
171 // terminal palette inheritance enabled.
172 require.Equal(t, themeConfigSnapshot{}, d.currentThemeConfig())
173
174 for _, tt := range []struct {
175 name string
176 mode domain.ThemeMode
177 gate bool
178 }{
179 {name: "auto palette enabled", mode: domain.ThemeAuto, gate: true},
180 {name: "auto palette disabled", mode: domain.ThemeAuto, gate: false},
181 {name: "dark palette enabled", mode: domain.ThemeDark, gate: true},
182 {name: "dark palette disabled", mode: domain.ThemeDark, gate: false},
183 {name: "light palette enabled", mode: domain.ThemeLight, gate: true},
184 {name: "light palette disabled", mode: domain.ThemeLight, gate: false},
185 } {
186 t.Run(tt.name, func(t *testing.T) {
187 d.storeThemeConfig(domain.Config{Theme: tt.mode, ThemePalette: tt.gate})
188 require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig())
189 })
190 }
191
192 // Publish complementary configurations concurrently. Every load must be a
193 // complete published snapshot, never mode from one update and gate from
194 // another. Coordination establishes concurrent readers/writers without
195 // timing sleeps.
196 first := domain.Config{Theme: domain.ThemeAuto, ThemePalette: false}
197 second := domain.Config{Theme: domain.ThemeDark, ThemePalette: true}
198 allowed := map[themeConfigSnapshot]struct{}{
199 {mode: domain.ThemeAuto, paletteOff: true}: {},
200 {mode: domain.ThemeDark, paletteOff: false}: {},
201 }
202 firstPublished := make(chan struct{})
203 firstObserved := make(chan struct{})
204 secondPublished := make(chan struct{})
205 secondObserved := make(chan struct{})
206 observed := make(chan themeConfigSnapshot, 2)
207 invalid := make(chan themeConfigSnapshot, 1)
208 var wg sync.WaitGroup
209 wg.Add(2)
210 go func() {
211 defer wg.Done()
212 d.storeThemeConfig(first)
213 close(firstPublished)
214 <-firstObserved
215 d.storeThemeConfig(second)
216 close(secondPublished)
217 <-secondObserved
218 for range 10_000 {
219 d.storeThemeConfig(second)
220 d.storeThemeConfig(first)
221 }
222 }()
223 go func() {
224 defer wg.Done()
225 <-firstPublished
226 observed <- d.currentThemeConfig()
227 close(firstObserved)
228 <-secondPublished
229 observed <- d.currentThemeConfig()
230 close(secondObserved)
231 for range 20_000 {
232 snapshot := d.currentThemeConfig()
233 if _, ok := allowed[snapshot]; !ok {
234 select {
235 case invalid <- snapshot:
236 default:
237 }
238 return
239 }
240 }
241 }()
242 wg.Wait()
243 require.Equal(t, themeConfigSnapshot{mode: domain.ThemeAuto, paletteOff: true}, <-observed)
244 require.Equal(t, themeConfigSnapshot{mode: domain.ThemeDark, paletteOff: false}, <-observed)
245 select {
246 case snapshot := <-invalid:
247 t.Fatalf("observed mixed theme configuration: %#v", snapshot)
248 default:
249 }
250}
251
252func TestApplyConfigPublishesCopyConfig(t *testing.T) {
253 tests := []struct {
254 name string
255 apply func(*Daemon)
256 want domain.CopyConfig
257 }{
258 {
259 name: "default",
260 want: domain.CopyConfig{WordSeparators: domain.DefaultWordSeparators},
261 },
262 {
263 name: "custom",
264 apply: func(d *Daemon) {
265 cfg := domain.Defaults()
266 cfg.Copy = domain.CopyConfig{WordSeparators: "/:"}
267 d.ApplyConfig(cfg)
268 },
269 want: domain.CopyConfig{WordSeparators: "/:"},
270 },
271 {
272 name: "reload empty",
273 apply: func(d *Daemon) {
274 configured := domain.Defaults()
275 configured.Copy = domain.CopyConfig{WordSeparators: "/:"}
276 d.ApplyConfig(configured)
277
278 reloaded := domain.Defaults()
279 reloaded.Copy = domain.CopyConfig{}
280 d.ApplyConfig(reloaded)
281 },
282 want: domain.CopyConfig{},
283 },
284 }
285
286 for _, tt := range tests {
287 t.Run(tt.name, func(t *testing.T) {
288 d := newTestDaemon(t, nil, stubClock{})
289 if tt.apply != nil {
290 tt.apply(d)
291 }
292 require.Equal(t, tt.want, d.currentCopyConfig())
293 })
294 }
295}
296
297func TestApplyConfigPublishesImmutablePaletteSnapshot(t *testing.T) {
298 d := newTestDaemon(t, nil, stubClock{})
299 require.Equal(t, domain.Defaults().Palette, d.currentPaletteConfig())
300
301 firstConfig := domain.Defaults()
302 firstConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
303 d.ApplyConfig(firstConfig)
304 first := d.currentPaletteConfig()
305
306 secondConfig := domain.Defaults()
307 secondConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}
308 d.ApplyConfig(secondConfig)
309 second := d.currentPaletteConfig()
310
311 require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, first)
312 require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, second)
313}
314
315func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) {
316 p, release := newBlockingPTY(t)
317 defer release()
318 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
319 d.enterPalette(sess, ac)
320 awaitFrame(t, sends, ports.MsgOutput)
321 d.handlePaletteInput(ac, []byte("new"))
322 awaitFrame(t, sends, ports.MsgOutput)
323 d.handlePaletteInput(ac, []byte{0x0e})
324 awaitFrame(t, sends, ports.MsgOutput)
325
326 ac.overlays.paletteMu.Lock()
327 model := ac.overlays.palette
328 query := model.Query()
329 selected, ok := model.Selected()
330 ac.overlays.paletteMu.Unlock()
331 require.True(t, ok)
332
333 cfg := domain.Defaults()
334 cfg.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}
335 d.ApplyConfig(cfg)
336 awaitFrame(t, sends, ports.MsgOutput)
337
338 ac.overlays.paletteMu.Lock()
339 defer ac.overlays.paletteMu.Unlock()
340 require.Same(t, model, ac.overlays.palette)
341 require.Equal(t, query, ac.overlays.palette.Query())
342 selectedAfter, ok := ac.overlays.palette.Selected()
343 require.True(t, ok)
344 selectedCommand, ok := selected.Command()
345 require.True(t, ok)
346 selectedAfterCommand, ok := selectedAfter.Command()
347 require.True(t, ok)
348 require.Equal(t, selectedCommand.Slug, selectedAfterCommand.Slug)
349 require.Equal(t, selectedCommand.Code, selectedAfterCommand.Code)
350}
351
352func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
353 d := newTestDaemon(t, nil, stubClock{})
354 require.Equal(t, domain.Defaults().Floating, d.currentFloatingConfig())
355
356 firstConfig := domain.Defaults()
357 firstConfig.Floating = domain.FloatingConfig{Command: "btop", Width: 70, Height: 60}
358 d.ApplyConfig(firstConfig)
359 first := d.currentFloatingConfig()
360
361 secondConfig := domain.Defaults()
362 secondConfig.Floating = domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}
363 d.ApplyConfig(secondConfig)
364 second := d.currentFloatingConfig()
365
366 require.Equal(t, domain.FloatingConfig{Command: "btop", Width: 70, Height: 60}, first)
367 require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second)
368}
369
370func TestApplyConfigPublishesImmutableNavSnapshot(t *testing.T) {
371 d := newTestDaemon(t, nil, stubClock{})
372 require.Equal(t, domain.NavConfig{}, d.currentNavConfig())
373
374 firstConfig := domain.Defaults()
375 firstConfig.Nav = domain.NavConfig{OverflowTabs: true}
376 d.ApplyConfig(firstConfig)
377 first := d.currentNavConfig()
378
379 secondConfig := domain.Defaults()
380 secondConfig.Nav = domain.NavConfig{OverflowSessions: true}
381 d.ApplyConfig(secondConfig)
382 second := d.currentNavConfig()
383
384 require.Equal(t, domain.NavConfig{OverflowTabs: true}, first)
385 require.Equal(t, domain.NavConfig{OverflowSessions: true}, second)
386}
387
388func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) {
389 d := newTestDaemon(t, nil, stubClock{})
390 require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig())
391
392 firstConfig := domain.Defaults()
393 firstConfig.Tabs = domain.TabsConfig{TerminalTitle: false}
394 d.ApplyConfig(firstConfig)
395 first := d.currentTabsConfig()
396
397 secondConfig := domain.Defaults()
398 secondConfig.Tabs = domain.TabsConfig{TerminalTitle: true}
399 d.ApplyConfig(secondConfig)
400 second := d.currentTabsConfig()
401
402 require.Equal(t, domain.TabsConfig{TerminalTitle: false}, first)
403 require.Equal(t, domain.TabsConfig{TerminalTitle: true}, second)
404}
405
406func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
407 tests := []struct {
408 name string
409 cfg domain.Config
410 check func(t *testing.T, allow map[string]struct{})
411 }{
412 {
413 name: "default allowlist",
414 check: func(t *testing.T, allow map[string]struct{}) {
415 t.Helper()
416 require.Contains(t, allow, "claude")
417 },
418 },
419 {
420 name: "explicit empty disables restore",
421 cfg: domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}},
422 check: func(t *testing.T, allow map[string]struct{}) {
423 t.Helper()
424 require.Empty(t, allow)
425 },
426 },
427 {
428 name: "explicit values are trimmed",
429 cfg: domain.Config{Snapshot: domain.SnapshotConfig{
430 RestoreProcessesSet: true,
431 RestoreProcesses: []string{"less", "", " pi "},
432 }},
433 check: func(t *testing.T, allow map[string]struct{}) {
434 t.Helper()
435 require.Contains(t, allow, "less")
436 require.Contains(t, allow, "pi")
437 require.NotContains(t, allow, "")
438 },
439 },
440 }
441
442 for _, tt := range tests {
443 t.Run(tt.name, func(t *testing.T) {
444 d := newTestDaemon(t, nil, stubClock{})
445 if tt.cfg.Snapshot.RestoreProcessesSet || len(tt.cfg.Snapshot.RestoreProcesses) > 0 {
446 d.ApplyConfig(tt.cfg)
447 }
448 tt.check(t, d.restoreProcessAllowlistSnapshot())
449 })
450 }
451}
452
453func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {
454 d := newTestDaemon(t, nil, stubClock{})
455 d.ApplyConfig(domain.Config{
456 Theme: domain.ThemeAuto,
457 Codes: map[string]string{
458 "new-tab": "NN",
459 },
460 })
461
462 commands := d.paletteCommands()
463 require.NotEmpty(t, commands)
464 require.Equal(t, "new-tab", commands[0].Slug)
465 require.Equal(t, "NN", commands[0].Code)
466
467 cmd, ok := d.commandByEffectiveCode("nn")
468 require.True(t, ok)
469 require.Equal(t, "new-tab", cmd.Slug)
470 require.Equal(t, "NN", cmd.Code)
471}
472
473func TestPaletteCommandsUseOverrideSnapshotForRecentAndListing(t *testing.T) {
474 d := newTestDaemon(t, nil, stubClock{})
475 d.ApplyConfig(domain.Config{
476 Theme: domain.ThemeAuto,
477 Codes: map[string]string{
478 "split-right": "SRX",
479 },
480 })
481 d.recordPaletteUse("SRX")
482
483 commands := d.paletteCommands()
484 require.NotEmpty(t, commands)
485 require.Equal(t, "split-right", commands[0].Slug)
486 require.Equal(t, "SRX", commands[0].Code)
487
488 seen := 0
489 for _, cmd := range commands {
490 if cmd.Slug == "split-right" {
491 seen++
492 require.Equal(t, "SRX", cmd.Code)
493 }
494 }
495 require.Equal(t, 1, seen)
496}
497
498func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) {
499 d := newTestDaemon(t, nil, stubClock{})
500 d.ApplyConfig(domain.Config{
501 Theme: domain.ThemeAuto,
502 Codes: map[string]string{
503 "new-session": "CNT",
504 "new-tab": "DET",
505 },
506 })
507
508 commands := d.paletteCommands()
509 byCode := make(map[string]string, len(commands))
510 for _, cmd := range commands {
511 if existing, ok := byCode[cmd.Code]; ok {
512 t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
513 }
514 byCode[cmd.Code] = cmd.Slug
515 }
516 require.Equal(t, "new-tab", byCode["CNT"])
517 require.Equal(t, "new-session", byCode["CNS"])
518 require.Equal(t, "detach", byCode["DET"])
519}
520
521func TestCodeOverrideVacatedDefaultIsReusableRegardlessOfSlugOrder(t *testing.T) {
522 d := newTestDaemon(t, nil, stubClock{})
523 d.ApplyConfig(domain.Config{
524 Theme: domain.ThemeAuto,
525 Codes: map[string]string{
526 "new-tab": "NT",
527 "new-session": "CNT",
528 },
529 })
530
531 commands := d.paletteCommands()
532 byCode := make(map[string]string, len(commands))
533 for _, cmd := range commands {
534 if existing, ok := byCode[cmd.Code]; ok {
535 t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug)
536 }
537 byCode[cmd.Code] = cmd.Slug
538 }
539 require.Equal(t, "new-tab", byCode["NT"])
540 require.Equal(t, "new-session", byCode["CNT"])
541}
542
543func TestBuildCodeOverridesOrdersIndependentConflictWarnings(t *testing.T) {
544 d := newTestDaemon(t, nil, stubClock{})
545
546 _, warnings := d.buildCodeOverrides(map[string]string{
547 "new-session": "ZZ",
548 "new-tab": "ZZ",
549 "split-left": "YY",
550 "split-right": "YY",
551 })
552
553 require.Equal(t, []domain.Warning{
554 {Msg: `command code "YY" for "split-right" conflicts with "split-left"`},
555 {Msg: `command code "ZZ" for "new-tab" conflicts with "new-session"`},
556 }, warnings)
557}
558
559func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) {
560 d := newTestDaemon(t, nil, stubClock{})
561 require.NotPanics(t, func() {
562 d.ApplyConfig(domain.Config{
563 Theme: domain.ThemeAuto,
564 BindingEntries: []domain.ConfigEntry{
565 {Key: "unknown-action", Value: "alt+x"},
566 {Key: "focus-pane-left", Value: "ctrl+x"},
567 },
568 Codes: map[string]string{
569 "new-tab": "toolong",
570 "split-right": "det",
571 "missing-command": "OK",
572 },
573 })
574 })
575
576 cmd, ok := d.commandByEffectiveCode("CNT")
577 require.True(t, ok)
578 require.Equal(t, "new-tab", cmd.Slug)
579 _, ok = d.commandByEffectiveCode("TOOLONG")
580 require.False(t, ok)
581}
582
583func TestConfigReloadNoticeMessageIncludesLineOnlyWhenKnown(t *testing.T) {
584 tests := []struct {
585 name string
586 warnings []domain.Warning
587 want string
588 }{
589 {
590 name: "no source line",
591 warnings: []domain.Warning{{Msg: "unknown action"}},
592 want: "config reloaded with 1 warning(s): unknown action",
593 },
594 {
595 name: "source line",
596 warnings: []domain.Warning{{Line: 12, Msg: "unknown action"}},
597 want: "config reloaded with 1 warning(s): line 12: unknown action",
598 },
599 }
600 for _, tt := range tests {
601 t.Run(tt.name, func(t *testing.T) {
602 require.Equal(t, tt.want, configReloadNoticeMessage(tt.warnings))
603 })
604 }
605}
606
607func TestApplyConfigSurfacesOneNoticeAggregatingAllWarnings(t *testing.T) {
608 d := newTestDaemon(t, nil, stubClock{})
609 d.ApplyConfig(domain.Config{
610 Theme: domain.ThemeAuto,
611 BindingEntries: []domain.ConfigEntry{
612 {Key: "unknown-action", Value: "alt+x"},
613 },
614 Codes: map[string]string{
615 "missing-command": "OK",
616 "new-tab": "toolong",
617 },
618 })
619
620 history := d.notices.history()
621 var reloadNotices []domain.Notification
622 for _, n := range history {
623 if n.Code == domain.NoticeConfigReload {
624 reloadNotices = append(reloadNotices, n)
625 }
626 }
627 require.Len(t, reloadNotices, 1, "expected exactly one config-reload notice per ApplyConfig call")
628
629 n := reloadNotices[0]
630 require.Equal(t, domain.NoticeWarn, n.Severity)
631 require.Equal(t, domain.SessionID(""), n.SessionID, "config reload notice must be global")
632 require.Contains(t, n.Message, "config reloaded with 3 warning(s):")
633 require.NotContains(t, n.Message, "line 0:")
634 require.Contains(t, n.Details, `unknown action "unknown-action"`)
635 require.Contains(t, n.Details, `unknown command code slug "missing-command"`)
636 require.Contains(t, n.Details, `invalid command code for "new-tab"`)
637}