1package config
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "reflect"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/bnema/vev/internal/domain"
14 portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 "github.com/stretchr/testify/require"
16)
17
18func TestDefaultsDisableBarCommands(t *testing.T) {
19 bar := domain.Defaults().Bar
20 require.Empty(t, bar.TopRight)
21 require.Empty(t, bar.BottomRight)
22}
23
24func TestParse(t *testing.T) {
25 t.Parallel()
26
27 tests := []struct {
28 name string
29 input string
30 want domain.Config
31 wantWarnings []domain.Warning
32 }{
33 {
34 name: "comments and inline comments",
35 input: "# full line\nnew-tab = alt+t # open tab\ndetach = alt+#\n",
36 want: domain.Config{
37 Theme: domain.ThemeAuto,
38 BindingEntries: []domain.ConfigEntry{
39 {Key: "new-tab", Value: "alt+t"},
40 {Key: "detach", Value: "alt+#"},
41 },
42 Codes: map[string]string{},
43 },
44 },
45 {
46 name: "crlf and code prefix",
47 input: "theme = light\r\ncode.detach = dt\r\n",
48 want: domain.Config{
49 Theme: domain.ThemeLight,
50 BindingEntries: []domain.ConfigEntry{},
51 Codes: map[string]string{"detach": "dt"},
52 },
53 },
54 {
55 name: "invalid theme warns and keeps default",
56 input: "theme = blue\n",
57 want: domain.Config{
58 Theme: domain.ThemeAuto,
59 BindingEntries: []domain.ConfigEntry{},
60 Codes: map[string]string{},
61 },
62 wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid theme \"blue\""}},
63 },
64 {
65 name: "unknown binding key stored",
66 input: "unknown.action = alt+x\n",
67 want: domain.Config{
68 Theme: domain.ThemeAuto,
69 BindingEntries: []domain.ConfigEntry{{Key: "unknown.action", Value: "alt+x"}},
70 Codes: map[string]string{},
71 },
72 },
73 {
74 name: "duplicates warn and last wins",
75 input: "new-tab = alt+t\nnew-tab = alt+n\ncode.detach = DT\ncode.detach = DX\n",
76 want: domain.Config{
77 Theme: domain.ThemeAuto,
78 BindingEntries: []domain.ConfigEntry{{Key: "new-tab", Value: "alt+n"}},
79 Codes: map[string]string{"detach": "DX"},
80 },
81 wantWarnings: []domain.Warning{
82 {Line: 2, Msg: "duplicate key \"new-tab\""},
83 {Line: 4, Msg: "duplicate key \"code.detach\""},
84 },
85 },
86 {
87 name: "invalid lines report line numbers",
88 input: "\nno equals\n = value\ntheme = dark\n",
89 want: domain.Config{
90 Theme: domain.ThemeDark,
91 BindingEntries: []domain.ConfigEntry{},
92 Codes: map[string]string{},
93 },
94 wantWarnings: []domain.Warning{
95 {Line: 2, Msg: "missing '='"},
96 {Line: 3, Msg: "missing key"},
97 },
98 },
99 {
100 name: "floating settings custom values",
101 input: "floating.command = \"btop --utf-force\"\nfloating.width = 75%\nfloating.height = 60%\n",
102 want: domain.Config{
103 Theme: domain.ThemeAuto,
104 Floating: domain.FloatingConfig{
105 Command: "btop --utf-force",
106 Width: 75,
107 Height: 60,
108 },
109 BindingEntries: []domain.ConfigEntry{},
110 Codes: map[string]string{},
111 },
112 },
113 {
114 name: "floating command can be empty or quoted",
115 input: "floating.command = echo ready\nfloating.command = \"\"\n",
116 want: domain.Config{
117 Theme: domain.ThemeAuto,
118 Floating: domain.FloatingConfig{
119 Width: 80,
120 Height: 80,
121 },
122 BindingEntries: []domain.ConfigEntry{},
123 Codes: map[string]string{},
124 },
125 wantWarnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"floating.command\""}},
126 },
127 {
128 name: "floating percentage endpoints",
129 input: "floating.width = 1%\nfloating.height = 100%\n",
130 want: domain.Config{
131 Theme: domain.ThemeAuto,
132 Floating: domain.FloatingConfig{
133 Width: 1,
134 Height: 100,
135 },
136 BindingEntries: []domain.ConfigEntry{},
137 Codes: map[string]string{},
138 },
139 },
140 {
141 name: "floating invalid percentages warn and preserve defaults",
142 input: "floating.width = 80\nfloating.height = 0%\nfloating.width = 101%\nfloating.height = eighty%\n",
143 want: domain.Config{
144 Theme: domain.ThemeAuto,
145 Floating: domain.FloatingConfig{
146 Width: 80,
147 Height: 80,
148 },
149 BindingEntries: []domain.ConfigEntry{},
150 Codes: map[string]string{},
151 },
152 wantWarnings: []domain.Warning{
153 {Line: 1, Msg: "invalid floating.width \"80\""},
154 {Line: 2, Msg: "invalid floating.height \"0%\""},
155 {Line: 3, Msg: "duplicate key \"floating.width\""},
156 {Line: 3, Msg: "invalid floating.width \"101%\""},
157 {Line: 4, Msg: "duplicate key \"floating.height\""},
158 {Line: 4, Msg: "invalid floating.height \"eighty%\""},
159 },
160 },
161 {
162 name: "floating duplicate keys warn and last valid value wins",
163 input: "floating.command = btop\nfloating.command = lazygit\nfloating.width = 70%\nfloating.width = 90%\nfloating.height = 60%\nfloating.height = 85%\n",
164 want: domain.Config{
165 Theme: domain.ThemeAuto,
166 Floating: domain.FloatingConfig{
167 Command: "lazygit",
168 Width: 90,
169 Height: 85,
170 },
171 BindingEntries: []domain.ConfigEntry{},
172 Codes: map[string]string{},
173 },
174 wantWarnings: []domain.Warning{
175 {Line: 2, Msg: "duplicate key \"floating.command\""},
176 {Line: 4, Msg: "duplicate key \"floating.width\""},
177 {Line: 6, Msg: "duplicate key \"floating.height\""},
178 },
179 },
180 {
181 name: "floating malformed quoted command warns and keeps default",
182 input: "floating.command = \"bad\\q\"\n",
183 want: domain.Config{
184 Theme: domain.ThemeAuto,
185 BindingEntries: []domain.ConfigEntry{},
186 Codes: map[string]string{},
187 },
188 wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid floating.command \"\\\"bad\\\\q\\\"\""}},
189 },
190 {
191 name: "bar settings custom values",
192 input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",
193 want: domain.Config{
194 Theme: domain.ThemeAuto,
195 Bar: domain.BarConfig{
196 TopRight: "custom-top",
197 BottomRight: "custom-bottom",
198 Interval: 2 * time.Second,
199 },
200 BindingEntries: []domain.ConfigEntry{},
201 Codes: map[string]string{},
202 },
203 },
204 {
205 name: "bar settings quoted commands unquote",
206 input: "bar.top-right = \"date +%H:%M\"\nbar.bottom-right = \"\"\n",
207 want: domain.Config{
208 Theme: domain.ThemeAuto,
209 Bar: domain.BarConfig{
210 TopRight: "date +%H:%M",
211 BottomRight: "",
212 Interval: 5 * time.Second,
213 },
214 BindingEntries: []domain.ConfigEntry{},
215 Codes: map[string]string{},
216 },
217 },
218 {
219 name: "bar settings quoted commands preserve hash",
220 input: "bar.top-right = \"echo foo #1\" # inline comment\n",
221 want: domain.Config{
222 Theme: domain.ThemeAuto,
223 Bar: domain.BarConfig{
224 TopRight: "echo foo #1",
225 BottomRight: "",
226 Interval: 5 * time.Second,
227 },
228 BindingEntries: []domain.ConfigEntry{},
229 Codes: map[string]string{},
230 },
231 },
232 {
233 name: "bar settings malformed quoted command warns and keeps default",
234 input: "bar.top-right = \"bad\\q\"\n",
235 want: domain.Config{
236 Theme: domain.ThemeAuto,
237 Bar: domain.BarConfig{
238 TopRight: "",
239 BottomRight: "",
240 Interval: 5 * time.Second,
241 },
242 BindingEntries: []domain.ConfigEntry{},
243 Codes: map[string]string{},
244 },
245 wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.top-right \"\\\"bad\\\\q\\\"\""}},
246 },
247 {
248 name: "bar settings empty commands disable anchors",
249 input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",
250 want: domain.Config{
251 Theme: domain.ThemeAuto,
252 Bar: domain.BarConfig{
253 TopRight: "",
254 BottomRight: "",
255 Interval: 5 * time.Second,
256 },
257 BindingEntries: []domain.ConfigEntry{},
258 Codes: map[string]string{},
259 },
260 },
261 {
262 name: "bar interval invalid warns and keeps default",
263 input: "bar.interval = nope\n",
264 want: domain.Config{
265 Theme: domain.ThemeAuto,
266 Bar: domain.BarConfig{
267 TopRight: "",
268 BottomRight: "",
269 Interval: 5 * time.Second,
270 },
271 BindingEntries: []domain.ConfigEntry{},
272 Codes: map[string]string{},
273 },
274 wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}},
275 },
276 {
277 name: "bar interval below minimum clamps and warns",
278 input: "bar.interval = 100ms\n",
279 want: domain.Config{
280 Theme: domain.ThemeAuto,
281 Bar: domain.BarConfig{
282 TopRight: "",
283 BottomRight: "",
284 Interval: time.Second,
285 },
286 BindingEntries: []domain.ConfigEntry{},
287 Codes: map[string]string{},
288 },
289 wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}},
290 },
291 {
292 name: "snapshot restore processes",
293 input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",
294 want: domain.Config{
295 Theme: domain.ThemeAuto,
296 BindingEntries: []domain.ConfigEntry{},
297 Codes: map[string]string{},
298 Snapshot: domain.SnapshotConfig{
299 RestoreProcesses: []string{"claude", "codex", "pi", "opencode", "btop"},
300 RestoreProcessesSet: true,
301 },
302 },
303 },
304 {
305 name: "snapshot restore processes empty disables",
306 input: "snapshot.restore_processes =\n",
307 want: domain.Config{
308 Theme: domain.ThemeAuto,
309 BindingEntries: []domain.ConfigEntry{},
310 Codes: map[string]string{},
311 Snapshot: domain.SnapshotConfig{
312 RestoreProcessesSet: true,
313 },
314 },
315 },
316 {
317 name: "snapshot restore processes warns on malformed entries",
318 input: "snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n",
319 want: domain.Config{
320 Theme: domain.ThemeAuto,
321 BindingEntries: []domain.ConfigEntry{},
322 Codes: map[string]string{},
323 Snapshot: domain.SnapshotConfig{
324 RestoreProcesses: []string{"claude", "custom-tool", "zsh"},
325 RestoreProcessesSet: true,
326 },
327 },
328 wantWarnings: []domain.Warning{
329 {Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""},
330 {Line: 1, Msg: "invalid snapshot restore process \":all:\""},
331 {Line: 1, Msg: "invalid snapshot restore process \"bad name\""},
332 },
333 },
334 }
335
336 for _, tt := range tests {
337 t.Run(tt.name, func(t *testing.T) {
338 t.Parallel()
339
340 if !tt.want.ThemePalette {
341 tt.want.ThemePalette = domain.Defaults().ThemePalette
342 }
343 if tt.want.Bar.Interval == 0 {
344 tt.want.Bar = domain.Defaults().Bar
345 }
346 if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {
347 tt.want.Floating = domain.Defaults().Floating
348 }
349 if !tt.want.Tabs.TerminalTitle {
350 tt.want.Tabs = domain.Defaults().Tabs
351 }
352 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
353 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
354 }
355 if tt.want.Copy.WordSeparators == "" {
356 tt.want.Copy = domain.Defaults().Copy
357 }
358 if !tt.want.Palette.AnchorSet {
359 tt.want.Palette = domain.Defaults().Palette
360 }
361 got, warnings, err := Parse(strings.NewReader(tt.input))
362 if err != nil {
363 t.Fatalf("Parse() error = %v", err)
364 }
365 if !reflect.DeepEqual(got, tt.want) {
366 t.Fatalf("Parse() config = %#v, want %#v", got, tt.want)
367 }
368 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
369 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
370 }
371 })
372 }
373}
374
375func TestParseThemePalette(t *testing.T) {
376 t.Parallel()
377
378 tests := []struct {
379 name string
380 input string
381 want bool
382 wantWarnings []domain.Warning
383 }{
384 {
385 name: "on enables terminal palette inheritance",
386 input: "theme.palette = on\n",
387 want: true,
388 },
389 {
390 name: "off disables terminal palette inheritance",
391 input: "theme.palette = off\n",
392 want: false,
393 },
394 {
395 name: "invalid value warns once and keeps default",
396 input: "theme.palette = maybe\n",
397 want: true,
398 wantWarnings: []domain.Warning{
399 {Line: 1, Msg: "invalid theme.palette \"maybe\""},
400 },
401 },
402 {
403 name: "absent key keeps default",
404 input: "theme = dark\n",
405 want: true,
406 },
407 }
408
409 for _, tt := range tests {
410 t.Run(tt.name, func(t *testing.T) {
411 t.Parallel()
412
413 got, warnings, err := Parse(strings.NewReader(tt.input))
414 require.NoError(t, err)
415 require.Equal(t, tt.want, got.ThemePalette)
416 require.Equal(t, tt.wantWarnings, warnings)
417 })
418 }
419}
420
421func TestDefaultsThemePalette(t *testing.T) {
422 t.Parallel()
423
424 require.True(t, domain.Defaults().ThemePalette)
425}
426
427func TestParseTabsTerminalTitle(t *testing.T) {
428 t.Parallel()
429
430 tests := []struct {
431 name string
432 input string
433 want bool
434 wantWarnings []domain.Warning
435 }{
436 {
437 name: "on enables terminal title",
438 input: "tabs.terminal-title = on\n",
439 want: true,
440 },
441 {
442 name: "off disables terminal title",
443 input: "tabs.terminal-title = off\n",
444 want: false,
445 },
446 {
447 name: "invalid value warns and keeps default",
448 input: "tabs.terminal-title = maybe\n",
449 want: true,
450 wantWarnings: []domain.Warning{
451 {Line: 1, Msg: "invalid tabs.terminal-title \"maybe\""},
452 },
453 },
454 {
455 name: "absent key keeps default",
456 input: "theme = dark\n",
457 want: true,
458 },
459 }
460
461 for _, tt := range tests {
462 t.Run(tt.name, func(t *testing.T) {
463 t.Parallel()
464
465 got, warnings, err := Parse(strings.NewReader(tt.input))
466 if err != nil {
467 t.Fatalf("Parse() error = %v", err)
468 }
469 if got.Tabs.TerminalTitle != tt.want {
470 t.Fatalf("Parse() Tabs.TerminalTitle = %v, want %v", got.Tabs.TerminalTitle, tt.want)
471 }
472 if !reflect.DeepEqual(warnings, tt.wantWarnings) {
473 t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
474 }
475 })
476 }
477}
478
479func TestParseNavigationOverflow(t *testing.T) {
480 t.Parallel()
481
482 tests := []struct {
483 name string
484 input string
485 want domain.NavConfig
486 wantWarnings []domain.Warning
487 }{
488 {
489 name: "absent defaults both settings off",
490 },
491 {
492 name: "tabs on enables only tab overflow",
493 input: "nav.overflow-tabs = on\n",
494 want: domain.NavConfig{OverflowTabs: true},
495 },
496 {
497 name: "sessions on enables only session overflow",
498 input: "nav.overflow-sessions = on\n",
499 want: domain.NavConfig{OverflowSessions: true},
500 },
501 {
502 name: "off disables both settings",
503 input: "nav.overflow-tabs = off\nnav.overflow-sessions = off\n",
504 },
505 {
506 name: "settings are independent",
507 input: "nav.overflow-tabs = on\nnav.overflow-sessions = off\n",
508 want: domain.NavConfig{OverflowTabs: true},
509 },
510 {
511 name: "invalid values warn and preserve defaults",
512 input: "nav.overflow-tabs = maybe\nnav.overflow-sessions = always\n",
513 wantWarnings: []domain.Warning{
514 {Line: 1, Msg: `invalid nav.overflow-tabs "maybe"`},
515 {Line: 2, Msg: `invalid nav.overflow-sessions "always"`},
516 },
517 },
518 {
519 name: "duplicate keys warn and last valid value wins",
520 input: "nav.overflow-tabs = off\nnav.overflow-tabs = on\nnav.overflow-sessions = on\nnav.overflow-sessions = off\n",
521 want: domain.NavConfig{OverflowTabs: true},
522 wantWarnings: []domain.Warning{
523 {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
524 {Line: 4, Msg: `duplicate key "nav.overflow-sessions"`},
525 },
526 },
527 {
528 name: "invalid duplicate still warns and keeps previous value",
529 input: "nav.overflow-tabs = on\nnav.overflow-tabs = maybe\n",
530 want: domain.NavConfig{OverflowTabs: true},
531 wantWarnings: []domain.Warning{
532 {Line: 2, Msg: `duplicate key "nav.overflow-tabs"`},
533 {Line: 2, Msg: `invalid nav.overflow-tabs "maybe"`},
534 },
535 },
536 }
537
538 for _, tt := range tests {
539 t.Run(tt.name, func(t *testing.T) {
540 t.Parallel()
541
542 cfg, warnings, err := Parse(strings.NewReader(tt.input))
543 require.NoError(t, err)
544 require.Equal(t, tt.want, cfg.Nav)
545 require.Equal(t, tt.wantWarnings, warnings)
546 })
547 }
548}
549
550func TestDefaultsDisableNavigationOverflow(t *testing.T) {
551 t.Parallel()
552
553 require.Equal(t, domain.NavConfig{}, domain.Defaults().Nav)
554}
555
556func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
557 t.Parallel()
558
559 first := domain.Defaults()
560 first.Snapshot.RestoreProcesses[0] = "mutated"
561 second := domain.Defaults()
562 require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
563}
564
565func TestParseCopyWordSeparators(t *testing.T) {
566 t.Parallel()
567
568 tests := []struct {
569 name string
570 input string
571 want string
572 wantWarnings []domain.Warning
573 }{
574 {
575 name: "default",
576 input: "",
577 want: domain.DefaultWordSeparators,
578 },
579 {
580 name: "quoted preserves leading separator space",
581 input: `copy.word-separators = " -_@/"`,
582 want: " -_@/",
583 },
584 {
585 name: "quoted empty is valid",
586 input: `copy.word-separators = ""`,
587 want: "",
588 },
589 {
590 name: "unquoted empty is valid",
591 input: "copy.word-separators =",
592 want: "",
593 },
594 {
595 name: "malformed quote keeps default",
596 input: `copy.word-separators = "unterminated`,
597 want: domain.DefaultWordSeparators,
598 wantWarnings: []domain.Warning{
599 {Line: 1, Msg: `invalid copy.word-separators "\"unterminated"`},
600 },
601 },
602 {
603 name: "duplicate last value wins",
604 input: "copy.word-separators = /\ncopy.word-separators = _",
605 want: "_",
606 wantWarnings: []domain.Warning{
607 {Line: 2, Msg: `duplicate key "copy.word-separators"`},
608 },
609 },
610 }
611
612 for _, tt := range tests {
613 t.Run(tt.name, func(t *testing.T) {
614 t.Parallel()
615
616 cfg, warnings, err := Parse(strings.NewReader(tt.input))
617 require.NoError(t, err)
618 require.Equal(t, tt.want, cfg.Copy.WordSeparators)
619 require.Equal(t, tt.wantWarnings, warnings)
620 })
621 }
622}
623
624func TestDefaultsCopyWordSeparators(t *testing.T) {
625 t.Parallel()
626
627 require.Equal(t, " -_@", domain.Defaults().Copy.WordSeparators)
628}
629
630func TestParsePreservesBindingFileOrder(t *testing.T) {
631 t.Parallel()
632
633 cfg, warnings, err := Parse(strings.NewReader("open-palette = alt+p\nfocus-pane-left = alt+p\n"))
634 if err != nil {
635 t.Fatalf("Parse() error = %v", err)
636 }
637 if len(warnings) != 0 {
638 t.Fatalf("Parse() warnings = %#v, want none", warnings)
639 }
640 want := []domain.ConfigEntry{
641 {Key: "open-palette", Value: "alt+p"},
642 {Key: "focus-pane-left", Value: "alt+p"},
643 }
644 if !reflect.DeepEqual(cfg.BindingEntries, want) {
645 t.Fatalf("Parse() BindingEntries = %#v, want %#v", cfg.BindingEntries, want)
646 }
647}
648
649func TestLoadMissingFileReturnsDefaults(t *testing.T) {
650 t.Parallel()
651
652 cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
653 if err != nil {
654 t.Fatalf("Load() error = %v", err)
655 }
656 if len(warnings) != 0 {
657 t.Fatalf("Load() warnings = %#v, want none", warnings)
658 }
659 if !reflect.DeepEqual(cfg, domain.Defaults()) {
660 t.Fatalf("Load() config = %#v, want defaults", cfg)
661 }
662}
663
664func TestWatchReloadsChangedFileWithFakeClock(t *testing.T) {
665 t.Parallel()
666
667 path := filepath.Join(t.TempDir(), "config")
668 if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
669 t.Fatal(err)
670 }
671
672 clk := newWatchClockMock(t)
673 ctx, cancel := context.WithCancel(context.Background())
674 defer cancel()
675
676 type change struct {
677 cfg domain.Config
678 warnings []domain.Warning
679 }
680 changes := make(chan change, 2)
681 done := make(chan error, 1)
682 go func() {
683 done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
684 changes <- change{cfg: cfg, warnings: warnings}
685 })
686 }()
687
688 clk.waitForTimer()
689 if err := os.WriteFile(path, []byte("theme = light\nbad line\n"), 0o600); err != nil {
690 t.Fatal(err)
691 }
692 clk.fire()
693
694 var got change
695 select {
696 case got = <-changes:
697 case <-time.After(2 * time.Second):
698 t.Fatal("timed out waiting for config reload")
699 }
700 if got.cfg.Theme != domain.ThemeLight {
701 t.Fatalf("Watch() theme = %v, want %v", got.cfg.Theme, domain.ThemeLight)
702 }
703 if !reflect.DeepEqual(got.warnings, []domain.Warning{{Line: 2, Msg: "missing '='"}}) {
704 t.Fatalf("Watch() warnings = %#v", got.warnings)
705 }
706
707 cancel()
708 if err := <-done; !errors.Is(err, context.Canceled) {
709 t.Fatalf("Watch() error = %v, want context.Canceled", err)
710 }
711}
712
713func TestWatchUsesDefaultsWhenReloadLoadFails(t *testing.T) {
714 t.Parallel()
715
716 path := filepath.Join(t.TempDir(), "config")
717 if err := os.WriteFile(path, []byte("theme = dark\n"), 0o600); err != nil {
718 t.Fatal(err)
719 }
720
721 clk := newWatchClockMock(t)
722 ctx, cancel := context.WithCancel(context.Background())
723 defer cancel()
724
725 type change struct {
726 cfg domain.Config
727 warnings []domain.Warning
728 }
729 changes := make(chan change, 2)
730 done := make(chan error, 1)
731 go func() {
732 done <- Watch(ctx, clk.clock, path, func(cfg domain.Config, warnings []domain.Warning) {
733 changes <- change{cfg: cfg, warnings: warnings}
734 })
735 }()
736
737 clk.waitForTimer()
738 tooLong := strings.Repeat("x", 70*1024)
739 if err := os.WriteFile(path, []byte(tooLong), 0o600); err != nil {
740 t.Fatal(err)
741 }
742 clk.fire()
743
744 got := <-changes
745 require.Equal(t, domain.Defaults(), got.cfg)
746 require.NotEmpty(t, got.warnings)
747
748 cancel()
749 if err := <-done; !errors.Is(err, context.Canceled) {
750 t.Fatalf("Watch() error = %v, want context.Canceled", err)
751 }
752}
753
754func TestWatchDoesNotRepeatPersistentStatError(t *testing.T) {
755 t.Parallel()
756
757 path := filepath.Join(t.TempDir(), strings.Repeat("x", 5000))
758 clk := newWatchClockMock(t)
759 ctx, cancel := context.WithCancel(context.Background())
760 defer cancel()
761
762 changes := make(chan []domain.Warning, 4)
763 done := make(chan error, 1)
764 go func() {
765 done <- Watch(ctx, clk.clock, path, func(_ domain.Config, warnings []domain.Warning) {
766 changes <- warnings
767 })
768 }()
769
770 first := <-changes
771 require.Len(t, first, 1)
772 clk.waitForTimer()
773 clk.fire()
774 clk.fire()
775
776 select {
777 case extra := <-changes:
778 t.Fatalf("Watch() repeated persistent stat error: %#v", extra)
779 case <-time.After(100 * time.Millisecond):
780 }
781
782 cancel()
783 if err := <-done; !errors.Is(err, context.Canceled) {
784 t.Fatalf("Watch() error = %v, want context.Canceled", err)
785 }
786}
787
788type watchClockMock struct {
789 t *testing.T
790 clock *portsmocks.MockClock
791 ticks chan time.Time
792 ready chan struct{}
793 resets chan struct{}
794}
795
796func newWatchClockMock(t *testing.T) *watchClockMock {
797 t.Helper()
798 timer := portsmocks.NewMockTimer(t)
799 clock := portsmocks.NewMockClock(t)
800 m := &watchClockMock{
801 t: t,
802 clock: clock,
803 ticks: make(chan time.Time),
804 ready: make(chan struct{}),
805 resets: make(chan struct{}),
806 }
807 timer.EXPECT().C().Return(m.ticks)
808 timer.EXPECT().Reset(pollInterval).Run(func(time.Duration) {
809 m.resets <- struct{}{}
810 }).Return(false)
811 timer.EXPECT().Stop().Return(true)
812 clock.EXPECT().NewTimer(pollInterval).Run(func(time.Duration) {
813 close(m.ready)
814 }).Return(timer).Once()
815 return m
816}
817
818func (m *watchClockMock) waitForTimer() {
819 m.t.Helper()
820 select {
821 case <-m.ready:
822 case <-time.After(time.Second):
823 m.t.Fatal("timed out waiting for watcher timer creation")
824 }
825}
826
827func (m *watchClockMock) fire() {
828 m.t.Helper()
829 select {
830 case m.ticks <- time.Now():
831 case <-time.After(time.Second):
832 m.t.Fatal("timed out firing watcher timer")
833 }
834 select {
835 case <-m.resets:
836 case <-time.After(time.Second):
837 m.t.Fatal("timed out waiting for watcher timer reset")
838 }
839}