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/adapters/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			// A loaded machine may pause past the debounce inside the burst
85			// and reload the truncated file in between: that is a change, not a
86			// duplicate. Only the same config sent twice in a row is one.
87			prev := "#00ff00"
88			for quiet := false; !quiet; {
89				select {
90				case c := <-out:
91					if c.Config.Background.Color == prev {
92						t.Fatal("duplicate reload")
93					}
94					prev = c.Config.Background.Color
95				case <-time.After(250 * time.Millisecond):
96					quiet = true
97				}
98			}
99			// An invalid value keeps the default for that key; other keys still apply.
100			write(path, "background = bad\nborder.width = 5\n")
101			select {
102			case c := <-out:
103				if c.Config.Background.Color != "#111111" || c.Config.Border.Width != 5 {
104					t.Fatalf("partial reload: %+v", c.Config)
105				}
106			case <-time.After(time.Second):
107				t.Fatal("no partial reload")
108			}
109			// Rewriting the same content sends nothing.
110			write(path, "background = bad\nborder.width = 5\n")
111			select {
112			case <-out:
113				t.Fatal("unchanged config sent")
114			case <-time.After(300 * time.Millisecond):
115			}
116			if err := os.Remove(path); err != nil {
117				t.Fatal(err)
118			}
119			select {
120			case <-out:
121				t.Fatal("removed config sent")
122			case <-time.After(250 * time.Millisecond):
123			}
124		})
125	}
126}
127
128// A config symlinked elsewhere (dotfiles), directly or through a chain,
129// reloads when its target changes, follows a retargeted link, and survives
130// a target that is missing for a while.
131func TestWatchSymlink(t *testing.T) {
132	root := t.TempDir()
133	dirs := map[string]string{}
134	for _, d := range []string{"config", "dotfiles", "chain", "other"} {
135		dirs[d] = filepath.Join(root, d)
136		if err := os.Mkdir(dirs[d], 0700); err != nil {
137			t.Fatal(err)
138		}
139	}
140	write := func(file, body string) {
141		t.Helper()
142		if err := os.WriteFile(file, []byte(body), 0600); err != nil {
143			t.Fatal(err)
144		}
145	}
146	link := func(target, at string) {
147		t.Helper()
148		tmp := at + ".tmp"
149		if err := os.Symlink(target, tmp); err != nil {
150			t.Fatal(err)
151		}
152		if err := os.Rename(tmp, at); err != nil {
153			t.Fatal(err)
154		}
155	}
156	// config/config -> chain/config (relative) -> dotfiles/neferwl.conf
157	target := filepath.Join(dirs["dotfiles"], "neferwl.conf")
158	write(target, "background = #000000\n")
159	middle := filepath.Join(dirs["chain"], "config")
160	link("../dotfiles/neferwl.conf", middle)
161	path := filepath.Join(dirs["config"], "config")
162	link(middle, path)
163	ctx, cancel := context.WithCancel(context.Background())
164	out := make(chan ports.ConfigChanged, 8)
165	done := make(chan error, 1)
166	old := directoryPollInterval
167	directoryPollInterval = 20 * time.Millisecond
168	defer func() { directoryPollInterval = old }()
169	go func() { done <- Watch(ctx, path, out, logging.For(ctx, "config")) }()
170	defer func() {
171		cancel()
172		if err := <-done; err != nil {
173			t.Error(err)
174		}
175	}()
176	// until repeats change until the config reloads to want: the watches
177	// may not be set up yet on a slow machine. Each try changes another key
178	// too, as the watcher may have read the first try at startup.
179	tries := 0
180	until := func(want string, change func(body string)) {
181		t.Helper()
182		try := func() {
183			tries++
184			change(fmt.Sprintf("background = %s\nborder.width = %d\n", want, tries%32))
185		}
186		try()
187		deadline := time.After(2 * time.Second)
188		retry := time.NewTicker(300 * time.Millisecond)
189		defer retry.Stop()
190		for {
191			select {
192			case c := <-out:
193				if c.Config.Background.Color == want {
194					return
195				}
196			case <-retry.C:
197				try()
198			case <-deadline:
199				t.Fatalf("no config change to %s", want)
200			}
201		}
202	}
203	atomic := func(file string) func(string) {
204		return func(body string) {
205			write(file+".new", body)
206			if err := os.Rename(file+".new", file); err != nil {
207				t.Fatal(err)
208			}
209		}
210	}
211	until("#ff0000", func(body string) { write(target, body) })
212	until("#00ff00", atomic(target))
213	// The middle link now points to another file, once: later writes to it
214	// reload only if the watch moved there.
215	moved := filepath.Join(dirs["other"], "config")
216	write(moved, "background = #0000ff\n")
217	link(moved, middle)
218	until("#0000ff", func(string) {})
219	until("#ffffff", func(body string) { write(moved, body) })
220	// The target disappears with its directory, then comes back once: later
221	// writes reload only if the missing directory was watched again.
222	if err := os.RemoveAll(dirs["other"]); err != nil {
223		t.Fatal(err)
224	}
225	time.Sleep(50 * time.Millisecond)
226	if err := os.Mkdir(dirs["other"], 0700); err != nil {
227		t.Fatal(err)
228	}
229	write(moved, "background = #000001\n")
230	time.Sleep(100 * time.Millisecond) // past the 20 ms retry
231	until("#abcdef", func(body string) { write(moved, body) })
232	// The old target no longer matters.
233	write(target, "background = #123456\n")
234	select {
235	case c := <-out:
236		t.Fatalf("old target reloaded: %+v", c.Config.Background)
237	case <-time.After(250 * time.Millisecond):
238	}
239}
240
241func TestWatchedFiles(t *testing.T) {
242	dir := t.TempDir()
243	path := filepath.Join(dir, "config")
244	if got := watchedFiles(path); !slices.Equal(got, []string{path}) {
245		t.Fatal(got)
246	}
247	// A dangling link still names its target; a loop stops.
248	if err := os.Symlink("sub/../missing", path); err != nil {
249		t.Fatal(err)
250	}
251	if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) {
252		t.Fatal(got)
253	}
254	if err := os.Symlink("config", filepath.Join(dir, "missing")); err != nil {
255		t.Fatal(err)
256	}
257	if got := watchedFiles(path); !slices.Equal(got, []string{path, filepath.Join(dir, "missing")}) {
258		t.Fatal(got)
259	}
260}

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 26

Tags 10

↑↓ moveenter openesc close