package daemon import ( "sync" "testing" "github.com/stretchr/testify/require" renderer "github.com/bnema/vev-vt" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/protocol" "github.com/bnema/vev/internal/usecase/keys" themeui "github.com/bnema/vev/internal/usecase/theme" ) type captureKeyHandler struct { actions []keys.Action forward [][]byte } func (h *captureKeyHandler) Forward(data []byte) { h.forward = append(h.forward, append([]byte(nil), data...)) } func (h *captureKeyHandler) Action(action keys.Action, _ []byte) { h.actions = append(h.actions, action) } func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) h := &captureKeyHandler{} r := keys.NewRouter(stubClock{}, h, &d.bindings) r.Route([]byte("\x1bh")) require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions) d.ApplyConfig(domain.Config{ Theme: domain.ThemeAuto, BindingEntries: []domain.ConfigEntry{ {Key: "focus-pane-left", Value: "alt+q"}, }, Codes: map[string]string{ "new-tab": "nt", }, }) r.Route([]byte("\x1bh")) require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft}, h.actions, "old key should no longer be intercepted") require.Equal(t, [][]byte{{'\x1b', 'h'}}, h.forward) r.Route([]byte("\x1bq")) require.Equal(t, []keys.Action{keys.ActionFocusPaneLeft, keys.ActionFocusPaneLeft}, h.actions) cmd, ok := d.commandByEffectiveCode("NT") require.True(t, ok) require.Equal(t, "new-tab", cmd.Slug) require.Equal(t, "NT", cmd.Code) } func TestApplyThemeCopiesTerminalPaletteAndHotReloadGatesIt(t *testing.T) { p, release := newBlockingPTY(t) defer release() d, sess, ac, _ := newManualSessionWithPTYs(t, p) clientPalette := [16]renderer.RGB{} clientPalette[1] = renderer.RGB{R: 1, G: 2, B: 3} clientPalette[12] = renderer.RGB{R: 4, G: 5, B: 6} d.applyTheme(sess, ac, protocol.Theme{ HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9}, HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12}, Palette: clientPalette, PaletteKnown: 1<<1 | 1<<12, }) require.Equal(t, clientPalette, ac.getClientTheme().Palette) require.Equal(t, uint16(1<<1|1<<12), ac.getClientTheme().PaletteKnown) require.True(t, ac.getAppliedTheme().Raw.UsePalette) cfg := domain.Defaults() cfg.ThemePalette = false d.ApplyConfig(cfg) // ApplyConfig reuses the live client report and repaints without requiring // a reconnect; only palette inheritance changes. reloaded := ac.getAppliedTheme().Raw require.Equal(t, clientPalette, reloaded.Palette) require.Equal(t, uint16(1<<1|1<<12), reloaded.PaletteKnown) require.False(t, reloaded.UsePalette) } func TestEffectiveThemePaletteGate(t *testing.T) { clientPalette := [16]renderer.RGB{} clientPalette[3] = renderer.RGB{R: 1, G: 2, B: 3} clientTheme := themeui.Theme{ Palette: clientPalette, PaletteKnown: 1 << 3, } tests := []struct { name string config domain.Config want themeui.Theme usePalette bool }{ { name: "auto inherits terminal palette by default", config: domain.Defaults(), want: clientTheme, usePalette: true, }, { name: "auto disables terminal palette", config: func() domain.Config { cfg := domain.Defaults() cfg.ThemePalette = false return cfg }(), want: clientTheme, }, { name: "forced dark uses palette free builtin", config: domain.Config{ Theme: domain.ThemeDark, ThemePalette: true, }, want: themeui.BuiltinDark, }, { name: "forced dark ignores disabled palette inheritance", config: domain.Config{ Theme: domain.ThemeDark, ThemePalette: false, }, want: themeui.BuiltinDark, }, { name: "forced light uses palette free builtin", config: domain.Config{ Theme: domain.ThemeLight, ThemePalette: true, }, want: themeui.BuiltinLight, }, { name: "forced light ignores disabled palette inheritance", config: domain.Config{ Theme: domain.ThemeLight, ThemePalette: false, }, want: themeui.BuiltinLight, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) d.ApplyConfig(tt.config) got := d.effectiveTheme(clientTheme) want := tt.want want.UsePalette = tt.usePalette require.Equal(t, want, got) }) } } func TestThemeConfigSnapshotIsAtomic(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) // A nil atomic pointer is the zero/default snapshot: auto theme with // terminal palette inheritance enabled. require.Equal(t, themeConfigSnapshot{}, d.currentThemeConfig()) for _, tt := range []struct { name string mode domain.ThemeMode gate bool }{ {name: "auto palette enabled", mode: domain.ThemeAuto, gate: true}, {name: "auto palette disabled", mode: domain.ThemeAuto, gate: false}, {name: "dark palette enabled", mode: domain.ThemeDark, gate: true}, {name: "dark palette disabled", mode: domain.ThemeDark, gate: false}, {name: "light palette enabled", mode: domain.ThemeLight, gate: true}, {name: "light palette disabled", mode: domain.ThemeLight, gate: false}, } { t.Run(tt.name, func(t *testing.T) { d.storeThemeConfig(domain.Config{Theme: tt.mode, ThemePalette: tt.gate}) require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig()) }) } // Publish complementary configurations concurrently. Every load must be a // complete published snapshot, never mode from one update and gate from // another. Coordination establishes concurrent readers/writers without // timing sleeps. first := domain.Config{Theme: domain.ThemeAuto, ThemePalette: false} second := domain.Config{Theme: domain.ThemeDark, ThemePalette: true} allowed := map[themeConfigSnapshot]struct{}{ {mode: domain.ThemeAuto, paletteOff: true}: {}, {mode: domain.ThemeDark, paletteOff: false}: {}, } firstPublished := make(chan struct{}) firstObserved := make(chan struct{}) secondPublished := make(chan struct{}) secondObserved := make(chan struct{}) observed := make(chan themeConfigSnapshot, 2) invalid := make(chan themeConfigSnapshot, 1) var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() d.storeThemeConfig(first) close(firstPublished) <-firstObserved d.storeThemeConfig(second) close(secondPublished) <-secondObserved for range 10_000 { d.storeThemeConfig(second) d.storeThemeConfig(first) } }() go func() { defer wg.Done() <-firstPublished observed <- d.currentThemeConfig() close(firstObserved) <-secondPublished observed <- d.currentThemeConfig() close(secondObserved) for range 20_000 { snapshot := d.currentThemeConfig() if _, ok := allowed[snapshot]; !ok { select { case invalid <- snapshot: default: } return } } }() wg.Wait() require.Equal(t, themeConfigSnapshot{mode: domain.ThemeAuto, paletteOff: true}, <-observed) require.Equal(t, themeConfigSnapshot{mode: domain.ThemeDark, paletteOff: false}, <-observed) select { case snapshot := <-invalid: t.Fatalf("observed mixed theme configuration: %#v", snapshot) default: } } func TestApplyConfigPublishesCopyConfig(t *testing.T) { tests := []struct { name string apply func(*Daemon) want domain.CopyConfig }{ { name: "default", want: domain.CopyConfig{WordSeparators: domain.DefaultWordSeparators}, }, { name: "custom", apply: func(d *Daemon) { cfg := domain.Defaults() cfg.Copy = domain.CopyConfig{WordSeparators: "/:"} d.ApplyConfig(cfg) }, want: domain.CopyConfig{WordSeparators: "/:"}, }, { name: "reload empty", apply: func(d *Daemon) { configured := domain.Defaults() configured.Copy = domain.CopyConfig{WordSeparators: "/:"} d.ApplyConfig(configured) reloaded := domain.Defaults() reloaded.Copy = domain.CopyConfig{} d.ApplyConfig(reloaded) }, want: domain.CopyConfig{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) if tt.apply != nil { tt.apply(d) } require.Equal(t, tt.want, d.currentCopyConfig()) }) } } func TestApplyConfigPublishesImmutablePaletteSnapshot(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) require.Equal(t, domain.Defaults().Palette, d.currentPaletteConfig()) firstConfig := domain.Defaults() firstConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true} d.ApplyConfig(firstConfig) first := d.currentPaletteConfig() secondConfig := domain.Defaults() secondConfig.Palette = domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true} d.ApplyConfig(secondConfig) second := d.currentPaletteConfig() require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true}, first) require.Equal(t, domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, second) } func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) { p, release := newBlockingPTY(t) defer release() d, sess, ac, sends := newManualSessionWithPTYs(t, p) d.enterPalette(sess, ac) awaitFrame(t, sends, "Output") d.handlePaletteInput(ac, []byte("new")) awaitFrame(t, sends, "Output") d.handlePaletteInput(ac, []byte{0x0e}) awaitFrame(t, sends, "Output") ac.overlays.paletteMu.Lock() model := ac.overlays.palette query := model.Query() selected, ok := model.Selected() ac.overlays.paletteMu.Unlock() require.True(t, ok) cfg := domain.Defaults() cfg.Palette = domain.PaletteConfig{Anchor: domain.AnchorTopLeft, AnchorSet: true} d.ApplyConfig(cfg) awaitFrame(t, sends, "Output") ac.overlays.paletteMu.Lock() defer ac.overlays.paletteMu.Unlock() require.Same(t, model, ac.overlays.palette) require.Equal(t, query, ac.overlays.palette.Query()) selectedAfter, ok := ac.overlays.palette.Selected() require.True(t, ok) selectedCommand, ok := selected.Command() require.True(t, ok) selectedAfterCommand, ok := selectedAfter.Command() require.True(t, ok) require.Equal(t, selectedCommand.Slug, selectedAfterCommand.Slug) require.Equal(t, selectedCommand.Code, selectedAfterCommand.Code) } func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) require.Equal(t, domain.Defaults().Floating, d.currentFloatingConfig()) firstConfig := domain.Defaults() firstConfig.Floating = domain.FloatingConfig{Command: "btop", Width: 70, Height: 60} d.ApplyConfig(firstConfig) first := d.currentFloatingConfig() secondConfig := domain.Defaults() secondConfig.Floating = domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85} d.ApplyConfig(secondConfig) second := d.currentFloatingConfig() require.Equal(t, domain.FloatingConfig{Command: "btop", Width: 70, Height: 60}, first) require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second) } func TestApplyConfigPublishesImmutableNavSnapshot(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) require.Equal(t, domain.NavConfig{}, d.currentNavConfig()) firstConfig := domain.Defaults() firstConfig.Nav = domain.NavConfig{OverflowTabs: true} d.ApplyConfig(firstConfig) first := d.currentNavConfig() secondConfig := domain.Defaults() secondConfig.Nav = domain.NavConfig{OverflowSessions: true} d.ApplyConfig(secondConfig) second := d.currentNavConfig() require.Equal(t, domain.NavConfig{OverflowTabs: true}, first) require.Equal(t, domain.NavConfig{OverflowSessions: true}, second) } func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig()) firstConfig := domain.Defaults() firstConfig.Tabs = domain.TabsConfig{TerminalTitle: false} d.ApplyConfig(firstConfig) first := d.currentTabsConfig() secondConfig := domain.Defaults() secondConfig.Tabs = domain.TabsConfig{TerminalTitle: true} d.ApplyConfig(secondConfig) second := d.currentTabsConfig() require.Equal(t, domain.TabsConfig{TerminalTitle: false}, first) require.Equal(t, domain.TabsConfig{TerminalTitle: true}, second) } func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) { tests := []struct { name string cfg domain.Config check func(t *testing.T, allow map[string]struct{}) }{ { name: "default allowlist", check: func(t *testing.T, allow map[string]struct{}) { t.Helper() require.Contains(t, allow, "claude") }, }, { name: "explicit empty disables restore", cfg: domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}}, check: func(t *testing.T, allow map[string]struct{}) { t.Helper() require.Empty(t, allow) }, }, { name: "explicit values are trimmed", cfg: domain.Config{Snapshot: domain.SnapshotConfig{ RestoreProcessesSet: true, RestoreProcesses: []string{"less", "", " pi "}, }}, check: func(t *testing.T, allow map[string]struct{}) { t.Helper() require.Contains(t, allow, "less") require.Contains(t, allow, "pi") require.NotContains(t, allow, "") }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) if tt.cfg.Snapshot.RestoreProcessesSet || len(tt.cfg.Snapshot.RestoreProcesses) > 0 { d.ApplyConfig(tt.cfg) } tt.check(t, d.restoreProcessAllowlistSnapshot()) }) } } func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) d.ApplyConfig(domain.Config{ Theme: domain.ThemeAuto, Codes: map[string]string{ "new-tab": "NN", }, }) commands := d.paletteCommands() require.NotEmpty(t, commands) require.Equal(t, "new-tab", commands[0].Slug) require.Equal(t, "NN", commands[0].Code) cmd, ok := d.commandByEffectiveCode("nn") require.True(t, ok) require.Equal(t, "new-tab", cmd.Slug) require.Equal(t, "NN", cmd.Code) } func TestPaletteCommandsUseOverrideSnapshotForRecentAndListing(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) d.ApplyConfig(domain.Config{ Theme: domain.ThemeAuto, Codes: map[string]string{ "split-right": "SRX", }, }) d.recordPaletteUse("SRX") commands := d.paletteCommands() require.NotEmpty(t, commands) require.Equal(t, "split-right", commands[0].Slug) require.Equal(t, "SRX", commands[0].Code) seen := 0 for _, cmd := range commands { if cmd.Slug == "split-right" { seen++ require.Equal(t, "SRX", cmd.Code) } } require.Equal(t, 1, seen) } func TestCodeOverrideConflictRestoresDefaultWithoutDuplicates(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) d.ApplyConfig(domain.Config{ Theme: domain.ThemeAuto, Codes: map[string]string{ "new-session": "CNT", "new-tab": "DET", }, }) commands := d.paletteCommands() byCode := make(map[string]string, len(commands)) for _, cmd := range commands { if existing, ok := byCode[cmd.Code]; ok { t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug) } byCode[cmd.Code] = cmd.Slug } require.Equal(t, "new-tab", byCode["CNT"]) require.Equal(t, "new-session", byCode["CNS"]) require.Equal(t, "detach", byCode["DET"]) } func TestCodeOverrideVacatedDefaultIsReusableRegardlessOfSlugOrder(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) d.ApplyConfig(domain.Config{ Theme: domain.ThemeAuto, Codes: map[string]string{ "new-tab": "NT", "new-session": "CNT", }, }) commands := d.paletteCommands() byCode := make(map[string]string, len(commands)) for _, cmd := range commands { if existing, ok := byCode[cmd.Code]; ok { t.Fatalf("duplicate effective code %q for %q and %q", cmd.Code, existing, cmd.Slug) } byCode[cmd.Code] = cmd.Slug } require.Equal(t, "new-tab", byCode["NT"]) require.Equal(t, "new-session", byCode["CNT"]) } func TestBuildCodeOverridesOrdersIndependentConflictWarnings(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) _, warnings := d.buildCodeOverrides(map[string]string{ "new-session": "ZZ", "new-tab": "ZZ", "split-left": "YY", "split-right": "YY", }) require.Equal(t, []domain.Warning{ {Msg: `command code "YY" for "split-right" conflicts with "split-left"`}, {Msg: `command code "ZZ" for "new-tab" conflicts with "new-session"`}, }, warnings) } func TestApplyConfigInvalidDefaultsWithoutPanic(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) require.NotPanics(t, func() { d.ApplyConfig(domain.Config{ Theme: domain.ThemeAuto, BindingEntries: []domain.ConfigEntry{ {Key: "unknown-action", Value: "alt+x"}, {Key: "focus-pane-left", Value: "ctrl+x"}, }, Codes: map[string]string{ "new-tab": "toolong", "split-right": "det", "missing-command": "OK", }, }) }) cmd, ok := d.commandByEffectiveCode("CNT") require.True(t, ok) require.Equal(t, "new-tab", cmd.Slug) _, ok = d.commandByEffectiveCode("TOOLONG") require.False(t, ok) } func TestConfigReloadNoticeMessageIncludesLineOnlyWhenKnown(t *testing.T) { tests := []struct { name string warnings []domain.Warning want string }{ { name: "no source line", warnings: []domain.Warning{{Msg: "unknown action"}}, want: "config reloaded with 1 warning(s): unknown action", }, { name: "source line", warnings: []domain.Warning{{Line: 12, Msg: "unknown action"}}, want: "config reloaded with 1 warning(s): line 12: unknown action", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, configReloadNoticeMessage(tt.warnings)) }) } } func TestApplyConfigSurfacesOneNoticeAggregatingAllWarnings(t *testing.T) { d := newTestDaemon(t, nil, stubClock{}) d.ApplyConfig(domain.Config{ Theme: domain.ThemeAuto, BindingEntries: []domain.ConfigEntry{ {Key: "unknown-action", Value: "alt+x"}, }, Codes: map[string]string{ "missing-command": "OK", "new-tab": "toolong", }, }) history := d.notices.history() var reloadNotices []domain.Notification for _, n := range history { if n.Code == domain.NoticeConfigReload { reloadNotices = append(reloadNotices, n) } } require.Len(t, reloadNotices, 1, "expected exactly one config-reload notice per ApplyConfig call") n := reloadNotices[0] require.Equal(t, domain.NoticeWarn, n.Severity) require.Equal(t, domain.SessionID(""), n.SessionID, "config reload notice must be global") require.Contains(t, n.Message, "config reloaded with 3 warning(s):") require.NotContains(t, n.Message, "line 0:") require.Contains(t, n.Details, `unknown action "unknown-action"`) require.Contains(t, n.Details, `unknown command code slug "missing-command"`) require.Contains(t, n.Details, `invalid command code for "new-tab"`) }