feat/data-dnd e05cb15

watch_test.go RawHistoryFolder

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

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