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}