detached 1e7884d

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
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}

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