v0.1.0 3b1b6ef

watch_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close