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