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