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\nborder.radius = 10\n")
97 select {
98 case c := <-out:
99 if c.Config.Background.Color != "#111111" || c.Config.Border.Width != 5 || c.Config.Border.Radius != 10 {
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\nborder.radius = 10\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}