1package config
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "slices"
9 "testing"
10 "time"
11
12 "github.com/bnema/neferwl/internal/adapters/logging"
13 "github.com/bnema/neferwl/internal/ports"
14)
15
16func TestWatch(t *testing.T) {
17 for _, later := range []bool{false, true} {
18 t.Run(map[bool]string{false: "existing", true: "created later"}[later], func(t *testing.T) {
19 dir := filepath.Join(t.TempDir(), "config")
20 if !later {
21 if err := os.Mkdir(dir, 0700); err != nil {
22 t.Fatal(err)
23 }
24 }
25 path := filepath.Join(dir, "config")
26 ctx, cancel := context.WithCancel(context.Background())
27 out := make(chan ports.ConfigChanged, 8)
28 done := make(chan error, 1)
29 old := directoryPollInterval
30 directoryPollInterval = 20 * time.Millisecond
31 defer func() { directoryPollInterval = old }()
32 go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()
33 defer func() {
34 cancel()
35 if err := <-done; err != nil {
36 t.Error(err)
37 }
38 }()
39 if later {
40 time.Sleep(40 * time.Millisecond)
41 if err := os.Mkdir(dir, 0700); err != nil {
42 t.Fatal(err)
43 }
44 time.Sleep(80 * time.Millisecond)
45 } else {
46 time.Sleep(40 * time.Millisecond)
47 }
48 write := func(file, body string) {
49 t.Helper()
50 if err := os.WriteFile(file, []byte(body), 0600); err != nil {
51 t.Fatal(err)
52 }
53 }
54 write(path, "background = #000000\n")
55 // receive waits for want. A slow machine may pause longer than the
56 // debounce between WriteFile's truncation and its write: the
57 // watcher then reads an empty file first, which is skipped here.
58 receive := func(want string) {
59 t.Helper()
60 deadline := time.After(time.Second)
61 for {
62 select {
63 case c := <-out:
64 if c.Config.Background.Color == want {
65 return
66 }
67 case <-deadline:
68 t.Fatalf("no config change to %s", want)
69 }
70 }
71 }
72 receive("#000000")
73 tmp := filepath.Join(dir, "temp")
74 write(tmp, "background = #ff0000\n")
75 if err := os.Rename(tmp, path); err != nil {
76 t.Fatal(err)
77 }
78 receive("#ff0000")
79 for i := 0; i < 3; i++ {
80 write(path, "background = #00ff00\n")
81 time.Sleep(15 * time.Millisecond)
82 }
83 receive("#00ff00")
84 // A loaded machine may pause past the debounce inside the burst
85 // and reload the truncated file in between: that is a change, not a
86 // duplicate. Only the same config sent twice in a row is one.
87 prev := "#00ff00"
88 for quiet := false; !quiet; {
89 select {
90 case c := <-out:
91 if c.Config.Background.Color == prev {
92 t.Fatal("duplicate reload")
93 }
94 prev = c.Config.Background.Color
95 case <-time.After(250 * time.Millisecond):
96 quiet = true
97 }
98 }
99 // An invalid value keeps the default for that key; other keys still apply.
100 write(path, "background = bad\nborder.width = 5\n")
101 select {
102 case c := <-out:
103 if c.Config.Background.Color != "#111111" || c.Config.Border.Width != 5 {
104 t.Fatalf("partial reload: %+v", c.Config)
105 }
106 case <-time.After(time.Second):
107 t.Fatal("no partial reload")
108 }
109 // Rewriting the same content sends nothing.
110 write(path, "background = bad\nborder.width = 5\n")
111 select {
112 case <-out:
113 t.Fatal("unchanged config sent")
114 case <-time.After(300 * time.Millisecond):
115 }
116 if err := os.Remove(path); err != nil {
117 t.Fatal(err)
118 }
119 select {
120 case <-out:
121 t.Fatal("removed config sent")
122 case <-time.After(250 * time.Millisecond):
123 }
124 })
125 }
126}
127
128// A config symlinked elsewhere (dotfiles), directly or through a chain,
129// reloads when its target changes, follows a retargeted link, and survives
130// a target that is missing for a while.
131func TestWatchSymlink(t *testing.T) {
132 root := t.TempDir()
133 dirs := map[string]string{}
134 for _, d := range []string{"config", "dotfiles", "chain", "other"} {
135 dirs[d] = filepath.Join(root, d)
136 if err := os.Mkdir(dirs[d], 0700); err != nil {
137 t.Fatal(err)
138 }
139 }
140 write := func(file, body string) {
141 t.Helper()
142 if err := os.WriteFile(file, []byte(body), 0600); err != nil {
143 t.Fatal(err)
144 }
145 }
146 link := func(target, at string) {
147 t.Helper()
148 tmp := at + ".tmp"
149 if err := os.Symlink(target, tmp); err != nil {
150 t.Fatal(err)
151 }
152 if err := os.Rename(tmp, at); err != nil {
153 t.Fatal(err)
154 }
155 }
156 // config/config -> chain/config (relative) -> dotfiles/neferwl.conf
157 target := filepath.Join(dirs["dotfiles"], "neferwl.conf")
158 write(target, "background = #000000\n")
159 middle := filepath.Join(dirs["chain"], "config")
160 link("../dotfiles/neferwl.conf", middle)
161 path := filepath.Join(dirs["config"], "config")
162 link(middle, path)
163 ctx, cancel := context.WithCancel(context.Background())
164 out := make(chan ports.ConfigChanged, 8)
165 done := make(chan error, 1)
166 old := directoryPollInterval
167 directoryPollInterval = 20 * time.Millisecond
168 defer func() { directoryPollInterval = old }()
169 go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()
170 defer func() {
171 cancel()
172 if err := <-done; err != nil {
173 t.Error(err)
174 }
175 }()
176 // until repeats change until the config reloads to want: the watches
177 // may not be set up yet on a slow machine. Each try changes another key
178 // too, as the watcher may have read the first try at startup.
179 tries := 0
180 until := func(want string, change func(body string)) {
181 t.Helper()
182 try := func() {
183 tries++
184 change(fmt.Sprintf("background = %s\nborder.width = %d\n", want, tries%32))
185 }
186 try()
187 deadline := time.After(2 * time.Second)
188 retry := time.NewTicker(300 * time.Millisecond)
189 defer retry.Stop()
190 for {
191 select {
192 case c := <-out:
193 if c.Config.Background.Color == want {
194 return
195 }
196 case <-retry.C:
197 try()
198 case <-deadline:
199 t.Fatalf("no config change to %s", want)
200 }
201 }
202 }
203 atomic := func(file string) func(string) {
204 return func(body string) {
205 write(file+".new", body)
206 if err := os.Rename(file+".new", file); err != nil {
207 t.Fatal(err)
208 }
209 }
210 }
211 until("#ff0000", func(body string) { write(target, body) })
212 until("#00ff00", atomic(target))
213 // The middle link now points to another file, once: later writes to it
214 // reload only if the watch moved there.
215 moved := filepath.Join(dirs["other"], "config")
216 write(moved, "background = #0000ff\n")
217 link(moved, middle)
218 until("#0000ff", func(string) {})
219 until("#ffffff", func(body string) { write(moved, body) })
220 // The target disappears with its directory, then comes back once: later
221 // writes reload only if the missing directory was watched again.
222 if err := os.RemoveAll(dirs["other"]); err != nil {
223 t.Fatal(err)
224 }
225 time.Sleep(50 * time.Millisecond)
226 if err := os.Mkdir(dirs["other"], 0700); err != nil {
227 t.Fatal(err)
228 }
229 write(moved, "background = #000001\n")
230 time.Sleep(100 * time.Millisecond) // past the 20 ms retry
231 until("#abcdef", func(body string) { write(moved, body) })
232 // The old target no longer matters.
233 write(target, "background = #123456\n")
234 select {
235 case c := <-out:
236 t.Fatalf("old target reloaded: %+v", c.Config.Background)
237 case <-time.After(250 * time.Millisecond):
238 }
239}
240
241func TestWatchedFiles(t *testing.T) {
242 dir := t.TempDir()
243 path := filepath.Join(dir, "config")
244 if got := watchedFiles(path); !slices.Equal(got, []string{path}) {
245 t.Fatal(got)
246 }
247 // A dangling link still names its target; a loop stops.
248 if err := os.Symlink("sub/../missing", path); err != nil {
249 t.Fatal(err)
250 }
251 if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) {
252 t.Fatal(got)
253 }
254 if err := os.Symlink("config", filepath.Join(dir, "missing")); err != nil {
255 t.Fatal(err)
256 }
257 if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) {
258 t.Fatal(got)
259 }
260}