v0.30.0 eb3068f

watcher_test.go RawHistoryFolder

1package noctalia
2
3import (
4	"context"
5	"os"
6	"path/filepath"
7	"testing"
8	"time"
9
10	"github.com/bnema/dumber/internal/domain/entity"
11)
12
13func TestFileWatcherStartStopAndContextCancellation(t *testing.T) {
14	path := writeThemeFile(t, t.TempDir(), "theme.json")
15	watcher := newFastWatcher()
16
17	ctx, cancel := context.WithCancel(context.Background())
18	if err := watcher.Start(ctx, watcherConfig(path), func() {}); err != nil {
19		t.Fatalf("Start() error = %v", err)
20	}
21	if !watcher.isRunningForTest() {
22		t.Fatal("watcher is not running after Start")
23	}
24	if err := watcher.Stop(); err != nil {
25		t.Fatalf("Stop() error = %v", err)
26	}
27	if err := watcher.Stop(); err != nil {
28		t.Fatalf("second Stop() error = %v", err)
29	}
30
31	if err := watcher.Start(ctx, watcherConfig(path), func() {}); err != nil {
32		t.Fatalf("Start() after Stop error = %v", err)
33	}
34	cancel()
35	waitUntil(t, func() bool { return !watcher.isRunningForTest() })
36}
37
38func TestFileWatcherStartSamePathDoesNotDuplicateWatcher(t *testing.T) {
39	dir := t.TempDir()
40	path := writeThemeFile(t, dir, "theme.json")
41	watcher := newFastWatcher()
42	changes := make(chan struct{}, 4)
43
44	if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
45		t.Fatalf("Start() error = %v", err)
46	}
47	if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
48		t.Fatalf("second Start() same path error = %v", err)
49	}
50
51	writeThemeFile(t, dir, "theme.json")
52	waitForChange(t, changes)
53	assertNoChange(t, changes, 60*time.Millisecond)
54}
55
56func TestFileWatcherPathChangeRestartsAndDisabledStops(t *testing.T) {
57	dir := t.TempDir()
58	first := writeThemeFile(t, dir, "first.json")
59	second := writeThemeFile(t, dir, "second.json")
60	watcher := newFastWatcher()
61	changes := make(chan string, 8)
62
63	if err := watcher.Start(context.Background(), watcherConfig(first), func() { changes <- "first" }); err != nil {
64		t.Fatalf("Start(first) error = %v", err)
65	}
66	if err := watcher.Start(context.Background(), watcherConfig(second), func() { changes <- "second" }); err != nil {
67		t.Fatalf("Start(second) error = %v", err)
68	}
69
70	writeThemeFile(t, dir, "first.json")
71	assertNoStringChange(t, changes, 80*time.Millisecond)
72	writeThemeFile(t, dir, "second.json")
73	if got := waitForStringChange(t, changes); got != "second" {
74		t.Fatalf("callback = %q, want second", got)
75	}
76
77	cfg := watcherConfig(second)
78	cfg.Enabled = false
79	if err := watcher.Start(context.Background(), cfg, func() { changes <- "disabled" }); err != nil {
80		t.Fatalf("Start(disabled) error = %v", err)
81	}
82	if watcher.isRunningForTest() {
83		t.Fatal("watcher running after disabled config")
84	}
85	writeThemeFile(t, dir, "second.json")
86	assertNoStringChange(t, changes, 80*time.Millisecond)
87}
88
89func TestFileWatcherDebouncesBurstToOneCallback(t *testing.T) {
90	dir := t.TempDir()
91	path := writeThemeFile(t, dir, "theme.json")
92	watcher := NewFileWatcher()
93	watcher.delay = 100 * time.Millisecond
94	changes := make(chan struct{}, 8)
95
96	if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
97		t.Fatalf("Start() error = %v", err)
98	}
99	for range 5 {
100		writeThemeFile(t, dir, "theme.json")
101	}
102
103	waitForChange(t, changes)
104	assertNoChange(t, changes, 150*time.Millisecond)
105}
106
107func TestFileWatcherAtomicRenameWriteEmitsCallback(t *testing.T) {
108	dir := t.TempDir()
109	path := writeThemeFile(t, dir, "theme.json")
110	watcher := newFastWatcher()
111	changes := make(chan struct{}, 4)
112
113	if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
114		t.Fatalf("Start() error = %v", err)
115	}
116	tmp := filepath.Join(dir, "theme.json.tmp")
117	if err := os.WriteFile(tmp, []byte(`{"light":{},"dark":{}}`), 0o600); err != nil {
118		t.Fatalf("WriteFile(tmp) error = %v", err)
119	}
120	if err := os.Rename(tmp, path); err != nil {
121		t.Fatalf("Rename() error = %v", err)
122	}
123
124	waitForChange(t, changes)
125}
126
127func TestFileWatcherInvalidOrEmptyPathBehavior(t *testing.T) {
128	watcher := newFastWatcher()
129	if err := watcher.Start(context.Background(), watcherConfig(""), nil); err != nil {
130		t.Fatalf("Start(empty path) error = %v", err)
131	}
132	if watcher.isRunningForTest() {
133		t.Fatal("watcher running for empty path")
134	}
135	missingParent := filepath.Join(t.TempDir(), "missing", "theme.json")
136	if err := watcher.Start(context.Background(), watcherConfig(missingParent), func() {}); err == nil {
137		t.Fatal("Start(missing parent) error = nil, want error")
138	}
139}
140
141func TestWatchPathFromConfigSupportsNoctaliaFormats(t *testing.T) {
142	path := filepath.Join(t.TempDir(), "theme.json")
143	for _, format := range []string{"colors-json", "dumber-json"} {
144		t.Run(format, func(t *testing.T) {
145			got, enabled, err := watchPathFromConfig(entity.ExternalThemeConfig{
146				Enabled:  true,
147				Provider: " NOCTALIA ",
148				Format:   " " + format + " ",
149				Path:     path,
150			})
151			if err != nil {
152				t.Fatalf("watchPathFromConfig() error = %v", err)
153			}
154			if !enabled {
155				t.Fatal("enabled = false, want true")
156			}
157			if got != path {
158				t.Fatalf("path = %q, want %q", got, path)
159			}
160		})
161	}
162
163	_, enabled, err := watchPathFromConfig(entity.ExternalThemeConfig{
164		Enabled:  true,
165		Provider: "noctalia",
166		Format:   "toml",
167		Path:     path,
168	})
169	if err != nil {
170		t.Fatalf("watchPathFromConfig(unsupported) error = %v", err)
171	}
172	if enabled {
173		t.Fatal("enabled = true for unsupported format, want false")
174	}
175}
176
177func TestFileWatcherInvalidPathStopsPreviousWatcher(t *testing.T) {
178	dir := t.TempDir()
179	path := writeThemeFile(t, dir, "theme.json")
180	watcher := newFastWatcher()
181	changes := make(chan struct{}, 4)
182
183	if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
184		t.Fatalf("Start(valid) error = %v", err)
185	}
186	missingParent := filepath.Join(dir, "missing", "theme.json")
187	if err := watcher.Start(context.Background(), watcherConfig(missingParent), func() { changes <- struct{}{} }); err == nil {
188		t.Fatal("Start(missing parent) error = nil, want error")
189	}
190	if watcher.isRunningForTest() {
191		t.Fatal("watcher running after invalid path")
192	}
193	writeThemeFile(t, dir, "theme.json")
194	assertNoChange(t, changes, 80*time.Millisecond)
195}
196
197func newFastWatcher() *FileWatcher {
198	watcher := NewFileWatcher()
199	watcher.delay = 20 * time.Millisecond
200	return watcher
201}
202
203func watcherConfig(path string) entity.ExternalThemeConfig {
204	return entity.ExternalThemeConfig{Enabled: true, Provider: providerName, Format: formatName, Path: path}
205}
206
207func writeThemeFile(t *testing.T, dir, name string) string {
208	t.Helper()
209	path := filepath.Join(dir, name)
210	if err := os.WriteFile(path, []byte(`{"light":{},"dark":{}}`), 0o600); err != nil {
211		t.Fatalf("WriteFile(%s) error = %v", name, err)
212	}
213	return path
214}
215
216func waitForChange(t *testing.T, ch <-chan struct{}) {
217	t.Helper()
218	select {
219	case <-ch:
220	case <-time.After(500 * time.Millisecond):
221		t.Fatal("timed out waiting for callback")
222	}
223}
224
225func assertNoChange(t *testing.T, ch <-chan struct{}, d time.Duration) {
226	t.Helper()
227	select {
228	case <-ch:
229		t.Fatal("unexpected callback")
230	case <-time.After(d):
231	}
232}
233
234func waitForStringChange(t *testing.T, ch <-chan string) string {
235	t.Helper()
236	select {
237	case got := <-ch:
238		return got
239	case <-time.After(500 * time.Millisecond):
240		t.Fatal("timed out waiting for callback")
241	}
242	panic("unreachable")
243}
244
245func assertNoStringChange(t *testing.T, ch <-chan string, d time.Duration) {
246	t.Helper()
247	select {
248	case got := <-ch:
249		t.Fatalf("unexpected callback %q", got)
250	case <-time.After(d):
251	}
252}
253
254func waitUntil(t *testing.T, condition func() bool) {
255	t.Helper()
256	deadline := time.Now().Add(500 * time.Millisecond)
257	for time.Now().Before(deadline) {
258		if condition() {
259			return
260		}
261		time.Sleep(5 * time.Millisecond)
262	}
263	t.Fatal("condition not met before timeout")
264}

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 8

Tags 42

↑↓ moveenter openesc close