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