validation_test.go RawHistoryFolder

1package config
2
3import (
4	"math"
5	"os"
6	"path/filepath"
7	"testing"
8
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11)
12
13func TestValidateConfig_ModeLegend(t *testing.T) {
14	for _, tc := range []struct {
15		name, mode string
16		delay      int
17		wantError  string
18	}{
19		{name: "default", mode: "delay", delay: 500},
20		{name: "always", mode: "always"},
21		{name: "off", mode: "off"},
22		{name: "unknown mode", mode: "sometimes", wantError: "workspace.styling.mode_legend must be always, delay, or off"},
23		{name: "negative delay", mode: "delay", delay: -1, wantError: "workspace.styling.mode_legend_delay_ms must be non-negative"},
24	} {
25		t.Run(tc.name, func(t *testing.T) {
26			cfg := DefaultConfig()
27			cfg.Workspace.Styling.ModeLegend = tc.mode
28			cfg.Workspace.Styling.ModeLegendDelayMs = tc.delay
29			err := validateConfig(cfg)
30			if tc.wantError != "" {
31				require.EqualError(t, err, "config validation failed:\n  - "+tc.wantError)
32			} else {
33				require.NoError(t, err)
34			}
35		})
36	}
37}
38
39func TestValidateConfig_OmniboxStyle(t *testing.T) {
40	tests := []struct {
41		style   string
42		wantErr bool
43	}{
44		{style: "command"},
45		{style: "multiplexer"},
46		{style: "minimal"},
47		{style: "", wantErr: true},
48		{style: "classic", wantErr: true},
49	}
50
51	for _, tt := range tests {
52		t.Run(tt.style, func(t *testing.T) {
53			cfg := DefaultConfig()
54			cfg.Omnibox.Style = tt.style
55			err := validateConfig(cfg)
56			if tt.wantErr {
57				require.Error(t, err)
58				assert.Contains(t, err.Error(), "omnibox.style")
59				return
60			}
61			require.NoError(t, err)
62		})
63	}
64}
65
66func TestValidateConfig_EngineType(t *testing.T) {
67	t.Setenv("DUMBER_ENGINE", "")
68
69	tests := []struct {
70		name       string
71		engineType string
72		wantErr    bool
73	}{
74		{name: "cef", engineType: EngineTypeCEF, wantErr: false},
75		{name: "webkit", engineType: EngineTypeWebKit, wantErr: false},
76		{name: "empty is invalid", engineType: "", wantErr: true},
77		{name: "invalid", engineType: "netscape", wantErr: true},
78	}
79
80	for _, tt := range tests {
81		t.Run(tt.name, func(t *testing.T) {
82			cfg := DefaultConfig()
83			cfg.Engine.Type = tt.engineType
84
85			err := validateConfig(cfg)
86			if tt.wantErr {
87				require.Error(t, err)
88				assert.Contains(t, err.Error(), "engine.type")
89				return
90			}
91			require.NoError(t, err)
92		})
93	}
94}
95
96func TestValidateConfig_EngineTypeIgnoresEnvOverride(t *testing.T) {
97	t.Run("invalid configured type is still rejected when env override is valid", func(t *testing.T) {
98		t.Setenv("DUMBER_ENGINE", EngineTypeWebKit)
99		cfg := DefaultConfig()
100		cfg.Engine.Type = "netscape"
101
102		err := validateConfig(cfg)
103
104		require.Error(t, err)
105		assert.Contains(t, err.Error(), "engine.type")
106	})
107
108	t.Run("valid configured type is not rejected when env override is invalid", func(t *testing.T) {
109		t.Setenv("DUMBER_ENGINE", "netscape")
110		cfg := DefaultConfig()
111		cfg.Engine.Type = EngineTypeCEF
112
113		err := validateConfig(cfg)
114
115		require.NoError(t, err)
116	})
117}
118
119func TestValidateConfig_EngineCookiePolicy(t *testing.T) {
120	tests := []struct {
121		name         string
122		cookiePolicy CookiePolicy
123		wantErr      bool
124	}{
125		{name: "always", cookiePolicy: CookiePolicyAlways, wantErr: false},
126		{name: "no_third_party", cookiePolicy: CookiePolicyNoThirdParty, wantErr: false},
127		{name: "never", cookiePolicy: CookiePolicyNever, wantErr: false},
128		{name: "invalid", cookiePolicy: CookiePolicy("bad_policy"), wantErr: true},
129	}
130
131	for _, tt := range tests {
132		t.Run(tt.name, func(t *testing.T) {
133			cfg := DefaultConfig()
134			cfg.Engine.CookiePolicy = tt.cookiePolicy
135
136			err := validateConfig(cfg)
137			if tt.wantErr {
138				require.Error(t, err)
139				assert.Contains(t, err.Error(), "engine.cookie_policy")
140				return
141			}
142			require.NoError(t, err)
143		})
144	}
145}
146
147func TestValidateConfig_WebKitMediaSettings(t *testing.T) {
148	tests := []struct {
149		name      string
150		mutate    func(*Config)
151		wantErr   bool
152		wantField string
153	}{
154		{
155			name: "valid gl rendering modes",
156			mutate: func(cfg *Config) {
157				cfg.Engine.WebKit.GLRenderingMode = GLRenderingModeGLES2
158			},
159			wantErr: false,
160		},
161		{
162			name: "invalid gl rendering mode",
163			mutate: func(cfg *Config) {
164				cfg.Engine.WebKit.GLRenderingMode = GLRenderingMode("metal")
165			},
166			wantErr:   true,
167			wantField: "engine.webkit.gl_rendering_mode",
168		},
169		{
170			name: "gstreamer debug level lower bound",
171			mutate: func(cfg *Config) {
172				cfg.Engine.WebKit.GStreamerDebugLevel = 0
173			},
174			wantErr: false,
175		},
176		{
177			name: "gstreamer debug level upper bound",
178			mutate: func(cfg *Config) {
179				cfg.Engine.WebKit.GStreamerDebugLevel = 5
180			},
181			wantErr: false,
182		},
183		{
184			name: "negative gstreamer debug level",
185			mutate: func(cfg *Config) {
186				cfg.Engine.WebKit.GStreamerDebugLevel = -1
187			},
188			wantErr:   true,
189			wantField: "engine.webkit.gstreamer_debug_level",
190		},
191		{
192			name: "too high gstreamer debug level",
193			mutate: func(cfg *Config) {
194				cfg.Engine.WebKit.GStreamerDebugLevel = 6
195			},
196			wantErr:   true,
197			wantField: "engine.webkit.gstreamer_debug_level",
198		},
199	}
200
201	for _, tt := range tests {
202		t.Run(tt.name, func(t *testing.T) {
203			cfg := DefaultConfig()
204			tt.mutate(cfg)
205
206			err := validateConfig(cfg)
207			if tt.wantErr {
208				require.Error(t, err)
209				assert.Contains(t, err.Error(), tt.wantField)
210				return
211			}
212			require.NoError(t, err)
213		})
214	}
215}
216
217func TestValidateConfig_CEFConfig(t *testing.T) {
218	tests := []struct {
219		name     string
220		mutate   func(*Config)
221		wantErr  bool
222		wantText string
223	}{
224		{
225			name: "valid defaults",
226			mutate: func(_ *Config) {
227			},
228			wantErr: false,
229		},
230		{
231			name: "invalid log severity",
232			mutate: func(cfg *Config) {
233				cfg.Engine.CEF.LogSeverity = 7
234			},
235			wantErr:  true,
236			wantText: "engine.cef.log_severity",
237		},
238		{
239			name: "zero frame rate uses default",
240			mutate: func(cfg *Config) {
241				cfg.Engine.CEF.WindowlessFrameRate = 0
242			},
243			wantErr: false,
244		},
245		{
246			name: "negative frame rate",
247			mutate: func(cfg *Config) {
248				cfg.Engine.CEF.WindowlessFrameRate = -1
249			},
250			wantErr:  true,
251			wantText: "engine.cef.windowless_frame_rate",
252		},
253		{
254			name: "zero idle residency timeout disables",
255			mutate: func(cfg *Config) {
256				cfg.Engine.CEF.IdleRuntimeTimeoutMs = 0
257			},
258			wantErr: false,
259		},
260		{
261			name: "maximum idle residency timeout accepted",
262			mutate: func(cfg *Config) {
263				cfg.Engine.CEF.IdleRuntimeTimeoutMs = 300000
264			},
265			wantErr: false,
266		},
267		{
268			name: "negative idle residency timeout",
269			mutate: func(cfg *Config) {
270				cfg.Engine.CEF.IdleRuntimeTimeoutMs = -1
271			},
272			wantErr:  true,
273			wantText: "engine.cef.idle_runtime_timeout_ms",
274		},
275		{
276			name: "oversized idle residency timeout",
277			mutate: func(cfg *Config) {
278				cfg.Engine.CEF.IdleRuntimeTimeoutMs = 300001
279			},
280			wantErr:  true,
281			wantText: "engine.cef.idle_runtime_timeout_ms",
282		},
283		{
284			name: "zero wheel multiplier",
285			mutate: func(cfg *Config) {
286				cfg.Engine.CEF.Input.ScrollWheelMultiplier = 0
287			},
288			wantErr:  true,
289			wantText: "engine.cef.input.scroll_wheel_multiplier",
290		},
291		{
292			name: "negative precise multiplier",
293			mutate: func(cfg *Config) {
294				cfg.Engine.CEF.Input.ScrollPreciseMultiplier = -0.1
295			},
296			wantErr:  true,
297			wantText: "engine.cef.input.scroll_precise_multiplier",
298		},
299		{
300			name: "zero horizontal multiplier",
301			mutate: func(cfg *Config) {
302				cfg.Engine.CEF.Input.ScrollHorizontalMultiplier = 0
303			},
304			wantErr:  true,
305			wantText: "engine.cef.input.scroll_horizontal_multiplier",
306		},
307		{
308			name: "negative vertical multiplier",
309			mutate: func(cfg *Config) {
310				cfg.Engine.CEF.Input.ScrollVerticalMultiplier = -0.1
311			},
312			wantErr:  true,
313			wantText: "engine.cef.input.scroll_vertical_multiplier",
314		},
315		{
316			name: "nan horizontal multiplier",
317			mutate: func(cfg *Config) {
318				cfg.Engine.CEF.Input.ScrollHorizontalMultiplier = math.NaN()
319			},
320			wantErr:  true,
321			wantText: "engine.cef.input.scroll_horizontal_multiplier",
322		},
323		{
324			name: "infinite vertical multiplier",
325			mutate: func(cfg *Config) {
326				cfg.Engine.CEF.Input.ScrollVerticalMultiplier = math.Inf(1)
327			},
328			wantErr:  true,
329			wantText: "engine.cef.input.scroll_vertical_multiplier",
330		},
331		{
332			name: "zero max delta disables clamp",
333			mutate: func(cfg *Config) {
334				cfg.Engine.CEF.Input.ScrollMaxDelta = 0
335			},
336			wantErr: false,
337		},
338		{
339			name: "negative max delta",
340			mutate: func(cfg *Config) {
341				cfg.Engine.CEF.Input.ScrollMaxDelta = -1
342			},
343			wantErr:  true,
344			wantText: "engine.cef.input.scroll_max_delta",
345		},
346		{
347			name: "zero touchpad navigation min delta",
348			mutate: func(cfg *Config) {
349				cfg.Engine.CEF.Input.TouchpadNavigationMinDelta = 0
350			},
351			wantErr:  true,
352			wantText: "engine.cef.input.touchpad_navigation_min_delta",
353		},
354		{
355			name: "nan touchpad navigation max vertical ratio",
356			mutate: func(cfg *Config) {
357				cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio = math.NaN()
358			},
359			wantErr:  true,
360			wantText: "engine.cef.input.touchpad_navigation_max_vertical_ratio",
361		},
362	}
363
364	for _, tt := range tests {
365		t.Run(tt.name, func(t *testing.T) {
366			cfg := DefaultConfig()
367			tt.mutate(cfg)
368
369			err := validateConfig(cfg)
370			if tt.wantErr {
371				require.Error(t, err)
372				assert.Contains(t, err.Error(), tt.wantText)
373				return
374			}
375			require.NoError(t, err)
376		})
377	}
378}
379
380func TestValidateConfig_ExternalLinksAcceptsSupportedValues(t *testing.T) {
381	for _, behavior := range []ExternalLinkBehavior{
382		ExternalLinkBehaviorWindowed,
383		ExternalLinkBehaviorTabbed,
384		ExternalLinkBehaviorSplit,
385		ExternalLinkBehaviorStacked,
386	} {
387		for _, placement := range []ExternalLinkPlacement{
388			ExternalLinkPlacementRight,
389			ExternalLinkPlacementLeft,
390			ExternalLinkPlacementTop,
391			ExternalLinkPlacementBottom,
392		} {
393			cfg := DefaultConfig()
394			cfg.Workspace.ExternalLinks.Behavior = behavior
395			cfg.Workspace.ExternalLinks.Placement = placement
396			require.NoError(t, validateConfig(cfg))
397		}
398	}
399}
400
401func TestValidateConfig_ExternalLinksRejectsUnsupportedValues(t *testing.T) {
402	cfg := DefaultConfig()
403	cfg.Workspace.ExternalLinks.Behavior = "invalid"
404	cfg.Workspace.ExternalLinks.Placement = "invalid"
405
406	err := validateConfig(cfg)
407	require.Error(t, err)
408	assert.Contains(t, err.Error(), "workspace.external_links.behavior")
409	assert.Contains(t, err.Error(), "workspace.external_links.placement")
410}
411
412func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingAbsoluteLocalPath(t *testing.T) {
413	tmpDir := t.TempDir()
414	path := filepath.Join(tmpDir, "page.html")
415	require.NoError(t, os.WriteFile(path, []byte("ok"), 0644))
416
417	cfg := DefaultConfig()
418	cfg.Workspace.NewPaneURL = path
419
420	require.NoError(t, validateConfig(cfg))
421}
422
423func TestValidateConfig_WorkspaceNewPaneURLRejectsRelativeLocalPathLikeValues(t *testing.T) {
424	// This test mutates process CWD; do not add t.Parallel here.
425	tmpDir := t.TempDir()
426	oldWD, err := os.Getwd()
427	require.NoError(t, err)
428	childDir := filepath.Join(tmpDir, "child")
429	require.NoError(t, os.Mkdir(childDir, 0755))
430	require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "page.html"), []byte("ok"), 0644))
431	require.NoError(t, os.WriteFile(filepath.Join(childDir, "page.html"), []byte("ok"), 0644))
432	require.NoError(t, os.Chdir(childDir))
433	t.Cleanup(func() { _ = os.Chdir(oldWD) })
434
435	for _, value := range []string{"./page.html", "../page.html"} {
436		t.Run(value, func(t *testing.T) {
437			cfg := DefaultConfig()
438			cfg.Workspace.NewPaneURL = value
439
440			err := validateConfig(cfg)
441			require.Error(t, err)
442			assert.Contains(t, err.Error(), "workspace.new_pane_url relative local path cannot be resolved")
443		})
444	}
445}
446
447func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingLocalPathLikeValues(t *testing.T) {
448	for _, value := range []string{"/missing/dumber/page.html", "~/missing-dumber-page.html"} {
449		t.Run(value, func(t *testing.T) {
450			cfg := DefaultConfig()
451			cfg.Workspace.NewPaneURL = value
452
453			err := validateConfig(cfg)
454			require.Error(t, err)
455			assert.Contains(t, err.Error(), "workspace.new_pane_url local path must exist")
456		})
457	}
458}
459
460func TestValidateConfig_WorkspaceNewPaneURLRejectsExistingBareRelativeFile(t *testing.T) {
461	// This test mutates process CWD; do not add t.Parallel here.
462	tmpDir := t.TempDir()
463	oldWD, err := os.Getwd()
464	require.NoError(t, err)
465	require.NoError(t, os.Chdir(tmpDir))
466	t.Cleanup(func() { _ = os.Chdir(oldWD) })
467	require.NoError(t, os.WriteFile("README", []byte("ok"), 0644))
468	require.NoError(t, os.WriteFile("index.html", []byte("ok"), 0644))
469
470	for _, value := range []string{"README", "index.html"} {
471		t.Run(value, func(t *testing.T) {
472			cfg := DefaultConfig()
473			cfg.Workspace.NewPaneURL = value
474
475			validationErr := validateConfig(cfg)
476			require.Error(t, validationErr)
477			assert.Contains(
478				t,
479				validationErr.Error(),
480				"workspace.new_pane_url relative local path cannot be resolved",
481			)
482		})
483	}
484}
485
486func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingBareRelativeValue(t *testing.T) {
487	// This test mutates process CWD; do not add t.Parallel here.
488	tmpDir := t.TempDir()
489	oldWD, err := os.Getwd()
490	require.NoError(t, err)
491	require.NoError(t, os.Chdir(tmpDir))
492	t.Cleanup(func() { _ = os.Chdir(oldWD) })
493
494	cfg := DefaultConfig()
495	cfg.Workspace.NewPaneURL = "missing-local-file"
496
497	validationErr := validateConfig(cfg)
498	require.Error(t, validationErr)
499	assert.Contains(t, validationErr.Error(), "workspace.new_pane_url")
500	assert.Contains(t, validationErr.Error(), "relative local path cannot be resolved without config file context")
501}
502
503func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {
504	cfg := DefaultConfig()
505	cfg.Engine.Profile = ProfileDefault
506	cfg.Engine.WebKit.SkiaGPUPaintingThreads = 0
507
508	err := validateConfig(cfg)
509	require.NoError(t, err)
510}
511
512func TestValidateConfig_OmniboxMaxHistoryDays(t *testing.T) {
513	t.Run("default is valid", func(t *testing.T) {
514		cfg := DefaultConfig()
515		require.NoError(t, validateConfig(cfg))
516	})
517
518	t.Run("zero means all history and is valid", func(t *testing.T) {
519		cfg := DefaultConfig()
520		cfg.Omnibox.MaxHistoryDays = 0
521		require.NoError(t, validateConfig(cfg))
522	})
523
524	t.Run("negative is rejected", func(t *testing.T) {
525		cfg := DefaultConfig()
526		cfg.Omnibox.MaxHistoryDays = -1
527
528		err := validateConfig(cfg)
529
530		require.Error(t, err)
531		assert.Contains(t, err.Error(), "omnibox.max_history_days must be non-negative")
532	})
533}
534
535func TestValidateKeymap(t *testing.T) {
536	cfg := DefaultConfig()
537	assert.Empty(t, validateKeymap(cfg))
538
539	cfg.Workspace.Keymap = KeymapTmux
540	assert.Empty(t, validateKeymap(cfg))
541
542	cfg.Workspace.Keymap = "emacs"
543	assert.Contains(t, validateKeymap(cfg), "workspace.keymap must be 'zellij' or 'tmux' (got: emacs)")
544
545	cfg.Workspace.Keymap = KeymapZellij
546	cfg.Workspace.PrefixMode.TimeoutMilliseconds = -1
547	assert.Contains(t, validateKeymap(cfg), "workspace.prefix_mode.timeout_ms must be non-negative")
548}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close