jesseduffield.lazygit/pkg/config/user_config_validation_test.go
Stefan Haller e0927d4faf Validate the context names in the "context" field of custom commands
If a custom command's "context" field contains a context name that
doesn't exist, lazygit panics when building the keybindings. This could
happen either because of a typo, or because a context is removed or
renamed in a later version. Prevent the panic by validating those names
at config load time, and rejecting the config as invalid there, like we
do for other config errors.

The gui package owns the list, but can't be imported from here, so it is
mirrored and a test over there ensures the copies stay in sync.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:07:32 +02:00

435 lines
13 KiB
Go

package config
import (
"strings"
"testing"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
func TestUserConfigValidate_enums(t *testing.T) {
type testCase struct {
value string
valid bool
}
scenarios := []struct {
name string
setup func(config *UserConfig, value string)
testCases []testCase
}{
{
name: "Gui.StatusPanelView",
setup: func(config *UserConfig, value string) {
config.Gui.StatusPanelView = value
},
testCases: []testCase{
{value: "dashboard", valid: true},
{value: "allBranchesLog", valid: true},
{value: "", valid: false},
{value: "invalid_value", valid: false},
},
},
{
name: "Gui.ShowDivergenceFromBaseBranch",
setup: func(config *UserConfig, value string) {
config.Gui.ShowDivergenceFromBaseBranch = value
},
testCases: []testCase{
{value: "none", valid: true},
{value: "onlyArrow", valid: true},
{value: "arrowAndNumber", valid: true},
{value: "", valid: false},
{value: "invalid_value", valid: false},
},
},
{
name: "Git.AutoForwardBranches",
setup: func(config *UserConfig, value string) {
config.Git.AutoForwardBranches = value
},
testCases: []testCase{
{value: "none", valid: true},
{value: "onlyMainBranches", valid: true},
{value: "allBranches", valid: true},
{value: "", valid: false},
{value: "invalid_value", valid: false},
},
},
{
name: "Git.LocalBranchSortOrder",
setup: func(config *UserConfig, value string) {
config.Git.LocalBranchSortOrder = value
},
testCases: []testCase{
{value: "date", valid: true},
{value: "recency", valid: true},
{value: "alphabetical", valid: true},
{value: "", valid: false},
{value: "invalid_value", valid: false},
},
},
{
name: "Git.RemoteBranchSortOrder",
setup: func(config *UserConfig, value string) {
config.Git.RemoteBranchSortOrder = value
},
testCases: []testCase{
{value: "date", valid: true},
{value: "recency", valid: false},
{value: "alphabetical", valid: true},
{value: "", valid: false},
{value: "invalid_value", valid: false},
},
},
{
name: "Git.Log.Order",
setup: func(config *UserConfig, value string) {
config.Git.Log.Order = value
},
testCases: []testCase{
{value: "date-order", valid: true},
{value: "author-date-order", valid: true},
{value: "topo-order", valid: true},
{value: "default", valid: true},
{value: "", valid: false},
{value: "invalid_value", valid: false},
},
},
{
name: "Git.Log.ShowGraph",
setup: func(config *UserConfig, value string) {
config.Git.Log.ShowGraph = value
},
testCases: []testCase{
{value: "always", valid: true},
{value: "never", valid: true},
{value: "when-maximised", valid: true},
{value: "", valid: false},
{value: "invalid_value", valid: false},
},
},
{
name: "Keybindings",
setup: func(config *UserConfig, value string) {
config.Keybinding.Universal.Quit = Keybinding{value}
},
testCases: []testCase{
{value: "", valid: true},
{value: "<disabled>", valid: true},
{value: "q", valid: true},
{value: "<c-c>", valid: true},
{value: "invalid_value", valid: false},
},
},
{
name: "JumpToBlock keybinding",
setup: func(config *UserConfig, value string) {
labels := strings.Split(value, ",")
config.Keybinding.Universal.JumpToBlock = lo.Map(labels, func(label string, _ int) Keybinding {
return Keybinding{label}
})
},
testCases: []testCase{
// The number of entries no longer has to match the number of side
// panels, so only the validity of the individual keys matters.
{value: "1,2,3", valid: true},
{value: "1,2,3,4,5", valid: true},
{value: "1,2,3,4,5,6", valid: true},
{value: "1,2,3,4,invalid", valid: false},
},
},
{
name: "Custom command keybinding",
setup: func(config *UserConfig, value string) {
config.CustomCommands = []CustomCommand{
{
Key: Keybinding{value},
Command: "echo 'hello'",
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "<disabled>", valid: true},
{value: "q", valid: true},
{value: "<c-c>", valid: true},
{value: "invalid_value", valid: false},
},
},
{
name: "Custom command keybinding in sub menu",
setup: func(config *UserConfig, value string) {
config.CustomCommands = []CustomCommand{
{
Key: Keybinding{"X"},
Description: "My Custom Commands",
CommandMenu: []CustomCommand{
{Key: Keybinding{value}, Command: "echo 'hello'", Context: "global"},
},
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "<disabled>", valid: true},
{value: "q", valid: true},
{value: "<c-c>", valid: true},
{value: "invalid_value", valid: false},
},
},
{
name: "Custom command keybinding in prompt menu",
setup: func(config *UserConfig, value string) {
config.CustomCommands = []CustomCommand{
{
Key: Keybinding{"X"},
Description: "My Custom Commands",
Prompts: []CustomCommandPrompt{
{
Options: []CustomCommandMenuOption{
{Key: Keybinding{value}},
},
},
},
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "<disabled>", valid: true},
{value: "q", valid: true},
{value: "<c-c>", valid: true},
{value: "invalid_value", valid: false},
},
},
{
name: "Custom command output",
setup: func(config *UserConfig, value string) {
config.CustomCommands = []CustomCommand{
{
Output: value,
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "none", valid: true},
{value: "terminal", valid: true},
{value: "log", valid: true},
{value: "logWithPty", valid: true},
{value: "popup", valid: true},
{value: "invalid_value", valid: false},
},
},
{
name: "Custom command context",
setup: func(config *UserConfig, value string) {
config.CustomCommands = []CustomCommand{
{
Context: value,
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "global", valid: true},
{value: "commits", valid: true},
{value: "commits, subCommits", valid: true},
{value: "commits,subCommits", valid: true},
{value: "invalid_value", valid: false},
{value: "commits, invalid_value", valid: false},
},
},
{
name: "Custom command context in a sub menu",
setup: func(config *UserConfig, value string) {
config.CustomCommands = []CustomCommand{
{
Key: Keybinding{"X"},
CommandMenu: []CustomCommand{
{Key: Keybinding{"1"}, Command: "echo 'hello'", Context: value},
},
},
}
},
testCases: []testCase{
{value: "", valid: true},
{value: "commits", valid: true},
{value: "invalid_value", valid: false},
},
},
{
name: "Custom command sub menu",
setup: func(config *UserConfig, _ string) {
config.CustomCommands = []CustomCommand{
{
Key: Keybinding{"X"},
Description: "My Custom Commands",
CommandMenu: []CustomCommand{
{Key: Keybinding{"1"}, Command: "echo 'hello'", Context: "global"},
},
},
}
},
testCases: []testCase{
{value: "", valid: true},
},
},
{
name: "Custom command sub menu",
setup: func(config *UserConfig, _ string) {
config.CustomCommands = []CustomCommand{
{
Key: Keybinding{"X"},
Context: "global", // context is not allowed for submenus
CommandMenu: []CustomCommand{
{Key: Keybinding{"1"}, Command: "echo 'hello'", Context: "global"},
},
},
}
},
testCases: []testCase{
{value: "", valid: false},
},
},
{
name: "Custom command sub menu",
setup: func(config *UserConfig, _ string) {
config.CustomCommands = []CustomCommand{
{
Key: Keybinding{"X"},
LoadingText: "loading", // other properties are not allowed for submenus (using loadingText as an example)
CommandMenu: []CustomCommand{
{Key: Keybinding{"1"}, Command: "echo 'hello'", Context: "global"},
},
},
}
},
testCases: []testCase{
{value: "", valid: false},
},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
for _, testCase := range s.testCases {
config := GetDefaultConfig()
s.setup(config, testCase.value)
err := config.Validate()
if testCase.valid {
assert.NoError(t, err)
} else {
assert.Error(t, err)
}
}
})
}
}
func TestUserConfigValidate_spinnerFrames(t *testing.T) {
scenarios := []struct {
name string
frames []string
valid bool
}{
{name: "empty", frames: []string{}, valid: false},
{name: "single frame", frames: []string{"|"}, valid: true},
{name: "all same width", frames: []string{"|", "/", "-", "\\"}, valid: true},
{name: "all same width, multi-char", frames: []string{". ", ".. ", "..."}, valid: true},
{name: "all same width, wide runes", frames: []string{"⠋", "⠙", "⠹"}, valid: true},
{name: "differing widths", frames: []string{"|", "//"}, valid: false},
{name: "first differs from rest", frames: []string{"||", "/", "-"}, valid: false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
config := GetDefaultConfig()
config.Gui.Spinner.Frames = s.frames
err := config.Validate()
if s.valid {
assert.NoError(t, err)
} else {
assert.Error(t, err)
}
})
}
}
func TestUserConfigValidate_sidePanels(t *testing.T) {
scenarios := []struct {
name string
panels []SidePanel
valid bool
}{
{name: "default layout", panels: []SidePanel{{"status"}, {"files", "worktrees", "submodules"}, {"branches", "remotes", "tags"}, {"commits", "reflog"}, {"stash"}}, valid: true},
{name: "reordered", panels: []SidePanel{{"status"}, {"files"}, {"commits"}, {"branches"}, {"stash"}}, valid: true},
{name: "hidden stash panel", panels: []SidePanel{{"status"}, {"files"}, {"branches"}, {"commits"}}, valid: true},
{name: "promoted tab", panels: []SidePanel{{"files", "submodules"}, {"worktrees"}, {"branches"}, {"commits"}}, valid: true},
{name: "core panels only", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}}, valid: true},
{name: "empty", panels: []SidePanel{}, valid: false},
{name: "empty panel", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}, {}}, valid: false},
{name: "unknown name", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}, {"bogus"}}, valid: false},
{name: "duplicate within panel", panels: []SidePanel{{"files", "files"}, {"branches"}, {"commits"}}, valid: false},
{name: "duplicate across panels", panels: []SidePanel{{"files"}, {"branches", "files"}, {"commits"}}, valid: false},
{name: "missing files", panels: []SidePanel{{"branches"}, {"commits"}}, valid: false},
{name: "missing branches", panels: []SidePanel{{"files"}, {"commits"}}, valid: false},
{name: "missing commits", panels: []SidePanel{{"files"}, {"branches"}}, valid: false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
config := GetDefaultConfig()
config.Gui.SidePanels = s.panels
err := config.Validate()
if s.valid {
assert.NoError(t, err)
} else {
assert.Error(t, err)
}
})
}
}
func TestUserConfigValidate_diffRenderers(t *testing.T) {
scenarios := []struct {
name string
diffRenderer DiffRendererConfig
valid bool
}{
{name: "stdinFilter with type default", diffRenderer: DiffRendererConfig{Command: "delta"}, valid: true},
{name: "stdinFilter with explicit type", diffRenderer: DiffRendererConfig{Type: "stdinFilter", Command: "delta"}, valid: true},
{name: "stdinFilter with explicit type", diffRenderer: DiffRendererConfig{Type: "stdinFilter"}, valid: false},
{name: "stdinFilter with type default without command", diffRenderer: DiffRendererConfig{}, valid: false},
{name: "stdinFilter with args", diffRenderer: DiffRendererConfig{Type: "stdinFilter", Command: "delta", Args: []string{"-x"}}, valid: false},
{name: "external diff", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft"}, valid: true},
{name: "external diff without command", diffRenderer: DiffRendererConfig{Type: "extDiff"}, valid: true},
{name: "external diff with args", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft", Args: []string{"-x"}}, valid: false},
{name: "raw git", diffRenderer: DiffRendererConfig{Type: "rawGit"}, valid: true},
{name: "raw git with args", diffRenderer: DiffRendererConfig{Type: "rawGit", Args: []string{"-x"}}, valid: true},
{name: "raw git with command", diffRenderer: DiffRendererConfig{Type: "rawGit", Command: "delta"}, valid: false},
{name: "unknown type", diffRenderer: DiffRendererConfig{Type: "unknown"}, valid: false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
config := GetDefaultConfig()
config.Git.DiffRenderers = []DiffRendererConfig{s.diffRenderer}
err := config.Validate()
if s.valid {
assert.NoError(t, err)
} else {
assert.Error(t, err)
}
})
}
}