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}