1package kvconf
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "runtime"
8 "testing"
9 "time"
10)
11
12// quiet is how long a test waits to be sure no change is reported.
13const quiet = 400 * time.Millisecond
14
15type watchEnv struct {
16 t *testing.T
17 dir string
18 path string
19 out chan struct{}
20}
21
22func (e *watchEnv) write(rel, content string) {
23 e.t.Helper()
24 p := filepath.Join(e.dir, rel)
25 must(e.t, os.MkdirAll(filepath.Dir(p), 0o700))
26 must(e.t, os.WriteFile(p, []byte(content), 0o600))
27}
28
29// replace saves like most editors: write a temporary file, rename it over.
30func (e *watchEnv) replace(rel, content string) {
31 e.t.Helper()
32 p := filepath.Join(e.dir, rel)
33 tmp := p + ".tmp~"
34 must(e.t, os.WriteFile(tmp, []byte(content), 0o600))
35 must(e.t, os.Rename(tmp, p))
36}
37
38func (e *watchEnv) symlink(target, rel string) {
39 e.t.Helper()
40 p := filepath.Join(e.dir, rel)
41 _ = os.Remove(p)
42 if err := os.Symlink(filepath.Join(e.dir, target), p); err != nil {
43 e.t.Skip("symlinks unavailable:", err)
44 }
45}
46
47func (e *watchEnv) expectChange(why string) {
48 e.t.Helper()
49 select {
50 case <-e.out:
51 case <-time.After(5 * time.Second):
52 e.t.Fatalf("no change reported after %s", why)
53 }
54}
55
56func (e *watchEnv) expectQuiet(why string) {
57 e.t.Helper()
58 select {
59 case <-e.out:
60 e.t.Fatalf("unexpected change after %s", why)
61 case <-time.After(quiet):
62 }
63}
64
65// settle lets a step's events drain so the next expectation is its own.
66func (e *watchEnv) settle() {
67 time.Sleep(quiet / 2)
68 select {
69 case <-e.out:
70 default:
71 }
72}
73
74type backendCase struct {
75 name string
76 poll time.Duration
77}
78
79func backends() []backendCase {
80 return []backendCase{{name: "native"}, {name: "poll", poll: 20 * time.Millisecond}}
81}
82
83func TestWatch(t *testing.T) {
84 tests := []struct {
85 name string
86 rel string // watched path, relative to the temp dir
87 dir bool
88 // setup runs before the watch starts.
89 setup func(e *watchEnv)
90 // steps run in order once the watch is ready.
91 steps func(e *watchEnv)
92 }{
93 {
94 name: "write in place",
95 rel: "config",
96 setup: func(e *watchEnv) { e.write("config", "a = 1\n") },
97 steps: func(e *watchEnv) {
98 e.write("config", "a = 2\n")
99 e.expectChange("an in-place write")
100 },
101 },
102 {
103 name: "atomic replace",
104 rel: "config",
105 setup: func(e *watchEnv) { e.write("config", "a = 1\n") },
106 steps: func(e *watchEnv) {
107 e.replace("config", "a = 2\n")
108 e.expectChange("a rename over the file")
109 e.replace("config", "a = 3\n")
110 e.expectChange("a second rename")
111 },
112 },
113 {
114 name: "create then remove",
115 rel: "config",
116 steps: func(e *watchEnv) {
117 e.write("config", "a = 1\n")
118 e.expectChange("creation")
119 must(e.t, os.Remove(e.path))
120 e.expectChange("removal")
121 },
122 },
123 {
124 name: "other files are ignored",
125 rel: "config",
126 setup: func(e *watchEnv) { e.write("config", "a = 1\n") },
127 steps: func(e *watchEnv) {
128 e.write("other", "x")
129 e.write(".config.swp", "x")
130 e.expectQuiet("writing other files")
131 },
132 },
133 {
134 name: "missing directory appears",
135 rel: "a/b/config",
136 steps: func(e *watchEnv) {
137 e.write("a/b/config", "a = 1\n")
138 e.expectChange("the directories and file appeared")
139 e.settle()
140 e.write("a/b/config", "a = 2\n")
141 e.expectChange("a write once the directory exists")
142 },
143 },
144 {
145 name: "directory removed and recreated",
146 rel: "d/config",
147 setup: func(e *watchEnv) { e.write("d/config", "a = 1\n") },
148 steps: func(e *watchEnv) {
149 must(e.t, os.RemoveAll(filepath.Join(e.dir, "d")))
150 e.expectChange("the directory removal")
151 e.settle()
152 e.write("d/config", "a = 2\n")
153 e.expectChange("the recreated file")
154 },
155 },
156 {
157 name: "symlink target changes",
158 rel: "config",
159 setup: func(e *watchEnv) {
160 e.write("dotfiles/config", "a = 1\n")
161 e.symlink("dotfiles/config", "config")
162 },
163 steps: func(e *watchEnv) {
164 e.replace("dotfiles/config", "a = 2\n")
165 e.expectChange("a save at the link target")
166 },
167 },
168 {
169 name: "symlink retargeted",
170 rel: "config",
171 setup: func(e *watchEnv) {
172 e.write("one/config", "a = 1\n")
173 e.write("two/config", "a = 2\n")
174 e.symlink("one/config", "config")
175 },
176 steps: func(e *watchEnv) {
177 e.symlink("two/config", "config")
178 e.expectChange("retargeting the link")
179 e.settle()
180 e.write("two/config", "a = 3\n")
181 e.expectChange("a write at the new target")
182 e.settle()
183 e.write("one/config", "a = 4\n")
184 e.expectQuiet("a write at the old target")
185 },
186 },
187 {
188 name: "directory renamed away and replaced",
189 rel: "app/config",
190 setup: func(e *watchEnv) { e.write("app/config", "a = 1\n"); e.write("new/config", "a = 2\n") },
191 steps: func(e *watchEnv) {
192 must(e.t, os.Rename(filepath.Join(e.dir, "app"), filepath.Join(e.dir, "app.old")))
193 must(e.t, os.Rename(filepath.Join(e.dir, "new"), filepath.Join(e.dir, "app")))
194 e.expectChange("the directory swap")
195 e.settle()
196 e.write("app/config", "a = 3\n")
197 e.expectChange("a write in the new directory")
198 e.settle()
199 e.write("app.old/config", "a = 4\n")
200 e.expectQuiet("a write in the old directory")
201 },
202 },
203 {
204 name: "symlinked parent directory retargeted",
205 rel: "app/config",
206 setup: func(e *watchEnv) {
207 e.write("one/config", "a = 1\n")
208 e.write("two/config", "a = 2\n")
209 e.symlink("one", "app")
210 },
211 steps: func(e *watchEnv) {
212 e.write("one/config", "a = 3\n")
213 e.expectChange("a write through the linked directory")
214 e.settle()
215 e.symlink("two", "app")
216 e.expectChange("retargeting the directory link")
217 e.settle()
218 e.write("two/config", "a = 4\n")
219 e.expectChange("a write in the new target")
220 e.settle()
221 e.write("one/config", "a = 5\n")
222 e.expectQuiet("a write in the old target")
223 },
224 },
225 {
226 name: "directory mode ignores subdirectories",
227 rel: "scripts",
228 dir: true,
229 setup: func(e *watchEnv) { e.write("scripts/a.sh", "1") },
230 steps: func(e *watchEnv) {
231 must(e.t, os.Mkdir(filepath.Join(e.dir, "scripts", "sub"), 0o700))
232 e.expectQuiet("creating a subdirectory")
233 },
234 },
235 {
236 name: "directory mode",
237 rel: "scripts",
238 dir: true,
239 setup: func(e *watchEnv) { e.write("scripts/a.sh", "1") },
240 steps: func(e *watchEnv) {
241 e.write("scripts/b.sh", "1")
242 e.expectChange("a new file")
243 e.settle()
244 e.write("scripts/a.sh", "2")
245 e.expectChange("a modified file")
246 e.settle()
247 e.write("scripts/.hidden", "1")
248 e.write("scripts/a.sh~", "1")
249 e.expectQuiet("hidden and backup files")
250 },
251 },
252 }
253 for _, bc := range backends() {
254 for _, tt := range tests {
255 t.Run(bc.name+"/"+tt.name, func(t *testing.T) {
256 t.Parallel()
257 dir := t.TempDir()
258 if runtime.GOOS == "darwin" {
259 dir, _ = filepath.EvalSymlinks(dir) // /var is a link to /private/var
260 }
261 e := &watchEnv{t: t, dir: dir, path: filepath.Join(dir, tt.rel), out: make(chan struct{}, 1)}
262 if tt.setup != nil {
263 tt.setup(e)
264 }
265 ctx, cancel := context.WithCancel(context.Background())
266 ready := make(chan struct{})
267 errc := make(chan error, 1)
268 go func() {
269 errc <- Watch(ctx, e.path, WatchOptions{Dir: tt.dir, Poll: bc.poll, Debounce: 20 * time.Millisecond, Ready: ready}, e.out)
270 }()
271 select {
272 case <-ready:
273 case err := <-errc:
274 t.Fatalf("Watch: %v", err)
275 }
276 tt.steps(e)
277 cancel()
278 select {
279 case err := <-errc:
280 must(t, err)
281 case <-time.After(5 * time.Second):
282 t.Fatal("Watch did not return after cancel")
283 }
284 })
285 }
286 }
287}
288
289func TestWatchDebounce(t *testing.T) {
290 for _, bc := range backends() {
291 t.Run(bc.name, func(t *testing.T) {
292 dir := t.TempDir()
293 path := filepath.Join(dir, "config")
294 must(t, os.WriteFile(path, nil, 0o600))
295 out := make(chan struct{}, 10)
296 ctx, cancel := context.WithCancel(context.Background())
297 defer cancel()
298 ready := make(chan struct{})
299 go func() {
300 _ = Watch(ctx, path, WatchOptions{Poll: bc.poll, Debounce: 300 * time.Millisecond, Ready: ready}, out)
301 }()
302 <-ready
303 for i := range 5 {
304 must(t, os.WriteFile(path, []byte{byte('a' + i)}, 0o600))
305 time.Sleep(50 * time.Millisecond)
306 }
307 select {
308 case <-out:
309 case <-time.After(5 * time.Second):
310 t.Fatal("no change reported")
311 }
312 select {
313 case <-out:
314 t.Fatal("a burst of writes was reported more than once")
315 case <-time.After(quiet):
316 }
317 })
318 }
319}
320
321// An atomic save is one change: nothing is reported again when the watcher
322// looks at the files later.
323func TestWatchReportsASaveOnce(t *testing.T) {
324 for _, bc := range backends() {
325 t.Run(bc.name, func(t *testing.T) {
326 dir := t.TempDir()
327 path := filepath.Join(dir, "config")
328 must(t, os.WriteFile(path, []byte("a = 1\n"), 0o600))
329 out := make(chan struct{}, 10)
330 ctx, cancel := context.WithCancel(context.Background())
331 defer cancel()
332 ready := make(chan struct{})
333 go func() {
334 _ = Watch(ctx, path, WatchOptions{Poll: bc.poll, Debounce: 20 * time.Millisecond, Ready: ready}, out)
335 }()
336 <-ready
337 must(t, WriteFile(path, []byte("a = 2\n"), 0o600))
338 select {
339 case <-out:
340 case <-time.After(5 * time.Second):
341 t.Fatal("no change reported")
342 }
343 select {
344 case <-out:
345 t.Fatal("one save was reported twice")
346 case <-time.After(retryMissing + 500*time.Millisecond):
347 }
348 })
349 }
350}
351
352func TestWatchCancelBeforeEvents(t *testing.T) {
353 for _, bc := range backends() {
354 t.Run(bc.name, func(t *testing.T) {
355 ctx, cancel := context.WithCancel(context.Background())
356 cancel()
357 done := make(chan error, 1)
358 go func() {
359 done <- Watch(ctx, filepath.Join(t.TempDir(), "config"), WatchOptions{Poll: bc.poll}, make(chan struct{}, 1))
360 }()
361 select {
362 case err := <-done:
363 must(t, err)
364 case <-time.After(5 * time.Second):
365 t.Fatal("Watch ignored a cancelled context")
366 }
367 })
368 }
369}