v0.2.0 13c6a90

watch_test.go RawHistoryFolder

1package config
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8	"slices"
9	"testing"
10	"time"
11
12	"github.com/bnema/neferwl/internal/logging"
13	"github.com/bnema/neferwl/internal/ports"
14)
15
16func TestWatch(t *testing.T) {
17	for _, later := range []bool{false, true} {
18		t.Run(map[bool]string{false: "existing", true: "created later"}[later], func(t *testing.T) {
19			dir := filepath.Join(t.TempDir(), "config")
20			if !later {
21				if err := os.Mkdir(dir, 0700); err != nil {
22					t.Fatal(err)
23				}
24			}
25			path := filepath.Join(dir, "config")
26			ctx, cancel := context.WithCancel(context.Background())
27			out := make(chan ports.ConfigChanged, 8)
28			done := make(chan error, 1)
29			old := directoryPollInterval
30			directoryPollInterval = 20 * time.Millisecond
31			defer func() { directoryPollInterval = old }()
32			go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()
33			defer func() {
34				cancel()
35				if err := <-done; err != nil {
36					t.Error(err)
37				}
38			}()
39			if later {
40				time.Sleep(40 * time.Millisecond)
41				if err := os.Mkdir(dir, 0700); err != nil {
42					t.Fatal(err)
43				}
44				time.Sleep(80 * time.Millisecond)
45			} else {
46				time.Sleep(40 * time.Millisecond)
47			}
48			write := func(file, body string) {
49				t.Helper()
50				if err := os.WriteFile(file, []byte(body), 0600); err != nil {
51					t.Fatal(err)
52				}
53			}
54			write(path, "background = #000000\n")
55			// receive waits for want. A slow machine may pause longer than the
56			// debounce between WriteFile's truncation and its write: the
57			// watcher then reads an empty file first, which is skipped here.
58			receive := func(want string) {
59				t.Helper()
60				deadline := time.After(time.Second)
61				for {
62					select {
63					case c := <-out:
64						if c.Config.Background.Color == want {
65							return
66						}
67					case <-deadline:
68						t.Fatalf("no config change to %s", want)
69					}
70				}
71			}
72			receive("#000000")
73			tmp := filepath.Join(dir, "temp")
74			write(tmp, "background = #ff0000\n")
75			if err := os.Rename(tmp, path); err != nil {
76				t.Fatal(err)
77			}
78			receive("#ff0000")
79			for i := 0; i < 3; i++ {
80				write(path, "background = #00ff00\n")
81				time.Sleep(15 * time.Millisecond)
82			}
83			receive("#00ff00")
84			select {
85			case <-out:
86				t.Fatal("duplicate reload")
87			case <-time.After(250 * time.Millisecond):
88			}
89			// An invalid value keeps the default for that key; other keys still apply.
90			write(path, "background = bad\nborder.width = 5\n")
91			select {
92			case c := <-out:
93				if c.Config.Background.Color != "#111111" || c.Config.Border.Width != 5 {
94					t.Fatalf("partial reload: %+v", c.Config)
95				}
96			case <-time.After(time.Second):
97				t.Fatal("no partial reload")
98			}
99			// Rewriting the same content sends nothing.
100			write(path, "background = bad\nborder.width = 5\n")
101			select {
102			case <-out:
103				t.Fatal("unchanged config sent")
104			case <-time.After(300 * time.Millisecond):
105			}
106			if err := os.Remove(path); err != nil {
107				t.Fatal(err)
108			}
109			select {
110			case <-out:
111				t.Fatal("removed config sent")
112			case <-time.After(250 * time.Millisecond):
113			}
114		})
115	}
116}
117
118// A config symlinked elsewhere (dotfiles), directly or through a chain,
119// reloads when its target changes, follows a retargeted link, and survives
120// a target that is missing for a while.
121func TestWatchSymlink(t *testing.T) {
122	root := t.TempDir()
123	dirs := map[string]string{}
124	for _, d := range []string{"config", "dotfiles", "chain", "other"} {
125		dirs[d] = filepath.Join(root, d)
126		if err := os.Mkdir(dirs[d], 0700); err != nil {
127			t.Fatal(err)
128		}
129	}
130	write := func(file, body string) {
131		t.Helper()
132		if err := os.WriteFile(file, []byte(body), 0600); err != nil {
133			t.Fatal(err)
134		}
135	}
136	link := func(target, at string) {
137		t.Helper()
138		tmp := at + ".tmp"
139		if err := os.Symlink(target, tmp); err != nil {
140			t.Fatal(err)
141		}
142		if err := os.Rename(tmp, at); err != nil {
143			t.Fatal(err)
144		}
145	}
146	// config/config -> chain/config (relative) -> dotfiles/neferwl.conf
147	target := filepath.Join(dirs["dotfiles"], "neferwl.conf")
148	write(target, "background = #000000\n")
149	middle := filepath.Join(dirs["chain"], "config")
150	link("../dotfiles/neferwl.conf", middle)
151	path := filepath.Join(dirs["config"], "config")
152	link(middle, path)
153	ctx, cancel := context.WithCancel(context.Background())
154	out := make(chan ports.ConfigChanged, 8)
155	done := make(chan error, 1)
156	old := directoryPollInterval
157	directoryPollInterval = 20 * time.Millisecond
158	defer func() { directoryPollInterval = old }()
159	go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()
160	defer func() {
161		cancel()
162		if err := <-done; err != nil {
163			t.Error(err)
164		}
165	}()
166	// until repeats change until the config reloads to want: the watches
167	// may not be set up yet on a slow machine. Each try changes another key
168	// too, as the watcher may have read the first try at startup.
169	tries := 0
170	until := func(want string, change func(body string)) {
171		t.Helper()
172		try := func() {
173			tries++
174			change(fmt.Sprintf("background = %s\nborder.width = %d\n", want, tries%32))
175		}
176		try()
177		deadline := time.After(2 * time.Second)
178		retry := time.NewTicker(300 * time.Millisecond)
179		defer retry.Stop()
180		for {
181			select {
182			case c := <-out:
183				if c.Config.Background.Color == want {
184					return
185				}
186			case <-retry.C:
187				try()
188			case <-deadline:
189				t.Fatalf("no config change to %s", want)
190			}
191		}
192	}
193	atomic := func(file string) func(string) {
194		return func(body string) {
195			write(file+".new", body)
196			if err := os.Rename(file+".new", file); err != nil {
197				t.Fatal(err)
198			}
199		}
200	}
201	until("#ff0000", func(body string) { write(target, body) })
202	until("#00ff00", atomic(target))
203	// The middle link now points to another file, once: later writes to it
204	// reload only if the watch moved there.
205	moved := filepath.Join(dirs["other"], "config")
206	write(moved, "background = #0000ff\n")
207	link(moved, middle)
208	until("#0000ff", func(string) {})
209	until("#ffffff", func(body string) { write(moved, body) })
210	// The target disappears with its directory, then comes back once: later
211	// writes reload only if the missing directory was watched again.
212	if err := os.RemoveAll(dirs["other"]); err != nil {
213		t.Fatal(err)
214	}
215	time.Sleep(50 * time.Millisecond)
216	if err := os.Mkdir(dirs["other"], 0700); err != nil {
217		t.Fatal(err)
218	}
219	write(moved, "background = #000001\n")
220	time.Sleep(100 * time.Millisecond) // past the 20 ms retry
221	until("#abcdef", func(body string) { write(moved, body) })
222	// The old target no longer matters.
223	write(target, "background = #123456\n")
224	select {
225	case c := <-out:
226		t.Fatalf("old target reloaded: %+v", c.Config.Background)
227	case <-time.After(250 * time.Millisecond):
228	}
229}
230
231func TestWatchedFiles(t *testing.T) {
232	dir := t.TempDir()
233	path := filepath.Join(dir, "config")
234	if got := watchedFiles(path); !slices.Equal(got, []string{path}) {
235		t.Fatal(got)
236	}
237	// A dangling link still names its target; a loop stops.
238	if err := os.Symlink("sub/../missing", path); err != nil {
239		t.Fatal(err)
240	}
241	if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) {
242		t.Fatal(got)
243	}
244	if err := os.Symlink("config", filepath.Join(dir, "missing")); err != nil {
245		t.Fatal(err)
246	}
247	if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) {
248		t.Fatal(got)
249	}
250}

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 25

Tags 10

↑↓ moveenter openesc close