refactor/kvconf 3421f16

watch_test.go RawHistoryFolder

1package config
2
3import (
4	"context"
5	"errors"
6	"github.com/bnema/kvconf"
7	"github.com/bnema/neferafk/internal/ports"
8	"os"
9	"path/filepath"
10	"testing"
11	"time"
12)
13
14func recv[T any](t *testing.T, ch <-chan T) T {
15	t.Helper()
16	select {
17	case v := <-ch:
18		return v
19	case <-time.After(2 * time.Second):
20		t.Fatal("watch timeout")
21		var z T
22		return z
23	}
24}
25func save(t *testing.T, path, text string) {
26	t.Helper()
27	tmp := path + ".new"
28	if err := os.WriteFile(tmp, []byte(text), 0600); err != nil {
29		t.Fatal(err)
30	}
31	if err := os.Rename(tmp, path); err != nil {
32		t.Fatal(err)
33	}
34}
35func TestWatchAtomicInvalidTransactionalAndSymlinkRetarget(t *testing.T) {
36	dir := t.TempDir()
37	targetDir := t.TempDir()
38	path := filepath.Join(dir, "config")
39	target := filepath.Join(targetDir, "target")
40	save(t, path, "lock.after=5m\n")
41	out := make(chan ports.ConfigChanged, 4)
42	errs := make(chan error, 4)
43	ready := make(chan struct{})
44	ctx, cancel := context.WithCancel(context.Background())
45	done := make(chan error, 1)
46	go func() { done <- Watch(ctx, path, out, errs, ready) }()
47	recv(t, ready)
48	if initial := recv(t, out); initial.Config.LockAfter != 5*time.Minute {
49		t.Fatal(initial)
50	}
51	save(t, path, "lock.after=2m\n")
52	if got := recv(t, out); got.Config.LockAfter != 2*time.Minute {
53		t.Fatal(got)
54	}
55	save(t, path, "lock.after=-1s\n")
56	recv(t, errs)
57	select {
58	case v := <-out:
59		t.Fatalf("invalid reload emitted %+v", v)
60	default:
61	}
62	save(t, target, "lock.after=3m\n")
63	if err := os.Symlink(target, path+".link"); err != nil {
64		t.Fatal(err)
65	}
66	if err := os.Rename(path+".link", path); err != nil {
67		t.Fatal(err)
68	}
69	if got := recv(t, out); got.Config.LockAfter != 3*time.Minute {
70		t.Fatal(got)
71	}
72	save(t, target, "lock.after=4m\n")
73	if got := recv(t, out); got.Config.LockAfter != 4*time.Minute {
74		t.Fatal(got)
75	}
76	cancel()
77	if err := recv(t, done); err != nil {
78		t.Fatal(err)
79	}
80}
81func TestWatchCancellationWhileDeliveryBlocked(t *testing.T) {
82	path := filepath.Join(t.TempDir(), "config")
83	save(t, path, "lock.after=5m\n")
84	out := make(chan ports.ConfigChanged)
85	ready := make(chan struct{})
86	ctx, cancel := context.WithCancel(context.Background())
87	done := make(chan error, 1)
88	go func() { done <- Watch(ctx, path, out, nil, ready) }()
89	recv(t, ready)
90	save(t, path, "lock.after=1m\n")
91	cancel()
92	if err := recv(t, done); err != nil {
93		t.Fatal(err)
94	}
95}
96
97func TestWatchMissingDirectoryStartupThenRemoveRecreate(t *testing.T) {
98	root := t.TempDir()
99	dir := filepath.Join(root, "missing", "nested")
100	path := filepath.Join(dir, "config")
101	out := make(chan ports.ConfigChanged, 4)
102	ready := make(chan struct{})
103	ctx, cancel := context.WithCancel(context.Background())
104	defer cancel()
105	done := make(chan error, 1)
106	go func() { done <- Watch(ctx, path, out, nil, ready) }()
107	recv(t, ready)
108	if err := os.MkdirAll(dir, 0700); err != nil {
109		t.Fatal(err)
110	}
111	save(t, path, "lock.after=1m\n")
112	if got := recv(t, out); got.Config.LockAfter != time.Minute {
113		t.Fatal(got)
114	}
115	// Rename the entire watched directory out of the path, then recreate it.
116	if err := os.Rename(dir, dir+"-old"); err != nil {
117		t.Fatal(err)
118	}
119	if err := os.MkdirAll(dir, 0700); err != nil {
120		t.Fatal(err)
121	}
122	save(t, path, "lock.after=2m\n")
123	if got := recv(t, out); got.Config.LockAfter != 2*time.Minute {
124		t.Fatal(got)
125	}
126	cancel()
127	if err := recv(t, done); err != nil {
128		t.Fatal(err)
129	}
130}
131
132func TestWatchRetargetToUnavailableThenReappearKeepsLastValid(t *testing.T) {
133	root := t.TempDir()
134	path := filepath.Join(root, "config")
135	save(t, path, "lock.after=1m\n")
136	out := make(chan ports.ConfigChanged, 4)
137	ready := make(chan struct{})
138	ctx, cancel := context.WithCancel(context.Background())
139	defer cancel()
140	done := make(chan error, 1)
141	go func() { done <- Watch(ctx, path, out, nil, ready) }()
142	recv(t, ready)
143	recv(t, out)
144	target := filepath.Join(t.TempDir(), "absent", "entry")
145	if err := os.Symlink(target, path+".link"); err != nil {
146		t.Fatal(err)
147	}
148	if err := os.Rename(path+".link", path); err != nil {
149		t.Fatal(err)
150	}
151	select {
152	case reset := <-out:
153		t.Fatalf("missing target reset last valid %+v", reset)
154	case err := <-done:
155		t.Fatalf("watch stopped on missing target %v", err)
156	case <-time.After(200 * time.Millisecond):
157	}
158	if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
159		t.Fatal(err)
160	}
161	save(t, target, "lock.after=3m\n")
162	if got := recv(t, out); got.Config.LockAfter != 3*time.Minute {
163		t.Fatal(got)
164	}
165	cancel()
166	if err := recv(t, done); err != nil {
167		t.Fatal(err)
168	}
169}
170
171func TestWatchFirstCandidateClosesLoadStartupGapAndMissingCancellation(t *testing.T) {
172	path := filepath.Join(t.TempDir(), "config")
173	save(t, path, "lock.after=1m\n")
174	old, err := Load(path)
175	if err != nil {
176		t.Fatal(err)
177	}
178	save(t, path, "lock.after=2m\n") // save after app's Load, before Watch
179	out := make(chan ports.ConfigChanged, 1)
180	ready := make(chan struct{})
181	ctx, cancel := context.WithCancel(context.Background())
182	done := make(chan error, 1)
183	go func() { done <- Watch(ctx, path, out, nil, ready) }()
184	recv(t, ready)
185	if got := recv(t, out); got.Config == old || got.Config.LockAfter != 2*time.Minute {
186		t.Fatal(got)
187	}
188	cancel()
189	if err := recv(t, done); err != nil {
190		t.Fatal(err)
191	}
192	ctx, cancel = context.WithCancel(context.Background())
193	done = make(chan error, 1)
194	ready = make(chan struct{})
195	go func() { done <- Watch(ctx, filepath.Join(t.TempDir(), "never", "config"), out, nil, ready) }()
196	recv(t, ready)
197	cancel()
198	if err := recv(t, done); err != nil {
199		t.Fatal(err)
200	}
201}
202
203// A watch the OS refuses must fall back to polling, never stop the daemon.
204func TestWatchFallsBackToPolling(t *testing.T) {
205	orig, origPoll := kvWatch, fallbackPoll
206	t.Cleanup(func() { kvWatch, fallbackPoll = orig, origPoll })
207	fallbackPoll = 20 * time.Millisecond
208	for _, tc := range []struct {
209		name  string
210		setup func(t *testing.T, dir string)
211	}{
212		{"native watch fails at startup", func(t *testing.T, _ string) {
213			kvWatch = func(ctx context.Context, path string, opts kvconf.WatchOptions, out chan<- struct{}) error {
214				if opts.Poll == 0 {
215					return errors.New("no space left on device")
216				}
217				return orig(ctx, path, opts, out)
218			}
219		}},
220		{"native watch fails after install", func(t *testing.T, _ string) {
221			kvWatch = func(ctx context.Context, path string, opts kvconf.WatchOptions, out chan<- struct{}) error {
222				if opts.Poll == 0 {
223					close(opts.Ready)
224					return errors.New("no space left on device")
225				}
226				return orig(ctx, path, opts, out)
227			}
228		}},
229		{"directory not readable", func(t *testing.T, dir string) {
230			if os.Geteuid() == 0 {
231				t.Skip("root ignores directory permissions")
232			}
233			kvWatch = orig
234			if err := os.Chmod(dir, 0300); err != nil {
235				t.Fatal(err)
236			}
237			t.Cleanup(func() { os.Chmod(dir, 0700) })
238		}},
239	} {
240		t.Run(tc.name, func(t *testing.T) {
241			dir := t.TempDir()
242			path := filepath.Join(dir, "config")
243			save(t, path, "lock.after=1m\n")
244			tc.setup(t, dir)
245			out := make(chan ports.ConfigChanged, 4)
246			errs := make(chan error, 4)
247			ready := make(chan struct{})
248			ctx, cancel := context.WithCancel(context.Background())
249			defer cancel()
250			done := make(chan error, 1)
251			go func() { done <- Watch(ctx, path, out, errs, ready) }()
252			recv(t, ready)
253			if err := recv(t, errs); err.Error() != "config watch failed; polling" {
254				t.Fatal(err)
255			}
256			if got := recv(t, out); got.Config.LockAfter != time.Minute {
257				t.Fatal(got)
258			}
259			save(t, path, "lock.after=2m\n")
260			if got := recv(t, out); got.Config.LockAfter != 2*time.Minute {
261				t.Fatal(got)
262			}
263			cancel()
264			if err := recv(t, done); err != nil {
265				t.Fatal(err)
266			}
267		})
268	}
269}

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 2

Tags 9

↑↓ moveenter openesc close